Merge pull request #424 from AtkinsSJ/sed

Actually parse sed scripts
This commit is contained in:
Eric Dubé
2024-06-03 12:22:15 -04:00
committed by GitHub
10 changed files with 1435 additions and 668 deletions
+3 -1
View File
@@ -1,6 +1,6 @@
import { adapt_parser, VALUE } from './parser.js';
import { Discard, FirstMatch, Optional, Repeat, Sequence } from './parsers/combinators.js';
import { Literal, None, StringOf, Symbol } from './parsers/terminals.js';
import { Fail, Literal, None, StringOf, StringUntil, Symbol } from './parsers/terminals.js';
class ParserWithAction {
#parser;
@@ -81,6 +81,7 @@ export class GrammarContext {
export const standard_parsers = () => {
return {
discard: Discard,
fail: Fail,
firstMatch: FirstMatch,
literal: Literal,
none: None,
@@ -88,6 +89,7 @@ export const standard_parsers = () => {
repeat: Repeat,
sequence: Sequence,
stringOf: StringOf,
stringUntil: StringUntil,
symbol: Symbol,
}
}
@@ -79,7 +79,7 @@ export class Optional extends Parser {
export class Repeat extends Parser {
_create (value_parser, separator_parser, { trailing = false } = {}) {
this.value_parser = adapt_parser(value_parser);
this.separator_parser = adapt_parser(separator_parser);
this.separator_parser = separator_parser ? adapt_parser(separator_parser) : null;
this.trailing = trailing;
}
@@ -99,22 +99,23 @@ export class Repeat extends Parser {
// Repeatedly parse <separator> <value>
for (;;) {
// Separator
if (!this.separator_parser)
continue;
const separatorResult = this.separator_parser.parse(subStream);
if (separatorResult.status === UNRECOGNIZED)
break;
if (separatorResult.status === INVALID)
return { status: INVALID, value: separatorResult };
stream.join(subStream);
if (!separatorResult.$discard) results.push(separatorResult);
let parsed_separator = false;
if (this.separator_parser) {
const separatorResult = this.separator_parser.parse(subStream);
if (separatorResult.status === UNRECOGNIZED)
break;
if (separatorResult.status === INVALID)
return { status: INVALID, value: separatorResult };
stream.join(subStream);
if (!separatorResult.$discard) results.push(separatorResult);
parsed_separator = true;
}
// Value
const result = this.value_parser.parse(subStream);
if (result.status === UNRECOGNIZED) {
// If we failed to parse a value, we have a trailing separator
if (this.trailing === false)
if (parsed_separator && this.trailing === false)
return { status: INVALID, value: result };
break;
}
@@ -53,6 +53,53 @@ export class StringOf extends Parser {
}
}
/**
* Parses characters into a string, until it encounters the given character, unescaped.
* @param testOrCharacter End of the string. Either a character, or a function that takes a character,
* and returns whether it ends the string.
* @param escapeCharacter Character to use as the escape character. By default, is '\'.
*/
export class StringUntil extends Parser {
_create(testOrCharacter, { escapeCharacter = '\\' } = {}) {
if (typeof testOrCharacter === 'string') {
this.test = (c => c === testOrCharacter);
} else {
this.test = testOrCharacter;
}
this.escapeCharacter = escapeCharacter;
}
_parse(stream) {
const subStream = stream.fork();
let text = '';
let lastWasEscape = false;
while (true) {
let { done, value } = subStream.look();
if ( done ) break;
if ( !lastWasEscape && this.test(value) )
break;
subStream.next();
if (value === this.escapeCharacter) {
lastWasEscape = true;
continue;
}
lastWasEscape = false;
text += value;
}
if (lastWasEscape)
return INVALID;
if (text.length === 0)
return UNRECOGNIZED;
stream.join(subStream);
return { status: VALUE, $: 'stringUntil', value: text };
}
}
/**
* Parses an object defined by the symbol registry.
* @param symbolName The name of the symbol to parse.
@@ -91,3 +138,14 @@ export class None extends Parser {
return { status: VALUE, $: 'none', $discard: true };
}
}
/**
* Always fails parsing.
*/
export class Fail extends Parser {
_create () {}
_parse (stream) {
return UNRECOGNIZED;
}
}
@@ -43,5 +43,7 @@ export default {
ctx.locals.values = result.values;
ctx.locals.positionals = result.positionals;
if (result.tokens)
ctx.locals.tokens = result.tokens;
}
}
+38 -655
View File
@@ -18,620 +18,7 @@
*/
import { Exit } from './coreutil_lib/exit.js';
import { fileLines } from '../../util/file.js';
function makeIndent(size) {
return ' '.repeat(size);
}
// Either a line number or a regex
class Address {
constructor(value) {
this.value = value;
}
matches(lineNumber, line) {
if (this.value instanceof RegExp) {
return this.value.test(line);
}
return this.value === lineNumber;
}
isLineNumberBefore(lineNumber) {
return (typeof this.value === 'number') && this.value < lineNumber;
}
dump(indent) {
if (this.value instanceof RegExp) {
return `${makeIndent(indent)}REGEX: ${this.value}\n`;
}
return `${makeIndent(indent)}LINE: ${this.value}\n`;
}
}
class AddressRange {
// Three kinds of AddressRange:
// - Empty (includes everything)
// - Single (matches individual line)
// - Range (matches lines between start and end, inclusive)
constructor({ start, end, inverted = false } = {}) {
this.start = start;
this.end = end;
this.inverted = inverted;
this.insideRange = false;
this.leaveRangeNextLine = false;
}
updateMatchState(lineNumber, line) {
// Only ranges have a state to update
if (!(this.start && this.end)) {
return;
}
// Reset our state each time we start a new file.
if (lineNumber === 1) {
this.insideRange = false;
this.leaveRangeNextLine = false;
}
// Leave the range if the previous line matched the end.
if (this.leaveRangeNextLine) {
this.insideRange = false;
this.leaveRangeNextLine = false;
}
if (this.insideRange) {
// We're inside the range, does this line end it?
// If the end address is a line number in the past, yes, immediately.
if (this.end.isLineNumberBefore(lineNumber)) {
this.insideRange = false;
return;
}
// If the line matches the end address, include it but leave the range on the next line.
this.leaveRangeNextLine = this.end.matches(lineNumber, line);
} else {
// Does this line start the range?
this.insideRange = this.start.matches(lineNumber, line);
}
}
matches(lineNumber, line) {
const invertIfNeeded = (value) => {
return this.inverted ? !value : value;
};
// Empty - matches all lines
if (!this.start) {
return invertIfNeeded(true);
}
// Range
if (this.end) {
return invertIfNeeded(this.insideRange);
}
// Single
return invertIfNeeded(this.start.matches(lineNumber, line));
}
dump(indent) {
const inverted = this.inverted ? `${makeIndent(indent+1)}(INVERTED)\n` : '';
if (!this.start) {
return `${makeIndent(indent)}ADDRESS RANGE (EMPTY)\n`
+ inverted;
}
if (this.end) {
return `${makeIndent(indent)}ADDRESS RANGE (RANGE):\n`
+ inverted
+ this.start.dump(indent+1)
+ this.end.dump(indent+1);
}
return `${makeIndent(indent)}ADDRESS RANGE (SINGLE):\n`
+ this.start.dump(indent+1)
+ inverted;
}
}
const JumpLocation = {
None: Symbol('None'),
EndOfCycle: Symbol('EndOfCycle'),
StartOfCycle: Symbol('StartOfCycle'),
Label: Symbol('Label'),
Quit: Symbol('Quit'),
QuitSilent: Symbol('QuitSilent'),
};
class Command {
constructor(addressRange) {
this.addressRange = addressRange ?? new AddressRange();
}
updateMatchState(context) {
this.addressRange.updateMatchState(context.lineNumber, context.patternSpace);
}
async runCommand(context) {
if (this.addressRange.matches(context.lineNumber, context.patternSpace)) {
return await this.run(context);
}
return JumpLocation.None;
}
async run(context) {
throw new Error('run() not implemented for ' + this.constructor.name);
}
dump(indent) {
throw new Error('dump() not implemented for ' + this.constructor.name);
}
}
// '{}' - Group other commands
class GroupCommand extends Command {
constructor(addressRange, subCommands) {
super(addressRange);
this.subCommands = subCommands;
}
updateMatchState(context) {
super.updateMatchState(context);
for (const command of this.subCommands) {
command.updateMatchState(context);
}
}
async run(context) {
for (const command of this.subCommands) {
const result = await command.runCommand(context);
if (result !== JumpLocation.None) {
return result;
}
}
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}GROUP:\n`
+ this.addressRange.dump(indent+1)
+ `${makeIndent(indent+1)}CHILDREN:\n`
+ this.subCommands.map(command => command.dump(indent+2)).join('');
}
}
// '=' - Output line number
class LineNumberCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
await context.out.write(`${context.lineNumber}\n`);
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}LINE-NUMBER:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'a' - Append text
class AppendTextCommand extends Command {
constructor(addressRange, text) {
super(addressRange);
this.text = text;
}
async run(context) {
context.queuedOutput += this.text + '\n';
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}APPEND-TEXT:\n`
+ this.addressRange.dump(indent+1)
+ `${makeIndent(indent+1)}CONTENTS: '${this.text}'\n`;
}
}
// 'c' - Replace line with text
class ReplaceCommand extends Command {
constructor(addressRange, text) {
super(addressRange);
this.text = text;
}
async run(context) {
context.patternSpace = '';
// Output if we're either a 0-address range, 1-address range, or 2-address on the last line.
if (this.addressRange.leaveRangeNextLine || !this.addressRange.end) {
await context.out.write(this.text + '\n');
}
return JumpLocation.EndOfCycle;
}
dump(indent) {
return `${makeIndent(indent)}REPLACE-TEXT:\n`
+ this.addressRange.dump(indent+1)
+ `${makeIndent(indent+1)}CONTENTS: '${this.text}'\n`;
}
}
// 'd' - Delete pattern
class DeleteCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
context.patternSpace = '';
return JumpLocation.EndOfCycle;
}
dump(indent) {
return `${makeIndent(indent)}DELETE:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'D' - Delete first line of pattern
class DeleteLineCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
const [ firstLine, rest ] = context.patternSpace.split('\n', 2);
context.patternSpace = rest ?? '';
if (rest === undefined) {
return JumpLocation.EndOfCycle;
}
return JumpLocation.StartOfCycle;
}
dump(indent) {
return `${makeIndent(indent)}DELETE-LINE:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'g' - Get the held line into the pattern
class GetCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
context.patternSpace = context.holdSpace;
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}GET-HELD:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'G' - Get the held line and append it to the pattern
class GetAppendCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
context.patternSpace += '\n' + context.holdSpace;
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}GET-HELD-APPEND:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'h' - Hold the pattern
class HoldCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
context.holdSpace = context.patternSpace;
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}HOLD:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'H' - Hold append the pattern
class HoldAppendCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
context.holdSpace += '\n' + context.patternSpace;
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}HOLD-APPEND:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'i' - Insert text
class InsertTextCommand extends Command {
constructor(addressRange, text) {
super(addressRange);
this.text = text;
}
async run(context) {
await context.out.write(this.text + '\n');
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}INSERT-TEXT:\n`
+ this.addressRange.dump(indent+1)
+ `${makeIndent(indent+1)}CONTENTS: '${this.text}'\n`;
}
}
// 'l' - Print pattern in debug format
class DebugPrintCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
let output = '';
for (const c of context.patternSpace) {
if (c < ' ') {
const charCode = c.charCodeAt(0);
switch (charCode) {
case 0x07: output += '\\a'; break;
case 0x08: output += '\\b'; break;
case 0x0C: output += '\\f'; break;
case 0x0A: output += '$\n'; break;
case 0x0D: output += '\\r'; break;
case 0x09: output += '\\t'; break;
case 0x0B: output += '\\v'; break;
default: {
const octal = charCode.toString(8);
output += '\\' + '0'.repeat(3 - octal.length) + octal;
}
}
} else if (c === '\\') {
output += '\\\\';
} else {
output += c;
}
}
await context.out.write(output);
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}DEBUG-PRINT:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'p' - Print pattern
class PrintCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
await context.out.write(context.patternSpace);
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}PRINT:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'P' - Print first line of pattern
class PrintLineCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
const firstLine = context.patternSpace.split('\n', 2)[0];
await context.out.write(firstLine);
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}PRINT-LINE:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'q' - Quit
class QuitCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
return JumpLocation.Quit;
}
dump(indent) {
return `${makeIndent(indent)}QUIT:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'Q' - Quit, suppressing the default output
class QuitSilentCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
return JumpLocation.QuitSilent;
}
dump(indent) {
return `${makeIndent(indent)}QUIT-SILENT:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'x' - Exchange hold and pattern
class ExchangeCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
const oldPattern = context.patternSpace;
context.patternSpace = context.holdSpace;
context.holdSpace = oldPattern;
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}EXCHANGE:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'y' - Transliterate characters
class TransliterateCommand extends Command {
constructor(addressRange, inputCharacters, replacementCharacters) {
super(addressRange);
this.inputCharacters = inputCharacters;
this.replacementCharacters = replacementCharacters;
if (inputCharacters.length !== replacementCharacters.length) {
throw new Error('inputCharacters and replacementCharacters must be the same length!');
}
}
async run(context) {
let newPatternSpace = '';
for (let i = 0; i < context.patternSpace.length; ++i) {
const char = context.patternSpace[i];
const replacementIndex = this.inputCharacters.indexOf(char);
if (replacementIndex !== -1) {
newPatternSpace += this.replacementCharacters[replacementIndex];
continue;
}
newPatternSpace += char;
}
context.patternSpace = newPatternSpace;
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}TRANSLITERATE:\n`
+ this.addressRange.dump(indent+1)
+ `${makeIndent(indent+1)}FROM '${this.inputCharacters}'\n`
+ `${makeIndent(indent+1)}TO '${this.replacementCharacters}'\n`;
}
}
// 'z' - Zap, delete the pattern without ending cycle
class ZapCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
context.patternSpace = '';
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}ZAP:\n`
+ this.addressRange.dump(indent+1);
}
}
const CycleResult = {
Continue: Symbol('Continue'),
Quit: Symbol('Quit'),
QuitSilent: Symbol('QuitSilent'),
};
class Script {
constructor(commands) {
this.commands = commands;
}
async runCycle(context) {
for (let i = 0; i < this.commands.length; i++) {
const command = this.commands[i];
command.updateMatchState(context);
const result = await command.runCommand(context);
switch (result) {
case JumpLocation.Label:
// TODO: Implement labels
break;
case JumpLocation.Quit:
return CycleResult.Quit;
case JumpLocation.QuitSilent:
return CycleResult.QuitSilent;
case JumpLocation.StartOfCycle:
i = -1; // To start at 0 after the loop increment.
continue;
case JumpLocation.EndOfCycle:
return CycleResult.Continue;
case JumpLocation.None:
continue;
}
}
}
dump() {
return `SCRIPT:\n`
+ this.commands.map(command => command.dump(1)).join('');
}
}
function parseScript(scriptString) {
const commands = [];
// Generate a hard-coded script for now.
// TODO: Actually parse input!
commands.push(new TransliterateCommand(new AddressRange(), 'abcdefABCDEF', 'ABCDEFabcdef'));
// commands.push(new ZapCommand(new AddressRange({start: new Address(1), end: new Address(10)})));
// commands.push(new HoldAppendCommand(new AddressRange({start: new Address(1), end: new Address(10)})));
// commands.push(new GetCommand(new AddressRange({start: new Address(11)})));
// commands.push(new DebugPrintCommand(new AddressRange()));
// commands.push(new ReplaceCommand(new AddressRange({start: new Address(3), end: new Address(30)}), "LOL"));
// commands.push(new GroupCommand(new AddressRange({ start: new Address(5), end: new Address(10) }), [
// // new LineNumberCommand(),
// // new TextCommand(new AddressRange({ start: new Address(8) }), "Well hello friends! :^)"),
// new QuitCommand(new AddressRange({ start: new Address(8) })),
// new NoopCommand(new AddressRange()),
// new PrintCommand(new AddressRange({ start: new Address(2), end: new Address(14) })),
// ]));
// commands.push(new LineNumberCommand(new AddressRange({ start: new Address(5), end: new Address(10) })));
// commands.push(new PrintCommand());
// commands.push(new NoopCommand());
// commands.push(new PrintCommand());
return new Script(commands);
}
import { parseScript } from './sed/parser.js';
export default {
name: 'sed',
@@ -645,7 +32,13 @@ export default {
args: {
$: 'simple-parser',
allowPositionals: true,
tokens: true,
options: {
dump: {
description: 'Dump a representation of the parsed script, for debugging.',
type: 'boolean',
default: false,
},
expression: {
description: 'Specify an additional script to execute. May be specified multiple times.',
type: 'string',
@@ -653,6 +46,13 @@ export default {
multiple: true,
default: [],
},
file: {
description: 'Specify a script file to execute. May be specified multiple times.',
type: 'string',
short: 'f',
multiple: true,
default: [],
},
quiet: {
description: 'Suppress default printing of selected lines.',
type: 'boolean',
@@ -663,7 +63,7 @@ export default {
},
execute: async ctx => {
const { out, err } = ctx.externs;
const { positionals, values } = ctx.locals;
const { positionals, values, tokens } = ctx.locals;
if (positionals.length < 1) {
await err.write('sed: No inputs given\n');
@@ -675,51 +75,34 @@ export default {
// made, if the previous addition (if any) was from a -e option, a <newline> shall be inserted before the new
// addition. The resulting script shall have the same properties as the script operand, described in the
// OPERANDS section."
// TODO: -f loads scripts from a file
let scriptString = '';
if (values.expression.length > 0) {
scriptString = values.expression.join('\n');
if (values.expression.length + values.file.length > 0) {
// These have to be in order, and -e and -f could be intermixed, so iterate the tokens
for (let token of tokens) {
if (token.kind !== 'option') continue;
if (token.name === 'expression') {
scriptString += token.value + '\n';
continue;
}
if (token.name === 'file') {
for await (const line of fileLines(ctx, token.value)) {
scriptString += line;
}
continue;
}
}
} else {
scriptString = positionals.shift();
}
const script = parseScript(scriptString);
await out.write(script.dump());
const context = {
out: out,
patternSpace: '',
holdSpace: '\n',
lineNumber: 1,
queuedOutput: '',
}
// All remaining positionals are file paths to process.
for (const relPath of positionals) {
context.lineNumber = 1;
for await (const line of fileLines(ctx, relPath)) {
context.patternSpace = line.replace(/\n$/, '');
const result = await script.runCycle(context);
switch (result) {
case CycleResult.Quit: {
if (!values.quiet) {
await out.write(context.patternSpace + '\n');
}
return;
}
case CycleResult.QuitSilent: {
return;
}
}
if (!values.quiet) {
await out.write(context.patternSpace + '\n');
}
if (context.queuedOutput) {
await out.write(context.queuedOutput + '\n');
context.queuedOutput = '';
}
context.lineNumber++;
}
try {
const script = parseScript(scriptString, values);
if (values.dump)
await out.write(script.dump());
await script.run(ctx);
} catch (e) {
console.error(e);
await err.write(`sed: ${e.message}\n`);
}
}
};
@@ -0,0 +1,134 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Phoenix Shell.
*
* Phoenix Shell is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { makeIndent } from './utils.js';
// Either a line number or a regex
export class Address {
constructor(value) {
this.value = value;
}
matches(lineNumber, line) {
if (this.value instanceof RegExp) {
return this.value.test(line);
}
return this.value === lineNumber;
}
isLineNumberBefore(lineNumber) {
return (typeof this.value === 'number') && this.value < lineNumber;
}
dump(indent) {
if (this.value instanceof RegExp) {
return `${makeIndent(indent)}REGEX: ${this.value}\n`;
}
return `${makeIndent(indent)}LINE: ${this.value}\n`;
}
}
export class AddressRange {
// Three kinds of AddressRange:
// - Empty (includes everything)
// - Single (matches individual line)
// - Range (matches lines between start and end, inclusive)
constructor({ start, end, inverted = false } = {}) {
this.start = start;
this.end = end;
this.inverted = inverted;
this.insideRange = false;
this.leaveRangeNextLine = false;
}
get addressCount() {
return (this.start ? 1 : 0) + (this.end ? 1 : 0);
}
updateMatchState(lineNumber, line) {
// Only ranges have a state to update
if (!(this.start && this.end)) {
return;
}
// Reset our state each time we start a new file.
if (lineNumber === 1) {
this.insideRange = false;
this.leaveRangeNextLine = false;
}
// Leave the range if the previous line matched the end.
if (this.leaveRangeNextLine) {
this.insideRange = false;
this.leaveRangeNextLine = false;
}
if (this.insideRange) {
// We're inside the range, does this line end it?
// If the end address is a line number in the past, yes, immediately.
if (this.end.isLineNumberBefore(lineNumber)) {
this.insideRange = false;
return;
}
// If the line matches the end address, include it but leave the range on the next line.
this.leaveRangeNextLine = this.end.matches(lineNumber, line);
} else {
// Does this line start the range?
this.insideRange = this.start.matches(lineNumber, line);
}
}
matches(lineNumber, line) {
const invertIfNeeded = (value) => {
return this.inverted ? !value : value;
};
// Empty - matches all lines
if (!this.start) {
return invertIfNeeded(true);
}
// Range
if (this.end) {
return invertIfNeeded(this.insideRange);
}
// Single
return invertIfNeeded(this.start.matches(lineNumber, line));
}
dump(indent) {
const inverted = this.inverted ? `${makeIndent(indent+1)}(INVERTED)\n` : '';
if (!this.start) {
return `${makeIndent(indent)}ADDRESS RANGE (EMPTY)\n`
+ inverted;
}
if (this.end) {
return `${makeIndent(indent)}ADDRESS RANGE (RANGE):\n`
+ inverted
+ this.start.dump(indent+1)
+ this.end.dump(indent+1);
}
return `${makeIndent(indent)}ADDRESS RANGE (SINGLE):\n`
+ this.start.dump(indent+1)
+ inverted;
}
}
@@ -0,0 +1,514 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Phoenix Shell.
*
* Phoenix Shell is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { AddressRange } from './address.js';
import { makeIndent } from './utils.js';
export const JumpLocation = {
None: Symbol('None'),
EndOfCycle: Symbol('EndOfCycle'),
StartOfCycle: Symbol('StartOfCycle'),
Label: Symbol('Label'),
GroupEnd: Symbol('GroupEnd'),
Quit: Symbol('Quit'),
QuitSilent: Symbol('QuitSilent'),
};
export class Command {
constructor(addressRange) {
this.addressRange = addressRange ?? new AddressRange();
}
updateMatchState(context) {
this.addressRange.updateMatchState(context.lineNumber, context.patternSpace);
}
async runCommand(context) {
if (this.addressRange.matches(context.lineNumber, context.patternSpace)) {
return await this.run(context);
}
return JumpLocation.None;
}
async run(context) {
throw new Error('run() not implemented for ' + this.constructor.name);
}
dump(indent) {
throw new Error('dump() not implemented for ' + this.constructor.name);
}
}
// '{}' - Group other commands
export class GroupStartCommand extends Command {
constructor(addressRange, id) {
super(addressRange);
this.id = id;
}
async runCommand(context) {
if (!this.addressRange.matches(context.lineNumber, context.patternSpace)) {
context.jumpParameter = this.id;
return JumpLocation.GroupEnd;
}
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}GROUP-START: #${this.id}\n`
+ this.addressRange.dump(indent+1);
}
}
export class GroupEndCommand extends Command {
constructor(id) {
super();
this.id = id;
}
async run(context) {
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}GROUP-END: #${this.id}\n`;
}
}
// ':' - Label
export class LabelCommand extends Command {
constructor(label) {
super();
this.label = label;
}
async run(context) {
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}LABEL:\n`
+ this.addressRange.dump(indent+1)
+ `${makeIndent(indent+1)}NAME: ${this.label}\n`;
}
}
// '=' - Output line number
export class LineNumberCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
await context.out.write(`${context.lineNumber}\n`);
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}LINE-NUMBER:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'a' - Append text
export class AppendTextCommand extends Command {
constructor(addressRange, text) {
super(addressRange);
this.text = text;
}
async run(context) {
context.queuedOutput += this.text + '\n';
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}APPEND-TEXT:\n`
+ this.addressRange.dump(indent+1)
+ `${makeIndent(indent+1)}CONTENTS: '${this.text}'\n`;
}
}
// 'b' - Branch to label
// 't' - Branch if substitution successful
// 'T' - Branch if substitution unsuccessful
export class BranchCommand extends Command {
constructor(addressRange, label, substitutionCondition) {
super(addressRange);
this.label = label;
this.substitutionCondition = substitutionCondition;
}
async run(context) {
if (typeof this.substitutionCondition === 'boolean') {
if (context.substitutionResult !== this.substitutionCondition)
return JumpLocation.None;
}
if (this.label) {
context.jumpParameter = this.label;
return JumpLocation.Label;
}
return JumpLocation.EndOfCycle;
}
dump(indent) {
return `${makeIndent(indent)}BRANCH:\n`
+ `${makeIndent(indent+1)}CONDITION: ${this.substitutionCondition ?? 'ALWAYS'}\n`
+ this.addressRange.dump(indent+1)
+ `${makeIndent(indent+1)}LABEL: ${this.label ? `'${this.label}'` : 'END'}\n`;
}
}
// 'c' - Replace line with text
export class ReplaceCommand extends Command {
constructor(addressRange, text) {
super(addressRange);
this.text = text;
}
async run(context) {
context.patternSpace = '';
// Output if we're either a 0-address range, 1-address range, or 2-address on the last line.
if (this.addressRange.leaveRangeNextLine || !this.addressRange.end) {
await context.out.write(this.text + '\n');
}
return JumpLocation.EndOfCycle;
}
dump(indent) {
return `${makeIndent(indent)}REPLACE-TEXT:\n`
+ this.addressRange.dump(indent+1)
+ `${makeIndent(indent+1)}CONTENTS: '${this.text}'\n`;
}
}
// 'd' - Delete pattern
// 'D' - Delete first line of pattern
export class DeleteCommand extends Command {
constructor(addressRange, firstLine = false) {
super(addressRange);
this.firstLine = firstLine;
}
async run(context) {
if (this.firstLine) {
const [ first, rest ] = context.patternSpace.split('\n', 2);
context.patternSpace = rest ?? '';
if (rest === undefined)
return JumpLocation.EndOfCycle;
return JumpLocation.StartOfCycle;
}
context.patternSpace = '';
return JumpLocation.EndOfCycle;
}
dump(indent) {
return `${makeIndent(indent)}DELETE: ${this.firstLine ? 'LINE' : 'ALL'}\n`
+ this.addressRange.dump(indent+1);
}
}
// 'g' - Get the held line into the pattern
// 'G' - Get the held line and append it to the pattern
export class GetCommand extends Command {
constructor(addressRange, append = false) {
super(addressRange);
this.append = append;
}
async run(context) {
if (this.append) {
context.patternSpace += '\n' + context.holdSpace;
} else {
context.patternSpace = context.holdSpace;
}
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}GET-HELD: ${this.append ? 'APPEND' : 'ALL'}\n`
+ this.addressRange.dump(indent+1);
}
}
// 'h' - Hold the pattern
// 'H' - Hold append the pattern
export class HoldCommand extends Command {
constructor(addressRange, append = false) {
super(addressRange);
this.append = append;
}
async run(context) {
if (this.append) {
context.holdSpace += '\n' + context.patternSpace;
} else {
context.holdSpace = context.patternSpace;
}
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}HOLD: ${this.append ? 'APPEND' : 'ALL'}\n`
+ this.addressRange.dump(indent+1);
}
}
// 'i' - Insert text
export class InsertTextCommand extends Command {
constructor(addressRange, text) {
super(addressRange);
this.text = text;
}
async run(context) {
await context.out.write(this.text + '\n');
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}INSERT-TEXT:\n`
+ this.addressRange.dump(indent+1)
+ `${makeIndent(indent+1)}CONTENTS: '${this.text}'\n`;
}
}
// 'l' - Print pattern in debug format
export class DebugPrintCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
let output = '';
for (const c of context.patternSpace) {
if (c < ' ') {
const charCode = c.charCodeAt(0);
switch (charCode) {
case 0x07: output += '\\a'; break;
case 0x08: output += '\\b'; break;
case 0x0C: output += '\\f'; break;
case 0x0A: output += '$\n'; break;
case 0x0D: output += '\\r'; break;
case 0x09: output += '\\t'; break;
case 0x0B: output += '\\v'; break;
default: {
const octal = charCode.toString(8);
output += '\\' + '0'.repeat(3 - octal.length) + octal;
}
}
} else if (c === '\\') {
output += '\\\\';
} else {
output += c;
}
}
await context.out.write(output + '\n');
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}DEBUG-PRINT:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'p' - Print pattern
// 'P' - Print first line of pattern
export class PrintCommand extends Command {
constructor(addressRange, firstLine = false) {
super(addressRange);
this.firstLine = firstLine;
}
async run(context) {
if (this.firstLine) {
const firstLine = context.patternSpace.split('\n', 2)[0];
await context.out.write(firstLine + '\n');
} else {
await context.out.write(context.patternSpace + '\n');
}
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}PRINT: ${this.firstLine ? 'LINE' : 'ALL'}\n`
+ this.addressRange.dump(indent+1);
}
}
// 'q' - Quit
// 'Q' - Quit, suppressing the default output
export class QuitCommand extends Command {
constructor(addressRange, silent) {
super(addressRange);
this.silent = silent;
}
async run(context) {
return this.silent ? JumpLocation.QuitSilent : JumpLocation.Quit;
}
dump(indent) {
return `${makeIndent(indent)}QUIT:\n`
+ this.addressRange.dump(indent+1)
+ `${makeIndent(indent+1)}SILENT = '${this.silent}'\n`;
}
}
// 's' - Substitute
export class SubstituteFlags {
constructor({ global = false, nthOccurrence = null, print = false, writeToFile = null } = {}) {
this.global = global;
this.nthOccurrence = nthOccurrence;
this.print = print;
this.writeToFile = writeToFile;
}
}
export class SubstituteCommand extends Command {
constructor(addressRange, regex, replacement, flags = new SubstituteFlags()) {
if (!(flags instanceof SubstituteFlags)) {
throw new Error('flags provided to SubstituteCommand must be an instance of SubstituteFlags');
}
super(addressRange);
this.regex = regex;
this.replacement = replacement;
this.flags = flags;
}
async run(context) {
if (this.flags.global) {
// replaceAll() requires that the regex have the g flag
const regex = new RegExp(this.regex, 'g');
context.substitutionResult = regex.test(context.patternSpace);
context.patternSpace = context.patternSpace.replaceAll(regex, this.replacement);
} else if (this.flags.nthOccurrence && this.flags.nthOccurrence !== 1) {
// Note: For n=1, it's easier to use the "replace first match" path below instead.
// matchAll() requires that the regex have the g flag
const matches = [...context.patternSpace.matchAll(new RegExp(this.regex, 'g'))];
const nthMatch = matches[this.flags.nthOccurrence - 1]; // n is 1-indexed
if (nthMatch !== undefined) {
// To only replace the Nth match:
// - Split the string in two, at the match position
// - Run the replacement on the second half
// - Combine that with the first half again
const firstHalf = context.patternSpace.substring(0, nthMatch.index);
const secondHalf = context.patternSpace.substring(nthMatch.index);
context.patternSpace = firstHalf + secondHalf.replace(this.regex, this.replacement);
context.substitutionResult = true;
} else {
context.substitutionResult = false;
}
} else {
context.substitutionResult = this.regex.test(context.patternSpace);
context.patternSpace = context.patternSpace.replace(this.regex, this.replacement);
}
if (context.substitutionResult) {
if (this.flags.print) {
await context.out.write(context.patternSpace + '\n');
}
if (this.flags.writeToFile) {
// TODO: Implement this.
}
}
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}SUBSTITUTE:\n`
+ this.addressRange.dump(indent+1)
+ `${makeIndent(indent+1)}REGEX '${this.regex}'\n`
+ `${makeIndent(indent+1)}REPLACEMENT '${this.replacement}'\n`
+ `${makeIndent(indent+1)}FLAGS ${JSON.stringify(this.flags)}\n`;
}
}
// 'x' - Exchange hold and pattern
export class ExchangeCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
const oldPattern = context.patternSpace;
context.patternSpace = context.holdSpace;
context.holdSpace = oldPattern;
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}EXCHANGE:\n`
+ this.addressRange.dump(indent+1);
}
}
// 'y' - Transliterate characters
export class TransliterateCommand extends Command {
constructor(addressRange, inputCharacters, replacementCharacters) {
super(addressRange);
this.inputCharacters = inputCharacters;
this.replacementCharacters = replacementCharacters;
if (inputCharacters.length !== replacementCharacters.length) {
throw new Error('inputCharacters and replacementCharacters must be the same length!');
}
}
async run(context) {
let newPatternSpace = '';
for (let i = 0; i < context.patternSpace.length; ++i) {
const char = context.patternSpace[i];
const replacementIndex = this.inputCharacters.indexOf(char);
if (replacementIndex !== -1) {
newPatternSpace += this.replacementCharacters[replacementIndex];
continue;
}
newPatternSpace += char;
}
context.patternSpace = newPatternSpace;
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}TRANSLITERATE:\n`
+ this.addressRange.dump(indent+1)
+ `${makeIndent(indent+1)}FROM '${this.inputCharacters}'\n`
+ `${makeIndent(indent+1)}TO '${this.replacementCharacters}'\n`;
}
}
// 'z' - Zap, delete the pattern without ending cycle
export class ZapCommand extends Command {
constructor(addressRange) {
super(addressRange);
}
async run(context) {
context.patternSpace = '';
return JumpLocation.None;
}
dump(indent) {
return `${makeIndent(indent)}ZAP:\n`
+ this.addressRange.dump(indent+1);
}
}
@@ -0,0 +1,529 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Phoenix Shell.
*
* Phoenix Shell is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Address, AddressRange } from './address.js';
import {
AppendTextCommand,
BranchCommand,
DebugPrintCommand,
DeleteCommand,
ExchangeCommand,
GetCommand,
GroupEndCommand,
GroupStartCommand,
HoldCommand,
InsertTextCommand,
LabelCommand,
LineNumberCommand,
PrintCommand,
QuitCommand,
ReplaceCommand,
SubstituteCommand,
SubstituteFlags,
TransliterateCommand,
ZapCommand,
} from './command.js';
import { Script } from './script.js';
import { GrammarContext, standard_parsers } from '../../../../packages/parsely/exports.js';
import { StringStream } from '../../../../packages/parsely/streams.js';
import { INVALID, Parser, UNRECOGNIZED, VALUE } from '../../../../packages/parsely/parser.js';
/**
* A slight hack: Parsely doesn't yet have an equivalent of backreferences.
* So, while parsing /foo/bar/, where the `/` can be any character, we set the current_delimiter variable
* to that delimiter character temporarily, so we can refer to it in the subsequent delimiters.
*/
class DelimiterParser extends Parser {
static current_delimiter;
_create({ first = false, character = null } = {}) {
this.character = character;
this.first = first;
}
_parse(stream) {
const sub_stream = stream.fork();
let { done, value } = sub_stream.next();
if (done) return UNRECOGNIZED;
if (this.first) {
if (this.character && this.character !== value)
return UNRECOGNIZED;
// Backslash and newline are disallowed as delimiters.
if (value === '\n' || value === '\\')
return UNRECOGNIZED;
DelimiterParser.current_delimiter = value;
} else if (DelimiterParser.current_delimiter !== value) {
return UNRECOGNIZED;
}
stream.join(sub_stream);
return { status: VALUE, $: 'delimiter', value };
}
}
export const parseScript = (script_string, options) => {
const grammar_context = new GrammarContext({
...standard_parsers(),
delimiter: DelimiterParser,
});
let group_start_id = 0;
let group_end_id = 0;
const parser = grammar_context.define_parser({
script: a => a.repeat(
a.optional(a.symbol('command')),
a.firstMatch(
a.literal('\n'),
a.literal(';'),
),
),
command: a => a.sequence(
a.symbol('whitespace'),
a.optional(a.symbol('address_range')),
a.symbol('whitespace'),
a.firstMatch(
a.discard(a.symbol('comment')),
a.symbol('{'),
a.symbol('}'),
a.symbol(':'),
a.symbol('='),
a.symbol('a'),
a.symbol('b'),
a.symbol('c'),
a.symbol('d'),
a.symbol('D'),
a.symbol('g'),
a.symbol('G'),
a.symbol('h'),
a.symbol('H'),
a.symbol('i'),
a.symbol('l'),
a.symbol('p'),
a.symbol('P'),
a.symbol('q'),
a.symbol('Q'),
a.symbol('s'),
a.symbol('t'),
a.symbol('T'),
a.symbol('x'),
a.symbol('y'),
a.symbol('z'),
),
),
address_range: a => a.sequence(
a.optional(
a.sequence(
a.symbol('address'),
a.optional(a.sequence(
a.literal(','),
a.symbol('address'),
)),
),
),
a.optional(
a.sequence(
a.symbol('whitespace'),
a.literal('!'),
),
),
),
address: a => a.firstMatch(
// TODO: A dollar sign, for "final line"
a.symbol('decimal_number'),
a.symbol('regex'),
),
decimal_number: a => a.stringOf(c => /\d/.test(c)),
regex: a => a.sequence(
a.firstMatch(
a.delimiter({ first: true, character: '/' }),
a.sequence(
a.literal('\\'),
a.delimiter({ first: true }),
),
),
a.stringUntil(c => c === DelimiterParser.current_delimiter),
a.delimiter(),
),
whitespace: a => a.discard(
a.optional(
a.stringOf(c => /[ \t]/.test(c)),
),
),
label: a => a.stringOf(c => {
// POSIX defines this as being characters within "the portable filename character set".
return /[A-Za-z0-9.\-_]/.test(c);
}),
filename: a => a.stringOf(c => {
return /[A-Za-z0-9.\-_]/.test(c);
}),
text: a => a.stringUntil('\n'),
comment: a => a.sequence(
a.literal('#'),
a.stringOf(c => c !== '\n'),
),
'{': a => a.literal('{'),
'}': a => a.literal('}'),
':': a => a.sequence(
a.literal(':'),
a.symbol('label'),
),
'=': a => a.literal('='),
a: a => a.sequence(
a.literal('a\\\n'),
a.symbol('text'),
),
b: a => a.sequence(
a.literal('b'),
a.optional(
a.sequence(
a.symbol('whitespace'),
a.symbol('label'),
),
),
),
c: a => a.sequence(
a.literal('c\\\n'),
a.symbol('text'),
),
d: a => a.literal('d'),
D: a => a.literal('D'),
g: a => a.literal('g'),
G: a => a.literal('G'),
h: a => a.literal('h'),
H: a => a.literal('H'),
i: a => a.sequence(
a.literal('i\\\n'),
a.symbol('text'),
),
l: a => a.literal('l'),
p: a => a.literal('p'),
P: a => a.literal('P'),
q: a => a.literal('q'),
Q: a => a.literal('Q'),
s: a => a.sequence(
a.literal('s'),
a.delimiter({ first: true }),
a.stringUntil(c => c === DelimiterParser.current_delimiter),
a.delimiter(),
a.stringUntil(c => c === DelimiterParser.current_delimiter),
a.delimiter(),
a.optional(
a.repeat(
a.firstMatch(
a.literal('g'),
a.literal('p'),
a.symbol('decimal_number'),
a.sequence(
a.literal('w'),
a.symbol('whitespace'),
a.symbol('filename'),
),
),
),
),
),
t: a => a.sequence(
a.literal('t'),
a.optional(
a.sequence(
a.symbol('whitespace'),
a.symbol('label'),
),
),
),
T: a => a.sequence(
a.literal('T'),
a.optional(
a.sequence(
a.symbol('whitespace'),
a.symbol('label'),
),
),
),
x: a => a.literal('x'),
y: a => a.sequence(
a.literal('y'),
a.delimiter({ first: true }),
a.stringUntil(c => c === DelimiterParser.current_delimiter),
a.delimiter(),
a.stringUntil(c => c === DelimiterParser.current_delimiter),
a.delimiter(),
),
z: a => a.literal('z'),
}, {
script: script => {
const commands = script
.filter(it => {
return it.$ === 'command' && it.value;
}).map(it => {
return it.value;
});
// Record all labels that exist in the script, so we can validate branch commands.
const labels = new Set();
for (const command of commands) {
if (command instanceof LabelCommand) {
labels.add(command.label);
}
}
// Validate commands
let group_depth = 0;
for (const command of commands) {
// Ensure branches all go to labels that exist
if (command instanceof BranchCommand) {
// Note: Branches to the end of the script don't have a label.
if (command.label && !labels.has(command.label))
throw new Error(`Label "${command.label}" does not exist in the script.`);
}
if (command instanceof GroupStartCommand) {
group_depth++;
}
if (command instanceof GroupEndCommand) {
if (group_depth < 1)
throw new Error('Unexpected "}": no open groups');
group_depth--;
}
}
if (group_depth !== 0)
throw new Error(`${group_depth} groups left open`);
return new Script(commands);
},
command: command => {
// Comments show up as empty commands. Just skip them.
if (command.length === 0)
return;
let addresses_provided = 0;
let address_range, func;
switch (command.length) {
case 1:
address_range = new AddressRange();
func = command[0];
break;
default:
address_range = command[0].value;
func = command[1];
addresses_provided = address_range.addressCount;
break;
}
const require_max_address_count = (count) => {
if (addresses_provided > count)
throw new Error(`Too many addresses provided to '${func.$}' command, most is ${count}`);
}
// Decode func into its command type
switch (func.$) {
case '{': {
require_max_address_count(2);
return new GroupStartCommand(address_range, ++group_start_id);
}
case '}': {
require_max_address_count(0);
return new GroupEndCommand(++group_end_id);
}
case ':': {
require_max_address_count(0);
return new LabelCommand(func.value);
}
case '=': {
require_max_address_count(1);
return new LineNumberCommand(address_range);
}
case 'a': {
require_max_address_count(1);
return new AppendTextCommand(address_range, func.value);
}
case 'b': {
require_max_address_count(2);
return new BranchCommand(address_range, func.value);
}
case 'c': {
require_max_address_count(2);
return new ReplaceCommand(address_range, func.value);
}
case 'd':
case 'D': {
require_max_address_count(2);
return new DeleteCommand(address_range, func.$ === 'D');
}
case 'g':
case 'G': {
require_max_address_count(2);
return new GetCommand(address_range, func.$ === 'G');
}
case 'h':
case 'H': {
require_max_address_count(2);
return new HoldCommand(address_range, func.$ === 'H');
}
case 'i': {
require_max_address_count(1);
return new InsertTextCommand(address_range, func.value);
}
case 'l': {
require_max_address_count(2);
return new DebugPrintCommand(address_range);
}
case 'p':
case 'P': {
require_max_address_count(2);
return new PrintCommand(address_range, func.$ === 'P');
}
case 'q':
case 'Q': {
require_max_address_count(1);
return new QuitCommand(address_range, func.$ === 'Q');
}
case 's': {
require_max_address_count(2);
const { regex, replacement, flags } = func.value;
return new SubstituteCommand(address_range, regex, replacement, flags);
}
case 't':
case 'T': {
require_max_address_count(2);
return new BranchCommand(address_range, func.value, func.$ === 't');
}
case 'x': {
require_max_address_count(2);
return new ExchangeCommand(address_range);
}
case 'y': {
require_max_address_count(2);
const { input, replacement } = func.value;
return new TransliterateCommand(address_range, input, replacement);
}
case 'z': {
require_max_address_count(2);
return new ZapCommand(address_range);
}
default:
throw new Error(`Unimplemented command '${func.$}'`);
}
},
address_range: address_range => {
if (address_range.length === 0)
return new AddressRange();
if (address_range.length === 1) {
if (address_range[0].value[0].$ === 'address') {
// Either 1 or two addresses
const parts = address_range[0].value;
const start = parts[0].value;
const end = parts[1] ? parts[1].value[1].value : null;
return new AddressRange({ start, end });
}
// No addresses, just inverted
return new AddressRange({ inverted: true });
}
// Addresses and inverted
const parts = address_range[0].value;
const start = parts[0].value;
const end = parts[1] ? parts[1].value[1].value : null;
return new AddressRange({ start, end, inverted: true });
},
address: address => {
if (address instanceof RegExp)
return new Address(address);
return new Address(Number(address));
},
regex: regex => new RegExp(regex[1].value),
// Functions with arguments
':': it => it[1].value,
a: it => it[1].value,
b: it => {
if (it.length < 2) return null;
return it[1].value[0].value;
},
c: it => it[1].value,
i: it => it[1].value,
s: it => {
const [ s, _, regex, __, replacement, ___, flag_values ] = it;
const flags = {
global: false,
nthOccurrence: null,
print: false,
writeToFile: null,
};
if (flag_values && flag_values.value.length) {
for (const flag of flag_values.value) {
if (flag.value instanceof Array) {
// It's a 'w'
if (flags.writeToFile)
throw new Error(`Multiple 'w' flags given to s command`);
flags.writeToFile = flag.value[1].value;
} else if (flag.value === 'g') {
if (flags.global)
throw new Error(`Multiple 'g' flags given to s command`);
flags.global = true;
} else if (flag.value === 'p') {
if (flags.print)
throw new Error(`Multiple 'p' flags given to s command`);
flags.print = true;
} else {
// Should be a number
if (flags.nthOccurrence !== null)
throw new Error(`Multiple number flags given to s command`);
flags.nthOccurrence = Number.parseInt(flag.value);
}
}
}
return {
regex: new RegExp(regex.value),
replacement: replacement.value,
flags: new SubstituteFlags(flags),
};
},
t: it => {
if (it.length < 2) return null;
return it[1].value[0].value;
},
T: it => {
if (it.length < 2) return null;
return it[1].value[0].value;
},
y: it => {
const input = it[2].value;
const replacement = it[4].value;
if (input.length !== replacement.length)
throw new Error('Input and replacement parts of y command must have the same length');
return { input, replacement };
}
});
const stream = new StringStream(script_string);
const result = parser(stream, 'script', { must_consume_all_input: true });
return result.value;
}
@@ -0,0 +1,123 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Phoenix Shell.
*
* Phoenix Shell is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { JumpLocation, LabelCommand, GroupEndCommand } from './command.js';
import { fileLines } from '../../../util/file.js';
const CycleResult = {
Continue: Symbol('Continue'),
Quit: Symbol('Quit'),
QuitSilent: Symbol('QuitSilent'),
};
export class Script {
constructor(commands) {
this.commands = commands;
}
async runCycle(context) {
let i = 0;
while (i < this.commands.length) {
const command = this.commands[i];
command.updateMatchState(context);
const result = await command.runCommand(context);
switch (result) {
case JumpLocation.Label: {
const label = context.jumpParameter;
context.jumpParameter = null;
const foundIndex = this.commands.findIndex(c => c instanceof LabelCommand && c.label === label);
if (foundIndex === -1) {
// TODO: Check for existence of labels during parsing too.
throw new Error(`Label ':${label}' not found.`);
}
i = foundIndex;
break;
}
case JumpLocation.GroupEnd: {
const groupId = context.jumpParameter;
context.jumpParameter = null;
const foundIndex = this.commands.findIndex(c => c instanceof GroupEndCommand && c.id === groupId);
if (foundIndex === -1) {
// TODO: Check for matching groups during parsing too.
throw new Error(`Matching } for group #${groupId} not found.`);
}
i = foundIndex;
break;
}
case JumpLocation.Quit:
return CycleResult.Quit;
case JumpLocation.QuitSilent:
return CycleResult.QuitSilent;
case JumpLocation.StartOfCycle:
i = 0;
continue;
case JumpLocation.EndOfCycle:
return CycleResult.Continue;
case JumpLocation.None:
i++;
break;
}
}
}
async run(ctx) {
const { out, err } = ctx.externs;
const { positionals, values } = ctx.locals;
const context = {
out: ctx.externs.out,
patternSpace: '',
holdSpace: '\n',
lineNumber: 1,
queuedOutput: '',
};
// All remaining positionals are file paths to process.
for (const relPath of positionals) {
context.lineNumber = 1;
for await (const line of fileLines(ctx, relPath)) {
context.patternSpace = line.replace(/\n$/, '');
const result = await this.runCycle(context);
switch (result) {
case CycleResult.Quit: {
if (!values.quiet) {
await out.write(context.patternSpace + '\n');
}
return;
}
case CycleResult.QuitSilent: {
return;
}
}
if (!values.quiet) {
await out.write(context.patternSpace + '\n');
}
if (context.queuedOutput) {
await out.write(context.queuedOutput + '\n');
context.queuedOutput = '';
}
context.lineNumber++;
}
}
}
dump() {
return `SCRIPT:\n`
+ this.commands.map(command => command.dump(1)).join('');
}
}
@@ -0,0 +1,21 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Phoenix Shell.
*
* Phoenix Shell is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export function makeIndent(size) {
return ' '.repeat(size);
}