Skip to content

Pipeline Splits

Chloë Strainge edited this page Oct 15, 2026 · 1 revision

Pipeline splits

A split sends the same upstream items to more than one path. The source runs once. Each path can transform, encode, or collect its own results.

Attach a branch

Place \- on the next line, with its backslash directly below the pipe you want to tap. It receives the output of the stage immediately to that pipe's left. The hyphen points toward the new branch's first stage.

let $main: [Integer]
let $extra: [Integer]
[2,4] | ($ * 10) | $main
      \- ($ + 1) | $extra
echo $main...
echo $extra...
20 40
3 5

Here both paths receive 2 and 4. Multiplication belongs only to the upper path; the lower path adds one to each original item. Branches use the same bare $, automatic function argument, and destination rules as ordinary pipelines.

Draw every connection

When a branch reaches past another diagram row, draw a vertical | on every intervening row. That guide continues an existing connection; it executes nothing. A branch's own real pipes can supply further branches. Each pipe supplies one continuation and one branch; add another stage and junction for further paths.

fn command($n: Integer): Integer { $n + 1 }
let $main: [Integer]
let $other: [Integer]
let $secondary: [Integer]
let $early: [Integer]
[1, 2] | command | command | command | $main
       |         \- command | $other
       |                    \- command | command | $secondary
       \- $early
echo $main...
echo $other...
echo $secondary...
echo $early...
4 5
3 4
5 6
1 2

The leftmost guide carries the early connection past both deeper branches. The secondary branch attaches to the real pipe on the preceding branch row. Shelly follows these drawn paths rather than searching arbitrarily far upward for a matching column.

Misaligned connectors, missing guides, and unfinished guides are compile errors. A blank or comment-only row cannot replace a required guide. Diagnostics identify the broken connection and report the effective tab size. They suggest a tab-size directive when hard tabs occur in the relevant diagram prefixes.

Share tabbed scripts

Put #tab-size N in the file header, before executable code. Blank lines, comments, and a shebang can precede it. Use one directive, with an integer from 1 through 32. This example contains a hard tab before the backslash:

#tab-size 8
let $main: [Integer]
let $copy: [Integer]
[1, 2]  | $main
	\- $copy
echo $main... $copy...
1 2 1 2

The precedence is:

  1. The current source's #tab-size N directive.
  2. An explicit --tab-width N / -t N option.
  3. $SHELLY_TAB_SIZE, inherited from the environment or set by startup scripts.
  4. Four-column tab stops.

Set $SHELLY_TAB_SIZE = 8 in your RC to select eight-column stops for later submissions and loaded sources. Export it if child Shelly processes should inherit it. A file directive changes only that source; it does not rewrite the variable. Each loaded module can choose its own directive.

Tabs advance to the next tab stop. Layout also accounts for Unicode display width. Source bytes stay unchanged: tabs inside strings, whitespace, and editor spans are preserved. Use a matching tab width in the editor when drawing diagrams.

Keep external programs streaming

The same notation can tap a byte stream. Here printf runs once: one path keeps all its output, while the other finishes after its first line.

let $all: String
let $first: String
printf "north\nsouth\n" | cat | $all
                        \- head -n 1 | $first
echo $first.trim
echo ($all == "north\nsouth\n")
north
true

The early-ending branch detaches; it does not prevent the other path from receiving the remaining output. No intermediate array of lines is needed.

Streaming and conversion

A fork broadcasts one item or byte chunk at a time, with bounded queues. A slow consumer applies backpressure; an early-ending consumer detaches while other consumers continue. Cooperative workers allow sibling I/O operations to progress. Execution order between sibling callbacks is not a synchronization guarantee.

Typed values retain their types and ordinary value-copy semantics. External programs retain byte streams. A branch connector accepts the same boundary annotations as a pipe: \-:json encodes, csv:\- decodes, and json:\-:Reply decodes into Reply. See pipeline formats. A fork at the source cannot mix whole-collection encoding with item iteration; put an explicit stage before that fork when those interpretations would differ.

A destination must already exist. Use different destination bindings for different leaves; assigning two leaves to the same binding is rejected before stages run. Final array destinations collect their path's items. Intermediate paths stay streaming. Live resources must be adapted to transferable items or bytes before broadcasting; retained-data and VM-task limits produce errors rather than unbounded buffering. Native calls in callbacks still need a managed suspension contract. Put external programs directly in pipeline stages; ordinary external calls inside a scripted callback are not admitted by that existing managed-task policy. Split pipelines are unavailable during error reporting and cleanup.

An unhandled failure stops the whole graph and cleans up its children. A graph containing external programs returns aggregate ExecResult status, which can be captured using the usual rules. Pure Shelly graphs return the main path's final value; side paths do not replace it. Ctrl+C interrupts the graph and returns an interactive shell to its editor. An external source reading terminal input gets foreground ownership for its lifetime; ownership returns to Shelly on EOF, error, or interruption. Concurrent sources cannot both own the same terminal input.


Related: Pipelines · Pipeline formats · REPL · Startup

Clone this wiki locally