diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0fd96e4..e83b13e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -124,6 +124,7 @@ jobs: ./target/release/stack check --help ./target/release/stack fmt --help ./target/release/stack render --help + ./target/release/stack lsp --help ./target/release/stack icons --help ./target/release/stack icons list ./target/release/stack icons list aws s3 @@ -137,6 +138,7 @@ jobs: test -s THIRD_PARTY_LICENSES.md test -s CONTRIBUTING.md test -s SECURITY.md + test -s docs/language-server.md test -s docs/publication-audit.md test -s docs/provider-icon-import.md test -s docs/distribution.md @@ -149,6 +151,7 @@ jobs: test -s Cargo.toml test -s Cargo.lock test -s src/config.rs + test -s src/lsp.rs test -s src/main.rs test -s src/templates.rs test -s src/provider.rs @@ -186,6 +189,7 @@ jobs: test -s templates/sources/01-minimal.stack test -s tests/specification-revision test -s tests/fixtures/render.stack + test -s tests/snapshots/lsp-help.txt msrv: name: Minimum supported Rust diff --git a/Cargo.lock b/Cargo.lock index bbe151f..3e49f8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -376,6 +376,7 @@ dependencies = [ "serde_json", "serde_yaml_ng", "sha2", + "stack-compiler", "stack-engine", "stack-theme", "ureq", @@ -385,12 +386,12 @@ dependencies = [ [[package]] name = "stack-compiler" version = "0.1.0" -source = "git+https://github.com/stack-sh/compiler.git?rev=4a18fac42afc2256a1bb3a6ff13d12d732a391e7#4a18fac42afc2256a1bb3a6ff13d12d732a391e7" +source = "git+https://github.com/stack-sh/compiler.git?rev=84ab5663a7f7c5b7dc0b5e9e2f04c8894ed02820#84ab5663a7f7c5b7dc0b5e9e2f04c8894ed02820" [[package]] name = "stack-engine" version = "0.6.0" -source = "git+https://github.com/stack-sh/engine.git?rev=2c9adabd2ffee627ac5fe3ee26331b0ad75bceac#2c9adabd2ffee627ac5fe3ee26331b0ad75bceac" +source = "git+https://github.com/stack-sh/engine.git?rev=9af727aea79233b8389e0ed6fdbae7d3f388dc29#9af727aea79233b8389e0ed6fdbae7d3f388dc29" dependencies = [ "roxmltree", "serde_json", @@ -403,7 +404,7 @@ dependencies = [ [[package]] name = "stack-formatter" version = "0.1.0" -source = "git+https://github.com/stack-sh/engine.git?rev=2c9adabd2ffee627ac5fe3ee26331b0ad75bceac#2c9adabd2ffee627ac5fe3ee26331b0ad75bceac" +source = "git+https://github.com/stack-sh/engine.git?rev=9af727aea79233b8389e0ed6fdbae7d3f388dc29#9af727aea79233b8389e0ed6fdbae7d3f388dc29" dependencies = [ "stack-compiler", ] diff --git a/Cargo.toml b/Cargo.toml index c3c2077..e134481 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,8 @@ serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" serde_yaml_ng = "=0.10.0" sha2 = "=0.11.0" -stack-engine = { git = "https://github.com/stack-sh/engine.git", rev = "2c9adabd2ffee627ac5fe3ee26331b0ad75bceac" } +stack-compiler = { git = "https://github.com/stack-sh/compiler.git", rev = "84ab5663a7f7c5b7dc0b5e9e2f04c8894ed02820" } +stack-engine = { git = "https://github.com/stack-sh/engine.git", rev = "9af727aea79233b8389e0ed6fdbae7d3f388dc29" } stack-theme = { git = "https://github.com/stack-sh/theme.git", rev = "7e208d6a3c90d255799f390a4e8b86248c73caee" } ureq = { version = "=3.4.0", default-features = false, features = ["rustls"] } zip = { version = "=6.0.0", default-features = false, features = ["deflate-flate2-zlib-rs"] } diff --git a/README.md b/README.md index af409de..6801182 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ stack fmt --check arch.stack stack fmt - stack render arch.stack stack render arch.stack -o arch.svg +stack lsp stack icons list stack icons list aws s3 stack icons import gcp --accept-terms @@ -55,6 +56,8 @@ stack render arch.stack -o arch.svg --notice arch.NOTICE.md `stack render` uses the same engine pipeline to produce deterministic standalone SVG. Without `-o`, standard output contains only SVG. With `-o`, the output is written atomically in the destination directory. It discovers imported `aws`, `gcp`, `azure`, and `simple-icons` packs below the shared icon store. Use `--provider-pack ` for a project-local icon-store root, and use `--notice ` to save the exact provider pack revisions, terms, source archives, and icon IDs embedded in that artifact. Pack files are bounded and validated before rendering. Diagnostics remain on standard error, warnings preserve SVG, and Stack errors never create or replace output. +`stack lsp` runs a native [Language Server Protocol 3.18 adapter](./docs/language-server.md) over standard input and output. It provides incremental document synchronization, versioned diagnostics, completion, hover, hierarchical document symbols, and whole-document formatting for `.stack` files. The adapter negotiates UTF-8, UTF-16, or UTF-32 positions and delegates language semantics and formatting to the pinned compiler and engine rather than reimplementing them. Standard output is reserved for framed JSON-RPC messages. + `stack icons list [PROVIDER] [QUERY]` searches the asset-free catalog by ID, product name, or category. The catalog currently contains 1,051 IDs: 305 AWS, 45 Google Cloud, 639 Azure, and 62 curated developer and collaboration tool icons. This command reads only metadata embedded in the CLI. `stack icons import --accept-terms` downloads the audited official archive set, verifies every complete SHA-256 before ZIP processing, reads allowlisted SVG entries with fixed size limits, sanitizes active and external content, preserves official colors and geometry, and writes the manifest, notice, and processed SVGs atomically. The default store is `$XDG_CONFIG_HOME/stack/icons`, falling back to `$HOME/.config/stack/icons`. `$XDG_CONFIG_HOME/stack/config.yaml` can set an absolute `default_icons_path`. Use `-o ` to put provider child directories below a project-local root. See [the provider icon guide](./docs/provider-icon-import.md) for configuration, project-local usage, sources, hashes, and rights. @@ -65,7 +68,7 @@ stack render arch.stack -o arch.svg --notice arch.NOTICE.md | One or more Stack error diagnostics, or `fmt --check` finds a difference | `1` | | Invalid arguments, host I/O failure, or engine operational failure | `2` | -The CLI links `stack-engine` as a native Rust dependency. It owns filesystem and standard-stream behavior, process exit codes, configuration discovery, provider-pack import, notice output, and command presentation. It must not duplicate compiler, formatter, layout, or SVG-rendering logic. +The CLI links `stack-engine` and the protocol-neutral `stack-compiler` language-intelligence API as native Rust dependencies. It owns filesystem and standard-stream behavior, process exit codes, configuration discovery, provider-pack import, LSP transport and document state, notice output, and command presentation. It must not duplicate compiler, formatter, layout, or SVG-rendering logic. The bundled engine resolves 30 provider-neutral core icons: `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, `observability`, `gateway`, `load-balancer`, `dns`, `cdn`, `firewall`, `network`, `event`, `stream`, `search`, `analytics`, `repository`, `pipeline`, `secret`, `document`, `task`, `chat`, `email`, and `ai`. User-managed provider packs preserve upstream artwork and attach source, archive hash, transformation, terms, and notice metadata. Rendering resolves namespaced IDs such as `aws:s3`, preserves the authored semantic `kind`, embeds the selected local asset, and writes its provenance into SVG metadata and the optional notice sidecar. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 52e435d..7384197 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -6,8 +6,8 @@ Audit date: 2026-09-05 | Component | Version or revision | License | Source | Distribution note | | --- | --- | --- | --- | --- | -| `stack-engine` / `stack-formatter` | `2c9adabd2ffee627ac5fe3ee26331b0ad75bceac` | Apache-2.0 | | Linked into the native binary; validates and renders caller-owned provider packs without bundling vendor assets. | -| `stack-compiler` | `4a18fac42afc2256a1bb3a6ff13d12d732a391e7` | Apache-2.0 | | Linked transitively through `stack-engine`; preserves namespaced provider icon identifiers in normalized IR. | +| `stack-engine` / `stack-formatter` | `9af727aea79233b8389e0ed6fdbae7d3f388dc29` | Apache-2.0 | | Linked into the native binary; validates and renders caller-owned provider packs without bundling vendor assets. | +| `stack-compiler` | `84ab5663a7f7c5b7dc0b5e9e2f04c8894ed02820` | Apache-2.0 | | Linked directly for protocol-neutral language intelligence and transitively through `stack-engine`; performs no runtime I/O. | | `stack-theme` | `7e208d6a3c90d255799f390a4e8b86248c73caee` | Apache-2.0 | | Linked directly and through `stack-engine`; its 30 fallback and 12 explicit core SVGs are Stack-authored Apache-2.0 assets. It also provides the asset-free provider-pack contract and types. | | `roxmltree` | `0.21.1` | MIT OR Apache-2.0 | | Parses untrusted local SVG into a read-only tree before allowlisted serialization. | | `sha2`, `digest`, `block-buffer`, `crypto-common`, `hybrid-array`, `const-oid`, `typenum` | `0.11.0`, `0.11.3`, `0.12.1`, `0.2.2`, `0.4.14`, `0.10.2`, `1.20.1` | MIT OR Apache-2.0 | | Computes complete archive and per-asset SHA-256 identities. | diff --git a/docs/language-server.md b/docs/language-server.md new file mode 100644 index 0000000..a8f9ee7 --- /dev/null +++ b/docs/language-server.md @@ -0,0 +1,72 @@ +# Native Stack language server + +`stack lsp` exposes Stack language intelligence through the Language Server Protocol (LSP) 3.18. It is a long-running standard-input/standard-output process intended to be launched by an editor or another LSP client: + +```sh +stack lsp +``` + +Standard output is reserved for `Content-Length` framed JSON-RPC messages. Operational framing failures are written to standard error and terminate the process with exit status `2`. Stack source diagnostics are sent through `textDocument/publishDiagnostics` and do not use the process exit status. + +## Capabilities + +The server advertises these static capabilities during `initialize`: + +| LSP capability | Method or notification | Behavior | +| --- | --- | --- | +| Incremental synchronization | `textDocument/didOpen`, `didChange`, `didClose` | Maintains one current, monotonically versioned UTF-8 snapshot per open URI and applies ranged changes in order. | +| Diagnostics | `textDocument/publishDiagnostics` | Publishes compiler errors and warnings after open and every accepted change, with the matching document version, and clears them on close. | +| Completion | `textDocument/completion` | Returns syntax- and scope-aware keywords, properties, enum values, document identifiers, and bundled core icon IDs. | +| Hover | `textDocument/hover` | Returns plain-text information for declarations, references, edges, and properties. | +| Document symbols | `textDocument/documentSymbol` | Returns a hierarchical diagram, group, node, and edge outline. | +| Formatting | `textDocument/formatting` | Returns one whole-document edit when the engine formatter changes valid source, or no edits for canonical or syntactically invalid source. | + +Completion uses the bundled Stack core icon catalog. User-imported provider packs remain local rendering inputs and are not read by the language-server process. + +## Client setup + +Configure the client to associate the `stack` language ID and `.stack` extension with the command `stack lsp`. The executable must be available in the environment inherited by the editor. A minimal Neovim setup is: + +```lua +vim.filetype.add({ extension = { stack = "stack" } }) + +vim.api.nvim_create_autocmd("FileType", { + pattern = "stack", + callback = function(args) + vim.lsp.start({ + name = "stack", + cmd = { "stack", "lsp" }, + root_dir = vim.fs.root(args.buf, { ".git" }) or vim.fn.getcwd(), + }) + end, +}) +``` + +Restart the editor after installing or replacing the binary so the new process uses the expected version. Run `stack lsp --help` in the same environment when diagnosing executable discovery. + +## Protocol lifecycle and positions + +The server accepts `initialize` exactly once, then normal requests and notifications, then `shutdown` followed by `exit`. Requests before initialization return `ServerNotInitialized`; requests after shutdown return `InvalidRequest`. An `exit` before `shutdown` terminates with status `1`, as required by the LSP lifecycle. + +The client may list `general.positionEncodings` in preference order. The server selects the first supported value among `utf-8`, `utf-16`, and `utf-32`, and defaults to the LSP-required UTF-16 encoding when the client omits the list. Ranges are end-exclusive. Incremental edits that split a Unicode scalar, address a missing line, move backwards, or do not increase the document version are rejected without changing the stored snapshot. + +The adapter processes messages serially. It accepts `$/cancelRequest`, bounds remembered request IDs, returns `RequestCancelled` when an ID is cancelled before its request begins, and never replaces an already committed result with cancellation. Work already executing in this synchronous MVP is not preempted. Versioned diagnostics and serialized snapshot access prevent results for an older accepted change from being published as current. + +## Resource and failure boundaries + +| Input | Limit | +| --- | ---: | +| One JSON-RPC payload | 8 MiB | +| One header block | 32 KiB | +| One open document | 4 MiB | +| Open documents | 64 | +| Remembered cancelled or completed request IDs | 1,024 per set | +| Document URI | 4,096 Unicode scalars | + +Malformed JSON produces a JSON-RPC parse error and the next correctly framed message can still be processed. Invalid methods, parameters, document versions, and source positions produce standard JSON-RPC or LSP errors. Invalid notification parameters are ignored and reported with `window/logMessage`. Invalid framing, truncated bodies, unsupported declared charsets, and I/O failures terminate the transport without attempting to resynchronize an untrusted byte stream. + +## Ownership boundary + +The CLI owns LSP framing, lifecycle, negotiated coordinate conversion, bounded open-document state, incremental changes, cancellation bookkeeping, and conversion to protocol values. `stack-compiler` owns diagnostics, completion, hover, symbol semantics, stable codes, and authored source spans for one immutable snapshot. `stack-engine` owns canonical formatting, and `stack-theme` owns bundled core icon metadata. None of those pure libraries perform editor transport, filesystem, network, clock, or process-environment access for an LSP request. + +The protocol reference is the [Language Server Protocol 3.18 specification](https://github.com/microsoft/language-server-protocol/blob/gh-pages/_specifications/lsp/3.18/specification.md). diff --git a/scripts/verify_release_binary.py b/scripts/verify_release_binary.py index 2be1c30..8ac71a2 100644 --- a/scripts/verify_release_binary.py +++ b/scripts/verify_release_binary.py @@ -89,6 +89,10 @@ def verify_commands(binary, version): require(command([binary, "--version"]) == expected_version, "--version output does not match Cargo version") require(command([binary, "version"]) == expected_version, "version command output does not match Cargo version") require(b"Usage:" in command([binary, "help"]), "help output is missing usage") + require( + b"stack lsp" in command([binary, "lsp", "--help"]), + "LSP help output is missing usage", + ) with tempfile.TemporaryDirectory(prefix="stack-release-smoke-") as temporary: working_directory = Path(temporary) diff --git a/src/lib.rs b/src/lib.rs index 0160c20..59be5a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ use stack_engine::{ }; mod config; +mod lsp; mod provider; mod provider_catalog; mod templates; @@ -38,6 +39,7 @@ Commands: check Validate a Stack source file without modifying it fmt Format a file in place or read from standard input render Render standalone SVG to standard output or a file + lsp Run the Stack language server over standard input and output icons List catalogs and import audited provider icon archives help Print this message or the help of a subcommand version Print version information @@ -52,6 +54,7 @@ Examples: stack check arch.stack stack fmt --check arch.stack stack render arch.stack -o arch.svg + stack lsp stack icons list aws s3 "; const INIT_HELP: &str = "\ @@ -146,6 +149,22 @@ Examples: stack render arch.stack -o arch.svg stack render arch.stack --notice arch.NOTICE.md -o arch.svg "; +const LSP_HELP: &str = "\ +Run the Stack language server over standard input and output + +Usage: + stack lsp + +Options: + -h, --help Print help + +Protocol: + LSP 3.18 JSON-RPC messages use Content-Length framing over standard input + and output. Standard output is reserved for protocol messages. + +Examples: + stack lsp +"; const ICONS_HELP: &str = "\ Manage local provider icon packs @@ -219,7 +238,7 @@ Usage: stack help icons Arguments: - init, check, fmt, render, icons, help, or version + init, check, fmt, render, lsp, icons, help, or version Examples: stack help @@ -303,6 +322,9 @@ pub fn run( if command == OsStr::new("render") { return run_render(arguments, stdout, stderr); } + if command == OsStr::new("lsp") { + return run_lsp(arguments, stdin, stdout, stderr); + } if command == OsStr::new("icons") { return run_icons(&mut arguments, stdout, stderr); } @@ -310,7 +332,9 @@ pub fn run( unknown_command_error( "stack", &command, - &["init", "check", "fmt", "render", "icons", "help", "version"], + &[ + "init", "check", "fmt", "render", "lsp", "icons", "help", "version", + ], "stack help", stderr, ) @@ -345,6 +369,8 @@ fn run_help( FORMAT_HELP } else if command == OsStr::new("render") { RENDER_HELP + } else if command == OsStr::new("lsp") { + LSP_HELP } else if command == OsStr::new("help") { HELP_HELP } else if command == OsStr::new("version") { @@ -353,7 +379,9 @@ fn run_help( return unknown_command_error( "stack help", &command, - &["init", "check", "fmt", "render", "icons", "help", "version"], + &[ + "init", "check", "fmt", "render", "lsp", "icons", "help", "version", + ], "stack help", stderr, ); @@ -526,6 +554,30 @@ fn run_version( ) } +fn run_lsp( + mut arguments: impl Iterator, + stdin: &mut dyn Read, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let Some(argument) = arguments.next() else { + return lsp::run(stdin, stdout, stderr); + }; + if is_help_flag(&argument) { + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + return write_stdout(LSP_HELP, stdout, stderr); + } + argument_error( + &format!("unexpected argument '{}'", argument.to_string_lossy()), + stderr, + ) +} + fn run_icons( arguments: &mut dyn Iterator, stdout: &mut dyn Write, diff --git a/src/lsp.rs b/src/lsp.rs new file mode 100644 index 0000000..ebd9767 --- /dev/null +++ b/src/lsp.rs @@ -0,0 +1,1839 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::io::{self, BufRead, BufReader, BufWriter, Read, Write}; +use std::sync::OnceLock; + +use serde_json::{Map, Value, json}; +use stack_compiler::diagnostic::{Diagnostic, Severity, SourcePosition, Span}; +use stack_compiler::language_intelligence::{ + self, CompletionCatalog, CompletionCatalogEntry, CompletionItem, CompletionKind, + DocumentSymbol, DocumentSymbolKind, Hover, IntelligenceError, +}; +use stack_engine::Engine; + +use crate::{EXIT_STACK_ERROR, EXIT_SUCCESS, EXIT_USAGE_OR_IO}; + +const MAX_MESSAGE_BYTES: usize = 8 * 1024 * 1024; +const MAX_HEADER_BYTES: usize = 32 * 1024; +const MAX_DOCUMENT_BYTES: usize = 4 * 1024 * 1024; +const MAX_OPEN_DOCUMENTS: usize = 64; +const MAX_REQUEST_IDS: usize = 1_024; +const MAX_URI_CHARS: usize = 4_096; + +const PARSE_ERROR: i64 = -32_700; +const INVALID_REQUEST: i64 = -32_600; +const METHOD_NOT_FOUND: i64 = -32_601; +const INVALID_PARAMS: i64 = -32_602; +const INTERNAL_ERROR: i64 = -32_603; +const SERVER_NOT_INITIALIZED: i64 = -32_002; +const REQUEST_CANCELLED: i64 = -32_800; + +pub(crate) fn run(stdin: &mut dyn Read, stdout: &mut dyn Write, stderr: &mut dyn Write) -> u8 { + let mut reader = BufReader::new(stdin); + let mut writer = BufWriter::new(stdout); + let mut session = Session::new(); + + loop { + let payload = match read_frame(&mut reader) { + Ok(Some(payload)) => payload, + Ok(None) => { + let _ = writeln!( + stderr, + "error: LSP input closed before the exit notification" + ); + return EXIT_USAGE_OR_IO; + } + Err(error) => { + let _ = writeln!(stderr, "error: invalid LSP frame: {error}"); + return EXIT_USAGE_OR_IO; + } + }; + let outcome = match serde_json::from_slice::(&payload) { + Ok(message) => session.handle(message), + Err(_) => Outcome::message(error_response( + Value::Null, + PARSE_ERROR, + "invalid JSON payload", + )), + }; + for message in outcome.messages { + if let Err(error) = write_frame(&mut writer, &message) { + let _ = writeln!(stderr, "error: cannot write LSP response: {error}"); + return EXIT_USAGE_OR_IO; + } + } + if let Err(error) = writer.flush() { + let _ = writeln!(stderr, "error: cannot flush LSP response: {error}"); + return EXIT_USAGE_OR_IO; + } + if let Some(exit_code) = outcome.exit_code { + return exit_code; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Lifecycle { + BeforeInitialize, + Running, + Shutdown, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PositionEncoding { + Utf8, + Utf16, + Utf32, +} + +impl PositionEncoding { + const fn name(self) -> &'static str { + match self { + Self::Utf8 => "utf-8", + Self::Utf16 => "utf-16", + Self::Utf32 => "utf-32", + } + } + + fn units(self, text: &str) -> usize { + match self { + Self::Utf8 => text.len(), + Self::Utf16 => text.chars().map(char::len_utf16).sum(), + Self::Utf32 => text.chars().count(), + } + } + + fn byte_offset(self, text: &str, target_units: usize) -> Option { + if target_units == 0 { + return Some(0); + } + let mut units = 0_usize; + for (byte_offset, scalar) in text.char_indices() { + units += match self { + Self::Utf8 => scalar.len_utf8(), + Self::Utf16 => scalar.len_utf16(), + Self::Utf32 => 1, + }; + if units == target_units { + return Some(byte_offset + scalar.len_utf8()); + } + if units > target_units { + return None; + } + } + (units == target_units).then_some(text.len()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Document { + text: String, + version: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum RequestId { + Number(i64), + String(String), +} + +impl RequestId { + fn from_json(value: &Value) -> ProtocolResult { + match value { + Value::Number(number) => number + .as_i64() + .map(Self::Number) + .ok_or_else(|| ProtocolError::invalid_request("request ID must be an integer")), + Value::String(identifier) if identifier.chars().count() <= 256 => { + Ok(Self::String(identifier.clone())) + } + Value::String(_) => Err(ProtocolError::invalid_request("request ID is too long")), + _ => Err(ProtocolError::invalid_request( + "request ID must be an integer or string", + )), + } + } + + fn json(&self) -> Value { + match self { + Self::Number(identifier) => json!(identifier), + Self::String(identifier) => json!(identifier), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +struct ProtocolError { + code: i64, + message: String, +} + +impl ProtocolError { + fn invalid_request(message: &str) -> Self { + Self { + code: INVALID_REQUEST, + message: message.to_owned(), + } + } + + fn invalid_params(message: &str) -> Self { + Self { + code: INVALID_PARAMS, + message: message.to_owned(), + } + } +} + +macro_rules! internal_error { + ($message:expr) => { + ProtocolError { + code: INTERNAL_ERROR, + message: ($message).into(), + } + }; +} + +type ProtocolResult = Result; + +#[derive(Debug)] +struct Outcome { + messages: Vec, + exit_code: Option, +} + +impl Outcome { + fn none() -> Self { + Self { + messages: Vec::new(), + exit_code: None, + } + } + + fn message(message: Value) -> Self { + Self { + messages: vec![message], + exit_code: None, + } + } + + fn exit(exit_code: u8) -> Self { + Self { + messages: Vec::new(), + exit_code: Some(exit_code), + } + } +} + +struct Session { + lifecycle: Lifecycle, + encoding: PositionEncoding, + documents: BTreeMap, + cancelled: BTreeSet, + completed: BTreeSet, +} + +impl Session { + fn new() -> Self { + Self { + lifecycle: Lifecycle::BeforeInitialize, + encoding: PositionEncoding::Utf16, + documents: BTreeMap::new(), + cancelled: BTreeSet::new(), + completed: BTreeSet::new(), + } + } + + fn handle(&mut self, message: Value) -> Outcome { + let Some(object) = message.as_object() else { + return Outcome::message(error_response( + Value::Null, + INVALID_REQUEST, + "JSON-RPC message must be an object", + )); + }; + if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Outcome::message(error_response( + Value::Null, + INVALID_REQUEST, + "jsonrpc must be 2.0", + )); + } + let Some(method) = object.get("method").and_then(Value::as_str) else { + return Outcome::message(error_response( + Value::Null, + INVALID_REQUEST, + "client message must contain a method", + )); + }; + let params = object.get("params").cloned().unwrap_or(Value::Null); + if let Some(identifier) = object.get("id") { + let identifier = match RequestId::from_json(identifier) { + Ok(identifier) => identifier, + Err(error) => { + return Outcome::message(error_response( + Value::Null, + error.code, + &error.message, + )); + } + }; + self.handle_request(identifier, method, ¶ms) + } else { + self.handle_notification(method, ¶ms) + } + } + + fn handle_request(&mut self, identifier: RequestId, method: &str, params: &Value) -> Outcome { + let response_identifier = identifier.json(); + if self.cancelled.remove(&identifier) { + self.remember_completed(identifier); + return Outcome::message(error_response( + response_identifier, + REQUEST_CANCELLED, + "request cancelled", + )); + } + + let result = match self.lifecycle { + Lifecycle::BeforeInitialize if method == "initialize" => self.initialize(params), + Lifecycle::BeforeInitialize => Err(ProtocolError { + code: SERVER_NOT_INITIALIZED, + message: "server is not initialized".into(), + }), + Lifecycle::Running if method == "initialize" => Err(ProtocolError::invalid_request( + "initialize may only be sent once", + )), + Lifecycle::Running if method == "shutdown" => { + self.lifecycle = Lifecycle::Shutdown; + Ok(Value::Null) + } + Lifecycle::Running => self.handle_feature_request(method, params), + Lifecycle::Shutdown => Err(ProtocolError::invalid_request( + "request received after shutdown", + )), + }; + self.remember_completed(identifier); + Outcome::message(match result { + Ok(result) => success_response(response_identifier, result), + Err(error) => error_response(response_identifier, error.code, &error.message), + }) + } + + fn handle_notification(&mut self, method: &str, params: &Value) -> Outcome { + if method == "exit" { + return Outcome::exit(if self.lifecycle == Lifecycle::Shutdown { + EXIT_SUCCESS + } else { + EXIT_STACK_ERROR + }); + } + match self.lifecycle { + Lifecycle::BeforeInitialize | Lifecycle::Shutdown => Outcome::none(), + Lifecycle::Running if method == "initialized" || method == "$/setTrace" => { + Outcome::none() + } + Lifecycle::Running if method == "$/cancelRequest" => { + self.cancel_request(params); + Outcome::none() + } + Lifecycle::Running if method == "textDocument/didOpen" => { + let result = self.did_open(params); + self.notification_result("didOpen", result) + } + Lifecycle::Running if method == "textDocument/didChange" => { + let result = self.did_change(params); + self.notification_result("didChange", result) + } + Lifecycle::Running if method == "textDocument/didClose" => { + let result = self.did_close(params); + self.notification_result("didClose", result) + } + Lifecycle::Running => Outcome::none(), + } + } + + fn initialize(&mut self, params: &Value) -> ProtocolResult { + let params = required_object(params, "initialize params")?; + self.encoding = negotiate_position_encoding(params); + self.lifecycle = Lifecycle::Running; + Ok(json!({ + "capabilities": { + "positionEncoding": self.encoding.name(), + "textDocumentSync": { + "openClose": true, + "change": 2 + }, + "completionProvider": { + "resolveProvider": false, + "triggerCharacters": [" ", "\"", ":"] + }, + "hoverProvider": true, + "documentSymbolProvider": true, + "documentFormattingProvider": true + }, + "serverInfo": { + "name": "stack-lsp", + "version": env!("CARGO_PKG_VERSION") + } + })) + } + + fn did_open(&mut self, params: &Value) -> ProtocolResult { + let document = required_object_field(params, "textDocument")?; + let uri = validated_uri(required_string(document, "uri")?)?; + let _language_id = required_string(document, "languageId")?; + let version = required_version(document)?; + let text = required_string(document, "text")?; + validate_document_size(text)?; + if !self.documents.contains_key(&uri) && self.documents.len() >= MAX_OPEN_DOCUMENTS { + return Err(ProtocolError::invalid_params( + "open document limit exceeded", + )); + } + self.documents.insert( + uri.clone(), + Document { + text: text.into(), + version, + }, + ); + publish_diagnostics(&uri, text, version, self.encoding) + } + + fn did_change(&mut self, params: &Value) -> ProtocolResult { + let identifier = required_object_field(params, "textDocument")?; + let uri = validated_uri(required_string(identifier, "uri")?)?; + let version = required_version(identifier)?; + let changes = required_array(params, "contentChanges")?; + let Some(current) = self.documents.get(&uri) else { + return Err(ProtocolError::invalid_params("document is not open")); + }; + if version <= current.version { + return Err(ProtocolError::invalid_params( + "document version must increase", + )); + } + let mut updated = current.text.clone(); + for change in changes { + apply_content_change(&mut updated, change, self.encoding)?; + } + validate_document_size(&updated)?; + self.documents.insert( + uri.clone(), + Document { + text: updated.clone(), + version, + }, + ); + publish_diagnostics(&uri, &updated, version, self.encoding) + } + + fn did_close(&mut self, params: &Value) -> ProtocolResult { + let identifier = required_object_field(params, "textDocument")?; + let uri = validated_uri(required_string(identifier, "uri")?)?; + let Some(document) = self.documents.remove(&uri) else { + return Err(ProtocolError::invalid_params("document is not open")); + }; + let version = document.version as i64; + Ok(json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": version, + "diagnostics": [] + } + })) + } + + fn handle_feature_request(&self, method: &str, params: &Value) -> ProtocolResult { + match method { + "textDocument/completion" => self.completion(params), + "textDocument/hover" => self.hover(params), + "textDocument/documentSymbol" => self.document_symbols(params), + "textDocument/formatting" => self.format_document(params), + _ => Err(ProtocolError { + code: METHOD_NOT_FOUND, + message: format!("unsupported method: {method}"), + }), + } + } + + fn completion(&self, params: &Value) -> ProtocolResult { + let document = self.request_document(params)?; + let position = compiler_position_from_lsp( + &document.text, + required_object_field(params, "position")?, + self.encoding, + )?; + let output = match language_intelligence::completion( + &document.text, + document.version, + position, + completion_catalog(), + ) { + Ok(output) => output, + Err(IntelligenceError::InvalidPosition) => { + return Err(ProtocolError::invalid_params( + "position is outside the document", + )); + } + Err(error) => { + return Err(internal_error!(format!( + "completion catalog is invalid: {error}" + ))); + } + }; + let items = output + .items + .iter() + .map(|item| completion_item_json(&document.text, item, self.encoding)) + .collect::>>()?; + Ok(json!({ + "isIncomplete": output.is_incomplete, + "items": items + })) + } + + fn hover(&self, params: &Value) -> ProtocolResult { + let document = self.request_document(params)?; + let position = compiler_position_from_lsp( + &document.text, + required_object_field(params, "position")?, + self.encoding, + )?; + let output = match language_intelligence::hover(&document.text, document.version, position) + { + Ok(output) => output, + Err(IntelligenceError::InvalidPosition) => { + return Err(ProtocolError::invalid_params( + "position is outside the document", + )); + } + Err(error) => { + return Err(internal_error!(format!( + "language intelligence failed: {error}" + ))); + } + }; + output + .hover + .as_ref() + .map(|hover| hover_json(&document.text, hover, self.encoding)) + .transpose() + .map(|hover| hover.unwrap_or(Value::Null)) + } + + fn document_symbols(&self, params: &Value) -> ProtocolResult { + let document = self.request_document(params)?; + let output = language_intelligence::document_symbols(&document.text, document.version); + let symbols = output + .symbols + .iter() + .map(|symbol| document_symbol_json(&document.text, symbol, self.encoding)) + .collect::>>()?; + Ok(Value::Array(symbols)) + } + + fn format_document(&self, params: &Value) -> ProtocolResult { + let _options = required_object_field(params, "options")?; + let document = self.request_document(params)?; + let output = match Engine::bundled().format(document.text.as_bytes()) { + Ok(output) => output, + Err(error) => return Err(internal_error!(format!("formatter failed: {error}"))), + }; + let Some(formatted) = output.formatted_source else { + return Ok(Value::Array(Vec::new())); + }; + if formatted == document.text { + return Ok(Value::Array(Vec::new())); + } + let whole_document = Span { + start: SourcePosition::start(), + end: source_end_position(&document.text), + }; + Ok(json!([{ + "range": lsp_range_json(&document.text, whole_document, self.encoding)?, + "newText": formatted + }])) + } + + fn request_document(&self, params: &Value) -> ProtocolResult<&Document> { + let identifier = required_object_field(params, "textDocument")?; + let uri = validated_uri(required_string(identifier, "uri")?)?; + self.documents + .get(&uri) + .ok_or_else(|| ProtocolError::invalid_params("document is not open")) + } + + fn notification_result(&self, name: &str, result: ProtocolResult) -> Outcome { + match result { + Ok(notification) => Outcome::message(notification), + Err(error) => Outcome::message(log_notification( + 1, + &format!("ignored {name}: {}", error.message), + )), + } + } + + fn cancel_request(&mut self, params: &Value) { + let Some(identifier) = params + .as_object() + .and_then(|object| object.get("id")) + .and_then(|value| RequestId::from_json(value).ok()) + else { + return; + }; + if self.completed.contains(&identifier) { + return; + } + if self.cancelled.len() >= MAX_REQUEST_IDS { + self.cancelled.clear(); + } + self.cancelled.insert(identifier); + } + + fn remember_completed(&mut self, identifier: RequestId) { + if self.completed.len() >= MAX_REQUEST_IDS { + self.completed.clear(); + } + self.completed.insert(identifier); + } +} + +fn negotiate_position_encoding(params: &Map) -> PositionEncoding { + let offered = params + .get("capabilities") + .and_then(Value::as_object) + .and_then(|capabilities| capabilities.get("general")) + .and_then(Value::as_object) + .and_then(|general| general.get("positionEncodings")) + .and_then(Value::as_array); + if let Some(items) = offered { + for item in items { + match item.as_str() { + Some("utf-8") => return PositionEncoding::Utf8, + Some("utf-16") => return PositionEncoding::Utf16, + Some("utf-32") => return PositionEncoding::Utf32, + _ => {} + } + } + } + PositionEncoding::Utf16 +} + +fn apply_content_change( + source: &mut String, + change: &Value, + encoding: PositionEncoding, +) -> ProtocolResult<()> { + let change = required_object(change, "content change")?; + let replacement = required_string(change, "text")?; + let Some(range) = change.get("range") else { + validate_document_size(replacement)?; + source.clear(); + source.push_str(replacement); + return Ok(()); + }; + let range = required_object(range, "change range")?; + let start = + lsp_position_to_offset(source, required_map_object_field(range, "start")?, encoding)?; + let end = lsp_position_to_offset(source, required_map_object_field(range, "end")?, encoding)?; + if start > end { + return Err(ProtocolError::invalid_params( + "change range start is after its end", + )); + } + let replaced_bytes = end - start; + let Some(updated_size) = source + .len() + .checked_sub(replaced_bytes) + .and_then(|size| size.checked_add(replacement.len())) + else { + return Err(ProtocolError::invalid_params( + "changed document size overflowed", + )); + }; + if updated_size > MAX_DOCUMENT_BYTES { + return Err(ProtocolError::invalid_params("document is too large")); + } + source.replace_range(start..end, replacement); + Ok(()) +} + +fn lsp_position_to_offset( + source: &str, + position: &Map, + encoding: PositionEncoding, +) -> ProtocolResult { + let line = required_usize(position, "line")?; + let character = required_usize(position, "character")?; + let Some((line_start, line_end)) = line_bounds(source, line) else { + return Err(ProtocolError::invalid_params( + "position line is outside the document", + )); + }; + let Some(line_text) = source.get(line_start..line_end) else { + return Err(internal_error!("document line is not valid UTF-8")); + }; + let Some(relative) = encoding.byte_offset(line_text, character) else { + return Err(ProtocolError::invalid_params( + "position character is outside a scalar boundary", + )); + }; + Ok(line_start + relative) +} + +fn compiler_position_from_lsp( + source: &str, + position: &Map, + encoding: PositionEncoding, +) -> ProtocolResult { + let line = required_usize(position, "line")?; + let byte_offset = lsp_position_to_offset(source, position, encoding)?; + let Some((line_start, _)) = line_bounds(source, line) else { + return Err(ProtocolError::invalid_params( + "position line is outside the document", + )); + }; + let Some(prefix) = source.get(line_start..byte_offset) else { + return Err(internal_error!("position is not a UTF-8 boundary")); + }; + Ok(SourcePosition { + byte_offset, + line: line + 1, + column: prefix.chars().count() + 1, + }) +} + +fn line_bounds(source: &str, requested_line: usize) -> Option<(usize, usize)> { + let mut line = 0_usize; + let mut start = 0_usize; + let bytes = source.as_bytes(); + let mut index = 0_usize; + while index < bytes.len() { + let separator_bytes = match bytes[index] { + b'\r' if bytes.get(index + 1) == Some(&b'\n') => 2, + b'\r' | b'\n' => 1, + _ => { + index += 1; + continue; + } + }; + if line == requested_line { + return Some((start, index)); + } + line += 1; + index += separator_bytes; + start = index; + } + (line == requested_line).then_some((start, source.len())) +} + +fn lsp_range_json(source: &str, span: Span, encoding: PositionEncoding) -> ProtocolResult { + Ok(json!({ + "start": lsp_position_json(source, span.start, encoding)?, + "end": lsp_position_json(source, span.end, encoding)?, + })) +} + +fn lsp_position_json( + source: &str, + position: SourcePosition, + encoding: PositionEncoding, +) -> ProtocolResult { + let Some(line) = position.line.checked_sub(1) else { + return Err(internal_error!( + "compiler returned a zero-based source line" + )); + }; + let Some((line_start, line_end)) = line_bounds(source, line) else { + return Err(internal_error!( + "compiler position line is outside the source" + )); + }; + if position.byte_offset < line_start || position.byte_offset > line_end { + return Err(internal_error!( + "compiler position byte offset is outside its line" + )); + } + let Some(prefix) = source.get(line_start..position.byte_offset) else { + return Err(internal_error!("compiler position is not a UTF-8 boundary")); + }; + Ok(json!({ + "line": line, + "character": encoding.units(prefix), + })) +} + +fn publish_diagnostics( + uri: &str, + source: &str, + version: u64, + encoding: PositionEncoding, +) -> ProtocolResult { + let output = language_intelligence::diagnostics(source, version); + let diagnostics = output + .diagnostics + .iter() + .map(|diagnostic| diagnostic_json(uri, source, diagnostic, encoding)) + .collect::>>()?; + let version = output.document_version as i64; + Ok(json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": version, + "diagnostics": diagnostics + } + })) +} + +fn completion_catalog() -> &'static CompletionCatalog { + static CATALOG: OnceLock = OnceLock::new(); + CATALOG.get_or_init(|| { + let mut icons = BTreeMap::new(); + for theme in &stack_theme::catalog().themes { + for icon in &theme.icons { + icons + .entry(icon.id.clone()) + .or_insert_with(|| CompletionCatalogEntry { + id: icon.id.clone(), + label: icon.subject.clone(), + detail: Some("Stack core icon".into()), + documentation: icon.description.clone(), + }); + } + } + CompletionCatalog { + icons: icons.into_values().collect(), + } + }) +} + +fn completion_item_json( + source: &str, + item: &CompletionItem, + encoding: PositionEncoding, +) -> ProtocolResult { + let mut value = Map::new(); + value.insert("label".into(), json!(item.label)); + value.insert( + "kind".into(), + json!(match item.kind { + CompletionKind::Keyword => 14, + CompletionKind::Property => 10, + CompletionKind::EnumValue => 20, + CompletionKind::Identifier => 18, + CompletionKind::Icon => 12, + }), + ); + if let Some(detail) = &item.detail { + value.insert("detail".into(), json!(detail)); + } + if let Some(documentation) = &item.documentation { + value.insert("documentation".into(), json!(documentation)); + } + value.insert("filterText".into(), json!(item.filter_text)); + value.insert("sortText".into(), json!(item.sort_text)); + value.insert("insertTextFormat".into(), json!(1)); + value.insert( + "textEdit".into(), + json!({ + "range": lsp_range_json(source, item.edit.range, encoding)?, + "newText": item.edit.new_text + }), + ); + Ok(Value::Object(value)) +} + +fn hover_json(source: &str, hover: &Hover, encoding: PositionEncoding) -> ProtocolResult { + let mut sections = vec![hover.label.clone()]; + if let Some(detail) = &hover.detail { + sections.push(detail.clone()); + } + if let Some(documentation) = &hover.documentation { + sections.push(documentation.clone()); + } + Ok(json!({ + "contents": { + "kind": "plaintext", + "value": sections.join("\n\n") + }, + "range": lsp_range_json(source, hover.range, encoding)? + })) +} + +fn document_symbol_json( + source: &str, + symbol: &DocumentSymbol, + encoding: PositionEncoding, +) -> ProtocolResult { + let children = symbol + .children + .iter() + .map(|child| document_symbol_json(source, child, encoding)) + .collect::>>()?; + let mut value = Map::new(); + value.insert("name".into(), json!(symbol.name)); + if let Some(detail) = &symbol.detail { + value.insert("detail".into(), json!(detail)); + } + value.insert( + "kind".into(), + json!(match symbol.kind { + DocumentSymbolKind::Diagram => 2, + DocumentSymbolKind::Group => 3, + DocumentSymbolKind::Node => 19, + DocumentSymbolKind::Edge => 25, + }), + ); + value.insert( + "range".into(), + lsp_range_json(source, symbol.range, encoding)?, + ); + value.insert( + "selectionRange".into(), + lsp_range_json(source, symbol.selection_range, encoding)?, + ); + value.insert("children".into(), Value::Array(children)); + Ok(Value::Object(value)) +} + +fn source_end_position(source: &str) -> SourcePosition { + let bytes = source.as_bytes(); + let mut line = 1_usize; + let mut line_start = 0_usize; + let mut index = 0_usize; + while index < bytes.len() { + let separator_bytes = match bytes[index] { + b'\r' if bytes.get(index + 1) == Some(&b'\n') => 2, + b'\r' | b'\n' => 1, + _ => { + index += 1; + continue; + } + }; + line += 1; + index += separator_bytes; + line_start = index; + } + SourcePosition { + byte_offset: source.len(), + line, + column: source[line_start..].chars().count() + 1, + } +} + +fn diagnostic_json( + uri: &str, + source: &str, + diagnostic: &Diagnostic, + encoding: PositionEncoding, +) -> ProtocolResult { + let mut related = Vec::new(); + for information in &diagnostic.related { + related.push(json!({ + "location": { + "uri": uri, + "range": lsp_range_json(source, information.span, encoding)? + }, + "message": information.message + })); + } + Ok(json!({ + "range": lsp_range_json(source, diagnostic.span, encoding)?, + "severity": match diagnostic.severity { + Severity::Error => 1, + Severity::Warning => 2, + }, + "code": diagnostic.code, + "source": "stack", + "message": diagnostic.message, + "relatedInformation": related, + "data": { + "schemaVersion": language_intelligence::SCHEMA_VERSION, + "expected": diagnostic.expected, + "help": diagnostic.help + } + })) +} + +fn validate_document_size(source: &str) -> ProtocolResult<()> { + if source.len() > MAX_DOCUMENT_BYTES { + Err(ProtocolError::invalid_params("document is too large")) + } else { + Ok(()) + } +} + +fn validated_uri(uri: &str) -> ProtocolResult { + let length = uri.chars().count(); + if (1..=MAX_URI_CHARS).contains(&length) { + Ok(uri.into()) + } else { + Err(ProtocolError::invalid_params("document URI is invalid")) + } +} + +fn required_object<'value>( + value: &'value Value, + name: &str, +) -> ProtocolResult<&'value Map> { + match value.as_object() { + Some(object) => Ok(object), + None => Err(ProtocolError::invalid_params(&format!( + "{name} must be an object" + ))), + } +} + +fn required_object_field<'value>( + value: &'value Value, + field: &str, +) -> ProtocolResult<&'value Map> { + match required_object(value, "params")? + .get(field) + .and_then(Value::as_object) + { + Some(object) => Ok(object), + None => Err(ProtocolError::invalid_params(&format!( + "{field} must be an object" + ))), + } +} + +fn required_map_object_field<'value>( + value: &'value Map, + field: &str, +) -> ProtocolResult<&'value Map> { + match value.get(field).and_then(Value::as_object) { + Some(object) => Ok(object), + None => Err(ProtocolError::invalid_params(&format!( + "{field} must be an object" + ))), + } +} + +fn required_array<'value>(value: &'value Value, field: &str) -> ProtocolResult<&'value Vec> { + match required_object(value, "params")? + .get(field) + .and_then(Value::as_array) + { + Some(items) => Ok(items), + None => Err(ProtocolError::invalid_params(&format!( + "{field} must be an array" + ))), + } +} + +fn required_string<'value>( + value: &'value Map, + field: &str, +) -> ProtocolResult<&'value str> { + match value.get(field).and_then(Value::as_str) { + Some(text) => Ok(text), + None => Err(ProtocolError::invalid_params(&format!( + "{field} must be a string" + ))), + } +} + +fn required_usize(value: &Map, field: &str) -> ProtocolResult { + let Some(number) = value.get(field).and_then(Value::as_u64) else { + return Err(ProtocolError::invalid_params(&format!( + "{field} must be an unsigned integer" + ))); + }; + match usize::try_from(number) { + Ok(number) => Ok(number), + Err(_) => Err(ProtocolError::invalid_params(&format!( + "{field} must fit the platform index range" + ))), + } +} + +fn required_version(value: &Map) -> ProtocolResult { + let version = value + .get("version") + .and_then(Value::as_i64) + .filter(|version| *version >= 0 && *version <= i64::from(i32::MAX)); + match version { + Some(version) => Ok(version as u64), + None => Err(ProtocolError::invalid_params( + "version must be a non-negative LSP integer", + )), + } +} + +fn success_response(identifier: Value, result: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": identifier, + "result": result + }) +} + +fn error_response(identifier: Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": identifier, + "error": { + "code": code, + "message": message + } + }) +} + +fn log_notification(message_type: u8, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "method": "window/logMessage", + "params": { + "type": message_type, + "message": message + } + }) +} + +#[derive(Debug, PartialEq, Eq)] +enum FrameError { + Io(io::ErrorKind), + HeaderTooLarge, + InvalidHeader, + MissingContentLength, + DuplicateContentLength, + InvalidContentLength, + MessageTooLarge, + UnsupportedEncoding, + TruncatedBody, +} + +impl fmt::Display for FrameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::Io(_) => "I/O failure", + Self::HeaderTooLarge => "header is too large", + Self::InvalidHeader => "header is invalid", + Self::MissingContentLength => "Content-Length is missing", + Self::DuplicateContentLength => "Content-Length is duplicated", + Self::InvalidContentLength => "Content-Length is invalid", + Self::MessageTooLarge => "message exceeds the size limit", + Self::UnsupportedEncoding => "message charset is not UTF-8", + Self::TruncatedBody => "message body is truncated", + }; + formatter.write_str(message) + } +} + +fn read_frame(reader: &mut dyn BufRead) -> Result>, FrameError> { + let mut content_length = None; + let mut total_header_bytes = 0_usize; + loop { + let mut line = Vec::new(); + let read = match reader.read_until(b'\n', &mut line) { + Ok(read) => read, + Err(error) => return Err(FrameError::Io(error.kind())), + }; + if read == 0 { + return if total_header_bytes == 0 { + Ok(None) + } else { + Err(FrameError::InvalidHeader) + }; + } + total_header_bytes = total_header_bytes + .checked_add(read) + .ok_or(FrameError::HeaderTooLarge)?; + if total_header_bytes > MAX_HEADER_BYTES { + return Err(FrameError::HeaderTooLarge); + } + if line == b"\r\n" || line == b"\n" { + break; + } + if !line.ends_with(b"\n") || !line.is_ascii() { + return Err(FrameError::InvalidHeader); + } + let line = match std::str::from_utf8(&line) { + Ok(line) => line, + Err(_) => return Err(FrameError::InvalidHeader), + }; + let line = line.trim_end_matches(['\r', '\n']); + let Some((name, value)) = line.split_once(':') else { + return Err(FrameError::InvalidHeader); + }; + let value = value.trim(); + if name.eq_ignore_ascii_case("Content-Length") { + if content_length.is_some() { + return Err(FrameError::DuplicateContentLength); + } + let length = match value.parse::() { + Ok(length) => length, + Err(_) => return Err(FrameError::InvalidContentLength), + }; + if length > MAX_MESSAGE_BYTES { + return Err(FrameError::MessageTooLarge); + } + content_length = Some(length); + } else if name.eq_ignore_ascii_case("Content-Type") { + let normalized = value.to_ascii_lowercase(); + if normalized.contains("charset=") + && !normalized.contains("charset=utf-8") + && !normalized.contains("charset=utf8") + { + return Err(FrameError::UnsupportedEncoding); + } + } + } + let length = content_length.ok_or(FrameError::MissingContentLength)?; + let mut payload = vec![0_u8; length]; + if let Err(error) = reader.read_exact(&mut payload) { + return if error.kind() == io::ErrorKind::UnexpectedEof { + Err(FrameError::TruncatedBody) + } else { + Err(FrameError::Io(error.kind())) + }; + } + Ok(Some(payload)) +} + +fn write_frame(writer: &mut dyn Write, message: &Value) -> io::Result<()> { + let payload = serde_json::to_vec(message).map_err(io::Error::other)?; + write!(writer, "Content-Length: {}\r\n\r\n", payload.len())?; + writer.write_all(&payload) +} + +#[cfg(test)] +mod tests { + use std::io::{BufReader, Cursor}; + + use serde_json::{Value, json}; + + use super::{ + Document, FrameError, Lifecycle, MAX_DOCUMENT_BYTES, MAX_MESSAGE_BYTES, MAX_OPEN_DOCUMENTS, + PositionEncoding, Session, apply_content_change, compiler_position_from_lsp, line_bounds, + lsp_position_json, read_frame, run, source_end_position, validate_document_size, + write_frame, + }; + use crate::{EXIT_STACK_ERROR, EXIT_SUCCESS, EXIT_USAGE_OR_IO}; + use stack_compiler::diagnostic::SourcePosition; + + fn initialized_session(source: &str) -> Session { + let mut session = Session::new(); + let initialized = session.handle(json!({ + "jsonrpc": "2.0", + "id": "initialize", + "method": "initialize", + "params": { + "capabilities": { "general": { "positionEncodings": ["utf-16"] } } + } + })); + assert_eq!(initialized.messages[0]["id"], "initialize"); + let opened = session.handle(json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": "file:///workspace/diagram.stack", + "languageId": "stack", + "version": 7, + "text": source + } + } + })); + assert_eq!( + opened.messages[0]["method"], + "textDocument/publishDiagnostics" + ); + session + } + + #[test] + fn position_encodings_reject_split_scalars_and_round_trip_compiler_positions() + -> Result<(), &'static str> { + let source = "a😀\r\nβz"; + let utf8 = json!({ "line": 0, "character": 5 }); + let utf16 = json!({ "line": 0, "character": 3 }); + let utf32 = json!({ "line": 0, "character": 2 }); + for (position, encoding) in [ + (&utf8, PositionEncoding::Utf8), + (&utf16, PositionEncoding::Utf16), + (&utf32, PositionEncoding::Utf32), + ] { + let object = position + .as_object() + .ok_or("test position must be an object")?; + let converted = compiler_position_from_lsp(source, object, encoding); + assert_eq!( + converted, + Ok(SourcePosition { + byte_offset: 5, + line: 1, + column: 3, + }) + ); + let encoded = lsp_position_json( + source, + SourcePosition { + byte_offset: 5, + line: 1, + column: 3, + }, + encoding, + ); + assert_eq!(encoded, Ok(position.clone())); + } + + for (character, encoding) in [ + (2, PositionEncoding::Utf8), + (2, PositionEncoding::Utf16), + (3, PositionEncoding::Utf32), + ] { + let invalid = json!({ "line": 0, "character": character }); + let object = invalid + .as_object() + .ok_or("test position must be an object")?; + assert!(compiler_position_from_lsp(source, object, encoding).is_err()); + } + assert_eq!(line_bounds(source, 1), Some((7, 10))); + assert_eq!(line_bounds(source, 2), None); + assert_eq!(line_bounds("a\rb", 1), Some((2, 3))); + assert_eq!(line_bounds("a\r\nb", 1), Some((3, 4))); + assert_eq!( + source_end_position("a\rb\r\nc\n"), + SourcePosition { + byte_offset: 7, + line: 4, + column: 1, + } + ); + Ok(()) + } + + #[test] + fn incremental_changes_apply_in_order_and_atomically_reject_bad_ranges() { + let mut source = "a😀\r\nβz".to_owned(); + let replace_emoji = json!({ + "range": { + "start": { "line": 0, "character": 1 }, + "end": { "line": 0, "character": 3 } + }, + "text": "X" + }); + assert_eq!( + apply_content_change(&mut source, &replace_emoji, PositionEncoding::Utf16), + Ok(()) + ); + let replace_second_line = json!({ + "range": { + "start": { "line": 1, "character": 0 }, + "end": { "line": 1, "character": 1 } + }, + "text": "B" + }); + assert_eq!( + apply_content_change(&mut source, &replace_second_line, PositionEncoding::Utf16), + Ok(()) + ); + assert_eq!(source, "aX\r\nBz"); + + let original = source.clone(); + let backwards = json!({ + "range": { + "start": { "line": 1, "character": 2 }, + "end": { "line": 0, "character": 0 } + }, + "text": "ignored" + }); + assert!(apply_content_change(&mut source, &backwards, PositionEncoding::Utf16).is_err()); + assert_eq!(source, original); + + let whole = json!({ "text": "replacement" }); + assert_eq!( + apply_content_change(&mut source, &whole, PositionEncoding::Utf8), + Ok(()) + ); + assert_eq!(source, "replacement"); + } + + #[test] + fn lifecycle_negotiates_encoding_tracks_versions_and_clears_diagnostics() { + let mut session = Session::new(); + let before = session.handle(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "shutdown" + })); + assert_eq!(before.messages[0]["error"]["code"], -32_002); + + let initialized = session.handle(json!({ + "jsonrpc": "2.0", + "id": "init", + "method": "initialize", + "params": { + "capabilities": { "general": { "positionEncodings": ["utf-8", "utf-16"] } } + } + })); + assert_eq!(session.lifecycle, Lifecycle::Running); + assert_eq!(session.encoding, PositionEncoding::Utf8); + assert_eq!( + initialized.messages[0]["result"]["capabilities"]["positionEncoding"], + "utf-8" + ); + + let opened = session.handle(json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": "file:///diagram.stack", + "languageId": "stack", + "version": 1, + "text": "stack 1.0 diagram \"D\" { node api }" + } + } + })); + assert_eq!( + opened.messages[0]["method"], + "textDocument/publishDiagnostics" + ); + assert_eq!(opened.messages[0]["params"]["version"], 1); + assert!(opened.messages[0]["params"]["diagnostics"].is_array()); + + let changed = session.handle(json!({ + "jsonrpc": "2.0", + "method": "textDocument/didChange", + "params": { + "textDocument": { "uri": "file:///diagram.stack", "version": 2 }, + "contentChanges": [{ "text": "stack 1.0 diagram \"D\" { node api \"API\" }" }] + } + })); + assert_eq!(changed.messages[0]["params"]["version"], 2); + assert_eq!(changed.messages[0]["params"]["diagnostics"], json!([])); + + let stale = session.handle(json!({ + "jsonrpc": "2.0", + "method": "textDocument/didChange", + "params": { + "textDocument": { "uri": "file:///diagram.stack", "version": 2 }, + "contentChanges": [{ "text": "ignored" }] + } + })); + assert_eq!(stale.messages[0]["method"], "window/logMessage"); + + let closed = session.handle(json!({ + "jsonrpc": "2.0", + "method": "textDocument/didClose", + "params": { "textDocument": { "uri": "file:///diagram.stack" } } + })); + assert_eq!(closed.messages[0]["params"]["diagnostics"], json!([])); + assert!(session.documents.is_empty()); + + let shutdown = session.handle(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "shutdown" + })); + assert_eq!(shutdown.messages[0]["result"], Value::Null); + let exit = session.handle(json!({ "jsonrpc": "2.0", "method": "exit" })); + assert_eq!(exit.exit_code, Some(EXIT_SUCCESS)); + + let mut preferred = Session::new(); + let initialized = preferred.handle(json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "initialize", + "params": { + "capabilities": { "general": { "positionEncodings": ["utf-32", "utf-16"] } } + } + })); + assert_eq!(preferred.encoding, PositionEncoding::Utf32); + assert_eq!( + initialized.messages[0]["result"]["capabilities"]["positionEncoding"], + "utf-32" + ); + } + + #[test] + fn core_requests_map_compiler_semantics_and_formatter_edits() { + let source = concat!( + "stack 1.0\n\n", + "diagram \"😀 Checkout\" {\n", + " node api \"API\" {\n", + " kind service\n", + " icon \"ser\"\n", + " }\n", + " node db \"Database\" { kind database }\n", + " edge api -> db\n", + "}\n", + ); + let mut session = initialized_session(source); + + let completion = session.handle(json!({ + "jsonrpc": "2.0", + "id": 20, + "method": "textDocument/completion", + "params": { + "textDocument": { "uri": "file:///workspace/diagram.stack" }, + "position": { "line": 5, "character": 13 } + } + })); + assert_eq!(completion.messages[0]["id"], 20); + let items = completion.messages[0]["result"]["items"].as_array(); + assert!( + items.is_some_and(|items| items.iter().any(|item| { + item["filterText"] == "server" + && item["label"] == "Server host" + && item["kind"] == 12 + && item["textEdit"]["newText"] == "server" + })), + "{:?}", + completion.messages[0] + ); + + let hover = session.handle(json!({ + "jsonrpc": "2.0", + "id": 21, + "method": "textDocument/hover", + "params": { + "textDocument": { "uri": "file:///workspace/diagram.stack" }, + "position": { "line": 8, "character": 8 } + } + })); + assert_eq!(hover.messages[0]["result"]["contents"]["kind"], "plaintext"); + assert!( + hover.messages[0]["result"]["contents"]["value"] + .as_str() + .is_some_and(|value| value.contains("API") && value.contains("node api")) + ); + assert_eq!(hover.messages[0]["result"]["range"]["start"]["line"], 8); + + let symbols = session.handle(json!({ + "jsonrpc": "2.0", + "id": 22, + "method": "textDocument/documentSymbol", + "params": { + "textDocument": { "uri": "file:///workspace/diagram.stack" } + } + })); + assert_eq!(symbols.messages[0]["result"][0]["name"], "😀 Checkout"); + assert_eq!(symbols.messages[0]["result"][0]["kind"], 2); + assert_eq!( + symbols.messages[0]["result"][0]["children"][0]["name"], + "API" + ); + assert_eq!(symbols.messages[0]["result"][0]["children"][2]["kind"], 25); + + let formatting = session.handle(json!({ + "jsonrpc": "2.0", + "id": 23, + "method": "textDocument/formatting", + "params": { + "textDocument": { "uri": "file:///workspace/diagram.stack" }, + "options": { "tabSize": 2, "insertSpaces": true } + } + })); + assert_eq!( + formatting.messages[0]["result"][0]["range"]["start"]["line"], + 0 + ); + assert_eq!( + formatting.messages[0]["result"][0]["range"]["end"]["line"], + 10 + ); + assert!( + formatting.messages[0]["result"][0]["newText"] + .as_str() + .is_some_and(|text| text.contains("diagram \"😀 Checkout\"")) + ); + } + + #[test] + fn diagnostic_ranges_match_utf16_editor_coordinates() { + let source = "stack 1.0 diagram \"😀\" { node api \"API\" { kind nope } }"; + let mut session = Session::new(); + let _initialized = session.handle(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "capabilities": {} } + })); + let opened = session.handle(json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": "file:///workspace/range.stack", + "languageId": "stack", + "version": 1, + "text": source + } + } + })); + let diagnostic = &opened.messages[0]["params"]["diagnostics"][0]; + assert_eq!(diagnostic["code"], "STK2002"); + assert_eq!( + diagnostic["range"]["start"], + json!({ "line": 0, "character": 47 }) + ); + assert_eq!( + diagnostic["range"]["end"], + json!({ "line": 0, "character": 51 }) + ); + } + + #[test] + fn invalid_feature_requests_return_empty_or_actionable_results() { + let mut session = initialized_session("stack 1.0 diagram \"Partial\" { node api"); + let hover = session.handle(json!({ + "jsonrpc": "2.0", + "id": 30, + "method": "textDocument/hover", + "params": { + "textDocument": { "uri": "file:///workspace/diagram.stack" }, + "position": { "line": 0, "character": 42 } + } + })); + assert_eq!(hover.messages[0]["result"], Value::Null); + + let symbols = session.handle(json!({ + "jsonrpc": "2.0", + "id": 31, + "method": "textDocument/documentSymbol", + "params": { + "textDocument": { "uri": "file:///workspace/diagram.stack" } + } + })); + assert_eq!(symbols.messages[0]["result"], json!([])); + + let formatting = session.handle(json!({ + "jsonrpc": "2.0", + "id": 32, + "method": "textDocument/formatting", + "params": { + "textDocument": { "uri": "file:///workspace/diagram.stack" }, + "options": { "tabSize": 2, "insertSpaces": true } + } + })); + assert_eq!(formatting.messages[0]["result"], json!([])); + + for (identifier, params) in [ + ( + 33, + json!({ + "textDocument": { "uri": "file:///workspace/missing.stack" }, + "position": { "line": 0, "character": 0 } + }), + ), + ( + 34, + json!({ + "textDocument": { "uri": "file:///workspace/diagram.stack" }, + "position": { "line": 99, "character": 0 } + }), + ), + ] { + let response = session.handle(json!({ + "jsonrpc": "2.0", + "id": identifier, + "method": "textDocument/hover", + "params": params + })); + assert_eq!(response.messages[0]["error"]["code"], -32_602); + } + } + + #[test] + fn cancellation_and_invalid_messages_return_protocol_errors() { + let mut session = Session::new(); + assert_eq!( + session + .handle(json!({ "jsonrpc": "2.0", "method": "exit" })) + .exit_code, + Some(EXIT_STACK_ERROR) + ); + for message in [ + json!([]), + json!({ "method": "initialize", "id": 1 }), + json!({ "jsonrpc": "2.0", "id": null, "method": "initialize" }), + json!({ "jsonrpc": "2.0", "id": 1.5, "method": "initialize" }), + ] { + let outcome = session.handle(message); + assert_eq!(outcome.messages[0]["error"]["code"], -32_600); + } + + let _ = session.handle(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "capabilities": {} } + })); + session.handle(json!({ + "jsonrpc": "2.0", + "method": "$/cancelRequest", + "params": { "id": 9 } + })); + let cancelled = session.handle(json!({ + "jsonrpc": "2.0", + "id": 9, + "method": "textDocument/hover", + "params": {} + })); + assert_eq!(cancelled.messages[0]["error"]["code"], -32_800); + + let repeated = session.handle(json!({ + "jsonrpc": "2.0", + "id": 10, + "method": "initialize", + "params": {} + })); + assert_eq!(repeated.messages[0]["error"]["code"], -32_600); + let unsupported = session.handle(json!({ + "jsonrpc": "2.0", + "id": 11, + "method": "workspace/symbol", + "params": {} + })); + assert_eq!(unsupported.messages[0]["error"]["code"], -32_601); + } + + #[test] + fn framing_round_trips_and_rejects_unsafe_headers() -> Result<(), &'static str> { + let message = json!({ "jsonrpc": "2.0", "method": "initialized", "params": {} }); + let mut bytes = Vec::new(); + assert!(write_frame(&mut bytes, &message).is_ok()); + let decoded = read_frame(&mut BufReader::new(Cursor::new(bytes))); + let payload = decoded + .map_err(|_| "written frame must decode")? + .ok_or("written frame must contain a payload")?; + let parsed = serde_json::from_slice::(&payload); + assert_eq!(parsed.as_ref().ok(), Some(&message)); + + for (frame, expected) in [ + (b"\r\n".as_slice(), FrameError::MissingContentLength), + ( + b"Content-Length: 1\r\nContent-Length: 1\r\n\r\nx".as_slice(), + FrameError::DuplicateContentLength, + ), + ( + b"Content-Length: nope\r\n\r\n".as_slice(), + FrameError::InvalidContentLength, + ), + ( + b"Content-Length: 4\r\nContent-Type: application/vscode-jsonrpc; charset=latin1\r\n\r\nnull".as_slice(), + FrameError::UnsupportedEncoding, + ), + ( + b"Content-Length: 4\r\n\r\n{}".as_slice(), + FrameError::TruncatedBody, + ), + ] { + assert_eq!( + read_frame(&mut BufReader::new(Cursor::new(frame))), + Err(expected) + ); + } + Ok(()) + } + + #[test] + fn resource_limits_reject_oversized_messages_and_documents() { + let oversized_document = "x".repeat(MAX_DOCUMENT_BYTES + 1); + assert!(validate_document_size(&oversized_document).is_err()); + + let oversized_header = format!("Content-Length: {}\r\n\r\n", MAX_MESSAGE_BYTES + 1); + assert_eq!( + read_frame(&mut BufReader::new(Cursor::new(oversized_header))), + Err(FrameError::MessageTooLarge) + ); + + let mut session = initialized_session("stack 1.0 diagram \"Open\" { node a \"A\" }"); + for index in 1..MAX_OPEN_DOCUMENTS { + session.documents.insert( + format!("file:///workspace/{index}.stack"), + Document { + text: String::new(), + version: 1, + }, + ); + } + let rejected = session.handle(json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": "file:///workspace/overflow.stack", + "languageId": "stack", + "version": 1, + "text": "stack 1.0 diagram \"Overflow\" { node a \"A\" }" + } + } + })); + assert_eq!(rejected.messages[0]["method"], "window/logMessage"); + assert!( + rejected.messages[0]["params"]["message"] + .as_str() + .is_some_and(|message| message.contains("open document limit exceeded")) + ); + assert!( + !session + .documents + .contains_key("file:///workspace/overflow.stack") + ); + } + + #[test] + fn stdio_transcript_recovers_from_json_errors_and_exits_cleanly() { + let messages = [ + None, + Some(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "capabilities": {} } + })), + Some(json!({ "jsonrpc": "2.0", "id": 2, "method": "shutdown" })), + Some(json!({ "jsonrpc": "2.0", "method": "exit" })), + ]; + let mut input = Vec::new(); + for message in messages { + if let Some(message) = message { + assert!(write_frame(&mut input, &message).is_ok()); + } else { + input.extend_from_slice(b"Content-Length: 1\r\n\r\n{"); + } + } + let mut output = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + run(&mut Cursor::new(input), &mut output, &mut stderr), + EXIT_SUCCESS + ); + assert!(stderr.is_empty()); + + let mut reader = BufReader::new(Cursor::new(output)); + let mut responses = Vec::new(); + while let Ok(Some(payload)) = read_frame(&mut reader) { + if let Ok(value) = serde_json::from_slice::(&payload) { + responses.push(value); + } + } + assert_eq!(responses.len(), 3); + assert_eq!(responses[0]["error"]["code"], -32_700); + assert_eq!(responses[1]["id"], 1); + assert_eq!(responses[2]["id"], 2); + + let mut empty_output = Vec::new(); + let mut empty_error = Vec::new(); + assert_eq!( + run( + &mut Cursor::new(Vec::::new()), + &mut empty_output, + &mut empty_error, + ), + EXIT_USAGE_OR_IO + ); + assert!(!empty_error.is_empty()); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index d8602e1..c2e49e6 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -6,6 +6,9 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; static CASE_ID: AtomicU64 = AtomicU64::new(0); @@ -80,6 +83,48 @@ fn stack_with_input( Ok(child.wait_with_output()?) } +fn lsp_frame(message: &Value) -> Result, Box> { + let payload = serde_json::to_vec(message)?; + let mut frame = format!("Content-Length: {}\r\n\r\n", payload.len()).into_bytes(); + frame.extend_from_slice(&payload); + Ok(frame) +} + +fn lsp_transcript(messages: &[Value]) -> Result, Box> { + let mut transcript = Vec::new(); + for message in messages { + transcript.extend_from_slice(&lsp_frame(message)?); + } + Ok(transcript) +} + +fn lsp_messages(bytes: &[u8]) -> Result, Box> { + let mut remaining = bytes; + let mut messages = Vec::new(); + while !remaining.is_empty() { + let header_offset = remaining + .windows(4) + .position(|window| window == b"\r\n\r\n") + .ok_or("missing LSP header terminator")?; + let header = std::str::from_utf8(&remaining[..header_offset])?; + let content_length = header + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + .ok_or("missing Content-Length")? + .parse::()?; + let body_start = header_offset + 4; + let body_end = body_start + .checked_add(content_length) + .ok_or("LSP body length overflow")?; + let body = remaining + .get(body_start..body_end) + .ok_or("truncated LSP body")?; + messages.push(serde_json::from_slice(body)?); + remaining = remaining.get(body_end..).ok_or("invalid LSP body end")?; + } + Ok(messages) +} + fn assert_unchanged(path: &Path, expected: &[u8]) -> Result<(), Box> { assert_eq!(fs::read(path)?, expected); Ok(()) @@ -192,6 +237,8 @@ fn help_snapshots_and_aliases_are_stdout_only() -> Result<(), Box> { &["help", "render"], include_bytes!("snapshots/render-help.txt"), ), + (&["lsp", "--help"], include_bytes!("snapshots/lsp-help.txt")), + (&["help", "lsp"], include_bytes!("snapshots/lsp-help.txt")), ( &["icons", "--help"], include_bytes!("snapshots/icons-help.txt"), @@ -273,6 +320,10 @@ fn command_typos_are_actionable_and_stderr_only() -> Result<(), Box> &["help", "rennder"], "error: unknown command for 'stack help': 'rennder'\n\nDid you mean 'render'?\n\nFor more information, try 'stack help'.\n", ), + ( + &["lspp"], + "error: unknown command 'lspp'\n\nDid you mean 'lsp'?\n\nFor more information, try 'stack help'.\n", + ), ( &["definitely-unknown"], "error: unknown command 'definitely-unknown'\n\nFor more information, try 'stack help'.\n", @@ -292,6 +343,207 @@ fn command_typos_are_actionable_and_stderr_only() -> Result<(), Box> Ok(()) } +#[test] +fn lsp_binary_serves_core_features_and_recovers_from_invalid_json() -> Result<(), Box> { + let source = concat!( + "stack 1.0\n\n", + "diagram \"😀 Checkout\" {\n", + " node api \"API\" {\n", + " kind service\n", + " icon \"ser\"\n", + " }\n", + " node db \"Database\" { kind database }\n", + " edge api -> db\n", + "}\n", + ); + let messages = [ + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "capabilities": { "general": { "positionEncodings": ["utf-16"] } } + } + }), + json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": "file:///workspace/diagram.stack", + "languageId": "stack", + "version": 1, + "text": source + } + } + }), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "textDocument/completion", + "params": { + "textDocument": { "uri": "file:///workspace/diagram.stack" }, + "position": { "line": 5, "character": 13 } + } + }), + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "textDocument/hover", + "params": { + "textDocument": { "uri": "file:///workspace/diagram.stack" }, + "position": { "line": 8, "character": 8 } + } + }), + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "textDocument/documentSymbol", + "params": { + "textDocument": { "uri": "file:///workspace/diagram.stack" } + } + }), + json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "textDocument/formatting", + "params": { + "textDocument": { "uri": "file:///workspace/diagram.stack" }, + "options": { "tabSize": 2, "insertSpaces": true } + } + }), + json!({ "jsonrpc": "2.0", "id": 6, "method": "shutdown" }), + json!({ "jsonrpc": "2.0", "method": "exit" }), + ]; + let mut transcript = b"Content-Length: 1\r\n\r\n{".to_vec(); + transcript.extend_from_slice(&lsp_transcript(&messages)?); + + let output = stack_with_input(["lsp"], &transcript)?; + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let responses = lsp_messages(&output.stdout)?; + assert_eq!(responses.len(), 8); + assert_eq!(responses[0]["error"]["code"], -32_700); + assert_eq!( + responses[1]["result"]["capabilities"]["positionEncoding"], + "utf-16" + ); + assert_eq!(responses[2]["method"], "textDocument/publishDiagnostics"); + + let completion = responses + .iter() + .find(|message| message["id"] == 2) + .ok_or("missing completion response")?; + assert!( + completion["result"]["items"] + .as_array() + .is_some_and(|items| items.iter().any(|item| item["filterText"] == "server")) + ); + let hover = responses + .iter() + .find(|message| message["id"] == 3) + .ok_or("missing hover response")?; + assert!( + hover["result"]["contents"]["value"] + .as_str() + .is_some_and(|value| value.contains("node api")) + ); + let symbols = responses + .iter() + .find(|message| message["id"] == 4) + .ok_or("missing symbols response")?; + assert_eq!(symbols["result"][0]["name"], "😀 Checkout"); + let formatting = responses + .iter() + .find(|message| message["id"] == 5) + .ok_or("missing formatting response")?; + assert!( + formatting["result"] + .as_array() + .is_some_and(|edits| !edits.is_empty()) + ); + assert_eq!( + responses.last().and_then(|message| message.get("id")), + Some(&json!(6)) + ); + Ok(()) +} + +#[test] +fn lsp_large_invalid_document_has_bounded_latency_and_no_crash() -> Result<(), Box> { + let padding = "x".repeat(1024 * 1024); + let source = format!("// {padding}\nstack 1.0 diagram \"Incomplete\" {{ node api"); + let messages = [ + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "capabilities": {} } + }), + json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": "file:///workspace/large.stack", + "languageId": "stack", + "version": 1, + "text": source + } + } + }), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "textDocument/documentSymbol", + "params": { "textDocument": { "uri": "file:///workspace/large.stack" } } + }), + json!({ "jsonrpc": "2.0", "id": 3, "method": "shutdown" }), + json!({ "jsonrpc": "2.0", "method": "exit" }), + ]; + let transcript = lsp_transcript(&messages)?; + let started = Instant::now(); + let output = stack_with_input(["lsp"], &transcript)?; + let elapsed = started.elapsed(); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + assert!( + elapsed < Duration::from_secs(5), + "large document took {elapsed:?}" + ); + let responses = lsp_messages(&output.stdout)?; + let diagnostics = responses + .iter() + .find(|message| message["method"] == "textDocument/publishDiagnostics") + .ok_or("missing diagnostics")?; + assert!( + diagnostics["params"]["diagnostics"] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); + let symbols = responses + .iter() + .find(|message| message["id"] == 2) + .ok_or("missing symbols response")?; + assert_eq!(symbols["result"], json!([])); + Ok(()) +} + +#[test] +fn lsp_rejects_untrusted_framing_before_allocating_the_body() -> Result<(), Box> { + let input = b"Content-Length: 8388609\r\n\r\n"; + let output = stack_with_input(["lsp"], input)?; + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert_eq!( + output.stderr, + b"error: invalid LSP frame: message exceeds the size limit\n" + ); + Ok(()) +} + #[test] fn provider_catalog_commands_are_available_from_the_binary() -> Result<(), Box> { let listed = stack(["icons", "list", "simple-icons", "linear"])?; diff --git a/tests/snapshots/help-help.txt b/tests/snapshots/help-help.txt index 19088a8..cd6c2f6 100644 --- a/tests/snapshots/help-help.txt +++ b/tests/snapshots/help-help.txt @@ -6,7 +6,7 @@ Usage: stack help icons Arguments: - init, check, fmt, render, icons, help, or version + init, check, fmt, render, lsp, icons, help, or version Examples: stack help diff --git a/tests/snapshots/help.txt b/tests/snapshots/help.txt index a1de9f7..a7cd360 100644 --- a/tests/snapshots/help.txt +++ b/tests/snapshots/help.txt @@ -9,6 +9,7 @@ Commands: check Validate a Stack source file without modifying it fmt Format a file in place or read from standard input render Render standalone SVG to standard output or a file + lsp Run the Stack language server over standard input and output icons List catalogs and import audited provider icon archives help Print this message or the help of a subcommand version Print version information @@ -23,4 +24,5 @@ Examples: stack check arch.stack stack fmt --check arch.stack stack render arch.stack -o arch.svg + stack lsp stack icons list aws s3 diff --git a/tests/snapshots/lsp-help.txt b/tests/snapshots/lsp-help.txt new file mode 100644 index 0000000..ae4edbc --- /dev/null +++ b/tests/snapshots/lsp-help.txt @@ -0,0 +1,14 @@ +Run the Stack language server over standard input and output + +Usage: + stack lsp + +Options: + -h, --help Print help + +Protocol: + LSP 3.18 JSON-RPC messages use Content-Length framing over standard input + and output. Standard output is reserved for protocol messages. + +Examples: + stack lsp