Download packages/tree-sitter-bash/src/lexer.ts from SaylorTwift/kimi-code: direct link, hf CLI and curl.
- Browser
- Download file 29.2 kB
-
https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/tree-sitter-bash/src/lexer.ts
- Command line
-
hf download hf://SaylorTwift/kimi-code/packages/tree-sitter-bash/src/lexer.ts
-
curl -L -o lexer.ts https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/tree-sitter-bash/src/lexer.ts
29.2 kB
| // src/lexer.ts | |
| // | |
| // Hand-written bash tokenizer. Produces a flat token stream of: | |
| // | |
| // word a maximal run of adjacent word material: bare characters, | |
| // '...' / "..." quotes, $var / ${...} / $(...) / `...` expansions, | |
| // <(...) / >(...) process substitutions. Quotes and substitutions | |
| // are skipped over with nesting awareness; the parser re-scans | |
| // the token range to build the actual sub-tree. The single | |
| // characters { } [ ] are emitted as their own word tokens (they | |
| // are not word characters in bash), matching tree-sitter-bash's | |
| // _special_character behaviour. | |
| // op statement operators (&& || | |& ; ;; & ( )) and redirect | |
| // operators (< > >> >& <& &> &>> >| <> >&- <&- <<< << <<-). | |
| // io_number a run of digits immediately followed by < or > — the file | |
| // descriptor prefix of a redirect (2>/dev/null). | |
| // newline a statement-terminating \n. Carries any heredoc bodies that | |
| // were queued on the lexer and are scanned right after the line. | |
| // comment # ... to end of line (only at token start; # inside a word is | |
| // a plain word character). | |
| // eof end of the lexer's range. Also carries pending heredoc bodies. | |
| // | |
| // Additionally: | |
| // - `;&` and `;;&` are single op tokens (case_item fallthrough | |
| // terminators). | |
| // - `((...))` at token start is scanned as one word token (arithmetic | |
| // command / c-style for header), so the parser can re-scan the range | |
| // with its expression parser; the word ends right after the balanced | |
| // close and does not merge with following word characters. | |
| // - peekAt(n) looks arbitrarily far ahead (function_definition | |
| // detection); reposition(pos) rewinds the stream (test commands parse | |
| // their [[ ... ]] range character-wise and then resume tokenizing past | |
| // the closer). | |
| // | |
| // The lexer is range-bounded: sub-parsers lex $(...) / `...` bodies with | |
| // their own Lexer over the same source but a narrower [start, end) window. | |
| // | |
| // Heredoc queue: the parser registers a HeredocSpec for every << / <<- it | |
| // accepts. When the lexer produces the next newline (or eof) token it scans | |
| // one body per queued spec, in registration order, and attaches them to the | |
| // token; the parser completes the matching heredoc_redirect nodes when it | |
| // consumes that token. | |
| // | |
| // Budget: the lexer never creates nodes, so it only checks the deadline — | |
| // once per produced token and periodically (every SCAN_TICK_INTERVAL | |
| // characters) inside every long scan loop (word runs, quote/paren skipping, | |
| // comments, blanks, heredoc bodies) — via budget.progress(), so a | |
| // pathological single token cannot starve the deadline check. Node counting | |
| // (budget.tick()) is the parser's job. | |
| import type { ParseBudget } from '#/budget'; | |
| import { SPECIAL_VARIABLE_CHARS } from '#/grammar'; | |
| export type TokenType = 'word' | 'op' | 'io_number' | 'newline' | 'comment' | 'eof'; | |
| export interface Token { | |
| readonly type: TokenType; | |
| readonly start: number; | |
| readonly end: number; | |
| /** Heredoc bodies scanned when this newline/eof token was produced. */ | |
| readonly heredocBodies: HeredocBody[]; | |
| } | |
| export interface HeredocSpec { | |
| /** Delimiter text with any quoting removed — what must appear at line start. */ | |
| readonly delimiter: string; | |
| /** `<<-`: leading tabs of the first body line are stripped. */ | |
| readonly stripTabs: boolean; | |
| /** The delimiter was quoted ('EOF', "EOF", \EOF): the body is not expanded. */ | |
| readonly quoted: boolean; | |
| } | |
| export interface HeredocBody { | |
| /** Start of the heredoc_body node (after first-line tab stripping). */ | |
| readonly bodyStart: number; | |
| /** End of the heredoc_body node: right before the end marker, so the final | |
| * newline and any tabs preceding the marker stay inside the body. */ | |
| readonly bodyEnd: number; | |
| /** Start of the heredoc_end marker (the delimiter word itself). */ | |
| readonly endStart: number; | |
| /** End of the heredoc_end marker. */ | |
| readonly endEnd: number; | |
| /** False when the delimiter line never appeared: body runs to the end of | |
| * the lexer's range and there is no heredoc_end. */ | |
| readonly found: boolean; | |
| } | |
| /** How often long scan loops tick the budget, in scanned characters. */ | |
| const SCAN_TICK_INTERVAL = 2048; | |
| /** | |
| * Depth cap for the scanBalanced ↔ skipDoubleQuoted mutual recursion | |
| * (`${a#"${a#"…` nests two scan frames per level). Beyond the cap the scan | |
| * reports "unbalanced", letting the parser degrade the construct with | |
| * hasError instead of overflowing the call stack. Sized above the parser's | |
| * own nesting guards (which trigger first for parser-driven recursion); | |
| * this cap only protects lexer-driven scans of pathological input. | |
| */ | |
| const MAX_SCAN_DEPTH = 1024; | |
| const CONTROL_OPERATORS = ['&>>', '&>', '&&', '&', '|&', '||', '|', ';;&', ';;', ';&', ';', '(', ')'] as const; | |
| const REDIRECT_OPERATORS = ['<<-', '<<<', '<<', '<&-', '<&', '<>', '<', '>&-', '>&', '>>', '>|', '>'] as const; | |
| function isWordChar(ch: string | undefined): boolean { | |
| return ch !== undefined && /[\w]/.test(ch); | |
| } | |
| function isBlank(ch: string | undefined): boolean { | |
| return ch === ' ' || ch === '\t' || ch === '\r'; | |
| } | |
| function isDigitAt(source: string, i: number): boolean { | |
| const ch = source[i]!; | |
| return ch >= '0' && ch <= '9'; | |
| } | |
| /** Skip a "..." quoted region starting at `i` (which points at the opening | |
| * quote). Returns the index just past the closing quote, or `end` when the | |
| * string is unterminated. Substitution-aware: $(...), ${...} and `...` | |
| * inside the string may themselves contain quotes. `depth` tracks the | |
| * scanBalanced ↔ skipDoubleQuoted recursion (see MAX_SCAN_DEPTH). */ | |
| export function skipDoubleQuoted(source: string, budget: ParseBudget, i: number, end: number, depth = 0): number { | |
| if (depth >= MAX_SCAN_DEPTH) return end; | |
| let j = i + 1; | |
| let sinceTick = 0; | |
| while (j < end) { | |
| if (++sinceTick >= SCAN_TICK_INTERVAL) { | |
| budget.progress(); | |
| sinceTick = 0; | |
| } | |
| const ch = source[j]!; | |
| if (ch === '\\') { | |
| j += 2; | |
| continue; | |
| } | |
| if (ch === '"') return j + 1; | |
| if (ch === '`') { | |
| j = skipBacktick(source, budget, j, end); | |
| continue; | |
| } | |
| if (ch === '$') { | |
| const next = source[j + 1]; | |
| if (next === '(') { | |
| j = scanBalancedStatements(source, budget, j + 1, end, depth + 1).end; | |
| continue; | |
| } | |
| if (next === '{') { | |
| j = scanBalanced(source, budget, j + 1, end, '{', '}', depth + 1).end; | |
| continue; | |
| } | |
| } | |
| j++; | |
| } | |
| return end; | |
| } | |
| /** Skip a '...' region starting at `i`. No escapes exist in raw strings. */ | |
| export function skipSingleQuoted(source: string, _budget: ParseBudget, i: number, end: number): number { | |
| const close = source.indexOf("'", i + 1); | |
| if (close === -1 || close >= end) return end; | |
| return close + 1; | |
| } | |
| /** Skip a `...` region starting at `i`; \` is an escaped backtick. */ | |
| export function skipBacktick(source: string, budget: ParseBudget, i: number, end: number): number { | |
| let j = i + 1; | |
| let sinceTick = 0; | |
| while (j < end) { | |
| if (++sinceTick >= SCAN_TICK_INTERVAL) { | |
| budget.progress(); | |
| sinceTick = 0; | |
| } | |
| const ch = source[j]!; | |
| if (ch === '\\') { | |
| j += 2; | |
| continue; | |
| } | |
| if (ch === '`') return j + 1; | |
| j++; | |
| } | |
| return end; | |
| } | |
| /** Result of scanning a balanced region. */ | |
| export interface BalancedScan { | |
| /** Index just past the matching close, or `end` when unbalanced. */ | |
| end: number; | |
| /** Whether the matching close was actually found. */ | |
| balanced: boolean; | |
| } | |
| /** Scan a balanced open/close region starting at `i` (which points at the | |
| * opening character). Quote- and escape-aware. `depth` tracks the | |
| * scanBalanced ↔ skipDoubleQuoted recursion (see MAX_SCAN_DEPTH). */ | |
| export function scanBalanced( | |
| source: string, | |
| budget: ParseBudget, | |
| i: number, | |
| end: number, | |
| open: string, | |
| close: string, | |
| depth = 0, | |
| ): BalancedScan { | |
| if (depth >= MAX_SCAN_DEPTH) return { end, balanced: false }; | |
| let nesting = 0; | |
| let j = i; | |
| let sinceTick = 0; | |
| while (j < end) { | |
| if (++sinceTick >= SCAN_TICK_INTERVAL) { | |
| budget.progress(); | |
| sinceTick = 0; | |
| } | |
| const ch = source[j]!; | |
| if (ch === '\\') { | |
| j += 2; | |
| continue; | |
| } | |
| if (ch === open) { | |
| nesting++; | |
| } else if (ch === close) { | |
| nesting--; | |
| if (nesting === 0) return { end: j + 1, balanced: true }; | |
| } else if (ch === '"') { | |
| j = skipDoubleQuoted(source, budget, j, end, depth + 1); | |
| continue; | |
| } else if (ch === "'") { | |
| j = skipSingleQuoted(source, budget, j, end); | |
| continue; | |
| } else if (ch === '`') { | |
| j = skipBacktick(source, budget, j, end); | |
| continue; | |
| } | |
| j++; | |
| } | |
| return { end, balanced: false }; | |
| } | |
| /** Words after which a `case` word opens a case_statement (statement | |
| * position) rather than being an ordinary argument. */ | |
| const CASE_ENABLING_WORDS: ReadonlySet<string> = new Set(['if', 'then', 'elif', 'else', 'while', 'until', 'do']); | |
| /** | |
| * Case-aware variant of scanBalanced for `(` … `)` regions that may hold | |
| * full statements (command/process substitution bodies). A naive paren | |
| * count closes the region early on a case_item pattern (`a) 1;; esac`): | |
| * every item's `)` would decrement the depth. While a `case` is open (seen | |
| * in statement position, not yet closed by `esac`), a `)` at the paren | |
| * depth where the `case` started is a pattern close and does NOT count; | |
| * parens from other constructs inside the case (subshells, $(…), the | |
| * optional `(` of an item) balance themselves normally. | |
| * | |
| * Statement position is approximated lexically: `case` counts as a keyword | |
| * after `(`, `;`, `&`, `|`, a newline, `{`, or one of the compound | |
| * keywords in CASE_ENABLING_WORDS — so `echo case` does not confuse the | |
| * scan. `esac` pops the innermost open case regardless of position (the | |
| * reference scanner emits the esac token even in argument position). | |
| * | |
| * Heredoc-aware: a `<<` / `<<-` operator queues its delimiter word, and the | |
| * body lines of every pending heredoc are skipped wholesale right after | |
| * the next newline — quotes, parens and substitutions inside a heredoc | |
| * body must not affect the paren count. `((` opens an arithmetic region, | |
| * skipped as one balanced unit so a left-shift `<<` is not mistaken for a | |
| * heredoc operator. Comments (`#` at the start of a word, judged by the | |
| * preceding character after looking through `\`+newline continuations) | |
| * are skipped to end of line, `${ ... }` / `$[ ... ]` expansions, | |
| * word-glued `[ ... ]` subscripts, and `[[ ... ]]` conditional regions | |
| * are skipped as balanced units, so a `<<` inside any of these | |
| * non-redirection contexts is likewise not mistaken for a heredoc | |
| * operator. | |
| */ | |
| export function scanBalancedStatements( | |
| source: string, | |
| budget: ParseBudget, | |
| i: number, | |
| end: number, | |
| depth = 0, | |
| ): BalancedScan { | |
| if (depth >= MAX_SCAN_DEPTH) return { end, balanced: false }; | |
| let nesting = 0; | |
| /** Paren depths at which each open case_statement started. */ | |
| const caseDepths: number[] = []; | |
| /** Heredoc delimiters queued since the last newline. */ | |
| const pendingHeredocs: { delimiter: string; stripTabs: boolean }[] = []; | |
| let j = i; | |
| /** What preceded the current position: 'start' | 'sep' | 'keyword' | 'word'. */ | |
| let previous: 'start' | 'sep' | 'keyword' | 'word' = 'start'; | |
| let sinceTick = 0; | |
| while (j < end) { | |
| if (++sinceTick >= SCAN_TICK_INTERVAL) { | |
| budget.progress(); | |
| sinceTick = 0; | |
| } | |
| const ch = source[j]!; | |
| if (ch === '\\') { | |
| j += 2; | |
| continue; | |
| } | |
| if (ch === '"') { | |
| j = skipDoubleQuoted(source, budget, j, end, depth + 1); | |
| previous = 'word'; | |
| continue; | |
| } | |
| if (ch === "'") { | |
| j = skipSingleQuoted(source, budget, j, end); | |
| previous = 'word'; | |
| continue; | |
| } | |
| if (ch === '`') { | |
| j = skipBacktick(source, budget, j, end); | |
| previous = 'word'; | |
| continue; | |
| } | |
| if (ch === ' ' || ch === '\t' || ch === '\r') { | |
| j++; | |
| continue; | |
| } | |
| if (ch === '\n') { | |
| j++; | |
| if (pendingHeredocs.length > 0) { | |
| j = skipHeredocBodies(source, budget, j, end, pendingHeredocs); | |
| pendingHeredocs.length = 0; | |
| } | |
| previous = 'sep'; | |
| continue; | |
| } | |
| if (ch === ';' || ch === '&' || ch === '|') { | |
| previous = 'sep'; | |
| j++; | |
| continue; | |
| } | |
| if (ch === '{') { | |
| previous = 'sep'; | |
| j++; | |
| continue; | |
| } | |
| if (ch === '#') { | |
| let p = j - 1; | |
| while (p - 1 >= i && source[p] === '\n' && source[p - 1] === '\\') p -= 2; | |
| const prev = p >= i ? source[p] : undefined; | |
| if (prev === undefined || isBlank(prev) || prev === '\n' || prev === ';' || prev === '&' || prev === '|' || prev === '(') { | |
| while (j < end && source[j] !== '\n') j++; | |
| continue; | |
| } | |
| } | |
| if (ch === '$' && (source[j + 1] === '{' || source[j + 1] === '[')) { | |
| const open = source[j + 1]!; | |
| j = scanBalanced(source, budget, j + 1, end, open, open === '{' ? '}' : ']', depth + 1).end; | |
| previous = 'word'; | |
| continue; | |
| } | |
| if (ch === '[' && source[j + 1] === '[' && previous !== 'word') { | |
| j = scanBalanced(source, budget, j, end, '[', ']', depth + 1).end; | |
| previous = 'word'; | |
| continue; | |
| } | |
| if (ch === '[' && j > i && isWordChar(source[j - 1])) { | |
| j = scanBalanced(source, budget, j, end, '[', ']', depth + 1).end; | |
| previous = 'word'; | |
| continue; | |
| } | |
| if (ch === '<') { | |
| const heredoc = scanHeredocDelimiter(source, budget, j, end, depth); | |
| if (heredoc !== null) { | |
| pendingHeredocs.push(heredoc); | |
| j = heredoc.end; | |
| } else { | |
| j++; | |
| } | |
| previous = 'word'; | |
| continue; | |
| } | |
| if (ch === '(') { | |
| if (source[j + 1] === '(') { | |
| const arith = scanBalanced(source, budget, j, end, '(', ')', depth + 1); | |
| if (j === i) return { end: arith.end, balanced: arith.balanced }; | |
| j = arith.end; | |
| previous = 'word'; | |
| continue; | |
| } | |
| nesting++; | |
| previous = 'sep'; | |
| j++; | |
| continue; | |
| } | |
| if (ch === ')') { | |
| if (caseDepths.length > 0 && caseDepths.at(-1) === nesting) { | |
| // A case_item pattern close: not a paren. | |
| previous = 'sep'; | |
| j++; | |
| continue; | |
| } | |
| nesting--; | |
| if (nesting === 0) return { end: j + 1, balanced: true }; | |
| previous = 'word'; | |
| j++; | |
| continue; | |
| } | |
| // A word run: check for the case/esac keywords. | |
| let k = j; | |
| while (k < end && isWordChar(source[k])) k++; | |
| if (k > j) { | |
| const word = source.slice(j, k); | |
| if (word === 'esac' && caseDepths.length > 0) { | |
| caseDepths.pop(); | |
| previous = 'sep'; | |
| } else if (word === 'case' && previous !== 'word') { | |
| caseDepths.push(nesting); | |
| previous = 'word'; | |
| } else { | |
| previous = CASE_ENABLING_WORDS.has(word) ? 'keyword' : 'word'; | |
| } | |
| j = k; | |
| continue; | |
| } | |
| previous = 'word'; | |
| j++; | |
| } | |
| return { end, balanced: false }; | |
| } | |
| /** Parse a heredoc operator (`<<` / `<<-`) and its delimiter word, starting | |
| * at `i` (which points at the first `<`). Returns the delimiter with quotes | |
| * and backslashes removed (mirroring the parser's extractHeredocSpec), | |
| * whether `<<-` strips leading tabs, and the index just past the delimiter | |
| * word — or null when this `<` does not open a heredoc with a non-empty | |
| * delimiter (`<<<` herestring, another redirect, or malformed input). | |
| * Substitution syntax inside the delimiter word (`$( )`, `${ }`, `$[ ]`, | |
| * backticks) is scanned wholesale as part of the word. */ | |
| function scanHeredocDelimiter( | |
| source: string, | |
| budget: ParseBudget, | |
| i: number, | |
| end: number, | |
| depth: number, | |
| ): { delimiter: string; stripTabs: boolean; end: number } | null { | |
| if (source[i + 1] !== '<') return null; | |
| let j = i + 2; | |
| if (source[j] === '<') return null; | |
| let stripTabs = false; | |
| if (source[j] === '-') { | |
| stripTabs = true; | |
| j++; | |
| } | |
| while (j < end && (source[j] === ' ' || source[j] === '\t' || source[j] === '\r')) j++; | |
| let raw = ''; | |
| while (j < end) { | |
| const ch = source[j]!; | |
| if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') break; | |
| if (ch === '&' || ch === '|' || ch === ';' || ch === '(' || ch === ')' || ch === '<' || ch === '>') break; | |
| if (ch === '$' && (source[j + 1] === '(' || source[j + 1] === '{' || source[j + 1] === '[')) { | |
| const open = source[j + 1]!; | |
| const region = | |
| open === '(' | |
| ? scanBalancedStatements(source, budget, j + 1, end, depth + 1) | |
| : scanBalanced(source, budget, j + 1, end, open, open === '{' ? '}' : ']', depth + 1); | |
| if (!region.balanced) return null; | |
| raw += source.slice(j, region.end); | |
| j = region.end; | |
| continue; | |
| } | |
| if (ch === '`') { | |
| const backtickEnd = skipBacktick(source, budget, j, end); | |
| if (backtickEnd >= end) return null; | |
| raw += source.slice(j, backtickEnd); | |
| j = backtickEnd; | |
| continue; | |
| } | |
| if (ch === '\\') { | |
| if (j + 1 >= end || source[j + 1] === '\n') return null; | |
| raw += ch + source[j + 1]; | |
| j += 2; | |
| continue; | |
| } | |
| if (ch === "'") { | |
| const close = source.indexOf("'", j + 1); | |
| if (close === -1 || close >= end) return null; | |
| raw += source.slice(j, close + 1); | |
| j = close + 1; | |
| continue; | |
| } | |
| if (ch === '"') { | |
| let k = j + 1; | |
| for (;;) { | |
| if (k >= end) return null; | |
| if (source[k] === '\\') { | |
| k += 2; | |
| continue; | |
| } | |
| if (source[k] === '"') break; | |
| k++; | |
| } | |
| raw += source.slice(j, k + 1); | |
| j = k + 1; | |
| continue; | |
| } | |
| raw += ch; | |
| j++; | |
| } | |
| let delimiter = ''; | |
| for (let k = 0; k < raw.length; k++) { | |
| const ch = raw[k]!; | |
| if (ch === '\\' && k + 1 < raw.length) { | |
| delimiter += raw[k + 1]; | |
| k++; | |
| } else if (ch !== '"' && ch !== "'") { | |
| delimiter += ch; | |
| } | |
| } | |
| if (delimiter.length === 0) return null; | |
| return { delimiter, stripTabs, end: j }; | |
| } | |
| /** Skip the body lines of each queued heredoc, starting at `i` (just past | |
| * the newline that ended the command line). Bodies are consumed in queue | |
| * order, each up to its delimiter line (`<<-` allows leading tabs before | |
| * the marker), mirroring readHeredocBody; a delimiter directly followed | |
| * by `)` also closes the body — the paren belongs to the enclosing | |
| * substitution and is left for the paren scan. A body whose delimiter | |
| * never appears swallows the rest of the range. */ | |
| function skipHeredocBodies( | |
| source: string, | |
| budget: ParseBudget, | |
| i: number, | |
| end: number, | |
| specs: readonly { delimiter: string; stripTabs: boolean }[], | |
| ): number { | |
| let j = i; | |
| for (const spec of specs) { | |
| let lineStart = j; | |
| let closed = false; | |
| while (lineStart < end) { | |
| budget.progress(); | |
| let marker = lineStart; | |
| if (spec.stripTabs) { | |
| while (marker < end && source[marker] === '\t') marker++; | |
| } | |
| if (source.startsWith(spec.delimiter, marker)) { | |
| const after = marker + spec.delimiter.length; | |
| if (after >= end) { | |
| j = end; | |
| closed = true; | |
| break; | |
| } | |
| if (source[after] === '\n') { | |
| j = after + 1; | |
| closed = true; | |
| break; | |
| } | |
| if (source[after] === ')') { | |
| j = after; | |
| closed = true; | |
| break; | |
| } | |
| } | |
| const newline = source.indexOf('\n', lineStart); | |
| if (newline === -1 || newline >= end) break; | |
| lineStart = newline + 1; | |
| } | |
| if (!closed) return end; | |
| } | |
| return j; | |
| } | |
| /** Skip a $-construct starting at `i` (which points at the `$`). Handles | |
| * $(...), $((...)), ${...}, $'...' (escape-aware: \' does not close), | |
| * $name and the single-character specials. A `$` followed by anything else | |
| * (including a double quote) consumes just the `$`. */ | |
| export function skipDollar(source: string, budget: ParseBudget, i: number, end: number): number { | |
| const next = source[i + 1]; | |
| if (next === '(') return scanBalancedStatements(source, budget, i + 1, end).end; | |
| if (next === '{') return scanBalanced(source, budget, i + 1, end, '{', '}').end; | |
| // $[...] legacy arithmetic (only when `[` directly follows the `$`; `$a[0]` | |
| // takes the word-character branch above). | |
| if (next === '[') return scanBalanced(source, budget, i + 1, end, '[', ']').end; | |
| if (next === "'") { | |
| // ANSI-C string: \' is an escaped quote and does not terminate it. | |
| let j = i + 2; | |
| let sinceTick = 0; | |
| while (j < end) { | |
| if (++sinceTick >= SCAN_TICK_INTERVAL) { | |
| budget.progress(); | |
| sinceTick = 0; | |
| } | |
| const ch = source[j]!; | |
| if (ch === '\\') { | |
| j += 2; | |
| continue; | |
| } | |
| if (ch === "'") return j + 1; | |
| j++; | |
| } | |
| return end; | |
| } | |
| if (isWordChar(next)) { | |
| let j = i + 1; | |
| while (j < end && isWordChar(source[j])) j++; | |
| return j; | |
| } | |
| if (next !== undefined && SPECIAL_VARIABLE_CHARS.includes(next)) return i + 2; | |
| return i + 1; | |
| } | |
| export class Lexer { | |
| /** Current scan position; exposed for the parser's heredoc bookkeeping. */ | |
| pos: number; | |
| private lookahead: Token[] = []; | |
| private readonly pendingHeredocs: HeredocSpec[] = []; | |
| constructor( | |
| private readonly source: string, | |
| private readonly budget: ParseBudget, | |
| start = 0, | |
| private readonly end = source.length, | |
| ) { | |
| this.pos = start; | |
| } | |
| /** End of the lexer's range (exclusive). */ | |
| get rangeEnd(): number { | |
| return this.end; | |
| } | |
| /** Queue a heredoc body to be scanned after the current line. */ | |
| queueHeredoc(spec: HeredocSpec): void { | |
| this.pendingHeredocs.push(spec); | |
| } | |
| peek(): Token { | |
| return this.peekAt(0); | |
| } | |
| /** Look `n` tokens ahead (0 = the next token). Tokens are scanned | |
| * on demand and buffered, so lookahead never re-scans. */ | |
| peekAt(n: number): Token { | |
| while (this.lookahead.length <= n) this.lookahead.push(this.scanToken()); | |
| return this.lookahead[n]!; | |
| } | |
| next(): Token { | |
| if (this.lookahead.length > 0) return this.lookahead.shift()!; | |
| return this.scanToken(); | |
| } | |
| /** Rewind the stream to `pos`, discarding buffered lookahead. Used when | |
| * the parser consumed a region character-wise (test commands). */ | |
| reposition(pos: number): void { | |
| this.pos = pos; | |
| this.lookahead = []; | |
| } | |
| text(token: Token): string { | |
| return this.source.slice(token.start, token.end); | |
| } | |
| private scanToken(): Token { | |
| this.budget.progress(); | |
| this.skipBlanks(); | |
| const start = this.pos; | |
| if (this.pos >= this.end) return this.scanBoundary('eof', start, start); | |
| const ch = this.source[this.pos]!; | |
| if (ch === '\n') { | |
| this.pos++; | |
| return this.scanBoundary('newline', start, this.pos); | |
| } | |
| if (ch === '#') { | |
| let sinceTick = 0; | |
| while (this.pos < this.end && this.source[this.pos] !== '\n') { | |
| if (++sinceTick >= SCAN_TICK_INTERVAL) { | |
| this.budget.progress(); | |
| sinceTick = 0; | |
| } | |
| this.pos++; | |
| } | |
| return { type: 'comment', start, end: this.pos, heredocBodies: [] }; | |
| } | |
| if (ch === '<' || ch === '>') { | |
| // <( / >( start a process substitution, which is word material. The | |
| // heredoc operators were already excluded: <<( is << + ( … | |
| if (this.source[this.pos + 1] === '(') return this.scanWord(); | |
| return this.scanOp(REDIRECT_OPERATORS); | |
| } | |
| if (ch === '&' || ch === '|' || ch === ';' || ch === '(' || ch === ')') { | |
| // `((` at token start opens arithmetic (an arithmetic command or a | |
| // c-style for header), which is word material, not two parens. | |
| if (ch === '(' && this.source[this.pos + 1] === '(') return this.scanWord(); | |
| return this.scanOp(CONTROL_OPERATORS); | |
| } | |
| if (ch === '{' || ch === '}' || ch === '[' || ch === ']') { | |
| this.pos++; | |
| return { type: 'word', start, end: this.pos, heredocBodies: [] }; | |
| } | |
| if (ch >= '0' && ch <= '9') { | |
| let i = this.pos; | |
| while (i < this.end && isDigitAt(this.source, i)) i++; | |
| const next = this.source[i]; | |
| if (next === '<' || next === '>') { | |
| this.pos = i; | |
| return { type: 'io_number', start, end: i, heredocBodies: [] }; | |
| } | |
| return this.scanWord(); | |
| } | |
| return this.scanWord(); | |
| } | |
| /** Produce a newline/eof token, scanning any queued heredoc bodies that | |
| * start right after it. */ | |
| private scanBoundary(type: 'newline' | 'eof', start: number, end: number): Token { | |
| const bodies: HeredocBody[] = []; | |
| while (this.pendingHeredocs.length > 0) { | |
| bodies.push(this.readHeredocBody(this.pendingHeredocs.shift()!)); | |
| } | |
| return { type, start, end, heredocBodies: bodies }; | |
| } | |
| private skipBlanks(): void { | |
| let sinceTick = 0; | |
| for (;;) { | |
| if (++sinceTick >= SCAN_TICK_INTERVAL) { | |
| this.budget.progress(); | |
| sinceTick = 0; | |
| } | |
| const ch = this.source[this.pos]; | |
| if (isBlank(ch)) { | |
| this.pos++; | |
| continue; | |
| } | |
| // Line continuation is whitespace, not word material. | |
| if (ch === '\\' && this.source[this.pos + 1] === '\n') { | |
| this.pos += 2; | |
| continue; | |
| } | |
| return; | |
| } | |
| } | |
| private scanOp(table: readonly string[]): Token { | |
| const start = this.pos; | |
| for (const op of table) { | |
| if (this.source.startsWith(op, this.pos) && this.pos + op.length <= this.end) { | |
| this.pos += op.length; | |
| return { type: 'op', start, end: this.pos, heredocBodies: [] }; | |
| } | |
| } | |
| // Unreachable for the callers above, but never loop forever. | |
| this.pos++; | |
| return { type: 'op', start, end: this.pos, heredocBodies: [] }; | |
| } | |
| private scanWord(): Token { | |
| const start = this.pos; | |
| // `((...))` at token start: one word token ending right after the | |
| // balanced close (the parser re-scans the range as arithmetic). | |
| if (this.source[start] === '(' && this.source[start + 1] === '(') { | |
| this.pos = scanBalanced(this.source, this.budget, start, this.end, '(', ')').end; | |
| return { type: 'word', start, end: this.pos, heredocBodies: [] }; | |
| } | |
| let i = this.pos; | |
| let sinceTick = 0; | |
| while (i < this.end) { | |
| if (++sinceTick >= SCAN_TICK_INTERVAL) { | |
| this.budget.progress(); | |
| sinceTick = 0; | |
| } | |
| const ch = this.source[i]!; | |
| if (isBlank(ch) || ch === '\n') break; | |
| if (ch === '&' || ch === '|' || ch === ';' || ch === '(' || ch === ')') break; | |
| if (ch === '{' || ch === '}' || ch === '[' || ch === ']') break; | |
| if (ch === '<' || ch === '>') { | |
| if (this.source[i + 1] === '(') { | |
| i = scanBalancedStatements(this.source, this.budget, i + 1, this.end).end; | |
| continue; | |
| } | |
| break; | |
| } | |
| if (ch === '\\') { | |
| // A line continuation ends the run (it acts as whitespace); a lone | |
| // trailing backslash at end of range is consumed as word text. | |
| if (this.source[i + 1] === '\n') break; | |
| i += 2; | |
| continue; | |
| } | |
| if (ch === '"') { | |
| i = skipDoubleQuoted(this.source, this.budget, i, this.end); | |
| continue; | |
| } | |
| if (ch === "'") { | |
| i = skipSingleQuoted(this.source, this.budget, i, this.end); | |
| continue; | |
| } | |
| if (ch === '`') { | |
| i = skipBacktick(this.source, this.budget, i, this.end); | |
| continue; | |
| } | |
| if (ch === '$') { | |
| i = skipDollar(this.source, this.budget, i, this.end); | |
| continue; | |
| } | |
| i++; | |
| } | |
| if (i === start) i++; // defensive: never emit a zero-width word token | |
| this.pos = i; | |
| return { type: 'word', start, end: i, heredocBodies: [] }; | |
| } | |
| /** | |
| * Scan one heredoc body, starting at the current position (right after the | |
| * newline that ended the command line). Matches tree-sitter-bash's layout: | |
| * for `<<-` only the first body line's leading tabs are stripped; the end | |
| * marker is the bare delimiter word and any tabs before it belong to the | |
| * body. | |
| */ | |
| private readHeredocBody(spec: HeredocSpec): HeredocBody { | |
| let bodyStart = this.pos; | |
| if (spec.stripTabs) { | |
| while (bodyStart < this.end && this.source[bodyStart] === '\t') bodyStart++; | |
| } | |
| let lineStart = this.pos; | |
| while (lineStart < this.end) { | |
| this.budget.progress(); | |
| let marker = lineStart; | |
| if (spec.stripTabs) { | |
| while (marker < this.end && this.source[marker] === '\t') marker++; | |
| } | |
| if (spec.delimiter.length > 0 && this.source.startsWith(spec.delimiter, marker)) { | |
| const after = marker + spec.delimiter.length; | |
| if (after >= this.end || this.source[after] === '\n') { | |
| this.pos = after; | |
| return { bodyStart, bodyEnd: marker, endStart: marker, endEnd: after, found: true }; | |
| } | |
| } | |
| const newline = this.source.indexOf('\n', lineStart); | |
| if (newline === -1) break; | |
| lineStart = newline + 1; | |
| } | |
| // Unterminated: the body runs to the end of this lexer's range. | |
| this.pos = this.end; | |
| return { bodyStart, bodyEnd: this.end, endStart: this.end, endEnd: this.end, found: false }; | |
| } | |
| } | |