diff --git a/.changeset/named-zsh-redirects.md b/.changeset/named-zsh-redirects.md new file mode 100644 index 0000000..60abae1 --- /dev/null +++ b/.changeset/named-zsh-redirects.md @@ -0,0 +1,5 @@ +--- +"@aliou/sh": patch +--- + +Parse named file descriptor redirects in Bash and Zsh. diff --git a/src/parser/dialect.test.ts b/src/parser/dialect.test.ts index 249d04c..bc4ff2c 100644 --- a/src/parser/dialect.test.ts +++ b/src/parser/dialect.test.ts @@ -49,6 +49,10 @@ describe("dialect enforcement: POSIX", () => { expectErr("foo <<< bar", "posix", /<< { + expectErr("foo {fd} { expectErr("diff <(foo) <(bar)", "posix", /process subst/); }); @@ -98,6 +102,10 @@ describe("dialect enforcement: mksh", () => { it("rejects ${!foo@}", () => { expectErr("echo ${!foo@}", "mksh", /\$\{!/); }); + + it("rejects named file descriptor redirects", () => { + expectErr("foo {fd} { diff --git a/src/parser/redirects.test.ts b/src/parser/redirects.test.ts index 5b0b8de..96f4009 100644 --- a/src/parser/redirects.test.ts +++ b/src/parser/redirects.test.ts @@ -66,6 +66,21 @@ describe("parse (phase 3: assignments and redirects)", () => { ), }); }); + + it.each([ + "bash", + "zsh", + ] as const)("parses named file descriptor redirects in %s", (dialect) => { + expect(parse("foo {fd} { diff --git a/src/tokenizer/tokenize.ts b/src/tokenizer/tokenize.ts index 02361cf..7ea8a81 100644 --- a/src/tokenizer/tokenize.ts +++ b/src/tokenizer/tokenize.ts @@ -1,6 +1,13 @@ import type { ParseOptions } from "../ast"; import { checkLang } from "../dialect"; -import { isDigit, operatorChars, redirChars, symbolChars } from "./charsets"; +import { + isDigit, + isNameChar, + isNameStart, + operatorChars, + redirChars, + symbolChars, +} from "./charsets"; import { SourceMap } from "./cursor"; import { scanBacktick } from "./scan-backtick"; import { scanExpansion } from "./scan-expansion"; @@ -174,6 +181,32 @@ export function tokenize(source: string, options: ParseOptions = {}): Token[] { } } + if (ch === "{" && atBoundary && isNameStart(source.charAt(i + 1))) { + let j = i + 2; + while (j < source.length && isNameChar(source.charAt(j))) { + j += 1; + } + if (source.charAt(j) === "}") { + const redir = tryRedirOp(source, j + 1); + if (redir) { + checkLang(options.dialect, map.posAt(i), "`{varname}` redirects", [ + "bash", + "zsh", + ]); + tokens.push({ + type: "redir", + op: redir.op, + fd: source.slice(i, j + 1), + pos: map.posAt(i), + end: map.posAt(j + 1 + redir.len), + }); + i = j + 1 + redir.len; + atBoundary = true; + continue; + } + } + } + if (isDigit(ch)) { let j = i; while (j < source.length && isDigit(source.charAt(j))) {