diff --git a/Cargo.lock b/Cargo.lock index 97d6350..00aeb18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,9 @@ name = "decodal-wasm" version = "0.1.2" dependencies = [ "decodal", + "decodal-language-service", + "js-sys", + "serde-wasm-bindgen", "serde_json", "wasm-bindgen", ] @@ -133,12 +136,47 @@ dependencies = [ "bitflags", ] +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "log" version = "0.4.33" @@ -183,6 +221,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -246,6 +290,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -296,6 +351,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "syn" version = "2.0.117" diff --git a/crates/decodal-core/src/eval.rs b/crates/decodal-core/src/eval.rs index aadd4b1..ece0cc6 100644 --- a/crates/decodal-core/src/eval.rs +++ b/crates/decodal-core/src/eval.rs @@ -107,6 +107,42 @@ impl Engine { thunk } + /// Returns the values installed in the host-owned global environment. + /// + /// Language tooling uses this after [`crate::HostEnvironment`] configures a + /// fresh engine, so editor-visible globals cannot drift from evaluation. + pub fn global_values(&mut self) -> Result> { + let bindings = self.envs[self.prelude_env.0 as usize].bindings.clone(); + bindings + .into_iter() + .map(|binding| Ok((binding.name, self.force(binding.value)?))) + .collect() + } + + /// Returns the immediately visible fields of an object runtime value. + pub fn value_fields( + &mut self, + value: &RuntimeValue, + ) -> Result>> { + let object = match value { + RuntimeValue::Concrete(crate::runtime::ConcreteValue::Object(object)) => object, + RuntimeValue::Abstract(abstract_value) => { + let Some(default) = abstract_value.default else { + return Ok(None); + }; + let default = self.force(default)?; + return self.value_fields(&default); + } + _ => return Ok(None), + }; + let fields = object.fields.clone(); + fields + .into_iter() + .map(|field| Ok((field.name, self.force(field.value)?))) + .collect::>>() + .map(Some) + } + pub fn add_root_source( &mut self, key: impl Into, diff --git a/crates/decodal-core/src/lib.rs b/crates/decodal-core/src/lib.rs index 9d50786..e0dec7a 100644 --- a/crates/decodal-core/src/lib.rs +++ b/crates/decodal-core/src/lib.rs @@ -23,7 +23,9 @@ pub use diagnostic::{Diagnostic, DiagnosticKind, Result}; pub use embedding::{HostField, HostValue}; pub use environment::HostEnvironment; pub use eval::{Engine, format_diagnostic_with}; -pub use module::{EmptyLoader, ImportLoader, LoadedImport, LoadedSource, LoadedValue, Module}; +pub use module::{ + EmptyLoader, ImportCandidate, ImportLoader, LoadedImport, LoadedSource, LoadedValue, Module, +}; pub use parser::{ParseOutput, Parser, SourceForm, parse_source, parse_source_with_source_id}; pub use runtime::{Constraint, Data, ExprRef, LiteralValue, ModuleId, PrimitiveType, RuntimeValue}; pub use span::{SourceId, Span}; diff --git a/crates/decodal-core/src/module.rs b/crates/decodal-core/src/module.rs index 70b5f8d..bbda6a4 100644 --- a/crates/decodal-core/src/module.rs +++ b/crates/decodal-core/src/module.rs @@ -1,4 +1,4 @@ -use alloc::string::String; +use alloc::{string::String, vec::Vec}; use crate::{ Ast, ExprId, HostValue, SourceForm, SourceId, @@ -36,6 +36,27 @@ pub enum LoadedImport { Value(LoadedValue), } +/// An import specifier offered by host-owned language tooling. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportCandidate { + pub specifier: String, + pub detail: Option, +} + +impl ImportCandidate { + pub fn new(specifier: impl Into) -> Self { + Self { + specifier: specifier.into(), + detail: None, + } + } + + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } +} + impl LoadedImport { pub fn source( key: impl Into, @@ -59,6 +80,19 @@ impl LoadedImport { pub trait ImportLoader { fn load(&mut self, current_key: Option<&str>, specifier: &str) -> crate::Result; + + /// Returns host-resolvable import specifiers matching an unfinished prefix. + /// + /// Runtime-only loaders may keep the default implementation. Hosts that + /// provide an editor should implement this from the same namespace used by + /// [`ImportLoader::load`]. + fn complete_import( + &mut self, + _current_key: Option<&str>, + _prefix: &str, + ) -> crate::Result> { + Ok(Vec::new()) + } } #[derive(Debug, Clone, Copy, Default)] diff --git a/crates/decodal-language-service/src/completion.rs b/crates/decodal-language-service/src/completion.rs new file mode 100644 index 0000000..188e490 --- /dev/null +++ b/crates/decodal-language-service/src/completion.rs @@ -0,0 +1,905 @@ +use std::collections::{BTreeMap, HashMap}; + +use decodal::{ + Engine, HostEnvironment, HostValue, ImportLoader, LoadedImport, Result, RuntimeValue, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompletionKind { + Keyword, + Constant, + Type, + Variable, + Namespace, + Property, + File, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompletionItem { + pub label: String, + pub kind: CompletionKind, + pub detail: Option, + pub priority: i32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompletionResult { + /// UTF-8 byte offset at which the unfinished token starts. + pub from: usize, + pub items: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct FieldTree(BTreeMap); + +impl FieldTree { + fn new() -> Self { + Self::default() + } + + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn insert(&mut self, name: String, fields: FieldTree) { + self.0.insert(name, fields); + } + + fn get(&self, name: &str) -> Option<&FieldTree> { + self.0.get(name) + } + + fn remove(&mut self, name: &str) -> Option { + self.0.remove(name) + } +} + +impl IntoIterator for FieldTree { + type Item = (String, FieldTree); + type IntoIter = std::collections::btree_map::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl FromIterator<(String, FieldTree)> for FieldTree { + fn from_iter>(iter: T) -> Self { + Self(iter.into_iter().collect()) + } +} + +pub(crate) fn complete( + environment: &E, + key: &str, + source: &str, + position: usize, + explicit: bool, +) -> Result> { + let position = floor_char_boundary(source, position.min(source.len())); + if let Some((from, prefix)) = unfinished_import(source, position) { + let mut loader = environment.create_loader(); + let items = loader + .complete_import(Some(key), prefix)? + .into_iter() + .map(|candidate| CompletionItem { + label: candidate.specifier, + kind: CompletionKind::File, + detail: candidate.detail, + priority: 30, + }) + .collect(); + return Ok(Some(CompletionResult { + from, + items: unique_items(items), + })); + } + + if in_string_or_comment(source, position) { + return Ok(None); + } + + let tokens = tokenize(source); + let local_fields = collect_fields(&tokens); + let imports = collect_import_bindings(&tokens); + let globals = collect_globals(environment)?; + + if let Some((from, path)) = member_path(source, position) { + let mut parts = path.split('.'); + let Some(root) = parts.next() else { + return Ok(None); + }; + let mut fields; + let detail; + if let Some(specifier) = imports.get(root) { + let mut loader = environment.create_loader(); + let loaded = loader.load(Some(key), specifier)?; + detail = match &loaded { + LoadedImport::Source(source) => source.name.clone(), + LoadedImport::Value(value) => value.key.clone(), + }; + fields = match loaded { + LoadedImport::Source(source) => collect_fields(&tokenize(&source.source)), + LoadedImport::Value(value) => fields_from_host_value(&value.value), + }; + } else if let Some(local) = local_fields.get(root) { + fields = local.clone(); + detail = String::from("local value"); + } else if let Some(global) = globals.get(root) { + fields = global.clone(); + detail = String::from("host global"); + } else { + return Ok(None); + } + + for part in parts { + let Some(nested) = fields.remove(part) else { + return Ok(None); + }; + fields = nested; + } + let items = fields + .into_iter() + .map(|(label, children)| CompletionItem { + label, + kind: if children.is_empty() { + CompletionKind::Property + } else { + CompletionKind::Namespace + }, + detail: Some(detail.clone()), + priority: 30, + }) + .collect(); + return Ok(Some(CompletionResult { + from, + items: unique_items(items), + })); + } + + let word_from = word_start(source, position); + if word_from == position && !explicit { + return Ok(None); + } + + let mut items = builtin_items(); + for (label, children) in local_fields { + items.push(CompletionItem { + label, + kind: if children.is_empty() { + CompletionKind::Variable + } else { + CompletionKind::Namespace + }, + detail: Some(if children.is_empty() { + String::from("local value") + } else { + String::from("local object") + }), + priority: 20, + }); + } + for label in collect_parameters(&tokens) { + items.push(CompletionItem { + label, + kind: CompletionKind::Variable, + detail: Some(String::from("parameter")), + priority: 20, + }); + } + for (label, specifier) in imports { + items.push(CompletionItem { + label, + kind: CompletionKind::Namespace, + detail: Some(specifier), + priority: 30, + }); + } + for (label, children) in globals { + items.push(CompletionItem { + label, + kind: if children.is_empty() { + CompletionKind::Variable + } else { + CompletionKind::Namespace + }, + detail: Some(String::from("host global")), + priority: 40, + }); + } + + Ok(Some(CompletionResult { + from: word_from, + items: unique_items(items), + })) +} + +fn builtin_items() -> Vec { + [ + ("let", CompletionKind::Keyword, "local bindings", 5), + ("in", CompletionKind::Keyword, "let body", 0), + ("match", CompletionKind::Keyword, "pattern matching", 5), + ("import", CompletionKind::Keyword, "load a module", 5), + ("default", CompletionKind::Keyword, "fallback value", 0), + ("true", CompletionKind::Constant, "Bool", 0), + ("false", CompletionKind::Constant, "Bool", 0), + ("String", CompletionKind::Type, "string constraint", 5), + ("Int", CompletionKind::Type, "integer constraint", 5), + ("Float", CompletionKind::Type, "float constraint", 5), + ("Bool", CompletionKind::Type, "boolean constraint", 5), + ] + .into_iter() + .map(|(label, kind, detail, priority)| CompletionItem { + label: label.into(), + kind, + detail: Some(detail.into()), + priority, + }) + .collect() +} + +fn collect_globals(environment: &E) -> Result { + let mut engine = environment.create_engine()?; + let mut fields = FieldTree::new(); + for (name, value) in engine.global_values()? { + fields.insert(name, fields_from_runtime(&mut engine, &value, 0)?); + } + Ok(fields) +} + +fn fields_from_runtime( + engine: &mut Engine, + value: &RuntimeValue, + depth: usize, +) -> Result { + if depth >= 32 { + return Ok(FieldTree::new()); + } + let Some(fields) = engine.value_fields(value)? else { + return Ok(FieldTree::new()); + }; + let mut tree = FieldTree::new(); + for (name, value) in fields { + tree.insert(name, fields_from_runtime(engine, &value, depth + 1)?); + } + Ok(tree) +} + +fn fields_from_host_value(value: &HostValue) -> FieldTree { + match value { + HostValue::Object(fields) => fields + .iter() + .map(|field| (field.name.clone(), fields_from_host_value(&field.value))) + .collect(), + HostValue::ArrayConstraint { + default: Some(value), + .. + } + | HostValue::Abstract { + default: Some(value), + .. + } => fields_from_host_value(value), + _ => FieldTree::new(), + } +} + +fn unique_items(items: Vec) -> Vec { + let mut unique = HashMap::::new(); + let mut order = Vec::new(); + for item in items { + if !unique.contains_key(&item.label) { + order.push(item.label.clone()); + } + let replace = unique + .get(&item.label) + .is_none_or(|previous| item.priority > previous.priority); + if replace { + unique.insert(item.label.clone(), item); + } + } + order + .into_iter() + .filter_map(|label| unique.remove(&label)) + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TokenKind { + Identifier(String), + String(String), + Symbol(char), + Arrow, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Token { + kind: TokenKind, +} + +fn tokenize(source: &str) -> Vec { + let bytes = source.as_bytes(); + let mut tokens = Vec::new(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b' ' | b'\t' | b'\r' | b'\n' => index += 1, + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'"' => { + index += 1; + let start = index; + let mut escaped = false; + while index < bytes.len() { + let byte = bytes[index]; + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' || byte == b'\n' { + break; + } + index += 1; + } + let value = unescape_string(&source[start..index]); + index = index.saturating_add(1); + tokens.push(Token { + kind: TokenKind::String(value), + }); + } + byte if is_identifier_start(byte) => { + let start = index; + index += 1; + while index < bytes.len() && is_identifier_continue(bytes[index]) { + index += 1; + } + tokens.push(Token { + kind: TokenKind::Identifier(source[start..index].into()), + }); + } + b'=' if bytes.get(index + 1) == Some(&b'>') => { + tokens.push(Token { + kind: TokenKind::Arrow, + }); + index += 2; + } + byte => { + tokens.push(Token { + kind: TokenKind::Symbol(byte as char), + }); + index += 1; + } + } + } + tokens +} + +fn unescape_string(value: &str) -> String { + let mut chars = value.chars(); + let mut unescaped = String::new(); + while let Some(ch) = chars.next() { + if ch != '\\' { + unescaped.push(ch); + continue; + } + let Some(escaped) = chars.next() else { + unescaped.push('\\'); + break; + }; + unescaped.push(match escaped { + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + escaped => escaped, + }); + } + unescaped +} + +fn collect_fields(tokens: &[Token]) -> FieldTree { + let mut fields = FieldTree::new(); + collect_fields_in(tokens, 0, tokens.len(), &mut fields); + fields +} + +fn collect_fields_in(tokens: &[Token], start: usize, end: usize, fields: &mut FieldTree) { + let mut index = start; + while index < end { + let Some((path, equals)) = field_definition_at(tokens, index, end) else { + index += 1; + continue; + }; + let value_start = equals + 1; + let value_end = definition_end(tokens, value_start, end); + let mut nested = &mut *fields; + for part in path { + nested = nested.0.entry(part).or_default(); + } + collect_fields_in(tokens, value_start, value_end, nested); + index = value_end.saturating_add(1); + } +} + +fn field_definition_at(tokens: &[Token], start: usize, end: usize) -> Option<(Vec, usize)> { + let TokenKind::Identifier(first) = &tokens.get(start)?.kind else { + return None; + }; + let mut path = vec![first.clone()]; + let mut index = start + 1; + while index + 1 < end + && matches!(tokens[index].kind, TokenKind::Symbol('.')) + && matches!(tokens[index + 1].kind, TokenKind::Identifier(_)) + { + let TokenKind::Identifier(part) = &tokens[index + 1].kind else { + unreachable!() + }; + path.push(part.clone()); + index += 2; + } + matches!( + tokens.get(index).map(|token| &token.kind), + Some(TokenKind::Symbol('=')) + ) + .then_some((path, index)) +} + +fn definition_end(tokens: &[Token], start: usize, end: usize) -> usize { + let mut delimiters = Vec::new(); + for (index, token) in tokens.iter().enumerate().take(end).skip(start) { + match token.kind { + TokenKind::Symbol('(' | '[' | '{') => delimiters.push(token.kind.clone()), + TokenKind::Symbol(')') if matches!(delimiters.last(), Some(TokenKind::Symbol('('))) => { + delimiters.pop(); + } + TokenKind::Symbol(']') if matches!(delimiters.last(), Some(TokenKind::Symbol('['))) => { + delimiters.pop(); + } + TokenKind::Symbol('}') if matches!(delimiters.last(), Some(TokenKind::Symbol('{'))) => { + delimiters.pop(); + } + TokenKind::Symbol(';') if delimiters.is_empty() => return index, + _ => {} + } + } + end +} + +fn collect_import_bindings(tokens: &[Token]) -> BTreeMap { + let mut imports = BTreeMap::new(); + for window in tokens.windows(4) { + let ( + TokenKind::Identifier(binding), + TokenKind::Symbol('='), + TokenKind::Identifier(keyword), + TokenKind::String(specifier), + ) = ( + &window[0].kind, + &window[1].kind, + &window[2].kind, + &window[3].kind, + ) + else { + continue; + }; + if keyword == "import" { + imports.insert(binding.clone(), specifier.clone()); + } + } + imports +} + +fn collect_parameters(tokens: &[Token]) -> Vec { + let mut parameters = Vec::new(); + for (close, token) in tokens.iter().enumerate() { + if !matches!(token.kind, TokenKind::Symbol(')')) + || !matches!( + tokens.get(close + 1).map(|token| &token.kind), + Some(TokenKind::Arrow) + ) + { + continue; + } + let Some(open) = matching_open_paren(tokens, close) else { + continue; + }; + let mut depth = 0usize; + let mut segment_start = true; + for token in &tokens[open + 1..close] { + match &token.kind { + TokenKind::Symbol('(' | '[' | '{') => depth += 1, + TokenKind::Symbol(')' | ']' | '}') => depth = depth.saturating_sub(1), + TokenKind::Symbol(',') if depth == 0 => segment_start = true, + TokenKind::Identifier(name) if depth == 0 && segment_start => { + parameters.push(name.clone()); + segment_start = false; + } + _ => {} + } + } + } + parameters +} + +fn matching_open_paren(tokens: &[Token], close: usize) -> Option { + let mut depth = 0usize; + for index in (0..close).rev() { + match tokens[index].kind { + TokenKind::Symbol(')') => depth += 1, + TokenKind::Symbol('(') if depth == 0 => return Some(index), + TokenKind::Symbol('(') => depth -= 1, + _ => {} + } + } + None +} + +fn unfinished_import(source: &str, position: usize) -> Option<(usize, &str)> { + let bytes = source.as_bytes(); + let mut index = 0usize; + let mut previous_word = None; + while index < position { + match bytes[index] { + b' ' | b'\t' | b'\r' | b'\n' => index += 1, + b'#' => { + while index < position && bytes[index] != b'\n' { + index += 1; + } + previous_word = None; + } + b'"' => { + let from = index + 1; + index += 1; + let mut escaped = false; + while index < position { + let byte = bytes[index]; + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' || byte == b'\n' { + break; + } + index += 1; + } + if index == position && previous_word == Some("import") { + return Some((from, &source[from..position])); + } + index = index.saturating_add(1); + previous_word = None; + } + byte if is_identifier_start(byte) => { + let start = index; + index += 1; + while index < position && is_identifier_continue(bytes[index]) { + index += 1; + } + previous_word = Some(&source[start..index]); + } + _ => { + index += 1; + previous_word = None; + } + } + } + None +} + +fn in_string_or_comment(source: &str, position: usize) -> bool { + let bytes = source.as_bytes(); + let mut index = 0usize; + while index < position { + match bytes[index] { + b'#' => { + while index < position && bytes[index] != b'\n' { + index += 1; + } + if index == position { + return true; + } + } + b'"' => { + index += 1; + let mut escaped = false; + while index < position { + let byte = bytes[index]; + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' || byte == b'\n' { + break; + } + index += 1; + } + if index == position { + return true; + } + index = index.saturating_add(1); + } + _ => index += 1, + } + } + false +} + +fn member_path(source: &str, position: usize) -> Option<(usize, &str)> { + let bytes = source.as_bytes(); + let from = word_start(source, position); + if from == 0 || bytes[from - 1] != b'.' { + return None; + } + let mut base_start = from - 1; + while base_start > 0 { + let byte = bytes[base_start - 1]; + if is_identifier_continue(byte) || byte == b'.' { + base_start -= 1; + } else { + break; + } + } + let base = &source[base_start..from - 1]; + (!base.is_empty() + && base + .split('.') + .all(|part| !part.is_empty() && is_identifier(part))) + .then_some((from, base)) +} + +fn word_start(source: &str, position: usize) -> usize { + let bytes = source.as_bytes(); + let mut from = position; + while from > 0 && is_identifier_continue(bytes[from - 1]) { + from -= 1; + } + from +} + +fn is_identifier(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.first().is_some_and(|byte| is_identifier_start(*byte)) + && bytes[1..].iter().all(|byte| is_identifier_continue(*byte)) +} + +fn is_identifier_start(byte: u8) -> bool { + byte.is_ascii_alphabetic() || byte == b'_' +} + +fn is_identifier_continue(byte: u8) -> bool { + is_identifier_start(byte) || byte.is_ascii_digit() +} + +fn floor_char_boundary(source: &str, mut position: usize) -> usize { + while !source.is_char_boundary(position) { + position -= 1; + } + position +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use decodal::{ + Diagnostic, DiagnosticKind, EmptyLoader, HostValue, ImportCandidate, LoadedImport, Span, + }; + + use super::*; + + #[derive(Clone, Default)] + struct TestEnvironment { + files: BTreeMap, + } + + #[derive(Clone, Default)] + struct TestLoader { + files: BTreeMap, + } + + impl ImportLoader for TestLoader { + fn load(&mut self, _current_key: Option<&str>, specifier: &str) -> Result { + if specifier == "./post.md" { + return Ok(LoadedImport::value( + "post.md", + HostValue::object([ + ( + "frontmatter", + HostValue::object([ + ("title", HostValue::string("Hello")), + ("draft", HostValue::bool(false)), + ]), + ), + ("body", HostValue::string("# Hello")), + ]), + )); + } + let key = resolve_virtual("main.dcdl", specifier); + let Some(source) = self.files.get(&key) else { + return Err(Diagnostic::new( + DiagnosticKind::Import, + Span::default(), + "unknown import", + )); + }; + Ok(LoadedImport::source(key.clone(), key, source)) + } + + fn complete_import( + &mut self, + _current_key: Option<&str>, + prefix: &str, + ) -> Result> { + Ok(self + .files + .keys() + .map(|path| format!("./{path}")) + .filter(|path| path.starts_with(prefix)) + .map(ImportCandidate::new) + .collect()) + } + } + + impl HostEnvironment for TestEnvironment { + type Loader = TestLoader; + + fn create_loader(&self) -> Self::Loader { + TestLoader { + files: self.files.clone(), + } + } + + fn configure_engine(&self, engine: &mut Engine) -> Result<()> { + engine.bind_global( + "App", + HostValue::object([( + "config", + HostValue::object([ + ("enabled", HostValue::bool_type()), + ("port", HostValue::int_type()), + ]), + )]), + )?; + Ok(()) + } + } + + fn environment() -> TestEnvironment { + TestEnvironment { + files: BTreeMap::from([ + ( + "schemas/service.dcdl".into(), + "Service = { name = String; resources = { cpu = Int; }; };".into(), + ), + ( + "env/production.dcdl".into(), + "capacity = { cpu = 2000; };".into(), + ), + ( + "資料/service.dcdl".into(), + "Service = { label = String; };".into(), + ), + ]), + } + } + + fn labels(result: CompletionResult) -> Vec { + result.items.into_iter().map(|item| item.label).collect() + } + + #[test] + fn completes_import_paths_from_the_host_loader() { + let source = "schema = import \"./sch"; + let result = complete(&environment(), "main.dcdl", source, source.len(), false) + .unwrap() + .unwrap(); + assert_eq!(result.from, source.len() - 5); + assert!(labels(result).contains(&"./schemas/service.dcdl".into())); + } + + #[test] + fn completes_nested_fields_from_imported_source() { + let source = "let schema = import \"./schemas/service.dcdl\"; in schema.Service."; + let result = complete(&environment(), "main.dcdl", source, source.len(), false) + .unwrap() + .unwrap(); + assert_eq!(labels(result), ["name", "resources"]); + } + + #[test] + fn completes_structured_host_imports() { + let source = "let post = import \"./post.md\"; in post.frontmatter."; + let result = complete(&environment(), "main.dcdl", source, source.len(), false) + .unwrap() + .unwrap(); + assert_eq!(labels(result), ["draft", "title"]); + } + + #[test] + fn preserves_unicode_import_specifiers() { + let source = "let schema = import \"./資料/service.dcdl\"; in schema.Service."; + let result = complete(&environment(), "main.dcdl", source, source.len(), false) + .unwrap() + .unwrap(); + assert_eq!(labels(result), ["label"]); + } + + #[test] + fn completes_host_globals_from_the_evaluation_environment() { + let source = "App.config."; + let result = complete(&environment(), "main.dcdl", source, source.len(), false) + .unwrap() + .unwrap(); + assert_eq!(labels(result), ["enabled", "port"]); + } + + #[test] + fn completes_language_locals_parameters_and_partial_members() { + let source = "let service = { port = 8080; }; in (value: Int) => service.po"; + let result = complete(&environment(), "main.dcdl", source, source.len(), false) + .unwrap() + .unwrap(); + assert_eq!(result.from, source.len() - 2); + assert_eq!(labels(result), ["port"]); + + let source = "let service = { port = 8080; }; in (value: Int) => val"; + let result = complete(&environment(), "main.dcdl", source, source.len(), false) + .unwrap() + .unwrap(); + let labels = labels(result); + assert!(labels.contains(&"String".into())); + assert!(labels.contains(&"service".into())); + assert!(labels.contains(&"value".into())); + assert!(labels.contains(&"App".into())); + } + + #[test] + fn suppresses_completions_in_strings_and_comments() { + let string = "value = \"hello world\""; + assert!( + complete(&environment(), "main.dcdl", string, string.len() - 1, false) + .unwrap() + .is_none() + ); + let comment = "value = true; # hello"; + assert!( + complete(&environment(), "main.dcdl", comment, comment.len(), false) + .unwrap() + .is_none() + ); + } + + #[test] + fn empty_environment_remains_supported() { + let result = complete(&EmptyEnvironment, "main.dcdl", "Str", 3, false) + .unwrap() + .unwrap(); + assert!(labels(result).contains(&"String".into())); + } + + struct EmptyEnvironment; + + impl HostEnvironment for EmptyEnvironment { + type Loader = EmptyLoader; + + fn create_loader(&self) -> Self::Loader { + EmptyLoader + } + } + + fn resolve_virtual(current: &str, specifier: &str) -> String { + let mut parts = current.split('/').collect::>(); + parts.pop(); + for part in specifier.split('/') { + match part { + "" | "." => {} + ".." => { + parts.pop(); + } + part => parts.push(part), + } + } + parts.join("/") + } +} diff --git a/crates/decodal-language-service/src/lib.rs b/crates/decodal-language-service/src/lib.rs index 82c1827..7f21daf 100644 --- a/crates/decodal-language-service/src/lib.rs +++ b/crates/decodal-language-service/src/lib.rs @@ -1,5 +1,9 @@ use decodal::{Data, Diagnostic, HostEnvironment, Result}; +mod completion; + +pub use completion::{CompletionItem, CompletionKind, CompletionResult}; + /// Semantic tooling backed by the same host environment as production. pub struct LanguageService { environment: E, @@ -59,6 +63,18 @@ impl LanguageService { }, } } + + /// Completes the document using the same host globals and import loader as + /// production evaluation. + pub fn complete( + &self, + key: impl AsRef, + source: &str, + position: usize, + explicit: bool, + ) -> Result> { + completion::complete(&self.environment, key.as_ref(), source, position, explicit) + } } #[derive(Debug, Clone, PartialEq)] diff --git a/crates/decodal-lsp/src/lib.rs b/crates/decodal-lsp/src/lib.rs index f2ea29a..c7405a8 100644 --- a/crates/decodal-lsp/src/lib.rs +++ b/crates/decodal-lsp/src/lib.rs @@ -6,19 +6,23 @@ use std::{ }; use decodal::{ - Diagnostic as DecodalDiagnostic, DiagnosticKind, HostEnvironment, ImportLoader, LoadedImport, - LoadedSource, SourceId, Span, + Diagnostic as DecodalDiagnostic, DiagnosticKind, HostEnvironment, ImportCandidate, + ImportLoader, LoadedImport, LoadedSource, SourceId, Span, +}; +use decodal_language_service::{ + CompletionKind as ServiceCompletionKind, CompletionResult, LanguageService, SemanticAnalysis, }; -use decodal_language_service::{LanguageService, SemanticAnalysis}; use decodal_language_tools::format_source; use lsp_server::{Connection, ErrorCode, Message, Notification, Request, Response}; pub use lsp_types::InitializeParams; use lsp_types::{ - Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DidChangeTextDocumentParams, - DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams, - DocumentFormattingParams, InitializeResult, Location, NumberOrString, OneOf, Position, - PositionEncodingKind, PublishDiagnosticsParams, Range, SaveOptions, ServerCapabilities, - ServerInfo, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions, + CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse, + CompletionTextEdit, CompletionTriggerKind, Diagnostic, DiagnosticRelatedInformation, + DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams, + DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentFormattingParams, + InitializeResult, Location, NumberOrString, OneOf, Position, PositionEncodingKind, + PublishDiagnosticsParams, Range, SaveOptions, ServerCapabilities, ServerInfo, + TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions, TextDocumentSyncSaveOptions, TextEdit, Uri, }; @@ -124,6 +128,11 @@ fn server_capabilities() -> ServerCapabilities { }, )), document_formatting_provider: Some(OneOf::Left(true)), + completion_provider: Some(CompletionOptions { + resolve_provider: Some(false), + trigger_characters: Some(vec![String::from("."), String::from("/")]), + ..CompletionOptions::default() + }), ..ServerCapabilities::default() } } @@ -166,11 +175,66 @@ impl Server { fn handle_request(&self, request: Request) -> ServerResult { match request.method.as_str() { + "textDocument/completion" => self.complete_document(request), "textDocument/formatting" => self.format_document(request), _ => self.reject_unknown_request(request), } } + fn complete_document(&self, request: Request) -> ServerResult { + let id = request.id; + let params: CompletionParams = match serde_json::from_value(request.params) { + Ok(params) => params, + Err(error) => { + self.connection + .sender + .send(Message::Response(Response::new_err( + id, + ErrorCode::InvalidParams as i32, + error.to_string(), + )))?; + return Ok(()); + } + }; + let document_id = params.text_document_position.text_document.uri.as_str(); + let Some(document) = self.documents.get(document_id) else { + self.connection + .sender + .send(Message::Response(Response::new_ok( + id, + Option::::None, + )))?; + return Ok(()); + }; + let position = params.text_document_position.position; + let offset = position_to_byte_offset(&document.source, position); + let explicit = params + .context + .is_some_and(|context| context.trigger_kind == CompletionTriggerKind::INVOKED); + let completion = + match self + .service + .complete(&document.key, &document.source, offset, explicit) + { + Ok(completion) => completion + .map(|completion| completion_to_lsp(completion, &document.source, offset)), + Err(error) => { + self.connection + .sender + .send(Message::Response(Response::new_err( + id, + ErrorCode::InternalError as i32, + error.message, + )))?; + return Ok(()); + } + }; + self.connection + .sender + .send(Message::Response(Response::new_ok(id, completion)))?; + Ok(()) + } + fn format_document(&self, request: Request) -> ServerResult { let id = request.id; let params: DocumentFormattingParams = match serde_json::from_value(request.params) { @@ -413,6 +477,42 @@ fn span_to_range(source: &str, span: Span) -> Range { } } +fn completion_to_lsp( + completion: CompletionResult, + source: &str, + offset: usize, +) -> CompletionResponse { + let range = Range::new( + byte_offset_to_position(source, completion.from), + byte_offset_to_position(source, offset), + ); + CompletionResponse::Array( + completion + .items + .into_iter() + .map(|item| CompletionItem { + sort_text: Some(format!("{:04}-{}", 9999 - item.priority, item.label)), + text_edit: Some(CompletionTextEdit::Edit(TextEdit::new( + range, + item.label.clone(), + ))), + label: item.label, + kind: Some(match item.kind { + ServiceCompletionKind::Keyword => CompletionItemKind::KEYWORD, + ServiceCompletionKind::Constant => CompletionItemKind::CONSTANT, + ServiceCompletionKind::Type => CompletionItemKind::TYPE_PARAMETER, + ServiceCompletionKind::Variable => CompletionItemKind::VARIABLE, + ServiceCompletionKind::Namespace => CompletionItemKind::MODULE, + ServiceCompletionKind::Property => CompletionItemKind::PROPERTY, + ServiceCompletionKind::File => CompletionItemKind::FILE, + }), + detail: item.detail, + ..CompletionItem::default() + }) + .collect(), + ) +} + fn byte_offset_to_position(source: &str, offset: usize) -> Position { let mut offset = offset.min(source.len()); while !source.is_char_boundary(offset) { @@ -431,6 +531,36 @@ fn byte_offset_to_position(source: &str, offset: usize) -> Position { Position { line, character } } +fn position_to_byte_offset(source: &str, position: Position) -> usize { + let mut line = 0u32; + let mut line_start = 0usize; + for (offset, ch) in source.char_indices() { + if line == position.line { + line_start = offset; + break; + } + if ch == '\n' { + line += 1; + line_start = offset + ch.len_utf8(); + } + } + if line < position.line { + return source.len(); + } + let mut utf16 = 0u32; + for (relative, ch) in source[line_start..].char_indices() { + if ch == '\n' || utf16 >= position.character { + return line_start + relative; + } + let next = utf16 + ch.len_utf16() as u32; + if next > position.character { + return line_start + relative; + } + utf16 = next; + } + source.len() +} + fn document_key(uri: &Uri) -> String { if uri .scheme() @@ -543,6 +673,64 @@ impl ImportLoader for FileSystemLoader { source, })) } + + fn complete_import( + &mut self, + current_key: Option<&str>, + prefix: &str, + ) -> decodal::Result> { + let current_dir = current_key + .and_then(|key| Path::new(key).parent()) + .unwrap_or_else(|| Path::new(".")); + let prefix_path = Path::new(prefix); + let resolved = if prefix_path.is_absolute() { + prefix_path.to_path_buf() + } else { + current_dir.join(prefix_path) + }; + let ends_with_separator = prefix.ends_with('/') || prefix.ends_with('\\'); + let directory = if ends_with_separator { + resolved.as_path() + } else { + resolved.parent().unwrap_or_else(|| Path::new(".")) + }; + let fragment = if ends_with_separator { + "" + } else { + resolved + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + }; + let label_base = if ends_with_separator { + prefix.to_owned() + } else { + prefix + .rfind(['/', '\\']) + .map_or_else(String::new, |index| prefix[..=index].to_owned()) + }; + let Ok(entries) = fs::read_dir(directory) else { + return Ok(Vec::new()); + }; + let mut candidates = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.starts_with(fragment) { + continue; + } + let is_directory = entry.file_type().is_ok_and(|kind| kind.is_dir()); + let mut specifier = format!("{label_base}{name}"); + if is_directory { + specifier.push('/'); + } + candidates.push( + ImportCandidate::new(specifier) + .with_detail(entry.path().to_string_lossy().into_owned()), + ); + } + candidates.sort_by(|left, right| left.specifier.cmp(&right.specifier)); + Ok(candidates) + } } #[cfg(test)] @@ -642,6 +830,14 @@ mod tests { byte_offset_to_position(source, source.len()), Position::new(1, 1) ); + assert_eq!(position_to_byte_offset(source, Position::new(0, 0)), 0); + assert_eq!(position_to_byte_offset(source, Position::new(0, 1)), 1); + assert_eq!(position_to_byte_offset(source, Position::new(0, 3)), 5); + assert_eq!(position_to_byte_offset(source, Position::new(1, 0)), 6); + assert_eq!( + position_to_byte_offset(source, Position::new(1, 1)), + source.len() + ); } #[test] @@ -651,7 +847,7 @@ mod tests { } #[test] - fn publishes_host_environment_diagnostics_over_lsp() { + fn serves_host_environment_diagnostics_and_completion_over_lsp() { let (server_connection, client_connection) = Connection::memory(); let initialized_workspace = Arc::new(Mutex::new(None)); let server_workspace = Arc::clone(&initialized_workspace); @@ -689,6 +885,9 @@ mod tests { panic!("expected initialize response") }; assert!(initialized.response_result.is_ok()); + let initialize_result: InitializeResult = + serde_json::from_value(initialized.response_result.as_ref().unwrap().clone()).unwrap(); + assert!(initialize_result.capabilities.completion_provider.is_some()); assert_eq!( initialized_workspace.lock().unwrap().as_deref(), Some("file:///tmp") @@ -779,10 +978,60 @@ mod tests { assert_eq!(edits.len(), 1); assert!(edits[0].new_text.ends_with('\n')); + client_connection + .sender + .send(Message::Notification(Notification::new( + String::from("textDocument/didChange"), + json!({ + "textDocument": { "uri": uri, "version": 2 }, + "contentChanges": [{ "text": "Post.dr" }] + }), + ))) + .unwrap(); + let published = client_connection + .receiver + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + assert!(matches!(published, Message::Notification(_))); + client_connection .sender .send(Message::Request(Request { id: RequestId::from(3), + method: String::from("textDocument/completion"), + params: json!({ + "textDocument": { "uri": uri }, + "position": { "line": 0, "character": 7 }, + "context": { "triggerKind": 1 } + }), + })) + .unwrap(); + let completed = client_connection + .receiver + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + let Message::Response(completed) = completed else { + panic!("expected completion response") + }; + let completion: CompletionResponse = + serde_json::from_value(completed.response_result.unwrap()).unwrap(); + let CompletionResponse::Array(items) = completion else { + panic!("expected completion item array") + }; + let draft = items.iter().find(|item| item.label == "draft").unwrap(); + assert_eq!(draft.kind, Some(CompletionItemKind::PROPERTY)); + let Some(CompletionTextEdit::Edit(edit)) = &draft.text_edit else { + panic!("expected completion text edit") + }; + assert_eq!( + edit.range, + Range::new(Position::new(0, 5), Position::new(0, 7)) + ); + + client_connection + .sender + .send(Message::Request(Request { + id: RequestId::from(4), method: String::from("shutdown"), params: json!(null), })) diff --git a/crates/decodal-wasm/Cargo.toml b/crates/decodal-wasm/Cargo.toml index f15b342..1657583 100644 --- a/crates/decodal-wasm/Cargo.toml +++ b/crates/decodal-wasm/Cargo.toml @@ -6,7 +6,7 @@ rust-version.workspace = true license.workspace = true repository.workspace = true readme.workspace = true -description = "WebAssembly wrapper for evaluating Decodal in browser playgrounds." +description = "Host-configurable Decodal evaluator and language service for JavaScript runtimes." keywords = ["decodal", "wasm", "dsl", "config"] categories = ["wasm", "config"] publish = false @@ -16,8 +16,13 @@ crate-type = ["cdylib", "rlib"] [dependencies] decodal = { version = "0.1.2", path = "../decodal-core" } +decodal-language-service = { version = "0.1.2", path = "../decodal-language-service" } serde_json.workspace = true wasm-bindgen.workspace = true +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" +serde-wasm-bindgen = "0.6" + [package.metadata.wasm-pack.profile.release] wasm-opt = false diff --git a/crates/decodal-wasm/src/host_value.rs b/crates/decodal-wasm/src/host_value.rs new file mode 100644 index 0000000..e30df7b --- /dev/null +++ b/crates/decodal-wasm/src/host_value.rs @@ -0,0 +1,228 @@ +use decodal::{CompareOp, Constraint, HostValue, LiteralValue, PrimitiveType}; +use serde_json::{Map, Value}; + +pub(crate) fn from_json(value: &Value) -> Result { + match value { + Value::Null => Err(String::from("null is not a Decodal host value")), + Value::Bool(value) => Ok(HostValue::bool(*value)), + Value::Number(value) => number(value), + Value::String(value) => Ok(HostValue::string(value)), + Value::Array(items) => items + .iter() + .enumerate() + .map(|(index, value)| { + from_json(value).map_err(|error| format!("array item {index}: {error}")) + }) + .collect::, _>>() + .map(HostValue::array), + Value::Object(fields) => object(fields), + } +} + +fn number(value: &serde_json::Number) -> Result { + if let Some(value) = value.as_i64() { + return Ok(HostValue::int(value)); + } + if let Some(value) = value.as_u64() { + return i64::try_from(value) + .map(HostValue::int) + .map_err(|_| String::from("integer host value is outside the signed 64-bit range")); + } + value + .as_f64() + .filter(|value| value.is_finite()) + .map(HostValue::float) + .ok_or_else(|| String::from("invalid numeric host value")) +} + +fn object(fields: &Map) -> Result { + let Some(descriptor) = fields.get("$decodal") else { + return fields + .iter() + .map(|(name, value)| { + from_json(value) + .map(|value| (name.clone(), value)) + .map_err(|error| format!("field `{name}`: {error}")) + }) + .collect::, _>>() + .map(HostValue::object); + }; + + let descriptor = descriptor + .as_str() + .ok_or_else(|| String::from("`$decodal` must be a descriptor name"))?; + let constraints = parse_constraints(fields.get("constraints"))?; + let default = fields + .get("default") + .map(from_json) + .transpose()? + .map(Box::new); + + match descriptor { + "String" | "Int" | "Float" | "Bool" => { + let primitive = match descriptor { + "String" => PrimitiveType::String, + "Int" => PrimitiveType::Int, + "Float" => PrimitiveType::Float, + "Bool" => PrimitiveType::Bool, + _ => unreachable!(), + }; + let mut all_constraints = vec![Constraint::Type(primitive)]; + all_constraints.extend(constraints); + Ok(HostValue::Abstract { + constraints: all_constraints, + default, + }) + } + "Array" => { + let item = fields + .get("item") + .ok_or_else(|| String::from("Array descriptor requires `item`"))?; + Ok(HostValue::ArrayConstraint { + item: Box::new(from_json(item)?), + constraints, + default, + }) + } + "Abstract" => Ok(HostValue::Abstract { + constraints, + default, + }), + name => Err(format!("unknown Decodal host descriptor `{name}`")), + } +} + +fn parse_constraints(value: Option<&Value>) -> Result, String> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let items = value + .as_array() + .ok_or_else(|| String::from("`constraints` must be an array"))?; + items + .iter() + .enumerate() + .map(|(index, value)| { + parse_constraint(value).map_err(|error| format!("constraint {index}: {error}")) + }) + .collect() +} + +fn parse_constraint(value: &Value) -> Result { + let fields = value + .as_object() + .ok_or_else(|| String::from("constraint must be an object"))?; + let kind = required_string(fields, "kind")?; + match kind { + "type" => match required_string(fields, "value")? { + "String" => Ok(Constraint::Type(PrimitiveType::String)), + "Int" => Ok(Constraint::Type(PrimitiveType::Int)), + "Float" => Ok(Constraint::Type(PrimitiveType::Float)), + "Bool" => Ok(Constraint::Type(PrimitiveType::Bool)), + value => Err(format!("unknown primitive type `{value}`")), + }, + "compare" => { + let operation = match required_string(fields, "op")? { + ">" => CompareOp::Gt, + ">=" => CompareOp::Gte, + "<" => CompareOp::Lt, + "<=" => CompareOp::Lte, + operation => return Err(format!("unknown comparison operator `{operation}`")), + }; + let value = fields + .get("value") + .ok_or_else(|| String::from("compare constraint requires `value`"))?; + Ok(Constraint::Compare(operation, literal(value)?)) + } + "regex" => Ok(Constraint::Regex( + required_string(fields, "value")?.to_owned(), + )), + "predicate" => Ok(Constraint::BuiltinPredicate( + required_string(fields, "value")?.to_owned(), + )), + kind => Err(format!("unknown constraint kind `{kind}`")), + } +} + +fn literal(value: &Value) -> Result { + match value { + Value::String(value) => Ok(LiteralValue::String(value.clone())), + Value::Bool(value) => Ok(LiteralValue::Bool(*value)), + Value::Number(value) => match number(value)? { + HostValue::Int(value) => Ok(LiteralValue::Int(value)), + HostValue::Float(value) => Ok(LiteralValue::Float(value)), + _ => unreachable!(), + }, + _ => Err(String::from("comparison value must be a primitive literal")), + } +} + +fn required_string<'a>(fields: &'a Map, name: &str) -> Result<&'a str, String> { + fields + .get(name) + .and_then(Value::as_str) + .ok_or_else(|| format!("constraint requires string `{name}`")) +} + +#[cfg(test)] +mod tests { + use decodal::{Constraint, HostValue, LiteralValue, PrimitiveType}; + + use super::from_json; + + #[test] + fn converts_plain_js_shaped_values() { + let value = serde_json::json!({ + "frontmatter": { "draft": false }, + "body": "# Hello", + }); + assert_eq!( + from_json(&value).unwrap(), + HostValue::object([ + ("body", HostValue::string("# Hello")), + ( + "frontmatter", + HostValue::object([("draft", HostValue::bool(false))]), + ), + ]) + ); + } + + #[test] + fn converts_schema_descriptors() { + let value = serde_json::json!({ + "$decodal": "Array", + "item": { + "$decodal": "Int", + "constraints": [{ "kind": "compare", "op": ">", "value": 0 }], + }, + "default": [1, 2], + }); + assert_eq!( + from_json(&value).unwrap(), + HostValue::ArrayConstraint { + item: Box::new(HostValue::Abstract { + constraints: vec![ + Constraint::Type(PrimitiveType::Int), + Constraint::Compare(decodal::CompareOp::Gt, LiteralValue::Int(0)), + ], + default: None, + }), + constraints: Vec::new(), + default: Some(Box::new(HostValue::array([ + HostValue::int(1), + HostValue::int(2), + ]))), + } + ); + } + + #[test] + fn rejects_null_values() { + assert!( + from_json(&serde_json::Value::Null) + .unwrap_err() + .contains("null") + ); + } +} diff --git a/crates/decodal-wasm/src/lib.rs b/crates/decodal-wasm/src/lib.rs index 5a2705f..494f805 100644 --- a/crates/decodal-wasm/src/lib.rs +++ b/crates/decodal-wasm/src/lib.rs @@ -1,25 +1,67 @@ -use std::collections::BTreeMap; - -use decodal::{ - Data, Diagnostic, DiagnosticKind, EmptyLoader, Engine, ImportLoader, LoadedImport, - LoadedSource, SourceId, Span, format_diagnostic_with, -}; +#[cfg(target_arch = "wasm32")] +use decodal::HostEnvironment; +use decodal::{Data, EmptyLoader, Engine, SourceId, format_diagnostic_with}; +#[cfg(target_arch = "wasm32")] +use decodal_language_service::{CompletionKind, LanguageService}; use wasm_bindgen::prelude::*; +#[cfg(any(target_arch = "wasm32", test))] +mod host_value; +#[cfg(target_arch = "wasm32")] +mod web_environment; + +#[cfg(target_arch = "wasm32")] +use web_environment::JsEnvironment; + +/// Evaluates one standalone Decodal source with no host globals or imports. #[wasm_bindgen] pub fn evaluate(source: &str) -> String { encode_result(evaluate_inner(source)) } -#[wasm_bindgen(js_name = evaluateProject)] -pub fn evaluate_project(entry: &str, files_json: &str) -> String { - encode_result(evaluate_project_inner(entry, files_json)) +/// A browser-facing language service configured entirely by its JavaScript host. +/// +/// The host owns globals, import loading, and import completion. This keeps +/// filesystem, network, and virtual-project policy outside the WASM package. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen(js_name = DecodalLanguageService)] +pub struct WebLanguageService { + environment: JsEnvironment, +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen(js_class = DecodalLanguageService)] +impl WebLanguageService { + #[wasm_bindgen(constructor)] + pub fn new(options: JsValue) -> Result { + Ok(Self { + environment: JsEnvironment::from_options(options)?, + }) + } + + /// Evaluates a source using the injected globals and import loader. + pub fn evaluate(&self, key: &str, name: &str, source: &str) -> String { + encode_result(evaluate_with_environment( + &self.environment, + key, + name, + source, + )) + } + + /// Completes a source using the same injected environment as evaluation. + /// + /// Positions and returned ranges are UTF-16 offsets, matching browser + /// editors and the Language Server Protocol. + pub fn complete(&self, key: &str, source: &str, position: usize, explicit: bool) -> String { + encode_completion(&self.environment, key, source, position, explicit) + } } fn encode_result(result: Result) -> String { match result { - Ok(output) => format!("{{\"ok\":true,\"output\":{}}}", json_string(&output)), - Err(error) => format!("{{\"ok\":false,\"error\":{}}}", json_string(&error)), + Ok(output) => serde_json::json!({ "ok": true, "output": output }).to_string(), + Err(error) => serde_json::json!({ "ok": false, "error": error }).to_string(), } } @@ -40,103 +82,106 @@ fn evaluate_inner(source: &str) -> Result { Ok(format_data(&data, 0)) } -fn evaluate_project_inner(entry: &str, files_json: &str) -> Result { - let raw_files: BTreeMap = serde_json::from_str(files_json) - .map_err(|error| format!("failed to read playground files: {error}"))?; - let mut files = BTreeMap::new(); - for (path, source) in raw_files { - let path = normalize_path(&path).ok_or_else(|| format!("invalid file path `{path}`"))?; - files.insert(path, source); - } - - let entry = normalize_path(entry).ok_or_else(|| format!("invalid entry path `{entry}`"))?; - let source = files - .get(&entry) - .cloned() - .ok_or_else(|| format!("entry file `{entry}` was not found"))?; - - let mut engine = Engine::new(VirtualLoader { files }); - let module = match engine.add_root_source(entry.clone(), entry.clone(), &source) { +#[cfg(target_arch = "wasm32")] +fn evaluate_with_environment( + environment: &JsEnvironment, + key: &str, + name: &str, + source: &str, +) -> Result { + let mut engine = environment + .create_engine() + .map_err(|diagnostic| diagnostic.message)?; + let module = match engine.add_root_source(key, name, source) { Ok(module) => module, - Err(error) => return Err(format_diagnostic_with_root(&error, &entry)), + Err(diagnostic) => return Err(engine.format_diagnostic(&diagnostic)), }; let value = match engine.eval_module(module) { Ok(value) => value, - Err(error) => return Err(engine.format_diagnostic(&error)), + Err(diagnostic) => return Err(engine.format_diagnostic(&diagnostic)), }; let data = match engine.materialize(&value) { Ok(data) => data, - Err(error) => return Err(engine.format_diagnostic(&error)), + Err(diagnostic) => return Err(engine.format_diagnostic(&diagnostic)), }; Ok(format_data(&data, 0)) } -#[derive(Debug, Clone)] -struct VirtualLoader { - files: BTreeMap, -} - -impl ImportLoader for VirtualLoader { - fn load( - &mut self, - current_key: Option<&str>, - specifier: &str, - ) -> decodal::Result { - let key = resolve_import(current_key, specifier).ok_or_else(|| { - Diagnostic::new( - DiagnosticKind::Import, - Span::default(), - format!("invalid import path `{specifier}`"), - ) - })?; - let source = self.files.get(&key).cloned().ok_or_else(|| { - Diagnostic::new( - DiagnosticKind::Import, - Span::default(), - format!("import `{specifier}` resolved to `{key}`, but that file does not exist"), - ) - })?; - Ok(LoadedImport::Source(LoadedSource { - key: key.clone(), - name: key, - source, - })) - } -} - -fn resolve_import(current_key: Option<&str>, specifier: &str) -> Option { - if specifier.starts_with('/') { - return normalize_path(specifier); - } - - let mut base = String::new(); - if let Some(current_key) = current_key { - if let Some((parent, _file)) = current_key.rsplit_once('/') { - base.push_str(parent); - base.push('/'); +#[cfg(target_arch = "wasm32")] +fn encode_completion( + environment: &JsEnvironment, + key: &str, + source: &str, + position: usize, + explicit: bool, +) -> String { + let byte_position = utf16_offset_to_byte(source, position); + let service = LanguageService::new(environment); + let completion = match service.complete(key, source, byte_position, explicit) { + Ok(completion) => completion, + Err(diagnostic) => { + return serde_json::json!({ + "ok": false, + "error": diagnostic.message, + }) + .to_string(); } - } - base.push_str(specifier); - normalize_path(&base) + }; + let completion = completion.map(|completion| { + let from = byte_offset_to_utf16(source, completion.from); + let options = completion + .items + .into_iter() + .map(|item| { + serde_json::json!({ + "label": item.label, + "kind": completion_kind_name(item.kind), + "detail": item.detail, + "priority": item.priority, + }) + }) + .collect::>(); + serde_json::json!({ "from": from, "options": options }) + }); + serde_json::json!({ "ok": true, "completion": completion }).to_string() } -fn normalize_path(path: &str) -> Option { - let mut parts = Vec::new(); - let normalized = path.replace('\\', "/"); - for part in normalized.split('/') { - match part { - "" | "." => {} - ".." => { - parts.pop()?; - } - part => parts.push(part), +#[cfg(target_arch = "wasm32")] +fn completion_kind_name(kind: CompletionKind) -> &'static str { + match kind { + CompletionKind::Keyword => "keyword", + CompletionKind::Constant => "constant", + CompletionKind::Type => "type", + CompletionKind::Variable => "variable", + CompletionKind::Namespace => "namespace", + CompletionKind::Property => "property", + CompletionKind::File => "file", + } +} + +#[cfg(any(target_arch = "wasm32", test))] +fn utf16_offset_to_byte(source: &str, offset: usize) -> usize { + let mut utf16 = 0usize; + for (byte, ch) in source.char_indices() { + if utf16 >= offset { + return byte; } + let next = utf16 + ch.len_utf16(); + if next > offset { + return byte; + } + utf16 = next; } - if parts.is_empty() { - None - } else { - Some(parts.join("/")) + source.len() +} + +#[cfg(any(target_arch = "wasm32", test))] +fn byte_offset_to_utf16(source: &str, mut offset: usize) -> usize { + offset = offset.min(source.len()); + while !source.is_char_boundary(offset) { + offset = offset.saturating_sub(1); } + source[..offset].encode_utf16().count() } fn format_diagnostic_with_root(diagnostic: &decodal::Diagnostic, root_name: &str) -> String { @@ -191,66 +236,28 @@ fn format_data(data: &Data, indent: usize) -> String { } fn json_string(value: &str) -> String { - let mut out = String::from("\""); - for ch in value.chars() { - match ch { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - ch if ch.is_control() => { - use core::fmt::Write; - let _ = write!(out, "\\u{:04x}", ch as u32); - } - ch => out.push(ch), - } - } - out.push('"'); - out + serde_json::to_string(value).expect("strings are always JSON-serializable") } #[cfg(test)] mod tests { - use super::{evaluate_project_inner, normalize_path, resolve_import}; + use super::{byte_offset_to_utf16, evaluate_inner, utf16_offset_to_byte}; #[test] - fn normalizes_virtual_paths() { + fn evaluates_standalone_sources() { assert_eq!( - normalize_path("/schemas/../main.dcdl"), - Some("main.dcdl".into()) - ); - assert_eq!(normalize_path("../main.dcdl"), None); - } - - #[test] - fn resolves_imports_relative_to_current_file() { - assert_eq!( - resolve_import(Some("schemas/service.dcdl"), "./types.dcdl"), - Some("schemas/types.dcdl".into()) + evaluate_inner("value = 1;").unwrap(), + "{\n \"value\": 1\n}" ); } #[test] - fn evaluates_project_imports() { - let files = r#"{ - "main.dcdl":"let dep = import \"./schemas/service.dcdl\"; in dep.Service & { port = 9443; }", - "schemas/service.dcdl":"Service = { name = String default \"api\"; port = Int & > 443 default 8443; }" - }"#; - let output = evaluate_project_inner("main.dcdl", files).unwrap(); - assert!(output.contains("\"port\": 9443")); - } - - #[test] - fn project_diagnostics_use_virtual_file_names() { - let files = r#"{ - "main.dcdl":"let dep = import \"./schemas/service.dcdl\"; in dep.Service & { port = 80; }", - "schemas/service.dcdl":"Service = { port = Int & > 443 default 8443; }" - }"#; - let error = evaluate_project_inner("main.dcdl", files).unwrap_err(); - assert!(error.contains("main.dcdl:")); - assert!(error.contains("schemas/service.dcdl:")); - assert!(!error.contains("source 0:")); - assert!(!error.contains("source 1:")); + fn converts_web_utf16_offsets() { + let source = "a😀β"; + assert_eq!(utf16_offset_to_byte(source, 0), 0); + assert_eq!(utf16_offset_to_byte(source, 1), 1); + assert_eq!(utf16_offset_to_byte(source, 3), 5); + assert_eq!(byte_offset_to_utf16(source, 5), 3); + assert_eq!(byte_offset_to_utf16(source, source.len()), 4); } } diff --git a/crates/decodal-wasm/src/web_environment.rs b/crates/decodal-wasm/src/web_environment.rs new file mode 100644 index 0000000..25799c3 --- /dev/null +++ b/crates/decodal-wasm/src/web_environment.rs @@ -0,0 +1,209 @@ +use std::collections::BTreeMap; + +use decodal::{ + Diagnostic, DiagnosticKind, Engine, HostEnvironment, HostValue, ImportCandidate, ImportLoader, + LoadedImport, Span, +}; +use js_sys::{Function, Reflect}; +use serde_json::Value; +use wasm_bindgen::{JsCast, JsValue}; + +use crate::host_value; + +pub(crate) struct JsEnvironment { + globals: BTreeMap, + load_import: Option, + complete_import: Option, +} + +impl JsEnvironment { + pub(crate) fn from_options(options: JsValue) -> Result { + if !options.is_null() && !options.is_undefined() && !options.is_object() { + return Err(JsValue::from_str( + "DecodalLanguageService options must be an object", + )); + } + + let globals = property(&options, "globals")?; + let globals = if globals.is_null() || globals.is_undefined() { + BTreeMap::new() + } else { + let globals: Value = serde_wasm_bindgen::from_value(globals).map_err(|error| { + JsValue::from_str(&format!("failed to read `globals`: {error}")) + })?; + let globals = globals.as_object().ok_or_else(|| { + JsValue::from_str("DecodalLanguageService `globals` must be an object") + })?; + globals + .iter() + .map(|(name, value)| { + host_value::from_json(value) + .map(|value| (name.clone(), value)) + .map_err(|error| { + JsValue::from_str(&format!("invalid global `{name}`: {error}")) + }) + }) + .collect::, _>>()? + }; + + Ok(Self { + globals, + load_import: optional_function(&options, "loadImport")?, + complete_import: optional_function(&options, "completeImport")?, + }) + } +} + +impl HostEnvironment for JsEnvironment { + type Loader = JsLoader; + + fn create_loader(&self) -> Self::Loader { + JsLoader { + load_import: self.load_import.clone(), + complete_import: self.complete_import.clone(), + } + } + + fn configure_engine(&self, engine: &mut Engine) -> decodal::Result<()> { + for (name, value) in &self.globals { + engine.bind_global(name, value.clone())?; + } + Ok(()) + } +} + +pub(crate) struct JsLoader { + load_import: Option, + complete_import: Option, +} + +impl ImportLoader for JsLoader { + fn load( + &mut self, + current_key: Option<&str>, + specifier: &str, + ) -> decodal::Result { + let callback = self.load_import.as_ref().ok_or_else(|| { + import_error(format!( + "no JavaScript `loadImport` callback is configured for `{specifier}`" + )) + })?; + let current_key = current_key.map(JsValue::from_str).unwrap_or(JsValue::NULL); + let specifier = JsValue::from_str(specifier); + let loaded = callback + .call2(&JsValue::UNDEFINED, ¤t_key, &specifier) + .map_err(|error| import_error(js_error_message(error)))?; + let loaded: Value = serde_wasm_bindgen::from_value(loaded) + .map_err(|error| import_error(format!("invalid `loadImport` result: {error}")))?; + loaded_import(&loaded).map_err(import_error) + } + + fn complete_import( + &mut self, + current_key: Option<&str>, + prefix: &str, + ) -> decodal::Result> { + let Some(callback) = &self.complete_import else { + return Ok(Vec::new()); + }; + let current_key = current_key.map(JsValue::from_str).unwrap_or(JsValue::NULL); + let prefix = JsValue::from_str(prefix); + let candidates = callback + .call2(&JsValue::UNDEFINED, ¤t_key, &prefix) + .map_err(|error| import_error(js_error_message(error)))?; + let candidates: Value = serde_wasm_bindgen::from_value(candidates) + .map_err(|error| import_error(format!("invalid `completeImport` result: {error}")))?; + import_candidates(&candidates).map_err(import_error) + } +} + +fn loaded_import(value: &Value) -> Result { + let fields = value + .as_object() + .ok_or_else(|| String::from("`loadImport` must return an object"))?; + let kind = string_field(fields, "kind")?; + let key = string_field(fields, "key")?; + match kind { + "source" => { + let source = string_field(fields, "source")?; + let name = fields.get("name").and_then(Value::as_str).unwrap_or(key); + Ok(LoadedImport::source(key, name, source)) + } + "value" => { + let value = fields + .get("value") + .ok_or_else(|| String::from("value import requires `value`"))?; + let value = host_value::from_json(value) + .map_err(|error| format!("invalid imported value: {error}"))?; + Ok(LoadedImport::value(key, value)) + } + kind => Err(format!("unknown `loadImport` result kind `{kind}`")), + } +} + +fn import_candidates(value: &Value) -> Result, String> { + value + .as_array() + .ok_or_else(|| String::from("`completeImport` must return an array"))? + .iter() + .enumerate() + .map(|(index, value)| match value { + Value::String(specifier) => Ok(ImportCandidate::new(specifier)), + Value::Object(fields) => { + let mut candidate = ImportCandidate::new(string_field(fields, "specifier")?); + if let Some(detail) = fields.get("detail") { + let detail = detail.as_str().ok_or_else(|| { + format!("import candidate {index} `detail` must be a string") + })?; + candidate = candidate.with_detail(detail); + } + Ok(candidate) + } + _ => Err(format!( + "import candidate {index} must be a string or object" + )), + }) + .collect() +} + +fn string_field<'a>( + fields: &'a serde_json::Map, + name: &str, +) -> Result<&'a str, String> { + fields + .get(name) + .and_then(Value::as_str) + .ok_or_else(|| format!("`loadImport` result requires string `{name}`")) +} + +fn property(options: &JsValue, name: &str) -> Result { + if options.is_null() || options.is_undefined() { + return Ok(JsValue::UNDEFINED); + } + Reflect::get(options, &JsValue::from_str(name)) +} + +fn optional_function(options: &JsValue, name: &str) -> Result, JsValue> { + let value = property(options, name)?; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + value + .dyn_into::() + .map(Some) + .map_err(|_| JsValue::from_str(&format!("`{name}` must be a function"))) +} + +fn js_error_message(value: JsValue) -> String { + if let Some(message) = value.as_string() { + return message; + } + Reflect::get(&value, &JsValue::from_str("message")) + .ok() + .and_then(|message| message.as_string()) + .unwrap_or_else(|| String::from("JavaScript import callback failed")) +} + +fn import_error(message: impl Into) -> Diagnostic { + Diagnostic::new(DiagnosticKind::Import, Span::default(), message) +} diff --git a/packages/decodal-wasm/README.md b/packages/decodal-wasm/README.md index 27365eb..d2fbf0e 100644 --- a/packages/decodal-wasm/README.md +++ b/packages/decodal-wasm/README.md @@ -2,7 +2,7 @@ WebAssembly runtime package for Decodal. -This package exposes the Decodal evaluator to browsers and other JavaScript runtimes that can load WebAssembly modules. +This package exposes the Decodal evaluator and shared language service to browsers and other JavaScript runtimes that can load WebAssembly modules. It is generated from the Rust crate in `crates/decodal-wasm` with `wasm-pack` and is used by the official playground. Use `decodal-codemirror` separately when you need CodeMirror 6 language support. @@ -22,15 +22,48 @@ deno add jsr:@hare/decodal-wasm ## Usage ```js -import init, { evaluateProject } from 'decodal-wasm'; +import init, { DecodalLanguageService } from 'decodal-wasm'; await init(); -const result = evaluateProject('main.dcdl', JSON.stringify({ - 'main.dcdl': 'Server = { port = Int default 8080; };', -})); +const files = { + 'main.dcdl': 'let schema = import "./schema.dcdl"; in schema.Server', + 'schema.dcdl': 'Server = App.Server & { port = 8080; };', +}; + +const service = new DecodalLanguageService({ + globals: { + App: { + Server: { + port: { $decodal: 'Int' }, + }, + }, + }, + loadImport(_currentKey, specifier) { + const key = specifier.startsWith('./') ? specifier.slice(2) : specifier; + return { kind: 'source', key, name: key, source: files[key] }; + }, + completeImport() { + return ['./schema.dcdl']; + }, +}); + +const result = service.evaluate('main.dcdl', 'main.dcdl', files['main.dcdl']); console.log(JSON.parse(result)); + +const completion = service.complete( + 'main.dcdl', + 'App.Server.po', + 'App.Server.po'.length, + false, +); + +console.log(JSON.parse(completion)); ``` -The exported functions return JSON strings so callers can handle diagnostics and successful results without depending on Rust data structures. +`globals`, `loadImport`, and `completeImport` are host-owned. The package does not assume a filesystem or virtual project model. Import callbacks are synchronous; preload or cache remote content before evaluation. + +Plain strings, numbers, booleans, arrays, and objects are concrete host values. Use `{ $decodal: 'String' | 'Int' | 'Float' | 'Bool' }` for primitive schemas and `{ $decodal: 'Array', item: value }` for array schemas. Descriptors also accept `constraints` and `default`. + +Methods return JSON strings so callers can handle diagnostics and successful results without depending on Rust data structures. diff --git a/packages/decodal-wasm/decodal_wasm.d.ts b/packages/decodal-wasm/decodal_wasm.d.ts index e88b72f..d691d47 100644 --- a/packages/decodal-wasm/decodal_wasm.d.ts +++ b/packages/decodal-wasm/decodal_wasm.d.ts @@ -1,20 +1,105 @@ /* tslint:disable */ /* eslint-disable */ -export function evaluate(source: string): string; +/** A concrete JavaScript value or Decodal schema descriptor supplied by the host. */ +export type DecodalHostValue = + | string + | number + | boolean + | DecodalHostValue[] + | { [field: string]: DecodalHostValue } + | DecodalPrimitiveDescriptor + | DecodalArrayDescriptor + | DecodalAbstractDescriptor; -export function evaluateProject(entry: string, files_json: string): string; +export type DecodalPrimitiveType = 'String' | 'Int' | 'Float' | 'Bool'; + +export type DecodalConstraint = + | { kind: 'type'; value: DecodalPrimitiveType } + | { kind: 'compare'; op: '>' | '>=' | '<' | '<='; value: string | number | boolean } + | { kind: 'regex'; value: string } + | { kind: 'predicate'; value: string }; + +export interface DecodalPrimitiveDescriptor { + $decodal: DecodalPrimitiveType; + constraints?: DecodalConstraint[]; + default?: DecodalHostValue; +} + +export interface DecodalArrayDescriptor { + $decodal: 'Array'; + item: DecodalHostValue; + constraints?: DecodalConstraint[]; + default?: DecodalHostValue; +} + +export interface DecodalAbstractDescriptor { + $decodal: 'Abstract'; + constraints?: DecodalConstraint[]; + default?: DecodalHostValue; +} + +export type DecodalLoadedImport = + | { kind: 'source'; key: string; name?: string; source: string } + | { kind: 'value'; key: string; value: DecodalHostValue }; + +export type DecodalImportCandidate = + | string + | { specifier: string; detail?: string }; + +/** Host-owned environment shared by evaluation and language tooling. */ +export interface DecodalEnvironment { + globals?: Record; + /** Synchronous import callback. Preload or cache asynchronous resources first. */ + loadImport?: (currentKey: string | null, specifier: string) => DecodalLoadedImport; + /** Synchronous import completion callback. */ + completeImport?: (currentKey: string | null, prefix: string) => DecodalImportCandidate[]; +} + +/** + * A browser-facing language service configured entirely by its JavaScript host. + * + * The host owns globals, import loading, and import completion. This keeps + * filesystem, network, and virtual-project policy outside the WASM package. + */ +export class DecodalLanguageService { + free(): void; + [Symbol.dispose](): void; + /** + * Completes a source using the same injected environment as evaluation. + * + * Positions and returned ranges are UTF-16 offsets, matching browser + * editors and the Language Server Protocol. + */ + complete(key: string, source: string, position: number, explicit: boolean): string; + /** + * Evaluates a source using the injected globals and import loader. + */ + evaluate(key: string, name: string, source: string): string; + constructor(options?: DecodalEnvironment); +} + +/** + * Evaluates one standalone Decodal source with no host globals or imports. + */ +export function evaluate(source: string): string; export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; export interface InitOutput { readonly memory: WebAssembly.Memory; + readonly __wbg_decodallanguageservice_free: (a: number, b: number) => void; + readonly decodallanguageservice_complete: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number]; + readonly decodallanguageservice_evaluate: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number]; + readonly decodallanguageservice_new: (a: any) => [number, number, number]; readonly evaluate: (a: number, b: number) => [number, number]; - readonly evaluateProject: (a: number, b: number, c: number, d: number) => [number, number]; - readonly __wbindgen_externrefs: WebAssembly.Table; readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_exn_store: (a: number) => void; + readonly __externref_table_alloc: () => number; + readonly __wbindgen_externrefs: WebAssembly.Table; readonly __wbindgen_free: (a: number, b: number, c: number) => void; + readonly __externref_table_dealloc: (a: number) => void; readonly __wbindgen_start: () => void; } diff --git a/packages/decodal-wasm/decodal_wasm.js b/packages/decodal-wasm/decodal_wasm.js index 800a76f..80c07ca 100644 --- a/packages/decodal-wasm/decodal_wasm.js +++ b/packages/decodal-wasm/decodal_wasm.js @@ -1,6 +1,91 @@ /* @ts-self-types="./decodal_wasm.d.ts" */ /** + * A browser-facing language service configured entirely by its JavaScript host. + * + * The host owns globals, import loading, and import completion. This keeps + * filesystem, network, and virtual-project policy outside the WASM package. + */ +export class DecodalLanguageService { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + DecodalLanguageServiceFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_decodallanguageservice_free(ptr, 0); + } + /** + * Completes a source using the same injected environment as evaluation. + * + * Positions and returned ranges are UTF-16 offsets, matching browser + * editors and the Language Server Protocol. + * @param {string} key + * @param {string} source + * @param {number} position + * @param {boolean} explicit + * @returns {string} + */ + complete(key, source, position, explicit) { + let deferred3_0; + let deferred3_1; + try { + const ptr0 = passStringToWasm0(key, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(source, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.decodallanguageservice_complete(this.__wbg_ptr, ptr0, len0, ptr1, len1, position, explicit); + deferred3_0 = ret[0]; + deferred3_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); + } + } + /** + * Evaluates a source using the injected globals and import loader. + * @param {string} key + * @param {string} name + * @param {string} source + * @returns {string} + */ + evaluate(key, name, source) { + let deferred4_0; + let deferred4_1; + try { + const ptr0 = passStringToWasm0(key, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ptr2 = passStringToWasm0(source, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len2 = WASM_VECTOR_LEN; + const ret = wasm.decodallanguageservice_evaluate(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2); + deferred4_0 = ret[0]; + deferred4_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred4_0, deferred4_1, 1); + } + } + /** + * @param {any} options + */ + constructor(options) { + const ret = wasm.decodallanguageservice_new(options); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + this.__wbg_ptr = ret[0]; + DecodalLanguageServiceFinalization.register(this, this.__wbg_ptr, this); + return this; + } +} +if (Symbol.dispose) DecodalLanguageService.prototype[Symbol.dispose] = DecodalLanguageService.prototype.free; + +/** + * Evaluates one standalone Decodal source with no host globals or imports. * @param {string} source * @returns {string} */ @@ -18,31 +103,204 @@ export function evaluate(source) { wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); } } - -/** - * @param {string} entry - * @param {string} files_json - * @returns {string} - */ -export function evaluateProject(entry, files_json) { - let deferred3_0; - let deferred3_1; - try { - const ptr0 = passStringToWasm0(entry, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ptr1 = passStringToWasm0(files_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; - const ret = wasm.evaluateProject(ptr0, len0, ptr1, len1); - deferred3_0 = ret[0]; - deferred3_1 = ret[1]; - return getStringFromWasm0(ret[0], ret[1]); - } finally { - wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); - } -} function __wbg_get_imports() { const import0 = { __proto__: null, + __wbg_Error_fdd633d4bb5dd76a: function(arg0, arg1) { + const ret = Error(getStringFromWasm0(arg0, arg1)); + return ret; + }, + __wbg_String_8564e559799eccda: function(arg0, arg1) { + const ret = String(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_bigint_get_as_i64_d9e915702856f831: function(arg0, arg1) { + const v = arg1; + const ret = typeof(v) === 'bigint' ? v : undefined; + getDataViewMemory0().setBigInt64(arg0 + 8 * 1, isLikeNone(ret) ? BigInt(0) : ret, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); + }, + __wbg___wbindgen_boolean_get_edaed31a367ce1bd: function(arg0) { + const v = arg0; + const ret = typeof(v) === 'boolean' ? v : undefined; + return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; + }, + __wbg___wbindgen_debug_string_8a447059637473e2: function(arg0, arg1) { + const ret = debugString(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_in_4990f46af709e33c: function(arg0, arg1) { + const ret = arg0 in arg1; + return ret; + }, + __wbg___wbindgen_is_bigint_90b5ccfe67c78460: function(arg0) { + const ret = typeof(arg0) === 'bigint'; + return ret; + }, + __wbg___wbindgen_is_function_acc5528be2b923f2: function(arg0) { + const ret = typeof(arg0) === 'function'; + return ret; + }, + __wbg___wbindgen_is_null_6d937fbfb6478470: function(arg0) { + const ret = arg0 === null; + return ret; + }, + __wbg___wbindgen_is_object_0beba4a1980d3eea: function(arg0) { + const val = arg0; + const ret = typeof(val) === 'object' && val !== null; + return ret; + }, + __wbg___wbindgen_is_undefined_721f8decd50c87a3: function(arg0) { + const ret = arg0 === undefined; + return ret; + }, + __wbg___wbindgen_jsval_eq_4e8c38722cb8ff51: function(arg0, arg1) { + const ret = arg0 === arg1; + return ret; + }, + __wbg___wbindgen_jsval_loose_eq_4b9aba9e5b3c4582: function(arg0, arg1) { + const ret = arg0 == arg1; + return ret; + }, + __wbg___wbindgen_number_get_1cc01dd708740256: function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'number' ? obj : undefined; + getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); + }, + __wbg___wbindgen_string_get_71bb4348194e31f0: function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_throw_ea4887a5f8f9a9db: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg_call_0e855b388e315e17: function() { return handleError(function (arg0, arg1, arg2, arg3) { + const ret = arg0.call(arg1, arg2, arg3); + return ret; + }, arguments); }, + __wbg_call_8e98ed2f3c86c4b5: function() { return handleError(function (arg0, arg1) { + const ret = arg0.call(arg1); + return ret; + }, arguments); }, + __wbg_done_b62d4a7d2286852a: function(arg0) { + const ret = arg0.done; + return ret; + }, + __wbg_entries_c261c3fa1f281256: function(arg0) { + const ret = Object.entries(arg0); + return ret; + }, + __wbg_get_197a3fe98f169e38: function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; + }, + __wbg_get_9a29be2cb383ed9a: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.get(arg0, arg1); + return ret; + }, arguments); }, + __wbg_get_dddb90ff5d27a080: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.get(arg0, arg1); + return ret; + }, arguments); }, + __wbg_get_unchecked_54a4374c38e08460: function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; + }, + __wbg_instanceof_ArrayBuffer_2a7bb09fee70c2da: function(arg0) { + let result; + try { + result = arg0 instanceof ArrayBuffer; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Map_afa18d5840c04c15: function(arg0) { + let result; + try { + result = arg0 instanceof Map; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Uint8Array_f080092dc70f5d58: function(arg0) { + let result; + try { + result = arg0 instanceof Uint8Array; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_isArray_145a34fd0a38d37b: function(arg0) { + const ret = Array.isArray(arg0); + return ret; + }, + __wbg_isSafeInteger_a3389a198582f5f6: function(arg0) { + const ret = Number.isSafeInteger(arg0); + return ret; + }, + __wbg_iterator_cc47ba25a2be735a: function() { + const ret = Symbol.iterator; + return ret; + }, + __wbg_length_589238bdcf171f0e: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_length_c6054974c0a6cdb9: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_new_81880fb5002cb255: function(arg0) { + const ret = new Uint8Array(arg0); + return ret; + }, + __wbg_next_0c4066e251d2eff9: function() { return handleError(function (arg0) { + const ret = arg0.next(); + return ret; + }, arguments); }, + __wbg_next_402fa10b59ab20c3: function(arg0) { + const ret = arg0.next; + return ret; + }, + __wbg_prototypesetcall_d721637c7ca66eb8: function(arg0, arg1, arg2) { + Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); + }, + __wbg_value_49f783bb59765962: function(arg0) { + const ret = arg0.value; + return ret; + }, + __wbindgen_cast_0000000000000001: function(arg0) { + // Cast intrinsic for `I64 -> Externref`. + const ret = arg0; + return ret; + }, + __wbindgen_cast_0000000000000002: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_cast_0000000000000003: function(arg0) { + // Cast intrinsic for `U64 -> Externref`. + const ret = BigInt.asUintN(64, arg0); + return ret; + }, __wbindgen_init_externref_table: function() { const table = wasm.__wbindgen_externrefs; const offset = table.grow(4); @@ -59,6 +317,94 @@ function __wbg_get_imports() { }; } +const DecodalLanguageServiceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_decodallanguageservice_free(ptr, 1)); + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_externrefs.set(idx, obj); + return idx; +} + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}`; + } + if (type == 'string') { + return `"${val}"`; + } + if (type == 'symbol') { + const description = val.description; + if (description == null) { + return 'Symbol'; + } else { + return `Symbol(${description})`; + } + } + if (type == 'function') { + const name = val.name; + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})`; + } else { + return 'Function'; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = '['; + if (length > 0) { + debug += debugString(val[0]); + } + for(let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]); + } + debug += ']'; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')'; + } catch (_) { + return 'Object'; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + function getStringFromWasm0(ptr, len) { return decodeText(ptr >>> 0, len); } @@ -71,6 +417,19 @@ function getUint8ArrayMemory0() { return cachedUint8ArrayMemory0; } +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + const idx = addToExternrefTable0(e); + wasm.__wbindgen_exn_store(idx); + } +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + function passStringToWasm0(arg, malloc, realloc) { if (realloc === undefined) { const buf = cachedTextEncoder.encode(arg); @@ -108,6 +467,12 @@ function passStringToWasm0(arg, malloc, realloc) { return ptr; } +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); cachedTextDecoder.decode(); const MAX_SAFARI_DECODE_BYTES = 2146435072; @@ -142,6 +507,7 @@ function __wbg_finalize_init(instance, module) { wasmInstance = instance; wasm = instance.exports; wasmModule = module; + cachedDataViewMemory0 = null; cachedUint8ArrayMemory0 = null; wasm.__wbindgen_start(); return wasm; diff --git a/packages/decodal-wasm/decodal_wasm_bg.wasm b/packages/decodal-wasm/decodal_wasm_bg.wasm index b49e380..eefa87d 100644 Binary files a/packages/decodal-wasm/decodal_wasm_bg.wasm and b/packages/decodal-wasm/decodal_wasm_bg.wasm differ diff --git a/packages/decodal-wasm/decodal_wasm_bg.wasm.d.ts b/packages/decodal-wasm/decodal_wasm_bg.wasm.d.ts index 4f894cc..80e82f2 100644 --- a/packages/decodal-wasm/decodal_wasm_bg.wasm.d.ts +++ b/packages/decodal-wasm/decodal_wasm_bg.wasm.d.ts @@ -1,10 +1,16 @@ /* tslint:disable */ /* eslint-disable */ export const memory: WebAssembly.Memory; +export const __wbg_decodallanguageservice_free: (a: number, b: number) => void; +export const decodallanguageservice_complete: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number]; +export const decodallanguageservice_evaluate: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number]; +export const decodallanguageservice_new: (a: any) => [number, number, number]; export const evaluate: (a: number, b: number) => [number, number]; -export const evaluateProject: (a: number, b: number, c: number, d: number) => [number, number]; -export const __wbindgen_externrefs: WebAssembly.Table; export const __wbindgen_malloc: (a: number, b: number) => number; export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_exn_store: (a: number) => void; +export const __externref_table_alloc: () => number; +export const __wbindgen_externrefs: WebAssembly.Table; export const __wbindgen_free: (a: number, b: number, c: number) => void; +export const __externref_table_dealloc: (a: number) => void; export const __wbindgen_start: () => void; diff --git a/packages/decodal-wasm/mod.ts b/packages/decodal-wasm/mod.ts index c67e7fb..cf50ca8 100644 --- a/packages/decodal-wasm/mod.ts +++ b/packages/decodal-wasm/mod.ts @@ -1,66 +1,26 @@ /** - * Browser and JavaScript runtime bindings for the Decodal evaluator. - * - * This module wraps the wasm-bindgen output generated from the Rust - * `decodal-wasm` crate and exposes stable, documented entrypoints for JSR - * users. The exported evaluator functions return JSON strings. + * Host-configurable Decodal evaluator and language service for JavaScript. * * @module */ -import initWasm, { - evaluate as evaluateImpl, - evaluateProject as evaluateProjectImpl, - initSync as initSyncImpl, +export { + DecodalLanguageService, + default, + evaluate, + initSync, } from './decodal_wasm.js'; -/** Input accepted by the wasm-bindgen initializer. */ -export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; - -/** Initialized Decodal WebAssembly exports. */ -export interface InitOutput { - /** Linear memory exported by the WebAssembly module. */ - readonly memory: WebAssembly.Memory; - readonly evaluate: (source: number, len: number) => [number, number, number, number]; - readonly evaluateProject: (entry: number, entryLen: number, files: number, filesLen: number) => [number, number, number, number]; -} - -/** - * Initialize the Decodal WebAssembly runtime asynchronously. - * - * @param moduleOrPath - Optional WebAssembly module, bytes, response, URL, or request. - * @returns The initialized WebAssembly exports. - */ -export default async function initDecodalRuntime(moduleOrPath?: InitInput): Promise { - return await initWasm(moduleOrPath) as InitOutput; -} - -/** - * Initialize the Decodal WebAssembly runtime synchronously. - * - * @param module - Compiled module, bytes, or wasm-bindgen sync initialization input. - * @returns The initialized WebAssembly exports. - */ -export function initSync(module: WebAssembly.Module | BufferSource): InitOutput { - return initSyncImpl(module) as InitOutput; -} - -/** - * Evaluate and materialize a single Decodal source string. - * - * @param source - Decodal source text. - * @returns A JSON string containing either `{ ok: true, output }` or `{ ok: false, error }`. - */ -export function evaluate(source: string): string { - return evaluateImpl(source); -} - -/** - * Evaluate and materialize a virtual multi-file Decodal project. - * - * @param entry - Entry point file path to materialize. - * @param filesJson - JSON object mapping virtual file paths to source strings. - * @returns A JSON string containing either `{ ok: true, output }` or `{ ok: false, error }`. - */ -export function evaluateProject(entry: string, filesJson: string): string { - return evaluateProjectImpl(entry, filesJson); -} +export type { + DecodalAbstractDescriptor, + DecodalArrayDescriptor, + DecodalConstraint, + DecodalEnvironment, + DecodalHostValue, + DecodalImportCandidate, + DecodalLoadedImport, + DecodalPrimitiveDescriptor, + DecodalPrimitiveType, + InitInput, + InitOutput, + SyncInitInput, +} from './decodal_wasm.js'; diff --git a/packages/decodal-wasm/package.json b/packages/decodal-wasm/package.json index dbcd228..9a8c4b3 100644 --- a/packages/decodal-wasm/package.json +++ b/packages/decodal-wasm/package.json @@ -1,7 +1,7 @@ { "name": "decodal-wasm", "type": "module", - "description": "WebAssembly wrapper for evaluating Decodal in browser playgrounds.", + "description": "Host-configurable Decodal evaluator and language service for JavaScript runtimes.", "version": "0.1.3", "license": "MIT OR Apache-2.0", "repository": { diff --git a/site/decodal-site/scripts/prepare-wasm-package.mjs b/site/decodal-site/scripts/prepare-wasm-package.mjs index 733a930..672612e 100644 --- a/site/decodal-site/scripts/prepare-wasm-package.mjs +++ b/site/decodal-site/scripts/prepare-wasm-package.mjs @@ -3,6 +3,73 @@ import { resolve } from 'node:path'; const packageDir = resolve(import.meta.dirname, '../../../packages/decodal-wasm'); +const declarationPath = resolve(packageDir, 'decodal_wasm.d.ts'); +let declarations = readFileSync(declarationPath, 'utf8'); +const hostTypes = ` +/** A concrete JavaScript value or Decodal schema descriptor supplied by the host. */ +export type DecodalHostValue = + | string + | number + | boolean + | DecodalHostValue[] + | { [field: string]: DecodalHostValue } + | DecodalPrimitiveDescriptor + | DecodalArrayDescriptor + | DecodalAbstractDescriptor; + +export type DecodalPrimitiveType = 'String' | 'Int' | 'Float' | 'Bool'; + +export type DecodalConstraint = + | { kind: 'type'; value: DecodalPrimitiveType } + | { kind: 'compare'; op: '>' | '>=' | '<' | '<='; value: string | number | boolean } + | { kind: 'regex'; value: string } + | { kind: 'predicate'; value: string }; + +export interface DecodalPrimitiveDescriptor { + $decodal: DecodalPrimitiveType; + constraints?: DecodalConstraint[]; + default?: DecodalHostValue; +} + +export interface DecodalArrayDescriptor { + $decodal: 'Array'; + item: DecodalHostValue; + constraints?: DecodalConstraint[]; + default?: DecodalHostValue; +} + +export interface DecodalAbstractDescriptor { + $decodal: 'Abstract'; + constraints?: DecodalConstraint[]; + default?: DecodalHostValue; +} + +export type DecodalLoadedImport = + | { kind: 'source'; key: string; name?: string; source: string } + | { kind: 'value'; key: string; value: DecodalHostValue }; + +export type DecodalImportCandidate = + | string + | { specifier: string; detail?: string }; + +/** Host-owned environment shared by evaluation and language tooling. */ +export interface DecodalEnvironment { + globals?: Record; + /** Synchronous import callback. Preload or cache asynchronous resources first. */ + loadImport?: (currentKey: string | null, specifier: string) => DecodalLoadedImport; + /** Synchronous import completion callback. */ + completeImport?: (currentKey: string | null, prefix: string) => DecodalImportCandidate[]; +} +`; +if (!declarations.includes('export interface DecodalEnvironment')) { + declarations = declarations.replace('/* eslint-disable */\n', `/* eslint-disable */\n${hostTypes}`); +} +declarations = declarations.replace( + 'constructor(options: any);', + 'constructor(options?: DecodalEnvironment);', +); +writeFileSync(declarationPath, declarations); + writeFileSync( resolve(packageDir, '.gitignore'), '# wasm-pack output is committed for the browser runtime package.\n', @@ -19,7 +86,7 @@ writeFileSync( WebAssembly runtime package for Decodal. -This package exposes the Decodal evaluator to browsers and other JavaScript runtimes that can load WebAssembly modules. +This package exposes the Decodal evaluator and shared language service to browsers and other JavaScript runtimes that can load WebAssembly modules. It is generated from the Rust crate in \`crates/decodal-wasm\` with \`wasm-pack\` and is used by the official playground. Use \`decodal-codemirror\` separately when you need CodeMirror 6 language support. @@ -39,18 +106,51 @@ deno add jsr:@hare/decodal-wasm ## Usage \`\`\`js -import init, { evaluateProject } from 'decodal-wasm'; +import init, { DecodalLanguageService } from 'decodal-wasm'; await init(); -const result = evaluateProject('main.dcdl', JSON.stringify({ - 'main.dcdl': 'Server = { port = Int default 8080; };', -})); +const files = { + 'main.dcdl': 'let schema = import "./schema.dcdl"; in schema.Server', + 'schema.dcdl': 'Server = App.Server & { port = 8080; };', +}; + +const service = new DecodalLanguageService({ + globals: { + App: { + Server: { + port: { $decodal: 'Int' }, + }, + }, + }, + loadImport(_currentKey, specifier) { + const key = specifier.startsWith('./') ? specifier.slice(2) : specifier; + return { kind: 'source', key, name: key, source: files[key] }; + }, + completeImport() { + return ['./schema.dcdl']; + }, +}); + +const result = service.evaluate('main.dcdl', 'main.dcdl', files['main.dcdl']); console.log(JSON.parse(result)); + +const completion = service.complete( + 'main.dcdl', + 'App.Server.po', + 'App.Server.po'.length, + false, +); + +console.log(JSON.parse(completion)); \`\`\` -The exported functions return JSON strings so callers can handle diagnostics and successful results without depending on Rust data structures. +\`globals\`, \`loadImport\`, and \`completeImport\` are host-owned. The package does not assume a filesystem or virtual project model. Import callbacks are synchronous; preload or cache remote content before evaluation. + +Plain strings, numbers, booleans, arrays, and objects are concrete host values. Use \`{ $decodal: 'String' | 'Int' | 'Float' | 'Bool' }\` for primitive schemas and \`{ $decodal: 'Array', item: value }\` for array schemas. Descriptors also accept \`constraints\` and \`default\`. + +Methods return JSON strings so callers can handle diagnostics and successful results without depending on Rust data structures. `, ); diff --git a/site/decodal-site/src/scripts/playground-completion.js b/site/decodal-site/src/scripts/playground-completion.js index 97dea80..7f50f6c 100644 --- a/site/decodal-site/src/scripts/playground-completion.js +++ b/site/decodal-site/src/scripts/playground-completion.js @@ -1,256 +1,46 @@ -import { parser } from 'decodal-codemirror/parser'; +const completionTypes = { + keyword: 'keyword', + constant: 'constant', + type: 'type', + variable: 'variable', + namespace: 'namespace', + property: 'property', + file: 'text', +}; -const identifierPattern = /[A-Za-z_][A-Za-z0-9_]*$/; -const memberPattern = /([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\.([A-Za-z_][A-Za-z0-9_]*)?$/; -const importPattern = /\bimport\s+"([^"\n]*)$/; - -const builtinCompletions = [ - { label: 'let', type: 'keyword', detail: 'local bindings', boost: 5 }, - { label: 'in', type: 'keyword', detail: 'let body' }, - { label: 'match', type: 'keyword', detail: 'pattern matching', boost: 5 }, - { label: 'import', type: 'keyword', detail: 'load a module', boost: 5 }, - { label: 'default', type: 'keyword', detail: 'fallback value' }, - { label: 'true', type: 'constant', detail: 'Bool' }, - { label: 'false', type: 'constant', detail: 'Bool' }, - { label: 'String', type: 'type', detail: 'string constraint', boost: 5 }, - { label: 'Int', type: 'type', detail: 'integer constraint', boost: 5 }, - { label: 'Float', type: 'type', detail: 'float constraint', boost: 5 }, - { label: 'Bool', type: 'type', detail: 'boolean constraint', boost: 5 }, -]; - -export function createProjectCompletionSource({ getFiles, getActivePath }) { - return (context) => getProjectCompletions({ - source: context.state.doc.toString(), - position: context.pos, - explicit: context.explicit, - files: getFiles(), - activePath: getActivePath(), - }); -} - -export function getProjectCompletions({ - source, - position, - explicit = false, - files = {}, - activePath = '', -}) { - const before = source.slice(0, position); - const projectFiles = new Map(Object.entries(files)); - const normalizedActivePath = normalizePath(activePath); - if (normalizedActivePath) projectFiles.set(normalizedActivePath, source); - - const importMatch = before.match(importPattern); - if (importMatch) { - return completeImportPath(position, importMatch[1], projectFiles, normalizedActivePath); - } - - if (isLiteralOrComment(source, position)) return null; - - const memberMatch = before.match(memberPattern); - if (memberMatch) { - return completeMemberPath({ - source, - position, - basePath: memberMatch[1], - prefix: memberMatch[2] ?? '', - files: projectFiles, - activePath: normalizedActivePath, - }); - } - - const word = before.match(identifierPattern); - if (!word && !explicit) return null; - const options = [...builtinCompletions]; - const currentFields = collectFieldTree(source); - for (const [label, children] of currentFields) { - options.push({ - label, - type: children.size ? 'namespace' : 'variable', - detail: children.size ? 'local object' : 'local value', - boost: 20, - }); - } - for (const label of collectParameters(source)) { - options.push({ label, type: 'variable', detail: 'parameter', boost: 20 }); - } - for (const [label, specifier] of collectImportBindings(source)) { - options.push({ label, type: 'namespace', detail: specifier, boost: 30 }); - } - - return { - from: word ? position - word[0].length : position, - options: uniqueOptions(options), - validFor: /^[A-Za-z_][A-Za-z0-9_]*$/, +export function createLanguageServiceCompletionSource({ complete, getActivePath }) { + return (context) => { + const activePath = getActivePath(); + let response; + try { + response = complete({ + key: activePath, + source: context.state.doc.toString(), + position: context.pos, + explicit: context.explicit, + }); + if (!response) return null; + if (typeof response === 'string') response = JSON.parse(response); + } catch (_error) { + // The editor can become interactive just before WASM initialization + // finishes. A later completion request will use the initialized service. + return null; + } + if (!response.ok || !response.completion) return null; + return completionResultToCodeMirror(response.completion); }; } -function completeImportPath(position, prefix, files, activePath) { - const options = []; - for (const path of [...files.keys()].sort()) { - if (!path || path === activePath) continue; - const label = prefix.startsWith('/') - ? `/${path}` - : relativeImportPath(activePath, path); - options.push({ - label, - apply: label, - type: 'text', - detail: 'project file', - }); - } +export function completionResultToCodeMirror(completion) { + const fileCompletion = completion.options.some((option) => option.kind === 'file'); return { - from: position - prefix.length, - options, - validFor: /^[^"\n]*$/, - }; -} - -function completeMemberPath({ source, position, basePath, prefix, files, activePath }) { - const parts = basePath.split('.'); - const imports = collectImportBindings(source); - let fields; - let detail = basePath; - - const importSpecifier = imports.get(parts[0]); - if (importSpecifier) { - const importedPath = resolveImportPath(activePath, importSpecifier); - const importedSource = importedPath ? files.get(importedPath) : undefined; - if (importedSource === undefined) return null; - fields = collectFieldTree(importedSource); - parts.shift(); - detail = importedPath; - } else { - fields = collectFieldTree(source); - } - - for (const part of parts) { - fields = fields.get(part); - if (!fields) return null; - } - - return { - from: position - prefix.length, - options: [...fields.entries()].map(([label, children]) => ({ - label, - type: children.size ? 'namespace' : 'property', - detail, - boost: 30, + from: completion.from, + options: completion.options.map((option) => ({ + label: option.label, + type: completionTypes[option.kind] ?? 'text', + detail: option.detail ?? undefined, + boost: option.priority ?? 0, })), - validFor: /^[A-Za-z_][A-Za-z0-9_]*$/, + validFor: fileCompletion ? /^[^"\n]*$/ : /^[A-Za-z_][A-Za-z0-9_]*$/, }; } - -function collectFieldTree(source) { - const fields = new Map(); - collectDefinitions(parser.parse(source).topNode, fields, source); - return fields; -} - -function collectDefinitions(node, target, source) { - for (let child = node.firstChild; child; child = child.nextSibling) { - if (child.name === 'FieldDefinition') { - collectDefinition(child, target, source); - } else { - collectDefinitions(child, target, source); - } - } -} - -function collectDefinition(node, target, source) { - const fieldPath = findDirectChild(node, 'FieldPath'); - if (!fieldPath) return; - const parts = source.slice(fieldPath.from, fieldPath.to).match(/[A-Za-z_][A-Za-z0-9_]*/g); - if (!parts?.length) return; - let nested = target; - for (const part of parts) { - if (!nested.has(part)) nested.set(part, new Map()); - nested = nested.get(part); - } - for (let child = node.firstChild; child; child = child.nextSibling) { - if (child !== fieldPath) collectDefinitions(child, nested, source); - } -} - -function collectParameters(source) { - const parameters = new Set(); - const tree = parser.parse(source); - const visit = (node) => { - for (let child = node.firstChild; child; child = child.nextSibling) { - if (child.name === 'Parameter') { - const identifier = findDirectChild(child, 'Identifier'); - if (identifier) parameters.add(source.slice(identifier.from, identifier.to)); - } - visit(child); - } - }; - visit(tree.topNode); - return parameters; -} - -function collectImportBindings(source) { - const bindings = new Map(); - const pattern = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*import\s+"([^"\n]+)"/g; - for (const match of source.matchAll(pattern)) bindings.set(match[1], match[2]); - return bindings; -} - -function isLiteralOrComment(source, position) { - const tree = parser.parse(source); - let node = tree.resolve(Math.max(0, position - 1), -1); - while (node) { - if (node.name === 'String' || node.name === 'Regex' || node.name === 'Comment') return true; - node = node.parent; - } - return false; -} - -function findDirectChild(node, name) { - for (let child = node.firstChild; child; child = child.nextSibling) { - if (child.name === name) return child; - } - return null; -} - -function resolveImportPath(currentPath, specifier) { - const base = specifier.startsWith('/') - ? [] - : currentPath.split('/').slice(0, -1); - return normalizePath([...base, ...specifier.split('/')].join('/')); -} - -function relativeImportPath(currentPath, targetPath) { - const from = currentPath.split('/').slice(0, -1); - const to = targetPath.split('/'); - while (from.length && to.length && from[0] === to[0]) { - from.shift(); - to.shift(); - } - const relative = [...from.map(() => '..'), ...to].join('/'); - return relative.startsWith('.') ? relative : `./${relative}`; -} - -function normalizePath(path) { - const parts = []; - for (const part of String(path).replaceAll('\\', '/').split('/')) { - if (!part || part === '.') continue; - if (part === '..') { - if (!parts.length) return ''; - parts.pop(); - } else { - parts.push(part); - } - } - return parts.join('/'); -} - -function uniqueOptions(options) { - const unique = new Map(); - for (const option of options) { - const previous = unique.get(option.label); - if (!previous || (option.boost ?? 0) > (previous.boost ?? 0)) { - unique.set(option.label, option); - } - } - return [...unique.values()]; -} diff --git a/site/decodal-site/src/scripts/playground-completion.test.mjs b/site/decodal-site/src/scripts/playground-completion.test.mjs index 997cb71..0f45128 100644 --- a/site/decodal-site/src/scripts/playground-completion.test.mjs +++ b/site/decodal-site/src/scripts/playground-completion.test.mjs @@ -1,81 +1,72 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { getProjectCompletions } from './playground-completion.js'; +import { + completionResultToCodeMirror, + createLanguageServiceCompletionSource, +} from './playground-completion.js'; -const files = { - 'main.dcdl': '', - 'schemas/service.dcdl': `Service = { - name = String; - port = Int default 8080; - resources = { cpu = Int; memory = Int; }; -};`, - 'env/production.dcdl': `capacity = { - base_replicas = 6; - cpu_milli = 2000; -};`, -}; - -function complete(source) { - return getProjectCompletions({ - source, - position: source.length, - files, - activePath: 'main.dcdl', +test('passes the active document to an injected language service', () => { + const calls = []; + const source = createLanguageServiceCompletionSource({ + getActivePath: () => 'main.dcdl', + complete(request) { + calls.push(request); + return JSON.stringify({ ok: true, completion: null }); + }, }); -} -test('completes virtual import paths', () => { - const result = complete('schema = import "./sch'); - assert.ok(result.options.some((option) => option.label === './schemas/service.dcdl')); + assert.equal(source({ + state: { doc: { toString: () => 'Post.ti' } }, + pos: 7, + explicit: true, + }), null); + assert.deepEqual(calls, [{ + key: 'main.dcdl', + source: 'Post.ti', + position: 7, + explicit: true, + }]); }); -test('completes imported module fields', () => { - const result = complete('let schema = import "./schemas/service.dcdl"; in schema.'); - assert.deepEqual( - result.options.map((option) => option.label), - ['Service'], - ); -}); - -test('completes nested schema fields', () => { - const result = complete('let schema = import "./schemas/service.dcdl"; in schema.Service.'); - assert.deepEqual( - result.options.map((option) => option.label), - ['name', 'port', 'resources'], - ); -}); - -test('replaces only the partial member name', () => { - const source = 'let schema = import "./schemas/service.dcdl"; in schema.Service.po'; - const result = complete(source); - assert.ok(result.options.some((option) => option.label === 'port')); - assert.equal(result.from, source.length - 2); -}); - -test('completes nested values from another virtual file', () => { - const result = complete('let env = import "./env/production.dcdl"; in env.capacity.'); - assert.deepEqual( - result.options.map((option) => option.label), - ['base_replicas', 'cpu_milli'], - ); -}); - -test('includes language and local binding completions', () => { - const source = 'let service = { port = 8080; }; in ser'; - const result = complete(source); - assert.ok(result.options.some((option) => option.label === 'service')); - assert.ok(result.options.some((option) => option.label === 'String')); - assert.equal(result.from, source.length - 3); -}); - -test('resolves imports relative to nested active files', () => { - const source = 'schema = import "../schemas/serv'; - const result = getProjectCompletions({ - source, - position: source.length, - files, - activePath: 'env/main.dcdl', +test('adapts shared language-service completions for CodeMirror', () => { + const result = completionResultToCodeMirror({ + from: 12, + options: [ + { + label: 'port', + kind: 'property', + detail: 'schemas/service.dcdl', + priority: 30, + }, + ], }); - assert.ok(result.options.some((option) => option.label === '../schemas/service.dcdl')); + + assert.equal(result.from, 12); + assert.deepEqual(result.options, [ + { + label: 'port', + type: 'property', + detail: 'schemas/service.dcdl', + boost: 30, + }, + ]); + assert.ok(result.validFor.test('partial_name')); +}); + +test('uses import-path filtering for file completions', () => { + const result = completionResultToCodeMirror({ + from: 8, + options: [ + { + label: './schemas/service.dcdl', + kind: 'file', + detail: 'project file', + priority: 30, + }, + ], + }); + + assert.ok(result.validFor.test('./schemas/serv')); + assert.equal(result.options[0].type, 'text'); }); diff --git a/site/decodal-site/src/scripts/playground-environment.js b/site/decodal-site/src/scripts/playground-environment.js new file mode 100644 index 0000000..435bc5e --- /dev/null +++ b/site/decodal-site/src/scripts/playground-environment.js @@ -0,0 +1,67 @@ +export function createPlaygroundEnvironment(getFiles) { + return { + globals: {}, + + loadImport(currentKey, specifier) { + const key = resolveImportPath(currentKey, specifier); + const source = getFiles()[key]; + if (source === undefined) { + throw new Error(`import ${JSON.stringify(specifier)} resolved to ${JSON.stringify(key)}, but that file does not exist`); + } + return { kind: 'source', key, name: key, source }; + }, + + completeImport(currentKey, prefix) { + return Object.keys(getFiles()) + .filter((path) => path !== currentKey) + .map((path) => ({ + specifier: prefix.startsWith('/') + ? `/${path}` + : relativeImportPath(currentKey, path), + detail: 'project file', + })) + .filter((candidate) => candidate.specifier.startsWith(prefix)) + .sort((left, right) => left.specifier.localeCompare(right.specifier)); + }, + }; +} + +export function resolveImportPath(currentKey, specifier) { + if (specifier.startsWith('/')) return normalizeVirtualPath(specifier); + const parent = currentKey?.includes('/') + ? currentKey.slice(0, currentKey.lastIndexOf('/')) + : ''; + return normalizeVirtualPath(parent ? `${parent}/${specifier}` : specifier); +} + +export function relativeImportPath(currentKey, target) { + const from = currentKey?.includes('/') + ? currentKey.slice(0, currentKey.lastIndexOf('/')).split('/').filter(Boolean) + : []; + const to = target.split('/').filter(Boolean); + let common = 0; + while (common < from.length && common < to.length && from[common] === to[common]) { + common += 1; + } + const parts = [ + ...Array(from.length - common).fill('..'), + ...to.slice(common), + ]; + const relative = parts.join('/'); + return relative.startsWith('.') ? relative : `./${relative}`; +} + +function normalizeVirtualPath(path) { + const parts = []; + for (const part of String(path).replaceAll('\\', '/').split('/')) { + if (!part || part === '.') continue; + if (part === '..') { + if (parts.length === 0) throw new Error(`invalid virtual path ${JSON.stringify(path)}`); + parts.pop(); + } else { + parts.push(part); + } + } + if (parts.length === 0) throw new Error(`invalid virtual path ${JSON.stringify(path)}`); + return parts.join('/'); +} diff --git a/site/decodal-site/src/scripts/playground-environment.test.mjs b/site/decodal-site/src/scripts/playground-environment.test.mjs new file mode 100644 index 0000000..6f30121 --- /dev/null +++ b/site/decodal-site/src/scripts/playground-environment.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createPlaygroundEnvironment, + relativeImportPath, + resolveImportPath, +} from './playground-environment.js'; + +test('resolves virtual imports in the playground host', () => { + assert.equal( + resolveImportPath('content/pages/home.dcdl', '../schemas/page.dcdl'), + 'content/schemas/page.dcdl', + ); + assert.equal( + relativeImportPath('content/pages/home.dcdl', 'content/schemas/page.dcdl'), + '../schemas/page.dcdl', + ); +}); + +test('loads source and completes paths from playground-owned files', () => { + const files = { + 'main.dcdl': 'import "./schemas/page.dcdl"', + 'schemas/page.dcdl': 'Page = { title = String; };', + }; + const environment = createPlaygroundEnvironment(() => files); + + assert.deepEqual(environment.loadImport('main.dcdl', './schemas/page.dcdl'), { + kind: 'source', + key: 'schemas/page.dcdl', + name: 'schemas/page.dcdl', + source: files['schemas/page.dcdl'], + }); + assert.deepEqual(environment.completeImport('main.dcdl', './sch'), [ + { specifier: './schemas/page.dcdl', detail: 'project file' }, + ]); +}); diff --git a/site/decodal-site/src/scripts/playground-runtime.test.mjs b/site/decodal-site/src/scripts/playground-runtime.test.mjs new file mode 100644 index 0000000..ecb1965 --- /dev/null +++ b/site/decodal-site/src/scripts/playground-runtime.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +import initRuntime, { DecodalLanguageService } from 'decodal-wasm'; + +const wasmPath = new URL('../../../../packages/decodal-wasm/decodal_wasm_bg.wasm', import.meta.url); + +test('injects one JavaScript host environment into evaluation and completion', async () => { + await initRuntime({ module_or_path: await readFile(wasmPath) }); + const files = { + 'schema.dcdl': 'Server = { port = Int; };', + }; + const service = new DecodalLanguageService({ + globals: { + App: { + enabled: { $decodal: 'Bool', default: true }, + }, + }, + loadImport(_currentKey, specifier) { + if (specifier === './post.md') { + return { + kind: 'value', + key: 'post.md', + value: { frontmatter: { draft: false }, body: '# Hello' }, + }; + } + return { + kind: 'source', + key: 'schema.dcdl', + source: files['schema.dcdl'], + }; + }, + completeImport() { + return [{ specifier: './schema.dcdl', detail: 'test source' }]; + }, + }); + + const evaluated = JSON.parse(service.evaluate( + 'main.dcdl', + 'main.dcdl', + 'let s = import "./schema.dcdl"; in { server = s.Server & { port = 8080; }; enabled = App.enabled; post = import "./post.md"; }', + )); + assert.equal(evaluated.ok, true, evaluated.error); + assert.match(evaluated.output, /"port": 8080/); + assert.match(evaluated.output, /"body": "# Hello"/); + + const member = JSON.parse(service.complete('main.dcdl', 'App.en', 6, false)); + assert.equal(member.ok, true, member.error); + assert.ok(member.completion.options.some((item) => item.label === 'enabled')); + + const imported = JSON.parse(service.complete('main.dcdl', 'import "./sch', 13, false)); + assert.equal(imported.ok, true, imported.error); + assert.ok(imported.completion.options.some((item) => item.label === './schema.dcdl')); + + service.free(); +}); diff --git a/site/decodal-site/src/scripts/playground.js b/site/decodal-site/src/scripts/playground.js index f93f2c5..82b99fe 100644 --- a/site/decodal-site/src/scripts/playground.js +++ b/site/decodal-site/src/scripts/playground.js @@ -1,4 +1,4 @@ -import initRuntime, { evaluateProject } from 'decodal-wasm'; +import initRuntime, { DecodalLanguageService } from 'decodal-wasm'; import runtimeWasmUrl from 'decodal-wasm/decodal_wasm_bg.wasm?url'; import { EditorView, basicSetup } from 'codemirror'; import { keymap } from '@codemirror/view'; @@ -6,7 +6,8 @@ import { decodal, decodalLanguage } from 'decodal-codemirror'; import { formatDecodal, initDecodalFormatter } from 'decodal-codemirror/format'; import toolsWasmUrl from 'decodal-codemirror/wasm/decodal_language_tools_bg.wasm?url'; import { playgroundExamples } from './playground-examples.js'; -import { createProjectCompletionSource } from './playground-completion.js'; +import { createLanguageServiceCompletionSource } from './playground-completion.js'; +import { createPlaygroundEnvironment } from './playground-environment.js'; const STORAGE_KEY = 'decodal-playground-project-v2'; const starterProject = playgroundExamples[0]; @@ -25,6 +26,7 @@ const entrySelect = document.getElementById('entry-select'); const loadExample = document.getElementById('load-example'); const project = loadProject(); +let languageService; const editorTheme = EditorView.theme({ '&': { @@ -78,9 +80,14 @@ const editorTheme = EditorView.theme({ }, }, { dark: true }); -const completionSource = createProjectCompletionSource({ - getFiles: () => project.files, +const completionSource = createLanguageServiceCompletionSource({ getActivePath: () => project.activePath, + complete: ({ key, source, position, explicit }) => languageService?.complete( + key, + source, + position, + explicit, + ), }); const editor = new EditorView({ @@ -241,7 +248,11 @@ function execute() { project.entryPath = entryPath; updateRunLabel(); saveProject(); - const result = JSON.parse(evaluateProject(entryPath, JSON.stringify(project.files))); + const result = JSON.parse(languageService.evaluate( + entryPath, + entryPath, + project.files[entryPath], + )); output.textContent = result.ok ? result.output : result.error; output.classList.toggle('error', !result.ok); } @@ -312,6 +323,9 @@ function compareNodes(a, b) { try { await Promise.all([initRuntime(runtimeWasmUrl), initDecodalFormatter(toolsWasmUrl)]); + languageService = new DecodalLanguageService( + createPlaygroundEnvironment(() => project.files), + ); run.disabled = false; formatButton.disabled = false; status.textContent = 'Ctrl+Space: complete';