Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/named-fd-redirects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aliou/sh": patch
---

Parse `{varname}` file descriptor redirects in Bash and Zsh, and reject them in POSIX and mksh.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}<file`, Bash/Zsh only)
- Assignments (`FOO=bar cmd`), append assignments (`FOO+=bar`)
- Array expressions (`arr=(a b c)`, `arr=([0]=x [1]=y)`)
- Declaration builtins as special forms (`declare`, `local`, `export`, `readonly`, `typeset`, `nameref`)
Expand Down
11 changes: 11 additions & 0 deletions skills/sh-ast/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,17 @@ extractRedirects(cmd);
// [{ op: ">", 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}<file");
const cmd = ast.body[0].command as SimpleCommand;
extractRedirects(cmd);
// [{ op: "<", fd: "{fd}", target: "file" }]
```

`{varname}` redirects are rejected in POSIX and mksh dialects.

### Count AST Nodes (complexity metric)

```typescript
Expand Down
8 changes: 8 additions & 0 deletions src/parser/dialect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ describe("dialect enforcement: POSIX", () => {
expectErr("diff <(foo) <(bar)", "posix", /process subst/);
});

it("rejects named file descriptor redirects", () => {
expectErr("foo {fd}<f", "posix", /\{varname\}.*bash\/zsh feature/);
});

it("rejects extended glob", () => {
expectErr("ls @(foo)", "posix", /extended glob/);
});
Expand Down Expand Up @@ -98,6 +102,10 @@ describe("dialect enforcement: mksh", () => {
it("rejects ${!foo@}", () => {
expectErr("echo ${!foo@}", "mksh", /\$\{!/);
});

it("rejects named file descriptor redirects", () => {
expectErr("foo {fd}<f", "mksh", /\{varname\}.*bash\/zsh feature/);
});
});

describe("dialect enforcement: bash (default) accepts everything", () => {
Expand Down
44 changes: 44 additions & 0 deletions src/parser/redirects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}<f", { dialect })).toMatchAst({
ast: program(
stmt({
type: "SimpleCommand",
words: [word("foo")],
redirects: [redirect("<", "f", "{fd}")],
}),
),
});
},
);

it("parses named file descriptor redirects before the command", () => {
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)", () => {
Expand Down
38 changes: 37 additions & 1 deletion src/tokenizer/tokenize.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -174,6 +181,35 @@ export function tokenize(source: string, options: ParseOptions = {}): Token[] {
}
}

// `{varname}` file descriptor redirects, e.g. `foo {fd}<file`. Only a
// valid name wrapped in braces and immediately followed by a redirect
// operator counts; anything else falls through to word parsing.
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))) {
Expand Down
Loading