/** * Pure shell syntax helpers (if/fi, loops, functions, case patterns). */ import { bareOsFnmatch } from './shell-glob.js' /** * Strip one layer of matching single/double quotes from alias value. * @param {string} val */ export function stripAliasQuotes(val) { const v = val.trim() if (v.length >= 2) { const q = v[0] if ((q === "'" || q === '"') && v[v.length - 1] === q) { return v.slice(1, -1) } } return v } export function collapseShellLineContinuations(s) { return String(s || '').replace(/\\\r?\n/g, '') } /** * Split on `;` only at depth 0 (`if` / `fi` nesting). * @param {Token[]} tokens * @returns {Token[][]} */ export function splitTopLevelStatements(tokens) { /** @type {Token[][]} */ const out = [] /** @type {Token[]} */ let cur = [] let depth = 0 let parenDepth = 0 let braceDepth = 0 for (const t of tokens) { if (t.type === 'word') { if (t.value === 'if') depth++ else if (t.value === 'fi') depth = Math.max(0, depth - 1) else if ( t.value === 'while' || t.value === 'until' || t.value === 'for' || t.value === 'select' ) depth++ else if (t.value === 'done') depth = Math.max(0, depth - 1) else if (t.value === 'case') depth++ else if (t.value === 'esac') depth = Math.max(0, depth - 1) } else if (t.type === 'op') { if (t.value === '(') parenDepth++ else if (t.value === ')') parenDepth = Math.max(0, parenDepth - 1) else if (t.value === '{') braceDepth++ else if (t.value === '}') braceDepth = Math.max(0, braceDepth - 1) } if ( t.type === 'op' && t.value === ';' && depth === 0 && parenDepth === 0 && braceDepth === 0 ) { if (cur.length) out.push(cur) cur = [] } else { cur.push(t) } } if (cur.length) out.push(cur) return out } /** * Split on `&` at depth 0 (outside `if`/`fi`). Each segment except the last runs in the background. * @param {Token[]} tokens * @returns {Token[][]} */ export function splitTopLevelByAmpersand(tokens) { /** @type {Token[][]} */ const out = [] /** @type {Token[]} */ let cur = [] let depth = 0 let parenDepth = 0 let braceDepth = 0 for (const t of tokens) { if (t.type === 'word') { if (t.value === 'if') depth++ else if (t.value === 'fi') depth = Math.max(0, depth - 1) else if ( t.value === 'while' || t.value === 'until' || t.value === 'for' || t.value === 'select' ) depth++ else if (t.value === 'done') depth = Math.max(0, depth - 1) else if (t.value === 'case') depth++ else if (t.value === 'esac') depth = Math.max(0, depth - 1) } else if (t.type === 'op') { if (t.value === '(') parenDepth++ else if (t.value === ')') parenDepth = Math.max(0, parenDepth - 1) else if (t.value === '{') braceDepth++ else if (t.value === '}') braceDepth = Math.max(0, braceDepth - 1) } if ( t.type === 'op' && t.value === '&' && depth === 0 && parenDepth === 0 && braceDepth === 0 ) { out.push(cur) cur = [] } else { cur.push(t) } } out.push(cur) return out } /** * @param {Token[]} tokens * @param {number} start index after `if` */ export function findThenIndex(tokens, start) { let d = 1 for (let j = start; j < tokens.length; j++) { const t = tokens[j] if (t.type !== 'word') continue if (t.value === 'if') d++ else if (t.value === 'fi') d-- else if (t.value === 'then' && d === 1) return j } return -1 } /** * After `then`, find `else` at depth 1 or closing `fi` at depth 0. * @param {Token[]} tokens * @param {number} start index after `then` token * @returns {{ kind: 'else' | 'fi', idx: number } | null} */ export function findElseOrFiAfterThen(tokens, start) { let d = 1 for (let j = start; j < tokens.length; j++) { const t = tokens[j] if (t.type !== 'word') continue if (t.value === 'if') d++ else if (t.value === 'fi') { d-- if (d === 0) return { kind: 'fi', idx: j } } else if (t.value === 'else' && d === 1) return { kind: 'else', idx: j } } return null } /** * @param {Token[]} tokens * @param {number} start index after `else` */ export function findFiAfterElse(tokens, start) { let d = 1 for (let j = start; j < tokens.length; j++) { const t = tokens[j] if (t.type !== 'word') continue if (t.value === 'if') d++ else if (t.value === 'fi') { d-- if (d === 0) return j } } return -1 } /** * @param {Token[]} tokens * @returns {number} */ export function findWhileDoSplit(tokens) { for (let j = 1; j < tokens.length - 2; j++) { const t = tokens[j] if (t.type === 'op' && t.value === ';') { const n = tokens[j + 1] if (n && n.type === 'word' && n.value === 'do') return j } } return -1 } export function isValidShellIdentifier(name) { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) } /** * @param {Token[]} stmt * @returns {{ name: string, body: Token[] } | null} */ export function parseShellFunctionDeclaration(stmt) { const n = stmt.length if (n < 5) return null let name = '' let bodyStart = -1 if ( stmt[0]?.type === 'word' && stmt[0].value === 'function' && stmt[1]?.type === 'word' ) { name = stmt[1].value if ( stmt[2]?.type === 'op' && stmt[2].value === '(' && stmt[3]?.type === 'op' && stmt[3].value === ')' ) { if (stmt[4]?.type === 'op' && stmt[4].value === '{') bodyStart = 5 } else if (stmt[2]?.type === 'op' && stmt[2].value === '{') { bodyStart = 3 } } else if ( stmt[0]?.type === 'word' && stmt[1]?.type === 'op' && stmt[1].value === '(' && stmt[2]?.type === 'op' && stmt[2].value === ')' && stmt[3]?.type === 'op' && stmt[3].value === '{' ) { name = stmt[0].value bodyStart = 4 } if (!name || bodyStart < 0 || !isValidShellIdentifier(name)) return null let depth = 1 let close = -1 for (let i = bodyStart; i < stmt.length; i++) { const t = stmt[i] if (t.type === 'op' && t.value === '{') depth++ else if (t.type === 'op' && t.value === '}') { depth-- if (depth === 0) { close = i break } } } if (close < 0 || close !== stmt.length - 1) return null const body = stmt.slice(bodyStart, close) if (body.length && body[body.length - 1]?.type === 'op' && body[body.length - 1].value === ';') { body.pop() } return { name, body } } /** * @param {string} subject * @param {string} pat */ export function casePatternMatches(subject, pat) { if (pat.includes('|')) { return pat.split('|').some((p) => casePatternMatches(subject, p.trim())) } if (pat === '*') return true if (!/[*?\[]/.test(pat)) return subject === pat return bareOsFnmatch(subject, pat) }