Share host-aware completion across runtimes

This commit is contained in:
2026-08-13 18:55:03 +09:00
parent e862e53f3e
commit d28edcf041
25 changed files with 2836 additions and 578 deletions
+36
View File
@@ -107,6 +107,42 @@ impl<L: ImportLoader> Engine<L> {
thunk
}
/// Returns the values installed in the host-owned global environment.
///
/// Language tooling uses this after [`crate::HostEnvironment`] configures a
/// fresh engine, so editor-visible globals cannot drift from evaluation.
pub fn global_values(&mut self) -> Result<Vec<(String, RuntimeValue)>> {
let bindings = self.envs[self.prelude_env.0 as usize].bindings.clone();
bindings
.into_iter()
.map(|binding| Ok((binding.name, self.force(binding.value)?)))
.collect()
}
/// Returns the immediately visible fields of an object runtime value.
pub fn value_fields(
&mut self,
value: &RuntimeValue,
) -> Result<Option<Vec<(String, RuntimeValue)>>> {
let object = match value {
RuntimeValue::Concrete(crate::runtime::ConcreteValue::Object(object)) => object,
RuntimeValue::Abstract(abstract_value) => {
let Some(default) = abstract_value.default else {
return Ok(None);
};
let default = self.force(default)?;
return self.value_fields(&default);
}
_ => return Ok(None),
};
let fields = object.fields.clone();
fields
.into_iter()
.map(|field| Ok((field.name, self.force(field.value)?)))
.collect::<Result<Vec<_>>>()
.map(Some)
}
pub fn add_root_source(
&mut self,
key: impl Into<String>,
+3 -1
View File
@@ -23,7 +23,9 @@ pub use diagnostic::{Diagnostic, DiagnosticKind, Result};
pub use embedding::{HostField, HostValue};
pub use environment::HostEnvironment;
pub use eval::{Engine, format_diagnostic_with};
pub use module::{EmptyLoader, ImportLoader, LoadedImport, LoadedSource, LoadedValue, Module};
pub use module::{
EmptyLoader, ImportCandidate, ImportLoader, LoadedImport, LoadedSource, LoadedValue, Module,
};
pub use parser::{ParseOutput, Parser, SourceForm, parse_source, parse_source_with_source_id};
pub use runtime::{Constraint, Data, ExprRef, LiteralValue, ModuleId, PrimitiveType, RuntimeValue};
pub use span::{SourceId, Span};
+35 -1
View File
@@ -1,4 +1,4 @@
use alloc::string::String;
use alloc::{string::String, vec::Vec};
use crate::{
Ast, ExprId, HostValue, SourceForm, SourceId,
@@ -36,6 +36,27 @@ pub enum LoadedImport {
Value(LoadedValue),
}
/// An import specifier offered by host-owned language tooling.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportCandidate {
pub specifier: String,
pub detail: Option<String>,
}
impl ImportCandidate {
pub fn new(specifier: impl Into<String>) -> Self {
Self {
specifier: specifier.into(),
detail: None,
}
}
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
}
impl LoadedImport {
pub fn source(
key: impl Into<String>,
@@ -59,6 +80,19 @@ impl LoadedImport {
pub trait ImportLoader {
fn load(&mut self, current_key: Option<&str>, specifier: &str) -> crate::Result<LoadedImport>;
/// Returns host-resolvable import specifiers matching an unfinished prefix.
///
/// Runtime-only loaders may keep the default implementation. Hosts that
/// provide an editor should implement this from the same namespace used by
/// [`ImportLoader::load`].
fn complete_import(
&mut self,
_current_key: Option<&str>,
_prefix: &str,
) -> crate::Result<Vec<ImportCandidate>> {
Ok(Vec::new())
}
}
#[derive(Debug, Clone, Copy, Default)]
@@ -0,0 +1,905 @@
use std::collections::{BTreeMap, HashMap};
use decodal::{
Engine, HostEnvironment, HostValue, ImportLoader, LoadedImport, Result, RuntimeValue,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompletionKind {
Keyword,
Constant,
Type,
Variable,
Namespace,
Property,
File,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompletionItem {
pub label: String,
pub kind: CompletionKind,
pub detail: Option<String>,
pub priority: i32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompletionResult {
/// UTF-8 byte offset at which the unfinished token starts.
pub from: usize,
pub items: Vec<CompletionItem>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct FieldTree(BTreeMap<String, FieldTree>);
impl FieldTree {
fn new() -> Self {
Self::default()
}
fn is_empty(&self) -> bool {
self.0.is_empty()
}
fn insert(&mut self, name: String, fields: FieldTree) {
self.0.insert(name, fields);
}
fn get(&self, name: &str) -> Option<&FieldTree> {
self.0.get(name)
}
fn remove(&mut self, name: &str) -> Option<FieldTree> {
self.0.remove(name)
}
}
impl IntoIterator for FieldTree {
type Item = (String, FieldTree);
type IntoIter = std::collections::btree_map::IntoIter<String, FieldTree>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl FromIterator<(String, FieldTree)> for FieldTree {
fn from_iter<T: IntoIterator<Item = (String, FieldTree)>>(iter: T) -> Self {
Self(iter.into_iter().collect())
}
}
pub(crate) fn complete<E: HostEnvironment>(
environment: &E,
key: &str,
source: &str,
position: usize,
explicit: bool,
) -> Result<Option<CompletionResult>> {
let position = floor_char_boundary(source, position.min(source.len()));
if let Some((from, prefix)) = unfinished_import(source, position) {
let mut loader = environment.create_loader();
let items = loader
.complete_import(Some(key), prefix)?
.into_iter()
.map(|candidate| CompletionItem {
label: candidate.specifier,
kind: CompletionKind::File,
detail: candidate.detail,
priority: 30,
})
.collect();
return Ok(Some(CompletionResult {
from,
items: unique_items(items),
}));
}
if in_string_or_comment(source, position) {
return Ok(None);
}
let tokens = tokenize(source);
let local_fields = collect_fields(&tokens);
let imports = collect_import_bindings(&tokens);
let globals = collect_globals(environment)?;
if let Some((from, path)) = member_path(source, position) {
let mut parts = path.split('.');
let Some(root) = parts.next() else {
return Ok(None);
};
let mut fields;
let detail;
if let Some(specifier) = imports.get(root) {
let mut loader = environment.create_loader();
let loaded = loader.load(Some(key), specifier)?;
detail = match &loaded {
LoadedImport::Source(source) => source.name.clone(),
LoadedImport::Value(value) => value.key.clone(),
};
fields = match loaded {
LoadedImport::Source(source) => collect_fields(&tokenize(&source.source)),
LoadedImport::Value(value) => fields_from_host_value(&value.value),
};
} else if let Some(local) = local_fields.get(root) {
fields = local.clone();
detail = String::from("local value");
} else if let Some(global) = globals.get(root) {
fields = global.clone();
detail = String::from("host global");
} else {
return Ok(None);
}
for part in parts {
let Some(nested) = fields.remove(part) else {
return Ok(None);
};
fields = nested;
}
let items = fields
.into_iter()
.map(|(label, children)| CompletionItem {
label,
kind: if children.is_empty() {
CompletionKind::Property
} else {
CompletionKind::Namespace
},
detail: Some(detail.clone()),
priority: 30,
})
.collect();
return Ok(Some(CompletionResult {
from,
items: unique_items(items),
}));
}
let word_from = word_start(source, position);
if word_from == position && !explicit {
return Ok(None);
}
let mut items = builtin_items();
for (label, children) in local_fields {
items.push(CompletionItem {
label,
kind: if children.is_empty() {
CompletionKind::Variable
} else {
CompletionKind::Namespace
},
detail: Some(if children.is_empty() {
String::from("local value")
} else {
String::from("local object")
}),
priority: 20,
});
}
for label in collect_parameters(&tokens) {
items.push(CompletionItem {
label,
kind: CompletionKind::Variable,
detail: Some(String::from("parameter")),
priority: 20,
});
}
for (label, specifier) in imports {
items.push(CompletionItem {
label,
kind: CompletionKind::Namespace,
detail: Some(specifier),
priority: 30,
});
}
for (label, children) in globals {
items.push(CompletionItem {
label,
kind: if children.is_empty() {
CompletionKind::Variable
} else {
CompletionKind::Namespace
},
detail: Some(String::from("host global")),
priority: 40,
});
}
Ok(Some(CompletionResult {
from: word_from,
items: unique_items(items),
}))
}
fn builtin_items() -> Vec<CompletionItem> {
[
("let", CompletionKind::Keyword, "local bindings", 5),
("in", CompletionKind::Keyword, "let body", 0),
("match", CompletionKind::Keyword, "pattern matching", 5),
("import", CompletionKind::Keyword, "load a module", 5),
("default", CompletionKind::Keyword, "fallback value", 0),
("true", CompletionKind::Constant, "Bool", 0),
("false", CompletionKind::Constant, "Bool", 0),
("String", CompletionKind::Type, "string constraint", 5),
("Int", CompletionKind::Type, "integer constraint", 5),
("Float", CompletionKind::Type, "float constraint", 5),
("Bool", CompletionKind::Type, "boolean constraint", 5),
]
.into_iter()
.map(|(label, kind, detail, priority)| CompletionItem {
label: label.into(),
kind,
detail: Some(detail.into()),
priority,
})
.collect()
}
fn collect_globals<E: HostEnvironment>(environment: &E) -> Result<FieldTree> {
let mut engine = environment.create_engine()?;
let mut fields = FieldTree::new();
for (name, value) in engine.global_values()? {
fields.insert(name, fields_from_runtime(&mut engine, &value, 0)?);
}
Ok(fields)
}
fn fields_from_runtime<L: ImportLoader>(
engine: &mut Engine<L>,
value: &RuntimeValue,
depth: usize,
) -> Result<FieldTree> {
if depth >= 32 {
return Ok(FieldTree::new());
}
let Some(fields) = engine.value_fields(value)? else {
return Ok(FieldTree::new());
};
let mut tree = FieldTree::new();
for (name, value) in fields {
tree.insert(name, fields_from_runtime(engine, &value, depth + 1)?);
}
Ok(tree)
}
fn fields_from_host_value(value: &HostValue) -> FieldTree {
match value {
HostValue::Object(fields) => fields
.iter()
.map(|field| (field.name.clone(), fields_from_host_value(&field.value)))
.collect(),
HostValue::ArrayConstraint {
default: Some(value),
..
}
| HostValue::Abstract {
default: Some(value),
..
} => fields_from_host_value(value),
_ => FieldTree::new(),
}
}
fn unique_items(items: Vec<CompletionItem>) -> Vec<CompletionItem> {
let mut unique = HashMap::<String, CompletionItem>::new();
let mut order = Vec::new();
for item in items {
if !unique.contains_key(&item.label) {
order.push(item.label.clone());
}
let replace = unique
.get(&item.label)
.is_none_or(|previous| item.priority > previous.priority);
if replace {
unique.insert(item.label.clone(), item);
}
}
order
.into_iter()
.filter_map(|label| unique.remove(&label))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum TokenKind {
Identifier(String),
String(String),
Symbol(char),
Arrow,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Token {
kind: TokenKind,
}
fn tokenize(source: &str) -> Vec<Token> {
let bytes = source.as_bytes();
let mut tokens = Vec::new();
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b' ' | b'\t' | b'\r' | b'\n' => index += 1,
b'#' => {
while index < bytes.len() && bytes[index] != b'\n' {
index += 1;
}
}
b'"' => {
index += 1;
let start = index;
let mut escaped = false;
while index < bytes.len() {
let byte = bytes[index];
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' || byte == b'\n' {
break;
}
index += 1;
}
let value = unescape_string(&source[start..index]);
index = index.saturating_add(1);
tokens.push(Token {
kind: TokenKind::String(value),
});
}
byte if is_identifier_start(byte) => {
let start = index;
index += 1;
while index < bytes.len() && is_identifier_continue(bytes[index]) {
index += 1;
}
tokens.push(Token {
kind: TokenKind::Identifier(source[start..index].into()),
});
}
b'=' if bytes.get(index + 1) == Some(&b'>') => {
tokens.push(Token {
kind: TokenKind::Arrow,
});
index += 2;
}
byte => {
tokens.push(Token {
kind: TokenKind::Symbol(byte as char),
});
index += 1;
}
}
}
tokens
}
fn unescape_string(value: &str) -> String {
let mut chars = value.chars();
let mut unescaped = String::new();
while let Some(ch) = chars.next() {
if ch != '\\' {
unescaped.push(ch);
continue;
}
let Some(escaped) = chars.next() else {
unescaped.push('\\');
break;
};
unescaped.push(match escaped {
'n' => '\n',
'r' => '\r',
't' => '\t',
escaped => escaped,
});
}
unescaped
}
fn collect_fields(tokens: &[Token]) -> FieldTree {
let mut fields = FieldTree::new();
collect_fields_in(tokens, 0, tokens.len(), &mut fields);
fields
}
fn collect_fields_in(tokens: &[Token], start: usize, end: usize, fields: &mut FieldTree) {
let mut index = start;
while index < end {
let Some((path, equals)) = field_definition_at(tokens, index, end) else {
index += 1;
continue;
};
let value_start = equals + 1;
let value_end = definition_end(tokens, value_start, end);
let mut nested = &mut *fields;
for part in path {
nested = nested.0.entry(part).or_default();
}
collect_fields_in(tokens, value_start, value_end, nested);
index = value_end.saturating_add(1);
}
}
fn field_definition_at(tokens: &[Token], start: usize, end: usize) -> Option<(Vec<String>, usize)> {
let TokenKind::Identifier(first) = &tokens.get(start)?.kind else {
return None;
};
let mut path = vec![first.clone()];
let mut index = start + 1;
while index + 1 < end
&& matches!(tokens[index].kind, TokenKind::Symbol('.'))
&& matches!(tokens[index + 1].kind, TokenKind::Identifier(_))
{
let TokenKind::Identifier(part) = &tokens[index + 1].kind else {
unreachable!()
};
path.push(part.clone());
index += 2;
}
matches!(
tokens.get(index).map(|token| &token.kind),
Some(TokenKind::Symbol('='))
)
.then_some((path, index))
}
fn definition_end(tokens: &[Token], start: usize, end: usize) -> usize {
let mut delimiters = Vec::new();
for (index, token) in tokens.iter().enumerate().take(end).skip(start) {
match token.kind {
TokenKind::Symbol('(' | '[' | '{') => delimiters.push(token.kind.clone()),
TokenKind::Symbol(')') if matches!(delimiters.last(), Some(TokenKind::Symbol('('))) => {
delimiters.pop();
}
TokenKind::Symbol(']') if matches!(delimiters.last(), Some(TokenKind::Symbol('['))) => {
delimiters.pop();
}
TokenKind::Symbol('}') if matches!(delimiters.last(), Some(TokenKind::Symbol('{'))) => {
delimiters.pop();
}
TokenKind::Symbol(';') if delimiters.is_empty() => return index,
_ => {}
}
}
end
}
fn collect_import_bindings(tokens: &[Token]) -> BTreeMap<String, String> {
let mut imports = BTreeMap::new();
for window in tokens.windows(4) {
let (
TokenKind::Identifier(binding),
TokenKind::Symbol('='),
TokenKind::Identifier(keyword),
TokenKind::String(specifier),
) = (
&window[0].kind,
&window[1].kind,
&window[2].kind,
&window[3].kind,
)
else {
continue;
};
if keyword == "import" {
imports.insert(binding.clone(), specifier.clone());
}
}
imports
}
fn collect_parameters(tokens: &[Token]) -> Vec<String> {
let mut parameters = Vec::new();
for (close, token) in tokens.iter().enumerate() {
if !matches!(token.kind, TokenKind::Symbol(')'))
|| !matches!(
tokens.get(close + 1).map(|token| &token.kind),
Some(TokenKind::Arrow)
)
{
continue;
}
let Some(open) = matching_open_paren(tokens, close) else {
continue;
};
let mut depth = 0usize;
let mut segment_start = true;
for token in &tokens[open + 1..close] {
match &token.kind {
TokenKind::Symbol('(' | '[' | '{') => depth += 1,
TokenKind::Symbol(')' | ']' | '}') => depth = depth.saturating_sub(1),
TokenKind::Symbol(',') if depth == 0 => segment_start = true,
TokenKind::Identifier(name) if depth == 0 && segment_start => {
parameters.push(name.clone());
segment_start = false;
}
_ => {}
}
}
}
parameters
}
fn matching_open_paren(tokens: &[Token], close: usize) -> Option<usize> {
let mut depth = 0usize;
for index in (0..close).rev() {
match tokens[index].kind {
TokenKind::Symbol(')') => depth += 1,
TokenKind::Symbol('(') if depth == 0 => return Some(index),
TokenKind::Symbol('(') => depth -= 1,
_ => {}
}
}
None
}
fn unfinished_import(source: &str, position: usize) -> Option<(usize, &str)> {
let bytes = source.as_bytes();
let mut index = 0usize;
let mut previous_word = None;
while index < position {
match bytes[index] {
b' ' | b'\t' | b'\r' | b'\n' => index += 1,
b'#' => {
while index < position && bytes[index] != b'\n' {
index += 1;
}
previous_word = None;
}
b'"' => {
let from = index + 1;
index += 1;
let mut escaped = false;
while index < position {
let byte = bytes[index];
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' || byte == b'\n' {
break;
}
index += 1;
}
if index == position && previous_word == Some("import") {
return Some((from, &source[from..position]));
}
index = index.saturating_add(1);
previous_word = None;
}
byte if is_identifier_start(byte) => {
let start = index;
index += 1;
while index < position && is_identifier_continue(bytes[index]) {
index += 1;
}
previous_word = Some(&source[start..index]);
}
_ => {
index += 1;
previous_word = None;
}
}
}
None
}
fn in_string_or_comment(source: &str, position: usize) -> bool {
let bytes = source.as_bytes();
let mut index = 0usize;
while index < position {
match bytes[index] {
b'#' => {
while index < position && bytes[index] != b'\n' {
index += 1;
}
if index == position {
return true;
}
}
b'"' => {
index += 1;
let mut escaped = false;
while index < position {
let byte = bytes[index];
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' || byte == b'\n' {
break;
}
index += 1;
}
if index == position {
return true;
}
index = index.saturating_add(1);
}
_ => index += 1,
}
}
false
}
fn member_path(source: &str, position: usize) -> Option<(usize, &str)> {
let bytes = source.as_bytes();
let from = word_start(source, position);
if from == 0 || bytes[from - 1] != b'.' {
return None;
}
let mut base_start = from - 1;
while base_start > 0 {
let byte = bytes[base_start - 1];
if is_identifier_continue(byte) || byte == b'.' {
base_start -= 1;
} else {
break;
}
}
let base = &source[base_start..from - 1];
(!base.is_empty()
&& base
.split('.')
.all(|part| !part.is_empty() && is_identifier(part)))
.then_some((from, base))
}
fn word_start(source: &str, position: usize) -> usize {
let bytes = source.as_bytes();
let mut from = position;
while from > 0 && is_identifier_continue(bytes[from - 1]) {
from -= 1;
}
from
}
fn is_identifier(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.first().is_some_and(|byte| is_identifier_start(*byte))
&& bytes[1..].iter().all(|byte| is_identifier_continue(*byte))
}
fn is_identifier_start(byte: u8) -> bool {
byte.is_ascii_alphabetic() || byte == b'_'
}
fn is_identifier_continue(byte: u8) -> bool {
is_identifier_start(byte) || byte.is_ascii_digit()
}
fn floor_char_boundary(source: &str, mut position: usize) -> usize {
while !source.is_char_boundary(position) {
position -= 1;
}
position
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use decodal::{
Diagnostic, DiagnosticKind, EmptyLoader, HostValue, ImportCandidate, LoadedImport, Span,
};
use super::*;
#[derive(Clone, Default)]
struct TestEnvironment {
files: BTreeMap<String, String>,
}
#[derive(Clone, Default)]
struct TestLoader {
files: BTreeMap<String, String>,
}
impl ImportLoader for TestLoader {
fn load(&mut self, _current_key: Option<&str>, specifier: &str) -> Result<LoadedImport> {
if specifier == "./post.md" {
return Ok(LoadedImport::value(
"post.md",
HostValue::object([
(
"frontmatter",
HostValue::object([
("title", HostValue::string("Hello")),
("draft", HostValue::bool(false)),
]),
),
("body", HostValue::string("# Hello")),
]),
));
}
let key = resolve_virtual("main.dcdl", specifier);
let Some(source) = self.files.get(&key) else {
return Err(Diagnostic::new(
DiagnosticKind::Import,
Span::default(),
"unknown import",
));
};
Ok(LoadedImport::source(key.clone(), key, source))
}
fn complete_import(
&mut self,
_current_key: Option<&str>,
prefix: &str,
) -> Result<Vec<ImportCandidate>> {
Ok(self
.files
.keys()
.map(|path| format!("./{path}"))
.filter(|path| path.starts_with(prefix))
.map(ImportCandidate::new)
.collect())
}
}
impl HostEnvironment for TestEnvironment {
type Loader = TestLoader;
fn create_loader(&self) -> Self::Loader {
TestLoader {
files: self.files.clone(),
}
}
fn configure_engine(&self, engine: &mut Engine<Self::Loader>) -> Result<()> {
engine.bind_global(
"App",
HostValue::object([(
"config",
HostValue::object([
("enabled", HostValue::bool_type()),
("port", HostValue::int_type()),
]),
)]),
)?;
Ok(())
}
}
fn environment() -> TestEnvironment {
TestEnvironment {
files: BTreeMap::from([
(
"schemas/service.dcdl".into(),
"Service = { name = String; resources = { cpu = Int; }; };".into(),
),
(
"env/production.dcdl".into(),
"capacity = { cpu = 2000; };".into(),
),
(
"資料/service.dcdl".into(),
"Service = { label = String; };".into(),
),
]),
}
}
fn labels(result: CompletionResult) -> Vec<String> {
result.items.into_iter().map(|item| item.label).collect()
}
#[test]
fn completes_import_paths_from_the_host_loader() {
let source = "schema = import \"./sch";
let result = complete(&environment(), "main.dcdl", source, source.len(), false)
.unwrap()
.unwrap();
assert_eq!(result.from, source.len() - 5);
assert!(labels(result).contains(&"./schemas/service.dcdl".into()));
}
#[test]
fn completes_nested_fields_from_imported_source() {
let source = "let schema = import \"./schemas/service.dcdl\"; in schema.Service.";
let result = complete(&environment(), "main.dcdl", source, source.len(), false)
.unwrap()
.unwrap();
assert_eq!(labels(result), ["name", "resources"]);
}
#[test]
fn completes_structured_host_imports() {
let source = "let post = import \"./post.md\"; in post.frontmatter.";
let result = complete(&environment(), "main.dcdl", source, source.len(), false)
.unwrap()
.unwrap();
assert_eq!(labels(result), ["draft", "title"]);
}
#[test]
fn preserves_unicode_import_specifiers() {
let source = "let schema = import \"./資料/service.dcdl\"; in schema.Service.";
let result = complete(&environment(), "main.dcdl", source, source.len(), false)
.unwrap()
.unwrap();
assert_eq!(labels(result), ["label"]);
}
#[test]
fn completes_host_globals_from_the_evaluation_environment() {
let source = "App.config.";
let result = complete(&environment(), "main.dcdl", source, source.len(), false)
.unwrap()
.unwrap();
assert_eq!(labels(result), ["enabled", "port"]);
}
#[test]
fn completes_language_locals_parameters_and_partial_members() {
let source = "let service = { port = 8080; }; in (value: Int) => service.po";
let result = complete(&environment(), "main.dcdl", source, source.len(), false)
.unwrap()
.unwrap();
assert_eq!(result.from, source.len() - 2);
assert_eq!(labels(result), ["port"]);
let source = "let service = { port = 8080; }; in (value: Int) => val";
let result = complete(&environment(), "main.dcdl", source, source.len(), false)
.unwrap()
.unwrap();
let labels = labels(result);
assert!(labels.contains(&"String".into()));
assert!(labels.contains(&"service".into()));
assert!(labels.contains(&"value".into()));
assert!(labels.contains(&"App".into()));
}
#[test]
fn suppresses_completions_in_strings_and_comments() {
let string = "value = \"hello world\"";
assert!(
complete(&environment(), "main.dcdl", string, string.len() - 1, false)
.unwrap()
.is_none()
);
let comment = "value = true; # hello";
assert!(
complete(&environment(), "main.dcdl", comment, comment.len(), false)
.unwrap()
.is_none()
);
}
#[test]
fn empty_environment_remains_supported() {
let result = complete(&EmptyEnvironment, "main.dcdl", "Str", 3, false)
.unwrap()
.unwrap();
assert!(labels(result).contains(&"String".into()));
}
struct EmptyEnvironment;
impl HostEnvironment for EmptyEnvironment {
type Loader = EmptyLoader;
fn create_loader(&self) -> Self::Loader {
EmptyLoader
}
}
fn resolve_virtual(current: &str, specifier: &str) -> String {
let mut parts = current.split('/').collect::<Vec<_>>();
parts.pop();
for part in specifier.split('/') {
match part {
"" | "." => {}
".." => {
parts.pop();
}
part => parts.push(part),
}
}
parts.join("/")
}
}
@@ -1,5 +1,9 @@
use decodal::{Data, Diagnostic, HostEnvironment, Result};
mod completion;
pub use completion::{CompletionItem, CompletionKind, CompletionResult};
/// Semantic tooling backed by the same host environment as production.
pub struct LanguageService<E> {
environment: E,
@@ -59,6 +63,18 @@ impl<E: HostEnvironment> LanguageService<E> {
},
}
}
/// Completes the document using the same host globals and import loader as
/// production evaluation.
pub fn complete(
&self,
key: impl AsRef<str>,
source: &str,
position: usize,
explicit: bool,
) -> Result<Option<CompletionResult>> {
completion::complete(&self.environment, key.as_ref(), source, position, explicit)
}
}
#[derive(Debug, Clone, PartialEq)]
+258 -9
View File
@@ -6,19 +6,23 @@ use std::{
};
use decodal::{
Diagnostic as DecodalDiagnostic, DiagnosticKind, HostEnvironment, ImportLoader, LoadedImport,
LoadedSource, SourceId, Span,
Diagnostic as DecodalDiagnostic, DiagnosticKind, HostEnvironment, ImportCandidate,
ImportLoader, LoadedImport, LoadedSource, SourceId, Span,
};
use decodal_language_service::{
CompletionKind as ServiceCompletionKind, CompletionResult, LanguageService, SemanticAnalysis,
};
use decodal_language_service::{LanguageService, SemanticAnalysis};
use decodal_language_tools::format_source;
use lsp_server::{Connection, ErrorCode, Message, Notification, Request, Response};
pub use lsp_types::InitializeParams;
use lsp_types::{
Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DidChangeTextDocumentParams,
DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams,
DocumentFormattingParams, InitializeResult, Location, NumberOrString, OneOf, Position,
PositionEncodingKind, PublishDiagnosticsParams, Range, SaveOptions, ServerCapabilities,
ServerInfo, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
CompletionTextEdit, CompletionTriggerKind, Diagnostic, DiagnosticRelatedInformation,
DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams,
DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentFormattingParams,
InitializeResult, Location, NumberOrString, OneOf, Position, PositionEncodingKind,
PublishDiagnosticsParams, Range, SaveOptions, ServerCapabilities, ServerInfo,
TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
TextDocumentSyncSaveOptions, TextEdit, Uri,
};
@@ -124,6 +128,11 @@ fn server_capabilities() -> ServerCapabilities {
},
)),
document_formatting_provider: Some(OneOf::Left(true)),
completion_provider: Some(CompletionOptions {
resolve_provider: Some(false),
trigger_characters: Some(vec![String::from("."), String::from("/")]),
..CompletionOptions::default()
}),
..ServerCapabilities::default()
}
}
@@ -166,11 +175,66 @@ impl<E: LspEnvironment> Server<E> {
fn handle_request(&self, request: Request) -> ServerResult {
match request.method.as_str() {
"textDocument/completion" => self.complete_document(request),
"textDocument/formatting" => self.format_document(request),
_ => self.reject_unknown_request(request),
}
}
fn complete_document(&self, request: Request) -> ServerResult {
let id = request.id;
let params: CompletionParams = match serde_json::from_value(request.params) {
Ok(params) => params,
Err(error) => {
self.connection
.sender
.send(Message::Response(Response::new_err(
id,
ErrorCode::InvalidParams as i32,
error.to_string(),
)))?;
return Ok(());
}
};
let document_id = params.text_document_position.text_document.uri.as_str();
let Some(document) = self.documents.get(document_id) else {
self.connection
.sender
.send(Message::Response(Response::new_ok(
id,
Option::<CompletionResponse>::None,
)))?;
return Ok(());
};
let position = params.text_document_position.position;
let offset = position_to_byte_offset(&document.source, position);
let explicit = params
.context
.is_some_and(|context| context.trigger_kind == CompletionTriggerKind::INVOKED);
let completion =
match self
.service
.complete(&document.key, &document.source, offset, explicit)
{
Ok(completion) => completion
.map(|completion| completion_to_lsp(completion, &document.source, offset)),
Err(error) => {
self.connection
.sender
.send(Message::Response(Response::new_err(
id,
ErrorCode::InternalError as i32,
error.message,
)))?;
return Ok(());
}
};
self.connection
.sender
.send(Message::Response(Response::new_ok(id, completion)))?;
Ok(())
}
fn format_document(&self, request: Request) -> ServerResult {
let id = request.id;
let params: DocumentFormattingParams = match serde_json::from_value(request.params) {
@@ -413,6 +477,42 @@ fn span_to_range(source: &str, span: Span) -> Range {
}
}
fn completion_to_lsp(
completion: CompletionResult,
source: &str,
offset: usize,
) -> CompletionResponse {
let range = Range::new(
byte_offset_to_position(source, completion.from),
byte_offset_to_position(source, offset),
);
CompletionResponse::Array(
completion
.items
.into_iter()
.map(|item| CompletionItem {
sort_text: Some(format!("{:04}-{}", 9999 - item.priority, item.label)),
text_edit: Some(CompletionTextEdit::Edit(TextEdit::new(
range,
item.label.clone(),
))),
label: item.label,
kind: Some(match item.kind {
ServiceCompletionKind::Keyword => CompletionItemKind::KEYWORD,
ServiceCompletionKind::Constant => CompletionItemKind::CONSTANT,
ServiceCompletionKind::Type => CompletionItemKind::TYPE_PARAMETER,
ServiceCompletionKind::Variable => CompletionItemKind::VARIABLE,
ServiceCompletionKind::Namespace => CompletionItemKind::MODULE,
ServiceCompletionKind::Property => CompletionItemKind::PROPERTY,
ServiceCompletionKind::File => CompletionItemKind::FILE,
}),
detail: item.detail,
..CompletionItem::default()
})
.collect(),
)
}
fn byte_offset_to_position(source: &str, offset: usize) -> Position {
let mut offset = offset.min(source.len());
while !source.is_char_boundary(offset) {
@@ -431,6 +531,36 @@ fn byte_offset_to_position(source: &str, offset: usize) -> Position {
Position { line, character }
}
fn position_to_byte_offset(source: &str, position: Position) -> usize {
let mut line = 0u32;
let mut line_start = 0usize;
for (offset, ch) in source.char_indices() {
if line == position.line {
line_start = offset;
break;
}
if ch == '\n' {
line += 1;
line_start = offset + ch.len_utf8();
}
}
if line < position.line {
return source.len();
}
let mut utf16 = 0u32;
for (relative, ch) in source[line_start..].char_indices() {
if ch == '\n' || utf16 >= position.character {
return line_start + relative;
}
let next = utf16 + ch.len_utf16() as u32;
if next > position.character {
return line_start + relative;
}
utf16 = next;
}
source.len()
}
fn document_key(uri: &Uri) -> String {
if uri
.scheme()
@@ -543,6 +673,64 @@ impl ImportLoader for FileSystemLoader {
source,
}))
}
fn complete_import(
&mut self,
current_key: Option<&str>,
prefix: &str,
) -> decodal::Result<Vec<ImportCandidate>> {
let current_dir = current_key
.and_then(|key| Path::new(key).parent())
.unwrap_or_else(|| Path::new("."));
let prefix_path = Path::new(prefix);
let resolved = if prefix_path.is_absolute() {
prefix_path.to_path_buf()
} else {
current_dir.join(prefix_path)
};
let ends_with_separator = prefix.ends_with('/') || prefix.ends_with('\\');
let directory = if ends_with_separator {
resolved.as_path()
} else {
resolved.parent().unwrap_or_else(|| Path::new("."))
};
let fragment = if ends_with_separator {
""
} else {
resolved
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("")
};
let label_base = if ends_with_separator {
prefix.to_owned()
} else {
prefix
.rfind(['/', '\\'])
.map_or_else(String::new, |index| prefix[..=index].to_owned())
};
let Ok(entries) = fs::read_dir(directory) else {
return Ok(Vec::new());
};
let mut candidates = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if !name.starts_with(fragment) {
continue;
}
let is_directory = entry.file_type().is_ok_and(|kind| kind.is_dir());
let mut specifier = format!("{label_base}{name}");
if is_directory {
specifier.push('/');
}
candidates.push(
ImportCandidate::new(specifier)
.with_detail(entry.path().to_string_lossy().into_owned()),
);
}
candidates.sort_by(|left, right| left.specifier.cmp(&right.specifier));
Ok(candidates)
}
}
#[cfg(test)]
@@ -642,6 +830,14 @@ mod tests {
byte_offset_to_position(source, source.len()),
Position::new(1, 1)
);
assert_eq!(position_to_byte_offset(source, Position::new(0, 0)), 0);
assert_eq!(position_to_byte_offset(source, Position::new(0, 1)), 1);
assert_eq!(position_to_byte_offset(source, Position::new(0, 3)), 5);
assert_eq!(position_to_byte_offset(source, Position::new(1, 0)), 6);
assert_eq!(
position_to_byte_offset(source, Position::new(1, 1)),
source.len()
);
}
#[test]
@@ -651,7 +847,7 @@ mod tests {
}
#[test]
fn publishes_host_environment_diagnostics_over_lsp() {
fn serves_host_environment_diagnostics_and_completion_over_lsp() {
let (server_connection, client_connection) = Connection::memory();
let initialized_workspace = Arc::new(Mutex::new(None));
let server_workspace = Arc::clone(&initialized_workspace);
@@ -689,6 +885,9 @@ mod tests {
panic!("expected initialize response")
};
assert!(initialized.response_result.is_ok());
let initialize_result: InitializeResult =
serde_json::from_value(initialized.response_result.as_ref().unwrap().clone()).unwrap();
assert!(initialize_result.capabilities.completion_provider.is_some());
assert_eq!(
initialized_workspace.lock().unwrap().as_deref(),
Some("file:///tmp")
@@ -779,10 +978,60 @@ mod tests {
assert_eq!(edits.len(), 1);
assert!(edits[0].new_text.ends_with('\n'));
client_connection
.sender
.send(Message::Notification(Notification::new(
String::from("textDocument/didChange"),
json!({
"textDocument": { "uri": uri, "version": 2 },
"contentChanges": [{ "text": "Post.dr" }]
}),
)))
.unwrap();
let published = client_connection
.receiver
.recv_timeout(Duration::from_secs(2))
.unwrap();
assert!(matches!(published, Message::Notification(_)));
client_connection
.sender
.send(Message::Request(Request {
id: RequestId::from(3),
method: String::from("textDocument/completion"),
params: json!({
"textDocument": { "uri": uri },
"position": { "line": 0, "character": 7 },
"context": { "triggerKind": 1 }
}),
}))
.unwrap();
let completed = client_connection
.receiver
.recv_timeout(Duration::from_secs(2))
.unwrap();
let Message::Response(completed) = completed else {
panic!("expected completion response")
};
let completion: CompletionResponse =
serde_json::from_value(completed.response_result.unwrap()).unwrap();
let CompletionResponse::Array(items) = completion else {
panic!("expected completion item array")
};
let draft = items.iter().find(|item| item.label == "draft").unwrap();
assert_eq!(draft.kind, Some(CompletionItemKind::PROPERTY));
let Some(CompletionTextEdit::Edit(edit)) = &draft.text_edit else {
panic!("expected completion text edit")
};
assert_eq!(
edit.range,
Range::new(Position::new(0, 5), Position::new(0, 7))
);
client_connection
.sender
.send(Message::Request(Request {
id: RequestId::from(4),
method: String::from("shutdown"),
params: json!(null),
}))
+6 -1
View File
@@ -6,7 +6,7 @@ rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme.workspace = true
description = "WebAssembly wrapper for evaluating Decodal in browser playgrounds."
description = "Host-configurable Decodal evaluator and language service for JavaScript runtimes."
keywords = ["decodal", "wasm", "dsl", "config"]
categories = ["wasm", "config"]
publish = false
@@ -16,8 +16,13 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
decodal = { version = "0.1.2", path = "../decodal-core" }
decodal-language-service = { version = "0.1.2", path = "../decodal-language-service" }
serde_json.workspace = true
wasm-bindgen.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
serde-wasm-bindgen = "0.6"
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
+228
View File
@@ -0,0 +1,228 @@
use decodal::{CompareOp, Constraint, HostValue, LiteralValue, PrimitiveType};
use serde_json::{Map, Value};
pub(crate) fn from_json(value: &Value) -> Result<HostValue, String> {
match value {
Value::Null => Err(String::from("null is not a Decodal host value")),
Value::Bool(value) => Ok(HostValue::bool(*value)),
Value::Number(value) => number(value),
Value::String(value) => Ok(HostValue::string(value)),
Value::Array(items) => items
.iter()
.enumerate()
.map(|(index, value)| {
from_json(value).map_err(|error| format!("array item {index}: {error}"))
})
.collect::<Result<Vec<_>, _>>()
.map(HostValue::array),
Value::Object(fields) => object(fields),
}
}
fn number(value: &serde_json::Number) -> Result<HostValue, String> {
if let Some(value) = value.as_i64() {
return Ok(HostValue::int(value));
}
if let Some(value) = value.as_u64() {
return i64::try_from(value)
.map(HostValue::int)
.map_err(|_| String::from("integer host value is outside the signed 64-bit range"));
}
value
.as_f64()
.filter(|value| value.is_finite())
.map(HostValue::float)
.ok_or_else(|| String::from("invalid numeric host value"))
}
fn object(fields: &Map<String, Value>) -> Result<HostValue, String> {
let Some(descriptor) = fields.get("$decodal") else {
return fields
.iter()
.map(|(name, value)| {
from_json(value)
.map(|value| (name.clone(), value))
.map_err(|error| format!("field `{name}`: {error}"))
})
.collect::<Result<Vec<_>, _>>()
.map(HostValue::object);
};
let descriptor = descriptor
.as_str()
.ok_or_else(|| String::from("`$decodal` must be a descriptor name"))?;
let constraints = parse_constraints(fields.get("constraints"))?;
let default = fields
.get("default")
.map(from_json)
.transpose()?
.map(Box::new);
match descriptor {
"String" | "Int" | "Float" | "Bool" => {
let primitive = match descriptor {
"String" => PrimitiveType::String,
"Int" => PrimitiveType::Int,
"Float" => PrimitiveType::Float,
"Bool" => PrimitiveType::Bool,
_ => unreachable!(),
};
let mut all_constraints = vec![Constraint::Type(primitive)];
all_constraints.extend(constraints);
Ok(HostValue::Abstract {
constraints: all_constraints,
default,
})
}
"Array" => {
let item = fields
.get("item")
.ok_or_else(|| String::from("Array descriptor requires `item`"))?;
Ok(HostValue::ArrayConstraint {
item: Box::new(from_json(item)?),
constraints,
default,
})
}
"Abstract" => Ok(HostValue::Abstract {
constraints,
default,
}),
name => Err(format!("unknown Decodal host descriptor `{name}`")),
}
}
fn parse_constraints(value: Option<&Value>) -> Result<Vec<Constraint>, String> {
let Some(value) = value else {
return Ok(Vec::new());
};
let items = value
.as_array()
.ok_or_else(|| String::from("`constraints` must be an array"))?;
items
.iter()
.enumerate()
.map(|(index, value)| {
parse_constraint(value).map_err(|error| format!("constraint {index}: {error}"))
})
.collect()
}
fn parse_constraint(value: &Value) -> Result<Constraint, String> {
let fields = value
.as_object()
.ok_or_else(|| String::from("constraint must be an object"))?;
let kind = required_string(fields, "kind")?;
match kind {
"type" => match required_string(fields, "value")? {
"String" => Ok(Constraint::Type(PrimitiveType::String)),
"Int" => Ok(Constraint::Type(PrimitiveType::Int)),
"Float" => Ok(Constraint::Type(PrimitiveType::Float)),
"Bool" => Ok(Constraint::Type(PrimitiveType::Bool)),
value => Err(format!("unknown primitive type `{value}`")),
},
"compare" => {
let operation = match required_string(fields, "op")? {
">" => CompareOp::Gt,
">=" => CompareOp::Gte,
"<" => CompareOp::Lt,
"<=" => CompareOp::Lte,
operation => return Err(format!("unknown comparison operator `{operation}`")),
};
let value = fields
.get("value")
.ok_or_else(|| String::from("compare constraint requires `value`"))?;
Ok(Constraint::Compare(operation, literal(value)?))
}
"regex" => Ok(Constraint::Regex(
required_string(fields, "value")?.to_owned(),
)),
"predicate" => Ok(Constraint::BuiltinPredicate(
required_string(fields, "value")?.to_owned(),
)),
kind => Err(format!("unknown constraint kind `{kind}`")),
}
}
fn literal(value: &Value) -> Result<LiteralValue, String> {
match value {
Value::String(value) => Ok(LiteralValue::String(value.clone())),
Value::Bool(value) => Ok(LiteralValue::Bool(*value)),
Value::Number(value) => match number(value)? {
HostValue::Int(value) => Ok(LiteralValue::Int(value)),
HostValue::Float(value) => Ok(LiteralValue::Float(value)),
_ => unreachable!(),
},
_ => Err(String::from("comparison value must be a primitive literal")),
}
}
fn required_string<'a>(fields: &'a Map<String, Value>, name: &str) -> Result<&'a str, String> {
fields
.get(name)
.and_then(Value::as_str)
.ok_or_else(|| format!("constraint requires string `{name}`"))
}
#[cfg(test)]
mod tests {
use decodal::{Constraint, HostValue, LiteralValue, PrimitiveType};
use super::from_json;
#[test]
fn converts_plain_js_shaped_values() {
let value = serde_json::json!({
"frontmatter": { "draft": false },
"body": "# Hello",
});
assert_eq!(
from_json(&value).unwrap(),
HostValue::object([
("body", HostValue::string("# Hello")),
(
"frontmatter",
HostValue::object([("draft", HostValue::bool(false))]),
),
])
);
}
#[test]
fn converts_schema_descriptors() {
let value = serde_json::json!({
"$decodal": "Array",
"item": {
"$decodal": "Int",
"constraints": [{ "kind": "compare", "op": ">", "value": 0 }],
},
"default": [1, 2],
});
assert_eq!(
from_json(&value).unwrap(),
HostValue::ArrayConstraint {
item: Box::new(HostValue::Abstract {
constraints: vec![
Constraint::Type(PrimitiveType::Int),
Constraint::Compare(decodal::CompareOp::Gt, LiteralValue::Int(0)),
],
default: None,
}),
constraints: Vec::new(),
default: Some(Box::new(HostValue::array([
HostValue::int(1),
HostValue::int(2),
]))),
}
);
}
#[test]
fn rejects_null_values() {
assert!(
from_json(&serde_json::Value::Null)
.unwrap_err()
.contains("null")
);
}
}
+148 -141
View File
@@ -1,25 +1,67 @@
use std::collections::BTreeMap;
use decodal::{
Data, Diagnostic, DiagnosticKind, EmptyLoader, Engine, ImportLoader, LoadedImport,
LoadedSource, SourceId, Span, format_diagnostic_with,
};
#[cfg(target_arch = "wasm32")]
use decodal::HostEnvironment;
use decodal::{Data, EmptyLoader, Engine, SourceId, format_diagnostic_with};
#[cfg(target_arch = "wasm32")]
use decodal_language_service::{CompletionKind, LanguageService};
use wasm_bindgen::prelude::*;
#[cfg(any(target_arch = "wasm32", test))]
mod host_value;
#[cfg(target_arch = "wasm32")]
mod web_environment;
#[cfg(target_arch = "wasm32")]
use web_environment::JsEnvironment;
/// Evaluates one standalone Decodal source with no host globals or imports.
#[wasm_bindgen]
pub fn evaluate(source: &str) -> String {
encode_result(evaluate_inner(source))
}
#[wasm_bindgen(js_name = evaluateProject)]
pub fn evaluate_project(entry: &str, files_json: &str) -> String {
encode_result(evaluate_project_inner(entry, files_json))
/// A browser-facing language service configured entirely by its JavaScript host.
///
/// The host owns globals, import loading, and import completion. This keeps
/// filesystem, network, and virtual-project policy outside the WASM package.
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(js_name = DecodalLanguageService)]
pub struct WebLanguageService {
environment: JsEnvironment,
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(js_class = DecodalLanguageService)]
impl WebLanguageService {
#[wasm_bindgen(constructor)]
pub fn new(options: JsValue) -> Result<WebLanguageService, JsValue> {
Ok(Self {
environment: JsEnvironment::from_options(options)?,
})
}
/// Evaluates a source using the injected globals and import loader.
pub fn evaluate(&self, key: &str, name: &str, source: &str) -> String {
encode_result(evaluate_with_environment(
&self.environment,
key,
name,
source,
))
}
/// Completes a source using the same injected environment as evaluation.
///
/// Positions and returned ranges are UTF-16 offsets, matching browser
/// editors and the Language Server Protocol.
pub fn complete(&self, key: &str, source: &str, position: usize, explicit: bool) -> String {
encode_completion(&self.environment, key, source, position, explicit)
}
}
fn encode_result(result: Result<String, String>) -> String {
match result {
Ok(output) => format!("{{\"ok\":true,\"output\":{}}}", json_string(&output)),
Err(error) => format!("{{\"ok\":false,\"error\":{}}}", json_string(&error)),
Ok(output) => serde_json::json!({ "ok": true, "output": output }).to_string(),
Err(error) => serde_json::json!({ "ok": false, "error": error }).to_string(),
}
}
@@ -40,103 +82,106 @@ fn evaluate_inner(source: &str) -> Result<String, String> {
Ok(format_data(&data, 0))
}
fn evaluate_project_inner(entry: &str, files_json: &str) -> Result<String, String> {
let raw_files: BTreeMap<String, String> = serde_json::from_str(files_json)
.map_err(|error| format!("failed to read playground files: {error}"))?;
let mut files = BTreeMap::new();
for (path, source) in raw_files {
let path = normalize_path(&path).ok_or_else(|| format!("invalid file path `{path}`"))?;
files.insert(path, source);
}
let entry = normalize_path(entry).ok_or_else(|| format!("invalid entry path `{entry}`"))?;
let source = files
.get(&entry)
.cloned()
.ok_or_else(|| format!("entry file `{entry}` was not found"))?;
let mut engine = Engine::new(VirtualLoader { files });
let module = match engine.add_root_source(entry.clone(), entry.clone(), &source) {
#[cfg(target_arch = "wasm32")]
fn evaluate_with_environment(
environment: &JsEnvironment,
key: &str,
name: &str,
source: &str,
) -> Result<String, String> {
let mut engine = environment
.create_engine()
.map_err(|diagnostic| diagnostic.message)?;
let module = match engine.add_root_source(key, name, source) {
Ok(module) => module,
Err(error) => return Err(format_diagnostic_with_root(&error, &entry)),
Err(diagnostic) => return Err(engine.format_diagnostic(&diagnostic)),
};
let value = match engine.eval_module(module) {
Ok(value) => value,
Err(error) => return Err(engine.format_diagnostic(&error)),
Err(diagnostic) => return Err(engine.format_diagnostic(&diagnostic)),
};
let data = match engine.materialize(&value) {
Ok(data) => data,
Err(error) => return Err(engine.format_diagnostic(&error)),
Err(diagnostic) => return Err(engine.format_diagnostic(&diagnostic)),
};
Ok(format_data(&data, 0))
}
#[derive(Debug, Clone)]
struct VirtualLoader {
files: BTreeMap<String, String>,
}
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,
}))
}
}
fn resolve_import(current_key: Option<&str>, specifier: &str) -> Option<String> {
if specifier.starts_with('/') {
return normalize_path(specifier);
}
let mut base = String::new();
if let Some(current_key) = current_key {
if let Some((parent, _file)) = current_key.rsplit_once('/') {
base.push_str(parent);
base.push('/');
#[cfg(target_arch = "wasm32")]
fn encode_completion(
environment: &JsEnvironment,
key: &str,
source: &str,
position: usize,
explicit: bool,
) -> String {
let byte_position = utf16_offset_to_byte(source, position);
let service = LanguageService::new(environment);
let completion = match service.complete(key, source, byte_position, explicit) {
Ok(completion) => completion,
Err(diagnostic) => {
return serde_json::json!({
"ok": false,
"error": diagnostic.message,
})
.to_string();
}
}
base.push_str(specifier);
normalize_path(&base)
};
let completion = completion.map(|completion| {
let from = byte_offset_to_utf16(source, completion.from);
let options = completion
.items
.into_iter()
.map(|item| {
serde_json::json!({
"label": item.label,
"kind": completion_kind_name(item.kind),
"detail": item.detail,
"priority": item.priority,
})
})
.collect::<Vec<_>>();
serde_json::json!({ "from": from, "options": options })
});
serde_json::json!({ "ok": true, "completion": completion }).to_string()
}
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),
#[cfg(target_arch = "wasm32")]
fn completion_kind_name(kind: CompletionKind) -> &'static str {
match kind {
CompletionKind::Keyword => "keyword",
CompletionKind::Constant => "constant",
CompletionKind::Type => "type",
CompletionKind::Variable => "variable",
CompletionKind::Namespace => "namespace",
CompletionKind::Property => "property",
CompletionKind::File => "file",
}
}
#[cfg(any(target_arch = "wasm32", test))]
fn utf16_offset_to_byte(source: &str, offset: usize) -> usize {
let mut utf16 = 0usize;
for (byte, ch) in source.char_indices() {
if utf16 >= offset {
return byte;
}
let next = utf16 + ch.len_utf16();
if next > offset {
return byte;
}
utf16 = next;
}
if parts.is_empty() {
None
} else {
Some(parts.join("/"))
source.len()
}
#[cfg(any(target_arch = "wasm32", test))]
fn byte_offset_to_utf16(source: &str, mut offset: usize) -> usize {
offset = offset.min(source.len());
while !source.is_char_boundary(offset) {
offset = offset.saturating_sub(1);
}
source[..offset].encode_utf16().count()
}
fn format_diagnostic_with_root(diagnostic: &decodal::Diagnostic, root_name: &str) -> String {
@@ -191,66 +236,28 @@ fn format_data(data: &Data, indent: usize) -> String {
}
fn json_string(value: &str) -> String {
let mut out = String::from("\"");
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
ch if ch.is_control() => {
use core::fmt::Write;
let _ = write!(out, "\\u{:04x}", ch as u32);
}
ch => out.push(ch),
}
}
out.push('"');
out
serde_json::to_string(value).expect("strings are always JSON-serializable")
}
#[cfg(test)]
mod tests {
use super::{evaluate_project_inner, normalize_path, resolve_import};
use super::{byte_offset_to_utf16, evaluate_inner, utf16_offset_to_byte};
#[test]
fn normalizes_virtual_paths() {
fn evaluates_standalone_sources() {
assert_eq!(
normalize_path("/schemas/../main.dcdl"),
Some("main.dcdl".into())
);
assert_eq!(normalize_path("../main.dcdl"), None);
}
#[test]
fn resolves_imports_relative_to_current_file() {
assert_eq!(
resolve_import(Some("schemas/service.dcdl"), "./types.dcdl"),
Some("schemas/types.dcdl".into())
evaluate_inner("value = 1;").unwrap(),
"{\n \"value\": 1\n}"
);
}
#[test]
fn evaluates_project_imports() {
let files = r#"{
"main.dcdl":"let dep = import \"./schemas/service.dcdl\"; in dep.Service & { port = 9443; }",
"schemas/service.dcdl":"Service = { name = String default \"api\"; port = Int & > 443 default 8443; }"
}"#;
let output = evaluate_project_inner("main.dcdl", files).unwrap();
assert!(output.contains("\"port\": 9443"));
}
#[test]
fn project_diagnostics_use_virtual_file_names() {
let files = r#"{
"main.dcdl":"let dep = import \"./schemas/service.dcdl\"; in dep.Service & { port = 80; }",
"schemas/service.dcdl":"Service = { port = Int & > 443 default 8443; }"
}"#;
let error = evaluate_project_inner("main.dcdl", files).unwrap_err();
assert!(error.contains("main.dcdl:"));
assert!(error.contains("schemas/service.dcdl:"));
assert!(!error.contains("source 0:"));
assert!(!error.contains("source 1:"));
fn converts_web_utf16_offsets() {
let source = "a😀β";
assert_eq!(utf16_offset_to_byte(source, 0), 0);
assert_eq!(utf16_offset_to_byte(source, 1), 1);
assert_eq!(utf16_offset_to_byte(source, 3), 5);
assert_eq!(byte_offset_to_utf16(source, 5), 3);
assert_eq!(byte_offset_to_utf16(source, source.len()), 4);
}
}
+209
View File
@@ -0,0 +1,209 @@
use std::collections::BTreeMap;
use decodal::{
Diagnostic, DiagnosticKind, Engine, HostEnvironment, HostValue, ImportCandidate, ImportLoader,
LoadedImport, Span,
};
use js_sys::{Function, Reflect};
use serde_json::Value;
use wasm_bindgen::{JsCast, JsValue};
use crate::host_value;
pub(crate) struct JsEnvironment {
globals: BTreeMap<String, HostValue>,
load_import: Option<Function>,
complete_import: Option<Function>,
}
impl JsEnvironment {
pub(crate) fn from_options(options: JsValue) -> Result<Self, JsValue> {
if !options.is_null() && !options.is_undefined() && !options.is_object() {
return Err(JsValue::from_str(
"DecodalLanguageService options must be an object",
));
}
let globals = property(&options, "globals")?;
let globals = if globals.is_null() || globals.is_undefined() {
BTreeMap::new()
} else {
let globals: Value = serde_wasm_bindgen::from_value(globals).map_err(|error| {
JsValue::from_str(&format!("failed to read `globals`: {error}"))
})?;
let globals = globals.as_object().ok_or_else(|| {
JsValue::from_str("DecodalLanguageService `globals` must be an object")
})?;
globals
.iter()
.map(|(name, value)| {
host_value::from_json(value)
.map(|value| (name.clone(), value))
.map_err(|error| {
JsValue::from_str(&format!("invalid global `{name}`: {error}"))
})
})
.collect::<Result<BTreeMap<_, _>, _>>()?
};
Ok(Self {
globals,
load_import: optional_function(&options, "loadImport")?,
complete_import: optional_function(&options, "completeImport")?,
})
}
}
impl HostEnvironment for JsEnvironment {
type Loader = JsLoader;
fn create_loader(&self) -> Self::Loader {
JsLoader {
load_import: self.load_import.clone(),
complete_import: self.complete_import.clone(),
}
}
fn configure_engine(&self, engine: &mut Engine<Self::Loader>) -> decodal::Result<()> {
for (name, value) in &self.globals {
engine.bind_global(name, value.clone())?;
}
Ok(())
}
}
pub(crate) struct JsLoader {
load_import: Option<Function>,
complete_import: Option<Function>,
}
impl ImportLoader for JsLoader {
fn load(
&mut self,
current_key: Option<&str>,
specifier: &str,
) -> decodal::Result<LoadedImport> {
let callback = self.load_import.as_ref().ok_or_else(|| {
import_error(format!(
"no JavaScript `loadImport` callback is configured for `{specifier}`"
))
})?;
let current_key = current_key.map(JsValue::from_str).unwrap_or(JsValue::NULL);
let specifier = JsValue::from_str(specifier);
let loaded = callback
.call2(&JsValue::UNDEFINED, &current_key, &specifier)
.map_err(|error| import_error(js_error_message(error)))?;
let loaded: Value = serde_wasm_bindgen::from_value(loaded)
.map_err(|error| import_error(format!("invalid `loadImport` result: {error}")))?;
loaded_import(&loaded).map_err(import_error)
}
fn complete_import(
&mut self,
current_key: Option<&str>,
prefix: &str,
) -> decodal::Result<Vec<ImportCandidate>> {
let Some(callback) = &self.complete_import else {
return Ok(Vec::new());
};
let current_key = current_key.map(JsValue::from_str).unwrap_or(JsValue::NULL);
let prefix = JsValue::from_str(prefix);
let candidates = callback
.call2(&JsValue::UNDEFINED, &current_key, &prefix)
.map_err(|error| import_error(js_error_message(error)))?;
let candidates: Value = serde_wasm_bindgen::from_value(candidates)
.map_err(|error| import_error(format!("invalid `completeImport` result: {error}")))?;
import_candidates(&candidates).map_err(import_error)
}
}
fn loaded_import(value: &Value) -> Result<LoadedImport, String> {
let fields = value
.as_object()
.ok_or_else(|| String::from("`loadImport` must return an object"))?;
let kind = string_field(fields, "kind")?;
let key = string_field(fields, "key")?;
match kind {
"source" => {
let source = string_field(fields, "source")?;
let name = fields.get("name").and_then(Value::as_str).unwrap_or(key);
Ok(LoadedImport::source(key, name, source))
}
"value" => {
let value = fields
.get("value")
.ok_or_else(|| String::from("value import requires `value`"))?;
let value = host_value::from_json(value)
.map_err(|error| format!("invalid imported value: {error}"))?;
Ok(LoadedImport::value(key, value))
}
kind => Err(format!("unknown `loadImport` result kind `{kind}`")),
}
}
fn import_candidates(value: &Value) -> Result<Vec<ImportCandidate>, String> {
value
.as_array()
.ok_or_else(|| String::from("`completeImport` must return an array"))?
.iter()
.enumerate()
.map(|(index, value)| match value {
Value::String(specifier) => Ok(ImportCandidate::new(specifier)),
Value::Object(fields) => {
let mut candidate = ImportCandidate::new(string_field(fields, "specifier")?);
if let Some(detail) = fields.get("detail") {
let detail = detail.as_str().ok_or_else(|| {
format!("import candidate {index} `detail` must be a string")
})?;
candidate = candidate.with_detail(detail);
}
Ok(candidate)
}
_ => Err(format!(
"import candidate {index} must be a string or object"
)),
})
.collect()
}
fn string_field<'a>(
fields: &'a serde_json::Map<String, Value>,
name: &str,
) -> Result<&'a str, String> {
fields
.get(name)
.and_then(Value::as_str)
.ok_or_else(|| format!("`loadImport` result requires string `{name}`"))
}
fn property(options: &JsValue, name: &str) -> Result<JsValue, JsValue> {
if options.is_null() || options.is_undefined() {
return Ok(JsValue::UNDEFINED);
}
Reflect::get(options, &JsValue::from_str(name))
}
fn optional_function(options: &JsValue, name: &str) -> Result<Option<Function>, JsValue> {
let value = property(options, name)?;
if value.is_null() || value.is_undefined() {
return Ok(None);
}
value
.dyn_into::<Function>()
.map(Some)
.map_err(|_| JsValue::from_str(&format!("`{name}` must be a function")))
}
fn js_error_message(value: JsValue) -> String {
if let Some(message) = value.as_string() {
return message;
}
Reflect::get(&value, &JsValue::from_str("message"))
.ok()
.and_then(|message| message.as_string())
.unwrap_or_else(|| String::from("JavaScript import callback failed"))
}
fn import_error(message: impl Into<String>) -> Diagnostic {
Diagnostic::new(DiagnosticKind::Import, Span::default(), message)
}