From 32d2f52f354ee55c63afdfde5ea71ed32280624a Mon Sep 17 00:00:00 2001 From: psy_inf Date: Sat, 19 Sep 2026 22:41:51 +0200 Subject: [PATCH] feat: fan-in groups read graph inputs directly A group entry may now be `in` or `in.`, positional or named: `(plots: plots, ticks: in.ticks) -> Tracker -> out`. Both parsers accept the `.key`; buildStatement spells it `in.`, registers the input in GraphProgram::inputs like a single source does, and rejects `x.key`, `out` and `end` inside a group. An undeclared input read only by a merge slot takes the slot's type. The namedPipeline example gets a section for it, and README, EXAMPLE.md and CHANGELOG describe it. --- CHANGELOG.md | 11 +- EXAMPLE.md | 41 ++++- README.md | 17 ++- apps/namedPipeline/main.cpp | 17 +++ .../core/filterGraph/DslFilterGraph.hpp | 10 +- .../core/filterGraph/GraphLang.hpp | 84 ++++++++-- .../core/filterGraph/GraphLangHandwritten.hpp | 21 ++- tests/FilterGraphTests/FilterGraphTests.cpp | 29 ++++ tests/FilterGraphTests/GraphInputsTests.cpp | 144 +++++++++++++++++- 9 files changed, 345 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac0d86..c4c0ecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,8 +65,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 a merge whose same-typed slots are matched by name (`mergeInputNames()`), a lambda merge with named slots (`registerTypedMergeFilter` with names), a graph with several named inputs fed by `push()` and `filter(GraphInputs)`, the - build-time and feed-time checks the names enable, and `toMermaid` labelling a - merge's edges with its slot names. EXAMPLE.md section 10 walks through it; the + build-time and feed-time checks the names enable, `toMermaid` labelling a + merge's edges with its slot names, and a merge reading a named input directly. + EXAMPLE.md section 10 walks through it; the named-slot and named-input blocks move there from `apps/textPipeline`. - **Named merge slots** — a fan-in group can name the slots of a merge, `(raw: msg, checked: valid) -> Merge`, and is then matched by name, in any @@ -90,7 +91,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 overloads, and they are checked when the graph is built; an undeclared input takes the type of its first reader. Feeding an unknown key throws `std::out_of_range`, a value of the wrong type `std::invalid_argument`. - `dsl::GraphProgram::inputs` lists the inputs a program reads. + `dsl::GraphProgram::inputs` lists the inputs a program reads. A fan-in group + can read `in` and `in.` directly, positional or named + (`(plots: plots, ticks: in.ticks) -> Tracker`), with no pass-through stage + to copy the input onto an edge first. An undeclared input read only by a + group takes the type of its slot. - **`TODO.md`** — the planned work, with what the library does today, the gap, an API sketch and the workaround for each item; the README roadmap summarizes it. diff --git a/EXAMPLE.md b/EXAMPLE.md index d026ce7..7fbfa4f 100644 --- a/EXAMPLE.md +++ b/EXAMPLE.md @@ -8,7 +8,7 @@ them small text pipelines: | 1–7 | [`textPipeline`](apps/textPipeline/main.cpp) | the core building blocks: a **compile-time** `FilterGraph`, **runtime graphs in the text DSL** (a tap, a merge, several named outputs, up-front diagnostics) and the same pipeline in the **JSON format** | | 8 | [`statefulPipeline`](apps/statefulPipeline/main.cpp) | stages that **carry state**: `finish()`, the `GraphContext`, a merge with per-instance state | | 9 | [`compositePipeline`](apps/compositePipeline/main.cpp) | **composition**: `JoinFilter`, a graph nested as a stage, a `Void` sink, in-band ticks | -| 10 | [`namedPipeline`](apps/namedPipeline/main.cpp) | **names in the wiring**: named merge slots, several named graph inputs, and the checks both make possible | +| 10 | [`namedPipeline`](apps/namedPipeline/main.cpp) | **names in the wiring**: named merge slots, several named graph inputs (also read directly by a merge), and the checks both make possible | Start at the top: sections 8–10 assume the vocabulary of 1–7. @@ -933,6 +933,41 @@ flowchart LR s2 --> e4 ``` +### A merge reading an input directly + +Only the name needs preparing; the greeting can go into `Greet` as it arrives. +A group reads `in.` (or plain `in`) like any other edge, so no stage has +to copy the input onto an edge of its own first: + +```cpp +DslFilterGraph greet(R"dsl( + in.name -> Capitalize -> proper + (greeting: in.greeting, name: proper) -> Greet -> out +)dsl"); + +greet.push("greeting", std::string{"Welcome"}); +greet.filter(GraphInputs{}.set("greeting", std::string{"Welcome"}).set("name", std::string{"lINUS"})); +std::cout << dsl::toAscii(greet.program()); +``` + +Nothing is declared here, so `in.greeting` takes the type of the `greeting` +slot it feeds. As before, the unfed input is a hole: + +```text +[direct] Welcome, stranger! +[direct] Welcome, Linus! + +in.name +`-> Capitalize -> proper + `-> Greet (merge, see below) + +in.greeting +`-> Greet (merge, see below) + +(greeting: in.greeting, name: proper) +`-> Greet -> out +``` + ## Build & run these examples ```powershell @@ -1034,5 +1069,5 @@ And of `namedPipeline` (section 10): [greet] HI, Grace! ``` -followed by the Mermaid flowchart shown in section 10. (The `[feed check]` type -name is shortened here too.) +followed by the Mermaid flowchart and the `[direct]` block shown in section 10. +(The `[feed check]` type name is shortened here too.) diff --git a/README.md b/README.md index 27361ed..783d37a 100644 --- a/README.md +++ b/README.md @@ -247,8 +247,8 @@ edge -> Stage -> edge -> Stage(key=value) -> edge | Name | Meaning | | --- | --- | - | `in` | the graph's input; may only start a statement | - | `in.` | a named input, for a graph with several (see [Several named inputs](#several-named-inputs)) | + | `in` | the graph's input; may only start a statement or appear in a fan-in group | + | `in.` | a named input, for a graph with several (see [Several named inputs](#several-named-inputs)); placed like `in` | | `out` | the graph's output; may only end a statement | | `out.` | a named output (several outputs are ordered as written) | | `end` | a dead end: the value is discarded (required for `Void` stages) | @@ -262,6 +262,8 @@ edge -> Stage -> edge -> Stage(key=value) -> edge `registerMergeFilter(name, combiner)` does. A group may name the slots of a merge that declares names, `(raw: msg, checked: valid) -> Merge`, and is then matched by name, in any order (see [Named slots](#named-slots)). + A group may read the graph's input directly, `(in, valid)` or + `(quote: in.quotes, order: order)`; `out` and `end` cannot appear in a group. - `#` starts a comment that runs to the end of the line. ### How a graph runs @@ -399,11 +401,20 @@ matcher.filter(GraphInputs{}.set("orders", order).set("quotes", quote)); // one - One `push` or `filter` is one run. An input not fed in that run is empty, exactly like a dropped path: the stages reading it are skipped, and a merge sees a hole. +- A merge can read named inputs directly, so a stage that reacts to two kinds + of message needs no pass-through stage in front of it: + + ```text + in.plots -> DropDuplicatePlots -> plots + (plots: plots, ticks: in.ticks) -> GatedTracker -> out + ``` + + Each run feeds one input, and the merge sees a hole in the other slot. - `GraphInputs::of(keys...)` declares the input types, which are then checked like every other edge when the graph is built; a declared input the graph never reads, and a read input that is not declared, are diagnostics. Without a declaration, an input takes the type of the first stage that reads - it. + it (or, for a merge, of the slot it feeds). - Feeding an unknown key throws `std::out_of_range`; a value whose type differs from the input's (declared or inferred) type throws `std::invalid_argument`. - A graph uses either `in` or named inputs: `in` with `GraphInputs`, or diff --git a/apps/namedPipeline/main.cpp b/apps/namedPipeline/main.cpp index cad934c..c0107dd 100644 --- a/apps/namedPipeline/main.cpp +++ b/apps/namedPipeline/main.cpp @@ -5,6 +5,7 @@ // - several named graph inputs, `in.`, fed by push() or filter() // - the build-time and run-time checks that the names make possible // - toMermaid, which labels a merge's edges with its slot names +// - a merge that reads a named input directly, `(greeting: in.greeting, ...)` // // See EXAMPLE.md, "Names in the wiring: merge slots and graph inputs". #include @@ -214,5 +215,21 @@ int main() std::cout << '\n' << dsl::toMermaid(greet.program()); } + // 7) A group can read a named input directly: the greeting needs no + // preparing, so no stage has to copy it onto an edge of its own. The + // undeclared input takes the type of the slot it feeds. + { + DslFilterGraph greet(R"dsl( + in.name -> Capitalize -> proper + (greeting: in.greeting, name: proper) -> Greet -> out + )dsl"); + + std::cout << "\n[direct] " << *greet.push("greeting", std::string{"Welcome"}) << '\n'; + std::cout << "[direct] " + << *greet.filter(GraphInputs{}.set("greeting", std::string{"Welcome"}).set("name", std::string{"lINUS"})) + << "\n\n" + << dsl::toAscii(greet.program()); + } + return 0; } diff --git a/libs/filterGraph/core/filterGraph/DslFilterGraph.hpp b/libs/filterGraph/core/filterGraph/DslFilterGraph.hpp index beab82e..693333b 100644 --- a/libs/filterGraph/core/filterGraph/DslFilterGraph.hpp +++ b/libs/filterGraph/core/filterGraph/DslFilterGraph.hpp @@ -437,11 +437,12 @@ inline std::optional> matchSlotNames(const StageNode& // matches named slots by name and positional ones by position, checks the // edges against the declared slot types, and returns the group's edges in the // stage's slot order. An untyped merge with positional slots is not checked. +// An undeclared graph input read by a slot takes the slot's type. // Problems are appended to `diagnostics`; the plan must then not be run. inline std::vector wireMergeSlots(const StageNode& node, const MergeSlotTypes& types, const std::vector& names, - const EdgeTypes& edgeTypes, + EdgeTypes& edgeTypes, std::vector& diagnostics) { if (!names.empty() && !checkDeclaredSlotNames(node, types, names, diagnostics)) @@ -480,7 +481,12 @@ inline std::vector wireMergeSlots(const StageNode& n { const std::type_index expected = types.uniform ? types.types.front() : types.types[slot]; const auto type = edgeTypes.find(edges[slot]); - if (type && *type != expected) + if (!type && isInputEdge(edges[slot])) + { + edgeTypes.types.emplace(edges[slot], expected); + edgeTypes.inferredFrom.emplace(edges[slot], node.type); + } + else if (type && *type != expected) { const std::string label = names.empty() ? std::to_string(slot + 1) : std::format("'{}'", names[slot]); diagnostics.push_back({node.loc, diff --git a/libs/filterGraph/core/filterGraph/GraphLang.hpp b/libs/filterGraph/core/filterGraph/GraphLang.hpp index 6939d72..4f73b08 100644 --- a/libs/filterGraph/core/filterGraph/GraphLang.hpp +++ b/libs/filterGraph/core/filterGraph/GraphLang.hpp @@ -45,7 +45,8 @@ // - fan-in = a source group `(a, b) -> Merge -> c`, where Merge is a merge // stage (see registerMergeFilter in MergeFilter.hpp); the group may name the // merge's slots, `(raw: a, checked: b) -> Merge -> c`, which matches them by -// name instead of by position; +// name instead of by position; a group may read `in` / `in.` directly, +// `(in, a)` or `(plots: a, ticks: in.ticks)`; // - `#` starts a comment that runs to the end of the line. // // This header parses (with a lexy-based parser) and structurally validates a @@ -327,10 +328,53 @@ struct Term std::optional key; // for `in.` / `out.` nlohmann::json config; // for Stage std::vector edges; // for Group + std::vector> edgeKeys; // for Group: per edge, from `edge.` + std::vector edgeLocs; // for Group: per edge std::vector slotNames; // for Group: per edge, from `name: edge`; "" when unnamed SourceLoc loc; }; +// Adds the graph input `edge` ("in" or "in.") to the program on its first use. +inline void bindInput(GraphProgram& program, const std::string& edge, const std::optional& key, + const SourceLoc& loc) +{ + const bool seen = std::any_of(program.inputs.begin(), program.inputs.end(), + [&](const InputBinding& input) { return input.edge == edge; }); + if (!seen) + { + program.inputs.push_back({edge, key, loc}); + } +} + +// Checks the edges of a fan-in group: `in` and `in.` are graph inputs, +// `out` and `end` are sinks, and no other edge takes a key. Spells each keyed +// input as its edge, "in.". +inline bool checkGroupEdges(Term& group, std::vector& diags) +{ + for (std::size_t j = 0; j < group.edges.size(); ++j) + { + std::string& edge = group.edges[j]; + const std::optional& key = group.edgeKeys[j]; + if (edge == "out" || edge == "end") + { + diags.push_back({group.edgeLocs[j], + std::format("'{}' may only appear as the last term of a statement", edge)}); + return false; + } + if (key && edge != "in") + { + diags.push_back({group.edgeLocs[j], + std::format("only 'in' and 'out' take a '.', not '{}.{}'", edge, *key)}); + return false; + } + if (key) + { + edge = "in." + *key; + } + } + return true; +} + // Checks the slot names of a fan-in group: all slots are named or none, and no // name is used twice. inline bool checkSlotNames(const Term& group, std::vector& diags) @@ -419,7 +463,8 @@ inline void buildStatement(std::vector& terms, diags.push_back({terms[i].loc, "a fan-in group needs at least one edge"}); return; } - if (terms[i].kind == Term::Kind::Group && !checkSlotNames(terms[i], diags)) + if (terms[i].kind == Term::Kind::Group && + (!checkGroupEdges(terms[i], diags) || !checkSlotNames(terms[i], diags))) { return; } @@ -454,16 +499,18 @@ inline void buildStatement(std::vector& terms, { node.slotNames = source.slotNames; } + for (std::size_t j = 0; j < source.edges.size(); ++j) + { + if (source.edges[j] == "in" || source.edgeKeys[j]) + { + bindInput(program, source.edges[j], source.edgeKeys[j], source.edgeLocs[j]); + } + } } else if (source.name == "in") { const std::string edge = source.key ? "in." + *source.key : std::string{"in"}; - const bool seen = std::any_of(program.inputs.begin(), program.inputs.end(), - [&](const InputBinding& input) { return input.edge == edge; }); - if (!seen) - { - program.inputs.push_back({edge, source.key, source.loc}); - } + bindInput(program, edge, source.key, source.loc); node.inputs = {edge}; } else @@ -876,8 +923,9 @@ inline bool parseStatementLine(std::string_view lineText, { return unexpected("expected an edge name in group"); } - // `edge`, or `name: edge` for a named slot. - std::string edge = captureIdent(); + // `edge`, or `name: edge` for a named slot; the edge may be `in.`. + SourceLoc edgeLoc = locOf(sc.position()); + std::string edge = captureIdent(); std::string slot; skipBlank(); if (sc.branch(ld::lit_c<':'>)) @@ -887,9 +935,23 @@ inline bool parseStatementLine(std::string_view lineText, { return unexpected(std::format("expected an edge name after '{}:'", edge)); } - slot = std::exchange(edge, captureIdent()); + edgeLoc = locOf(sc.position()); + slot = std::exchange(edge, captureIdent()); + skipBlank(); + } + std::optional key; + if (sc.branch(ld::lit_c<'.'>)) + { + skipBlank(); + if (!sc.peek(ld::ascii::alpha_underscore)) + { + return unexpected("expected a key name after '.'"); + } + key = captureIdent(); } term.edges.push_back(std::move(edge)); + term.edgeKeys.push_back(std::move(key)); + term.edgeLocs.push_back(edgeLoc); term.slotNames.push_back(std::move(slot)); } }; diff --git a/libs/filterGraph/core/filterGraph/GraphLangHandwritten.hpp b/libs/filterGraph/core/filterGraph/GraphLangHandwritten.hpp index 6be002b..5927986 100644 --- a/libs/filterGraph/core/filterGraph/GraphLangHandwritten.hpp +++ b/libs/filterGraph/core/filterGraph/GraphLangHandwritten.hpp @@ -324,8 +324,9 @@ class Parser unexpected("expected an edge name in group"); return term; } - // `edge`, or `name: edge` for a named slot. - std::string edge = next().text; + // `edge`, or `name: edge` for a named slot; the edge may be `in.`. + SourceLoc edgeLoc = peek().loc; + std::string edge = next().text; std::string slot; if (peek().kind == TokenKind::Colon) { @@ -335,9 +336,23 @@ class Parser unexpected(std::format("expected an edge name after '{}:'", edge)); return term; } - slot = std::exchange(edge, next().text); + edgeLoc = peek().loc; + slot = std::exchange(edge, next().text); + } + std::optional key; + if (peek().kind == TokenKind::Dot) + { + next(); + if (peek().kind != TokenKind::Identifier) + { + unexpected("expected a key name after '.'"); + return term; + } + key = next().text; } term.edges.push_back(std::move(edge)); + term.edgeKeys.push_back(std::move(key)); + term.edgeLocs.push_back(edgeLoc); term.slotNames.push_back(std::move(slot)); } } diff --git a/tests/FilterGraphTests/FilterGraphTests.cpp b/tests/FilterGraphTests/FilterGraphTests.cpp index bafcf50..97d14dc 100644 --- a/tests/FilterGraphTests/FilterGraphTests.cpp +++ b/tests/FilterGraphTests/FilterGraphTests.cpp @@ -963,6 +963,20 @@ TEST_CASE("toAscii shows arguments, dead ends and outputs, in plain or Unicode c "\xE2\x94\x94\xE2\x94\x80\xE2\x96\xBA RunDouble \xE2\x94\x80\xE2\x96\xBA out\n"); } +TEST_CASE("toAscii roots a tree at a graph input read only by a group", "[GraphDsl]") +{ + const auto program = dsl::parseGraphProgram("(in.orders, in.quotes) -> X -> out\n"); + + REQUIRE(dsl::toAscii(program) == "in.orders\n" + "`-> X (merge, see below)\n" + "\n" + "in.quotes\n" + "`-> X (merge, see below)\n" + "\n" + "(in.orders, in.quotes)\n" + "`-> X -> out\n"); +} + TEST_CASE("toAscii prints every stage of an invalid program once", "[GraphDsl]") { auto program = dsl::parseGraphProgram("a -> X -> b\nb -> Y -> a\n"); @@ -1078,6 +1092,13 @@ TEST_CASE("Both parsers report the same located syntax errors", "[GraphDslErrors {"in -> RunDouble: -> out", "1:16: expected '->' but found ':'"}, {"in -> RunDouble -> q.x -> RunDouble -> out", "1:20: only 'in' and 'out' take a '.', not 'q.x'"}, {"in. -> RunDouble -> out", "1:5: expected a key name after '.' but found '->'"}, + {"(in., b) -> RunDouble -> out", "1:5: expected a key name after '.' but found ','"}, + {"(a: in.) -> RunDouble -> out", "1:8: expected a key name after '.' but found ')'"}, + {"(msg.x, y) -> RunDouble -> out", "1:2: only 'in' and 'out' take a '.', not 'msg.x'"}, + {"(a: x, b: q.k) -> RunDouble -> out", "1:11: only 'in' and 'out' take a '.', not 'q.k'"}, + {"(out, y) -> RunDouble -> out", "1:2: 'out' may only appear as the last term of a statement"}, + {"(y, out.k) -> RunDouble -> out", "1:5: 'out' may only appear as the last term of a statement"}, + {"(end, y) -> RunDouble -> out", "1:2: 'end' may only appear as the last term of a statement"}, }; for (const auto& [source, expected] : cases) @@ -1097,6 +1118,14 @@ TEST_CASE("A syntax error ends only its own statement", "[GraphDslErrors]") == "1:17: expected '->' but found 'RunDouble'\n2:41: unterminated string literal"); } +TEST_CASE("Both parsers agree on graph inputs inside fan-in groups", "[GraphDslErrors]") +{ + parseWithBoth("(in.orders, in.quotes) -> RunDouble -> out\n"); + parseWithBoth("(quote: in . quotes, order: in.orders) -> RunDouble -> out\n"); + parseWithBoth("in.orders -> RunDouble -> a\n(x: a, y: in.quotes) -> RunDouble -> out\n"); + parseWithBoth("in -> RunDouble -> a\n(x: in, y: a) -> RunDouble -> out\n"); +} + TEST_CASE("Both parsers agree on strings, numbers and comments", "[GraphDslErrors]") { const char* source = R"dsl(# a comment line diff --git a/tests/FilterGraphTests/GraphInputsTests.cpp b/tests/FilterGraphTests/GraphInputsTests.cpp index 845761a..6bf02bb 100644 --- a/tests/FilterGraphTests/GraphInputsTests.cpp +++ b/tests/FilterGraphTests/GraphInputsTests.cpp @@ -63,10 +63,45 @@ class MatchMerge : public TypedMergeFilter } }; -static FilterRegistrar registerParseOrder("InParseOrder"); -static FilterRegistrar registerParseQuote("InParseQuote"); -static FilterRegistrar registerIntToString("InIntToString"); -static FilterRegistrar registerMatch("InMatch"); +// Reads the raw inputs: "@", with "-" for a hole. +class OrderQuoteMerge : public TypedMergeFilter +{ +public: + std::optional merge(std::optional&& order, std::optional&& quote) override + { + return std::format("{}@{}", order ? std::to_string(order->quantity) : "-", + quote ? std::format("{}", quote->price) : "-"); + } +}; + +// OrderQuoteMerge with named slots. +class NamedOrderQuoteMerge : public OrderQuoteMerge +{ +public: + std::vector mergeInputNames() const override + { + return {"order", "quote"}; + } +}; + +// "=", for a single-input graph. +class OrderQuantityMerge : public TypedMergeFilter +{ +public: + std::optional merge(std::optional&& order, std::optional&& quantity) override + { + return std::format("{}={}", order ? std::to_string(order->quantity) : "-", + quantity ? std::to_string(*quantity) : "-"); + } +}; + +static FilterRegistrar registerOrderQuantity("InOrderQuantity"); +static FilterRegistrar registerParseOrder("InParseOrder"); +static FilterRegistrar registerParseQuote("InParseQuote"); +static FilterRegistrar registerIntToString("InIntToString"); +static FilterRegistrar registerMatch("InMatch"); +static FilterRegistrar registerOrderQuote("InOrderQuote"); +static FilterRegistrar registerNamedOrderQuote("InNamedOrderQuote"); const char* kMatchGraph = R"( in.orders -> InParseOrder -> quantity @@ -198,6 +233,107 @@ TEST_CASE("The parsed program lists its inputs", "[GraphInputs]") REQUIRE_FALSE(plain.inputs[0].key.has_value()); } +TEST_CASE("A fan-in group reads named inputs directly", "[GraphInputs]") +{ + DslFilterGraph graph("(in.orders, in.quotes) -> InOrderQuote -> out"); + + REQUIRE(graph.push("orders", Order{3}).value() == "3@-"); + REQUIRE(graph.push("quotes", Quote{1.5}).value() == "-@1.5"); + REQUIRE(graph.filter(GraphInputs{}.set("orders", Order{2}).set("quotes", Quote{0.5})).value() == "2@0.5"); + REQUIRE_FALSE(graph.filter(GraphInputs{}).has_value()); +} + +TEST_CASE("A named slot binds a named input by name", "[GraphInputs]") +{ + DslFilterGraph graph("(quote: in.quotes, order: in.orders) -> InNamedOrderQuote -> out"); + + REQUIRE(graph.push("orders", Order{4}).value() == "4@-"); + REQUIRE(graph.filter(GraphInputs{}.set("orders", Order{1}).set("quotes", Quote{2.5})).value() == "1@2.5"); +} + +TEST_CASE("A group mixes named inputs and ordinary edges", "[GraphInputs]") +{ + DslFilterGraph graph(R"( + in.orders -> InParseOrder -> quantity + (quantity, in.quotes) -> InMatch -> out + )"); + + REQUIRE(graph.push("orders", Order{3}).value() == "3@-"); + REQUIRE(graph.filter(GraphInputs{}.set("orders", Order{2}).set("quotes", 0.5)).value() == "2@0.5"); +} + +TEST_CASE("A plain in feeds a fan-in group of a single-input graph", "[GraphInputs]") +{ + DslFilterGraph graph(R"( + in -> InParseOrder -> quantity + (in, quantity) -> InOrderQuantity -> out + )"); + + REQUIRE(graph.filter(Order{5}).value() == "5=5"); + REQUIRE(dsl::parseGraphProgram("(in, in) -> InOrderQuote -> out").inputs.size() == 1); +} + +TEST_CASE("Declared input types are checked for group readers", "[GraphInputs]") +{ + const char* source = "(in.orders, in.quotes) -> InOrderQuote -> out"; + + DslFilterGraph graph(source, GraphInputs::of("orders", "quotes")); + REQUIRE(graph.push("quotes", Quote{0.5}).value() == "-@0.5"); + + const auto swapped = + validateDslGraph(source, GraphInputs::of("orders", "quotes")); + REQUIRE(swapped.size() == 2); + REQUIRE(swapped[1].message.starts_with("slot 2 of 'InOrderQuote' expects")); + REQUIRE(swapped[1].message.find("the graph input 'in.quotes' carries") != std::string::npos); +} + +TEST_CASE("An undeclared input read only by a group takes the slot's type", "[GraphInputs]") +{ + DslFilterGraph graph(R"( + in.orders -> InParseOrder -> quantity + (quantity, in.quotes) -> InMatch -> out + )"); + + REQUIRE(graph.push("quotes", 1.5).value() == "-@1.5"); + REQUIRE_THROWS_AS(graph.push("quotes", Quote{1.5}), std::invalid_argument); + + const auto diagnostics = validateDslGraph(R"( + (in.orders, in.quotes) -> InOrderQuote -> out.pair + in.quotes -> InIntToString -> out.text + )"); + REQUIRE(diagnostics.size() == 1); + REQUIRE(diagnostics[0].message.find("the graph input 'in.quotes' (typed by its reader 'InOrderQuote')") + != std::string::npos); +} + +TEST_CASE("Only in takes a key inside a group, and sinks cannot feed one", "[GraphInputs]") +{ + const auto keyed = dsl::parseGraphProgram("(msg.x, y) -> InMatch -> out"); + REQUIRE(dsl::formatDiagnostics(keyed.diagnostics) == "1:2: only 'in' and 'out' take a '.', not 'msg.x'"); + + const auto out = dsl::parseGraphProgram("(out, y) -> InMatch -> out"); + REQUIRE(dsl::formatDiagnostics(out.diagnostics) == "1:2: 'out' may only appear as the last term of a statement"); + + const auto outKey = dsl::parseGraphProgram("(y, out.x) -> InMatch -> out"); + REQUIRE(dsl::formatDiagnostics(outKey.diagnostics) == "1:5: 'out' may only appear as the last term of a statement"); +} + +TEST_CASE("The parsed program lists inputs read only by a group", "[GraphInputs]") +{ + const auto program = dsl::parseGraphProgram("in.orders -> InParseOrder -> a\n" + "(a, in.quotes) -> InMatch -> out\n"); + REQUIRE(program.ok()); + REQUIRE(program.inputs.size() == 2); + REQUIRE(program.inputs[1].edge == "in.quotes"); + REQUIRE(program.inputs[1].key == "quotes"); + REQUIRE(program.inputs[1].loc.line == 2); + REQUIRE(program.inputs[1].loc.column == 5); + REQUIRE(program.stages[1].inputs == std::vector{"a", "in.quotes"}); + + const auto twice = dsl::parseGraphProgram("(in.orders, in.orders) -> InOrderQuote -> out"); + REQUIRE(twice.inputs.size() == 1); +} + TEST_CASE("A graph with named inputs nests as a stage", "[GraphInputs]") { FilterRegistrar> registrar(