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
|
### 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.
|
||||||
|
|||||||
@@ -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\)"
|
||||||
|
|||||||
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 {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
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 {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
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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user