diff --git a/src/background/config_rc.test.ts b/src/background/config_rc.test.ts index 872ff6ef..43c5c5c4 100644 --- a/src/background/config_rc.test.ts +++ b/src/background/config_rc.test.ts @@ -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 => { diff --git a/src/lib/commandline_cmds.ts b/src/lib/commandline_cmds.ts index 2bc42d4f..b736d0b5 100644 --- a/src/lib/commandline_cmds.ts +++ b/src/lib/commandline_cmds.ts @@ -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]) diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 18198efc..3f5a5e80 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -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 diff --git a/src/lib/config.ts b/src/lib/config.ts index a7e3caaa..333352b2 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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}`) } diff --git a/src/lib/controller.ts b/src/lib/controller.ts index 2f416d20..d7f6bb3d 100644 --- a/src/lib/controller.ts +++ b/src/lib/controller.ts @@ -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 => { diff --git a/src/lib/excmd.ts b/src/lib/excmd.ts index 335674ae..cac3f87f 100644 --- a/src/lib/excmd.ts +++ b/src/lib/excmd.ts @@ -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 { diff --git a/src/parsers/exdsl.test.ts b/src/parsers/exdsl.test.ts index d3086631..bb88379f 100644 --- a/src/parsers/exdsl.test.ts +++ b/src/parsers/exdsl.test.ts @@ -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 < { 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 } < 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) +}) diff --git a/src/parsers/exdsl.ts b/src/parsers/exdsl.ts index 898c1112..751bcb7c 100644 --- a/src/parsers/exdsl.ts +++ b/src/parsers/exdsl.ts @@ -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, }, }) diff --git a/src/static/clippy/4-command_mode.md b/src/static/clippy/4-command_mode.md index 118a4a32..4ff186e3 100644 --- a/src/static/clippy/4-command_mode.md +++ b/src/static/clippy/4-command_mode.md @@ -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 <