web: repair config source editor

This commit is contained in:
2026-08-14 23:28:14 +09:00
parent 1ed835fb79
commit 83a99541b2
18 changed files with 876 additions and 54 deletions
+20 -3
View File
@@ -8,6 +8,7 @@ use wasm_bindgen::prelude::*;
thread_local! { thread_local! {
static SESSION: RefCell<Option<ConfigTreeSnapshot>> = const { RefCell::new(None) }; static SESSION: RefCell<Option<ConfigTreeSnapshot>> = const { RefCell::new(None) };
static SCHEMA_BUNDLE: RefCell<Option<WorkspaceConfigSchemaBundle>> = const { RefCell::new(None) };
} }
#[wasm_bindgen] #[wasm_bindgen]
@@ -23,6 +24,13 @@ pub fn set_snapshot(snapshot: JsValue) -> Result<(), JsValue> {
Ok(()) 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] #[wasm_bindgen]
pub fn apply_changes(changes: JsValue) -> Result<JsValue, JsValue> { pub fn apply_changes(changes: JsValue) -> Result<JsValue, JsValue> {
let changes: Vec<ConfigTreeChange> = decode(changes)?; let changes: Vec<ConfigTreeChange> = decode(changes)?;
@@ -91,8 +99,8 @@ pub fn complete_current(
.as_ref() .as_ref()
.ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?; .ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?;
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?; let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
let result = SnapshotEnvironment::new(snapshot.clone()) let result = session_environment(snapshot.clone())
.complete(&entrypoint, &source, utf8_byte_offset, explicit) .complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
.map_err(|error| JsValue::from_str(&format!("{error:?}")))? .map_err(|error| JsValue::from_str(&format!("{error:?}")))?
.map(|result| WasmCompletionResult { .map(|result| WasmCompletionResult {
from: result.from, from: result.from,
@@ -132,7 +140,16 @@ pub fn analyze_snapshot(
) -> Result<JsValue, JsValue> { ) -> Result<JsValue, JsValue> {
let snapshot: ConfigTreeSnapshot = decode(snapshot)?; let snapshot: ConfigTreeSnapshot = decode(snapshot)?;
let entrypoint = VirtualPath::parse(entrypoint).map_err(js_error)?; 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] #[wasm_bindgen]
+359 -3
View File
@@ -3,7 +3,7 @@ use std::fmt;
use decodal::{ use decodal::{
Data, Diagnostic, DiagnosticKind, Engine, HostEnvironment, ImportCandidate, ImportLoader, Data, Diagnostic, DiagnosticKind, Engine, HostEnvironment, ImportCandidate, ImportLoader,
LoadedImport, Span, Value, LoadedImport, Span, SyntaxToken, SyntaxTokenKind, Value, tokenize_source,
}; };
use decodal_language_service::{CompletionResult, LanguageService}; use decodal_language_service::{CompletionResult, LanguageService};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -752,14 +752,249 @@ pub struct EvaluationResult {
pub projection_digest: String, pub projection_digest: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConfigFieldCompletionContext {
schema_path: Vec<String>,
from: usize,
}
#[derive(Debug)]
enum ConfigCompletionContainer {
Object {
schema_path: Vec<String>,
pending_path: Vec<String>,
last_identifier_from: Option<usize>,
trailing_dot: bool,
reading_value: bool,
},
Array {
schema_path: Vec<String>,
},
Other {
schema_path: Vec<String>,
},
}
fn workspace_schema_assertion_object_start(
tokens: &[SyntaxToken],
utf8_byte_offset: usize,
) -> Option<usize> {
let mut object_stack = Vec::<usize>::new();
let mut matching_assertions = Vec::<usize>::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<ConfigFieldCompletionContext> {
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::<ConfigCompletionContainer>::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<String> {
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<ConfigCompletionContainer>,
matches: impl Fn(&ConfigCompletionContainer) -> bool,
) {
if let Some(index) = containers.iter().rposition(matches) {
containers.truncate(index);
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SnapshotEnvironment { pub struct SnapshotEnvironment {
snapshot: ConfigTreeSnapshot, snapshot: ConfigTreeSnapshot,
schema_bundle: Option<WorkspaceConfigSchemaBundle>,
} }
impl SnapshotEnvironment { impl SnapshotEnvironment {
pub fn new(snapshot: ConfigTreeSnapshot) -> Self { 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 { pub fn snapshot(&self) -> &ConfigTreeSnapshot {
@@ -954,6 +1189,32 @@ impl SnapshotEnvironment {
LanguageService::new(self).complete(entrypoint.as_str(), source, utf8_byte_offset, explicit) 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<Option<CompletionResult>> {
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<String, String> { pub fn format(&self, source: &str) -> Result<String, String> {
decodal_language_tools::format_source(source).map_err(|error| error.to_string()) 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<Self::Loader>) -> decodal::Result<()> { fn configure_engine(&self, engine: &mut Engine<Self::Loader>) -> 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(()) Ok(())
} }
} }
@@ -1576,6 +1847,91 @@ mod tests {
assert_eq!(left.digest, right.digest); 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] #[test]
fn relative_imports_and_completion_share_the_snapshot_namespace() { fn relative_imports_and_completion_share_the_snapshot_namespace() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries(
+3 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "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", "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", "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", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -16,8 +16,10 @@
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0", "@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/state": "npm:@codemirror/state@6.7.1",
"@codemirror/view": "npm:@codemirror/view@6.43.8", "@codemirror/view": "npm:@codemirror/view@6.43.8",
"@lezer/highlight": "npm:@lezer/highlight@1.2.3",
"decodal-codemirror": "npm:decodal-codemirror@0.3.0", "decodal-codemirror": "npm:decodal-codemirror@0.3.0",
"clsx": "npm:clsx@2.1.1", "clsx": "npm:clsx@2.1.1",
"cookie": "npm:cookie@0.6.0", "cookie": "npm:cookie@0.6.0",
+6 -1
View File
@@ -4,9 +4,11 @@
"jsr:@std/assert@*": "1.0.19", "jsr:@std/assert@*": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.12": "1.0.14",
"npm:@codemirror/autocomplete@6.20.0": "6.20.0", "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/state@6.7.1": "6.7.1",
"npm:@codemirror/view@6.43.8": "6.43.8", "npm:@codemirror/view@6.43.8": "6.43.8",
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0", "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/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/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", "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==" "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="
}, },
"@ungap/structured-clone@1.3.0": { "@ungap/structured-clone@1.3.0": {
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"deprecated": true
}, },
"acorn@8.16.0": { "acorn@8.16.0": {
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
@@ -1000,8 +1003,10 @@
"workspace": { "workspace": {
"dependencies": [ "dependencies": [
"npm:@codemirror/autocomplete@6.20.0", "npm:@codemirror/autocomplete@6.20.0",
"npm:@codemirror/language@6.12.4",
"npm:@codemirror/state@6.7.1", "npm:@codemirror/state@6.7.1",
"npm:@codemirror/view@6.43.8", "npm:@codemirror/view@6.43.8",
"npm:@lezer/highlight@1.2.3",
"npm:@sveltejs/adapter-static@3.0.9", "npm:@sveltejs/adapter-static@3.0.9",
"npm:@sveltejs/kit@2.49.4", "npm:@sveltejs/kit@2.49.4",
"npm:@sveltejs/vite-plugin-svelte@6.2.1", "npm:@sveltejs/vite-plugin-svelte@6.2.1",
@@ -27,7 +27,7 @@
let baseRevision = $state(0); let baseRevision = $state(0);
let baseDigest = $state(""); let baseDigest = $state("");
let renamePath = $state(""); let renamePath = $state("");
let baseSnapshot = $state<WorkspaceConfigTreeResponse["snapshot"] | null>(null); let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
let preflightDigest = $state(""); let preflightDigest = $state("");
let conflict = $state(false); let conflict = $state(false);
let candidateContract = $state<WorkspaceConfigTreeResponse["contract"] | null>(null); let candidateContract = $state<WorkspaceConfigTreeResponse["contract"] | null>(null);
@@ -56,10 +56,10 @@
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? ""; selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
} }
source = selectedPath ? treeState.snapshot.entries[selectedPath].content : ""; source = selectedPath ? treeState.snapshot.entries[selectedPath].content : "";
baseSnapshot = structuredClone(treeState.snapshot); baseSnapshot = $state.snapshot(treeState.snapshot);
baseRevision = treeState.snapshot.revision; baseRevision = treeState.snapshot.revision;
baseDigest = treeState.snapshot.digest; baseDigest = treeState.snapshot.digest;
await toolchain?.setSnapshot(treeState.snapshot); await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
draftChanges = []; draftChanges = [];
renamePath = selectedPath; renamePath = selectedPath;
diagnostics = []; diagnostics = [];
@@ -181,10 +181,10 @@
preflightDigest = ""; preflightDigest = "";
candidateContract = null; candidateContract = null;
conflict = false; conflict = false;
baseSnapshot = structuredClone(treeState.snapshot); baseSnapshot = $state.snapshot(treeState.snapshot);
baseRevision = treeState.snapshot.revision; baseRevision = treeState.snapshot.revision;
baseDigest = treeState.snapshot.digest; baseDigest = treeState.snapshot.digest;
await toolchain?.setSnapshot(treeState.snapshot); await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
source = treeState.snapshot.entries[selectedPath]?.content ?? ""; source = treeState.snapshot.entries[selectedPath]?.content ?? "";
diagnostics = []; diagnostics = [];
status = `Committed revision ${treeState.snapshot.revision}.`; status = `Committed revision ${treeState.snapshot.revision}.`;
@@ -210,7 +210,7 @@
baseSnapshot = structuredClone(remote.snapshot); baseSnapshot = structuredClone(remote.snapshot);
baseRevision = remote.snapshot.revision; baseRevision = remote.snapshot.revision;
baseDigest = remote.snapshot.digest; baseDigest = remote.snapshot.digest;
await toolchain.setSnapshot(remote.snapshot); await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
try { try {
const candidate = await toolchain.applyChanges(localChanges); const candidate = await toolchain.applyChanges(localChanges);
draftChanges = localChanges; draftChanges = localChanges;
@@ -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");
}
@@ -19,6 +19,8 @@ export function formatSource(source: string): string;
export function format_source(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 function set_snapshot(snapshot: any): void;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; 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_current: (a: any) => [number, number, number];
readonly evaluate_snapshot: (a: any, b: 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 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 set_snapshot: (a: any) => [number, number];
readonly formatSource: (a: number, b: number) => [number, number]; readonly formatSource: (a: number, b: number) => [number, number];
readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_malloc: (a: number, b: number) => number;
@@ -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 * @param {any} snapshot
*/ */
@@ -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_current: (a: any) => [number, number, number];
export const evaluate_snapshot: (a: any, b: 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 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 set_snapshot: (a: any) => [number, number];
export const formatSource: (a: number, b: number) => [number, number]; export const formatSource: (a: number, b: number) => [number, number];
export const __wbindgen_malloc: (a: number, b: number) => number; export const __wbindgen_malloc: (a: number, b: number) => number;
@@ -0,0 +1,3 @@
export function jsonWorkerMessage<T>(request: T): T {
return JSON.parse(JSON.stringify(request)) as T;
}
@@ -1,5 +1,19 @@
import type { ConfigDiagnostic, ConfigTreeChange, ConfigTreeSnapshot, ToolchainContract } from "./types.ts"; import type {
import type { ConfigSourceWorkerRequest, ConfigSourceWorkerResponse } from "./toolchain.worker.ts"; 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 = type Command =
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "set_snapshot" }>, "id"> | Omit<Extract<ConfigSourceWorkerRequest, { kind: "set_snapshot" }>, "id">
@@ -13,26 +27,42 @@ type Command =
export class ConfigSourceToolchain { export class ConfigSourceToolchain {
#worker: Worker; #worker: Worker;
#nextId = 1; #nextId = 1;
#pending = new Map<number, { resolve: (value: unknown) => 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; this.#worker = worker;
worker.addEventListener("message", (event: MessageEvent<ConfigSourceWorkerResponse>) => { worker.addEventListener(
"message",
(event: MessageEvent<ConfigSourceWorkerResponse>) => {
const pending = this.#pending.get(event.data.id); const pending = this.#pending.get(event.data.id);
if (!pending) return; if (!pending) return;
this.#pending.delete(event.data.id); this.#pending.delete(event.data.id);
if (event.data.ok) pending.resolve(event.data.result); if (event.data.ok) pending.resolve(event.data.result);
else pending.reject(event.data.error); else pending.reject(event.data.error);
}); },
);
} }
setSnapshot(snapshot: ConfigTreeSnapshot): Promise<void> { setSnapshot(
return this.#request({ kind: "set_snapshot", snapshot }); snapshot: ConfigTreeSnapshot,
schemaBundle: WorkspaceConfigSchemaBundle,
): Promise<void> {
return this.#request({ kind: "set_snapshot", snapshot, schemaBundle });
} }
applyChanges(changes: ConfigTreeChange[]): Promise<ConfigTreeSnapshot> { applyChanges(changes: ConfigTreeChange[]): Promise<ConfigTreeSnapshot> {
return this.#request({ kind: "apply_changes", changes }); return this.#request({ kind: "apply_changes", changes });
} }
changesBetween(base: ConfigTreeSnapshot, candidate: ConfigTreeSnapshot): Promise<ConfigTreeChange[]> { changesBetween(
base: ConfigTreeSnapshot,
candidate: ConfigTreeSnapshot,
): Promise<ConfigTreeChange[]> {
return this.#request({ kind: "changes_between", base, candidate }); return this.#request({ kind: "changes_between", base, candidate });
} }
analyze(path: string, source?: string): Promise<ConfigDiagnostic[]> { analyze(path: string, source?: string): Promise<ConfigDiagnostic[]> {
@@ -41,22 +71,40 @@ export class ConfigSourceToolchain {
evaluate(contract: ToolchainContract) { evaluate(contract: ToolchainContract) {
return this.#request({ kind: "evaluate", contract }); return this.#request({ kind: "evaluate", contract });
} }
complete(path: string, source: string, utf16Offset: number, explicit = false): Promise<import("@codemirror/autocomplete").CompletionResult | null> { async complete(
return this.#request({ kind: "complete", path, source, utf16Offset, explicit }); path: string,
source: string,
utf16Offset: number,
explicit = false,
): Promise<import("@codemirror/autocomplete").CompletionResult | null> {
const result = await this.#request<ConfigSourceCompletionResult | null>({
kind: "complete",
path,
source,
utf16Offset,
explicit,
});
return toCodeMirrorCompletion(source, result);
} }
format(source: string): Promise<string> { format(source: string): Promise<string> {
return this.#request({ kind: "format", source }); return this.#request({ kind: "format", source });
} }
close(): void { close(): void {
this.#worker.terminate(); 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(); this.#pending.clear();
} }
#request<T>(request: Command): Promise<T> { #request<T>(request: Command): Promise<T> {
const id = this.#nextId++; const id = this.#nextId++;
const message = jsonWorkerMessage({ ...request, id });
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
this.#pending.set(id, { resolve: (value) => resolve(value as T), reject }); this.#pending.set(id, {
this.#worker.postMessage({ ...request, id }); resolve: (value) => resolve(value as T),
reject,
});
this.#worker.postMessage(message);
}); });
} }
} }
@@ -5,17 +5,30 @@ import init, {
complete_current, complete_current,
evaluate_current, evaluate_current,
format_source, format_source,
set_schema_bundle,
set_snapshot, set_snapshot,
} from "./generated/config_source_wasm.js"; } from "./generated/config_source_wasm.js";
import type { ConfigTreeChange } from "./types.ts"; import type { ConfigTreeChange } from "./types.ts";
export type ConfigSourceWorkerRequest = 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: "apply_changes"; changes: ConfigTreeChange[] }
| { id: number; kind: "changes_between"; base: unknown; candidate: unknown } | { id: number; kind: "changes_between"; base: unknown; candidate: unknown }
| { id: number; kind: "analyze"; path: string; source?: string } | { id: number; kind: "analyze"; path: string; source?: string }
| { id: number; kind: "evaluate"; contract: unknown } | { 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 }; | { id: number; kind: "format"; source: string };
export type ConfigSourceWorkerResponse = export type ConfigSourceWorkerResponse =
@@ -25,7 +38,9 @@ export type ConfigSourceWorkerResponse =
const ready = init(); const ready = init();
let snapshot: unknown = null; let snapshot: unknown = null;
self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise<void> => { self.onmessage = async (
event: MessageEvent<ConfigSourceWorkerRequest>,
): Promise<void> => {
const request = event.data; const request = event.data;
try { try {
await ready; await ready;
@@ -34,6 +49,7 @@ self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise
case "set_snapshot": case "set_snapshot":
snapshot = request.snapshot; snapshot = request.snapshot;
set_snapshot(request.snapshot); set_snapshot(request.snapshot);
set_schema_bundle(request.schemaBundle);
result = null; result = null;
break; break;
case "apply_changes": case "apply_changes":
@@ -44,14 +60,21 @@ self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise
result = changes_between(request.base, request.candidate); result = changes_between(request.base, request.candidate);
break; break;
case "analyze": 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); result = analyze_snapshot(snapshot, request.path, request.source);
break; break;
case "evaluate": case "evaluate":
result = evaluate_current(request.contract); result = evaluate_current(request.contract);
break; break;
case "complete": 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; break;
case "format": case "format":
result = format_source(request.source); result = format_source(request.source);
@@ -59,6 +82,10 @@ self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise
} }
self.postMessage({ id: request.id, ok: true, result }); self.postMessage({ id: request.id, ok: true, result });
} catch (error) { } 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,
});
} }
}; };
@@ -459,11 +459,11 @@ Deno.test("Decodal source editor keeps imperative EditorView out of reactive sta
); );
assert( assert(
editor.includes("let view: EditorView | null = null") && editor.includes("let view = $state.raw<EditorView | null>(null)") &&
!editor.includes("$state<EditorView") && editor.includes("untrack(() => view)") &&
editor.includes("untrack(() => value)") && editor.includes("untrack(() => value)") &&
editor.includes("untrack(() => onChange)"), 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",
); );
}); });
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'; import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state'; import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'; import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
import { tags } from '@lezer/highlight';
import { decodal } from 'decodal-codemirror'; import { decodal } from 'decodal-codemirror';
let { let {
@@ -20,25 +22,43 @@
} = $props(); } = $props();
let host = $state<HTMLDivElement | null>(null); let host = $state<HTMLDivElement | null>(null);
let view: EditorView | null = null; let view = $state.raw<EditorView | null>(null);
const readonlyCompartment = new Compartment();
const syntaxTheme = HighlightStyle.define([
{ tag: tags.keyword, color: 'var(--accent)', fontWeight: '700' },
{ tag: tags.variableName, color: 'var(--code)' },
{ tag: [tags.bool, tags.number], color: 'var(--warning)' },
{ tag: [tags.string, tags.regexp], color: 'var(--success)' },
{ tag: tags.lineComment, color: 'var(--text-muted)', fontStyle: 'italic' },
{ tag: tags.operator, color: 'var(--danger)' },
{ tag: [tags.brace, tags.squareBracket, tags.paren, tags.punctuation], color: 'var(--accent-muted)' },
]);
const theme = EditorView.theme({ const theme = EditorView.theme({
'&': { '&': {
border: '1px solid var(--border-subtle)', border: '1px solid var(--line)',
borderRadius: '0.75rem', borderRadius: '0.75rem',
minHeight: '24rem', minHeight: '24rem',
background: 'var(--surface-2)', background: 'var(--bg-raised)',
color: 'var(--text-primary)', color: 'var(--text)',
fontSize: '0.9rem', fontSize: '0.9rem',
}, },
'&.cm-focused': { outline: '1px solid var(--accent-muted)', outlineOffset: '-1px' },
'.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' }, '.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' },
'.cm-content': { padding: '0.75rem 0' }, '.cm-content': { padding: '0.75rem 0', caretColor: 'var(--text-strong)' },
'.cm-gutters': { background: 'var(--surface-2)', color: 'var(--text-muted)' }, '.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--text-strong)', borderLeftWidth: '2px' },
'.cm-activeLine': { backgroundColor: 'rgba(125, 211, 252, 0.08)' }, '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'var(--interactive-selected)',
},
'.cm-gutters': { background: 'var(--bg-raised)', color: 'var(--text-muted)', borderColor: 'var(--line)' },
'.cm-activeLine, .cm-activeLineGutter': { backgroundColor: 'var(--interactive-hover)' },
'.cm-tooltip': { background: 'var(--bg-raised)', color: 'var(--text)', borderColor: 'var(--line-strong)' },
'.cm-tooltip-autocomplete > ul > li[aria-selected]': { background: 'var(--interactive-selected)', color: 'var(--text-strong)' },
}); });
$effect(() => { $effect(() => {
if (!host || view) return; if (!host || untrack(() => view)) return;
const initialValue = untrack(() => value); const initialValue = untrack(() => value);
const initialReadonly = untrack(() => readonly); const initialReadonly = untrack(() => readonly);
const handleChange = untrack(() => onChange); const handleChange = untrack(() => onChange);
@@ -51,14 +71,17 @@
lineNumbers(), lineNumbers(),
drawSelection(), drawSelection(),
highlightActiveLine(), highlightActiveLine(),
decodal(), decodal({ highlight: false }),
syntaxHighlighting(syntaxTheme),
...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => { ...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => {
const doc = context.state.doc.toString(); const doc = context.state.doc.toString();
return await handleComplete(doc, context.pos, context.explicit); return await handleComplete(doc, context.pos, context.explicit);
}] })] : []), }] })] : []),
keymap.of([]), keymap.of([]),
readonlyCompartment.of([
EditorState.readOnly.of(initialReadonly), EditorState.readOnly.of(initialReadonly),
EditorView.editable.of(!initialReadonly), EditorView.editable.of(!initialReadonly),
]),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged) handleChange(update.state.doc.toString()); if (update.docChanged) handleChange(update.state.doc.toString());
}), }),
@@ -73,6 +96,18 @@
}; };
}); });
$effect(() => {
const editor = view;
const nextReadonly = readonly;
if (!editor) return;
editor.dispatch({
effects: readonlyCompartment.reconfigure([
EditorState.readOnly.of(nextReadonly),
EditorView.editable.of(!nextReadonly),
]),
});
});
$effect(() => { $effect(() => {
if (!view) return; if (!view) return;
const current = view.state.doc.toString(); const current = view.state.doc.toString();
@@ -0,0 +1,75 @@
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
readTextFile(path: URL): Promise<string>;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("config editor snapshots Svelte proxies before cloning baselines", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
import.meta.url,
),
);
assert(
source.includes("$state.raw<WorkspaceConfigTreeResponse"),
"the immutable config baseline should not be wrapped in another deep proxy",
);
assert(
source.includes("$state.snapshot(treeState.snapshot)"),
"reactive tree snapshots should be converted to plain values before reuse",
);
assert(
!source.includes("structuredClone(treeState.snapshot)"),
"structuredClone must never receive a Svelte state proxy",
);
assert(
source.includes("toolchain?.setSnapshot(") &&
source.includes("treeState.contract.schema_bundle") &&
source.includes("remote.contract.schema_bundle"),
"the authoritative schema bundle should be installed with every snapshot",
);
});
Deno.test("Decodal editor follows readonly prop changes after mount", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/settings/DecodalSourceEditor.svelte",
import.meta.url,
),
);
assert(
source.includes("let view = $state.raw<EditorView | null>(null)") &&
source.includes("if (!host || untrack(() => view)) return"),
"imperative EditorView should avoid deep proxying and remain outside the mount effect dependency graph",
);
assert(
source.includes("new Compartment()") &&
source.includes("readonlyCompartment.reconfigure") &&
source.includes("EditorView.editable.of(!nextReadonly)"),
"readonly and editable facets should be reconfigured when the prop changes",
);
assert(
source.includes("syntaxHighlighting(syntaxTheme)") &&
source.includes("tags.keyword") &&
source.includes("tags.string"),
"Decodal tokens should use an explicit syntax theme",
);
assert(
source.includes(".cm-cursor, .cm-dropCursor") &&
source.includes(".cm-selectionBackground") &&
source.includes("var(--interactive-selected)"),
"cursor and selection should be visible against the workspace theme",
);
assert(
!source.includes("--surface-2") &&
!source.includes("--text-primary") &&
!source.includes("--border-subtle"),
"CodeMirror theme must use workspace tokens that actually exist",
);
});
@@ -0,0 +1,109 @@
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
readTextFile(path: URL): Promise<string>;
};
import { toCodeMirrorCompletion } from "../../src/lib/workspace/config-source/completion.ts";
import { jsonWorkerMessage } from "../../src/lib/workspace/config-source/toolchain-message.ts";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("toolchain converts reactive-like proxies to plain Worker messages", async () => {
const snapshot = new Proxy(
{
revision: 1,
digest: "sha256:test",
entries: {},
},
{},
);
const request = {
id: 1,
kind: "set_snapshot" as const,
snapshot,
};
let cloneRejected = false;
try {
structuredClone(request);
} catch (error) {
cloneRejected = error instanceof DOMException &&
error.name === "DataCloneError";
}
assert(
cloneRejected,
"fixture should reproduce the Worker postMessage clone failure",
);
const message = jsonWorkerMessage(request);
assert(
message.snapshot !== snapshot,
"snapshot should be detached from the Proxy",
);
structuredClone(message);
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/config-source/toolchain.ts",
import.meta.url,
),
);
assert(
source.includes("const message = jsonWorkerMessage({ ...request, id })") &&
source.includes("this.#worker.postMessage(message)"),
"ConfigSourceToolchain should normalize every command at the Worker boundary",
);
const workerSource = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/config-source/toolchain.worker.ts",
import.meta.url,
),
);
assert(
workerSource.includes("set_schema_bundle(request.schemaBundle)"),
"Config Source worker should install the WorkspaceConfigSchema global contract",
);
});
Deno.test("toolchain adapts WASM completion items and byte offsets for CodeMirror", () => {
const source = "let 名 = tru";
const result = toCodeMirrorCompletion(source, {
from: new TextEncoder().encode("let 名 = ").length,
items: [
{
label: "true",
kind: "constant",
detail: "Bool",
priority: 20,
},
],
});
assert(result !== null, "WASM completion should produce a CodeMirror result");
assert(
result.from === "let 名 = ".length,
"byte offsets should become UTF-16 offsets",
);
assert(
result.options.length === 1,
"WASM items should become CodeMirror options",
);
assert(
result.options[0].label === "true",
"completion label should be preserved",
);
assert(
result.options[0].type === "constant",
"completion kind should become the icon type",
);
assert(
result.options[0].detail === "Bool",
"completion detail should be preserved",
);
assert(
result.options[0].boost === 20,
"completion priority should become its boost",
);
});
@@ -3,8 +3,11 @@
import { assertEquals } from "jsr:@std/assert"; import { assertEquals } from "jsr:@std/assert";
import init, { import init, {
analyze_snapshot, analyze_snapshot,
complete_current,
compose_schema_bundle, compose_schema_bundle,
evaluate_snapshot, evaluate_snapshot,
set_schema_bundle,
set_snapshot,
} from "../../src/lib/workspace/config-source/generated/config_source_wasm.js"; } from "../../src/lib/workspace/config-source/generated/config_source_wasm.js";
import type { import type {
ConfigTreeSnapshot, ConfigTreeSnapshot,
@@ -241,3 +244,74 @@ Deno.test("generated WASM preserves native Decodal 0.4 diagnostic semantics", ()
assertEquals(diagnostic.span.end_byte > diagnostic.span.start_byte, true); assertEquals(diagnostic.span.end_byte > diagnostic.span.start_byte, true);
} }
}); });
Deno.test("generated WASM returns completion items for the editor adapter", () => {
const source = 'import "./"';
set_snapshot({
...snapshot,
entries: {
...snapshot.entries,
"workspace.dcdl": {
...snapshot.entries["workspace.dcdl"],
content: source,
},
},
});
const result = complete_current(
"workspace.dcdl",
source,
source.length - 1,
true,
) as {
from: number;
items: Array<{ label: string; kind: string }>;
};
assertEquals(result.from, 8);
assertEquals(result.items[0].label, "./lib/value.dcdl");
assertEquals(result.items[0].kind, "file");
});
Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () => {
const bareSource = "{ pro }";
const source = "{ pro } as WorkspaceConfigSchema";
const cursor = source.indexOf("pro") + 3;
set_snapshot({
...snapshot,
entries: {
...snapshot.entries,
"workspace.dcdl": {
...snapshot.entries["workspace.dcdl"],
content: source,
},
},
});
set_schema_bundle({
contributions: [],
source: "{ profile = { default_profile = String; }; prompts = {}; }",
fingerprint: "sha256:test-schema",
});
const bare = complete_current(
"workspace.dcdl",
bareSource,
bareSource.indexOf("pro") + 3,
true,
) as { items: Array<{ label: string }> } | null;
assertEquals(
bare?.items.some((item) => item.label === "profile") ?? false,
false,
);
const result = complete_current(
"workspace.dcdl",
source,
cursor,
true,
) as {
from: number;
items: Array<{ label: string; kind: string }>;
};
assertEquals(result.from, 2);
assertEquals(result.items.some((item) => item.label === "profile"), true);
});