286 lines
11 KiB
Rust
286 lines
11 KiB
Rust
//! Compile the program the product actually emits.
|
|
//!
|
|
//! Every other codegen test in this repo compiles a *fragment*: it takes one widget's
|
|
//! `out.process`, splices it into a hand-written `fn main`, binds the inputs itself and
|
|
//! supplies its own imports. That harness silently repairs the four things the real
|
|
//! assembler can get wrong — main's signature, unbound inputs, cross-widget name
|
|
//! collisions, and glob-import conflicts between pillars — so a fragment test structurally
|
|
//! cannot catch an assembler bug.
|
|
//!
|
|
//! It did not catch one, either. `primitives_to_rust` hard-coded `fn main() {` while
|
|
//! `data.*` and `stats.*` emit dozens of `?` operators into it, and all 37 of those widgets
|
|
//! shipped programs that failed with E0277 before running. This file exists so that class
|
|
//! of bug fails here first.
|
|
//!
|
|
//! # What it can and cannot cover
|
|
//!
|
|
//! A graph is only checked if every crate its program asks for is resolvable from the
|
|
//! local registry. `image`, `imageproc` and `tract-onnx` are; **`polars` is not** — its
|
|
//! transitive `chrono` requirement cannot be satisfied offline, which rules out `data.*`
|
|
//! and `stats.*`, the very pillars the bug above lived in. Those are guarded instead by
|
|
//! `no_widget_emits_a_try_operator_into_a_unit_returning_main` in `codegen::rust`, which
|
|
//! proves the wrapper for every registered widget without compiling anything.
|
|
//!
|
|
//! Skips are printed, never silent. A test that quietly checks nothing is worse than no
|
|
//! test, because it reads as coverage.
|
|
|
|
use flowrs_core::graph::{Graph, WireMode};
|
|
|
|
/// Versions for the crates a generated program may ask for.
|
|
///
|
|
/// The emitted `// Cargo.toml needs:` line deliberately omits versions — codegen will not
|
|
/// invent one it cannot stand behind. The harness supplies them, and a crate missing from
|
|
/// this table is a skip with a named reason rather than a guess.
|
|
const DEPS: &[(&str, &str)] = &[
|
|
("image", "0.25"),
|
|
("imageproc", "0.27"),
|
|
("tract_onnx", "0.23"),
|
|
("rustfft", "6"),
|
|
];
|
|
|
|
struct Golden {
|
|
name: &'static str,
|
|
build: fn() -> Graph,
|
|
}
|
|
|
|
fn image_pipeline() -> Graph {
|
|
let mut g = Graph::new();
|
|
let load = g.add_node("image.load", 0.0, 0.0);
|
|
let gray = g.add_node("image.grayscale", 120.0, 0.0);
|
|
let blur = g.add_node("image.blur", 240.0, 0.0);
|
|
g.add_wire(load, "image", gray, "image", WireMode::Move)
|
|
.unwrap();
|
|
g.add_wire(gray, "gray", blur, "image", WireMode::Move)
|
|
.unwrap();
|
|
g
|
|
}
|
|
|
|
fn vision_pipeline() -> Graph {
|
|
let mut g = Graph::new();
|
|
let load = g.add_node("image.load", 0.0, 0.0);
|
|
let corners = g.add_node("vision.corners", 140.0, 0.0);
|
|
g.add_wire(load, "image", corners, "image", WireMode::Move)
|
|
.unwrap();
|
|
g
|
|
}
|
|
|
|
/// Two pillars in ONE program. This is the case a per-widget test can never reach: each
|
|
/// pillar's glob import is fine alone, and they may still collide when the assembler puts
|
|
/// them in the same file.
|
|
fn mixed_image_and_vision() -> Graph {
|
|
let mut g = Graph::new();
|
|
let load = g.add_node("image.load", 0.0, 0.0);
|
|
let gray = g.add_node("image.grayscale", 120.0, 0.0);
|
|
let corners = g.add_node("vision.corners", 240.0, 0.0);
|
|
g.add_wire(load, "image", gray, "image", WireMode::Move)
|
|
.unwrap();
|
|
g.add_wire(gray, "gray", corners, "image", WireMode::Move)
|
|
.unwrap();
|
|
g
|
|
}
|
|
|
|
/// The primitives implemented in this commit: boolean logic, strings, Option and Print.
|
|
/// Pure std, so it needs no dependency and compiles in a blink — which is the point: the
|
|
/// nodes a beginner reaches for first were the ones emitting `// TODO: unsupported`.
|
|
fn logic_and_strings() -> Graph {
|
|
let mut g = Graph::new();
|
|
let a = g.add_node("BoolLiteral", 0.0, 0.0);
|
|
let b = g.add_node("BoolLiteral", 0.0, 80.0);
|
|
let and = g.add_node("And", 120.0, 40.0);
|
|
let not = g.add_node("Not", 240.0, 40.0);
|
|
let s = g.add_node("StringNew", 0.0, 200.0);
|
|
let len = g.add_node("StringLen", 120.0, 200.0);
|
|
let print = g.add_node("Print", 360.0, 40.0);
|
|
g.add_wire(a, "value", and, "a", WireMode::Move).unwrap();
|
|
g.add_wire(b, "value", and, "b", WireMode::Move).unwrap();
|
|
g.add_wire(and, "result", not, "input", WireMode::Move)
|
|
.unwrap();
|
|
g.add_wire(not, "result", print, "input", WireMode::Move)
|
|
.unwrap();
|
|
g.add_wire(s, "string", len, "string", WireMode::Move)
|
|
.unwrap();
|
|
g
|
|
}
|
|
|
|
const GOLDENS: &[Golden] = &[
|
|
Golden {
|
|
name: "logic_and_strings",
|
|
build: logic_and_strings,
|
|
},
|
|
Golden {
|
|
name: "image_pipeline",
|
|
build: image_pipeline,
|
|
},
|
|
Golden {
|
|
name: "vision_pipeline",
|
|
build: vision_pipeline,
|
|
},
|
|
Golden {
|
|
name: "mixed_image_and_vision",
|
|
build: mixed_image_and_vision,
|
|
},
|
|
];
|
|
|
|
/// The crates a generated program declares it needs.
|
|
fn needed_crates(program: &str) -> Vec<String> {
|
|
program
|
|
.lines()
|
|
.find(|l| l.starts_with("// Cargo.toml needs:"))
|
|
.map(|l| {
|
|
l.trim_start_matches("// Cargo.toml needs:")
|
|
.split(',')
|
|
.map(|c| c.trim().to_string())
|
|
.filter(|c| !c.is_empty())
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
#[test]
|
|
fn every_golden_graph_emits_a_program_that_compiles() {
|
|
// A missing cargo used to SKIP and pass green. It now fails unless the run opts out
|
|
// explicitly with FLOWRS_ALLOW_MISSING_CARGO=1.
|
|
if std::process::Command::new("cargo")
|
|
.arg("-V")
|
|
.output()
|
|
.is_err()
|
|
{
|
|
assert!(
|
|
std::env::var("FLOWRS_ALLOW_MISSING_CARGO").as_deref() == Ok("1"),
|
|
"golden_graphs: no cargo on PATH; the compile check cannot pass without it \
|
|
(set FLOWRS_ALLOW_MISSING_CARGO=1 to skip on purpose)"
|
|
);
|
|
eprintln!("SKIP golden_graphs: no cargo on PATH (FLOWRS_ALLOW_MISSING_CARGO=1)");
|
|
return;
|
|
}
|
|
// One scratch crate, reused across every golden: the dependency tree is built once and
|
|
// cached in target/, so only the emitted main.rs is recompiled per graph.
|
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.and_then(|p| p.parent())
|
|
.expect("workspace root")
|
|
.to_path_buf();
|
|
let dir = std::env::var_os("CARGO_TARGET_DIR")
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or_else(|| root.join("target"))
|
|
.join("flowrs_golden_graphs");
|
|
std::fs::create_dir_all(dir.join("src")).expect("scratch crate");
|
|
|
|
let mut checked = 0;
|
|
let mut skipped: Vec<String> = Vec::new();
|
|
let mut failures: Vec<String> = Vec::new();
|
|
|
|
for g in GOLDENS {
|
|
let graph = (g.build)();
|
|
let program = match graph.to_rust() {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
failures.push(format!("{}: codegen failed: {e}", g.name));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let needs = needed_crates(&program);
|
|
let mut deps = String::new();
|
|
let mut missing = Vec::new();
|
|
for c in &needs {
|
|
match DEPS.iter().find(|(k, _)| k == c) {
|
|
// The hint carries the *import* spelling (`tract_onnx`); the crate is
|
|
// `tract-onnx`. Cargo accepts either in a manifest key, so normalise.
|
|
Some((_, v)) => deps.push_str(&format!("{} = \"{}\"\n", c.replace('_', "-"), v)),
|
|
None => missing.push(c.clone()),
|
|
}
|
|
}
|
|
if !missing.is_empty() {
|
|
skipped.push(format!(
|
|
"{} (no pinned version for: {})",
|
|
g.name,
|
|
missing.join(", ")
|
|
));
|
|
continue;
|
|
}
|
|
|
|
std::fs::write(
|
|
dir.join("Cargo.toml"),
|
|
format!(
|
|
"[workspace]\n[package]\nname = \"flowrs_golden\"\nversion = \"0.0.0\"\n\
|
|
edition = \"2021\"\n[dependencies]\n{deps}"
|
|
),
|
|
)
|
|
.expect("manifest");
|
|
std::fs::write(dir.join("src/main.rs"), &program).expect("source");
|
|
|
|
let out = std::process::Command::new("cargo")
|
|
.args(["check", "--quiet", "--offline"])
|
|
.current_dir(&dir)
|
|
.output()
|
|
.expect("cargo check");
|
|
if out.status.success() {
|
|
checked += 1;
|
|
continue;
|
|
}
|
|
let err = String::from_utf8_lossy(&out.stderr);
|
|
// An unresolvable dependency is an environment limit, not a codegen defect, but it
|
|
// is not a pass either: it FAILS unless the run opts out with
|
|
// FLOWRS_ALLOW_OFFLINE_SKIP=1, the same rule as the in-crate compile harnesses
|
|
// (`skip_for_missing_dependency`). rustc having run means the code is the problem.
|
|
// `"registry"` is deliberately not a marker: every diagnostic that cites a
|
|
// dependency's source path contains it, so it turned real errors into skips.
|
|
let lower = err.to_ascii_lowercase();
|
|
let missing_dependency = !err.contains("error[E")
|
|
&& !err.contains("could not compile")
|
|
&& [
|
|
"no matching package",
|
|
"failed to download",
|
|
"attempting to make an http request",
|
|
"failed to get",
|
|
"failed to load source",
|
|
"failed to select a version",
|
|
"offline mode",
|
|
]
|
|
.iter()
|
|
.any(|m| lower.contains(m));
|
|
if missing_dependency {
|
|
if std::env::var("FLOWRS_ALLOW_OFFLINE_SKIP").as_deref() == Ok("1") {
|
|
skipped.push(format!("{} (deps unresolvable offline)", g.name));
|
|
continue;
|
|
}
|
|
failures.push(format!(
|
|
"{}: its dependencies are not available offline, so it was not compiled; \
|
|
fetch them once with `cargo fetch --manifest-path {}`, or set \
|
|
FLOWRS_ALLOW_OFFLINE_SKIP=1 to skip on purpose\n{}",
|
|
g.name,
|
|
dir.join("Cargo.toml").display(),
|
|
err.trim()
|
|
));
|
|
continue;
|
|
}
|
|
failures.push(format!(
|
|
"{}: generated program did not compile\n{}\n--- source ---\n{}",
|
|
g.name,
|
|
err.trim(),
|
|
program
|
|
));
|
|
}
|
|
|
|
for s in &skipped {
|
|
eprintln!("SKIP golden_graphs: {s}");
|
|
}
|
|
assert!(
|
|
failures.is_empty(),
|
|
"{} of {} golden graph(s) emitted code that does not compile:\n\n{}",
|
|
failures.len(),
|
|
GOLDENS.len(),
|
|
failures.join("\n\n")
|
|
);
|
|
// If every graph skipped, the test proved nothing — fail rather than report green.
|
|
assert!(
|
|
checked > 0,
|
|
"no golden graph could be compiled ({} skipped); this test verified NOTHING",
|
|
skipped.len()
|
|
);
|
|
eprintln!(
|
|
"golden_graphs: {checked} compiled, {} skipped",
|
|
skipped.len()
|
|
);
|
|
}
|