+3
-0
@@ -56,2 +56,3 @@ import type { DeferredCommandExpansion, ParseError, Word, WordPart } from "./types.ts"; | ||
| raw: boolean; | ||
| keywordEligible: boolean; | ||
| constructor(owner?: Lexer | null); | ||
@@ -122,2 +123,3 @@ get value(): string; | ||
| peek(ctx?: LexContext): TokenValue; | ||
| peekFollow(closers: Uint8Array): TokenValue; | ||
| next(ctx?: LexContext): TokenValue; | ||
@@ -140,2 +142,3 @@ unshift(tok: TokenValue): void; | ||
| private _wordHasExpansions; | ||
| private _wordKeywordEligible; | ||
| private _wordIsAssignment; | ||
@@ -142,0 +145,0 @@ private _wordAssignmentOperatorPos; |
+46
-9
@@ -167,2 +167,16 @@ import { hasEmbeddedWordStructure, LexContext, MAX_SYNTAX_NESTING, Token, Lexer, TokenValue } from "./lexer.js"; | ||
| listTerminators[Token.DoubleSemiAmp] = 1; | ||
| // After one of these Bash is at a command-start position, the only place a reserved-word | ||
| // terminator may follow with no separator. | ||
| const compoundClosers = new Uint8Array(37); | ||
| compoundClosers[Token.RParen] = 1; | ||
| compoundClosers[Token.RBrace] = 1; | ||
| compoundClosers[Token.DblRBracket] = 1; | ||
| compoundClosers[Token.Fi] = 1; | ||
| compoundClosers[Token.Done] = 1; | ||
| compoundClosers[Token.Esac] = 1; | ||
| compoundClosers[Token.ArithCmd] = 1; | ||
| // Inside `[[ ]]` only an unquoted `!` negates; `'!'` and `\!` are ordinary operands. | ||
| function isTestNegation(t) { | ||
| return t.token === Token.Word && t.keywordEligible && t.value === "!"; | ||
| } | ||
| const commandStarts = new Uint8Array(37); | ||
@@ -272,2 +286,23 @@ commandStarts[Token.Word] = 1; | ||
| const commands = this.list(); | ||
| for (;;) { | ||
| const unexpected = this.tok.peek(LexContext.CommandStart); | ||
| if (unexpected.token === Token.EOF) | ||
| break; | ||
| this.error(`unexpected token '${unexpected.value}'`, unexpected.pos); | ||
| // `In` cannot join `listTerminators`: `list()` shares it, and `in` must not terminate a | ||
| // list inside `for`/`case`. | ||
| if (!listTerminators[unexpected.token] && unexpected.token !== Token.In) | ||
| break; | ||
| this.tok.next(LexContext.CommandStart); | ||
| let separator = this.tok.peek(LexContext.CommandStart).token; | ||
| if (separator !== Token.Semi && separator !== Token.Newline && separator !== Token.Amp) | ||
| break; | ||
| while (separator === Token.Semi || separator === Token.Newline || separator === Token.Amp) { | ||
| this.tok.next(LexContext.CommandStart); | ||
| separator = this.tok.peek(LexContext.CommandStart).token; | ||
| } | ||
| const recovered = this.list(); | ||
| for (let i = 0; i < recovered.length; i++) | ||
| commands.push(recovered[i]); | ||
| } | ||
| const lexerErrors = this.tok._errors; | ||
@@ -279,2 +314,4 @@ if (lexerErrors !== null && lexerErrors.length > 0) { | ||
| } | ||
| if (this.errors !== null && this.errors.length > 1) | ||
| this.errors.sort((a, b) => a.pos - b.pos); | ||
| const result = { | ||
@@ -336,3 +373,3 @@ type: "Script", | ||
| for (;;) { | ||
| t = this.tok.peek(LexContext.Normal).token; | ||
| t = this.tok.peekFollow(compoundClosers).token; | ||
| if (t !== Token.Semi && t !== Token.Newline && t !== Token.Amp) | ||
@@ -408,8 +445,8 @@ break; | ||
| let pipelinePos = 0; | ||
| if (this.tok.peek(LexContext.CommandStart).token === Token.Word && | ||
| this.tok.peek(LexContext.CommandStart).value === "time") { | ||
| const firstToken = this.tok.peek(LexContext.CommandStart); | ||
| if (firstToken.token === Token.Word && firstToken.keywordEligible && firstToken.value === "time") { | ||
| time = true; | ||
| pipelinePos = this.tok.next(LexContext.CommandStart).pos; | ||
| if (this.tok.peek(LexContext.CommandStart).token === Token.Word && | ||
| this.tok.peek(LexContext.CommandStart).value === "-p") | ||
| const flag = this.tok.peek(LexContext.CommandStart); | ||
| if (flag.token === Token.Word && flag.keywordEligible && flag.value === "-p") | ||
| this.tok.next(LexContext.CommandStart); | ||
@@ -519,3 +556,3 @@ } | ||
| let redirects = EMPTY_REDIRECTS; | ||
| while (this.tok.peek(LexContext.Normal).token === Token.Redirect) { | ||
| while (this.tok.peekFollow(compoundClosers).token === Token.Redirect) { | ||
| redirects = this.collectRedirect(redirects, LexContext.Normal); | ||
@@ -972,7 +1009,7 @@ } | ||
| let t = this.tok.peek(LexContext.TestMode); | ||
| if (t.token !== Token.Word || t.value !== "!") | ||
| if (!isTestNegation(t)) | ||
| return this.parseTestPrimary(); | ||
| const firstPos = this.tok.next(LexContext.TestMode).pos; | ||
| t = this.tok.peek(LexContext.TestMode); | ||
| if (t.token !== Token.Word || t.value !== "!") { | ||
| if (!isTestNegation(t)) { | ||
| const operand = this.parseTestPrimary(); | ||
@@ -982,3 +1019,3 @@ return { type: "TestNot", pos: firstPos, end: operand.end, operand }; | ||
| const positions = [firstPos]; | ||
| while (t.token === Token.Word && t.value === "!") { | ||
| while (isTestNegation(t)) { | ||
| positions.push(this.tok.next(LexContext.TestMode).pos); | ||
@@ -985,0 +1022,0 @@ t = this.tok.peek(LexContext.TestMode); |
+51
-21
| import { WordImpl } from "./word.js"; | ||
| export function print(script) { | ||
| let out = ""; | ||
| if (script.shebang) | ||
| out += script.shebang + "\n\n"; | ||
| out += stmts(script.commands, 0); | ||
| return out; | ||
| // Saved and restored, not cleared: a lazy getter can re-enter print(), and clearing would | ||
| // drop the outer call's pending heredocs. | ||
| const outerQueued = heredocQueue.length; | ||
| try { | ||
| let out = ""; | ||
| if (script.shebang) | ||
| out += script.shebang + "\n\n"; | ||
| out += stmts(script.commands, 0); | ||
| return out; | ||
| } | ||
| finally { | ||
| heredocQueue.length = outerQueued; | ||
| } | ||
| } | ||
@@ -23,2 +31,3 @@ function isFunc(s) { | ||
| const pad = " ".repeat(indent); | ||
| const queued = heredocQueue.length; | ||
| let out = pad + printNode(s.command, indent); | ||
@@ -29,22 +38,39 @@ for (const r of s.redirects) | ||
| out += " &"; | ||
| out += heredocBodies(s); | ||
| return out; | ||
| return heredocQueue.length === queued ? out : flushHeredocs(out, queued); | ||
| } | ||
| function heredocBodies(s) { | ||
| // Bash reads a heredoc body from the line after its `<<`. `redir` marks where it printed and | ||
| // this drains each mark at the end of its own line, so reflowing cannot misplace a body. | ||
| const HEREDOC_MARK = "\u0000"; | ||
| const heredocQueue = []; | ||
| function flushHeredocs(text, queued) { | ||
| if (heredocQueue.length === queued) | ||
| return text; | ||
| const pending = heredocQueue.splice(queued); | ||
| if (pending.length === 1 && text.endsWith(HEREDOC_MARK)) { | ||
| return text.slice(0, -1) + "\n" + pending[0].content + delimName(pending[0]); | ||
| } | ||
| let out = ""; | ||
| if (s.command.type === "Command") { | ||
| for (const r of s.command.redirects) | ||
| out += heredocBody(r); | ||
| let copied = 0; | ||
| let next = 0; | ||
| let mark = text.indexOf(HEREDOC_MARK); | ||
| while (mark !== -1) { | ||
| let lineEnd = text.indexOf("\n", mark); | ||
| if (lineEnd === -1) | ||
| lineEnd = text.length; | ||
| let marks = 0; | ||
| while (mark !== -1 && mark < lineEnd) { | ||
| out += text.slice(copied, mark); | ||
| copied = mark + 1; | ||
| marks++; | ||
| mark = text.indexOf(HEREDOC_MARK, copied); | ||
| } | ||
| out += text.slice(copied, lineEnd); | ||
| copied = lineEnd; | ||
| // `content` already ends in a newline, so this lands the delimiter on its own line. | ||
| for (; marks > 0 && next < pending.length; marks--, next++) { | ||
| out += "\n" + pending[next].content + delimName(pending[next]); | ||
| } | ||
| } | ||
| for (const r of s.redirects) | ||
| out += heredocBody(r); | ||
| return out; | ||
| return out + text.slice(copied); | ||
| } | ||
| function heredocBody(r) { | ||
| if (r.operator !== "<<" && r.operator !== "<<-") | ||
| return ""; | ||
| if (r.content == null) | ||
| return ""; | ||
| return "\n" + r.content + delimName(r); | ||
| } | ||
| function delimName(r) { | ||
@@ -431,2 +457,6 @@ return r.target?.value ?? ""; | ||
| } | ||
| if (r.content != null && (r.operator === "<<" || r.operator === "<<-")) { | ||
| heredocQueue.push(r); | ||
| out += HEREDOC_MARK; | ||
| } | ||
| return out; | ||
@@ -433,0 +463,0 @@ } |
+1
-1
| { | ||
| "name": "unbash", | ||
| "version": "4.0.6", | ||
| "version": "4.0.7", | ||
| "description": "Fast 0-deps bash parser written in TypeScript", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
+33
-23
@@ -5,2 +5,4 @@ # unbash | ||
| [![NPM Version][2]][1] [![NPM Downloads][3]][1] | ||
| ## Install | ||
@@ -52,4 +54,4 @@ | ||
| To parse `process.argv` (`string[]`), use Node.js [`parseArgs`][1] or a CLI | ||
| library such as [yargs][2] or [citty][3]. | ||
| To parse `process.argv` (`string[]`), use Node.js [`parseArgs`][4] or a CLI | ||
| library such as [yargs][5] or [citty][6]. | ||
@@ -80,2 +82,4 @@ ## Usage | ||
| See the full AST at [unbash.statichost.page#input=if \[ -f...][7] | ||
| ### Word parts | ||
@@ -126,3 +130,5 @@ | ||
| ```js | ||
| const nested = word.parts.find((part) => part.type === "CommandExpansion").script; | ||
| const nested = word.parts.find( | ||
| (part) => part.type === "CommandExpansion", | ||
| ).script; | ||
| const command = nested.commands[0].command; | ||
@@ -166,3 +172,3 @@ | ||
| [tree-sitter-bash][4] is the right choice when you need: | ||
| [tree-sitter-bash][8] is the right choice when you need: | ||
@@ -182,7 +188,7 @@ - Incremental parsing | ||
| - Best-effort error recovery that preserves a partial AST and collects errors | ||
| - Also see [tree-sitter-bash gaps covered by unbash][5] | ||
| - Also see [tree-sitter-bash gaps covered by unbash][9] | ||
| ## unbash vs sh-syntax | ||
| [sh-syntax][6] is a WASM wrapper around the robust [mvdan/sh][7] Go parser. It | ||
| [sh-syntax][10] is a WASM wrapper around the robust [mvdan/sh][11] Go parser. It | ||
| is highly recommended if you need: | ||
@@ -199,8 +205,8 @@ | ||
| - Best-effort partial ASTs for malformed or incomplete editor and user input | ||
| - Also see [sh-syntax gaps covered by unbash][8] | ||
| - Also see [sh-syntax gaps covered by unbash][12] | ||
| ## unbash vs bash-parser | ||
| [bash-parser][9] (last publish: 2017) and its fork | ||
| [@ericcornelissen/bash-parser][10] (community dependency maintenance fork ❤️ now | ||
| [bash-parser][13] (last publish: 2017) and its fork | ||
| [@ericcornelissen/bash-parser][14] (community dependency maintenance fork ❤️ now | ||
| archived) provide: | ||
@@ -248,4 +254,4 @@ | ||
| - [unbash.statichost.page][11] | ||
| - [ast-explorer.dev][12] | ||
| - [unbash.statichost.page][15] | ||
| - [ast-explorer.dev][16] | ||
@@ -256,13 +262,17 @@ ## License | ||
| [1]: https://nodejs.org/api/util.html#utilparseargsconfig | ||
| [2]: https://yargs.js.org/ | ||
| [3]: https://www.npmjs.com/package/citty | ||
| [4]: https://github.com/tree-sitter/tree-sitter-bash | ||
| [5]: https://github.com/webpro-nl/unbash/issues/6 | ||
| [6]: https://github.com/un-ts/sh-syntax | ||
| [7]: https://github.com/mvdan/sh | ||
| [8]: https://github.com/webpro-nl/unbash/issues/7 | ||
| [9]: https://github.com/vorpaljs/bash-parser | ||
| [10]: https://github.com/ericcornelissen/bash-parser | ||
| [11]: https://unbash.statichost.page | ||
| [12]: https://ast-explorer.dev/#eNoVjDsKwzAQRK8yDK5CyAGS2nVAId02jixZAbFr/Kls393rbh7zeBsrn5xLqpV3jr5X/XVzcYgOKRaD8LoNzffTBqFotgkZf8XtUW14oTdRIHaLq00WYscwpRFtCO8g2psm75n3toPHCdz+Ivg= | ||
| [1]: https://www.npmx.dev/package/unbash | ||
| [2]: https://img.shields.io/npm/v/unbash?color=f56e0f | ||
| [3]: https://img.shields.io/npm/dm/unbash?color=f56e0f | ||
| [4]: https://nodejs.org/api/util.html#utilparseargsconfig | ||
| [5]: https://yargs.js.org/ | ||
| [6]: https://www.npmjs.com/package/citty | ||
| [7]: https://unbash.statichost.page/#input=if%20%5B%20-f%20%22%241%22%20%5D%3B%20then%20cat%20%22%241%22%3B%20fi | ||
| [8]: https://github.com/tree-sitter/tree-sitter-bash | ||
| [9]: https://github.com/webpro-nl/unbash/issues/6 | ||
| [10]: https://github.com/un-ts/sh-syntax | ||
| [11]: https://github.com/mvdan/sh | ||
| [12]: https://github.com/webpro-nl/unbash/issues/7 | ||
| [13]: https://github.com/vorpaljs/bash-parser | ||
| [14]: https://github.com/ericcornelissen/bash-parser | ||
| [15]: https://unbash.statichost.page | ||
| [16]: https://ast-explorer.dev/#eNoVjDsKwzAQRK8yDK5CyAGS2nVAId02jixZAbFr/Kls393rbh7zeBsrn5xLqpV3jr5X/XVzcYgOKRaD8LoNzffTBqFotgkZf8XtUW14oTdRIHaLq00WYscwpRFtCO8g2psm75n3toPHCdz+Ivg= |
Sorry, the diff of this file is too big to display
265336
2.42%6802
1.78%270
3.85%