diff --git a/.changeset/named-fd-redirects.md b/.changeset/named-fd-redirects.md new file mode 100644 index 0000000..f23740e --- /dev/null +++ b/.changeset/named-fd-redirects.md @@ -0,0 +1,5 @@ +--- +"@aliou/sh": patch +--- + +Parse `{varname}` file descriptor redirects in Bash and Zsh, and reject them in POSIX and mksh. diff --git a/README.md b/README.md index 825bac7..16fef80 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ extractCommandNames(ast); // ["grep", "head"] - Command substitution (`$(cmd)`, `` `cmd` ``), arithmetic expansion (`$((expr))`) - Process substitution (`<(cmd)`, `>(cmd)`) - Heredocs (`<<`, `<<-`), herestrings (`<<<`) -- All redirect operators (`>`, `>>`, `<`, `>&`, `<&`, `<>`, `>|`, `&>`, `&>>`) +- All redirect operators (`>`, `>>`, `<`, `>&`, `<&`, `<>`, `>|`, `&>`, `&>>`), including `{varname}` file-descriptor redirects (`foo {fd}", target: "file.txt" }, { op: ">&", fd: "2", target: "1" }] ``` +`fd` is either a numeric string (`"2"`) or, in Bash/Zsh, a `{varname}` redirect such as `"{fd}"`: + +```typescript +const { ast } = parse("foo {fd} { expectErr("diff <(foo) <(bar)", "posix", /process subst/); }); + it("rejects named file descriptor redirects", () => { + expectErr("foo {fd} { expectErr("ls @(foo)", "posix", /extended glob/); }); @@ -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..18dd4b0 100644 --- a/src/parser/redirects.test.ts +++ b/src/parser/redirects.test.ts @@ -66,6 +66,50 @@ 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} { + expect(parse("{fd}>>out foo")).toMatchAst({ + ast: program( + stmt({ + type: "SimpleCommand", + words: [word("foo")], + redirects: [redirect(">>", "out", "{fd}")], + }), + ), + }); + }); + + it.each(["{1fd}>out", "{fd-x}>out", "{}>out"])( + "treats %s as a word, not a named redirect", + (source) => { + const { ast } = parse(`foo ${source}`); + const command = ast.body[0]?.command as { + words?: { parts: { value?: string }[] }[]; + redirects?: { fd?: string }[]; + }; + expect(command.redirects?.[0]?.fd).toBeUndefined(); + expect(command.words?.[1]?.parts[0]?.value).toBe(source.split(">")[0]); + }, + ); + + it("does not treat brace groups as named redirects", () => { + expect(parse("{ foo; }").ast.body[0]?.command.type).toBe("Block"); + }); }); describe("parse (phase 12: extended redirects)", () => { diff --git a/src/tokenizer/tokenize.ts b/src/tokenizer/tokenize.ts index 02361cf..2458d31 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,35 @@ export function tokenize(source: string, options: ParseOptions = {}): Token[] { } } + // `{varname}` file descriptor redirects, e.g. `foo {fd}