@taliesinb/mash 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +275 -2
- package/bin/mash.ts +253 -0
- package/dist/bin/mash.d.ts +39 -0
- package/dist/bin/mash.d.ts.map +1 -0
- package/dist/bin/mash.js +281 -0
- package/dist/bin/mash.js.map +1 -0
- package/dist/src/analysis/analyze.d.ts +97 -0
- package/dist/src/analysis/analyze.d.ts.map +1 -0
- package/dist/src/analysis/analyze.js +1406 -0
- package/dist/src/analysis/analyze.js.map +1 -0
- package/dist/src/analysis/fs.d.ts +39 -0
- package/dist/src/analysis/fs.d.ts.map +1 -0
- package/dist/src/analysis/fs.js +117 -0
- package/dist/src/analysis/fs.js.map +1 -0
- package/dist/src/analysis/outputs.d.ts +16 -0
- package/dist/src/analysis/outputs.d.ts.map +1 -0
- package/dist/src/analysis/outputs.js +277 -0
- package/dist/src/analysis/outputs.js.map +1 -0
- package/dist/src/analysis/value.d.ts +76 -0
- package/dist/src/analysis/value.d.ts.map +1 -0
- package/dist/src/analysis/value.js +248 -0
- package/dist/src/analysis/value.js.map +1 -0
- package/dist/src/behaviors/defs/core.d.ts +15 -0
- package/dist/src/behaviors/defs/core.d.ts.map +1 -0
- package/dist/src/behaviors/defs/core.js +1084 -0
- package/dist/src/behaviors/defs/core.js.map +1 -0
- package/dist/src/behaviors/defs/devtools.d.ts +9 -0
- package/dist/src/behaviors/defs/devtools.d.ts.map +1 -0
- package/dist/src/behaviors/defs/devtools.js +432 -0
- package/dist/src/behaviors/defs/devtools.js.map +1 -0
- package/dist/src/behaviors/defs/macos.d.ts +12 -0
- package/dist/src/behaviors/defs/macos.d.ts.map +1 -0
- package/dist/src/behaviors/defs/macos.js +454 -0
- package/dist/src/behaviors/defs/macos.js.map +1 -0
- package/dist/src/behaviors/defs/tools.d.ts +9 -0
- package/dist/src/behaviors/defs/tools.d.ts.map +1 -0
- package/dist/src/behaviors/defs/tools.js +639 -0
- package/dist/src/behaviors/defs/tools.js.map +1 -0
- package/dist/src/behaviors/dsl.d.ts +95 -0
- package/dist/src/behaviors/dsl.d.ts.map +1 -0
- package/dist/src/behaviors/dsl.js +267 -0
- package/dist/src/behaviors/dsl.js.map +1 -0
- package/dist/src/behaviors/index.d.ts +12 -0
- package/dist/src/behaviors/index.d.ts.map +1 -0
- package/dist/src/behaviors/index.js +18 -0
- package/dist/src/behaviors/index.js.map +1 -0
- package/dist/src/behaviors/registry.d.ts +45 -0
- package/dist/src/behaviors/registry.d.ts.map +1 -0
- package/dist/src/behaviors/registry.js +266 -0
- package/dist/src/behaviors/registry.js.map +1 -0
- package/dist/src/behaviors/roles.d.ts +29 -0
- package/dist/src/behaviors/roles.d.ts.map +1 -0
- package/dist/src/behaviors/roles.js +198 -0
- package/dist/src/behaviors/roles.js.map +1 -0
- package/dist/src/behaviors/types.d.ts +120 -0
- package/dist/src/behaviors/types.d.ts.map +1 -0
- package/dist/src/behaviors/types.js +12 -0
- package/dist/src/behaviors/types.js.map +1 -0
- package/dist/src/index.d.ts +27 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +24 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/integrations/deepsea.d.ts +121 -0
- package/dist/src/integrations/deepsea.d.ts.map +1 -0
- package/dist/src/integrations/deepsea.js +199 -0
- package/dist/src/integrations/deepsea.js.map +1 -0
- package/dist/src/machine.d.ts +139 -0
- package/dist/src/machine.d.ts.map +1 -0
- package/dist/src/machine.js +400 -0
- package/dist/src/machine.js.map +1 -0
- package/dist/src/parser/ast.d.ts +369 -0
- package/dist/src/parser/ast.d.ts.map +1 -0
- package/dist/src/parser/ast.js +17 -0
- package/dist/src/parser/ast.js.map +1 -0
- package/dist/src/parser/parser.d.ts +48 -0
- package/dist/src/parser/parser.d.ts.map +1 -0
- package/dist/src/parser/parser.js +1950 -0
- package/dist/src/parser/parser.js.map +1 -0
- package/dist/src/policy/effects.d.ts +86 -0
- package/dist/src/policy/effects.d.ts.map +1 -0
- package/dist/src/policy/effects.js +53 -0
- package/dist/src/policy/effects.js.map +1 -0
- package/dist/src/policy/policy.d.ts +130 -0
- package/dist/src/policy/policy.d.ts.map +1 -0
- package/dist/src/policy/policy.js +532 -0
- package/dist/src/policy/policy.js.map +1 -0
- package/dist/src/printer/doc.d.ts +151 -0
- package/dist/src/printer/doc.d.ts.map +1 -0
- package/dist/src/printer/doc.js +409 -0
- package/dist/src/printer/doc.js.map +1 -0
- package/dist/src/printer/highlight.d.ts +53 -0
- package/dist/src/printer/highlight.d.ts.map +1 -0
- package/dist/src/printer/highlight.js +339 -0
- package/dist/src/printer/highlight.js.map +1 -0
- package/dist/src/printer/html.d.ts +72 -0
- package/dist/src/printer/html.d.ts.map +1 -0
- package/dist/src/printer/html.js +423 -0
- package/dist/src/printer/html.js.map +1 -0
- package/dist/src/printer/print.d.ts +63 -0
- package/dist/src/printer/print.d.ts.map +1 -0
- package/dist/src/printer/print.js +484 -0
- package/dist/src/printer/print.js.map +1 -0
- package/dist/src/printer/summary.d.ts +50 -0
- package/dist/src/printer/summary.d.ts.map +1 -0
- package/dist/src/printer/summary.js +621 -0
- package/dist/src/printer/summary.js.map +1 -0
- package/dist/src/trace/events.d.ts +183 -0
- package/dist/src/trace/events.d.ts.map +1 -0
- package/dist/src/trace/events.js +9 -0
- package/dist/src/trace/events.js.map +1 -0
- package/dist/src/trace/progress.d.ts +58 -0
- package/dist/src/trace/progress.d.ts.map +1 -0
- package/dist/src/trace/progress.js +83 -0
- package/dist/src/trace/progress.js.map +1 -0
- package/dist/src/vm/arith.d.ts +29 -0
- package/dist/src/vm/arith.d.ts.map +1 -0
- package/dist/src/vm/arith.js +258 -0
- package/dist/src/vm/arith.js.map +1 -0
- package/dist/src/vm/builtins.d.ts +31 -0
- package/dist/src/vm/builtins.d.ts.map +1 -0
- package/dist/src/vm/builtins.js +1773 -0
- package/dist/src/vm/builtins.js.map +1 -0
- package/dist/src/vm/emulate.d.ts +75 -0
- package/dist/src/vm/emulate.d.ts.map +1 -0
- package/dist/src/vm/emulate.js +380 -0
- package/dist/src/vm/emulate.js.map +1 -0
- package/dist/src/vm/expand.d.ts +68 -0
- package/dist/src/vm/expand.d.ts.map +1 -0
- package/dist/src/vm/expand.js +744 -0
- package/dist/src/vm/expand.js.map +1 -0
- package/dist/src/vm/external.d.ts +34 -0
- package/dist/src/vm/external.d.ts.map +1 -0
- package/dist/src/vm/external.js +228 -0
- package/dist/src/vm/external.js.map +1 -0
- package/dist/src/vm/fifo.d.ts +66 -0
- package/dist/src/vm/fifo.d.ts.map +1 -0
- package/dist/src/vm/fifo.js +232 -0
- package/dist/src/vm/fifo.js.map +1 -0
- package/dist/src/vm/interp.d.ts +209 -0
- package/dist/src/vm/interp.d.ts.map +1 -0
- package/dist/src/vm/interp.js +1641 -0
- package/dist/src/vm/interp.js.map +1 -0
- package/dist/src/vm/io.d.ts +180 -0
- package/dist/src/vm/io.d.ts.map +1 -0
- package/dist/src/vm/io.js +502 -0
- package/dist/src/vm/io.js.map +1 -0
- package/dist/src/vm/pattern.d.ts +46 -0
- package/dist/src/vm/pattern.d.ts.map +1 -0
- package/dist/src/vm/pattern.js +498 -0
- package/dist/src/vm/pattern.js.map +1 -0
- package/dist/src/vm/proc.d.ts +94 -0
- package/dist/src/vm/proc.d.ts.map +1 -0
- package/dist/src/vm/proc.js +130 -0
- package/dist/src/vm/proc.js.map +1 -0
- package/dist/src/vm/state.d.ts +104 -0
- package/dist/src/vm/state.d.ts.map +1 -0
- package/dist/src/vm/state.js +233 -0
- package/dist/src/vm/state.js.map +1 -0
- package/dist/src/vm/test.d.ts +11 -0
- package/dist/src/vm/test.d.ts.map +1 -0
- package/dist/src/vm/test.js +277 -0
- package/dist/src/vm/test.js.map +1 -0
- package/package.json +63 -4
- package/src/analysis/analyze.ts +1395 -0
- package/src/analysis/fs.ts +121 -0
- package/src/analysis/outputs.ts +242 -0
- package/src/analysis/value.ts +264 -0
- package/src/behaviors/defs/core.ts +970 -0
- package/src/behaviors/defs/devtools.ts +400 -0
- package/src/behaviors/defs/macos.ts +426 -0
- package/src/behaviors/defs/tools.ts +596 -0
- package/src/behaviors/dsl.ts +293 -0
- package/src/behaviors/index.ts +19 -0
- package/src/behaviors/registry.ts +261 -0
- package/src/behaviors/roles.ts +187 -0
- package/src/behaviors/types.ts +127 -0
- package/src/index.ts +27 -0
- package/src/integrations/deepsea.ts +259 -0
- package/src/machine.ts +483 -0
- package/src/parser/ast.ts +472 -0
- package/src/parser/parser.ts +1826 -0
- package/src/policy/effects.ts +102 -0
- package/src/policy/policy.ts +578 -0
- package/src/printer/doc.ts +454 -0
- package/src/printer/highlight.ts +283 -0
- package/src/printer/html.ts +441 -0
- package/src/printer/print.ts +528 -0
- package/src/printer/summary.ts +601 -0
- package/src/trace/events.ts +102 -0
- package/src/trace/progress.ts +119 -0
- package/src/vm/arith.ts +236 -0
- package/src/vm/builtins.ts +1590 -0
- package/src/vm/emulate.ts +337 -0
- package/src/vm/expand.ts +721 -0
- package/src/vm/external.ts +265 -0
- package/src/vm/fifo.ts +226 -0
- package/src/vm/interp.ts +1623 -0
- package/src/vm/io.ts +570 -0
- package/src/vm/pattern.ts +477 -0
- package/src/vm/proc.ts +147 -0
- package/src/vm/state.ts +267 -0
- package/src/vm/test.ts +253 -0
|
@@ -0,0 +1,1826 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scannerless recursive-descent parser for bash. Bash's lexical structure
|
|
3
|
+
* depends on context (command position, `[[ ]]`, `$(( ))`, `${ }`, here-docs,
|
|
4
|
+
* case patterns), so instead of a separate tokenizer the parser scans the
|
|
5
|
+
* source directly with a cursor and context-specific word scanners.
|
|
6
|
+
*
|
|
7
|
+
* Every node gets a span of offsets into the source. Command substitutions
|
|
8
|
+
* and process substitutions are parsed in place, so their spans are exact;
|
|
9
|
+
* backquoted substitutions are parsed from their unescaped text and their
|
|
10
|
+
* inner spans are approximate (offset from the opening backquote).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type * as A from './ast.ts'
|
|
14
|
+
import { DECL_BUILTINS } from './ast.ts'
|
|
15
|
+
|
|
16
|
+
export class ParseError extends Error {
|
|
17
|
+
readonly offset: number
|
|
18
|
+
readonly line: number
|
|
19
|
+
readonly column: number
|
|
20
|
+
/** True when the error is "unexpected end of input" (an incomplete snippet). */
|
|
21
|
+
readonly incomplete: boolean
|
|
22
|
+
|
|
23
|
+
constructor(message: string, source: string, offset: number, incomplete = false) {
|
|
24
|
+
const { line, column } = lineColumn(source, offset)
|
|
25
|
+
super(`${message} (line ${line}, column ${column})`)
|
|
26
|
+
this.name = 'ParseError'
|
|
27
|
+
this.offset = offset
|
|
28
|
+
this.line = line
|
|
29
|
+
this.column = column
|
|
30
|
+
this.incomplete = incomplete
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 1-based line and column of an offset. */
|
|
35
|
+
export function lineColumn(source: string, offset: number): { line: number; column: number } {
|
|
36
|
+
let line = 1
|
|
37
|
+
let lineStart = 0
|
|
38
|
+
for (let i = 0; i < offset && i < source.length; i++) {
|
|
39
|
+
if (source.charCodeAt(i) === 10) {
|
|
40
|
+
line++
|
|
41
|
+
lineStart = i + 1
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { line, column: offset - lineStart + 1 }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ParseOptions {
|
|
48
|
+
/** Starting node id (default 1). Lets callers keep ids unique across several parses (e.g. `eval`). */
|
|
49
|
+
firstId?: number
|
|
50
|
+
/** Return the statements before a syntax error (in `script.error`) instead of throwing. */
|
|
51
|
+
partial?: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse a bash script into an AST. Throws {@link ParseError}. */
|
|
55
|
+
/** Mutable state shared by a parser and its sub-parsers (backquotes, here-docs). */
|
|
56
|
+
interface Shared {
|
|
57
|
+
next: number
|
|
58
|
+
/** Comments seen, keyed by start offset (backtracking may visit one twice). */
|
|
59
|
+
comments?: Map<number, A.Comment>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sortedComments(shared: Shared): A.Comment[] {
|
|
63
|
+
return [...(shared.comments?.values() ?? [])].sort((a, b) => a.span.start - b.span.start)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parse(source: string, options: ParseOptions = {}): A.Script {
|
|
67
|
+
const ids: Shared = { next: options.firstId ?? 1, comments: new Map() }
|
|
68
|
+
const p = new Parser(source, 0, ids, source)
|
|
69
|
+
if (!options.partial) {
|
|
70
|
+
const script = p.parseScript()
|
|
71
|
+
script.comments = sortedComments(ids)
|
|
72
|
+
return script
|
|
73
|
+
}
|
|
74
|
+
const sink: A.Stmt[] = []
|
|
75
|
+
try {
|
|
76
|
+
const script = p.parseScript(sink)
|
|
77
|
+
script.comments = sortedComments(ids)
|
|
78
|
+
return script
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if (!(e instanceof ParseError)) throw e
|
|
81
|
+
// keep complete statements on lines before the error line
|
|
82
|
+
const lineStart = source.lastIndexOf('\n', Math.max(0, e.offset - 1)) + 1
|
|
83
|
+
const errLineStart = e.offset >= source.length ? source.lastIndexOf('\n', source.length - 1) + 1 : lineStart
|
|
84
|
+
const body = sink.filter((s) => s.span.end < errLineStart && source.indexOf('\n', s.span.end) < errLineStart && source.indexOf('\n', s.span.end) !== -1)
|
|
85
|
+
const last = body[body.length - 1]?.span.end ?? 0
|
|
86
|
+
return { id: ids.next++, span: { start: 0, end: source.length }, type: 'Script', body, error: e, comments: sortedComments(ids).filter((c) => c.span.end <= last) }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const RESERVED = new Set([
|
|
91
|
+
'if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'case', 'esac', 'while', 'until',
|
|
92
|
+
'for', 'in', 'select', 'function', 'time', '{', '}', '!', '[[', ']]', 'coproc',
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
const COND_UNARY = new Set([
|
|
96
|
+
'-a', '-b', '-c', '-d', '-e', '-f', '-g', '-h', '-k', '-p', '-r', '-s', '-t', '-u', '-w', '-x',
|
|
97
|
+
'-G', '-L', '-N', '-O', '-S', '-z', '-n', '-o', '-v', '-R',
|
|
98
|
+
])
|
|
99
|
+
|
|
100
|
+
const COND_BINARY = new Set([
|
|
101
|
+
'==', '=', '!=', '=~', '<', '>', '-eq', '-ne', '-lt', '-le', '-gt', '-ge', '-nt', '-ot', '-ef',
|
|
102
|
+
])
|
|
103
|
+
|
|
104
|
+
const SPECIAL_PARAMS = new Set(['@', '*', '#', '?', '-', '$', '!', '0'])
|
|
105
|
+
|
|
106
|
+
type StopFn = (c: string, p: Parser) => boolean
|
|
107
|
+
|
|
108
|
+
const ARITH_EXPR_TYPES = new Set([
|
|
109
|
+
'ArithNum', 'ArithVar', 'ArithWord', 'ArithUnary', 'ArithBinary', 'ArithAssign', 'ArithTernary', 'ArithIncDec', 'ArithGroup',
|
|
110
|
+
])
|
|
111
|
+
|
|
112
|
+
const arithSources = new WeakMap<object, { src: string; offset: number }>()
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The exact source text of an arithmetic expression node. Bash expands `$x`
|
|
116
|
+
* inside arithmetic textually before evaluating, so the VM needs the raw text
|
|
117
|
+
* around `ArithWord`s.
|
|
118
|
+
*/
|
|
119
|
+
export function arithText(node: A.ArithExpr, span: A.Span = node.span): string | undefined {
|
|
120
|
+
const s = arithSources.get(node)
|
|
121
|
+
if (!s) return undefined
|
|
122
|
+
return s.src.slice(span.start - s.offset, span.end - s.offset)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface PendingHeredoc {
|
|
126
|
+
redirect: A.Redirect
|
|
127
|
+
delimiter: string
|
|
128
|
+
quoted: boolean
|
|
129
|
+
strip: boolean
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isBlank(c: string): boolean {
|
|
133
|
+
return c === ' ' || c === '\t'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isMeta(c: string): boolean {
|
|
137
|
+
return c === ' ' || c === '\t' || c === '\n' || c === '|' || c === '&' || c === ';' || c === '(' || c === ')' || c === '<' || c === '>'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isNameStart(c: string | undefined): boolean {
|
|
141
|
+
return c !== undefined && /[A-Za-z_]/.test(c)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isNameChar(c: string | undefined): boolean {
|
|
145
|
+
return c !== undefined && /[A-Za-z0-9_]/.test(c)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const normalStop: StopFn = (c) => isMeta(c)
|
|
149
|
+
|
|
150
|
+
class Parser {
|
|
151
|
+
pos = 0
|
|
152
|
+
private pending: PendingHeredoc[] = []
|
|
153
|
+
/** >0 while scanning inside double quotes (affects `${v:-'x'}`). */
|
|
154
|
+
private dquoteDepth = 0
|
|
155
|
+
|
|
156
|
+
readonly src: string
|
|
157
|
+
/** Added to every span (non-zero for backquote sub-parsers). */
|
|
158
|
+
private readonly offset: number
|
|
159
|
+
private readonly ids: Shared
|
|
160
|
+
/** The whole original source, for error positions. */
|
|
161
|
+
private readonly root: string
|
|
162
|
+
|
|
163
|
+
constructor(src: string, offset: number, ids: Shared, root: string) {
|
|
164
|
+
this.src = src
|
|
165
|
+
this.offset = offset
|
|
166
|
+
this.ids = ids
|
|
167
|
+
this.root = root
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// -------------------------------------------------------------------------
|
|
171
|
+
// Utilities
|
|
172
|
+
// -------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
private node<T extends A.Node>(start: number, end: number, fields: Omit<T, 'id' | 'span'>): T {
|
|
175
|
+
const n = { id: this.ids.next++, span: { start: start + this.offset, end: end + this.offset }, ...fields } as T
|
|
176
|
+
if (ARITH_EXPR_TYPES.has(n.type)) arithSources.set(n, { src: this.src, offset: this.offset })
|
|
177
|
+
return n
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private error(message: string, at = this.pos): never {
|
|
181
|
+
throw new ParseError(message, this.root, at + this.offset, at >= this.src.length)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private peek(n = 0): string {
|
|
185
|
+
return this.src[this.pos + n] ?? ''
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private startsWith(s: string): boolean {
|
|
189
|
+
return this.src.startsWith(s, this.pos)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private eof(): boolean {
|
|
193
|
+
return this.pos >= this.src.length
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Skip spaces, tabs, line continuations and comments (not newlines). */
|
|
197
|
+
private skipBlanks(): void {
|
|
198
|
+
for (;;) {
|
|
199
|
+
const c = this.peek()
|
|
200
|
+
if (isBlank(c)) this.pos++
|
|
201
|
+
else if (c === '\\' && this.peek(1) === '\n') this.pos += 2
|
|
202
|
+
else if (c === '#') {
|
|
203
|
+
const start = this.pos
|
|
204
|
+
while (!this.eof() && this.peek() !== '\n') this.pos++
|
|
205
|
+
const span = { start: start + this.offset, end: this.pos + this.offset }
|
|
206
|
+
this.ids.comments?.set(span.start, { span, text: this.src.slice(start, this.pos) })
|
|
207
|
+
} else return
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Consume one newline and then any here-document bodies queued on the line it ends. */
|
|
212
|
+
private consumeNewline(): void {
|
|
213
|
+
this.pos++
|
|
214
|
+
if (this.pending.length > 0) this.readHeredocBodies()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Skip blanks, comments and newlines. */
|
|
218
|
+
private skipNewlines(): void {
|
|
219
|
+
for (;;) {
|
|
220
|
+
this.skipBlanks()
|
|
221
|
+
if (this.peek() === '\n') this.consumeNewline()
|
|
222
|
+
else return
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* If an unquoted reserved word (or `{`/`}`/`!`/`[[`) sits at the cursor and is
|
|
228
|
+
* followed by a delimiter, return it (without consuming).
|
|
229
|
+
*/
|
|
230
|
+
private peekReserved(): string | null {
|
|
231
|
+
const m = /^(?:[A-Za-z]+|\{|\}|!|\[\[|\]\])/.exec(this.src.slice(this.pos, this.pos + 10))
|
|
232
|
+
if (!m) return null
|
|
233
|
+
const w = m[0]
|
|
234
|
+
if (!RESERVED.has(w)) return null
|
|
235
|
+
const after = this.src[this.pos + w.length]
|
|
236
|
+
if (after === undefined || isMeta(after)) return w
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private expectReserved(word: string): void {
|
|
241
|
+
this.skipNewlines()
|
|
242
|
+
if (this.peekReserved() !== word) this.error(`expected '${word}'`)
|
|
243
|
+
this.pos += word.length
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// -------------------------------------------------------------------------
|
|
247
|
+
// Script, lists, statements
|
|
248
|
+
// -------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
parseScript(sink?: A.Stmt[]): A.Script {
|
|
251
|
+
const body = this.parseList(new Set(), false, sink)
|
|
252
|
+
this.skipNewlines()
|
|
253
|
+
if (!this.eof()) this.error(`unexpected '${this.peek()}'`)
|
|
254
|
+
if (this.pending.length > 0) {
|
|
255
|
+
// Here-docs at the very end without a trailing newline: read what there is.
|
|
256
|
+
this.readHeredocBodies()
|
|
257
|
+
}
|
|
258
|
+
return this.node<A.Script>(0, this.src.length, { type: 'Script', body })
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** True when the cursor (after blanks/newlines) is at a terminator of the current list. */
|
|
262
|
+
private atListEnd(stopWords: Set<string>, stopParen: boolean): boolean {
|
|
263
|
+
if (this.eof()) return true
|
|
264
|
+
const c = this.peek()
|
|
265
|
+
if (c === ')' && stopParen) return true
|
|
266
|
+
if (stopWords.has(';;') && (this.startsWith(';;') || this.startsWith(';&'))) return true
|
|
267
|
+
if (c === ')' && stopWords.has(')')) return true
|
|
268
|
+
const r = this.peekReserved()
|
|
269
|
+
return r !== null && stopWords.has(r)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Parse statements until one of `stopWords` (reserved words, or ';;' for case
|
|
274
|
+
* items) or, with `stopParen`, a `)`.
|
|
275
|
+
*/
|
|
276
|
+
private parseList(stopWords: Set<string>, stopParen: boolean, sink?: A.Stmt[]): A.Stmt[] {
|
|
277
|
+
const stmts: A.Stmt[] = sink ?? []
|
|
278
|
+
for (;;) {
|
|
279
|
+
this.skipNewlines()
|
|
280
|
+
if (this.atListEnd(stopWords, stopParen)) break
|
|
281
|
+
const c = this.peek()
|
|
282
|
+
if (c === ')' && !stopParen) this.error(`unexpected ')'`)
|
|
283
|
+
if (c === ';') this.error(`unexpected ';'`)
|
|
284
|
+
const start = this.pos
|
|
285
|
+
const command = this.parseAndOr()
|
|
286
|
+
const end = command.span.end - this.offset
|
|
287
|
+
this.skipBlanks()
|
|
288
|
+
let background = false
|
|
289
|
+
const sep = this.peek()
|
|
290
|
+
if (sep === '&' && !this.startsWith('&&') && !this.startsWith('&>')) {
|
|
291
|
+
background = true
|
|
292
|
+
this.pos++
|
|
293
|
+
} else if (sep === ';' && !this.startsWith(';;') && !this.startsWith(';&')) {
|
|
294
|
+
this.pos++
|
|
295
|
+
} else if (sep === '\n') {
|
|
296
|
+
this.consumeNewline()
|
|
297
|
+
} else if (!this.eof() && !this.atListEnd(stopWords, stopParen) && sep !== ')' && !this.startsWith(';;') && !this.startsWith(';&')) {
|
|
298
|
+
this.error(`unexpected '${sep}'`)
|
|
299
|
+
}
|
|
300
|
+
stmts.push(this.node<A.Stmt>(start, end, { type: 'Stmt', command, background }))
|
|
301
|
+
}
|
|
302
|
+
return stmts
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private parseAndOr(): A.Command {
|
|
306
|
+
const start = this.pos
|
|
307
|
+
const items: A.Command[] = [this.parsePipeline()]
|
|
308
|
+
const ops: ('&&' | '||')[] = []
|
|
309
|
+
let end = items[0].span.end - this.offset
|
|
310
|
+
for (;;) {
|
|
311
|
+
this.skipBlanks()
|
|
312
|
+
if (this.startsWith('&&') || this.startsWith('||')) {
|
|
313
|
+
ops.push(this.src.slice(this.pos, this.pos + 2) as '&&' | '||')
|
|
314
|
+
this.pos += 2
|
|
315
|
+
this.skipNewlines()
|
|
316
|
+
items.push(this.parsePipeline())
|
|
317
|
+
end = items[items.length - 1].span.end - this.offset
|
|
318
|
+
} else break
|
|
319
|
+
}
|
|
320
|
+
if (items.length === 1) return items[0]
|
|
321
|
+
return this.node<A.AndOr>(start, end, { type: 'AndOr', items, ops })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private parsePipeline(): A.Command {
|
|
325
|
+
this.skipBlanks()
|
|
326
|
+
const start = this.pos
|
|
327
|
+
let timed = false
|
|
328
|
+
let negated = false
|
|
329
|
+
for (;;) {
|
|
330
|
+
const r = this.peekReserved()
|
|
331
|
+
if (r === 'time' && !timed) {
|
|
332
|
+
timed = true
|
|
333
|
+
this.pos += 4
|
|
334
|
+
this.skipBlanks()
|
|
335
|
+
if (this.startsWith('-p') && (isMeta(this.peek(2)) || this.peek(2) === '')) {
|
|
336
|
+
this.pos += 2
|
|
337
|
+
this.skipBlanks()
|
|
338
|
+
}
|
|
339
|
+
} else if (r === '!') {
|
|
340
|
+
negated = !negated
|
|
341
|
+
this.pos += 1
|
|
342
|
+
this.skipBlanks()
|
|
343
|
+
} else break
|
|
344
|
+
}
|
|
345
|
+
const stages: A.Command[] = [this.parseCommand()]
|
|
346
|
+
const pipeStderr: boolean[] = []
|
|
347
|
+
let end = stages[0].span.end - this.offset
|
|
348
|
+
for (;;) {
|
|
349
|
+
this.skipBlanks()
|
|
350
|
+
if (this.peek() === '|' && this.peek(1) !== '|') {
|
|
351
|
+
const amp = this.peek(1) === '&'
|
|
352
|
+
this.pos += amp ? 2 : 1
|
|
353
|
+
pipeStderr.push(amp)
|
|
354
|
+
this.skipNewlines()
|
|
355
|
+
stages.push(this.parseCommand())
|
|
356
|
+
end = stages[stages.length - 1].span.end - this.offset
|
|
357
|
+
} else break
|
|
358
|
+
}
|
|
359
|
+
if (stages.length === 1 && !negated && !timed) return stages[0]
|
|
360
|
+
return this.node<A.Pipeline>(start, end, { type: 'Pipeline', stages, pipeStderr, negated, timed })
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// -------------------------------------------------------------------------
|
|
364
|
+
// Commands
|
|
365
|
+
// -------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
private parseCommand(): A.Command {
|
|
368
|
+
this.skipBlanks()
|
|
369
|
+
const start = this.pos
|
|
370
|
+
if (this.eof()) this.error('unexpected end of input, expected a command')
|
|
371
|
+
const r = this.peekReserved()
|
|
372
|
+
switch (r) {
|
|
373
|
+
case 'if': return this.withRedirs(this.parseIf())
|
|
374
|
+
case 'while':
|
|
375
|
+
case 'until': return this.withRedirs(this.parseLoop(r))
|
|
376
|
+
case 'for': return this.withRedirs(this.parseFor())
|
|
377
|
+
case 'select': return this.withRedirs(this.parseSelect())
|
|
378
|
+
case 'case': return this.withRedirs(this.parseCase())
|
|
379
|
+
case '{': return this.withRedirs(this.parseGroup())
|
|
380
|
+
case '[[': return this.withRedirs(this.parseCondCommand())
|
|
381
|
+
case 'function': return this.parseFunctionKeyword()
|
|
382
|
+
case 'then': case 'else': case 'elif': case 'fi': case 'do': case 'done': case 'esac': case '}':
|
|
383
|
+
this.error(`unexpected reserved word '${r}'`)
|
|
384
|
+
}
|
|
385
|
+
if (this.startsWith('((')) {
|
|
386
|
+
const arith = this.tryParseArithCommand()
|
|
387
|
+
if (arith) return this.withRedirs(arith)
|
|
388
|
+
}
|
|
389
|
+
if (this.peek() === '(') return this.withRedirs(this.parseSubshell())
|
|
390
|
+
// `name () compound`
|
|
391
|
+
const fm = /^([^\s|&;()<>'"`$\\=]+)[ \t]*\([ \t]*\)/.exec(this.src.slice(this.pos))
|
|
392
|
+
if (fm && !RESERVED.has(fm[1])) {
|
|
393
|
+
this.pos += fm[0].length
|
|
394
|
+
return this.parseFunctionBody(start, fm[1])
|
|
395
|
+
}
|
|
396
|
+
return this.parseSimpleCommand()
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private withRedirs<T extends A.CompoundCommand>(cmd: T): T {
|
|
400
|
+
for (;;) {
|
|
401
|
+
this.skipBlanks()
|
|
402
|
+
const r = this.tryParseRedirect()
|
|
403
|
+
if (!r) break
|
|
404
|
+
cmd.redirs.push(r)
|
|
405
|
+
cmd.span.end = this.pos + this.offset
|
|
406
|
+
}
|
|
407
|
+
return cmd
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private parseFunctionKeyword(): A.FunctionDef {
|
|
411
|
+
const start = this.pos
|
|
412
|
+
this.pos += 'function'.length
|
|
413
|
+
this.skipBlanks()
|
|
414
|
+
const m = /^[^\s|&;()<>'"`$\\]+/.exec(this.src.slice(this.pos))
|
|
415
|
+
if (!m) this.error('expected function name')
|
|
416
|
+
this.pos += m[0].length
|
|
417
|
+
this.skipBlanks()
|
|
418
|
+
if (this.startsWith('(')) {
|
|
419
|
+
this.pos++
|
|
420
|
+
this.skipBlanks()
|
|
421
|
+
if (this.peek() !== ')') this.error(`expected ')'`)
|
|
422
|
+
this.pos++
|
|
423
|
+
}
|
|
424
|
+
return this.parseFunctionBody(start, m[0])
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private parseFunctionBody(start: number, name: string): A.FunctionDef {
|
|
428
|
+
this.skipNewlines()
|
|
429
|
+
const body = this.parseCommand()
|
|
430
|
+
if (body.type === 'SimpleCommand' || body.type === 'Pipeline' || body.type === 'AndOr' || body.type === 'FunctionDef') {
|
|
431
|
+
this.error('function body must be a compound command', body.span.start - this.offset)
|
|
432
|
+
}
|
|
433
|
+
return this.node<A.FunctionDef>(start, this.pos, { type: 'FunctionDef', name, body })
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private parseGroup(): A.Group {
|
|
437
|
+
const start = this.pos
|
|
438
|
+
this.pos++ // {
|
|
439
|
+
const body = this.parseList(new Set(['}']), false)
|
|
440
|
+
this.expectReserved('}')
|
|
441
|
+
return this.node<A.Group>(start, this.pos, { type: 'Group', body, redirs: [] })
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private parseSubshell(): A.Subshell {
|
|
445
|
+
const start = this.pos
|
|
446
|
+
this.pos++ // (
|
|
447
|
+
const body = this.parseList(new Set(), true)
|
|
448
|
+
this.skipNewlines()
|
|
449
|
+
if (this.peek() !== ')') this.error(`expected ')'`)
|
|
450
|
+
this.pos++
|
|
451
|
+
return this.node<A.Subshell>(start, this.pos, { type: 'Subshell', body, redirs: [] })
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private parseIf(): A.If {
|
|
455
|
+
const start = this.pos
|
|
456
|
+
this.pos += 2
|
|
457
|
+
const clauses: A.IfClause[] = []
|
|
458
|
+
let elseBody: A.Stmt[] | null = null
|
|
459
|
+
const condStop = new Set(['then'])
|
|
460
|
+
const bodyStop = new Set(['elif', 'else', 'fi'])
|
|
461
|
+
for (;;) {
|
|
462
|
+
const cond = this.parseList(condStop, false)
|
|
463
|
+
this.expectReserved('then')
|
|
464
|
+
const body = this.parseList(bodyStop, false)
|
|
465
|
+
clauses.push({ cond, body })
|
|
466
|
+
this.skipNewlines()
|
|
467
|
+
const r = this.peekReserved()
|
|
468
|
+
if (r === 'elif') {
|
|
469
|
+
this.pos += 4
|
|
470
|
+
continue
|
|
471
|
+
}
|
|
472
|
+
if (r === 'else') {
|
|
473
|
+
this.pos += 4
|
|
474
|
+
elseBody = this.parseList(new Set(['fi']), false)
|
|
475
|
+
}
|
|
476
|
+
this.expectReserved('fi')
|
|
477
|
+
break
|
|
478
|
+
}
|
|
479
|
+
return this.node<A.If>(start, this.pos, { type: 'If', clauses, elseBody, redirs: [] })
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private parseDoGroup(): A.Stmt[] {
|
|
483
|
+
this.skipNewlines()
|
|
484
|
+
const r = this.peekReserved()
|
|
485
|
+
if (r === '{') {
|
|
486
|
+
// `for x in a; { ...; }` (undocumented bash form)
|
|
487
|
+
this.pos++
|
|
488
|
+
const body = this.parseList(new Set(['}']), false)
|
|
489
|
+
this.expectReserved('}')
|
|
490
|
+
return body
|
|
491
|
+
}
|
|
492
|
+
this.expectReserved('do')
|
|
493
|
+
const body = this.parseList(new Set(['done']), false)
|
|
494
|
+
this.expectReserved('done')
|
|
495
|
+
return body
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private parseLoop(kind: 'while' | 'until'): A.Loop {
|
|
499
|
+
const start = this.pos
|
|
500
|
+
this.pos += kind.length
|
|
501
|
+
const cond = this.parseList(new Set(['do']), false)
|
|
502
|
+
const body = this.parseDoGroup()
|
|
503
|
+
return this.node<A.Loop>(start, this.pos, { type: 'Loop', kind, cond, body, redirs: [] })
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private parseForHead(keyword: string): { variable: string; items: A.Word[] | null } {
|
|
507
|
+
this.pos += keyword.length
|
|
508
|
+
this.skipBlanks()
|
|
509
|
+
const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.src.slice(this.pos))
|
|
510
|
+
if (!m) this.error(`expected a variable name after '${keyword}'`)
|
|
511
|
+
this.pos += m[0].length
|
|
512
|
+
this.skipBlanks()
|
|
513
|
+
let items: A.Word[] | null = null
|
|
514
|
+
// Optional newlines before `in`
|
|
515
|
+
const save = this.pos
|
|
516
|
+
this.skipNewlines()
|
|
517
|
+
if (this.peekReserved() === 'in') {
|
|
518
|
+
this.pos += 2
|
|
519
|
+
items = []
|
|
520
|
+
for (;;) {
|
|
521
|
+
this.skipBlanks()
|
|
522
|
+
const c = this.peek()
|
|
523
|
+
if (c === ';' || c === '\n' || this.eof()) break
|
|
524
|
+
const w = this.parseWord(normalStop)
|
|
525
|
+
if (!w) this.error(`unexpected '${c}' in for list`)
|
|
526
|
+
items.push(w)
|
|
527
|
+
}
|
|
528
|
+
if (this.peek() === ';') this.pos++
|
|
529
|
+
} else {
|
|
530
|
+
this.pos = save
|
|
531
|
+
this.skipBlanks()
|
|
532
|
+
if (this.peek() === ';') this.pos++
|
|
533
|
+
}
|
|
534
|
+
return { variable: m[0], items }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private parseFor(): A.For | A.ArithFor {
|
|
538
|
+
const start = this.pos
|
|
539
|
+
// for (( ... ))
|
|
540
|
+
const after = /^for[ \t]*\(\(/.exec(this.src.slice(this.pos))
|
|
541
|
+
if (after) {
|
|
542
|
+
this.pos += after[0].length
|
|
543
|
+
const parts: (A.ArithExpr | null)[] = []
|
|
544
|
+
for (let i = 0; i < 3; i++) {
|
|
545
|
+
this.skipArithSpace()
|
|
546
|
+
const term = i < 2 ? ';' : ')'
|
|
547
|
+
if (this.peek() === term) {
|
|
548
|
+
parts.push(null)
|
|
549
|
+
} else {
|
|
550
|
+
parts.push(this.parseArithComma())
|
|
551
|
+
this.skipArithSpace()
|
|
552
|
+
}
|
|
553
|
+
if (this.peek() !== term) this.error(`expected '${term}' in arithmetic for`)
|
|
554
|
+
this.pos++
|
|
555
|
+
}
|
|
556
|
+
if (this.peek() !== ')') this.error(`expected '))'`)
|
|
557
|
+
this.pos++
|
|
558
|
+
this.skipBlanks()
|
|
559
|
+
if (this.peek() === ';') this.pos++
|
|
560
|
+
const body = this.parseDoGroup()
|
|
561
|
+
return this.node<A.ArithFor>(start, this.pos, { type: 'ArithFor', init: parts[0], cond: parts[1], step: parts[2], body, redirs: [] })
|
|
562
|
+
}
|
|
563
|
+
const { variable, items } = this.parseForHead('for')
|
|
564
|
+
const body = this.parseDoGroup()
|
|
565
|
+
return this.node<A.For>(start, this.pos, { type: 'For', variable, items, body, redirs: [] })
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private parseSelect(): A.Select {
|
|
569
|
+
const start = this.pos
|
|
570
|
+
const { variable, items } = this.parseForHead('select')
|
|
571
|
+
const body = this.parseDoGroup()
|
|
572
|
+
return this.node<A.Select>(start, this.pos, { type: 'Select', variable, items, body, redirs: [] })
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private parseCase(): A.Case {
|
|
576
|
+
const start = this.pos
|
|
577
|
+
this.pos += 4
|
|
578
|
+
this.skipBlanks()
|
|
579
|
+
const word = this.parseWord(normalStop)
|
|
580
|
+
if (!word) this.error('expected a word after case')
|
|
581
|
+
this.skipNewlines()
|
|
582
|
+
if (this.peekReserved() !== 'in') this.error(`expected 'in'`)
|
|
583
|
+
this.pos += 2
|
|
584
|
+
const items: A.CaseItem[] = []
|
|
585
|
+
const patStop: StopFn = (c) => isBlank(c) || c === '\n' || c === '|' || c === ')' || c === ';' || c === '&' || c === '<' || c === '>'
|
|
586
|
+
for (;;) {
|
|
587
|
+
this.skipNewlines()
|
|
588
|
+
if (this.peekReserved() === 'esac') {
|
|
589
|
+
this.pos += 4
|
|
590
|
+
break
|
|
591
|
+
}
|
|
592
|
+
if (this.eof()) this.error(`expected 'esac'`)
|
|
593
|
+
const itemStart = this.pos
|
|
594
|
+
if (this.peek() === '(') this.pos++
|
|
595
|
+
const patterns: A.Word[] = []
|
|
596
|
+
for (;;) {
|
|
597
|
+
this.skipBlanks()
|
|
598
|
+
const w = this.parseWord(patStop)
|
|
599
|
+
if (!w) this.error('expected a case pattern')
|
|
600
|
+
patterns.push(w)
|
|
601
|
+
this.skipBlanks()
|
|
602
|
+
if (this.peek() === '|') {
|
|
603
|
+
this.pos++
|
|
604
|
+
continue
|
|
605
|
+
}
|
|
606
|
+
if (this.peek() === ')') {
|
|
607
|
+
this.pos++
|
|
608
|
+
break
|
|
609
|
+
}
|
|
610
|
+
this.error(`expected ')' or '|' in case pattern`)
|
|
611
|
+
}
|
|
612
|
+
const body = this.parseList(new Set([';;', 'esac']), false)
|
|
613
|
+
this.skipNewlines()
|
|
614
|
+
let terminator: ';;' | ';&' | ';;&' = ';;'
|
|
615
|
+
if (this.startsWith(';;&')) {
|
|
616
|
+
terminator = ';;&'
|
|
617
|
+
this.pos += 3
|
|
618
|
+
} else if (this.startsWith(';;')) {
|
|
619
|
+
this.pos += 2
|
|
620
|
+
} else if (this.startsWith(';&')) {
|
|
621
|
+
terminator = ';&'
|
|
622
|
+
this.pos += 2
|
|
623
|
+
} else if (this.peekReserved() !== 'esac') {
|
|
624
|
+
this.error(`expected ';;' or 'esac'`)
|
|
625
|
+
}
|
|
626
|
+
items.push(this.node<A.CaseItem>(itemStart, this.pos, { type: 'CaseItem', patterns, body, terminator }))
|
|
627
|
+
}
|
|
628
|
+
return this.node<A.Case>(start, this.pos, { type: 'Case', word, items, redirs: [] })
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private tryParseArithCommand(): A.ArithCommand | null {
|
|
632
|
+
const start = this.pos
|
|
633
|
+
const savePending = this.pending.length
|
|
634
|
+
try {
|
|
635
|
+
this.pos += 2
|
|
636
|
+
const expr = this.parseArithComma()
|
|
637
|
+
this.skipArithSpace()
|
|
638
|
+
if (!this.startsWith('))')) throw new Error('no ))')
|
|
639
|
+
this.pos += 2
|
|
640
|
+
return this.node<A.ArithCommand>(start, this.pos, { type: 'ArithCommand', expr, redirs: [] })
|
|
641
|
+
} catch {
|
|
642
|
+
this.pos = start
|
|
643
|
+
this.pending.length = savePending
|
|
644
|
+
const close = this.matchingArithClose(start + 2)
|
|
645
|
+
if (close !== -1) {
|
|
646
|
+
const text = this.src.slice(start + 2, close)
|
|
647
|
+
// a subshell inside a subshell, `((cmd) ...)`, is not arithmetic
|
|
648
|
+
if (/^\s*\(/.test(text) && !this.looksArithmetic(text)) return null
|
|
649
|
+
const expr = this.rawArithWord(start + 2, close)
|
|
650
|
+
this.pos = close + 2
|
|
651
|
+
return this.node<A.ArithCommand>(start, this.pos, { type: 'ArithCommand', expr, redirs: [] })
|
|
652
|
+
}
|
|
653
|
+
return null
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private looksArithmetic(text: string): boolean {
|
|
658
|
+
return /^[\s\d\w+\-*/%<>=!&|^~?:,()$#\[\]{}"'.]*$/.test(text) && !/[a-zA-Z_][\w]*\s+[a-zA-Z_-]/.test(text)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// -------------------------------------------------------------------------
|
|
662
|
+
// Simple commands, assignments, redirections
|
|
663
|
+
// -------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
private parseSimpleCommand(): A.SimpleCommand {
|
|
666
|
+
const start = this.pos
|
|
667
|
+
const assigns: A.Assign[] = []
|
|
668
|
+
const words: (A.Word | A.Assign)[] = []
|
|
669
|
+
const redirs: A.Redirect[] = []
|
|
670
|
+
let declMode = false
|
|
671
|
+
let end = this.pos
|
|
672
|
+
for (;;) {
|
|
673
|
+
this.skipBlanks()
|
|
674
|
+
if (this.eof()) break
|
|
675
|
+
const c = this.peek()
|
|
676
|
+
const redir = this.tryParseRedirect()
|
|
677
|
+
if (redir) {
|
|
678
|
+
redirs.push(redir)
|
|
679
|
+
end = this.pos
|
|
680
|
+
continue
|
|
681
|
+
}
|
|
682
|
+
if (c === '\n' || c === ';' || c === '&' || c === '|' || c === ')') break
|
|
683
|
+
if (c === '(') {
|
|
684
|
+
this.error(`unexpected '('`)
|
|
685
|
+
}
|
|
686
|
+
if (c === '<' || c === '>') {
|
|
687
|
+
// process substitution as a word
|
|
688
|
+
if (this.peek(1) !== '(') this.error(`unexpected '${c}'`)
|
|
689
|
+
}
|
|
690
|
+
if (words.length === 0 || declMode) {
|
|
691
|
+
const a = this.tryParseAssign(declMode)
|
|
692
|
+
if (a) {
|
|
693
|
+
if (words.length === 0) assigns.push(a)
|
|
694
|
+
else words.push(a)
|
|
695
|
+
end = this.pos
|
|
696
|
+
continue
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const w = this.parseWord(normalStop)
|
|
700
|
+
if (!w) this.error(`unexpected '${c}'`)
|
|
701
|
+
if (words.length === 0 && w.parts.length === 1 && w.parts[0].type === 'Lit' && DECL_BUILTINS.has(w.parts[0].value)) {
|
|
702
|
+
declMode = true
|
|
703
|
+
}
|
|
704
|
+
words.push(w)
|
|
705
|
+
end = this.pos
|
|
706
|
+
}
|
|
707
|
+
if (assigns.length === 0 && words.length === 0 && redirs.length === 0) this.error('expected a command')
|
|
708
|
+
return this.node<A.SimpleCommand>(start, end, { type: 'SimpleCommand', assigns, words, redirs })
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** `name=value`, `name+=value`, `name[sub]=value`, `name=(...)`. In decl mode a bare `name` is not an assignment. */
|
|
712
|
+
private tryParseAssign(declMode: boolean): A.Assign | null {
|
|
713
|
+
const start = this.pos
|
|
714
|
+
const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.src.slice(this.pos, this.pos + 256))
|
|
715
|
+
if (!m) return null
|
|
716
|
+
let p = this.pos + m[0].length
|
|
717
|
+
let index: A.Word | null = null
|
|
718
|
+
if (this.src[p] === '[') {
|
|
719
|
+
// find matching ]
|
|
720
|
+
let depth = 0
|
|
721
|
+
let q = p
|
|
722
|
+
for (; q < this.src.length; q++) {
|
|
723
|
+
const ch = this.src[q]
|
|
724
|
+
if (ch === '[') depth++
|
|
725
|
+
else if (ch === ']') {
|
|
726
|
+
depth--
|
|
727
|
+
if (depth === 0) break
|
|
728
|
+
} else if (ch === '\n') return null
|
|
729
|
+
}
|
|
730
|
+
if (q >= this.src.length) return null
|
|
731
|
+
const after = this.src[q + 1]
|
|
732
|
+
if (!(after === '=' || (after === '+' && this.src[q + 2] === '='))) return null
|
|
733
|
+
this.pos = p + 1
|
|
734
|
+
index = this.parseWord((c) => c === ']', { allowBlanks: true }) ?? this.node<A.Word>(this.pos, this.pos, { type: 'Word', parts: [] })
|
|
735
|
+
if (this.peek() !== ']') {
|
|
736
|
+
this.pos = start
|
|
737
|
+
return null
|
|
738
|
+
}
|
|
739
|
+
this.pos++
|
|
740
|
+
p = this.pos
|
|
741
|
+
}
|
|
742
|
+
let append = false
|
|
743
|
+
if (this.src[p] === '+' && this.src[p + 1] === '=') {
|
|
744
|
+
append = true
|
|
745
|
+
p += 2
|
|
746
|
+
} else if (this.src[p] === '=') {
|
|
747
|
+
p += 1
|
|
748
|
+
} else {
|
|
749
|
+
this.pos = start
|
|
750
|
+
return null
|
|
751
|
+
}
|
|
752
|
+
this.pos = p
|
|
753
|
+
let value: A.Word | A.ArrayLiteral | null = null
|
|
754
|
+
if (this.peek() === '(') {
|
|
755
|
+
value = this.parseArrayLiteral()
|
|
756
|
+
} else {
|
|
757
|
+
value = this.parseWord(normalStop)
|
|
758
|
+
}
|
|
759
|
+
return this.node<A.Assign>(start, this.pos, { type: 'Assign', name: m[0], index, append, value })
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private parseArrayLiteral(): A.ArrayLiteral {
|
|
763
|
+
const start = this.pos
|
|
764
|
+
this.pos++ // (
|
|
765
|
+
const elems: A.ArrayElem[] = []
|
|
766
|
+
for (;;) {
|
|
767
|
+
this.skipNewlines()
|
|
768
|
+
if (this.peek() === ')') {
|
|
769
|
+
this.pos++
|
|
770
|
+
break
|
|
771
|
+
}
|
|
772
|
+
if (this.eof()) this.error(`expected ')' to close array`)
|
|
773
|
+
let index: A.Word | null = null
|
|
774
|
+
if (this.peek() === '[') {
|
|
775
|
+
const save = this.pos
|
|
776
|
+
this.pos++
|
|
777
|
+
const idx = this.parseWord((c) => c === ']', { allowBlanks: true })
|
|
778
|
+
if (this.peek() === ']' && this.peek(1) === '=') {
|
|
779
|
+
index = idx ?? this.node<A.Word>(this.pos, this.pos, { type: 'Word', parts: [] })
|
|
780
|
+
this.pos += 2
|
|
781
|
+
} else {
|
|
782
|
+
this.pos = save
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
const value = this.parseWord(normalStop) ?? this.node<A.Word>(this.pos, this.pos, { type: 'Word', parts: [] })
|
|
786
|
+
if (!index && value.parts.length === 0) this.error(`unexpected '${this.peek()}' in array`)
|
|
787
|
+
elems.push({ index, value })
|
|
788
|
+
}
|
|
789
|
+
return this.node<A.ArrayLiteral>(start, this.pos, { type: 'ArrayLiteral', elems })
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private tryParseRedirect(): A.Redirect | null {
|
|
793
|
+
const start = this.pos
|
|
794
|
+
let fd: number | null = null
|
|
795
|
+
let fdVar: string | null = null
|
|
796
|
+
const rest = this.src.slice(this.pos, this.pos + 64)
|
|
797
|
+
let m = /^(\d+)(?=[<>])/.exec(rest)
|
|
798
|
+
if (m) {
|
|
799
|
+
fd = Number(m[1])
|
|
800
|
+
this.pos += m[0].length
|
|
801
|
+
} else if ((m = /^\{([A-Za-z_][A-Za-z0-9_]*)\}(?=[<>])/.exec(rest))) {
|
|
802
|
+
fdVar = m[1]
|
|
803
|
+
this.pos += m[0].length
|
|
804
|
+
}
|
|
805
|
+
const ops: A.RedirectOp[] = ['&>>', '&>', '<<<', '<<-', '<<', '<&', '<>', '>>', '>&', '>|', '<', '>']
|
|
806
|
+
let op: A.RedirectOp | null = null
|
|
807
|
+
for (const o of ops) {
|
|
808
|
+
if (this.startsWith(o)) {
|
|
809
|
+
op = o
|
|
810
|
+
break
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (op === null || ((op === '<' || op === '>') && this.peek(1) === '(') || ((op === '&>' || op === '&>>') && (fd !== null || fdVar !== null))) {
|
|
814
|
+
this.pos = start
|
|
815
|
+
return null
|
|
816
|
+
}
|
|
817
|
+
this.pos += op.length
|
|
818
|
+
this.skipBlanks()
|
|
819
|
+
const target = this.parseWord(normalStop)
|
|
820
|
+
if (!target) this.error(`expected a word after '${op}'`)
|
|
821
|
+
const redirect = this.node<A.Redirect>(start, this.pos, { type: 'Redirect', op, fd, fdVar, target, heredoc: null })
|
|
822
|
+
if (op === '<<' || op === '<<-') {
|
|
823
|
+
let delimiter = ''
|
|
824
|
+
let quoted = false
|
|
825
|
+
for (const part of target.parts) {
|
|
826
|
+
if (part.type === 'Lit') delimiter += part.value
|
|
827
|
+
else if (part.type === 'SglQuoted' || part.type === 'Escaped') {
|
|
828
|
+
delimiter += part.value
|
|
829
|
+
quoted = true
|
|
830
|
+
} else if (part.type === 'DblQuoted') {
|
|
831
|
+
quoted = true
|
|
832
|
+
for (const inner of part.parts) {
|
|
833
|
+
if (inner.type === 'Lit') delimiter += inner.value
|
|
834
|
+
else delimiter += this.src.slice(inner.span.start - this.offset, inner.span.end - this.offset)
|
|
835
|
+
}
|
|
836
|
+
} else {
|
|
837
|
+
delimiter += this.src.slice(part.span.start - this.offset, part.span.end - this.offset)
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
this.pending.push({ redirect, delimiter, quoted, strip: op === '<<-' })
|
|
841
|
+
}
|
|
842
|
+
return redirect
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private readHeredocBodies(): void {
|
|
846
|
+
const queue = this.pending
|
|
847
|
+
this.pending = []
|
|
848
|
+
for (const h of queue) {
|
|
849
|
+
const bodyStart = this.pos
|
|
850
|
+
let bodyEnd = this.pos
|
|
851
|
+
let found = false
|
|
852
|
+
while (!this.eof()) {
|
|
853
|
+
const nl = this.src.indexOf('\n', this.pos)
|
|
854
|
+
const lineEnd = nl === -1 ? this.src.length : nl
|
|
855
|
+
let line = this.src.slice(this.pos, lineEnd)
|
|
856
|
+
if (h.strip) line = line.replace(/^\t+/, '')
|
|
857
|
+
if (line === h.delimiter) {
|
|
858
|
+
bodyEnd = this.pos
|
|
859
|
+
this.pos = nl === -1 ? this.src.length : nl + 1
|
|
860
|
+
found = true
|
|
861
|
+
break
|
|
862
|
+
}
|
|
863
|
+
this.pos = nl === -1 ? this.src.length : nl + 1
|
|
864
|
+
bodyEnd = this.pos
|
|
865
|
+
}
|
|
866
|
+
if (!found) bodyEnd = this.src.length
|
|
867
|
+
const missingNewline = !found && bodyEnd > bodyStart && this.src[bodyEnd - 1] !== '\n'
|
|
868
|
+
let body: A.Word
|
|
869
|
+
if (h.quoted) {
|
|
870
|
+
let text = this.src.slice(bodyStart, bodyEnd)
|
|
871
|
+
if (h.strip) text = text.replace(/^\t+/gm, '')
|
|
872
|
+
if (missingNewline) text += '\n'
|
|
873
|
+
body = this.node<A.Word>(bodyStart, bodyEnd, {
|
|
874
|
+
type: 'Word',
|
|
875
|
+
parts: [this.node<A.SglQuoted>(bodyStart, bodyEnd, { type: 'SglQuoted', value: text, ansi: false })],
|
|
876
|
+
})
|
|
877
|
+
} else {
|
|
878
|
+
const resume = this.pos
|
|
879
|
+
const sub = new Parser(this.src.slice(0, bodyEnd), this.offset, this.ids, this.root)
|
|
880
|
+
sub.pos = bodyStart
|
|
881
|
+
const parts = sub.parseDquotedParts('heredoc')
|
|
882
|
+
if (h.strip) {
|
|
883
|
+
for (const part of parts) {
|
|
884
|
+
if (part.type !== 'Lit') continue
|
|
885
|
+
const atLineStart = part.span.start - this.offset === bodyStart || this.src[part.span.start - this.offset - 1] === '\n'
|
|
886
|
+
part.value = part.value.replace(/\n\t+/g, '\n')
|
|
887
|
+
if (atLineStart) part.value = part.value.replace(/^\t+/, '')
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
if (missingNewline) parts.push(this.node<A.Lit>(bodyEnd, bodyEnd, { type: 'Lit', value: '\n' }))
|
|
891
|
+
body = this.node<A.Word>(bodyStart, bodyEnd, { type: 'Word', parts })
|
|
892
|
+
this.pos = resume
|
|
893
|
+
}
|
|
894
|
+
h.redirect.heredoc = { delimiter: h.delimiter, quoted: h.quoted, body }
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// -------------------------------------------------------------------------
|
|
899
|
+
// Words
|
|
900
|
+
// -------------------------------------------------------------------------
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Parse one word. `stop` decides which unquoted characters end it. Returns
|
|
904
|
+
* null if the word would be empty (cursor at a stop character).
|
|
905
|
+
*/
|
|
906
|
+
parseWord(stop: StopFn, opts: { allowBlanks?: boolean; inDquote?: boolean; sglLiteral?: boolean } = {}): A.Word | null {
|
|
907
|
+
const start = this.pos
|
|
908
|
+
const parts = this.parseWordParts(stop, opts)
|
|
909
|
+
if (parts.length === 0 && this.pos === start) return null
|
|
910
|
+
return this.node<A.Word>(start, this.pos, { type: 'Word', parts })
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private parseWordParts(stop: StopFn, opts: { allowBlanks?: boolean; inDquote?: boolean; sglLiteral?: boolean }): A.WordPart[] {
|
|
914
|
+
const parts: A.WordPart[] = []
|
|
915
|
+
let litStart = -1
|
|
916
|
+
let lit = ''
|
|
917
|
+
const flush = (): void => {
|
|
918
|
+
if (litStart >= 0) {
|
|
919
|
+
parts.push(this.node<A.Lit>(litStart, this.pos, { type: 'Lit', value: lit }))
|
|
920
|
+
litStart = -1
|
|
921
|
+
lit = ''
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
while (!this.eof()) {
|
|
925
|
+
const c = this.peek()
|
|
926
|
+
if (c === '\\' && this.peek(1) === '\n') {
|
|
927
|
+
flush()
|
|
928
|
+
this.pos += 2
|
|
929
|
+
continue
|
|
930
|
+
}
|
|
931
|
+
if ((c === '<' || c === '>') && this.peek(1) === '(' && !opts.inDquote && !opts.allowBlanks) {
|
|
932
|
+
flush()
|
|
933
|
+
parts.push(this.parseProcSubst())
|
|
934
|
+
continue
|
|
935
|
+
}
|
|
936
|
+
if ('@*+?!'.includes(c) && this.peek(1) === '(' && !opts.inDquote) {
|
|
937
|
+
const end = this.extglobEnd(this.pos + 1)
|
|
938
|
+
if (end !== -1) {
|
|
939
|
+
if (litStart < 0) litStart = this.pos
|
|
940
|
+
lit += this.src.slice(this.pos, end)
|
|
941
|
+
this.pos = end
|
|
942
|
+
continue
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (stop(c, this) && !(opts.allowBlanks && isBlank(c))) break
|
|
946
|
+
if (c === '\\') {
|
|
947
|
+
flush()
|
|
948
|
+
const s = this.pos
|
|
949
|
+
if (this.pos + 1 >= this.src.length) {
|
|
950
|
+
// a backslash at the very end of the input: bash 5 keeps it literally (3.2 dropped it)
|
|
951
|
+
this.pos++
|
|
952
|
+
parts.push(this.node<A.Lit>(s, this.pos, { type: 'Lit', value: '\\' }))
|
|
953
|
+
continue
|
|
954
|
+
}
|
|
955
|
+
this.pos += 2
|
|
956
|
+
if (opts.inDquote) {
|
|
957
|
+
const e = this.src[s + 1]
|
|
958
|
+
const value = '$`"\\}'.includes(e) ? e : '\\' + e
|
|
959
|
+
parts.push(this.node<A.Lit>(s, this.pos, { type: 'Lit', value }))
|
|
960
|
+
} else {
|
|
961
|
+
parts.push(this.node<A.Escaped>(s, this.pos, { type: 'Escaped', value: this.src[s + 1] }))
|
|
962
|
+
}
|
|
963
|
+
continue
|
|
964
|
+
}
|
|
965
|
+
if (c === "'" && !opts.inDquote && !opts.sglLiteral) {
|
|
966
|
+
flush()
|
|
967
|
+
const s = this.pos
|
|
968
|
+
const close = this.src.indexOf("'", this.pos + 1)
|
|
969
|
+
if (close === -1) this.error('unterminated single quote', s)
|
|
970
|
+
this.pos = close + 1
|
|
971
|
+
parts.push(this.node<A.SglQuoted>(s, this.pos, { type: 'SglQuoted', value: this.src.slice(s + 1, close), ansi: false }))
|
|
972
|
+
continue
|
|
973
|
+
}
|
|
974
|
+
if (c === '"') {
|
|
975
|
+
flush()
|
|
976
|
+
if (opts.inDquote) break
|
|
977
|
+
parts.push(this.parseDquoted(false))
|
|
978
|
+
continue
|
|
979
|
+
}
|
|
980
|
+
if (c === '$') {
|
|
981
|
+
const d = this.parseDollar(opts.inDquote ?? false)
|
|
982
|
+
if (d) {
|
|
983
|
+
flush()
|
|
984
|
+
parts.push(d)
|
|
985
|
+
continue
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (c === '`') {
|
|
989
|
+
flush()
|
|
990
|
+
parts.push(this.parseBackquote())
|
|
991
|
+
continue
|
|
992
|
+
}
|
|
993
|
+
if (litStart < 0) litStart = this.pos
|
|
994
|
+
lit += c
|
|
995
|
+
this.pos++
|
|
996
|
+
}
|
|
997
|
+
flush()
|
|
998
|
+
return parts
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** End offset (exclusive) of an extglob group starting at the `(` at `open`, or -1. */
|
|
1002
|
+
private extglobEnd(open: number): number {
|
|
1003
|
+
let depth = 0
|
|
1004
|
+
for (let i = open; i < this.src.length; i++) {
|
|
1005
|
+
const c = this.src[i]
|
|
1006
|
+
if (c === '\\') {
|
|
1007
|
+
i++
|
|
1008
|
+
continue
|
|
1009
|
+
}
|
|
1010
|
+
if (c === '(') depth++
|
|
1011
|
+
else if (c === ')') {
|
|
1012
|
+
depth--
|
|
1013
|
+
if (depth === 0) return i + 1
|
|
1014
|
+
} else if (c === '\n' || ((c === ' ' || c === '\t') && depth <= 1 && false)) return -1
|
|
1015
|
+
}
|
|
1016
|
+
return -1
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
private parseDquoted(locale: boolean): A.DblQuoted {
|
|
1020
|
+
const s = locale ? this.pos - 1 : this.pos
|
|
1021
|
+
this.pos++ // "
|
|
1022
|
+
const parts = this.parseDquotedParts('dquote')
|
|
1023
|
+
if (this.peek() !== '"') this.error('unterminated double quote', s)
|
|
1024
|
+
this.pos++
|
|
1025
|
+
return this.node<A.DblQuoted>(s, this.pos, { type: 'DblQuoted', parts, locale })
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Parts inside double quotes (until an unescaped `"`) or in an unquoted
|
|
1030
|
+
* here-document body (until the end of the sub-source).
|
|
1031
|
+
*/
|
|
1032
|
+
parseDquotedParts(mode: 'dquote' | 'heredoc'): A.WordPart[] {
|
|
1033
|
+
this.dquoteDepth++
|
|
1034
|
+
try {
|
|
1035
|
+
return this.parseDquotedPartsInner(mode)
|
|
1036
|
+
} finally {
|
|
1037
|
+
this.dquoteDepth--
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private parseDquotedPartsInner(mode: 'dquote' | 'heredoc'): A.WordPart[] {
|
|
1042
|
+
const parts: A.WordPart[] = []
|
|
1043
|
+
let litStart = -1
|
|
1044
|
+
let lit = ''
|
|
1045
|
+
const flush = (): void => {
|
|
1046
|
+
if (litStart >= 0) {
|
|
1047
|
+
parts.push(this.node<A.Lit>(litStart, this.pos, { type: 'Lit', value: lit }))
|
|
1048
|
+
litStart = -1
|
|
1049
|
+
lit = ''
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
while (!this.eof()) {
|
|
1053
|
+
const c = this.peek()
|
|
1054
|
+
if (c === '"' && mode === 'dquote') break
|
|
1055
|
+
if (c === '\\') {
|
|
1056
|
+
const e = this.peek(1)
|
|
1057
|
+
if (e === '\n') {
|
|
1058
|
+
flush()
|
|
1059
|
+
this.pos += 2
|
|
1060
|
+
continue
|
|
1061
|
+
}
|
|
1062
|
+
const escapable = mode === 'dquote' ? '$`"\\' : '$`\\'
|
|
1063
|
+
if (e !== '' && escapable.includes(e)) {
|
|
1064
|
+
if (litStart < 0) litStart = this.pos
|
|
1065
|
+
lit += e
|
|
1066
|
+
this.pos += 2
|
|
1067
|
+
continue
|
|
1068
|
+
}
|
|
1069
|
+
if (litStart < 0) litStart = this.pos
|
|
1070
|
+
lit += c
|
|
1071
|
+
this.pos++
|
|
1072
|
+
continue
|
|
1073
|
+
}
|
|
1074
|
+
if (c === '$') {
|
|
1075
|
+
const d = this.parseDollar(true)
|
|
1076
|
+
if (d) {
|
|
1077
|
+
flush()
|
|
1078
|
+
parts.push(d)
|
|
1079
|
+
continue
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (c === '`') {
|
|
1083
|
+
flush()
|
|
1084
|
+
parts.push(this.parseBackquote())
|
|
1085
|
+
continue
|
|
1086
|
+
}
|
|
1087
|
+
if (litStart < 0) litStart = this.pos
|
|
1088
|
+
lit += c
|
|
1089
|
+
this.pos++
|
|
1090
|
+
}
|
|
1091
|
+
flush()
|
|
1092
|
+
return parts
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
private parseBackquote(): A.CmdSubst {
|
|
1096
|
+
const s = this.pos
|
|
1097
|
+
this.pos++
|
|
1098
|
+
let text = ''
|
|
1099
|
+
for (;;) {
|
|
1100
|
+
if (this.eof()) this.error('unterminated backquote', s)
|
|
1101
|
+
const c = this.peek()
|
|
1102
|
+
if (c === '`') {
|
|
1103
|
+
this.pos++
|
|
1104
|
+
break
|
|
1105
|
+
}
|
|
1106
|
+
if (c === '\\' && (('`\\$'.includes(this.peek(1)) && this.peek(1) !== '') || (this.peek(1) === '"' && this.dquoteDepth > 0))) {
|
|
1107
|
+
text += this.peek(1)
|
|
1108
|
+
this.pos += 2
|
|
1109
|
+
continue
|
|
1110
|
+
}
|
|
1111
|
+
text += c
|
|
1112
|
+
this.pos++
|
|
1113
|
+
}
|
|
1114
|
+
const sub = new Parser(text, this.offset + s + 1, this.ids, this.root)
|
|
1115
|
+
const body = sub.parseList(new Set(), false)
|
|
1116
|
+
sub.skipNewlines()
|
|
1117
|
+
if (!sub.eof()) sub.error(`unexpected '${sub.peek()}'`)
|
|
1118
|
+
return this.node<A.CmdSubst>(s, this.pos, { type: 'CmdSubst', body, backquote: true })
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
private parseProcSubst(): A.ProcSubst {
|
|
1122
|
+
const s = this.pos
|
|
1123
|
+
const direction = this.peek() as '<' | '>'
|
|
1124
|
+
this.pos += 2
|
|
1125
|
+
const body = this.parseList(new Set(), true)
|
|
1126
|
+
this.skipNewlines()
|
|
1127
|
+
if (this.peek() !== ')') this.error(`expected ')' to close process substitution`, s)
|
|
1128
|
+
this.pos++
|
|
1129
|
+
return this.node<A.ProcSubst>(s, this.pos, { type: 'ProcSubst', direction, body })
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** Parse a `$`-construct at the cursor, or return null if `$` is literal here. */
|
|
1133
|
+
private parseDollar(inDquote: boolean): A.WordPart | null {
|
|
1134
|
+
const s = this.pos
|
|
1135
|
+
const n = this.peek(1)
|
|
1136
|
+
if (n === '(') {
|
|
1137
|
+
if (this.peek(2) === '(') {
|
|
1138
|
+
// $(( arith )) — or a command substitution starting with a subshell
|
|
1139
|
+
const savePending = this.pending.length
|
|
1140
|
+
const close = this.matchingArithClose(this.pos + 3)
|
|
1141
|
+
try {
|
|
1142
|
+
this.pos += 3
|
|
1143
|
+
this.skipArithSpace()
|
|
1144
|
+
if (this.startsWith('))')) {
|
|
1145
|
+
this.pos += 2
|
|
1146
|
+
return this.node<A.ArithExp>(s, this.pos, { type: 'ArithExp', expr: this.node<A.ArithNum>(s, this.pos, { type: 'ArithNum', text: '0' }) })
|
|
1147
|
+
}
|
|
1148
|
+
const expr = this.parseArithComma()
|
|
1149
|
+
this.skipArithSpace()
|
|
1150
|
+
if (!this.startsWith('))')) throw new Error('not arithmetic')
|
|
1151
|
+
this.pos += 2
|
|
1152
|
+
return this.node<A.ArithExp>(s, this.pos, { type: 'ArithExp', expr })
|
|
1153
|
+
} catch {
|
|
1154
|
+
this.pos = s
|
|
1155
|
+
this.pending.length = savePending
|
|
1156
|
+
if (close !== -1) {
|
|
1157
|
+
// balanced `$(( ... ))` that does not parse: an arithmetic syntax error at runtime, like bash
|
|
1158
|
+
return this.rawArith(s, this.pos + 3, close)
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
this.pos += 2
|
|
1163
|
+
const savedDq = this.dquoteDepth
|
|
1164
|
+
this.dquoteDepth = 0
|
|
1165
|
+
const body = this.parseList(new Set(), true)
|
|
1166
|
+
this.dquoteDepth = savedDq
|
|
1167
|
+
this.skipNewlines()
|
|
1168
|
+
if (this.peek() !== ')') this.error(`expected ')' to close command substitution`, s)
|
|
1169
|
+
this.pos++
|
|
1170
|
+
return this.node<A.CmdSubst>(s, this.pos, { type: 'CmdSubst', body, backquote: false })
|
|
1171
|
+
}
|
|
1172
|
+
if (n === '[') {
|
|
1173
|
+
// deprecated $[ expr ]
|
|
1174
|
+
const close = this.src.indexOf(']', this.pos + 2)
|
|
1175
|
+
if (close !== -1) {
|
|
1176
|
+
const save = this.pos
|
|
1177
|
+
try {
|
|
1178
|
+
this.pos += 2
|
|
1179
|
+
const expr = this.parseArithComma()
|
|
1180
|
+
this.skipArithSpace()
|
|
1181
|
+
if (this.peek() !== ']') throw new Error('not arithmetic')
|
|
1182
|
+
this.pos++
|
|
1183
|
+
return this.node<A.ArithExp>(s, this.pos, { type: 'ArithExp', expr })
|
|
1184
|
+
} catch {
|
|
1185
|
+
this.pos = save
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (n === '{') return this.parseBracedParam()
|
|
1190
|
+
if (n === "'" && !inDquote) return this.parseAnsiQuoted()
|
|
1191
|
+
if (n === '"' && !inDquote) {
|
|
1192
|
+
this.pos++
|
|
1193
|
+
return this.parseDquoted(true)
|
|
1194
|
+
}
|
|
1195
|
+
if (isNameStart(n)) {
|
|
1196
|
+
let e = this.pos + 2
|
|
1197
|
+
while (isNameChar(this.src[e])) e++
|
|
1198
|
+
const name = this.src.slice(this.pos + 1, e)
|
|
1199
|
+
this.pos = e
|
|
1200
|
+
return this.param(s, name, true)
|
|
1201
|
+
}
|
|
1202
|
+
if (/[0-9]/.test(n) || SPECIAL_PARAMS.has(n)) {
|
|
1203
|
+
this.pos += 2
|
|
1204
|
+
return this.param(s, n, true)
|
|
1205
|
+
}
|
|
1206
|
+
return null
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* If `from` starts arithmetic text whose parentheses balance up to a `))`,
|
|
1211
|
+
* the offset of that `))`; else -1. (bash decides `$((` vs `$( (` this way.)
|
|
1212
|
+
*/
|
|
1213
|
+
private matchingArithClose(from: number): number {
|
|
1214
|
+
let depth = 0
|
|
1215
|
+
for (let i = from; i < this.src.length; i++) {
|
|
1216
|
+
const c = this.src[i]
|
|
1217
|
+
if (c === '(') depth++
|
|
1218
|
+
else if (c === ')') {
|
|
1219
|
+
if (depth === 0) return this.src[i + 1] === ')' ? i : -1
|
|
1220
|
+
depth--
|
|
1221
|
+
} else if (c === '\n' && depth === 0) {
|
|
1222
|
+
// keep scanning: arithmetic may span lines
|
|
1223
|
+
} else if (c === "'" || c === '"' || c === '`') return -1
|
|
1224
|
+
}
|
|
1225
|
+
return -1
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** An arithmetic expansion kept as raw text (evaluated, and diagnosed, at runtime). */
|
|
1229
|
+
private rawArith(s: number, textStart: number, close: number): A.ArithExp {
|
|
1230
|
+
const expr = this.rawArithWord(textStart, close)
|
|
1231
|
+
this.pos = close + 2
|
|
1232
|
+
return this.node<A.ArithExp>(s, this.pos, { type: 'ArithExp', expr })
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** Raw arithmetic text as one word with `$`-expansions active (substituted textually, then parsed). */
|
|
1236
|
+
private rawArithWord(textStart: number, close: number): A.ArithWord {
|
|
1237
|
+
const sub = new Parser(this.src.slice(0, close), this.offset, this.ids, this.root)
|
|
1238
|
+
sub.pos = textStart
|
|
1239
|
+
const parts = sub.parseDquotedParts('heredoc')
|
|
1240
|
+
const word = this.node<A.Word>(textStart, close, { type: 'Word', parts })
|
|
1241
|
+
return this.node<A.ArithWord>(textStart, close, { type: 'ArithWord', word })
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
private param(s: number, name: string, short: boolean, extra: Partial<A.ParamExp> = {}): A.ParamExp {
|
|
1245
|
+
return this.node<A.ParamExp>(s, this.pos, {
|
|
1246
|
+
type: 'ParamExp', short, name, index: null, length: false, indirect: false, prefixNames: null, op: null, ...extra,
|
|
1247
|
+
})
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
private parseAnsiQuoted(): A.SglQuoted {
|
|
1251
|
+
const s = this.pos
|
|
1252
|
+
this.pos += 2
|
|
1253
|
+
let value = ''
|
|
1254
|
+
for (;;) {
|
|
1255
|
+
if (this.eof()) this.error('unterminated $\'', s)
|
|
1256
|
+
const c = this.peek()
|
|
1257
|
+
if (c === "'") {
|
|
1258
|
+
this.pos++
|
|
1259
|
+
break
|
|
1260
|
+
}
|
|
1261
|
+
if (c !== '\\') {
|
|
1262
|
+
value += c
|
|
1263
|
+
this.pos++
|
|
1264
|
+
continue
|
|
1265
|
+
}
|
|
1266
|
+
const e = this.peek(1)
|
|
1267
|
+
this.pos += 2
|
|
1268
|
+
switch (e) {
|
|
1269
|
+
case 'n': value += '\n'; break
|
|
1270
|
+
case 't': value += '\t'; break
|
|
1271
|
+
case 'r': value += '\r'; break
|
|
1272
|
+
case 'a': value += '\x07'; break
|
|
1273
|
+
case 'b': value += '\b'; break
|
|
1274
|
+
case 'e': case 'E': value += '\x1b'; break
|
|
1275
|
+
case 'f': value += '\f'; break
|
|
1276
|
+
case 'v': value += '\v'; break
|
|
1277
|
+
case '\\': value += '\\'; break
|
|
1278
|
+
case "'": value += "'"; break
|
|
1279
|
+
case '"': value += '"'; break
|
|
1280
|
+
case '?': value += '?'; break
|
|
1281
|
+
case 'c': {
|
|
1282
|
+
const ch = this.peek()
|
|
1283
|
+
this.pos++
|
|
1284
|
+
value += String.fromCharCode(ch.toUpperCase().charCodeAt(0) ^ 0x40)
|
|
1285
|
+
break
|
|
1286
|
+
}
|
|
1287
|
+
case 'x': case 'u': case 'U': {
|
|
1288
|
+
const max = e === 'x' ? 2 : e === 'u' ? 4 : 8
|
|
1289
|
+
const m = new RegExp(`^[0-9a-fA-F]{1,${max}}`).exec(this.src.slice(this.pos))
|
|
1290
|
+
if (!m) {
|
|
1291
|
+
value += '\\' + e
|
|
1292
|
+
break
|
|
1293
|
+
}
|
|
1294
|
+
this.pos += m[0].length
|
|
1295
|
+
value += String.fromCodePoint(parseInt(m[0], 16))
|
|
1296
|
+
break
|
|
1297
|
+
}
|
|
1298
|
+
default: {
|
|
1299
|
+
if (/[0-7]/.test(e)) {
|
|
1300
|
+
const m = /^[0-7]{0,2}/.exec(this.src.slice(this.pos))!
|
|
1301
|
+
this.pos += m[0].length
|
|
1302
|
+
value += String.fromCharCode(parseInt(e + m[0], 8) & 0xff)
|
|
1303
|
+
} else {
|
|
1304
|
+
value += '\\' + e
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
return this.node<A.SglQuoted>(s, this.pos, { type: 'SglQuoted', value, ansi: true })
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
private parseBracedParam(): A.ParamExp {
|
|
1313
|
+
const s = this.pos
|
|
1314
|
+
this.pos += 2 // ${
|
|
1315
|
+
let length = false
|
|
1316
|
+
let indirect = false
|
|
1317
|
+
const c0 = this.peek()
|
|
1318
|
+
if (c0 === '#' && this.peek(1) !== '}' && (isNameStart(this.peek(1)) || /[0-9@*#?$!-]/.test(this.peek(1)))) {
|
|
1319
|
+
// ${#x} — but ${#} is $#, and ${#-} etc are rare
|
|
1320
|
+
length = true
|
|
1321
|
+
this.pos++
|
|
1322
|
+
} else if (c0 === '!' && this.peek(1) !== '}' && (isNameStart(this.peek(1)) || /[0-9@*#?$-]/.test(this.peek(1)))) {
|
|
1323
|
+
indirect = true
|
|
1324
|
+
this.pos++
|
|
1325
|
+
}
|
|
1326
|
+
let name: string
|
|
1327
|
+
const c = this.peek()
|
|
1328
|
+
if (isNameStart(c)) {
|
|
1329
|
+
let e = this.pos + 1
|
|
1330
|
+
while (isNameChar(this.src[e])) e++
|
|
1331
|
+
name = this.src.slice(this.pos, e)
|
|
1332
|
+
this.pos = e
|
|
1333
|
+
} else if (/[0-9]/.test(c)) {
|
|
1334
|
+
let e = this.pos + 1
|
|
1335
|
+
while (/[0-9]/.test(this.src[e] ?? '')) e++
|
|
1336
|
+
name = this.src.slice(this.pos, e)
|
|
1337
|
+
this.pos = e
|
|
1338
|
+
} else if (SPECIAL_PARAMS.has(c)) {
|
|
1339
|
+
name = c
|
|
1340
|
+
this.pos++
|
|
1341
|
+
} else {
|
|
1342
|
+
this.error('bad substitution', s)
|
|
1343
|
+
}
|
|
1344
|
+
if (indirect && (this.peek() === '*' || this.peek() === '@') && this.peek(1) === '}') {
|
|
1345
|
+
const which = this.peek() as '*' | '@'
|
|
1346
|
+
this.pos += 2
|
|
1347
|
+
return this.param(s, name, false, { prefixNames: which })
|
|
1348
|
+
}
|
|
1349
|
+
let index: A.Word | null = null
|
|
1350
|
+
if (this.peek() === '[') {
|
|
1351
|
+
this.pos++
|
|
1352
|
+
index = this.parseWord((ch) => ch === ']', { allowBlanks: true }) ?? this.node<A.Word>(this.pos, this.pos, { type: 'Word', parts: [] })
|
|
1353
|
+
if (this.peek() !== ']') this.error(`expected ']'`)
|
|
1354
|
+
this.pos++
|
|
1355
|
+
}
|
|
1356
|
+
let op: A.ParamOp | null = null
|
|
1357
|
+
const braceStop: StopFn = (ch) => ch === '}'
|
|
1358
|
+
const wordUntilBrace = (): A.Word | null => this.parseWord(braceStop, { allowBlanks: true })
|
|
1359
|
+
// inside "...", single quotes in ${v:-'x'} are literal characters
|
|
1360
|
+
const defaultWord = (): A.Word | null => this.parseWord(braceStop, { allowBlanks: true, sglLiteral: this.dquoteDepth > 0 })
|
|
1361
|
+
const emptyWord = (): A.Word => this.node<A.Word>(this.pos, this.pos, { type: 'Word', parts: [] })
|
|
1362
|
+
const o = this.peek()
|
|
1363
|
+
if (o === '}') {
|
|
1364
|
+
// plain
|
|
1365
|
+
} else if (o === ':' && '-=?+'.includes(this.peek(1)) && this.peek(1) !== '') {
|
|
1366
|
+
const kind = ({ '-': 'default', '=': 'assign', '?': 'error', '+': 'alternative' } as const)[this.peek(1) as '-' | '=' | '?' | '+']
|
|
1367
|
+
this.pos += 2
|
|
1368
|
+
op = { kind, colon: true, word: defaultWord() }
|
|
1369
|
+
} else if ('-=?+'.includes(o)) {
|
|
1370
|
+
const kind = ({ '-': 'default', '=': 'assign', '?': 'error', '+': 'alternative' } as const)[o as '-' | '=' | '?' | '+']
|
|
1371
|
+
this.pos += 1
|
|
1372
|
+
op = { kind, colon: false, word: defaultWord() }
|
|
1373
|
+
} else if (o === ':') {
|
|
1374
|
+
this.pos++
|
|
1375
|
+
const offset = this.parseArithSegment([':', '}'])
|
|
1376
|
+
let len: A.ArithExpr | null = null
|
|
1377
|
+
if (this.peek() === ':') {
|
|
1378
|
+
this.pos++
|
|
1379
|
+
len = this.parseArithSegment(['}'])
|
|
1380
|
+
}
|
|
1381
|
+
op = { kind: 'substring', offset, length: len }
|
|
1382
|
+
} else if (o === '#' || o === '%') {
|
|
1383
|
+
const longest = this.peek(1) === o
|
|
1384
|
+
this.pos += longest ? 2 : 1
|
|
1385
|
+
op = { kind: o === '#' ? 'removePrefix' : 'removeSuffix', longest, pattern: wordUntilBrace() ?? emptyWord() }
|
|
1386
|
+
} else if (o === '/') {
|
|
1387
|
+
let mode: 'first' | 'all' | 'prefix' | 'suffix' = 'first'
|
|
1388
|
+
this.pos++
|
|
1389
|
+
if (this.peek() === '/') { mode = 'all'; this.pos++ } else if (this.peek() === '#') { mode = 'prefix'; this.pos++ } else if (this.peek() === '%') { mode = 'suffix'; this.pos++ }
|
|
1390
|
+
const pattern = this.parseWord((ch) => ch === '/' || ch === '}', { allowBlanks: true }) ?? emptyWord()
|
|
1391
|
+
let replacement: A.Word | null = null
|
|
1392
|
+
if (this.peek() === '/') {
|
|
1393
|
+
this.pos++
|
|
1394
|
+
replacement = wordUntilBrace() ?? emptyWord()
|
|
1395
|
+
}
|
|
1396
|
+
op = { kind: 'replace', mode, pattern, replacement }
|
|
1397
|
+
} else if (o === '^' || o === ',') {
|
|
1398
|
+
const all = this.peek(1) === o
|
|
1399
|
+
this.pos += all ? 2 : 1
|
|
1400
|
+
op = { kind: 'case', upper: o === '^', all, pattern: wordUntilBrace() }
|
|
1401
|
+
} else if (o === '@') {
|
|
1402
|
+
this.pos++
|
|
1403
|
+
const operator = this.peek()
|
|
1404
|
+
this.pos++
|
|
1405
|
+
op = { kind: 'transform', operator }
|
|
1406
|
+
} else {
|
|
1407
|
+
this.error('bad substitution', s)
|
|
1408
|
+
}
|
|
1409
|
+
if (this.peek() !== '}') this.error(`expected '}' to close parameter expansion`, s)
|
|
1410
|
+
this.pos++
|
|
1411
|
+
return this.param(s, name, false, { index, length, indirect, op })
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// -------------------------------------------------------------------------
|
|
1415
|
+
// [[ conditional ]]
|
|
1416
|
+
// -------------------------------------------------------------------------
|
|
1417
|
+
|
|
1418
|
+
private parseCondCommand(): A.CondCommand {
|
|
1419
|
+
const start = this.pos
|
|
1420
|
+
this.pos += 2
|
|
1421
|
+
const expr = this.parseCondOr()
|
|
1422
|
+
this.skipCondSpace()
|
|
1423
|
+
if (!this.startsWith(']]')) this.error(`expected ']]'`)
|
|
1424
|
+
this.pos += 2
|
|
1425
|
+
return this.node<A.CondCommand>(start, this.pos, { type: 'CondCommand', expr, redirs: [] })
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
private skipCondSpace(): void {
|
|
1429
|
+
for (;;) {
|
|
1430
|
+
this.skipBlanks()
|
|
1431
|
+
if (this.peek() === '\n') this.consumeNewline()
|
|
1432
|
+
else return
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
private atCondEnd(): boolean {
|
|
1437
|
+
return this.startsWith(']]') && (isMeta(this.peek(2)) || this.peek(2) === '')
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
private parseCondOr(): A.CondExpr {
|
|
1441
|
+
this.skipCondSpace()
|
|
1442
|
+
const start = this.pos
|
|
1443
|
+
let left = this.parseCondAnd()
|
|
1444
|
+
for (;;) {
|
|
1445
|
+
this.skipCondSpace()
|
|
1446
|
+
if (!this.startsWith('||')) return left
|
|
1447
|
+
this.pos += 2
|
|
1448
|
+
const right = this.parseCondAnd()
|
|
1449
|
+
left = this.node<A.CondAndOr>(start, this.pos, { type: 'CondAndOr', op: '||', left, right })
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
private parseCondAnd(): A.CondExpr {
|
|
1454
|
+
this.skipCondSpace()
|
|
1455
|
+
const start = this.pos
|
|
1456
|
+
let left = this.parseCondNot()
|
|
1457
|
+
for (;;) {
|
|
1458
|
+
this.skipCondSpace()
|
|
1459
|
+
if (!this.startsWith('&&')) return left
|
|
1460
|
+
this.pos += 2
|
|
1461
|
+
const right = this.parseCondNot()
|
|
1462
|
+
left = this.node<A.CondAndOr>(start, this.pos, { type: 'CondAndOr', op: '&&', left, right })
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
private parseCondNot(): A.CondExpr {
|
|
1467
|
+
this.skipCondSpace()
|
|
1468
|
+
const start = this.pos
|
|
1469
|
+
if (this.peek() === '!' && (isMeta(this.peek(1)) || this.peek(1) === '')) {
|
|
1470
|
+
this.pos++
|
|
1471
|
+
const operand = this.parseCondNot()
|
|
1472
|
+
return this.node<A.CondNot>(start, this.pos, { type: 'CondNot', operand })
|
|
1473
|
+
}
|
|
1474
|
+
return this.parseCondPrimary()
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
private condWord(): A.Word {
|
|
1478
|
+
const stop: StopFn = (c, p) => isBlank(c) || c === '\n' || c === '(' || c === ')' || c === '<' || c === '>' || c === ';'
|
|
1479
|
+
|| (c === '&' && p.peek(1) === '&') || (c === '|' && p.peek(1) === '|')
|
|
1480
|
+
const w = this.parseWord(stop)
|
|
1481
|
+
if (!w) this.error(this.eof() ? 'unexpected end of input in [[ ]]' : `unexpected '${this.peek()}' in [[ ]]`)
|
|
1482
|
+
return w
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
private regexWord(): A.Word {
|
|
1486
|
+
// RHS of =~: parens and | are part of the regex; blanks end it at depth 0.
|
|
1487
|
+
let depth = 0
|
|
1488
|
+
const stop: StopFn = (c, p) => {
|
|
1489
|
+
if (c === '(') { depth++; return false }
|
|
1490
|
+
if (c === ')') {
|
|
1491
|
+
if (depth === 0) return true
|
|
1492
|
+
depth--
|
|
1493
|
+
return false
|
|
1494
|
+
}
|
|
1495
|
+
if (depth > 0) return c === '\n'
|
|
1496
|
+
return isBlank(c) || c === '\n' || (c === '&' && p.peek(1) === '&') || (c === '|' && p.peek(1) === '|')
|
|
1497
|
+
}
|
|
1498
|
+
const w = this.parseWord(stop)
|
|
1499
|
+
if (!w) this.error('expected a regular expression after =~')
|
|
1500
|
+
return w
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
private parseCondPrimary(): A.CondExpr {
|
|
1504
|
+
this.skipCondSpace()
|
|
1505
|
+
const start = this.pos
|
|
1506
|
+
if (this.peek() === '(') {
|
|
1507
|
+
this.pos++
|
|
1508
|
+
const expr = this.parseCondOr()
|
|
1509
|
+
this.skipCondSpace()
|
|
1510
|
+
if (this.peek() !== ')') this.error(`expected ')' in [[ ]]`)
|
|
1511
|
+
this.pos++
|
|
1512
|
+
return this.node<A.CondGroup>(start, this.pos, { type: 'CondGroup', expr })
|
|
1513
|
+
}
|
|
1514
|
+
if (this.atCondEnd()) this.error('expected an expression in [[ ]]')
|
|
1515
|
+
const first = this.condWord()
|
|
1516
|
+
const firstLit = literalOf(first)
|
|
1517
|
+
this.skipBlanks()
|
|
1518
|
+
if (firstLit !== null && COND_UNARY.has(firstLit) && !this.atCondEnd() && !this.startsWith('&&') && !this.startsWith('||') && this.peek() !== ')') {
|
|
1519
|
+
// unary unless followed by a binary operator
|
|
1520
|
+
const save = this.pos
|
|
1521
|
+
const opm = this.peekCondBinaryOp()
|
|
1522
|
+
if (opm === null) {
|
|
1523
|
+
const operand = this.condWord()
|
|
1524
|
+
return this.node<A.CondUnary>(start, this.pos, { type: 'CondUnary', op: firstLit, operand })
|
|
1525
|
+
}
|
|
1526
|
+
this.pos = save
|
|
1527
|
+
}
|
|
1528
|
+
const op = this.peekCondBinaryOp()
|
|
1529
|
+
if (op !== null) {
|
|
1530
|
+
this.pos += op.length
|
|
1531
|
+
this.skipBlanks()
|
|
1532
|
+
const right = op === '=~' ? this.regexWord() : this.condWord()
|
|
1533
|
+
return this.node<A.CondBinary>(start, this.pos, { type: 'CondBinary', op, left: first, right })
|
|
1534
|
+
}
|
|
1535
|
+
return this.node<A.CondWord>(start, this.pos, { type: 'CondWord', word: first })
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
private peekCondBinaryOp(): string | null {
|
|
1539
|
+
const m = /^(==|=~|!=|=|<|>|-(?:eq|ne|lt|le|gt|ge|nt|ot|ef))/.exec(this.src.slice(this.pos, this.pos + 4))
|
|
1540
|
+
if (!m) return null
|
|
1541
|
+
const after = this.src[this.pos + m[0].length]
|
|
1542
|
+
if (m[0] === '<' || m[0] === '>') return m[0]
|
|
1543
|
+
if (after !== undefined && !isBlank(after) && after !== '\n') return null
|
|
1544
|
+
return COND_BINARY.has(m[0]) ? m[0] : null
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// -------------------------------------------------------------------------
|
|
1548
|
+
// Arithmetic
|
|
1549
|
+
// -------------------------------------------------------------------------
|
|
1550
|
+
|
|
1551
|
+
private skipArithSpace(): void {
|
|
1552
|
+
for (;;) {
|
|
1553
|
+
const c = this.peek()
|
|
1554
|
+
if (c === ' ' || c === '\t' || c === '\r') this.pos++
|
|
1555
|
+
else if (c === '\n') this.consumeNewline()
|
|
1556
|
+
else if (c === '\\' && this.peek(1) === '\n') this.pos += 2
|
|
1557
|
+
else return
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/** Arithmetic inside `${x:off:len}`: parse up to one of `terminators`. */
|
|
1562
|
+
private parseArithSegment(terminators: string[]): A.ArithExpr {
|
|
1563
|
+
const start = this.pos
|
|
1564
|
+
this.skipArithSpace()
|
|
1565
|
+
if (terminators.includes(this.peek())) {
|
|
1566
|
+
// empty offset/length means 0
|
|
1567
|
+
return this.node<A.ArithNum>(start, this.pos, { type: 'ArithNum', text: '0' })
|
|
1568
|
+
}
|
|
1569
|
+
const e = this.parseArithTernary()
|
|
1570
|
+
this.skipArithSpace()
|
|
1571
|
+
if (!terminators.includes(this.peek())) this.error(`unexpected '${this.peek()}' in substring expansion`)
|
|
1572
|
+
return e
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
parseArithComma(): A.ArithExpr {
|
|
1576
|
+
this.skipArithSpace()
|
|
1577
|
+
const start = this.pos
|
|
1578
|
+
let left = this.parseArithAssign()
|
|
1579
|
+
for (;;) {
|
|
1580
|
+
this.skipArithSpace()
|
|
1581
|
+
if (this.peek() !== ',') return left
|
|
1582
|
+
this.pos++
|
|
1583
|
+
const right = this.parseArithAssign()
|
|
1584
|
+
left = this.node<A.ArithBinary>(start, this.pos, { type: 'ArithBinary', op: ',', left, right })
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
private parseArithAssign(): A.ArithExpr {
|
|
1589
|
+
this.skipArithSpace()
|
|
1590
|
+
const start = this.pos
|
|
1591
|
+
const left = this.parseArithTernary()
|
|
1592
|
+
this.skipArithSpace()
|
|
1593
|
+
const m = /^(<<=|>>=|\*=|\/=|%=|\+=|-=|&=|\^=|\|=|=(?!=))/.exec(this.src.slice(this.pos, this.pos + 3))
|
|
1594
|
+
if (m) {
|
|
1595
|
+
if (left.type !== 'ArithVar') this.error('attempted assignment to non-variable')
|
|
1596
|
+
this.pos += m[0].length
|
|
1597
|
+
const value = this.parseArithAssign()
|
|
1598
|
+
return this.node<A.ArithAssign>(start, this.pos, { type: 'ArithAssign', op: m[0] as A.ArithAssign['op'], target: left, value })
|
|
1599
|
+
}
|
|
1600
|
+
return left
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
private parseArithTernary(): A.ArithExpr {
|
|
1604
|
+
this.skipArithSpace()
|
|
1605
|
+
const start = this.pos
|
|
1606
|
+
const cond = this.parseArithBinary(0)
|
|
1607
|
+
this.skipArithSpace()
|
|
1608
|
+
if (this.peek() !== '?') return cond
|
|
1609
|
+
this.pos++
|
|
1610
|
+
const then = this.parseArithAssign()
|
|
1611
|
+
this.skipArithSpace()
|
|
1612
|
+
if (this.peek() !== ':') this.error(`expected ':' in ternary`)
|
|
1613
|
+
this.pos++
|
|
1614
|
+
const els = this.parseArithAssign()
|
|
1615
|
+
return this.node<A.ArithTernary>(start, this.pos, { type: 'ArithTernary', cond, then, else: els })
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
private static readonly BIN_LEVELS: string[][] = [
|
|
1619
|
+
['||'], ['&&'], ['|'], ['^'], ['&'], ['==', '!='], ['<=', '>=', '<', '>'], ['<<', '>>'], ['+', '-'], ['*', '/', '%'],
|
|
1620
|
+
]
|
|
1621
|
+
|
|
1622
|
+
private matchArithOp(level: number): string | null {
|
|
1623
|
+
for (const op of Parser.BIN_LEVELS[level]) {
|
|
1624
|
+
if (!this.startsWith(op)) continue
|
|
1625
|
+
const next = this.src[this.pos + op.length]
|
|
1626
|
+
// don't confuse with longer operators / assignments
|
|
1627
|
+
if (op === '|' && (next === '|' || next === '=')) continue
|
|
1628
|
+
if (op === '&' && (next === '&' || next === '=')) continue
|
|
1629
|
+
if ((op === '<' || op === '>') && (next === op || next === '=')) continue
|
|
1630
|
+
if ((op === '<<' || op === '>>') && next === '=') continue
|
|
1631
|
+
if ((op === '+' || op === '-') && (next === op || next === '=')) continue
|
|
1632
|
+
if (op === '*' && (next === '*' || next === '=')) continue
|
|
1633
|
+
if ((op === '/' || op === '%' || op === '^') && next === '=') continue
|
|
1634
|
+
return op
|
|
1635
|
+
}
|
|
1636
|
+
return null
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
private parseArithBinary(level: number): A.ArithExpr {
|
|
1640
|
+
if (level >= Parser.BIN_LEVELS.length) return this.parseArithPower()
|
|
1641
|
+
this.skipArithSpace()
|
|
1642
|
+
const start = this.pos
|
|
1643
|
+
let left = this.parseArithBinary(level + 1)
|
|
1644
|
+
for (;;) {
|
|
1645
|
+
this.skipArithSpace()
|
|
1646
|
+
const op = this.matchArithOp(level)
|
|
1647
|
+
if (op === null) return left
|
|
1648
|
+
this.pos += op.length
|
|
1649
|
+
const right = this.parseArithBinary(level + 1)
|
|
1650
|
+
left = this.node<A.ArithBinary>(start, this.pos, { type: 'ArithBinary', op: op as A.ArithBinaryOp, left, right })
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
private parseArithPower(): A.ArithExpr {
|
|
1655
|
+
this.skipArithSpace()
|
|
1656
|
+
const start = this.pos
|
|
1657
|
+
const base = this.parseArithUnary()
|
|
1658
|
+
this.skipArithSpace()
|
|
1659
|
+
if (this.startsWith('**') && this.peek(2) !== '=') {
|
|
1660
|
+
this.pos += 2
|
|
1661
|
+
const exp = this.parseArithPower()
|
|
1662
|
+
return this.node<A.ArithBinary>(start, this.pos, { type: 'ArithBinary', op: '**', left: base, right: exp })
|
|
1663
|
+
}
|
|
1664
|
+
return base
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
private parseArithUnary(): A.ArithExpr {
|
|
1668
|
+
this.skipArithSpace()
|
|
1669
|
+
const start = this.pos
|
|
1670
|
+
if (this.startsWith('++') || this.startsWith('--')) {
|
|
1671
|
+
const op = this.src.slice(this.pos, this.pos + 2) as '++' | '--'
|
|
1672
|
+
this.pos += 2
|
|
1673
|
+
this.skipArithSpace()
|
|
1674
|
+
const target = this.parseArithPostfix()
|
|
1675
|
+
if (target.type !== 'ArithVar') this.error(`${op} requires a variable`)
|
|
1676
|
+
return this.node<A.ArithIncDec>(start, this.pos, { type: 'ArithIncDec', op, prefix: true, target })
|
|
1677
|
+
}
|
|
1678
|
+
const c = this.peek()
|
|
1679
|
+
if (c === '-' || c === '+' || c === '!' || c === '~') {
|
|
1680
|
+
this.pos++
|
|
1681
|
+
const operand = this.parseArithUnary()
|
|
1682
|
+
return this.node<A.ArithUnary>(start, this.pos, { type: 'ArithUnary', op: c, operand })
|
|
1683
|
+
}
|
|
1684
|
+
return this.parseArithPostfix()
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
private parseArithPostfix(): A.ArithExpr {
|
|
1688
|
+
const start = this.pos
|
|
1689
|
+
const primary = this.parseArithPrimary()
|
|
1690
|
+
if (primary.type === 'ArithVar') {
|
|
1691
|
+
const save = this.pos
|
|
1692
|
+
this.skipArithSpace()
|
|
1693
|
+
if (this.startsWith('++') || this.startsWith('--')) {
|
|
1694
|
+
const op = this.src.slice(this.pos, this.pos + 2) as '++' | '--'
|
|
1695
|
+
this.pos += 2
|
|
1696
|
+
return this.node<A.ArithIncDec>(start, this.pos, { type: 'ArithIncDec', op, prefix: false, target: primary })
|
|
1697
|
+
}
|
|
1698
|
+
this.pos = save
|
|
1699
|
+
}
|
|
1700
|
+
return primary
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
private parseArithPrimary(): A.ArithExpr {
|
|
1704
|
+
this.skipArithSpace()
|
|
1705
|
+
const start = this.pos
|
|
1706
|
+
const c = this.peek()
|
|
1707
|
+
if (c === '(') {
|
|
1708
|
+
this.pos++
|
|
1709
|
+
const expr = this.parseArithComma()
|
|
1710
|
+
this.skipArithSpace()
|
|
1711
|
+
if (this.peek() !== ')') this.error(`expected ')' in arithmetic`)
|
|
1712
|
+
this.pos++
|
|
1713
|
+
return this.node<A.ArithGroup>(start, this.pos, { type: 'ArithGroup', expr })
|
|
1714
|
+
}
|
|
1715
|
+
if (/[0-9]/.test(c)) {
|
|
1716
|
+
const based = /^([0-9]+#)(?=[$`])/.exec(this.src.slice(this.pos, this.pos + 8))
|
|
1717
|
+
if (based) {
|
|
1718
|
+
// `10#$n`: base prefix glued to an expansion — kept as one word, evaluated textually
|
|
1719
|
+
const litEnd = this.pos + based[1].length
|
|
1720
|
+
const lit = this.node<A.Lit>(this.pos, litEnd, { type: 'Lit', value: based[1] })
|
|
1721
|
+
this.pos = litEnd
|
|
1722
|
+
const d = this.peek() === '$' ? this.parseDollar(false) : this.parseBackquote()
|
|
1723
|
+
if (!d) this.error('bad base expression')
|
|
1724
|
+
const word = this.node<A.Word>(start, this.pos, { type: 'Word', parts: [lit, d] })
|
|
1725
|
+
return this.node<A.ArithWord>(start, this.pos, { type: 'ArithWord', word })
|
|
1726
|
+
}
|
|
1727
|
+
const m = /^(0[xX][0-9a-fA-F]+|[0-9]+#[0-9a-zA-Z@_]+|[0-9]+)/.exec(this.src.slice(this.pos))!
|
|
1728
|
+
this.pos += m[0].length
|
|
1729
|
+
return this.node<A.ArithNum>(start, this.pos, { type: 'ArithNum', text: m[0] })
|
|
1730
|
+
}
|
|
1731
|
+
if (isNameStart(c)) {
|
|
1732
|
+
let e = this.pos + 1
|
|
1733
|
+
while (isNameChar(this.src[e])) e++
|
|
1734
|
+
const name = this.src.slice(this.pos, e)
|
|
1735
|
+
this.pos = e
|
|
1736
|
+
let index: A.ArithExpr | null = null
|
|
1737
|
+
if (this.peek() === '[') {
|
|
1738
|
+
this.pos++
|
|
1739
|
+
index = this.parseArithComma()
|
|
1740
|
+
this.skipArithSpace()
|
|
1741
|
+
if (this.peek() !== ']') this.error(`expected ']' in arithmetic`)
|
|
1742
|
+
this.pos++
|
|
1743
|
+
}
|
|
1744
|
+
return this.node<A.ArithVar>(start, this.pos, { type: 'ArithVar', name, index })
|
|
1745
|
+
}
|
|
1746
|
+
if (c === '$' || c === '`' || c === '"' || c === "'") {
|
|
1747
|
+
// A word: $x, ${x}, $(cmd), `cmd`, "..."
|
|
1748
|
+
const parts: A.WordPart[] = []
|
|
1749
|
+
for (;;) {
|
|
1750
|
+
const ch = this.peek()
|
|
1751
|
+
if (ch === '$') {
|
|
1752
|
+
const d = this.parseDollar(false)
|
|
1753
|
+
if (!d) break
|
|
1754
|
+
parts.push(d)
|
|
1755
|
+
} else if (ch === '`') {
|
|
1756
|
+
parts.push(this.parseBackquote())
|
|
1757
|
+
} else if (ch === '"') {
|
|
1758
|
+
parts.push(this.parseDquoted(false))
|
|
1759
|
+
} else if (ch === "'") {
|
|
1760
|
+
const close = this.src.indexOf("'", this.pos + 1)
|
|
1761
|
+
if (close === -1) this.error('unterminated quote')
|
|
1762
|
+
const s = this.pos
|
|
1763
|
+
this.pos = close + 1
|
|
1764
|
+
parts.push(this.node<A.SglQuoted>(s, this.pos, { type: 'SglQuoted', value: this.src.slice(s + 1, close), ansi: false }))
|
|
1765
|
+
} else if (isNameChar(ch) && parts.length > 0) {
|
|
1766
|
+
// $x1 style continuation (e.g. ${a}b)
|
|
1767
|
+
const s = this.pos
|
|
1768
|
+
let e = this.pos
|
|
1769
|
+
while (isNameChar(this.src[e])) e++
|
|
1770
|
+
this.pos = e
|
|
1771
|
+
parts.push(this.node<A.Lit>(s, e, { type: 'Lit', value: this.src.slice(s, e) }))
|
|
1772
|
+
} else break
|
|
1773
|
+
}
|
|
1774
|
+
if (parts.length === 0) this.error(`unexpected '${c}' in arithmetic`)
|
|
1775
|
+
const word = this.node<A.Word>(start, this.pos, { type: 'Word', parts })
|
|
1776
|
+
return this.node<A.ArithWord>(start, this.pos, { type: 'ArithWord', word })
|
|
1777
|
+
}
|
|
1778
|
+
this.error(this.eof() ? 'unexpected end of arithmetic expression' : `unexpected '${c}' in arithmetic expression`)
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/** The literal value of a word if it contains only unquoted literal text. */
|
|
1783
|
+
export function literalOf(w: A.Word): string | null {
|
|
1784
|
+
let s = ''
|
|
1785
|
+
for (const p of w.parts) {
|
|
1786
|
+
if (p.type === 'Lit') s += p.value
|
|
1787
|
+
else return null
|
|
1788
|
+
}
|
|
1789
|
+
return s
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* The static value of a word if it involves no expansions (quotes allowed),
|
|
1794
|
+
* e.g. `'a b'"c"` → `a bc`. Glob characters in unquoted parts are kept as-is.
|
|
1795
|
+
*/
|
|
1796
|
+
export function staticWordValue(w: A.Word): string | null {
|
|
1797
|
+
let s = ''
|
|
1798
|
+
for (const p of w.parts) {
|
|
1799
|
+
switch (p.type) {
|
|
1800
|
+
case 'Lit':
|
|
1801
|
+
case 'SglQuoted':
|
|
1802
|
+
case 'Escaped':
|
|
1803
|
+
s += p.value
|
|
1804
|
+
break
|
|
1805
|
+
case 'DblQuoted':
|
|
1806
|
+
for (const q of p.parts) {
|
|
1807
|
+
if (q.type === 'Lit') s += q.value
|
|
1808
|
+
else return null
|
|
1809
|
+
}
|
|
1810
|
+
break
|
|
1811
|
+
default:
|
|
1812
|
+
return null
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
return s
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/** The `parseArithmetic` entry point used by the VM to re-parse textually expanded arithmetic. */
|
|
1819
|
+
export function parseArithmetic(text: string, firstId = 1_000_000_000): A.ArithExpr {
|
|
1820
|
+
const p = new Parser(text, 0, { next: firstId }, text)
|
|
1821
|
+
const expr = p.parseArithComma()
|
|
1822
|
+
// ensure everything consumed
|
|
1823
|
+
const rest = text.slice(p.pos).trim()
|
|
1824
|
+
if (rest !== '') throw new ParseError(`syntax error in expression (error token is "${rest}")`, text, p.pos)
|
|
1825
|
+
return expr
|
|
1826
|
+
}
|