#!/usr/bin/env node const fs = require('fs') const EventEmitter = require('events') const {exec, execSync} = require('child_process') process.stdout.on('error', function (err) { if (err.code === 'EPIPE') { exec('stty echo', () => { process.exit(0) }) } }) class ArgError extends Error {} function requireArg(name, value) { if (value == null) { throw new ArgError(`Missing argument: ${name}`) } } class Rule { delim = '' pattern = '' replace args constructor(delim, pattern, replace, args) { this.delim = delim this.pattern = pattern this.replace = replace this.args = args } rules() { return [this] } /** * Called on a ConditionalRule to return a rule that performs an additional check. */ addCondition(rule) { return rule } /** * Returns a rule that applies the condition on every line */ toConditionalLineRule(lineRule) { return lineRule } /** * Returns a rule that applies the condition to the entire document. * * This has two behaviours, depending on the condition type. Most conditions * operate on each line, in which case */ toConditionalDocumentRule(rule) { return rule } /** * Run before any lines are processed */ before(state, options) {} /** * Run after all lines have processed. */ after(state, options) {} /** * If the last line is blank, this function will be called. Default behaviour * returns '', but rules can override this behaviour. */ blankLastLine() { return '' } explain(_rules) { return '' } } class OnlyRule extends Rule { _rules constructor(rules) { super('', '', '', []) this._rules = rules } rules() { return [] } report(lines) { return lines } before(state, options) { this._rules.forEach(rule => rule.before(state, options)) } processLines(lines, state, options) { return lines } processDocument(linesWithNumbers, options) { return linesWithNumbers.map(([line]) => line) } after(state, options) { this._rules.forEach(rule => rule.after(state, options)) } explain() { const rules = this._rules.flatMap(rule => rule.rules()) return this._rules.map(rule => rule.explain(rules)).join('\n') } } class OnlyDocumentRules extends OnlyRule { run(linesWithNumbers, state, options) { return this.processDocument(linesWithNumbers, options) } processDocument(linesWithNumbers, options) { let lines = [] for (const documentRule of this._rules) { const state = resetState() lines = documentRule.run(linesWithNumbers, state, options) ?? [] linesWithNumbers = lines.map((line, index) => [line, index + 1]) } return lines } } class OnlyLineRules extends OnlyRule { run(lines, state, options) { if (!lines) { return null } return this.processLines(lines, state, options) } processLines(lines, state, options) { return lines.flatMap(line => { state.lineNumber += 1 for (const lineRule of this._rules) { lineRule.beforeEach(line, state) } let mappedLines = [line] for (const lineRule of this._rules) { mappedLines = mappedLines.flatMap(line => { let nextLines = lineRule.run(line, state.lineNumber, state) if (nextLines == null) { // ensure that run() is always called once return [null] } if (Array.isArray(nextLines)) { return nextLines.flatMap(line => line.split('\n')) } return nextLines.split('\n') }) } mappedLines = mappedLines.filter(line => line != null) for (const lineRule of this._rules) { mappedLines.forEach(line => { lineRule.afterEach(line, state) }) } return mappedLines }) } report(lines, options) { lines.forEach(line => options.print(line)) return [] } blankLastLine() { let blank = '' for (const lineRule of this._rules) { blank = lineRule.blankLastLine() if (blank == null) { return blank } } return blank } } class LineRule extends Rule { /** * Run before every line */ beforeEach(line, state, options) {} /** * Runs on every line, even if previous rules returned null/undefined */ run(line, lineNo, state, options) { if (line != null) { return this.line(line, lineNo) } } /** * Should only run on lines that have text, even if the text is '' */ line(line, lineNo) {} /** * Run after every line (or lines, if a rule returns multiple lines) */ afterEach(line, state, options) {} } class DocumentRule extends Rule { /** * Runs on every rule, even if there are no lines. */ run(documentLines, state, options) { return this.lines(documentLines, options) } /** * lines is an array of [line, lineNo] */ lines(documentLines, options) { return [] } } class ConditionalRule extends Rule { constructor(isNegated, delim, pattern, replace, check) { super(delim, pattern, replace) if (isNegated) { this.check = (line, lineNo, state) => !check(line, lineNo, state) } else { this.check = check } } checkDocumentLines(documentLines, state, options) { for (const [line, lineNo] of documentLines) { const didMatch = this.condRule.check(line, lineNo, state) if (didMatch) { return true } } return false } addCondition(rule) { return new ConditionalAndRule(this, rule) } toConditionalLineRule(rule) { return new ConditionalLineRule(this, rule) } toConditionalDocumentRule(rule) { return new LimitedDocumentRule(this, rule) } } class IfConditionRule extends ConditionalRule { constructor(isNegated, delim, pattern, flags) { let check if (delim === ':') { check = toLineRangeTest(pattern) } else if (delim === '`') { check = line => line.includes(pattern) } else { const regex = new RegExp(pattern, flags) check = line => line.match(regex) } super(isNegated, delim, pattern, '', check) } explain() { return `if ${explainPattern(this.delim, this.pattern, 'pattern')} matches` } } class IfAnyDocumentConditionRule extends IfConditionRule { isNegated constructor(isNegated, delim, pattern, flags) { super(false, delim, pattern, flags) this.isNegated = isNegated } checkDocumentLines(documentLines, state, options) { for (const [line, lineNo] of documentLines) { const didMatch = this.check(line, lineNo, state) if (didMatch) { return this.isNegated ? false : true } } return this.isNegated ? true : false } toConditionalLineRule(rule) { let documentRule if (rule instanceof OnlyLineRules) { documentRule = new DocumentLineRules(rule._rules) } else { documentRule = new DocumentLineRules([rule]) } return new ConditionalDocumentRule(this, documentRule) } toConditionalDocumentRule(documentRule) { return new ConditionalDocumentRule(this, documentRule) } explain() { return `if the document matches ${explainPattern( this.delim, this.pattern, 'pattern', )}` } } class BetweenConditionRule extends ConditionalRule { constructor(isNegated, delim, startPattern, stopPattern, flags) { let checkStart, checkStop if (delim === ':') { checkStart = toLineRangeTest(startPattern) checkStop = toLineRangeTest(stopPattern) } else if (delim === '`') { checkStart = line => line.includes(startPattern) checkStop = line => line.includes(stopPattern) } else { const startRegex = new RegExp(startPattern, flags) const stopRegex = new RegExp(stopPattern, flags) checkStart = line => line.match(startRegex) checkStop = line => line.match(stopRegex) } const check = (line, lineNo, state) => { if (!state.conditionOn && checkStart(line, lineNo)) { state.conditionOn = true } else if (state.conditionOn && checkStop(line, lineNo)) { state.conditionOn = false return true } return state.conditionOn } super(isNegated, delim, startPattern, stopPattern, check) } explain() { const startPattern = this.pattern const stopPattern = this.replace return `starting at lines matching ${explainPattern( this.delim, startPattern, 'pattern', )} and stopping at lines matching ${explainPattern( this.delim, stopPattern, 'pattern', )}` } } class ConditionalAndRule extends ConditionalRule { prevRule constructor(prevRule, nextRule) { super( false, '', '', '', [], (line, lineNo, state) => prevRule.check(line, lineNo, state) && nextRule.check(line, lineNo, state), ) this.prevRule = prevRule this.nextRule = nextRule } rules() { return [this.prevRule, this.nextRule].flatMap(rule => rule.rules()) } addCondition(rule) { return new ConditionalAndRule(this, rule) } explain(rules) { return `${this.prevRule.explain(rules)}\n && ${this.nextRule.explain( rules, )}` } } class ConditionalLineRule extends LineRule { condRule lineRule constructor(condRule, lineRule) { super('', '', '', []) this.condRule = condRule this.lineRule = lineRule } rules() { return [this.condRule, this.lineRule].flatMap(rule => rule.rules()) } before(state, options) { this.condRule.before(state, options) this.lineRule.before(state, options) } beforeEach(line, state, options) { // do not call this.lineRule.beforeEach here – only if the condition passes } run(line, lineNo, state, options) { if (line == null) { return } if (this.condRule.check(line, lineNo, state)) { const conditionState = resetState() conditionState.lineNumber = lineNo - 1 return this.lineRule.processLines([line], conditionState, options) } else { return line } } afterEach(line, state, options) { // do not call this.lineRule.afterEach here – only if the condition passes } after(state, options) { this.condRule.after(state, options) this.lineRule.after(state, options) } blankLastLine() { const line = this.condRule.blankLastLine() return line === '' && this.lineRule.blankLastLine() } explain(rules) { return `Only apply the following rule ${this.condRule.explain( rules, )}:\n${indent(this.lineRule.explain(rules))}` } } /** * Runs `documentRule` only against lines that match `condRule`. * * Lines are grouped into subsequent "blocks". IE if the first 5 lines and last 5 * lines of a 15 line document match the rule, `documentRule` will be run twice. * Once on the first 5 lines, then the middle 5 lines will be added "as-is", and * then the last 5 lines will be processed by `documentRule` as if they were a * separate document. */ class LimitedDocumentRule extends DocumentRule { documentRule constructor(condRule, documentRule) { super('', '', '', []) this.condRule = condRule this.documentRule = documentRule } rules() { return [this.condRule, this.documentRule].flatMap(rule => rule.rules()) } before(state, options) { this.condRule.before(state, options) this.documentRule.before(state, options) } run(documentLines, state, options) { if (documentLines == null) { return } let didMatch = false let buffer = [] let returnLines = [] for (const [line, lineNo] of documentLines) { if (this.condRule.check(line, lineNo, state)) { if (!didMatch && buffer.length) { returnLines.push(...buffer) buffer = [] } didMatch = true buffer.push([line, lineNo]) } else { if (didMatch && buffer.length) { const processedLines = this.documentRule.processDocument( buffer, options, ) returnLines.push(...processedLines) buffer = [] } didMatch = false buffer.push(line) } } if (didMatch) { const processedLines = this.documentRule.processDocument(buffer, options) returnLines.push(...processedLines) } else if (!didMatch && buffer.length) { returnLines.push(...buffer) } return returnLines } after(state, options) { this.condRule.after(state, options) this.documentRule.after(state, options) } blankLastLine() { return this.documentRule.blankLastLine() } explain(rules) { return `On any lines that match:\n${indent( this.condRule.explain(rules), )}, apply the rule:\n${indent(this.documentRule.explain(rules))}` } } /** * Runs `documentRule` only if `condRule` passes. The condition is run against * the entire document, so usually this means that `documentRule` is run if *any* * line passes the `condRule`. */ class ConditionalDocumentRule extends DocumentRule { documentRule constructor(condRule, documentRule) { super('', '', '', []) this.condRule = condRule this.documentRule = documentRule } rules() { return [this.condRule, this.documentRule].flatMap(rule => rule.rules()) } before(state, options) { this.condRule.before(state, options) this.documentRule.before(state, options) } run(documentLines, state, options) { if (documentLines == null) { return } let didMatch = this.condRule.checkDocumentLines( documentLines, state, options, ) if (didMatch) { return this.documentRule.run(documentLines, state, options) } return documentLines.map(([line]) => line) } after(state, options) { this.condRule.after(state, options) this.documentRule.after(state, options) } blankLastLine() { return this.documentRule.blankLastLine() } explain(rules) { return `Only apply the following rule ${this.condRule.explain( rules, )}:\n${indent(this.documentRule.explain(rules))}` } } function toCondRule([cmd, pattern, replace, rest, delim]) { switch (cmd) { case '!if': case 'if': return new IfConditionRule( cmd.startsWith('!'), // isNegated delim, pattern, replace || '', // flags ) case '!between': case 'between': return new BetweenConditionRule( cmd.startsWith('!'), delim, pattern, replace || '', ) case '!ifany': case 'ifany': case 'ifnone': return new IfAnyDocumentConditionRule( cmd === '!ifany' || cmd === 'ifnone', delim, pattern, replace || '', ) } } /** * Performs a single substitution on every line that matches. Use gsub to replace * multiple substitutions per-line. * * When matching line numbers (':' delimiter), the entire line is replaced with the * matching text. * * The '`' delimiter can be used to match literal strings, all other delimiters * match using Regex. * * Usage: * ssed sub/{pattern}/{replace} * ssed sub`{search}`{replace} * ssed sub:{line-number}:replace-lines * * Aliases: * ssed s/{pattern}/{replace} * * @example using regex * this is text | sub/this/that => that is text * * @example Using line numbers * this * is * text | sub:1:that => that * is * text */ class SubstitutionRule extends LineRule { constructor(delim, pattern, replace, flags) { replace ??= '' requireArg('pattern', pattern) super(delim, pattern, replace, []) if (delim === ':') { const lineRangeTest = toLineRangeTest(pattern) this.line = (line, lineNo) => { if (lineRangeTest(line, lineNo)) { return replace } return line } } else if (delim === '`') { if (flags && flags.includes('i')) { this.line = line => { const index = line.toLowerCase().indexOf(pattern.toLowerCase()) if (~index) { return ( line.slice(0, index) + replace + line.slice(index + pattern.length) ) } return line } } else { this.line = line => line.replace(pattern, replace) } } else { const regex = new RegExp(pattern, flags) this.line = line => line.replace(regex, replace) } } explain() { return `Replace ${explainPattern( this.delim, this.pattern, 'pattern', )} with '${this.replace}'` } } /** * Replaces every match with the substitution. * * The '`' delimiter can be used to match literal strings, all other delimiters * match using Regex. * * Does not support line numbers. * * Usage: * ssed gsub/{pattern} * ssed gsub`{search} * * Aliases: * ssed g/{pattern} * * @example Using regex * this is text | gsub/t/T => This is TexT * this is text | g|t|T => This is Text */ class GlobalSubstitutionRule extends LineRule { constructor(delim, pattern, replace, flags) { if (delim === ':') { throw new ArgError(`'gsub' does not support line numbers (use 'sub')`) } replace ??= '' requireArg('pattern', pattern) super(delim, pattern, replace ?? '', []) if (delim === '`') { if (flags && flags.includes('i')) { this.line = line => { let index = 0 let buffer = '' let remainder = line while (~index) { index = remainder.toLowerCase().indexOf(pattern.toLowerCase()) if (~index) { buffer += remainder.slice(0, index) + replace remainder = remainder.slice(index + pattern.length) } } return buffer + remainder } } else { this.line = line => line.replaceAll(pattern, replace) } } else { const regex = new RegExp(pattern, 'g' + flags) this.line = line => line.replace(regex, replace) } } run(line, lineNo, state, options) { if (line != null) { return this.line(line, lineNo) } } explain() { return `Replace all matches of ${explainPattern( this.delim, this.pattern, 'pattern', )} with '${this.replace}'` } } /** * Splits a line into multiple lines. Does not support line numbers. Default split is whitespace * * Usage: * ssed split/{pattern} * ssed split`{search} * * @example using regex * this is text | split/' ' => that * is * text */ class SplitRule extends LineRule { constructor(delim, pattern, flags) { if (delim === ':') { throw new ArgError(`'split' does not support line numbers`) } pattern ??= delim === '`' ? ' ' : '\\w' super(delim, pattern, '', []) if (delim === '`') { if (flags && flags.includes('i')) { this.line = line => { const lines = [] let index = 0 const parts = [] let remainder = line while (~index) { index = remainder.toLowerCase().indexOf(pattern.toLowerCase()) if (~index) { lines.push(remainder.slice(0, index)) remainder = remainder.slice(index + pattern.length) } } lines.push(remainder) return lines } } else { this.line = line => line.split(pattern) } } else { const regex = new RegExp(pattern, flags) this.line = line => line.split(regex) } } explain() { return `Replace ${explainPattern( this.delim, this.pattern, 'pattern', )} with '${this.replace}'` } } /** * Only print the matching part of the line, or print the entire line if 'pattern' * doesn't match. (hint: Use `takeprint / tp` to only print matching lines) * * The '`' delimiter can be used to match literal strings, all other delimiters * match using Regex. * * Does not support line numbers. * * Usage: * ssed take/{pattern} * ssed take`{search} * * Aliases: * ssed t/{pattern} * * @example Using regex * this is text | take/t\w+ => this * this is text | t/t\w+ => this * how now | take/t\w+ => how now */ class TakeRule extends LineRule { constructor(delim, pattern, flags) { if (delim === ':') { throw new ArgError(`'take' does not support line numbers`) } super(delim, pattern, '', []) if (delim === '`') { this.line = line => { if (line.includes(pattern)) { return pattern } return line } } else { const regex = new RegExp(pattern, flags) this.line = line => { const match = line.match(regex) return match ? match[0] : line } } } explain() { return `If the line matches ${explainPattern( this.delim, this.pattern, 'pattern', )}, only print the matching part of the line.` } } /** * Removes the matching part of the line, or print the entire line if 'pattern' * doesn't match. * * The '`' delimiter can be used to match literal strings, all other delimiters * match using Regex. * * Usage: * ssed rm/{pattern} * ssed rm`{pattern} * * Aliases: * ssed r/{pattern} * * @example Using regex * this is text | rm/^\w+ is/ => text */ class RemoveRule extends LineRule { constructor(delim, pattern, flags) { if (delim === ':') { throw new ArgError(`'rm' does not support line numbers`) } super(delim, pattern, '', []) if (delim === '`') { this.line = line => line.replace(pattern, '') } else { const regex = new RegExp(pattern, flags) this.line = line => line.replace(regex, '') } } explain() { return `Remove ${explainPattern( this.delim, this.pattern, 'pattern', )} from every line` } } /** * Only print lines that match 'pattern'. * * When matching line numbers, only the matching lines are printed. This works * identically to the 'take' command. * * Usage: * ssed print/{pattern} * ssed print:{line-number} * * Aliases: * ssed p/{pattern} * ssed p`{search} * * @example Using regex * this * is * text | p/^t => this * text * * @example Using line numbers * this * is * text | print:2-3 => is * text */ class PrintLineRule extends LineRule { check constructor(delim, pattern, flags) { pattern ??= '' super(delim, pattern, '', []) if (delim === ':') { this.check = toLineRangeTest(pattern) } else if (delim === '`') { this.check = line => line.includes(pattern) } else { const regex = new RegExp(pattern, flags) this.check = line => line.match(regex) } } line(line, lineNo) { return this.check(line, lineNo) ? line : undefined } explain() { return `Print ${explainPattern(this.delim, this.pattern, 'matching-lines')}` } } /** * Removes lines that match the pattern. Inverse of "print". * * Usage: * ssed del/{pattern} * ssed del:{line-number} * * Aliases: * ssed d/{pattern} * ssed !p/{pattern} * ssed !print/{pattern} * * When matching line numbers, the matching lines are removed. * * @example Using regex * this * is * some * text | d/is => some * text * * @example Using line numbers * this * is * text | del:1 => is * text */ class DeleteLineRule extends LineRule { check constructor(delim, pattern, flags) { super(delim, pattern ?? '', '', []) if (delim === ':') { this.check = toLineRangeTest(pattern) } else if (delim === '`') { this.check = line => line.includes(pattern) } else { const regex = new RegExp(pattern, flags) this.check = line => { return line.match(regex) } } } line(line, lineNo) { return this.check(line, lineNo) ? undefined : line } explain() { if (!this.pattern && this.delim !== ':') { return 'Remove all lines' } return `Remove ${explainPattern( this.delim, this.pattern, 'matching-lines', )}` } } /** * Only prints unique lines. Default behaviour matches the entire line, but you can * specify a regular expression to only match part of the line. If the line doesn't * match, it isn't printed. * * Does not support line-numbers. * * Usage: * ssed unique * ssed unique/{pattern} * * Aliases: * ssed uniq * * @example * alice * alice * bob * bob | uniq => alice * bob * * @example Using regex * who is alice? * what is alice? * I don't know. * do you know bob? * does anyone know bob? | uniq/\w+\? => who is alice? * do you know bob? */ class UniqueRule extends LineRule { part constructor(delim, pattern, flags) { super(delim, pattern, '', []) if (delim === ':') { throw new ArgError(`'uniq' does not support line numbers`) } if (delim === '`') { throw new ArgError(`'uniq' does not support literal matches`) } if (pattern) { const regex = new RegExp(pattern, flags) this.part = line => { const match = line.match(regex) return match ? match[0] : null } } else { this.part = line => line } const uniqueLines = new Set() this.line = line => { const match = this.part(line) if (match == null) { return null } if (!uniqueLines.has(match)) { uniqueLines.add(match) return line } } } explain() { if (this.pattern) { return `Only print lines that match ${explainPattern( this.delim, this.pattern, 'pattern', )}. Uniqueness is determined by the matching part of the line. The entire line is printed.` } return 'Only print unique lines' } } /** * Trims whitespace from the line. Defaults to trimming whitespace on both sides, * but 'left' or 'right' can be specified to only trim on one side. * * Usage: * ssed trim * ssed trim:left * ssed trim:right * * @example '|' indicates start and end of line * | line 1| * |line 2 | * | line 3 | ssed trim => |line 1| * |line 2| * |line 3| * * @example * | line 1| * |line 2 | * | line 3 | ssed trim:right => | line 1| * |line 2| * | line 3| */ class TrimRule extends LineRule { trim constructor(delim, pattern) { super(delim, pattern, '', []) if (delim === ':') { throw new ArgError(`'trim' does not support line numbers`) } // Set the appropriate trim function based on the pattern switch (pattern) { case 'left': this.trim = line => line.replace(/^\s+/, '') break case 'right': this.trim = line => line.replace(/\s+$/, '') break case 'both': default: this.trim = line => line.trim() break } this.line = line => this.trim(line) } explain() { switch (this.pattern) { case 'left': return 'Trim whitespace from the left side of the line' case 'right': return 'Trim whitespace from the right side of the line' default: return 'Trim whitespace from both sides of the line' } } } /** * Surround each line with text. * * Usage: * ssed surround/{prepend}/{append} * * @example * 1 * 2 * 3 * 4 | 'surround:->:<-' => ->1<- * ->2<- * ->3<- * ->4<- */ class SurroundRule extends LineRule { prepend append constructor(delim, prepend, append) { super(delim, '', '', []) // pattern and replace are not used in this rule this.prepend = prepend ?? '' this.append = append ?? '' } line(line) { return this.prepend + line + this.append } explain() { return `Surround each line with '${this.prepend}' and '${this.append}'` } } /** * Prepends (prefix) each line with text. * * Usage: * ssed prepend/{text} * * Aliases: * ssed prefix/{text} * * @example * 1 * 2 * 3 * 4 | prepend:'line ' => line 1 * line 2 * line 3 * line 4 */ class PrependRule extends SurroundRule { constructor(delim, prependText) { super(delim, prependText, '') } explain() { return `Prepend each line with '${this.prepend}'` } } /** * Append (suffix) each line with text. * * Usage: * ssed append/{text} * * Aliases: * ssed suffix/{text} * * @example * 1 * 2 * 3 * 4 | append:' foo' => 1 foo * 2 foo * 3 foo * 4 foo */ class AppendRule extends SurroundRule { constructor(delim, appendText) { super(delim, '', appendText) } explain() { return `Append each line with '${this.append}'` } } /** * Insert text after each matching line. * * Usage: * ssed insert/{pattern}/{text} * * @example * 1 * 2 * 3 * 4 | 'insert:%2:!!!' => ->1<- * ->2<- * ->3<- * ->4<- */ class InsertRule extends LineRule { check constructor(delim, pattern, text, flags) { requireArg('pattern', pattern) requireArg('text', text) super(delim, pattern, text, []) if (delim === ':') { this.check = toLineRangeTest(pattern) } else if (delim === '`') { this.check = line => line.includes(pattern) } else { const regex = new RegExp(pattern, flags) this.check = line => line.match(regex) } } line(line, lineNo) { if (this.check(line, lineNo)) { return [line, this.replace] // this.replace holds the text to be inserted } return line } explain() { return `After ${explainPattern( this.delim, this.pattern, 'pattern', )} insert '${this.replace}'` } } /** * Commands to turn printing on and off based on a pattern or line number. * * on: turn printing on at the matching line * after: turn printing on after the matching line * off: turn printing off at the matching line * toggle: printing starts on, toggles on matching lines * * Usage: * ssed on/{pattern} * ssed on:{line-number} * ssed after/{pattern} * ssed off/{pattern} * ssed toggle/{pattern} * * @example * a * b * c * d * e * f * g | on/b off/d after/f => b # starts "off", is turned on by matching 'b' * c # still "on", turned off by matching 'd' * g # off until 'f', then turned on starting at next line */ class ControlPrintingRule extends LineRule { cmd check constructor(cmd, delim, pattern, flags) { super(delim, pattern, '', []) this.cmd = cmd if (!delim) { this.check = (_, lineNo) => lineNo === 1 } else if (delim === ':') { this.check = toLineRangeTest(pattern) } else if (delim === '`') { this.check = line => line.includes(pattern) } else { const regex = new RegExp(pattern, flags) this.check = line => line.match(regex) } } before(state) { if (state.printOn == null) { if (this.cmd === 'on' || this.cmd === 'after') { state.printOn = false } else if (this.cmd === 'off' || this.cmd === 'toggle') { state.printOn = true } } } beforeEach(line, state) { state.suppressedLine = null } run(line, lineNo, state) { if ( this.cmd === 'on' && state.suppressedLine != null && this.check(state.suppressedLine, lineNo) ) { state.printOn = true line = state.suppressedLine } else if ( this.cmd === 'on' && line != null && (state.printOn || this.check(line, lineNo)) ) { state.printOn = true } else if ( this.cmd === 'off' && state.printOn && line != null && this.check(line, lineNo) ) { state.printOn = false } else if ( this.cmd === 'toggle' && line != null && this.check(line, lineNo) ) { state.printOn = !state.printOn } if (state.printOn) { state.suppressedLine = null return line } else { state.suppressedLine ??= line } if ( this.cmd === 'after' && state.suppressedLine != null && !state.printOn && this.check(state.suppressedLine, lineNo) ) { state.printOn = true } else if ( this.cmd === 'after' && line != null && this.check(line, lineNo) ) { state.printOn = true } } explain(rules) { const firstIndex = rules.findIndex( rule => rule instanceof ControlPrintingRule, ) const myIndex = rules.findIndex(rule => rule === this) const isFirst = firstIndex === myIndex const matchingLines = explainPattern( this.delim, this.pattern, 'matching-lines', ) if (this.cmd === 'on') { return `Printing ${ isFirst ? 'starts off, and ' : '' }is turned on at ${matchingLines}` } if (this.cmd === 'off') { return `Printing ${ isFirst ? 'starts on, and ' : '' }is turned off at ${matchingLines}` } if (this.cmd === 'toggle') { return `Printing ${ isFirst ? 'starts on, and ' : '' }is toggled at ${matchingLines}` } // cmd === 'after' return `Printing ${ isFirst ? 'starts off, and ' : '' }is turned on after ${matchingLines}` } } /** * Replaces the pattern with the nth group of the match. If no pattern is given, * the line is separated by whitespace and the nth column is printed. * * Does not support line-numbers. * * Usage: * # for every line that matches pattern, replace the Nth regex group * ssed 1/{pattern(group1)} * ssed 2/{pattern(group1)(group2)} * ssed …/{pattern(…)} * * # print the first "column" (columns are separated by whitespace, quotes are * # ignored) * ssed 1 * * @example * text | 1/\w(\w+)$ => ext * this is text | 2/(\w+) (\w+) (\w+) => is */ class GroupMatchRule extends LineRule { index constructor(index, delim, pattern, flags) { super(delim, pattern, '', []) this.index = index if (delim === ':') { throw new ArgError(`'${index}' does not support line numbers`) } if (delim === '`') { throw new ArgError(`'${index}' does not support literal matches`) } if (!pattern) { this.line = line => { const match = line.split(/\s+/, index) return match.length >= index - 1 ? match[index - 1] : line } } else { const regex = new RegExp(pattern, flags) this.line = line => { const match = line.match(regex) return match && match[index] ? match[index] : line } } } explain() { if (!this.pattern) { return `Print column #${this.index}, columns are separated by whitespace` } return `Replace ${explainPattern( this.delim, this.pattern, 'pattern', )} with group #${this.index}` } } /** * A straightforward 'awk' feature: separate line on whitespace and print certain columns. * * Usage: * # Use default separator (\s+) and print columns 1,3,2 * ssed cols//1,3,2 * * # Split lines by ':' and print columns 1 and 5 * # (columns will be joined with a space) * ssed cols/:/1,5 * * # Same, but join columns using ':' * ssed cols/:/1,5/: * * @example * this is text | cols//2,1,3 => is this text * 1:bb:3:4:Z | cols/:/1,3,2 => 1 3 bb */ class ColumnsRule extends LineRule { columns joiner constructor(delim, pattern, columns, args) { pattern ??= '' super(delim, pattern, '', []) requireArg('columns', columns) if (delim === ':') { throw new Error(`'cols' does not support line numbers`) } if (!columns.match(/^\d+(,\d+)*$/)) { throw new Error( `Invalid columns '${columns}', expected a comma-separated list of numbers`, ) } this.columns = columns.split(',').map(Number) let splitter if (pattern && delim === '`') { splitter = pattern } else if (pattern) { splitter = new RegExp(pattern === '' ? /\s+/ : pattern) } else { splitter = /\s+/ } this.joiner = args[0] ?? ' ' this.line = line => { const parts = line.split(splitter) return this.columns.map(index => parts[index - 1] ?? '').join(this.joiner) } } explain() { let explanation = '' if (this.pattern) { explanation = `Split each line into columns using ${explainPattern( this.delim, this.pattern, 'pattern', )}` } else { explanation = 'Split each line into columns by whitespace' } explanation += ` and print columns ${this.columns.join(', ')}` if (this.joiner) { explanation += ` joined by '${this.joiner}'` } else { explanation += ` joined by ' '` } return explanation } } class CombineRules extends LineRule { _rules constructor(...rules) { super('', '', '', []) // Delim, pattern, replace, and args are not directly used in CombineRules this._rules = rules } rules() { return this._rules } before(state) { this._rules.forEach(rule => rule.before(state)) } beforeEach(line, state) { this._rules.forEach(rule => rule.beforeEach(line, state)) } run(line, lineNo, state) { return this._rules.reduce( (line, rule) => rule.run(line, lineNo, state), line, ) } afterEach(line, state) { this._rules.forEach(rule => rule.afterEach(line, state)) } after(state) { this._rules.forEach(rule => rule.after(state)) } explain(rules) { return this._rules.map(rule => rule.explain(rules)).join('\n') } } class TapRule extends LineRule { constructor() { super('', '', '', []) } line(line, lineNo) { process.stderr.write(line + '\n') return line } explain() { return 'Output each line to STDERR' } } function toLineRule([cmd, pattern, replace, rest, delim]) { switch (cmd) { case 'sub': case 's': return new SubstitutionRule(delim, pattern, replace, rest[0] || '') case 'gsub': case 'g': return new GlobalSubstitutionRule(delim, pattern, replace, rest[0] || '') case 'split': return new SplitRule(delim, pattern, replace ?? '') case 'take': case 't': return new TakeRule(delim, pattern, rest[0] || '') case 'rm': case 'r': return new RemoveRule(delim, pattern, rest[0] || '') case 'print': case 'p': return new PrintLineRule(delim, pattern, rest[0] || '') case 'takeprint': case 'pt': case 'tp': { const take = new TakeRule(delim, pattern, rest[0] || '') const print = new PrintLineRule(delim, pattern, rest[0] || '') return new CombineRules(print, take) } case 'rmprint': case 'pr': case 'rp': { const remove = new RemoveRule(delim, pattern, rest[0] || '') const print = new PrintLineRule(delim, pattern, rest[0] || '') return new CombineRules(print, remove) } case 'del': case 'd': case '!p': case '!print': return new DeleteLineRule(delim, pattern, replace || '') case 'uniq': case 'unique': return new UniqueRule(delim, pattern, replace || '') case 'trim': return new TrimRule(delim, pattern) case 'prepend': case 'prefix': return new PrependRule(delim, pattern) case 'append': case 'suffix': return new AppendRule(delim, pattern) case 'surround': return new SurroundRule(delim, pattern, replace) case 'insert': return new InsertRule(delim, pattern, replace, rest[0] || '') case 'on': case 'off': case 'after': case 'toggle': return new ControlPrintingRule(cmd, delim, pattern, rest[0] || '') case 'cols': return new ColumnsRule(delim, pattern, replace, rest) case 'tap': return new TapRule() default: if (cmd.match(/^\d+$/)) { const index = parseInt(cmd) return new GroupMatchRule(index, delim, pattern, rest[0] || '') } } } class DocumentSortRule extends DocumentRule { ascending constructor(pattern, replace, ascending, flags) { super('', pattern, replace, []) const regex = pattern && new RegExp(pattern, flags) this.ascending = ascending this.lines = documentLines => documentLines .map(([line]) => line) .toSorted((a, b) => { if (regex) { let aMatch = a.match(regex) let bMatch = b.match(regex) if (aMatch && bMatch) { if (this.replace) { aMatch = a.replace(regex, this.replace) bMatch = b.replace(regex, this.replace) } else { aMatch = aMatch[0] bMatch = bMatch[0] } return aMatch.localeCompare(bMatch) * (this.ascending ? 1 : -1) } else { return 0 } } return a.localeCompare(b) * (this.ascending ? 1 : -1) }) } explain() { const leading = `Sort lines in${ this.ascending ? '' : ' reverse' } alphabetical order` if (this.pattern && this.replace) { return `${leading} based on the matching part of ${explainPattern( this.delim, this.pattern, 'pattern', )} after replacing with '${this.replace}'` } else if (this.pattern) { return `${leading} based on the matching part of ${explainPattern( this.delim, this.pattern, 'pattern', )}` } else { return leading } } } class DocumentSortNumericRule extends DocumentRule { ascending constructor(pattern, replace, ascending, flags) { super('', pattern, replace, []) const regex = pattern && new RegExp(pattern, flags) this.ascending = ascending this.lines = documentLines => documentLines .map(([line]) => line) .toSorted((a, b) => { if (regex) { let aMatch = a.match(regex) let bMatch = b.match(regex) if (aMatch && bMatch) { if (replace) { a = a.replace(regex, replace) b = b.replace(regex, replace) } else { a = aMatch[0] b = bMatch[0] } } else { return 0 } } let numberA = a.replace(/^.*?(-?\b\d+(\.\d*)?).*$/, '$1') if (numberA.isNaN()) { numberA = 0 } let numberB = b.replace(/^.*?(-?\b\d+(\.\d*)?).*$/, '$1') if (numberB.isNaN()) { numberB = 0 } return (Number(numberA) - Number(numberB)) * (ascending ? 1 : -1) }) } explain() { const leading = `Sort lines in${ this.ascending ? '' : ' reverse' } numeric order` if (this.pattern && this.replace) { return `${leading} based on the matching part of ${explainPattern( this.delim, this.pattern, 'pattern', )} after replacing with '${this.replace}'` } else if (this.pattern) { return `${leading} based on the matching part of ${explainPattern( this.delim, this.pattern, 'pattern', )}` } else { return leading } } } class DocumentReverseRule extends DocumentRule { constructor() { super('', '', '', []) } lines(documentLines) { return documentLines.toReversed() } explain() { return 'Reverse the order of all lines' } } class DocumentLineNumbersRule extends DocumentRule { minWidth constructor(pattern, replace) { super('', pattern, null, []) if (replace) { const minWidth = replace ? Number(replace) : 0 if (minWidth.isNaN()) { throw new ArgError(`Invalid width '${replace}', expected a number`) } else { this.minWidth = minWidth this.pad = '0' } } else { this.pad = ' ' this.minWidth = 0 } } lines(documentLines) { const max = documentLines.length.toString().length return documentLines.map(([line, lineNo]) => { let lineNoStr = lineNo.toString() let pad if (this.replace) { pad = '0'.repeat(Math.max(Number(this.replace), max) - lineNoStr.length) } else { pad = ' '.repeat(max - lineNoStr.length) } const joiner = this.pattern ?? ':' return pad + lineNoStr + joiner + line }) } explain() { let leading = `Add line numbers to each line` if (this.pattern !== ':') { leading += ` with '${this.pattern ?? ':'}' as separator` } if (this.minWidth) { leading += ` and padding with '${this.replace ? '0' : 'spaces'}'` } return leading } } /** * Similar to the line substitution rule, but the substituted text is "spread" * across all the lines that match. */ class DocumentSubstituteLinesRule extends DocumentRule { constructor(delim, pattern, replace, flags) { requireArg('pattern', pattern) super(delim, pattern, replace, []) let check if (delim === ':') { check = toLineRangeTest(pattern) } else if (delim === '`') { if (flags && flags.includes('i')) { check = line => line.toLowerCase().includes(pattern.toLowerCase) } else { check = line => line.includes(pattern) } } else { const regex = new RegExp(pattern, flags) check = line => line.match(regex) } const replaceLines = replace.split('\n') this.lines = documentLines => { const matchedLineNumbers = [] // find all the lines that match, put into matchedLineNumbers // if (matchedLineNumbers >= replaceLines) { // remove extra matched lines // } else (matchedLineNumbers < replaceLines) { // add matched lines 1:1 with replaceLines until we // get to the last matched line, then insert all // remaining lines // } documentLines.forEach(([line, lineNo]) => { if (check(line, lineNo)) { matchedLineNumbers.push(lineNo) } }) return documentLines.flatMap(([line, lineNo]) => { if (matchedLineNumbers.length && matchedLineNumbers[0] === lineNo) { matchedLineNumbers.shift() if (matchedLineNumbers.length === 0) { return replaceLines } else { const nextLine = replaceLines.shift() if (nextLine == null) { return [] } else { return [nextLine] } } } return [line] }) } } explain() { return `Substitute lines matching pattern '${explainPattern( this.delim, this.pattern, 'pattern', )}' with provided replacement lines` } } /** * Similar to the line substitution rule, but the substituted text is "spread" * across all the lines that match. */ class DocumentExecRule extends DocumentRule { constructor() { super('', '', '', []) } lines(documentLines, options) { const input = documentLines.map(([line]) => line).join('\n') if (options.dryRun) { return input.split('\n') } if (input) { return execSync(input).toString('utf-8').split('\n') } return [] } explain() { return 'Execute entire document as a shell script and replace with the command output' } } class DocumentSurroundRule extends DocumentRule { prependLines appendLines constructor(prependText, appendText) { super('', '', '', []) this.prependLines = prependText === '' ? [] : prependText.split('\n') this.appendLines = appendText === '' ? [] : appendText.split('\n') } lines(documentLines) { const lines = documentLines.map(([line]) => line) return [...this.prependLines, ...lines, ...this.appendLines] } blankLastLine() { return null } explain() { let explanation = '' if (this.prependLines.length) { explanation += `Prepend document with '''\n${this.prependLines.join( '\\n', )}\n'''` } if (this.prependLines.length && this.appendLines.length) { explanation += ' and append ' } else if (this.appendLines.length) { explanation += 'Append ' } if (this.appendLines.length) { explanation += `document with '''\n${this.appendLines.join('\\n')}\n'''` } return explanation } } class DocumentJoinLinesRule extends DocumentRule { joiner constructor(joiner) { super('', '', '', []) this.joiner = joiner || ' ' } lines(documentLines) { return [documentLines.map(([line]) => line).join(this.joiner)] } blankLastLine() { return null } explain() { return `Join all lines into a single line using '${this.joiner}' as the separator` } } class DocumentCountRule extends DocumentRule { constructor() { super('', '', '', []) } lines(documentLines) { return [documentLines.length.toString()] } blankLastLine() { return null } explain() { return 'Count the number of lines in the document and replace the content with the count' } } class DocumentLineRules extends DocumentRule { rule constructor(lineRules) { super('', '', '', []) this.rule = new OnlyLineRules(lineRules) } rules() { return this.rule._rules.flatMap(rule => rule.rules()) } lines(documentLines, options) { const state = resetState() const inputLines = documentLines.map(([line]) => line) return this.rule.processLines(inputLines, state, options) } explain(rules) { return this.rule._rules.map(rule => rule.explain(rules)).join('\n') } } class DocumentCatRule extends DocumentRule { constructor() { super('', '', '', []) } lines(documentLines) { return documentLines.map(([line]) => line) } blankLastLine() { return null } explain() { return 'Print the entire document, resetting the line numbers' } } function toDocumentRule([cmd, pattern, replace, rest, delim]) { switch (cmd) { case 'cat': return new DocumentCatRule() case 'count': return new DocumentCountRule() case '!sort': case 'sort': return new DocumentSortRule( pattern, replace, !cmd.startsWith('!'), rest[0] || '', ) case '!sortn': case 'sortn': return new DocumentSortNumericRule( pattern, replace, !cmd.startsWith('!'), rest[0] || '', ) case 'reverse': return new DocumentReverseRule() case 'begin': case 'border': return new DocumentSurroundRule(pattern ?? '', replace ?? '') case 'end': return new DocumentSurroundRule('', pattern) case 'line': case 'lines': return new DocumentLineNumbersRule(pattern, replace) case 'join': return new DocumentJoinLinesRule(pattern) case 'sl': case 'sublines': return new DocumentSubstituteLinesRule( delim, pattern, replace, rest[0] || '', ) case 'exec': return new DocumentExecRule() case 'help': help() process.exit(0) case 'version': version() process.exit(0) case 'docsurround': console.error('`' + cmd + '` has been removed, use `border` instead') process.exit(1) case 'docprepend': case 'docprefix': console.error('`' + cmd + '` has been removed, use `begin` instead') process.exit(1) case 'docappend': case 'docsuffix': console.error('`' + cmd + '` has been removed, use `end` instead') process.exit(1) } } function escapeRegex(pattern) { if (pattern == null) { throw new Error('Pattern is required') } return pattern.replaceAll('/', '\\/') } function escapeLiteral(pattern) { return pattern.replaceAll("'", "\\'") } function explainPattern(delim, pattern, text) { if (delim === ':') { return _toLineRangeTest(pattern, 'explain') } if (delim === '`') { if (text === 'pattern') { return `'${escapeLiteral(pattern)}'` } else if (text === 'matching-lines') { return `lines that match '${escapeLiteral(pattern)}'` } } if (text === 'pattern') { return `/${escapeRegex(pattern)}/` } else if (text === 'matching-lines') { return `lines that match /${escapeRegex(pattern)}/` } return `/${escapeRegex(pattern)}/` } function toLineRangeTest(pattern) { return _toLineRangeTest(pattern, 'run') } function _toLineRangeTest(pattern, job) { const matchModulo = /%\s*\d+\s*(-\s*\d+)?/ const matchRange = /\d+\s*-\s*\d+|-\s*\d+|\d+\s*-|\d+/ const valid = new RegExp( `^\\*|(${matchModulo.source}|${matchRange.source})(\s*,\s*(${matchModulo.source}|${matchRange.source}))*$`, ) if (!pattern || !pattern.match(valid)) { const allowed = [ '', '* (all lines)', '$start-$stop (line range)', '$start-, -$stop (starting at line, ending at line)', '%$frequency (every Nth line)', '%$frequency-$offset (every Nth line minus offset)', '$range1, $range2 [, $range3] (multiple ranges)', ] throw new ArgError( 'Invalid line range pattern, expected:' + allowed.join('\n - '), ) } if (!pattern || pattern === '*') { if (job === 'explain') { return 'all lines' } return () => true } if (pattern.includes(',')) { const rules = pattern.split(',').map(rule => toLineRangeTest(rule, job)) if (job === 'explain') { return `(${rules.join(' or ')})` } return (line, lineNo) => rules.some(fn => fn(line, lineNo)) } if (pattern.startsWith('%')) { let modulus = pattern.slice(1) if (modulus.includes('-')) { const [mod, offset] = modulus.split('-', 2).map(Number) if (job === 'explain') { return `every ${mod}-th - ${offset} line` } return (_, lineNo) => (lineNo + offset) % mod === 0 } else { if (job === 'explain') { return `every ${modulus}-th line` } return (_, lineNo) => lineNo % Number(modulus) === 0 } } if (pattern.includes('-')) { const [start, stop] = pattern.split('-', 2).map((number, index) => { if (number === '' && index === 0) { return 1 } else if (number === '' && index === 1) { return Infinity } else { return Number(number) } }) if (job === 'explain') { return start === 1 && stop === Infinity ? 'all lines' : start === 1 ? `all lines up to ${stop}` : stop === Infinity ? `lines from ${start} to end` : `lines from ${start} to ${stop}` } return (_, lineNo) => lineNo >= start && lineNo <= stop } if (job === 'explain') { return `line ${Number(pattern)}` } return (_, lineNo) => lineNo === Number(pattern) } function lineStream(inputStream, transform = undefined) { const stdin = new EventEmitter() let buffer = '' let closed = false function emit(line) { if (closed) { return } if (transform) { line = transform(line) } if (line !== undefined) { stdin.emit('line', line) } } inputStream.on('open', function () { stdin.emit('open') }) inputStream.on('data', function (chunk) { buffer += chunk const lines = buffer.split('\n') buffer = lines.pop() lines.forEach(line => emit(line)) }) inputStream.on('end', () => { emit(buffer) let autoclose = true function keepOpen() { autoclose = false } function close() { stdin.emit('close') } stdin.emit('end', keepOpen, close) if (autoclose) { close() } }) inputStream.resume() inputStream.setEncoding('utf-8') stdin.pause = inputStream.pause.bind(inputStream) stdin.close = () => { closed = true inputStream.pause() } stdin.pipe = transform => { return lineStream(stdin, transform) } return stdin } function parseOption(options, arg, args) { let option if (arg.startsWith('--')) { ;[option] = arg.slice(2).match(/^([\w-]+)/) ?? [option] } else if (arg.startsWith('-')) { ;[option] = arg.slice(1).match(/^(\w+)/) ?? [option] return option.split('').map(opt => parseOption(options, `--${opt}`, args)) } else { return } switch (option) { case 'h': case 'help': { help() process.exit(0) } case 'version': { version() process.exit(0) } case 'explain': return { explain: true, } case 'no-color': case 'color': { return { color: option === 'color', } } case 'n': case 'no-dry-run': case 'dry-run': { return { dryRun: option === 'dry-run', } } case 'no-diff': case 'diff': { return { diff: option === 'diff', } } case 'no-interactive': case 'interactive': { return { interactive: option === 'interactive', } } case 'no-write': return { write: false, diff: false, writeToFile: undefined, } case 'no-write-to': case 'no-write-replace': throw new ArgError(`Invalid option: ${option}, use \`--no-write\``) case 'write': { let writeToFile if (arg.match(/^--\w+=/)) { ;[, writeToFile] = arg.split('=', 2) } return { write: option === 'write', diff: option === 'write', writeToFile, } } case 'write-to': { let writeToFile if (arg.match(/^--\w+=/)) { ;[, writeToFile] = arg.split('=', 2) } else { writeToFile = args.shift() } return { write: option === 'write', diff: option === 'write', writeToFile, } } case 'write-rename': { let writeToFile if (arg.match(/^--\w+=/)) { ;[, writeToFile] = arg.split('=', 2) } else { writeToFile = args.shift() } return { write: true, diff: true, writeToFile, renameInput: true, } } case 'stdin': return {inputFrom: 'stdin', input: process.stdin} case 'ls': return {inputFrom: 'ls', input: null} case 'input': { let file if (arg.match(/^--\w+=/)) { ;[, file] = arg.split('=', 2) } else { file = args.shift() } const fileNames = file.split(',') const prevFiles = options.inputFrom === 'files' ? options.input : options.inputFrom === 'file' ? [options.input] : [] const files = [...prevFiles, ...fileNames].map(file => { if (fs.existsSync(file)) { return file } else { throw new ArgError(`File does not exist: ${file}`) } }) if (files.length === 0) { throw new ArgError(`No files provided`) } else if (files.length === 1) { return { inputFrom: 'file', input: files[0], } } else { return { inputFrom: 'files', input: files, } } break } default: throw new ArgError(`Invalid option: ${option}`) } } function main(args, options = {}) { options = { input: process.stdin, inputFrom: 'stdin', explain: false, interactive: false, write: false, writeToFile: null, renameInput: false, // replace '%' of input diff: false, diffHeader: '', diffContext: 3, // number of lines to print before and after diffs color: process.stdout.isTTY, print: (line, nl = true) => { process.stdout.write(line + (nl ? '\n' : '')) }, error: (line, nl = true) => { process.stderr.write(line + (nl ? '\n' : '')) }, ...options, } const argsCopy = [...args] const rules = [] let arg while ((arg = argsCopy.shift()) != null) { const option = parseOption(options, arg, argsCopy) if (option !== undefined) { options = {...options, ...option} } else { rules.push(arg) } } run(rules, options) } function run(rules, options) { const rulesIter = (function* () { for (let index = 0; index < rules.length; index++) { yield rules[index] } })() const onlyRule = processRules(rulesIter) if (options.explain) { options.print(onlyRule.explain()) process.exit(0) } // inputFrom: 'stdin' => parse stdin only (` input: list of files (`ssed --input=file1,file2`) // inputFrom: 'ls' => read filenames from stdin (`ssed --input` - or `ssed --ls`) if (options.inputFrom === 'ls' || options.inputFrom === 'files') { const files = [] function next() { const file = files.pop() if (file == null) { return } run(rules, { ...options, inputFrom: 'file', input: file, }) .on('open', () => { if (!options.diff) { options.print(yellow(`ssed: ${file}`)) } }) .on('end', () => { next() }) } if (options.inputFrom === 'ls') { const stdin = lineStream(process.stdin) // treat stdin as list of files stdin .on('line', file => { if (file === '') { return } files.push(file) }) .on('end', () => { next() }) } else { files.push(...options.input) next() } return } if (options.inputFrom === 'file') { const file = options.input let diff = `ssed` if (options.write && !options.writeToFile && file !== process.stdin) { options.writeToFile = file diff = file } else if (options.writeToFile && options.renameInput) { options.writeToFile = options.writeToFile.replace('%', file) diff = options.writeToFile } options.diffHeader = `diff ${options.input} --- ${options.input} +++ ${diff}` options.input = fs.createReadStream(file) } else if ( options.inputFrom === 'stdin' && options.write && !options.writeToFile ) { throw new ArgError( `When reading from stdin, --write must be accompanied with a filename`, ) } const inputStream = lineStream(options.input) const originalLines = [] let finalLines = [] // if ( // we only have line rules, // no document rules, // and we're not diffing the output // ), then: we perform a "stream edit". // // 1. create a state for the line rules // 2. don't store the output in lines // 3. output immediately const state = resetState() onlyRule.before(state, options) return inputStream .on('line', line => { eachLine(onlyRule, line, originalLines, finalLines, state, options) }) .on('end', (keepOpen, close) => { finalLines = finalize(onlyRule, originalLines, finalLines, state, options) onlyRule.after(state, options) if (options.diff) { let inputLines = originalLines if (options.writeToFile) { const file = options.writeToFile if (fs.existsSync(file)) { inputLines = fs.readFileSync(file, 'utf-8').split('\n') } else { inputLines = null } } const linesDiff = inputLines ? diff(inputLines, finalLines) : [] const hasDiff = linesDiff.length === 0 || (linesDiff.length && !(linesDiff.length === 1 && linesDiff[0].type === 'same')) if (hasDiff) { renderDiff(linesDiff, options) if (options.write) { if (options.interactive) { if (!process.stdin.isTTY) { options.error( red( `Cannot run interactively – input is coming from stdin`, options.color, ), ) process.exit(1) } options.print( yellow( `Write changes to ${options.writeToFile}? [Yn] `, options.color, ), false, ) keepOpen() const stdin = lineStream(process.stdin) stdin.on('line', line => { if (line === '' || line === 'y' || line === 'yes') { if (options.dryRun) { options.error( yellow(`Dry Run: ${options.writeToFile}`, options.color), ) } else { writeLinesToFile(finalLines, options.writeToFile) } } else if (line === 'n') { options.error( yellow(`Skipping ${options.writeToFile}`, options.color), ) } else { options.error( red(`Unknown response '${line}'. Aborting`, options.color), ) process.exit(1) } stdin.close() close() }) } else { if (options.dryRun) { options.error( yellow(`Dry Run: ${options.writeToFile}`, options.color), ) } else { writeLinesToFile(finalLines, options.writeToFile) } } } } } else { for (const line of finalLines) { options.print(line) } } }) } function processRules(rulesIter) { let lineRules = [] const documentRules = [] let prevCondRule = null let value, done while (({value: rule, done} = rulesIter.next()) && !done) { if (rule === '}') { break } let lineRule, documentRule if (rule === '{') { const nextRule = processRules(rulesIter) if (nextRule instanceof OnlyDocumentRules) { documentRule = nextRule } else { lineRule = nextRule } } else { const parsed = parseRuleArgs(rule) if ((condRule = toCondRule(parsed))) { if (prevCondRule) { prevCondRule = prevCondRule.addCondition(condRule) } else { prevCondRule = condRule } // NB early exit continue } else if ((lineRule = toLineRule(parsed))) { } else if ((documentRule = toDocumentRule(parsed))) { } else { throw new ArgError(`Invalid rule: ${rule}`) } } if (prevCondRule) { // convert the line/document rule using prevCondRule.reduce*Rule, // then assign the returned rule into a line or document rule // reduce*Rule will return either ConditionalLineRule or ConditionalDocumentRule let nextRule if (lineRule) { nextRule = prevCondRule.toConditionalLineRule(lineRule) } else { nextRule = prevCondRule.toConditionalDocumentRule(documentRule) } if (nextRule instanceof ConditionalLineRule) { lineRule = nextRule documentRule = null } else { lineRule = null documentRule = nextRule } } if (lineRule) { lineRules.push(lineRule) } else if (documentRule) { if (lineRules.length) { documentRules.push(new DocumentLineRules(lineRules)) lineRules = [] } documentRules.push(documentRule) } prevCondRule = null } if (prevCondRule) { throw new ArgError(`Unmatched conditional rule`) } if (lineRules.length && documentRules.length) { documentRules.push(new DocumentLineRules(lineRules)) } if (documentRules.length) { return new OnlyDocumentRules(documentRules) } else { return new OnlyLineRules(lineRules) } } function eachLine(onlyRule, line, originalLines, finalLines, state, options) { const lastLine = originalLines.at(-1) originalLines.push(line) let nextLines = null if (line === '') { if (lastLine === '') { // current line is '' and _previous_ line was '' nextLines = [''] } // else nextLines will go unassigned, and the current blank line will be skipped // (for now) } else if (lastLine === '') { nextLines = [lastLine, line] } else { nextLines = [line] } if (!nextLines) { return } let processedLines = onlyRule.processLines(nextLines, state, options) if (!options.diff) { processedLines = onlyRule.report(processedLines, options) } finalLines.push(...processedLines) } function finalize(onlyRule, originalLines, finalLines, state, options) { const lastLine = originalLines.at(-1) let isLastLineBlank = lastLine == '' if (isLastLineBlank) { state.lineNumber += 1 } const finalLinesWithNumbers = finalLines.map((line, index) => [ line, index + 1, ]) const nextLines = onlyRule.processDocument(finalLinesWithNumbers, options) if (isLastLineBlank) { const lastLine = onlyRule.blankLastLine() if (lastLine != null) { nextLines.push('') } } return nextLines } function delimiterTestFn(delim) { if (delim === '{' || delim === '}') { return char => /[\{\}]/.test(char) } if (delim === '[' || delim === ']') { return char => /[\[\]]/.test(char) } if (delim === '(' || delim === ')') { return char => /[\(\)]/.test(char) } if (delim === '<' || delim === '>') { return char => /[<>]/.test(char) } return char => char === delim } function split(str, delim) { const test = delimiterTestFn(delim) const args = [] let buffer = '' let isEscaped = false for (const char of str) { if (isEscaped) { if (char !== delim) { buffer += '\\' } buffer += char isEscaped = false continue } if (char === '\\') { isEscaped = true continue } if (test(char)) { args.push(buffer) buffer = '' } else { buffer += char } } if (isEscaped) { buffer += '\\' } if (buffer.length) { args.push(buffer) } return args } function unescape(str) { return str.replace(/\\(.)/g, (_, char) => { if (char === 'n') { return '\n' } if (char === 't') { return '\t' } if (char === '0') { return '\0' } return `\\${char}` }) } function parseRuleArgs(rule) { const command = rule.match(/^(!?\w+\b|\d+)(.)/) if (!command) { const [simpleCommand] = rule.match(/^!?(\w+\b|\d+)$/) ?? [] if (!simpleCommand) { throw new ArgError(`Invalid rule: ${rule}`) } return [simpleCommand, null, null, []] } const [cmd, delim] = [command[1], command[2]] const argsString = rule.slice(command[0].length) const splitArgs = split(argsString, delim) const [pattern, replace, ...rest] = splitArgs.map(unescape) return [cmd.toLowerCase(), pattern, replace, rest, delim] } function version() { const VERSION = require('./package.json').version process.stderr.write(VERSION + '\n') } function help() { process.stderr.write( // START HELP `ssed(1) -- general purpose stream and file editor ======== SYNOPSIS ----- ssed --help something | ssed [commands] \`del\` Alias for \`del\` because I find it easier to remember. * \`t/$pattern\`, \`take/$pattern\` Only print the matching part of the line, or print the entire line if 'pattern' doesn't match * \`r/$pattern\`, \`rm/$pattern\` Remove the matching part of the line, or print the entire line if 'pattern' doesn't match * \`1/$pattern\`, \`2/$pattern\`, … Only print the first (or 2nd, or 3rd, …) group of the match * \`1\`, \`2\`, … Only print the first (or 2nd, or 3rd, …) "column" (columns are separated by whitespace) * \`prepend/$text\`, \`prefix/$text\`, \`append/$text\`, \`suffix/$text\` Adds text to the beginning (prepend) or end (append) of the line * \`surround/$prefix/$suffix\` Adds text to the beginning *and* end of the line * \`cols/$pattern/$columns\` e.g. \`cols/,/1,2,3\` Split the line by 'pattern' (default is \`/\\s+/\`) and print $columns, joined by ' ' * \`cols/$pattern/$columns/$joiner\` Same, but columns are joined by $joiner * \`on/$pattern\`, \`on:$lines\` Start printing on the line where $pattern/$lines is matched. If no pattern is given, the first line matches. * \`off/$pattern\`, \`off:$lines\` Stop printing on the line where $pattern/$lines is matched. If no pattern is given, the first line matches. * \`after/$pattern\`, \`after:$lines\` Start printing on the line *after* $pattern/$lines is matched. * \`toggle/$pattern\`, \`toggle:$lines\` Turn printing off at the matching line, then off, then on... * \`uniq\`, \`unique\`, \`uniq/$pattern\` Only print unique lines. Optionanly, uniqueness can be determined by the matching regex. The entire line is still printed. * \`tap\` Prints the *current document* to STDERR. Usefull for debugging, or in conjunction with \`--write\` to verify expected output. DOCUMENT RULES -------------- Document rules operate on the entire document, and so processing will not begin until the entire input is read. If you are streaming from STDIN, you cannot use document rules with a stream that will never finish (e.g. \`tail | sed sort\` won't work). * \`sublines/$pattern/$replace\`, \`sl/$pattern/$replace\` For every line that matches, insert one line from replace. Remaining lines will be inserted into the last matched line. Does not do regex replacement. * \`sort\`, \`sort/$pattern\` Sort the lines alphabetically using localeCompare. If a pattern is provided, the matching part of the line will be used, but the entire line will be printed. * \`sortn\`, \`sortn/$pattern\` Sort the lines numerically. If no pattern is given, it matches the *first* number (ignoring all preceding non-number characters). * \`reverse\` Obvious, I think. * \`line\`, \`lines\` Prepend each line with the line number. * \`begin:$prepend\`, \`end:$append\`, \`border:$prepend:$append\` Prepend, append, or surround the document (i.e. add header/footer to the document). These are named after awk's BEGIN/END commands. * \`join\`, \`join/$separator\` Join lines with a space or $separator. * \`cat\` Print the entire document. This is useful for resetting line numbers. CONDITIONS ---------- You can apply rules only under certain conditions, e.g. 'if/{pattern} {rule}' only runs \`rule\` only lines that match \`pattern\`. You can group rules using \`{ rule1 rule2 … }\`, and rules can be negated with a preceding '!'. * \`if/$pattern [rule]\`, \`if:$lines [rule]\` Only run \`rule\` if the line matches $pattern/$lines. * \`!if/$pattern [rule]\` \`!if:$lines [rule]\` Run \`rule\` on lines that *don't* match $pattern/$lines. * \`between/$onPattern/$offPattern [rule]\`, \`between:$onLines:$offLines [rule]\` Starting at $onPattern/$onLines, apply [rule] until $offPattern/$offLines. * \`!between/$onPattern/$offPattern [rule]\` Run [rule] on all lines that are not between $onPattern/$offPattern. * \`ifany/$pattern [rule]\` Runs [rule] on *all lines* if any line matches $pattern. Supports $lines, which can be used to run [rule] if the document is/isn't a minimum length. * \`ifnone/$pattern [rule]\`, \`!ifany/$pattern [rule]\` Runs [rule] on *all lines* as long as *no lines* match $pattern. ### Example ssed 'if/(first-name|last-name):' { s/colin/REDACTED-FIRST/i s/gray/REDACTED-LAST/i } This rule will only run on lines that include 'first-name:' or 'last-name:'. On only those lines, it will replace 'colin'/'gray' with 'REDACTED-FIRST'/'REDACTED-LAST'. LINE NUMBER RULES ----------------- Using the special delimiter ':' you can apply most rules on line numbers instead of line content. In the case of the 'sub' command, the entire line will be replaced with the literal text. Not all rules support this feature, but typically any rule that _could_ support it, _does_ ### Example * \`s:1:replace\` Replaces line 1 with the word "replace" * \`p:1\` Only print line 1 Line numbers can be expressed as a single number, a range, an open range, a modulo operation (with offset), and a comma-separated list of line rules. * \`p:1\` Only matches the line number (only matches line 1) * \`p:%2\` Matches lines that are modulo-N (even lines) * \`p:%2-1\` Matches lines that are modulo-N minus Y (odd lines) * \`p:1,3,5\` Matches the listed line numbers (and only these) * \`p:1-5\` Matches the range of number, inclusive (1,2,3,4,5) * \`p:9-\` Matches the line number and all subsequent lines (lines 9 and onward) * \`p:-9\` Matches lines up to and including the line number (lines 1-9) * \`p:1-5,10-15,20,30+\` Line rules can be mixed and matched `, // END HELP ) } function resetState() { return { // line-commands `on`, `after`, and `off` all share this value; toggles printing on/off printOn: null, lineNumber: 0, } } function ansi(code, input) { return `\x1b[${code}m${input}\x1b[0m` } function red(input, enabled) { return enabled ? ansi('1;31', input) : input } function green(input, enabled) { return enabled ? ansi('1;32', input) : input } function yellow(input, enabled) { return enabled ? ansi('38;5;227', input) : input } function magenta(input, enabled) { return enabled ? ansi('1;35', input) : input } function escapeShell(arg) { if (arg.match(/[;\\]/)) { return '"' + arg.replaceAll('\\', '\\\\').replaceAll('"', '\\"') + '"' } return arg } function writeLinesToFile(lines, file) { if (!file) { return } const output = lines.join('\n') fs.writeFileSync(file, output) } function diff(oldLines, newLines) { const result = [] let oldIndex = 0 let newIndex = 0 while (oldIndex < oldLines.length || newIndex < newLines.length) { if (oldIndex >= oldLines.length) { // All remaining lines in newLines are additions result.push({type: 'added', lines: newLines.slice(newIndex)}) newIndex = newLines.length break } if (newIndex >= newLines.length) { // All remaining lines in oldLines are removals result.push({type: 'removed', lines: oldLines.slice(oldIndex)}) oldIndex = oldLines.length break } if (oldLines[oldIndex] === newLines[newIndex]) { // Lines are the same let sameCount = 0 while ( oldIndex + sameCount < oldLines.length && newIndex + sameCount < newLines.length && oldLines[oldIndex + sameCount] === newLines[newIndex + sameCount] ) { sameCount++ } result.push({ type: 'same', lines: oldLines.slice(oldIndex, oldIndex + sameCount), }) oldIndex += sameCount newIndex += sameCount } else { // remove lines from old until same line let lhRemoveCount = 0 let lhAddCount = 0 while ( oldIndex + lhRemoveCount < oldLines.length && (oldLines[oldIndex + lhRemoveCount].trim() === '' || newLines.indexOf(oldLines[oldIndex + lhRemoveCount], newIndex) === -1) ) { lhRemoveCount++ } if (oldIndex + lhRemoveCount < oldLines.length) { lhAddCount = newLines.indexOf(oldLines[oldIndex + lhRemoveCount], newIndex) - newIndex } else { lhAddCount = newLines.length - newIndex } let rhRemoveCount = 0 let rhAddCount = 0 while ( newIndex + rhAddCount < newLines.length && (newLines[newIndex + rhAddCount].trim() === '' || oldLines.indexOf(newLines[newIndex + rhAddCount], oldIndex) === -1) ) { rhAddCount++ } if (newIndex + rhAddCount < newLines.length) { rhRemoveCount = oldLines.indexOf(newLines[newIndex + rhAddCount], oldIndex) - oldIndex } else { rhRemoveCount = oldLines.length - newIndex } if (lhRemoveCount + lhAddCount < rhAddCount + rhRemoveCount) { result.push({ type: 'removed', lines: oldLines.slice(oldIndex, oldIndex + lhRemoveCount), }) oldIndex += lhRemoveCount result.push({ type: 'added', lines: newLines.slice(newIndex, newIndex + lhAddCount), }) newIndex += lhAddCount } else { result.push({ type: 'removed', lines: oldLines.slice(oldIndex, oldIndex + rhRemoveCount), }) oldIndex += rhRemoveCount result.push({ type: 'added', lines: newLines.slice(newIndex, newIndex + rhAddCount), }) newIndex += rhAddCount } } } return result } function renderDiff(linesDiff, options) { options.print(yellow(options.diffHeader, options.color)) let inputLineNumber = 1 let outputLineNumber = 1 let didPrintNumbers = false function printLineNumbers(inputLineNumber, outputLineNumber) { if (!didPrintNumbers) { options.print( magenta( `@@ -${inputLineNumber} +${outputLineNumber} @@`, options.color, ), ) didPrintNumbers = true } } linesDiff.forEach((entry, index) => { if (!entry.lines.length) { return } if (entry.type === 'same') { if (index === 0) { didPrintNumbers = false const printLines = entry.lines.slice( Math.max(entry.lines.length - 3, 0), ) inputLineNumber += entry.lines.length - printLines.length outputLineNumber += entry.lines.length - printLines.length printLineNumbers(inputLineNumber, outputLineNumber) printLines.forEach(line => options.print(' ' + line)) inputLineNumber += printLines.length outputLineNumber += printLines.length } else if (index === linesDiff.length - 1) { const printLines = entry.lines.slice(0, 4) printLineNumbers(inputLineNumber, outputLineNumber) inputLineNumber += entry.lines.length - printLines.length outputLineNumber += entry.lines.length - printLines.length printLines.forEach(line => options.print(' ' + line)) inputLineNumber += printLines.length outputLineNumber += printLines.length } else if (entry.lines.length <= 5) { entry.lines.forEach(line => options.print(' ' + line)) inputLineNumber += entry.lines.length outputLineNumber += entry.lines.length } else { didPrintNumbers = false const beforeLines = entry.lines.slice(0, 3) const afterLines = entry.lines.slice( Math.max(entry.lines.length - 3, 0), ) beforeLines.forEach(line => options.print(' ' + line)) inputLineNumber += entry.lines.length - afterLines.length outputLineNumber += entry.lines.length - afterLines.length printLineNumbers(inputLineNumber, outputLineNumber) afterLines.forEach(line => options.print(' ' + line)) } return } entry.lines.forEach(line => { printLineNumbers(inputLineNumber, outputLineNumber) if (entry.type === 'added') { outputLineNumber += 1 options.print(green(`+${line}`, options.color)) } else if (entry.type === 'removed') { inputLineNumber += 1 options.print(red(`-${line}`, options.color)) } }) }) } function indent(line) { return ' ' + line.replaceAll('\n', '\n ') } if (!Array.prototype.toSorted) { Array.prototype.toSorted = function (compareFn) { return [...this].sort(compareFn) } } if (!Array.prototype.toReversed) { Array.prototype.toReversed = function () { return [...this].reverse() } } try { // remove 'node' and 'ssed' from argv main(process.argv.slice(2)) } catch (e) { if (e instanceof ArgError) { process.stderr.write(`ssed: ${e.message}\n`) } else { throw e } }