web: repair config source editor
This commit is contained in:
@@ -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]
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+6
-1
@@ -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");
|
||||||
|
}
|
||||||
+3
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Binary file not shown.
+1
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user