Compare commits
11
Commits
46b2b3b1b6
...
8198b615a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8198b615a8 | ||
|
|
62aa81b91c | ||
|
|
9c46635ee0 | ||
|
|
515adc2533 | ||
|
|
d9cf24d0eb | ||
|
|
d28edcf041 | ||
|
|
e862e53f3e | ||
|
|
610358110f | ||
|
|
bdaccd9803 | ||
|
|
08603dc4b5 | ||
|
|
165daada23 |
Generated
+162
-39
@@ -11,31 +11,42 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.66"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "decodal"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"decodal-derive",
|
||||
"regex",
|
||||
@@ -43,46 +54,93 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "decodal-cli"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"decodal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decodal-derive"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"decodal",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decodal-language-service"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"decodal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decodal-language-tools"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"decodal",
|
||||
"serde_json",
|
||||
"tree-sitter",
|
||||
"tree-sitter-decodal",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decodal-lsp"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"decodal",
|
||||
"decodal-language-service",
|
||||
"decodal-language-tools",
|
||||
"lsp-server",
|
||||
"lsp-types",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decodal-wasm"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"decodal",
|
||||
"decodal-language-service",
|
||||
"js-sys",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
name = "fluent-uri"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
@@ -90,6 +148,49 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lsp-server"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ee25a31f2e571e426eef2896179450cafc7e2f5be00d8a93b1c2d21c0ff7656"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lsp-types"
|
||||
version = "0.97.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fluent-uri",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
@@ -102,6 +203,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -162,6 +269,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-wasm-bindgen"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -181,7 +300,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -198,10 +317,21 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
@@ -215,21 +345,14 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.22.6"
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df7cc499ceadd4dcdf7ec6d4cbc34ece92c3fa07821e287aedecd4416c516dca"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-decodal"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -270,7 +393,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.117",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
|
||||
+4
-1
@@ -5,11 +5,14 @@ members = [
|
||||
"crates/decodal-wasm",
|
||||
"crates/decodal-derive",
|
||||
"crates/decodal-language-tools",
|
||||
"crates/decodal-language-service",
|
||||
"crates/decodal-lsp",
|
||||
]
|
||||
exclude = ["editors/tree-sitter-decodal"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -4,7 +4,8 @@ Decodal is a small deterministic DSL for describing, composing, validating, and
|
||||
|
||||
It is designed around a lightweight Rust library:
|
||||
|
||||
- host-supplied imports through `SourceLoader`
|
||||
- host-supplied source and structured-value imports through `ImportLoader`
|
||||
- shared host environments for production and semantic editor tooling
|
||||
- no filesystem access in the library core
|
||||
- concrete and abstract values with constraints and defaults
|
||||
- deterministic expression evaluation
|
||||
@@ -13,7 +14,7 @@ It is designed around a lightweight Rust library:
|
||||
|
||||
## Library crate
|
||||
|
||||
Embedded hosts should depend on `decodal` and provide imports with a `SourceLoader`.
|
||||
Embedded hosts should depend on `decodal` and provide imports with an `ImportLoader`.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
@@ -64,6 +65,26 @@ Enable optional regex support when needed:
|
||||
cargo run -q -p decodal-cli --features regex -- examples/regex/main.dcdl
|
||||
```
|
||||
|
||||
## Language server
|
||||
|
||||
The `decodal-language-service` crate provides transport-independent evaluation and completion, while `decodal-lsp` exposes it through the Language Server Protocol with live semantic diagnostics and document formatting.
|
||||
Its library API accepts the same host environment used for production evaluation, so application-defined globals and structured import routing remain available in the editor.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
decodal = "0.2.0"
|
||||
decodal-language-service = "0.2.0"
|
||||
decodal-lsp = "0.2.0"
|
||||
```
|
||||
|
||||
Run the default filesystem-backed server from the repository:
|
||||
|
||||
```sh
|
||||
cargo run -q -p decodal-lsp
|
||||
```
|
||||
|
||||
Embedded applications can implement `decodal_lsp::LspEnvironment` for their existing `HostEnvironment` and construct it from the `InitializeParams` passed to `decodal_lsp::run_stdio`.
|
||||
|
||||
## Web playground
|
||||
|
||||
The static documentation site and browser playground live under:
|
||||
@@ -72,6 +93,8 @@ The static documentation site and browser playground live under:
|
||||
site/decodal-site/
|
||||
```
|
||||
|
||||
Browser hosts can use `decodal-wasm`. Its `DecodalLanguageService` accepts JavaScript-owned `globals`, `loadImport`, and `completeImport` callbacks; filesystem and virtual-project policy remain outside the package.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Releasing Decodal
|
||||
|
||||
Run all commands from the repository root. Publishing requires Cargo, npm, and JSR credentials; validation does not.
|
||||
|
||||
## Versions
|
||||
|
||||
- `decodal-derive`: `0.1.3` (already published)
|
||||
- Other published Rust crates: `0.2.0`
|
||||
- `decodal-wasm` npm/JSR package: `0.1.4`
|
||||
- `decodal-codemirror`: `0.1.6`
|
||||
|
||||
The npm packages advertise the repository's dual `MIT OR Apache-2.0` license.
|
||||
JSR metadata uses the single `MIT` identifier required by its publisher; both license files remain included in every package.
|
||||
|
||||
## Validate
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo test --workspace
|
||||
cargo test -p decodal --no-default-features
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo clippy -p decodal-wasm --target wasm32-unknown-unknown -- -D warnings
|
||||
cargo clippy -p decodal-language-tools --target wasm32-unknown-unknown -- -D warnings
|
||||
npm --prefix site/decodal-site run build:wasm
|
||||
npm --prefix packages/decodal-codemirror test
|
||||
npm --prefix site/decodal-site test
|
||||
npm --prefix site/decodal-site run build
|
||||
deno check packages/decodal-wasm/mod.ts
|
||||
npm pack --dry-run ./packages/decodal-wasm
|
||||
deno publish --dry-run --config packages/decodal-wasm/jsr.json
|
||||
npm pack --dry-run ./packages/decodal-codemirror
|
||||
deno publish --dry-run --config packages/decodal-codemirror/jsr.json
|
||||
```
|
||||
|
||||
## Publish Rust crates
|
||||
|
||||
Publish in dependency order. Wait for each crate to become available in the crates.io index before continuing.
|
||||
Run the corresponding command with `--dry-run` immediately before each real publish.
|
||||
|
||||
```sh
|
||||
cargo publish -p decodal
|
||||
cargo publish -p decodal-language-service
|
||||
cargo publish -p decodal-language-tools
|
||||
cargo publish -p decodal-lsp
|
||||
```
|
||||
|
||||
`decodal-cli` and the Rust `decodal-wasm` wrapper remain repository-only packages.
|
||||
|
||||
## Publish JavaScript packages
|
||||
|
||||
The WASM build regenerates and normalizes both committed npm/JSR packages before publishing.
|
||||
|
||||
```sh
|
||||
npm --prefix site/decodal-site run build:wasm
|
||||
npm publish ./packages/decodal-wasm
|
||||
deno publish --config packages/decodal-wasm/jsr.json
|
||||
npm publish ./packages/decodal-codemirror
|
||||
deno publish --config packages/decodal-codemirror/jsr.json
|
||||
```
|
||||
|
||||
After both registries accept the release, tag the release commit and update the deployed site.
|
||||
@@ -20,4 +20,4 @@ default = []
|
||||
regex = ["decodal/regex"]
|
||||
|
||||
[dependencies]
|
||||
decodal = { version = "0.1.2", path = "../decodal-core" }
|
||||
decodal = { version = "0.2.0", path = "../decodal-core" }
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::{
|
||||
};
|
||||
|
||||
use decodal::{
|
||||
Data, Diagnostic, DiagnosticKind, Engine, LoadedSource, SourceId, SourceLoader, Span,
|
||||
format_diagnostic_with,
|
||||
Data, Diagnostic, DiagnosticKind, Engine, ImportLoader, LoadedImport, LoadedSource, SourceId,
|
||||
Span, format_diagnostic_with,
|
||||
};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -95,12 +95,12 @@ fn read_root_source(path: &str) -> Result<LoadedSource, Diagnostic> {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct FsLoader;
|
||||
|
||||
impl SourceLoader for FsLoader {
|
||||
impl ImportLoader for FsLoader {
|
||||
fn load(
|
||||
&mut self,
|
||||
current_key: Option<&str>,
|
||||
specifier: &str,
|
||||
) -> Result<LoadedSource, Diagnostic> {
|
||||
) -> Result<LoadedImport, Diagnostic> {
|
||||
let path = Path::new(specifier);
|
||||
let path = if path.is_absolute() {
|
||||
PathBuf::from(path)
|
||||
@@ -112,7 +112,7 @@ impl SourceLoader for FsLoader {
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
};
|
||||
load_path(&path)
|
||||
load_path(&path).map(LoadedImport::Source)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,5 +17,5 @@ derive = ["dep:decodal-derive"]
|
||||
regex = ["std", "dep:regex"]
|
||||
|
||||
[dependencies]
|
||||
decodal-derive = { version = "0.1.1", path = "../decodal-derive", optional = true }
|
||||
decodal-derive = { version = "0.1.3", path = "../decodal-derive", optional = true }
|
||||
regex = { version = "1.10", default-features = false, features = ["std", "unicode-perl"], optional = true }
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
use decodal::{
|
||||
Data, Diagnostic, DiagnosticKind, Engine, HostValue, ImportLoader, LoadedImport, Span,
|
||||
};
|
||||
|
||||
const POST: &str = r#"---
|
||||
title: Hello
|
||||
draft: false
|
||||
---
|
||||
# Hello
|
||||
|
||||
This body stays as Markdown.
|
||||
"#;
|
||||
|
||||
struct ContentLoader;
|
||||
|
||||
impl ImportLoader for ContentLoader {
|
||||
fn load(
|
||||
&mut self,
|
||||
_current_key: Option<&str>,
|
||||
specifier: &str,
|
||||
) -> decodal::Result<LoadedImport> {
|
||||
if specifier != "./post.md" {
|
||||
return Err(Diagnostic::new(
|
||||
DiagnosticKind::Import,
|
||||
Span::default(),
|
||||
format!("unknown content import `{specifier}`"),
|
||||
));
|
||||
}
|
||||
Ok(LoadedImport::value(
|
||||
"content/post.md",
|
||||
parse_markdown(POST)?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_markdown(source: &str) -> decodal::Result<HostValue> {
|
||||
let source = source.strip_prefix("---\n").ok_or_else(frontmatter_error)?;
|
||||
let (frontmatter, body) = source.split_once("\n---\n").ok_or_else(frontmatter_error)?;
|
||||
let mut fields = Vec::new();
|
||||
for line in frontmatter.lines() {
|
||||
let (name, value) = line.split_once(':').ok_or_else(frontmatter_error)?;
|
||||
let value = match value.trim() {
|
||||
"true" => HostValue::bool(true),
|
||||
"false" => HostValue::bool(false),
|
||||
value => HostValue::string(value),
|
||||
};
|
||||
fields.push((name.trim(), value));
|
||||
}
|
||||
Ok(HostValue::object([
|
||||
("frontmatter", HostValue::object(fields)),
|
||||
("body", HostValue::string(body)),
|
||||
]))
|
||||
}
|
||||
|
||||
fn frontmatter_error() -> Diagnostic {
|
||||
Diagnostic::new(
|
||||
DiagnosticKind::Import,
|
||||
Span::default(),
|
||||
"invalid Markdown frontmatter",
|
||||
)
|
||||
}
|
||||
|
||||
fn main() -> decodal::Result<()> {
|
||||
let mut engine = Engine::new(ContentLoader);
|
||||
let module = engine.add_root_source(
|
||||
"main.dcdl",
|
||||
"main.dcdl",
|
||||
r#"
|
||||
let post = import "./post.md";
|
||||
in {
|
||||
title = post.frontmatter.title;
|
||||
draft = post.frontmatter.draft;
|
||||
body = post.body;
|
||||
}
|
||||
"#,
|
||||
)?;
|
||||
let value = engine.eval_module(module)?;
|
||||
let data = engine.materialize(&value)?;
|
||||
|
||||
let Data::Object(fields) = data else {
|
||||
panic!("expected imported Markdown to produce an object")
|
||||
};
|
||||
assert_eq!(fields[0].value, Data::String(String::from("Hello")));
|
||||
assert_eq!(fields[1].value, Data::Bool(false));
|
||||
assert!(matches!(fields[2].value, Data::String(_)));
|
||||
Ok(())
|
||||
}
|
||||
@@ -70,6 +70,13 @@ pub enum Expr {
|
||||
params: Vec<Param>,
|
||||
body: ExprId,
|
||||
},
|
||||
/// An explicitly parenthesized expression.
|
||||
///
|
||||
/// Keeping this node preserves the delimiters and their trivia for source
|
||||
/// tools while evaluation remains equivalent to evaluating `expr`.
|
||||
Parenthesized {
|
||||
expr: ExprId,
|
||||
},
|
||||
Match {
|
||||
scrutinee: ExprId,
|
||||
arms: Vec<MatchArm>,
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct Diagnostic {
|
||||
pub span: Span,
|
||||
pub message: String,
|
||||
pub labels: Vec<DiagnosticLabel>,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -25,6 +26,7 @@ impl Diagnostic {
|
||||
span,
|
||||
message: message.into(),
|
||||
labels: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +41,11 @@ impl Diagnostic {
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_note(mut self, message: impl Into<String>) -> Self {
|
||||
self.notes.push(message.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use crate::{Engine, ImportLoader, Result};
|
||||
|
||||
/// Host-owned configuration shared by runtime and language tooling.
|
||||
///
|
||||
/// Implementations create the import loader and install global bindings on a
|
||||
/// fresh engine. Calling [`HostEnvironment::create_engine`] therefore gives
|
||||
/// every consumer the same Decodal execution environment.
|
||||
pub trait HostEnvironment {
|
||||
type Loader: ImportLoader;
|
||||
|
||||
fn create_loader(&self) -> Self::Loader;
|
||||
|
||||
fn configure_engine(&self, _engine: &mut Engine<Self::Loader>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_engine(&self) -> Result<Engine<Self::Loader>> {
|
||||
let mut engine = Engine::new(self.create_loader());
|
||||
self.configure_engine(&mut engine)?;
|
||||
Ok(engine)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: HostEnvironment + ?Sized> HostEnvironment for &T {
|
||||
type Loader = T::Loader;
|
||||
|
||||
fn create_loader(&self) -> Self::Loader {
|
||||
T::create_loader(*self)
|
||||
}
|
||||
|
||||
fn configure_engine(&self, engine: &mut Engine<Self::Loader>) -> Result<()> {
|
||||
T::configure_engine(*self, engine)
|
||||
}
|
||||
}
|
||||
+541
-40
@@ -6,7 +6,7 @@ use crate::{
|
||||
constraints::normalize_constraints,
|
||||
diagnostic::{Diagnostic, DiagnosticKind, Result},
|
||||
embedding::HostValue,
|
||||
module::{EmptyLoader, LoadedSource, Module, SourceLoader},
|
||||
module::{EmptyLoader, ImportLoader, LoadedImport, LoadedSource, LoadedValue, Module},
|
||||
parse_source_with_source_id,
|
||||
runtime::{
|
||||
AbstractValue, Binding, ConcreteValue, Constraint, ConstraintEntry, Data, DataField, Env,
|
||||
@@ -19,10 +19,50 @@ pub struct Engine<L = EmptyLoader> {
|
||||
loader: L,
|
||||
prelude_env: EnvId,
|
||||
modules: Vec<Module>,
|
||||
imported_values: Vec<ImportedValue>,
|
||||
thunks: Vec<Thunk>,
|
||||
thunk_import_origins: Vec<Option<ImportedValueOrigin>>,
|
||||
envs: Vec<Env>,
|
||||
}
|
||||
|
||||
struct ImportedValue {
|
||||
key: String,
|
||||
root: ThunkId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ImportedValueOrigin {
|
||||
key: String,
|
||||
path: Vec<String>,
|
||||
}
|
||||
|
||||
impl ImportedValueOrigin {
|
||||
fn root(key: String) -> Self {
|
||||
Self {
|
||||
key,
|
||||
path: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn field(&self, name: String) -> Self {
|
||||
let mut path = self.path.clone();
|
||||
path.push(name);
|
||||
Self {
|
||||
key: self.key.clone(),
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
fn index(&self, index: usize) -> Self {
|
||||
let mut path = self.path.clone();
|
||||
path.push(format!("[{index}]"));
|
||||
Self {
|
||||
key: self.key.clone(),
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Engine<EmptyLoader> {
|
||||
pub fn from_parse(ast: Ast, root: ExprId) -> Self {
|
||||
let mut this = Self::new(EmptyLoader);
|
||||
@@ -38,13 +78,15 @@ impl Engine<EmptyLoader> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<L: SourceLoader> Engine<L> {
|
||||
impl<L: ImportLoader> Engine<L> {
|
||||
pub fn new(loader: L) -> Self {
|
||||
Self {
|
||||
loader,
|
||||
prelude_env: EnvId(0),
|
||||
modules: Vec::new(),
|
||||
imported_values: Vec::new(),
|
||||
thunks: Vec::new(),
|
||||
thunk_import_origins: Vec::new(),
|
||||
envs: vec![Env {
|
||||
parent: None,
|
||||
bindings: Vec::new(),
|
||||
@@ -65,6 +107,42 @@ impl<L: SourceLoader> 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>,
|
||||
@@ -236,10 +314,38 @@ impl<L: SourceLoader> Engine<L> {
|
||||
.map(|index| ModuleId(index as u32))
|
||||
}
|
||||
|
||||
fn load_import(&mut self, current: ModuleId, specifier: &str) -> Result<ModuleId> {
|
||||
fn eval_import(&mut self, current: ModuleId, specifier: &str) -> Result<RuntimeValue> {
|
||||
let current_key = self.modules[current.0 as usize].key.clone();
|
||||
let LoadedSource { key, name, source } = self.loader.load(Some(¤t_key), specifier)?;
|
||||
self.add_source(key, name, &source)
|
||||
let loaded = self.loader.load(Some(¤t_key), specifier)?;
|
||||
match loaded {
|
||||
LoadedImport::Source(LoadedSource { key, name, source }) => {
|
||||
if let Some(value) = self.find_imported_value(&key) {
|
||||
return self.force(value);
|
||||
}
|
||||
let module = self.add_source(key, name, &source)?;
|
||||
self.eval_module(module)
|
||||
}
|
||||
LoadedImport::Value(LoadedValue { key, value }) => {
|
||||
if let Some(module) = self.find_module(&key) {
|
||||
return self.eval_module(module);
|
||||
}
|
||||
if let Some(value) = self.find_imported_value(&key) {
|
||||
return self.force(value);
|
||||
}
|
||||
let origin = ImportedValueOrigin::root(key.clone());
|
||||
let value = self.internalize_host_value_with_origin(value, Some(&origin))?;
|
||||
let root = self.add_value_thunk_with_import_origin(value, origin);
|
||||
self.imported_values.push(ImportedValue { key, root });
|
||||
self.force(root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_imported_value(&self, key: &str) -> Option<ThunkId> {
|
||||
self.imported_values
|
||||
.iter()
|
||||
.find(|value| value.key == key)
|
||||
.map(|value| value.root)
|
||||
}
|
||||
|
||||
fn eval_expr(&mut self, reference: ExprRef, env: EnvId) -> Result<RuntimeValue> {
|
||||
@@ -303,10 +409,7 @@ impl<L: SourceLoader> Engine<L> {
|
||||
let_env,
|
||||
)
|
||||
}
|
||||
Expr::Import(specifier) => {
|
||||
let module = self.load_import(reference.module, &specifier)?;
|
||||
self.eval_module(module)
|
||||
}
|
||||
Expr::Import(specifier) => self.eval_import(reference.module, &specifier),
|
||||
Expr::Path { base, field } => {
|
||||
let base = self.eval_expr(
|
||||
ExprRef {
|
||||
@@ -368,6 +471,13 @@ impl<L: SourceLoader> Engine<L> {
|
||||
},
|
||||
)))
|
||||
}
|
||||
Expr::Parenthesized { expr } => self.eval_expr(
|
||||
ExprRef {
|
||||
module: reference.module,
|
||||
expr,
|
||||
},
|
||||
env,
|
||||
),
|
||||
Expr::Match { scrutinee, arms } => {
|
||||
let value = self.eval_expr(
|
||||
ExprRef {
|
||||
@@ -724,6 +834,14 @@ impl<L: SourceLoader> Engine<L> {
|
||||
) -> Result<bool> {
|
||||
match self.expr(pattern).clone() {
|
||||
Expr::Wildcard => Ok(true),
|
||||
Expr::Parenthesized { expr } => self.matches_pattern(
|
||||
value,
|
||||
ExprRef {
|
||||
module: pattern.module,
|
||||
expr,
|
||||
},
|
||||
env,
|
||||
),
|
||||
Expr::ArrayConstraint { .. }
|
||||
| Expr::CompareConstraint { .. }
|
||||
| Expr::RegexConstraint(_)
|
||||
@@ -805,17 +923,59 @@ impl<L: SourceLoader> Engine<L> {
|
||||
.iter()
|
||||
.position(|lhs_field| lhs_field.name == rhs_field.name)
|
||||
{
|
||||
let lhs_thunk = lhs.fields[index].value;
|
||||
let lhs_field_span = lhs.fields[index].span;
|
||||
let lhs_value = self.force(lhs.fields[index].value)?;
|
||||
let lhs_origin = self.thunk_import_origin(lhs_thunk).cloned();
|
||||
let rhs_origin = self.thunk_import_origin(rhs_field.value).cloned();
|
||||
let lhs_value = self.force(lhs_thunk)?;
|
||||
let rhs_value = self.force(rhs_field.value)?;
|
||||
let value = self
|
||||
.compose_and(lhs_value, rhs_value, span)
|
||||
.compose_and(lhs_value.clone(), rhs_value.clone(), span)
|
||||
.map_err(|diag| {
|
||||
diag.with_label(span, format!("while composing field `{}`", rhs_field.name))
|
||||
.with_label(lhs_field_span, format!("left field `{}`", rhs_field.name))
|
||||
.with_label(rhs_field.span, format!("right field `{}`", rhs_field.name))
|
||||
let mut diagnostic = diag.with_label(
|
||||
span,
|
||||
format!("while composing field `{}`", rhs_field.name),
|
||||
);
|
||||
if lhs_origin.is_some() {
|
||||
diagnostic = annotate_imported_value(
|
||||
diagnostic,
|
||||
lhs_origin.as_ref(),
|
||||
Some(&lhs_value),
|
||||
);
|
||||
} else {
|
||||
diagnostic = diagnostic.with_label(
|
||||
lhs_field_span,
|
||||
format!("left field `{}`", rhs_field.name),
|
||||
);
|
||||
}
|
||||
if rhs_origin.is_some() {
|
||||
diagnostic = annotate_imported_value(
|
||||
diagnostic,
|
||||
rhs_origin.as_ref(),
|
||||
Some(&rhs_value),
|
||||
);
|
||||
} else {
|
||||
diagnostic = diagnostic.with_label(
|
||||
rhs_field.span,
|
||||
format!("right field `{}`", rhs_field.name),
|
||||
);
|
||||
}
|
||||
diagnostic
|
||||
})?;
|
||||
lhs.fields[index].value = self.add_value_thunk_with_span(value, rhs_field.span);
|
||||
let result_span = match (lhs_origin.is_some(), rhs_origin.is_some()) {
|
||||
(false, true) => lhs_field_span,
|
||||
(true, false) => rhs_field.span,
|
||||
_ => rhs_field.span,
|
||||
};
|
||||
let result_origin = rhs_origin.clone().or(lhs_origin.clone());
|
||||
lhs.fields[index].value = self.add_value_thunk_with_span_and_import_origin(
|
||||
value,
|
||||
result_span,
|
||||
result_origin,
|
||||
);
|
||||
if lhs_origin.is_some() && rhs_origin.is_none() {
|
||||
lhs.fields[index].span = rhs_field.span;
|
||||
}
|
||||
} else {
|
||||
lhs.fields.push(rhs_field);
|
||||
}
|
||||
@@ -841,9 +1001,14 @@ impl<L: SourceLoader> Engine<L> {
|
||||
.position(|lhs_field| lhs_field.name == rhs_field.name)
|
||||
{
|
||||
let lhs_value = self.force(lhs.fields[index].value)?;
|
||||
let rhs_origin = self.thunk_import_origin(rhs_field.value).cloned();
|
||||
let rhs_value = self.force(rhs_field.value)?;
|
||||
let value = self.patch(lhs_value, rhs_value)?;
|
||||
lhs.fields[index].value = self.add_value_thunk_with_span(value, rhs_field.span);
|
||||
lhs.fields[index].value = self.add_value_thunk_with_span_and_import_origin(
|
||||
value,
|
||||
rhs_field.span,
|
||||
rhs_origin,
|
||||
);
|
||||
lhs.fields[index].span = rhs_field.span;
|
||||
} else {
|
||||
lhs.fields.push(rhs_field);
|
||||
@@ -893,38 +1058,68 @@ impl<L: SourceLoader> Engine<L> {
|
||||
constraint,
|
||||
span,
|
||||
value_label,
|
||||
"value does not satisfy primitive type constraint",
|
||||
format!(
|
||||
"expected {primitive:?}, found {}",
|
||||
runtime_value_kind(&value)
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
Constraint::ArrayItems(item_constraint) => {
|
||||
let value_kind = runtime_value_kind(&value);
|
||||
let RuntimeValue::Concrete(ConcreteValue::Array(items)) = value else {
|
||||
return Err(self.constraint_violation(
|
||||
constraint,
|
||||
span,
|
||||
value_label,
|
||||
"value does not satisfy array element constraint",
|
||||
format!("expected Array, found {value_kind}"),
|
||||
));
|
||||
};
|
||||
let item_constraint_value = self.force(*item_constraint)?;
|
||||
let mut constrained_items = Vec::with_capacity(items.len());
|
||||
for (index, item) in items.into_iter().enumerate() {
|
||||
let item_span = self.thunk_span(item);
|
||||
let item_origin = self.thunk_import_origin(item).cloned();
|
||||
let item_value = self.force(item)?;
|
||||
let composition_span = if item_origin.is_some() {
|
||||
constraint.span
|
||||
} else {
|
||||
item_span
|
||||
};
|
||||
let constrained = self
|
||||
.compose_and(item_constraint_value.clone(), item_value, item_span)
|
||||
.compose_and(
|
||||
item_constraint_value.clone(),
|
||||
item_value.clone(),
|
||||
composition_span,
|
||||
)
|
||||
.map_err(|diagnostic| {
|
||||
diagnostic
|
||||
.with_label(
|
||||
let diagnostic = diagnostic.with_label(
|
||||
constraint.span,
|
||||
"array element constraint declared here",
|
||||
);
|
||||
if item_origin.is_some() {
|
||||
annotate_imported_value(
|
||||
diagnostic,
|
||||
item_origin.as_ref(),
|
||||
Some(&item_value),
|
||||
)
|
||||
.with_label(
|
||||
} else {
|
||||
diagnostic.with_label(
|
||||
item_span,
|
||||
format!("array element [{index}] checked here"),
|
||||
)
|
||||
}
|
||||
})?;
|
||||
constrained_items.push(self.add_value_thunk_with_span(constrained, item_span));
|
||||
let constrained_span = if item_origin.is_some() {
|
||||
constraint.span
|
||||
} else {
|
||||
item_span
|
||||
};
|
||||
constrained_items.push(self.add_value_thunk_with_span_and_import_origin(
|
||||
constrained,
|
||||
constrained_span,
|
||||
item_origin,
|
||||
));
|
||||
}
|
||||
Ok(RuntimeValue::Concrete(ConcreteValue::Array(
|
||||
constrained_items,
|
||||
@@ -965,7 +1160,7 @@ impl<L: SourceLoader> Engine<L> {
|
||||
constraint: &ConstraintEntry,
|
||||
operation_span: Span,
|
||||
value_label: Option<(Span, &'static str)>,
|
||||
message: &'static str,
|
||||
message: impl Into<String>,
|
||||
) -> Diagnostic {
|
||||
let mut diagnostic = Diagnostic::new(
|
||||
DiagnosticKind::ConstraintViolation,
|
||||
@@ -1001,6 +1196,14 @@ impl<L: SourceLoader> Engine<L> {
|
||||
}
|
||||
|
||||
fn internalize_host_value(&mut self, value: HostValue) -> Result<RuntimeValue> {
|
||||
self.internalize_host_value_with_origin(value, None)
|
||||
}
|
||||
|
||||
fn internalize_host_value_with_origin(
|
||||
&mut self,
|
||||
value: HostValue,
|
||||
origin: Option<&ImportedValueOrigin>,
|
||||
) -> Result<RuntimeValue> {
|
||||
match value {
|
||||
HostValue::String(value) => Ok(RuntimeValue::Concrete(ConcreteValue::String(value))),
|
||||
HostValue::Int(value) => Ok(RuntimeValue::Concrete(ConcreteValue::Int(value))),
|
||||
@@ -1008,9 +1211,12 @@ impl<L: SourceLoader> Engine<L> {
|
||||
HostValue::Bool(value) => Ok(RuntimeValue::Concrete(ConcreteValue::Bool(value))),
|
||||
HostValue::Array(items) => {
|
||||
let mut thunks = Vec::new();
|
||||
for item in items {
|
||||
let value = self.internalize_host_value(item)?;
|
||||
thunks.push(self.add_value_thunk(value));
|
||||
for (index, item) in items.into_iter().enumerate() {
|
||||
let item_origin = origin.map(|origin| origin.index(index));
|
||||
let value =
|
||||
self.internalize_host_value_with_origin(item, item_origin.as_ref())?;
|
||||
thunks
|
||||
.push(self.add_value_thunk_with_optional_import_origin(value, item_origin));
|
||||
}
|
||||
Ok(RuntimeValue::Concrete(ConcreteValue::Array(thunks)))
|
||||
}
|
||||
@@ -1019,11 +1225,11 @@ impl<L: SourceLoader> Engine<L> {
|
||||
mut constraints,
|
||||
default,
|
||||
} => {
|
||||
let item = self.internalize_host_value(*item)?;
|
||||
let item = self.add_value_thunk(item);
|
||||
let item = self.internalize_host_value_with_origin(*item, origin)?;
|
||||
let item = self.add_value_thunk_with_optional_import_origin(item, origin.cloned());
|
||||
let default = if let Some(default) = default {
|
||||
let value = self.internalize_host_value(*default)?;
|
||||
Some(self.add_value_thunk(value))
|
||||
let value = self.internalize_host_value_with_origin(*default, origin)?;
|
||||
Some(self.add_value_thunk_with_optional_import_origin(value, origin.cloned()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1044,19 +1250,27 @@ impl<L: SourceLoader> Engine<L> {
|
||||
HostValue::Object(fields) => {
|
||||
let mut object = ObjectValue { fields: Vec::new() };
|
||||
for field in fields {
|
||||
let field_origin = origin.map(|origin| origin.field(field.name.clone()));
|
||||
if object
|
||||
.fields
|
||||
.iter()
|
||||
.any(|existing| existing.name == field.name)
|
||||
{
|
||||
return Err(Diagnostic::new(
|
||||
let diagnostic = Diagnostic::new(
|
||||
DiagnosticKind::Conflict,
|
||||
Span::default(),
|
||||
format!("duplicate host object field `{}`", field.name),
|
||||
);
|
||||
return Err(annotate_imported_value(
|
||||
diagnostic,
|
||||
field_origin.as_ref(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
let value = self.internalize_host_value(field.value)?;
|
||||
let value = self.add_value_thunk(value);
|
||||
let value = self
|
||||
.internalize_host_value_with_origin(field.value, field_origin.as_ref())?;
|
||||
let value =
|
||||
self.add_value_thunk_with_optional_import_origin(value, field_origin);
|
||||
object.fields.push(ObjectField {
|
||||
name: field.name,
|
||||
value,
|
||||
@@ -1070,8 +1284,8 @@ impl<L: SourceLoader> Engine<L> {
|
||||
default,
|
||||
} => {
|
||||
let default = if let Some(default) = default {
|
||||
let value = self.internalize_host_value(*default)?;
|
||||
Some(self.add_value_thunk(value))
|
||||
let value = self.internalize_host_value_with_origin(*default, origin)?;
|
||||
Some(self.add_value_thunk_with_optional_import_origin(value, origin.cloned()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1178,13 +1392,48 @@ impl<L: SourceLoader> Engine<L> {
|
||||
self.add_thunk(ThunkKind::Value(value), span)
|
||||
}
|
||||
|
||||
fn add_value_thunk_with_import_origin(
|
||||
&mut self,
|
||||
value: RuntimeValue,
|
||||
origin: ImportedValueOrigin,
|
||||
) -> ThunkId {
|
||||
self.add_value_thunk_with_optional_import_origin(value, Some(origin))
|
||||
}
|
||||
|
||||
fn add_value_thunk_with_optional_import_origin(
|
||||
&mut self,
|
||||
value: RuntimeValue,
|
||||
origin: Option<ImportedValueOrigin>,
|
||||
) -> ThunkId {
|
||||
self.add_value_thunk_with_span_and_import_origin(value, Span::default(), origin)
|
||||
}
|
||||
|
||||
fn add_value_thunk_with_span_and_import_origin(
|
||||
&mut self,
|
||||
value: RuntimeValue,
|
||||
span: Span,
|
||||
origin: Option<ImportedValueOrigin>,
|
||||
) -> ThunkId {
|
||||
self.add_thunk_with_import_origin(ThunkKind::Value(value), span, origin)
|
||||
}
|
||||
|
||||
fn add_thunk(&mut self, kind: ThunkKind, span: Span) -> ThunkId {
|
||||
self.add_thunk_with_import_origin(kind, span, None)
|
||||
}
|
||||
|
||||
fn add_thunk_with_import_origin(
|
||||
&mut self,
|
||||
kind: ThunkKind,
|
||||
span: Span,
|
||||
origin: Option<ImportedValueOrigin>,
|
||||
) -> ThunkId {
|
||||
let id = ThunkId(self.thunks.len() as u32);
|
||||
self.thunks.push(Thunk {
|
||||
kind,
|
||||
state: ThunkState::Unevaluated,
|
||||
span,
|
||||
});
|
||||
self.thunk_import_origins.push(origin);
|
||||
id
|
||||
}
|
||||
|
||||
@@ -1238,6 +1487,10 @@ impl<L: SourceLoader> Engine<L> {
|
||||
self.thunks[thunk.0 as usize].span
|
||||
}
|
||||
|
||||
fn thunk_import_origin(&self, thunk: ThunkId) -> Option<&ImportedValueOrigin> {
|
||||
self.thunk_import_origins[thunk.0 as usize].as_ref()
|
||||
}
|
||||
|
||||
fn with_path_context(
|
||||
&self,
|
||||
diagnostic: Diagnostic,
|
||||
@@ -1296,6 +1549,9 @@ pub fn format_diagnostic_with<'a>(
|
||||
label.message,
|
||||
));
|
||||
}
|
||||
for note in &diagnostic.notes {
|
||||
out.push_str(&format!("\nnote: {note}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1370,6 +1626,45 @@ fn format_path(path: &[String]) -> String {
|
||||
output
|
||||
}
|
||||
|
||||
fn annotate_imported_value(
|
||||
diagnostic: Diagnostic,
|
||||
origin: Option<&ImportedValueOrigin>,
|
||||
value: Option<&RuntimeValue>,
|
||||
) -> Diagnostic {
|
||||
let Some(origin) = origin else {
|
||||
return diagnostic;
|
||||
};
|
||||
if diagnostic
|
||||
.notes
|
||||
.iter()
|
||||
.any(|note| note.starts_with("imported `"))
|
||||
{
|
||||
return diagnostic;
|
||||
}
|
||||
let path = if origin.path.is_empty() {
|
||||
String::from("<root>")
|
||||
} else {
|
||||
format_path(&origin.path)
|
||||
};
|
||||
let value = value
|
||||
.map(|value| format!(" supplied a value of type {}", runtime_value_kind(value)))
|
||||
.unwrap_or_default();
|
||||
diagnostic.with_note(format!("imported `{}` at `{path}`{value}", origin.key))
|
||||
}
|
||||
|
||||
fn runtime_value_kind(value: &RuntimeValue) -> &'static str {
|
||||
match value {
|
||||
RuntimeValue::Concrete(ConcreteValue::String(_)) => "String",
|
||||
RuntimeValue::Concrete(ConcreteValue::Int(_)) => "Int",
|
||||
RuntimeValue::Concrete(ConcreteValue::Float(_)) => "Float",
|
||||
RuntimeValue::Concrete(ConcreteValue::Bool(_)) => "Bool",
|
||||
RuntimeValue::Concrete(ConcreteValue::Array(_)) => "Array",
|
||||
RuntimeValue::Concrete(ConcreteValue::Object(_)) => "Object",
|
||||
RuntimeValue::Concrete(ConcreteValue::Function(_)) => "Function",
|
||||
RuntimeValue::Abstract(_) => "abstract",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "regex")]
|
||||
fn satisfies_regex(value: &RuntimeValue, pattern: &str, span: Span) -> Result<()> {
|
||||
let RuntimeValue::Concrete(ConcreteValue::String(value)) = value else {
|
||||
@@ -1763,6 +2058,13 @@ mod tests {
|
||||
assert_eq!(fields[4].value, Data::Int(-2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parenthesized_wildcard_remains_a_match_pattern() {
|
||||
let data = eval_data("result = match 1 { (_): 2; };");
|
||||
let Data::Object(fields) = data else { panic!() };
|
||||
assert_eq!(fields[0].value, Data::Int(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_can_feed_constraints() {
|
||||
let data = eval_data("port = Int & > 4000 + 42 default 8080;");
|
||||
@@ -1964,8 +2266,8 @@ mod tests {
|
||||
sources: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl SourceLoader for MapLoader {
|
||||
fn load(&mut self, _current_key: Option<&str>, specifier: &str) -> Result<LoadedSource> {
|
||||
impl ImportLoader for MapLoader {
|
||||
fn load(&mut self, _current_key: Option<&str>, specifier: &str) -> Result<LoadedImport> {
|
||||
let source = self
|
||||
.sources
|
||||
.iter()
|
||||
@@ -1974,11 +2276,11 @@ mod tests {
|
||||
.ok_or_else(|| {
|
||||
Diagnostic::new(DiagnosticKind::Import, Span::default(), "missing source")
|
||||
})?;
|
||||
Ok(LoadedSource {
|
||||
Ok(LoadedImport::Source(LoadedSource {
|
||||
key: specifier.into(),
|
||||
name: specifier.into(),
|
||||
source,
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2003,6 +2305,205 @@ mod tests {
|
||||
assert_eq!(fields[0].value, Data::Int(9000));
|
||||
}
|
||||
|
||||
struct MarkdownLoader {
|
||||
source: String,
|
||||
}
|
||||
|
||||
impl ImportLoader for MarkdownLoader {
|
||||
fn load(&mut self, _current_key: Option<&str>, specifier: &str) -> Result<LoadedImport> {
|
||||
if specifier != "./post.md" {
|
||||
return Err(Diagnostic::new(
|
||||
DiagnosticKind::Import,
|
||||
Span::default(),
|
||||
format!("unknown content import `{specifier}`"),
|
||||
));
|
||||
}
|
||||
let value = parse_test_markdown(&self.source)?;
|
||||
Ok(LoadedImport::value("content/post.md", value))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_test_markdown(source: &str) -> Result<HostValue> {
|
||||
let source = source.strip_prefix("---\n").ok_or_else(|| {
|
||||
Diagnostic::new(
|
||||
DiagnosticKind::Import,
|
||||
Span::default(),
|
||||
"markdown frontmatter must start with `---`",
|
||||
)
|
||||
})?;
|
||||
let (frontmatter, body) = source.split_once("\n---\n").ok_or_else(|| {
|
||||
Diagnostic::new(
|
||||
DiagnosticKind::Import,
|
||||
Span::default(),
|
||||
"markdown frontmatter is not terminated",
|
||||
)
|
||||
})?;
|
||||
let mut fields = Vec::new();
|
||||
for line in frontmatter.lines() {
|
||||
let (name, value) = line.split_once(':').ok_or_else(|| {
|
||||
Diagnostic::new(
|
||||
DiagnosticKind::Import,
|
||||
Span::default(),
|
||||
"invalid markdown frontmatter field",
|
||||
)
|
||||
})?;
|
||||
let value = match value.trim() {
|
||||
"true" => HostValue::bool(true),
|
||||
"false" => HostValue::bool(false),
|
||||
value => HostValue::string(value.trim_matches('"')),
|
||||
};
|
||||
fields.push((name.trim(), value));
|
||||
}
|
||||
Ok(HostValue::object([
|
||||
("frontmatter", HostValue::object(fields)),
|
||||
("body", HostValue::string(body)),
|
||||
]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_host_structured_content() {
|
||||
let mut engine = Engine::new(MarkdownLoader {
|
||||
source: String::from("---\ntitle: \"Hello\"\ndraft: false\n---\n# Hello\n\nBody text."),
|
||||
});
|
||||
let module = engine
|
||||
.add_root_source(
|
||||
"main.dcdl",
|
||||
"main.dcdl",
|
||||
r#"
|
||||
let
|
||||
Post = {
|
||||
frontmatter = {
|
||||
title = String;
|
||||
draft = Bool;
|
||||
};
|
||||
body = String;
|
||||
};
|
||||
post = Post & import "./post.md";
|
||||
in {
|
||||
title = post.frontmatter.title;
|
||||
draft = post.frontmatter.draft;
|
||||
body = post.body;
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let value = engine.eval_module(module).unwrap();
|
||||
let data = engine.materialize(&value).unwrap();
|
||||
let Data::Object(fields) = data else { panic!() };
|
||||
assert_eq!(fields[0].value, Data::String(String::from("Hello")));
|
||||
assert_eq!(fields[1].value, Data::Bool(false));
|
||||
assert_eq!(
|
||||
fields[2].value,
|
||||
Data::String(String::from("# Hello\n\nBody text."))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_import_violation_reports_key_path_and_schema_span() {
|
||||
let mut engine = Engine::new(MarkdownLoader {
|
||||
source: String::from("---\ntitle: \"Hello\"\ndraft: maybe\n---\n# Hello"),
|
||||
});
|
||||
let module = engine
|
||||
.add_root_source(
|
||||
"main.dcdl",
|
||||
"main.dcdl",
|
||||
r#"
|
||||
let
|
||||
Post = {
|
||||
frontmatter = {
|
||||
title = String;
|
||||
draft = Bool;
|
||||
};
|
||||
body = String;
|
||||
};
|
||||
in Post & import "./post.md"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let error = engine.eval_module(module).unwrap_err();
|
||||
|
||||
assert_eq!(error.kind, DiagnosticKind::ConstraintViolation);
|
||||
assert_eq!(error.message, "expected Bool, found String");
|
||||
assert!(
|
||||
error
|
||||
.labels
|
||||
.iter()
|
||||
.all(|label| label.span != Span::default())
|
||||
);
|
||||
assert!(
|
||||
error
|
||||
.labels
|
||||
.iter()
|
||||
.any(|label| label.message == "constraint declared here")
|
||||
);
|
||||
assert_eq!(
|
||||
error.notes,
|
||||
["imported `content/post.md` at `frontmatter.draft` supplied a value of type String"]
|
||||
);
|
||||
assert!(engine.format_diagnostic(&error).contains(
|
||||
"note: imported `content/post.md` at `frontmatter.draft` supplied a value of type String"
|
||||
));
|
||||
}
|
||||
|
||||
struct StructuredArrayLoader;
|
||||
|
||||
impl ImportLoader for StructuredArrayLoader {
|
||||
fn load(&mut self, _current_key: Option<&str>, _specifier: &str) -> Result<LoadedImport> {
|
||||
Ok(LoadedImport::value(
|
||||
"content/navigation.json",
|
||||
HostValue::object([(
|
||||
"items",
|
||||
HostValue::array([HostValue::string("home"), HostValue::int(2)]),
|
||||
)]),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_array_violation_reports_indexed_import_path() {
|
||||
let mut engine = Engine::new(StructuredArrayLoader);
|
||||
let module = engine
|
||||
.add_root_source(
|
||||
"main.dcdl",
|
||||
"main.dcdl",
|
||||
r#"{ items = [...String]; } & import "./navigation.json""#,
|
||||
)
|
||||
.unwrap();
|
||||
let error = engine.eval_module(module).unwrap_err();
|
||||
|
||||
assert_eq!(error.message, "expected String, found Int");
|
||||
assert_eq!(
|
||||
error.notes,
|
||||
["imported `content/navigation.json` at `items[1]` supplied a value of type Int"]
|
||||
);
|
||||
}
|
||||
|
||||
struct StructuredAliasLoader;
|
||||
|
||||
impl ImportLoader for StructuredAliasLoader {
|
||||
fn load(&mut self, _current_key: Option<&str>, _specifier: &str) -> Result<LoadedImport> {
|
||||
Ok(LoadedImport::value(
|
||||
"shared-value",
|
||||
HostValue::int_type().default_int(1)?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_imports_share_cached_runtime_value_by_key() {
|
||||
let mut engine = Engine::new(StructuredAliasLoader);
|
||||
let module = engine
|
||||
.add_root_source(
|
||||
"main.dcdl",
|
||||
"main.dcdl",
|
||||
r#"(import "./first.data") & (import "./second.data")"#,
|
||||
)
|
||||
.unwrap();
|
||||
let value = engine.eval_module(module).unwrap();
|
||||
let data = engine.materialize(&value).unwrap();
|
||||
assert_eq!(data, Data::Int(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_fields_are_recursive_module_scope() {
|
||||
let mut engine = Engine::new(EmptyLoader);
|
||||
|
||||
@@ -52,9 +52,25 @@ pub enum TokenKind {
|
||||
Gte,
|
||||
Lt,
|
||||
Lte,
|
||||
/// A line comment, including its leading `#` and excluding its newline.
|
||||
Comment,
|
||||
Eof,
|
||||
}
|
||||
|
||||
/// Tokenizes source while retaining comments and source spans for tooling.
|
||||
///
|
||||
/// Whitespace remains available through the gaps between adjacent token spans,
|
||||
/// making this a lossless syntax view when paired with the original source.
|
||||
pub fn tokenize_source(source: &str) -> Result<Vec<Token>> {
|
||||
tokenize_source_with_source_id(SourceId(0), source)
|
||||
}
|
||||
|
||||
/// Tokenizes source with a caller-provided source identifier while retaining
|
||||
/// comments and source spans for tooling.
|
||||
pub fn tokenize_source_with_source_id(source_id: SourceId, source: &str) -> Result<Vec<Token>> {
|
||||
Lexer::with_source_id(source_id, source).tokenize_with_comments()
|
||||
}
|
||||
|
||||
pub struct Lexer<'a> {
|
||||
source_id: SourceId,
|
||||
source: &'a str,
|
||||
@@ -78,10 +94,26 @@ impl<'a> Lexer<'a> {
|
||||
}
|
||||
|
||||
pub fn tokenize(mut self) -> Result<Vec<Token>> {
|
||||
self.tokenize_impl(false)
|
||||
}
|
||||
|
||||
fn tokenize_with_comments(mut self) -> Result<Vec<Token>> {
|
||||
self.tokenize_impl(true)
|
||||
}
|
||||
|
||||
fn tokenize_impl(&mut self, include_comments: bool) -> Result<Vec<Token>> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut previous = None;
|
||||
loop {
|
||||
let token = self.next_token(previous.as_ref())?;
|
||||
self.skip_whitespace();
|
||||
if self.peek() == Some(b'#') {
|
||||
let comment = self.lex_comment();
|
||||
if include_comments {
|
||||
tokens.push(comment);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let token = self.next_non_ws_token(previous.as_ref())?;
|
||||
let is_eof = token.kind == TokenKind::Eof;
|
||||
if !is_eof {
|
||||
previous = Some(token.kind.clone());
|
||||
@@ -93,11 +125,6 @@ impl<'a> Lexer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn next_token(&mut self, previous: Option<&TokenKind>) -> Result<Token> {
|
||||
self.skip_ws_and_comments();
|
||||
self.next_non_ws_token(previous)
|
||||
}
|
||||
|
||||
fn next_non_ws_token(&mut self, previous: Option<&TokenKind>) -> Result<Token> {
|
||||
let start = self.pos;
|
||||
let Some(ch) = self.peek() else {
|
||||
@@ -259,21 +286,23 @@ impl<'a> Lexer<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
fn skip_ws_and_comments(&mut self) {
|
||||
loop {
|
||||
fn skip_whitespace(&mut self) {
|
||||
while matches!(self.peek(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
|
||||
self.pos += 1;
|
||||
}
|
||||
if self.peek() == Some(b'#') {
|
||||
}
|
||||
|
||||
fn lex_comment(&mut self) -> Token {
|
||||
let start = self.pos;
|
||||
while let Some(c) = self.peek() {
|
||||
self.pos += 1;
|
||||
if c == b'\n' {
|
||||
break;
|
||||
}
|
||||
self.pos += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
Token {
|
||||
kind: TokenKind::Comment,
|
||||
span: self.span(start, self.pos),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,4 +482,30 @@ mod tests {
|
||||
assert!(matches!(tokens[2].kind, TokenKind::Ident(_)));
|
||||
assert_eq!(tokens[3].kind, TokenKind::RBracket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooling_tokens_retain_comments_and_whitespace_gaps() {
|
||||
let source = "value = 1; # trailing\n\n# leading\nnext = 2;";
|
||||
let tokens = tokenize_source(source).unwrap();
|
||||
let comments = tokens
|
||||
.iter()
|
||||
.filter(|token| token.kind == TokenKind::Comment)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(comments.len(), 2);
|
||||
assert_eq!(
|
||||
&source[comments[0].span.start as usize..comments[0].span.end as usize],
|
||||
"# trailing"
|
||||
);
|
||||
assert!(
|
||||
source[comments[0].span.end as usize..comments[1].span.start as usize].contains("\n\n")
|
||||
);
|
||||
|
||||
let source_id = SourceId(7);
|
||||
let identified = tokenize_source_with_source_id(source_id, "# comment").unwrap();
|
||||
assert!(
|
||||
identified
|
||||
.iter()
|
||||
.all(|token| token.span.source == source_id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod ast;
|
||||
pub mod constraints;
|
||||
pub mod diagnostic;
|
||||
pub mod embedding;
|
||||
pub mod environment;
|
||||
pub mod eval;
|
||||
mod lexer;
|
||||
pub mod module;
|
||||
@@ -20,8 +21,15 @@ pub use constraints::normalize_constraints;
|
||||
pub use decodal_derive::Decodal;
|
||||
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, LoadedSource, Module, SourceLoader};
|
||||
pub use lexer::{
|
||||
Token as SyntaxToken, TokenKind as SyntaxTokenKind, tokenize_source,
|
||||
tokenize_source_with_source_id,
|
||||
};
|
||||
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,7 +1,7 @@
|
||||
use alloc::string::String;
|
||||
use alloc::{string::String, vec::Vec};
|
||||
|
||||
use crate::{
|
||||
Ast, ExprId, SourceForm, SourceId,
|
||||
Ast, ExprId, HostValue, SourceForm, SourceId,
|
||||
runtime::{EnvId, ThunkId},
|
||||
};
|
||||
|
||||
@@ -24,15 +24,82 @@ pub struct LoadedSource {
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
pub trait SourceLoader {
|
||||
fn load(&mut self, current_key: Option<&str>, specifier: &str) -> crate::Result<LoadedSource>;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoadedValue {
|
||||
pub key: String,
|
||||
pub value: HostValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LoadedImport {
|
||||
Source(LoadedSource),
|
||||
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>,
|
||||
name: impl Into<String>,
|
||||
source: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::Source(LoadedSource {
|
||||
key: key.into(),
|
||||
name: name.into(),
|
||||
source: source.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn value(key: impl Into<String>, value: HostValue) -> Self {
|
||||
Self::Value(LoadedValue {
|
||||
key: key.into(),
|
||||
value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
pub struct EmptyLoader;
|
||||
|
||||
impl SourceLoader for EmptyLoader {
|
||||
fn load(&mut self, _current_key: Option<&str>, specifier: &str) -> crate::Result<LoadedSource> {
|
||||
impl ImportLoader for EmptyLoader {
|
||||
fn load(&mut self, _current_key: Option<&str>, specifier: &str) -> crate::Result<LoadedImport> {
|
||||
Err(crate::Diagnostic::new(
|
||||
crate::DiagnosticKind::Import,
|
||||
crate::Span::default(),
|
||||
|
||||
@@ -38,7 +38,10 @@ pub struct Parser {
|
||||
impl Parser {
|
||||
pub fn new(tokens: Vec<Token>) -> Self {
|
||||
Self {
|
||||
tokens,
|
||||
tokens: tokens
|
||||
.into_iter()
|
||||
.filter(|token| token.kind != TokenKind::Comment)
|
||||
.collect(),
|
||||
pos: 0,
|
||||
ast: Ast::new(),
|
||||
}
|
||||
@@ -380,8 +383,9 @@ impl Parser {
|
||||
}
|
||||
|
||||
let expr = self.parse_expr(0)?;
|
||||
self.expect_kind(&TokenKind::RParen, "expected ')' after expression")?;
|
||||
Ok(expr)
|
||||
let end_span = self.expect_kind(&TokenKind::RParen, "expected ')' after expression")?;
|
||||
let span = start_span.join(end_span);
|
||||
Ok(self.ast.push(Expr::Parenthesized { expr }, span))
|
||||
}
|
||||
|
||||
fn parse_params_after_lparen(&mut self) -> Result<Vec<Param>> {
|
||||
@@ -692,7 +696,24 @@ mod tests {
|
||||
let Expr::ArrayConstraint { item } = parsed.ast.get(parsed.root).expr else {
|
||||
panic!()
|
||||
};
|
||||
assert!(matches!(parsed.ast.get(item).expr, Expr::Binary { .. }));
|
||||
let Expr::Parenthesized { expr } = parsed.ast.get(item).expr else {
|
||||
panic!()
|
||||
};
|
||||
assert!(matches!(parsed.ast.get(expr).expr, Expr::Binary { .. }));
|
||||
assert_eq!(parsed.ast.span(item), Span::new(SourceId(0), 4, 22));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_the_public_lossless_token_stream() {
|
||||
let tokens = crate::tokenize_source("value = (# note\n 1);").unwrap();
|
||||
let parsed = Parser::new(tokens).parse().unwrap();
|
||||
let Expr::Object(fields) = &parsed.ast.get(parsed.root).expr else {
|
||||
panic!()
|
||||
};
|
||||
assert!(matches!(
|
||||
parsed.ast.get(fields[0].value).expr,
|
||||
Expr::Parenthesized { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "decodal-derive"
|
||||
version.workspace = true
|
||||
version = "0.1.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -19,4 +19,4 @@ quote = "1"
|
||||
syn = { version = "2", features = ["full"] }
|
||||
|
||||
[dev-dependencies]
|
||||
decodal = { version = "0.1", path = "../decodal-core" }
|
||||
decodal = { version = "0.2.0", path = "../decodal-core" }
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "decodal-language-service"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
readme.workspace = true
|
||||
description = "Host-configurable semantic language service for Decodal."
|
||||
keywords = ["decodal", "lsp", "language-server", "editor"]
|
||||
categories = ["development-tools", "text-editors"]
|
||||
|
||||
[dependencies]
|
||||
decodal = { version = "0.2.0", path = "../decodal-core" }
|
||||
@@ -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("/")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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,
|
||||
}
|
||||
|
||||
impl<E> LanguageService<E> {
|
||||
pub fn new(environment: E) -> Self {
|
||||
Self { environment }
|
||||
}
|
||||
|
||||
pub fn environment(&self) -> &E {
|
||||
&self.environment
|
||||
}
|
||||
|
||||
pub fn environment_mut(&mut self) -> &mut E {
|
||||
&mut self.environment
|
||||
}
|
||||
|
||||
pub fn into_environment(self) -> E {
|
||||
self.environment
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: HostEnvironment> LanguageService<E> {
|
||||
/// Runs the production parse, evaluation, and materialization pipeline.
|
||||
pub fn evaluate(
|
||||
&self,
|
||||
key: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
source: &str,
|
||||
) -> Result<Data> {
|
||||
let mut engine = self.environment.create_engine()?;
|
||||
let module = engine.add_root_source(key, name, source)?;
|
||||
let value = engine.eval_module(module)?;
|
||||
engine.materialize(&value)
|
||||
}
|
||||
|
||||
/// Evaluates a document and exposes failures in an editor-friendly form.
|
||||
///
|
||||
/// The evaluator currently stops at the first failure, so an invalid
|
||||
/// analysis contains one diagnostic. The collection leaves room for a
|
||||
/// future diagnostic-accumulation pass without changing this API.
|
||||
pub fn analyze(
|
||||
&self,
|
||||
key: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
source: &str,
|
||||
) -> SemanticAnalysis {
|
||||
match self.evaluate(key, name, source) {
|
||||
Ok(data) => SemanticAnalysis {
|
||||
data: Some(data),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
Err(diagnostic) => SemanticAnalysis {
|
||||
data: None,
|
||||
diagnostics: vec![diagnostic],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct SemanticAnalysis {
|
||||
pub data: Option<Data>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
impl SemanticAnalysis {
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.diagnostics.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use decodal::{
|
||||
Data, Diagnostic, DiagnosticKind, Engine, HostValue, ImportLoader, LoadedImport, Span,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
const ROOT: &str = r#"Post & import "./post.md""#;
|
||||
|
||||
struct ContentEnvironment {
|
||||
draft: HostValue,
|
||||
}
|
||||
|
||||
struct ContentLoader {
|
||||
draft: HostValue,
|
||||
}
|
||||
|
||||
impl ImportLoader for ContentLoader {
|
||||
fn load(
|
||||
&mut self,
|
||||
_current_key: Option<&str>,
|
||||
specifier: &str,
|
||||
) -> decodal::Result<LoadedImport> {
|
||||
if specifier != "./post.md" {
|
||||
return Err(Diagnostic::new(
|
||||
DiagnosticKind::Import,
|
||||
Span::default(),
|
||||
"unknown content import",
|
||||
));
|
||||
}
|
||||
Ok(LoadedImport::value(
|
||||
"content/post.md",
|
||||
HostValue::object([
|
||||
(
|
||||
"frontmatter",
|
||||
HostValue::object([
|
||||
("title", HostValue::string("Hello")),
|
||||
("draft", self.draft.clone()),
|
||||
]),
|
||||
),
|
||||
("body", HostValue::string("# Hello")),
|
||||
]),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl HostEnvironment for ContentEnvironment {
|
||||
type Loader = ContentLoader;
|
||||
|
||||
fn create_loader(&self) -> Self::Loader {
|
||||
ContentLoader {
|
||||
draft: self.draft.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_engine(&self, engine: &mut Engine<Self::Loader>) -> decodal::Result<()> {
|
||||
engine.bind_global(
|
||||
"Post",
|
||||
HostValue::object([
|
||||
(
|
||||
"frontmatter",
|
||||
HostValue::object([
|
||||
("title", HostValue::string_type()),
|
||||
("draft", HostValue::bool_type()),
|
||||
]),
|
||||
),
|
||||
("body", HostValue::string_type()),
|
||||
]),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_direct(environment: &ContentEnvironment) -> decodal::Result<Data> {
|
||||
let mut engine = environment.create_engine()?;
|
||||
let module = engine.add_root_source("main.dcdl", "main.dcdl", ROOT)?;
|
||||
let value = engine.eval_module(module)?;
|
||||
engine.materialize(&value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_environment_matches_direct_evaluation() {
|
||||
let environment = ContentEnvironment {
|
||||
draft: HostValue::bool(false),
|
||||
};
|
||||
let direct = evaluate_direct(&environment).unwrap();
|
||||
let service = LanguageService::new(&environment);
|
||||
let analysis = service.analyze("main.dcdl", "main.dcdl", ROOT);
|
||||
|
||||
assert!(analysis.is_valid());
|
||||
assert_eq!(analysis.data, Some(direct));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_environment_preserves_import_diagnostics() {
|
||||
let service = LanguageService::new(ContentEnvironment {
|
||||
draft: HostValue::string("maybe"),
|
||||
});
|
||||
let analysis = service.analyze("main.dcdl", "main.dcdl", ROOT);
|
||||
|
||||
assert!(!analysis.is_valid());
|
||||
assert_eq!(analysis.data, None);
|
||||
assert_eq!(
|
||||
analysis.diagnostics[0].message,
|
||||
"expected Bool, found String"
|
||||
);
|
||||
assert_eq!(
|
||||
analysis.diagnostics[0].notes,
|
||||
["imported `content/post.md` at `frontmatter.draft` supplied a value of type String"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,15 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
readme.workspace = true
|
||||
description = "Source-level language tooling for Decodal."
|
||||
keywords = ["decodal", "formatter", "lsp", "tree-sitter"]
|
||||
keywords = ["decodal", "formatter", "lsp", "language-tools"]
|
||||
categories = ["development-tools", "text-processing"]
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
decodal = { version = "0.1.2", path = "../decodal-core" }
|
||||
decodal = { version = "0.2.0", path = "../decodal-core" }
|
||||
serde_json.workspace = true
|
||||
tree-sitter = "0.22.6"
|
||||
tree-sitter-decodal = { path = "../../editors/tree-sitter-decodal" }
|
||||
wasm-bindgen.workspace = true
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "decodal-lsp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
readme.workspace = true
|
||||
description = "Language Server Protocol implementation for Decodal."
|
||||
keywords = ["decodal", "lsp", "language-server", "editor"]
|
||||
categories = ["development-tools", "text-editors"]
|
||||
|
||||
[dependencies]
|
||||
decodal = { version = "0.2.0", path = "../decodal-core" }
|
||||
decodal-language-service = { version = "0.2.0", path = "../decodal-language-service" }
|
||||
decodal-language-tools = { version = "0.2.0", path = "../decodal-language-tools" }
|
||||
lsp-server = "0.10"
|
||||
lsp-types = "0.97"
|
||||
serde_json.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match decodal_lsp::run_stdio(|_| Ok(decodal_lsp::FileSystemEnvironment::default())) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("decodal-lsp failed: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -15,9 +15,14 @@ publish = false
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
decodal = { version = "0.1.2", path = "../decodal-core" }
|
||||
decodal = { version = "0.2.0", path = "../decodal-core" }
|
||||
decodal-language-service = { version = "0.2.0", 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
+147
-140
@@ -1,25 +1,67 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use decodal::{
|
||||
Data, Diagnostic, DiagnosticKind, EmptyLoader, Engine, LoadedSource, SourceId, SourceLoader,
|
||||
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 SourceLoader for VirtualLoader {
|
||||
fn load(
|
||||
&mut self,
|
||||
current_key: Option<&str>,
|
||||
specifier: &str,
|
||||
) -> decodal::Result<LoadedSource> {
|
||||
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(LoadedSource {
|
||||
key: key.clone(),
|
||||
name: key,
|
||||
source,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn encode_completion(
|
||||
environment: &JsEnvironment,
|
||||
key: &str,
|
||||
source: &str,
|
||||
position: usize,
|
||||
explicit: bool,
|
||||
) -> String {
|
||||
let byte_position = utf16_offset_to_byte(source, position);
|
||||
let service = LanguageService::new(environment);
|
||||
let completion = match service.complete(key, source, byte_position, explicit) {
|
||||
Ok(completion) => completion,
|
||||
Err(diagnostic) => {
|
||||
return serde_json::json!({
|
||||
"ok": false,
|
||||
"error": diagnostic.message,
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
};
|
||||
let completion = completion.map(|completion| {
|
||||
let from = byte_offset_to_utf16(source, completion.from);
|
||||
let options = completion
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
serde_json::json!({
|
||||
"label": item.label,
|
||||
"kind": completion_kind_name(item.kind),
|
||||
"detail": item.detail,
|
||||
"priority": item.priority,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::json!({ "from": from, "options": options })
|
||||
});
|
||||
serde_json::json!({ "ok": true, "completion": completion }).to_string()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn completion_kind_name(kind: CompletionKind) -> &'static str {
|
||||
match kind {
|
||||
CompletionKind::Keyword => "keyword",
|
||||
CompletionKind::Constant => "constant",
|
||||
CompletionKind::Type => "type",
|
||||
CompletionKind::Variable => "variable",
|
||||
CompletionKind::Namespace => "namespace",
|
||||
CompletionKind::Property => "property",
|
||||
CompletionKind::File => "file",
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_import(current_key: Option<&str>, specifier: &str) -> Option<String> {
|
||||
if specifier.starts_with('/') {
|
||||
return normalize_path(specifier);
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn utf16_offset_to_byte(source: &str, offset: usize) -> usize {
|
||||
let mut utf16 = 0usize;
|
||||
for (byte, ch) in source.char_indices() {
|
||||
if utf16 >= offset {
|
||||
return byte;
|
||||
}
|
||||
let next = utf16 + ch.len_utf16();
|
||||
if next > offset {
|
||||
return byte;
|
||||
}
|
||||
utf16 = next;
|
||||
}
|
||||
source.len()
|
||||
}
|
||||
|
||||
let mut base = String::new();
|
||||
if let Some(current_key) = current_key {
|
||||
if let Some((parent, _file)) = current_key.rsplit_once('/') {
|
||||
base.push_str(parent);
|
||||
base.push('/');
|
||||
}
|
||||
}
|
||||
base.push_str(specifier);
|
||||
normalize_path(&base)
|
||||
}
|
||||
|
||||
fn normalize_path(path: &str) -> Option<String> {
|
||||
let mut parts = Vec::new();
|
||||
let normalized = path.replace('\\', "/");
|
||||
for part in normalized.split('/') {
|
||||
match part {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
parts.pop()?;
|
||||
}
|
||||
part => parts.push(part),
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("/"))
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn byte_offset_to_utf16(source: &str, mut offset: usize) -> usize {
|
||||
offset = offset.min(source.len());
|
||||
while !source.is_char_boundary(offset) {
|
||||
offset = offset.saturating_sub(1);
|
||||
}
|
||||
source[..offset].encode_utf16().count()
|
||||
}
|
||||
|
||||
fn format_diagnostic_with_root(diagnostic: &decodal::Diagnostic, root_name: &str) -> String {
|
||||
@@ -191,66 +236,28 @@ fn format_data(data: &Data, indent: usize) -> String {
|
||||
}
|
||||
|
||||
fn json_string(value: &str) -> String {
|
||||
let mut out = String::from("\"");
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
ch if ch.is_control() => {
|
||||
use core::fmt::Write;
|
||||
let _ = write!(out, "\\u{:04x}", ch as u32);
|
||||
}
|
||||
ch => out.push(ch),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
serde_json::to_string(value).expect("strings are always JSON-serializable")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{evaluate_project_inner, normalize_path, resolve_import};
|
||||
use super::{byte_offset_to_utf16, evaluate_inner, utf16_offset_to_byte};
|
||||
|
||||
#[test]
|
||||
fn normalizes_virtual_paths() {
|
||||
fn evaluates_standalone_sources() {
|
||||
assert_eq!(
|
||||
normalize_path("/schemas/../main.dcdl"),
|
||||
Some("main.dcdl".into())
|
||||
);
|
||||
assert_eq!(normalize_path("../main.dcdl"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_imports_relative_to_current_file() {
|
||||
assert_eq!(
|
||||
resolve_import(Some("schemas/service.dcdl"), "./types.dcdl"),
|
||||
Some("schemas/types.dcdl".into())
|
||||
evaluate_inner("value = 1;").unwrap(),
|
||||
"{\n \"value\": 1\n}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluates_project_imports() {
|
||||
let files = r#"{
|
||||
"main.dcdl":"let dep = import \"./schemas/service.dcdl\"; in dep.Service & { port = 9443; }",
|
||||
"schemas/service.dcdl":"Service = { name = String default \"api\"; port = Int & > 443 default 8443; }"
|
||||
}"#;
|
||||
let output = evaluate_project_inner("main.dcdl", files).unwrap();
|
||||
assert!(output.contains("\"port\": 9443"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_diagnostics_use_virtual_file_names() {
|
||||
let files = r#"{
|
||||
"main.dcdl":"let dep = import \"./schemas/service.dcdl\"; in dep.Service & { port = 80; }",
|
||||
"schemas/service.dcdl":"Service = { port = Int & > 443 default 8443; }"
|
||||
}"#;
|
||||
let error = evaluate_project_inner("main.dcdl", files).unwrap_err();
|
||||
assert!(error.contains("main.dcdl:"));
|
||||
assert!(error.contains("schemas/service.dcdl:"));
|
||||
assert!(!error.contains("source 0:"));
|
||||
assert!(!error.contains("source 1:"));
|
||||
fn converts_web_utf16_offsets() {
|
||||
let source = "a😀β";
|
||||
assert_eq!(utf16_offset_to_byte(source, 0), 0);
|
||||
assert_eq!(utf16_offset_to_byte(source, 1), 1);
|
||||
assert_eq!(utf16_offset_to_byte(source, 3), 5);
|
||||
assert_eq!(byte_offset_to_utf16(source, 5), 3);
|
||||
assert_eq!(byte_offset_to_utf16(source, source.len()), 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use decodal::{
|
||||
Diagnostic, DiagnosticKind, Engine, HostEnvironment, HostValue, ImportCandidate, ImportLoader,
|
||||
LoadedImport, Span,
|
||||
};
|
||||
use js_sys::{Function, Reflect};
|
||||
use serde_json::Value;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
|
||||
use crate::host_value;
|
||||
|
||||
pub(crate) struct JsEnvironment {
|
||||
globals: BTreeMap<String, HostValue>,
|
||||
load_import: Option<Function>,
|
||||
complete_import: Option<Function>,
|
||||
}
|
||||
|
||||
impl JsEnvironment {
|
||||
pub(crate) fn from_options(options: JsValue) -> Result<Self, JsValue> {
|
||||
if !options.is_null() && !options.is_undefined() && !options.is_object() {
|
||||
return Err(JsValue::from_str(
|
||||
"DecodalLanguageService options must be an object",
|
||||
));
|
||||
}
|
||||
|
||||
let globals = property(&options, "globals")?;
|
||||
let globals = if globals.is_null() || globals.is_undefined() {
|
||||
BTreeMap::new()
|
||||
} else {
|
||||
let globals: Value = serde_wasm_bindgen::from_value(globals).map_err(|error| {
|
||||
JsValue::from_str(&format!("failed to read `globals`: {error}"))
|
||||
})?;
|
||||
let globals = globals.as_object().ok_or_else(|| {
|
||||
JsValue::from_str("DecodalLanguageService `globals` must be an object")
|
||||
})?;
|
||||
globals
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
host_value::from_json(value)
|
||||
.map(|value| (name.clone(), value))
|
||||
.map_err(|error| {
|
||||
JsValue::from_str(&format!("invalid global `{name}`: {error}"))
|
||||
})
|
||||
})
|
||||
.collect::<Result<BTreeMap<_, _>, _>>()?
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
globals,
|
||||
load_import: optional_function(&options, "loadImport")?,
|
||||
complete_import: optional_function(&options, "completeImport")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl HostEnvironment for JsEnvironment {
|
||||
type Loader = JsLoader;
|
||||
|
||||
fn create_loader(&self) -> Self::Loader {
|
||||
JsLoader {
|
||||
load_import: self.load_import.clone(),
|
||||
complete_import: self.complete_import.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_engine(&self, engine: &mut Engine<Self::Loader>) -> decodal::Result<()> {
|
||||
for (name, value) in &self.globals {
|
||||
engine.bind_global(name, value.clone())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct JsLoader {
|
||||
load_import: Option<Function>,
|
||||
complete_import: Option<Function>,
|
||||
}
|
||||
|
||||
impl ImportLoader for JsLoader {
|
||||
fn load(
|
||||
&mut self,
|
||||
current_key: Option<&str>,
|
||||
specifier: &str,
|
||||
) -> decodal::Result<LoadedImport> {
|
||||
let callback = self.load_import.as_ref().ok_or_else(|| {
|
||||
import_error(format!(
|
||||
"no JavaScript `loadImport` callback is configured for `{specifier}`"
|
||||
))
|
||||
})?;
|
||||
let current_key = current_key.map(JsValue::from_str).unwrap_or(JsValue::NULL);
|
||||
let specifier = JsValue::from_str(specifier);
|
||||
let loaded = callback
|
||||
.call2(&JsValue::UNDEFINED, ¤t_key, &specifier)
|
||||
.map_err(|error| import_error(js_error_message(error)))?;
|
||||
let loaded: Value = serde_wasm_bindgen::from_value(loaded)
|
||||
.map_err(|error| import_error(format!("invalid `loadImport` result: {error}")))?;
|
||||
loaded_import(&loaded).map_err(import_error)
|
||||
}
|
||||
|
||||
fn complete_import(
|
||||
&mut self,
|
||||
current_key: Option<&str>,
|
||||
prefix: &str,
|
||||
) -> decodal::Result<Vec<ImportCandidate>> {
|
||||
let Some(callback) = &self.complete_import else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let current_key = current_key.map(JsValue::from_str).unwrap_or(JsValue::NULL);
|
||||
let prefix = JsValue::from_str(prefix);
|
||||
let candidates = callback
|
||||
.call2(&JsValue::UNDEFINED, ¤t_key, &prefix)
|
||||
.map_err(|error| import_error(js_error_message(error)))?;
|
||||
let candidates: Value = serde_wasm_bindgen::from_value(candidates)
|
||||
.map_err(|error| import_error(format!("invalid `completeImport` result: {error}")))?;
|
||||
import_candidates(&candidates).map_err(import_error)
|
||||
}
|
||||
}
|
||||
|
||||
fn loaded_import(value: &Value) -> Result<LoadedImport, String> {
|
||||
let fields = value
|
||||
.as_object()
|
||||
.ok_or_else(|| String::from("`loadImport` must return an object"))?;
|
||||
let kind = string_field(fields, "kind")?;
|
||||
let key = string_field(fields, "key")?;
|
||||
match kind {
|
||||
"source" => {
|
||||
let source = string_field(fields, "source")?;
|
||||
let name = fields.get("name").and_then(Value::as_str).unwrap_or(key);
|
||||
Ok(LoadedImport::source(key, name, source))
|
||||
}
|
||||
"value" => {
|
||||
let value = fields
|
||||
.get("value")
|
||||
.ok_or_else(|| String::from("value import requires `value`"))?;
|
||||
let value = host_value::from_json(value)
|
||||
.map_err(|error| format!("invalid imported value: {error}"))?;
|
||||
Ok(LoadedImport::value(key, value))
|
||||
}
|
||||
kind => Err(format!("unknown `loadImport` result kind `{kind}`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn import_candidates(value: &Value) -> Result<Vec<ImportCandidate>, String> {
|
||||
value
|
||||
.as_array()
|
||||
.ok_or_else(|| String::from("`completeImport` must return an array"))?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| match value {
|
||||
Value::String(specifier) => Ok(ImportCandidate::new(specifier)),
|
||||
Value::Object(fields) => {
|
||||
let mut candidate = ImportCandidate::new(string_field(fields, "specifier")?);
|
||||
if let Some(detail) = fields.get("detail") {
|
||||
let detail = detail.as_str().ok_or_else(|| {
|
||||
format!("import candidate {index} `detail` must be a string")
|
||||
})?;
|
||||
candidate = candidate.with_detail(detail);
|
||||
}
|
||||
Ok(candidate)
|
||||
}
|
||||
_ => Err(format!(
|
||||
"import candidate {index} must be a string or object"
|
||||
)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn string_field<'a>(
|
||||
fields: &'a serde_json::Map<String, Value>,
|
||||
name: &str,
|
||||
) -> Result<&'a str, String> {
|
||||
fields
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| format!("`loadImport` result requires string `{name}`"))
|
||||
}
|
||||
|
||||
fn property(options: &JsValue, name: &str) -> Result<JsValue, JsValue> {
|
||||
if options.is_null() || options.is_undefined() {
|
||||
return Ok(JsValue::UNDEFINED);
|
||||
}
|
||||
Reflect::get(options, &JsValue::from_str(name))
|
||||
}
|
||||
|
||||
fn optional_function(options: &JsValue, name: &str) -> Result<Option<Function>, JsValue> {
|
||||
let value = property(options, name)?;
|
||||
if value.is_null() || value.is_undefined() {
|
||||
return Ok(None);
|
||||
}
|
||||
value
|
||||
.dyn_into::<Function>()
|
||||
.map(Some)
|
||||
.map_err(|_| JsValue::from_str(&format!("`{name}` must be a function")))
|
||||
}
|
||||
|
||||
fn js_error_message(value: JsValue) -> String {
|
||||
if let Some(message) = value.as_string() {
|
||||
return message;
|
||||
}
|
||||
Reflect::get(&value, &JsValue::from_str("message"))
|
||||
.ok()
|
||||
.and_then(|message| message.as_string())
|
||||
.unwrap_or_else(|| String::from("JavaScript import callback failed"))
|
||||
}
|
||||
|
||||
fn import_error(message: impl Into<String>) -> Diagnostic {
|
||||
Diagnostic::new(DiagnosticKind::Import, Span::default(), message)
|
||||
}
|
||||
@@ -41,9 +41,28 @@ The WebAssembly package is for execution, not syntax highlighting.
|
||||
|
||||
## Language tools
|
||||
|
||||
Source-level tooling lives in the Rust language tools crate.
|
||||
Semantic editor integration lives in the host-configurable language service crate.
|
||||
It depends only on the runtime and accepts the same `HostEnvironment` implementation used by a production application.
|
||||
The LSP crate adapts that service to the Language Server Protocol over stdin/stdout.
|
||||
It provides full document synchronization, semantic diagnostics, and whole-document formatting.
|
||||
A host-specific LSP binary injects its loader and global schema configuration without reimplementing evaluation rules.
|
||||
|
||||
Important paths:
|
||||
|
||||
```text
|
||||
crates/decodal-language-service/
|
||||
crates/decodal-lsp/
|
||||
```
|
||||
|
||||
The default `decodal-lsp` binary reads Decodal imports from the filesystem.
|
||||
Embedded hosts can call its library entry point with a custom `LspEnvironment` to reuse structured imports and to make unsaved external documents, such as Markdown, visible to the loader.
|
||||
|
||||
Source formatting lives in a separate Rust language tools crate.
|
||||
It uses the canonical Decodal AST together with the runtime lexer's lossless syntax tokens, so native LSP and WebAssembly callers execute the same formatter implementation.
|
||||
|
||||
This component is responsible for operations that must preserve source text details such as comments and whitespace.
|
||||
It is an internal crate used by the future Rust LSP and by the CodeMirror package's bundled formatter WebAssembly.
|
||||
It is used by the CodeMirror package's bundled formatter WebAssembly and can also be used by an LSP adapter for formatting.
|
||||
It does not depend on Tree-sitter or Lezer.
|
||||
|
||||
Important paths:
|
||||
|
||||
@@ -52,12 +71,11 @@ crates/decodal-language-tools/
|
||||
```
|
||||
|
||||
The current language tools crate exposes the formatter.
|
||||
Future LSP functionality should build on the same source-level tooling layer rather than on the runtime core.
|
||||
|
||||
## Web editor components
|
||||
|
||||
The Web playground editor uses CodeMirror 6 with a generated Lezer parser.
|
||||
This is the source of syntax highlighting, folding, indentation, editor syntax tree behavior, and the browser formatter command in the browser UI.
|
||||
Lezer provides syntax highlighting, folding, indentation, and editor syntax tree behavior. The browser formatter command calls the canonical Rust formatter compiled to WebAssembly.
|
||||
|
||||
Important paths:
|
||||
|
||||
@@ -103,16 +121,20 @@ doc/manual/souce/language/grammar.md
|
||||
This EBNF is the language-level reference.
|
||||
The Rust parser, Lezer grammar, and Tree-sitter grammar should be kept aligned with it, but each implementation may encode precedence and recovery behavior in the form required by its parser generator or runtime.
|
||||
|
||||
## What is not a public component
|
||||
## Syntax token API
|
||||
|
||||
The Rust lexer is an implementation detail of the Rust parser.
|
||||
Decodal does not expose a standalone public tokenizer API for editor tooling.
|
||||
Consumers that need syntax information should use the component that matches their environment:
|
||||
The `decodal` crate exposes `tokenize_source`, `tokenize_source_with_source_id`, `SyntaxToken`, and `SyntaxTokenKind` for source-preserving tooling.
|
||||
Comments have explicit tokens, while whitespace is represented by gaps between token spans and can be recovered from the original source.
|
||||
The evaluator parser and formatter therefore share one lexical definition without making Tree-sitter an upstream dependency.
|
||||
|
||||
Consumers should otherwise use the component matching their environment:
|
||||
|
||||
- Rust execution and embedding: `decodal`
|
||||
- Browser execution: `decodal-wasm`
|
||||
- Web formatting and editor syntax: Lezer / CodeMirror
|
||||
- Rust LSP internals: `decodal-language-tools`
|
||||
- Web formatting and editor syntax: canonical formatter / CodeMirror / Lezer
|
||||
- Semantic editor analysis: `decodal-language-service`
|
||||
- Language Server Protocol integration: `decodal-lsp`
|
||||
- Rust formatting: `decodal-language-tools`
|
||||
- General editor syntax: Tree-sitter
|
||||
|
||||
This avoids having a separate token stream API whose behavior would have to be kept compatible with both runtime parsing and editor grammars.
|
||||
Tree-sitter and Lezer remain downstream editor grammars and are not dependencies of the runtime formatter.
|
||||
|
||||
@@ -11,6 +11,7 @@ Diagnostic {
|
||||
span: Span
|
||||
message: String
|
||||
labels: Vec<DiagnosticLabel>
|
||||
notes: Vec<String>
|
||||
}
|
||||
|
||||
DiagnosticLabel {
|
||||
@@ -22,6 +23,7 @@ DiagnosticLabel {
|
||||
`span` は primary location を示す。
|
||||
表示時には `Span.source` を source id のまま出すのではなく、可能な限り file path や virtual file name に解決する。
|
||||
`labels` は同じ error に関係する追加 location を示す。
|
||||
`notes` は source location を持たない semantic context を示す。
|
||||
合成や materialize の失敗では、衝突した constraint、value、default、または処理中 field path を label に含める。
|
||||
|
||||
代表的な diagnostic kind:
|
||||
@@ -37,6 +39,9 @@ DiagnosticLabel {
|
||||
- match failure
|
||||
- materialization failure
|
||||
|
||||
Structured imports use semantic provenance rather than synthetic source spans.
|
||||
Constraint failures report the imported value's stable key and logical field or array path alongside the source span of the Decodal constraint that rejected it.
|
||||
|
||||
## エラーは値ではない
|
||||
|
||||
評価失敗は `RuntimeValue` ではなく `Diagnostic` を返す。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Embedding API
|
||||
|
||||
Decodal core can be embedded without giving the core crate access to a filesystem.
|
||||
The host supplies imported sources through `SourceLoader` and may also provide global bindings through the host prelude API.
|
||||
The host supplies imports through `ImportLoader` and may also provide global bindings through the host prelude API.
|
||||
|
||||
## Host prelude
|
||||
|
||||
@@ -131,11 +131,95 @@ Supported field attributes are intentionally small:
|
||||
The derive does not add host callbacks or reflection.
|
||||
It only generates schema construction and typed decoding code.
|
||||
|
||||
## SourceLoader and prelude together
|
||||
## ImportLoader and prelude together
|
||||
|
||||
`SourceLoader` and host prelude bindings are independent mechanisms.
|
||||
`ImportLoader` and host prelude bindings are independent mechanisms.
|
||||
|
||||
- Use `SourceLoader` when user sources should explicitly import host-provided modules.
|
||||
- Use `ImportLoader` when user sources should explicitly import host-provided sources or values.
|
||||
- Use prelude bindings when host-provided schemas or constants should be globally available.
|
||||
|
||||
Both mechanisms share the same runtime evaluator, thunk model, and materialization rules.
|
||||
|
||||
## Shared host environment
|
||||
|
||||
An embedded application can implement `HostEnvironment` to keep loader creation and global binding setup in one place.
|
||||
Both production evaluation and semantic editor tooling create their engines from this environment, preventing the editor from drifting onto a separate validation path.
|
||||
|
||||
```rust
|
||||
use decodal::{Engine, HostEnvironment};
|
||||
|
||||
struct AppEnvironment;
|
||||
|
||||
impl HostEnvironment for AppEnvironment {
|
||||
type Loader = ContentLoader;
|
||||
|
||||
fn create_loader(&self) -> Self::Loader {
|
||||
ContentLoader::new()
|
||||
}
|
||||
|
||||
fn configure_engine(
|
||||
&self,
|
||||
engine: &mut Engine<Self::Loader>,
|
||||
) -> decodal::Result<()> {
|
||||
engine.bind_global("Site", site_schema())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let environment = AppEnvironment;
|
||||
let mut engine = environment.create_engine()?;
|
||||
```
|
||||
|
||||
The semantic service accepts the same environment by value:
|
||||
|
||||
```rust
|
||||
use decodal_language_service::LanguageService;
|
||||
|
||||
let service = LanguageService::new(&environment);
|
||||
let analysis = service.analyze("site.dcdl", "site.dcdl", source);
|
||||
```
|
||||
|
||||
Each analysis uses a fresh engine and runs the normal parse, evaluate, and materialize pipeline.
|
||||
An environment may create loaders backed by shared filesystem, database, or editor-overlay state when repeated analysis needs a current workspace snapshot.
|
||||
|
||||
The LSP adapter constructs that environment after receiving the client's initialization parameters:
|
||||
|
||||
```rust
|
||||
use decodal_lsp::{LspEnvironment, run_stdio};
|
||||
|
||||
impl LspEnvironment for AppEnvironment {}
|
||||
|
||||
run_stdio(|initialize| {
|
||||
let _ = initialize;
|
||||
Ok(AppEnvironment)
|
||||
})?;
|
||||
```
|
||||
|
||||
`LspEnvironment` adds document lifecycle hooks on top of `HostEnvironment`.
|
||||
A `run_stdio` environment factory always receives the client's `InitializeParams`; the host decides whether to use its workspace folders, initialization options, and capabilities or ignore them.
|
||||
A host that keeps unsaved buffers in shared state can update them from `open_document`, `change_document`, and `close_document`; every subsequent diagnostic pass creates the normal import loader from that updated environment.
|
||||
Synchronized non-Decodal documents are passed through these hooks but are not evaluated as Decodal roots, so a loader can parse an unsaved Markdown file into `HostValue` and immediately revalidate the open Decodal documents that import it.
|
||||
|
||||
## Structured imports
|
||||
|
||||
`ImportLoader::load` returns either `LoadedImport::Source` or `LoadedImport::Value`.
|
||||
The value variant carries a `HostValue`, allowing a host to parse non-Decodal resources such as Markdown into an application-specific structure.
|
||||
|
||||
```text
|
||||
import "./post.md"
|
||||
-> host parses content
|
||||
-> LoadedImport::Value(
|
||||
{ frontmatter: {...}, body: "..." }
|
||||
)
|
||||
-> Engine internalizes HostValue
|
||||
-> normal composition and materialization
|
||||
```
|
||||
|
||||
The core does not select content types or bundle Markdown/frontmatter parsers.
|
||||
The loader owns path resolution, media or extension dispatch, parsing rules, and parse diagnostics.
|
||||
The stable loader key is also used to cache structured imports.
|
||||
|
||||
When a structured value fails a Decodal constraint, the diagnostic keeps the Decodal constraint span and identifies the host value by its stable import key and logical value path, such as `content/post.md` and `frontmatter.draft`.
|
||||
`HostValue` does not need source spans: syntax diagnostics for the external format remain the loader's responsibility, while cross-value validation reports semantic provenance.
|
||||
|
||||
`load` is the single import hook: loaders dispatch by extension, media type, or another host-defined rule and return the appropriate variant directly.
|
||||
|
||||
@@ -23,6 +23,9 @@ materialize
|
||||
data / diagnostics
|
||||
```
|
||||
|
||||
Production applications and semantic language services can construct this pipeline through the same host-defined `HostEnvironment`.
|
||||
The environment creates the `ImportLoader` and installs host globals before the root module is registered.
|
||||
|
||||
## lexer / parser
|
||||
|
||||
lexer / parser は source を AST に変換する。
|
||||
@@ -66,14 +69,16 @@ ModuleRegistry:
|
||||
処理系は、まず root module を parse / desugar して registry に登録する。
|
||||
import 先 module は、この段階で全て読み込む必要はない。
|
||||
|
||||
import expression が評価されたとき、処理系は `SourceLoader` に現在の module key と import specifier を渡す。
|
||||
loader は module key、表示名、source text を返す。
|
||||
module registry は key が未登録なら対象 module を parse / desugar して登録する。
|
||||
登録された module は module root thunk を持つ。
|
||||
同じ module が複数回 import された場合は、同じ `ModuleId` を返す。
|
||||
import expression が評価されたとき、処理系は `ImportLoader::load` に現在の module key と import specifier を渡す。
|
||||
loader は module key、表示名、および DCDL source text または構造化済み `HostValue` を返す。
|
||||
DCDL source の場合、module registry は key が未登録なら対象 module を parse / desugar して登録する。
|
||||
登録された source module は module root thunk を持つ。
|
||||
構造化値の場合、host value を runtime value に internalize した root thunk を key でキャッシュする。
|
||||
同じ key が複数回 import された場合は、同じ source module または structured root thunk を使う。
|
||||
|
||||
つまり import は module を即時評価しない。
|
||||
つまり source import は module を即時評価しない。
|
||||
module を読み込み、module root を thunk として登録するだけにする。
|
||||
structured import も root value を thunk として保持し、object field や array item の既存 thunk model を利用する。
|
||||
|
||||
AST の `ExprId` は module-local である。
|
||||
そのため runtime が保持する式参照は `ExprRef { module, expr }` として module-qualified にする。
|
||||
|
||||
@@ -104,7 +104,7 @@ Concrete(Int(8000))
|
||||
## Runtime scope
|
||||
|
||||
Decodal runtime は application runtime ではなく、pure value evaluator である。
|
||||
同じ source、同じ import sources、同じ host globals が与えられた場合、評価結果は決定的である。
|
||||
同じ source、同じ import results、同じ host globals が与えられた場合、評価結果は決定的である。
|
||||
|
||||
runtime が扱う責務は以下に限る。
|
||||
|
||||
@@ -114,7 +114,7 @@ runtime が扱う責務は以下に限る。
|
||||
- materialize 時に constraint を検証する。
|
||||
|
||||
runtime は filesystem、network、environment variable、time、random、mutation を扱わない。
|
||||
core における import は host supplied source を受け取る境界であり、filesystem access ではない。
|
||||
core における import は host supplied source または structured value を受け取る境界であり、filesystem access ではない。
|
||||
|
||||
## Constraint
|
||||
|
||||
|
||||
@@ -13,6 +13,18 @@ cargo check -p decodal --no-default-features
|
||||
nix flake check
|
||||
```
|
||||
|
||||
Run the default stdio language server with:
|
||||
|
||||
```sh
|
||||
cargo run -q -p decodal-lsp
|
||||
```
|
||||
|
||||
The protocol integration tests use an in-memory LSP connection and can be run independently:
|
||||
|
||||
```sh
|
||||
cargo test -p decodal-lsp
|
||||
```
|
||||
|
||||
Regex support is optional and should be tested explicitly when touched.
|
||||
|
||||
```sh
|
||||
@@ -22,33 +34,13 @@ cargo run -q -p decodal-cli --features regex -- examples/regex/main.dcdl
|
||||
|
||||
## crates.io release
|
||||
|
||||
The primary crates.io package is `decodal`, which contains the embeddable library.
|
||||
`decodal-derive` provides optional derive macros for Rust struct integration and is published only when the derive crate changes.
|
||||
Workspace support crates such as `decodal-cli`, the Rust source crate `decodal-wasm`, and `decodal-language-tools` are not published to crates.io.
|
||||
The crates.io release contains `decodal`, `decodal-derive`, `decodal-language-service`, `decodal-language-tools`, and `decodal-lsp`.
|
||||
`decodal-cli` and the Rust source crate `decodal-wasm` remain repository-only packages.
|
||||
The generated WebAssembly package under `packages/decodal-wasm/` is published to npm and JSR.
|
||||
The CodeMirror package bundles the generated formatter WebAssembly from `decodal-language-tools`.
|
||||
The project is dual licensed as `MIT OR Apache-2.0`.
|
||||
|
||||
```sh
|
||||
cargo publish -p decodal
|
||||
```
|
||||
|
||||
Publish `decodal-derive` first only when that crate has a new version:
|
||||
|
||||
```sh
|
||||
cargo publish -p decodal-derive
|
||||
```
|
||||
|
||||
Before publishing, run:
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo test
|
||||
cargo check -p decodal --no-default-features
|
||||
cargo publish -p decodal --dry-run
|
||||
```
|
||||
|
||||
If `decodal-derive` changed, also run `cargo publish -p decodal-derive --dry-run`.
|
||||
The authoritative validation commands, dependency order, and publish commands are maintained in `RELEASING.md` at the repository root.
|
||||
|
||||
## Web site and playground
|
||||
|
||||
@@ -91,8 +83,10 @@ npm run build
|
||||
```
|
||||
|
||||
`npm run build:wasm` builds both generated WebAssembly packages.
|
||||
`npm run build:runtime` writes generated runtime files into `packages/decodal-wasm/`.
|
||||
`npm run build:tools` writes generated language tools files into `packages/decodal-codemirror/wasm/`.
|
||||
`npm run build:wasm:runtime` writes generated runtime files into `packages/decodal-wasm/`.
|
||||
`npm run build:wasm:formatter` writes generated formatter files into `packages/decodal-codemirror/wasm/`.
|
||||
`npm run build` builds only the static site from the current package files.
|
||||
Use `npm run build:all` to regenerate both WebAssembly packages and then build the static site in one command.
|
||||
|
||||
These generated files are committed so the site can be built without requiring every consumer to regenerate the wasm packages first.
|
||||
|
||||
@@ -113,7 +107,7 @@ npx jsr publish --dry-run
|
||||
|
||||
The npm package name is `decodal-wasm`.
|
||||
The JSR package name is `@hare/decodal-wasm`.
|
||||
JSR currently expects a single SPDX license identifier in `jsr.json`; the WebAssembly package metadata uses `MIT` while the Rust workspace remains dual licensed as `MIT OR Apache-2.0`.
|
||||
The npm package advertises `MIT OR Apache-2.0`; JSR metadata uses `MIT` because its publisher requires a single recognized license identifier. Both license files are included in the package.
|
||||
|
||||
The playground editor uses the `decodal-codemirror` package with the generated Lezer parser in `packages/decodal-codemirror/src/decodal-parser.js`.
|
||||
The canonical grammar is documented in `doc/manual/souce/language/grammar.md`; regenerate the Lezer parser when that grammar or `editors/lezer-decodal/decodal.grammar` changes.
|
||||
@@ -137,7 +131,7 @@ npx jsr publish --dry-run
|
||||
|
||||
The npm package name is `decodal-codemirror`.
|
||||
The JSR package name is `@hare/decodal-codemirror`.
|
||||
JSR currently expects a single SPDX license identifier in `jsr.json`; the CodeMirror package metadata uses `MIT` while the Rust workspace remains dual licensed as `MIT OR Apache-2.0`.
|
||||
As with `decodal-wasm`, JSR metadata uses `MIT` while both the MIT and Apache-2.0 license files remain included.
|
||||
|
||||
The documentation build still uses the lightweight JavaScript fallback highlighter so Astro can render Markdown without initializing WASM at build time.
|
||||
|
||||
@@ -166,7 +160,7 @@ CLOUDFLARE_PROJECT_NAME=my-pages-project npm run deploy
|
||||
If the committed WASM package must be regenerated before deploy, use:
|
||||
|
||||
```sh
|
||||
npm run deploy:wasm
|
||||
npm run deploy:all
|
||||
```
|
||||
|
||||
## Tree-sitter grammar
|
||||
|
||||
@@ -32,15 +32,77 @@ result = schema;
|
||||
通常の object literal 内の field は、その object 内の sibling field を暗黙には識別子として参照できない。
|
||||
object 内の値を参照する場合は、外側で束縛された値や明示的な path reference を使う。
|
||||
|
||||
## SourceLoader
|
||||
## ImportLoader
|
||||
|
||||
`import` specifier の解決は処理系 core ではなく host 側の `SourceLoader` が行う。
|
||||
`import` specifier の解決は処理系 core ではなく host 側の `ImportLoader` が行う。
|
||||
CLI では、specifier を現在の module path からの相対 path として解決する。
|
||||
組み込み利用では、resource table や static source map など、filesystem 以外の loader を使える。
|
||||
|
||||
module cache の key は loader が返す安定 key を使う。
|
||||
CLI では canonical path を key とする。
|
||||
|
||||
### 構造化 import
|
||||
|
||||
`ImportLoader::load` は DCDL source または host が構築した `HostValue` を import 結果として返す。
|
||||
Markdown、JSON、TOML などの解釈規則は core に固定せず、loader がファイル種別を判定して構造化する。
|
||||
|
||||
```rust
|
||||
use decodal::{HostValue, ImportLoader, LoadedImport};
|
||||
|
||||
struct ContentLoader;
|
||||
|
||||
impl ImportLoader for ContentLoader {
|
||||
fn load(
|
||||
&mut self,
|
||||
current_key: Option<&str>,
|
||||
specifier: &str,
|
||||
) -> decodal::Result<LoadedImport> {
|
||||
if specifier.ends_with(".md") {
|
||||
let markdown = read_content(current_key, specifier)?;
|
||||
let parsed = parse_frontmatter(&markdown)?;
|
||||
return Ok(LoadedImport::value(
|
||||
parsed.key,
|
||||
HostValue::object([
|
||||
("frontmatter", parsed.frontmatter),
|
||||
("body", HostValue::string(parsed.body)),
|
||||
]),
|
||||
));
|
||||
}
|
||||
|
||||
let source = read_dcdl(current_key, specifier)?;
|
||||
Ok(LoadedImport::source(
|
||||
source.key,
|
||||
specifier,
|
||||
source.text,
|
||||
))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`read_content` と `parse_frontmatter` は host 独自の処理であり、Decodal core は Markdown や YAML parser に依存しない。
|
||||
上の loader を使うと、DCDL 側から次のように扱える。
|
||||
|
||||
```dcdl
|
||||
Post = {
|
||||
frontmatter = {
|
||||
title = String;
|
||||
draft = Bool default false;
|
||||
};
|
||||
body = String;
|
||||
};
|
||||
|
||||
post = Post & import "./hello.md";
|
||||
title = post.frontmatter.title;
|
||||
body = post.body;
|
||||
```
|
||||
|
||||
`LoadedImport::Value` は通常の concrete runtime value に internalize される。
|
||||
そのため、path reference、object composition、constraint validation、materialize は source 由来の値と同じ規則を使う。
|
||||
安定した `key` が同じ構造化 import は、engine 内で同じ値としてキャッシュされる。
|
||||
|
||||
`load` が唯一の import hook である。
|
||||
loader は拡張子、media type、または host 独自の規則で振り分け、対応する `LoadedImport` variant を直接返す。
|
||||
|
||||
## 循環 import
|
||||
|
||||
モジュール間に循環参照があっても、必要なフィールドの依存関係が循環していなければ評価できる。
|
||||
@@ -93,4 +155,5 @@ Module func
|
||||
- ファイルが読めない。
|
||||
- import 先の構文解析に失敗する。
|
||||
- import 先の評価で必要な値がエラーになる。
|
||||
- host による非 DCDL content の読み込みまたは構造化に失敗する。
|
||||
- 実装が禁止する import 循環に該当する。
|
||||
|
||||
@@ -6,7 +6,7 @@ license = "MIT"
|
||||
readme = "README.md"
|
||||
keywords = ["incremental", "parsing", "tree-sitter", "decodal"]
|
||||
categories = ["parsing", "text-editors"]
|
||||
repository = "https://github.com/tree-sitter/tree-sitter-decodal"
|
||||
repository = "https://gitea.hareworks.net/Hare/Decodal"
|
||||
edition = "2021"
|
||||
autoexamples = false
|
||||
|
||||
@@ -17,7 +17,7 @@ include = ["bindings/rust/*", "grammar.js", "queries/*", "src/*"]
|
||||
path = "bindings/rust/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
tree-sitter = ">=0.22.6"
|
||||
tree-sitter = "0.22.6"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1.0.87"
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
apps.default = mkApp "decodal" "Run Decodal";
|
||||
apps.decodal = mkApp "decodal" "Run Decodal";
|
||||
apps.decodal-lsp = mkApp "decodal-lsp" "Run the Decodal language server";
|
||||
|
||||
checks.default = decodal;
|
||||
|
||||
|
||||
+4
-1
@@ -24,7 +24,7 @@ let
|
||||
in
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "decodal";
|
||||
version = "0.1.1";
|
||||
version = "0.1.2";
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = srcRoot;
|
||||
@@ -38,6 +38,8 @@ rustPlatform.buildRustPackage {
|
||||
cargoBuildFlags = [
|
||||
"-p"
|
||||
"decodal-cli"
|
||||
"-p"
|
||||
"decodal-lsp"
|
||||
];
|
||||
|
||||
doInstallCheck = true;
|
||||
@@ -45,6 +47,7 @@ rustPlatform.buildRustPackage {
|
||||
runHook preInstallCheck
|
||||
|
||||
test -x "$out/bin/decodal"
|
||||
test -x "$out/bin/decodal-lsp"
|
||||
"$out/bin/decodal" >/dev/null
|
||||
|
||||
runHook postInstallCheck
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Decodal contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hare/decodal-codemirror",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
".": "./src/mod.ts",
|
||||
@@ -15,6 +15,8 @@
|
||||
"publish": {
|
||||
"include": [
|
||||
"README.md",
|
||||
"LICENSE-MIT",
|
||||
"LICENSE-APACHE",
|
||||
"src/mod.ts",
|
||||
"src/format.ts",
|
||||
"src/decodal.js",
|
||||
|
||||
+3
-2
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "decodal-codemirror",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "decodal-codemirror",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.10"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "decodal-codemirror",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"description": "CodeMirror 6 language support for Decodal.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
@@ -9,6 +9,9 @@
|
||||
"url": "git+https://gitea.hareworks.net/Hare/Decodal.git",
|
||||
"directory": "packages/decodal-codemirror"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test src/*.test.mjs"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/decodal.d.ts",
|
||||
@@ -25,6 +28,8 @@
|
||||
"types": "./src/decodal.d.ts",
|
||||
"files": [
|
||||
"README.md",
|
||||
"LICENSE-MIT",
|
||||
"LICENSE-APACHE",
|
||||
"src/",
|
||||
"wasm/decodal_language_tools.js",
|
||||
"wasm/decodal_language_tools.d.ts",
|
||||
@@ -44,6 +49,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.10"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LRLanguage, LanguageSupport, HighlightStyle, syntaxHighlighting, foldNodeProp, indentNodeProp } from '@codemirror/language';
|
||||
import { LRLanguage, LanguageSupport, HighlightStyle, syntaxHighlighting, delimitedIndent, foldNodeProp, indentNodeProp } from '@codemirror/language';
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
import { parser } from './decodal-parser.js';
|
||||
|
||||
@@ -19,10 +19,10 @@ const parserWithMetadata = parser.configure({
|
||||
'Semicolon Comma Dot Colon': t.punctuation,
|
||||
}),
|
||||
indentNodeProp.add({
|
||||
Object: context => context.column(context.node.from) + context.unit,
|
||||
Array: context => context.column(context.node.from) + context.unit,
|
||||
ArrayConstraint: context => context.column(context.node.from) + context.unit,
|
||||
MatchExpression: context => context.column(context.node.from) + context.unit,
|
||||
Object: delimitedIndent({ closing: '}', align: false }),
|
||||
Array: delimitedIndent({ closing: ']', align: false }),
|
||||
ArrayConstraint: delimitedIndent({ closing: ']', align: false }),
|
||||
MatchExpression: delimitedIndent({ closing: '}', align: false }),
|
||||
LetExpression: context => context.column(context.node.from) + context.unit,
|
||||
}),
|
||||
foldNodeProp.add({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { getIndentation, indentUnit } from '@codemirror/language';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
import { decodal } from './decodal.js';
|
||||
|
||||
function indentationAt(doc, lineNumber) {
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
extensions: [decodal(), indentUnit.of(' ')],
|
||||
});
|
||||
return getIndentation(state, state.doc.line(lineNumber).from);
|
||||
}
|
||||
|
||||
test('indents an inline object from its containing line', () => {
|
||||
const doc = `hoge = fuga & piyo & {
|
||||
|
||||
}`;
|
||||
|
||||
assert.equal(indentationAt(doc, 2), 2);
|
||||
assert.equal(indentationAt(doc, 3), 0);
|
||||
});
|
||||
|
||||
test('preserves the surrounding indentation for a nested inline object', () => {
|
||||
const doc = `root = {
|
||||
hoge = fuga & piyo & {
|
||||
|
||||
};
|
||||
};`;
|
||||
|
||||
assert.equal(indentationAt(doc, 3), 4);
|
||||
assert.equal(indentationAt(doc, 4), 2);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { formatDecodal, initDecodalFormatter } from './format.js';
|
||||
|
||||
const wasmPath = new URL('../wasm/decodal_language_tools_bg.wasm', import.meta.url);
|
||||
|
||||
test('WASM uses the canonical formatter and is idempotent', async () => {
|
||||
await initDecodalFormatter({ module_or_path: await readFile(wasmPath) });
|
||||
const source = 'Server={\n# host\nhost=String default "localhost"; # trailing\n\nport=Int&>0;\n};';
|
||||
const expected = 'Server = {\n # host\n host = String default "localhost"; # trailing\n\n port = Int & > 0;\n};\n';
|
||||
|
||||
assert.deepEqual(formatDecodal(source), { ok: true, source: expected });
|
||||
assert.deepEqual(formatDecodal(expected), { ok: true, source: expected });
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Decodal source formatting helpers for CodeMirror integrations.
|
||||
*
|
||||
* The formatter is backed by WebAssembly generated from the internal Rust
|
||||
* `decodal-language-tools` crate. Call {@link initDecodalFormatter} once before
|
||||
* The formatter is backed by WebAssembly generated from the canonical Rust
|
||||
* `decodal-language-tools` formatter. Call {@link initDecodalFormatter} once before
|
||||
* using {@link formatDecodal} or {@link formatDecodalCommand}.
|
||||
*
|
||||
* @module
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
import { HighlightStyle, LRLanguage, LanguageSupport, foldNodeProp, indentNodeProp, syntaxHighlighting } from 'npm:@codemirror/language@^6.12.4';
|
||||
import { HighlightStyle, LRLanguage, LanguageSupport, delimitedIndent, foldNodeProp, indentNodeProp, syntaxHighlighting } from 'npm:@codemirror/language@^6.12.4';
|
||||
import { styleTags, tags as t } from 'npm:@lezer/highlight@^1.2.3';
|
||||
import { parser } from './decodal-parser-jsr.js';
|
||||
|
||||
@@ -29,10 +29,10 @@ const parserWithMetadata = parser.configure({
|
||||
'Semicolon Comma Dot Colon': t.punctuation,
|
||||
}),
|
||||
indentNodeProp.add({
|
||||
Object: context => context.column(context.node.from) + context.unit,
|
||||
Array: context => context.column(context.node.from) + context.unit,
|
||||
ArrayConstraint: context => context.column(context.node.from) + context.unit,
|
||||
MatchExpression: context => context.column(context.node.from) + context.unit,
|
||||
Object: delimitedIndent({ closing: '}', align: false }),
|
||||
Array: delimitedIndent({ closing: ']', align: false }),
|
||||
ArrayConstraint: delimitedIndent({ closing: ']', align: false }),
|
||||
MatchExpression: delimitedIndent({ closing: '}', align: false }),
|
||||
LetExpression: context => context.column(context.node.from) + context.unit,
|
||||
}),
|
||||
foldNodeProp.add({
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,173 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Decodal contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
WebAssembly runtime package for Decodal.
|
||||
|
||||
This package exposes the Decodal evaluator to browsers and other JavaScript runtimes that can load WebAssembly modules.
|
||||
This package exposes the Decodal evaluator and shared language service to browsers and other JavaScript runtimes that can load WebAssembly modules.
|
||||
It is generated from the Rust crate in `crates/decodal-wasm` with `wasm-pack` and is used by the official playground.
|
||||
|
||||
Use `decodal-codemirror` separately when you need CodeMirror 6 language support.
|
||||
@@ -22,15 +22,48 @@ deno add jsr:@hare/decodal-wasm
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import init, { evaluateProject } from 'decodal-wasm';
|
||||
import init, { DecodalLanguageService } from 'decodal-wasm';
|
||||
|
||||
await init();
|
||||
|
||||
const result = evaluateProject('main.dcdl', JSON.stringify({
|
||||
'main.dcdl': 'Server = { port = Int default 8080; };',
|
||||
}));
|
||||
const files = {
|
||||
'main.dcdl': 'let schema = import "./schema.dcdl"; in schema.Server',
|
||||
'schema.dcdl': 'Server = App.Server & { port = 8080; };',
|
||||
};
|
||||
|
||||
const service = new DecodalLanguageService({
|
||||
globals: {
|
||||
App: {
|
||||
Server: {
|
||||
port: { $decodal: 'Int' },
|
||||
},
|
||||
},
|
||||
},
|
||||
loadImport(_currentKey, specifier) {
|
||||
const key = specifier.startsWith('./') ? specifier.slice(2) : specifier;
|
||||
return { kind: 'source', key, name: key, source: files[key] };
|
||||
},
|
||||
completeImport() {
|
||||
return ['./schema.dcdl'];
|
||||
},
|
||||
});
|
||||
|
||||
const result = service.evaluate('main.dcdl', 'main.dcdl', files['main.dcdl']);
|
||||
|
||||
console.log(JSON.parse(result));
|
||||
|
||||
const completion = service.complete(
|
||||
'main.dcdl',
|
||||
'App.Server.po',
|
||||
'App.Server.po'.length,
|
||||
false,
|
||||
);
|
||||
|
||||
console.log(JSON.parse(completion));
|
||||
```
|
||||
|
||||
The exported functions return JSON strings so callers can handle diagnostics and successful results without depending on Rust data structures.
|
||||
`globals`, `loadImport`, and `completeImport` are host-owned. The package does not assume a filesystem or virtual project model. Import callbacks are synchronous; preload or cache remote content before evaluation.
|
||||
|
||||
Plain strings, numbers, booleans, arrays, and objects are concrete host values. Use `{ $decodal: 'String' | 'Int' | 'Float' | 'Bool' }` for primitive schemas and `{ $decodal: 'Array', item: value }` for array schemas. Descriptors also accept `constraints` and `default`.
|
||||
|
||||
Methods return JSON strings so callers can handle diagnostics and successful results without depending on Rust data structures.
|
||||
|
||||
+89
-4
@@ -1,20 +1,105 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export function evaluate(source: string): string;
|
||||
/** A concrete JavaScript value or Decodal schema descriptor supplied by the host. */
|
||||
export type DecodalHostValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| DecodalHostValue[]
|
||||
| { [field: string]: DecodalHostValue }
|
||||
| DecodalPrimitiveDescriptor
|
||||
| DecodalArrayDescriptor
|
||||
| DecodalAbstractDescriptor;
|
||||
|
||||
export function evaluateProject(entry: string, files_json: string): string;
|
||||
export type DecodalPrimitiveType = 'String' | 'Int' | 'Float' | 'Bool';
|
||||
|
||||
export type DecodalConstraint =
|
||||
| { kind: 'type'; value: DecodalPrimitiveType }
|
||||
| { kind: 'compare'; op: '>' | '>=' | '<' | '<='; value: string | number | boolean }
|
||||
| { kind: 'regex'; value: string }
|
||||
| { kind: 'predicate'; value: string };
|
||||
|
||||
export interface DecodalPrimitiveDescriptor {
|
||||
$decodal: DecodalPrimitiveType;
|
||||
constraints?: DecodalConstraint[];
|
||||
default?: DecodalHostValue;
|
||||
}
|
||||
|
||||
export interface DecodalArrayDescriptor {
|
||||
$decodal: 'Array';
|
||||
item: DecodalHostValue;
|
||||
constraints?: DecodalConstraint[];
|
||||
default?: DecodalHostValue;
|
||||
}
|
||||
|
||||
export interface DecodalAbstractDescriptor {
|
||||
$decodal: 'Abstract';
|
||||
constraints?: DecodalConstraint[];
|
||||
default?: DecodalHostValue;
|
||||
}
|
||||
|
||||
export type DecodalLoadedImport =
|
||||
| { kind: 'source'; key: string; name?: string; source: string }
|
||||
| { kind: 'value'; key: string; value: DecodalHostValue };
|
||||
|
||||
export type DecodalImportCandidate =
|
||||
| string
|
||||
| { specifier: string; detail?: string };
|
||||
|
||||
/** Host-owned environment shared by evaluation and language tooling. */
|
||||
export interface DecodalEnvironment {
|
||||
globals?: Record<string, DecodalHostValue>;
|
||||
/** Synchronous import callback. Preload or cache asynchronous resources first. */
|
||||
loadImport?: (currentKey: string | null, specifier: string) => DecodalLoadedImport;
|
||||
/** Synchronous import completion callback. */
|
||||
completeImport?: (currentKey: string | null, prefix: string) => DecodalImportCandidate[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A browser-facing language service configured entirely by its JavaScript host.
|
||||
*
|
||||
* The host owns globals, import loading, and import completion. This keeps
|
||||
* filesystem, network, and virtual-project policy outside the WASM package.
|
||||
*/
|
||||
export class DecodalLanguageService {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
/**
|
||||
* Completes a source using the same injected environment as evaluation.
|
||||
*
|
||||
* Positions and returned ranges are UTF-16 offsets, matching browser
|
||||
* editors and the Language Server Protocol.
|
||||
*/
|
||||
complete(key: string, source: string, position: number, explicit: boolean): string;
|
||||
/**
|
||||
* Evaluates a source using the injected globals and import loader.
|
||||
*/
|
||||
evaluate(key: string, name: string, source: string): string;
|
||||
constructor(options?: DecodalEnvironment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates one standalone Decodal source with no host globals or imports.
|
||||
*/
|
||||
export function evaluate(source: string): string;
|
||||
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
|
||||
export interface InitOutput {
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly __wbg_decodallanguageservice_free: (a: number, b: number) => void;
|
||||
readonly decodallanguageservice_complete: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number];
|
||||
readonly decodallanguageservice_evaluate: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number];
|
||||
readonly decodallanguageservice_new: (a: any) => [number, number, number];
|
||||
readonly evaluate: (a: number, b: number) => [number, number];
|
||||
readonly evaluateProject: (a: number, b: number, c: number, d: number) => [number, number];
|
||||
readonly __wbindgen_externrefs: WebAssembly.Table;
|
||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
readonly __wbindgen_exn_store: (a: number) => void;
|
||||
readonly __externref_table_alloc: () => number;
|
||||
readonly __wbindgen_externrefs: WebAssembly.Table;
|
||||
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
readonly __externref_table_dealloc: (a: number) => void;
|
||||
readonly __wbindgen_start: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,91 @@
|
||||
/* @ts-self-types="./decodal_wasm.d.ts" */
|
||||
|
||||
/**
|
||||
* A browser-facing language service configured entirely by its JavaScript host.
|
||||
*
|
||||
* The host owns globals, import loading, and import completion. This keeps
|
||||
* filesystem, network, and virtual-project policy outside the WASM package.
|
||||
*/
|
||||
export class DecodalLanguageService {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
DecodalLanguageServiceFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_decodallanguageservice_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* Completes a source using the same injected environment as evaluation.
|
||||
*
|
||||
* Positions and returned ranges are UTF-16 offsets, matching browser
|
||||
* editors and the Language Server Protocol.
|
||||
* @param {string} key
|
||||
* @param {string} source
|
||||
* @param {number} position
|
||||
* @param {boolean} explicit
|
||||
* @returns {string}
|
||||
*/
|
||||
complete(key, source, position, explicit) {
|
||||
let deferred3_0;
|
||||
let deferred3_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(key, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(source, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.decodallanguageservice_complete(this.__wbg_ptr, ptr0, len0, ptr1, len1, position, explicit);
|
||||
deferred3_0 = ret[0];
|
||||
deferred3_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Evaluates a source using the injected globals and import loader.
|
||||
* @param {string} key
|
||||
* @param {string} name
|
||||
* @param {string} source
|
||||
* @returns {string}
|
||||
*/
|
||||
evaluate(key, name, source) {
|
||||
let deferred4_0;
|
||||
let deferred4_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(key, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ptr2 = passStringToWasm0(source, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len2 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.decodallanguageservice_evaluate(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
|
||||
deferred4_0 = ret[0];
|
||||
deferred4_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred4_0, deferred4_1, 1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {any} options
|
||||
*/
|
||||
constructor(options) {
|
||||
const ret = wasm.decodallanguageservice_new(options);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
this.__wbg_ptr = ret[0];
|
||||
DecodalLanguageServiceFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) DecodalLanguageService.prototype[Symbol.dispose] = DecodalLanguageService.prototype.free;
|
||||
|
||||
/**
|
||||
* Evaluates one standalone Decodal source with no host globals or imports.
|
||||
* @param {string} source
|
||||
* @returns {string}
|
||||
*/
|
||||
@@ -18,31 +103,204 @@ export function evaluate(source) {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} entry
|
||||
* @param {string} files_json
|
||||
* @returns {string}
|
||||
*/
|
||||
export function evaluateProject(entry, files_json) {
|
||||
let deferred3_0;
|
||||
let deferred3_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(entry, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(files_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.evaluateProject(ptr0, len0, ptr1, len1);
|
||||
deferred3_0 = ret[0];
|
||||
deferred3_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
|
||||
}
|
||||
}
|
||||
function __wbg_get_imports() {
|
||||
const import0 = {
|
||||
__proto__: null,
|
||||
__wbg_Error_fdd633d4bb5dd76a: function(arg0, arg1) {
|
||||
const ret = Error(getStringFromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
},
|
||||
__wbg_String_8564e559799eccda: function(arg0, arg1) {
|
||||
const ret = String(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg___wbindgen_bigint_get_as_i64_d9e915702856f831: function(arg0, arg1) {
|
||||
const v = arg1;
|
||||
const ret = typeof(v) === 'bigint' ? v : undefined;
|
||||
getDataViewMemory0().setBigInt64(arg0 + 8 * 1, isLikeNone(ret) ? BigInt(0) : ret, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true);
|
||||
},
|
||||
__wbg___wbindgen_boolean_get_edaed31a367ce1bd: function(arg0) {
|
||||
const v = arg0;
|
||||
const ret = typeof(v) === 'boolean' ? v : undefined;
|
||||
return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0;
|
||||
},
|
||||
__wbg___wbindgen_debug_string_8a447059637473e2: function(arg0, arg1) {
|
||||
const ret = debugString(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg___wbindgen_in_4990f46af709e33c: function(arg0, arg1) {
|
||||
const ret = arg0 in arg1;
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_bigint_90b5ccfe67c78460: function(arg0) {
|
||||
const ret = typeof(arg0) === 'bigint';
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_function_acc5528be2b923f2: function(arg0) {
|
||||
const ret = typeof(arg0) === 'function';
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_null_6d937fbfb6478470: function(arg0) {
|
||||
const ret = arg0 === null;
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_object_0beba4a1980d3eea: function(arg0) {
|
||||
const val = arg0;
|
||||
const ret = typeof(val) === 'object' && val !== null;
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_undefined_721f8decd50c87a3: function(arg0) {
|
||||
const ret = arg0 === undefined;
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_jsval_eq_4e8c38722cb8ff51: function(arg0, arg1) {
|
||||
const ret = arg0 === arg1;
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_jsval_loose_eq_4b9aba9e5b3c4582: function(arg0, arg1) {
|
||||
const ret = arg0 == arg1;
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_number_get_1cc01dd708740256: function(arg0, arg1) {
|
||||
const obj = arg1;
|
||||
const ret = typeof(obj) === 'number' ? obj : undefined;
|
||||
getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true);
|
||||
},
|
||||
__wbg___wbindgen_string_get_71bb4348194e31f0: function(arg0, arg1) {
|
||||
const obj = arg1;
|
||||
const ret = typeof(obj) === 'string' ? obj : undefined;
|
||||
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
var len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg___wbindgen_throw_ea4887a5f8f9a9db: function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg_call_0e855b388e315e17: function() { return handleError(function (arg0, arg1, arg2, arg3) {
|
||||
const ret = arg0.call(arg1, arg2, arg3);
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_call_8e98ed2f3c86c4b5: function() { return handleError(function (arg0, arg1) {
|
||||
const ret = arg0.call(arg1);
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_done_b62d4a7d2286852a: function(arg0) {
|
||||
const ret = arg0.done;
|
||||
return ret;
|
||||
},
|
||||
__wbg_entries_c261c3fa1f281256: function(arg0) {
|
||||
const ret = Object.entries(arg0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_get_197a3fe98f169e38: function(arg0, arg1) {
|
||||
const ret = arg0[arg1 >>> 0];
|
||||
return ret;
|
||||
},
|
||||
__wbg_get_9a29be2cb383ed9a: function() { return handleError(function (arg0, arg1) {
|
||||
const ret = Reflect.get(arg0, arg1);
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_get_dddb90ff5d27a080: function() { return handleError(function (arg0, arg1) {
|
||||
const ret = Reflect.get(arg0, arg1);
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_get_unchecked_54a4374c38e08460: function(arg0, arg1) {
|
||||
const ret = arg0[arg1 >>> 0];
|
||||
return ret;
|
||||
},
|
||||
__wbg_instanceof_ArrayBuffer_2a7bb09fee70c2da: function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof ArrayBuffer;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
},
|
||||
__wbg_instanceof_Map_afa18d5840c04c15: function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof Map;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
},
|
||||
__wbg_instanceof_Uint8Array_f080092dc70f5d58: function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof Uint8Array;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
},
|
||||
__wbg_isArray_145a34fd0a38d37b: function(arg0) {
|
||||
const ret = Array.isArray(arg0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_isSafeInteger_a3389a198582f5f6: function(arg0) {
|
||||
const ret = Number.isSafeInteger(arg0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_iterator_cc47ba25a2be735a: function() {
|
||||
const ret = Symbol.iterator;
|
||||
return ret;
|
||||
},
|
||||
__wbg_length_589238bdcf171f0e: function(arg0) {
|
||||
const ret = arg0.length;
|
||||
return ret;
|
||||
},
|
||||
__wbg_length_c6054974c0a6cdb9: function(arg0) {
|
||||
const ret = arg0.length;
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_81880fb5002cb255: function(arg0) {
|
||||
const ret = new Uint8Array(arg0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_next_0c4066e251d2eff9: function() { return handleError(function (arg0) {
|
||||
const ret = arg0.next();
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_next_402fa10b59ab20c3: function(arg0) {
|
||||
const ret = arg0.next;
|
||||
return ret;
|
||||
},
|
||||
__wbg_prototypesetcall_d721637c7ca66eb8: function(arg0, arg1, arg2) {
|
||||
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
|
||||
},
|
||||
__wbg_value_49f783bb59765962: function(arg0) {
|
||||
const ret = arg0.value;
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000001: function(arg0) {
|
||||
// Cast intrinsic for `I64 -> Externref`.
|
||||
const ret = arg0;
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0) {
|
||||
// Cast intrinsic for `U64 -> Externref`.
|
||||
const ret = BigInt.asUintN(64, arg0);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_init_externref_table: function() {
|
||||
const table = wasm.__wbindgen_externrefs;
|
||||
const offset = table.grow(4);
|
||||
@@ -59,6 +317,94 @@ function __wbg_get_imports() {
|
||||
};
|
||||
}
|
||||
|
||||
const DecodalLanguageServiceFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_decodallanguageservice_free(ptr, 1));
|
||||
|
||||
function addToExternrefTable0(obj) {
|
||||
const idx = wasm.__externref_table_alloc();
|
||||
wasm.__wbindgen_externrefs.set(idx, obj);
|
||||
return idx;
|
||||
}
|
||||
|
||||
function debugString(val) {
|
||||
// primitive types
|
||||
const type = typeof val;
|
||||
if (type == 'number' || type == 'boolean' || val == null) {
|
||||
return `${val}`;
|
||||
}
|
||||
if (type == 'string') {
|
||||
return `"${val}"`;
|
||||
}
|
||||
if (type == 'symbol') {
|
||||
const description = val.description;
|
||||
if (description == null) {
|
||||
return 'Symbol';
|
||||
} else {
|
||||
return `Symbol(${description})`;
|
||||
}
|
||||
}
|
||||
if (type == 'function') {
|
||||
const name = val.name;
|
||||
if (typeof name == 'string' && name.length > 0) {
|
||||
return `Function(${name})`;
|
||||
} else {
|
||||
return 'Function';
|
||||
}
|
||||
}
|
||||
// objects
|
||||
if (Array.isArray(val)) {
|
||||
const length = val.length;
|
||||
let debug = '[';
|
||||
if (length > 0) {
|
||||
debug += debugString(val[0]);
|
||||
}
|
||||
for(let i = 1; i < length; i++) {
|
||||
debug += ', ' + debugString(val[i]);
|
||||
}
|
||||
debug += ']';
|
||||
return debug;
|
||||
}
|
||||
// Test for built-in
|
||||
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
|
||||
let className;
|
||||
if (builtInMatches && builtInMatches.length > 1) {
|
||||
className = builtInMatches[1];
|
||||
} else {
|
||||
// Failed to match the standard '[object ClassName]'
|
||||
return toString.call(val);
|
||||
}
|
||||
if (className == 'Object') {
|
||||
// we're a user defined class or Object
|
||||
// JSON.stringify avoids problems with cycles, and is generally much
|
||||
// easier than looping through ownProperties of `val`.
|
||||
try {
|
||||
return 'Object(' + JSON.stringify(val) + ')';
|
||||
} catch (_) {
|
||||
return 'Object';
|
||||
}
|
||||
}
|
||||
// errors
|
||||
if (val instanceof Error) {
|
||||
return `${val.name}: ${val.message}\n${val.stack}`;
|
||||
}
|
||||
// TODO we could test for more things here, like `Set`s and `Map`s.
|
||||
return className;
|
||||
}
|
||||
|
||||
function getArrayU8FromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
||||
}
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
return decodeText(ptr >>> 0, len);
|
||||
}
|
||||
@@ -71,6 +417,19 @@ function getUint8ArrayMemory0() {
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function handleError(f, args) {
|
||||
try {
|
||||
return f.apply(this, args);
|
||||
} catch (e) {
|
||||
const idx = addToExternrefTable0(e);
|
||||
wasm.__wbindgen_exn_store(idx);
|
||||
}
|
||||
}
|
||||
|
||||
function isLikeNone(x) {
|
||||
return x === undefined || x === null;
|
||||
}
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
@@ -108,6 +467,12 @@ function passStringToWasm0(arg, malloc, realloc) {
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
@@ -142,6 +507,7 @@ function __wbg_finalize_init(instance, module) {
|
||||
wasmInstance = instance;
|
||||
wasm = instance.exports;
|
||||
wasmModule = module;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
|
||||
Binary file not shown.
+8
-2
@@ -1,10 +1,16 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const __wbg_decodallanguageservice_free: (a: number, b: number) => void;
|
||||
export const decodallanguageservice_complete: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number];
|
||||
export const decodallanguageservice_evaluate: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number];
|
||||
export const decodallanguageservice_new: (a: any) => [number, number, number];
|
||||
export const evaluate: (a: number, b: number) => [number, number];
|
||||
export const evaluateProject: (a: number, b: number, c: number, d: number) => [number, number];
|
||||
export const __wbindgen_externrefs: WebAssembly.Table;
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __wbindgen_exn_store: (a: number) => void;
|
||||
export const __externref_table_alloc: () => number;
|
||||
export const __wbindgen_externrefs: WebAssembly.Table;
|
||||
export const __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
export const __externref_table_dealloc: (a: number) => void;
|
||||
export const __wbindgen_start: () => void;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "@hare/decodal-wasm",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"license": "MIT",
|
||||
"exports": "./mod.ts",
|
||||
"publish": {
|
||||
"include": [
|
||||
"README.md",
|
||||
"LICENSE-MIT",
|
||||
"LICENSE-APACHE",
|
||||
"mod.ts",
|
||||
"decodal_wasm.js",
|
||||
"decodal_wasm.d.ts",
|
||||
|
||||
@@ -1,66 +1,26 @@
|
||||
/**
|
||||
* Browser and JavaScript runtime bindings for the Decodal evaluator.
|
||||
*
|
||||
* This module wraps the wasm-bindgen output generated from the Rust
|
||||
* `decodal-wasm` crate and exposes stable, documented entrypoints for JSR
|
||||
* users. The exported evaluator functions return JSON strings.
|
||||
* Host-configurable Decodal evaluator and language service for JavaScript.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
import initWasm, {
|
||||
evaluate as evaluateImpl,
|
||||
evaluateProject as evaluateProjectImpl,
|
||||
initSync as initSyncImpl,
|
||||
export {
|
||||
DecodalLanguageService,
|
||||
default,
|
||||
evaluate,
|
||||
initSync,
|
||||
} from './decodal_wasm.js';
|
||||
|
||||
/** Input accepted by the wasm-bindgen initializer. */
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
|
||||
/** Initialized Decodal WebAssembly exports. */
|
||||
export interface InitOutput {
|
||||
/** Linear memory exported by the WebAssembly module. */
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly evaluate: (source: number, len: number) => [number, number, number, number];
|
||||
readonly evaluateProject: (entry: number, entryLen: number, files: number, filesLen: number) => [number, number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Decodal WebAssembly runtime asynchronously.
|
||||
*
|
||||
* @param moduleOrPath - Optional WebAssembly module, bytes, response, URL, or request.
|
||||
* @returns The initialized WebAssembly exports.
|
||||
*/
|
||||
export default async function initDecodalRuntime(moduleOrPath?: InitInput): Promise<InitOutput> {
|
||||
return await initWasm(moduleOrPath) as InitOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Decodal WebAssembly runtime synchronously.
|
||||
*
|
||||
* @param module - Compiled module, bytes, or wasm-bindgen sync initialization input.
|
||||
* @returns The initialized WebAssembly exports.
|
||||
*/
|
||||
export function initSync(module: WebAssembly.Module | BufferSource): InitOutput {
|
||||
return initSyncImpl(module) as InitOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate and materialize a single Decodal source string.
|
||||
*
|
||||
* @param source - Decodal source text.
|
||||
* @returns A JSON string containing either `{ ok: true, output }` or `{ ok: false, error }`.
|
||||
*/
|
||||
export function evaluate(source: string): string {
|
||||
return evaluateImpl(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate and materialize a virtual multi-file Decodal project.
|
||||
*
|
||||
* @param entry - Entry point file path to materialize.
|
||||
* @param filesJson - JSON object mapping virtual file paths to source strings.
|
||||
* @returns A JSON string containing either `{ ok: true, output }` or `{ ok: false, error }`.
|
||||
*/
|
||||
export function evaluateProject(entry: string, filesJson: string): string {
|
||||
return evaluateProjectImpl(entry, filesJson);
|
||||
}
|
||||
export type {
|
||||
DecodalAbstractDescriptor,
|
||||
DecodalArrayDescriptor,
|
||||
DecodalConstraint,
|
||||
DecodalEnvironment,
|
||||
DecodalHostValue,
|
||||
DecodalImportCandidate,
|
||||
DecodalLoadedImport,
|
||||
DecodalPrimitiveDescriptor,
|
||||
DecodalPrimitiveType,
|
||||
InitInput,
|
||||
InitOutput,
|
||||
SyncInitInput,
|
||||
} from './decodal_wasm.js';
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
{
|
||||
"name": "decodal-wasm",
|
||||
"type": "module",
|
||||
"description": "WebAssembly wrapper for evaluating Decodal in browser playgrounds.",
|
||||
"version": "0.1.3",
|
||||
"description": "Host-configurable Decodal evaluator and language service for JavaScript runtimes.",
|
||||
"version": "0.1.4",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.hareworks.net/Hare/Decodal"
|
||||
},
|
||||
"files": [
|
||||
"README.md",
|
||||
"LICENSE-MIT",
|
||||
"LICENSE-APACHE",
|
||||
"decodal_wasm_bg.wasm",
|
||||
"decodal_wasm.js",
|
||||
"decodal_wasm.d.ts"
|
||||
@@ -23,5 +26,13 @@
|
||||
"wasm",
|
||||
"dsl",
|
||||
"config"
|
||||
]
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./decodal_wasm.d.ts",
|
||||
"import": "./decodal_wasm.js"
|
||||
},
|
||||
"./decodal_wasm_bg.wasm": "./decodal_wasm_bg.wasm",
|
||||
"./package.json": "./package.json"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+3
-2
@@ -25,10 +25,11 @@
|
||||
}
|
||||
},
|
||||
"../../packages/decodal-codemirror": {
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.10"
|
||||
@@ -41,7 +42,7 @@
|
||||
}
|
||||
},
|
||||
"../../packages/decodal-wasm": {
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
"node_modules/@astrojs/check": {
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
"scripts": {
|
||||
"dev": "astro dev --host 0.0.0.0",
|
||||
"build": "astro build",
|
||||
"build:all": "npm run build:wasm && npm run build",
|
||||
"build:wasm": "npm run build:wasm:runtime && npm run build:wasm:formatter",
|
||||
"build:wasm:runtime": "wasm-pack build ../../crates/decodal-wasm --target web --out-dir ../../packages/decodal-wasm --release && node scripts/prepare-wasm-package.mjs",
|
||||
"build:wasm:formatter": "wasm-pack build ../../crates/decodal-language-tools --target web --out-dir ../../packages/decodal-codemirror/wasm --release && node scripts/prepare-codemirror-wasm.mjs",
|
||||
"test": "node --test src/scripts/*.test.mjs",
|
||||
"preview": "astro preview --host 0.0.0.0",
|
||||
"build:runtime": "wasm-pack build ../../crates/decodal-wasm --target web --out-dir ../../packages/decodal-wasm --release && node scripts/prepare-wasm-package.mjs",
|
||||
"build:tools": "wasm-pack build ../../crates/decodal-language-tools --target web --out-dir ../../packages/decodal-codemirror/wasm --release && node scripts/prepare-codemirror-wasm.mjs",
|
||||
"build:wasm": "npm run build:runtime && npm run build:tools",
|
||||
"build:packages": "npm run build:wasm",
|
||||
"deploy": "npm run build && node scripts/deploy-pages.mjs",
|
||||
"deploy:wasm": "npm run build:wasm && npm run deploy"
|
||||
"deploy:all": "npm run build:wasm && npm run deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { rmSync, writeFileSync } from 'node:fs';
|
||||
import { copyFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const wasmDir = resolve(import.meta.dirname, '../../../packages/decodal-codemirror/wasm');
|
||||
const packageDir = resolve(wasmDir, '..');
|
||||
|
||||
copyFileSync(resolve(packageDir, '../../LICENSE-MIT'), resolve(packageDir, 'LICENSE-MIT'));
|
||||
copyFileSync(resolve(packageDir, '../../LICENSE-APACHE'), resolve(packageDir, 'LICENSE-APACHE'));
|
||||
|
||||
rmSync(resolve(wasmDir, 'README.md'), { force: true });
|
||||
rmSync(resolve(wasmDir, 'package.json'), { force: true });
|
||||
|
||||
@@ -1,8 +1,78 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { copyFileSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const packageDir = resolve(import.meta.dirname, '../../../packages/decodal-wasm');
|
||||
|
||||
copyFileSync(resolve(packageDir, '../../LICENSE-MIT'), resolve(packageDir, 'LICENSE-MIT'));
|
||||
copyFileSync(resolve(packageDir, '../../LICENSE-APACHE'), resolve(packageDir, 'LICENSE-APACHE'));
|
||||
|
||||
const declarationPath = resolve(packageDir, 'decodal_wasm.d.ts');
|
||||
let declarations = readFileSync(declarationPath, 'utf8');
|
||||
const hostTypes = `
|
||||
/** A concrete JavaScript value or Decodal schema descriptor supplied by the host. */
|
||||
export type DecodalHostValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| DecodalHostValue[]
|
||||
| { [field: string]: DecodalHostValue }
|
||||
| DecodalPrimitiveDescriptor
|
||||
| DecodalArrayDescriptor
|
||||
| DecodalAbstractDescriptor;
|
||||
|
||||
export type DecodalPrimitiveType = 'String' | 'Int' | 'Float' | 'Bool';
|
||||
|
||||
export type DecodalConstraint =
|
||||
| { kind: 'type'; value: DecodalPrimitiveType }
|
||||
| { kind: 'compare'; op: '>' | '>=' | '<' | '<='; value: string | number | boolean }
|
||||
| { kind: 'regex'; value: string }
|
||||
| { kind: 'predicate'; value: string };
|
||||
|
||||
export interface DecodalPrimitiveDescriptor {
|
||||
$decodal: DecodalPrimitiveType;
|
||||
constraints?: DecodalConstraint[];
|
||||
default?: DecodalHostValue;
|
||||
}
|
||||
|
||||
export interface DecodalArrayDescriptor {
|
||||
$decodal: 'Array';
|
||||
item: DecodalHostValue;
|
||||
constraints?: DecodalConstraint[];
|
||||
default?: DecodalHostValue;
|
||||
}
|
||||
|
||||
export interface DecodalAbstractDescriptor {
|
||||
$decodal: 'Abstract';
|
||||
constraints?: DecodalConstraint[];
|
||||
default?: DecodalHostValue;
|
||||
}
|
||||
|
||||
export type DecodalLoadedImport =
|
||||
| { kind: 'source'; key: string; name?: string; source: string }
|
||||
| { kind: 'value'; key: string; value: DecodalHostValue };
|
||||
|
||||
export type DecodalImportCandidate =
|
||||
| string
|
||||
| { specifier: string; detail?: string };
|
||||
|
||||
/** Host-owned environment shared by evaluation and language tooling. */
|
||||
export interface DecodalEnvironment {
|
||||
globals?: Record<string, DecodalHostValue>;
|
||||
/** Synchronous import callback. Preload or cache asynchronous resources first. */
|
||||
loadImport?: (currentKey: string | null, specifier: string) => DecodalLoadedImport;
|
||||
/** Synchronous import completion callback. */
|
||||
completeImport?: (currentKey: string | null, prefix: string) => DecodalImportCandidate[];
|
||||
}
|
||||
`;
|
||||
if (!declarations.includes('export interface DecodalEnvironment')) {
|
||||
declarations = declarations.replace('/* eslint-disable */\n', `/* eslint-disable */\n${hostTypes}`);
|
||||
}
|
||||
declarations = declarations.replace(
|
||||
'constructor(options: any);',
|
||||
'constructor(options?: DecodalEnvironment);',
|
||||
);
|
||||
writeFileSync(declarationPath, declarations);
|
||||
|
||||
writeFileSync(
|
||||
resolve(packageDir, '.gitignore'),
|
||||
'# wasm-pack output is committed for the browser runtime package.\n',
|
||||
@@ -10,7 +80,23 @@ writeFileSync(
|
||||
|
||||
const packageJsonPath = resolve(packageDir, 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||
packageJson.version = '0.1.3';
|
||||
packageJson.version = '0.1.4';
|
||||
packageJson.files = [
|
||||
'README.md',
|
||||
'LICENSE-MIT',
|
||||
'LICENSE-APACHE',
|
||||
'decodal_wasm_bg.wasm',
|
||||
'decodal_wasm.js',
|
||||
'decodal_wasm.d.ts',
|
||||
];
|
||||
packageJson.exports = {
|
||||
'.': {
|
||||
types: './decodal_wasm.d.ts',
|
||||
import: './decodal_wasm.js',
|
||||
},
|
||||
'./decodal_wasm_bg.wasm': './decodal_wasm_bg.wasm',
|
||||
'./package.json': './package.json',
|
||||
};
|
||||
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
|
||||
|
||||
writeFileSync(
|
||||
@@ -19,7 +105,7 @@ writeFileSync(
|
||||
|
||||
WebAssembly runtime package for Decodal.
|
||||
|
||||
This package exposes the Decodal evaluator to browsers and other JavaScript runtimes that can load WebAssembly modules.
|
||||
This package exposes the Decodal evaluator and shared language service to browsers and other JavaScript runtimes that can load WebAssembly modules.
|
||||
It is generated from the Rust crate in \`crates/decodal-wasm\` with \`wasm-pack\` and is used by the official playground.
|
||||
|
||||
Use \`decodal-codemirror\` separately when you need CodeMirror 6 language support.
|
||||
@@ -39,18 +125,51 @@ deno add jsr:@hare/decodal-wasm
|
||||
## Usage
|
||||
|
||||
\`\`\`js
|
||||
import init, { evaluateProject } from 'decodal-wasm';
|
||||
import init, { DecodalLanguageService } from 'decodal-wasm';
|
||||
|
||||
await init();
|
||||
|
||||
const result = evaluateProject('main.dcdl', JSON.stringify({
|
||||
'main.dcdl': 'Server = { port = Int default 8080; };',
|
||||
}));
|
||||
const files = {
|
||||
'main.dcdl': 'let schema = import "./schema.dcdl"; in schema.Server',
|
||||
'schema.dcdl': 'Server = App.Server & { port = 8080; };',
|
||||
};
|
||||
|
||||
const service = new DecodalLanguageService({
|
||||
globals: {
|
||||
App: {
|
||||
Server: {
|
||||
port: { $decodal: 'Int' },
|
||||
},
|
||||
},
|
||||
},
|
||||
loadImport(_currentKey, specifier) {
|
||||
const key = specifier.startsWith('./') ? specifier.slice(2) : specifier;
|
||||
return { kind: 'source', key, name: key, source: files[key] };
|
||||
},
|
||||
completeImport() {
|
||||
return ['./schema.dcdl'];
|
||||
},
|
||||
});
|
||||
|
||||
const result = service.evaluate('main.dcdl', 'main.dcdl', files['main.dcdl']);
|
||||
|
||||
console.log(JSON.parse(result));
|
||||
|
||||
const completion = service.complete(
|
||||
'main.dcdl',
|
||||
'App.Server.po',
|
||||
'App.Server.po'.length,
|
||||
false,
|
||||
);
|
||||
|
||||
console.log(JSON.parse(completion));
|
||||
\`\`\`
|
||||
|
||||
The exported functions return JSON strings so callers can handle diagnostics and successful results without depending on Rust data structures.
|
||||
\`globals\`, \`loadImport\`, and \`completeImport\` are host-owned. The package does not assume a filesystem or virtual project model. Import callbacks are synchronous; preload or cache remote content before evaluation.
|
||||
|
||||
Plain strings, numbers, booleans, arrays, and objects are concrete host values. Use \`{ $decodal: 'String' | 'Int' | 'Float' | 'Bool' }\` for primitive schemas and \`{ $decodal: 'Array', item: value }\` for array schemas. Descriptors also accept \`constraints\` and \`default\`.
|
||||
|
||||
Methods return JSON strings so callers can handle diagnostics and successful results without depending on Rust data structures.
|
||||
`,
|
||||
);
|
||||
|
||||
@@ -59,12 +178,16 @@ writeFileSync(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: '@hare/decodal-wasm',
|
||||
version: '0.1.3',
|
||||
version: '0.1.4',
|
||||
// JSR's publisher currently accepts a single recognized identifier.
|
||||
// npm package metadata remains dual-licensed and both files are included.
|
||||
license: 'MIT',
|
||||
exports: './mod.ts',
|
||||
publish: {
|
||||
include: [
|
||||
'README.md',
|
||||
'LICENSE-MIT',
|
||||
'LICENSE-APACHE',
|
||||
'mod.ts',
|
||||
'decodal_wasm.js',
|
||||
'decodal_wasm.d.ts',
|
||||
|
||||
@@ -32,8 +32,8 @@ Production = Server & {
|
||||
<strong>crates.io/crates/decodal</strong>
|
||||
</a>
|
||||
<ul class="package-list">
|
||||
<li>decodal-wasm on <a href="https://jsr.io/@hare/decodal-wasm@0.1.3">jsr</a> / <a href="https://www.npmjs.com/package/decodal-wasm">npm</a></li>
|
||||
<li>decodal-codemirror on <a href="https://jsr.io/@hare/decodal-codemirror@0.1.5">jsr</a> / <a href="https://www.npmjs.com/package/decodal-codemirror">npm</a></li>
|
||||
<li>decodal-wasm on <a href="https://jsr.io/@hare/decodal-wasm@0.1.4">jsr</a> / <a href="https://www.npmjs.com/package/decodal-wasm">npm</a></li>
|
||||
<li>decodal-codemirror on <a href="https://jsr.io/@hare/decodal-codemirror@0.1.6">jsr</a> / <a href="https://www.npmjs.com/package/decodal-codemirror">npm</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const completionTypes = {
|
||||
keyword: 'keyword',
|
||||
constant: 'constant',
|
||||
type: 'type',
|
||||
variable: 'variable',
|
||||
namespace: 'namespace',
|
||||
property: 'property',
|
||||
file: 'text',
|
||||
};
|
||||
|
||||
export function createLanguageServiceCompletionSource({ complete, getActivePath }) {
|
||||
return (context) => {
|
||||
const activePath = getActivePath();
|
||||
let response;
|
||||
try {
|
||||
response = complete({
|
||||
key: activePath,
|
||||
source: context.state.doc.toString(),
|
||||
position: context.pos,
|
||||
explicit: context.explicit,
|
||||
});
|
||||
if (!response) return null;
|
||||
if (typeof response === 'string') response = JSON.parse(response);
|
||||
} catch (_error) {
|
||||
// The editor can become interactive just before WASM initialization
|
||||
// finishes. A later completion request will use the initialized service.
|
||||
return null;
|
||||
}
|
||||
if (!response.ok || !response.completion) return null;
|
||||
return completionResultToCodeMirror(response.completion);
|
||||
};
|
||||
}
|
||||
|
||||
export function completionResultToCodeMirror(completion) {
|
||||
const fileCompletion = completion.options.some((option) => option.kind === 'file');
|
||||
return {
|
||||
from: completion.from,
|
||||
options: completion.options.map((option) => ({
|
||||
label: option.label,
|
||||
type: completionTypes[option.kind] ?? 'text',
|
||||
detail: option.detail ?? undefined,
|
||||
boost: option.priority ?? 0,
|
||||
})),
|
||||
validFor: fileCompletion ? /^[^"\n]*$/ : /^[A-Za-z_][A-Za-z0-9_]*$/,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
completionResultToCodeMirror,
|
||||
createLanguageServiceCompletionSource,
|
||||
} from './playground-completion.js';
|
||||
|
||||
test('passes the active document to an injected language service', () => {
|
||||
const calls = [];
|
||||
const source = createLanguageServiceCompletionSource({
|
||||
getActivePath: () => 'main.dcdl',
|
||||
complete(request) {
|
||||
calls.push(request);
|
||||
return JSON.stringify({ ok: true, completion: null });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(source({
|
||||
state: { doc: { toString: () => 'Post.ti' } },
|
||||
pos: 7,
|
||||
explicit: true,
|
||||
}), null);
|
||||
assert.deepEqual(calls, [{
|
||||
key: 'main.dcdl',
|
||||
source: 'Post.ti',
|
||||
position: 7,
|
||||
explicit: true,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('adapts shared language-service completions for CodeMirror', () => {
|
||||
const result = completionResultToCodeMirror({
|
||||
from: 12,
|
||||
options: [
|
||||
{
|
||||
label: 'port',
|
||||
kind: 'property',
|
||||
detail: 'schemas/service.dcdl',
|
||||
priority: 30,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.from, 12);
|
||||
assert.deepEqual(result.options, [
|
||||
{
|
||||
label: 'port',
|
||||
type: 'property',
|
||||
detail: 'schemas/service.dcdl',
|
||||
boost: 30,
|
||||
},
|
||||
]);
|
||||
assert.ok(result.validFor.test('partial_name'));
|
||||
});
|
||||
|
||||
test('uses import-path filtering for file completions', () => {
|
||||
const result = completionResultToCodeMirror({
|
||||
from: 8,
|
||||
options: [
|
||||
{
|
||||
label: './schemas/service.dcdl',
|
||||
kind: 'file',
|
||||
detail: 'project file',
|
||||
priority: 30,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(result.validFor.test('./schemas/serv'));
|
||||
assert.equal(result.options[0].type, 'text');
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
export function createPlaygroundEnvironment(getFiles) {
|
||||
return {
|
||||
globals: {},
|
||||
|
||||
loadImport(currentKey, specifier) {
|
||||
const key = resolveImportPath(currentKey, specifier);
|
||||
const source = getFiles()[key];
|
||||
if (source === undefined) {
|
||||
throw new Error(`import ${JSON.stringify(specifier)} resolved to ${JSON.stringify(key)}, but that file does not exist`);
|
||||
}
|
||||
return { kind: 'source', key, name: key, source };
|
||||
},
|
||||
|
||||
completeImport(currentKey, prefix) {
|
||||
return Object.keys(getFiles())
|
||||
.filter((path) => path !== currentKey)
|
||||
.map((path) => ({
|
||||
specifier: prefix.startsWith('/')
|
||||
? `/${path}`
|
||||
: relativeImportPath(currentKey, path),
|
||||
detail: 'project file',
|
||||
}))
|
||||
.filter((candidate) => candidate.specifier.startsWith(prefix))
|
||||
.sort((left, right) => left.specifier.localeCompare(right.specifier));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveImportPath(currentKey, specifier) {
|
||||
if (specifier.startsWith('/')) return normalizeVirtualPath(specifier);
|
||||
const parent = currentKey?.includes('/')
|
||||
? currentKey.slice(0, currentKey.lastIndexOf('/'))
|
||||
: '';
|
||||
return normalizeVirtualPath(parent ? `${parent}/${specifier}` : specifier);
|
||||
}
|
||||
|
||||
export function relativeImportPath(currentKey, target) {
|
||||
const from = currentKey?.includes('/')
|
||||
? currentKey.slice(0, currentKey.lastIndexOf('/')).split('/').filter(Boolean)
|
||||
: [];
|
||||
const to = target.split('/').filter(Boolean);
|
||||
let common = 0;
|
||||
while (common < from.length && common < to.length && from[common] === to[common]) {
|
||||
common += 1;
|
||||
}
|
||||
const parts = [
|
||||
...Array(from.length - common).fill('..'),
|
||||
...to.slice(common),
|
||||
];
|
||||
const relative = parts.join('/');
|
||||
return relative.startsWith('.') ? relative : `./${relative}`;
|
||||
}
|
||||
|
||||
function normalizeVirtualPath(path) {
|
||||
const parts = [];
|
||||
for (const part of String(path).replaceAll('\\', '/').split('/')) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') {
|
||||
if (parts.length === 0) throw new Error(`invalid virtual path ${JSON.stringify(path)}`);
|
||||
parts.pop();
|
||||
} else {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) throw new Error(`invalid virtual path ${JSON.stringify(path)}`);
|
||||
return parts.join('/');
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
createPlaygroundEnvironment,
|
||||
relativeImportPath,
|
||||
resolveImportPath,
|
||||
} from './playground-environment.js';
|
||||
|
||||
test('resolves virtual imports in the playground host', () => {
|
||||
assert.equal(
|
||||
resolveImportPath('content/pages/home.dcdl', '../schemas/page.dcdl'),
|
||||
'content/schemas/page.dcdl',
|
||||
);
|
||||
assert.equal(
|
||||
relativeImportPath('content/pages/home.dcdl', 'content/schemas/page.dcdl'),
|
||||
'../schemas/page.dcdl',
|
||||
);
|
||||
});
|
||||
|
||||
test('loads source and completes paths from playground-owned files', () => {
|
||||
const files = {
|
||||
'main.dcdl': 'import "./schemas/page.dcdl"',
|
||||
'schemas/page.dcdl': 'Page = { title = String; };',
|
||||
};
|
||||
const environment = createPlaygroundEnvironment(() => files);
|
||||
|
||||
assert.deepEqual(environment.loadImport('main.dcdl', './schemas/page.dcdl'), {
|
||||
kind: 'source',
|
||||
key: 'schemas/page.dcdl',
|
||||
name: 'schemas/page.dcdl',
|
||||
source: files['schemas/page.dcdl'],
|
||||
});
|
||||
assert.deepEqual(environment.completeImport('main.dcdl', './sch'), [
|
||||
{ specifier: './schemas/page.dcdl', detail: 'project file' },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import initRuntime, { DecodalLanguageService } from 'decodal-wasm';
|
||||
|
||||
const wasmPath = new URL('../../../../packages/decodal-wasm/decodal_wasm_bg.wasm', import.meta.url);
|
||||
|
||||
test('injects one JavaScript host environment into evaluation and completion', async () => {
|
||||
await initRuntime({ module_or_path: await readFile(wasmPath) });
|
||||
const files = {
|
||||
'schema.dcdl': 'Server = { port = Int; };',
|
||||
};
|
||||
const service = new DecodalLanguageService({
|
||||
globals: {
|
||||
App: {
|
||||
enabled: { $decodal: 'Bool', default: true },
|
||||
},
|
||||
},
|
||||
loadImport(_currentKey, specifier) {
|
||||
if (specifier === './post.md') {
|
||||
return {
|
||||
kind: 'value',
|
||||
key: 'post.md',
|
||||
value: { frontmatter: { draft: false }, body: '# Hello' },
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'source',
|
||||
key: 'schema.dcdl',
|
||||
source: files['schema.dcdl'],
|
||||
};
|
||||
},
|
||||
completeImport() {
|
||||
return [{ specifier: './schema.dcdl', detail: 'test source' }];
|
||||
},
|
||||
});
|
||||
|
||||
const evaluated = JSON.parse(service.evaluate(
|
||||
'main.dcdl',
|
||||
'main.dcdl',
|
||||
'let s = import "./schema.dcdl"; in { server = s.Server & { port = 8080; }; enabled = App.enabled; post = import "./post.md"; }',
|
||||
));
|
||||
assert.equal(evaluated.ok, true, evaluated.error);
|
||||
assert.match(evaluated.output, /"port": 8080/);
|
||||
assert.match(evaluated.output, /"body": "# Hello"/);
|
||||
|
||||
const member = JSON.parse(service.complete('main.dcdl', 'App.en', 6, false));
|
||||
assert.equal(member.ok, true, member.error);
|
||||
assert.ok(member.completion.options.some((item) => item.label === 'enabled'));
|
||||
|
||||
const imported = JSON.parse(service.complete('main.dcdl', 'import "./sch', 13, false));
|
||||
assert.equal(imported.ok, true, imported.error);
|
||||
assert.ok(imported.completion.options.some((item) => item.label === './schema.dcdl'));
|
||||
|
||||
service.free();
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import initRuntime, { evaluateProject } from 'decodal-wasm';
|
||||
import initRuntime, { DecodalLanguageService } from 'decodal-wasm';
|
||||
import runtimeWasmUrl from 'decodal-wasm/decodal_wasm_bg.wasm?url';
|
||||
import { EditorView, basicSetup } from 'codemirror';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { decodal } from 'decodal-codemirror';
|
||||
import { decodal, decodalLanguage } from 'decodal-codemirror';
|
||||
import { formatDecodal, initDecodalFormatter } from 'decodal-codemirror/format';
|
||||
import toolsWasmUrl from 'decodal-codemirror/wasm/decodal_language_tools_bg.wasm?url';
|
||||
import { playgroundExamples } from './playground-examples.js';
|
||||
import { createLanguageServiceCompletionSource } from './playground-completion.js';
|
||||
import { createPlaygroundEnvironment } from './playground-environment.js';
|
||||
|
||||
const STORAGE_KEY = 'decodal-playground-project-v2';
|
||||
const starterProject = playgroundExamples[0];
|
||||
@@ -24,6 +26,7 @@ const entrySelect = document.getElementById('entry-select');
|
||||
const loadExample = document.getElementById('load-example');
|
||||
|
||||
const project = loadProject();
|
||||
let languageService;
|
||||
|
||||
const editorTheme = EditorView.theme({
|
||||
'&': {
|
||||
@@ -56,14 +59,44 @@ const editorTheme = EditorView.theme({
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: 'rgb(59 130 246 / 0.35)',
|
||||
},
|
||||
'.cm-tooltip-autocomplete': {
|
||||
backgroundColor: '#111827',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '8px',
|
||||
color: '#e5e7eb',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
'.cm-tooltip-autocomplete > ul > li': {
|
||||
padding: '3px 8px',
|
||||
},
|
||||
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
|
||||
backgroundColor: '#1d4ed8',
|
||||
color: '#fff',
|
||||
},
|
||||
'.cm-completionDetail': {
|
||||
color: '#94a3b8',
|
||||
fontStyle: 'normal',
|
||||
marginLeft: '1.5em',
|
||||
},
|
||||
}, { dark: true });
|
||||
|
||||
const completionSource = createLanguageServiceCompletionSource({
|
||||
getActivePath: () => project.activePath,
|
||||
complete: ({ key, source, position, explicit }) => languageService?.complete(
|
||||
key,
|
||||
source,
|
||||
position,
|
||||
explicit,
|
||||
),
|
||||
});
|
||||
|
||||
const editor = new EditorView({
|
||||
doc: project.files[project.activePath] ?? '',
|
||||
parent: editorHost,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
decodal(),
|
||||
decodalLanguage.data.of({ autocomplete: completionSource }),
|
||||
editorTheme,
|
||||
keymap.of([
|
||||
{
|
||||
@@ -215,7 +248,11 @@ function execute() {
|
||||
project.entryPath = entryPath;
|
||||
updateRunLabel();
|
||||
saveProject();
|
||||
const result = JSON.parse(evaluateProject(entryPath, JSON.stringify(project.files)));
|
||||
const result = JSON.parse(languageService.evaluate(
|
||||
entryPath,
|
||||
entryPath,
|
||||
project.files[entryPath],
|
||||
));
|
||||
output.textContent = result.ok ? result.output : result.error;
|
||||
output.classList.toggle('error', !result.ok);
|
||||
}
|
||||
@@ -286,9 +323,12 @@ function compareNodes(a, b) {
|
||||
|
||||
try {
|
||||
await Promise.all([initRuntime(runtimeWasmUrl), initDecodalFormatter(toolsWasmUrl)]);
|
||||
languageService = new DecodalLanguageService(
|
||||
createPlaygroundEnvironment(() => project.files),
|
||||
);
|
||||
run.disabled = false;
|
||||
formatButton.disabled = false;
|
||||
status.textContent = '';
|
||||
status.textContent = 'Ctrl+Space: complete';
|
||||
execute();
|
||||
} catch (error) {
|
||||
status.textContent = `Failed to load WASM: ${error?.message ?? error}`;
|
||||
|
||||
Reference in New Issue
Block a user