Implement convert and while + file-based editing
This commit is contained in:
10
HELP.md
10
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 <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
|
||||
The file `~/.str.rc` is automatically executed when `str` starts up.
|
||||
You can use this to define a user-specific environment.
|
||||
|
||||
@@ -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 "\<contains\>"
|
||||
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\)"
|
||||
|
||||
50
src/index.ts
50
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 <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
75
src/util/transliterate.ts
Normal 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,
|
||||
}
|
||||
37
src/vm/commands/convert.ts
Normal file
37
src/vm/commands/convert.ts
Normal 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))
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
57
src/vm/commands/while.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user