736 lines
18 KiB
JavaScript
736 lines
18 KiB
JavaScript
/**
|
|
* POSIX-oriented sed engine for Bare OS /bin/sed (no import; concatenated before src/sed.js).
|
|
* Covers: -n -e -f -E, addresses (#,$,/re/,n,m,n~s), s///[ngp0-9], y///, d D p P n N,
|
|
* h H g G x, b t :, q, r w, =, l, a i c (backslash forms), comments, hold space, line continuations.
|
|
*/
|
|
|
|
/**
|
|
* @param {string} delim
|
|
* @param {string} script
|
|
* @param {number} start
|
|
* @returns {{ raw: string, end: number } | null}
|
|
*/
|
|
function bareSedReadDelimited(delim, script, start) {
|
|
if (delim === '\n' || delim === '') return null
|
|
let i = start
|
|
let out = ''
|
|
while (i < script.length) {
|
|
const c = script[i]
|
|
if (c === '\\' && i + 1 < script.length) {
|
|
out += script[i + 1]
|
|
i += 2
|
|
continue
|
|
}
|
|
if (c === delim) return { raw: out, end: i + 1 }
|
|
out += c
|
|
i++
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* @param {string} reStr
|
|
* @param {boolean} extended
|
|
*/
|
|
function bareSedCompileRegex(reStr, extended) {
|
|
let flags = extended ? 'u' : 'u'
|
|
let body = reStr
|
|
if (extended) {
|
|
body = body
|
|
.replace(/\(\?#[^)]*\)/g, '')
|
|
.replace(/\(\?:/g, '(')
|
|
.replace(/\+/g, '{1,}')
|
|
.replace(/\?/g, '{0,1}')
|
|
}
|
|
try {
|
|
return new RegExp(body, flags)
|
|
} catch {
|
|
return /$^/
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} repl
|
|
* @param {string[]} caps
|
|
* @param {string} match
|
|
* @param {string} line
|
|
* @param {number} off
|
|
*/
|
|
function bareSedApplyReplacement(repl, caps, match, line, off) {
|
|
let o = ''
|
|
for (let i = 0; i < repl.length; i++) {
|
|
const c = repl[i]
|
|
if (c === '&') {
|
|
o += match
|
|
continue
|
|
}
|
|
if (c === '\\' && i + 1 < repl.length) {
|
|
const n = repl[i + 1]
|
|
if (n >= '1' && n <= '9') {
|
|
o += caps[Number(n)] || ''
|
|
i++
|
|
continue
|
|
}
|
|
if (n === '&') {
|
|
o += '&'
|
|
i++
|
|
continue
|
|
}
|
|
if (n === '\\') {
|
|
o += '\\'
|
|
i++
|
|
continue
|
|
}
|
|
o += n
|
|
i++
|
|
continue
|
|
}
|
|
o += c
|
|
}
|
|
return o
|
|
}
|
|
|
|
function bareSedListLine(s) {
|
|
let o = ''
|
|
for (let i = 0; i < s.length; i++) {
|
|
const code = s.charCodeAt(i)
|
|
if (code === 10) o += '\\n'
|
|
else if (code === 9) o += '\\t'
|
|
else if (code === 92) o += '\\\\'
|
|
else if (code < 32 || code > 126)
|
|
o += '\\' + code.toString(8).padStart(3, '0')
|
|
else o += s[i]
|
|
}
|
|
return o + '$'
|
|
}
|
|
|
|
/**
|
|
* @typedef {{ type: string, [k: string]: unknown }} BareSedCmd
|
|
*/
|
|
|
|
/**
|
|
* @param {string} script
|
|
* @param {boolean} extended
|
|
* @returns {BareSedCmd[]}
|
|
*/
|
|
function bareSedParseScript(script, extended) {
|
|
/** @type {BareSedCmd[]} */
|
|
const cmds = []
|
|
let i = 0
|
|
const len = script.length
|
|
|
|
function skipWs() {
|
|
while (i < len && /[ \t\r]/.test(script[i])) i++
|
|
}
|
|
|
|
function skipCommentLine() {
|
|
while (i < len && script[i] !== '\n') i++
|
|
if (i < len && script[i] === '\n') i++
|
|
}
|
|
|
|
function readAddr() {
|
|
skipWs()
|
|
if (i >= len) return null
|
|
const c = script[i]
|
|
if (c === '#') {
|
|
skipCommentLine()
|
|
return 'skip'
|
|
}
|
|
if (c === '0' && script[i + 1] >= '1' && script[i + 1] <= '9') {
|
|
/* fall through to number */
|
|
} else if (c >= '1' && c <= '9') {
|
|
let n = 0
|
|
while (i < len && script[i] >= '0' && script[i] <= '9') {
|
|
n = n * 10 + (script[i].charCodeAt(0) - 48)
|
|
i++
|
|
}
|
|
return { kind: 'num', n }
|
|
}
|
|
if (c === '$') {
|
|
i++
|
|
return { kind: 'last' }
|
|
}
|
|
if (c === '/' || c === '\\') {
|
|
let delim = c
|
|
let start = i + 1
|
|
if (c === '\\') {
|
|
delim = script[i + 1] || '/'
|
|
start = i + 2
|
|
}
|
|
const got = bareSedReadDelimited(delim, script, start)
|
|
if (!got) throw new Error('sed: unterminated address regex')
|
|
i = got.end
|
|
return { kind: 're', rx: bareSedCompileRegex(got.raw, extended) }
|
|
}
|
|
return null
|
|
}
|
|
|
|
function readAddrPair() {
|
|
const a = readAddr()
|
|
if (a === 'skip' || a === null) return a
|
|
skipWs()
|
|
if (i < len && script[i] === ',') {
|
|
i++
|
|
const b = readAddr()
|
|
if (b === 'skip' || b === null) throw new Error('sed: invalid address')
|
|
return { kind: 'range', a, b }
|
|
}
|
|
if (i < len && script[i] === '~') {
|
|
i++
|
|
let step = 0
|
|
while (i < len && script[i] >= '0' && script[i] <= '9') {
|
|
step = step * 10 + (script[i].charCodeAt(0) - 48)
|
|
i++
|
|
}
|
|
if (step < 1) step = 1
|
|
return { kind: 'step', a, step }
|
|
}
|
|
return a
|
|
}
|
|
|
|
while (i < len) {
|
|
skipWs()
|
|
if (i >= len) break
|
|
if (script[i] === '#' || (script[i] === '\n' && (i++, false))) {
|
|
if (script[i - 1] === '#') skipCommentLine()
|
|
else continue
|
|
continue
|
|
}
|
|
if (script[i] === ';') {
|
|
i++
|
|
continue
|
|
}
|
|
if (script[i] === '\n') {
|
|
i++
|
|
continue
|
|
}
|
|
|
|
let neg = false
|
|
if (script[i] === '!') {
|
|
neg = true
|
|
i++
|
|
skipWs()
|
|
}
|
|
|
|
const addr1 = readAddrPair()
|
|
if (addr1 === 'skip') continue
|
|
skipWs()
|
|
if (i >= len) break
|
|
|
|
const ch = script[i]
|
|
if (ch === '#') {
|
|
skipCommentLine()
|
|
continue
|
|
}
|
|
|
|
if (ch === ':') {
|
|
i++
|
|
let lab = ''
|
|
while (i < len && /[A-Za-z0-9_]/.test(script[i])) lab += script[i++]
|
|
cmds.push({ type: 'label', name: lab, neg, addr: addr1 })
|
|
continue
|
|
}
|
|
|
|
if (ch === 'b' || ch === 't') {
|
|
const ty = ch
|
|
i++
|
|
skipWs()
|
|
let lab = ''
|
|
while (i < len && /[A-Za-z0-9_]/.test(script[i])) lab += script[i++]
|
|
cmds.push({ type: ty, label: lab, neg, addr: addr1 })
|
|
continue
|
|
}
|
|
|
|
if (ch === 'r' || ch === 'w') {
|
|
const ty = ch
|
|
i++
|
|
skipWs()
|
|
let path = ''
|
|
while (i < len && script[i] !== '\n' && script[i] !== ';')
|
|
path += script[i++]
|
|
path = path.replace(/[ \t]+$/, '')
|
|
cmds.push({
|
|
type: ty === 'r' ? 'readFile' : 'writeFile',
|
|
path,
|
|
neg,
|
|
addr: addr1
|
|
})
|
|
continue
|
|
}
|
|
|
|
if (ch === 'a' || ch === 'i' || ch === 'c') {
|
|
const ty = ch
|
|
i++
|
|
skipWs()
|
|
if (i < len && script[i] === '\\') i++
|
|
let text = ''
|
|
while (i < len && script[i] !== '\n') text += script[i++]
|
|
if (i < len && script[i] === '\n') i++
|
|
while (i < len && script[i] === '\\') {
|
|
i++
|
|
let cont = ''
|
|
while (i < len && script[i] !== '\n') cont += script[i++]
|
|
text += '\n' + cont
|
|
if (i < len && script[i] === '\n') i++
|
|
}
|
|
cmds.push({
|
|
type: ty === 'a' ? 'append' : ty === 'i' ? 'insert' : 'change',
|
|
text,
|
|
neg,
|
|
addr: addr1
|
|
})
|
|
continue
|
|
}
|
|
|
|
if (ch === 's') {
|
|
i++
|
|
const delim = script[i++]
|
|
const pat = bareSedReadDelimited(delim, script, i)
|
|
if (!pat) throw new Error('sed: unterminated s command')
|
|
i = pat.end
|
|
const rep = bareSedReadDelimited(delim, script, i)
|
|
if (!rep) throw new Error('sed: unterminated s replacement')
|
|
i = rep.end
|
|
/** @type {{ g?: boolean, p?: boolean, n?: number }} */
|
|
const fl = {}
|
|
while (i < len && /[gpn0-9]/.test(script[i])) {
|
|
const f = script[i++]
|
|
if (f === 'g') fl.g = true
|
|
else if (f === 'p') fl.p = true
|
|
else if (f >= '1' && f <= '9') fl.n = Number(f)
|
|
}
|
|
cmds.push({
|
|
type: 'subst',
|
|
rx: bareSedCompileRegex(pat.raw, extended),
|
|
rep: rep.raw,
|
|
flags: fl,
|
|
neg,
|
|
addr: addr1
|
|
})
|
|
continue
|
|
}
|
|
|
|
if (ch === 'y') {
|
|
i++
|
|
const delim = script[i++]
|
|
const from = bareSedReadDelimited(delim, script, i)
|
|
if (!from) throw new Error('sed: unterminated y')
|
|
i = from.end
|
|
const to = bareSedReadDelimited(delim, script, i)
|
|
if (!to) throw new Error('sed: unterminated y')
|
|
i = to.end
|
|
if (from.raw.length !== to.raw.length)
|
|
throw new Error('sed: y strings must be same length')
|
|
cmds.push({ type: 'y', from: from.raw, to: to.raw, neg, addr: addr1 })
|
|
continue
|
|
}
|
|
|
|
const map = {
|
|
d: 'del',
|
|
D: 'delFirst',
|
|
p: 'print',
|
|
P: 'printFirst',
|
|
n: 'nextLine',
|
|
N: 'appendNext',
|
|
h: 'hold',
|
|
H: 'holdAppend',
|
|
g: 'get',
|
|
G: 'getAppend',
|
|
x: 'swap',
|
|
q: 'quit',
|
|
l: 'list',
|
|
'=': 'lineNum'
|
|
}
|
|
const ty = map[ch]
|
|
if (ty) {
|
|
i++
|
|
let count = 1
|
|
if (ty === 'quit' && i < len && script[i] >= '0' && script[i] <= '9') {
|
|
count = 0
|
|
while (i < len && script[i] >= '0' && script[i] <= '9') {
|
|
count = count * 10 + (script[i].charCodeAt(0) - 48)
|
|
i++
|
|
}
|
|
}
|
|
cmds.push({ type: ty, neg, addr: addr1, quitCode: count })
|
|
continue
|
|
}
|
|
|
|
throw new Error('sed: unknown command: ' + ch)
|
|
}
|
|
|
|
return cmds
|
|
}
|
|
|
|
/**
|
|
* @param {unknown} addr
|
|
* @param {number} lineNo
|
|
* @param {number} lastLine
|
|
* @param {string} ps
|
|
*/
|
|
function bareSedAddrSimple(addr, lineNo, lastLine, ps) {
|
|
if (addr == null) return true
|
|
if (typeof addr === 'object' && addr.kind === 'num') return lineNo === addr.n
|
|
if (typeof addr === 'object' && addr.kind === 'last')
|
|
return lineNo === lastLine
|
|
if (typeof addr === 'object' && addr.kind === 're') {
|
|
addr.rx.lastIndex = 0
|
|
return addr.rx.test(ps)
|
|
}
|
|
if (typeof addr === 'object' && addr.kind === 'step') {
|
|
const an = addr.a
|
|
if (typeof an === 'object' && an.kind === 'num') {
|
|
return lineNo >= an.n && (lineNo - an.n) % addr.step === 0
|
|
}
|
|
return false
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* @param {unknown} addr
|
|
* @param {number} lineNo
|
|
* @param {number} lastLine
|
|
* @param {string} ps
|
|
* @param {Map<number, { active: boolean }>} rangeStates
|
|
* @param {number} cmdIndex
|
|
*/
|
|
function bareSedAddrMatchFull(
|
|
addr,
|
|
lineNo,
|
|
lastLine,
|
|
ps,
|
|
rangeStates,
|
|
cmdIndex
|
|
) {
|
|
if (addr == null) return true
|
|
if (typeof addr === 'object' && addr.kind === 'range') {
|
|
const a = addr.a
|
|
const b = addr.b
|
|
if (
|
|
typeof a === 'object' &&
|
|
a.kind === 'num' &&
|
|
typeof b === 'object' &&
|
|
b.kind === 'num'
|
|
) {
|
|
return lineNo >= a.n && lineNo <= b.n
|
|
}
|
|
if (
|
|
typeof a === 'object' &&
|
|
a.kind === 'num' &&
|
|
typeof b === 'object' &&
|
|
b.kind === 'last'
|
|
) {
|
|
return lineNo >= a.n && lineNo <= lastLine
|
|
}
|
|
if (
|
|
typeof a === 'object' &&
|
|
a.kind === 'last' &&
|
|
typeof b === 'object' &&
|
|
b.kind === 'num'
|
|
) {
|
|
return lineNo >= lastLine && lineNo <= b.n
|
|
}
|
|
if (
|
|
typeof a === 'object' &&
|
|
a.kind === 're' &&
|
|
typeof b === 'object' &&
|
|
b.kind === 're'
|
|
) {
|
|
let st = rangeStates.get(cmdIndex)
|
|
if (!st) {
|
|
st = { active: false }
|
|
rangeStates.set(cmdIndex, st)
|
|
}
|
|
a.rx.lastIndex = 0
|
|
b.rx.lastIndex = 0
|
|
const hitA = a.rx.test(ps)
|
|
const hitB = b.rx.test(ps)
|
|
if (!st.active && hitA) st.active = true
|
|
const inRange = st.active
|
|
if (st.active && hitB) st.active = false
|
|
return inRange
|
|
}
|
|
if (
|
|
typeof a === 'object' &&
|
|
a.kind === 'num' &&
|
|
typeof b === 'object' &&
|
|
b.kind === 're'
|
|
) {
|
|
let st = rangeStates.get(cmdIndex)
|
|
if (!st) {
|
|
st = { active: false }
|
|
rangeStates.set(cmdIndex, st)
|
|
}
|
|
if (lineNo === a.n) st.active = true
|
|
b.rx.lastIndex = 0
|
|
const hitB = b.rx.test(ps)
|
|
const inRange = st.active
|
|
if (st.active && hitB) st.active = false
|
|
return inRange
|
|
}
|
|
if (
|
|
typeof a === 'object' &&
|
|
a.kind === 're' &&
|
|
typeof b === 'object' &&
|
|
b.kind === 'num'
|
|
) {
|
|
let st = rangeStates.get(cmdIndex)
|
|
if (!st) {
|
|
st = { active: false }
|
|
rangeStates.set(cmdIndex, st)
|
|
}
|
|
a.rx.lastIndex = 0
|
|
if (!st.active && a.rx.test(ps)) st.active = true
|
|
const inRange = st.active
|
|
if (st.active && lineNo >= b.n) st.active = false
|
|
return inRange
|
|
}
|
|
return false
|
|
}
|
|
return bareSedAddrSimple(addr, lineNo, lastLine, ps)
|
|
}
|
|
|
|
function bareSedMatchAddr(
|
|
addr,
|
|
neg,
|
|
lineNo,
|
|
lastLine,
|
|
ps,
|
|
rangeStates,
|
|
cmdIndex
|
|
) {
|
|
const m = bareSedAddrMatchFull(
|
|
addr,
|
|
lineNo,
|
|
lastLine,
|
|
ps,
|
|
rangeStates,
|
|
cmdIndex
|
|
)
|
|
return neg ? !m : m
|
|
}
|
|
|
|
/**
|
|
* @param {string[]} lines
|
|
* @param {string[]} scripts
|
|
* @param {{ silent?: boolean, extended?: boolean, nullData?: boolean, readFile?: (p: string) => string | null, writeFile?: (p: string, chunk: string) => void, lastLineHint?: number }} opts
|
|
* @returns {string}
|
|
*/
|
|
function bareSedRun(lines, scripts, opts) {
|
|
const silent = !!opts.silent
|
|
const extended = !!opts.extended
|
|
const eol = opts.nullData ? '\0' : '\n'
|
|
const readF = opts.readFile || (() => null)
|
|
const writeF = opts.writeFile || (() => {})
|
|
const fullScript = scripts.join('\n')
|
|
const cmds = bareSedParseScript(fullScript.replace(/\\\n/g, ''), extended)
|
|
/** @type {Record<string, number>} */
|
|
const labels = {}
|
|
for (let ci = 0; ci < cmds.length; ci++) {
|
|
if (cmds[ci].type === 'label')
|
|
labels[/** @type {string} */ (cmds[ci].name)] = ci
|
|
}
|
|
|
|
const lastLine = opts.lastLineHint != null ? opts.lastLineHint : lines.length
|
|
/** @type {string[]} */
|
|
const out = []
|
|
let hold = ''
|
|
let quit = 0
|
|
let lastSubst = false
|
|
/** @type {Map<number, { active: boolean }>} */
|
|
const rangeStates = new Map()
|
|
|
|
function emit(s) {
|
|
out.push(s)
|
|
}
|
|
|
|
let lineIdx = 0
|
|
while (lineIdx < lines.length && quit === 0) {
|
|
let ps = lines[lineIdx]
|
|
const lineNo = lineIdx + 1
|
|
let autoPrint = !silent
|
|
let delLine = false
|
|
let nextRead = false
|
|
let ci = 0
|
|
|
|
while (ci < cmds.length && quit === 0) {
|
|
const cmd = cmds[ci]
|
|
if (cmd.type === 'label') {
|
|
ci++
|
|
continue
|
|
}
|
|
const addr = cmd.addr
|
|
if (
|
|
!bareSedMatchAddr(
|
|
addr,
|
|
!!cmd.neg,
|
|
lineNo,
|
|
lastLine,
|
|
ps,
|
|
rangeStates,
|
|
ci
|
|
)
|
|
) {
|
|
ci++
|
|
continue
|
|
}
|
|
|
|
switch (cmd.type) {
|
|
case 'subst': {
|
|
lastSubst = false
|
|
const rx = /** @type {RegExp} */ (cmd.rx)
|
|
const rep = /** @type {string} */ (cmd.rep)
|
|
const fl = /** @type {{ g?: boolean, p?: boolean, n?: number }} */ (
|
|
cmd.flags
|
|
)
|
|
let count = 0
|
|
let res = ''
|
|
let pos = 0
|
|
const g = !!fl.g
|
|
const wantN = fl.n != null ? fl.n : g ? Infinity : 1
|
|
let replCount = 0
|
|
rx.lastIndex = 0
|
|
let m
|
|
const str = ps
|
|
while ((m = rx.exec(str)) && replCount < wantN) {
|
|
res += str.slice(pos, m.index)
|
|
const caps = m.map((x) => (x == null ? '' : String(x)))
|
|
res += bareSedApplyReplacement(rep, caps, m[0], str, m.index)
|
|
pos = m.index + m[0].length
|
|
count++
|
|
replCount++
|
|
lastSubst = true
|
|
if (!g) break
|
|
if (m[0].length === 0) {
|
|
rx.lastIndex++
|
|
if (rx.lastIndex > str.length) break
|
|
}
|
|
}
|
|
if (count) {
|
|
ps = res + str.slice(pos)
|
|
if (fl.p) emit(ps + eol)
|
|
}
|
|
break
|
|
}
|
|
case 'y': {
|
|
const from = /** @type {string} */ (cmd.from)
|
|
const to = /** @type {string} */ (cmd.to)
|
|
const map = {}
|
|
for (let j = 0; j < from.length; j++) map[from[j]] = to[j]
|
|
let ns = ''
|
|
for (let j = 0; j < ps.length; j++)
|
|
ns += map[ps[j]] != null ? map[ps[j]] : ps[j]
|
|
ps = ns
|
|
break
|
|
}
|
|
case 'del':
|
|
delLine = true
|
|
autoPrint = false
|
|
break
|
|
case 'delFirst': {
|
|
const nl = ps.indexOf('\n')
|
|
if (nl === -1) {
|
|
delLine = true
|
|
autoPrint = false
|
|
} else ps = ps.slice(nl + 1)
|
|
ci = -1
|
|
break
|
|
}
|
|
case 'print':
|
|
emit(ps + eol)
|
|
break
|
|
case 'printFirst': {
|
|
const nl = ps.indexOf('\n')
|
|
emit((nl === -1 ? ps : ps.slice(0, nl)) + eol)
|
|
break
|
|
}
|
|
case 'nextLine':
|
|
if (autoPrint && !silent) emit(ps + eol)
|
|
lineIdx++
|
|
nextRead = true
|
|
ci = cmds.length
|
|
break
|
|
case 'appendNext':
|
|
lineIdx++
|
|
if (lineIdx < lines.length) ps += '\n' + lines[lineIdx]
|
|
else delLine = true
|
|
break
|
|
case 'hold':
|
|
hold = ps
|
|
break
|
|
case 'holdAppend':
|
|
hold += (hold ? '\n' : '') + ps
|
|
break
|
|
case 'get':
|
|
ps = hold
|
|
break
|
|
case 'getAppend':
|
|
ps += '\n' + hold
|
|
break
|
|
case 'swap': {
|
|
const t = ps
|
|
ps = hold
|
|
hold = t
|
|
break
|
|
}
|
|
case 'quit':
|
|
if (autoPrint && !silent) emit(ps + eol)
|
|
quit = /** @type {number} */ (cmd.quitCode) || 0
|
|
break
|
|
case 'list':
|
|
emit(bareSedListLine(ps) + eol)
|
|
break
|
|
case 'lineNum':
|
|
emit(String(lineNo) + eol)
|
|
break
|
|
case 'readFile': {
|
|
const text = readF(/** @type {string} */ (cmd.path))
|
|
if (text) emit(text.endsWith(eol) ? text : text + eol)
|
|
break
|
|
}
|
|
case 'writeFile':
|
|
writeF(/** @type {string} */ (cmd.path), ps + '\n')
|
|
break
|
|
case 'append':
|
|
emit(/** @type {string} */ (cmd.text) + eol)
|
|
break
|
|
case 'insert':
|
|
/* handled as emit before line — approximated by prepending to output before autoPrint */
|
|
out.push(/** @type {string} */ (cmd.text) + '\n')
|
|
break
|
|
case 'change':
|
|
autoPrint = false
|
|
emit(/** @type {string} */ (cmd.text) + eol)
|
|
delLine = true
|
|
break
|
|
case 'b': {
|
|
const lab = /** @type {string} */ (cmd.label)
|
|
if (lab && labels[lab] != null) ci = labels[lab]
|
|
break
|
|
}
|
|
case 't': {
|
|
if (lastSubst) {
|
|
const lab = /** @type {string} */ (cmd.label)
|
|
if (lab && labels[lab] != null) ci = labels[lab]
|
|
lastSubst = false
|
|
}
|
|
break
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
ci++
|
|
if (delLine) break
|
|
if (nextRead) break
|
|
}
|
|
|
|
if (quit) break
|
|
if (nextRead) continue
|
|
if (!delLine && autoPrint) emit(ps + eol)
|
|
lineIdx++
|
|
}
|
|
|
|
return out.join('')
|
|
}
|