rip-lang 3.16.1 → 3.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +2 -3
  2. package/bin/rip +39 -8
  3. package/bin/rip-schema +175 -0
  4. package/docs/RIP-APP.md +91 -2
  5. package/docs/RIP-DUCKDB.md +64 -1
  6. package/docs/RIP-INTRO.md +4 -4
  7. package/docs/RIP-LANG.md +32 -33
  8. package/docs/RIP-SCHEMA.md +1204 -364
  9. package/docs/dist/rip.js +3245 -611
  10. package/docs/dist/rip.min.js +1161 -289
  11. package/docs/dist/rip.min.js.br +0 -0
  12. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  13. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  14. package/docs/extensions/vscode/rip/index.html +2 -1
  15. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  16. package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
  17. package/docs/index.html +1 -1
  18. package/docs/ui/hljs-rip.js +1 -1
  19. package/package.json +7 -4
  20. package/src/AGENTS.md +39 -8
  21. package/src/compiler.js +220 -36
  22. package/src/components.js +315 -14
  23. package/src/dts.js +18 -3
  24. package/src/grammar/README.md +29 -170
  25. package/src/grammar/grammar.rip +17 -12
  26. package/src/grammar/solar.rip +4 -17
  27. package/src/lexer.js +24 -17
  28. package/src/parser.js +229 -229
  29. package/src/schema/dts.js +328 -54
  30. package/src/schema/loader-server.js +2 -1
  31. package/src/schema/runtime-browser-stubs.js +20 -9
  32. package/src/schema/runtime-ddl.js +161 -44
  33. package/src/schema/runtime-migrate.js +681 -0
  34. package/src/schema/runtime-orm.js +698 -54
  35. package/src/schema/runtime-validate.js +808 -24
  36. package/src/schema/runtime.generated.js +2395 -135
  37. package/src/schema/schema.js +1049 -89
  38. package/src/typecheck.js +283 -55
  39. package/src/types.js +5 -1
  40. package/src/grammar/lunar.rip +0 -2412
@@ -1,27 +1,25 @@
1
- # Solar & Lunar Dual Parser Generators
1
+ # Solar — Rip's Parser Generator
2
2
 
3
- One grammar. Two parsers. Two fundamentally different parsing strategies.
3
+ One grammar. One parser. Mathematically derived.
4
4
 
5
5
  ```
6
- grammar.rip ──→ Solar ──→ parser.js (SLR(1) table-driven, 215KB)
7
- └→ Lunar ──→ parser-rd.js (predictive recursive descent, 110KB)
6
+ grammar.rip ──→ Solar ──→ parser.js (SLR(1) table-driven)
8
7
  ```
9
8
 
10
- Both parsers accept the same token stream from the lexer and produce identical
11
- s-expression ASTs. They share no code at runtimethey are completely
12
- independent implementations derived from the same grammar specification.
13
-
14
- **Test parity:** 1,162 / 1,182 tests passing (98.3%) — 20 files at 100%.
9
+ The parser accepts the token stream from the lexer and produces s-expression
10
+ ASTs simple arrays like `["=", "x", 42]` that the code emitter consumes.
15
11
 
16
12
  ---
17
13
 
18
14
  ## Files
19
15
 
20
- | File | Lines | Purpose |
21
- |------|-------|---------|
22
- | `grammar.rip` | 944 | Grammar specification — defines all syntax rules |
23
- | `solar.rip` | 929 | SLR(1) parser generator — produces table-driven parsers |
24
- | `lunar.rip` | 2,412 | Predictive recursive descent generator — produces PRD parsers |
16
+ | File | Purpose |
17
+ |------|---------|
18
+ | `grammar.rip` | Grammar specification — defines all syntax rules |
19
+ | `solar.rip` | SLR(1) parser generator — produces the table-driven parser |
20
+
21
+ `src/parser.js` is the generated output. Never edit it by hand — regenerate
22
+ with `bun run parser` after any grammar change.
25
23
 
26
24
  ---
27
25
 
@@ -51,6 +49,13 @@ Operation: [
51
49
  ]
52
50
  ```
53
51
 
52
+ Action format:
53
+
54
+ - Numbers (`1`, `2`, `...3`) reference matched symbols by position
55
+ - `...N` spreads the array at position N
56
+ - String literals become s-expression nodes: `'["if", 2, 3]'`
57
+ - Default action (no action given) returns position 1
58
+
54
59
  ---
55
60
 
56
61
  ## Solar (`solar.rip`) — SLR(1) Table Parser Generator
@@ -66,169 +71,23 @@ Grammar → Process Rules → Build LR Automaton → Compute FIRST/FOLLOW
66
71
 
67
72
  A table-driven parser where every parsing decision is a lookup:
68
73
  `parseTable[state][symbol]` → shift, reduce, or accept. The parse table
69
- encodes 801 states with all transitions delta-compressed for minimal size.
74
+ encodes all states with transitions delta-compressed for minimal size.
70
75
 
71
76
  ### How to use
72
77
 
73
78
  ```bash
79
+ bun run parser # Regenerate src/parser.js
74
80
  bun src/grammar/solar.rip grammar.rip # → parser.js (SLR table)
75
- bun src/grammar/solar.rip -r grammar.rip # → parser-rd.js (PRD via Lunar)
76
81
  bun src/grammar/solar.rip --info grammar.rip # Show grammar statistics
82
+ bun src/grammar/solar.rip --info --conflicts grammar.rip # Conflict details
77
83
  bun src/grammar/solar.rip --sexpr grammar.rip # Show grammar as s-expression
84
+ bun src/grammar/solar.rip -o out.js grammar.rip # Custom output path
78
85
  ```
79
86
 
80
- ### Integration with Lunar
81
-
82
- Solar imports Lunar and installs it with one line:
83
-
84
- ```coffee
85
- import { install as installLunar } from './lunar.rip'
86
- # ... (Generator class definition) ...
87
- installLunar Generator
88
- ```
89
-
90
- This adds `generateRD()` to the Generator prototype. When `-r` is passed,
91
- Solar calls `generator.generateRD()` instead of `generator.generate()`.
92
-
93
- ---
94
-
95
- ## Lunar (`lunar.rip`) — Predictive Recursive Descent Generator
96
-
97
- Lunar analyzes the same grammar that Solar processes and generates a
98
- hand-rolled-looking recursive descent parser with Pratt expression parsing.
99
-
100
- ### Architecture
101
-
102
- The generated parser has five layers:
103
-
104
- 1. **Token management** — `advance()`, `expect()`, `match()`, `loc()`, `withLoc()`
105
- 2. **Speculation** — `mark()`, `reset()`, `speculate()` for backtracking
106
- 3. **Pratt expression parser** — `parseExpression(minBP)` with binding powers
107
- 4. **Nonterminal functions** — one `parseX()` function per grammar nonterminal
108
- 5. **Parser shell** — same API as the table parser (`parser.parse()`, exports)
109
-
110
- ### How it works
111
-
112
- Lunar derives everything from the grammar — no hardcoded token or nonterminal
113
- names in the core generators:
114
-
115
- **Expression analysis** (`_analyzeExpressionRules`) — walks the grammar to detect:
116
- - Which nonterminal is the "expression" (contains Operation as an alternative)
117
- - Which is the "operation" (has the most `NT OP NT` binary rules with precedence)
118
- - Which is the "value" (pure choice nonterminal with the most atom alternatives)
119
- - Which is "code" (FIRST set contains `->` or `=>`)
120
- - Assignment operators (nonterminals where all rules are `LHS TOKEN RHS`)
121
- - Prefix starters (keyword tokens that begin expression alternatives)
122
- - Postfix chains (left-recursive property/index/call rules through Value chain)
123
- - Atom types (terminals reachable through the Value nonterminal chain)
124
- - Expression-handled tokens (for skipping redundant choice alternatives)
125
-
126
- **Pratt parser generation** (`_generateRDExpression`) — builds the while loop:
127
- - Prefix starters dispatch to keyword-led parsers (IF→parseIf, FOR→parseFor, etc.)
128
- - Assignment operators checked at binding power 0
129
- - Postfix operators from Operation rules (with INDENT/TERMINATOR variants)
130
- - Postfix chains from property/index/call rules (resolved to FIRST sets)
131
- - Infix binary operators with correct associativity and control-flow merging
132
- - Ternary operators
133
- - Postfix if/unless/while/until and comprehensions
134
- - Statement tokens (RETURN, STATEMENT) enter the Pratt loop for postfix patterns
135
-
136
- **Nonterminal classification** (`_classifyNonterminal`) — detects patterns:
137
-
138
- | Pattern | Detection | Example |
139
- |---------|-----------|---------|
140
- | `root` | Grammar start symbol | Root |
141
- | `body-list` | Left-recursive with TERMINATOR | Body, ComponentBody |
142
- | `comma-list` | Left-recursive with `,` | ArgList, ParamList |
143
- | `concat-list` | Left-recursive, no separator | Interpolations, Cases |
144
- | `left-rec-loop` | Self-referential with terminal continuation | IfBlock |
145
- | `expression` | Contains the operation nonterminal | Expression |
146
- | `operation` | Has binary operator rules | Operation |
147
- | `token` | Single rule, single terminal | Identifier, Property |
148
- | `keyword` | All rules start with unique terminals | Return, Def, Enum |
149
- | `choice` | All rules are single-nonterminal passthroughs | Value, Line, Statement |
150
- | `sequence` | Everything else | Assign, Catch, Block |
151
-
152
- **Shared prefix disambiguation** (`_generateRDSharedPrefix`) — handles rules
153
- that share a common beginning:
154
- - Optional chain detection with rule-length-based action dispatch
155
- - Same-token grouping with deeper disambiguation
156
- - Terminal and nonterminal suffix separation with FIRST set grouping
157
- - Empty-rule-as-default when nonterminal suffixes have FIRST checks
158
-
159
- **Speculation** — `mark()`/`reset()`/`speculate()` for grammar ambiguities:
160
- - Range vs Array: `[1..10]` vs `[1, 2, 3]` — try Range first
161
- - Slice vs Expression in INDEX_START: `arr[1..3]` vs `arr[0]`
162
- - Range vs destructuring in For: `for [1..5]` vs `for [a, b] as iter`
163
- - Terminal/nonterminal overlap: `...` as Splat vs expansion marker
164
- - Left-rec-loop lookahead: `ELSE IF` vs `ELSE Block` in IfBlock
165
-
166
- ### Three specialized generators
167
-
168
- Three nonterminals (out of 93) have patterns that require multi-token lookahead
169
- and can't be handled by the generic generators:
170
-
171
- - **For** (34 rules) — FORIN/FOROF/FORAS variants with optional WHEN/BY in both orders
172
- - **Object** (5 rules) — comprehension vs regular object determined after parsing key:value
173
- - **AssignObj** (6 rules) — key:value vs key=default vs shorthand vs rest after parsing key
174
-
175
- 90 of 93 nonterminals (96.8%) are generated purely from grammar analysis.
176
-
177
- ### Remaining 20 test failures (98.3% → 100%)
178
-
179
- The remaining failures cluster into a few fixable categories:
180
-
181
- | Category | Tests | Root Cause |
182
- |----------|-------|------------|
183
- | **Array elisions** | 5 | `[,1]`, `[1,,3]` — ArgElisionList/Elision not parsed |
184
- | **Trailing comma** | 1 | `[1,2,]` — OptElisions at end of array |
185
- | **Export/Import edge** | 3 | `export { x }` without FROM, `export x ~= ...`, `import x, * as m` — deeper shared-prefix disambiguation generates duplicate conditions |
186
- | **Semicolons context** | 3 | `def foo(); 42` — Def/async without block body (CALL_END before Block) |
187
- | **Class patterns** | 2 | Class expression without name, `@bar:` static in class body |
188
- | **Postfix ternary** | 1 | `a = x if true else 0` — assignment context for postfix ternary |
189
- | **Other edge cases** | 5 | `invalid extends`, `array destructuring skip`, type alias, typed runtime |
190
-
191
- **Key fix needed for Export/Import:** The shared-prefix handler generates duplicate
192
- `else if` branches with identical conditions when two rules share a common prefix
193
- through nonterminals but diverge at a terminal after parsing (e.g., `} FROM` vs `}`).
194
- The inner terminal group disambiguator needs to check terminal continuation instead
195
- of generating separate branches.
196
-
197
- **Key fix needed for elisions:** The Array parser routes `[` to Range speculation
198
- then Array. Array uses ArgElisionList which calls Arg → Expression. But leading
199
- commas `[,1]` and sparse `[1,,3]` need the Elision nonterminal which the generic
200
- comma-list handler doesn't generate.
201
-
202
- ---
203
-
204
- ## Comparison
205
-
206
- | Aspect | Solar (SLR) | Lunar (PRD) |
207
- |--------|-------------|-------------|
208
- | Strategy | Bottom-up table lookup | Top-down predictive |
209
- | Output size | 215KB (encoded tables) | 110KB (readable code) |
210
- | Startup | Decode table on load | Zero initialization |
211
- | Debugging | "State 437" errors | Named function call stacks |
212
- | Correctness | Mathematically derived | Grammar-derived + 3 specializations |
213
- | Test parity | 1,235/1,235 (100%) | 1,162/1,182 (98.3%) |
214
- | Speed | O(n) tight loop | O(n) function calls |
215
- | Expressions | Shift/reduce with precedence | Pratt with binding powers |
216
-
217
- Both produce identical s-expressions for 98.3% of the test suite. Having two
218
- independent implementations from the same grammar provides cross-validation.
219
-
220
- ---
221
-
222
- ## The Innovation
223
-
224
- Most parser generators commit to one strategy: Yacc produces LALR tables,
225
- ANTLR produces LL recursive descent, PEG.js produces PEG parsers. Solar and
226
- Lunar generate **both** from a single grammar specification.
227
-
228
- The key insight: the FIRST/FOLLOW sets and operator precedence table that
229
- Solar computes for SLR(1) contain all the information a Pratt-based recursive
230
- descent parser needs. The data is the same — it's just read differently.
231
- Solar reads it as table entries. Lunar reads it as dispatch conditions and
232
- binding powers.
87
+ ### Workflow for grammar changes
233
88
 
234
- Same grammar. Same semantics. Two fundamentally different parsers.
89
+ 1. Edit `src/grammar/grammar.rip`
90
+ 2. Run `bun run parser`
91
+ 3. Verify the new parse shape with `echo 'code' | ./bin/rip -s`
92
+ 4. Update codegen in `src/compiler.js` if the node shape changed
93
+ 5. Run `bun run test`
@@ -96,6 +96,7 @@ grammar =
96
96
  o 'ComputedAssign'
97
97
  o 'ReadonlyAssign'
98
98
  o 'Effect'
99
+ o 'Gate'
99
100
  o 'If'
100
101
  o 'Try'
101
102
  o 'While'
@@ -233,6 +234,13 @@ grammar =
233
234
  o 'Assignable READONLY_ASSIGN INDENT Expression OUTDENT', '["readonly", 1, 4]'
234
235
  ]
235
236
 
237
+ # Render-ready gate (<~) — load-before-render binding; component bodies only.
238
+ # One form on purpose: the RHS must be a one-line literal stash path, so the
239
+ # compiler can hoist it into the component's static gate-set.
240
+ Gate: [
241
+ o 'Assignable GATE Expression', '["gate", 1, 3]'
242
+ ]
243
+
236
244
  # Reactive effect (~>) — side effects that run when dependencies change
237
245
  Effect: [
238
246
  o 'Assignable EFFECT Expression' , '["effect", 1, 3]'
@@ -592,14 +600,15 @@ grammar =
592
600
  o 'INDENT Body OUTDENT', '["block", ...2]'
593
601
  ]
594
602
 
595
- # Parenthetical mark-up: when the inner expression is a single `=` assignment,
596
- # tag it with `.parenthesized = true` so the codegen ternary-hoist doesn't
597
- # silently turn `(x = "a") if cond else "b"` (conditional assign) into
598
- # `x = (cond ? "a" : "b")` (unconditional). The marker is harmless for any
599
- # other consumer; it's just a flag the hoist consults.
603
+ # Parenthetical mark-up: tag a single parenthesized expression with
604
+ # `.parenthesized = true`. Consumers that respect explicit grouping
605
+ # consult it: the codegen ternary-hoist (so `(x = "a") if cond else "b"`
606
+ # stays a conditional assign) and comparison chaining (so
607
+ # `(a == b) == c` compares the boolean instead of chaining). The marker
608
+ # is harmless for any other consumer.
600
609
  Parenthetical: [
601
- o '( Body )' , '$2.length === 1 ? (Array.isArray($2[0]) && $2[0][0] === "=" && ($2[0].parenthesized = true), $2[0]) : ["block", ...$2]'
602
- o '( INDENT Body OUTDENT )', '$3.length === 1 ? (Array.isArray($3[0]) && $3[0][0] === "=" && ($3[0].parenthesized = true), $3[0]) : ["block", ...$3]'
610
+ o '( Body )' , '$2.length === 1 ? (Array.isArray($2[0]) && ($2[0].parenthesized = true), $2[0]) : ["block", ...$2]'
611
+ o '( INDENT Body OUTDENT )', '$3.length === 1 ? (Array.isArray($3[0]) && ($3[0].parenthesized = true), $3[0]) : ["block", ...$3]'
603
612
  ]
604
613
 
605
614
  OptComma: [
@@ -1004,9 +1013,6 @@ grammar =
1004
1013
  o 'Expression || Expression' , '["||", 1, 3]'
1005
1014
  o 'Expression ?? Expression' , '["??", 1, 3]'
1006
1015
 
1007
- # Pipe
1008
- o 'Expression PIPE Expression' , '["|>", 1, 3]'
1009
-
1010
1016
  # Relational
1011
1017
  o 'Expression RELATION Expression', '[2, 1, 3]' # in, of, instanceof
1012
1018
 
@@ -1046,8 +1052,7 @@ operators = """
1046
1052
  left ^
1047
1053
  left |
1048
1054
  left &&
1049
- left ||
1050
- left PIPE
1055
+ left || ??
1051
1056
  right TERNARY
1052
1057
  nonassoc INDENT OUTDENT
1053
1058
  right YIELD
@@ -10,7 +10,6 @@
10
10
  import * as fs from 'fs'
11
11
  import * as path from 'path'
12
12
  import { fileURLToPath, pathToFileURL } from 'url'
13
- import { install as installLunar } from './lunar.rip'
14
13
 
15
14
  VERSION = '1.5.0'
16
15
 
@@ -90,11 +89,9 @@ class Generator
90
89
  # Build parser
91
90
  @timing '💥 Total time', =>
92
91
  @timing 'processGrammar' , => @processGrammar grammar # Process grammar rules
93
- unless @options.rd
94
- @timing 'buildLRAutomaton' , => @buildLRAutomaton() # Build LR(0) automaton
92
+ @timing 'buildLRAutomaton' , => @buildLRAutomaton() # Build LR(0) automaton
95
93
  @timing 'processLookaheads', => @processLookaheads() # Compute FIRST/FOLLOW and assign lookaheads
96
- unless @options.rd
97
- @timing 'buildParseTable' , => @buildParseTable() # Build parse table with default actions
94
+ @timing 'buildParseTable' , => @buildParseTable() # Build parse table with default actions
98
95
 
99
96
  # ============================================================================
100
97
  # Helper Functions
@@ -656,8 +653,6 @@ class Generator
656
653
  # Decoder reconstructs [{key: val}, ...] at runtime
657
654
  "(()=>{let d=[#{data.join ','}],t=[],p=0,n,o,k,a;while(p<d.length){n=d[p++];o={};k=0;a=[];while(n--)k+=d[p++],a.push(k);for(k of a)o[k]=d[p++];t.push(o)}return t})()"
658
655
 
659
- # Recursive descent generation — installed by Lunar (see lunar.rip)
660
-
661
656
  # ============================================================================
662
657
  # Runtime Parser
663
658
  # ============================================================================
@@ -772,8 +767,6 @@ class Generator
772
767
  # Exports
773
768
  # ==============================================================================
774
769
 
775
- installLunar Generator
776
-
777
770
  export { Generator }
778
771
 
779
772
  # ==============================================================================
@@ -805,7 +798,6 @@ if isRunAsScript
805
798
  -v, --version Show version
806
799
  -i, --info Show grammar information
807
800
  -s, --sexpr Show grammar as s-expression
808
- -r, --rd Generate recursive descent parser (parser-rd.js)
809
801
  -c, --conflicts Show conflict details (use with --info)
810
802
  -o, --output <file> Output file (default: parser.js)
811
803
 
@@ -843,7 +835,7 @@ if isRunAsScript
843
835
  console.log " Resolution: #{conflict.resolution} (by default)"
844
836
 
845
837
  # Parse command line
846
- options = {help: false, version: false, info: false, sexpr: false, conflicts: false, rd: false, output: 'parser.js'}
838
+ options = {help: false, version: false, info: false, sexpr: false, conflicts: false, output: 'parser.js'}
847
839
  grammarFile = null
848
840
  i = 0
849
841
 
@@ -854,7 +846,6 @@ if isRunAsScript
854
846
  when '-v', '--version' then options.version = true
855
847
  when '-i', '--info' then options.info = true
856
848
  when '-s', '--sexpr' then options.sexpr = true
857
- when '-r', '--rd' then options.rd = true
858
849
  when '-c', '--conflicts' then options.conflicts = true
859
850
  when '-o', '--output' then options.output = process.argv[++i + 2]
860
851
  else grammarFile = arg unless arg.startsWith('-')
@@ -915,11 +906,7 @@ if isRunAsScript
915
906
  if options.info
916
907
  showStats generator
917
908
  else
918
- if options.rd
919
- options.output = 'parser-rd.js' if options.output is 'parser.js'
920
- parserCode = generator.generateRD()
921
- else
922
- parserCode = generator.generate()
909
+ parserCode = generator.generate()
923
910
  fs.writeFileSync options.output, parserCode
924
911
  console.log "\nParser generated: #{options.output}"
925
912
 
package/src/lexer.js CHANGED
@@ -129,7 +129,7 @@ let IMPLICIT_UNSPACED_CALL = new Set(['+', '-']);
129
129
  // Tokens that end an implicit call
130
130
  let IMPLICIT_END = new Set([
131
131
  'POST_IF', 'POST_UNLESS', 'FOR', 'WHILE', 'UNTIL',
132
- 'WHEN', 'BY', 'LOOP', 'TERMINATOR', '||', '&&', 'PIPE',
132
+ 'WHEN', 'BY', 'LOOP', 'TERMINATOR', '||', '&&',
133
133
  ]);
134
134
 
135
135
  // Tokens that trigger implicit comma insertion before arrows
@@ -251,7 +251,7 @@ let UNARY_MATH = new Set(['!', '~']);
251
251
  // conflict with ?. (optional chaining), ?? (nullish), ?! (presence), ?.( and ?.[
252
252
  let IDENTIFIER_RE = /^(?!\d)((?:(?!\s)[$\w\x7f-\uffff])+(?:!|[?](?![.?![(]))?)([^\n\S]*:(?![=:]))?/;
253
253
  let NUMBER_RE = /^0b[01](?:_?[01])*n?|^0o[0-7](?:_?[0-7])*n?|^0x[\da-f](?:_?[\da-f])*n?|^\d+(?:_\d+)*n|^(?:\d+(?:_\d+)*)?\.?\d+(?:_\d+)*(?:e[+-]?\d+(?:_\d+)*)?/i;
254
- let OPERATOR_RE = /^(?:<=>|::|\*>|[-=]>|~>|~=|:=|=!|===|!==|\?\!|\?\?|=~|\|>|[-+*\/%<>&|^!?=]=|>>>=?|([-+:])\1|([&|<>*\/%])\2=?|\?\.?|\.{2,3})/;
254
+ let OPERATOR_RE = /^(?:<=>|<~|::|\*>|[-=]>|~>|~=|:=|=!|===|!==|\?\!|\?\?|=~|[-+*\/%<>&|^!?=]=|>>>=?|([-+:])\1|([&|<>*\/%])\2=?|\?\.?|\.{2,3})/;
255
255
  let WHITESPACE_RE = /^[^\n\S]+/;
256
256
  let NEWLINE_RE = /^(?:\n[^\n\S]*)+/;
257
257
  let COMMENT_RE = /^(\s*)###([^#][\s\S]*?)(?:###([^\n\S]*)|###$)|^((?:\s*#(?!##[^#]).*)+)/;
@@ -748,18 +748,6 @@ export class Lexer {
748
748
  }
749
749
  if (/^\s+#[a-zA-Z_]/.test(this.chunk)) return 0; // let lineToken handle indentation first
750
750
  }
751
- // Schema field modifier: `#` adjacent (unspaced) to an identifier acts
752
- // as the unique-marker inside schema bodies (e.g. `email!# email`).
753
- // Return 0 so literalToken emits a standalone `#` token; rewriteSchema
754
- // absorbs it. Outside schema bodies the `#` token is harmless because
755
- // nothing else in the grammar accepts `IDENTIFIER #` without a space.
756
- if (this.chunk[0] === '#') {
757
- let prev = this.prev();
758
- if (prev && !prev.spaced && !prev.newLine &&
759
- (prev[0] === 'IDENTIFIER' || prev[0] === 'PROPERTY')) {
760
- return 0;
761
- }
762
- }
763
751
  let match = COMMENT_RE.exec(this.chunk);
764
752
  if (!match) return 0;
765
753
  return match[0].length;
@@ -1388,8 +1376,6 @@ export class Lexer {
1388
1376
  this.inTypeAnnotation = false;
1389
1377
  tag = 'TERMINATOR';
1390
1378
  }
1391
- // Pipe operator
1392
- else if (val === '|>') tag = 'PIPE';
1393
1379
  // Type operators
1394
1380
  else if (val === '::' && /^[a-zA-Z_$]/.test(this.chunk[2] || '')) {
1395
1381
  // Prototype access: String::trim → String.prototype.trim
@@ -1403,6 +1389,7 @@ export class Lexer {
1403
1389
  else if (val === '~=') tag = 'COMPUTED_ASSIGN';
1404
1390
  else if (val === ':=') tag = 'REACTIVE_ASSIGN';
1405
1391
  else if (val === '<=>') tag = 'BIND';
1392
+ else if (val === '<~') { tag = 'GATE'; this.inTypeAnnotation = false; }
1406
1393
  else if (val === '~>') { tag = 'EFFECT'; this.inTypeAnnotation = false; }
1407
1394
  else if (val === '=!') { tag = 'READONLY_ASSIGN'; this.inTypeAnnotation = false; }
1408
1395
  // Merge assignment: *>config = {a: 1} → Object.assign(config, {a: 1})
@@ -2063,12 +2050,32 @@ export class Lexer {
2063
2050
  } else if (t && t !== 'TERMINATOR' && t !== 'OUTDENT' && t !== ',') j++;
2064
2051
  logicalKeep = tokens[j]?.[0] === ',';
2065
2052
  }
2053
+ // A postfix conditional on the FIRST property line of a multiline
2054
+ // implicit object must bind to the property's value, not close the
2055
+ // object — otherwise `a: 1 if c` followed by more properties splits
2056
+ // the object into separate arguments. Later property lines already
2057
+ // bind to the value (the sameLine flag is false by then), so only
2058
+ // first-line conditionals need the lookahead: keep the object open
2059
+ // when the property list continues after this line.
2060
+ let objectContinues = (j) => {
2061
+ for (let d = 0; j < tokens.length; j++) {
2062
+ let t = tokens[j][0];
2063
+ if (t === '(' || t === '[' || t === '{' || t === 'CALL_START' || t === 'INDEX_START' || t === 'INDENT') d++;
2064
+ else if (t === ')' || t === ']' || t === '}' || t === 'CALL_END' || t === 'INDEX_END' || t === 'OUTDENT') {
2065
+ if (d === 0) return false;
2066
+ d--;
2067
+ }
2068
+ else if (t === 'TERMINATOR' && d === 0) return this.looksObjectish(j + 1);
2069
+ }
2070
+ return false;
2071
+ };
2066
2072
  if ((IMPLICIT_END.has(tag) && !logicalKeep) || (CALL_CLOSERS.has(tag) && newLine)) {
2067
2073
  while (isImplicit(stackTop())) {
2068
2074
  let [stackTag, , {sameLine, startsLine}] = stackTop();
2069
2075
  if (inImplicitCall() && prevTag !== ',') {
2070
2076
  endImplicitCall();
2071
- } else if (inImplicitObject() && !isLogicalOp && sameLine && tag !== 'TERMINATOR' && prevTag !== ':') {
2077
+ } else if (inImplicitObject() && !isLogicalOp && sameLine && tag !== 'TERMINATOR' && prevTag !== ':' &&
2078
+ !((tag === 'POST_IF' || tag === 'POST_UNLESS') && objectContinues(i + 1))) {
2072
2079
  endImplicitObject();
2073
2080
  } else if (inImplicitObject() && tag === 'TERMINATOR' && prevTag !== ',' && !(startsLine && this.looksObjectish(i + 1))) {
2074
2081
  endImplicitObject();