shell surfaces in-tree. Roadmap / CI - docs/data/shell-roadmap-features.json: all shell-001…shell-100 rows and P1–P8 phases marked implemented; note documents closure date and pointers. - scripts/verify-shell-roadmap.mjs: validate JSON (schema 1, 8 phases, 100 implemented items) plus existing source needles; wire npm run verify:shell-roadmap into root pretest (package.json). - scripts/README.md: document verifier behavior. Lexer & expansion (packages/bare-os-booter/lib) - shell-lex.js: central lexShellLine; ANSI-C $'…' via decodeBareOsDollarQuote; keep diagnostics/tokenizer aligned with execution tokenizer. - shell.js: stray reserved words at statement start → syntax error exit 2; optional [[ … ]] when BARE_OS_SHELL_DOUBLE_BRACKET=1 (==, !=); alias expansion before function dispatch (ordering tests); expandWordWithCmdSubst: balanced $(…) vs skipped $((…)); backtick command substitution when BARE_OS_SHELL_CMDSUBST; default ctx.execLine for nested cmdsubst when unset; index passthrough for DOUBLE_BRACKET env. - shell-glob.js: ~login → HOME when USER matches, else /home/login (bounded login pattern); tests for pathname + execShellLine. - shell-tokenizer.js: align with shell-lex detailed spans/modes where needed. Completion / REPL - completion-engine.js: completion depth / collectors per shell program work. - packages/bare-os-booter/index.js: small wiring for shell env passthrough. /bin/sh front-end - packages/bare-os-coreutils/src/sh.js; kernel/bin/sh; seeder copies: stay in sync with shell behavior and env flags. Tests - packages/bare-os-booter/test.js: coverage for misplaced reserved words, gated [[ ]], alias vs function, ~user/~~ paths, $'…', cmdsubst $(…) and backticks, and related regressions. Documentation - docs/reference/shell-grammar.md: $'…', $(…) / backticks vs $((…)). - handbook/09-posix-utilities-shell-and-vfs.md, environment appendix, shell-troubleshooting / shell-unsupported-behavior, release checklist, docs/reference/README.md: shell behavior and operator surfaces. - developer-guide/19-how-to-fish-keybinding-completer.md: Fish keybinding / completer how-to (new). Generated / synced artifacts - kernel/lib/bare/manifest.json, kernel/share/man/man.json, kernel/lib/bare/shell-completion.json, posix_utilities.json, docs/audit/bundle-health.json: regenerated or synced with tooling. - scripts/bench-shell-phases.mjs: bench script touch.
343 lines
8.1 KiB
JavaScript
343 lines
8.1 KiB
JavaScript
/**
|
|
* Shell lexical analysis (operators + words) shared by {@link ./shell.js} and diagnostics.
|
|
*/
|
|
|
|
/**
|
|
* @param {string} s
|
|
* @param {number} start
|
|
*/
|
|
export function findArithmeticClose(s, start) {
|
|
let depth = 0
|
|
for (let i = start; i < s.length - 1; i++) {
|
|
const ch = s[i]
|
|
if (ch === '(') depth++
|
|
else if (ch === ')') {
|
|
if (depth > 0) depth--
|
|
else if (s[i + 1] === ')') return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
/**
|
|
* POSIX-style ANSI strings for `$'…'` (lexer-only; emitted as single-quoted parts).
|
|
* @param {string} raw bytes between quotes after `$'`, still containing backslashes
|
|
*/
|
|
export function decodeBareOsDollarQuote(raw) {
|
|
let out = ''
|
|
let j = 0
|
|
while (j < raw.length) {
|
|
if (raw[j] !== '\\') {
|
|
out += raw[j++]
|
|
continue
|
|
}
|
|
j++
|
|
if (j >= raw.length) break
|
|
const e = raw[j++]
|
|
switch (e) {
|
|
case 'n':
|
|
out += '\n'
|
|
break
|
|
case 'r':
|
|
out += '\r'
|
|
break
|
|
case 't':
|
|
out += '\t'
|
|
break
|
|
case 'a':
|
|
out += '\x07'
|
|
break
|
|
case 'b':
|
|
out += '\b'
|
|
break
|
|
case 'f':
|
|
out += '\f'
|
|
break
|
|
case 'v':
|
|
out += '\v'
|
|
break
|
|
case '\\':
|
|
out += '\\'
|
|
break
|
|
case "'":
|
|
out += "'"
|
|
break
|
|
case 'x': {
|
|
let hex = ''
|
|
while (j < raw.length && /[0-9a-fA-F]/.test(raw[j]) && hex.length < 2)
|
|
hex += raw[j++]
|
|
if (hex.length)
|
|
out += String.fromCharCode(Number.parseInt(hex, 16) || 0)
|
|
break
|
|
}
|
|
default:
|
|
out += e
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* @typedef {{ q: 'u' | 's' | 'd', t: string }} ShellWordPart
|
|
*/
|
|
|
|
/**
|
|
* @typedef {{
|
|
* type: 'word',
|
|
* value: string,
|
|
* parts: ShellWordPart[],
|
|
* start?: number,
|
|
* end?: number
|
|
* } | {
|
|
* type: 'op',
|
|
* value: string,
|
|
* start?: number,
|
|
* end?: number
|
|
* }} ShellLexToken
|
|
*/
|
|
|
|
/**
|
|
* Tokenize one shell line into words and operators (POSIX-ish).
|
|
* @param {string} line
|
|
* @returns {ShellLexToken[]}
|
|
*/
|
|
export function lexShellLine(line) {
|
|
/** @type {ShellLexToken[]} */
|
|
const tokens = []
|
|
let i = 0
|
|
|
|
const skipWs = () => {
|
|
while (i < line.length && /\s/.test(line[i])) i++
|
|
}
|
|
|
|
while (i < line.length) {
|
|
skipWs()
|
|
if (i >= line.length) break
|
|
|
|
const c = line[i]
|
|
const opStart = i
|
|
if (c === ';') {
|
|
if (line[i + 1] === ';') {
|
|
tokens.push({ type: 'op', value: ';;', start: opStart, end: i + 2 })
|
|
i += 2
|
|
} else if (line[i + 1] === '&') {
|
|
tokens.push({ type: 'op', value: ';&', start: opStart, end: i + 2 })
|
|
i += 2
|
|
} else {
|
|
tokens.push({ type: 'op', value: ';', start: opStart, end: i + 1 })
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
if (c === '&') {
|
|
if (line[i + 1] === '&') {
|
|
tokens.push({ type: 'op', value: '&&', start: opStart, end: i + 2 })
|
|
i += 2
|
|
} else {
|
|
tokens.push({ type: 'op', value: '&', start: opStart, end: i + 1 })
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
if (c === '|') {
|
|
if (line[i + 1] === '|') {
|
|
tokens.push({ type: 'op', value: '||', start: opStart, end: i + 2 })
|
|
i += 2
|
|
} else if (line[i + 1] === '&') {
|
|
tokens.push({ type: 'op', value: '|&', start: opStart, end: i + 2 })
|
|
i += 2
|
|
} else {
|
|
tokens.push({ type: 'op', value: '|', start: opStart, end: i + 1 })
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
if (c === '2' && line[i + 1] === '>') {
|
|
if (line[i + 2] === '>') {
|
|
tokens.push({ type: 'op', value: '2>>', start: opStart, end: i + 3 })
|
|
i += 3
|
|
continue
|
|
}
|
|
if (line[i + 2] === '&' && line[i + 3] === '1') {
|
|
tokens.push({ type: 'op', value: '2>&1', start: opStart, end: i + 4 })
|
|
i += 4
|
|
continue
|
|
}
|
|
tokens.push({ type: 'op', value: '2>', start: opStart, end: i + 2 })
|
|
i += 2
|
|
continue
|
|
}
|
|
if (c === '>') {
|
|
if (line[i + 1] === '>') {
|
|
tokens.push({ type: 'op', value: '>>', start: opStart, end: i + 2 })
|
|
i += 2
|
|
} else if (line[i + 1] === '&') {
|
|
tokens.push({ type: 'op', value: '>&', start: opStart, end: i + 2 })
|
|
i += 2
|
|
} else {
|
|
tokens.push({ type: 'op', value: '>', start: opStart, end: i + 1 })
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
if (c === '(') {
|
|
tokens.push({ type: 'op', value: '(', start: opStart, end: i + 1 })
|
|
i++
|
|
continue
|
|
}
|
|
if (c === '{') {
|
|
tokens.push({ type: 'op', value: '{', start: opStart, end: i + 1 })
|
|
i++
|
|
continue
|
|
}
|
|
if (c === ')') {
|
|
tokens.push({ type: 'op', value: ')', start: opStart, end: i + 1 })
|
|
i++
|
|
continue
|
|
}
|
|
if (c === '}') {
|
|
tokens.push({ type: 'op', value: '}', start: opStart, end: i + 1 })
|
|
i++
|
|
continue
|
|
}
|
|
if (c === '<') {
|
|
if (line[i + 1] === '<' && line[i + 2] === '<') {
|
|
tokens.push({ type: 'op', value: '<<<', start: opStart, end: i + 3 })
|
|
i += 3
|
|
continue
|
|
}
|
|
if (line[i + 1] === '<' && line[i + 2] === '-') {
|
|
tokens.push({ type: 'op', value: '<<-', start: opStart, end: i + 3 })
|
|
i += 3
|
|
continue
|
|
}
|
|
if (line[i + 1] === '<') {
|
|
tokens.push({ type: 'op', value: '<<', start: opStart, end: i + 2 })
|
|
i += 2
|
|
continue
|
|
}
|
|
tokens.push({ type: 'op', value: '<', start: opStart, end: i + 1 })
|
|
i++
|
|
continue
|
|
}
|
|
|
|
const wordStart = i
|
|
/** @type {ShellWordPart[]} */
|
|
const parts = []
|
|
/** @type {{ q: 'u', t: string }} */
|
|
let cur = { q: 'u', t: '' }
|
|
const flushU = () => {
|
|
if (cur.t.length) {
|
|
parts.push(cur)
|
|
cur = { q: 'u', t: '' }
|
|
}
|
|
}
|
|
|
|
while (i < line.length) {
|
|
const ch = line[i]
|
|
if (ch === '$' && line[i + 1] === "'") {
|
|
flushU()
|
|
i += 2
|
|
let raw = ''
|
|
while (i < line.length) {
|
|
if (line[i] === '\\') {
|
|
raw += '\\'
|
|
i++
|
|
if (i < line.length) raw += line[i++]
|
|
continue
|
|
}
|
|
if (line[i] === "'") break
|
|
raw += line[i++]
|
|
}
|
|
if (i < line.length) i++
|
|
parts.push({ q: 's', t: decodeBareOsDollarQuote(raw) })
|
|
continue
|
|
}
|
|
if (ch === '\\') {
|
|
i++
|
|
if (i < line.length) cur.t += line[i++]
|
|
continue
|
|
}
|
|
if (ch === "'") {
|
|
flushU()
|
|
i++
|
|
let inner = ''
|
|
while (i < line.length && line[i] !== "'") inner += line[i++]
|
|
if (i < line.length) i++
|
|
parts.push({ q: 's', t: inner })
|
|
continue
|
|
}
|
|
if (ch === '"') {
|
|
flushU()
|
|
i++
|
|
let inner = ''
|
|
while (i < line.length && line[i] !== '"') {
|
|
if (
|
|
line[i] === '$' &&
|
|
line[i + 1] === '(' &&
|
|
line[i + 2] === '('
|
|
) {
|
|
const close = findArithmeticClose(line, i + 3)
|
|
if (close < 0) {
|
|
inner += line.slice(i)
|
|
i = line.length
|
|
break
|
|
}
|
|
inner += line.slice(i, close + 2)
|
|
i = close + 2
|
|
continue
|
|
}
|
|
if (line[i] === '\\' && i + 1 < line.length) {
|
|
i++
|
|
inner += line[i++]
|
|
continue
|
|
}
|
|
inner += line[i++]
|
|
}
|
|
if (i < line.length) i++
|
|
parts.push({ q: 'd', t: inner })
|
|
continue
|
|
}
|
|
if (ch === '$' && line[i + 1] === '(' && line[i + 2] === '(') {
|
|
const close = findArithmeticClose(line, i + 3)
|
|
if (close < 0) {
|
|
cur.t += line.slice(i)
|
|
i = line.length
|
|
break
|
|
}
|
|
cur.t += line.slice(i, close + 2)
|
|
i = close + 2
|
|
continue
|
|
}
|
|
if (
|
|
/\s/.test(ch) ||
|
|
ch === '|' ||
|
|
ch === '>' ||
|
|
ch === '<' ||
|
|
ch === ';' ||
|
|
ch === '&' ||
|
|
ch === '(' ||
|
|
ch === ')' ||
|
|
ch === '{' ||
|
|
ch === '}'
|
|
)
|
|
break
|
|
cur.t += ch
|
|
i++
|
|
}
|
|
flushU()
|
|
if (parts.length) {
|
|
const value = parts.map((p) => p.t).join('')
|
|
tokens.push({
|
|
type: 'word',
|
|
value,
|
|
parts,
|
|
start: wordStart,
|
|
end: i
|
|
})
|
|
}
|
|
}
|
|
|
|
return tokens
|
|
}
|