+3
-1
@@ -63,2 +63,3 @@ import type { DeferredCommandExpansion, ParseError, Word, WordPart } from "./types.ts"; | ||
| private src; | ||
| private srcEnd; | ||
| private pos; | ||
@@ -72,3 +73,4 @@ private current; | ||
| _buildParts: boolean; | ||
| constructor(src: string); | ||
| constructor(src: string, start?: number, end?: number); | ||
| getSource(): string; | ||
| get errors(): ParseError[]; | ||
@@ -75,0 +77,0 @@ getCollectedExpansions(): DeferredCommandExpansion[]; |
+3
-0
@@ -6,1 +6,4 @@ export type * from "./types.ts"; | ||
| }; | ||
| export declare function parseRegion(source: string, start: number, end: number): Script & { | ||
| errors?: ParseError[]; | ||
| }; |
+17
-8
@@ -240,17 +240,26 @@ import { LexContext, Token, Lexer, TokenValue } from "./lexer.js"; | ||
| export function parse(source) { | ||
| const parser = new Parser(source); | ||
| return parser.parse(source.length); | ||
| return new Parser(source, 0, source.length).run(); | ||
| } | ||
| // Parse a [start, end) window of `source` in place, so the resulting nodes index the original | ||
| // source directly. Used to resolve substitution scripts with absolute offsets; not public API. | ||
| export function parseRegion(source, start, end) { | ||
| return new Parser(source, start, end).run(); | ||
| } | ||
| class Parser { | ||
| tok; | ||
| source; | ||
| start; | ||
| end; | ||
| errors = []; | ||
| _redirects = []; | ||
| constructor(source) { | ||
| this.tok = new Lexer(source); | ||
| constructor(source, start, end) { | ||
| this.tok = new Lexer(source, start, end); | ||
| this.source = source; | ||
| this.start = start; | ||
| this.end = end; | ||
| } | ||
| parse(sourceLen) { | ||
| run() { | ||
| const start = this.start; | ||
| let shebang; | ||
| if (this.source.charCodeAt(0) === 35 && this.source.charCodeAt(1) === 33) { | ||
| if (start === 0 && this.source.charCodeAt(0) === 35 && this.source.charCodeAt(1) === 33) { | ||
| const nl = this.source.indexOf("\n"); | ||
@@ -267,4 +276,4 @@ shebang = nl === -1 ? this.source : this.source.slice(0, nl); | ||
| type: "Script", | ||
| pos: 0, | ||
| end: sourceLen, | ||
| pos: start, | ||
| end: this.end, | ||
| shebang, | ||
@@ -271,0 +280,0 @@ commands, |
+21
-4
| import { Lexer } from "./lexer.js"; | ||
| import { parse } from "./parser.js"; | ||
| import { parse, parseRegion } from "./parser.js"; | ||
| /** | ||
@@ -10,3 +10,7 @@ * Compute the structural parts of a word by re-scanning the source. | ||
| export function computeWordParts(source, word) { | ||
| const lexer = new Lexer(source); | ||
| // Bound the re-lex to the word's span. A word inside a substitution script carries the | ||
| // whole original as its source, so an unbounded scan would overrun the word into an | ||
| // adjacent delimiter (e.g. a backtick or `)` immediately after it). For top-level words | ||
| // word.end is the natural boundary, so the bound is a no-op. | ||
| const lexer = new Lexer(source, word.pos, word.end); | ||
| const parts = lexer.buildWordParts(word.pos); | ||
@@ -24,3 +28,3 @@ if (!parts) | ||
| export function computeHereDocBodyParts(source, word) { | ||
| const lexer = new Lexer(source); | ||
| const lexer = new Lexer(source, word.pos, word.end); | ||
| const parts = lexer.buildHereDocParts(word.pos, word.end); | ||
@@ -32,9 +36,22 @@ if (!parts) | ||
| } | ||
| /** | ||
| * Resolve each collected substitution's inner script. The script is parsed *in place* | ||
| * against the original source over the window [innerStart, innerStart + inner.length), | ||
| * so every node is born with absolute pos/end — no slicing, no re-basing — and nested | ||
| * substitutions compose because deeper words re-lex the original on demand. | ||
| * | ||
| * Escaped backticks rebuild `inner` with the escapes removed, so it is no longer a verbatim | ||
| * substring of the source and carries no innerStart; those parse the rebuilt slice and stay | ||
| * relative to it — the single documented exception to absolute offsets. | ||
| */ | ||
| function resolveCollected(lexer) { | ||
| const source = lexer.getSource(); | ||
| for (const e of lexer.getCollectedExpansions()) { | ||
| if (e.inner !== undefined) { | ||
| e.script = parse(e.inner); | ||
| e.script = | ||
| e.innerStart !== undefined ? parseRegion(source, e.innerStart, e.innerStart + e.inner.length) : parse(e.inner); | ||
| e.inner = undefined; | ||
| e.innerStart = undefined; | ||
| } | ||
| } | ||
| } |
+5
-0
@@ -60,2 +60,4 @@ export interface Word { | ||
| inner: string | undefined; | ||
| /** Internal: absolute offset of `inner` in the original source; cleared after resolution. */ | ||
| innerStart?: number; | ||
| } | ||
@@ -73,2 +75,4 @@ export interface ArithmeticExpansionPart { | ||
| inner: string | undefined; | ||
| /** Internal: absolute offset of `inner` in the original source; cleared after resolution. */ | ||
| innerStart?: number; | ||
| } | ||
@@ -130,2 +134,3 @@ export type ExtGlobOperator = "?" | "*" | "+" | "@" | "!"; | ||
| script: Script | undefined; | ||
| innerStart?: number; | ||
| } | ||
@@ -132,0 +137,0 @@ export type DoubleQuotedChild = LiteralPart | SimpleExpansionPart | ParameterExpansionPart | CommandExpansionPart | ArithmeticExpansionPart; |
+9
-9
| { | ||
| "name": "unbash", | ||
| "version": "3.0.0", | ||
| "version": "4.0.0", | ||
| "description": "Fast 0-deps bash parser written in TypeScript", | ||
@@ -48,15 +48,15 @@ "keywords": [ | ||
| "@ericcornelissen/bash-parser": "^0.5.3", | ||
| "@types/node": "^24.10.13", | ||
| "@types/node": "^24.13.1", | ||
| "bash-parser": "^0.5.0", | ||
| "mitata": "^1.0.34", | ||
| "oxfmt": "^0.35.0", | ||
| "oxlint": "^1.50.0", | ||
| "oxfmt": "^0.53.0", | ||
| "oxlint": "^1.68.0", | ||
| "remark": "^15.0.1", | ||
| "remark-preset-webpro": "^2.1.1", | ||
| "rolldown": "1.0.0-rc.6", | ||
| "remark-preset-webpro": "^2.2.0", | ||
| "rolldown": "1.1.0", | ||
| "sh-syntax": "^0.5.8", | ||
| "tree-sitter": "^0.25.0", | ||
| "tree-sitter-bash": "^0.25.1", | ||
| "typescript": "^5.8.3", | ||
| "web-tree-sitter": "^0.26.5" | ||
| "typescript": "^6.0.3", | ||
| "web-tree-sitter": "^0.26.9" | ||
| }, | ||
@@ -71,3 +71,3 @@ "remarkConfig": { | ||
| }, | ||
| "packageManager": "pnpm@10.29.2" | ||
| "packageManager": "pnpm@11.5.2" | ||
| } |
+15
-0
@@ -32,2 +32,17 @@ # unbash | ||
| ## Source offsets | ||
| Every node carries `pos`/`end` as absolute offsets into the original source, so `source.slice(node.pos, node.end)` yields that node's text at any nesting depth — including inside command, process, arithmetic, and `${ }` substitutions, and parameter-expansion sub-fields. No need to track inner offsets while traversing. | ||
| ```ts | ||
| const source = 'echo "$(date -u) $(whoami)"'; | ||
| const ast = parse(source); | ||
| const sub = ast.commands[0].command.suffix[0].parts[0].parts[0]; // the $(date -u) | ||
| source.slice(sub.script.pos, sub.script.end); // → "date -u" | ||
| ``` | ||
| `parts` is computed lazily — accessing it resolves a word's substitutions in place. Every word's `text` equals its source span (`source.slice(pos, end)`); `value` carries the interpreted form (quotes resolved). | ||
| The one exception to absolute offsets: legacy escaped backticks (`` `… \`…\` …` ``) rebuild their inner with the escapes removed, so it is no longer a verbatim substring of the source — the nested script's offsets stay relative to that rebuilt inner. | ||
@@ -34,0 +49,0 @@ |
Sorry, the diff of this file is too big to display
181866
2.81%5045
1.37%148
11.28%