Share host-aware completion across runtimes
This commit is contained in:
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
+148
-141
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user