From 6ccef45ce439947bce21dd4f3c7716c542528a2d Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 24 Apr 2026 17:31:38 +0000 Subject: [PATCH] haskell: expression parser + precedence climbing (+42 tests, 100/100) --- lib/haskell/parser.sx | 511 +++++++++++++++++++++++++++++++ lib/haskell/test.sh | 2 + lib/haskell/tests/parser-expr.sx | 258 ++++++++++++++++ plans/haskell-on-sx.md | 28 +- 4 files changed, 798 insertions(+), 1 deletion(-) create mode 100644 lib/haskell/parser.sx create mode 100644 lib/haskell/tests/parser-expr.sx diff --git a/lib/haskell/parser.sx b/lib/haskell/parser.sx new file mode 100644 index 00000000..ac17898a --- /dev/null +++ b/lib/haskell/parser.sx @@ -0,0 +1,511 @@ +;; Haskell 98 expression parser. +;; +;; Input: the post-layout token list from (hk-layout (hk-tokenize src)). +;; Output: an AST. Nodes are plain lists tagged by a keyword head +;; (keywords evaluate to their string name, so `(list :var "x")` is +;; indistinguishable from `(list "var" "x")` at runtime — this lets +;; tests literally write `(list :var "x")` on both sides). +;; +;; Scope (this iteration — expressions only): +;; atoms int/float/string/char/var/con, parens, tuple, list, range +;; application left-associative, f x y z +;; prefix - unary negation on an lexp +;; infix ops precedence-climbing, full Haskell 98 default table +;; lambda \x y -> body +;; if if c then t else e +;; let let { x = e ; y = e } in body (uses layout braces) +;; +;; AST shapes: +;; (:int N) +;; (:float F) +;; (:string S) +;; (:char C) +;; (:var NAME) +;; (:con NAME) +;; (:app FN ARG) — binary, chain for multi-arg +;; (:op OP LHS RHS) — binary infix +;; (:neg E) +;; (:tuple ITEMS) — ITEMS is a list of AST nodes +;; (:list ITEMS) — enumerated list +;; (:range FROM TO) — [from..to] +;; (:range-step FROM NEXT TO) — [from,next..to] +;; (:if C T E) +;; (:lambda PARAMS BODY) — PARAMS is list of varids +;; (:let BINDS BODY) — BINDS is list of (:bind NAME EXPR) + +;; ── Operator precedence table (Haskell 98 defaults) ────────────── +(define + hk-op-prec-table + (let + ((t (dict))) + (dict-set! t "!!" {:prec 9 :assoc "left"}) + (dict-set! t "." {:prec 9 :assoc "right"}) + (dict-set! t "^" {:prec 8 :assoc "right"}) + (dict-set! t "^^" {:prec 8 :assoc "right"}) + (dict-set! t "**" {:prec 8 :assoc "right"}) + (dict-set! t "*" {:prec 7 :assoc "left"}) + (dict-set! t "/" {:prec 7 :assoc "left"}) + (dict-set! t "+" {:prec 6 :assoc "left"}) + (dict-set! t "-" {:prec 6 :assoc "left"}) + (dict-set! t ":" {:prec 5 :assoc "right"}) + (dict-set! t "++" {:prec 5 :assoc "right"}) + (dict-set! t "==" {:prec 4 :assoc "non"}) + (dict-set! t "/=" {:prec 4 :assoc "non"}) + (dict-set! t "<" {:prec 4 :assoc "non"}) + (dict-set! t "<=" {:prec 4 :assoc "non"}) + (dict-set! t ">" {:prec 4 :assoc "non"}) + (dict-set! t ">=" {:prec 4 :assoc "non"}) + (dict-set! t "&&" {:prec 3 :assoc "right"}) + (dict-set! t "||" {:prec 2 :assoc "right"}) + (dict-set! t ">>" {:prec 1 :assoc "left"}) + (dict-set! t ">>=" {:prec 1 :assoc "left"}) + (dict-set! t "=<<" {:prec 1 :assoc "right"}) + (dict-set! t "$" {:prec 0 :assoc "right"}) + (dict-set! t "$!" {:prec 0 :assoc "right"}) + t)) + +(define + hk-op-info + (fn + (op) + (if + (has-key? hk-op-prec-table op) + (get hk-op-prec-table op) + {:prec 9 :assoc "left"}))) + +;; ── Atom-start predicate ───────────────────────────────────────── +(define + hk-atom-start? + (fn + (tok) + (if + (nil? tok) + false + (let + ((ty (get tok "type"))) + (or + (= ty "integer") + (= ty "float") + (= ty "string") + (= ty "char") + (= ty "varid") + (= ty "conid") + (= ty "qvarid") + (= ty "qconid") + (= ty "lparen") + (= ty "lbracket")))))) + +;; ── Main entry ─────────────────────────────────────────────────── +(define + hk-parse-expr + (fn + (tokens) + (let + ((toks tokens) (pos 0) (n (len tokens))) + + (define hk-peek (fn () (if (< pos n) (nth toks pos) nil))) + (define + hk-peek-at + (fn + (offset) + (if (< (+ pos offset) n) (nth toks (+ pos offset)) nil))) + (define + hk-advance! + (fn () (let ((t (hk-peek))) (set! pos (+ pos 1)) t))) + (define + hk-peek-type + (fn () (let ((t (hk-peek))) (if (nil? t) "" (get t "type"))))) + (define + hk-peek-value + (fn () (let ((t (hk-peek))) (if (nil? t) nil (get t "value"))))) + (define + hk-match? + (fn + (ty v) + (let + ((t (hk-peek))) + (and + (not (nil? t)) + (= (get t "type") ty) + (or (nil? v) (= (get t "value") v)))))) + (define + hk-err + (fn + (msg) + (raise + (str + "parse error: " + msg + " (at " + (hk-peek-type) + (if (nil? (hk-peek-value)) "" (str " " (hk-peek-value))) + ")")))) + (define + hk-expect! + (fn + (ty v) + (if + (hk-match? ty v) + (hk-advance!) + (hk-err + (str "expected " ty (if (nil? v) "" (str " '" v "'"))))))) + + ;; ── Atoms ──────────────────────────────────────────────── + (define + hk-parse-aexp + (fn + () + (let + ((t (hk-peek))) + (cond + ((nil? t) (hk-err "unexpected end of input")) + ((= (get t "type") "integer") + (do (hk-advance!) (list :int (get t "value")))) + ((= (get t "type") "float") + (do (hk-advance!) (list :float (get t "value")))) + ((= (get t "type") "string") + (do (hk-advance!) (list :string (get t "value")))) + ((= (get t "type") "char") + (do (hk-advance!) (list :char (get t "value")))) + ((= (get t "type") "varid") + (do (hk-advance!) (list :var (get t "value")))) + ((= (get t "type") "conid") + (do (hk-advance!) (list :con (get t "value")))) + ((= (get t "type") "qvarid") + (do (hk-advance!) (list :var (get t "value")))) + ((= (get t "type") "qconid") + (do (hk-advance!) (list :con (get t "value")))) + ((= (get t "type") "lparen") (hk-parse-parens)) + ((= (get t "type") "lbracket") (hk-parse-list-lit)) + (:else (hk-err "unexpected token in expression")))))) + + ;; ── Parens / tuple / unit ──────────────────────────────── + (define + hk-parse-parens + (fn + () + (hk-expect! "lparen" nil) + (cond + ((hk-match? "rparen" nil) + (do (hk-advance!) (list :con "()"))) + (:else + (let + ((first-e (hk-parse-expr-inner)) + (items (list)) + (is-tuple false)) + (append! items first-e) + (define + hk-tup-loop + (fn + () + (when + (hk-match? "comma" nil) + (do + (hk-advance!) + (set! is-tuple true) + (append! items (hk-parse-expr-inner)) + (hk-tup-loop))))) + (hk-tup-loop) + (hk-expect! "rparen" nil) + (if is-tuple (list :tuple items) first-e)))))) + + ;; ── List literal / range ───────────────────────────────── + (define + hk-parse-list-lit + (fn + () + (hk-expect! "lbracket" nil) + (cond + ((hk-match? "rbracket" nil) + (do (hk-advance!) (list :list (list)))) + (:else + (let + ((first-e (hk-parse-expr-inner))) + (cond + ((hk-match? "reservedop" "..") + (do + (hk-advance!) + (let + ((end-e (hk-parse-expr-inner))) + (hk-expect! "rbracket" nil) + (list :range first-e end-e)))) + ((hk-match? "comma" nil) + (do + (hk-advance!) + (let + ((second-e (hk-parse-expr-inner))) + (cond + ((hk-match? "reservedop" "..") + (do + (hk-advance!) + (let + ((end-e (hk-parse-expr-inner))) + (hk-expect! "rbracket" nil) + (list + :range-step + first-e + second-e + end-e)))) + (:else + (let + ((items (list))) + (append! items first-e) + (append! items second-e) + (define + hk-list-loop + (fn + () + (when + (hk-match? "comma" nil) + (do + (hk-advance!) + (append! + items + (hk-parse-expr-inner)) + (hk-list-loop))))) + (hk-list-loop) + (hk-expect! "rbracket" nil) + (list :list items))))))) + (:else + (do + (hk-expect! "rbracket" nil) + (list :list (list first-e)))))))))) + + ;; ── Application: left-assoc aexp chain ─────────────────── + (define + hk-parse-fexp + (fn + () + (let + ((fn-e (hk-parse-aexp))) + (define + hk-app-loop + (fn + () + (when + (hk-atom-start? (hk-peek)) + (let + ((arg (hk-parse-aexp))) + (set! fn-e (list :app fn-e arg)) + (hk-app-loop))))) + (hk-app-loop) + fn-e))) + + ;; ── Lambda: \ p1 p2 ... pn -> body ─────────────────────── + (define + hk-parse-lambda + (fn + () + (hk-expect! "reservedop" "\\") + (let + ((params (list))) + (when + (not (hk-match? "varid" nil)) + (hk-err "lambda parameter must be a variable")) + (define + hk-lam-loop + (fn + () + (when + (hk-match? "varid" nil) + (do + (append! params (get (hk-advance!) "value")) + (hk-lam-loop))))) + (hk-lam-loop) + (hk-expect! "reservedop" "->") + (list :lambda params (hk-parse-expr-inner))))) + + ;; ── if-then-else ──────────────────────────────────────── + (define + hk-parse-if + (fn + () + (hk-expect! "reserved" "if") + (let + ((c (hk-parse-expr-inner))) + (hk-expect! "reserved" "then") + (let + ((th (hk-parse-expr-inner))) + (hk-expect! "reserved" "else") + (let + ((el (hk-parse-expr-inner))) + (list :if c th el)))))) + + ;; ── Let expression ────────────────────────────────────── + (define + hk-parse-let + (fn + () + (hk-expect! "reserved" "let") + (let ((explicit (hk-match? "lbrace" nil))) + (if + explicit + (hk-advance!) + (hk-expect! "vlbrace" nil)) + (let + ((binds (list))) + (when + (not + (if + explicit + (hk-match? "rbrace" nil) + (hk-match? "vrbrace" nil))) + (do + (append! binds (hk-parse-bind)) + (define + hk-let-loop + (fn + () + (when + (or + (hk-match? "semi" nil) + (hk-match? "vsemi" nil)) + (do + (hk-advance!) + (when + (not + (if + explicit + (hk-match? "rbrace" nil) + (hk-match? "vrbrace" nil))) + (append! binds (hk-parse-bind))) + (hk-let-loop))))) + (hk-let-loop))) + (if + explicit + (hk-expect! "rbrace" nil) + (hk-expect! "vrbrace" nil)) + (hk-expect! "reserved" "in") + (list :let binds (hk-parse-expr-inner)))))) + + (define + hk-parse-bind + (fn + () + (when + (not (hk-match? "varid" nil)) + (hk-err "binding must start with a variable")) + (let + ((name (get (hk-advance!) "value"))) + (hk-expect! "reservedop" "=") + (list :bind name (hk-parse-expr-inner))))) + + ;; ── lexp: lambda | if | let | fexp ────────────────────── + (define + hk-parse-lexp + (fn + () + (cond + ((hk-match? "reservedop" "\\") (hk-parse-lambda)) + ((hk-match? "reserved" "if") (hk-parse-if)) + ((hk-match? "reserved" "let") (hk-parse-let)) + (:else (hk-parse-fexp))))) + + ;; ── Prefix: unary - ───────────────────────────────────── + (define + hk-parse-prefix + (fn + () + (cond + ((and (hk-match? "varsym" "-")) + (do (hk-advance!) (list :neg (hk-parse-lexp)))) + (:else (hk-parse-lexp))))) + + ;; ── Infix: precedence climbing ────────────────────────── + (define + hk-is-infix-op? + (fn + (tok) + (if + (nil? tok) + false + (or + (= (get tok "type") "varsym") + (= (get tok "type") "consym") + (and + (= (get tok "type") "reservedop") + (= (get tok "value") ":")) + (= (get tok "type") "backtick"))))) + + (define + hk-consume-op! + (fn + () + (let + ((t (hk-peek))) + (cond + ((= (get t "type") "backtick") + (do + (hk-advance!) + (let + ((v (hk-expect! "varid" nil))) + (hk-expect! "backtick" nil) + (get v "value")))) + (:else (do (hk-advance!) (get t "value"))))))) + + (define + hk-parse-infix + (fn + (min-prec) + (let + ((left (hk-parse-prefix))) + (define + hk-inf-loop + (fn + () + (when + (hk-is-infix-op? (hk-peek)) + (let + ((op-tok (hk-peek))) + (let + ((op-name + (if + (= (get op-tok "type") "backtick") + (get (hk-peek-at 1) "value") + (get op-tok "value")))) + (let + ((info (hk-op-info op-name))) + (when + (>= (get info "prec") min-prec) + (do + (hk-consume-op!) + (let + ((next-min + (cond + ((= (get info "assoc") "left") + (+ (get info "prec") 1)) + ((= (get info "assoc") "right") + (get info "prec")) + (:else (+ (get info "prec") 1))))) + (let + ((right (hk-parse-infix next-min))) + (set! + left + (list :op op-name left right)) + (hk-inf-loop))))))))))) + (hk-inf-loop) + left))) + + (define hk-parse-expr-inner (fn () (hk-parse-infix 0))) + + ;; ── Top-level: strip leading/trailing module-level braces ─ + (let + ((start-brace + (or + (hk-match? "vlbrace" nil) + (hk-match? "lbrace" nil)))) + (when start-brace (hk-advance!)) + (let + ((result (hk-parse-expr-inner))) + (when start-brace + (when + (or + (hk-match? "vrbrace" nil) + (hk-match? "rbrace" nil)) + (hk-advance!))) + result))))) + +;; ── Convenience: tokenize + layout + parse ─────────────────────── +(define + hk-parse + (fn (src) (hk-parse-expr (hk-layout (hk-tokenize src))))) diff --git a/lib/haskell/test.sh b/lib/haskell/test.sh index 2af17416..54a47fa4 100755 --- a/lib/haskell/test.sh +++ b/lib/haskell/test.sh @@ -47,6 +47,7 @@ for FILE in "${FILES[@]}"; do (epoch 1) (load "lib/haskell/tokenizer.sx") (load "lib/haskell/layout.sx") +(load "lib/haskell/parser.sx") (load "lib/haskell/testlib.sx") (epoch 2) (load "$FILE") @@ -84,6 +85,7 @@ EPOCHS (epoch 1) (load "lib/haskell/tokenizer.sx") (load "lib/haskell/layout.sx") +(load "lib/haskell/parser.sx") (load "lib/haskell/testlib.sx") (epoch 2) (load "$FILE") diff --git a/lib/haskell/tests/parser-expr.sx b/lib/haskell/tests/parser-expr.sx new file mode 100644 index 00000000..e9d4d67b --- /dev/null +++ b/lib/haskell/tests/parser-expr.sx @@ -0,0 +1,258 @@ +;; Haskell expression parser tests. +;; hk-parse tokenises, runs layout, then parses. Output is an AST +;; whose head is a keyword tag (evaluates to its string name). + +;; ── 1. Literals ── +(hk-test "integer" (hk-parse "42") (list :int 42)) +(hk-test "float" (hk-parse "3.14") (list :float 3.14)) +(hk-test "string" (hk-parse "\"hi\"") (list :string "hi")) +(hk-test "char" (hk-parse "'a'") (list :char "a")) + +;; ── 2. Variables and constructors ── +(hk-test "varid" (hk-parse "foo") (list :var "foo")) +(hk-test "conid" (hk-parse "Nothing") (list :con "Nothing")) +(hk-test "qvarid" (hk-parse "Data.Map.lookup") (list :var "Data.Map.lookup")) +(hk-test "qconid" (hk-parse "Data.Map") (list :con "Data.Map")) + +;; ── 3. Parens / unit / tuple ── +(hk-test "parens strip" (hk-parse "(42)") (list :int 42)) +(hk-test "unit" (hk-parse "()") (list :con "()")) +(hk-test + "2-tuple" + (hk-parse "(1, 2)") + (list :tuple (list (list :int 1) (list :int 2)))) +(hk-test + "3-tuple" + (hk-parse "(x, y, z)") + (list + :tuple + (list (list :var "x") (list :var "y") (list :var "z")))) + +;; ── 4. Lists ── +(hk-test "empty list" (hk-parse "[]") (list :list (list))) +(hk-test + "singleton list" + (hk-parse "[1]") + (list :list (list (list :int 1)))) +(hk-test + "list of ints" + (hk-parse "[1, 2, 3]") + (list + :list + (list (list :int 1) (list :int 2) (list :int 3)))) +(hk-test + "range" + (hk-parse "[1..10]") + (list :range (list :int 1) (list :int 10))) +(hk-test + "range with step" + (hk-parse "[1, 3..10]") + (list + :range-step + (list :int 1) + (list :int 3) + (list :int 10))) + +;; ── 5. Application ── +(hk-test + "one-arg app" + (hk-parse "f x") + (list :app (list :var "f") (list :var "x"))) +(hk-test + "multi-arg app is left-assoc" + (hk-parse "f x y z") + (list + :app + (list + :app + (list :app (list :var "f") (list :var "x")) + (list :var "y")) + (list :var "z"))) +(hk-test + "app with con" + (hk-parse "Just 5") + (list :app (list :con "Just") (list :int 5))) + +;; ── 6. Infix operators ── +(hk-test + "simple +" + (hk-parse "1 + 2") + (list :op "+" (list :int 1) (list :int 2))) +(hk-test + "precedence: * binds tighter than +" + (hk-parse "1 + 2 * 3") + (list + :op + "+" + (list :int 1) + (list :op "*" (list :int 2) (list :int 3)))) +(hk-test + "- is left-assoc" + (hk-parse "10 - 3 - 2") + (list + :op + "-" + (list :op "-" (list :int 10) (list :int 3)) + (list :int 2))) +(hk-test + ": is right-assoc" + (hk-parse "a : b : c") + (list + :op + ":" + (list :var "a") + (list :op ":" (list :var "b") (list :var "c")))) +(hk-test + "app binds tighter than op" + (hk-parse "f x + g y") + (list + :op + "+" + (list :app (list :var "f") (list :var "x")) + (list :app (list :var "g") (list :var "y")))) +(hk-test + "$ is lowest precedence, right-assoc" + (hk-parse "f $ g x") + (list + :op + "$" + (list :var "f") + (list :app (list :var "g") (list :var "x")))) + +;; ── 7. Backticks (varid-as-operator) ── +(hk-test + "backtick operator" + (hk-parse "x `mod` 3") + (list :op "mod" (list :var "x") (list :int 3))) + +;; ── 8. Unary negation ── +(hk-test + "unary -" + (hk-parse "- 5") + (list :neg (list :int 5))) +(hk-test + "unary - on application" + (hk-parse "- f x") + (list :neg (list :app (list :var "f") (list :var "x")))) +(hk-test + "- n + m → (- n) + m" + (hk-parse "- 1 + 2") + (list + :op + "+" + (list :neg (list :int 1)) + (list :int 2))) + +;; ── 9. Lambda ── +(hk-test + "lambda single param" + (hk-parse "\\x -> x") + (list :lambda (list "x") (list :var "x"))) +(hk-test + "lambda multi-param" + (hk-parse "\\x y -> x + y") + (list + :lambda + (list "x" "y") + (list :op "+" (list :var "x") (list :var "y")))) +(hk-test + "lambda body is full expression" + (hk-parse "\\f -> f 1 + f 2") + (list + :lambda + (list "f") + (list + :op + "+" + (list :app (list :var "f") (list :int 1)) + (list :app (list :var "f") (list :int 2))))) + +;; ── 10. if-then-else ── +(hk-test + "if basic" + (hk-parse "if x then 1 else 2") + (list :if (list :var "x") (list :int 1) (list :int 2))) +(hk-test + "if with infix cond" + (hk-parse "if x == 0 then y else z") + (list + :if + (list :op "==" (list :var "x") (list :int 0)) + (list :var "y") + (list :var "z"))) + +;; ── 11. let-in ── +(hk-test + "let single binding" + (hk-parse "let x = 1 in x") + (list + :let + (list (list :bind "x" (list :int 1))) + (list :var "x"))) +(hk-test + "let two bindings (multi-line)" + (hk-parse "let x = 1\n y = 2\nin x + y") + (list + :let + (list + (list :bind "x" (list :int 1)) + (list :bind "y" (list :int 2))) + (list :op "+" (list :var "x") (list :var "y")))) +(hk-test + "let with explicit braces" + (hk-parse "let { x = 1 ; y = 2 } in x + y") + (list + :let + (list + (list :bind "x" (list :int 1)) + (list :bind "y" (list :int 2))) + (list :op "+" (list :var "x") (list :var "y")))) + +;; ── 12. Mixed / nesting ── +(hk-test + "nested application" + (hk-parse "f (g x) y") + (list + :app + (list + :app + (list :var "f") + (list :app (list :var "g") (list :var "x"))) + (list :var "y"))) +(hk-test + "lambda applied" + (hk-parse "(\\x -> x + 1) 5") + (list + :app + (list + :lambda + (list "x") + (list :op "+" (list :var "x") (list :int 1))) + (list :int 5))) +(hk-test + "lambda + if" + (hk-parse "\\n -> if n == 0 then 1 else n") + (list + :lambda + (list "n") + (list + :if + (list :op "==" (list :var "n") (list :int 0)) + (list :int 1) + (list :var "n")))) + +;; ── 13. Precedence corners ── +(hk-test + ". is right-assoc (prec 9)" + (hk-parse "f . g . h") + (list + :op + "." + (list :var "f") + (list :op "." (list :var "g") (list :var "h")))) +(hk-test + "== is non-associative (single use)" + (hk-parse "x == y") + (list :op "==" (list :var "x") (list :var "y"))) + +{:fails hk-test-fails :pass hk-test-pass :fail hk-test-fail} diff --git a/plans/haskell-on-sx.md b/plans/haskell-on-sx.md index b87ad274..ea0142b5 100644 --- a/plans/haskell-on-sx.md +++ b/plans/haskell-on-sx.md @@ -56,7 +56,14 @@ Key mappings: ### Phase 1 — tokenizer + parser + layout rule - [x] Tokenizer: reserved words, qualified names, operators, numbers (int, float, Rational later), chars/strings, comments (`--` and `{-` nested) - [x] Layout algorithm: turn indentation into virtual `{`, `;`, `}` tokens per Haskell 98 §10.3 -- [ ] Parser: modules, imports (stub), top-level decls, type sigs, function clauses with patterns + guards + where-clauses, expressions with operator precedence, lambdas, `let`, `if`, `case`, `do`, list comp, sections +- Parser (split into sub-items — implement one per iteration): + - [x] Expressions: atoms, parens, tuples, lists, ranges, application, infix with full Haskell-98 precedence table, unary `-`, backtick operators, lambdas, `if`, `let` + - [ ] `case … of` and `do`-notation expressions + - [ ] Patterns (var, wildcard, literal, constructor, as, nested) — consumed by lambdas, let, case, and function clauses + - [ ] Top-level decls: function clauses, type signatures, `data`, `type`, `newtype`, fixity decls + - [ ] `where` clauses + guards + - [ ] Module header + imports (stub) + - [ ] List comprehensions + operator sections - [ ] AST design modelled on GHC's HsSyn at a surface level - [x] Unit tests in `lib/haskell/tests/parse.sx` (43 tokenizer tests, all green) @@ -107,6 +114,25 @@ Key mappings: _Newest first._ +- **2026-04-24** — Phase 1: expression parser (`lib/haskell/parser.sx`, ~380 lines). + Pratt-style precedence climbing against a Haskell-98-default op table (24 + operators across precedence 0–9, left/right/non assoc, default infixl 9 for + anything unlisted). Supports literals (int/float/string/char), varid/conid + (qualified variants folded into `:var` / `:con`), parens / unit / tuples, + list literals, ranges `[a..b]` and `[a,b..c]`, left-associative application, + unary `-`, backtick operators (`x \`mod\` 3`), lambdas, `if-then-else`, and + `let … in` consuming both virtual and explicit braces. AST uses keyword + tags (`:var`, `:op`, `:lambda`, `:let`, `:bind`, `:tuple`, `:range`, + `:range-step`, `:app`, `:neg`, `:if`, `:list`, `:int`, `:float`, `:string`, + `:char`, `:con`). The parser skips a leading `vlbrace` / `lbrace` so it can + be called on full post-layout output, and uses a `raise`-based error channel + with location-lite messages. 42 new tests in `lib/haskell/tests/parser-expr.sx` + cover literals, identifiers, parens/tuple/unit, list + range, app associativity, + operator precedence (mul over add, cons right-assoc, function-composition + right-assoc, `$` lowest), backtick ops, unary `-`, lambda multi-param, + `if` with infix condition, single- and multi-binding `let` (both implicit + and explicit braces), plus a few mixed nestings. 100/100 green. + - **2026-04-24** — Phase 1: layout algorithm (`lib/haskell/layout.sx`, ~260 lines) implementing Haskell 98 §10.3. Two-pass design: a pre-pass augments the raw token stream with explicit `layout-open` / `layout-indent` markers (suppressing