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

10
HELP.md
View File

@@ -90,6 +90,11 @@ str %>
### I/O & Editor Control ### 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` #### `exit`
Stop `str` and exit. This can also be done with `^C`. 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. Can generate individual words, lines, or paragraphs.
Example: `lipsum 4 word` -> `lorem ipsum dolor sit` Example: `lipsum 4 word` -> `lorem ipsum dolor sit`
#### `convert <from> <to>`
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 #### User-specific setup files
The file `~/.str.rc` is automatically executed when `str` starts up. The file `~/.str.rc` is automatically executed when `str` starts up.
You can use this to define a user-specific environment. You can use this to define a user-specific environment.

View File

@@ -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 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 keyword strKeyword line word each on drop take missing lines words split chunk join sort unique zip
syn match strKeyword "\<contains\>" syn match strKeyword "\<contains\>"
syn keyword strKeyword to from set over call lipsum syn keyword strKeyword to from set over call lipsum convert if unless
" Types " Types
syn match strType "::\s*\zs\(string\|int\|destructured\)" syn match strType "::\s*\zs\(string\|int\|destructured\)"

View File

@@ -6,21 +6,26 @@ import {Parser} from "./vm/parser.js";
import {commands} from "./vm/commands/index.js"; import {commands} from "./vm/commands/index.js";
import {Executor} from "./vm/vm.js"; import {Executor} from "./vm/vm.js";
import {ConsoleDisplay, OutputManager, WlClipboard} from "./vm/output.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"; import * as fs from "node:fs";
;(async () => { ;(async () => {
const lifecycle = new Lifecycle() const lifecycle = new Lifecycle()
// Setup the input reader & logging:
const input = new Input() const input = new Input()
input.adoptLifecycle(lifecycle) input.adoptLifecycle(lifecycle)
input.subscribe(line => log.verbose('input', { line })) input.subscribe(line => log.verbose('input', { line }))
// Chain on the lexer:
const lexer = new Lexer(input) const lexer = new Lexer(input)
lexer.subscribe(token => log.verbose('token', token)) lexer.subscribe(token => log.verbose('token', token))
// Chain on the parser:
const parser = new Parser(commands, lexer) const parser = new Parser(commands, lexer)
parser.subscribe(exec => log.verbose('exec', exec)) parser.subscribe(exec => log.verbose('exec', exec))
// Chain on the VM executor:
const output: OutputManager = { const output: OutputManager = {
display: new ConsoleDisplay, display: new ConsoleDisplay,
clipboard: new WlClipboard, clipboard: new WlClipboard,
@@ -29,10 +34,12 @@ import * as fs from "node:fs";
const exec = new Executor(output, parser, input) const exec = new Executor(output, parser, input)
exec.adoptLifecycle(lifecycle) exec.adoptLifecycle(lifecycle)
// A little bit of window dressing:
console.log('`str` : An interactive string manipulation environment') console.log('`str` : An interactive string manipulation environment')
console.log('Copyright (C) 2026 Garrett Mills <shout@garrettmills.dev>') console.log('Copyright (C) 2026 Garrett Mills <shout@garrettmills.dev>')
console.log('') console.log('')
// If the user has an rc-file, execute it:
const rcFile = processPath('~/.str.rc') const rcFile = processPath('~/.str.rc')
if ( fs.existsSync(rcFile) ) { if ( fs.existsSync(rcFile) ) {
log.verbose('rc', { rcFile }) log.verbose('rc', { rcFile })
@@ -43,8 +50,49 @@ import * as fs from "node:fs";
console.log('Successfully loaded ~/.str.rc\n') 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()) exec.subscribe(state => state.outputSubject())
// Start the prompt:
input.setupPrompt() 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('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 {Flatten} from "./flatten.js";
import {If} from "./if.js"; import {If} from "./if.js";
import {Unless} from "./unless.js"; import {Unless} from "./unless.js";
import {Convert} from "./convert.js";
import {While} from "./while.js";
export type Commands = Command<CommandData>[] export type Commands = Command<CommandData>[]
export const commands: Commands = [ export const commands: Commands = [
@@ -65,6 +67,7 @@ export const commands: Commands = [
new Clear, new Clear,
new Concat, new Concat,
new Contains, new Contains,
new Convert,
new Copy, new Copy,
new Drop, new Drop,
new Each, new Each,
@@ -113,6 +116,7 @@ export const commands: Commands = [
new Unless, new Unless,
new Unquote, new Unquote,
new Upper, new Upper,
new While,
new Word, new Word,
new Words, new Words,
new Zip, 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 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 { adoptLifecycle(lifecycle: Lifecycle): void {
this.lifecycle = lifecycle this.lifecycle = lifecycle
} }