270 lines
6.8 KiB
JavaScript
270 lines
6.8 KiB
JavaScript
/**
|
|
* 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)
|
|
}
|
|
|