Swap {} for |{ }| blocks

Makes much more JS compatible without changes
This commit is contained in:
Oliver Blanthorn 2026-07-30 16:19:39 +02:00
parent 00b1445575
commit 8735a6a115
No known key found for this signature in database
GPG key ID: 2BB8C36BB504BFF3
9 changed files with 131 additions and 58 deletions

View file

@ -18,6 +18,27 @@ test.each([
expect([...rcFileToExCmds(rc)]).toEqual(expected) expect([...rcFileToExCmds(rc)]).toEqual(expected)
}) })
test("groups multiline v2 blocks with explicit delimiters", () => {
expect([
...rcFileToExCmds("set exversion 2\nbind x |{\necho one\necho two\n}|"),
]).toEqual([
"set exversion 2",
{
source: "bind x |{\necho one\necho two\n}|",
exversion: 2,
},
])
})
test.each([
["bind x |{\necho one", "incomplete"],
["bind x }|", "invalid"],
])("rejects %s v2 RC blocks", (source, error) => {
expect(() => [...rcFileToExCmds(`set exversion 2\n${source}`)]).toThrow(
`${error} ex command`,
)
})
test("runRc updates and saves versioned config", async () => { test("runRc updates and saves versioned config", async () => {
await config.clear() await config.clear()
jest.mocked(controller.acceptExCmd).mockImplementation(async cmd => { jest.mocked(controller.acceptExCmd).mockImplementation(async cmd => {

View file

@ -194,7 +194,7 @@ export function getCommandlineFns(cmdline_state: {
if ( if (
!command.startsWith(" ") && !command.startsWith(" ") &&
!browser.extension.inIncognitoContext && !browser.extension.inIncognitoContext &&
!/(^|[\s;|{}])--?private(?=$|[\s;|{}])/.test(command) !/(^|[\s;|])--?private(?=$|[\s;|])/.test(command)
) { ) {
State.getAsync("cmdHistory").then(c => { State.getAsync("cmdHistory").then(c => {
cmdline_state.state.cmdHistory = c.concat([command]) cmdline_state.state.cmdHistory = c.concat([command])

View file

@ -229,13 +229,15 @@ test("groups versioned programs after legacy RC commands", () => {
expect(rc.indexOf("bind x echo legacy | command")).toBeLessThan( expect(rc.indexOf("bind x echo legacy | command")).toBeLessThan(
rc.indexOf("set exversion 2"), rc.indexOf("set exversion 2"),
) )
expect(rc).toContain("bind y {\necho one\necho two\n}") expect(rc).toContain("bind y |{\necho one\necho two\n}|")
expect(rc).toContain("bind = {\necho equals\n}") expect(rc).toContain("bind = |{\necho equals\n}|")
expect(rc).toContain("set exversion 1") expect(rc).toContain("set exversion 1")
tri.config.USERCONFIG.nmaps = { for (const key of [";", "\\|{", "\\}|"]) {
";": { source: "echo unsafe", exversion: 2 }, tri.config.USERCONFIG.nmaps = {
[key]: { source: "echo unsafe", exversion: 2 },
}
expect(() => tri.config.parseConfig()).toThrow("safely export")
} }
expect(() => tri.config.parseConfig()).toThrow("safely export")
} finally { } finally {
tri.config.USERCONFIG.nmaps = nmaps tri.config.USERCONFIG.nmaps = nmaps
tri.config.USERCONFIG.exversion = exversion tri.config.USERCONFIG.exversion = exversion

View file

@ -18,12 +18,24 @@
*/ */
import * as R from "ramda" import * as R from "ramda"
import * as binding from "@src/lib/binding" import * as binding from "@src/lib/binding"
import { ExCommand, formatExProgram, isExProgram } from "@src/lib/excmd" import {
EX_BLOCK_CLOSE,
EX_BLOCK_OPEN,
ExCommand,
formatExProgram,
isExProgram,
} from "@src/lib/excmd"
import * as platform from "@src/lib/platform" import * as platform from "@src/lib/platform"
import { DeepPartial } from "tsdef" import { DeepPartial } from "tsdef"
const v2Syntax = [".|", "&&", "||", "|", ";", EX_BLOCK_OPEN, EX_BLOCK_CLOSE]
const escapableV2Syntax = [".|", "|", ";", EX_BLOCK_OPEN, EX_BLOCK_CLOSE]
const assertV2Argument = (value: string) => { const assertV2Argument = (value: string) => {
if (/[\s'"]|^(?:\.\||&&|\|\||[|;{}])$/.test(value)) if (
/[\s'"]/.test(value) ||
v2Syntax.includes(value) ||
(value[0] === "\\" && escapableV2Syntax.includes(value.slice(1)))
)
throw new Error(`Cannot safely export dialect 2 argument: ${value}`) throw new Error(`Cannot safely export dialect 2 argument: ${value}`)
} }

View file

@ -97,7 +97,7 @@ export async function acceptExCmd(
func !== stored_excmds[""].fillcmdline_tmp && func !== stored_excmds[""].fillcmdline_tmp &&
func !== stored_excmds[""].fillcmdline_nofocus && func !== stored_excmds[""].fillcmdline_nofocus &&
func !== stored_excmds[""].updatecheck && func !== stored_excmds[""].updatecheck &&
!/(^|[\s;|{}])-private(?=$|[\s;|{}])/.test(exstr) !/(^|[\s;|])--?private(?=$|[\s;|])/.test(exstr)
) { ) {
lastExUpdate = State.getAsync("last_ex_str").then( lastExUpdate = State.getAsync("last_ex_str").then(
last_ex_str => { last_ex_str => {

View file

@ -3,6 +3,9 @@ export interface ExProgram {
exversion: 2 exversion: 2
} }
export const EX_BLOCK_OPEN = "|{"
export const EX_BLOCK_CLOSE = "}|"
export const EX_CANCELLED = Object.freeze({ export const EX_CANCELLED = Object.freeze({
__tridactylExOutcome: "cancelled" as const, __tridactylExOutcome: "cancelled" as const,
}) })
@ -36,7 +39,7 @@ export const formatExProgram = (command: ExCommand) => {
if (!isExProgram(command)) return command if (!isExProgram(command)) return command
const prefix = command.source.startsWith("\n") ? "" : "\n" const prefix = command.source.startsWith("\n") ? "" : "\n"
const suffix = command.source.endsWith("\n") ? "" : "\n" const suffix = command.source.endsWith("\n") ? "" : "\n"
return `{${prefix}${command.source}${suffix}}` return `${EX_BLOCK_OPEN}${prefix}${command.source}${suffix}${EX_BLOCK_CLOSE}`
} }
export function joinExCommand(parts: ExCommand[]): ExCommand { export function joinExCommand(parts: ExCommand[]): ExCommand {

View file

@ -38,7 +38,9 @@ test.each([
"echo a |b", "echo a |b",
"echo C:\\windows\\etc", "echo C:\\windows\\etc",
"echo /a\\|b/", "echo /a\\|b/",
"echo {| b", "echo a|{b",
"echo a}|b",
"echo a|{b}|c",
])("does not split protected or non-standalone operators in %s", source => { ])("does not split protected or non-standalone operators in %s", source => {
expect(shape(source)).toEqual([["text", source, undefined]]) expect(shape(source)).toEqual([["text", source, undefined]])
expect(parseStructure(source).status).toBe("complete") expect(parseStructure(source).status).toBe("complete")
@ -46,9 +48,9 @@ test.each([
test("only backslash protects and is removed from standalone DSL syntax", async () => { test("only backslash protects and is removed from standalone DSL syntax", async () => {
const run = jest.fn() const run = jest.fn()
await evaluate("echo \\| \\&& \\|| \\.| \\; \\{ \\} \\{\\}", run) await evaluate("echo \\| \\&& \\|| \\.| \\; \\|{ \\}| \\{ \\}", run)
expect(run).toHaveBeenCalledWith( expect(run).toHaveBeenCalledWith(
"echo | \\&& \\|| .| ; { } {}", "echo | \\&& \\|| .| ; |{ }| \\{ \\}",
false, false,
undefined, undefined,
undefined, undefined,
@ -89,12 +91,12 @@ test("protects operators in whole-line comments and separates lines", () => {
}) })
test("parses nested blocks independently", () => { test("parses nested blocks independently", () => {
const source = "bind x { hint | tabopen } ; echo done" const source = "bind x |{ hint | tabopen }| ; echo done"
expect(shape(source)).toEqual([ expect(shape(source)).toEqual([
["text", "bind x ", undefined], ["text", "bind x ", undefined],
[ [
"block", "block",
"{ hint | tabopen }", "|{ hint | tabopen }|",
[ [
["text", " hint ", undefined], ["text", " hint ", undefined],
["operator", "|", undefined], ["operator", "|", undefined],
@ -107,8 +109,14 @@ test("parses nested blocks independently", () => {
]) ])
}) })
test("allows an escaped closing delimiter inside a block", async () => {
const run = jest.fn()
await evaluate("|{ echo \\}| }|", run)
expect(run).toHaveBeenCalledWith("echo }|", false, undefined, undefined)
})
test("reports an unterminated block as incomplete", () => test("reports an unterminated block as incomplete", () =>
expect(parseStructure("bind x { echo done").status).toBe("incomplete")) expect(parseStructure("bind x |{ echo done").status).toBe("incomplete"))
test.each(["echo a |", "echo a .|", "echo a ;"])( test.each(["echo a |", "echo a .|", "echo a ;"])(
"reports a missing right operand in %s", "reports a missing right operand in %s",
@ -119,8 +127,8 @@ test.each([
"| echo a", "| echo a",
"echo a | | echo b", "echo a | | echo b",
"echo a ; ; echo b", "echo a ; ; echo b",
"echo a }", "echo a }|",
"{ echo a | }", "|{ echo a | }|",
"js <<JS console.log(1)\necho done", "js <<JS console.log(1)\necho done",
"js <<JS console.log(1)\rmore JS", "js <<JS console.log(1)\rmore JS",
"js <<JS console.log(1)\r", "js <<JS console.log(1)\r",
@ -145,7 +153,7 @@ test("evaluates pipes and sequences in order", async () => {
test("ignores leading colons on every command", async () => { test("ignores leading colons on every command", async () => {
const run = jest.fn(source => source) const run = jest.fn(source => source)
await evaluate(":one\n{ :::two } ; :echo :::argument", run) await evaluate(":one\n|{ :::two }| ; :echo :::argument", run)
expect(run.mock.calls.map(call => call[0])).toEqual([ expect(run.mock.calls.map(call => call[0])).toEqual([
"one", "one",
"two", "two",
@ -357,7 +365,7 @@ test("maps a command with arguments and pipes the collected results", async () =
test("maps a multi-stage block", () => test("maps a multi-stage block", () =>
expect( expect(
evaluate( evaluate(
"values .| { double | stringify }", "values .| |{ double | stringify }|",
(command, _piped, input) => { (command, _piped, input) => {
if (command === "values") return [1, 2] if (command === "values") return [1, 2]
if (command === "double") return input * 2 if (command === "double") return input * 2
@ -368,7 +376,7 @@ test("maps a multi-stage block", () =>
test("supports nested block maps", () => test("supports nested block maps", () =>
expect( expect(
evaluate("matrix .| { pass .| double }", (command, _piped, input) => { evaluate("matrix .| |{ pass .| double }|", (command, _piped, input) => {
if (command === "matrix") if (command === "matrix")
return [ return [
[1, 2], [1, 2],
@ -473,7 +481,7 @@ test("continues sequences after a rejected pipeline", async () => {
expect(run.mock.calls.map(call => call[0])).toEqual(["a", "c"]) expect(run.mock.calls.map(call => call[0])).toEqual(["a", "c"])
}) })
test.each(["a | cancel | b ; c", "a | { cancel | b } ; c"])( test.each(["a | cancel | b ; c", "a | |{ cancel | b }| ; c"])(
"cancellation stops the whole program in %s", "cancellation stops the whole program in %s",
async source => { async source => {
const run = jest.fn(command => const run = jest.fn(command =>
@ -517,7 +525,9 @@ test("evaluates nested standalone blocks with pipeline input", async () => {
calls.push([source, piped, value]) calls.push([source, piped, value])
return piped ? `${source}(${value})` : source return piped ? `${source}(${value})` : source
}) })
await expect(evaluate("a | { b | { c } }", run)).resolves.toBe("c(b(a))") await expect(evaluate("a | |{ b | |{ c }| }|", run)).resolves.toBe(
"c(b(a))",
)
expect(calls).toEqual([ expect(calls).toEqual([
["a", false, undefined], ["a", false, undefined],
["b", true, "a"], ["b", true, "a"],
@ -527,7 +537,7 @@ test("evaluates nested standalone blocks with pipeline input", async () => {
test("passes a trailing block as a versioned program argument", async () => { test("passes a trailing block as a versioned program argument", async () => {
const run = jest.fn() const run = jest.fn()
await evaluate("bind x { a\n# keep this comment\nb }", run) await evaluate("bind x |{ a\n# keep this comment\nb }|", run)
expect(run).toHaveBeenCalledWith("bind x", false, undefined, { expect(run).toHaveBeenCalledWith("bind x", false, undefined, {
source: " a\n# keep this comment\nb ", source: " a\n# keep this comment\nb ",
exversion: 2, exversion: 2,
@ -605,17 +615,28 @@ test("formats blocks containing whole-line comments safely", async () => {
test("rejects unsupported nested syntax before executing", async () => { test("rejects unsupported nested syntax before executing", async () => {
const run = jest.fn() const run = jest.fn()
await expect(evaluate("a | { bind x { b } }", run)).rejects.toThrow( await expect(evaluate("a | |{ bind x |{ b }| }|", run)).rejects.toThrow(
"Unsupported", "Unsupported",
) )
expect(run).not.toHaveBeenCalled() expect(run).not.toHaveBeenCalled()
}) })
test.each([ test.each([
"bind { a } trailing", "bind |{ a }| trailing",
"bind x { a } { b }", "bind x |{ a }| |{ b }|",
"a | bind x { b }", "a | bind x |{ b }|",
"bind x { a } <<JS\nb\nJS", "bind x |{ a }| <<JS\nb\nJS",
])("rejects ambiguous block arguments in %s", source => ])("rejects ambiguous block arguments in %s", source =>
expect(evaluate(source, jest.fn())).rejects.toThrow("Unsupported"), expect(evaluate(source, jest.fn())).rejects.toThrow("Unsupported"),
) )
test.each([
"js if (ready) { work() }",
"js const value = { key: 1 }",
"js let url; { let node = find() }",
"echo { unmatched } braces",
])("treats ordinary braces as command text in %s", async source => {
const run = jest.fn()
await evaluate(source, run)
expect(run).toHaveBeenCalledWith(source, false, undefined, undefined)
})

View file

@ -1,4 +1,10 @@
import { ExProgram, isExCancelled, stripLeadingColons } from "@src/lib/excmd" import {
EX_BLOCK_CLOSE,
EX_BLOCK_OPEN,
ExProgram,
isExCancelled,
stripLeadingColons,
} from "@src/lib/excmd"
const operators = [".|", "|", ";"] as const const operators = [".|", "|", ";"] as const
@ -20,13 +26,20 @@ export type ExPart =
| ({ type: "operator"; operator: ExOperator } & Span) | ({ type: "operator"; operator: ExOperator } & Span)
| ({ type: "comment" } & Span) | ({ type: "comment" } & Span)
| ({ type: "heredoc"; bodyStart: number; bodyEnd: number } & Span) | ({ type: "heredoc"; bodyStart: number; bodyEnd: number } & Span)
| ({ type: "block"; body: ExStructure } & Span) | ({
type: "block"
body: ExStructure
bodyStart: number
bodyEnd: number
} & Span)
interface ParseResult extends ExStructure { interface ParseResult extends ExStructure {
end: number end: number
bodyEnd?: number
} }
const boundaries = " \t\r\n" const boundaries = " \t\r\n"
const blockDelimiters = [EX_BLOCK_OPEN, EX_BLOCK_CLOSE]
const status = ( const status = (
invalid: boolean, invalid: boolean,
@ -54,13 +67,14 @@ function escapedSyntaxEnd(source: string, index: number) {
isBoundary(source[index + candidate.length + 1]), isBoundary(source[index + candidate.length + 1]),
) )
if (operator) return index + operator.length + 1 if (operator) return index + operator.length + 1
const escaped = source[index + 1] const blockDelimiter = blockDelimiters.find(
if ( delimiter =>
(escaped === "{" || escaped === "}") && source.startsWith(delimiter, index + 1) &&
isBraceBoundary(source, index - 1) && isBoundary(source[index - 1]) &&
isBraceBoundary(source, index + 2) isBoundary(source[index + delimiter.length + 1]),
) )
return index + 2 if (blockDelimiter) return index + blockDelimiter.length + 1
const escaped = source[index + 1]
if ( if (
(escaped === "#" || escaped === '"') && (escaped === "#" || escaped === '"') &&
isWholeLineComment(source, index) isWholeLineComment(source, index)
@ -69,18 +83,12 @@ function escapedSyntaxEnd(source: string, index: number) {
return undefined return undefined
} }
function isBraceBoundary(source: string, index: number) { function blockDelimiterAt(source: string, index: number) {
const character = source[index] if (!isBoundary(source[index - 1])) return undefined
return ( return blockDelimiters.find(
isBoundary(character) || delimiter =>
"{}".includes(character) || source.startsWith(delimiter, index) &&
(character === "\\" && "{}".includes(source[index + 1])) isBoundary(source[index + delimiter.length]),
)
}
function isStandaloneBrace(source: string, index: number) {
return (
isBraceBoundary(source, index - 1) && isBraceBoundary(source, index + 1)
) )
} }
@ -206,14 +214,18 @@ function parseRange(
textStart = index + 1 textStart = index + 1
continue continue
} }
if (character === "{" && isStandaloneBrace(source, index)) { const blockDelimiter = blockDelimiterAt(source, index)
if (blockDelimiter === EX_BLOCK_OPEN) {
text(index) text(index)
const child = parseRange(source, index + 1, true) const bodyStart = index + EX_BLOCK_OPEN.length
const child = parseRange(source, bodyStart, true)
const end = child.end const end = child.end
parts.push({ parts.push({
type: "block", type: "block",
start: index, start: index,
end, end,
bodyStart,
bodyEnd: child.bodyEnd ?? source.length,
body: { parts: child.parts, status: child.status }, body: { parts: child.parts, status: child.status },
}) })
incomplete = incomplete || child.status === "incomplete" incomplete = incomplete || child.status === "incomplete"
@ -223,7 +235,7 @@ function parseRange(
textStart = end textStart = end
continue continue
} }
if (nested && character === "}" && isStandaloneBrace(source, index)) { if (nested && blockDelimiter === EX_BLOCK_CLOSE) {
text(index) text(index)
return { return {
parts, parts,
@ -231,11 +243,13 @@ function parseRange(
invalid || expectation === "operand", invalid || expectation === "operand",
incomplete, incomplete,
), ),
end: index + 1, end: index + EX_BLOCK_CLOSE.length,
bodyEnd: index,
} }
} }
if (!nested && character === "}" && isStandaloneBrace(source, index)) { if (!nested && blockDelimiter === EX_BLOCK_CLOSE) {
invalid = true invalid = true
index += EX_BLOCK_CLOSE.length - 1
continue continue
} }
@ -340,7 +354,7 @@ function compile(
command: blockCommand, command: blockCommand,
piped, piped,
program: { program: {
source: source.slice(block.start + 1, block.end - 1), source: source.slice(block.bodyStart, block.bodyEnd),
exversion: 2, exversion: 2,
}, },
}) })

View file

@ -57,12 +57,12 @@ echo hello
# bare expressions are allowed in pipes # bare expressions are allowed in pipes
:js [{hello: "world"}, {mellow: "yellow"}, {hello: "universe"}] .| _.hello | filter | fillcmdline :js [{hello: "world"}, {mellow: "yellow"}, {hello: "universe"}] .| _.hello | filter | fillcmdline
# { } form blocks # |{ }| form blocks
:bind ;Y { :bind ;Y |{
hint -eJc img | hint -eJc img |
_.src | _.src |
yankimage yankimage
} }|
# heredocs are supported for making javascript more pleasant # heredocs are supported for making javascript more pleasant
:js <<JS :js <<JS