Implement convert and while + file-based editing

This commit is contained in:
2026-05-28 09:37:45 -05:00
parent 1d20aa59d1
commit afd99d7dfd
8 changed files with 238 additions and 2 deletions

View File

@@ -6,21 +6,26 @@ import {Parser} from "./vm/parser.js";
import {commands} from "./vm/commands/index.js";
import {Executor} from "./vm/vm.js";
import {ConsoleDisplay, OutputManager, WlClipboard} from "./vm/output.js";
import {processPath} from "./vm/commands/command.js";
import {processPath, unwrapString} from "./vm/commands/command.js";
import * as fs from "node:fs";
;(async () => {
const lifecycle = new Lifecycle()
// Setup the input reader & logging:
const input = new Input()
input.adoptLifecycle(lifecycle)
input.subscribe(line => log.verbose('input', { line }))
// Chain on the lexer:
const lexer = new Lexer(input)
lexer.subscribe(token => log.verbose('token', token))
// Chain on the parser:
const parser = new Parser(commands, lexer)
parser.subscribe(exec => log.verbose('exec', exec))
// Chain on the VM executor:
const output: OutputManager = {
display: new ConsoleDisplay,
clipboard: new WlClipboard,
@@ -29,10 +34,12 @@ import * as fs from "node:fs";
const exec = new Executor(output, parser, input)
exec.adoptLifecycle(lifecycle)
// A little bit of window dressing:
console.log('`str` : An interactive string manipulation environment')
console.log('Copyright (C) 2026 Garrett Mills <shout@garrettmills.dev>')
console.log('')
// If the user has an rc-file, execute it:
const rcFile = processPath('~/.str.rc')
if ( fs.existsSync(rcFile) ) {
log.verbose('rc', { rcFile })
@@ -43,8 +50,49 @@ import * as fs from "node:fs";
console.log('Successfully loaded ~/.str.rc\n')
}
// If the user specified a filepath, load it as the initial subject:
const editingFile: string|undefined = process.argv[2]
if ( editingFile ) {
log.debug('rc', { editingFile })
if ( !fs.existsSync(editingFile) ) {
log.error('rc', 'Could not open file: ' + editingFile)
process.exit(1)
}
const editingFileContent = fs.readFileSync(editingFile).toString()
log.info('rc', 'Read file: ' + editingFile)
log.verbose('rc', { editingFileContent })
await exec.tapVM(async vm => {
await vm.replaceContextMatchingTerm({
override: editingFileContent,
})
await vm.outputSubject()
})
}
// Print the subject after each command:
exec.subscribe(state => state.outputSubject())
// Start the prompt:
input.setupPrompt()
// If we were editing a file, save the contents on close:
lifecycle.onClose(async () => {
log.debug('rc', { cleanupAndExit: true, editingFile })
if ( editingFile ) {
await exec.tapVM(vm =>
vm.tapInPlace(ctx => {
const editingFileOutputContent = unwrapString(ctx.getSubject())
log.verbose('rc', { editingFileOutputContent })
fs.writeFileSync(editingFile, editingFileOutputContent)
log.info('rc', 'Wrote file: ' + editingFile)
})
)
}
})
process.on('SIGINT', () => lifecycle.close())
process.on('SIGTERM', () => lifecycle.close())
process.on('SIGQUIT', () => lifecycle.close())
})()

75
src/util/transliterate.ts Normal file
View File

@@ -0,0 +1,75 @@
import {Awaitable, JSONData} from "./types.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { writeFile, unlink } from "node:fs/promises";
import { tempFile } from "./fs.js";
const execFileAsync = promisify(execFile);
const PHP_EVAL_SCRIPT = `
echo json_encode(eval("return " . file_get_contents($argv[1]) . ";"));
`;
const PHP_MODERN_EXPORT_SCRIPT = `
$data = json_decode(file_get_contents($argv[1]), true);
function modern_export($var, $indent = "") {
if (is_array($var)) {
if (empty($var)) return "[]";
$indexed = array_keys($var) === range(0, count($var) - 1);
$r = [];
foreach ($var as $k => $v) {
$r[] = $indent . " " . ($indexed ? "" : var_export($k, true) . " => ") . modern_export($v, $indent . " ");
}
return "[\\n" . implode(",\\n", $r) . ",\\n" . $indent . "]";
} else {
$export = var_export($var, true);
if ($var === null) return "null";
return $export;
}
}
echo modern_export($data);
`;
export interface TransliterateTarget {
fromTarget(input: string): Awaitable<JSONData>
toTarget(data: JSONData): Awaitable<string>
}
export class JSONTarget implements TransliterateTarget {
fromTarget(input: string): Awaitable<JSONData> {
return JSON.parse(input)
}
toTarget(data: JSONData): Awaitable<string> {
return JSON.stringify(data, undefined, 4)
}
}
export class PHPTarget extends JSONTarget {
async fromTarget(input: string): Promise<JSONData> {
const tmp = tempFile();
await writeFile(tmp, input, 'utf-8');
try {
const { stdout } = await execFileAsync('php', ['-r', PHP_EVAL_SCRIPT, tmp]);
return JSON.parse(stdout);
} finally {
await unlink(tmp).catch(() => {});
}
}
async toTarget(data: JSONData): Promise<string> {
const tmp = tempFile();
await writeFile(tmp, JSON.stringify(data), 'utf-8');
try {
const { stdout } = await execFileAsync('php', ['-r', PHP_MODERN_EXPORT_SCRIPT, tmp]);
return stdout;
} finally {
await unlink(tmp).catch(() => {});
}
}
}
export const targets = {
json: new JSONTarget,
php: new PHPTarget,
}

View File

@@ -0,0 +1,37 @@
import {targets} from "../../util/transliterate.js";
import {Command, ParseContext} from "./command.js";
import {LexInput} from "../lexer.js";
import {Awaitable} from "../../util/types.js";
import {StrVM} from "../vm.js";
export type ConvertData = {
from: keyof (typeof targets),
to: keyof (typeof targets),
}
export class Convert extends Command<ConvertData> {
attemptParse(context: ParseContext): Awaitable<ConvertData> {
return {
from: context.popKeywordInSet(Object.keys(targets)).value as keyof (typeof targets),
to: context.popKeywordInSet(Object.keys(targets)).value as keyof (typeof targets),
}
}
isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'convert')
}
getDisplayName(): string {
return 'convert'
}
async execute(vm: StrVM, data: ConvertData): Promise<StrVM> {
return vm.replaceContextMatchingTerm({
string: async val => {
const from = targets[data.from]
const to = targets[data.to]
return to.toTarget(await from.fromTarget(val))
},
})
}
}

View File

@@ -56,6 +56,8 @@ import {Group} from "./group.js";
import {Flatten} from "./flatten.js";
import {If} from "./if.js";
import {Unless} from "./unless.js";
import {Convert} from "./convert.js";
import {While} from "./while.js";
export type Commands = Command<CommandData>[]
export const commands: Commands = [
@@ -65,6 +67,7 @@ export const commands: Commands = [
new Clear,
new Concat,
new Contains,
new Convert,
new Copy,
new Drop,
new Each,
@@ -113,6 +116,7 @@ export const commands: Commands = [
new Unless,
new Unquote,
new Upper,
new While,
new Word,
new Words,
new Zip,

57
src/vm/commands/while.ts Normal file
View File

@@ -0,0 +1,57 @@
import {LexInput} from "../lexer.js";
import {Command, ParseContext, StrTerm} from "./command.js";
import {ExecutionContext, StrVM} from "../vm.js";
import {Call} from "./call.js";
export type WhileData = {
cond: StrTerm,
callable: StrTerm,
}
export class While extends Command<WhileData> {
async attemptParse(context: ParseContext): Promise<WhileData> {
return {
cond: await context.popTerm(),
callable: await context.popTerm(),
}
}
isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'while')
}
getDisplayName(): string {
return 'while'
}
async execute(vm: StrVM, data: WhileData): Promise<StrVM> {
return vm.replaceContextFromChild(async (childVM, ctx) => {
while ( await this.evalCond(childVM, ctx, data) ) {
const callable = ctx.resolveLambda(data.callable)
await (new Call).execute(childVM, {
callable,
params: [],
})
}
})
}
private async evalCond(childVM: StrVM, ctx: ExecutionContext, data: WhileData): Promise<boolean> {
let cond = ctx.resolveRequired(data.cond)
// If `cond` is a lambda, then call it before evaluating its truthiness.
if ( cond.term === 'lambda' ) {
await childVM.runInChild(async (lambdaVM, lambdaCtx) => {
await (new Call).execute(lambdaVM, {
callable: cond,
params: [],
})
cond = lambdaCtx.getSubject()
})
}
return !((cond.term === 'string' || cond.term === 'int') && !cond.value);
}
}

View File

@@ -547,6 +547,11 @@ export class Executor extends BehaviorSubject<StrVM> implements LifecycleAware{
throw error
}
async tapVM<T>(exec: (vm: StrVM) => Awaitable<T>): Promise<T> {
const vm = this.currentValue || this.makeVM()
return exec(vm)
}
adoptLifecycle(lifecycle: Lifecycle): void {
this.lifecycle = lifecycle
}