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! {
static SESSION: RefCell<Option<ConfigTreeSnapshot>> = const { RefCell::new(None) };
static SCHEMA_BUNDLE: RefCell<Option<WorkspaceConfigSchemaBundle>> = 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<JsValue, JsValue> {
let changes: Vec<ConfigTreeChange> = 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<JsValue, JsValue> {
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]
+359 -3
View File
@@ -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<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)]
pub struct SnapshotEnvironment {
snapshot: ConfigTreeSnapshot,
schema_bundle: Option<WorkspaceConfigSchemaBundle>,
}
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<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> {
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(())
}
}
@@ -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(
+3 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
@@ -27,7 +27,7 @@
let baseRevision = $state(0);
let baseDigest = $state("");
let renamePath = $state("");
let baseSnapshot = $state<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
let preflightDigest = $state("");
let conflict = $state(false);
let candidateContract = $state<WorkspaceConfigTreeResponse["contract"] | null>(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;
@@ -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 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;
@@ -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
*/
@@ -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;
@@ -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 { 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<Extract<ConfigSourceWorkerRequest, { kind: "set_snapshot" }>, "id">
@@ -13,26 +27,42 @@ type Command =
export class ConfigSourceToolchain {
#worker: Worker;
#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;
worker.addEventListener("message", (event: MessageEvent<ConfigSourceWorkerResponse>) => {
worker.addEventListener(
"message",
(event: MessageEvent<ConfigSourceWorkerResponse>) => {
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<void> {
return this.#request({ kind: "set_snapshot", snapshot });
setSnapshot(
snapshot: ConfigTreeSnapshot,
schemaBundle: WorkspaceConfigSchemaBundle,
): Promise<void> {
return this.#request({ kind: "set_snapshot", snapshot, schemaBundle });
}
applyChanges(changes: ConfigTreeChange[]): Promise<ConfigTreeSnapshot> {
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 });
}
analyze(path: string, source?: string): Promise<ConfigDiagnostic[]> {
@@ -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<import("@codemirror/autocomplete").CompletionResult | null> {
return this.#request({ kind: "complete", path, source, utf16Offset, explicit });
async complete(
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> {
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<T>(request: Command): Promise<T> {
const id = this.#nextId++;
const message = jsonWorkerMessage({ ...request, id });
return new Promise<T>((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);
});
}
}
@@ -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<ConfigSourceWorkerRequest>): Promise<void> => {
self.onmessage = async (
event: MessageEvent<ConfigSourceWorkerRequest>,
): Promise<void> => {
const request = event.data;
try {
await ready;
@@ -34,6 +49,7 @@ self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): 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<ConfigSourceWorkerRequest>): 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<ConfigSourceWorkerRequest>): 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,
});
}
};
@@ -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<EditorView") &&
editor.includes("let view = $state.raw<EditorView | null>(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",
);
});
@@ -1,8 +1,10 @@
<script lang="ts">
import { untrack } from 'svelte';
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 { tags } from '@lezer/highlight';
import { decodal } from 'decodal-codemirror';
let {
@@ -20,25 +22,43 @@
} = $props();
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({
'&': {
border: '1px solid var(--border-subtle)',
border: '1px solid var(--line)',
borderRadius: '0.75rem',
minHeight: '24rem',
background: 'var(--surface-2)',
color: 'var(--text-primary)',
background: 'var(--bg-raised)',
color: 'var(--text)',
fontSize: '0.9rem',
},
'&.cm-focused': { outline: '1px solid var(--accent-muted)', outlineOffset: '-1px' },
'.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' },
'.cm-content': { padding: '0.75rem 0' },
'.cm-gutters': { background: 'var(--surface-2)', color: 'var(--text-muted)' },
'.cm-activeLine': { backgroundColor: 'rgba(125, 211, 252, 0.08)' },
'.cm-content': { padding: '0.75rem 0', caretColor: 'var(--text-strong)' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--text-strong)', borderLeftWidth: '2px' },
'&.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(() => {
if (!host || view) return;
if (!host || untrack(() => view)) return;
const initialValue = untrack(() => value);
const initialReadonly = untrack(() => readonly);
const handleChange = untrack(() => onChange);
@@ -51,14 +71,17 @@
lineNumbers(),
drawSelection(),
highlightActiveLine(),
decodal(),
decodal({ highlight: false }),
syntaxHighlighting(syntaxTheme),
...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => {
const doc = context.state.doc.toString();
return await handleComplete(doc, context.pos, context.explicit);
}] })] : []),
keymap.of([]),
readonlyCompartment.of([
EditorState.readOnly.of(initialReadonly),
EditorView.editable.of(!initialReadonly),
]),
EditorView.updateListener.of((update) => {
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(() => {
if (!view) return;
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 init, {
analyze_snapshot,
complete_current,
compose_schema_bundle,
evaluate_snapshot,
set_schema_bundle,
set_snapshot,
} from "../../src/lib/workspace/config-source/generated/config_source_wasm.js";
import type {
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);
}
});
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);
});