11import { ExternalTokenizer } from "@lezer/lr"
2- import { Regex , divideOp } from "./syntax.grammar.terms"
2+ import {
3+ Regex , divideOp ,
4+ Heredoc , lessThanOp , lessThanEqOp ,
5+ } from "./syntax.grammar.terms"
36
47// ============================================================
58// Regex / Division external tokenizer (#7)
69//
710// `/` is ambiguous: regex literal or division operator.
8- // This tokenizer handles both:
9- //
10- // 1. Try to match /pattern/flags (regex)
11- // 2. If the parser state allows Regex AND the pattern is valid, emit Regex
12- // 3. Otherwise emit divideOp (aliased to ArithOp) for division
13- //
14- // The parser state determines which is valid:
15- // - At expression-start (after =, (, [, ,, operator): Regex is valid
16- // - After a value (identifier, number, ), ]): only divideOp is valid
11+ // Tries to match /pattern/flags; falls back to divideOp.
12+ // Parser state (stack.canShift) determines which is valid.
1713// ============================================================
1814
1915export const regexTokenizer = new ExternalTokenizer ( ( input , stack ) => {
2016 if ( input . next !== 47 /* '/' */ ) return
2117
22- // Try to match a regex pattern
2318 let pos = 1
2419 let isRegex = true
2520 while ( true ) {
2621 const ch = input . peek ( pos )
27- if ( ch === - 1 || ch === 10 /* \n */ || ch === 13 /* \r */ ) { isRegex = false ; break }
22+ if ( ch === - 1 || ch === 10 || ch === 13 ) { isRegex = false ; break }
2823 if ( ch === 92 /* \\ */ ) {
2924 pos ++
30- // Check the escaped char isn't newline/EOF
3125 const next = input . peek ( pos )
3226 if ( next === - 1 || next === 10 || next === 13 ) { isRegex = false ; break }
3327 pos ++
@@ -38,7 +32,7 @@ export const regexTokenizer = new ExternalTokenizer((input, stack) => {
3832 }
3933
4034 if ( isRegex ) {
41- // Consume optional flags: i, m, x, o, u, s, n
35+ // Consume optional flags
4236 while ( true ) {
4337 const ch = input . peek ( pos )
4438 if ( ch === 105 || ch === 109 || ch === 120 || ch === 111 ||
@@ -48,15 +42,137 @@ export const regexTokenizer = new ExternalTokenizer((input, stack) => {
4842 break
4943 }
5044 }
51-
5245 if ( stack . canShift ( Regex ) ) {
5346 input . acceptToken ( Regex , pos )
5447 return
5548 }
5649 }
5750
58- // Fall back to division
5951 if ( stack . canShift ( divideOp ) ) {
6052 input . acceptToken ( divideOp , 1 )
6153 }
6254} )
55+
56+ // ============================================================
57+ // Less-than / Heredoc external tokenizer (#10)
58+ //
59+ // `<` is ambiguous: comparison, `<=`, `<<` left shift, or heredoc start.
60+ // When `<<` is followed by [-~]?IDENTIFIER (or quoted string), and the
61+ // parser allows Heredoc, we scan to the matching closing delimiter
62+ // and emit the entire heredoc as one token.
63+ // Otherwise emit lessThanOp or lessThanEqOp for comparison.
64+ // ============================================================
65+
66+ export const lessThanTokenizer = new ExternalTokenizer ( ( input , stack ) => {
67+ if ( input . next !== 60 /* '<' */ ) return
68+
69+ const second = input . peek ( 1 )
70+
71+ // Try heredoc: <<
72+ if ( second === 60 /* '<' */ && stack . canShift ( Heredoc ) ) {
73+ const heredocLen = tryMatchHeredoc ( input )
74+ if ( heredocLen > 0 ) {
75+ input . acceptToken ( Heredoc , heredocLen )
76+ return
77+ }
78+ }
79+
80+ // <=
81+ if ( second === 61 /* '=' */ && stack . canShift ( lessThanEqOp ) ) {
82+ input . acceptToken ( lessThanEqOp , 2 )
83+ return
84+ }
85+
86+ // Plain <
87+ if ( stack . canShift ( lessThanOp ) ) {
88+ input . acceptToken ( lessThanOp , 1 )
89+ }
90+ } )
91+
92+ // Try to match a complete heredoc starting at the current position.
93+ // Returns the total length including the closing delimiter, or 0 if no match.
94+ function tryMatchHeredoc ( input : { peek ( offset : number ) : number } ) : number {
95+ let pos = 2 // past <<
96+
97+ // Optional - or ~
98+ const modifier = input . peek ( pos )
99+ if ( modifier === 45 /* - */ || modifier === 126 /* ~ */ ) pos ++
100+
101+ // Read the delimiter
102+ let delimiter = ""
103+ const quoteChar = input . peek ( pos )
104+
105+ if ( quoteChar === 39 /* ' */ || quoteChar === 34 /* " */ || quoteChar === 96 /* ` */ ) {
106+ // Quoted delimiter: <<~"DELIM"
107+ pos ++
108+ while ( true ) {
109+ const ch = input . peek ( pos )
110+ if ( ch === - 1 || ch === 10 ) return 0 // unterminated quote
111+ if ( ch === quoteChar ) { pos ++ ; break }
112+ delimiter += String . fromCharCode ( ch )
113+ pos ++
114+ }
115+ } else {
116+ // Bare identifier delimiter: <<~DELIM
117+ if ( ! isIdentStart ( input . peek ( pos ) ) ) return 0
118+ while ( isIdentChar ( input . peek ( pos ) ) ) {
119+ delimiter += String . fromCharCode ( input . peek ( pos ) )
120+ pos ++
121+ }
122+ }
123+
124+ if ( ! delimiter ) return 0
125+
126+ // Must be followed by newline (or end of opening line)
127+ // Skip to end of opening line
128+ while ( true ) {
129+ const ch = input . peek ( pos )
130+ if ( ch === - 1 ) return 0 // no newline after heredoc start
131+ if ( ch === 10 ) { pos ++ ; break }
132+ if ( ch === 13 ) { pos ++ ; if ( input . peek ( pos ) === 10 ) pos ++ ; break }
133+ pos ++
134+ }
135+
136+ // Scan lines looking for the closing delimiter
137+ const isIndented = modifier === 45 || modifier === 126 // <<- or <<~
138+ while ( true ) {
139+ let lineContent = ""
140+
141+ // For indented heredocs (<<- or <<~), skip leading whitespace
142+ if ( isIndented ) {
143+ while ( input . peek ( pos ) === 32 || input . peek ( pos ) === 9 ) pos ++
144+ }
145+
146+ // Read the rest of the line
147+ while ( true ) {
148+ const ch = input . peek ( pos )
149+ if ( ch === - 1 || ch === 10 || ch === 13 ) break
150+ lineContent += String . fromCharCode ( ch )
151+ pos ++
152+ }
153+
154+ // Check if this line matches the delimiter (trimmed)
155+ if ( lineContent === delimiter ) {
156+ // Include the delimiter line in the token
157+ // Advance past newline if present
158+ const ch = input . peek ( pos )
159+ if ( ch === 10 ) pos ++
160+ else if ( ch === 13 ) { pos ++ ; if ( input . peek ( pos ) === 10 ) pos ++ }
161+ return pos
162+ }
163+
164+ // Advance past newline
165+ const ch = input . peek ( pos )
166+ if ( ch === - 1 ) return pos // unterminated heredoc — emit what we have
167+ if ( ch === 10 ) pos ++
168+ else if ( ch === 13 ) { pos ++ ; if ( input . peek ( pos ) === 10 ) pos ++ }
169+ }
170+ }
171+
172+ function isIdentStart ( ch : number ) : boolean {
173+ return ( ch >= 65 && ch <= 90 ) || ( ch >= 97 && ch <= 122 ) || ch === 95
174+ }
175+
176+ function isIdentChar ( ch : number ) : boolean {
177+ return isIdentStart ( ch ) || ( ch >= 48 && ch <= 57 )
178+ }
0 commit comments