Files
bare-operating-system/packages/bare-os-booter/lib/shell-lex.js
T
Raven Scott aa81fbff5b Close the BareOS shell + Fish REPL roadmap tracker and ship the remaining
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.
2026-04-27 00:41:06 -04:00

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
}