diff --git a/HELP.md b/HELP.md index 276336a..3f34851 100644 --- a/HELP.md +++ b/HELP.md @@ -90,6 +90,11 @@ str %> ### I/O & Editor Control +By default, `str` launches with an empty string as its initial subject (`''`). +The command also accepts a file as its only parameter (e.g. `str my-file.txt`). +If a file is provided, `str` will read from the file **and overwrite the file +with the current subject when `exit` is called.** + #### `exit` Stop `str` and exit. This can also be done with `^C`. @@ -401,6 +406,11 @@ Replace the current subject with "Lorem ipsum..." placeholder text. Can generate individual words, lines, or paragraphs. Example: `lipsum 4 word` -> `lorem ipsum dolor sit` +#### `convert ` +Assuming the current subject is a valid single data document, convert it `from` a lang `to` another lang. +Supports: `php`|`json` +Example: `{"a": 1}` -> `convert json php` -> `['a' => 1]` + #### User-specific setup files The file `~/.str.rc` is automatically executed when `str` starts up. You can use this to define a user-specific environment. diff --git a/editor-support/vim/syntax/str.vim b/editor-support/vim/syntax/str.vim index 0dbee6a..9def1eb 100644 --- a/editor-support/vim/syntax/str.vim +++ b/editor-support/vim/syntax/str.vim @@ -11,7 +11,7 @@ syn keyword strKeyword exit paste copy infile outfile assign clear show undo red syn keyword strKeyword enclose lower upper lsub rsub prefix suffix quote unquote replace rev trim indent concat syn keyword strKeyword line word each on drop take missing lines words split chunk join sort unique zip syn match strKeyword "\" -syn keyword strKeyword to from set over call lipsum +syn keyword strKeyword to from set over call lipsum convert if unless " Types syn match strType "::\s*\zs\(string\|int\|destructured\)" diff --git a/src/index.ts b/src/index.ts index b5e0bb6..aba7a6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 ') 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()) })() diff --git a/src/util/transliterate.ts b/src/util/transliterate.ts new file mode 100644 index 0000000..05eb8e3 --- /dev/null +++ b/src/util/transliterate.ts @@ -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 + toTarget(data: JSONData): Awaitable +} + +export class JSONTarget implements TransliterateTarget { + fromTarget(input: string): Awaitable { + return JSON.parse(input) + } + + toTarget(data: JSONData): Awaitable { + return JSON.stringify(data, undefined, 4) + } +} + +export class PHPTarget extends JSONTarget { + async fromTarget(input: string): Promise { + 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 { + 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, +} diff --git a/src/vm/commands/convert.ts b/src/vm/commands/convert.ts new file mode 100644 index 0000000..9529d22 --- /dev/null +++ b/src/vm/commands/convert.ts @@ -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 { + attemptParse(context: ParseContext): Awaitable { + 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 { + return vm.replaceContextMatchingTerm({ + string: async val => { + const from = targets[data.from] + const to = targets[data.to] + return to.toTarget(await from.fromTarget(val)) + }, + }) + } +} diff --git a/src/vm/commands/index.ts b/src/vm/commands/index.ts index 7fea161..a0f4d21 100644 --- a/src/vm/commands/index.ts +++ b/src/vm/commands/index.ts @@ -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[] 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, diff --git a/src/vm/commands/while.ts b/src/vm/commands/while.ts new file mode 100644 index 0000000..f88e209 --- /dev/null +++ b/src/vm/commands/while.ts @@ -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 { + async attemptParse(context: ParseContext): Promise { + 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 { + 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 { + 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); + } +} diff --git a/src/vm/vm.ts b/src/vm/vm.ts index d2754fc..8873be3 100644 --- a/src/vm/vm.ts +++ b/src/vm/vm.ts @@ -547,6 +547,11 @@ export class Executor extends BehaviorSubject implements LifecycleAware{ throw error } + async tapVM(exec: (vm: StrVM) => Awaitable): Promise { + const vm = this.currentValue || this.makeVM() + return exec(vm) + } + adoptLifecycle(lifecycle: Lifecycle): void { this.lifecycle = lifecycle }