Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/flow_io.zig
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,14 @@ pub const other_field_specs = [_]OtherFieldSpec{
// field, so it needs no spec — its inspector shows the verbatim view.
.{ .type_name = "Cooldown", .key = "seconds", .label = "Seconds", .widget = .literal },
.{ .type_name = "Delay", .key = "seconds", .label = "Seconds", .widget = .literal },
// String/Text reporter nodes (flow-codegen#26). `Format` carries a
// `template` field — the `std.fmt` format string (e.g. `"hits={d}"`) —
// stored as a JSON string and edited through the `.text` widget the
// same way `Identifier.name` is, so it encodes/round-trips as a quoted
// string codegen parses verbatim. `Concat`/`IntToString`/`FloatToString`
// carry no editable field — their inputs are data pins — so they need
// no spec.
.{ .type_name = "Format", .key = "template", .label = "Template", .widget = .text },
};

/// Return the editable-field spec for a node `type_name`, or null when
Expand Down
169 changes: 167 additions & 2 deletions src/modules/flow_doc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,75 @@ pub fn switchCaseOutputCount(node_id: u32, exec_edges: []const flow_io.ExecEdge)
return @min(count, max_case_outputs);
}

/// Upper bound on the number of `arg<N>` data inputs a variadic reporter
/// (`Concat` / `Format`, flow-codegen#26) will render, so a hand-edited
/// file with an absurd arg index can't ask the editor to draw thousands
/// of pins.
const max_arg_inputs: u32 = 64;

/// Static `arg0`..`arg<max_arg_inputs-1>` pin-name literals. Built at
/// comptime so each rendered/recorded `arg<N>` pin name is a 'static
/// slice that outlives the frame — `recordPin` borrows the name into the
/// pin registry, so a per-frame formatted string would dangle (mirrors
/// the `case_pin_names` table the Switch node uses, and the inline
/// `arg0..arg15` table the CustomNode renderer uses).
const var_arg_pin_names: [max_arg_inputs][]const u8 = blk: {
@setEvalBranchQuota(max_arg_inputs * 2000);
var names: [max_arg_inputs][]const u8 = undefined;
for (0..max_arg_inputs) |i| {
names[i] = std.fmt.comptimePrint("arg{d}", .{i});
}
break :blk names;
};

/// Parse an `arg<N>` data-pin name to its index `N`, or null if `pin`
/// isn't a well-formed `arg<N>` name. The digit run must be non-empty
/// and all-decimal; overflow yields null rather than wrapping. Mirrors
/// `caseIndexOf`'s shape for the variadic reporters.
pub fn argIndexOf(pin: []const u8) ?u32 {
if (!std.mem.startsWith(u8, pin, "arg")) return null;
const digits = pin[3..];
if (digits.len == 0) return null;
// Digits-only — `parseInt` would otherwise accept a leading `+`
// (`arg+1` → 1). Matches `caseIndexOf`/codegen's `isCaseExecPin`
// strictness (gemini #207).
for (digits) |c| {
if (!std.ascii.isDigit(c)) return null;
}
return std.fmt.parseInt(u32, digits, 10) catch null;
}
Comment on lines +828 to +839

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The argIndexOf function parses the pin name suffix using std.fmt.parseInt. However, parseInt allows a leading + sign (e.g., arg+1 would parse as 1). To ensure strict correctness and prevent malformed pin names from being parsed as valid indices, we should verify that all characters in digits are ASCII digits before parsing.

pub fn argIndexOf(pin: []const u8) ?u32 {
    if (!std.mem.startsWith(u8, pin, "arg")) return null;
    const digits = pin[3..];
    if (digits.len == 0) return null;
    for (digits) |c| {
        if (!std.ascii.isDigit(c)) return null;
    }
    return std.fmt.parseInt(u32, digits, 10) catch null;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — argIndexOf now rejects any non-digit suffix before parseInt, so arg+1 (and other malformed forms) no longer parse. Matches caseIndexOf/codegen's isCaseExecPin.


/// How many `arg<N>` data inputs a variadic reporter (`Concat` /
/// `Format`, flow-codegen#26) should render, given the document's data
/// edges. The arg count is dynamic — codegen joins however many are
/// wired — so we can't statically enumerate them; instead we derive the
/// count from which `arg<N>` pins already have an incoming data edge
/// *into this node*, then add one spare slot so a fresh (or
/// just-extended) node is always wireable.
///
/// Rule: count = (highest wired `arg<N>` index + 1) + 1 spare. The
/// rendered arg pins are then `arg0` .. `arg<count-1>`. Consequences:
/// - No data edges → count 1 → renders `arg0` (the spare), so a
/// brand-new `Concat`/`Format` is immediately wireable.
/// - `arg0` + `arg2` wired (sparse) → highest is 2 → count 4 →
/// renders `arg0`,`arg1`,`arg2` (filling the gap) + `arg3` (spare).
/// Bounded by `max_arg_inputs` so a hand-edited file with an absurd
/// index can't ask the editor to draw thousands of pins.
pub fn varArgInputCount(node_id: u32, edges: []const flow_io.Edge) u32 {
var highest: ?u32 = null;
for (edges) |e| {
if (e.to_node != node_id) continue;
const idx = argIndexOf(e.to_pin) orelse continue;
highest = if (highest) |h| @max(h, idx) else idx;
}
Comment on lines +857 to +863

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Zig, we can write the tracking of the highest index more idiomatically by using @max and avoiding the force-unwrap highest.?.

pub fn varArgInputCount(node_id: u32, edges: []const flow_io.Edge) u32 {
    var highest: ?u32 = null;
    for (edges) |e| {
        if (e.to_node != node_id) continue;
        const idx = argIndexOf(e.to_pin) orelse continue;
        highest = if (highest) |h| @max(h, idx) else idx;
    }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — highest = if (highest) |h| @max(h, idx) else idx;, dropping the force-unwrap.

// Saturating adds mirror `switchCaseOutputCount`: a hand-edited
// `arg<N>` pin can parse to a huge index, and an unchecked `+ 1`
// would panic in safe builds before the clamp runs.
const wired_span: u32 = if (highest) |h| h +| 1 else 0;
const count = wired_span +| 1; // + one spare empty arg slot
return @min(count, max_arg_inputs);
}

/// Whether `pin` is a legal exec-output pin name on a node of
/// `type_name`. Branch/loop pins are matched against
/// `controlExecPinNames`; `Switch` arm pins (`case<N>` / `default`)
Expand Down Expand Up @@ -2020,6 +2089,47 @@ fn renderNodeBody(s: *FlowDocState, allocator: std.mem.Allocator, n: flow_io.Nod
zgui.text("value >", .{});
ne.endPin();
recordPin(s, n.id, "value", .output);
} else if (std.mem.eql(u8, n.type_name, "IntToString") or
std.mem.eql(u8, n.type_name, "FloatToString"))
{
// Scalar-to-string reporters (flow-codegen#26). One data
// INPUT `value` (the number to stringify) + one data
// OUTPUT `value` (the `[]const u8` result). Both share the
// name `value`; the pin id encodes direction, so input and
// output are distinct ids and codegen reads them by
// direction the same way. No exec pins — they render as
// rounded reporters (see `nodeVisual` / `isReporterTypeName`).
ne.beginPin(pinId(n.id, "value", .input), .input);
zgui.text("> value", .{});
ne.endPin();
recordPin(s, n.id, "value", .input);
ne.beginPin(pinId(n.id, "value", .output), .output);
zgui.text("value >", .{});
ne.endPin();
recordPin(s, n.id, "value", .output);
} else if (std.mem.eql(u8, n.type_name, "Concat") or
std.mem.eql(u8, n.type_name, "Format"))
{
// Variadic string reporters (flow-codegen#26). A dynamic
// set of `arg<N>` data INPUT pins (codegen joins them, in
// order) + one `value` data OUTPUT (`[]const u8`). The arg
// count is derived from the data edges already wired into
// this node, plus one spare slot so a fresh node is always
// wireable (`varArgInputCount`). `Format` additionally
// carries a `template` field (the `std.fmt` format string),
// shown in the inspector + the extras hint below. No exec
// pins — rounded reporters (see `isReporterTypeName`).
const arg_count = varArgInputCount(n.id, s.doc.edges);
for (0..arg_count) |i| {
ne.beginPin(pinId(n.id, var_arg_pin_names[i], .input), .input);
zgui.text("> {s}", .{var_arg_pin_names[i]});
ne.endPin();
recordPin(s, n.id, var_arg_pin_names[i], .input);
}
ne.beginPin(pinId(n.id, "value", .output), .output);
zgui.text("value >", .{});
ne.endPin();
recordPin(s, n.id, "value", .output);
} else if (std.mem.eql(u8, n.type_name, "SetField")) {
ne.beginPin(pinId(n.id, "entity", .input), .input);
zgui.text("> entity", .{});
Expand Down Expand Up @@ -2117,23 +2227,53 @@ fn renderNodeBody(s: *FlowDocState, allocator: std.mem.Allocator, n: flow_io.Nod

/// Per-node visual style — corner radius + border color. Drives the
/// command vs reporter visual difference (RFC §6, phase 4 item 6).
const NodeVisual = struct {
pub const NodeVisual = struct {
rounding: f32,
border: [4]f32,
};

/// Whether a built-in `.other`-kind node renders as a *reporter* (rounded
/// silhouette, data-only, off the exec spine) rather than a command.
/// `.other` carries no command/reporter polarity in `NodeKind`, so the
/// editor classifies these expression nodes by `type_name` — the same set
/// flow-codegen treats as pure-value reporters. The string/text reporters
/// (`Concat`/`Format`/`IntToString`/`FloatToString`, flow-codegen#26) join
/// this set so `nodeVisual` gives them the rounded shape and the generic
/// classifier never awards them an exec-in anchor (they're not
/// `isControlNode`, so `needs_exec_in` stays false).
Comment on lines +2239 to +2243
pub fn isReporterTypeName(type_name: []const u8) bool {
const reporters = [_][]const u8{
"BinOp", "Compare", "Logic", "Literal",
"Identifier", "GetComponent", "Concat", "Format",
"IntToString", "FloatToString",
};
for (reporters) |r| {
if (std.mem.eql(u8, type_name, r)) return true;
}
return false;
}

/// Map a node kind to its visual treatment. Commands are rectangular
/// (rounding 4), reporters rounded (rounding 14), the `Event` trigger
/// stays command-shaped but with a warm border color so the entry point
/// reads at a glance. Unknown kinds get the neutral default.
fn nodeVisual(n: flow_io.Node) NodeVisual {
pub fn nodeVisual(n: flow_io.Node) NodeVisual {
const command_round: f32 = 4.0;
const reporter_round: f32 = 14.0;
const neutral_border: [4]f32 = .{ 0.4, 0.4, 0.45, 1.0 };
const reporter_border: [4]f32 = .{ 0.4, 0.65, 0.45, 1.0 };
const command_border: [4]f32 = .{ 0.4, 0.55, 0.85, 1.0 };
const trigger_border: [4]f32 = .{ 0.95, 0.74, 0.2, 1.0 };

// `.other` expression reporters (BinOp/Compare/Logic/Literal/Identifier
// and the string/text reporters, flow-codegen#26) are classified by
// `type_name` — they carry no polarity in `NodeKind`. Round them and
// give them the reporter border so they read as pure-value nodes.
if (n.kind == .other and isReporterTypeName(n.type_name)) return .{
.rounding = reporter_round,
.border = reporter_border,
};

switch (n.kind) {
// Reporter ops (RFC §6) — rounded, green-ish border.
.get_variable, .has_value_variable, .param => return .{
Expand Down Expand Up @@ -2946,6 +3086,31 @@ fn renderNodePalette(s: *FlowDocState) void {
addOtherNode(s, "Delay", &.{.{ .key = "seconds", .value_text = "1" }}) catch |err| nodeAddErr(err);
}

// ── Text section (flow-codegen#26) ──
// String/text reporter nodes — rounded, produce a `value` ([]const u8)
// output that wires into command data inputs (`Log`/`Emit`/
// `SetVariable`, …). `Format` seeds an empty Zig string literal
// `template` (the `std.fmt` format string, edited via the `.text`
// widget — matches `Identifier`'s `""` name seed); the others carry no
// editable field — their inputs are data pins (`Concat`/`Format` are
// variadic `arg<N>`; `IntToString`/`FloatToString` take one `value`).
zgui.text("Text", .{});
if (zgui.button("+ Format", .{})) {
addOtherNode(s, "Format", &.{.{ .key = "template", .value_text = "\"\"" }}) catch |err| nodeAddErr(err);
}
zgui.sameLine(.{});
if (zgui.button("+ Concat", .{})) {
addOtherNode(s, "Concat", &.{}) catch |err| nodeAddErr(err);
}
zgui.sameLine(.{});
if (zgui.button("+ IntToString", .{})) {
addOtherNode(s, "IntToString", &.{}) catch |err| nodeAddErr(err);
}
zgui.sameLine(.{});
if (zgui.button("+ FloatToString", .{})) {
addOtherNode(s, "FloatToString", &.{}) catch |err| nodeAddErr(err);
}

// Raw `Call` escape hatch (RFC §7) — surfaced separately so a user
// who finds it has knowingly opted into raw Zig source rather than
// mistaking it for a normal palette entry.
Expand Down
166 changes: 166 additions & 0 deletions src/tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7147,6 +7147,172 @@ pub const FlowDocExecEdgeTests = struct {
try expect.toBeTrue(saw_delay_seconds);
}

// ── String/Text reporter nodes (flow-codegen#26) ──
//
// Editor-side palette work for the four string reporters:
// `Format`, `Concat`, `IntToString`, `FloatToString`. They are
// `.other`-kind REPORTERS (rounded, data-only) producing a `value`
// ([]const u8) output that wires into command data inputs. `Format`
// carries a `template` (`std.fmt` format string) field; `Concat` /
// `Format` take variadic `arg<N>` data inputs; `IntToString` /
// `FloatToString` take one `value` data input.

test "appendOtherNode creates fieldless Concat / IntToString / FloatToString" {
const a = std.testing.allocator;
const src =
\\{ "event": { "type": "OnCreate" }, "nodes": [], "edges": [] }
;
var doc = try flow_io.parse(a, src);
defer doc.deinit();

const cat = try flow_io.appendOtherNode(&doc, "Concat", &.{});
const its = try flow_io.appendOtherNode(&doc, "IntToString", &.{});
const fts = try flow_io.appendOtherNode(&doc, "FloatToString", &.{});

try expect.equal(doc.nodes.len, @as(usize, 3));
for (doc.nodes, [_]u32{ cat, its, fts }) |n, id| {
try expect.equal(n.id, id);
try expect.toBeTrue(n.kind == .other);
// No editable field — no spec, no seeded extras.
try expect.toBeTrue(flow_io.otherFieldSpec(n.type_name) == null);
try expect.equal(n.extras.len, @as(usize, 0));
}
try expect.toBeTrue(std.mem.eql(u8, doc.nodes[0].type_name, "Concat"));
try expect.toBeTrue(std.mem.eql(u8, doc.nodes[1].type_name, "IntToString"));
try expect.toBeTrue(std.mem.eql(u8, doc.nodes[2].type_name, "FloatToString"));
}

test "appendOtherNode seeds Format with a template field" {
const a = std.testing.allocator;
const src =
\\{ "event": { "type": "OnCreate" }, "nodes": [], "edges": [] }
;
var doc = try flow_io.parse(a, src);
defer doc.deinit();

const fmt = try flow_io.appendOtherNode(
&doc,
"Format",
&.{.{ .key = "template", .value_text = "\"hits={d}\"" }},
);
const n = doc.nodes[0];
try expect.equal(n.id, fmt);
try expect.toBeTrue(std.mem.eql(u8, n.type_name, "Format"));
// The `template` field is the inspector-editable text field; it is
// stored verbatim as a JSON string (matching codegen's
// `{ "type": "Format", "template": "hits={d}" }`).
const spec = flow_io.otherFieldSpec(n.type_name).?;
try expect.toBeTrue(spec.widget == .text);
try expect.toBeTrue(std.mem.eql(u8, spec.key, "template"));
try expect.toBeTrue(std.mem.eql(u8, flow_io.extraValue(n, "template").?, "\"hits={d}\""));
}

test "string reporters classify as rounded reporters with no exec-in" {
// All four render as REPORTERS (rounded silhouette) — `nodeVisual`
// returns the reporter rounding (14) via `isReporterTypeName`, and
// they carry no exec pins so they're never swept onto the exec
// spine (`isReporterTypeName` set is disjoint from the control set).
const reporter_round: f32 = 14.0;
Comment on lines +7210 to +7215
const names = [_][]const u8{ "Format", "Concat", "IntToString", "FloatToString" };
for (names) |name| {
try expect.toBeTrue(flow_doc.isReporterTypeName(name));
const n: flow_io.Node = .{ .id = 1, .type_name = name, .kind = .other };
try expect.equal(flow_doc.nodeVisual(n).rounding, reporter_round);
}
// A command-y `.other` (SetField) is NOT classified as a reporter,
// confirming the helper is a closed set and didn't over-match.
try expect.toBeTrue(!flow_doc.isReporterTypeName("SetField"));
}

test "varArgInputCount: fresh Concat/Format shows one spare arg pin" {
// No data edges → count 1 → a brand-new node renders `arg0` (the
// spare) so it's immediately wireable.
const empty: []const flow_io.Edge = &.{};
try expect.equal(flow_doc.varArgInputCount(7, empty), @as(u32, 1));
}

test "varArgInputCount: derives from wired arg<N> data edges + one spare" {
const a = std.testing.allocator;
// arg0 + arg2 wired into node 2 (sparse). Highest is 2 → count 4
// (arg0..arg2 filled + arg3 spare). Edges into other nodes / other
// pins are ignored.
const src =
\\{
\\ "event": { "type": "OnUpdate" },
\\ "nodes": [
\\ { "id": 1, "type": "Literal", "value": "a", "pos": [0, 0] },
\\ { "id": 2, "type": "Concat", "pos": [10, 0] },
\\ { "id": 3, "type": "IntToString", "pos": [0, 10] }
\\ ],
\\ "edges": [
\\ { "from": { "node": 1, "pin": "value" }, "to": { "node": 2, "pin": "arg0" } },
\\ { "from": { "node": 1, "pin": "value" }, "to": { "node": 2, "pin": "arg2" } },
\\ { "from": { "node": 1, "pin": "value" }, "to": { "node": 3, "pin": "value" } }
\\ ]
\\}
;
var doc = try flow_io.parse(a, src);
defer doc.deinit();
try expect.equal(flow_doc.varArgInputCount(2, doc.edges), @as(u32, 4));
// Node 3's `value` input isn't an `arg<N>` pin → 0 wired → count 1.
try expect.equal(flow_doc.varArgInputCount(3, doc.edges), @as(u32, 1));
}

test "argIndexOf parses arg<N> and rejects non-arg names" {
try expect.equal(flow_doc.argIndexOf("arg0").?, @as(u32, 0));
try expect.equal(flow_doc.argIndexOf("arg12").?, @as(u32, 12));
try expect.toBeTrue(flow_doc.argIndexOf("arg") == null);
try expect.toBeTrue(flow_doc.argIndexOf("value") == null);
try expect.toBeTrue(flow_doc.argIndexOf("argx") == null);
}

test "string reporter nodes round-trip through parse / render; template persists" {
const a = std.testing.allocator;
const src =
\\{ "event": { "type": "OnCreate" }, "nodes": [], "edges": [] }
;
var doc = try flow_io.parse(a, src);
defer doc.deinit();

_ = try flow_io.appendOtherNode(&doc, "Concat", &.{});
_ = try flow_io.appendOtherNode(&doc, "IntToString", &.{});
_ = try flow_io.appendOtherNode(&doc, "FloatToString", &.{});
_ = try flow_io.appendOtherNode(
&doc,
"Format",
&.{.{ .key = "template", .value_text = "\"hits={d}\"" }},
);

const text1 = try flow_io.render(a, doc);
defer a.free(text1);

// The node types survive the writer, and `Format.template` is
// emitted as the codegen contract's quoted string.
try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"type\": \"Concat\"") != null);
try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"type\": \"IntToString\"") != null);
try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"type\": \"FloatToString\"") != null);
try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"type\": \"Format\"") != null);
try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"template\": \"hits={d}\"") != null);

// Load → save is byte-stable (these carry only string / no extras,
// so there's no numeric normalisation to settle).
Comment on lines +7297 to +7298
var doc2 = try flow_io.parse(a, text1);
defer doc2.deinit();
const text2 = try flow_io.render(a, doc2);
defer a.free(text2);
try expect.toBeTrue(std.mem.eql(u8, text1, text2));

// `Format.template` persists across the load → save cycle.
var saw_template = false;
for (doc2.nodes) |n| {
if (std.mem.eql(u8, n.type_name, "Format")) {
const v = flow_io.extraValue(n, "template") orelse continue;
saw_template = std.mem.eql(u8, v, "\"hits={d}\"");
}
}
try expect.toBeTrue(saw_template);
}

// ── Switch case-output derivation (#199) ──
//
// A Switch's cases are dynamic, so the editor derives how many
Expand Down
Loading