Compare commits

20 Commits
Author SHA1 Message Date
Hare 87fef44c68 Make decodal the published library crate 2026-06-25 22:51:16 +09:00
Hare 6e2363632b Prepare crates.io release metadata 2026-06-25 22:25:01 +09:00
Hare c26f9ca50a Add playground example loader 2026-06-25 03:39:40 +09:00
Hare f928028007 Deploy Pages production branch by default 2026-06-25 03:12:37 +09:00
Hare d8af980f7d Add Cloudflare Pages deploy script 2026-06-25 02:49:15 +09:00
Hare a2a6dee025 Show source names in diagnostics 2026-06-22 18:05:26 +09:00
Hare cc5ab63922 Improve composition diagnostics 2026-06-22 17:37:51 +09:00
Hare 8b3df21cfa Document lightweight runtime boundaries 2026-06-19 22:48:33 +09:00
Hare 01ad6dca52 Add array concat operator 2026-06-19 01:01:04 +09:00
Hare 6da0ec4c77 Fix inline code escaping 2026-06-18 23:55:11 +09:00
Hare 683151f6bd Remove unimplemented operator note 2026-06-18 23:54:10 +09:00
Hare 9e728fb634 Document operator reference 2026-06-18 23:42:53 +09:00
Hare 2fe54bda62 Add logical and comparison expressions 2026-06-17 23:38:44 +09:00
Hare 3f7dd7c692 Add arithmetic expressions 2026-06-17 23:00:14 +09:00
Hare dc28cddbff Add dark theme support 2026-06-17 14:39:44 +09:00
Hare aa6e5c51c1 Number manual sidebar navigation 2026-06-17 13:37:53 +09:00
Hare 4ab47e5719 Add multi-file playground imports 2026-06-17 10:17:00 +09:00
Hare 19c9de1601 Add site syntax highlighting 2026-06-17 07:39:00 +09:00
Hare 0e873fbd51 Migrate documentation site to Astro 2026-06-17 00:22:32 +09:00
Hare 4020b7c2d5 Add Svelte docs site and WASM playground 2026-06-17 00:07:11 +09:00
65 changed files with 21014 additions and 3293 deletions
+6
View File
@@ -3,3 +3,9 @@
/.env
/.yoi
/result
node_modules
/dist
site/decodal-site/dist
# Astro
site/decodal-site/.astro
Generated
+173 -6
View File
@@ -12,25 +12,76 @@ dependencies = [
]
[[package]]
name = "decodal"
version = "0.1.0"
dependencies = [
"decodal-core",
]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "decodal-core"
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "decodal"
version = "0.1.0"
dependencies = [
"regex",
]
[[package]]
name = "decodal-cli"
version = "0.1.0"
dependencies = [
"decodal",
]
[[package]]
name = "decodal-wasm"
version = "0.1.0"
dependencies = [
"decodal",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.12.4"
@@ -59,3 +110,119 @@ name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasm-bindgen"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
dependencies = [
"unicode-ident",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+13 -7
View File
@@ -2,12 +2,18 @@
members = [
"crates/decodal-core",
"crates/decodal-cli",
"crates/decodal-wasm",
]
resolver = "3"
resolver = "2"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
repository = "https://gitea.hareworks.net/Hare/Decodal"
readme = "README.md"
[workspace.dependencies]
serde_json = "1"
wasm-bindgen = "0.2"
+173
View File
@@ -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
+21
View File
@@ -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.
+55
View File
@@ -0,0 +1,55 @@
# Decodal
Decodal is a small deterministic DSL for describing, composing, validating, and materializing structured data.
It is designed around a lightweight Rust library:
- host-supplied imports through `SourceLoader`
- no filesystem access in the library core
- concrete and abstract values with constraints and defaults
- deterministic expression evaluation
- optional regex support behind a Cargo feature
- browser playground support through WebAssembly
## Library crate
Embedded hosts should depend on `decodal` and provide imports with a `SourceLoader`.
```toml
[dependencies]
decodal = "0.1"
```
## CLI
A standalone CLI is kept in this repository as the `decodal-cli` workspace package.
It builds a `decodal` binary, but it is not the primary crates.io package.
Run a Decodal file from the repository:
```sh
cargo run -q -p decodal-cli -- examples/advanced/main.dcdl
```
Enable optional regex support when needed:
```sh
cargo run -q -p decodal-cli --features regex -- examples/regex/main.dcdl
```
## Web playground
The static documentation site and browser playground live under:
```text
site/decodal-site/
```
## License
Licensed under either of:
- Apache License, Version 2.0
- MIT license
at your option.
+16 -4
View File
@@ -1,11 +1,23 @@
[package]
name = "decodal-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme.workspace = true
description = "Command-line interface for the Decodal data description language."
keywords = ["decodal", "dsl", "config", "cli"]
categories = ["command-line-utilities", "config"]
publish = false
[[bin]]
name = "decodal"
version = "0.1.0"
edition = "2024"
path = "src/main.rs"
[features]
default = []
regex = ["decodal-core/regex"]
regex = ["decodal/regex"]
[dependencies]
decodal-core = { path = "../decodal-core" }
decodal = { version = "0.1.0", path = "../decodal-core" }
+25 -18
View File
@@ -4,19 +4,22 @@ use std::{
process::ExitCode,
};
use decodal_core::{Data, Diagnostic, DiagnosticKind, Engine, LoadedSource, SourceLoader, Span};
use decodal::{
Data, Diagnostic, DiagnosticKind, Engine, LoadedSource, SourceId, SourceLoader, Span,
format_diagnostic_with,
};
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
print_diagnostic(&error);
eprintln!("{error}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<(), Diagnostic> {
fn run() -> Result<(), String> {
let mut args = env::args().skip(1);
let first = args.next().unwrap_or_else(|| String::from("--help"));
match first.as_str() {
@@ -25,7 +28,7 @@ fn run() -> Result<(), Diagnostic> {
Ok(())
}
"--version" | "-V" => {
println!("Decodal {}", decodal_core::version());
println!("Decodal {}", decodal::version());
Ok(())
}
"check" => {
@@ -51,12 +54,19 @@ fn run() -> Result<(), Diagnostic> {
}
}
fn materialize_path(path: &str) -> Result<Data, Diagnostic> {
let root = read_root_source(path)?;
fn materialize_path(path: &str) -> Result<Data, String> {
let root = read_root_source(path).map_err(format_raw_diagnostic)?;
let root_name = root.name.clone();
let mut engine = Engine::new(FsLoader);
let module = engine.add_root_source(root.key, root.name, &root.source)?;
let value = engine.eval_module(module)?;
engine.materialize(&value)
let module = engine
.add_root_source(root.key, root.name, &root.source)
.map_err(|error| format_diagnostic_with_root(&error, &root_name))?;
let value = engine
.eval_module(module)
.map_err(|error| engine.format_diagnostic(&error))?;
engine
.materialize(&value)
.map_err(|error| engine.format_diagnostic(&error))
}
fn read_root_source(path: &str) -> Result<LoadedSource, Diagnostic> {
@@ -139,15 +149,12 @@ fn print_help() {
println!(" decodal - Read DCDL source from stdin");
}
fn print_diagnostic(error: &Diagnostic) {
eprintln!(
"error[{kind:?}] {source}:{start}..{end}: {message}",
kind = error.kind,
source = error.span.source.0,
start = error.span.start,
end = error.span.end,
message = error.message,
);
fn format_diagnostic_with_root(error: &Diagnostic, root_name: &str) -> String {
format_diagnostic_with(error, |source| (source == SourceId(0)).then_some(root_name))
}
fn format_raw_diagnostic(error: Diagnostic) -> String {
format_diagnostic_with(&error, |_| None)
}
fn print_data(data: &Data, indent: usize) {
+10 -3
View File
@@ -1,7 +1,14 @@
[package]
name = "decodal-core"
version = "0.1.0"
edition = "2024"
name = "decodal"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme.workspace = true
description = "Parser, evaluator, and embedding API for the Decodal data description language."
keywords = ["decodal", "dsl", "config", "schema"]
categories = ["config", "parser-implementations"]
[features]
default = ["std"]
+2 -2
View File
@@ -1,6 +1,6 @@
use decodal_core::{Data, EmptyLoader, Engine, HostValue};
use decodal::{Data, EmptyLoader, Engine, HostValue};
fn main() -> decodal_core::Result<()> {
fn main() -> decodal::Result<()> {
let mut engine = Engine::new(EmptyLoader);
engine.bind_global(
+23
View File
@@ -71,6 +71,10 @@ pub enum Expr {
scrutinee: ExprId,
arms: Vec<MatchArm>,
},
Unary {
op: UnaryOp,
expr: ExprId,
},
Binary {
op: BinaryOp,
lhs: ExprId,
@@ -117,8 +121,27 @@ pub enum Literal {
Bool(bool),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnaryOp {
Neg,
Not,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinaryOp {
Add,
Sub,
Mul,
Div,
Concat,
Equal,
NotEqual,
Greater,
GreaterEqual,
Less,
LessEqual,
LogicalAnd,
LogicalOr,
And,
Patch,
}
+161 -83
View File
@@ -3,103 +3,154 @@ use alloc::{string::String, vec::Vec};
use crate::{
Diagnostic, DiagnosticKind, Span,
ast::CompareOp,
runtime::{Constraint, LiteralValue, PrimitiveType},
runtime::{Constraint, ConstraintEntry, LiteralValue, PrimitiveType},
};
pub fn normalize_constraints(
constraints: Vec<Constraint>,
constraints: Vec<ConstraintEntry>,
span: Span,
) -> crate::Result<Vec<Constraint>> {
let mut primitive = None;
let mut lower: Option<Bound> = None;
let mut upper: Option<Bound> = None;
) -> crate::Result<Vec<ConstraintEntry>> {
let mut primitive: Option<(PrimitiveType, Span)> = None;
let mut lower: Option<(Bound, Span)> = None;
let mut upper: Option<(Bound, Span)> = None;
let mut rest = Vec::new();
for constraint in constraints {
match constraint {
for entry in constraints {
match entry.constraint {
Constraint::Type(next) => match primitive {
Some(current) if current != next => {
Some((current, current_span)) if current != next => {
return Err(Diagnostic::new(
DiagnosticKind::Conflict,
span,
"primitive type constraints conflict",
));
)
.with_label(current_span, "first primitive constraint")
.with_label(entry.span, "conflicting primitive constraint"));
}
Some(_) => {}
None => primitive = Some(next),
None => primitive = Some((next, entry.span)),
},
Constraint::Compare(op, value) => {
let number = Number::from_literal(&value).ok_or_else(|| {
Diagnostic::new(
DiagnosticKind::TypeMismatch,
span,
entry.span,
"comparison constraints require numeric literals",
)
})?;
match op {
CompareOp::Gt => merge_lower(&mut lower, Bound::new(number, false)),
CompareOp::Gte => merge_lower(&mut lower, Bound::new(number, true)),
CompareOp::Lt => merge_upper(&mut upper, Bound::new(number, false)),
CompareOp::Lte => merge_upper(&mut upper, Bound::new(number, true)),
CompareOp::Gt => {
merge_lower(&mut lower, (Bound::new(number, false), entry.span))
}
CompareOp::Gte => {
merge_lower(&mut lower, (Bound::new(number, true), entry.span))
}
CompareOp::Lt => {
merge_upper(&mut upper, (Bound::new(number, false), entry.span))
}
CompareOp::Lte => {
merge_upper(&mut upper, (Bound::new(number, true), entry.span))
}
CompareOp::Eq => {
merge_lower(&mut lower, Bound::new(number, true));
merge_upper(&mut upper, Bound::new(number, true));
merge_lower(&mut lower, (Bound::new(number, true), entry.span));
merge_upper(&mut upper, (Bound::new(number, true), entry.span));
}
}
}
Constraint::Regex(pattern) => rest.push(Constraint::Regex(pattern)),
Constraint::BuiltinPredicate(name) => rest.push(Constraint::BuiltinPredicate(name)),
Constraint::Regex(pattern) => rest.push(ConstraintEntry {
constraint: Constraint::Regex(pattern),
span: entry.span,
}),
Constraint::BuiltinPredicate(name) => rest.push(ConstraintEntry {
constraint: Constraint::BuiltinPredicate(name),
span: entry.span,
}),
}
}
if matches!(primitive, Some(PrimitiveType::String | PrimitiveType::Bool))
&& (lower.is_some() || upper.is_some())
if matches!(
primitive,
Some((PrimitiveType::String | PrimitiveType::Bool, _))
) && (lower.is_some() || upper.is_some())
{
return Err(Diagnostic::new(
let mut diagnostic = Diagnostic::new(
DiagnosticKind::Conflict,
span,
"numeric comparison constraints conflict with non-numeric primitive type",
));
);
if let Some((_, primitive_span)) = primitive {
diagnostic = diagnostic.with_label(primitive_span, "non-numeric primitive constraint");
}
if let Some((_, lower_span)) = lower {
diagnostic = diagnostic.with_label(lower_span, "numeric comparison constraint");
}
if let Some((_, upper_span)) = upper {
diagnostic = diagnostic.with_label(upper_span, "numeric comparison constraint");
}
return Err(diagnostic);
}
if primitive == Some(PrimitiveType::Int)
if matches!(primitive, Some((PrimitiveType::Int, _)))
&& lower
.iter()
.chain(upper.iter())
.any(|bound| !matches!(bound.number, Number::Int(_)))
.any(|(bound, _)| !matches!(bound.number, Number::Int(_)))
{
return Err(Diagnostic::new(
let mut diagnostic = Diagnostic::new(
DiagnosticKind::Conflict,
span,
"Int comparison constraints must use integer literals",
));
);
if let Some((_, primitive_span)) = primitive {
diagnostic = diagnostic.with_label(primitive_span, "Int primitive constraint");
}
if let Some((bound, bound_span)) = lower {
if !matches!(bound.number, Number::Int(_)) {
diagnostic = diagnostic.with_label(bound_span, "non-integer comparison constraint");
}
}
if let Some((bound, bound_span)) = upper {
if !matches!(bound.number, Number::Int(_)) {
diagnostic = diagnostic.with_label(bound_span, "non-integer comparison constraint");
}
}
return Err(diagnostic);
}
ensure_bounds_non_empty(primitive, lower, upper, span)?;
let mut normalized = Vec::new();
if let Some(primitive) = primitive {
normalized.push(Constraint::Type(primitive));
if let Some((primitive, primitive_span)) = primitive {
normalized.push(ConstraintEntry {
constraint: Constraint::Type(primitive),
span: primitive_span,
});
}
if let Some(lower) = lower {
normalized.push(Constraint::Compare(
if lower.inclusive {
CompareOp::Gte
} else {
CompareOp::Gt
},
lower.number.into_literal(),
));
if let Some((lower, lower_span)) = lower {
normalized.push(ConstraintEntry {
constraint: Constraint::Compare(
if lower.inclusive {
CompareOp::Gte
} else {
CompareOp::Gt
},
lower.number.into_literal(),
),
span: lower_span,
});
}
if let Some(upper) = upper {
normalized.push(Constraint::Compare(
if upper.inclusive {
CompareOp::Lte
} else {
CompareOp::Lt
},
upper.number.into_literal(),
));
if let Some((upper, upper_span)) = upper {
normalized.push(ConstraintEntry {
constraint: Constraint::Compare(
if upper.inclusive {
CompareOp::Lte
} else {
CompareOp::Lt
},
upper.number.into_literal(),
),
span: upper_span,
});
}
normalized.extend(rest);
Ok(normalized)
@@ -147,18 +198,22 @@ impl Number {
}
}
fn merge_lower(current: &mut Option<Bound>, next: Bound) {
fn merge_lower(current: &mut Option<(Bound, Span)>, next: (Bound, Span)) {
match current {
None => *current = Some(next),
Some(current_bound) if is_stricter_lower(next, *current_bound) => *current_bound = next,
Some((current_bound, _)) if is_stricter_lower(next.0, *current_bound) => {
*current = Some(next)
}
Some(_) => {}
}
}
fn merge_upper(current: &mut Option<Bound>, next: Bound) {
fn merge_upper(current: &mut Option<(Bound, Span)>, next: (Bound, Span)) {
match current {
None => *current = Some(next),
Some(current_bound) if is_stricter_upper(next, *current_bound) => *current_bound = next,
Some((current_bound, _)) if is_stricter_upper(next.0, *current_bound) => {
*current = Some(next)
}
Some(_) => {}
}
}
@@ -178,28 +233,32 @@ fn is_stricter_upper(next: Bound, current: Bound) -> bool {
}
fn ensure_bounds_non_empty(
primitive: Option<PrimitiveType>,
lower: Option<Bound>,
upper: Option<Bound>,
primitive: Option<(PrimitiveType, Span)>,
lower: Option<(Bound, Span)>,
upper: Option<(Bound, Span)>,
span: Span,
) -> crate::Result<()> {
if primitive == Some(PrimitiveType::Int) {
let min = lower.map(int_lower_bound).unwrap_or(i128::from(i64::MIN));
let max = upper.map(int_upper_bound).unwrap_or(i128::from(i64::MAX));
if matches!(primitive, Some((PrimitiveType::Int, _))) {
let min = lower
.map(|(bound, _)| int_lower_bound(bound))
.unwrap_or(i128::from(i64::MIN));
let max = upper
.map(|(bound, _)| int_upper_bound(bound))
.unwrap_or(i128::from(i64::MAX));
if min > max {
return Err(empty_numeric_bounds(span));
return Err(empty_numeric_bounds(span, lower, upper));
}
return Ok(());
}
if let (Some(lower), Some(upper)) = (lower, upper) {
let lower_value = lower.number.as_f64();
let upper_value = upper.number.as_f64();
if let (Some((lower_bound, _)), Some((upper_bound, _))) = (lower, upper) {
let lower_value = lower_bound.number.as_f64();
let upper_value = upper_bound.number.as_f64();
if lower_value > upper_value {
return Err(empty_numeric_bounds(span));
return Err(empty_numeric_bounds(span, lower, upper));
}
if lower_value == upper_value && !(lower.inclusive && upper.inclusive) {
return Err(empty_numeric_bounds(span));
if lower_value == upper_value && !(lower_bound.inclusive && upper_bound.inclusive) {
return Err(empty_numeric_bounds(span, lower, upper));
}
}
Ok(())
@@ -227,12 +286,23 @@ fn int_upper_bound(bound: Bound) -> i128 {
}
}
fn empty_numeric_bounds(span: Span) -> Diagnostic {
Diagnostic::new(
fn empty_numeric_bounds(
span: Span,
lower: Option<(Bound, Span)>,
upper: Option<(Bound, Span)>,
) -> Diagnostic {
let mut diagnostic = Diagnostic::new(
DiagnosticKind::Conflict,
span,
String::from("numeric comparison constraints have an empty intersection"),
)
);
if let Some((_, lower_span)) = lower {
diagnostic = diagnostic.with_label(lower_span, "lower bound constraint");
}
if let Some((_, upper_span)) = upper {
diagnostic = diagnostic.with_label(upper_span, "upper bound constraint");
}
diagnostic
}
#[cfg(test)]
@@ -240,13 +310,20 @@ mod tests {
use super::*;
use crate::runtime::LiteralValue;
fn entry(constraint: Constraint) -> ConstraintEntry {
ConstraintEntry {
constraint,
span: Span::default(),
}
}
#[test]
fn detects_primitive_conflict() {
assert!(
normalize_constraints(
alloc::vec![
Constraint::Type(PrimitiveType::Int),
Constraint::Type(PrimitiveType::String),
entry(Constraint::Type(PrimitiveType::Int)),
entry(Constraint::Type(PrimitiveType::String)),
],
Span::default(),
)
@@ -259,9 +336,9 @@ mod tests {
assert!(
normalize_constraints(
alloc::vec![
Constraint::Type(PrimitiveType::Int),
Constraint::Compare(CompareOp::Gt, LiteralValue::Int(10)),
Constraint::Compare(CompareOp::Lt, LiteralValue::Int(11)),
entry(Constraint::Type(PrimitiveType::Int)),
entry(Constraint::Compare(CompareOp::Gt, LiteralValue::Int(10))),
entry(Constraint::Compare(CompareOp::Lt, LiteralValue::Int(5))),
],
Span::default(),
)
@@ -270,15 +347,16 @@ mod tests {
}
#[test]
fn keeps_regex_constraints_without_intersection_check() {
let constraints = normalize_constraints(
alloc::vec![
Constraint::Regex(String::from("^a$")),
Constraint::Regex(String::from("^b$")),
],
Span::default(),
)
.unwrap();
assert_eq!(constraints.len(), 2);
fn detects_non_integer_int_bound() {
assert!(
normalize_constraints(
alloc::vec![
entry(Constraint::Type(PrimitiveType::Int)),
entry(Constraint::Compare(CompareOp::Gt, LiteralValue::Float(1.5))),
],
Span::default(),
)
.is_err()
);
}
}
+17 -1
View File
@@ -1,4 +1,4 @@
use alloc::string::String;
use alloc::{string::String, vec::Vec};
use crate::span::Span;
@@ -9,6 +9,13 @@ pub struct Diagnostic {
pub kind: DiagnosticKind,
pub span: Span,
pub message: String,
pub labels: Vec<DiagnosticLabel>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnosticLabel {
pub span: Span,
pub message: String,
}
impl Diagnostic {
@@ -17,12 +24,21 @@ impl Diagnostic {
kind,
span,
message: message.into(),
labels: Vec::new(),
}
}
pub fn syntax(span: Span, message: impl Into<String>) -> Self {
Self::new(DiagnosticKind::Syntax, span, message)
}
pub fn with_label(mut self, span: Span, message: impl Into<String>) -> Self {
self.labels.push(DiagnosticLabel {
span,
message: message.into(),
});
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
File diff suppressed because it is too large Load Diff
+77 -3
View File
@@ -34,8 +34,18 @@ pub enum TokenKind {
Dot,
Colon,
Equal,
EqualEqual,
Bang,
BangEqual,
Arrow,
Amp,
AmpAmp,
PipePipe,
Plus,
PlusPlus,
Minus,
Star,
Slash,
SlashSlash,
Gt,
Gte,
@@ -67,9 +77,13 @@ impl<'a> Lexer<'a> {
pub fn tokenize(mut self) -> Result<Vec<Token>> {
let mut tokens = Vec::new();
let mut previous = None;
loop {
let token = self.next_token()?;
let token = self.next_token(previous.as_ref())?;
let is_eof = token.kind == TokenKind::Eof;
if !is_eof {
previous = Some(token.kind.clone());
}
tokens.push(token);
if is_eof {
return Ok(tokens);
@@ -77,7 +91,7 @@ impl<'a> Lexer<'a> {
}
}
fn next_token(&mut self) -> Result<Token> {
fn next_token(&mut self, previous: Option<&TokenKind>) -> Result<Token> {
self.skip_ws_and_comments();
let start = self.pos;
let Some(ch) = self.peek() else {
@@ -134,16 +148,57 @@ impl<'a> Lexer<'a> {
}
b'&' => {
self.pos += 1;
TokenKind::Amp
if self.consume(b'&') {
TokenKind::AmpAmp
} else {
TokenKind::Amp
}
}
b'|' => {
self.pos += 1;
if self.consume(b'|') {
TokenKind::PipePipe
} else {
return Err(Diagnostic::syntax(
self.span(start, self.pos),
"expected '|' after '|'",
));
}
}
b'+' => {
self.pos += 1;
if self.consume(b'+') {
TokenKind::PlusPlus
} else {
TokenKind::Plus
}
}
b'-' => {
self.pos += 1;
TokenKind::Minus
}
b'*' => {
self.pos += 1;
TokenKind::Star
}
b'=' => {
self.pos += 1;
if self.consume(b'>') {
TokenKind::Arrow
} else if self.consume(b'=') {
TokenKind::EqualEqual
} else {
TokenKind::Equal
}
}
b'!' => {
self.pos += 1;
if self.consume(b'=') {
TokenKind::BangEqual
} else {
TokenKind::Bang
}
}
b'>' => {
self.pos += 1;
if self.consume(b'=') {
@@ -164,6 +219,8 @@ impl<'a> Lexer<'a> {
self.pos += 1;
if self.consume(b'/') {
TokenKind::SlashSlash
} else if previous.is_some_and(token_can_end_expr) {
TokenKind::Slash
} else {
self.lex_regex(start)?
}
@@ -341,6 +398,23 @@ fn is_ident_continue(c: u8) -> bool {
c.is_ascii_alphanumeric() || c == b'_'
}
fn token_can_end_expr(kind: &TokenKind) -> bool {
matches!(
kind,
TokenKind::Ident(_)
| TokenKind::Int(_)
| TokenKind::Float(_)
| TokenKind::String(_)
| TokenKind::Regex(_)
| TokenKind::True
| TokenKind::False
| TokenKind::Underscore
| TokenKind::RBrace
| TokenKind::RBracket
| TokenKind::RParen
)
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -17,7 +17,7 @@ pub use ast::{Ast, BinaryOp, CompareOp, Expr, ExprId, Field, Literal, Param};
pub use constraints::normalize_constraints;
pub use diagnostic::{Diagnostic, DiagnosticKind, Result};
pub use embedding::{HostField, HostValue};
pub use eval::Engine;
pub use eval::{Engine, format_diagnostic_with};
pub use lexer::{Lexer, Token, TokenKind};
pub use module::{EmptyLoader, LoadedSource, Module, SourceLoader};
pub use parser::{ParseOutput, Parser, SourceForm, parse_source, parse_source_with_source_id};
+154 -2
View File
@@ -2,7 +2,7 @@ use alloc::{string::String, vec::Vec};
use crate::{
SourceId, Span,
ast::{Ast, BinaryOp, CompareOp, Expr, ExprId, Field, Literal, MatchArm, Param},
ast::{Ast, BinaryOp, CompareOp, Expr, ExprId, Field, Literal, MatchArm, Param, UnaryOp},
diagnostic::{Diagnostic, Result},
lexer::{Lexer, Token, TokenKind},
};
@@ -115,6 +115,110 @@ impl Parser {
let rhs = self.parse_expr(r_bp)?;
let span = self.ast.span(lhs).join(self.ast.span(rhs)).join(op_span);
lhs = match kind {
InfixKind::Add => self.ast.push(
Expr::Binary {
op: BinaryOp::Add,
lhs,
rhs,
},
span,
),
InfixKind::Sub => self.ast.push(
Expr::Binary {
op: BinaryOp::Sub,
lhs,
rhs,
},
span,
),
InfixKind::Mul => self.ast.push(
Expr::Binary {
op: BinaryOp::Mul,
lhs,
rhs,
},
span,
),
InfixKind::Div => self.ast.push(
Expr::Binary {
op: BinaryOp::Div,
lhs,
rhs,
},
span,
),
InfixKind::Concat => self.ast.push(
Expr::Binary {
op: BinaryOp::Concat,
lhs,
rhs,
},
span,
),
InfixKind::Equal => self.ast.push(
Expr::Binary {
op: BinaryOp::Equal,
lhs,
rhs,
},
span,
),
InfixKind::NotEqual => self.ast.push(
Expr::Binary {
op: BinaryOp::NotEqual,
lhs,
rhs,
},
span,
),
InfixKind::Greater => self.ast.push(
Expr::Binary {
op: BinaryOp::Greater,
lhs,
rhs,
},
span,
),
InfixKind::GreaterEqual => self.ast.push(
Expr::Binary {
op: BinaryOp::GreaterEqual,
lhs,
rhs,
},
span,
),
InfixKind::Less => self.ast.push(
Expr::Binary {
op: BinaryOp::Less,
lhs,
rhs,
},
span,
),
InfixKind::LessEqual => self.ast.push(
Expr::Binary {
op: BinaryOp::LessEqual,
lhs,
rhs,
},
span,
),
InfixKind::LogicalAnd => self.ast.push(
Expr::Binary {
op: BinaryOp::LogicalAnd,
lhs,
rhs,
},
span,
),
InfixKind::LogicalOr => self.ast.push(
Expr::Binary {
op: BinaryOp::LogicalOr,
lhs,
rhs,
},
span,
),
InfixKind::And => self.ast.push(
Expr::Binary {
op: BinaryOp::And,
@@ -173,6 +277,28 @@ impl Parser {
TokenKind::Let => self.parse_let(token.span),
TokenKind::Match => self.parse_match(token.span),
TokenKind::Import => self.parse_import(token.span),
TokenKind::Minus => {
let expr = self.parse_expr(17)?;
let span = token.span.join(self.ast.span(expr));
Ok(self.ast.push(
Expr::Unary {
op: UnaryOp::Neg,
expr,
},
span,
))
}
TokenKind::Bang => {
let expr = self.parse_expr(17)?;
let span = token.span.join(self.ast.span(expr));
Ok(self.ast.push(
Expr::Unary {
op: UnaryOp::Not,
expr,
},
span,
))
}
TokenKind::Gt | TokenKind::Gte | TokenKind::Lt | TokenKind::Lte => {
let op = match token.kind {
TokenKind::Gt => CompareOp::Gt,
@@ -181,7 +307,7 @@ impl Parser {
TokenKind::Lte => CompareOp::Lte,
_ => unreachable!(),
};
let value = self.parse_expr(8)?;
let value = self.parse_expr(12)?;
let span = token.span.join(self.ast.span(value));
Ok(self.ast.push(Expr::CompareConstraint { op, value }, span))
}
@@ -406,6 +532,19 @@ impl Parser {
TokenKind::Default => Some((InfixKind::Default, 1, 2)),
TokenKind::SlashSlash => Some((InfixKind::Patch, 3, 4)),
TokenKind::Amp => Some((InfixKind::And, 5, 6)),
TokenKind::PipePipe => Some((InfixKind::LogicalOr, 7, 8)),
TokenKind::AmpAmp => Some((InfixKind::LogicalAnd, 9, 10)),
TokenKind::EqualEqual => Some((InfixKind::Equal, 11, 12)),
TokenKind::BangEqual => Some((InfixKind::NotEqual, 11, 12)),
TokenKind::Gt => Some((InfixKind::Greater, 11, 12)),
TokenKind::Gte => Some((InfixKind::GreaterEqual, 11, 12)),
TokenKind::Lt => Some((InfixKind::Less, 11, 12)),
TokenKind::Lte => Some((InfixKind::LessEqual, 11, 12)),
TokenKind::PlusPlus => Some((InfixKind::Concat, 12, 13)),
TokenKind::Plus => Some((InfixKind::Add, 13, 14)),
TokenKind::Minus => Some((InfixKind::Sub, 13, 14)),
TokenKind::Star => Some((InfixKind::Mul, 15, 16)),
TokenKind::Slash => Some((InfixKind::Div, 15, 16)),
_ => None,
}
}
@@ -498,6 +637,19 @@ impl Parser {
#[derive(Debug, Clone, Copy)]
enum InfixKind {
Add,
Sub,
Mul,
Div,
Concat,
Equal,
NotEqual,
Greater,
GreaterEqual,
Less,
LessEqual,
LogicalAnd,
LogicalOr,
And,
Patch,
Default,
+10 -2
View File
@@ -1,6 +1,6 @@
use alloc::{string::String, vec::Vec};
use crate::{ExprId, ast::CompareOp};
use crate::{ExprId, Span, ast::CompareOp};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ModuleId(pub u32);
@@ -37,6 +37,7 @@ pub struct ObjectValue {
pub struct ObjectField {
pub name: String,
pub value: ThunkId,
pub span: Span,
}
#[derive(Debug, Clone, PartialEq)]
@@ -54,10 +55,16 @@ pub struct FunctionParam {
#[derive(Debug, Clone, PartialEq)]
pub struct AbstractValue {
pub constraints: Vec<Constraint>,
pub constraints: Vec<ConstraintEntry>,
pub default: Option<ThunkId>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConstraintEntry {
pub constraint: Constraint,
pub span: Span,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Constraint {
Type(PrimitiveType),
@@ -108,6 +115,7 @@ pub struct EnvId(pub u32);
pub struct Thunk {
pub kind: ThunkKind,
pub state: ThunkState,
pub span: Span,
}
#[derive(Debug, Clone)]
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "decodal-wasm"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme.workspace = true
description = "WebAssembly wrapper for evaluating Decodal in browser playgrounds."
keywords = ["decodal", "wasm", "dsl", "config"]
categories = ["wasm", "config"]
publish = false
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
decodal = { version = "0.1.0", path = "../decodal-core" }
serde_json.workspace = true
wasm-bindgen.workspace = true
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
+256
View File
@@ -0,0 +1,256 @@
use std::collections::BTreeMap;
use decodal::{
Data, Diagnostic, DiagnosticKind, EmptyLoader, Engine, LoadedSource, SourceId, SourceLoader,
Span, format_diagnostic_with,
};
use wasm_bindgen::prelude::*;
#[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))
}
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)),
}
}
fn evaluate_inner(source: &str) -> Result<String, String> {
let mut engine = Engine::new(EmptyLoader);
let module = match engine.add_root_source("playground", "playground", source) {
Ok(module) => module,
Err(error) => return Err(format_diagnostic_with_root(&error, "playground")),
};
let value = match engine.eval_module(module) {
Ok(value) => value,
Err(error) => return Err(engine.format_diagnostic(&error)),
};
let data = match engine.materialize(&value) {
Ok(data) => data,
Err(error) => return Err(engine.format_diagnostic(&error)),
};
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) {
Ok(module) => module,
Err(error) => return Err(format_diagnostic_with_root(&error, &entry)),
};
let value = match engine.eval_module(module) {
Ok(value) => value,
Err(error) => return Err(engine.format_diagnostic(&error)),
};
let data = match engine.materialize(&value) {
Ok(data) => data,
Err(error) => return Err(engine.format_diagnostic(&error)),
};
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,
})
}
}
fn resolve_import(current_key: Option<&str>, specifier: &str) -> Option<String> {
if specifier.starts_with('/') {
return normalize_path(specifier);
}
let mut base = String::new();
if let Some(current_key) = current_key {
if let Some((parent, _file)) = current_key.rsplit_once('/') {
base.push_str(parent);
base.push('/');
}
}
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("/"))
}
}
fn format_diagnostic_with_root(diagnostic: &decodal::Diagnostic, root_name: &str) -> String {
format_diagnostic_with(diagnostic, |source| {
(source == SourceId(0)).then_some(root_name)
})
}
fn format_data(data: &Data, indent: usize) -> String {
match data {
Data::String(value) => json_string(value),
Data::Int(value) => value.to_string(),
Data::Float(value) => value.to_string(),
Data::Bool(value) => value.to_string(),
Data::Array(items) => {
if items.is_empty() {
return String::from("[]");
}
let mut out = String::from("[\n");
for (index, item) in items.iter().enumerate() {
out.push_str(&" ".repeat(indent + 2));
out.push_str(&format_data(item, indent + 2));
if index + 1 != items.len() {
out.push(',');
}
out.push('\n');
}
out.push_str(&" ".repeat(indent));
out.push(']');
out
}
Data::Object(fields) => {
if fields.is_empty() {
return String::from("{}");
}
let mut out = String::from("{\n");
for (index, field) in fields.iter().enumerate() {
out.push_str(&" ".repeat(indent + 2));
out.push_str(&json_string(&field.name));
out.push_str(": ");
out.push_str(&format_data(&field.value, indent + 2));
if index + 1 != fields.len() {
out.push(',');
}
out.push('\n');
}
out.push_str(&" ".repeat(indent));
out.push('}');
out
}
}
}
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
}
#[cfg(test)]
mod tests {
use super::{evaluate_project_inner, normalize_path, resolve_import};
#[test]
fn normalizes_virtual_paths() {
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())
);
}
#[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:"));
}
}
+2
View File
@@ -4,11 +4,13 @@ pkgs.mkShell {
cargo
clippy
git
lld
nodejs
nixfmt
rustc
rustfmt
tree-sitter
wasm-pack
];
shellHook = ''
@@ -117,3 +117,9 @@ Abstract { constraints, default: None }
materialize は default を採用する唯一の段階である。
通常評価中に明示値が得られた場合、default は採用されない。
## Diagnostic context
Composition and materialization keep source spans for object fields, constraints, defaults, and thunks where possible.
When a conflict occurs, the diagnostic should identify the operation span and related participant spans, such as the left field, right field, constraint, or default value.
For object materialization errors, diagnostics also include the field path being processed when known.
@@ -10,10 +10,20 @@ Diagnostic {
kind: DiagnosticKind
span: Span
message: String
notes: Vec<Note>
labels: Vec<DiagnosticLabel>
}
DiagnosticLabel {
span: Span
message: String
}
```
`span` は primary location を示す。
表示時には `Span.source` を source id のまま出すのではなく、可能な限り file path や virtual file name に解決する。
`labels` は同じ error に関係する追加 location を示す。
合成や materialize の失敗では、衝突した constraint、value、default、または処理中 field path を label に含める。
代表的な diagnostic kind:
- syntax error
@@ -38,6 +48,18 @@ Result<RuntimeValue, Diagnostic>
これにより、制約違反、未定義識別子、循環依存、import 失敗などが通常値として流れることを避ける。
## 合成と materialize の diagnostic
合成や materialize の失敗は、以下を示す。
1. どの段階で失敗したか: composition、patch、materialization
2. どの field path を処理中だったか
3. どの constraint、value、default が衝突したか
4. なぜ合成または materialize できないか
例えば default が constraint を満たさない場合は、constraint の位置と default value の位置の両方を label として持つ。
object field の合成で concrete value が衝突する場合は、左辺 field と右辺 field の位置を label として持つ。
## `try / catch` は core に入れない
汎用 `try / catch` は core に入れない。
@@ -47,21 +69,6 @@ fallback は有限で明示的な仕組みに限定する。
- `default`: 未指定値の fallback。
- `match`: 有限 pattern に基づく分岐。
- optional import: ファイル不存在など、限定された失敗だけを fallback 可能にする候補。
- optional field access: field 不在だけを fallback 可能にする候補。
- union / tagged schema: 複数 schema の選択を明示的に表す将来候補。
## optional fallback の扱い
optional import や optional field access を導入する場合も、捕捉できる失敗は限定する。
例として optional import は、ファイル不存在だけを fallback 可能にし、parse error や import 先の制約違反は diagnostic として報告する方がよい。
```text
optional import:
file not found -> fallback
parse error -> diagnostic
eval error -> diagnostic
```
この方針により、fallback は通常の値選択として扱い、エラー内容に依存した実行時分岐は避ける。
Decodal は `unknown` / `any` を持たないため、field 不在や未解決 identifier を fallback 可能な通常値として扱わない。
それらは diagnostic として報告する。
+1 -1
View File
@@ -24,7 +24,7 @@ Primitive type names such as `String`, `Int`, `Float`, and `Bool` are handled be
The host can bind values before adding or evaluating user sources.
```rust
use decodal_core::{EmptyLoader, Engine, HostValue};
use decodal::{EmptyLoader, Engine, HostValue};
let mut engine = Engine::new(EmptyLoader);
+38 -5
View File
@@ -1,10 +1,43 @@
# Features
Decodal keeps the embedded core small by making heavy functionality opt-in.
Decodal embedded use と小さい runtime を優先する。
言語機能を追加するときは、値の合成・検証・materialization に直接必要なものを core に残し、重い依存や高度な推論は optional feature または外部 tooling に分ける。
## Core feature boundary
Core に入れる機能は、基本的に deterministic な value transformation に限る。
- arithmetic / logical / comparison operators
- array concat
- object / constraint composition
- default materialization
- pure function evaluation
- host supplied import evaluation
Core に入れないものは以下である。
- filesystem / network / environment access
- time / random
- mutation
- reflection or existence probing
- arbitrary host function calls
- symbolic constraint solving beyond simple normalization
未解決 identifier や missing field は `unknown` として流れず、diagnostic になる。
この方針により、存在チェックや optional chaining のような dynamic object inspection は core language の対象外とする。
## Constraint reasoning
Constraint normalization は軽量な範囲に留める。
primitive type conflict や明らかな numeric bound conflict は合成時に検出してよい。
一方で、symbolic arithmetic、boolean algebra、regex intersection、array length dependent typing のような重い推論は行わない。
評価済みの concrete value に対する検証は runtime / materialization で行う。
静的に完全な型検査フェーズを増やすのではなく、parse、evaluate、compose、materialize の各段階で自然に分かる error を diagnostic として返す。
## Core defaults
`decodal-core` defaults to `std` only.
`decodal` defaults to `std` only.
```toml
[features]
@@ -13,7 +46,7 @@ std = []
regex = ["std", "dep:regex"]
```
Building `decodal-core` with `--no-default-features` keeps the core in `no_std + alloc` mode and avoids optional dependencies.
Building `decodal` with `--no-default-features` keeps the core in `no_std + alloc` mode and avoids optional dependencies.
## Regex
@@ -21,7 +54,7 @@ Regex constraints are implemented behind the `regex` feature.
When the feature is disabled, regex constraints parse and compose, but validating a concrete value against them returns an unsupported feature diagnostic.
```sh
cargo run -q -p decodal --features regex -- examples/regex/main.dcdl
cargo run -q -p decodal-cli --features regex -- examples/regex/main.dcdl
```
Regex constraints are accumulated during `&` composition.
@@ -30,5 +63,5 @@ Concrete strings must match every regex constraint attached to the abstract valu
## CLI features
`decodal-cli` exposes a matching `regex` feature that enables `decodal-core/regex`.
`decodal-cli` exposes a matching `regex` feature that enables `decodal/regex`.
The feature is not enabled by default so the default CLI binary remains small.
+30 -2
View File
@@ -14,6 +14,10 @@ RuntimeValue =
`Concrete` は明示的な値である。
`Abstract` は、まだ具体値に確定していない制約付きの値である。
Decodal は `unknown``any``null` のような「存在するが意味が未確定な値」を runtime value として持たない。
識別子や field が解決できない場合は、その場で diagnostic になる。
未解決値を後続の演算へ流して推論することはしない。
## ConcreteValue
```text
@@ -32,7 +36,11 @@ object は concrete structure として扱う。
```text
ObjectValue:
fields: Map<Symbol, ThunkId>
fields: Map<Symbol, ObjectField>
ObjectField:
value: ThunkId
span: Span
```
例えば以下の schema object は、object 自体は concrete だが、field の値は abstract value になる。
@@ -57,9 +65,14 @@ Concrete(Object {
```text
AbstractValue {
constraints: Vec<Constraint>
constraints: Vec<ConstraintEntry>
default: Option<ThunkId>
}
ConstraintEntry {
constraint: Constraint
span: Span
}
```
`default``AbstractValue` にだけ存在する。
@@ -88,6 +101,21 @@ port = 8000;
Concrete(Int(8000))
```
## Runtime scope
Decodal runtime は application runtime ではなく、pure value evaluator である。
同じ source、同じ import sources、同じ host globals が与えられた場合、評価結果は決定的である。
runtime が扱う責務は以下に限る。
- expression を評価する。
- thunk を必要に応じて force する。
- concrete / abstract value を合成する。
- materialize 時に constraint を検証する。
runtime は filesystem、network、environment variable、time、random、mutation を扱わない。
core における import は host supplied source を受け取る境界であり、filesystem access ではない。
## Constraint
constraint は concrete value とは別の型として扱う。
+91 -4
View File
@@ -9,15 +9,100 @@ Run the normal Rust checks from the repository root.
```sh
cargo fmt --check
cargo test
cargo check -p decodal-core --no-default-features
cargo check -p decodal --no-default-features
nix flake check
```
Regex support is optional and should be tested explicitly when touched.
```sh
cargo test -p decodal-core --features regex
cargo run -q -p decodal --features regex -- examples/regex/main.dcdl
cargo test -p decodal --features regex
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.
Workspace support crates such as `decodal-cli` and `decodal-wasm` are not published for 0.1.0.
The project is dual licensed as `MIT OR Apache-2.0`.
```sh
cargo publish -p decodal
```
Before publishing, run:
```sh
cargo fmt --check
cargo test
cargo check -p decodal --no-default-features
cargo publish -p decodal --dry-run
```
## Web site and playground
The Astro documentation site and browser playground are kept in:
```text
site/decodal-site/
```
The site imports Markdown files from `doc/manual/souce/` and renders them as mdBook-style pages.
The playground loads `decodal-wasm` and evaluates DCDL entirely in the browser.
Important files:
```text
site/decodal-site/src/pages/docs/[...slug].astro
site/decodal-site/src/pages/playground.astro
site/decodal-site/src/layouts/ManualLayout.astro
site/decodal-site/src/lib/docs.js
crates/decodal-wasm/src/lib.rs
```
Build the WebAssembly package before building the site:
```sh
cd site/decodal-site
npm install
npm run build:wasm
npm run build
```
`npm run build:wasm` writes generated files into:
```text
site/decodal-site/src/wasm/
```
These generated files are committed so the site can be built without requiring every consumer to regenerate the wasm package first.
To run the site locally:
```sh
cd site/decodal-site
npm run dev
```
Deploy the static site to Cloudflare Pages with Wrangler direct upload:
```sh
cd site/decodal-site
npm run deploy
```
The deploy script runs `npm run build` and then uploads `dist/` to the Pages project named `decodal-site` on branch `master`.
Cloudflare Pages treats this as production when the project production branch is `master`.
Use `CLOUDFLARE_PROJECT_NAME` when deploying to a differently named Pages project.
```sh
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
```
## Tree-sitter grammar
@@ -73,7 +158,7 @@ When the Decodal syntax changes:
## Development shell
The Nix development shell includes Rust tooling, Node.js, and Tree-sitter CLI tooling.
The Nix development shell includes Rust tooling, Node.js, Tree-sitter CLI tooling, and wasm-pack tooling.
```sh
nix develop
@@ -87,5 +172,7 @@ The shell provides:
- `clippy`
- `node`
- `npm`
- `wasm-pack`
- `lld`
- `tree-sitter`
- `nixfmt`
@@ -0,0 +1,43 @@
# Arithmetic Expression
Decodal supports arithmetic over concrete numeric values.
```dcdl
{
workers = 2 + 2;
timeout = 30.0 / 2;
port = 8000 + 80;
negative = -1;
}
```
## Operators
- `+` addition
- `-` subtraction
- `*` multiplication
- `/` division
- unary `-` negation
`*` and `/` bind tighter than `+` and `-`.
Parentheses can be used to make grouping explicit.
```dcdl
2 + 3 * 4 # 14
(2 + 3) * 4 # 20
```
## Numeric behavior
Arithmetic requires concrete `Int` or `Float` operands.
`Int + Int`, `Int - Int`, and `Int * Int` produce `Int` when no overflow occurs.
Mixed `Int` / `Float` arithmetic produces `Float`.
Division always produces `Float`.
Division by zero and integer overflow are evaluation errors.
Arithmetic expressions can be used anywhere a concrete numeric expression is expected, including defaults and numeric constraints.
```dcdl
port = Int & > 4000 + 42 default 8080;
```
+17 -5
View File
@@ -7,9 +7,21 @@ array expression は、順序付きの値の列を表す。
["a", "b", "c"]
```
## 未確定事項
## Array concat
- 配列要素の制約表現
- 異種配列を許可するか。
- `//` による patch を右辺置換だけにするか。
- append / prepend / remove などの操作を提供するか。
`++` は concrete array 同士を連結する
```dcdl
base = ["read", "write"];
extra = ["admin"];
roles = base ++ extra;
```
`roles` は以下と同じ値になる。
```dcdl
["read", "write", "admin"]
```
`++` は配列要素を変換しない。
左辺の要素の後に右辺の要素が並ぶ。
@@ -0,0 +1,41 @@
# Logical and Comparison Expressions
Decodal supports boolean logic over concrete `Bool` values and comparison over concrete scalar values.
```dcdl
{
is_prod = env == "prod";
high_port = port > 9000;
enabled = is_prod && high_port;
disabled = !enabled;
}
```
## Logical operators
- `!expr` negates a concrete `Bool`.
- `lhs && rhs` returns boolean AND.
- `lhs || rhs` returns boolean OR.
`&&` and `||` short-circuit: the right-hand side is evaluated only when needed.
Logical operands must evaluate to concrete `Bool` values.
## Comparison operators
- `==`
- `!=`
- `<`
- `<=`
- `>`
- `>=`
`==` and `!=` compare concrete scalar values: `String`, `Bool`, `Int`, and `Float`.
`Int` and `Float` can be compared to each other numerically.
Ordering operators `<`, `<=`, `>`, and `>=` compare concrete numeric values only.
They are separate from prefix comparison constraints such as `> 443`.
```dcdl
port = Int & > 443 default 9443;
is_high = port > 9000;
```
+75 -2
View File
@@ -1,6 +1,79 @@
# 合成演算子
# 演算子
この章では、`&``//` の意味を定義する。
この章では、Decodal の演算子の意味を定義する。
## 演算子一覧
| 演算子 | 形 | 種類 | 対象 | 結果 / 意味 |
|---|---|---|---|---|
| `.` | `object.field` | field reference | object / abstract object | field value |
| call | `fn(arg)` | function call | function | function result |
| `!` | `!expr` | unary logical | concrete `Bool` | concrete `Bool` |
| `-` | `-expr` | unary arithmetic | concrete `Int` / `Float` | negated number |
| `*` | `lhs * rhs` | arithmetic | concrete `Int` / `Float` | numeric product |
| `/` | `lhs / rhs` | arithmetic | concrete `Int` / `Float` | `Float` quotient |
| `+` | `lhs + rhs` | arithmetic | concrete `Int` / `Float` | numeric sum |
| `-` | `lhs - rhs` | arithmetic | concrete `Int` / `Float` | numeric difference |
| `++` | `lhs ++ rhs` | array concat | concrete arrays | concatenated array |
| `==` | `lhs == rhs` | equality | concrete scalar | concrete `Bool` |
| `!=` | `lhs != rhs` | equality | concrete scalar | concrete `Bool` |
| `<` | `lhs < rhs` | ordering | concrete `Int` / `Float` | concrete `Bool` |
| `<=` | `lhs <= rhs` | ordering | concrete `Int` / `Float` | concrete `Bool` |
| `>` | `lhs > rhs` | ordering | concrete `Int` / `Float` | concrete `Bool` |
| `>=` | `lhs >= rhs` | ordering | concrete `Int` / `Float` | concrete `Bool` |
| `>` | `> value` | comparison constraint | numeric constraint value | abstract constraint |
| `>=` | `>= value` | comparison constraint | numeric constraint value | abstract constraint |
| `<` | `< value` | comparison constraint | numeric constraint value | abstract constraint |
| `<=` | `<= value` | comparison constraint | numeric constraint value | abstract constraint |
| `&&` | `lhs && rhs` | logical | concrete `Bool` | short-circuit AND |
| `||` | `lhs || rhs` | logical | concrete `Bool` | short-circuit OR |
| `&` | `lhs & rhs` | composition | value / constraint / object | constraint-preserving composition |
| `//` | `lhs // rhs` | patch | object / value | right-biased structural patch |
| `default` | `base default fallback` | default | abstract value | materialization fallback |
`concrete scalar``String``Bool``Int``Float` を指す。
## 優先順位
優先順位は高い順に以下である。
1. 関数呼び出しとフィールド参照
2. unary `!` `-`
3. `*` `/`
4. `+` `-`
5. `++`
6. `==` `!=` `<` `<=` `>` `>=`
7. `&&`
8. `||`
9. `&`
10. `//`
11. `default`
同じ優先順位の二項演算子は左結合である。
`default` は右結合である。
## Arithmetic operators
`+` `-` `*` `/` は具体的な `Int` / `Float` に対する四則演算である。
詳しくは [Arithmetic Expression](./expression/arithmetic.md) を参照する。
## Array concat operator
`++` は concrete array 同士を連結する演算子である。
要素は変換されず、左辺の要素の後に右辺の要素が並ぶ。
```dcdl
["read", "write"] ++ ["admin"]
```
## Logical and comparison operators
`!` `&&` `||` は concrete `Bool` に対する論理演算である。
`&&``||` は短絡評価される。
`==` `!=` は concrete scalar value を比較する。
`<` `<=` `>` `>=` は concrete numeric value を比較する。
詳しくは [Logical and Comparison Expressions](./expression/logical-and-comparison.md) を参照する。
## `&`: 制約合成
+10 -7
View File
@@ -115,12 +115,15 @@ rec
主要な演算子は以下である。
```text
& 制約合成
// patch 合成
default fallback 指定
=> 関数
. フィールド参照 / ドットパス定義
+ - * / 四則演算
++ 配列結合
! && || 論理演算
== != < <= > >= 比較式
& 制約合成
// patch 合成
default fallback 指定
=> 関数
. フィールド参照 / ドットパス定義
```
演算子の優先順位は未確定である。
詳細は [合成演算子](./operators.md) で定義する。
演算子の優先順位は [合成演算子](./operators.md) で定義する。
@@ -74,3 +74,87 @@ base // {
body: (literal (string)))
(match_arm
body: (literal (string))))))))
==================
Arithmetic
==================
{
value = 1 + 2 * 3;
grouped = (1 + 2) / -3;
}
---
(source_file
(object
(field_definition
path: (field_path (identifier))
value: (binary_expression
left: (literal (integer))
right: (binary_expression
left: (literal (integer))
right: (literal (integer)))))
(field_definition
path: (field_path (identifier))
value: (binary_expression
left: (parenthesized_expression
(binary_expression
left: (literal (integer))
right: (literal (integer))))
right: (unary_expression
operand: (literal (integer)))))))
==================
Logical and comparison
==================
{
enabled = env == "prod" && replicas > 1;
disabled = !enabled || false;
}
---
(source_file
(object
(field_definition
path: (field_path (identifier))
value: (binary_expression
left: (binary_expression
left: (identifier)
right: (literal (string)))
right: (binary_expression
left: (identifier)
right: (literal (integer)))))
(field_definition
path: (field_path (identifier))
value: (binary_expression
left: (unary_expression
operand: (identifier))
right: (literal (boolean))))))
==================
Array concat
==================
{
roles = ["read"] ++ ["write", "admin"];
ports = [8000 + 80] ++ [9443];
}
---
(source_file
(object
(field_definition
path: (field_path (identifier))
value: (binary_expression
left: (array
(literal (string)))
right: (array
(literal (string))
(literal (string)))))
(field_definition
path: (field_path (identifier))
value: (binary_expression
left: (array
(binary_expression
left: (literal (integer))
right: (literal (integer))))
right: (array
(literal (integer)))))))
+49 -6
View File
@@ -1,9 +1,16 @@
const PREC = {
DEFAULT: 1,
PATCH: 2,
AND: 3,
CALL: 7,
PATH: 8,
COMPOSE: 3,
OR: 4,
LOGICAL_AND: 5,
COMPARE: 6,
CONCAT: 7,
ADD: 8,
MUL: 9,
UNARY: 10,
CALL: 11,
PATH: 12,
};
function commaSep(rule) {
@@ -52,6 +59,7 @@ module.exports = grammar({
$.parenthesized_expression,
$.call_expression,
$.path_expression,
$.unary_expression,
$.binary_expression,
$.default_expression,
),
@@ -159,13 +167,48 @@ module.exports = grammar({
field('field', $.identifier),
)),
comparison_constraint: $ => prec(6, seq(
comparison_constraint: $ => prec.right(PREC.UNARY + 1, seq(
field('operator', choice('>', '>=', '<', '<=')),
field('value', choice($.integer, $.float)),
field('value', $._expression),
)),
unary_expression: $ => prec(PREC.UNARY, seq(
field('operator', choice('-', '!')),
field('operand', $._expression),
)),
binary_expression: $ => choice(
prec.left(PREC.AND, seq(
prec.left(PREC.OR, seq(
field('left', $._expression),
field('operator', token(prec(2, '||'))),
field('right', $._expression),
)),
prec.left(PREC.LOGICAL_AND, seq(
field('left', $._expression),
field('operator', token(prec(2, '&&'))),
field('right', $._expression),
)),
prec.left(PREC.COMPARE, seq(
field('left', $._expression),
field('operator', choice('==', '!=', '>', '>=', '<', '<=')),
field('right', $._expression),
)),
prec.left(PREC.CONCAT, seq(
field('left', $._expression),
field('operator', token(prec(2, '++'))),
field('right', $._expression),
)),
prec.left(PREC.ADD, seq(
field('left', $._expression),
field('operator', choice('+', '-')),
field('right', $._expression),
)),
prec.left(PREC.MUL, seq(
field('left', $._expression),
field('operator', choice('*', '/')),
field('right', $._expression),
)),
prec.left(PREC.COMPOSE, seq(
field('left', $._expression),
field('operator', '&'),
field('right', $._expression),
@@ -17,6 +17,16 @@
[
"&"
"//"
"+"
"-"
"*"
"/"
"++"
"&&"
"||"
"!"
"=="
"!="
"=>"
"="
">"
+299 -8
View File
@@ -127,6 +127,10 @@
"type": "SYMBOL",
"name": "path_expression"
},
{
"type": "SYMBOL",
"name": "unary_expression"
},
{
"type": "SYMBOL",
"name": "binary_expression"
@@ -712,7 +716,7 @@
},
"call_expression": {
"type": "PREC_LEFT",
"value": 7,
"value": 11,
"content": {
"type": "SEQ",
"members": [
@@ -782,7 +786,7 @@
},
"path_expression": {
"type": "PREC_LEFT",
"value": 8,
"value": 12,
"content": {
"type": "SEQ",
"members": [
@@ -810,8 +814,8 @@
}
},
"comparison_constraint": {
"type": "PREC",
"value": 6,
"type": "PREC_RIGHT",
"value": 11,
"content": {
"type": "SEQ",
"members": [
@@ -843,19 +847,44 @@
{
"type": "FIELD",
"name": "value",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
}
]
}
},
"unary_expression": {
"type": "PREC",
"value": 10,
"content": {
"type": "SEQ",
"members": [
{
"type": "FIELD",
"name": "operator",
"content": {
"type": "CHOICE",
"members": [
{
"type": "SYMBOL",
"name": "integer"
"type": "STRING",
"value": "-"
},
{
"type": "SYMBOL",
"name": "float"
"type": "STRING",
"value": "!"
}
]
}
},
{
"type": "FIELD",
"name": "operand",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
}
]
}
@@ -863,6 +892,268 @@
"binary_expression": {
"type": "CHOICE",
"members": [
{
"type": "PREC_LEFT",
"value": 4,
"content": {
"type": "SEQ",
"members": [
{
"type": "FIELD",
"name": "left",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
},
{
"type": "FIELD",
"name": "operator",
"content": {
"type": "TOKEN",
"content": {
"type": "PREC",
"value": 2,
"content": {
"type": "STRING",
"value": "||"
}
}
}
},
{
"type": "FIELD",
"name": "right",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
}
]
}
},
{
"type": "PREC_LEFT",
"value": 5,
"content": {
"type": "SEQ",
"members": [
{
"type": "FIELD",
"name": "left",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
},
{
"type": "FIELD",
"name": "operator",
"content": {
"type": "TOKEN",
"content": {
"type": "PREC",
"value": 2,
"content": {
"type": "STRING",
"value": "&&"
}
}
}
},
{
"type": "FIELD",
"name": "right",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
}
]
}
},
{
"type": "PREC_LEFT",
"value": 6,
"content": {
"type": "SEQ",
"members": [
{
"type": "FIELD",
"name": "left",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
},
{
"type": "FIELD",
"name": "operator",
"content": {
"type": "CHOICE",
"members": [
{
"type": "STRING",
"value": "=="
},
{
"type": "STRING",
"value": "!="
},
{
"type": "STRING",
"value": ">"
},
{
"type": "STRING",
"value": ">="
},
{
"type": "STRING",
"value": "<"
},
{
"type": "STRING",
"value": "<="
}
]
}
},
{
"type": "FIELD",
"name": "right",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
}
]
}
},
{
"type": "PREC_LEFT",
"value": 7,
"content": {
"type": "SEQ",
"members": [
{
"type": "FIELD",
"name": "left",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
},
{
"type": "FIELD",
"name": "operator",
"content": {
"type": "TOKEN",
"content": {
"type": "PREC",
"value": 2,
"content": {
"type": "STRING",
"value": "++"
}
}
}
},
{
"type": "FIELD",
"name": "right",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
}
]
}
},
{
"type": "PREC_LEFT",
"value": 8,
"content": {
"type": "SEQ",
"members": [
{
"type": "FIELD",
"name": "left",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
},
{
"type": "FIELD",
"name": "operator",
"content": {
"type": "CHOICE",
"members": [
{
"type": "STRING",
"value": "+"
},
{
"type": "STRING",
"value": "-"
}
]
}
},
{
"type": "FIELD",
"name": "right",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
}
]
}
},
{
"type": "PREC_LEFT",
"value": 9,
"content": {
"type": "SEQ",
"members": [
{
"type": "FIELD",
"name": "left",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
},
{
"type": "FIELD",
"name": "operator",
"content": {
"type": "CHOICE",
"members": [
{
"type": "STRING",
"value": "*"
},
{
"type": "STRING",
"value": "/"
}
]
}
},
{
"type": "FIELD",
"name": "right",
"content": {
"type": "SYMBOL",
"name": "_expression"
}
}
]
}
},
{
"type": "PREC_LEFT",
"value": 3,
+308 -2
View File
@@ -66,6 +66,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
}
@@ -137,6 +141,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
},
@@ -144,13 +152,65 @@
"multiple": false,
"required": true,
"types": [
{
"type": "!=",
"named": false
},
{
"type": "&",
"named": false
},
{
"type": "&&",
"named": false
},
{
"type": "*",
"named": false
},
{
"type": "+",
"named": false
},
{
"type": "++",
"named": false
},
{
"type": "-",
"named": false
},
{
"type": "/",
"named": false
},
{
"type": "//",
"named": false
},
{
"type": "<",
"named": false
},
{
"type": "<=",
"named": false
},
{
"type": "==",
"named": false
},
{
"type": ">",
"named": false
},
{
"type": ">=",
"named": false
},
{
"type": "||",
"named": false
}
]
},
@@ -217,6 +277,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
}
@@ -294,6 +358,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
}
@@ -361,6 +429,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
}
@@ -396,11 +468,67 @@
"required": true,
"types": [
{
"type": "float",
"type": "array",
"named": true
},
{
"type": "integer",
"type": "binary_expression",
"named": true
},
{
"type": "call_expression",
"named": true
},
{
"type": "comparison_constraint",
"named": true
},
{
"type": "default_expression",
"named": true
},
{
"type": "function_expression",
"named": true
},
{
"type": "identifier",
"named": true
},
{
"type": "import_expression",
"named": true
},
{
"type": "let_expression",
"named": true
},
{
"type": "literal",
"named": true
},
{
"type": "match_expression",
"named": true
},
{
"type": "object",
"named": true
},
{
"type": "parenthesized_expression",
"named": true
},
{
"type": "path_expression",
"named": true
},
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
@@ -474,6 +602,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
},
@@ -540,6 +672,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
}
@@ -622,6 +758,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
}
@@ -709,6 +849,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
}
@@ -807,6 +951,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
}
@@ -916,6 +1064,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
},
@@ -986,6 +1138,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
}
@@ -1058,6 +1214,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
}
@@ -1155,6 +1315,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
},
@@ -1237,6 +1401,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
}
@@ -1318,6 +1486,10 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
}
@@ -1394,14 +1566,120 @@
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
}
},
{
"type": "unary_expression",
"named": true,
"fields": {
"operand": {
"multiple": false,
"required": true,
"types": [
{
"type": "array",
"named": true
},
{
"type": "binary_expression",
"named": true
},
{
"type": "call_expression",
"named": true
},
{
"type": "comparison_constraint",
"named": true
},
{
"type": "default_expression",
"named": true
},
{
"type": "function_expression",
"named": true
},
{
"type": "identifier",
"named": true
},
{
"type": "import_expression",
"named": true
},
{
"type": "let_expression",
"named": true
},
{
"type": "literal",
"named": true
},
{
"type": "match_expression",
"named": true
},
{
"type": "object",
"named": true
},
{
"type": "parenthesized_expression",
"named": true
},
{
"type": "path_expression",
"named": true
},
{
"type": "regex_literal",
"named": true
},
{
"type": "unary_expression",
"named": true
}
]
},
"operator": {
"multiple": false,
"required": true,
"types": [
{
"type": "!",
"named": false
},
{
"type": "-",
"named": false
}
]
}
}
},
{
"type": "!",
"named": false
},
{
"type": "!=",
"named": false
},
{
"type": "&",
"named": false
},
{
"type": "&&",
"named": false
},
{
"type": "(",
"named": false
@@ -1410,14 +1688,34 @@
"type": ")",
"named": false
},
{
"type": "*",
"named": false
},
{
"type": "+",
"named": false
},
{
"type": "++",
"named": false
},
{
"type": ",",
"named": false
},
{
"type": "-",
"named": false
},
{
"type": ".",
"named": false
},
{
"type": "/",
"named": false
},
{
"type": "//",
"named": false
@@ -1442,6 +1740,10 @@
"type": "=",
"named": false
},
{
"type": "==",
"named": false
},
{
"type": "=>",
"named": false
@@ -1522,6 +1824,10 @@
"type": "{",
"named": false
},
{
"type": "||",
"named": false
},
{
"type": "}",
"named": false
+8262 -3020
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,7 +16,7 @@ This example exercises multiple current Decodal features together:
Run it with:
```sh
cargo run -q -p decodal -- examples/advanced/main.dcdl
cargo run -q -p decodal-cli -- examples/advanced/main.dcdl
```
The entrypoint is `main.dcdl`.
+7
View File
@@ -0,0 +1,7 @@
{
workers = 2 + 2;
memory_gib = 1.5 * 4;
port = 9000 + 443;
timeout_seconds = 30 / 2;
negative_offset = -3;
}
+6
View File
@@ -0,0 +1,6 @@
{
base_roles = ["read", "write"];
extra_roles = ["admin"];
roles = ["read", "write"] ++ ["admin"];
ports = [8000 + 80] ++ [9000 + 443];
}
+11
View File
@@ -0,0 +1,11 @@
let
env = "prod";
replicas = 3;
in
{
is_prod = env == "prod";
scaled = replicas > 1;
enabled = env == "prod" && replicas > 1;
disabled = !(env == "prod" && replicas > 1);
safe = env != "dev" || replicas >= 1;
}
+1 -1
View File
@@ -3,7 +3,7 @@
Regex constraints are optional. Run this example with the `regex` feature enabled:
```sh
cargo run -q -p decodal --features regex -- examples/regex/main.dcdl
cargo run -q -p decodal-cli --features regex -- examples/regex/main.dcdl
```
Without the feature, regex validation returns an unsupported feature diagnostic.
+1 -1
View File
@@ -37,7 +37,7 @@ rustPlatform.buildRustPackage {
cargoBuildFlags = [
"-p"
"decodal"
"decodal-cli"
];
doInstallCheck = true;
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'static',
});
+7937
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "decodal-site",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev --host 0.0.0.0",
"build": "astro build",
"preview": "astro preview --host 0.0.0.0",
"build:wasm": "wasm-pack build ../../crates/decodal-wasm --target web --out-dir ../../site/decodal-site/src/wasm --release",
"deploy": "npm run build && wrangler pages deploy dist --project-name ${CLOUDFLARE_PROJECT_NAME:-decodal-site} --branch ${CLOUDFLARE_BRANCH:-master}",
"deploy:wasm": "npm run build:wasm && npm run deploy"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"astro": "^4.16.18",
"marked": "^12.0.2"
},
"devDependencies": {
"wrangler": "^4.104.0"
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />
@@ -0,0 +1,46 @@
---
import { nav } from '../lib/docs.js';
import '../style.css';
const { title = 'Decodal', active = '', playground = false } = Astro.props;
function renderNav(items, prefix = []) {
return `<ul class="nav-tree">${items
.map((item, index) => {
const number = [...prefix, index + 1];
const href = `/docs/${item.slug}/`;
const activeClass = active === item.slug ? ' class="active"' : '';
const children = item.children ? renderNav(item.children, number) : '';
return `<li><a${activeClass} href="${href}"><span class="nav-number">${number.join('.')}.</span>${item.title}</a>${children}</li>`;
})
.join('')}</ul>`;
}
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
</head>
<body>
<header class="topbar">
<a class="brand" href="/docs/introduction/">Decodal</a>
<nav class="topnav">
<a href="/docs/introduction/">Docs</a>
<a href="/playground/">Playground</a>
</nav>
</header>
<div class={playground ? 'layout playground-layout' : 'layout'}>
{!playground && (
<aside class="sidebar">
<a class="sidebar-title" href="/docs/">Manual</a>
<nav set:html={renderNav(nav)} />
</aside>
)}
<main class={playground ? 'playground' : ''}>
<slot />
</main>
</div>
</body>
</html>
+121
View File
@@ -0,0 +1,121 @@
import { marked } from 'marked';
import { escapeAttribute, highlightCode } from './highlight.js';
const modules = import.meta.glob('../../../../doc/manual/souce/**/*.md', {
query: '?raw',
import: 'default',
eager: true,
});
export const docs = Object.fromEntries(
Object.entries(modules).map(([path, content]) => {
const slug = path
.replace(/^\.\.\/\.\.\/\.\.\/\.\.\/doc\/manual\/souce\//, '')
.replace(/\.md$/, '')
.replace(/\/index$/, '');
return [slug || 'index', content];
}),
);
export const nav = [
{ title: 'Introduction', slug: 'introduction' },
{
title: 'Language Specification',
slug: 'language',
children: [
{ title: 'Syntax', slug: 'language/syntax' },
{
title: 'Value',
slug: 'language/value',
children: [
{ title: 'String', slug: 'language/value/string' },
{ title: 'Int', slug: 'language/value/int' },
{ title: 'Float', slug: 'language/value/float' },
{ title: 'Bool', slug: 'language/value/bool' },
],
},
{
title: 'Expression',
slug: 'language/expression',
children: [
{ title: 'Literal', slug: 'language/expression/literal' },
{ title: 'Identifier', slug: 'language/expression/identifier' },
{ title: 'Path Reference', slug: 'language/expression/path-reference' },
{ title: 'Object', slug: 'language/expression/object' },
{ title: 'Array', slug: 'language/expression/array' },
{ title: 'Function', slug: 'language/expression/function' },
{ title: 'Function Call', slug: 'language/expression/function-call' },
{ title: 'Let', slug: 'language/expression/let' },
{ title: 'Match', slug: 'language/expression/match' },
{ title: 'Import', slug: 'language/expression/import' },
{ title: 'Composition', slug: 'language/expression/composition' },
{ title: 'Default', slug: 'language/expression/default' },
{ title: 'Arithmetic', slug: 'language/expression/arithmetic' },
{ title: 'Logical and Comparison', slug: 'language/expression/logical-and-comparison' },
{ title: 'String Interpolation', slug: 'language/expression/string-interpolation' },
],
},
{ title: 'Constraints and Defaults', slug: 'language/constraints-and-defaults' },
{ title: 'Operators', slug: 'language/operators' },
{ title: 'Functions', slug: 'language/functions' },
{ title: 'Modules and Imports', slug: 'language/modules-and-imports' },
{ title: 'Evaluation Semantics', slug: 'language/evaluation' },
{ title: 'Materialization and Errors', slug: 'language/materialization-and-errors' },
{ title: 'Naming', slug: 'language/naming' },
{ title: 'Examples', slug: 'language/examples' },
],
},
{
title: 'Implementation Design',
slug: 'design',
children: [
{ title: 'Execution Pipeline', slug: 'design/execution-pipeline' },
{ title: 'Runtime Model', slug: 'design/runtime-model' },
{ title: 'Thunk and Lazy Evaluation', slug: 'design/thunk-and-lazy-evaluation' },
{ title: 'Composition and Materialization', slug: 'design/composition-and-materialization' },
{ title: 'Diagnostics and Fallback', slug: 'design/diagnostics-and-fallback' },
{ title: 'Embedding API', slug: 'design/embedding-api' },
{ title: 'Features', slug: 'design/features' },
],
},
{ title: 'Development', slug: 'development' },
{ title: 'Open Issues', slug: 'open-issues' },
];
const renderer = new marked.Renderer();
renderer.code = (code, language = '') => {
const normalizedLanguage = language.split(/\s+/)[0] ?? '';
const className = normalizedLanguage ? ` class="language-${escapeAttribute(normalizedLanguage)}"` : '';
return `<pre class="code-block"><code${className}>${highlightCode(code, normalizedLanguage)}</code></pre>`;
};
renderer.codespan = (code) => `<code>${code}</code>`;
marked.setOptions({ gfm: true, renderer });
export function allDocSlugs() {
return Object.keys(docs).filter((slug) => slug !== 'index');
}
export function renderMarkdown(slug) {
const source = docs[slug] ?? docs.index;
const html = marked.parse(source ?? '# Not found\n');
return html.replace(/href="([^"#][^"]*)\.md(#[^"]*)?"/g, (_all, href, hash = '') => {
const target = normalizeDocLink(slug, href);
return `href="/docs/${target}/${hash}"`;
});
}
function normalizeDocLink(currentSlug, href) {
const base = currentSlug.includes('/') ? currentSlug.split('/').slice(0, -1) : [];
const parts = [...base, ...href.split('/')];
const out = [];
for (const part of parts) {
if (!part || part === '.') continue;
if (part === '..') out.pop();
else out.push(part);
}
if (out[out.length - 1] === 'index') out.pop();
return out.join('/') || 'index';
}
+203
View File
@@ -0,0 +1,203 @@
const DECODAL_KEYWORDS = new Set(['let', 'in', 'fn', 'match', 'import', 'default']);
const DECODAL_TYPES = new Set(['String', 'Int', 'Float', 'Bool']);
const DECODAL_LITERALS = new Set(['true', 'false']);
const HTML_ESCAPE = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
export function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => HTML_ESCAPE[char]);
}
export function escapeAttribute(value) {
return escapeHtml(value).replace(/`/g, '&#96;');
}
export function highlightCode(code, language = '') {
const normalized = language.toLowerCase();
if (normalized === 'dcdl' || normalized === 'decodal') return highlightDecodal(code);
if (normalized === 'sh' || normalized === 'bash' || normalized === 'shell') return highlightShell(code);
return escapeHtml(code);
}
export function highlightDecodal(source) {
let html = '';
let index = 0;
let canEndExpression = false;
while (index < source.length) {
const char = source[index];
const next = source[index + 1];
if (char === '#') {
const end = readUntilLineEnd(source, index);
html += token('comment', source.slice(index, end));
index = end;
continue;
}
if (char === '"') {
const end = readString(source, index);
html += token('string', source.slice(index, end));
index = end;
canEndExpression = true;
continue;
}
if (char === '/' && next && next !== '/' && !canEndExpression) {
const end = readRegex(source, index);
html += token('regex', source.slice(index, end));
index = end;
canEndExpression = true;
continue;
}
if (isNumberStart(source, index)) {
const end = readNumber(source, index);
html += token('number', source.slice(index, end));
index = end;
canEndExpression = true;
continue;
}
if (isIdentifierStart(char)) {
const end = readIdentifier(source, index);
const ident = source.slice(index, end);
if (DECODAL_KEYWORDS.has(ident)) html += token('keyword', ident);
else if (DECODAL_TYPES.has(ident)) html += token('type', ident);
else if (DECODAL_LITERALS.has(ident)) html += token('literal', ident);
else html += escapeHtml(ident);
canEndExpression = true;
index = end;
continue;
}
if (isOperatorStart(char)) {
const end = readOperator(source, index);
html += token('operator', source.slice(index, end));
canEndExpression = /[})\]]/.test(char);
index = end;
continue;
}
html += escapeHtml(char);
index += 1;
}
return html;
}
function highlightShell(source) {
return source
.split(/(\n)/)
.map((line) => {
if (line === '\n') return line;
const commentIndex = line.indexOf('#');
const code = commentIndex >= 0 ? line.slice(0, commentIndex) : line;
const comment = commentIndex >= 0 ? line.slice(commentIndex) : '';
return `${highlightShellCode(code)}${comment ? token('comment', comment) : ''}`;
})
.join('');
}
function highlightShellCode(source) {
const escaped = escapeHtml(source);
return escaped
.replace(/(&quot;[^&]*(?:&(?!quot;)[^&]*)*&quot;|'[^']*')/g, '<span class="tok-string">$1</span>')
.replace(/(^|\s)(--?[A-Za-z0-9][A-Za-z0-9-]*)/g, '$1<span class="tok-operator">$2</span>');
}
function token(kind, value) {
return `<span class="tok-${kind}">${escapeHtml(value)}</span>`;
}
function readUntilLineEnd(source, start) {
const end = source.indexOf('\n', start);
return end < 0 ? source.length : end;
}
function readString(source, start) {
let index = start + 1;
while (index < source.length) {
if (source[index] === '\\') {
index += 2;
continue;
}
if (source[index] === '"') return index + 1;
index += 1;
}
return source.length;
}
function readRegex(source, start) {
let index = start + 1;
let inClass = false;
while (index < source.length) {
const char = source[index];
if (char === '\\') {
index += 2;
continue;
}
if (char === '[') inClass = true;
else if (char === ']') inClass = false;
else if (char === '/' && !inClass) {
index += 1;
while (/[A-Za-z]/.test(source[index] ?? '')) index += 1;
return index;
}
if (char === '\n') return index;
index += 1;
}
return source.length;
}
function isNumberStart(source, index) {
const char = source[index];
const next = source[index + 1];
const prev = source[index - 1];
if (/[0-9]/.test(char)) return true;
return char === '-' && /[0-9]/.test(next ?? '') && !isIdentifierPart(prev ?? '');
}
function readNumber(source, start) {
let index = start;
if (source[index] === '-') index += 1;
while (/[0-9]/.test(source[index] ?? '')) index += 1;
if (source[index] === '.' && /[0-9]/.test(source[index + 1] ?? '')) {
index += 1;
while (/[0-9]/.test(source[index] ?? '')) index += 1;
}
return index;
}
function isIdentifierStart(char) {
return /[A-Za-z_]/.test(char ?? '');
}
function isIdentifierPart(char) {
return /[A-Za-z0-9_-]/.test(char ?? '');
}
function readIdentifier(source, start) {
let index = start + 1;
while (isIdentifierPart(source[index])) index += 1;
return index;
}
function isOperatorStart(char) {
return '&=<>!|:;,.{}()[]+-*/'.includes(char ?? '');
}
function readOperator(source, start) {
let index = start + 1;
if ('&|/+'.includes(source[start]) && source[index] === source[start]) return index + 1;
if ((source[start] === '<' || source[start] === '>' || source[start] === '=' || source[start] === '!') && source[index] === '=') {
index += 1;
}
return index;
}
@@ -0,0 +1,17 @@
---
import ManualLayout from '../../layouts/ManualLayout.astro';
import { allDocSlugs, renderMarkdown } from '../../lib/docs.js';
export function getStaticPaths() {
return allDocSlugs().map((slug) => ({
params: { slug },
props: { slug },
}));
}
const { slug } = Astro.props;
const html = renderMarkdown(slug);
---
<ManualLayout title={`Decodal - ${slug}`} active={slug}>
<article class="markdown" set:html={html} />
</ManualLayout>
@@ -0,0 +1,3 @@
---
return Astro.redirect('/docs/introduction/');
---
+3
View File
@@ -0,0 +1,3 @@
---
return Astro.redirect('/docs/introduction/');
---
@@ -0,0 +1,49 @@
---
import ManualLayout from '../layouts/ManualLayout.astro';
---
<ManualLayout title="Decodal Playground" playground>
<section class="playground-page">
<div class="playground-header">
<div>
<h1>Playground</h1>
<p>Virtual files are evaluated in the browser through WebAssembly. Use import paths such as <code>./schemas/service.dcdl</code>.</p>
</div>
<div class="playground-actions">
<label class="example-picker">
<span>Example</span>
<select id="example-select" aria-label="Playground example"></select>
</label>
<button id="load-example" type="button">Load</button>
<p id="status" class="status">Loading WASM...</p>
<button id="run" disabled>Run</button>
</div>
</div>
<div class="playground-shell">
<aside class="file-panel">
<div class="panel-header">
<span>Files</span>
<button id="new-file" type="button">New</button>
</div>
<div id="file-tree" class="file-tree"></div>
<button id="delete-file" class="danger-button" type="button">Delete file</button>
</aside>
<label class="pane input-pane">
<span id="active-file">Input</span>
<div class="editor-wrap">
<pre id="source-highlight" aria-hidden="true"></pre>
<textarea id="source" spellcheck="false"></textarea>
</div>
</label>
<section class="pane output-pane">
<span>Output</span>
<pre id="output"></pre>
</section>
</div>
</section>
<script>
import '../scripts/playground.js';
</script>
</ManualLayout>
@@ -0,0 +1,174 @@
export const playgroundExamples = [
{
id: 'service-deployment',
title: 'Service deployment',
activePath: 'main.dcdl',
files: {
'main.dcdl': `let
schema = import "./schemas/service.dcdl";
prod = import "./env/prod.dcdl";
shared = import "./shared/tags.dcdl";
in
schema.Service & prod.ServicePatch & {
name = "api";
image = "registry.example.com/api:2026-06-24";
port = 9000 + 443;
public = prod.is_public && 9000 + 443 > 9000;
tags = shared.common ++ ["api", "edge"];
}
`,
'schemas/service.dcdl': `Service = {
name = String;
image = String;
port = Int & > 443 default 8443;
replicas = Int & > 0 default 2;
public = Bool default false;
feature.metrics = Bool default true;
feature.tracing = Bool default false;
};
`,
'env/prod.dcdl': `is_public = true;
ServicePatch = {
replicas = 3;
feature.tracing = true;
};
`,
'shared/tags.dcdl': `common = ["decodal", "prod"];
`,
},
},
{
id: 'access-policy',
title: 'Access policy',
activePath: 'main.dcdl',
files: {
'main.dcdl': `let
policy = import "./policy/base.dcdl";
teams = import "./policy/teams.dcdl";
env = "prod";
in
{
service = "payments";
readers = policy.defaultReaders ++ teams.observability;
writers = policy.defaultWriters ++ teams.payments;
admins = match env {
"prod": policy.breakglassAdmins;
_: teams.platform;
};
audit.required = env == "prod";
audit.retention_days = match env {
"prod": 365;
_: 30;
};
}
`,
'policy/base.dcdl': `defaultReaders = ["group:engineering", "group:support"];
defaultWriters = ["group:platform"];
breakglassAdmins = ["user:oncall-primary", "user:oncall-secondary"];
`,
'policy/teams.dcdl': `platform = ["group:platform-admins"];
payments = ["group:payments-api", "group:payments-sre"];
observability = ["group:observability"];
`,
},
},
{
id: 'edge-worker',
title: 'Edge worker config',
activePath: 'main.dcdl',
files: {
'main.dcdl': `let
schema = import "./schemas/worker.dcdl";
routes = import "./routes.dcdl";
production = true;
in
schema.Worker & {
name = "decodal-docs";
compatibility_date = "2026-06-15";
workers_dev = !production;
route.host = routes.primary_host;
route.paths = routes.docs_paths ++ routes.playground_paths;
cache.ttl_seconds = 60 * 60;
cache.bypass = !production;
}
`,
'schemas/worker.dcdl': `Worker = {
name = String;
compatibility_date = String;
workers_dev = Bool default false;
route.host = String;
cache.ttl_seconds = Int & >= 0 default 300;
cache.bypass = Bool default false;
};
`,
'routes.dcdl': `primary_host = "decodal.example.com";
docs_paths = ["/docs/*", "/assets/*"];
playground_paths = ["/playground/*"];
`,
},
},
{
id: 'data-pipeline',
title: 'Data pipeline',
activePath: 'main.dcdl',
files: {
'main.dcdl': `let
schema = import "./schemas/pipeline.dcdl";
presets = import "./presets/batch.dcdl";
env = "staging";
in
schema.Pipeline & presets.Batch & {
name = "events-rollup";
source.topic = "events.raw";
sink.table = "analytics.events_daily";
workers = match env {
"prod": 8;
"staging": 3;
_: 1;
};
alerts.enabled = env != "dev";
transforms = presets.standardTransforms ++ ["dedupe", "aggregate_daily"];
}
`,
'schemas/pipeline.dcdl': `Pipeline = {
name = String;
source.topic = String;
sink.table = String;
workers = Int & > 0 default 1;
batch.size = Int & >= 100 default 1000;
batch.timeout_seconds = Int & > 0 default 60;
alerts.enabled = Bool default false;
};
`,
'presets/batch.dcdl': `standardTransforms = ["parse_json", "validate_schema", "enrich_metadata"];
Batch = {
batch.size = 5000;
batch.timeout_seconds = 30 + 30;
};
`,
},
},
{
id: 'diagnostics',
title: 'Diagnostics: invalid port',
activePath: 'main.dcdl',
files: {
'main.dcdl': `let
schema = import "./schemas/service.dcdl";
in
schema.Service & {
name = "broken-api";
port = 442;
}
`,
'schemas/service.dcdl': `Service = {
name = String;
port = Int & > 443 default 8443;
replicas = Int & > 0 default 2;
};
`,
},
},
];
+207
View File
@@ -0,0 +1,207 @@
import init, { evaluateProject } from '../wasm/decodal_wasm.js';
import { highlightDecodal } from '../lib/highlight.js';
import { playgroundExamples } from './playground-examples.js';
const STORAGE_KEY = 'decodal-playground-project-v1';
const starterProject = playgroundExamples[0];
const source = document.getElementById('source');
const sourceHighlight = document.getElementById('source-highlight');
const output = document.getElementById('output');
const run = document.getElementById('run');
const status = document.getElementById('status');
const fileTree = document.getElementById('file-tree');
const activeFile = document.getElementById('active-file');
const newFile = document.getElementById('new-file');
const deleteFile = document.getElementById('delete-file');
const exampleSelect = document.getElementById('example-select');
const loadExample = document.getElementById('load-example');
const project = loadProject();
for (const example of playgroundExamples) {
const option = document.createElement('option');
option.value = example.id;
option.textContent = example.title;
exampleSelect.append(option);
}
exampleSelect.value = starterProject.id;
setActiveFile(project.activePath);
renderFileTree();
updateHighlight();
function loadProject() {
try {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null');
if (stored && stored.files && typeof stored.activePath === 'string') {
const files = normalizeFiles(stored.files);
const activePath = files[stored.activePath] === undefined ? Object.keys(files)[0] : stored.activePath;
if (activePath) return { files, activePath };
}
} catch (_error) {
// Fall back to the starter project.
}
return { files: cloneFiles(starterProject.files), activePath: starterProject.activePath };
}
function cloneFiles(files) {
return Object.fromEntries(Object.entries(files).map(([path, source]) => [path, source]));
}
function normalizeFiles(files) {
return Object.fromEntries(
Object.entries(files)
.filter(([_path, value]) => typeof value === 'string')
.map(([path, value]) => [normalizePath(path), value])
.filter(([path]) => path),
);
}
function saveProject() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
}
function loadExampleProject(exampleId) {
const example = playgroundExamples.find((item) => item.id === exampleId) ?? starterProject;
project.files = cloneFiles(example.files);
project.activePath = example.activePath;
exampleSelect.value = example.id;
setActiveFile(project.activePath);
output.textContent = '';
output.classList.remove('error');
execute();
}
function normalizePath(path) {
const parts = [];
for (const part of String(path).replaceAll('\\', '/').split('/')) {
if (!part || part === '.') continue;
if (part === '..') parts.pop();
else parts.push(part);
}
return parts.join('/');
}
function setActiveFile(path) {
const normalized = normalizePath(path);
if (project.files[normalized] === undefined) return;
project.activePath = normalized;
source.value = project.files[normalized];
activeFile.textContent = normalized;
deleteFile.disabled = Object.keys(project.files).length <= 1;
updateHighlight();
syncHighlightScroll();
renderFileTree();
saveProject();
}
function updateHighlight() {
sourceHighlight.innerHTML = `${highlightDecodal(source.value)}\n`;
}
function syncHighlightScroll() {
sourceHighlight.scrollTop = source.scrollTop;
sourceHighlight.scrollLeft = source.scrollLeft;
}
function execute() {
project.files[project.activePath] = source.value;
saveProject();
const result = JSON.parse(evaluateProject(project.activePath, JSON.stringify(project.files)));
output.textContent = result.ok ? result.output : result.error;
output.classList.toggle('error', !result.ok);
}
function renderFileTree() {
const tree = buildTree(Object.keys(project.files).sort());
fileTree.replaceChildren(renderTreeList(tree.children));
}
function buildTree(paths) {
const root = { name: '', children: new Map(), path: '' };
for (const path of paths) {
const parts = path.split('/');
let node = root;
let currentPath = '';
parts.forEach((part, index) => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (!node.children.has(part)) {
node.children.set(part, { name: part, children: new Map(), path: currentPath, file: index + 1 === parts.length });
}
node = node.children.get(part);
});
}
return root;
}
function renderTreeList(children) {
const list = document.createElement('ul');
for (const child of [...children.values()].sort(compareNodes)) {
const item = document.createElement('li');
if (child.file) {
const button = document.createElement('button');
button.type = 'button';
button.className = child.path === project.activePath ? 'file active' : 'file';
button.textContent = child.name;
button.title = child.path;
button.addEventListener('click', () => setActiveFile(child.path));
item.append(button);
} else {
const label = document.createElement('span');
label.className = 'folder';
label.textContent = `${child.name}/`;
item.append(label, renderTreeList(child.children));
}
list.append(item);
}
return list;
}
function compareNodes(a, b) {
if (a.file !== b.file) return a.file ? 1 : -1;
return a.name.localeCompare(b.name);
}
try {
await init();
run.disabled = false;
status.textContent = '';
execute();
} catch (error) {
status.textContent = `Failed to load WASM: ${error?.message ?? error}`;
}
run.addEventListener('click', execute);
loadExample.addEventListener('click', () => {
const example = playgroundExamples.find((item) => item.id === exampleSelect.value);
if (!example) return;
if (!confirm(`Load example "${example.title}"? This replaces the current virtual files.`)) return;
loadExampleProject(example.id);
});
newFile.addEventListener('click', () => {
const path = normalizePath(prompt('New virtual file path', 'schemas/types.dcdl') ?? '');
if (!path) return;
if (project.files[path] !== undefined) {
setActiveFile(path);
return;
}
project.files[path] = 'value = "new";\n';
setActiveFile(path);
});
deleteFile.addEventListener('click', () => {
if (Object.keys(project.files).length <= 1) return;
if (!confirm(`Delete ${project.activePath}?`)) return;
delete project.files[project.activePath];
setActiveFile(Object.keys(project.files).sort()[0]);
});
source.addEventListener('input', () => {
project.files[project.activePath] = source.value;
updateHighlight();
syncHighlightScroll();
saveProject();
});
source.addEventListener('scroll', syncHighlightScroll);
source.addEventListener('keydown', (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') execute();
});
+490
View File
@@ -0,0 +1,490 @@
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--bg: #f7f7f8;
--surface: #ffffff;
--text: #1f2328;
--heading: #111827;
--muted: #4b5563;
--subtle: #64748b;
--border: #e5e7eb;
--link: #2563eb;
--topbar-bg: #111827;
--topbar-text: #ffffff;
--topbar-link: #dbeafe;
--active-bg: #dbeafe;
--active-text: #1d4ed8;
--nav-number: #94a3b8;
--shadow: 0 8px 24px rgb(15 23 42 / 0.05);
--inline-code-bg: #eef2ff;
--inline-code-text: #3730a3;
--editor-bg: #0f172a;
--editor-text: #e5e7eb;
--danger: #b91c1c;
--error: #fecaca;
--selection: rgb(59 130 246 / 0.35);
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg: #0b1120;
--surface: #111827;
--text: #d1d5db;
--heading: #f9fafb;
--muted: #9ca3af;
--subtle: #94a3b8;
--border: #334155;
--link: #60a5fa;
--topbar-bg: #020617;
--topbar-text: #f8fafc;
--topbar-link: #bfdbfe;
--active-bg: #1e3a8a;
--active-text: #bfdbfe;
--nav-number: #64748b;
--shadow: 0 8px 24px rgb(0 0 0 / 0.25);
--inline-code-bg: #1e293b;
--inline-code-text: #bfdbfe;
--danger: #fca5a5;
}
}
body {
background: var(--bg);
color: var(--text);
margin: 0;
}
a {
color: var(--link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.topbar {
align-items: center;
background: var(--topbar-bg);
color: var(--topbar-text);
display: flex;
height: 56px;
justify-content: space-between;
padding: 0 24px;
}
.brand {
color: var(--topbar-text);
font-size: 20px;
font-weight: 700;
}
.topnav {
display: flex;
gap: 18px;
}
.topnav a {
color: var(--topbar-link);
}
.layout {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
min-height: calc(100vh - 56px);
}
.playground-layout {
display: block;
}
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
overflow: auto;
padding: 22px 18px;
}
.sidebar-title {
color: var(--heading);
display: block;
font-weight: 700;
margin-bottom: 12px;
}
.nav-tree {
list-style: none;
margin: 0;
padding-left: 0;
}
.nav-tree .nav-tree {
margin: 4px 0 6px 12px;
}
.nav-tree a {
border-radius: 6px;
color: var(--text);
display: flex;
font-size: 14px;
gap: 6px;
line-height: 1.35;
padding: 4px 8px;
}
.nav-number {
color: var(--nav-number);
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.nav-tree a.active {
background: var(--active-bg);
color: var(--active-text);
font-weight: 600;
}
.nav-tree a.active .nav-number {
color: var(--link);
}
main {
min-width: 0;
padding: 36px;
}
main.playground {
padding: 10px;
}
.markdown {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow);
margin: 0 auto;
max-width: 920px;
padding: 24px 36px 42px;
}
.markdown h1,
.markdown h2,
.markdown h3 {
color: var(--heading);
}
.markdown pre,
.output-pane pre {
background: var(--editor-bg);
border-radius: 10px;
color: var(--editor-text);
overflow: auto;
padding: 14px;
}
.markdown code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.markdown :not(pre) > code {
background: var(--inline-code-bg);
border-radius: 4px;
color: var(--inline-code-text);
padding: 1px 4px;
}
.markdown table {
border-collapse: collapse;
display: block;
margin: 20px 0;
overflow-x: auto;
width: 100%;
}
.markdown th,
.markdown td {
border: 1px solid var(--border);
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.markdown th {
background: var(--inline-code-bg);
color: var(--heading);
font-weight: 700;
}
.markdown tr:nth-child(even) td {
background: color-mix(in srgb, var(--surface) 92%, var(--border));
}
.tok-keyword {
color: #93c5fd;
font-weight: 700;
}
.tok-type {
color: #67e8f9;
}
.tok-literal,
.tok-number {
color: #fbbf24;
}
.tok-string,
.tok-regex {
color: #86efac;
}
.tok-comment {
color: var(--nav-number);
font-style: italic;
}
.tok-operator {
color: #f9a8d4;
}
.playground-page {
height: calc(100vh - 76px);
}
.playground-header {
align-items: center;
display: flex;
gap: 16px;
justify-content: space-between;
margin-bottom: 8px;
}
.playground-header h1 {
font-size: 20px;
margin: 0 0 2px;
}
.playground-header p {
color: var(--muted);
font-size: 13px;
margin: 0;
}
.playground-header code {
background: var(--inline-code-bg);
border-radius: 4px;
color: var(--inline-code-text);
padding: 1px 4px;
}
.playground-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.example-picker {
align-items: center;
color: var(--muted);
display: flex;
font-size: 12px;
font-weight: 700;
gap: 6px;
text-transform: uppercase;
}
.example-picker select {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font: inherit;
min-width: 180px;
padding: 7px 10px;
text-transform: none;
}
button {
background: var(--link);
border: 0;
border-radius: 8px;
color: var(--topbar-text);
cursor: pointer;
font-weight: 700;
padding: 8px 14px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.playground-shell {
display: grid;
gap: 10px;
grid-template-columns: 220px minmax(360px, 1.25fr) minmax(320px, 1fr);
height: calc(100% - 52px);
min-height: 0;
}
.file-panel,
.pane {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.panel-header,
.pane > span {
align-items: center;
border-bottom: 1px solid var(--border);
color: var(--text);
display: flex;
font-size: 13px;
font-weight: 700;
justify-content: space-between;
min-height: 38px;
padding: 0 10px;
text-transform: uppercase;
}
.panel-header button {
font-size: 12px;
padding: 5px 8px;
text-transform: none;
}
.file-tree {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px;
}
.file-tree ul {
list-style: none;
margin: 0;
padding-left: 12px;
}
.file-tree > ul {
padding-left: 0;
}
.file-tree li {
margin: 2px 0;
}
.file-tree .folder {
color: var(--subtle);
display: block;
font-size: 13px;
font-weight: 700;
padding: 4px 6px;
}
.file-tree .file {
background: transparent;
border-radius: 6px;
color: var(--text);
display: block;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 13px;
font-weight: 500;
padding: 5px 7px;
text-align: left;
width: 100%;
}
.file-tree .file:hover,
.file-tree .file.active {
background: var(--active-bg);
color: var(--active-text);
text-decoration: none;
}
.danger-button {
background: transparent;
border-top: 1px solid var(--border);
border-radius: 0;
color: var(--danger);
padding: 9px 10px;
text-align: left;
}
.editor-wrap {
background: var(--editor-bg);
flex: 1;
min-height: 0;
position: relative;
}
.editor-wrap pre,
.editor-wrap textarea {
border: 0;
box-sizing: border-box;
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
inset: 0;
margin: 0;
overflow: auto;
padding: 12px;
position: absolute;
tab-size: 2;
white-space: pre;
}
.editor-wrap pre {
color: var(--editor-text);
pointer-events: none;
}
.editor-wrap textarea {
background: transparent;
caret-color: var(--editor-text);
color: transparent;
outline: none;
resize: none;
width: 100%;
}
.editor-wrap textarea::selection {
background: var(--selection);
}
.output-pane pre {
border-radius: 0;
flex: 1;
margin: 0;
}
.output-pane pre.error {
color: var(--error);
}
.status {
color: var(--muted);
font-size: 13px;
margin: 0;
white-space: nowrap;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
border-bottom: 1px solid var(--border);
border-right: 0;
max-height: 260px;
}
.playground-shell {
grid-template-columns: 1fr;
}
}
+1
View File
@@ -0,0 +1 @@
# wasm-pack output is committed for the playground build.
+55
View File
@@ -0,0 +1,55 @@
# Decodal
Decodal is a small deterministic DSL for describing, composing, validating, and materializing structured data.
It is designed around a lightweight Rust library:
- host-supplied imports through `SourceLoader`
- no filesystem access in the library core
- concrete and abstract values with constraints and defaults
- deterministic expression evaluation
- optional regex support behind a Cargo feature
- browser playground support through WebAssembly
## Library crate
Embedded hosts should depend on `decodal` and provide imports with a `SourceLoader`.
```toml
[dependencies]
decodal = "0.1"
```
## CLI
A standalone CLI is kept in this repository as the `decodal-cli` workspace package.
It builds a `decodal` binary, but it is not the primary crates.io package.
Run a Decodal file from the repository:
```sh
cargo run -q -p decodal-cli -- examples/advanced/main.dcdl
```
Enable optional regex support when needed:
```sh
cargo run -q -p decodal-cli --features regex -- examples/regex/main.dcdl
```
## Web playground
The static documentation site and browser playground live under:
```text
site/decodal-site/
```
## License
Licensed under either of:
- Apache License, Version 2.0
- MIT license
at your option.
+41
View File
@@ -0,0 +1,41 @@
/* tslint:disable */
/* eslint-disable */
export function evaluate(source: string): string;
export function evaluateProject(entry: string, files_json: string): string;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
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_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
+231
View File
@@ -0,0 +1,231 @@
/* @ts-self-types="./decodal_wasm.d.ts" */
/**
* @param {string} source
* @returns {string}
*/
export function evaluate(source) {
let deferred2_0;
let deferred2_1;
try {
const ptr0 = passStringToWasm0(source, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.evaluate(ptr0, len0);
deferred2_0 = ret[0];
deferred2_1 = ret[1];
return getStringFromWasm0(ret[0], ret[1]);
} finally {
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,
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./decodal_wasm_bg.js": import0,
};
}
function getStringFromWasm0(ptr, len) {
return decodeText(ptr >>> 0, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasmInstance, wasm;
function __wbg_finalize_init(instance, module) {
wasmInstance = instance;
wasm = instance.exports;
wasmModule = module;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('decodal_wasm_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
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_free: (a: number, b: number, c: number) => void;
export const __wbindgen_start: () => void;
+27
View File
@@ -0,0 +1,27 @@
{
"name": "decodal-wasm",
"type": "module",
"description": "WebAssembly wrapper for evaluating Decodal in browser playgrounds.",
"version": "0.1.0",
"license": "MIT OR Apache-2.0",
"repository": {
"type": "git",
"url": "https://gitea.hareworks.net/Hare/Decodal"
},
"files": [
"decodal_wasm_bg.wasm",
"decodal_wasm.js",
"decodal_wasm.d.ts"
],
"main": "decodal_wasm.js",
"types": "decodal_wasm.d.ts",
"sideEffects": [
"./snippets/*"
],
"keywords": [
"decodal",
"wasm",
"dsl",
"config"
]
}