diff --git a/Cargo.lock b/Cargo.lock index 8769182..288230e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "decodal" version = "0.1.0" @@ -12,3 +21,41 @@ dependencies = [ [[package]] name = "decodal-core" version = "0.1.0" +dependencies = [ + "regex", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" diff --git a/crates/decodal-cli/Cargo.toml b/crates/decodal-cli/Cargo.toml index faee5fb..4fb5122 100644 --- a/crates/decodal-cli/Cargo.toml +++ b/crates/decodal-cli/Cargo.toml @@ -3,5 +3,9 @@ name = "decodal" version = "0.1.0" edition = "2024" +[features] +default = [] +regex = ["decodal-core/regex"] + [dependencies] decodal-core = { path = "../decodal-core" } diff --git a/crates/decodal-core/Cargo.toml b/crates/decodal-core/Cargo.toml index da71e4d..436ebe3 100644 --- a/crates/decodal-core/Cargo.toml +++ b/crates/decodal-core/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [features] default = ["std"] std = [] -regex = [] +regex = ["std", "dep:regex"] [dependencies] +regex = { version = "1.10", default-features = false, features = ["std", "unicode-perl"], optional = true } diff --git a/crates/decodal-core/src/eval.rs b/crates/decodal-core/src/eval.rs index a3201b5..6f29db2 100644 --- a/crates/decodal-core/src/eval.rs +++ b/crates/decodal-core/src/eval.rs @@ -707,11 +707,7 @@ impl Engine { "value does not satisfy comparison constraint", ) }), - Constraint::Regex(_) => Err(Diagnostic::new( - DiagnosticKind::UnsupportedFeature, - span, - "regex constraints require a future regex feature", - )), + Constraint::Regex(pattern) => satisfies_regex(value, pattern, span), Constraint::BuiltinPredicate(name) => Err(Diagnostic::new( DiagnosticKind::UnsupportedFeature, span, @@ -967,6 +963,42 @@ fn value_matches_primitive(value: &RuntimeValue, primitive: PrimitiveType) -> bo ) } +#[cfg(feature = "regex")] +fn satisfies_regex(value: &RuntimeValue, pattern: &str, span: Span) -> Result<()> { + let RuntimeValue::Concrete(ConcreteValue::String(value)) = value else { + return Err(Diagnostic::new( + DiagnosticKind::ConstraintViolation, + span, + "regex constraints require a string value", + )); + }; + let regex = regex::Regex::new(pattern).map_err(|error| { + Diagnostic::new( + DiagnosticKind::Syntax, + span, + format!("invalid regex constraint `{pattern}`: {error}"), + ) + })?; + if regex.is_match(value) { + Ok(()) + } else { + Err(Diagnostic::new( + DiagnosticKind::ConstraintViolation, + span, + "value does not satisfy regex constraint", + )) + } +} + +#[cfg(not(feature = "regex"))] +fn satisfies_regex(_value: &RuntimeValue, _pattern: &str, span: Span) -> Result<()> { + Err(Diagnostic::new( + DiagnosticKind::UnsupportedFeature, + span, + "regex constraints require the regex feature", + )) +} + fn compare_value(value: &RuntimeValue, op: CompareOp, expected: &LiteralValue) -> bool { match (value, expected) { (RuntimeValue::Concrete(ConcreteValue::Int(actual)), LiteralValue::Int(expected)) => { @@ -1169,4 +1201,22 @@ mod tests { let Data::Object(fields) = data else { panic!() }; assert_eq!(fields[1].value, Data::String(String::from("local"))); } + + #[cfg(feature = "regex")] + #[test] + fn regex_constraint_matches_concrete_string() { + let data = eval_data(r#"value = String & /^api-[0-9]+$/ default "api-123";"#); + let Data::Object(fields) = data else { panic!() }; + assert_eq!(fields[0].value, Data::String(String::from("api-123"))); + } + + #[cfg(feature = "regex")] + #[test] + fn regex_constraint_rejects_non_matching_string() { + let parsed = + crate::parse_source(r#"value = String & /^api-[0-9]+$/ default "web-123";"#).unwrap(); + let mut engine = Engine::from_parse(parsed.ast, parsed.root); + let value = engine.eval_root().unwrap(); + assert!(engine.materialize(&value).is_err()); + } } diff --git a/doc/manual/souce/design/features.md b/doc/manual/souce/design/features.md new file mode 100644 index 0000000..014a91e --- /dev/null +++ b/doc/manual/souce/design/features.md @@ -0,0 +1,34 @@ +# Features + +Decodal keeps the embedded core small by making heavy functionality opt-in. + +## Core defaults + +`decodal-core` defaults to `std` only. + +```toml +[features] +default = ["std"] +std = [] +regex = ["std", "dep:regex"] +``` + +Building `decodal-core` with `--no-default-features` keeps the core in `no_std + alloc` mode and avoids optional dependencies. + +## Regex + +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 +``` + +Regex constraints are accumulated during `&` composition. +The implementation does not try to prove whether the intersection of two regex constraints is empty. +Concrete strings must match every regex constraint attached to the abstract value. + +## CLI features + +`decodal-cli` exposes a matching `regex` feature that enables `decodal-core/regex`. +The feature is not enabled by default so the default CLI binary remains small. diff --git a/doc/manual/souce/design/index.md b/doc/manual/souce/design/index.md index 31ae7c6..f6da3d6 100644 --- a/doc/manual/souce/design/index.md +++ b/doc/manual/souce/design/index.md @@ -16,3 +16,4 @@ bytecode VM や JIT ではなく、AST を demand-driven に評価すること 4. [Composition and Materialization](./composition-and-materialization.md) 5. [Diagnostics and Fallback](./diagnostics-and-fallback.md) 6. [Embedding API](./embedding-api.md) +7. [Features](./features.md) diff --git a/doc/manual/souce/index.md b/doc/manual/souce/index.md index 385281a..a67d239 100644 --- a/doc/manual/souce/index.md +++ b/doc/manual/souce/index.md @@ -42,4 +42,5 @@ Decodal は Deferred Constraint Data Language、略称 DCDL のプロジェク 4. [Composition and Materialization](./design/composition-and-materialization.md) 5. [Diagnostics and Fallback](./design/diagnostics-and-fallback.md) 6. [Embedding API](./design/embedding-api.md) + 7. [Features](./design/features.md) 4. [Open Issues](./open-issues.md) diff --git a/examples/regex/README.md b/examples/regex/README.md new file mode 100644 index 0000000..933b4f5 --- /dev/null +++ b/examples/regex/README.md @@ -0,0 +1,9 @@ +# Regex example + +Regex constraints are optional. Run this example with the `regex` feature enabled: + +```sh +cargo run -q -p decodal --features regex -- examples/regex/main.dcdl +``` + +Without the feature, regex validation returns an unsupported feature diagnostic. diff --git a/examples/regex/main.dcdl b/examples/regex/main.dcdl new file mode 100644 index 0000000..61638d8 --- /dev/null +++ b/examples/regex/main.dcdl @@ -0,0 +1,7 @@ +let + Identifier = String & /^[A-Za-z_][A-Za-z0-9_]*$/; + ApiName = Identifier & /^api_/; +in +{ + service = ApiName default "api_gateway"; +}