From be3ff7f5af782ce00817c6f3376cad40b13a2917 Mon Sep 17 00:00:00 2001 From: konojunya Date: Sat, 5 Sep 2026 20:49:30 +0900 Subject: [PATCH] Expose browser language intelligence --- .github/workflows/ci.yaml | 3 + Cargo.lock | 4 +- README.md | 10 +- crates/stack-engine-wasm/Cargo.toml | 2 +- .../examples/language-intelligence-parity.rs | 94 +++ crates/stack-engine-wasm/src/lib.rs | 551 +++++++++++++++++- crates/stack-engine/Cargo.toml | 2 +- crates/stack-engine/src/language.rs | 506 ++++++++++++++++ crates/stack-engine/src/lib.rs | 17 + .../tests/language_intelligence.rs | 111 ++++ .../snapshots/render/complete-semantics.svg | 4 +- .../render/default-normalization.svg | 4 +- .../snapshots/render/explicit-core-icon.svg | 4 +- ...guage-intelligence-with-engine-catalogs.md | 27 + layout-corpus/catalog.json | 2 +- layout-corpus/snapshots/dense-commerce.svg | 4 +- .../snapshots/fanout-cross-edges.svg | 4 +- layout-corpus/snapshots/medium-group-flow.svg | 4 +- .../snapshots/multilingual-long-labels.svg | 4 +- layout-corpus/snapshots/nested-platform.svg | 4 +- .../snapshots/provider-icon-boundary.svg | 4 +- .../snapshots/small-request-path.svg | 4 +- package-lock.json | 6 +- package.json | 2 +- packages/engine/README.md | 23 +- packages/engine/package.json | 2 +- scripts/layout-corpus.test.mjs | 2 +- scripts/validate-wasm-package.mjs | 14 +- .../fixtures/language-intelligence-cases.json | 26 + tests/types.test.ts | 20 + tests/wasm.test.mjs | 140 ++++- 31 files changed, 1558 insertions(+), 46 deletions(-) create mode 100644 crates/stack-engine-wasm/examples/language-intelligence-parity.rs create mode 100644 crates/stack-engine/src/language.rs create mode 100644 crates/stack-engine/tests/language_intelligence.rs create mode 100644 docs/decisions/0007-adapt-language-intelligence-with-engine-catalogs.md create mode 100644 tests/fixtures/language-intelligence-cases.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 537fbbd..70064c6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -74,6 +74,9 @@ jobs: - name: Enforce layout runtime budget run: cargo +stable test --release -p stack-engine --test layout_corpus layout_runtime_stays_within_budget --locked -- --ignored --nocapture + - name: Enforce editor language-intelligence latency budget + run: cargo +stable test --release -p stack-engine --test language_intelligence language_intelligence_runtime_stays_within_budget --locked -- --ignored --nocapture + - name: Build layout regression gallery run: npm run layout:gallery diff --git a/Cargo.lock b/Cargo.lock index 5a5f57d..b3ea5b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,7 +195,7 @@ source = "git+https://github.com/stack-sh/compiler.git?rev=84ab5663a7f7c5b7dc0b5 [[package]] name = "stack-engine" -version = "0.6.0" +version = "0.7.0" dependencies = [ "roxmltree", "serde", @@ -208,7 +208,7 @@ dependencies = [ [[package]] name = "stack-engine-wasm" -version = "0.6.0" +version = "0.7.0" dependencies = [ "js-sys", "serde", diff --git a/README.md b/README.md index 207da82..88f8299 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ `stack-sh/engine` is the pure Rust execution engine for Stack architecture diagrams. -The workspace provides canonical Stack source formatting, the pure `stack-engine` operation facade, deterministic theme-aware scene layout and orthogonal edge routing, safe standalone SVG rendering, and a typed browser WebAssembly adapter. +The workspace provides canonical Stack source formatting, protocol-neutral language intelligence, the pure `stack-engine` operation facade, deterministic theme-aware scene layout and orthogonal edge routing, safe standalone SVG rendering, and a typed browser WebAssembly adapter. ## Workspace -- `stack-engine`: operation/output boundary, theme and icon fallback resolution, deterministic scene layout, edge routing, validation beyond the compiler stage, and standalone SVG rendering; +- `stack-engine`: operation/output boundary, theme and provider-aware completion catalogs, semantic hover, theme and icon fallback resolution, deterministic scene layout, edge routing, validation beyond the compiler stage, and standalone SVG rendering; - `stack-formatter`: comment-preserving canonical formatting for Stack source files (implemented); - `stack-engine-wasm` and npm `@stack-sh/engine`: a thin browser adapter exposing the same pure operations and portable result model. @@ -47,6 +47,7 @@ The versioned representative layout corpus covers small, medium, and dense diagr ```sh cargo test -p stack-engine --test layout_corpus --locked cargo test --release -p stack-engine --test layout_corpus layout_runtime_stays_within_budget --locked -- --ignored --nocapture +cargo test --release -p stack-engine --test language_intelligence language_intelligence_runtime_stays_within_budget --locked -- --ignored --nocapture npm run layout:gallery ``` @@ -54,11 +55,11 @@ The review-first snapshot policy and corpus contract are documented in [`layout- `stack-formatter` is pure and accepts source bytes or UTF-8 text. Lexical and syntax errors return diagnostics without formatted output. Syntactically valid source remains formattable when semantic diagnostics exist. -`stack-engine` exposes byte-oriented `format`, `check`, and `render` methods through an engine bound to the embedded or a caller-provided validated catalog. `ProviderPack::new` accepts a typed user-imported manifest and caller-owned SVG strings, verifies exact asset hashes and safe SVG structure, and computes a deterministic content revision before `Engine::with_provider_packs` can resolve namespaced IDs. Every normal output carries engine, authored language, theme catalog version, and theme catalog revision metadata. User-source failures stay in ordered portable diagnostics. Invalid provided catalogs or provider packs and violated normalized pipeline invariants use a separate operational-error channel. Checks and renders resolve the requested theme and provider packs, validate deterministic integer geometry, and route ordered edges outside node interiors. Missing themes and icons produce source-mapped `STK6001` and `STK5001` warnings while a fallback SVG remains available. An unsatisfied authored order hint produces `STK4001` at its source-map range; a satisfied hint does not. +`stack-engine` exposes byte-oriented `format`, `check`, and `render` methods plus UTF-8 `completion` and `hover` methods through an engine bound to the embedded or a caller-provided validated catalog. Language-intelligence results implement schema version 1.0 from the pinned compiler and echo the caller's document version. The Engine derives completion entries from its core theme catalog and validated provider packs, while the compiler remains the single owner of grammar, context, diagnostics, hover semantics, and text edits. `ProviderPack::new` accepts a typed user-imported manifest and caller-owned SVG strings, verifies exact asset hashes and safe SVG structure, and computes a deterministic content revision before `Engine::with_provider_packs` can resolve namespaced IDs. Every normal format, check, or render output carries engine, authored language, theme catalog version, and theme catalog revision metadata. User-source failures stay in ordered portable diagnostics. Invalid provided catalogs or provider packs, invalid language-intelligence positions, and violated normalized pipeline invariants use a separate operational-error channel. Checks and renders resolve the requested theme and provider packs, validate deterministic integer geometry, and route ordered edges outside node interiors. Missing themes and icons produce source-mapped `STK6001` and `STK5001` warnings while a fallback SVG remains available. An unsatisfied authored order hint produces `STK4001` at its source-map range; a satisfied hint does not. The renderer emits fixed-dimension standalone SVG with embedded catalog or provider icons, local marker references, escaped authored text, accessible title and description metadata, and no script, event handler, external URL, host font measurement, or runtime I/O. Provider artwork preserves the authored node `kind`; each render returns the exact used-asset notices and writes provider ID, icon IDs, and pack revision into SVG metadata. The bundled catalog provides 30 first-party explicit icon identifiers in every core theme: `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`. Canonical renderer and representative-layout SVG snapshots are byte-stable and parsed by `scripts/validate-svg.py`; set `UPDATE_STACK_SNAPSHOTS=1` or `UPDATE_STACK_LAYOUT_SNAPSHOTS=1` only when intentionally regenerating the corresponding reviewed references. CI also executes one exact numeric geometry fixture in both the native suite and a WASI build. -The npm package exports synchronous `format`, `check`, `render`, `checkWithProviderPacks`, and `renderWithProviderPacks` functions after asynchronous module initialization. Provider-pack operations accept JSON-compatible local manifest and SVG data; they never discover a path or initiate a request. Each operation accepts `string | Uint8Array` source and returns a specific typed result with camel-case metadata and portable diagnostics. Diagnostics preserve the compiler's primary range, ordered `expected` values, corrective help, and related source locations. Invalid UTF-8 remains a normal `STK1001` result. Unsupported JavaScript input types and internal operational failures throw at the adapter boundary. Shared fixtures exercise native and WebAssembly provider resolution. Artifact validation audits WebAssembly imports and package contents; browser consumers retain responsibility for loading the module and performing any DOM, filesystem, network, or clock work. +The npm package exports synchronous `format`, `check`, `render`, `completion`, and `hover` functions after asynchronous module initialization, with provider-aware variants for check, render, and completion. Provider-pack operations accept JSON-compatible local manifest and SVG data; they never discover a path or initiate a request. Format, check, and render accept `string | Uint8Array`; completion and hover require a UTF-8 string plus a safe-integer document version and a `{ byteOffset, line, column }` position. Results use explicit TypeScript contracts, camel-case fields, plain-text documentation, end-exclusive UTF-8 ranges, and ordered portable diagnostics. Invalid UTF-8 remains a normal `STK1001` result for byte-oriented operations. Unsupported JavaScript input types, inconsistent positions, and internal operational failures throw at the adapter boundary. Shared fixtures exercise native and WebAssembly parity for provider resolution, contextual completion, document-version echo, multilingual positions, and hover. Artifact validation audits WebAssembly imports and package contents; browser consumers retain responsibility for module loading, stale-result suppression, and every DOM, filesystem, network, or clock interaction. Public npm releases are produced from GitHub Releases after the repository checks pass. See [RELEASING.md](./RELEASING.md) for the first-release bootstrap and subsequent trusted-publishing flow. @@ -70,6 +71,7 @@ Public npm releases are produced from GitHub Releases after the repository check - [`docs/decisions/0004-route-orthogonal-edges-on-a-visibility-grid.md`](./docs/decisions/0004-route-orthogonal-edges-on-a-visibility-grid.md) - [`docs/decisions/0005-serialize-safe-standalone-svg.md`](./docs/decisions/0005-serialize-safe-standalone-svg.md) - [`docs/decisions/0006-expose-one-typed-browser-wasm-adapter.md`](./docs/decisions/0006-expose-one-typed-browser-wasm-adapter.md) +- [`docs/decisions/0007-adapt-language-intelligence-with-engine-catalogs.md`](./docs/decisions/0007-adapt-language-intelligence-with-engine-catalogs.md) - [`docs/dependency-audit.md`](./docs/dependency-audit.md) ## Licensing diff --git a/crates/stack-engine-wasm/Cargo.toml b/crates/stack-engine-wasm/Cargo.toml index 87b471d..bde6c3f 100644 --- a/crates/stack-engine-wasm/Cargo.toml +++ b/crates/stack-engine-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stack-engine-wasm" -version = "0.6.0" +version = "0.7.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/stack-engine-wasm/examples/language-intelligence-parity.rs b/crates/stack-engine-wasm/examples/language-intelligence-parity.rs new file mode 100644 index 0000000..0de9b31 --- /dev/null +++ b/crates/stack-engine-wasm/examples/language-intelligence-parity.rs @@ -0,0 +1,94 @@ +use std::error::Error; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use stack_engine_wasm::{CompletionResult, HoverResult, SourcePosition}; + +const CURSOR: &str = "<|>"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct FixtureCase { + name: String, + document_version: u64, + source_with_cursor: String, + provider_packs: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FixtureOutput { + name: String, + completion: CompletionResult, + hover: HoverResult, +} + +fn source_and_position(marked: &str) -> Result<(String, SourcePosition), Box> { + let marker = marked + .find(CURSOR) + .ok_or("fixture cursor marker is missing")?; + if marked[marker + CURSOR.len()..].contains(CURSOR) { + return Err("fixture contains more than one cursor marker".into()); + } + let mut source = marked.to_owned(); + source.replace_range(marker..marker + CURSOR.len(), ""); + let mut line = 1_u64; + let mut column = 1_u64; + let mut characters = source[..marker].chars().peekable(); + while let Some(character) = characters.next() { + if character == '\r' && characters.peek() == Some(&'\n') { + characters.next(); + line += 1; + column = 1; + } else if character == '\n' { + line += 1; + column = 1; + } else { + column += 1; + } + } + Ok(( + source, + SourcePosition { + byte_offset: marker as u64, + line, + column, + }, + )) +} + +fn main() -> Result<(), Box> { + let fixture_path = std::env::args_os() + .nth(1) + .map(PathBuf::from) + .ok_or("usage: language-intelligence-parity ")?; + let provider_path = std::env::args_os() + .nth(2) + .map(PathBuf::from) + .ok_or("usage: language-intelligence-parity ")?; + let fixtures = serde_json::from_slice::>(&std::fs::read(fixture_path)?)?; + let provider_packs = std::fs::read_to_string(provider_path)?; + let outputs = fixtures + .into_iter() + .map(|fixture| { + let (source, position) = source_and_position(&fixture.source_with_cursor)?; + let completion = if fixture.provider_packs { + stack_engine_wasm::completion_with_provider_packs_text( + &source, + fixture.document_version, + position, + &provider_packs, + )? + } else { + stack_engine_wasm::completion_text(&source, fixture.document_version, position)? + }; + Ok(FixtureOutput { + name: fixture.name, + completion, + hover: stack_engine_wasm::hover_text(&source, fixture.document_version, position)?, + }) + }) + .collect::, Box>>()?; + println!("{}", serde_json::to_string(&outputs)?); + Ok(()) +} diff --git a/crates/stack-engine-wasm/src/lib.rs b/crates/stack-engine-wasm/src/lib.rs index a322254..8da4333 100644 --- a/crates/stack-engine-wasm/src/lib.rs +++ b/crates/stack-engine-wasm/src/lib.rs @@ -52,6 +52,114 @@ pub struct RenderResult { pub provider_notices: Vec, } +/// JavaScript-facing result of a context-aware completion operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletionResult { + /// Portable language-intelligence schema version. + pub schema_version: String, + /// Caller-owned document version echoed by the engine. + pub document_version: u64, + /// Ordered compiler diagnostics for the same source snapshot. + pub diagnostics: Vec, + /// Whether more source context may materially change the list. + pub is_incomplete: bool, + /// Deterministically ordered completion items. + pub items: Vec, +} + +/// JavaScript-facing result of a semantic hover operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HoverResult { + /// Portable language-intelligence schema version. + pub schema_version: String, + /// Caller-owned document version echoed by the engine. + pub document_version: u64, + /// Ordered compiler diagnostics for the same source snapshot. + pub diagnostics: Vec, + /// Resolved semantic hover, if one covers the requested position. + pub hover: Option, +} + +/// A literal source replacement interpreted against one document snapshot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextEdit { + /// End-exclusive source range replaced by the edit. + pub range: SourceRange, + /// Literal Stack source inserted in place of the range. + pub new_text: String, +} + +/// Semantic category of a completion item. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum CompletionKind { + /// A grammatical Stack keyword. + Keyword, + /// A property or layout statement. + Property, + /// A closed value from the Stack language specification. + EnumValue, + /// A document-local semantic identifier. + Identifier, + /// A core or caller-owned provider icon. + Icon, +} + +/// One protocol-neutral source completion. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletionItem { + /// User-visible plain-text label. + pub label: String, + /// Semantic completion category. + pub kind: CompletionKind, + /// Optional plain-text secondary label. + pub detail: Option, + /// Optional plain-text documentation. + pub documentation: Option, + /// Plain string used by consumers for filtering. + pub filter_text: String, + /// Stable ordering key. + pub sort_text: String, + /// Literal source replacement. + pub edit: TextEdit, +} + +/// Semantic category described by hover information. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum HoverKind { + /// The document's diagram declaration. + Diagram, + /// A containment group. + Group, + /// A node declaration or reference. + Node, + /// An edge declaration. + Edge, + /// A language property, theme, or layout value. + Property, +} + +/// Plain-text semantic information for one source token. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Hover { + /// Exact end-exclusive source range described by the hover. + pub range: SourceRange, + /// Semantic hover category. + pub kind: HoverKind, + /// Short user-visible label. + pub label: String, + /// Optional plain-text secondary label. + pub detail: Option, + /// Optional plain-text documentation. + pub documentation: Option, +} + /// JavaScript-facing provider provenance for one rendered pack. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] @@ -231,6 +339,28 @@ pub fn render_bytes(source: &[u8]) -> OperationResult { Engine::bundled().render(source).map(RenderResult::from) } +/// Computes context-aware completion for one UTF-8 source snapshot. +pub fn completion_text( + source: &str, + document_version: u64, + position: SourcePosition, +) -> OperationResult { + Engine::bundled() + .completion(source, document_version, position.into()) + .map(CompletionResult::from) +} + +/// Resolves semantic hover for one UTF-8 source snapshot. +pub fn hover_text( + source: &str, + document_version: u64, + position: SourcePosition, +) -> OperationResult { + Engine::bundled() + .hover(source, document_version, position.into()) + .map(HoverResult::from) +} + /// Checks source against caller-owned provider packs encoded as local JSON data. pub fn check_with_provider_packs_bytes( source: &[u8], @@ -253,6 +383,19 @@ pub fn render_with_provider_packs_bytes( .map(RenderResult::from) } +/// Computes completion with caller-owned provider packs encoded as local JSON data. +pub fn completion_with_provider_packs_text( + source: &str, + document_version: u64, + position: SourcePosition, + provider_packs_json: &str, +) -> OperationResult { + let provider_packs = parse_provider_packs(provider_packs_json)?; + Engine::with_provider_packs(&provider_packs)? + .completion(source, document_version, position.into()) + .map(CompletionResult::from) +} + fn parse_provider_packs(provider_packs_json: &str) -> OperationResult> { let inputs: Vec = serde_json::from_str(provider_packs_json).map_err(|_| { @@ -321,6 +464,90 @@ impl From for RenderResult { } } +impl From for CompletionResult { + fn from(output: stack_engine::CompletionOutput) -> Self { + Self { + schema_version: output.schema_version, + document_version: output.document_version, + diagnostics: output + .diagnostics + .into_iter() + .map(Diagnostic::from) + .collect(), + is_incomplete: output.is_incomplete, + items: output.items.into_iter().map(CompletionItem::from).collect(), + } + } +} + +impl From for CompletionItem { + fn from(item: stack_engine::CompletionItem) -> Self { + Self { + label: item.label, + kind: CompletionKind::from(item.kind), + detail: item.detail, + documentation: item.documentation, + filter_text: item.filter_text, + sort_text: item.sort_text, + edit: TextEdit { + range: SourceRange::from(item.edit.range), + new_text: item.edit.new_text, + }, + } + } +} + +impl From for CompletionKind { + fn from(kind: stack_engine::CompletionKind) -> Self { + match kind { + stack_engine::CompletionKind::Keyword => Self::Keyword, + stack_engine::CompletionKind::Property => Self::Property, + stack_engine::CompletionKind::EnumValue => Self::EnumValue, + stack_engine::CompletionKind::Identifier => Self::Identifier, + stack_engine::CompletionKind::Icon => Self::Icon, + } + } +} + +impl From for HoverResult { + fn from(output: stack_engine::HoverOutput) -> Self { + Self { + schema_version: output.schema_version, + document_version: output.document_version, + diagnostics: output + .diagnostics + .into_iter() + .map(Diagnostic::from) + .collect(), + hover: output.hover.map(Hover::from), + } + } +} + +impl From for Hover { + fn from(hover: stack_engine::Hover) -> Self { + Self { + range: SourceRange::from(hover.range), + kind: HoverKind::from(hover.kind), + label: hover.label, + detail: hover.detail, + documentation: hover.documentation, + } + } +} + +impl From for HoverKind { + fn from(kind: stack_engine::HoverKind) -> Self { + match kind { + stack_engine::HoverKind::Diagram => Self::Diagram, + stack_engine::HoverKind::Group => Self::Group, + stack_engine::HoverKind::Node => Self::Node, + stack_engine::HoverKind::Edge => Self::Edge, + stack_engine::HoverKind::Property => Self::Property, + } + } +} + impl From for ProviderNotice { fn from(notice: stack_engine::ProviderNotice) -> Self { Self { @@ -447,11 +674,23 @@ impl From for SourcePosition { } } +impl From for stack_engine::SourcePosition { + fn from(position: SourcePosition) -> Self { + Self { + byte_offset: position.byte_offset, + line: position.line, + column: position.column, + } + } +} + #[cfg(target_arch = "wasm32")] #[wasm_bindgen(typescript_custom_section)] const TYPESCRIPT_TYPES: &'static str = r#" export type StackSource = string | Uint8Array; export type Severity = "error" | "warning"; +export type CompletionKind = "keyword" | "property" | "enumValue" | "identifier" | "icon"; +export type HoverKind = "diagram" | "group" | "node" | "edge" | "property"; export interface SourcePosition { readonly byteOffset: number; @@ -509,6 +748,44 @@ export interface RenderResult { readonly providerNotices: readonly ProviderNotice[]; } +export interface TextEdit { + readonly range: SourceRange; + readonly newText: string; +} + +export interface CompletionItem { + readonly label: string; + readonly kind: CompletionKind; + readonly detail: string | null; + readonly documentation: string | null; + readonly filterText: string; + readonly sortText: string; + readonly edit: TextEdit; +} + +export interface CompletionResult { + readonly schemaVersion: string; + readonly documentVersion: number; + readonly diagnostics: readonly Diagnostic[]; + readonly isIncomplete: boolean; + readonly items: readonly CompletionItem[]; +} + +export interface Hover { + readonly range: SourceRange; + readonly kind: HoverKind; + readonly label: string; + readonly detail: string | null; + readonly documentation: string | null; +} + +export interface HoverResult { + readonly schemaVersion: string; + readonly documentVersion: number; + readonly diagnostics: readonly Diagnostic[]; + readonly hover: Hover | null; +} + export interface ProviderNoticeIcon { readonly id: string; readonly productName: string; @@ -553,8 +830,11 @@ export interface ProviderPackInput { export function format(source: StackSource): FormatResult; export function check(source: StackSource): CheckResult; export function render(source: StackSource): RenderResult; +export function completion(source: string, documentVersion: number, position: SourcePosition): CompletionResult; +export function hover(source: string, documentVersion: number, position: SourcePosition): HoverResult; export function checkWithProviderPacks(source: StackSource, providerPacks: readonly ProviderPackInput[]): CheckResult; export function renderWithProviderPacks(source: StackSource, providerPacks: readonly ProviderPackInput[]): RenderResult; +export function completionWithProviderPacks(source: string, documentVersion: number, position: SourcePosition, providerPacks: readonly ProviderPackInput[]): CompletionResult; "#; #[cfg(target_arch = "wasm32")] @@ -584,6 +864,40 @@ pub fn render_js(source: JsValue) -> Result { .and_then(render_to_js) } +#[cfg(target_arch = "wasm32")] +/// Computes context-aware completion for a JavaScript UTF-8 string snapshot. +#[wasm_bindgen(js_name = completion, skip_typescript)] +pub fn completion_js( + source: JsValue, + document_version: JsValue, + position: JsValue, +) -> Result { + completion_text( + &text_source(source)?, + safe_integer(document_version, "Document version", 0)?, + position_from_js(position)?, + ) + .map_err(operation_error) + .and_then(completion_to_js) +} + +#[cfg(target_arch = "wasm32")] +/// Resolves semantic hover for a JavaScript UTF-8 string snapshot. +#[wasm_bindgen(js_name = hover, skip_typescript)] +pub fn hover_js( + source: JsValue, + document_version: JsValue, + position: JsValue, +) -> Result { + hover_text( + &text_source(source)?, + safe_integer(document_version, "Document version", 0)?, + position_from_js(position)?, + ) + .map_err(operation_error) + .and_then(hover_to_js) +} + #[cfg(target_arch = "wasm32")] /// Checks source using provider packs supplied as caller-owned JavaScript data. #[wasm_bindgen(js_name = checkWithProviderPacks, skip_typescript)] @@ -610,6 +924,26 @@ pub fn render_with_provider_packs_js( .and_then(render_to_js) } +#[cfg(target_arch = "wasm32")] +/// Computes completion using provider packs supplied as caller-owned JavaScript data. +#[wasm_bindgen(js_name = completionWithProviderPacks, skip_typescript)] +pub fn completion_with_provider_packs_js( + source: JsValue, + document_version: JsValue, + position: JsValue, + provider_packs: JsValue, +) -> Result { + let provider_packs = provider_packs_json(provider_packs)?; + completion_with_provider_packs_text( + &text_source(source)?, + safe_integer(document_version, "Document version", 0)?, + position_from_js(position)?, + &provider_packs, + ) + .map_err(operation_error) + .and_then(completion_to_js) +} + #[cfg(target_arch = "wasm32")] fn provider_packs_json(provider_packs: JsValue) -> Result { JSON::stringify(&provider_packs) @@ -629,6 +963,56 @@ fn source_bytes(source: JsValue) -> Result, JsValue> { Err(TypeError::new("Stack source must be a string or Uint8Array").into()) } +#[cfg(target_arch = "wasm32")] +fn text_source(source: JsValue) -> Result { + source + .as_string() + .ok_or_else(|| TypeError::new("Language intelligence source must be a string").into()) +} + +#[cfg(target_arch = "wasm32")] +fn position_from_js(position: JsValue) -> Result { + if !position.is_object() || position.is_null() { + return Err(TypeError::new("Source position must be an object").into()); + } + let byte_offset = safe_integer( + Reflect::get(&position, &JsValue::from_str("byteOffset"))?, + "Source position byteOffset", + 0, + )?; + let line = safe_integer( + Reflect::get(&position, &JsValue::from_str("line"))?, + "Source position line", + 1, + )?; + let column = safe_integer( + Reflect::get(&position, &JsValue::from_str("column"))?, + "Source position column", + 1, + )?; + Ok(SourcePosition { + byte_offset, + line, + column, + }) +} + +#[cfg(target_arch = "wasm32")] +fn safe_integer(value: JsValue, name: &str, minimum: u64) -> Result { + const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; + let Some(value) = value.as_f64() else { + return Err(TypeError::new(&format!("{name} must be a safe integer")).into()); + }; + if !value.is_finite() + || value.fract() != 0.0 + || value < minimum as f64 + || value > MAX_SAFE_INTEGER + { + return Err(TypeError::new(&format!("{name} must be a safe integer")).into()); + } + Ok(value as u64) +} + #[cfg(target_arch = "wasm32")] fn operation_error(error: stack_engine::OperationalError) -> JsValue { js_sys::Error::new(&error.to_string()).into() @@ -677,6 +1061,87 @@ fn render_to_js(result: RenderResult) -> Result { Ok(output.into()) } +#[cfg(target_arch = "wasm32")] +fn completion_to_js(result: CompletionResult) -> Result { + let output = Object::new(); + set(&output, "schemaVersion", result.schema_version.into())?; + set( + &output, + "documentVersion", + JsValue::from_f64(result.document_version as f64), + )?; + set( + &output, + "diagnostics", + diagnostics_to_js(result.diagnostics)?, + )?; + set(&output, "isIncomplete", result.is_incomplete.into())?; + let items = Array::new(); + for item in result.items { + let value = Object::new(); + set(&value, "label", item.label.into())?; + set( + &value, + "kind", + JsValue::from_str(match item.kind { + CompletionKind::Keyword => "keyword", + CompletionKind::Property => "property", + CompletionKind::EnumValue => "enumValue", + CompletionKind::Identifier => "identifier", + CompletionKind::Icon => "icon", + }), + )?; + set_optional_string(&value, "detail", item.detail)?; + set_optional_string(&value, "documentation", item.documentation)?; + set(&value, "filterText", item.filter_text.into())?; + set(&value, "sortText", item.sort_text.into())?; + let edit = Object::new(); + set(&edit, "range", range_to_js(item.edit.range)?)?; + set(&edit, "newText", item.edit.new_text.into())?; + set(&value, "edit", edit.into())?; + items.push(&value); + } + set(&output, "items", items.into())?; + Ok(output.into()) +} + +#[cfg(target_arch = "wasm32")] +fn hover_to_js(result: HoverResult) -> Result { + let output = Object::new(); + set(&output, "schemaVersion", result.schema_version.into())?; + set( + &output, + "documentVersion", + JsValue::from_f64(result.document_version as f64), + )?; + set( + &output, + "diagnostics", + diagnostics_to_js(result.diagnostics)?, + )?; + let hover = result.hover.map_or(Ok(JsValue::NULL), |hover| { + let value = Object::new(); + set(&value, "range", range_to_js(hover.range)?)?; + set( + &value, + "kind", + JsValue::from_str(match hover.kind { + HoverKind::Diagram => "diagram", + HoverKind::Group => "group", + HoverKind::Node => "node", + HoverKind::Edge => "edge", + HoverKind::Property => "property", + }), + )?; + set(&value, "label", hover.label.into())?; + set_optional_string(&value, "detail", hover.detail)?; + set_optional_string(&value, "documentation", hover.documentation)?; + Ok::(value.into()) + })?; + set(&output, "hover", hover)?; + Ok(output.into()) +} + #[cfg(target_arch = "wasm32")] fn provider_notices_to_js(notices: Vec) -> Result { let output = Array::new(); @@ -832,8 +1297,9 @@ mod tests { use std::error::Error; use super::{ - Diagnostic, Severity, check_bytes, check_with_provider_packs_bytes, format_bytes, - render_bytes, render_with_provider_packs_bytes, + CompletionKind, Diagnostic, HoverKind, Severity, SourcePosition, check_bytes, + check_with_provider_packs_bytes, completion_text, completion_with_provider_packs_text, + format_bytes, hover_text, render_bytes, render_with_provider_packs_bytes, }; #[test] @@ -911,4 +1377,85 @@ mod tests { assert!(check_with_provider_packs_bytes(source, "not json").is_err()); Ok(()) } + + #[test] + fn language_helpers_preserve_versions_ranges_and_provider_catalogs() + -> Result<(), Box> { + let source = "stack 1.0 diagram \"Provider\" { node item \"Example Storage\" { icon \"example:s\" } }"; + let icon_start = source.find("example:s").ok_or("missing icon prefix")?; + let position = SourcePosition { + byte_offset: (icon_start + "example:s".len()) as u64, + line: 1, + column: (icon_start + "example:s".len() + 1) as u64, + }; + let core = completion_text(source, 6, position)?; + assert_eq!(core.schema_version, "1.0"); + assert_eq!(core.document_version, 6); + assert!(core.items.is_empty()); + + let packs = include_str!("../../../tests/fixtures/provider-pack-input.json"); + let provider = completion_with_provider_packs_text(source, 7, position, packs)?; + assert_eq!(provider.document_version, 7); + assert_eq!(provider.items[0].kind, CompletionKind::Icon); + assert_eq!(provider.items[0].filter_text, "example:storage"); + assert_eq!( + provider.items[0].edit.range.start.byte_offset, + icon_start as u64 + ); + + let hover_source = "stack 1.0 diagram \"API\" { node api \"Public API\" }"; + let label = hover_source.find("Public API").ok_or("missing label")?; + let hovered = hover_text( + hover_source, + 8, + SourcePosition { + byte_offset: label as u64, + line: 1, + column: (label + 1) as u64, + }, + )?; + assert_eq!(hovered.document_version, 8); + let hover = hovered.hover.ok_or("missing hover")?; + assert_eq!(hover.kind, HoverKind::Node); + assert_eq!(hover.label, "Public API"); + Ok(()) + } + + #[test] + fn language_kind_conversions_cover_the_portable_contract() { + assert_eq!( + [ + stack_engine::CompletionKind::Keyword, + stack_engine::CompletionKind::Property, + stack_engine::CompletionKind::EnumValue, + stack_engine::CompletionKind::Identifier, + stack_engine::CompletionKind::Icon, + ] + .map(CompletionKind::from), + [ + CompletionKind::Keyword, + CompletionKind::Property, + CompletionKind::EnumValue, + CompletionKind::Identifier, + CompletionKind::Icon, + ] + ); + assert_eq!( + [ + stack_engine::HoverKind::Diagram, + stack_engine::HoverKind::Group, + stack_engine::HoverKind::Node, + stack_engine::HoverKind::Edge, + stack_engine::HoverKind::Property, + ] + .map(HoverKind::from), + [ + HoverKind::Diagram, + HoverKind::Group, + HoverKind::Node, + HoverKind::Edge, + HoverKind::Property, + ] + ); + } } diff --git a/crates/stack-engine/Cargo.toml b/crates/stack-engine/Cargo.toml index a3343a8..4042c36 100644 --- a/crates/stack-engine/Cargo.toml +++ b/crates/stack-engine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stack-engine" -version = "0.6.0" +version = "0.7.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/stack-engine/src/language.rs b/crates/stack-engine/src/language.rs new file mode 100644 index 0000000..bc1f244 --- /dev/null +++ b/crates/stack-engine/src/language.rs @@ -0,0 +1,506 @@ +//! Theme-aware adapter for protocol-neutral compiler language intelligence. + +use std::collections::BTreeMap; + +use stack_compiler::{ + diagnostic as compiler_diagnostic, language_intelligence as compiler_language, +}; + +use crate::{Diagnostic, Engine, OperationResult, OperationalError, SourcePosition, SourceRange}; + +/// Portable language-intelligence schema version implemented by the pinned compiler. +pub const LANGUAGE_INTELLIGENCE_SCHEMA_VERSION: &str = compiler_language::SCHEMA_VERSION; + +impl Engine<'_> { + /// Computes context-aware completion from one complete UTF-8 source snapshot. + /// + /// The response echoes `document_version` so a host can discard stale work. + /// Core and caller-owned provider icons come from the same validated catalogs + /// used by check and render operations. + pub fn completion( + &self, + source: &str, + document_version: u64, + position: SourcePosition, + ) -> OperationResult { + let catalog = self.completion_catalog()?; + compiler_language::completion( + source, + document_version, + compiler_diagnostic::SourcePosition::try_from(position)?, + &catalog, + ) + .map(CompletionOutput::from) + .map_err(language_intelligence_error) + } + + /// Resolves plain-text semantic hover for one complete UTF-8 source snapshot. + /// + /// The response echoes `document_version` so a host can discard stale work. + pub fn hover( + &self, + source: &str, + document_version: u64, + position: SourcePosition, + ) -> OperationResult { + compiler_language::hover( + source, + document_version, + compiler_diagnostic::SourcePosition::try_from(position)?, + ) + .map(HoverOutput::from) + .map_err(language_intelligence_error) + } + + fn completion_catalog(&self) -> OperationResult { + let mut icons = BTreeMap::new(); + for theme in &self.catalog.themes { + for icon in &theme.icons { + icons.entry(icon.id.clone()).or_insert_with(|| { + compiler_language::CompletionCatalogEntry { + id: icon.id.clone(), + label: icon.id.clone(), + detail: Some(icon.subject.clone()), + documentation: icon.description.clone(), + } + }); + } + } + for pack in self.provider_packs { + let manifest = pack.manifest(); + for icon in &manifest.icons { + icons.insert( + icon.id.clone(), + compiler_language::CompletionCatalogEntry { + id: icon.id.clone(), + label: icon.id.clone(), + detail: Some(icon.product_name.clone()), + documentation: Some(format!( + "{} provider icon: {}", + manifest.provider.name, icon.subject + )), + }, + ); + } + } + if icons.len() > compiler_language::MAX_COMPLETION_ICONS { + return Err(OperationalError::InvalidLanguageIntelligenceInput { + reason: "completion catalog exceeds the item limit", + }); + } + Ok(compiler_language::CompletionCatalog { + icons: icons.into_values().collect(), + }) + } +} + +fn language_intelligence_error(error: compiler_language::IntelligenceError) -> OperationalError { + let reason = match error { + compiler_language::IntelligenceError::InvalidPosition => "source position is invalid", + compiler_language::IntelligenceError::CompletionCatalogTooLarge => { + "completion catalog exceeds the item limit" + } + compiler_language::IntelligenceError::InvalidCompletionCatalogEntry { .. } => { + "completion catalog contains an invalid entry" + } + compiler_language::IntelligenceError::DuplicateCompletionCatalogId { .. } => { + "completion catalog contains a duplicate icon id" + } + }; + OperationalError::InvalidLanguageIntelligenceInput { reason } +} + +impl TryFrom for compiler_diagnostic::SourcePosition { + type Error = OperationalError; + + fn try_from(position: SourcePosition) -> Result { + Ok(Self { + byte_offset: usize::try_from(position.byte_offset).map_err(|_| { + OperationalError::InvalidLanguageIntelligenceInput { + reason: "source position exceeds the target address space", + } + })?, + line: usize::try_from(position.line).map_err(|_| { + OperationalError::InvalidLanguageIntelligenceInput { + reason: "source position exceeds the target address space", + } + })?, + column: usize::try_from(position.column).map_err(|_| { + OperationalError::InvalidLanguageIntelligenceInput { + reason: "source position exceeds the target address space", + } + })?, + }) + } +} + +/// A source replacement interpreted against the unchanged input snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextEdit { + /// End-exclusive source range replaced by this edit. + pub range: SourceRange, + /// Literal Stack source inserted in place of the range. + pub new_text: String, +} + +/// Semantic category of one completion item. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompletionKind { + /// A grammatical Stack keyword. + Keyword, + /// A property or layout statement valid in the current block. + Property, + /// A closed value from the Stack language specification. + EnumValue, + /// A document-local semantic identifier. + Identifier, + /// An icon from the engine's core or caller-owned provider catalog. + Icon, +} + +/// One literal, protocol-neutral source completion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompletionItem { + /// User-visible plain-text label. + pub label: String, + /// Semantic completion category. + pub kind: CompletionKind, + /// Optional plain-text secondary label. + pub detail: Option, + /// Optional plain-text documentation. + pub documentation: Option, + /// Plain string used by consumers for filtering. + pub filter_text: String, + /// Stable ordering key. + pub sort_text: String, + /// Literal source replacement for this item. + pub edit: TextEdit, +} + +/// Completion result for one caller-owned document version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompletionOutput { + /// Portable language-intelligence schema version. + pub schema_version: String, + /// Document version supplied by the caller. + pub document_version: u64, + /// Ordered compiler diagnostics for the same source snapshot. + pub diagnostics: Vec, + /// Whether more source context may materially change the list. + pub is_incomplete: bool, + /// Deterministically ordered completion items. + pub items: Vec, +} + +/// Semantic category described by hover information. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HoverKind { + /// The document's diagram declaration. + Diagram, + /// A containment group. + Group, + /// A node declaration or reference. + Node, + /// An edge declaration. + Edge, + /// A language property, theme, or layout value. + Property, +} + +/// Plain-text semantic information for one source token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hover { + /// Exact source range described by this hover. + pub range: SourceRange, + /// Semantic category. + pub kind: HoverKind, + /// Short user-visible label. + pub label: String, + /// Optional plain-text secondary label. + pub detail: Option, + /// Optional plain-text documentation. + pub documentation: Option, +} + +/// Hover result for one caller-owned document version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HoverOutput { + /// Portable language-intelligence schema version. + pub schema_version: String, + /// Document version supplied by the caller. + pub document_version: u64, + /// Ordered compiler diagnostics for the same source snapshot. + pub diagnostics: Vec, + /// Resolved semantic hover, if a trustworthy construct covers the position. + pub hover: Option, +} + +impl From for CompletionOutput { + fn from(output: compiler_language::CompletionOutput) -> Self { + Self { + schema_version: output.schema_version.to_owned(), + document_version: output.document_version, + diagnostics: output + .diagnostics + .into_iter() + .map(Diagnostic::from) + .collect(), + is_incomplete: output.is_incomplete, + items: output.items.into_iter().map(CompletionItem::from).collect(), + } + } +} + +impl From for CompletionItem { + fn from(item: compiler_language::CompletionItem) -> Self { + Self { + label: item.label, + kind: CompletionKind::from(item.kind), + detail: item.detail, + documentation: item.documentation, + filter_text: item.filter_text, + sort_text: item.sort_text, + edit: TextEdit { + range: SourceRange::from(item.edit.range), + new_text: item.edit.new_text, + }, + } + } +} + +impl From for CompletionKind { + fn from(kind: compiler_language::CompletionKind) -> Self { + match kind { + compiler_language::CompletionKind::Keyword => Self::Keyword, + compiler_language::CompletionKind::Property => Self::Property, + compiler_language::CompletionKind::EnumValue => Self::EnumValue, + compiler_language::CompletionKind::Identifier => Self::Identifier, + compiler_language::CompletionKind::Icon => Self::Icon, + } + } +} + +impl From for HoverOutput { + fn from(output: compiler_language::HoverOutput) -> Self { + Self { + schema_version: output.schema_version.to_owned(), + document_version: output.document_version, + diagnostics: output + .diagnostics + .into_iter() + .map(Diagnostic::from) + .collect(), + hover: output.hover.map(Hover::from), + } + } +} + +impl From for Hover { + fn from(hover: compiler_language::Hover) -> Self { + Self { + range: SourceRange::from(hover.range), + kind: HoverKind::from(hover.kind), + label: hover.label, + detail: hover.detail, + documentation: hover.documentation, + } + } +} + +impl From for HoverKind { + fn from(kind: compiler_language::HoverKind) -> Self { + match kind { + compiler_language::HoverKind::Diagram => Self::Diagram, + compiler_language::HoverKind::Group => Self::Group, + compiler_language::HoverKind::Node => Self::Node, + compiler_language::HoverKind::Edge => Self::Edge, + compiler_language::HoverKind::Property => Self::Property, + } + } +} + +#[cfg(test)] +mod tests { + use std::error::Error; + + use super::{CompletionKind, HoverKind, language_intelligence_error}; + use crate::{Engine, OperationalError, ProviderAsset, ProviderPack, SourcePosition}; + + fn position(source: &str, byte_offset: usize) -> SourcePosition { + let mut line = 1_u64; + let mut column = 1_u64; + for character in source[..byte_offset].chars() { + if character == '\n' { + line += 1; + column = 1; + } else { + column += 1; + } + } + SourcePosition { + byte_offset: byte_offset as u64, + line, + column, + } + } + + #[test] + fn completion_uses_context_and_echoes_the_document_version() -> Result<(), Box> { + let source = "stack 1.0\ndiagram \"Draft\" {\n no\n}\n"; + let cursor = source.find("no").ok_or("missing prefix")? + 2; + let output = Engine::bundled().completion(source, 42, position(source, cursor))?; + assert_eq!(output.schema_version, "1.0"); + assert_eq!(output.document_version, 42); + assert!(output.is_incomplete); + assert_eq!(output.items.len(), 1); + assert_eq!(output.items[0].label, "node"); + assert_eq!(output.items[0].kind, CompletionKind::Keyword); + assert_eq!(output.items[0].edit.new_text, "node"); + Ok(()) + } + + #[test] + fn completion_discovers_core_icon_ids() -> Result<(), Box> { + let source = "stack 1.0 diagram \"Icons\" { node api \"API\" { icon \"ga\" } }"; + let cursor = source.find("ga").ok_or("missing icon prefix")? + 2; + let output = Engine::bundled().completion(source, 3, position(source, cursor))?; + assert_eq!(output.items.len(), 1); + let item = &output.items[0]; + assert_eq!(item.label, "gateway"); + assert_eq!(item.filter_text, "gateway"); + assert_eq!(item.kind, CompletionKind::Icon); + assert_eq!(item.detail.as_deref(), Some("Network gateway")); + assert_eq!(item.edit.new_text, "gateway"); + Ok(()) + } + + #[test] + fn completion_includes_validated_provider_icons() -> Result<(), Box> { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../tests/fixtures/provider-pack-input.json" + ))?; + let input = fixture + .as_array() + .and_then(|items| items.first()) + .ok_or("missing provider fixture")?; + let manifest: stack_theme::ProviderPack = + serde_json::from_value(input.get("manifest").cloned().ok_or("missing manifest")?)?; + let assets = input + .get("assets") + .and_then(serde_json::Value::as_array) + .ok_or("missing assets")? + .iter() + .map(|asset| { + ProviderAsset::new( + asset + .get("path") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(), + asset + .get("svg") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(), + ) + }) + .collect(); + let pack = ProviderPack::new(manifest, assets)?; + let packs = [pack]; + let engine = Engine::with_provider_packs(&packs)?; + let source = + "stack 1.0 diagram \"Provider\" { node store \"Store\" { icon \"example:s\" } }"; + let cursor = source.find("example:s").ok_or("missing icon prefix")? + "example:s".len(); + let output = engine.completion(source, 9, position(source, cursor))?; + assert_eq!(output.items.len(), 1); + assert_eq!(output.items[0].label, "example:storage"); + assert_eq!(output.items[0].detail.as_deref(), Some("Example Storage")); + assert_eq!( + output.items[0].documentation.as_deref(), + Some("Example Cloud provider icon: Object storage service") + ); + Ok(()) + } + + #[test] + fn hover_resolves_semantics_and_preserves_exact_ranges() -> Result<(), Box> { + let source = "stack 1.0 diagram \"API\" { node api \"Public API\" edge api -> client node client \"Client\" }"; + let reference = source.find("edge api").ok_or("missing edge")? + "edge ".len(); + let output = Engine::bundled().hover(source, 11, position(source, reference))?; + assert_eq!(output.schema_version, "1.0"); + assert_eq!(output.document_version, 11); + let hover = output.hover.ok_or("missing hover")?; + assert_eq!(hover.kind, HoverKind::Node); + assert_eq!(hover.label, "Public API"); + assert_eq!(hover.detail.as_deref(), Some("node api · service")); + assert_eq!(hover.range.start.byte_offset, reference as u64); + assert_eq!(hover.range.end.byte_offset, (reference + 3) as u64); + Ok(()) + } + + #[test] + fn invalid_position_uses_the_operational_error_channel() { + let result = Engine::bundled().completion( + "stack 1.0", + 1, + SourcePosition { + byte_offset: 4, + line: 9, + column: 9, + }, + ); + assert!(matches!( + result, + Err(OperationalError::InvalidLanguageIntelligenceInput { + reason: "source position is invalid" + }) + )); + } + + #[test] + fn catalog_limits_and_validation_use_the_operational_error_channel() + -> Result<(), Box> { + let source = "stack 1.0 diagram \"Icons\" { node api \"API\" { icon \"\" } }"; + let cursor = source.find("\"\"").ok_or("missing empty icon")? + 1; + let mut oversized = stack_theme::catalog().clone(); + let template = oversized.themes[0].icons[0].clone(); + for index in 0..=stack_compiler::language_intelligence::MAX_COMPLETION_ICONS { + let mut icon = template.clone(); + icon.id = format!("extra-{index}"); + oversized.themes[0].icons.push(icon); + } + let engine = Engine::with_catalog(&oversized, stack_theme::CATALOG_REVISION)?; + assert!(matches!( + engine.completion(source, 1, position(source, cursor)), + Err(OperationalError::InvalidLanguageIntelligenceInput { + reason: "completion catalog exceeds the item limit" + }) + )); + + let mut invalid = stack_theme::catalog().clone(); + invalid.themes[0].icons[0].id = "INVALID".to_owned(); + let engine = Engine::with_catalog(&invalid, stack_theme::CATALOG_REVISION)?; + assert!(matches!( + engine.completion(source, 1, position(source, cursor)), + Err(OperationalError::InvalidLanguageIntelligenceInput { + reason: "completion catalog contains an invalid entry" + }) + )); + Ok(()) + } + + #[test] + fn compiler_catalog_errors_have_stable_engine_messages() { + use stack_compiler::language_intelligence::IntelligenceError; + + assert_eq!( + language_intelligence_error(IntelligenceError::CompletionCatalogTooLarge).to_string(), + "invalid language-intelligence input: completion catalog exceeds the item limit" + ); + assert_eq!( + language_intelligence_error(IntelligenceError::DuplicateCompletionCatalogId { + index: 1, + }) + .to_string(), + "invalid language-intelligence input: completion catalog contains a duplicate icon id" + ); + } +} diff --git a/crates/stack-engine/src/lib.rs b/crates/stack-engine/src/lib.rs index e136615..c8de954 100644 --- a/crates/stack-engine/src/lib.rs +++ b/crates/stack-engine/src/lib.rs @@ -29,7 +29,12 @@ mod routing; mod scene; mod svg; +mod language; mod provider; +pub use language::{ + CompletionItem, CompletionKind, CompletionOutput, Hover, HoverKind, HoverOutput, + LANGUAGE_INTELLIGENCE_SCHEMA_VERSION, TextEdit, +}; pub use provider::{ProviderAsset, ProviderPack}; /// Version of the Rust engine facade. @@ -353,6 +358,11 @@ pub enum OperationalError { /// Stable explanation of the violated provider-pack invariant. reason: &'static str, }, + /// A language-intelligence request violates its stateless input contract. + InvalidLanguageIntelligenceInput { + /// Stable explanation of the invalid source position or completion catalog. + reason: &'static str, + }, /// Compiler or layout data violates an invariant required by pure execution. InvalidIntermediateRepresentation { /// Stable explanation of the violated invariant. @@ -367,6 +377,9 @@ impl fmt::Display for OperationalError { Self::InvalidProviderPack { reason } => { write!(formatter, "invalid provider pack: {reason}") } + Self::InvalidLanguageIntelligenceInput { reason } => { + write!(formatter, "invalid language-intelligence input: {reason}") + } Self::InvalidIntermediateRepresentation { reason } => { write!(formatter, "invalid intermediate representation: {reason}") } @@ -1022,5 +1035,9 @@ mod tests { OperationalError::InvalidProviderPack { reason: "reason" }.to_string(), "invalid provider pack: reason" ); + assert_eq!( + OperationalError::InvalidLanguageIntelligenceInput { reason: "reason" }.to_string(), + "invalid language-intelligence input: reason" + ); } } diff --git a/crates/stack-engine/tests/language_intelligence.rs b/crates/stack-engine/tests/language_intelligence.rs new file mode 100644 index 0000000..44c4d39 --- /dev/null +++ b/crates/stack-engine/tests/language_intelligence.rs @@ -0,0 +1,111 @@ +use std::error::Error; +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use stack_engine::{Engine, ProviderAsset, ProviderPack, SourcePosition}; + +const WARMUP_ITERATIONS: usize = 5; +const MEASURED_ITERATIONS: usize = 100; +const MAX_P95: Duration = Duration::from_millis(20); +const MAX_SUITE: Duration = Duration::from_millis(500); + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProviderPackInput { + manifest: stack_theme::ProviderPack, + assets: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProviderAssetInput { + path: String, + svg: String, +} + +#[test] +#[ignore = "run explicitly in release mode to enforce the editor latency budget"] +fn language_intelligence_runtime_stays_within_budget() -> Result<(), Box> { + let source = "stack 1.0 diagram \"Provider\" { node store \"Store\" { icon \"example:s\" } }"; + let cursor = source.find("example:s").ok_or("missing icon prefix")? + "example:s".len(); + let position = SourcePosition { + byte_offset: cursor as u64, + line: 1, + column: (cursor + 1) as u64, + }; + let inputs: Vec = serde_json::from_str(include_str!( + "../../../tests/fixtures/provider-pack-input.json" + ))?; + let packs = inputs + .into_iter() + .map(|input| { + ProviderPack::new( + input.manifest, + input + .assets + .into_iter() + .map(|asset| ProviderAsset::new(asset.path, asset.svg)) + .collect(), + ) + }) + .collect::, _>>()?; + let engine = Engine::with_provider_packs(&packs)?; + + for version in 0..WARMUP_ITERATIONS as u64 { + require_language_result(&engine, source, version, position)?; + } + + let suite_started = Instant::now(); + let mut durations = Vec::with_capacity(MEASURED_ITERATIONS); + for version in 0..MEASURED_ITERATIONS as u64 { + let started = Instant::now(); + require_language_result(&engine, source, version, position)?; + durations.push(started.elapsed()); + } + let suite = suite_started.elapsed(); + durations.sort(); + let percentile_index = (durations.len() * 95).div_ceil(100) - 1; + let p95 = durations[percentile_index]; + if p95 > MAX_P95 { + return Err(format!( + "language intelligence p95 {:.3} ms exceeds {:.3} ms", + p95.as_secs_f64() * 1_000.0, + MAX_P95.as_secs_f64() * 1_000.0 + ) + .into()); + } + if suite > MAX_SUITE { + return Err(format!( + "language intelligence suite {:.3} ms exceeds {:.3} ms", + suite.as_secs_f64() * 1_000.0, + MAX_SUITE.as_secs_f64() * 1_000.0 + ) + .into()); + } + eprintln!( + "language intelligence: {MEASURED_ITERATIONS} completion + hover pairs, {:.3} ms suite, {:.3} ms p95", + suite.as_secs_f64() * 1_000.0, + p95.as_secs_f64() * 1_000.0 + ); + Ok(()) +} + +fn require_language_result( + engine: &Engine<'_>, + source: &str, + document_version: u64, + position: SourcePosition, +) -> Result<(), Box> { + let completion = engine.completion(source, document_version, position)?; + if completion.document_version != document_version + || completion.items.len() != 1 + || completion.items[0].filter_text != "example:storage" + { + return Err("completion output changed during the latency measurement".into()); + } + let hover = engine.hover(source, document_version, position)?; + if hover.document_version != document_version { + return Err("hover output changed during the latency measurement".into()); + } + Ok(()) +} diff --git a/crates/stack-engine/tests/snapshots/render/complete-semantics.svg b/crates/stack-engine/tests/snapshots/render/complete-semantics.svg index bab918d..aae17ed 100644 --- a/crates/stack-engine/tests/snapshots/render/complete-semantics.svg +++ b/crates/stack-engine/tests/snapshots/render/complete-semantics.svg @@ -1,8 +1,8 @@ - + Complete semantics Architecture diagram with 10 nodes, 3 groups, and 8 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/crates/stack-engine/tests/snapshots/render/default-normalization.svg b/crates/stack-engine/tests/snapshots/render/default-normalization.svg index 9fc4e47..232f79a 100644 --- a/crates/stack-engine/tests/snapshots/render/default-normalization.svg +++ b/crates/stack-engine/tests/snapshots/render/default-normalization.svg @@ -1,8 +1,8 @@ - + Default normalization Architecture diagram with 2 nodes, 0 groups, and 1 relationship. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/crates/stack-engine/tests/snapshots/render/explicit-core-icon.svg b/crates/stack-engine/tests/snapshots/render/explicit-core-icon.svg index d466b37..6dfa1a6 100644 --- a/crates/stack-engine/tests/snapshots/render/explicit-core-icon.svg +++ b/crates/stack-engine/tests/snapshots/render/explicit-core-icon.svg @@ -1,8 +1,8 @@ - + Core icon Architecture diagram with 1 node, 0 groups, and 0 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/docs/decisions/0007-adapt-language-intelligence-with-engine-catalogs.md b/docs/decisions/0007-adapt-language-intelligence-with-engine-catalogs.md new file mode 100644 index 0000000..9cbeadf --- /dev/null +++ b/docs/decisions/0007-adapt-language-intelligence-with-engine-catalogs.md @@ -0,0 +1,27 @@ +# ADR-0007: Adapt language intelligence with Engine-owned catalogs + +## Status + +Accepted + +## Context + +Browser editors need the same completion, hover, diagnostic ranges, and source edits as native tools. The compiler owns those language semantics through its protocol-neutral language-intelligence 1.0 contract, but intentionally receives icon metadata from its caller. The Engine already owns the effective core theme catalog and validated, caller-owned provider packs used by check and render operations. + +Building keyword or property suggestions in a Web application would create a second grammar. Building icon suggestions in each consumer would also allow completion to drift from the resources the Engine can actually resolve. + +## Decision + +Expose stateless `completion` and `hover` methods from the native Engine facade and its typed browser WebAssembly adapter. Forward source analysis to the pinned compiler without changing its semantics. Convert positions and outputs explicitly at the Engine boundary and preserve language-intelligence schema version 1.0, end-exclusive UTF-8 ranges, plain-text documentation, and the caller-owned document version. + +Derive completion catalog entries from the Engine's validated core catalog and provider packs. Use exact icon IDs as completion labels, filter text, and inserted text; use core subjects or provider product names as secondary detail. Provider assets remain local caller-owned inputs and are validated through the same pack constructor used by check and render. Enforce the compiler's bounded catalog size before analysis. + +The browser exports accept string snapshots for position-based operations, safe-integer document versions, and `{ byteOffset, line, column }` positions. Adapter misuse and inconsistent positions use the operational error channel. Source diagnostics remain normal results. The adapter stays synchronous and stateless; hosts own debounce, document lifecycle, cancellation, and stale-result suppression. + +## Consequences + +- Browser and native consumers share compiler-owned completion, hover, diagnostics, text edits, and exact parity fixtures. +- Core and uploaded provider icon completion cannot drift from Engine resource resolution. +- Web applications do not own or duplicate Stack grammar rules. +- Parsing provider-pack JSON on each provider-aware call has a measurable cost. A stateful cached adapter may be added later if browser latency evidence requires it, without changing the language-intelligence result contract. +- Position-aware operations reject byte arrays because a host must supply coordinates for decoded UTF-8 text. diff --git a/layout-corpus/catalog.json b/layout-corpus/catalog.json index af2c47d..8c690d3 100644 --- a/layout-corpus/catalog.json +++ b/layout-corpus/catalog.json @@ -1,7 +1,7 @@ { "$schema": "./schema.json", "schemaVersion": "1.0", - "engineVersion": "0.6.0", + "engineVersion": "0.7.0", "performance": { "warmupIterations": 3, "measuredIterations": 20, diff --git a/layout-corpus/snapshots/dense-commerce.svg b/layout-corpus/snapshots/dense-commerce.svg index e070084..9333eb8 100644 --- a/layout-corpus/snapshots/dense-commerce.svg +++ b/layout-corpus/snapshots/dense-commerce.svg @@ -1,8 +1,8 @@ - + Dense commerce platform Architecture diagram with 13 nodes, 5 groups, and 12 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/layout-corpus/snapshots/fanout-cross-edges.svg b/layout-corpus/snapshots/fanout-cross-edges.svg index e4dc45f..5a706aa 100644 --- a/layout-corpus/snapshots/fanout-cross-edges.svg +++ b/layout-corpus/snapshots/fanout-cross-edges.svg @@ -1,8 +1,8 @@ - + Fan-out and cross edges Architecture diagram with 8 nodes, 0 groups, and 10 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/layout-corpus/snapshots/medium-group-flow.svg b/layout-corpus/snapshots/medium-group-flow.svg index 7c44e5d..60b842a 100644 --- a/layout-corpus/snapshots/medium-group-flow.svg +++ b/layout-corpus/snapshots/medium-group-flow.svg @@ -1,8 +1,8 @@ - + Medium group flow Architecture diagram with 6 nodes, 2 groups, and 5 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/layout-corpus/snapshots/multilingual-long-labels.svg b/layout-corpus/snapshots/multilingual-long-labels.svg index 42c8a75..be32132 100644 --- a/layout-corpus/snapshots/multilingual-long-labels.svg +++ b/layout-corpus/snapshots/multilingual-long-labels.svg @@ -1,8 +1,8 @@ - + Multilingual long labels Architecture diagram with 4 nodes, 0 groups, and 3 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/layout-corpus/snapshots/nested-platform.svg b/layout-corpus/snapshots/nested-platform.svg index 3a68604..3529458 100644 --- a/layout-corpus/snapshots/nested-platform.svg +++ b/layout-corpus/snapshots/nested-platform.svg @@ -1,8 +1,8 @@ - + Nested platform boundaries Architecture diagram with 7 nodes, 4 groups, and 6 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/layout-corpus/snapshots/provider-icon-boundary.svg b/layout-corpus/snapshots/provider-icon-boundary.svg index d454fab..2c0f1cf 100644 --- a/layout-corpus/snapshots/provider-icon-boundary.svg +++ b/layout-corpus/snapshots/provider-icon-boundary.svg @@ -1,8 +1,8 @@ - + Provider icon boundary Architecture diagram with 3 nodes, 1 group, and 2 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53; providers example at sha256:6e05b396567a5fa3f141df079c515a8866033b98d11ec4027f486af31f14fa43 using example:storage + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53; providers example at sha256:6e05b396567a5fa3f141df079c515a8866033b98d11ec4027f486af31f14fa43 using example:storage diff --git a/layout-corpus/snapshots/small-request-path.svg b/layout-corpus/snapshots/small-request-path.svg index c95110e..3cf3928 100644 --- a/layout-corpus/snapshots/small-request-path.svg +++ b/layout-corpus/snapshots/small-request-path.svg @@ -1,8 +1,8 @@ - + Small request path Architecture diagram with 3 nodes, 0 groups, and 2 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/package-lock.json b/package-lock.json index bdb8800..0eaea2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stack-engine-workspace", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stack-engine-workspace", - "version": "0.6.0", + "version": "0.7.0", "workspaces": [ "packages/engine" ], @@ -454,7 +454,7 @@ }, "packages/engine": { "name": "@stack-sh/engine", - "version": "0.6.0", + "version": "0.7.0", "license": "Apache-2.0" } } diff --git a/package.json b/package.json index 402d829..6bdddc1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stack-engine-workspace", "private": true, - "version": "0.6.0", + "version": "0.7.0", "workspaces": [ "packages/engine" ], diff --git a/packages/engine/README.md b/packages/engine/README.md index de56fe6..a66f6e5 100644 --- a/packages/engine/README.md +++ b/packages/engine/README.md @@ -3,18 +3,35 @@ Browser WebAssembly adapter for the pure Stack diagram engine. ```js -import init, { check, format, render, renderWithProviderPacks } from "@stack-sh/engine"; +import init, { check, completion, format, hover, render, renderWithProviderPacks } from "@stack-sh/engine"; await init(); const formatted = format('stack 1.0 diagram "API" { node api "API" }'); const checked = check(new TextEncoder().encode('stack 1.0 diagram "API" { node api "API" }')); const rendered = render('stack 1.0 diagram "API" { node api "API" { icon "api" } }'); +const draft = 'stack 1.0 diagram "API" { no'; +const position = { + byteOffset: new TextEncoder().encode(draft).length, + line: 1, + column: Array.from(draft).length + 1, +}; +const completions = completion(draft, 1, position); + +const hoverSource = 'stack 1.0 diagram "API" { node api "API" }'; +const apiOffset = hoverSource.indexOf("api"); +const semanticHover = hover(hoverSource, 1, { + byteOffset: apiOffset, + line: 1, + column: apiOffset + 1, +}); ``` -`renderWithProviderPacks(source, packs)` and `checkWithProviderPacks(source, packs)` accept JSON-compatible, caller-owned provider manifests and processed SVG strings. They resolve namespaced identifiers such as `example:storage`, preserve the authored node kind, and return provider notices containing the exact pack revision, source release, archive hash, terms URL, and used icons. The adapter performs no filesystem, network, storage, clock, or DOM access. +`renderWithProviderPacks(source, packs)`, `checkWithProviderPacks(source, packs)`, and `completionWithProviderPacks(source, documentVersion, position, packs)` accept JSON-compatible, caller-owned provider manifests and processed SVG strings. They resolve or complete namespaced identifiers such as `example:storage`, while render preserves the authored node kind and returns provider notices containing the exact pack revision, source release, archive hash, terms URL, and used icons. The adapter performs no filesystem, network, storage, clock, or DOM access. -Each operation is synchronous after module initialization and accepts either a JavaScript string or `Uint8Array`. Invalid Stack source, including invalid UTF-8 bytes, returns normal portable diagnostics. Diagnostics include the primary range, ordered `expected` values, corrective help, and related source locations. A JavaScript value of any other type throws `TypeError` at the package boundary. +`completion` and `hover` implement Stack language-intelligence schema 1.0 over one complete string snapshot. Positions contain a zero-based UTF-8 byte offset plus one-based Unicode scalar line and column. Results echo `documentVersion`, allowing an editor to discard stale work, and expose only plain-text labels and documentation. Completion obtains keywords, properties, enum values, and document identifiers from the compiler; icon entries come from the Engine's core and validated provider catalogs. + +Each operation is synchronous after module initialization. Format, check, and render accept either a JavaScript string or `Uint8Array`; completion and hover accept a string because their positions are defined over UTF-8 text. Invalid Stack source, including invalid UTF-8 bytes in byte-oriented operations, returns normal portable diagnostics. Diagnostics include the primary range, ordered `expected` values, corrective help, and related source locations. Unsupported values and malformed number or position objects throw `TypeError` at the package boundary. The package does not read files, contact a network service, inspect the DOM, observe a clock, or measure host fonts. Consumers own module loading and all host I/O. diff --git a/packages/engine/package.json b/packages/engine/package.json index 2558d70..1a69c88 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -1,6 +1,6 @@ { "name": "@stack-sh/engine", - "version": "0.6.0", + "version": "0.7.0", "description": "Browser WebAssembly adapter for Stack diagram operations", "type": "module", "license": "Apache-2.0", diff --git a/scripts/layout-corpus.test.mjs b/scripts/layout-corpus.test.mjs index 7dcb423..3e12273 100644 --- a/scripts/layout-corpus.test.mjs +++ b/scripts/layout-corpus.test.mjs @@ -44,7 +44,7 @@ test("provider fixtures and declared provider coverage cannot drift", () => { test("the static gallery escapes source and exposes accessible comparisons", () => { const fixtureCatalog = { - engineVersion: "0.6.0", + engineVersion: "0.7.0", schemaVersion: "1.0", cases: [{ id: "fixture" }], }; diff --git a/scripts/validate-wasm-package.mjs b/scripts/validate-wasm-package.mjs index 0842650..868688f 100644 --- a/scripts/validate-wasm-package.mjs +++ b/scripts/validate-wasm-package.mjs @@ -19,8 +19,11 @@ assert.match(declaration, /export type StackSource = string \| Uint8Array;/); assert.match(declaration, /export function format\(source: StackSource\): FormatResult;/); assert.match(declaration, /export function check\(source: StackSource\): CheckResult;/); assert.match(declaration, /export function render\(source: StackSource\): RenderResult;/); +assert.match(declaration, /export function completion\(source: string/); +assert.match(declaration, /export function hover\(source: string/); assert.match(declaration, /export function checkWithProviderPacks/); assert.match(declaration, /export function renderWithProviderPacks/); +assert.match(declaration, /export function completionWithProviderPacks/); const module = new WebAssembly.Module(binary); const imports = WebAssembly.Module.imports(module); @@ -45,7 +48,16 @@ for (const requiredPrimitive of ["Array", "Error", "JSON", "Object", "Reflect", } const exports = new Set(WebAssembly.Module.exports(module).map(({ name }) => name)); -for (const operation of ["format", "check", "render", "checkWithProviderPacks", "renderWithProviderPacks"]) { +for (const operation of [ + "format", + "check", + "render", + "completion", + "hover", + "checkWithProviderPacks", + "renderWithProviderPacks", + "completionWithProviderPacks", +]) { assert.ok(exports.has(operation), `missing ${operation} WebAssembly export`); } diff --git a/tests/fixtures/language-intelligence-cases.json b/tests/fixtures/language-intelligence-cases.json new file mode 100644 index 0000000..a612414 --- /dev/null +++ b/tests/fixtures/language-intelligence-cases.json @@ -0,0 +1,26 @@ +[ + { + "name": "diagram-keyword", + "documentVersion": 3, + "sourceWithCursor": "stack 1.0\ndiagram \"Draft\" {\n no<|>\n}\n", + "providerPacks": false + }, + { + "name": "core-icon", + "documentVersion": 5, + "sourceWithCursor": "stack 1.0 diagram \"Icons\" { node api \"API\" { icon \"ga<|>\" } }", + "providerPacks": false + }, + { + "name": "provider-icon", + "documentVersion": 8, + "sourceWithCursor": "stack 1.0 diagram \"Provider\" { node store \"Store\" { icon \"example:s<|>\" } }", + "providerPacks": true + }, + { + "name": "multilingual-hover", + "documentVersion": 13, + "sourceWithCursor": "stack 1.0\ndiagram \"顧客 API\" {\n node user \"顧<|>客\"\n}\n", + "providerPacks": false + } +] diff --git a/tests/types.test.ts b/tests/types.test.ts index f8fee61..9e77a43 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -1,12 +1,17 @@ import init, { check, checkWithProviderPacks, + completion, + completionWithProviderPacks, format, + hover, render, renderWithProviderPacks, type CheckResult, + type CompletionResult, type Diagnostic, type FormatResult, + type HoverResult, type ProviderPackInput, type RenderResult, type StackSource, @@ -22,6 +27,15 @@ const diagnostic: Diagnostic | undefined = checked.diagnostics[0]; const providerPacks = JSON.parse("[]") as readonly ProviderPackInput[]; const providerChecked: CheckResult = checkWithProviderPacks(text, providerPacks); const providerRendered: RenderResult = renderWithProviderPacks(bytes, providerPacks); +const position = { byteOffset: 0, line: 1, column: 1 } as const; +const completed: CompletionResult = completion(text, 1, position); +const providerCompleted: CompletionResult = completionWithProviderPacks( + text, + 1, + position, + providerPacks, +); +const hovered: HoverResult = hover(text, 1, position); formatted.formattedSource?.toUpperCase(); rendered.svg?.startsWith(""); + assert.notEqual(marker, -1); + assert.equal(sourceWithCursor.indexOf("<|>", marker + 3), -1); + const source = sourceWithCursor.slice(0, marker) + sourceWithCursor.slice(marker + 3); + const prefix = source.slice(0, marker); + const lines = prefix.split(/\r\n|\n/); + return { + source, + position: { + byteOffset: new TextEncoder().encode(prefix).length, + line: lines.length, + column: Array.from(lines.at(-1) ?? "").length + 1, + }, + }; +} + +function wasmLanguageOutputs() { + return languageCases.map((fixture) => { + const { source, position } = sourceAndPosition(fixture.sourceWithCursor); + return { + name: fixture.name, + completion: fixture.providerPacks + ? completionWithProviderPacks( + source, + fixture.documentVersion, + position, + providerPacks, + ) + : completion(source, fixture.documentVersion, position), + hover: hover(source, fixture.documentVersion, position), + }; + }); +} + test("browser exports match native engine results for shared fixtures", () => { const native = JSON.parse( execFileSync( @@ -63,6 +106,77 @@ test("browser exports match native engine results for shared fixtures", () => { assert.deepEqual(wasmOutputs(), native); }); +test("browser language intelligence matches native results for shared fixtures", () => { + const native = JSON.parse( + execFileSync( + "cargo", + [ + "run", + "--quiet", + "--locked", + "-p", + "stack-engine-wasm", + "--example", + "language-intelligence-parity", + "--", + languageFixturePath, + providerFixturePath, + ], + { cwd: repositoryRoot, encoding: "utf8" }, + ), + ); + const browser = wasmLanguageOutputs(); + assert.deepEqual(browser, native); + assert.deepEqual( + browser.find(({ name }) => name === "diagram-keyword")?.completion.items.map( + ({ label }) => label, + ), + ["node"], + ); + assert.deepEqual( + browser.find(({ name }) => name === "core-icon")?.completion.items.map( + ({ filterText }) => filterText, + ), + ["gateway"], + ); + assert.deepEqual( + browser.find(({ name }) => name === "provider-icon")?.completion.items.map( + ({ filterText }) => filterText, + ), + ["example:storage"], + ); + assert.equal( + browser.find(({ name }) => name === "multilingual-hover")?.hover.hover?.label, + "顧客", + ); +}); + +test("provider-aware browser completion stays within the editor latency budget", (context) => { + const fixture = languageCases.find(({ name }) => name === "provider-icon"); + assert.ok(fixture); + const { source, position } = sourceAndPosition(fixture.sourceWithCursor); + for (let index = 0; index < 5; index += 1) { + completionWithProviderPacks(source, index, position, providerPacks); + } + const durations = []; + const suiteStarted = performance.now(); + for (let index = 0; index < 100; index += 1) { + const started = performance.now(); + const result = completionWithProviderPacks(source, index, position, providerPacks); + durations.push(performance.now() - started); + assert.equal(result.documentVersion, index); + assert.equal(result.items[0]?.filterText, "example:storage"); + } + const suiteMilliseconds = performance.now() - suiteStarted; + durations.sort((left, right) => left - right); + const p95Milliseconds = durations[Math.ceil(durations.length * 0.95) - 1]; + assert.ok(p95Milliseconds <= 20, `p95 ${p95Milliseconds.toFixed(3)} ms exceeded 20 ms`); + assert.ok(suiteMilliseconds <= 500, `suite ${suiteMilliseconds.toFixed(3)} ms exceeded 500 ms`); + context.diagnostic( + `100 provider completions: ${suiteMilliseconds.toFixed(3)} ms suite, ${p95Milliseconds.toFixed(3)} ms p95`, + ); +}); + test("invalid UTF-8 is a normal diagnostic result for every operation", () => { const invalid = wasmOutputs().find(({ name }) => name === "invalid-utf8-bytes"); assert.ok(invalid); @@ -81,7 +195,7 @@ test("browser diagnostics preserve actionable compiler guidance", () => { ); assert.ok(actionable); assert.equal(actionable.render.svg, null); - assert.equal(actionable.check.metadata.engineVersion, "0.6.0"); + assert.equal(actionable.check.metadata.engineVersion, "0.7.0"); assert.deepEqual(actionable.check.diagnostics[0], { code: "STK2002", severity: "error", @@ -103,7 +217,7 @@ test("browser rendering resolves the bundled explicit core icon", () => { assert.ok(explicitIcon); assert.deepEqual(explicitIcon.check.diagnostics, []); assert.deepEqual(explicitIcon.render.diagnostics, []); - assert.equal(explicitIcon.render.metadata.engineVersion, "0.6.0"); + assert.equal(explicitIcon.render.metadata.engineVersion, "0.7.0"); assert.equal(explicitIcon.render.metadata.themeCatalogVersion, "0.5.0"); assert.equal( explicitIcon.render.metadata.themeCatalogRevision, @@ -152,4 +266,20 @@ test("the JavaScript boundary rejects unsupported source values consistently", ( () => checkWithProviderPacks("stack 1.0", () => undefined), { name: "TypeError", message: "Provider packs must be JSON-compatible local data" }, ); + assert.throws( + () => completion(new Uint8Array(), 1, { byteOffset: 0, line: 1, column: 1 }), + { name: "TypeError", message: "Language intelligence source must be a string" }, + ); + assert.throws(() => completion("stack 1.0", -1, { byteOffset: 0, line: 1, column: 1 }), { + name: "TypeError", + message: "Document version must be a safe integer", + }); + assert.throws(() => hover("stack 1.0", 1, null), { + name: "TypeError", + message: "Source position must be an object", + }); + assert.throws( + () => hover("stack 1.0", 1, { byteOffset: 0, line: 0, column: 1 }), + { name: "TypeError", message: "Source position line must be a safe integer" }, + ); });