//! 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::(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| 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, origin: NodeOrigin| Node { id: NodeId(id), type_id: type_id.into(), properties: props .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect::>(), 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, `.` 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) { 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 = 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, fdsl: Vec, } #[derive(serde::Deserialize)] struct DocCase { file: String, reader: String, schema: bool, #[serde(default)] canonical: Option, why: String, } #[derive(serde::Deserialize)] struct TextCase { file: String, parses: bool, #[serde(default)] error: Option, 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 { let mut out: Vec = 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 = 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 { 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 = 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 { const SKIP: &[&str] = &[ "target", "node_modules", ".git", "dist", "test-results", "conformance", "vendor", ]; fn go(dir: &Path, ext: &str, out: &mut Vec) { 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 ``, its header and rule left out, /// each a list of its cells. fn table(md: &str, marker: &str) -> Vec> { let tag = format!(""); 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 = rows.iter().map(|r| unquote(&r[0])).collect(); // The schema's order, which is the canonical form's. let want: Vec = 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::>() ); // 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 = 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 = ""; let line = md[md.find(tag).unwrap() + tag.len()..] .lines() .find(|l| !l.trim().is_empty()) .unwrap(); let keywords: Vec = 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 = ""; let line = md[md.find(tag).unwrap() + tag.len()..] .lines() .find(|l| !l.trim().is_empty()) .unwrap(); let contextual: Vec = 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 { 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 = 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 { 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 = rows.iter().map(|r| unquote(&r[0])).collect(); assert_eq!(types, [f::INPUT, f::OUTPUT, f::CALL]); let props = |cell: &str| -> Vec { 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::>() ); 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 ); } } } }