diff --git a/README.md b/README.md index 95fb60b..cb6f4a7 100644 --- a/README.md +++ b/README.md @@ -200,14 +200,15 @@ Leader bindings require Normal/Visual and the target's existing mode eligibility ## Module seams -One crate, eight modules, no workspace or terminal-coordinate types in the editing backend: +One crate; no workspace or terminal-coordinate types in the editing backend: - `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected. - `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations. - `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets. - `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and semantic byte spans; it has no terminal color/geometry or editing dependency. - `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, and versioned asynchronous update contracts. It has no LSP protocol types. -- `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls asynchronous results, validates versioned diagnostics/navigation, and retires host and command ownership on invocation or effect-validation failure. +- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, and definition results. Its private configuration resolver supplies presets without restricting language IDs. +- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation, and retires failed plugin ownership. - `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy. - `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module. - The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI. @@ -220,7 +221,7 @@ Frontends inspect `count()` for the optional pending motion prefix; parsing and Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned. Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer. Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. -Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by a plugin and buffer revision; text changes and owner retirement clear them. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds. +Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes and owner/server retirement clear them. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds. The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active. @@ -242,27 +243,38 @@ Built-in Ex commands register as the protected `core` feature in the same comman A feature declares all capabilities it needs. Registration is atomic: if any requirement is unavailable or any command conflicts, none of its commands are installed. For example, `Vamp.plugin "canvas", requires: ["graphics"]` will be refused by this TUI. Capability names are extensible strings, not a terminal-vs-GUI switch. Capability declarations express compatibility; they are not security permissions. -### Optional LSP plugin +### Native LSP -LSP is implemented in `examples/plugins/lsp.rb`, not the editing core. It requires Ruby and an independently installed, trusted language server; it is not loaded by default. Enable it in your config (the plugin path is relative to that config): +LSP runs in Rust, independently of Ruby plugin hosts. **Every language server is disabled by default.** Ruby evaluates configuration only at startup; no LSP plugin needs loading. Install a trusted server yourself and explicitly opt in: ```ruby -config.plugin "plugins/lsp.rb", options: { - servers: [ - { language_id: "rust", extensions: ["rs"], command: ["rust-analyzer"] }, - { language_id: "python", extensions: ["py"], command: ["pylsp"] } - ] -} -config.bind "leader d", "lsp_definition" +Vamp.configure do |config| + config.lsp["rust"] = true # rust-analyzer, extensions: ["rs"] + config.lsp["python"] = false # disabled (also the default when absent) + + config.lsp["my-language"] = { + command: ["my-language-server", "--stdio"], + extensions: ["myl"], + initialization_options: {}, + settings: { "my-language-server" => { "exampleOption" => true } } + } + config.bind "leader d", "lsp_definition" +end ``` -Server commands are argv arrays, never shell commands. The first matching extension configuration wins. One server runs per configuration/project root, using the startup working directory as its workspace. The plugin initializes the server, honors its open/change/save capabilities, synchronizes in-memory text (including hidden dirty buffers), and sends close events. Servers requesting incremental synchronization receive whole-document range replacements. Server transport and requests run in plugin worker threads; the editor continues processing input and idle diagnostics. +Keys are arbitrary nonempty strings, used as the protocol's `languageId`; they need no Tree-sitter grammar or hardcoded language enum. Absent/`false` entries are disabled. `true` enables a built-in preset: currently `"rust"` uses `rust-analyzer`/`rs`, and `"python"` uses `pylsp`/`py`. Unknown keys need a configuration object with nonempty `command` argv and `extensions` (without leading dots). Objects also explicitly enable servers; known presets supply omitted command/extensions. For example, `config.lsp["rust"] = { command: ["/custom/path/rust-analyzer"] }` overrides the executable while retaining `rs`. + +Only `command`, `extensions`, `initialization_options`, and `settings` are accepted. Both option/settings values must be JSON-compatible objects and default to `{}`. Invalid declarations reject configuration with a notice rather than activating a guessed server. Server commands are executable/argument arrays, never shell commands; use an absolute path if the executable is not on PATH. No server installation or restart is automatic. + +Servers start lazily only when a matching buffer opens. Extension matching is case-sensitive; if enabled languages overlap, the first language in sorted key order wins. One worker runs per language configuration/project root, with at most 16 enabled languages/server-project keys. Start vamp from your project root: its startup working directory is the workspace; automatic root discovery is not implemented. + +The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses. `:lsp_definition` or the configured Space d binding requests a definition at the primary caret, once regardless of selection count. Both Location and LocationLink results are supported; multiple results use the first. Local-file navigation reuses loaded buffers without overwriting unsaved text. LSP UTF-16 positions and percent-encoded file URIs are translated to validated grapheme coordinates. Navigation is ignored if its source buffer, revision, caret, or latest request has changed. Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic appears in the message row. `:lsp_diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts. -This plugin currently provides synchronization, diagnostics, and go-to-definition—not completion, hover, rename, formatting, code actions, workspace edits, or semantic highlighting. Tree-sitter remains the syntax highlighter. Server configuration requests return `null` for defaults; custom server settings and workspace discovery are not implemented. +Native LSP currently provides synchronization, diagnostics, and go-to-definition—not completion, hover, rename, formatting, code actions, workspace edits, or semantic highlighting. Tree-sitter remains the syntax highlighter. The former Ruby LSP plugin has been removed; migrate its `servers` entries to `config.lsp[language_id]` objects without the `language_id` field. ## Ruby configuration @@ -381,7 +393,7 @@ JSON uses Ruby's standard library. Protocol Buffers is not installed or required ## Current limits -- One visible buffer at a time; Normal, Insert, Command, Search, and characterwise Visual modes. No named registers, line/block selection, Vim operators, splits/tabs, regex/multiline search, or graphical frontend. Optional LSP currently supports diagnostics and definitions only. Native system clipboard access currently supports macOS and Linux; Separate mode retains internal editing on unsupported/headless environments. +- One visible buffer at a time; Normal, Insert, Command, Search, and characterwise Visual modes. No named registers, line/block selection, Vim operators, splits/tabs, regex/multiline search, or graphical frontend. Native opt-in LSP currently supports diagnostics and definitions only. Native system clipboard access currently supports macOS and Linux; Separate mode retains internal editing on unsupported/headless environments. - Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance. - Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing. - Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload. @@ -399,4 +411,4 @@ Regression checks exercise Unicode edits, undo/redo, saving and failed saves, ca Unix terminal-hook checks run in isolated child processes and real PTYs to keep global hooks out of the parallel test runner. They cover entry failure, normal return, I/O error, panic cleanup before caller diagnostics, original panic-payload propagation, and later panics producing no stale terminal-restoration output. -LSP integration checks launch real framed-protocol fixture subprocesses through the optional Ruby plugin: fragmented/adjacent frames, initialization and synchronization capabilities, open/change/save/close, UTF-16 and encoded Unicode paths, dirty-buffer definitions, stale/superseded requests and diagnostics, unsupported server requests, crashes, malformed frames, unavailable servers, and surviving native editing. A real executable PTY smoke also exercised idle diagnostic redraw, active-server editing, definition navigation into a dirty loaded buffer, save/close notifications, and clean shutdown. No desktop clipboard or user files are touched. +LSP integration checks launch real framed-protocol fixture subprocesses directly from native Rust: fragmented/adjacent frames, initialization/custom settings, arbitrary language IDs, opt-in matching-only startup, negotiated synchronization, open/change/save/close/shutdown, UTF-16 and encoded Unicode paths, dirty targets without enabled LSPs, stale/superseded requests and diagnostics, moved carets, unsupported requests, crashes, malformed frames, unavailable servers, and surviving native editing. Executable PTY smoke exercised idle diagnostic redraw, active-server edits, definition navigation into a dirty non-LSP buffer, Unicode cursor placement, settings, saving, and clean shutdown; its server's parent PID confirmed there was no Ruby LSP host. No desktop clipboard or user files are touched. Windows transport/process cleanup is implemented but not exercised on this macOS workstation. diff --git a/examples/config.rb b/examples/config.rb index 1a12012..5658544 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -13,9 +13,11 @@ Vamp.configure do |config| config.bind "ctrl+h", config.core.left, mode: config.modes.tree config.bind "ctrl+l", config.core.right, mode: config.modes.tree config.plugin "plugins/greeting.rb" - # Optional LSP feature; install/configure a trusted language server first. - # config.plugin "plugins/lsp.rb", options: { - # servers: [{ language_id: "rust", extensions: ["rs"], command: ["rust-analyzer"] }] + # Native LSP is disabled by default; install a server and explicitly opt in. + # config.lsp["rust"] = true # built-in rust-analyzer preset + # config.lsp["my-language"] = { + # command: ["my-language-server", "--stdio"], extensions: ["myl"], + # initialization_options: {}, settings: {} # } # config.bind "leader d", "lsp_definition" config.bind "g", "greet" diff --git a/examples/plugins/lsp.rb b/examples/plugins/lsp.rb deleted file mode 100644 index c5fcc3e..0000000 --- a/examples/plugins/lsp.rb +++ /dev/null @@ -1,619 +0,0 @@ -require "json" -require "open3" -require "uri" - -# All server/protocol work stays in this optional plugin. Host callbacks only enqueue. -module VampLsp - MAX_BODY = 4 * 1024 * 1024 - MAX_HEADER = 16 * 1024 - QUEUE_SIZE = 128 - MAX_VERSION = (1 << 31) - 1 - - def self.clean(text) - raise ArgumentError, "LSP message must be UTF-8 text" unless text.is_a?(String) && text.valid_encoding? - text.gsub(/[[:cntrl:]]/, " ") - end - - def self.uri(path) - "file://#{URI::DEFAULT_PARSER.escape(File.expand_path(path), /[^A-Za-z0-9\-._~\/]/)}" - end - - def self.path(uri) - raise ArgumentError, "definition URI must be a String" unless uri.is_a?(String) - parsed = URI.parse(uri) - unless parsed.scheme == "file" && [nil, "", "localhost"].include?(parsed.host) && - parsed.userinfo.nil? && parsed.port.nil? && parsed.query.nil? && parsed.fragment.nil? && - parsed.path.start_with?("/") && !parsed.path.match?(/%(?![0-9a-fA-F]{2})/) - raise ArgumentError, "unsupported local file URI: #{clean(uri)}" - end - path = URI::DEFAULT_PARSER.unescape(parsed.path).force_encoding(Encoding::UTF_8) - raise ArgumentError, "invalid UTF-8 file URI" unless path.valid_encoding? && !path.match?(/[[:cntrl:]]/) - File.expand_path(path) - end - - def self.identity(path) - File.realpath(path) - rescue SystemCallError - File.expand_path(path) - end - - def self.lines(text) - text.gsub(/\r\n?/, "\n").split("\n", -1) - end - - def self.integer(value, label) - raise ArgumentError, "invalid LSP #{label}" unless value.is_a?(Integer) && value.between?(0, MAX_VERSION) - value - end - - # Expand positions inside a surrogate pair or combining cluster to whole graphemes. - def self.position(lines, value, round_up: false) - raise ArgumentError, "invalid LSP position" unless value.is_a?(Hash) - row = integer(value["line"], "line") - units = integer(value["character"], "character") - line = lines.fetch(row) { raise ArgumentError, "LSP line is outside the document" } - offset = 0 - column = 0 - line.each_grapheme_cluster do |cluster| - return { "row" => row, "column" => column } if units == offset - width = cluster.encode(Encoding::UTF_16LE).bytesize / 2 - if units < offset + width - return { "row" => row, "column" => column + (round_up ? 1 : 0) } - end - offset += width - column += 1 - end - # LSP specifies oversized character offsets as end-of-line. - { "row" => row, "column" => column } - end - - def self.range(lines, value) - raise ArgumentError, "invalid LSP range" unless value.is_a?(Hash) - first = value["start"] - last = value["end"] - start = position(lines, first) - finish = position(lines, last, round_up: true) - if ([integer(first["line"], "line"), integer(first["character"], "character")] <=> - [integer(last["line"], "line"), integer(last["character"], "character")]) == 1 - raise ArgumentError, "reversed LSP range" - end - [start, finish] - end - - def self.outbound_position(text, row, column) - line = lines(text).fetch(row) { raise ArgumentError, "definition source row is outside the document" } - clusters = line.each_grapheme_cluster.to_a - raise ArgumentError, "definition source column is outside the line" unless column.between?(0, clusters.length) - { "line" => row, "character" => clusters.take(column).join.encode(Encoding::UTF_16LE).bytesize / 2 } - end - - class Server - attr_reader :root, :config, :documents, :pending, :requests, :ready - attr_accessor :sync, :definition - - def initialize(config, root, events) - @config, @root, @events = config, root, events - @documents, @pending, @requests = {}, [], {} - @ready, @dead, @closing = false, false, false - @next_id = 1 - @writes = SizedQueue.new(QUEUE_SIZE) - @stdin, @stdout, @stderr, @wait = Open3.popen3(*config.fetch("command"), chdir: root) - [@stdin, @stdout, @stderr].each(&:binmode) - @reader = Thread.new do - loop { @events.push([:incoming, self, read_frame], true) } - rescue StandardError => error - failed(error) - end - @writer = Thread.new do - loop do - bytes = @writes.pop - break if bytes.nil? - @stdin.write(bytes) - @stdin.flush - end - rescue StandardError => error - failed(error) - end - @drain = Thread.new do - # Server stderr is intentionally not editor status or host protocol output. - loop { @stderr.readpartial(8192) } - rescue EOFError, IOError - nil - end - request("initialize", { - "processId" => Process.pid, - "clientInfo" => { "name" => "vamp" }, - "rootUri" => VampLsp.uri(root), - "workspaceFolders" => [{ "uri" => VampLsp.uri(root), "name" => File.basename(root) }], - "capabilities" => { - "general" => { "positionEncodings" => ["utf-16"] }, - "workspace" => { "configuration" => true, "workspaceFolders" => true }, - "textDocument" => { - "synchronization" => { "dynamicRegistration" => false, "willSave" => false, - "willSaveWaitUntil" => false, "didSave" => true }, - "definition" => { "dynamicRegistration" => false, "linkSupport" => true }, - "publishDiagnostics" => { "relatedInformation" => false, "versionSupport" => true } - } - } - }, :initialize) - rescue StandardError - stop - raise - end - - def dead? - @dead - end - - def initialized! - @ready = true - end - - def request(method, params, context) - raise ArgumentError, "LSP request IDs exhausted" if @next_id > MAX_VERSION - raise ThreadError, "LSP pending request queue is full" if @requests.length >= QUEUE_SIZE - id = @next_id - @next_id += 1 - @requests[id] = context - frame = { "jsonrpc" => "2.0", "id" => id, "method" => method } - frame["params"] = params unless params.nil? - send_frame(frame) - end - - def notify(method, params) - frame = { "jsonrpc" => "2.0", "method" => method } - frame["params"] = params unless params.nil? - send_frame(frame) - end - - def reply(id, result: nil, error: nil) - frame = { "jsonrpc" => "2.0", "id" => id } - frame[error ? "error" : "result"] = error || result - send_frame(frame) - end - - def shutdown - return if @dead || @closing - @closing = true - if @ready - request("shutdown", nil, :shutdown) - else - stop - end - end - - def stop - return if @dead - @dead = true - [@stdin, @stdout, @stderr].compact.each { |io| io.close rescue nil } - [@reader, @writer, @drain].compact.each { |thread| thread.kill unless thread == Thread.current } - if @wait - (Process.kill("TERM", @wait.pid) rescue nil) if @wait.alive? - # Reaping and forced termination never block the host callback/dispatcher. - Thread.new do - unless @wait.join(0.2) - Process.kill("KILL", @wait.pid) rescue nil - @wait.join - end - end - end - end - - private - - def send_frame(frame) - raise IOError, "LSP server is unavailable" if @dead - body = JSON.generate(frame).encode(Encoding::UTF_8) - raise ArgumentError, "LSP outgoing frame exceeds 4 MiB" if body.bytesize > MAX_BODY - @writes.push("Content-Length: #{body.bytesize}\r\n\r\n".b + body.b, true) - rescue ThreadError - raise ThreadError, "LSP outgoing queue is full" - end - - def read_frame - size = 0 - length = nil - loop do - line = @stdout.gets("\r\n", MAX_HEADER + 1) - raise EOFError, "LSP server exited" unless line - size += line.bytesize - raise ArgumentError, "LSP headers exceed 16 KiB" if size > MAX_HEADER - raise ArgumentError, "LSP headers must be ASCII" unless line.ascii_only? - raise ArgumentError, "invalid LSP header newline" unless line.end_with?("\r\n") - break if line == "\r\n" - name, value = line.chomp("\r\n").split(":", 2) - raise ArgumentError, "invalid LSP header" unless value - if name.downcase == "content-length" - raise ArgumentError, "invalid LSP Content-Length" if length || !value.strip.match?(/\A[0-9]+\z/) - length = value.to_i - elsif name.downcase == "content-type" && value.match?(/charset=/i) && - !value.match?(/charset\s*=\s*utf-?8\s*\z/i) - raise ArgumentError, "unsupported LSP content encoding" - end - end - raise ArgumentError, "invalid LSP body length" unless length && length.between?(1, MAX_BODY) - body = @stdout.read(length) - raise EOFError, "truncated LSP body" unless body && body.bytesize == length - body.force_encoding(Encoding::UTF_8) - raise ArgumentError, "invalid UTF-8 LSP body" unless body.valid_encoding? - JSON.parse(body) - end - - def failed(error) - return if @dead || @closing - @events.push([:failure, self, error.message], true) - rescue ThreadError - begin - Vamp.publish([{ "type" => "message", "text" => "LSP incoming queue is full" }]) - ensure - Kernel.exit!(1) # Host EOF retires the feature and clears all owned diagnostics. - end - end - end - - def self.clear(doc) - { "type" => "diagnostics", "buffer_id" => doc.fetch("id"), - "revision" => doc.fetch("revision"), "items" => [] } - end - - class Client - def initialize(options) - servers = options["servers"] - unless servers.is_a?(Array) && !servers.empty? && servers.length <= 16 - raise ArgumentError, "LSP options.servers must contain 1..16 server configurations" - end - @configs = servers.map do |config| - unless config.is_a?(Hash) && config["language_id"].is_a?(String) && !config["language_id"].empty? && - config["extensions"].is_a?(Array) && !config["extensions"].empty? && - config["extensions"].all? { |ext| ext.is_a?(String) && ext.match?(/\A[^.\/\\\s\x00]+\z/) } && - config["command"].is_a?(Array) && !config["command"].empty? && - config["command"].all? { |arg| arg.is_a?(String) && !arg.include?("\0") } && - !config["command"].first.empty? - raise ArgumentError, "LSP server requires language_id, extensions, and command argv" - end - config - end - @events = SizedQueue.new(QUEUE_SIZE) - @documents, @servers = {}, {} - @failure = nil - @worker = Thread.new { run } - end - - def enqueue(*event) - raise IOError, @failure if @failure - @events.push(event, true) - nil - rescue ThreadError - raise IOError, "LSP document/command queue is full" - end - - private - - def run - loop do - event, *args = @events.pop - case event - when :document then document(*args) - when :definition then definition(*args) - when :diagnostics then diagnostics_status(*args) - when :incoming then incoming(*args) - when :failure then fail_server(*args) - when :shutdown - @servers.values.compact.each(&:shutdown) - # The host process-group cleanup is the final safety net, including descendants. - end - rescue StandardError => error - server = [:incoming, :failure].include?(event) ? args.first : nil - if server.is_a?(Server) && !error.is_a?(ThreadError) - fail_server(server, error.message) - else - @failure = "LSP client: #{VampLsp.clean(error.message)}" - @servers.values.compact.each(&:stop) - begin - Vamp.publish([{ "type" => "message", "text" => @failure }]) - ensure - Kernel.exit!(1) - end - end - end - end - - def message(text, request_id: nil) - Vamp.publish([{ "type" => "message", "text" => "LSP: #{VampLsp.clean(text)}" }], request_id: request_id) - end - - def server_for(doc) - return unless doc["path"] - extension = File.extname(doc["path"]).delete_prefix(".") - index = @configs.index { |config| config["extensions"].include?(extension) } - return unless index - key = [index, File.expand_path(doc.fetch("root"))] - return @servers[key] if @servers.key?(key) - begin - @servers[key] = Server.new(@configs[index], key.last, @events) - rescue StandardError => error - # Remember failed starts: no automatic retries or restart loop. - @servers[key] = nil - message("#{@configs[index]["language_id"]} server failed to start: #{error.message}") - end - end - - def document(event) - kind = event.fetch("event") - snapshot = event.fetch("document") - id = snapshot.fetch("id") - old = @documents[id] - if kind == "close" - if old && (server = old["server"]) - close_document(server, old) - end - @documents.delete(id) - return - end - doc = snapshot.merge("uri" => snapshot["path"] && VampLsp.uri(snapshot["path"]), - "server" => old ? old["server"] : server_for(snapshot), - "version" => old ? old["version"] : 1, - "opened" => old ? old["opened"] : false, - "saved" => kind == "save") - server = doc["server"] - @documents[id] = doc - return unless server && !server.dead? - if old && old["revision"] != doc["revision"] - raise ArgumentError, "LSP document version exhausted" if doc["version"] == MAX_VERSION - doc["version"] += 1 - Vamp.publish([VampLsp.clear(doc)]) - end - server.documents[doc["uri"]] = doc - return unless server.ready - if !doc["opened"] - open_document(server, doc) - elsif old && old["revision"] != doc["revision"] - change_document(server, old, doc) - end - save_document(server, doc) if kind == "save" - end - - def open_document(server, doc) - doc["opened"] = true - return unless server.sync["openClose"] - server.notify("textDocument/didOpen", { - "textDocument" => { "uri" => doc["uri"], "languageId" => server.config["language_id"], - "version" => doc["version"], "text" => doc["text"] } - }) - end - - def change_document(server, old, doc) - kind = server.sync["change"] - return if kind == 0 - change = { "text" => doc["text"] } - if kind == 2 - old_lines = VampLsp.lines(old["text"]) - change["range"] = { "start" => { "line" => 0, "character" => 0 }, - "end" => { "line" => old_lines.length - 1, - "character" => old_lines.last.encode(Encoding::UTF_16LE).bytesize / 2 } } - end - server.notify("textDocument/didChange", { - "textDocument" => { "uri" => doc["uri"], "version" => doc["version"] }, - "contentChanges" => [change] - }) - end - - def save_document(server, doc) - save = server.sync["save"] - return unless save - params = { "textDocument" => { "uri" => doc["uri"] } } - params["text"] = doc["text"] if save.is_a?(Hash) && save["includeText"] - server.notify("textDocument/didSave", params) - doc["saved"] = false - end - - def close_document(server, doc) - server.documents.delete(doc["uri"]) - return unless server.ready && !server.dead? && doc["opened"] && server.sync["openClose"] - server.notify("textDocument/didClose", { "textDocument" => { "uri" => doc["uri"] } }) - end - - def definition(source) - doc = @documents[source.fetch("id")] - return unless doc && doc["revision"] == source["revision"] - server = doc["server"] - unless server && !server.dead? - message("no running server for this document", request_id: source["request_id"]) - return - end - unless server.ready - raise ThreadError, "LSP pending command queue is full" if server.pending.length == QUEUE_SIZE - server.pending << source - return - end - unless server.definition - message("server does not support go-to-definition", request_id: source["request_id"]) - return - end - server.request("textDocument/definition", { - "textDocument" => { "uri" => doc["uri"] }, - "position" => VampLsp.outbound_position(source["text"], source["row"], source["column"]) - }, source) - end - - def diagnostics_status(id) - doc = @documents[id] - server = doc && doc["server"] - message(if !server || server.dead? - "no running server for this document" - elsif !server.ready - "server initializing" - else - "diagnostics update automatically from #{server.config["language_id"]}" - end) - end - - def incoming(server, frame) - return if server.dead? - unless frame.is_a?(Hash) && frame["jsonrpc"] == "2.0" - raise ArgumentError, "invalid LSP JSON-RPC frame" - end - if frame.key?("method") - method = frame["method"] - raise ArgumentError, "invalid LSP method" unless method.is_a?(String) - if frame.key?("id") - server_request(server, frame) - elsif method == "textDocument/publishDiagnostics" - publish_diagnostics(server, frame["params"]) - elsif method == "window/showMessage" - params = frame["params"] - raise ArgumentError, "invalid LSP showMessage" unless params.is_a?(Hash) - message(params["message"]) - end - return - end - id = frame["id"] - unless id.is_a?(Integer) && server.requests.key?(id) && - (frame.key?("result") ^ frame.key?("error")) - raise ArgumentError, "unexpected LSP response" - end - context = server.requests.delete(id) - if context == :shutdown - server.notify("exit", nil) - return - end - if frame.key?("error") - error = frame["error"] - unless error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String) - raise ArgumentError, "invalid LSP error response" - end - raise IOError, "initialization failed: #{error["message"]}" if context == :initialize - message("definition failed: #{error["message"]}", request_id: context["request_id"]) - elsif context == :initialize - initialize_result(server, frame["result"]) - else - definition_result(context, frame["result"]) - end - end - - def initialize_result(server, result) - capabilities = result.is_a?(Hash) && result["capabilities"] - raise ArgumentError, "invalid LSP initialize result" unless capabilities.is_a?(Hash) - encoding = capabilities.fetch("positionEncoding", "utf-16") - raise ArgumentError, "server selected unsupported position encoding #{encoding}" unless encoding == "utf-16" - sync = capabilities.fetch("textDocumentSync", 0) - if sync.is_a?(Integer) && sync.between?(0, 2) - sync = { "openClose" => sync != 0, "change" => sync, "save" => false } - elsif sync.is_a?(Hash) - sync = { "openClose" => sync.fetch("openClose", false), "change" => sync.fetch("change", 0), - "save" => sync.fetch("save", false) } - else - raise ArgumentError, "invalid LSP textDocumentSync capability" - end - unless [true, false].include?(sync["openClose"]) && [0, 1, 2].include?(sync["change"]) && - ([true, false].include?(sync["save"]) || - (sync["save"].is_a?(Hash) && [true, false].include?(sync["save"].fetch("includeText", false)))) - raise ArgumentError, "invalid LSP synchronization options" - end - server.sync = sync - server.definition = capabilities["definitionProvider"] == true || capabilities["definitionProvider"].is_a?(Hash) - server.notify("initialized", {}) - server.initialized! - server.documents.each_value do |doc| - open_document(server, doc) - save_document(server, doc) if doc["saved"] - end - pending = server.pending.dup - server.pending.clear - pending.each { |source| definition(source) } - end - - def server_request(server, frame) - id = frame["id"] - raise ArgumentError, "invalid LSP server request ID" unless id.is_a?(Integer) || id.is_a?(String) - case frame["method"] - when "workspace/configuration" - params = frame["params"] - items = params.is_a?(Hash) && params["items"] - raise ArgumentError, "invalid LSP configuration request" unless items.is_a?(Array) - server.reply(id, result: items.map { nil }) - when "workspace/workspaceFolders" - server.reply(id, result: [{ "uri" => VampLsp.uri(server.root), "name" => File.basename(server.root) }]) - when "window/showMessageRequest" - server.reply(id, result: nil) - else - server.reply(id, error: { "code" => -32601, "message" => "Method not supported by vamp" }) - end - end - - def publish_diagnostics(server, params) - unless params.is_a?(Hash) && params["uri"].is_a?(String) && params["diagnostics"].is_a?(Array) - raise ArgumentError, "invalid LSP publishDiagnostics" - end - doc = server.documents[params["uri"]] - return unless doc - if params.key?("version") && !params["version"].nil? - version = params["version"] - unless version.is_a?(Integer) && version.between?(-(1 << 31), MAX_VERSION) - raise ArgumentError, "invalid LSP diagnostic version" - end - return if version < doc["version"] - raise ArgumentError, "LSP diagnostics refer to an unknown future version" if version > doc["version"] - end - # LSP permits no version: only arrival-time revision is knowable in that case. - text_lines = VampLsp.lines(doc["text"]) - items = params["diagnostics"].map do |item| - raise ArgumentError, "invalid LSP diagnostic" unless item.is_a?(Hash) - start, finish = VampLsp.range(text_lines, item["range"]) - severity = { 1 => "error", 2 => "warning", 3 => "information", 4 => "hint" }[item.fetch("severity", 1)] - raise ArgumentError, "invalid LSP diagnostic severity" unless severity - { "start" => start, "end" => finish, "severity" => severity, - "message" => VampLsp.clean(item["message"]) } - end - Vamp.publish([{ "type" => "diagnostics", "buffer_id" => doc["id"], - "revision" => doc["revision"], "items" => items }]) - end - - def definition_result(source, result) - doc = @documents[source["id"]] - return unless doc && doc["revision"] == source["revision"] - locations = result.nil? ? [] : (result.is_a?(Array) ? result : [result]) - if locations.empty? - message("no definition found", request_id: source["request_id"]) - return - end - location = locations.first - raise ArgumentError, "invalid LSP definition location" unless location.is_a?(Hash) - link = location.key?("targetUri") - path = VampLsp.path(location[link ? "targetUri" : "uri"]) - identity = VampLsp.identity(path) - cached = @documents.values.find { |candidate| candidate["path"] && VampLsp.identity(candidate["path"]) == identity } - text = cached ? cached["text"] : File.read(path, encoding: Encoding::UTF_8) - raise ArgumentError, "definition target is not UTF-8" unless text.valid_encoding? - text_lines = VampLsp.lines(text) - VampLsp.range(text_lines, location["targetRange"]) if link - start, = VampLsp.range(text_lines, location[link ? "targetSelectionRange" : "range"]) - effects = [{ "type" => "navigate", "buffer_id" => source["id"], "revision" => source["revision"], - "path" => path, "row" => start["row"], "column" => start["column"] }] - if locations.length > 1 - effects << { "type" => "message", "text" => "LSP: selected first of #{locations.length} definitions" } - end - Vamp.publish(effects, request_id: source["request_id"]) - rescue SystemCallError, ArgumentError => error - message("definition failed: #{error.message}", request_id: source["request_id"]) - end - - def fail_server(server, reason) - return if server.dead? - server.stop - Vamp.publish(server.documents.values.map { |doc| VampLsp.clear(doc) } + - [{ "type" => "message", "text" => "LSP server stopped: #{VampLsp.clean(reason)}" }]) - end - end -end - -Vamp.plugin "lsp", requires: ["text", "diagnostics"], async: true -client = VampLsp::Client.new(Vamp.options) -Vamp.on_document { |event| client.enqueue(:document, event) } -Vamp.command "lsp_definition" do |editor, _args| - client.enqueue(:definition, { "id" => editor.buffer_id, "revision" => editor.revision, - "text" => editor.text, "row" => editor.row - 1, - "column" => editor.column - 1, "request_id" => editor.request_id }) -end -Vamp.command("lsp_diagnostics") { |editor, _args| client.enqueue(:diagnostics, editor.buffer_id) } -Vamp.on_shutdown { client.enqueue(:shutdown) } diff --git a/src/editor.rs b/src/editor.rs index b87713e..448469d 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -896,8 +896,20 @@ impl Editor { name: "core".into(), required_capabilities: Vec::new(), commands: [ - "w", "q", "q!", "wq", "plugins", "edit", "buffers", "buffer", "bnext", - "bprev", "bdelete", "bdelete!", + "w", + "q", + "q!", + "wq", + "plugins", + "edit", + "buffers", + "buffer", + "bnext", + "bprev", + "bdelete", + "bdelete!", + "lsp_definition", + "lsp_diagnostics", ] .into_iter() .map(str::to_owned) @@ -2384,6 +2396,16 @@ impl Editor { }); } match name { + "lsp_definition" | "lsp_diagnostics" if !args.is_empty() => { + self.set_message(format!("The {name} command takes no arguments")); + } + "lsp_definition" | "lsp_diagnostics" => { + return Some(Invocation { + plugin: "core".into(), + command: name.into(), + args: String::new(), + }); + } "w" | "wq" => { if self.save(if args.is_empty() { None diff --git a/src/lib.rs b/src/lib.rs index 5f4b29d..f1e9fde 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ pub mod clipboard; pub mod editor; pub mod files; +pub mod lsp; +mod lsp_config; pub mod plugin; pub mod ruby; pub mod session; diff --git a/src/lsp.rs b/src/lsp.rs new file mode 100644 index 0000000..158eacc --- /dev/null +++ b/src/lsp.rs @@ -0,0 +1,1800 @@ +use std::{ + collections::{HashMap, VecDeque}, + io::{Read, Write}, + path::{Component, Path, PathBuf}, + process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Stdio}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver, SyncSender, TryRecvError, TrySendError}, + }, + thread, + time::{Duration, Instant}, +}; + +use serde_json::{Value, json}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::{ + editor::Context, + plugin::{ + Diagnostic, DiagnosticSeverity, DocumentEvent, DocumentEventKind, DocumentSnapshot, + PluginUpdate, TextPosition, UpdateEffect, + }, +}; + +const MAX_BODY: usize = 4 * 1024 * 1024; +const MAX_HEADER: usize = 16 * 1024; +const QUEUE_SIZE: usize = 128; +const MAX_SERVERS: usize = 16; +const MAX_VERSION: u64 = i32::MAX as u64; +const TICK: Duration = Duration::from_millis(5); + +#[derive(Clone, Debug)] +pub struct ServerConfig { + pub language_id: String, + pub command: Vec, + pub extensions: Vec, + pub initialization_options: Value, + pub settings: Value, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct ServerKey { + config: usize, + root: PathBuf, +} + +struct CachedDocument { + snapshot: Arc, + server: Option, +} + +struct Worker { + input: SyncSender, + output: Receiver, + failure: Receiver, + cancelled: Arc, + retired: bool, + thread: Option>, +} + +enum Input { + Document { + snapshot: Arc, + event: DocumentEventKind, + served: bool, + }, + Definition(Source), + Diagnostics(usize), +} + +struct Source { + buffer_id: usize, + revision: u64, + row: usize, + column: usize, + request_id: u64, +} + +/// Callbacks only enqueue snapshots. Each lazy server owns its process and I/O on +/// one worker; neither spawning, disk reads, pipe I/O nor reaping runs in poll. +pub struct Lsp { + configs: Vec>, + cwd: PathBuf, + documents: HashMap, + servers: HashMap, + updates: Vec, +} + +impl Lsp { + pub fn new(configs: Vec) -> Self { + Self { + configs: configs.into_iter().map(Arc::new).collect(), + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")), + documents: HashMap::new(), + servers: HashMap::new(), + updates: Vec::new(), + } + } + + pub fn is_enabled(&self) -> bool { + !self.configs.is_empty() + } + + pub fn notify_document(&mut self, event: &DocumentEvent) -> Result<(), String> { + if !self.is_enabled() { + return Ok(()); + } + let snapshot = self + .documents + .get(&event.document.id) + .filter(|cached| { + matches!(event.event, DocumentEventKind::Close) + || (cached.snapshot.revision == event.document.revision + && cached.snapshot.path == event.document.path + && cached.snapshot.root == event.document.root) + }) + .map(|cached| Arc::clone(&cached.snapshot)) + .unwrap_or_else(|| Arc::new(event.document.clone())); + let mut startup_error = None; + let key = snapshot.path.as_ref().and_then(|path| { + let extension = Path::new(path).extension()?.to_str()?; + let config = self.configs.iter().position(|config| { + config + .extensions + .iter() + .any(|candidate| candidate == extension) + })?; + Some(ServerKey { + config, + root: absolute(Path::new(&snapshot.root), &self.cwd), + }) + }); + if matches!(event.event, DocumentEventKind::Close) { + self.documents.remove(&snapshot.id); + } else { + self.documents.insert( + snapshot.id, + CachedDocument { + snapshot: Arc::clone(&snapshot), + server: key.clone(), + }, + ); + if let Some(key) = &key { + startup_error = self.start(key).err(); + } + } + let mut failures = Vec::new(); + for (server_key, worker) in &self.servers { + if worker.retired { + continue; + } + if worker + .input + .try_send(Input::Document { + snapshot: Arc::clone(&snapshot), + event: event.event, + served: key.as_ref() == Some(server_key), + }) + .is_err() + { + failures.push(server_key.clone()); + } + } + for key in failures { + self.retire( + &key, + "LSP server stopped: document/command queue is full or unavailable".into(), + ); + } + startup_error.map_or(Ok(()), Err) + } + + pub fn definition(&mut self, context: &Context, request_id: u64) -> Result<(), String> { + let source = Source { + buffer_id: context.buffer_id, + revision: context.revision, + row: context + .row + .checked_sub(1) + .ok_or("LSP definition row is invalid")?, + column: context + .column + .checked_sub(1) + .ok_or("LSP definition column is invalid")?, + request_id, + }; + self.command( + context.buffer_id, + Input::Definition(source), + Some(request_id), + ) + } + + pub fn diagnostics(&mut self, buffer_id: usize) -> Result<(), String> { + self.command(buffer_id, Input::Diagnostics(buffer_id), None) + } + + pub fn poll(&mut self) -> Vec { + let failures: Vec<_> = self + .servers + .iter() + .filter_map(|(key, worker)| { + if worker.retired { + return None; + } + match worker.failure.try_recv() { + Ok(reason) => Some((key.clone(), reason)), + Err(TryRecvError::Disconnected) => Some(( + key.clone(), + "LSP server stopped: worker exited unexpectedly".into(), + )), + Err(TryRecvError::Empty) => None, + } + }) + .collect(); + for (key, reason) in failures { + self.retire(&key, reason); + } + let mut updates = std::mem::take(&mut self.updates); + for worker in self.servers.values() { + if !worker.retired { + updates.extend(worker.output.try_iter().take(QUEUE_SIZE)); + } + } + updates + } + + fn start(&mut self, key: &ServerKey) -> Result<(), String> { + if self.servers.contains_key(key) { + return Ok(()); + } + if self.servers.len() >= MAX_SERVERS { + return Err("LSP server/project limit of 16 reached".into()); + } + let (input, receiver) = mpsc::sync_channel(QUEUE_SIZE); + let (sender, output) = mpsc::sync_channel(QUEUE_SIZE); + let (failed, failure) = mpsc::sync_channel(1); + let cancelled = Arc::new(AtomicBool::new(false)); + let stop = Arc::clone(&cancelled); + let config = Arc::clone(&self.configs[key.config]); + let root = key.root.clone(); + let initial: Vec<_> = self + .documents + .values() + .map(|doc| (Arc::clone(&doc.snapshot), doc.server.as_ref() == Some(key))) + .collect(); + let started = thread::Builder::new() + .name(format!("lsp-{}", config.language_id)) + .spawn(move || { + let mut runtime = + match Runtime::new(Arc::clone(&config), root, receiver, sender, stop) { + Ok(runtime) => runtime, + Err(reason) => { + let _ = failed.try_send(format!( + "LSP: {} server failed to start: {}", + config.language_id, + clean(&reason) + )); + return; + } + }; + let result = initial + .into_iter() + .try_for_each(|(snapshot, served)| { + runtime.document(snapshot, DocumentEventKind::Open, served) + }) + .and_then(|()| runtime.run()); + if let Err(reason) = result { + let _ = failed.try_send(format!("LSP server stopped: {}", clean(&reason))); + } + }); + let (thread, startup_error) = match started { + Ok(thread) => (Some(thread), None), + Err(error) => (None, Some(error)), + }; + self.servers.insert( + key.clone(), + Worker { + input, + output, + failure, + cancelled, + retired: false, + thread, + }, + ); + if let Some(error) = startup_error { + self.retire(key, format!("LSP: server failed to start: {error}")); + } + Ok(()) + } + + fn command( + &mut self, + buffer_id: usize, + input: Input, + request_id: Option, + ) -> Result<(), String> { + let key = self + .documents + .get(&buffer_id) + .and_then(|doc| doc.server.clone()); + if let Some(key) = key + && let Some(worker) = self.servers.get(&key) + && !worker.retired + { + if worker.input.try_send(input).is_ok() { + return Ok(()); + } + self.retire( + &key, + "LSP server stopped: document/command queue is full or unavailable".into(), + ); + } + if self.updates.len() >= QUEUE_SIZE { + return Err("LSP update queue is full".into()); + } + self.updates + .push(message("no running server for this document", request_id)); + Ok(()) + } + + fn retire(&mut self, key: &ServerKey, reason: String) { + let Some(worker) = self.servers.get_mut(key) else { + return; + }; + if worker.retired { + return; + } + worker.retired = true; + worker.cancelled.store(true, Ordering::Release); + let mut effects: Vec<_> = self + .documents + .values() + .filter(|doc| doc.server.as_ref() == Some(key)) + .map(|doc| clear(&doc.snapshot)) + .collect(); + effects.push(UpdateEffect::Message { text: reason }); + // At most one retirement update per bounded worker, independently of the + // command-message queue, so a full queue cannot hide diagnostic cleanup. + self.updates.push(PluginUpdate { + request_id: None, + effects, + }); + } +} + +impl Drop for Lsp { + fn drop(&mut self) { + for worker in self.servers.values() { + worker.cancelled.store(true, Ordering::Release); + } + // Application exit must not race detached workers' process cleanup. + for worker in self.servers.values_mut() { + if let Some(thread) = worker.thread.take() { + let _ = thread.join(); + } + } + } +} + +struct Document { + snapshot: Arc, + uri: String, + version: u64, + opened: bool, + saved: bool, +} + +#[derive(Default)] +struct SyncOptions { + open_close: bool, + change: u64, + save: bool, + include_text: bool, +} + +enum Request { + Initialize, + Definition(Source), + Shutdown, +} + +struct Runtime { + config: Arc, + root: PathBuf, + input: Receiver, + output: SyncSender, + cancelled: Arc, + transport: Transport, + snapshots: HashMap>, + documents: HashMap, + requests: HashMap, + pending: VecDeque, + next_id: u64, + ready: bool, + definition: bool, + sync: SyncOptions, +} + +impl Runtime { + fn new( + config: Arc, + root: PathBuf, + input: Receiver, + output: SyncSender, + cancelled: Arc, + ) -> Result { + let transport = Transport::spawn(&config.command, &root)?; + let mut runtime = Self { + config, + root, + input, + output, + cancelled, + transport, + snapshots: HashMap::new(), + documents: HashMap::new(), + requests: HashMap::new(), + pending: VecDeque::new(), + next_id: 1, + ready: false, + definition: false, + sync: SyncOptions::default(), + }; + runtime.request( + "initialize", + Some(json!({ + "processId": std::process::id(), + "clientInfo": {"name": "vamp"}, + "rootUri": file_uri(&runtime.root)?, + "workspaceFolders": runtime.workspace_folders()?, + "initializationOptions": runtime.config.initialization_options, + "capabilities": { + "general": {"positionEncodings": ["utf-16"]}, + "workspace": {"configuration": true, "workspaceFolders": true}, + "textDocument": { + "synchronization": {"dynamicRegistration": false, "willSave": false, + "willSaveWaitUntil": false, "didSave": true}, + "definition": {"dynamicRegistration": false, "linkSupport": true}, + "publishDiagnostics": {"relatedInformation": false, "versionSupport": true} + } + } + })), + Request::Initialize, + )?; + Ok(runtime) + } + + fn run(&mut self) -> Result<(), String> { + while !self.cancelled.load(Ordering::Acquire) { + for _ in 0..QUEUE_SIZE { + match self.input.try_recv() { + Ok(Input::Document { + snapshot, + event, + served, + }) => self.document(snapshot, event, served)?, + Ok(Input::Definition(source)) => self.definition(source)?, + Ok(Input::Diagnostics(id)) => { + let text = if !self.documents.contains_key(&id) { + "no running server for this document".to_owned() + } else if !self.ready { + "server initializing".to_owned() + } else { + format!( + "diagnostics update automatically from {}", + self.config.language_id + ) + }; + self.emit(message(&text, None))?; + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + self.cancelled.store(true, Ordering::Release); + break; + } + } + } + self.transport.flush()?; + self.transport.drain_stderr()?; + self.transport.read()?; + for _ in 0..QUEUE_SIZE { + let Some(frame) = self.transport.frame()? else { + break; + }; + self.incoming(frame)?; + } + self.transport.check_alive()?; + thread::sleep(TICK); + } + self.shutdown(); + Ok(()) + } + + fn emit(&self, update: PluginUpdate) -> Result<(), String> { + self.output.try_send(update).map_err(|error| match error { + TrySendError::Full(_) => "LSP incoming update queue is full".into(), + TrySendError::Disconnected(_) => "LSP client disconnected".into(), + }) + } + + fn document( + &mut self, + snapshot: Arc, + event: DocumentEventKind, + served: bool, + ) -> Result<(), String> { + let id = snapshot.id; + let old = self.documents.remove(&id); + if matches!(event, DocumentEventKind::Close) { + self.snapshots.remove(&id); + if let Some(old) = old { + self.close(&old)?; + } + return Ok(()); + } + self.snapshots.insert(id, Arc::clone(&snapshot)); + if !served { + if let Some(old) = old { + self.close(&old)?; + self.emit(PluginUpdate { + request_id: None, + effects: vec![clear(&snapshot)], + })?; + } + return Ok(()); + } + let path = snapshot.path.as_ref().ok_or("LSP document has no path")?; + let uri = file_uri(&absolute(Path::new(path), &self.root))?; + let same_uri = old.as_ref().is_some_and(|old| old.uri == uri); + if let Some(old) = &old + && !same_uri + { + self.close(old)?; + } + let changed = old + .as_ref() + .is_some_and(|old| old.snapshot.revision != snapshot.revision); + let version = if same_uri { + old.as_ref().unwrap().version + u64::from(changed) + } else { + 1 + }; + if version > MAX_VERSION { + return Err("LSP document version exhausted".into()); + } + let mut doc = Document { + snapshot, + uri, + version, + opened: same_uri && old.as_ref().is_some_and(|old| old.opened), + saved: matches!(event, DocumentEventKind::Save), + }; + if changed { + self.emit(PluginUpdate { + request_id: None, + effects: vec![clear(&doc.snapshot)], + })?; + } + if self.ready { + if !doc.opened { + self.open(&mut doc)?; + } else if changed { + self.change(old.as_ref().unwrap(), &doc)?; + } + if doc.saved { + self.save(&mut doc)?; + } + } + self.documents.insert(id, doc); + Ok(()) + } + + fn open(&mut self, doc: &mut Document) -> Result<(), String> { + doc.opened = true; + if self.sync.open_close { + self.notify( + "textDocument/didOpen", + Some(json!({"textDocument": { + "uri": doc.uri, "languageId": self.config.language_id, + "version": doc.version, "text": doc.snapshot.text + }})), + )?; + } + Ok(()) + } + + fn change(&mut self, old: &Document, doc: &Document) -> Result<(), String> { + if self.sync.change == 0 { + return Ok(()); + } + let mut change = json!({"text": doc.snapshot.text}); + if self.sync.change == 2 { + let text = normalized(&old.snapshot.text); + let mut lines = text.split('\n'); + let mut row = 0; + let mut last = lines.next().unwrap_or(""); + for line in lines { + row += 1; + last = line; + } + change["range"] = json!({"start": {"line": 0, "character": 0}, + "end": {"line": row, "character": last.encode_utf16().count()}}); + } + self.notify( + "textDocument/didChange", + Some(json!({ + "textDocument": {"uri": doc.uri, "version": doc.version}, "contentChanges": [change] + })), + ) + } + + fn save(&mut self, doc: &mut Document) -> Result<(), String> { + if self.sync.save { + let mut params = json!({"textDocument": {"uri": doc.uri}}); + if self.sync.include_text { + params["text"] = json!(doc.snapshot.text); + } + self.notify("textDocument/didSave", Some(params))?; + } + doc.saved = false; + Ok(()) + } + + fn close(&mut self, doc: &Document) -> Result<(), String> { + if self.ready && doc.opened && self.sync.open_close { + self.notify( + "textDocument/didClose", + Some(json!({"textDocument": {"uri": doc.uri}})), + )?; + } + Ok(()) + } + + fn definition(&mut self, source: Source) -> Result<(), String> { + let Some(doc) = self.documents.get(&source.buffer_id) else { + return Ok(()); + }; + if doc.snapshot.revision != source.revision { + return Ok(()); + } + if !self.ready { + if self.pending.len() == QUEUE_SIZE { + return Err("LSP pending command queue is full".into()); + } + self.pending.push_back(source); + return Ok(()); + } + if !self.definition { + return self.emit(message( + "server does not support go-to-definition", + Some(source.request_id), + )); + } + let position = outbound_position(&doc.snapshot.text, source.row, source.column)?; + self.request( + "textDocument/definition", + Some(json!({ + "textDocument": {"uri": doc.uri}, "position": position + })), + Request::Definition(source), + ) + } + + fn request( + &mut self, + method: &str, + params: Option, + context: Request, + ) -> Result<(), String> { + if self.next_id > MAX_VERSION { + return Err("LSP request IDs exhausted".into()); + } + if self.requests.len() == QUEUE_SIZE { + return Err("LSP pending request queue is full".into()); + } + let id = self.next_id; + self.next_id += 1; + let mut frame = json!({"jsonrpc": "2.0", "id": id, "method": method}); + if let Some(params) = params { + frame["params"] = params; + } + self.transport.send(frame)?; + self.requests.insert(id, context); + Ok(()) + } + + fn notify(&mut self, method: &str, params: Option) -> Result<(), String> { + let mut frame = json!({"jsonrpc": "2.0", "method": method}); + if let Some(params) = params { + frame["params"] = params; + } + self.transport.send(frame) + } + + fn incoming(&mut self, frame: Value) -> Result<(), String> { + let object = frame + .as_object() + .filter(|object| object.get("jsonrpc") == Some(&json!("2.0"))) + .ok_or("invalid LSP JSON-RPC frame")?; + if let Some(method) = object.get("method") { + let method = method.as_str().ok_or("invalid LSP method")?; + if let Some(id) = object.get("id") { + return self.server_request( + id, + method, + object.get("params").unwrap_or(&Value::Null), + ); + } + match method { + "textDocument/publishDiagnostics" => self.publish_diagnostics(&frame["params"]), + "window/showMessage" => { + let text = frame["params"]["message"] + .as_str() + .ok_or("invalid LSP showMessage")?; + self.emit(message(text, None)) + } + _ => Ok(()), + } + } else { + let id = frame["id"].as_u64().ok_or("unexpected LSP response")?; + if object.contains_key("result") == object.contains_key("error") { + return Err("unexpected LSP response".into()); + } + let context = self.requests.remove(&id).ok_or("unexpected LSP response")?; + if let Some(error) = object.get("error") { + let text = error["message"] + .as_str() + .filter(|_| error["code"].as_i64().is_some()) + .ok_or("invalid LSP error response")?; + match context { + Request::Initialize => Err(format!("initialization failed: {text}")), + Request::Definition(source) => self.emit(message( + &format!("definition failed: {text}"), + Some(source.request_id), + )), + Request::Shutdown => Ok(()), + } + } else { + match context { + Request::Initialize => self.initialized(&frame["result"]), + Request::Definition(source) => self.definition_result(source, &frame["result"]), + Request::Shutdown => self.notify("exit", None), + } + } + } + } + + fn initialized(&mut self, result: &Value) -> Result<(), String> { + let capabilities = result["capabilities"] + .as_object() + .ok_or("invalid LSP initialize result")?; + if capabilities + .get("positionEncoding") + .is_some_and(|encoding| encoding != "utf-16") + { + return Err("server selected unsupported position encoding".into()); + } + self.sync = sync_options(capabilities.get("textDocumentSync").unwrap_or(&json!(0)))?; + self.definition = capabilities + .get("definitionProvider") + .is_some_and(|value| value == true || value.is_object()); + self.notify("initialized", Some(json!({})))?; + self.notify( + "workspace/didChangeConfiguration", + Some(json!({"settings": self.config.settings})), + )?; + self.ready = true; + let documents = std::mem::take(&mut self.documents); + for (id, mut doc) in documents { + self.open(&mut doc)?; + if doc.saved { + self.save(&mut doc)?; + } + self.documents.insert(id, doc); + } + while let Some(source) = self.pending.pop_front() { + self.definition(source)?; + } + Ok(()) + } + + fn workspace_folders(&self) -> Result { + Ok( + json!([{"uri": file_uri(&self.root)?, "name": self.root.file_name().and_then(|name| name.to_str()).unwrap_or("/")}]), + ) + } + + fn server_request(&mut self, id: &Value, method: &str, params: &Value) -> Result<(), String> { + if !(id.is_string() || id.as_i64().is_some() || id.as_u64().is_some()) { + return Err("invalid LSP server request ID".into()); + } + let mut response = json!({"jsonrpc": "2.0", "id": id}); + response["result"] = match method { + "workspace/configuration" => { + let items = params["items"] + .as_array() + .ok_or("invalid LSP configuration request")?; + let mut values = Vec::with_capacity(items.len()); + for item in items { + if !item.is_object() { + return Err("invalid LSP configuration item".into()); + } + let value = match item.get("section") { + None | Some(Value::Null) => &self.config.settings, + Some(Value::String(section)) if section.is_empty() => &self.config.settings, + Some(Value::String(section)) => { + self.config.settings.get(section).unwrap_or_else(|| { + section + .split('.') + .try_fold(&self.config.settings, |value, part| value.get(part)) + .unwrap_or(&Value::Null) + }) + } + _ => return Err("invalid LSP configuration section".into()), + }; + values.push(value.clone()); + } + Value::Array(values) + } + "workspace/workspaceFolders" => self.workspace_folders()?, + "window/showMessageRequest" => Value::Null, + _ => { + response.as_object_mut().unwrap().remove("result"); + response["error"] = + json!({"code": -32601, "message": "Method not supported by vamp"}); + return self.transport.send(response); + } + }; + self.transport.send(response) + } + + fn publish_diagnostics(&self, params: &Value) -> Result<(), String> { + let uri = params["uri"] + .as_str() + .ok_or("invalid LSP publishDiagnostics")?; + let items = params["diagnostics"] + .as_array() + .ok_or("invalid LSP publishDiagnostics")?; + let Some(doc) = self.documents.values().find(|doc| doc.uri == uri) else { + return Ok(()); + }; + if let Some(version) = params.get("version").filter(|value| !value.is_null()) { + let version = version + .as_i64() + .filter(|value| i32::try_from(*value).is_ok()) + .ok_or("invalid LSP diagnostic version")?; + if version < doc.version as i64 { + return Ok(()); + } + if version > doc.version as i64 { + return Err("LSP diagnostics refer to an unknown future version".into()); + } + } + // Without a version only the revision at arrival is knowable. + let text = normalized(&doc.snapshot.text); + let mut diagnostics = Vec::with_capacity(items.len()); + for item in items { + let (start, end) = range(&text, &item["range"])?; + let severity = match item.get("severity").unwrap_or(&json!(1)).as_u64() { + Some(1) => DiagnosticSeverity::Error, + Some(2) => DiagnosticSeverity::Warning, + Some(3) => DiagnosticSeverity::Information, + Some(4) => DiagnosticSeverity::Hint, + _ => return Err("invalid LSP diagnostic severity".into()), + }; + diagnostics.push(Diagnostic { + start, + end, + severity, + message: clean( + item["message"] + .as_str() + .ok_or("invalid LSP diagnostic message")?, + ), + }); + } + self.emit(PluginUpdate { + request_id: None, + effects: vec![UpdateEffect::Diagnostics { + buffer_id: doc.snapshot.id, + revision: doc.snapshot.revision, + items: diagnostics, + }], + }) + } + + fn definition_result(&self, source: Source, result: &Value) -> Result<(), String> { + if !self + .documents + .get(&source.buffer_id) + .is_some_and(|doc| doc.snapshot.revision == source.revision) + { + return Ok(()); + } + let result = self.definition_effects(&source, result); + match result { + Ok(effects) => self.emit(PluginUpdate { + request_id: Some(source.request_id), + effects, + }), + Err(reason) => self.emit(message( + &format!("definition failed: {reason}"), + Some(source.request_id), + )), + } + } + + fn definition_effects( + &self, + source: &Source, + result: &Value, + ) -> Result, String> { + let (location, count) = match result { + Value::Null => return Ok(message("no definition found", None).effects), + Value::Array(locations) if locations.is_empty() => { + return Ok(message("no definition found", None).effects); + } + Value::Array(locations) => (&locations[0], locations.len()), + location => (location, 1), + }; + if !location.is_object() { + return Err("invalid LSP definition location".into()); + } + let link = location.get("targetUri").is_some(); + let path = uri_path( + location[if link { "targetUri" } else { "uri" }] + .as_str() + .ok_or("invalid definition URI")?, + )?; + let identity = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone()); + let cached = self.snapshots.values().find(|doc| { + doc.path.as_ref().is_some_and(|candidate| { + let candidate = absolute(Path::new(candidate), &self.root); + std::fs::canonicalize(&candidate).unwrap_or(candidate) == identity + }) + }); + let disk; + let text = if let Some(doc) = cached { + &doc.text + } else { + if !std::fs::metadata(&path) + .map_err(|error| error.to_string())? + .is_file() + { + return Err("definition target must be a regular file".into()); + } + let mut bytes = Vec::new(); + std::fs::File::open(&path) + .map_err(|error| error.to_string())? + .take((MAX_BODY + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + if bytes.len() > MAX_BODY { + return Err("definition target exceeds 4 MiB".into()); + } + disk = String::from_utf8(bytes).map_err(|_| "definition target is not UTF-8")?; + &disk + }; + let text = normalized(text); + if link { + range(&text, &location["targetRange"])?; + } + let (start, _) = range( + &text, + &location[if link { + "targetSelectionRange" + } else { + "range" + }], + )?; + let mut effects = vec![UpdateEffect::Navigate { + buffer_id: source.buffer_id, + revision: source.revision, + path, + row: start.row, + column: start.column, + }]; + if count > 1 { + effects.push(UpdateEffect::Message { + text: format!("LSP: selected first of {count} definitions"), + }); + } + Ok(effects) + } + + fn shutdown(&mut self) { + if !self.ready { + return; + } + if self.request("shutdown", None, Request::Shutdown).is_err() { + return; + } + let deadline = Instant::now() + Duration::from_millis(200); + let mut exiting = false; + while Instant::now() < deadline { + if self.transport.flush().is_err() || self.transport.read().is_err() { + break; + } + if exiting && self.transport.flushed() { + break; + } + match self.transport.frame() { + Ok(Some(frame)) => { + let shutdown = frame["id"].as_u64().is_some_and(|id| { + matches!(self.requests.get(&id), Some(Request::Shutdown)) + }); + if shutdown { + self.notify("exit", None).ok(); + exiting = true; + } + } + Ok(None) => thread::sleep(TICK), + Err(_) => break, + } + let _ = self.transport.drain_stderr(); + } + } +} + +fn sync_options(value: &Value) -> Result { + if let Some(kind) = value.as_u64().filter(|kind| *kind <= 2) { + return Ok(SyncOptions { + open_close: kind != 0, + change: kind, + ..SyncOptions::default() + }); + } + let object = value + .as_object() + .ok_or("invalid LSP textDocumentSync capability")?; + let open_close = object + .get("openClose") + .unwrap_or(&Value::Bool(false)) + .as_bool() + .ok_or("invalid LSP synchronization options")?; + let change = object + .get("change") + .unwrap_or(&json!(0)) + .as_u64() + .filter(|kind| *kind <= 2) + .ok_or("invalid LSP synchronization options")?; + let (save, include_text) = match object.get("save") { + None | Some(Value::Bool(false)) => (false, false), + Some(Value::Bool(true)) => (true, false), + Some(Value::Object(save)) => ( + true, + save.get("includeText") + .unwrap_or(&Value::Bool(false)) + .as_bool() + .ok_or("invalid LSP synchronization options")?, + ), + _ => return Err("invalid LSP synchronization options".into()), + }; + Ok(SyncOptions { + open_close, + change, + save, + include_text, + }) +} + +struct Transport { + child: Child, + #[cfg(windows)] + job: WindowsJob, + #[cfg(unix)] + stdin: ChildStdin, + #[cfg(unix)] + stdout: ChildStdout, + #[cfg(unix)] + stderr: ChildStderr, + #[cfg(not(unix))] + portable: PortablePipes, + incoming: Vec, + consumed: usize, + writes: VecDeque>, + #[cfg(unix)] + written: usize, +} + +impl Transport { + fn spawn(argv: &[String], root: &Path) -> Result { + let program = argv + .first() + .filter(|program| !program.is_empty()) + .ok_or("LSP command argv is empty")?; + let mut command = Command::new(program); + command + .args(&argv[1..]) + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut child = command.spawn().map_err(|error| error.to_string())?; + #[cfg(windows)] + let job = match WindowsJob::new(&child) { + Ok(job) => job, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + }; + let stdin = child.stdin.take().expect("piped LSP stdin"); + let stdout = child.stdout.take().expect("piped LSP stdout"); + let stderr = child.stderr.take().expect("piped LSP stderr"); + let transport = Self { + child, + #[cfg(windows)] + job, + #[cfg(unix)] + stdin, + #[cfg(unix)] + stdout, + #[cfg(unix)] + stderr, + #[cfg(not(unix))] + portable: PortablePipes::new(stdin, stdout, stderr), + incoming: Vec::new(), + consumed: 0, + writes: VecDeque::new(), + #[cfg(unix)] + written: 0, + }; + #[cfg(unix)] + { + nonblocking(&transport.stdin)?; + nonblocking(&transport.stdout)?; + nonblocking(&transport.stderr)?; + } + Ok(transport) + } + + fn send(&mut self, frame: Value) -> Result<(), String> { + if self.writes.len() == QUEUE_SIZE { + return Err("LSP outgoing queue is full".into()); + } + let body = serde_json::to_vec(&frame).map_err(|error| error.to_string())?; + if body.len() > MAX_BODY { + return Err("LSP outgoing frame exceeds 4 MiB".into()); + } + let mut bytes = format!("Content-Length: {}\r\n\r\n", body.len()).into_bytes(); + bytes.extend_from_slice(&body); + self.writes.push_back(bytes); + Ok(()) + } + + #[cfg(unix)] + fn flush(&mut self) -> Result<(), String> { + for _ in 0..QUEUE_SIZE { + let Some(bytes) = self.writes.front() else { + break; + }; + let result = self.stdin.write(&bytes[self.written..]); + match result { + Ok(0) => return Err("LSP server closed stdin".into()), + Ok(count) => { + self.written += count; + if self.written == bytes.len() { + self.writes.pop_front(); + self.written = 0; + } + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error.to_string()), + } + } + Ok(()) + } + + #[cfg(not(unix))] + fn flush(&mut self) -> Result<(), String> { + for _ in 0..QUEUE_SIZE { + let Some(bytes) = self.writes.pop_front() else { + break; + }; + self.portable.pending.fetch_add(1, Ordering::AcqRel); + match self + .portable + .writes + .as_ref() + .ok_or("LSP writer unavailable")? + .try_send(bytes) + { + Ok(()) => {} + Err(TrySendError::Full(bytes)) => { + self.portable.pending.fetch_sub(1, Ordering::Release); + self.writes.push_front(bytes); + break; + } + Err(TrySendError::Disconnected(_)) => { + self.portable.pending.fetch_sub(1, Ordering::Release); + return Err("LSP server closed stdin".into()); + } + } + } + Ok(()) + } + + fn flushed(&self) -> bool { + #[cfg(unix)] + { + self.writes.is_empty() + } + #[cfg(not(unix))] + { + self.writes.is_empty() && self.portable.pending.load(Ordering::Acquire) == 0 + } + } + + fn read(&mut self) -> Result<(), String> { + if self.consumed != 0 { + self.incoming.drain(..self.consumed); + self.consumed = 0; + } + let mut bytes = [0; 8192]; + for _ in 0..8 { + if self.incoming.len() >= MAX_BODY + MAX_HEADER { + break; + } + let remaining = (MAX_BODY + MAX_HEADER - self.incoming.len()).min(bytes.len()); + #[cfg(unix)] + let result = self.stdout.read(&mut bytes[..remaining]); + #[cfg(not(unix))] + let result = self.portable.read(&mut bytes[..remaining]); + match result { + Ok(0) => return Err("LSP server exited or truncated a frame".into()), + Ok(count) => self.incoming.extend_from_slice(&bytes[..count]), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error.to_string()), + } + } + Ok(()) + } + + fn frame(&mut self) -> Result, String> { + let bytes = &self.incoming[self.consumed..]; + let Some(end) = bytes.windows(4).position(|bytes| bytes == b"\r\n\r\n") else { + if bytes.len() > MAX_HEADER { + return Err("LSP headers exceed 16 KiB".into()); + } + return Ok(None); + }; + if end + 4 > MAX_HEADER { + return Err("LSP headers exceed 16 KiB".into()); + } + let header = &bytes[..end]; + if !header.is_ascii() { + return Err("LSP headers must be ASCII".into()); + } + let header = std::str::from_utf8(header).map_err(|_| "invalid LSP header")?; + let mut length = None; + for line in header.split("\r\n") { + let (name, value) = line.split_once(':').ok_or("invalid LSP header")?; + if name.is_empty() || line.contains(['\r', '\n']) { + return Err("invalid LSP header newline".into()); + } + let value = value.trim(); + if name.eq_ignore_ascii_case("content-length") { + if length.is_some() + || value.is_empty() + || !value.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err("invalid LSP Content-Length".into()); + } + length = Some( + value + .parse::() + .map_err(|_| "invalid LSP Content-Length")?, + ); + } else if name.eq_ignore_ascii_case("content-type") { + for parameter in value.split(';').skip(1) { + if let Some((name, encoding)) = parameter.trim().split_once('=') + && name.trim().eq_ignore_ascii_case("charset") + && !["utf8", "utf-8"] + .iter() + .any(|supported| encoding.trim().eq_ignore_ascii_case(supported)) + { + return Err("unsupported LSP content encoding".into()); + } + } + } + } + let length = length + .filter(|length| (1..=MAX_BODY).contains(length)) + .ok_or("invalid LSP body length")?; + let body_start = end + 4; + if bytes.len() < body_start + length { + return Ok(None); + } + let frame = serde_json::from_slice(&bytes[body_start..body_start + length]) + .map_err(|error| format!("invalid LSP JSON: {error}"))?; + self.consumed += body_start + length; + Ok(Some(frame)) + } + + #[cfg(unix)] + fn drain_stderr(&mut self) -> Result<(), String> { + let mut bytes = [0; 8192]; + for _ in 0..8 { + match self.stderr.read(&mut bytes) { + Ok(0) => break, + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error.to_string()), + } + } + Ok(()) + } + + #[cfg(not(unix))] + fn drain_stderr(&mut self) -> Result<(), String> { + match self.portable.failures.try_recv() { + Ok(reason) => Err(reason), + Err(_) => Ok(()), + } + } + + fn check_alive(&mut self) -> Result<(), String> { + if let Some(status) = self.child.try_wait().map_err(|error| error.to_string())? { + return Err(format!("LSP server exited: {status}")); + } + Ok(()) + } +} + +impl Drop for Transport { + fn drop(&mut self) { + // This runs only on the already bounded server worker, never on input or + // poll. Kill the entire group even if the direct child has already exited. + #[cfg(unix)] + unsafe { + kill(-(self.child.id() as i32), 9); + } + #[cfg(windows)] + self.job.terminate(); + let _ = self.child.kill(); + let _ = self.child.wait(); + #[cfg(not(unix))] + self.portable.stop(); + } +} + +// Anonymous Windows pipes cannot be made nonblocking through std. Three bounded +// pipe workers keep the same framed transport off the editor and server workers. +#[cfg(not(unix))] +struct PortablePipes { + writes: Option>>, + reads: Option>>, + failures: Receiver, + chunk: Vec, + consumed: usize, + pending: Arc, + workers: Vec>, +} + +#[cfg(not(unix))] +impl PortablePipes { + fn new(mut stdin: ChildStdin, mut stdout: ChildStdout, mut stderr: ChildStderr) -> Self { + let (writes, outgoing) = mpsc::sync_channel::>(QUEUE_SIZE); + let (incoming, reads) = mpsc::sync_channel(QUEUE_SIZE); + let (failed, failures) = mpsc::sync_channel(1); + let pending = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let written = Arc::clone(&pending); + let write_failed = failed.clone(); + let writer = thread::spawn(move || { + while let Ok(bytes) = outgoing.recv() { + let result = stdin.write_all(&bytes).and_then(|()| stdin.flush()); + written.fetch_sub(1, Ordering::Release); + if let Err(error) = result { + let _ = write_failed.try_send(error.to_string()); + break; + } + } + }); + let reader = thread::spawn(move || { + loop { + let mut bytes = vec![0; 8192]; + match stdout.read(&mut bytes) { + Ok(0) => { + let _ = failed.try_send("LSP server exited or truncated a frame".into()); + break; + } + Ok(count) => { + bytes.truncate(count); + if incoming.send(bytes).is_err() { + break; + } + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => { + let _ = failed.try_send(error.to_string()); + break; + } + } + } + }); + let drain = thread::spawn(move || { + let mut bytes = [0; 8192]; + loop { + match stderr.read(&mut bytes) { + Ok(0) => break, + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + }); + Self { + writes: Some(writes), + reads: Some(reads), + failures, + chunk: Vec::new(), + consumed: 0, + pending, + workers: vec![writer, reader, drain], + } + } + + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + if self.consumed == self.chunk.len() { + self.chunk = match self + .reads + .as_ref() + .ok_or(std::io::ErrorKind::BrokenPipe)? + .try_recv() + { + Ok(bytes) => bytes, + Err(TryRecvError::Empty) => return Err(std::io::ErrorKind::WouldBlock.into()), + Err(TryRecvError::Disconnected) => return Ok(0), + }; + self.consumed = 0; + } + let count = buffer.len().min(self.chunk.len() - self.consumed); + buffer[..count].copy_from_slice(&self.chunk[self.consumed..self.consumed + count]); + self.consumed += count; + Ok(count) + } + + fn stop(&mut self) { + self.writes.take(); + self.reads.take(); + for worker in self.workers.drain(..) { + let _ = worker.join(); + } + } +} + +#[cfg(windows)] +struct WindowsJob(*mut std::ffi::c_void); + +#[cfg(windows)] +impl WindowsJob { + fn new(child: &Child) -> Result { + use std::os::windows::io::AsRawHandle; + let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if handle.is_null() { + return Err(std::io::Error::last_os_error().to_string()); + } + let job = Self(handle); + let mut limits: JobLimits = unsafe { std::mem::zeroed() }; + limits.basic.flags = 0x2000; // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. + if unsafe { + SetInformationJobObject( + handle, + 9, + &limits as *const _ as *const std::ffi::c_void, + std::mem::size_of::() as u32, + ) == 0 + || AssignProcessToJobObject(handle, child.as_raw_handle()) == 0 + } { + return Err(std::io::Error::last_os_error().to_string()); + } + Ok(job) + } + + fn terminate(&self) { + unsafe { + TerminateJobObject(self.0, 1); + } + } +} + +#[cfg(windows)] +impl Drop for WindowsJob { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } +} + +#[cfg(windows)] +#[repr(C)] +struct BasicJobLimits { + process_time: i64, + job_time: i64, + flags: u32, + min_working_set: usize, + max_working_set: usize, + active_processes: u32, + affinity: usize, + priority: u32, + scheduling: u32, +} + +#[cfg(windows)] +#[repr(C)] +struct JobLimits { + basic: BasicJobLimits, + io: [u64; 6], + process_memory: usize, + job_memory: usize, + peak_process_memory: usize, + peak_job_memory: usize, +} + +#[cfg(windows)] +#[link(name = "kernel32")] +unsafe extern "system" { + fn CreateJobObjectW( + attributes: *const std::ffi::c_void, + name: *const u16, + ) -> *mut std::ffi::c_void; + fn SetInformationJobObject( + job: *mut std::ffi::c_void, + class: u32, + information: *const std::ffi::c_void, + length: u32, + ) -> i32; + fn AssignProcessToJobObject(job: *mut std::ffi::c_void, process: *mut std::ffi::c_void) -> i32; + fn TerminateJobObject(job: *mut std::ffi::c_void, code: u32) -> i32; + fn CloseHandle(handle: *mut std::ffi::c_void) -> i32; +} + +#[cfg(unix)] +fn nonblocking(pipe: &impl std::os::fd::AsRawFd) -> Result<(), String> { + #[cfg(any(target_os = "linux", target_os = "android"))] + const O_NONBLOCK: i32 = 0x800; + #[cfg(not(any(target_os = "linux", target_os = "android")))] + const O_NONBLOCK: i32 = 4; + let fd = pipe.as_raw_fd(); + unsafe { + let flags = fcntl(fd, 3); + if flags == -1 || fcntl(fd, 4, flags | O_NONBLOCK) == -1 { + return Err(std::io::Error::last_os_error().to_string()); + } + } + Ok(()) +} + +#[cfg(unix)] +unsafe extern "C" { + fn fcntl(fd: i32, command: i32, ...) -> i32; + fn kill(pid: i32, signal: i32) -> i32; +} + +fn clear(snapshot: &DocumentSnapshot) -> UpdateEffect { + UpdateEffect::Diagnostics { + buffer_id: snapshot.id, + revision: snapshot.revision, + items: Vec::new(), + } +} + +fn message(text: &str, request_id: Option) -> PluginUpdate { + PluginUpdate { + request_id, + effects: vec![UpdateEffect::Message { + text: format!("LSP: {}", clean(text)), + }], + } +} + +fn clean(text: &str) -> String { + text.chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect() +} + +fn normalized(text: &str) -> std::borrow::Cow<'_, str> { + if text.contains('\r') { + std::borrow::Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n")) + } else { + std::borrow::Cow::Borrowed(text) + } +} + +fn coordinates(value: &Value) -> Result<(usize, usize), String> { + let line = value["line"] + .as_u64() + .filter(|number| *number <= MAX_VERSION) + .ok_or("invalid LSP line")?; + let character = value["character"] + .as_u64() + .filter(|number| *number <= MAX_VERSION) + .ok_or("invalid LSP character")?; + Ok((line as usize, character as usize)) +} + +fn position(text: &str, value: &Value, round_up: bool) -> Result { + let (row, units) = coordinates(value)?; + let line = text + .split('\n') + .nth(row) + .ok_or("LSP line is outside the document")?; + let mut offset = 0; + let mut column = 0; + for cluster in line.graphemes(true) { + if units == offset { + return Ok(TextPosition { row, column }); + } + let width = cluster.encode_utf16().count(); + if units < offset + width { + return Ok(TextPosition { + row, + column: column + usize::from(round_up), + }); + } + offset += width; + column += 1; + } + Ok(TextPosition { row, column }) +} + +fn range(text: &str, value: &Value) -> Result<(TextPosition, TextPosition), String> { + if coordinates(&value["start"])? > coordinates(&value["end"])? { + return Err("reversed LSP range".into()); + } + Ok(( + position(text, &value["start"], false)?, + position(text, &value["end"], true)?, + )) +} + +fn outbound_position(text: &str, row: usize, column: usize) -> Result { + let text = normalized(text); + let line = text + .split('\n') + .nth(row) + .ok_or("definition source row is outside the document")?; + let mut units = 0; + let mut clusters = line.graphemes(true); + for _ in 0..column { + units += clusters + .next() + .ok_or("definition source column is outside the line")? + .encode_utf16() + .count(); + } + Ok(json!({"line": row, "character": units})) +} + +fn absolute(path: &Path, cwd: &Path) -> PathBuf { + let joined; + let path = if path.is_absolute() { + path + } else { + joined = cwd.join(path); + &joined + }; + let mut result = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + result.pop(); + } + component => result.push(component.as_os_str()), + } + } + result +} + +fn file_uri(path: &Path) -> Result { + use std::fmt::Write as _; + let path = path.to_str().ok_or("LSP path is not UTF-8")?; + #[cfg(windows)] + let windows_path = path.replace('\\', "/"); + #[cfg(windows)] + let path = windows_path.as_str(); + let mut uri = String::from("file://"); + #[cfg(windows)] + if path.as_bytes().get(1) == Some(&b':') { + uri.push('/'); + } + for byte in path.bytes() { + if byte.is_ascii_alphanumeric() || b"-._~/".contains(&byte) { + uri.push(byte as char); + } else { + let _ = write!(uri, "%{byte:02X}"); + } + } + Ok(uri) +} + +fn uri_path(uri: &str) -> Result { + let unsupported = || format!("unsupported local file URI: {}", clean(uri)); + let mut path = uri.strip_prefix("file:").ok_or_else(unsupported)?; + if let Some(rest) = path.strip_prefix("//") { + let (host, _) = rest.split_once('/').ok_or_else(unsupported)?; + if !host.is_empty() && host != "localhost" { + return Err(unsupported()); + } + path = &rest[host.len()..]; + } + if !path.starts_with('/') || path.contains(['?', '#']) { + return Err(unsupported()); + } + let mut decoded = Vec::with_capacity(path.len()); + let bytes = path.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let pair = bytes.get(index + 1..index + 3).ok_or_else(unsupported)?; + let hex = std::str::from_utf8(pair).map_err(|_| unsupported())?; + decoded.push(u8::from_str_radix(hex, 16).map_err(|_| unsupported())?); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + let path = String::from_utf8(decoded).map_err(|_| "invalid UTF-8 file URI")?; + if path.chars().any(char::is_control) { + return Err("invalid control character in file URI".into()); + } + #[cfg(windows)] + let path = if path.as_bytes().get(2) == Some(&b':') && path.starts_with('/') { + path[1..].to_owned() + } else { + path + }; + Ok(absolute(Path::new(&path), Path::new("/"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unicode_positions_and_local_uri_boundaries() { + let text = "A😀e\u{301}Z\n"; + let (start, end) = range( + text, + &json!({"start": {"line": 0, "character": 2}, "end": {"line": 0, "character": 4}}), + ) + .unwrap(); + assert_eq!(start, TextPosition { row: 0, column: 1 }); + assert_eq!(end, TextPosition { row: 0, column: 3 }); + assert_eq!( + outbound_position(text, 0, 3).unwrap(), + json!({"line": 0, "character": 5}) + ); + assert_eq!( + position(text, &json!({"line": 1, "character": 999}), false).unwrap(), + TextPosition { row: 1, column: 0 } + ); + assert!( + range( + text, + &json!({"start": {"line": 0, "character": 3}, "end": {"line": 0, "character": 2}}) + ) + .is_err() + ); + let path = Path::new("/tmp/source space λ😀.rs"); + assert_eq!(uri_path(&file_uri(path).unwrap()).unwrap(), path); + assert_eq!( + uri_path("file://localhost/tmp/a%23b").unwrap(), + Path::new("/tmp/a#b") + ); + for uri in [ + "https://example.invalid/a", + "file://remote/tmp/a", + "file:///tmp/%", + "file:///tmp/%00", + "file:///tmp/%FF", + "file:///tmp/a?query", + ] { + assert!(uri_path(uri).is_err(), "{uri}"); + } + } +} diff --git a/src/lsp_config.rs b/src/lsp_config.rs new file mode 100644 index 0000000..60f850d --- /dev/null +++ b/src/lsp_config.rs @@ -0,0 +1,154 @@ +use crate::lsp::ServerConfig; +use serde::Deserialize; +use serde_json::Value; + +pub fn resolve(value: Value) -> Result, String> { + let Value::Object(languages) = value else { + return Err("LSP configuration must be a language-keyed object".into()); + }; + let mut servers = Vec::new(); + for (language_id, value) in languages { + if language_id.is_empty() || language_id.chars().any(char::is_control) { + return Err("LSP language keys must not be empty or contain control characters".into()); + } + let preset = match language_id.as_str() { + "rust" => Some(("rust-analyzer", "rs")), + "python" => Some(("pylsp", "py")), + _ => None, + }; + let mut options = match value { + Value::Bool(false) => continue, + Value::Bool(true) => { + if preset.is_none() { + return Err(format!( + "LSP language {language_id:?} has no built-in preset; provide command and extensions" + )); + } + serde_json::Map::new() + } + Value::Object(options) => options, + _ => { + return Err(format!( + "LSP language {language_id:?} must be true, false, or a configuration object" + )); + } + }; + for key in options.keys() { + if !matches!( + key.as_str(), + "command" | "extensions" | "initialization_options" | "settings" + ) { + return Err(format!( + "LSP language {language_id:?} has unknown option {key:?}" + )); + } + } + let command = match options.remove("command") { + Some(value) => command(value, &language_id)?, + None => match preset { + Some((command, _)) => vec![command.into()], + None => { + return Err(format!( + "LSP language {language_id:?} requires command argv" + )); + } + }, + }; + let extensions = match options.remove("extensions") { + Some(value) => extensions(value, &language_id)?, + None => match preset { + Some((_, extension)) => vec![extension.into()], + None => return Err(format!("LSP language {language_id:?} requires extensions")), + }, + }; + let initialization_options = object( + options.remove("initialization_options"), + &language_id, + "initialization_options", + )?; + let settings = object(options.remove("settings"), &language_id, "settings")?; + servers.push(ServerConfig { + language_id, + command, + extensions, + initialization_options, + settings, + }); + if servers.len() > 16 { + return Err("LSP configuration supports at most 16 enabled languages".into()); + } + } + Ok(servers) +} + +pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + resolve(Value::deserialize(deserializer)?).map_err(serde::de::Error::custom) +} + +fn command(value: Value, language: &str) -> Result, String> { + let error = || { + format!( + "LSP language {language:?} command must be nonempty argv with a nonempty executable and no NUL bytes" + ) + }; + let Value::Array(values) = value else { + return Err(error()); + }; + if values.is_empty() { + return Err(error()); + } + let mut command = Vec::with_capacity(values.len()); + for value in values { + let Value::String(argument) = value else { + return Err(error()); + }; + if argument.contains('\0') || (command.is_empty() && argument.is_empty()) { + return Err(error()); + } + command.push(argument); + } + Ok(command) +} + +fn extensions(value: Value, language: &str) -> Result, String> { + let error = || { + format!( + "LSP language {language:?} extensions must be a nonempty array of suffixes without dots, separators, whitespace, or control characters" + ) + }; + let Value::Array(values) = value else { + return Err(error()); + }; + if values.is_empty() { + return Err(error()); + } + let mut extensions = Vec::with_capacity(values.len()); + for value in values { + let Value::String(extension) = value else { + return Err(error()); + }; + if extension.is_empty() + || extension.chars().any(|character| { + matches!(character, '.' | '/' | '\\') + || character.is_whitespace() + || character.is_control() + }) + { + return Err(error()); + } + extensions.push(extension); + } + Ok(extensions) +} + +fn object(value: Option, language: &str, option: &str) -> Result { + match value { + None => Ok(Value::Object(serde_json::Map::new())), + Some(value) if value.is_object() => Ok(value), + Some(_) => Err(format!( + "LSP language {language:?} {option} must be a JSON object" + )), + } +} diff --git a/src/main.rs b/src/main.rs index 3adcb41..8ed4e39 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,8 +36,9 @@ Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N] Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb Set VAMP_RUBY to choose the Ruby executable. -Optional LSP Ruby plugin: config.plugin PATH, options: { servers: [...] } -LSP commands when enabled: :lsp_definition, :lsp_diagnostics"; +Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset +Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] } +LSP commands: :lsp_definition, :lsp_diagnostics"; struct Options { file: Option, diff --git a/src/ruby.rs b/src/ruby.rs index a428d76..d5f701c 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -19,6 +19,7 @@ const INVOKE_TIMEOUT: Duration = Duration::from_millis(500); #[derive(Debug)] pub struct Config { pub plugins: Vec, + pub lsp: Vec, pub bindings: Vec, pub leader: String, pub file_explorer_side: String, @@ -52,6 +53,7 @@ impl Default for Config { fn default() -> Self { Self { plugins: Vec::new(), + lsp: Vec::new(), bindings: Vec::new(), leader: "space".into(), file_explorer_side: "right".into(), @@ -71,6 +73,7 @@ pub fn load_config(path: &Path) -> Result { match response { Response::Config { plugins, + lsp, bindings, leader, file_explorer_side, @@ -92,6 +95,7 @@ pub fn load_config(path: &Path) -> Result { }); } config.bindings = bindings; + config.lsp = lsp; config.leader = leader; config.file_explorer_side = file_explorer_side; config.file_picker_view = file_picker_view; @@ -375,6 +379,8 @@ enum Response { Config { version: u8, plugins: Vec, + #[serde(deserialize_with = "crate::lsp_config::deserialize")] + lsp: Vec, bindings: Vec, leader: String, file_explorer_side: String, @@ -879,6 +885,7 @@ end let defaults = Config::default(); assert_eq!(defaults.leader, "space"); assert!(defaults.plugins.is_empty() && defaults.bindings.is_empty()); + assert!(defaults.lsp.is_empty()); let path = fixture.file("config.rb", "Vamp.configure { |_| }"); assert_eq!(load_config(&path).unwrap().leader, "space"); for (source, key) in [ @@ -900,14 +907,219 @@ end assert!(load_config(&path).unwrap_err().contains(error), "{value}"); } for frame in [ - r#"{"type":"config","version":1,"plugins":[],"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#, - r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#, - r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#, + r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#, + r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#, + r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#, ] { assert!(serde_json::from_str::(frame).is_err(), "{frame}"); } } + #[test] + fn lsp_configuration_is_disabled_unless_explicitly_enabled() { + let fixture = Fixture::new(); + for source in [ + "Vamp.configure { |_| }", + r#"Vamp.configure do |config| + config.lsp["rust"] = false + config.lsp["python"] = false + config.lsp["not-a-preset"] = false +end"#, + ] { + let path = fixture.file("config.rb", source); + let config = load_config(&path).unwrap(); + assert!(config.lsp.is_empty()); + assert!(config.plugins.is_empty()); + } + } + + #[test] + fn lsp_presets_custom_languages_and_overrides_reach_native_configuration() { + let fixture = Fixture::new(); + let path = fixture.file( + "config.rb", + r#"Vamp.configure do |config| + config.lsp["rust"] = true + config.lsp["python"] = true +end"#, + ); + let config = load_config(&path).unwrap(); + assert_eq!(config.lsp.len(), 2); + for (language, command, extension) in + [("rust", "rust-analyzer", "rs"), ("python", "pylsp", "py")] + { + let server = config + .lsp + .iter() + .find(|server| server.language_id == language) + .unwrap(); + assert_eq!(server.command, [command]); + assert_eq!(server.extensions, [extension]); + assert_eq!(server.initialization_options, serde_json::json!({})); + assert_eq!(server.settings, serde_json::json!({})); + } + + let path = fixture.file( + "config.rb", + r#"Vamp.configure do |config| + config.lsp["rust"] = { + extensions: ["rs", "ron"], + initialization_options: {cargo: {features: ["α"]}}, + settings: {"rust-analyzer" => {check: {command: "clippy"}}} + } + config.lsp["python"] = {command: ["custom pylsp", "--stdio"]} + config.lsp["my-language.c++"] = { + command: ["custom server", "--flag", ""], + extensions: ["custom", "لغة"], + settings: {nested: {enabled: true, limit: 3, unset: nil}} + } +end"#, + ); + let config = load_config(&path).unwrap(); + assert_eq!(config.lsp.len(), 3); + let rust = config + .lsp + .iter() + .find(|server| server.language_id == "rust") + .unwrap(); + assert_eq!(rust.command, ["rust-analyzer"]); + assert_eq!(rust.extensions, ["rs", "ron"]); + assert_eq!( + rust.initialization_options, + serde_json::json!({"cargo": {"features": ["α"]}}) + ); + assert_eq!( + rust.settings, + serde_json::json!({"rust-analyzer": {"check": {"command": "clippy"}}}) + ); + let python = config + .lsp + .iter() + .find(|server| server.language_id == "python") + .unwrap(); + assert_eq!(python.command, ["custom pylsp", "--stdio"]); + assert_eq!(python.extensions, ["py"]); + assert_eq!(python.initialization_options, serde_json::json!({})); + assert_eq!(python.settings, serde_json::json!({})); + let custom = config + .lsp + .iter() + .find(|server| server.language_id == "my-language.c++") + .unwrap(); + assert_eq!(custom.command, ["custom server", "--flag", ""]); + assert_eq!(custom.extensions, ["custom", "لغة"]); + assert_eq!(custom.initialization_options, serde_json::json!({})); + assert_eq!( + custom.settings, + serde_json::json!({"nested": {"enabled": true, "limit": 3, "unset": null}}) + ); + } + + #[test] + fn lsp_configuration_rejects_invalid_keys_values_and_json_options() { + let fixture = Fixture::new(); + for assignment in [ + r#"config.lsp[:rust] = true"#, + r#"config.lsp[7] = false"#, + r#"config.lsp[""] = false"#, + r#"config.lsp["bad\nkey"] = false"#, + r#"config.lsp["\xFF".b] = false"#, + r#"config.lsp["rust"] = nil"#, + r#"config.lsp["rust"] = []"#, + r#"config.lsp["rust"] = "yes""#, + r#"config.lsp["custom"] = {}"#, + r#"config.lsp["custom"] = {command: ["server"]}"#, + r#"config.lsp["custom"] = {extensions: ["custom"]}"#, + r#"config.lsp["rust"] = {command: "server"}"#, + r#"config.lsp["rust"] = {command: []}"#, + r#"config.lsp["rust"] = {command: [""]}"#, + r#"config.lsp["rust"] = {command: ["server", 7]}"#, + r#"config.lsp["rust"] = {command: ["server", "bad\0argument"]}"#, + r#"config.lsp["rust"] = {extensions: "rs"}"#, + r#"config.lsp["rust"] = {extensions: []}"#, + r#"config.lsp["rust"] = {extensions: [""]}"#, + r#"config.lsp["rust"] = {extensions: [7]}"#, + r#"config.lsp["rust"] = {extensions: [".rs"]}"#, + r#"config.lsp["rust"] = {extensions: ["path/rs"]}"#, + r#"config.lsp["rust"] = {extensions: ["path\\rs"]}"#, + r#"config.lsp["rust"] = {extensions: ["r s"]}"#, + r#"config.lsp["rust"] = {extensions: ["r\u0001s"]}"#, + r#"config.lsp["rust"] = {initialization_options: []}"#, + r#"config.lsp["rust"] = {settings: nil}"#, + r#"config.lsp["rust"] = {settings: {bad: Object.new}}"#, + r#"config.lsp["rust"] = {settings: {bad: Float::NAN}}"#, + r#"config.lsp["rust"] = {settings: {7 => true}}"#, + r#"config.lsp["rust"] = {unknown: true}"#, + ] { + let path = fixture.file( + "invalid.rb", + &format!("Vamp.configure {{ |config| {assignment} }}"), + ); + assert!(load_config(&path).is_err(), "{assignment}"); + } + let path = fixture.file( + "invalid.rb", + r#"Vamp.configure { |config| config.lsp["unlisted"] = true }"#, + ); + let error = load_config(&path).unwrap_err(); + assert!( + error.contains("unlisted") && error.contains("preset"), + "{error}" + ); + } + + #[test] + fn lsp_wire_configuration_is_validated_before_being_consumed() { + let mut frame = serde_json::json!({ + "version": 1, "type": "config", "plugins": [], "lsp": {}, + "bindings": [], "leader": "space", "file_explorer_side": "right", + "file_picker_view": "tree", "file_explorer_view": "tree", + "clipboard_mode": "system" + }); + assert!(matches!( + serde_json::from_value::(frame.clone()).unwrap(), + Response::Config { lsp, .. } if lsp.is_empty() + )); + frame["lsp"] = serde_json::json!({"rust": true}); + assert!(matches!( + serde_json::from_value::(frame.clone()).unwrap(), + Response::Config { lsp, .. } + if lsp.len() == 1 && lsp[0].command == ["rust-analyzer"] + )); + for value in [ + serde_json::json!(null), + serde_json::json!([]), + serde_json::json!({"": false}), + serde_json::json!({"bad\u{0085}key": false}), + serde_json::json!({"rust": null}), + serde_json::json!({"unknown": true}), + serde_json::json!({"custom": {}}), + serde_json::json!({"rust": {"command": ["tool", "bad\0argument"]}}), + serde_json::json!({"rust": {"extensions": ["bad\nextension"]}}), + serde_json::json!({"rust": {"initialization_options": []}}), + serde_json::json!({"rust": {"settings": "not-an-object"}}), + serde_json::json!({"rust": {"unexpected": true}}), + ] { + frame["lsp"] = value; + assert!( + serde_json::from_value::(frame.clone()).is_err(), + "{frame}" + ); + } + let languages: serde_json::Map = (0..17) + .map(|index| { + ( + format!("custom-{index}"), + serde_json::json!({"command": ["server"], "extensions": ["custom"]}), + ) + }) + .collect(); + frame["lsp"] = serde_json::Value::Object(languages); + assert!(serde_json::from_value::(frame.clone()).is_err()); + frame.as_object_mut().unwrap().remove("lsp"); + assert!(serde_json::from_value::(frame).is_err()); + } + #[test] fn crash_hang_callback_failure_and_fresh_host_recovery() { let fixture = Fixture::new(); diff --git a/src/ruby_host.rb b/src/ruby_host.rb index 8169e9c..9cc5f5e 100644 --- a/src/ruby_host.rb +++ b/src/ruby_host.rb @@ -81,8 +81,11 @@ module Vamp Mode.private_class_method :new class Config + attr_reader :lsp + def initialize @plugins = [] + @lsp = {} @bindings = [] @leader = "space" @file_explorer_side = "right" @@ -159,8 +162,23 @@ module Vamp end def response + lsp = @lsp.to_h do |language, value| + language = Vamp.string!(language, "LSP language key") + if language.empty? || language.match?(/[[:cntrl:]]/) + raise ArgumentError, "LSP language keys must not be empty or contain control characters" + end + value = case value + when TrueClass, FalseClass + value + when Hash + Vamp.json_value!(value) + else + raise TypeError, "LSP language #{language.inspect} must be true, false, or a Hash" + end + [language, value] + end { "version" => 1, "type" => "config", "plugins" => @plugins, - "bindings" => @bindings, "leader" => @leader, + "lsp" => lsp, "bindings" => @bindings, "leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view, "file_explorer_view" => @file_explorer_view, diff --git a/src/session.rs b/src/session.rs index 3d36adf..afeb124 100644 --- a/src/session.rs +++ b/src/session.rs @@ -5,7 +5,8 @@ use std::{ }; use crate::{ - editor::{Editor, Effect, Input, Invocation}, + editor::{Cursor, Editor, Effect, Input, Invocation, Mode}, + lsp::Lsp, plugin::{ DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate, TextPosition, UpdateEffect, @@ -37,6 +38,9 @@ pub struct EditorSession { plugins: BTreeMap, documents: BTreeMap, requests: BTreeMap, + lsp: Lsp, + lsp_request_id: u64, + lsp_source_cursor: Option, } impl EditorSession { @@ -117,6 +121,9 @@ impl EditorSession { plugins, documents: BTreeMap::new(), requests: BTreeMap::new(), + lsp: Lsp::new(config.lsp), + lsp_request_id: 0, + lsp_source_cursor: None, }; session.synchronize_documents(); Ok(session) @@ -145,6 +152,31 @@ impl EditorSession { } fn dispatch(&mut self, invocation: Invocation) { + if invocation.plugin == "core" { + let result = match invocation.command.as_str() { + "lsp_definition" => { + if let Some(id) = self.lsp_request_id.checked_add(1) { + self.lsp_request_id = id; + let context = self.editor.context(); + let source = InvocationSource { + buffer_id: context.buffer_id, + revision: context.revision, + }; + self.requests.entry("core".into()).or_default().latest = Some((id, source)); + self.lsp_source_cursor = Some(self.editor.cursor()); + self.lsp.definition(&context, id) + } else { + Err("LSP request IDs exhausted".into()) + } + } + "lsp_diagnostics" => self.lsp.diagnostics(self.editor.buffer_id()), + _ => unreachable!("session-dispatched core command"), + }; + if let Err(error) = result { + self.editor.set_message(format!("LSP: {error}")); + } + return; + } let context = self.editor.context(); let result = match self.plugins.get_mut(&invocation.plugin) { Some(plugin) if plugin.is_async() => { @@ -178,7 +210,7 @@ impl EditorSession { } fn synchronize_documents(&mut self) -> bool { - if !self.plugins.values().any(RubyPlugin::is_async) { + if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) { self.documents.clear(); return false; } @@ -250,6 +282,13 @@ impl EditorSession { }); } let mut failures = Vec::new(); + let mut changed = false; + for event in &events { + if let Err(error) = self.lsp.notify_document(event) { + self.editor.set_message(format!("LSP: {error}")); + changed = true; + } + } for (owner, plugin) in &mut self.plugins { if !plugin.is_async() { continue; @@ -261,7 +300,7 @@ impl EditorSession { } } } - let changed = !failures.is_empty(); + changed |= !failures.is_empty(); for (owner, error) in failures { self.retire(&owner, &error); } @@ -282,6 +321,9 @@ impl EditorSession { ) -> bool { self.editor.buffer_id() == source.buffer_id && self.source_is_current(source) + && (owner != "core" + || (self.lsp_source_cursor == Some(self.editor.cursor()) + && self.editor.mode() == Mode::Normal)) && self .requests .get(owner) @@ -406,6 +448,16 @@ impl EditorSession { /// Drains ready plugin work without waiting for command or server responses. pub fn poll(&mut self) -> bool { let mut changed = self.synchronize_documents(); + for update in self.lsp.poll() { + match self.apply_update("core", update) { + Ok(applied) => changed |= applied, + Err(error) => { + self.editor.clear_plugin_diagnostics("core"); + self.editor.set_message(format!("LSP: {error}")); + changed = true; + } + } + } let mut ready = Vec::new(); for (owner, plugin) in &mut self.plugins { if plugin.is_async() { diff --git a/tests/lsp_plugin.rs b/tests/lsp.rs old mode 100755 new mode 100644 similarity index 86% rename from tests/lsp_plugin.rs rename to tests/lsp.rs index 6c44187..316469f --- a/tests/lsp_plugin.rs +++ b/tests/lsp.rs @@ -2,7 +2,7 @@ use std::{ fs, - path::{Path, PathBuf}, + path::PathBuf, sync::atomic::{AtomicU64, Ordering}, thread, time::{Duration, Instant}, @@ -17,7 +17,7 @@ use vamp::{ static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); -// A real subprocess speaking framed LSP, not a plugin echoing generic effects. +// A real subprocess speaking framed LSP to the native client. // Files are explicit test-controlled gates; no wall-clock ordering assumptions. const SERVER: &str = r#" require 'json' @@ -213,13 +213,14 @@ impl Fixture { self.alternate ]) }; - let options = json!({"servers": [{ - "language_id": "rust", "extensions": ["rs"], "command": command, - }]}); - let plugin = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/plugins/lsp.rb"); + let options = json!({ + "extensions": if mode == "irrelevant" { vec!["myl"] } else { vec!["rs"] }, + "command": command, + "initialization_options": {"fixture": true}, + "settings": {"rust-analyzer": {"cargo": {"allFeatures": true}}}, + }); let config = format!( - "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.plugin {}, options: JSON.parse({})\nend\n", - serde_json::to_string(&plugin).unwrap(), + "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.lsp['fixture-language'] = JSON.parse({})\nend\n", serde_json::to_string(&options.to_string()).unwrap(), ); let config_path = self.root.join("config.rb"); @@ -323,6 +324,10 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() { initialize["params"]["capabilities"]["general"]["positionEncodings"], json!(["utf-16"]) ); + assert_eq!( + initialize["params"]["initializationOptions"], + json!({"fixture": true}) + ); assert_eq!( initialize["params"]["capabilities"]["textDocument"]["publishDiagnostics"]["versionSupport"], true @@ -340,6 +345,10 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() { .position(|frame| frame["method"] == "initialized") .unwrap(); assert!(initialized_index < open_index); + assert_eq!( + frames[open_index]["params"]["textDocument"]["languageId"], + "fixture-language" + ); assert_eq!( frames[open_index]["params"]["textDocument"]["text"], "A😀e\u{301}Z\n" @@ -368,7 +377,7 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() { .iter() .find(|frame| frame["id"] == "settings") .unwrap()["result"], - json!([null, null]) + json!([{"cargo": {"allFeatures": true}}, null]) ); assert_eq!( frames.iter().find(|frame| frame["id"] == 0).unwrap()["error"]["code"], @@ -698,3 +707,87 @@ fn lsp_optional_configuration_numeric_sync_and_unversioned_diagnostics() { assert_eq!(fixture.method_count("textDocument/didChange"), 0); assert_eq!(fixture.method_count("textDocument/didSave"), 0); } + +#[test] +fn lsp_definition_reuses_dirty_targets_without_an_enabled_target_language() { + let mut fixture = Fixture::new(); + let target = fixture.target.with_extension("txt"); + fs::rename(&fixture.target, &target).unwrap(); + fixture.target = target; + let mut session = fixture.open("basic"); + until(&mut session, &fixture, |session| { + session.editor().diagnostics().count() == 2 + }); + let target_id = edit_target(&mut session, &fixture); + session.execute("lsp_definition"); + until(&mut session, &fixture, |session| { + session.editor().buffer_id() == target_id + }); + assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 }); + assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]); + assert!(session.editor().dirty()); + assert_eq!(fs::read_to_string(&fixture.target).unwrap(), "disk\n"); + assert_eq!(fixture.method_count("textDocument/didOpen"), 1); +} + +#[test] +fn lsp_definition_does_not_move_a_caret_that_has_moved_since_the_request() { + let fixture = Fixture::new(); + let mut session = fixture.open("stale"); + until(&mut session, &fixture, |session| { + session.editor().diagnostics().count() == 2 + }); + session.execute("lsp_definition"); + until(&mut session, &fixture, |_| { + fixture.method_count("textDocument/definition") == 1 + }); + session.handle(Input::Right); + fixture.release(); + until(&mut session, &fixture, |session| { + session + .editor() + .message() + .contains("stale response complete") + }); + assert_eq!(session.editor().buffer_id(), 1); + assert_eq!(session.editor().buffer_count(), 1); + assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 }); +} + +#[test] +fn lsp_only_starts_an_explicitly_configured_server_when_a_matching_buffer_opens() { + let fixture = Fixture::new(); + let mut session = fixture.open("irrelevant"); + session.execute("lsp_diagnostics"); + until(&mut session, &fixture, |session| { + session.editor().message().contains("no running server") + }); + assert!(!fixture.log.exists()); + let custom = fixture.root.join("custom.myl"); + fs::write(&custom, "custom language\n").unwrap(); + session.execute(&format!("edit {}", custom.display())); + until(&mut session, &fixture, |_| { + fixture.method_count("textDocument/didOpen") == 1 + }); + let frames = fixture.frames(); + let open = frames + .iter() + .find(|frame| frame["method"] == "textDocument/didOpen") + .unwrap(); + assert_eq!( + open["params"]["textDocument"]["languageId"], + "fixture-language" + ); + assert_eq!(open["params"]["textDocument"]["text"], "custom language\n"); +} + +#[test] +fn lsp_session_shutdown_finishes_the_server_protocol_before_returning() { + let fixture = Fixture::new(); + let mut session = fixture.open("basic"); + until(&mut session, &fixture, |session| { + session.editor().diagnostics().count() == 2 + }); + drop(session); + assert_eq!(fixture.method_count("shutdown"), 1); +}