Share host-aware completion across runtimes
This commit is contained in:
Generated
+61
@@ -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"
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¤t_key, &specifier)
|
||||
.map_err(|error| import_error(js_error_message(error)))?;
|
||||
let loaded: Value = serde_wasm_bindgen::from_value(loaded)
|
||||
.map_err(|error| import_error(format!("invalid `loadImport` result: {error}")))?;
|
||||
loaded_import(&loaded).map_err(import_error)
|
||||
}
|
||||
|
||||
fn complete_import(
|
||||
&mut self,
|
||||
current_key: Option<&str>,
|
||||
prefix: &str,
|
||||
) -> decodal::Result<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, ¤t_key, &prefix)
|
||||
.map_err(|error| import_error(js_error_message(error)))?;
|
||||
let candidates: Value = serde_wasm_bindgen::from_value(candidates)
|
||||
.map_err(|error| import_error(format!("invalid `completeImport` result: {error}")))?;
|
||||
import_candidates(&candidates).map_err(import_error)
|
||||
}
|
||||
}
|
||||
|
||||
fn loaded_import(value: &Value) -> Result<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)
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user