diff --git a/crates/config-source-wasm/src/lib.rs b/crates/config-source-wasm/src/lib.rs index f15c6f7b..6df1e834 100644 --- a/crates/config-source-wasm/src/lib.rs +++ b/crates/config-source-wasm/src/lib.rs @@ -8,6 +8,7 @@ use wasm_bindgen::prelude::*; thread_local! { static SESSION: RefCell> = const { RefCell::new(None) }; + static SCHEMA_BUNDLE: RefCell> = const { RefCell::new(None) }; } #[wasm_bindgen] @@ -23,6 +24,13 @@ pub fn set_snapshot(snapshot: JsValue) -> Result<(), JsValue> { Ok(()) } +#[wasm_bindgen] +pub fn set_schema_bundle(schema_bundle: JsValue) -> Result<(), JsValue> { + let schema_bundle: WorkspaceConfigSchemaBundle = decode(schema_bundle)?; + SCHEMA_BUNDLE.with(|session| session.replace(Some(schema_bundle))); + Ok(()) +} + #[wasm_bindgen] pub fn apply_changes(changes: JsValue) -> Result { let changes: Vec = decode(changes)?; @@ -91,8 +99,8 @@ pub fn complete_current( .as_ref() .ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?; let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?; - let result = SnapshotEnvironment::new(snapshot.clone()) - .complete(&entrypoint, &source, utf8_byte_offset, explicit) + let result = session_environment(snapshot.clone()) + .complete_config(&entrypoint, &source, utf8_byte_offset, explicit) .map_err(|error| JsValue::from_str(&format!("{error:?}")))? .map(|result| WasmCompletionResult { from: result.from, @@ -132,7 +140,16 @@ pub fn analyze_snapshot( ) -> Result { let snapshot: ConfigTreeSnapshot = decode(snapshot)?; let entrypoint = VirtualPath::parse(entrypoint).map_err(js_error)?; - encode(SnapshotEnvironment::new(snapshot).analyze(&entrypoint, source_override.as_deref())) + encode(session_environment(snapshot).analyze(&entrypoint, source_override.as_deref())) +} + +fn session_environment(snapshot: ConfigTreeSnapshot) -> SnapshotEnvironment { + let schema_bundle = SCHEMA_BUNDLE.with(|schema_bundle| schema_bundle.borrow().clone()); + let mut environment = SnapshotEnvironment::new(snapshot); + if let Some(schema_bundle) = schema_bundle { + environment = environment.with_schema_bundle(schema_bundle); + } + environment } #[wasm_bindgen] diff --git a/crates/config-source/src/lib.rs b/crates/config-source/src/lib.rs index 1dd92cc5..2219973b 100644 --- a/crates/config-source/src/lib.rs +++ b/crates/config-source/src/lib.rs @@ -3,7 +3,7 @@ use std::fmt; use decodal::{ Data, Diagnostic, DiagnosticKind, Engine, HostEnvironment, ImportCandidate, ImportLoader, - LoadedImport, Span, Value, + LoadedImport, Span, SyntaxToken, SyntaxTokenKind, Value, tokenize_source, }; use decodal_language_service::{CompletionResult, LanguageService}; use serde::{Deserialize, Serialize}; @@ -752,14 +752,249 @@ pub struct EvaluationResult { pub projection_digest: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ConfigFieldCompletionContext { + schema_path: Vec, + from: usize, +} + +#[derive(Debug)] +enum ConfigCompletionContainer { + Object { + schema_path: Vec, + pending_path: Vec, + last_identifier_from: Option, + trailing_dot: bool, + reading_value: bool, + }, + Array { + schema_path: Vec, + }, + Other { + schema_path: Vec, + }, +} + +fn workspace_schema_assertion_object_start( + tokens: &[SyntaxToken], + utf8_byte_offset: usize, +) -> Option { + let mut object_stack = Vec::::new(); + let mut matching_assertions = Vec::::new(); + + for (index, token) in tokens.iter().enumerate() { + match &token.kind { + SyntaxTokenKind::LBrace => object_stack.push(index), + SyntaxTokenKind::RBrace => { + let Some(object_index) = object_stack.pop() else { + continue; + }; + let object_start = tokens[object_index].span.start as usize; + let object_end = token.span.start as usize; + if !(object_start < utf8_byte_offset && utf8_byte_offset <= object_end) { + continue; + } + let mut suffix = tokens[index + 1..] + .iter() + .filter(|token| !matches!(token.kind, SyntaxTokenKind::Comment)); + if !matches!( + suffix.next().map(|token| &token.kind), + Some(SyntaxTokenKind::As) + ) { + continue; + } + let Some(SyntaxTokenKind::Ident(global)) = suffix.next().map(|token| &token.kind) + else { + continue; + }; + if global == WORKSPACE_CONFIG_SCHEMA_GLOBAL { + matching_assertions.push(object_start); + } + } + _ => {} + } + } + + matching_assertions.into_iter().max() +} + +fn config_field_completion_context( + source: &str, + utf8_byte_offset: usize, +) -> Option { + if utf8_byte_offset > source.len() || !source.is_char_boundary(utf8_byte_offset) { + return None; + } + let tokens = tokenize_source(source).ok()?; + let asserted_object_start = workspace_schema_assertion_object_start(&tokens, utf8_byte_offset)?; + let mut containers = Vec::::new(); + + for token in tokens { + let token_start = token.span.start as usize; + let token_end = token.span.end as usize; + if token_start < asserted_object_start { + continue; + } + if token_start >= utf8_byte_offset { + break; + } + let kind = token.kind; + match kind { + SyntaxTokenKind::LBrace => { + let schema_path = pending_container_path(&containers); + containers.push(ConfigCompletionContainer::Object { + schema_path, + pending_path: Vec::new(), + last_identifier_from: None, + trailing_dot: false, + reading_value: false, + }); + } + SyntaxTokenKind::RBrace => { + pop_container(&mut containers, |container| { + matches!(container, ConfigCompletionContainer::Object { .. }) + }); + } + SyntaxTokenKind::LBracket => { + let schema_path = pending_container_path(&containers); + containers.push(ConfigCompletionContainer::Array { schema_path }); + } + SyntaxTokenKind::RBracket => { + pop_container(&mut containers, |container| { + matches!(container, ConfigCompletionContainer::Array { .. }) + }); + } + SyntaxTokenKind::LParen => { + let schema_path = pending_container_path(&containers); + containers.push(ConfigCompletionContainer::Other { schema_path }); + } + SyntaxTokenKind::RParen => { + pop_container(&mut containers, |container| { + matches!(container, ConfigCompletionContainer::Other { .. }) + }); + } + SyntaxTokenKind::Ident(identifier) => { + let Some(ConfigCompletionContainer::Object { + pending_path, + last_identifier_from, + trailing_dot, + reading_value: false, + .. + }) = containers.last_mut() + else { + continue; + }; + let identifier = if token_end > utf8_byte_offset { + source[token_start..utf8_byte_offset].to_owned() + } else { + identifier + }; + if *trailing_dot || pending_path.is_empty() { + pending_path.push(identifier); + } else { + *pending_path.last_mut().expect("pending path is non-empty") = identifier; + } + *last_identifier_from = Some(token_start); + *trailing_dot = false; + } + SyntaxTokenKind::Dot => { + if let Some(ConfigCompletionContainer::Object { + trailing_dot, + reading_value: false, + .. + }) = containers.last_mut() + { + *trailing_dot = true; + } + } + SyntaxTokenKind::Equal => { + if let Some(ConfigCompletionContainer::Object { reading_value, .. }) = + containers.last_mut() + { + *reading_value = true; + } + } + SyntaxTokenKind::Semicolon => { + if let Some(ConfigCompletionContainer::Object { + pending_path, + last_identifier_from, + trailing_dot, + reading_value, + .. + }) = containers.last_mut() + { + pending_path.clear(); + *last_identifier_from = None; + *trailing_dot = false; + *reading_value = false; + } + } + _ => {} + } + } + + let ConfigCompletionContainer::Object { + schema_path, + pending_path, + last_identifier_from, + trailing_dot, + reading_value: false, + } = containers.last()? + else { + return None; + }; + let mut schema_path = schema_path.clone(); + schema_path.extend(pending_path.iter().cloned()); + Some(ConfigFieldCompletionContext { + schema_path, + from: if *trailing_dot { + utf8_byte_offset + } else { + last_identifier_from.unwrap_or(utf8_byte_offset) + }, + }) +} + +fn pending_container_path(containers: &[ConfigCompletionContainer]) -> Vec { + match containers.last() { + Some(ConfigCompletionContainer::Object { + schema_path, + pending_path, + reading_value: true, + .. + }) => schema_path.iter().chain(pending_path).cloned().collect(), + Some(ConfigCompletionContainer::Array { schema_path }) + | Some(ConfigCompletionContainer::Other { schema_path }) => schema_path.clone(), + _ => Vec::new(), + } +} + +fn pop_container( + containers: &mut Vec, + matches: impl Fn(&ConfigCompletionContainer) -> bool, +) { + if let Some(index) = containers.iter().rposition(matches) { + containers.truncate(index); + } +} + #[derive(Debug, Clone)] pub struct SnapshotEnvironment { snapshot: ConfigTreeSnapshot, + schema_bundle: Option, } impl SnapshotEnvironment { pub fn new(snapshot: ConfigTreeSnapshot) -> Self { - Self { snapshot } + Self { + snapshot, + schema_bundle: None, + } + } + + pub fn with_schema_bundle(mut self, schema_bundle: WorkspaceConfigSchemaBundle) -> Self { + self.schema_bundle = Some(schema_bundle); + self } pub fn snapshot(&self) -> &ConfigTreeSnapshot { @@ -954,6 +1189,32 @@ impl SnapshotEnvironment { LanguageService::new(self).complete(entrypoint.as_str(), source, utf8_byte_offset, explicit) } + pub fn complete_config( + &self, + entrypoint: &VirtualPath, + source: &str, + utf8_byte_offset: usize, + explicit: bool, + ) -> decodal::Result> { + if self.schema_bundle.is_some() + && let Some(context) = config_field_completion_context(source, utf8_byte_offset) + { + let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}."); + member_source.push_str(&context.schema_path.join(".")); + let mut completion = LanguageService::new(self).complete( + entrypoint.as_str(), + &member_source, + member_source.len(), + explicit, + )?; + if let Some(completion) = &mut completion { + completion.from = context.from; + } + return Ok(completion); + } + self.complete(entrypoint, source, utf8_byte_offset, explicit) + } + pub fn format(&self, source: &str) -> Result { decodal_language_tools::format_source(source).map_err(|error| error.to_string()) } @@ -989,7 +1250,17 @@ impl HostEnvironment for &SnapshotEnvironment { } } - fn configure_engine(&self, _engine: &mut Engine) -> decodal::Result<()> { + fn configure_engine(&self, engine: &mut Engine) -> decodal::Result<()> { + let Some(schema_bundle) = &self.schema_bundle else { + return Ok(()); + }; + let schema_module = engine.add_root_source( + WORKSPACE_CONFIG_SCHEMA_SOURCE, + WORKSPACE_CONFIG_SCHEMA_SOURCE, + &schema_bundle.source, + )?; + let schema = engine.eval_module(schema_module)?; + engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema); Ok(()) } } @@ -1576,6 +1847,91 @@ mod tests { assert_eq!(left.digest, right.digest); } + #[test] + fn schema_completion_tracks_only_asserted_config_object_paths() { + assert_eq!(config_field_completion_context("{ pro", 5), None); + + let root = "{ pro } as WorkspaceConfigSchema"; + let root_cursor = root.find("pro").unwrap() + 3; + assert_eq!( + config_field_completion_context(root, root_cursor), + Some(ConfigFieldCompletionContext { + schema_path: vec!["pro".into()], + from: root_cursor - 3, + }) + ); + let nested = "{ profile = { def } } as WorkspaceConfigSchema"; + let nested_cursor = nested.find("def").unwrap() + 3; + assert_eq!( + config_field_completion_context(nested, nested_cursor), + Some(ConfigFieldCompletionContext { + schema_path: vec!["profile".into(), "def".into()], + from: nested_cursor - 3, + }) + ); + let array = "{ profile = { entries = [{ sel }] } } as WorkspaceConfigSchema"; + let array_cursor = array.find("sel").unwrap() + 3; + assert_eq!( + config_field_completion_context(array, array_cursor), + Some(ConfigFieldCompletionContext { + schema_path: vec!["profile".into(), "entries".into(), "sel".into()], + from: array_cursor - 3, + }) + ); + let value = "{ profile = \"default\" } as WorkspaceConfigSchema"; + let value_cursor = value.find("default").unwrap() + 3; + assert_eq!(config_field_completion_context(value, value_cursor), None); + } + + #[test] + fn completion_projects_workspace_schema_fields_into_config_objects() { + let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); + let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:profile", + "profile", + "1", + "{ profile = { default_profile = String; entries = [{ selector = String; }]; }; }", + ) + .unwrap()]) + .unwrap(); + let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema); + + let bare_source = "{ pro }"; + let bare_cursor = bare_source.find("pro").unwrap() + 3; + let bare = environment + .complete_config(&path("main.dcdl"), bare_source, bare_cursor, true) + .unwrap(); + assert!( + bare.is_none_or(|completion| !completion + .items + .iter() + .any(|item| item.label == "profile")) + ); + + let root_source = "{ pro } as WorkspaceConfigSchema"; + let root_cursor = root_source.find("pro").unwrap() + 3; + let root = environment + .complete_config(&path("main.dcdl"), root_source, root_cursor, true) + .unwrap() + .unwrap(); + assert_eq!(root.from, root_cursor - 3); + assert!(root.items.iter().any(|item| item.label == "profile")); + + let nested_source = "{ profile = { def } } as WorkspaceConfigSchema"; + let nested_cursor = nested_source.find("def").unwrap() + 3; + let nested = environment + .complete_config(&path("main.dcdl"), nested_source, nested_cursor, true) + .unwrap() + .unwrap(); + assert_eq!(nested.from, nested_cursor - 3); + assert!( + nested + .items + .iter() + .any(|item| item.label == "default_profile") + ); + } + #[test] fn relative_imports_and_completion_share_the_snapshot_namespace() { let snapshot = ConfigTreeSnapshot::from_entries( diff --git a/web/workspace/deno.json b/web/workspace/deno.json index a86241c8..06f4bf98 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/wasm-parity.test.ts", + "test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, @@ -16,8 +16,10 @@ "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", "@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0", + "@codemirror/language": "npm:@codemirror/language@6.12.4", "@codemirror/state": "npm:@codemirror/state@6.7.1", "@codemirror/view": "npm:@codemirror/view@6.43.8", + "@lezer/highlight": "npm:@lezer/highlight@1.2.3", "decodal-codemirror": "npm:decodal-codemirror@0.3.0", "clsx": "npm:clsx@2.1.1", "cookie": "npm:cookie@0.6.0", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index d25f484e..a68fd449 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -4,9 +4,11 @@ "jsr:@std/assert@*": "1.0.19", "jsr:@std/internal@^1.0.12": "1.0.14", "npm:@codemirror/autocomplete@6.20.0": "6.20.0", + "npm:@codemirror/language@6.12.4": "6.12.4", "npm:@codemirror/state@6.7.1": "6.7.1", "npm:@codemirror/view@6.43.8": "6.43.8", "npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0", + "npm:@lezer/highlight@1.2.3": "1.2.3", "npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7", "npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7", "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7", @@ -509,7 +511,8 @@ "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==" }, "@ungap/structured-clone@1.3.0": { - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "deprecated": true }, "acorn@8.16.0": { "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", @@ -1000,8 +1003,10 @@ "workspace": { "dependencies": [ "npm:@codemirror/autocomplete@6.20.0", + "npm:@codemirror/language@6.12.4", "npm:@codemirror/state@6.7.1", "npm:@codemirror/view@6.43.8", + "npm:@lezer/highlight@1.2.3", "npm:@sveltejs/adapter-static@3.0.9", "npm:@sveltejs/kit@2.49.4", "npm:@sveltejs/vite-plugin-svelte@6.2.1", diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index b6303733..cfb9142b 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -27,7 +27,7 @@ let baseRevision = $state(0); let baseDigest = $state(""); let renamePath = $state(""); - let baseSnapshot = $state(null); + let baseSnapshot = $state.raw(null); let preflightDigest = $state(""); let conflict = $state(false); let candidateContract = $state(null); @@ -56,10 +56,10 @@ selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? ""; } source = selectedPath ? treeState.snapshot.entries[selectedPath].content : ""; - baseSnapshot = structuredClone(treeState.snapshot); + baseSnapshot = $state.snapshot(treeState.snapshot); baseRevision = treeState.snapshot.revision; baseDigest = treeState.snapshot.digest; - await toolchain?.setSnapshot(treeState.snapshot); + await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle); draftChanges = []; renamePath = selectedPath; diagnostics = []; @@ -181,10 +181,10 @@ preflightDigest = ""; candidateContract = null; conflict = false; - baseSnapshot = structuredClone(treeState.snapshot); + baseSnapshot = $state.snapshot(treeState.snapshot); baseRevision = treeState.snapshot.revision; baseDigest = treeState.snapshot.digest; - await toolchain?.setSnapshot(treeState.snapshot); + await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle); source = treeState.snapshot.entries[selectedPath]?.content ?? ""; diagnostics = []; status = `Committed revision ${treeState.snapshot.revision}.`; @@ -210,7 +210,7 @@ baseSnapshot = structuredClone(remote.snapshot); baseRevision = remote.snapshot.revision; baseDigest = remote.snapshot.digest; - await toolchain.setSnapshot(remote.snapshot); + await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle); try { const candidate = await toolchain.applyChanges(localChanges); draftChanges = localChanges; diff --git a/web/workspace/src/lib/workspace/config-source/completion.ts b/web/workspace/src/lib/workspace/config-source/completion.ts new file mode 100644 index 00000000..8467bdb2 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/completion.ts @@ -0,0 +1,57 @@ +import type { CompletionResult } from "@codemirror/autocomplete"; + +export type ConfigSourceCompletionResult = { + from: number; + items: ConfigSourceCompletionItem[]; +}; + +type ConfigSourceCompletionItem = { + label: string; + kind: string; + detail: string | null; + priority: number; +}; + +export function toCodeMirrorCompletion( + source: string, + result: ConfigSourceCompletionResult | null, +): CompletionResult | null { + if (!result) return null; + return { + from: utf8ByteOffsetToUtf16(source, result.from), + options: result.items.map((item) => ({ + label: item.label, + type: item.kind, + detail: item.detail ?? undefined, + boost: item.priority, + })), + }; +} + +function utf8ByteOffsetToUtf16(source: string, byteOffset: number): number { + if (!Number.isSafeInteger(byteOffset) || byteOffset < 0) { + throw new RangeError( + "completion byte offset must be a non-negative integer", + ); + } + + let bytes = 0; + let utf16 = 0; + for (const character of source) { + if (bytes === byteOffset) return utf16; + const codePoint = character.codePointAt(0)!; + bytes += codePoint <= 0x7f + ? 1 + : codePoint <= 0x7ff + ? 2 + : codePoint <= 0xffff + ? 3 + : 4; + utf16 += character.length; + if (bytes > byteOffset) { + throw new RangeError("completion byte offset splits a UTF-8 code point"); + } + } + if (bytes === byteOffset) return utf16; + throw new RangeError("completion byte offset is outside the source"); +} diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts index af61ca25..3868c368 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts @@ -19,6 +19,8 @@ export function formatSource(source: string): string; export function format_source(source: string): string; +export function set_schema_bundle(schema_bundle: any): void; + export function set_snapshot(snapshot: any): void; export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; @@ -33,6 +35,7 @@ export interface InitOutput { readonly evaluate_current: (a: any) => [number, number, number]; readonly evaluate_snapshot: (a: any, b: any) => [number, number, number]; readonly format_source: (a: number, b: number) => [number, number, number, number]; + readonly set_schema_bundle: (a: any) => [number, number]; readonly set_snapshot: (a: any) => [number, number]; readonly formatSource: (a: number, b: number) => [number, number]; readonly __wbindgen_malloc: (a: number, b: number) => number; diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js index 9fc330c8..96db090e 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js @@ -143,6 +143,16 @@ export function format_source(source) { } } +/** + * @param {any} schema_bundle + */ +export function set_schema_bundle(schema_bundle) { + const ret = wasm.set_schema_bundle(schema_bundle); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } +} + /** * @param {any} snapshot */ diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm index 5f621ea3..e4e3e6f6 100644 Binary files a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm and b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm differ diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts index 361b58be..cf8f17e8 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts @@ -9,6 +9,7 @@ export const compose_schema_bundle: (a: any) => [number, number, number]; export const evaluate_current: (a: any) => [number, number, number]; export const evaluate_snapshot: (a: any, b: any) => [number, number, number]; export const format_source: (a: number, b: number) => [number, number, number, number]; +export const set_schema_bundle: (a: any) => [number, number]; export const set_snapshot: (a: any) => [number, number]; export const formatSource: (a: number, b: number) => [number, number]; export const __wbindgen_malloc: (a: number, b: number) => number; diff --git a/web/workspace/src/lib/workspace/config-source/toolchain-message.ts b/web/workspace/src/lib/workspace/config-source/toolchain-message.ts new file mode 100644 index 00000000..d9d376b9 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/toolchain-message.ts @@ -0,0 +1,3 @@ +export function jsonWorkerMessage(request: T): T { + return JSON.parse(JSON.stringify(request)) as T; +} diff --git a/web/workspace/src/lib/workspace/config-source/toolchain.ts b/web/workspace/src/lib/workspace/config-source/toolchain.ts index cf27ed0f..311e99c8 100644 --- a/web/workspace/src/lib/workspace/config-source/toolchain.ts +++ b/web/workspace/src/lib/workspace/config-source/toolchain.ts @@ -1,5 +1,19 @@ -import type { ConfigDiagnostic, ConfigTreeChange, ConfigTreeSnapshot, ToolchainContract } from "./types.ts"; -import type { ConfigSourceWorkerRequest, ConfigSourceWorkerResponse } from "./toolchain.worker.ts"; +import type { + ConfigDiagnostic, + ConfigTreeChange, + ConfigTreeSnapshot, + ToolchainContract, + WorkspaceConfigSchemaBundle, +} from "./types.ts"; +import { jsonWorkerMessage } from "./toolchain-message.ts"; +import { + type ConfigSourceCompletionResult, + toCodeMirrorCompletion, +} from "./completion.ts"; +import type { + ConfigSourceWorkerRequest, + ConfigSourceWorkerResponse, +} from "./toolchain.worker.ts"; type Command = | Omit, "id"> @@ -13,26 +27,42 @@ type Command = export class ConfigSourceToolchain { #worker: Worker; #nextId = 1; - #pending = new Map void; reject: (reason: unknown) => void }>(); + #pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (reason: unknown) => void } + >(); - constructor(worker = new Worker(new URL("./toolchain.worker.ts", import.meta.url), { type: "module" })) { + constructor( + worker = new Worker(new URL("./toolchain.worker.ts", import.meta.url), { + type: "module", + }), + ) { this.#worker = worker; - worker.addEventListener("message", (event: MessageEvent) => { - const pending = this.#pending.get(event.data.id); - if (!pending) return; - this.#pending.delete(event.data.id); - if (event.data.ok) pending.resolve(event.data.result); - else pending.reject(event.data.error); - }); + worker.addEventListener( + "message", + (event: MessageEvent) => { + const pending = this.#pending.get(event.data.id); + if (!pending) return; + this.#pending.delete(event.data.id); + if (event.data.ok) pending.resolve(event.data.result); + else pending.reject(event.data.error); + }, + ); } - setSnapshot(snapshot: ConfigTreeSnapshot): Promise { - return this.#request({ kind: "set_snapshot", snapshot }); + setSnapshot( + snapshot: ConfigTreeSnapshot, + schemaBundle: WorkspaceConfigSchemaBundle, + ): Promise { + return this.#request({ kind: "set_snapshot", snapshot, schemaBundle }); } applyChanges(changes: ConfigTreeChange[]): Promise { return this.#request({ kind: "apply_changes", changes }); } - changesBetween(base: ConfigTreeSnapshot, candidate: ConfigTreeSnapshot): Promise { + changesBetween( + base: ConfigTreeSnapshot, + candidate: ConfigTreeSnapshot, + ): Promise { return this.#request({ kind: "changes_between", base, candidate }); } analyze(path: string, source?: string): Promise { @@ -41,22 +71,40 @@ export class ConfigSourceToolchain { evaluate(contract: ToolchainContract) { return this.#request({ kind: "evaluate", contract }); } - complete(path: string, source: string, utf16Offset: number, explicit = false): Promise { - return this.#request({ kind: "complete", path, source, utf16Offset, explicit }); + async complete( + path: string, + source: string, + utf16Offset: number, + explicit = false, + ): Promise { + const result = await this.#request({ + kind: "complete", + path, + source, + utf16Offset, + explicit, + }); + return toCodeMirrorCompletion(source, result); } format(source: string): Promise { return this.#request({ kind: "format", source }); } close(): void { this.#worker.terminate(); - for (const pending of this.#pending.values()) pending.reject(new Error("config source toolchain was closed")); + for (const pending of this.#pending.values()) { + pending.reject(new Error("config source toolchain was closed")); + } this.#pending.clear(); } #request(request: Command): Promise { const id = this.#nextId++; + const message = jsonWorkerMessage({ ...request, id }); return new Promise((resolve, reject) => { - this.#pending.set(id, { resolve: (value) => resolve(value as T), reject }); - this.#worker.postMessage({ ...request, id }); + this.#pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + }); + this.#worker.postMessage(message); }); } } diff --git a/web/workspace/src/lib/workspace/config-source/toolchain.worker.ts b/web/workspace/src/lib/workspace/config-source/toolchain.worker.ts index 32885cb7..9095114e 100644 --- a/web/workspace/src/lib/workspace/config-source/toolchain.worker.ts +++ b/web/workspace/src/lib/workspace/config-source/toolchain.worker.ts @@ -5,17 +5,30 @@ import init, { complete_current, evaluate_current, format_source, + set_schema_bundle, set_snapshot, } from "./generated/config_source_wasm.js"; import type { ConfigTreeChange } from "./types.ts"; export type ConfigSourceWorkerRequest = - | { id: number; kind: "set_snapshot"; snapshot: unknown } + | { + id: number; + kind: "set_snapshot"; + snapshot: unknown; + schemaBundle: unknown; + } | { id: number; kind: "apply_changes"; changes: ConfigTreeChange[] } | { id: number; kind: "changes_between"; base: unknown; candidate: unknown } | { id: number; kind: "analyze"; path: string; source?: string } | { id: number; kind: "evaluate"; contract: unknown } - | { id: number; kind: "complete"; path: string; source: string; utf16Offset: number; explicit: boolean } + | { + id: number; + kind: "complete"; + path: string; + source: string; + utf16Offset: number; + explicit: boolean; + } | { id: number; kind: "format"; source: string }; export type ConfigSourceWorkerResponse = @@ -25,7 +38,9 @@ export type ConfigSourceWorkerResponse = const ready = init(); let snapshot: unknown = null; -self.onmessage = async (event: MessageEvent): Promise => { +self.onmessage = async ( + event: MessageEvent, +): Promise => { const request = event.data; try { await ready; @@ -34,6 +49,7 @@ self.onmessage = async (event: MessageEvent): Promise case "set_snapshot": snapshot = request.snapshot; set_snapshot(request.snapshot); + set_schema_bundle(request.schemaBundle); result = null; break; case "apply_changes": @@ -44,14 +60,21 @@ self.onmessage = async (event: MessageEvent): Promise result = changes_between(request.base, request.candidate); break; case "analyze": - if (!snapshot) throw new Error("config source snapshot is not initialized"); + if (!snapshot) { + throw new Error("config source snapshot is not initialized"); + } result = analyze_snapshot(snapshot, request.path, request.source); break; case "evaluate": result = evaluate_current(request.contract); break; case "complete": - result = complete_current(request.path, request.source, request.utf16Offset, request.explicit); + result = complete_current( + request.path, + request.source, + request.utf16Offset, + request.explicit, + ); break; case "format": result = format_source(request.source); @@ -59,6 +82,10 @@ self.onmessage = async (event: MessageEvent): Promise } self.postMessage({ id: request.id, ok: true, result }); } catch (error) { - self.postMessage({ id: request.id, ok: false, error: error instanceof Error ? error.message : error }); + self.postMessage({ + id: request.id, + ok: false, + error: error instanceof Error ? error.message : error, + }); } }; diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 7199b611..0e7c53da 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -459,11 +459,11 @@ Deno.test("Decodal source editor keeps imperative EditorView out of reactive sta ); assert( - editor.includes("let view: EditorView | null = null") && - !editor.includes("$state(null)") && + editor.includes("untrack(() => view)") && editor.includes("untrack(() => value)") && editor.includes("untrack(() => onChange)"), - "CodeMirror EditorView must not be reactive state; otherwise mount cleanup can loop forever", + "CodeMirror EditorView must not be deep reactive or tracked by the mount effect; otherwise cleanup can loop forever", ); }); diff --git a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte index 20e58e61..4f4b5667 100644 --- a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte @@ -1,8 +1,10 @@