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(