Share host-aware completion across runtimes

This commit is contained in:
2026-08-13 18:55:03 +09:00
parent e862e53f3e
commit d28edcf041
25 changed files with 2836 additions and 578 deletions
Generated
+61
View File
@@ -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"
+36
View File
@@ -107,6 +107,42 @@ impl<L: ImportLoader> Engine<L> {
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<Vec<(String, RuntimeValue)>> {
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<Option<Vec<(String, RuntimeValue)>>> {
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::<Result<Vec<_>>>()
.map(Some)
}
pub fn add_root_source(
&mut self,
key: impl Into<String>,
+3 -1
View File
@@ -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};
+35 -1
View File
@@ -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<String>,
}
impl ImportCandidate {
pub fn new(specifier: impl Into<String>) -> Self {
Self {
specifier: specifier.into(),
detail: None,
}
}
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
}
impl LoadedImport {
pub fn source(
key: impl Into<String>,
@@ -59,6 +80,19 @@ impl LoadedImport {
pub trait ImportLoader {
fn load(&mut self, current_key: Option<&str>, specifier: &str) -> crate::Result<LoadedImport>;
/// 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<Vec<ImportCandidate>> {
Ok(Vec::new())
}
}
#[derive(Debug, Clone, Copy, Default)]
@@ -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<String>,
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<CompletionItem>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct FieldTree(BTreeMap<String, FieldTree>);
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<FieldTree> {
self.0.remove(name)
}
}
impl IntoIterator for FieldTree {
type Item = (String, FieldTree);
type IntoIter = std::collections::btree_map::IntoIter<String, FieldTree>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl FromIterator<(String, FieldTree)> for FieldTree {
fn from_iter<T: IntoIterator<Item = (String, FieldTree)>>(iter: T) -> Self {
Self(iter.into_iter().collect())
}
}
pub(crate) fn complete<E: HostEnvironment>(
environment: &E,
key: &str,
source: &str,
position: usize,
explicit: bool,
) -> Result<Option<CompletionResult>> {
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<CompletionItem> {
[
("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<E: HostEnvironment>(environment: &E) -> Result<FieldTree> {
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<L: ImportLoader>(
engine: &mut Engine<L>,
value: &RuntimeValue,
depth: usize,
) -> Result<FieldTree> {
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<CompletionItem>) -> Vec<CompletionItem> {
let mut unique = HashMap::<String, CompletionItem>::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<Token> {
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<String>, 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<String, String> {
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<String> {
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<usize> {
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<String, String>,
}
#[derive(Clone, Default)]
struct TestLoader {
files: BTreeMap<String, String>,
}
impl ImportLoader for TestLoader {
fn load(&mut self, _current_key: Option<&str>, specifier: &str) -> Result<LoadedImport> {
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<Vec<ImportCandidate>> {
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<Self::Loader>) -> 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<String> {
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::<Vec<_>>();
parts.pop();
for part in specifier.split('/') {
match part {
"" | "." => {}
".." => {
parts.pop();
}
part => parts.push(part),
}
}
parts.join("/")
}
}
@@ -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<E> {
environment: E,
@@ -59,6 +63,18 @@ impl<E: HostEnvironment> LanguageService<E> {
},
}
}
/// Completes the document using the same host globals and import loader as
/// production evaluation.
pub fn complete(
&self,
key: impl AsRef<str>,
source: &str,
position: usize,
explicit: bool,
) -> Result<Option<CompletionResult>> {
completion::complete(&self.environment, key.as_ref(), source, position, explicit)
}
}
#[derive(Debug, Clone, PartialEq)]
+258 -9
View File
@@ -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<E: LspEnvironment> Server<E> {
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::<CompletionResponse>::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<Vec<ImportCandidate>> {
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),
}))
+6 -1
View File
@@ -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
+228
View File
@@ -0,0 +1,228 @@
use decodal::{CompareOp, Constraint, HostValue, LiteralValue, PrimitiveType};
use serde_json::{Map, Value};
pub(crate) fn from_json(value: &Value) -> Result<HostValue, String> {
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::<Result<Vec<_>, _>>()
.map(HostValue::array),
Value::Object(fields) => object(fields),
}
}
fn number(value: &serde_json::Number) -> Result<HostValue, String> {
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<String, Value>) -> Result<HostValue, String> {
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::<Result<Vec<_>, _>>()
.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<Vec<Constraint>, 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<Constraint, String> {
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<LiteralValue, String> {
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<String, Value>, 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")
);
}
}
+146 -139
View File
@@ -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<WebLanguageService, JsValue> {
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, String>) -> 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<String, String> {
Ok(format_data(&data, 0))
}
fn evaluate_project_inner(entry: &str, files_json: &str) -> Result<String, String> {
let raw_files: BTreeMap<String, String> = 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<String, String> {
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<String, String>,
#[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();
}
};
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::<Vec<_>>();
serde_json::json!({ "from": from, "options": options })
});
serde_json::json!({ "ok": true, "completion": completion }).to_string()
}
impl ImportLoader for VirtualLoader {
fn load(
&mut self,
current_key: Option<&str>,
specifier: &str,
) -> decodal::Result<LoadedImport> {
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,
}))
#[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",
}
}
fn resolve_import(current_key: Option<&str>, specifier: &str) -> Option<String> {
if specifier.starts_with('/') {
return normalize_path(specifier);
#[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;
}
source.len()
}
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('/');
}
}
base.push_str(specifier);
normalize_path(&base)
}
fn normalize_path(path: &str) -> Option<String> {
let mut parts = Vec::new();
let normalized = path.replace('\\', "/");
for part in normalized.split('/') {
match part {
"" | "." => {}
".." => {
parts.pop()?;
}
part => parts.push(part),
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("/"))
#[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);
}
}
+209
View File
@@ -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<String, HostValue>,
load_import: Option<Function>,
complete_import: Option<Function>,
}
impl JsEnvironment {
pub(crate) fn from_options(options: JsValue) -> Result<Self, JsValue> {
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::<Result<BTreeMap<_, _>, _>>()?
};
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<Self::Loader>) -> decodal::Result<()> {
for (name, value) in &self.globals {
engine.bind_global(name, value.clone())?;
}
Ok(())
}
}
pub(crate) struct JsLoader {
load_import: Option<Function>,
complete_import: Option<Function>,
}
impl ImportLoader for JsLoader {
fn load(
&mut self,
current_key: Option<&str>,
specifier: &str,
) -> decodal::Result<LoadedImport> {
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, &current_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<Vec<ImportCandidate>> {
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, &current_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<LoadedImport, String> {
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<Vec<ImportCandidate>, 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<String, Value>,
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<JsValue, JsValue> {
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<Option<Function>, JsValue> {
let value = property(options, name)?;
if value.is_null() || value.is_undefined() {
return Ok(None);
}
value
.dyn_into::<Function>()
.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<String>) -> Diagnostic {
Diagnostic::new(DiagnosticKind::Import, Span::default(), message)
}
+39 -6
View File
@@ -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.
+89 -4
View File
@@ -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<string, DecodalHostValue>;
/** 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;
}
+388 -22
View File
@@ -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;
Binary file not shown.
+8 -2
View File
@@ -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;
+20 -60
View File
@@ -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<InitOutput> {
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';
+1 -1
View File
@@ -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": {
@@ -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<string, DecodalHostValue>;
/** 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.
`,
);
@@ -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({
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,
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_]*$/,
};
}
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',
});
}
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,
})),
validFor: /^[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;
}
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;
}
function resolveImportPath(currentPath, specifier) {
const base = specifier.startsWith('/')
? []
: currentPath.split('/').slice(0, -1);
return normalizePath([...base, ...specifier.split('/')].join('/'));
if (!response.ok || !response.completion) return null;
return completionResultToCodeMirror(response.completion);
};
}
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()];
export function completionResultToCodeMirror(completion) {
const fileCompletion = completion.options.some((option) => option.kind === 'file');
return {
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: fileCompletion ? /^[^"\n]*$/ : /^[A-Za-z_][A-Za-z0-9_]*$/,
};
}
@@ -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('completes virtual import paths', () => {
const result = complete('schema = import "./sch');
assert.ok(result.options.some((option) => option.label === './schemas/service.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 imported module fields', () => {
const result = complete('let schema = import "./schemas/service.dcdl"; in schema.');
assert.deepEqual(
result.options.map((option) => option.label),
['Service'],
);
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 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('adapts shared language-service completions for CodeMirror', () => {
const result = completionResultToCodeMirror({
from: 12,
options: [
{
label: 'port',
kind: 'property',
detail: 'schemas/service.dcdl',
priority: 30,
},
],
});
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);
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('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('uses import-path filtering for file completions', () => {
const result = completionResultToCodeMirror({
from: 8,
options: [
{
label: './schemas/service.dcdl',
kind: 'file',
detail: 'project file',
priority: 30,
},
],
});
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',
});
assert.ok(result.options.some((option) => option.label === '../schemas/service.dcdl'));
assert.ok(result.validFor.test('./schemas/serv'));
assert.equal(result.options[0].type, 'text');
});
@@ -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('/');
}
@@ -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' },
]);
});
@@ -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();
});
+19 -5
View File
@@ -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';