flowrs-engine/crates/flowrs-core/tests/document_spec.rs
flowRS 2b6f30d348 flowRS engine, cut from flowRS f907e0756a7c
Built alone from an empty CARGO_HOME, its tests passed, and flowrsc checked, ran and exported a
document from the conformance corpus; the export built alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 08:54:33 -05:00

1357 lines
47 KiB
Rust

//! The published document specification (`spec/flowrs-document-2.md`, its schema and its
//! conformance corpus) held to the code that reads documents and graph texts. Version 1.0.0's
//! prose and schema stay as they were published (`spec::PREVIOUS`).
//!
//! A specification is only worth publishing if it describes the reader people actually run,
//! so each claim it makes is read back here against that reader:
//!
//! - the committed schema is `spec::schema()`, and is itself a draft 2020-12 schema;
//! - a document that sets every member (written from the types' own struct literals, so a
//! field added to any of them does not compile here until it is given a value) writes
//! exactly the members the schema declares, validates, and is the committed
//! `every-member.flowrs`;
//! - for every member the schema declares, dropping it or giving it a value of the wrong type
//! leaves the schema and `Document::from_json` agreeing: both accept, or both refuse, and a
//! member is required in the schema exactly when the reader needs it;
//! - the conformance corpus is read as its manifest says, and every document and graph text
//! in the repository conforms (texts round-trip through the printer);
//! - the prose's tables are the schema's members, types and required flags, its modes and
//! authors the code's, its escapes, keywords, punctuation and modifiers the lexer's and the
//! parser's, its older spellings the ones read, its fingerprint the one computed, and its
//! examples parse, round-trip, or fail where it says.
//!
//! `FLOWRS_SPEC_BLESS=1` rewrites the three generated files (the schema, `every-member.flowrs`
//! and `functions.flowrs`) instead of comparing them.
use flowrs_core::graph::document::{
AppSpec, AppTurn, CardPlace, Document, FORMAT, FunctionDoc, LoadError, PanelLayout,
};
use flowrs_core::graph::dsl;
use flowrs_core::graph::ir::{Group, GroupId, Node, NodeId, Wire, WireId, WireMode};
use flowrs_core::graph::provenance::{Author, NodeOrigin};
use flowrs_core::graph::spec;
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.unwrap()
.to_path_buf()
}
fn read(rel: &str) -> String {
std::fs::read_to_string(root().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
}
fn bless() -> bool {
std::env::var("FLOWRS_SPEC_BLESS").is_ok_and(|v| v == "1")
}
/// `path` holds `text`, or, blessing, is made to.
fn holds(rel: &str, text: &str) {
if bless() {
std::fs::write(root().join(rel), text).unwrap();
return;
}
assert!(
read(rel) == text,
"{rel} is not what the code generates; rerun with FLOWRS_SPEC_BLESS=1 and review the diff"
);
}
fn schema() -> Value {
serde_json::from_str(&spec::schema()).unwrap()
}
fn validator() -> jsonschema::Validator {
jsonschema::options()
.build(&schema())
.expect("the schema compiles")
}
fn conforms(v: &jsonschema::Validator, text: &str) -> bool {
serde_json::from_str::<Value>(text).is_ok_and(|x| v.is_valid(&x))
}
/// What the reader does with `text`: `accepts`, `malformed` or `too_new`.
fn reader(text: &str) -> &'static str {
match Document::from_json(text) {
Ok(_) => "accepts",
Err(LoadError::Malformed(_)) => "malformed",
Err(LoadError::TooNew { .. }) => "too_new",
}
}
/// Whether `text` is the canonical form: what the reader read, written again, is the same
/// bytes, and every array is in id order.
fn canonical(text: &str) -> bool {
let Ok(d) = Document::from_json(text) else {
return false;
};
let sorted = |ids: Vec<u64>| ids.windows(2).all(|w| w[0] < w[1]);
d.to_json() == text.trim_end()
&& sorted(d.nodes.iter().map(|n| n.id.0).collect())
&& sorted(d.wires.iter().map(|w| w.id.0).collect())
&& sorted(d.groups.iter().map(|g| g.id.0).collect())
}
// -------------------------------------------------------------------------------------
// The document that sets every member.
// -------------------------------------------------------------------------------------
/// Every member of every object, each holding something other than its default, so each is
/// written. Struct literals with no `..`: a field added to any of these types fails to
/// compile here until it has a value, and then the schema must declare it.
fn every_member() -> Document {
let node = |id: u64,
type_id: &str,
props: &[(&str, &str)],
position: (f32, f32),
group: Option<u64>,
origin: NodeOrigin| Node {
id: NodeId(id),
type_id: type_id.into(),
properties: props
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<HashMap<_, _>>(),
position,
group: group.map(GroupId),
origin,
};
let wire = |id: u64, from: u64, fp: &str, to: u64, tp: &str, mode, feedback, origin| Wire {
id: WireId(id),
from_node: NodeId(from),
from_port: fp.into(),
to_node: NodeId(to),
to_port: tp.into(),
mode,
feedback,
origin,
};
Document {
format: FORMAT,
generator: "spec/conformance: every member".into(),
nodes: vec![
node(
0,
"FloatLiteral",
&[("value", "2"), ("unit", "V")],
(10.0, 20.0),
Some(1),
NodeOrigin {
node: Author::Human,
params: BTreeMap::from([
("unit".to_string(), Author::Unknown),
(
"value".to_string(),
Author::agent("anthropic/claude-example", "c1"),
),
]),
inputs: BTreeMap::from([("in".to_string(), Author::Mixed)]),
},
),
node(
1,
"math.add",
&[],
(260.0, 20.0),
None,
NodeOrigin {
node: Author::Imported {
dialect: "comfyui".into(),
},
params: BTreeMap::new(),
inputs: BTreeMap::new(),
},
),
node(
2,
"io.print",
&[("label", "sum")],
(520.0, 20.0),
Some(0),
NodeOrigin::default(),
),
node(
3,
"Call",
&[("fn", "scale@1"), ("k", "3")],
(260.0, 140.0),
None,
NodeOrigin::default(),
),
],
wires: vec![
wire(0, 0, "value", 1, "a", WireMode::Move, false, Author::Human),
wire(
1,
1,
"sum",
1,
"b",
WireMode::Copy,
true,
Author::agent("anthropic/claude-example", "c2"),
),
wire(
2,
1,
"sum",
2,
"value",
WireMode::Borrow,
false,
Author::Unknown,
),
wire(
3,
0,
"value",
2,
"value",
WireMode::BorrowMut,
false,
Author::Unknown,
),
wire(
4,
0,
"value",
2,
"value",
WireMode::Clone,
false,
Author::Unknown,
),
wire(
5,
0,
"value",
3,
"x",
WireMode::Copy,
false,
Author::Unknown,
),
],
groups: vec![
Group {
id: GroupId(0),
name: "Outer".into(),
parent: None,
position: (0.0, 0.0),
},
Group {
id: GroupId(1),
name: "Inner".into(),
parent: Some(GroupId(0)),
position: (40.0, 60.0),
},
],
functions: vec![FunctionDoc {
name: "scale".into(),
version: 1,
nodes: vec![
node(
0,
"FunctionInput",
&[("name", "x"), ("type", "f64")],
(0.0, 0.0),
None,
NodeOrigin::default(),
),
node(
1,
"FunctionInput",
&[
("default", "2"),
("index", "1"),
("name", "k"),
("type", "f64"),
],
(0.0, 120.0),
None,
NodeOrigin::default(),
),
node(
2,
"math.mul",
&[],
(220.0, 60.0),
Some(0),
NodeOrigin::default(),
),
node(
3,
"FunctionOutput",
&[("name", "y"), ("type", "f64")],
(440.0, 60.0),
None,
NodeOrigin::default(),
),
],
wires: vec![
wire(
0,
0,
"value",
2,
"a",
WireMode::Move,
false,
Author::Unknown,
),
wire(
1,
1,
"value",
2,
"b",
WireMode::Move,
false,
Author::Unknown,
),
wire(
2,
2,
"product",
3,
"value",
WireMode::Move,
false,
Author::Unknown,
),
],
groups: vec![Group {
id: GroupId(0),
name: "Body".into(),
parent: None,
position: (220.0, 60.0),
}],
}],
widgets: BTreeMap::from([("math.add".to_string(), "0123456789abcdef".to_string())]),
app: Some(AppSpec {
version: "1.0".into(),
library: "flowrs-app@1".into(),
source: "root = Stack([total])\ntotal = Metric(\"Sum\", n1.sum)".into(),
skin: "paper".into(),
panel: PanelLayout {
tabs: vec!["Main".into(), "Detail".into()],
cards: vec![CardPlace {
card: "ctl:0".into(),
col: 1,
row: 2,
tab: Some(1),
}],
},
title: "Add a number to itself".into(),
description: "Every member of the document, set.".into(),
steps: 40,
turns: vec![AppTurn {
request: "show the sum as a reading".into(),
checks: vec!["shows n1.sum as reading".into()],
}],
prefer: vec!["R06=Scope".into()],
}),
}
}
/// One member as the schema sees it in a document: which object it is in (the `$defs` name,
/// `#` for the document, `<def>.<member>` for a map), its name (`*` for a map's entries), its
/// schema, whether that object requires it, and where the object is (a JSON pointer).
#[derive(Clone, Debug)]
struct Site {
def: String,
key: String,
schema: Value,
required: bool,
at: String,
}
fn escape(k: &str) -> String {
k.replace('~', "~0").replace('/', "~1")
}
/// Walk `v` alongside its schema `s`, listing every member it holds.
fn walk(root: &Value, s: &Value, def: &str, v: &Value, at: &str, out: &mut Vec<Site>) {
if let Some(r) = s.get("$ref").and_then(Value::as_str) {
let name = r.trim_start_matches("#/$defs/");
return walk(root, &root["$defs"][name], name, v, at, out);
}
if let Some(branches) = s.get("oneOf").and_then(Value::as_array) {
if v.is_null() {
return;
}
let branch = branches
.iter()
.find(|b| match v.get("by") {
Some(by) => b["properties"]["by"]["const"] == *by,
None => b.get("type") != Some(&json!("null")),
})
.unwrap_or_else(|| panic!("{def} at {at}: no branch of the schema fits {v}"));
return walk(root, branch, def, v, at, out);
}
match v {
Value::Object(o) => {
if let Some(props) = s.get("properties").and_then(Value::as_object) {
let required: Vec<&str> = s["required"]
.as_array()
.map(|r| r.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
for (k, x) in o {
let ps = props
.get(k)
.unwrap_or_else(|| panic!("{def}: `{k}` is written but not declared"));
out.push(Site {
def: def.into(),
key: k.clone(),
schema: ps.clone(),
required: required.contains(&k.as_str()),
at: at.into(),
});
walk(
root,
ps,
&format!("{def}.{k}"),
x,
&format!("{at}/{}", escape(k)),
out,
);
}
} else if let Some(ap) = s.get("additionalProperties").filter(|a| a.is_object()) {
for (k, x) in o {
out.push(Site {
def: def.into(),
key: "*".into(),
schema: ap.clone(),
required: false,
at: format!("{at}/{}", escape(k)),
});
walk(root, ap, def, x, &format!("{at}/{}", escape(k)), out);
}
}
}
Value::Array(a) => {
if let Some(items) = s.get("items").filter(|i| i.is_object()) {
for (i, x) in a.iter().enumerate() {
walk(root, items, def, x, &format!("{at}/{i}"), out);
}
}
}
_ => {}
}
}
/// Every member the schema declares, as [`walk`] names them.
fn declared(root: &Value) -> BTreeSet<(String, String)> {
let mut out = BTreeSet::new();
let object = |def: &str, s: &Value, out: &mut BTreeSet<(String, String)>| {
let mut shapes = vec![s];
if let Some(b) = s.get("oneOf").and_then(Value::as_array) {
shapes.extend(b.iter());
}
for shape in shapes {
for (k, p) in shape
.get("properties")
.and_then(Value::as_object)
.into_iter()
.flatten()
{
out.insert((def.to_string(), k.clone()));
if p.get("additionalProperties").is_some_and(Value::is_object) {
out.insert((format!("{def}.{k}"), "*".into()));
}
}
}
};
object("#", root, &mut out);
for (name, s) in root["$defs"].as_object().unwrap() {
object(name, s, &mut out);
}
out
}
/// A value of a type the member does not take.
fn wrong(root: &Value, s: &Value) -> Value {
if let Some(r) = s.get("$ref").and_then(Value::as_str) {
return wrong(root, &root["$defs"][r.trim_start_matches("#/$defs/")]);
}
if let Some(b) = s.get("oneOf").and_then(Value::as_array) {
// An author (objects) takes no number; an optional id (null or an id) no string.
return if b.iter().any(|x| x.get("type") == Some(&json!("object"))) {
json!(7)
} else {
json!("x")
};
}
match s.get("const") {
Some(Value::Number(_)) => return json!("1"),
Some(Value::String(_)) => return json!(12345),
_ => {}
}
if s.get("enum").is_some() {
return json!(7);
}
match s["type"].as_str() {
Some("string") => json!(12345),
Some("integer") | Some("number") => json!("x"),
Some("boolean") => json!("yes"),
Some("array") => json!({}),
// Not an array: serde reads a struct from an array of its members (its sequence form),
// a leniency the prose states and the corpus shows (object-as-array.flowrs).
Some("object") => json!("x"),
other => panic!("no wrong value for a schema of type {other:?}: {s}"),
}
}
#[test]
fn the_committed_schema_is_the_one_the_code_generates_and_a_2020_12_schema() {
holds(spec::SCHEMA_FILE, &spec::schema());
let s = schema();
jsonschema::meta::validate(&s).expect("the schema meets draft 2020-12's own meta-schema");
assert_eq!(s["$schema"], "https://json-schema.org/draft/2020-12/schema");
assert_eq!(s["$id"], spec::SCHEMA_URL);
assert!(
s["$comment"]
.as_str()
.unwrap()
.contains(&format!("specification {}", spec::SPEC_VERSION))
);
}
#[test]
fn a_document_with_every_member_writes_exactly_what_the_schema_declares() {
let doc = every_member();
let mut text = doc.to_json();
text.push('\n');
holds("spec/conformance/flowrs/every-member.flowrs", &text);
let value: Value = serde_json::from_str(&text).unwrap();
let v = validator();
let errors: Vec<String> = v
.iter_errors(&value)
.map(|e| format!("{e} at {}", e.instance_path()))
.collect();
assert!(errors.is_empty(), "{errors:#?}");
assert_eq!(reader(&text), "accepts");
assert!(canonical(&text), "the document is not in canonical form");
let root = schema();
let mut sites = Vec::new();
walk(&root, &root, "#", &value, "", &mut sites);
let seen: BTreeSet<(String, String)> = sites
.iter()
.map(|s| (s.def.clone(), s.key.clone()))
.collect();
let want = declared(&root);
let unwritten: Vec<_> = want.difference(&seen).collect();
assert!(
unwritten.is_empty(),
"declared but never written by the document that sets every member: {unwritten:?}"
);
let undeclared: Vec<_> = seen.difference(&want).collect();
assert!(
undeclared.is_empty(),
"written but not declared: {undeclared:?}"
);
// Each kind of author appears, and each mode.
for a in spec::authors() {
let (by, _) = spec::author_shape(&a);
assert!(
text.contains(&format!("\"by\": \"{by}\"")),
"no {by} author"
);
}
for m in spec::WIRE_MODES {
assert!(text.contains(&format!("\"mode\": \"{}\"", spec::mode_name(m))));
}
}
#[test]
fn the_schema_and_the_reader_agree_on_every_member_dropped_or_mistyped() {
let root = schema();
let v = validator();
let value: Value = serde_json::from_str(&every_member().to_json()).unwrap();
let mut sites = Vec::new();
walk(&root, &root, "#", &value, "", &mut sites);
let mut done = BTreeSet::new();
let mut checked = 0;
let mut problems = Vec::new();
for s in &sites {
if !done.insert((s.def.clone(), s.key.clone())) {
continue;
}
let mut cases: Vec<(&str, Value, bool)> = Vec::new();
if s.key != "*" {
let mut dropped = value.clone();
dropped
.pointer_mut(&s.at)
.and_then(Value::as_object_mut)
.unwrap()
.remove(&s.key);
cases.push(("dropped", dropped, !s.required));
}
let mut mistyped = value.clone();
let slot = if s.key == "*" {
mistyped.pointer_mut(&s.at).unwrap()
} else {
mistyped
.pointer_mut(&format!("{}/{}", s.at, escape(&s.key)))
.unwrap()
};
*slot = wrong(&root, &s.schema);
cases.push(("mistyped", mistyped, false));
for (what, doc, readable) in cases {
checked += 1;
let text = serde_json::to_string(&doc).unwrap();
let (by_reader, by_schema) = (reader(&text) == "accepts", v.is_valid(&doc));
if by_reader != readable || by_schema != readable {
problems.push(format!(
"{}.{} {what}: the reader {}, the schema {}, the prose says {}",
s.def,
s.key,
if by_reader { "accepts" } else { "refuses" },
if by_schema { "accepts" } else { "refuses" },
if readable { "readable" } else { "refused" },
));
}
}
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
assert!(checked >= 60, "only {checked} cases");
}
#[derive(serde::Deserialize)]
struct Manifest {
spec: String,
flowrs: Vec<DocCase>,
fdsl: Vec<TextCase>,
}
#[derive(serde::Deserialize)]
struct DocCase {
file: String,
reader: String,
schema: bool,
#[serde(default)]
canonical: Option<bool>,
why: String,
}
#[derive(serde::Deserialize)]
struct TextCase {
file: String,
parses: bool,
#[serde(default)]
error: Option<String>,
why: String,
}
fn manifest() -> Manifest {
serde_json::from_str(&read("spec/conformance/manifest.json")).unwrap()
}
/// The files under `dir` with extension `ext`, sorted, from the repository's root.
fn listed(dir: &str, ext: &str) -> Vec<String> {
let mut out: Vec<String> = std::fs::read_dir(root().join(dir))
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == ext))
.map(|p| {
p.strip_prefix(root().join("spec/conformance"))
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect();
out.sort();
out
}
#[test]
fn the_conformance_documents_are_read_as_the_manifest_says() {
let m = manifest();
assert_eq!(m.spec, spec::PROSE_FILE);
let mut files: Vec<String> = m.flowrs.iter().map(|c| c.file.clone()).collect();
files.sort();
assert_eq!(
files,
listed("spec/conformance/flowrs", "flowrs"),
"every corpus document is in the manifest, once"
);
let v = validator();
let mut problems = Vec::new();
for c in &m.flowrs {
assert!(!c.why.trim().is_empty(), "{}: no why", c.file);
let text = read(&format!("spec/conformance/{}", c.file));
let got = reader(&text);
if got != c.reader {
problems.push(format!("{}: the reader {got}, not {}", c.file, c.reader));
}
if conforms(&v, &text) != c.schema {
problems.push(format!(
"{}: the schema {}",
c.file,
if c.schema { "refuses it" } else { "accepts it" }
));
}
match (c.reader.as_str(), c.canonical) {
("accepts", Some(want)) => {
if canonical(&text) != want {
problems.push(format!("{}: canonical is not {want}", c.file));
}
}
("accepts", None) => problems.push(format!("{}: canonical is not stated", c.file)),
(_, Some(_)) => problems.push(format!("{}: canonical of a refused file", c.file)),
_ => {}
}
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
// Each disagreement the schema cannot help is in the corpus: a document the schema accepts
// and the reader refuses, and one the reader accepts and the schema refuses.
assert!(m.flowrs.iter().any(|c| c.schema && c.reader != "accepts"));
assert!(m.flowrs.iter().any(|c| !c.schema && c.reader == "accepts"));
}
/// The first error's place in `text`, or `None` when it parses.
fn first_error(text: &str) -> Option<String> {
dsl::parse_graph(text)
.err()
.map(|e| format!("{}:{}", e[0].line, e[0].col))
}
/// Graph text → graph → text → graph is the identity on what the graph computes, with and
/// without the printer's type comments.
fn round_trips(text: &str) -> Result<(), String> {
let g = dsl::parse_graph(text).map_err(|e| format!("{e:?}"))?.graph;
for printed in [
dsl::print(&g, flowrs_core::graph::widget::builtin_registry()),
dsl::print_plain(&g),
] {
let back = dsl::parse_graph(&printed)
.map_err(|e| format!("the printed text does not parse: {e:?}\n{printed}"))?
.graph;
if dsl::semantics(&back) != dsl::semantics(&g) {
return Err(format!(
"the printed text computes something else:\n{printed}"
));
}
}
Ok(())
}
#[test]
fn the_conformance_texts_are_read_as_the_manifest_says() {
let m = manifest();
let mut files: Vec<String> = m.fdsl.iter().map(|c| c.file.clone()).collect();
files.sort();
assert_eq!(files, listed("spec/conformance/fdsl", "fdsl"));
let mut problems = Vec::new();
for c in &m.fdsl {
assert!(!c.why.trim().is_empty(), "{}: no why", c.file);
let text = read(&format!("spec/conformance/{}", c.file));
match (c.parses, first_error(&text), &c.error) {
(true, None, None) => {
if let Err(e) = round_trips(&text) {
problems.push(format!("{}: {e}", c.file));
}
}
(false, Some(at), Some(want)) if &at == want => {}
(want, got, at) => problems.push(format!(
"{}: the manifest says parses={want} {at:?}, the parser {got:?}",
c.file
)),
}
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
// The text of every form carries an app, and the app is read.
let every = dsl::parse_graph(&read("spec/conformance/fdsl/every-form.fdsl")).unwrap();
assert!(every.app.unwrap().contains("Heading(\"Every statement\")"));
assert!(every.graph.groups.len() >= 3 && every.graph.wires.values().any(|w| w.feedback));
}
/// Every `.flowrs` and `.fdsl` file in the repository but the conformance corpus (which holds
/// refused ones on purpose), and the generated and vendored trees.
fn repository_files(ext: &str) -> Vec<PathBuf> {
const SKIP: &[&str] = &[
"target",
"node_modules",
".git",
"dist",
"test-results",
"conformance",
"vendor",
];
fn go(dir: &Path, ext: &str, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
let name = e.file_name().to_string_lossy().into_owned();
if p.is_dir() {
if !SKIP.contains(&name.as_str()) {
go(&p, ext, out);
}
} else if p.extension().is_some_and(|x| x == ext) {
out.push(p);
}
}
}
let mut out = Vec::new();
go(&root(), ext, &mut out);
out.sort();
out
}
#[test]
#[ignore = "counts the documents of flowRS's whole repository, most of which are not here"]
fn every_document_in_the_repository_conforms() {
let v = validator();
let files = repository_files("flowrs");
let mut problems = Vec::new();
for f in &files {
let text = std::fs::read_to_string(f).unwrap();
let rel = f.strip_prefix(root()).unwrap().display().to_string();
if reader(&text) != "accepts" {
problems.push(format!("{rel}: the reader {}", reader(&text)));
continue;
}
let value: Value = serde_json::from_str(&text).unwrap();
for e in v.iter_errors(&value) {
problems.push(format!("{rel}: {e} at {}", e.instance_path()));
}
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
assert!(files.len() >= 40, "only {} documents found", files.len());
}
#[test]
fn every_graph_text_in_the_repository_parses_and_round_trips() {
let files = repository_files("fdsl");
let mut problems = Vec::new();
for f in &files {
let rel = f.strip_prefix(root()).unwrap().display().to_string();
if let Err(e) = round_trips(&std::fs::read_to_string(f).unwrap()) {
problems.push(format!("{rel}: {e}"));
}
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
assert!(files.len() >= 40, "only {} texts found", files.len());
}
// -------------------------------------------------------------------------------------
// The prose.
// -------------------------------------------------------------------------------------
fn prose() -> String {
read(spec::PROSE_FILE)
}
/// The rows of the table after the line `<!-- {marker} -->`, its header and rule left out,
/// each a list of its cells.
fn table(md: &str, marker: &str) -> Vec<Vec<String>> {
let tag = format!("<!-- {marker} -->");
let at = md
.find(&tag)
.unwrap_or_else(|| panic!("the prose has no {tag}"));
md[at + tag.len()..]
.lines()
.skip_while(|l| l.trim().is_empty())
.take_while(|l| l.trim_start().starts_with('|'))
.skip(2)
.map(|l| {
let l = l.trim().trim_start_matches('|').trim_end_matches('|');
l.split('|').map(|c| c.trim().to_string()).collect()
})
.collect()
}
fn unquote(cell: &str) -> String {
cell.trim().trim_matches('`').to_string()
}
/// A member's type as the prose's tables write it.
fn type_word(s: &Value) -> String {
if let Some(r) = s.get("$ref").and_then(Value::as_str) {
return r.trim_start_matches("#/$defs/").to_string();
}
if let Some(c) = s.get("const") {
return c.to_string();
}
if s.get("enum").is_some() {
return "enum".into();
}
if let Some(b) = s.get("oneOf").and_then(Value::as_array) {
let other = b
.iter()
.find(|x| x.get("type") != Some(&json!("null")))
.unwrap();
return format!("{} or null", type_word(other));
}
match s["type"].as_str().unwrap_or_default() {
"integer" if s["maximum"] == json!(u32::MAX) => "u32".into(),
"array" => format!("array of {}", type_word(&s["items"])),
"object" if s["additionalProperties"].is_object() => {
format!("map of {}", type_word(&s["additionalProperties"]))
}
t => t.to_string(),
}
}
#[test]
fn the_prose_tables_are_the_schemas_members_types_and_required_flags() {
let md = prose();
let root = schema();
let mut covered = BTreeSet::new();
for (marker, def) in [
("schema: #", "#"),
("schema: #/$defs/node", "node"),
("schema: #/$defs/wire", "wire"),
("schema: #/$defs/group", "group"),
("schema: #/$defs/function", "function"),
("schema: #/$defs/node_origin", "node_origin"),
("schema: #/$defs/app", "app"),
("schema: #/$defs/app_turn", "app_turn"),
("schema: #/$defs/panel", "panel"),
("schema: #/$defs/card", "card"),
] {
let s = if def == "#" {
&root
} else {
&root["$defs"][def]
};
let props = s["properties"].as_object().unwrap();
let required: Vec<&str> = s["required"]
.as_array()
.map(|r| r.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let rows = table(&md, marker);
let names: Vec<String> = rows.iter().map(|r| unquote(&r[0])).collect();
// The schema's order, which is the canonical form's.
let want: Vec<String> = props.keys().cloned().collect();
let mut sorted_names = names.clone();
sorted_names.sort();
let mut sorted_want = want.clone();
sorted_want.sort();
assert_eq!(sorted_names, sorted_want, "{def}: the members");
for r in &rows {
let name = unquote(&r[0]);
assert_eq!(r[1], type_word(&props[&name]), "{def}.{name}: the type");
assert_eq!(
r[2] == "yes",
required.contains(&name.as_str()),
"{def}.{name}: required"
);
assert!(r[2] == "yes" || r[2] == "no", "{def}.{name}: {:?}", r[2]);
assert!(!r[3].is_empty(), "{def}.{name}: no meaning");
}
covered.insert(def.to_string());
}
// Every object the schema defines is either tabled or one of the shapes stated in words.
for (name, s) in root["$defs"].as_object().unwrap() {
if s.get("properties").is_some() {
assert!(covered.contains(name), "no table for {name}");
}
}
// The modes, as the code spells and means them.
let modes = table(&md, "modes");
assert_eq!(modes.len(), spec::WIRE_MODES.len());
for (row, m) in modes.iter().zip(spec::WIRE_MODES) {
assert_eq!(unquote(&row[0]), spec::mode_name(m));
assert_eq!(row[1], spec::mode_meaning(m));
}
let modes_in_schema: Vec<&str> = root["$defs"]["wire"]["properties"]["mode"]["enum"]
.as_array()
.unwrap()
.iter()
.filter_map(Value::as_str)
.collect();
assert_eq!(
modes_in_schema,
spec::WIRE_MODES
.iter()
.map(|m| spec::mode_name(*m))
.collect::<Vec<_>>()
);
// The authors: each tag and the members it carries.
let authors = table(&md, "authors");
assert_eq!(authors.len(), spec::authors().len());
for (row, a) in authors.iter().zip(spec::authors()) {
let (by, members) = spec::author_shape(&a);
assert_eq!(unquote(&row[0]), by);
let listed: Vec<String> = if row[1] == "none" {
Vec::new()
} else {
row[1].split(',').map(unquote).collect()
};
assert_eq!(listed, members, "{by}");
}
// The skins.
assert_eq!(
root["$defs"]["app"]["properties"]["skin"]["enum"],
json!(spec::SKINS)
);
for skin in spec::SKINS {
assert!(md.contains(&format!("`\"{skin}\"`")), "{skin}");
}
}
/// The fenced blocks of the prose: (info string, contents).
fn blocks(md: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut lines = md.lines();
while let Some(l) = lines.next() {
if let Some(info) = l.strip_prefix("```") {
let mut body = String::new();
for b in lines.by_ref() {
if b.starts_with("```") {
break;
}
body.push_str(b);
body.push('\n');
}
out.push((info.trim().to_string(), body));
}
}
out
}
#[test]
fn the_prose_examples_load_parse_round_trip_or_fail_where_it_says() {
let md = prose();
let v = validator();
let mut documents = Vec::new();
let mut texts = Vec::new();
let mut refused = 0;
for (info, body) in blocks(&md) {
let words: Vec<&str> = info.split_whitespace().collect();
match words.as_slice() {
["json", "flowrs"] => {
assert_eq!(reader(&body), "accepts", "{body}");
assert!(conforms(&v, &body), "the schema refuses the example");
assert!(canonical(&body), "the example is not canonical");
documents.push(body);
}
["fdsl"] => {
round_trips(&body).unwrap_or_else(|e| panic!("{e}\n{body}"));
texts.push(body);
}
["fdsl", "refused", at] => {
assert_eq!(first_error(&body).as_deref(), Some(*at), "{body}");
refused += 1;
}
["ebnf"] => {}
other => panic!("a block the test does not read: {other:?}"),
}
}
assert!(!documents.is_empty() && texts.len() >= 2 && refused >= 3);
// The document example is the corpus's, and the first text is the same graph.
assert_eq!(
documents[0],
read("spec/conformance/flowrs/add-two-numbers.flowrs")
);
let doc = Document::from_json(&documents[0]).unwrap().into_graph();
let text = dsl::parse_graph(&texts[0]).unwrap().graph;
assert_eq!(dsl::semantics(&doc), dsl::semantics(&text));
assert_eq!(texts[0], read("spec/conformance/fdsl/add.fdsl"));
}
#[test]
fn the_prose_escapes_keywords_punctuation_and_modifiers_are_the_languages() {
use flowrs_core::graph::dsl::lex::{Tok, lex};
let md = prose();
// Keywords: the parser's list, in its order.
let tag = "<!-- keywords -->";
let line = md[md.find(tag).unwrap() + tag.len()..]
.lines()
.find(|l| !l.trim().is_empty())
.unwrap();
let keywords: Vec<String> = line.split_whitespace().map(unquote).collect();
assert_eq!(keywords, dsl::parse::KEYWORDS);
// The contextual words: not reserved (a node may still be called by one), and each begins
// the statement the grammar gives it.
let tag = "<!-- contextual -->";
let line = md[md.find(tag).unwrap() + tag.len()..]
.lines()
.find(|l| !l.trim().is_empty())
.unwrap();
let contextual: Vec<String> = line.split_whitespace().map(unquote).collect();
assert_eq!(contextual, ["fn", "out"]);
for w in &contextual {
assert!(
!dsl::parse::KEYWORDS.contains(&w.as_str()),
"{w} is reserved"
);
assert!(
dsl::parse_graph(&format!(
"let {w} = FloatLiteral {{ value: 1 }};
let n1 = io.print(value: {w}.value);"
))
.is_ok(),
"{w} cannot be a handle"
);
}
let f = dsl::parse_graph("fn f@1(x: f64) -> (y: f64) {\n out y = x.value;\n}").unwrap();
assert_eq!(f.graph.functions.len(), 1);
// Escapes: exactly the listed ones decode, each to what the prose says.
let rows = table(&md, "escapes");
let means = |w: &str| -> Option<char> {
Some(match w {
"line feed" => '\n',
"tab" => '\t',
"carriage return" => '\r',
"the NUL character" => '\0',
"a backslash" => '\\',
"a double quote" => '"',
"a single quote" => '\'',
_ => return None,
})
};
let mut listed = BTreeSet::new();
for r in &rows {
let esc = unquote(&r[0]);
let c = esc.chars().nth(1).unwrap();
listed.insert(c);
if c == 'u' {
assert_eq!(
lex(r#""\u{e9}""#).unwrap()[0].tok,
Tok::Str("é".into()),
"\\u{{…}}"
);
continue;
}
let want = means(&r[1]).unwrap_or_else(|| panic!("{:?} means nothing known", r[1]));
assert_eq!(
lex(&format!("\"\\{c}\"")).unwrap()[0].tok,
Tok::Str(want.to_string()),
"{esc}"
);
}
for c in (0x20u8..0x7f).map(char::from) {
let ok = lex(&format!("\"a\\{c}b\"")).is_ok();
assert_eq!(ok, listed.contains(&c) && c != 'u', "the escape \\{c}");
}
// Punctuation: each listed one is a token; every other ASCII symbol but those that open a
// string, a comment, a number or a name is an error.
let bullet = md.lines().find(|l| l.contains("**Punctuation**")).unwrap();
let punct: Vec<String> = bullet
.split('`')
.skip(1)
.step_by(2)
.map(str::to_string)
.collect();
assert_eq!(punct.len(), 15, "{punct:?}");
for p in &punct {
let t = lex(p).unwrap();
assert_eq!(t.len(), 2, "{p} is one token");
}
for c in (0x21u8..0x7f).map(char::from) {
if c.is_ascii_alphanumeric() || ['"', '_', '-', '/'].contains(&c) {
continue;
}
let one = punct.iter().any(|p| p == &c.to_string());
assert_eq!(lex(&c.to_string()).is_ok(), one, "the character {c}");
}
// Modifiers: the grammar's, and no other word.
let ebnf = blocks(&md)
.into_iter()
.find(|(i, _)| i == "ebnf")
.unwrap()
.1;
let rule = ebnf
.lines()
.find(|l| l.trim_start().starts_with("modifier"))
.unwrap();
let modifiers: Vec<&str> = rule.split('"').skip(1).step_by(2).collect();
assert_eq!(modifiers, ["clone", "copy", "prev"]);
let with = |m: &str| {
format!("let n0 = FloatLiteral {{ value: 1 }};\nlet n1 = io.print(value: n0.value.{m}());")
};
for m in &modifiers {
assert!(dsl::parse_graph(&with(m)).is_ok(), ".{m}()");
}
for m in ["borrow", "move", "next", "mut", "last", "hold", "ref"] {
assert!(dsl::parse_graph(&with(m)).is_err(), ".{m}() parses");
}
// Every terminal of the grammar is a keyword, a modifier, `_`, `mut`, or punctuation.
for t in ebnf.split('"').skip(1).step_by(2) {
let known = keywords.iter().any(|k| k == t)
|| contextual.iter().any(|k| k == t)
|| modifiers.contains(&t)
|| t == "_"
|| t == "mut"
|| punct.iter().any(|p| p == t);
assert!(known, "the grammar's terminal {t:?}");
}
}
#[test]
fn the_older_spellings_are_the_ones_read() {
use flowrs_core::graph::schedule::legacy_spellings;
let rows = table(&prose(), "legacy");
let code = legacy_spellings();
assert_eq!(rows.len(), code.len());
for (row, (old, base, sched)) in rows.iter().zip(&code) {
let quoted = |cell: &str| -> Vec<String> {
cell.split('`')
.skip(1)
.step_by(2)
.map(str::to_string)
.collect()
};
assert_eq!(quoted(&row[0]), [*old]);
assert_eq!(quoted(&row[1]), [*base, "schedule", sched.as_str()]);
let text = format!(
r#"{{"format": 1, "nodes": [{{"id": 0, "type_id": "{old}", "properties": {{}}}}], "wires": []}}"#
);
let g = flowrs_core::graph::document::load(&text).unwrap();
let n = &g.nodes[&NodeId(0)];
assert_eq!(n.type_id, *base);
assert_eq!(n.properties["schedule"], sched.as_str());
}
}
#[test]
fn the_fingerprint_is_the_one_the_prose_states() {
let md = prose();
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
assert!(md.contains("`0xcbf29ce484222325`") && md.contains("`0x100000001b3`"));
// Written from the prose, not from flowrs-core's function.
let fnv = |parts: &[String]| {
let mut h = OFFSET;
for p in parts {
for b in p.bytes().chain([0xffu8]) {
h ^= u64::from(b);
h = h.wrapping_mul(PRIME);
}
}
format!("{h:016x}")
};
let reg = flowrs_core::graph::widget::WidgetRegistry::with_builtins();
let mut checked = 0;
for w in reg.all() {
let mut parts = vec![w.id()];
for (kind, ports) in [("in", w.inputs()), ("out", w.outputs())] {
parts.push(kind.into());
for p in ports {
parts.push(p.name.clone());
parts.push(p.semantic_type.clone());
parts.push(if p.variadic { "variadic" } else { "single" }.into());
}
}
for p in w.parameters() {
parts.push(p.name.clone());
parts.push(p.ty.clone());
parts.push(p.default.clone());
}
assert_eq!(
fnv(&parts),
flowrs_core::graph::widget::fingerprint(w),
"{}",
w.id()
);
checked += 1;
}
assert!(checked > 100, "{checked} node types");
}
/// The prose's function example, saved, is the corpus's format-2 document; and it computes what
/// the prose says it does.
#[test]
fn the_prose_function_example_saved_is_the_corpus_document() {
let text = blocks(&prose())
.into_iter()
.find(|(i, b)| i == "fdsl" && b.contains("fn calibrate@1"))
.expect("the prose has a function example")
.1;
assert_eq!(text, read("spec/conformance/fdsl/function.fdsl"));
let g = dsl::parse_graph(&text).unwrap().graph;
let mut doc = Document::from_graph(&g);
doc.generator = "spec/conformance: a function and its calls".into();
assert_eq!(doc.format, FORMAT);
let mut json = doc.to_json();
json.push('\n');
holds("spec/conformance/flowrs/functions.flowrs", &json);
let back = Document::from_json(&json).unwrap().into_graph();
assert_eq!(dsl::semantics(&back), dsl::semantics(&g));
let mut ex =
flowrs_core::graph::GraphExecutor::builtin(flowrs_core::graph::runtime::Runtime::new());
let steps = ex.run(&back).unwrap();
let at = |id: u64| match steps
.iter()
.find(|s| s.node_id == NodeId(id))
.unwrap()
.outputs["volts"]
{
flowrs_core::graph::Value::Float(f) => f,
ref other => panic!("{other:?}"),
};
assert!(
(at(1) - 3.0).abs() < 1e-12 && (at(2) - 2.9).abs() < 1e-12,
"{} {}",
at(1),
at(2)
);
}
/// The prose's table of function node types is the code's: their names, the properties a
/// boundary node reads, and its one port.
#[test]
fn the_prose_function_nodes_are_the_codes() {
use flowrs_core::graph::function as f;
let rows = table(&prose(), "function nodes");
let types: Vec<String> = rows.iter().map(|r| unquote(&r[0])).collect();
assert_eq!(types, [f::INPUT, f::OUTPUT, f::CALL]);
let props = |cell: &str| -> Vec<String> {
cell.split('`')
.skip(1)
.step_by(2)
.map(str::to_string)
.collect()
};
assert_eq!(props(&rows[0][1]), f::BOUNDARY_KEYS);
assert_eq!(
props(&rows[1][1]),
f::BOUNDARY_KEYS
.iter()
.filter(|k| **k != f::DEFAULT)
.map(|k| k.to_string())
.collect::<Vec<_>>()
);
assert_eq!(props(&rows[2][1]), [f::FN]);
assert_eq!(props(&rows[0][2]), [f::PORT]);
assert_eq!(props(&rows[1][2]), [f::PORT]);
assert!(prose().contains(&format!("more than {} deep", f::MAX_DEPTH)));
}
/// Version 1.0.0 stays as it was published: its prose and its schema are in the repository,
/// the schema still a 2020-12 schema at its own address, and every format-1 document of the
/// corpus that it accepted this version's schema accepts too.
#[test]
fn the_previous_version_is_kept_as_published() {
for p in spec::PREVIOUS {
let prose = read(p.prose);
assert!(prose.contains(&format!("Specification version {}", p.version)));
let schema: Value = serde_json::from_str(&read(p.schema)).unwrap();
jsonschema::meta::validate(&schema).expect("a 2020-12 schema");
assert_eq!(schema["$id"], p.url);
let old = jsonschema::options().build(&schema).unwrap();
let new = validator();
for c in manifest().flowrs {
let text = read(&format!("spec/conformance/{}", c.file));
if conforms(&old, &text) {
assert!(
conforms(&new, &text),
"{}: 1.0.0 accepted it, 2.0.0 does not",
c.file
);
}
}
}
}