Files
bare-operating-system/packages/bare-os-booter/lib/shell/shell-stmt.js
T
2026-08-18 18:11:28 -04:00

250 lines
6.9 KiB
JavaScript

/**
* Small statement helpers: local/declare, limited `[[ ]]`, reserved-word
* diagnostics, function positional env, and `case` pattern lists.
*/
import { expandWord } from './shell-expand.js'
/**
* @typedef {{ type: string, value: string }} ShellStmtToken
*/
/**
* @param {Record<string, unknown>} ctx
* @param {ShellStmtToken[]} rest
* @returns {Promise<'exit' | 'ok'>}
*/
export async function execShellLocalBuiltin(ctx, rest) {
const vfs = ctx.vfs
const env = vfs?.env
if (!env || typeof env !== 'object') {
ctx.exitCode = 0
return 'ok'
}
let n = 0
for (const t of rest) {
if (++n > 48) break
if (t.type !== 'word') continue
const eq = t.value.indexOf('=')
if (eq <= 0) continue
const name = t.value.slice(0, eq).trim()
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue
env[name] = expandWord(t.value.slice(eq + 1), env)
}
ctx.exitCode = 0
return 'ok'
}
/**
* @param {Record<string, unknown>} ctx
* @param {ShellStmtToken[]} rest
* @returns {'ok' | null}
*/
export function tryExecShellDeclareBuiltin(ctx, rest) {
const vfs = ctx.vfs
const env = vfs?.env
if (!env || typeof env !== 'object') return 'ok'
if (rest[0]?.type !== 'word' || rest[0].value !== '-r') return null
let n = 0
for (let i = 1; i < rest.length; i++) {
if (++n > 32) break
const t = rest[i]
if (t.type !== 'word') continue
const eq = t.value.indexOf('=')
if (eq <= 0) continue
const name = t.value.slice(0, eq).trim()
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue
env[name] = expandWord(t.value.slice(eq + 1), env)
}
ctx.exitCode = 0
return 'ok'
}
/** Reserved words that cannot begin a simple or compound statement (POSIX-style). */
export const BARE_OS_SHELL_MISPLACED_STATEMENT_START = new Set([
'then',
'else',
'elif',
'fi',
'do',
'done',
'esac',
'in'
])
/**
* @param {Record<string, unknown>} ctx
* @param {ShellStmtToken[]} stmt
* @returns {boolean} true when an error was reported (caller should return)
*/
export function tryReportMisplacedReservedStatementStart(ctx, stmt) {
const h = stmt[0]
if (!h || h.type !== 'word') return false
const w = h.value
if (!BARE_OS_SHELL_MISPLACED_STATEMENT_START.has(w)) return false
ctx.console.error(
`shell: syntax error: reserved word '${w}' cannot start a statement`
)
ctx.exitCode = 2
return true
}
/**
* Minimal gated **`[[ … ]]`** — only **`[[ WORD == WORD ]]`** and **`[[ WORD != WORD ]]`**.
* @param {Record<string, unknown>} ctx
* @param {ShellStmtToken[]} stmt
* @returns {Promise<'exit' | 'ok'>}
*/
export async function execDoubleBracketLimited(ctx, stmt) {
const vfs = ctx.vfs
const env = vfs?.env && typeof vfs.env === 'object' ? vfs.env : {}
const last = stmt[stmt.length - 1]
if (!last || last.type !== 'word' || last.value !== ']]') {
ctx.console.error('shell: [[: missing closing ]]')
ctx.exitCode = 2
return 'ok'
}
const bodyStart = stmt[0]?.value === '[[' ? 1 : 2
const inner = stmt.slice(bodyStart, -1)
if (
inner.length === 3 &&
inner[0].type === 'word' &&
inner[1].type === 'word' &&
inner[2].type === 'word'
) {
const op = inner[1].value
if (op === '==' || op === '!=') {
const a = expandWord(inner[0].value, env)
const b = expandWord(inner[2].value, env)
ctx.exitCode = op === '==' ? (a === b ? 0 : 1) : a !== b ? 0 : 1
return 'ok'
}
}
ctx.console.error(
'shell: [[: only `[[ WORD == WORD ]]` and `[[ WORD != WORD ]]` are supported'
)
ctx.exitCode = 2
return 'ok'
}
/**
* @param {Record<string, string>} env
* @param {string[]} argv
*/
export function assignShellFunctionPositionalEnv(env, argv) {
env['0'] = argv[0] || ''
const fnArgs = argv.slice(1)
for (let i = 1; i <= 9; i++) env[String(i)] = fnArgs[i - 1] ?? ''
env['#'] = String(fnArgs.length)
}
/**
* @param {ShellStmtToken[]} toks
* @param {Record<string, string>} env
* @returns {string[]}
*/
export function casePatternList(toks, env) {
/** @type {string[]} */
const out = []
/** @type {ShellStmtToken[]} */
let cur = []
for (const t of toks) {
if (t.type === 'op' && t.value === '|') {
if (cur.length) {
const s = cur.map((w) => w.value).join(' ')
out.push(expandWord(s.trim(), env))
cur = []
}
} else if (t.type === 'word') {
cur.push(t)
}
}
if (cur.length) {
const s = cur.map((w) => w.value).join(' ')
out.push(expandWord(s.trim(), env))
}
return out.filter(Boolean)
}
/**
* Clear simulated background jobs on guest ↔ unlocked transitions (POSIX session model).
* @param {Record<string, unknown>} ctx
*/
export function bareOsResetShellIdentityState(ctx) {
if (
ctx.shellBackgroundJobs &&
typeof ctx.shellBackgroundJobs === 'object' &&
Array.isArray(ctx.shellBackgroundJobs.list)
) {
ctx.shellBackgroundJobs.list.length = 0
ctx.shellBackgroundJobs.nextId = 1
}
}
/**
* Consume an interactive here-document after `cmd <<DELIM`.
* @param {Record<string, unknown>} ctx
* @param {string} execLine
* @param {(ctx: Record<string, unknown>) => void} syncExit
* @returns {Promise<{ ok: true, execLine: string } | { ok: false }>}
*/
export async function consumeInteractiveHeredoc(ctx, execLine, syncExit) {
const readL = ctx.readLine
if (typeof readL !== 'function') return { ok: true, execLine }
const hm = execLine.match(/^(.*?)<<-?\s*(?:'([^']+)'|"([^"]+)"|(\S+))\s*$/)
if (!hm) return { ok: true, execLine }
const prefix = hm[1].trimEnd()
if (!prefix) {
ctx.console.error(
'shell: here-document requires a command before << on the same line'
)
ctx.exitCode = 2
syncExit(ctx)
return { ok: false }
}
const delim = hm[2] ?? hm[3] ?? hm[4]
const singleQuoted = hm[2] != null
const posixHeredocCap = (() => {
if (
ctx.env?.BARE_OS_SHELL_POSIX_MODE !== '1' &&
ctx.env?.BARE_OS_SHELL_POSIX_MODE !== 'true'
) {
return null
}
const raw = String(ctx.env.BARE_OS_SHELL_HEREDOC_MAX_BYTES || '').trim()
const n = parseInt(raw, 10)
if (Number.isFinite(n) && n > 0) return Math.min(2_000_000, n)
return 262144
})()
/** @type {string[]} */
const bodyLines = []
let heredocAcc = 0
for (;;) {
const ln = await readL('> ')
if (ln == null) break
if (ln === delim) break
if (posixHeredocCap != null) {
heredocAcc += ln.length + 1
if (heredocAcc > posixHeredocCap) {
ctx.console.error(
'shell: here-document exceeds BARE_OS_SHELL_HEREDOC_MAX_BYTES cap (POSIX mode)'
)
ctx.exitCode = 2
syncExit(ctx)
return { ok: false }
}
}
bodyLines.push(ln)
}
const vfs = ctx.vfs
const env = vfs?.env && typeof vfs.env === 'object' ? vfs.env : {}
let body = bodyLines.join('\n')
if (!singleQuoted) {
body = bodyLines.map((l) => expandWord(l, env)).join('\n')
}
ctx.shellHeredocOnce = body
if (body.length > 0 && !body.endsWith('\n')) {
ctx.shellHeredocOnce += '\n'
}
return { ok: true, execLine: prefix }
}