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)
})
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 () => {
await config.clear()
jest.mocked(controller.acceptExCmd).mockImplementation(async cmd => {

View file

@ -194,7 +194,7 @@ export function getCommandlineFns(cmdline_state: {
if (
!command.startsWith(" ") &&
!browser.extension.inIncognitoContext &&
!/(^|[\s;|{}])--?private(?=$|[\s;|{}])/.test(command)
!/(^|[\s;|])--?private(?=$|[\s;|])/.test(command)
) {
State.getAsync("cmdHistory").then(c => {
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(
rc.indexOf("set exversion 2"),
)
expect(rc).toContain("bind y {\necho one\necho two\n}")
expect(rc).toContain("bind = {\necho equals\n}")
expect(rc).toContain("bind y |{\necho one\necho two\n}|")
expect(rc).toContain("bind = |{\necho equals\n}|")
expect(rc).toContain("set exversion 1")
tri.config.USERCONFIG.nmaps = {
";": { source: "echo unsafe", exversion: 2 },
for (const key of [";", "\\|{", "\\}|"]) {
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 {
tri.config.USERCONFIG.nmaps = nmaps
tri.config.USERCONFIG.exversion = exversion

View file

@ -18,12 +18,24 @@
*/
import * as R from "ramda"
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 { DeepPartial } from "tsdef"
const v2Syntax = [".|", "&&", "||", "|", ";", EX_BLOCK_OPEN, EX_BLOCK_CLOSE]
const escapableV2Syntax = [".|", "|", ";", EX_BLOCK_OPEN, EX_BLOCK_CLOSE]
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}`)
}

View file

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

View file

@ -3,6 +3,9 @@ export interface ExProgram {
exversion: 2
}
export const EX_BLOCK_OPEN = "|{"
export const EX_BLOCK_CLOSE = "}|"
export const EX_CANCELLED = Object.freeze({
__tridactylExOutcome: "cancelled" as const,
})
@ -36,7 +39,7 @@ export const formatExProgram = (command: ExCommand) => {
if (!isExProgram(command)) return command
const prefix = command.source.startsWith("\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 {

View file

@ -38,7 +38,9 @@ test.each([
"echo a |b",
"echo C:\\windows\\etc",
"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 => {
expect(shape(source)).toEqual([["text", source, undefined]])
expect(parseStructure(source).status).toBe("complete")
@ -46,9 +48,9 @@ test.each([
test("only backslash protects and is removed from standalone DSL syntax", async () => {
const run = jest.fn()
await evaluate("echo \\| \\&& \\|| \\.| \\; \\{ \\} \\{\\}", run)
await evaluate("echo \\| \\&& \\|| \\.| \\; \\|{ \\}| \\{ \\}", run)
expect(run).toHaveBeenCalledWith(
"echo | \\&& \\|| .| ; { } {}",
"echo | \\&& \\|| .| ; |{ }| \\{ \\}",
false,
undefined,
undefined,
@ -89,12 +91,12 @@ test("protects operators in whole-line comments and separates lines", () => {
})
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([
["text", "bind x ", undefined],
[
"block",
"{ hint | tabopen }",
"|{ hint | tabopen }|",
[
["text", " hint ", 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", () =>
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 ;"])(
"reports a missing right operand in %s",
@ -119,8 +127,8 @@ test.each([
"| echo a",
"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)\rmore JS",
"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 () => {
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([
"one",
"two",
@ -357,7 +365,7 @@ test("maps a command with arguments and pipes the collected results", async () =
test("maps a multi-stage block", () =>
expect(
evaluate(
"values .| { double | stringify }",
"values .| |{ double | stringify }|",
(command, _piped, input) => {
if (command === "values") return [1, 2]
if (command === "double") return input * 2
@ -368,7 +376,7 @@ test("maps a multi-stage block", () =>
test("supports nested block maps", () =>
expect(
evaluate("matrix .| { pass .| double }", (command, _piped, input) => {
evaluate("matrix .| |{ pass .| double }|", (command, _piped, input) => {
if (command === "matrix")
return [
[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"])
})
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",
async source => {
const run = jest.fn(command =>
@ -517,7 +525,9 @@ test("evaluates nested standalone blocks with pipeline input", async () => {
calls.push([source, piped, value])
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([
["a", false, undefined],
["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 () => {
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, {
source: " a\n# keep this comment\nb ",
exversion: 2,
@ -605,17 +615,28 @@ test("formats blocks containing whole-line comments safely", async () => {
test("rejects unsupported nested syntax before executing", async () => {
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",
)
expect(run).not.toHaveBeenCalled()
})
test.each([
"bind { a } trailing",
"bind x { a } { b }",
"a | bind x { b }",
"bind x { a } <<JS\nb\nJS",
"bind |{ a }| trailing",
"bind x |{ a }| |{ b }|",
"a | bind x |{ b }|",
"bind x |{ a }| <<JS\nb\nJS",
])("rejects ambiguous block arguments in %s", source =>
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
@ -20,13 +26,20 @@ export type ExPart =
| ({ type: "operator"; operator: ExOperator } & Span)
| ({ type: "comment" } & 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 {
end: number
bodyEnd?: number
}
const boundaries = " \t\r\n"
const blockDelimiters = [EX_BLOCK_OPEN, EX_BLOCK_CLOSE]
const status = (
invalid: boolean,
@ -54,13 +67,14 @@ function escapedSyntaxEnd(source: string, index: number) {
isBoundary(source[index + candidate.length + 1]),
)
if (operator) return index + operator.length + 1
const escaped = source[index + 1]
if (
(escaped === "{" || escaped === "}") &&
isBraceBoundary(source, index - 1) &&
isBraceBoundary(source, index + 2)
const blockDelimiter = blockDelimiters.find(
delimiter =>
source.startsWith(delimiter, index + 1) &&
isBoundary(source[index - 1]) &&
isBoundary(source[index + delimiter.length + 1]),
)
return index + 2
if (blockDelimiter) return index + blockDelimiter.length + 1
const escaped = source[index + 1]
if (
(escaped === "#" || escaped === '"') &&
isWholeLineComment(source, index)
@ -69,18 +83,12 @@ function escapedSyntaxEnd(source: string, index: number) {
return undefined
}
function isBraceBoundary(source: string, index: number) {
const character = source[index]
return (
isBoundary(character) ||
"{}".includes(character) ||
(character === "\\" && "{}".includes(source[index + 1]))
)
}
function isStandaloneBrace(source: string, index: number) {
return (
isBraceBoundary(source, index - 1) && isBraceBoundary(source, index + 1)
function blockDelimiterAt(source: string, index: number) {
if (!isBoundary(source[index - 1])) return undefined
return blockDelimiters.find(
delimiter =>
source.startsWith(delimiter, index) &&
isBoundary(source[index + delimiter.length]),
)
}
@ -206,14 +214,18 @@ function parseRange(
textStart = index + 1
continue
}
if (character === "{" && isStandaloneBrace(source, index)) {
const blockDelimiter = blockDelimiterAt(source, index)
if (blockDelimiter === EX_BLOCK_OPEN) {
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
parts.push({
type: "block",
start: index,
end,
bodyStart,
bodyEnd: child.bodyEnd ?? source.length,
body: { parts: child.parts, status: child.status },
})
incomplete = incomplete || child.status === "incomplete"
@ -223,7 +235,7 @@ function parseRange(
textStart = end
continue
}
if (nested && character === "}" && isStandaloneBrace(source, index)) {
if (nested && blockDelimiter === EX_BLOCK_CLOSE) {
text(index)
return {
parts,
@ -231,11 +243,13 @@ function parseRange(
invalid || expectation === "operand",
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
index += EX_BLOCK_CLOSE.length - 1
continue
}
@ -340,7 +354,7 @@ function compile(
command: blockCommand,
piped,
program: {
source: source.slice(block.start + 1, block.end - 1),
source: source.slice(block.bodyStart, block.bodyEnd),
exversion: 2,
},
})

View file

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