diff --git a/dsl/Cargo.toml b/dsl/Cargo.toml index b5b863f..daf440e 100644 --- a/dsl/Cargo.toml +++ b/dsl/Cargo.toml @@ -16,6 +16,9 @@ proc-macro2 = "1.0.86" convert_case = "0.6.0" indexmap = "2" serde = { version = "1", features = ["derive"], optional = true } +# Only ever reached through the `json` feature, which only the `worktable-schemas` binary +# needs. Kept out of `serde` so that turning the IR into data does not also pull a parser in. +serde_json = { version = "1", optional = true } [dev-dependencies] # The integration test builds as its own crate, which is what makes it evidence @@ -44,3 +47,18 @@ spans = ["proc-macro2/span-locations"] # diagram, next to whatever it does enforce. Kept here because it is written # and tested, behind a flag so that turning it on is a decision. uml = [] +# The `worktable-schemas` binary, which dumps every declaration in a tree as JSON so an emitter +# in another language can be checked against real schemas. Separate from `serde` because +# serialising the IR and shipping a JSON writer are different costs to ask of a dependent. +json = ["serde", "dep:serde_json"] + +[[bin]] +name = "worktable-schemas" +path = "src/bin/worktable-schemas.rs" +required-features = ["json"] + +[[bin]] +# Needs nothing beyond the default build: it is the binary a cross-implementation test runs on +# every case, so anything it required would be a thing that test could be missing. +name = "worktable-parse" +path = "src/bin/worktable-parse.rs" diff --git a/dsl/src/bin/worktable-parse.rs b/dsl/src/bin/worktable-parse.rs new file mode 100644 index 0000000..7fcf60a --- /dev/null +++ b/dsl/src/bin/worktable-parse.rs @@ -0,0 +1,54 @@ +//! `worktable-parse` — read a declaration on stdin, write its canonical text to stdout. +//! +//! The input is the macro **body**: `name: Foo, columns: { .. }`, without `worktable!` or the +//! surrounding braces, which is what [`worktable_dsl::Schema::parse`] accepts and what +//! [`worktable_dsl::Schema::to_dsl`] produces. +//! +//! It exists so that an emitter written in another language can be checked against this one. +//! Two implementations of a language drift unless something compares them, and the comparison +//! has to be byte-exact: they can agree on every meaning and still disagree on every character +//! a person reads. +//! +//! It runs [`worktable_dsl::check`] rather than `Schema::parse`, and that is the whole point. +//! `parse` answers "is this a declaration"; `check` answers "would the macro accept it", which +//! is the question an emitter has to get right. `page_size: 4096` beside `persist: true` parses +//! perfectly and the macro refuses it, so a parse-only binary would report green for output +//! that does not compile. + +use std::io::{Read, Write}; + +fn main() { + let mut source = String::new(); + if std::io::stdin().read_to_string(&mut source).is_err() { + eprintln!("worktable-parse: could not read stdin"); + std::process::exit(2); + } + + let checked = worktable_dsl::check(&source); + + for d in &checked.diagnostics { + let stage = match d.stage { + worktable_dsl::Stage::Grammar => "grammar", + worktable_dsl::Stage::Rules => "rules", + }; + match d.span { + // Byte ranges need the `spans` feature. Absence of a location is never absence of + // a problem, so the message is printed either way. + Some(s) => eprintln!("{stage} [{}..{}]: {}", s.start, s.end, d.message), + None => eprintln!("{stage}: {}", d.message), + } + } + + let Some(schema) = checked.schema else { + std::process::exit(1); + }; + if !checked.diagnostics.is_empty() { + // A rule violation still yields a schema — an editor has to draw it so somebody can + // fix it — but it is not text the macro would accept, so it is not success. + std::process::exit(1); + } + + if std::io::stdout().write_all(schema.to_dsl().as_bytes()).is_err() { + std::process::exit(2); + } +} diff --git a/dsl/src/bin/worktable-schemas.rs b/dsl/src/bin/worktable-schemas.rs new file mode 100644 index 0000000..8467082 --- /dev/null +++ b/dsl/src/bin/worktable-schemas.rs @@ -0,0 +1,72 @@ +//! `worktable-schemas` — every `worktable!` declaration under a directory, as JSON. +//! +//! Each entry carries the schema and the canonical text this crate emits for it, so a consumer +//! written in another language can emit from the same model and compare bytes without needing +//! to reproduce the scan or the parse. +//! +//! It exists to make the cross-implementation check run against *real* declarations rather than +//! against the handful anybody thinks to invent. A hand-written corpus tests the cases its +//! author already understood; this repository's own tables are the ones people actually wrote, +//! and they are where an emitter's unexamined assumption shows up. +//! +//! Requires the `serde` feature, which is off by default because `worktable_dsl` is compiled +//! for the host as part of `worktable_codegen` before anything else in a dependent's build. + +use std::io::Write; +use std::path::Path; + +fn main() { + let root = std::env::args().nth(1).unwrap_or_else(|| ".".to_string()); + + let mut entries: Vec = Vec::new(); + let mut templates = 0usize; + let mut rejected = 0usize; + walk(Path::new(&root), &mut entries, &mut templates, &mut rejected); + + // Sorted, so the output is a function of the tree's contents rather than of the order a + // directory happened to be read in. A consumer diffing two runs should see only real change. + entries.sort(); + + let body = entries.join(",\n "); + let out = format!( + "{{\n \"templates\": {templates},\n \"rejected\": {rejected},\n \"schemas\": [\n {body}\n ]\n}}\n" + ); + if std::io::stdout().write_all(out.as_bytes()).is_err() { + std::process::exit(2); + } +} + +fn walk(dir: &Path, entries: &mut Vec, templates: &mut usize, rejected: &mut usize) { + let Ok(read) = std::fs::read_dir(dir) else { return }; + for entry in read.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if path.is_dir() { + // `target` is build output and would multiply the scan by every vendored crate. + if name == "target" || name == ".git" || name == "node_modules" { + continue; + } + walk(&path, entries, templates, rejected); + continue; + } + if path.extension().is_none_or(|e| e != "rs") { + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { continue }; + if !source.contains("worktable!") { + continue; + } + let Ok(found) = worktable_dsl::declarations_in_source(&source) else { continue }; + *templates += found.templates.len(); + *rejected += found.rejected.len(); + for schema in found.schemas { + let dsl = serde_json::to_string(&schema.to_dsl()).expect("a string serialises"); + let model = serde_json::to_string(&schema).expect("the schema serialises"); + entries.push(format!( + "{{ \"file\": {}, \"dsl\": {dsl}, \"schema\": {model} }}", + serde_json::to_string(&path.display().to_string()).expect("a path serialises") + )); + } + } +}