From 5a3d480edf4173c1556ae3c2ea52193594dd151a Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 26 Sep 2026 16:58:53 +0700 Subject: [PATCH] In .fln, x |> f(a) reads as f(x, a), below or, with the Emacs mode indenting and marking pipe lines. --- TODO.org | 5 ++++ emacs/flan-fln-mode.el | 28 ++++++++++++++++++- emacs/test-flan-fln.el | 25 +++++++++++++++++ lib/indent_printer.ml | 2 +- lib/indent_reader.ml | 61 ++++++++++++++++++++++++++++++++++++++--- spec-syntax.md | 26 ++++++++++++++++-- test/programs/pipe.fln | 42 ++++++++++++++++++++++++++++ test/test_acceptance.ml | 6 ++++ test/test_syntax.ml | 40 +++++++++++++++++++++++++++ 9 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 test/programs/pipe.fln diff --git a/TODO.org b/TODO.org index 6fc9c65a..2afee0e0 100644 --- a/TODO.org +++ b/TODO.org @@ -15,6 +15,11 @@ Found porting the prelude (2026-09-26): =declare= has no statement form; a quasi in the middle of an expression needs =quasiquote(...)=; =if not x= plus a block reads as =when=, so a template wanting =(if c (do …))= keeps call syntax; a spliced let binding list needs =let([…]):=. Each needs a .fln spelling now that .fln is the only syntax. +** DONE x |> f(a) is f(x, a) +CLOSED: [2026-09-26] +Decided (137): the pipe feeds the first argument, as Elixir's does, since Flan puts the +thing operated on first. Rules out F#'s last-argument pipe. Below =or=; the right side is +a name or a call to one, and the reader writes the plain call. ** CANCELLED rand-int returns i64 Decided 2026-09-26 (134): rand-int stays u64; write rand-int-range(lo, hi) for a signed range. diff --git a/emacs/flan-fln-mode.el b/emacs/flan-fln-mode.el index fe93577d..03be0160 100644 --- a/emacs/flan-fln-mode.el +++ b/emacs/flan-fln-mode.el @@ -77,7 +77,7 @@ fine here. Brackets and strings are still paired." ;; that starts with one, or follows a line that ends with one, continues the ;; line above. (defconst flan-fln--binops - '("or" "and" "==" "!=" "??" "<" "<=" ">" ">=" "||" "^^" "&&" "<<" ">>" "+" "-" + '("|>" "or" "and" "==" "!=" "??" "<" "<=" ">" ">=" "||" "^^" "&&" "<<" ">>" "+" "-" "*" "/" "%")) (defconst flan-fln--binop-re (regexp-opt flan-fln--binops)) @@ -1492,12 +1492,36 @@ lambda's block: it ends that block, and goes to the opening line's column." ((save-excursion (back-to-indentation) (looking-at flan-fln--clause-re)) (or (flan-fln--clause-columns (match-string-no-properties 1) (point)) (flan-fln--block-levels (point)))) + ((flan-fln--starts-with-pipe-p (point)) + (flan-fln--pipe-columns prev)) ((or (flan-fln--starts-with-op-p (point)) (flan-fln--ends-in-op-p prev)) (list (+ (flan-fln--indent-at (flan-fln--logical-start prev)) flan-fln-indent-offset))) (t (flan-fln--block-levels (point)))))))))) +(defun flan-fln--starts-with-pipe-p (pos) + "Non-nil if POS's line starts with the pipe `|>'." + (save-excursion + (goto-char pos) + (back-to-indentation) + (looking-at "|>\\(?:[ \t]\\|$\\)"))) + +(defun flan-fln--pipe-columns (prev) + "Columns for a line starting with `|>' whose code line above is PREV. +Under the pipe above it when PREV starts with one. Else under the value +PREV's statement binds -- `let t = x', then `|> f()' under the `x' -- and +one level in from the statement." + (if (flan-fln--starts-with-pipe-p prev) + (list (flan-fln--indent-at prev)) + (let* ((start (flan-fln--logical-start prev)) + (in (+ (flan-fln--indent-at start) flan-fln-indent-offset)) + (v (flan-fln--value-start start)) + (vcol (and v + (< v (save-excursion (goto-char start) (line-end-position))) + (save-excursion (goto-char v) (current-column))))) + (if (and vcol (> vcol in)) (list vcol in) (list in))))) + (defun flan-fln-indent-line () "Indent the line to a block column. A line with text at a valid column stays there: its column is its meaning, @@ -1891,6 +1915,8 @@ lambda or a `Fn(...)' type, and not after a match arm's." ;; The optional marks: `x ?? d', the unwrap of `x!' and the `?' of a ;; chain, `a?.b' and `a?[i]'. A type's `T?' stays the type's. ("[ \t]\\(\\?\\?\\)[ \t]" 1 font-lock-keyword-face) + ;; The pipe, `x |> f(a)', inside a line or starting one. + ("\\(?:^[ \t]*\\|[ \t]\\)\\(|>\\)\\(?:[ \t]\\|$\\)" 1 font-lock-keyword-face) ("[])[:alnum:]_]\\(!\\)\\(?:[^=]\\|$\\)" 1 font-lock-keyword-face) ("[])[:alnum:]_]\\(\\?\\)[.[]" 1 font-lock-keyword-face) (,(concat "\\_<" (regexp-opt flan--constants t) "\\_>") diff --git a/emacs/test-flan-fln.el b/emacs/test-flan-fln.el index 42ec72af..ef3000f0 100644 --- a/emacs/test-flan-fln.el +++ b/emacs/test-flan-fln.el @@ -950,6 +950,31 @@ defconst(k, 3) (forward-line 2) (test-flan--check "a line starting with ?? continues" (flan-fln--continuation-p (point)))) +;; The pipe: a line starting with `|>' goes under the value the statement +;; binds, then under the pipe above it. The first `|' of each text is point. +(test-flan-fln--is "a first |> goes under the let's value" + (test-flan-fln--tabs "let total = get(g)\n||> f()" 1) 12) +(test-flan-fln--is "and a second TAB one level in from the let" + (test-flan-fln--tabs "let total = get(g)\n||> f()" 2) 2) +(with-temp-buffer + (insert "let total = get(g)\n |> f()\n|> g()") + (flan-fln-mode) + (flan-fln-indent-line) + (test-flan-fln--is "a |> under a |> lines up with it" (current-indentation) 12)) +(test-flan-fln--is "a |> under a bare statement is one level in" + (test-flan-fln--tabs "fn f() -> ()\n get(g)\n||> f()" 1) 4) +(test-flan-fln--in "|let y = x |> f()\nz = a\n |> g\nw = b |>\n h\n" + (font-lock-ensure) + (test-flan-fln--is "a |> inside a line is marked" + (save-excursion (search-forward "x |>") (get-text-property (- (point) 2) 'face)) + 'font-lock-keyword-face) + (test-flan-fln--is "a |> starting a line is marked" + (save-excursion (search-forward " |>") (get-text-property (- (point) 2) 'face)) + 'font-lock-keyword-face) + (forward-line 2) + (test-flan--check "a line starting with |> continues" (flan-fln--continuation-p (point))) + (forward-line 2) + (test-flan--check "a line after a trailing |> continues it" (flan-fln--continuation-p (point)))) (test-flan-fln--is "else goes to its if's column, whatever the depth" (test-flan-fln--tabs "if a\n if b\n c\n |else" 1) 2) (test-flan-fln--is "and a second TAB to the outer if's" diff --git a/lib/indent_printer.ml b/lib/indent_printer.ml index 50e1db4e..8a8dbdb2 100644 --- a/lib/indent_printer.ml +++ b/lib/indent_printer.ml @@ -479,7 +479,7 @@ and list f h args = ((if t <> "" && t.[0] = '~' then "~(" ^ t ^ ")" else "~" ^ t), 13) | Form.Sym "unquote-splicing", [ x ] -> ("~@" ^ at 13 x, 13) | Form.Sym s, _ :: _ :: _ - when R.is_binop (infix_op s) && s <> "==" && s <> "??" + when R.is_binop (infix_op s) && s <> "==" && s <> "??" && s <> "|>" && not (s = "!=" && List.length args > 2) -> let op = infix_op s in let lvl = Option.get (R.binop_level op) in diff --git a/lib/indent_reader.ml b/lib/indent_reader.ml index 3898b753..b7dfc7e7 100644 --- a/lib/indent_reader.ml +++ b/lib/indent_reader.ml @@ -57,10 +57,11 @@ let binops = ("==", 4); ("!=", 4); ("<", 4); ("<=", 4); (">", 4); (">=", 4); ("||", 5); ("^^", 6); ("&&", 7); ("<<", 8); (">>", 8); ("+", 9); ("-", 9); ("*", 10); ("/", 10); ("%", 10); - (* [??] is here for the line rules — a line ending in it, or one starting - with it, continues — and is read by [operand] between 4 and 5, so no - level of [binary]'s is its own. *) - ("??", 0) ] + (* [??] and [|>] are here for the line rules — a line ending in one, or + starting with one, continues. [operand] reads [??] between 4 and 5, + and [binary] reads [|>] below [or]; no level of [binary_]'s climb is + their own. *) + ("??", 0); ("|>", 0) ] let binop_level s = List.assoc_opt s binops let is_binop s = binop_level s <> None @@ -1066,7 +1067,59 @@ let and_rewrite (xs : Form.t list) ops = separated only by whitespace. *) let rec expr p : Form.t * int = binary p 1 +(* [x |> f(a)] is [f(x, a)] and [x |> f] is [f(x)] (decision 137): the value + is the first argument, as Flan puts the thing operated on first. It is + below [or], so each side is a whole level-1 expression, and it reads to + the plain call, so nothing after the reader knows a pipe was written. + The right side is a name or a call to one and nothing else: those are the + only shapes the first-argument rewrite has one meaning for. *) and binary p lvl : Form.t * int = + if lvl <> 1 then binary_ p lvl + else + let l0 = (peek p).loc in + let ((first, _) as fp) = binary_ p 1 in + let rec more (x : Form.t) = + match (peek p).tok with + | NAME "|>" when not ((peek_at p 1).tok = LP && not (peek_at p 1).sp) -> + let ot = advance p in + if not (ot.sp && (peek p).sp) then + failk "unspaced-operator" ot.loc + "|> is an operator here, and a binary operator has a space on each \ + side: x |> f(a)"; + let i0 = p.i in + let t0 = p.toks.(i0) in + let rhs, rl = binary_ p 1 in + let head_at (h : Form.t) = + h.loc.Loc.line = t0.loc.Loc.line && h.loc.Loc.col = t0.loc.Loc.col + in + let glued_paren () = + i0 + 1 < Array.length p.toks + && p.toks.(i0 + 1).tok = LP && not p.toks.(i0 + 1).sp + in + let call = + match t0.tok, rhs.v with + | NAME _, Form.Sym _ when rl = 13 && p.i = i0 + 1 -> Some (rhs, []) + | NAME _, Form.List (({ v = Form.Sym _; _ } as h) :: args) + when rl = 12 && head_at h && (last p).tok = RP && glued_paren () -> + Some (h, args) + | _ -> None + in + (match call with + | Some (h, args) -> more (mk p l0 (Form.List (h :: x :: args))) + | None -> + failk "pipe-target" rhs.loc + "the right side of |> is a function name or a call, and %s is \ + neither. The value on the left becomes the first argument:\n\n\ + \ x |> f(a, b) is f(x, a, b)\n\ + \ x |> f is f(x)" + (if t0.tok = LP && rl = 13 then "(" ^ text_of rhs ^ ")" + else text_of rhs)) + | _ -> x + in + let r = more first in + if r == first then fp else (r, 1) + +and binary_ p lvl : Form.t * int = if lvl = 3 then not_ p else if lvl > 10 then unary p else diff --git a/spec-syntax.md b/spec-syntax.md index d6b13f7d..6b9dab1a 100644 --- a/spec-syntax.md +++ b/spec-syntax.md @@ -122,7 +122,7 @@ Each item: the proposal, then the reason in one line. a lambda's block there, laid out as at the top level against the column its line starts at, and the block ends where the enclosing bracket closes.* - **Continuation outside brackets:** a line that starts with a spaced infix - operator (`+`, `and`, `==`, …) continues the previous line; so does a line + operator (`+`, `and`, `==`, `|>`, …) continues the previous line; so does a line after one that ends in a spaced infix operator. (F# `LexFilter.fs` 360-380, 1850-1870, 2345-2360.) No `\` continuation. **Built** (`=` does not continue: `let x =` plus a block is a block value). A continuation line must @@ -163,11 +163,33 @@ Each item: the proposal, then the reason in one line. ### Expressions -- **Precedence**, low to high: `or` < `and` < `not` < comparisons +- **Precedence**, low to high: `|>` < `or` < `and` < `not` < comparisons (`== != < <= > >=`) < `??` < `||` < `^^` < `&&` < `<< >>` < `+ -` < `* / %` < prefix `-` and `~~` < postfix (call, index, field, `!`, `?`, `?.`). **Built.** An operator glued to `(` is always a call. The bit operators sit where Python and Rust put them, so `x && mask == 0` is `(x && mask) == 0`. +- **The pipe** (decision 137): `x |> f(a, b)` reads `(f x a b)`; `x |> f()` + and `x |> f` read `(f x)`. The value on the left is the first argument. A + chain runs left to right: `get(grid, r, c) |> or-else(empty) |> is-empty-cell()` + reads `(is-empty-cell (or-else (get grid r c) empty))`. The name may be + qualified, `x |> m/f(a)`, and the call may take a block, `xs |> each():`. + It is the loosest operator, so each side is a whole expression: + `a + 1 |> f()` is `f(a + 1)`, `a ?? b |> f()` is `f(a ?? b)`, + `a == b |> f()` is `f(a == b)`, `not x |> f()` is `f(not x)` and + `a or b |> f()` is `f(a or b)`. The left side is evaluated once, before + the call's other arguments, as the first argument of any call is. The + right side is a name or a call to one; anything else — `x |> 3`, + `x |> a + b`, `x |> a.b`, `x |> f(a).x`, `x |> f(a)(b)` — is refused. A + pipe line under a statement is indented past it: + + ``` + let cell = get(grid, r, c) + |> or-else(empty) + |> is-empty-cell() + ``` + + The reader writes the plain call, so nothing after it knows a pipe was + written. `|>(a, b)`, glued, is a call to a function named `|>`. **Built.** - **The bit operators** are `a && b`, `a || b`, `a ^^ b` and `~~a`, reading `(bit-and a b)`, `(bit-or a b)`, `(bit-xor a b)` and `(bit-not a)`. They take integers; `and`, `or` and `not` are the logical ones. `~~` is one token, so a diff --git a/test/programs/pipe.fln b/test/programs/pipe.fln new file mode 100644 index 00000000..2586017c --- /dev/null +++ b/test/programs/pipe.fln @@ -0,0 +1,42 @@ +;; The pipe x |> f(a) is f(x, a): the value on the left is the first argument, +;; evaluated before the others. Typed, dyn, chained, multi-line and qualified. + +import shape "pkgs/shape" +import area "pkgs/area" + +fn add(a: i32, b: i32) -> i32 = a + b +fn scale(a: i32, k: i32) -> i32 = a * k +fn twice(a: i32) -> i32 = a * 2 +fn sub3(a: i32, b: i32, c: i32) -> i32 = a - b - c + +;; Prints its argument, to show the order the arguments run in. +fn note(n: i32) -> i32 + println(n) + n + +fn held-or(o: i32?, d: i32) -> i32 = o ?? d + +fn bump(x) = x + 1 + +fn main() + println(3 |> add(4)) + println(3 |> twice) + println(3 |> twice()) + println(1 |> add(2) |> scale(10) |> twice) + ;; Below or and the arithmetic: the whole left side is piped. + println(2 + 3 |> scale(2)) + let n: i32? = None + println(n ?? 4 |> twice) + println(1 == 1 |> not) + let total = 10 + |> add(5) + |> scale(2) + println(total) + let other = 10 |> + sub3(1, 2) + println(other) + println(Some(7) |> held-or(0), n |> held-or(-1)) + println(shape/box(3, 2) |> area/of) + println(5 |> bump |> bump) + ;; The left side runs first, then the other arguments in order: 1 2 3. + println(note(1) |> sub3(note(2), note(3))) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 1233fb1c..7b8ace52 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -2276,6 +2276,12 @@ let () = [ ("optionals.fln", optionals_out); ("optionals-dyn.fln", optionals_dyn_out); (* x? tests and narrows, e? as g names what it found (decision 133). *) ("presence.fln", "true false true\n6\n-1\n3\n101 209 0\n11\n42\n2\nabsent\n6\nfalse true\n3\n6\n15\n"); ("presence-dyn.fln", "true false\n103 209 0\nno pet\nann\n3 2\n") ]; + (* The pipe (decision 137): chains, multi-line, qualified, dyn, and the + left side run before the other arguments (the last 1 2 3). *) + (let want = "7\n6\n6\n60\n10\n8\nfalse\n30\n7\n7 -1\n6\n7\n1\n2\n3\n-4\n" in + outputs "pipe.fln" "programs/pipe.fln" want; + outputs ~opt:"-O0" "pipe.fln, -O0" "programs/pipe.fln" want; + outputs ~x86:true "pipe.fln, --x86" "programs/pipe.fln" want); (* x! over nothing traps at its site and names the expression. *) List.iter (fun (x86, arg, want) -> diff --git a/test/test_syntax.ml b/test/test_syntax.ml index d64e413d..3f9711c8 100644 --- a/test/test_syntax.ml +++ b/test/test_syntax.ml @@ -1417,6 +1417,46 @@ let () = reads "while e? as g" "while pop(s)? as x\n f(x)" "(while true (if-let [x (pop s)] (do (f x)) (break)))"; refuses "as after no test" "if x as y\n y" "indent/as-test" "Write x? as name"; + (* Decision 137: x |> f(a) is f(x, a), below or. *) + reads "|> into a call" "y = x |> f(1, 2)" "(set y (f x 1 2))"; + reads "|> into an empty call" "y = x |> f()" "(set y (f x))"; + reads "|> into a name" "y = x |> f" "(set y (f x))"; + reads "|> into a case" "y = x |> Some" "(set y (Some x))"; + reads "|> chains left to right" + "y = get(grid, r, c) |> or-else(empty) |> is-empty-cell()" + "(set y (is-empty-cell (or-else (get grid r c) empty)))"; + reads "|> into a qualified name" "y = x |> m/f(a) |> m/g" "(set y (m/g (m/f x a)))"; + reads "|> is below arithmetic" "y = a + 1 |> f()" "(set y (f (+ a 1)))"; + reads "|> is below ??" "y = a ?? b |> f()" "(set y (f (?? a b)))"; + reads "|> is below comparisons" "y = a == b |> f()" "(set y (f (= a b)))"; + reads "|> is below not" "y = not x |> f()" "(set y (f (not x)))"; + reads "|> is below or" "y = a or b and c |> f()" "(set y (f (or a (and b c))))"; + reads "|> in an argument" "g(x |> f, 2)" "(g (f x) 2)"; + reads "|> in a condition" "if x |> is-empty() then 1 else 2" "(if (is-empty x) 1 2)"; + reads "|> starting lines" "let y = get(g, r)\n |> or-else(0)\n |> f()\ny" + "(def y dyn (f (or-else (get g r) 0)))\ny"; + reads "|> ending a line" "let y = get(g, r) |>\n or-else(0)\ny" + "(def y dyn (or-else (get g r) 0))\ny"; + reads "|> under a statement" "x\n |> f(1)\n |> g" "(g (f x 1))"; + reads "|> into a call with a block" "xs |> each(1):\n print(2)" "(each xs 1 (print 2))"; + reads "|>( is a call" "y = |>(a, b)" "(set y (|> a b))"; + refuses "|> into a number" "y = x |> 3" "indent/pipe-target" "3 is neither"; + refuses "|> into arithmetic" "y = x |> a + b" "indent/pipe-target" "a + b is neither"; + refuses "|> into a field" "y = x |> a.b" "indent/pipe-target" "a.b is neither"; + refuses "|> into a field of a call" "y = x |> f(a).x" "indent/pipe-target" "f(a).x"; + refuses "|> into a call's result" "y = x |> f(a)(b)" "indent/pipe-target" "f(a)(b)"; + refuses "|> into an index" "y = x |> a[1]" "indent/pipe-target" "a[1]"; + refuses "|> into a lambda" "y = x |> fn(a) => g(a)" "indent/pipe-target" "is neither"; + refuses "|> into parentheses" "y = x |> (f)" "indent/pipe-target" "(f) is neither"; + refuses "|> unspaced on the right" "y = x |>[f]" "indent/unspaced-operator" "x |> f(a)"; + refuses "|> at a statement's column" "f(x)\n|> g()" "indent/continuation" "starts with the operator |>"; + refuses "|> in a list separated by spaces" "y = [x |> f() 2]" "indent/separate-elements" "commas"; + (match read "y = |>(a, b)" with + | forms -> + let got = Indent_printer.program ~source:"y = |>(a, b)" forms in + if not (Test_support.contains got "|>(a, b)") then + fail "a call to |> prints as a call: %S" got + | exception e -> fail "a call to |> prints as a call: %s" (diag_text e)); refused "present-i32.fln" "fn main()\n let x = 5\n println(x?)\n" [ "x is i32, which always holds a value, so x? has nothing to test"; "a yes-or-no name starts with is- or has-, as in is-x" ];