From 46f85ee58928a7823309fd487d73d8a1bfad05b9 Mon Sep 17 00:00:00 2001 From: Mitchell Paulus Date: Fri, 10 Jul 2026 11:16:52 -0500 Subject: [PATCH 1/2] Add 2>&1 and 1>&2 stream merge redirects New single-token merge redirects (no internal spaces): 2>&1 sends stderr to stdout's final destination, 1>&2 sends stdout to stderr's. Unlike POSIX these are not order-sensitive fd duplication; the merged stream aliases the other stream's resolved destination, so `2>&1 *` captures both streams interleaved and `[[make] 2>&1 [grep err]] |;` sends stderr through the pipe, cross-platform. Works on command lists, pipeline stages, and quotations. Each stream now has exactly one destination: combining two destinations on the same stream (capture + file redirect, &> + 2>, a second >, merge + anything on that stream, circular double merge, or 2>&1 with <>) is an error at both runtime and in the static type checker, with identical messages. Previously the extra redirect was silently ignored or last-wins. The checker tracks per-stream destination states (file, in-place, merged) on the command type; <> now statically requires a Path target to match the runtime. Also fixes: loop quotations ignored stderr redirects and append mode and dropped the enclosing quotation's redirected streams (both loop paths now build their context via BuildExecutionContext), and CopyListParams never copied AppendOutput. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 22 ++ code/syntaxes/mshell.textmate.json | 4 + doc/execution.inc.html | 32 ++ doc/mshell.md | 26 ++ mshell/Evaluator.go | 251 ++++++++++----- mshell/Lexer.go | 30 ++ mshell/MShellObject.go | 86 ++++++ mshell/Type.go | 9 + mshell/TypeChecker.go | 290 +++++++++++++++++- mshell/TypeError.go | 6 + .../fail/redirect_conflict_both_redirect.msh | 2 + ...redirect_conflict_both_redirect.msh.stderr | 1 + tests/fail/redirect_conflict_capture_file.msh | 2 + .../redirect_conflict_capture_file.msh.stderr | 1 + .../fail/redirect_conflict_circular_merge.msh | 2 + ...edirect_conflict_circular_merge.msh.stderr | 1 + tests/fail/redirect_conflict_double_file.msh | 2 + .../redirect_conflict_double_file.msh.stderr | 1 + .../fail/redirect_conflict_merge_capture.msh | 2 + ...redirect_conflict_merge_capture.msh.stderr | 1 + tests/success/merge_file.msh | 3 + tests/success/merge_file.txt | 2 + tests/success/merge_file.txt.expected | 2 + tests/success/merge_pipeline.msh | 2 + tests/success/merge_pipeline.msh.stdout | 1 + tests/success/merge_stderr_capture.msh | 2 + tests/success/merge_stderr_capture.msh.stdout | 3 + tests/success/merge_stdout_to_stderr.msh | 2 + .../success/merge_stdout_to_stderr.msh.stdout | 1 + tests/success/quote_merge.msh | 2 + tests/success/quote_merge.msh.stdout | 2 + tests/success/quote_merge_err.txt | 2 + tests/success/quote_merge_err.txt.expected | 2 + tests/success/quote_merge_to_stderr.msh | 3 + .../typecheck_fail/inplace_merge_conflict.msh | 2 + .../typecheck_fail/merge_conflict_capture.msh | 2 + .../merge_conflict_circular.msh | 2 + .../typecheck_fail/merge_conflict_stdout.msh | 2 + .../typecheck_fail/redirect_conflict_both.msh | 2 + .../redirect_conflict_capture_file.msh | 2 + .../redirect_conflict_double_file.msh | 2 + .../redirect_conflict_stderr_file.msh | 2 + 42 files changed, 725 insertions(+), 91 deletions(-) create mode 100644 tests/fail/redirect_conflict_both_redirect.msh create mode 100644 tests/fail/redirect_conflict_both_redirect.msh.stderr create mode 100644 tests/fail/redirect_conflict_capture_file.msh create mode 100644 tests/fail/redirect_conflict_capture_file.msh.stderr create mode 100644 tests/fail/redirect_conflict_circular_merge.msh create mode 100644 tests/fail/redirect_conflict_circular_merge.msh.stderr create mode 100644 tests/fail/redirect_conflict_double_file.msh create mode 100644 tests/fail/redirect_conflict_double_file.msh.stderr create mode 100644 tests/fail/redirect_conflict_merge_capture.msh create mode 100644 tests/fail/redirect_conflict_merge_capture.msh.stderr create mode 100644 tests/success/merge_file.msh create mode 100644 tests/success/merge_file.txt create mode 100644 tests/success/merge_file.txt.expected create mode 100644 tests/success/merge_pipeline.msh create mode 100644 tests/success/merge_pipeline.msh.stdout create mode 100644 tests/success/merge_stderr_capture.msh create mode 100644 tests/success/merge_stderr_capture.msh.stdout create mode 100644 tests/success/merge_stdout_to_stderr.msh create mode 100644 tests/success/merge_stdout_to_stderr.msh.stdout create mode 100644 tests/success/quote_merge.msh create mode 100644 tests/success/quote_merge.msh.stdout create mode 100644 tests/success/quote_merge_err.txt create mode 100644 tests/success/quote_merge_err.txt.expected create mode 100644 tests/success/quote_merge_to_stderr.msh create mode 100644 tests/typecheck_fail/inplace_merge_conflict.msh create mode 100644 tests/typecheck_fail/merge_conflict_capture.msh create mode 100644 tests/typecheck_fail/merge_conflict_circular.msh create mode 100644 tests/typecheck_fail/merge_conflict_stdout.msh create mode 100644 tests/typecheck_fail/redirect_conflict_both.msh create mode 100644 tests/typecheck_fail/redirect_conflict_capture_file.msh create mode 100644 tests/typecheck_fail/redirect_conflict_double_file.msh create mode 100644 tests/typecheck_fail/redirect_conflict_stderr_file.msh diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f91771..da1fff25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Stream merge redirects `2>&1` (stderr to stdout's destination) and `1>&2` + (stdout to stderr's destination). Each is a single token with no internal + spaces, and works on command lists, pipeline stages, and quotations. + Unlike POSIX, they are not order-sensitive fd duplication: the merged + stream follows the other stream's *final* destination, so `2>&1 *` captures + both streams interleaved and `[[make] 2>&1 [grep err]] |;` sends stderr + through the pipe, cross-platform. + +### Changed + +- Each of stdout/stderr now has exactly one destination: combining two + destinations on the same stream (e.g. capture `*` plus file redirect `>`, + `&>` plus `2>`, a second `>`, or a merge plus anything else on that stream) + is now an error, caught both by the static type checker and at runtime. + Previously the extra redirect was silently ignored (capture won over file + redirects) or last-wins. `2>&1` combined with `<>` is also rejected. +- `loop` quotations now honor stderr redirects, append mode, and merges, and + inherit the enclosing quotation's redirected streams (previously a loop + body wrote straight to the terminal even inside a redirected quotation). + +### Added + - CLI completions for `cargo`: subcommands (including installed third-party ones via `cargo --list`), per-subcommand options, and dynamic values for `--target`, `--features`, `-p`/`--package`, `--bin`/`--example`/`--test`/`--bench` diff --git a/code/syntaxes/mshell.textmate.json b/code/syntaxes/mshell.textmate.json index b7bfb655..0cd82fd0 100644 --- a/code/syntaxes/mshell.textmate.json +++ b/code/syntaxes/mshell.textmate.json @@ -12,6 +12,10 @@ "name": "constant.language.bool", "match": "\\b(true|false)\\b" }, + { + "name": "keyword.operator.redirect.merge.mshell", + "match": "2>&1|1>&2" + }, { "name": "string.quoted.single.mshell", "begin": "'", diff --git a/doc/execution.inc.html b/doc/execution.inc.html index b9b3f920..7812d7b1 100644 --- a/doc/execution.inc.html +++ b/doc/execution.inc.html @@ -142,6 +142,36 @@ +

Merging one stream into the other

+ +

+Use 2>&1 to send standard error to wherever standard output ends up, +or 1>&2 to send standard output to wherever standard error ends up. +These are complete tokens: no spaces are allowed inside them. +Unlike POSIX shells, they are not order-sensitive file descriptor duplication: +a merge means "this stream goes to the other stream's final destination", +so there is no > file 2>&1 vs 2>&1 > file ordering trap. +Both streams share a single destination, preserving output order, and this works the same on every platform. +

+ +
+[yourCommand] 2>&1 * ! # Captures stdout and stderr interleaved as one string.
+[[make] 2>&1 [grep -i error]] |; # stderr flows through the pipe.
+[yourCommand] 1>&2 ; # stdout appears on stderr, e.g. visible even when the surrounding output is captured.
+[yourCommand] `output.log` > 2>&1 ! # Both streams into output.log; equivalent to &>.
+
+
+ +

Each stream has exactly one destination

+ +

+A stream's destination can be set once: a file redirect, a capture, an in-place redirect, or a merge. +Applying a second destination to the same stream is an error, caught both by the static type checker and at runtime. +For example, [cmd] * `f` > (capture and file redirect on stdout), [cmd] 2>&1 ^ (merge and capture on stderr), +and [cmd] 2>&1 1>&2 (circular merge) are all rejected. +2>&1 also cannot be combined with <>, since stderr text would be written back into the edited file. +

+ @@ -161,6 +191,8 @@ + + diff --git a/doc/mshell.md b/doc/mshell.md index 4b64c3e1..3c4e7af0 100644 --- a/doc/mshell.md +++ b/doc/mshell.md @@ -106,6 +106,30 @@ So the following are equivalent: [yourCommand] ^b *b ! ``` +#### Merging one stream into the other + +Use `2>&1` to send stderr to wherever stdout ends up, or `1>&2` to send stdout to wherever stderr ends up. +These are complete tokens: no spaces are allowed inside them. +Unlike POSIX shells, they are not order-sensitive fd duplication: +a merge means "this stream goes to the other stream's *final* destination", +so there is no `> file 2>&1` vs `2>&1 > file` ordering trap. +Both streams share a single destination, preserving output order, on every platform. + +```mshell +[yourCommand] 2>&1 * ! # Captures stdout and stderr interleaved as one string. +[[make] 2>&1 [grep -i error]] |; # stderr flows through the pipe. +[yourCommand] 1>&2 ; # stdout appears on stderr. +[yourCommand] `output.log` > 2>&1 ! # Both streams into output.log; equivalent to &>. +``` + +#### Each stream has exactly one destination + +A stream's destination can be set once: a file redirect, a capture, an in-place redirect, or a merge. +Applying a second destination to the same stream is an error, caught both by the static type checker and at runtime. +For example, ``[cmd] * `f` >`` (capture and file redirect on stdout), `[cmd] 2>&1 ^` (merge and capture on stderr), +and `[cmd] 2>&1 1>&2` (circular merge) are all rejected. +`2>&1` also cannot be combined with `<>`, since stderr text would be written back into the edited file. + Summary of external command operators: Operator | Effect on external commands | Notes @@ -123,6 +147,8 @@ Operator | Effect on external commands | Notes `&>>` | Redirect both stdout and stderr to a file. | Appends to the file. `^` | Capture stderr to the stack. | As a string. `^b` | Capture stderr to the stack. | As binary. +`2>&1` | Merge stderr into stdout's destination. | Single token, no spaces. Not order-sensitive: stderr follows stdout's final destination. +`1>&2` | Merge stdout into stderr's destination. | Single token, no spaces. `<` | Feed stdin from a value. | String, path, or binary. `<>` | In-place file modification. | Reads file to stdin, writes stdout back on success. `&` | Mark the command list to run asynchronously. | Marks the list; the trailing `;`/`!` starts the subprocess and returns immediately without waiting. Stdout and stderr default to discarded. diff --git a/mshell/Evaluator.go b/mshell/Evaluator.go index ab2f8854..42a7ebf3 100644 --- a/mshell/Evaluator.go +++ b/mshell/Evaluator.go @@ -1523,55 +1523,32 @@ func (state *EvalState) processLoop(t Token, frame *EvaluationFrame, frames *[]E return state.FailWithMessage(fmt.Sprintf("%d:%d: Loop quotation needs a minimum of one token.\n", t.Line, t.Column)) } - // Build loop context - loopContext := ExecuteContext{ - StandardInput: nil, - StandardOutput: nil, - Variables: context.Variables, - Pbm: context.Pbm, - } - - if quotation.StdinBehavior != STDIN_NONE { - switch quotation.StdinBehavior { - case STDIN_CONTENT: - loopContext.StandardInput = strings.NewReader(quotation.StandardInputContents) - case STDIN_BINARY: - loopContext.StandardInput = bytes.NewReader(quotation.StandardInputBinary) - case STDIN_FILE: - file, err := os.Open(quotation.StandardInputFile) - if err != nil { - return state.FailWithMessage(fmt.Sprintf("%d:%d: Error opening file %s for reading: %s\n", t.Line, t.Column, quotation.StandardInputFile, err.Error())) - } - loopContext.StandardInput = file - loopContext.ShouldCloseInput = true - } - } - - if quotation.StandardOutputFile != "" { - file, err := os.Create(quotation.StandardOutputFile) - if err != nil { - return state.FailWithMessage(fmt.Sprintf("%d:%d: Error opening file %s for writing: %s\n", t.Line, t.Column, quotation.StandardOutputFile, err.Error())) - } - loopContext.StandardOutput = file - loopContext.ShouldCloseOutput = true + // Build loop context. BuildExecutionContext handles all the quotation's + // redirections (stdin, stdout, stderr, merges) and propagates the outer + // context's streams when the quotation has none of its own. + loopContext, err := quotation.BuildExecutionContext(&context) + if err != nil { + return state.FailWithMessage(err.Error()) } + loopContext.Variables = context.Variables // Push FRAME_LOOP state.LoopDepth++ callStackItem := CallStackItem{MShellParseItem: quotation, Name: "loop", CallStackType: CALLSTACKQUOTE} state.CallStack.Push(callStackItem) newFrame := EvaluationFrame{ - Objects: quotation.Tokens, - Index: 0, - Context: loopContext, - Stack: stack, - Definitions: definitions, - CallStackItem: callStackItem, - FrameType: FRAME_LOOP, - LoopMax: 15000000, - LoopCount: 0, - LoopQuotation: quotation, - InitialStackSize: len(*stack), + Objects: quotation.Tokens, + Index: 0, + Context: *loopContext, + Stack: stack, + Definitions: definitions, + CallStackItem: callStackItem, + FrameType: FRAME_LOOP, + LoopMax: 15000000, + LoopCount: 0, + LoopQuotation: quotation, + InitialStackSize: len(*stack), + ShouldCloseContext: true, } *frames = append(*frames, newFrame) return SimpleSuccess() @@ -3350,6 +3327,33 @@ type Executable interface { GetStandardOutputFile() string } +// stdoutDestinationDescOf / stderrDestinationDescOf describe the operator +// that already claimed the stream on a redirectable object, or "" when the +// stream is unclaimed (or the object is not redirectable). +func stdoutDestinationDescOf(obj MShellObject) string { + switch o := obj.(type) { + case *MShellList: + return o.StdoutDestinationDesc() + case *MShellQuotation: + return o.StdoutDestinationDesc() + case *MShellPipe: + return o.StdoutDestinationDesc() + } + return "" +} + +func stderrDestinationDescOf(obj MShellObject) string { + switch o := obj.(type) { + case *MShellList: + return o.StderrDestinationDesc() + case *MShellQuotation: + return o.StderrDestinationDesc() + case *MShellPipe: + return o.StderrDestinationDesc() + } + return "" +} + func (list *MShellList) Execute(state *EvalState, context ExecuteContext, stack *MShellStack) (EvalResult, int, []byte, []byte) { result, exitCode, stdoutResult, stderrResult := RunProcess(*list, context, state) return result, exitCode, stdoutResult, stderrResult @@ -4019,6 +4023,17 @@ func RunProcess(list MShellList, context ExecuteContext, state *EvalState) (Eval } } + // MERGE REDIRECTS: at most one of these is set (enforced when the token + // is applied). The merged stream aliases the other stream's resolved + // destination. os/exec passes a duplicated fd when both are the same + // *os.File and serializes Writes when both are the same writer, so this + // is safe on every platform. + if list.StdoutToStderr { + cmd.Stdout = cmd.Stderr + } else if list.StderrToStdout { + cmd.Stderr = cmd.Stdout + } + var startErr error var exitCode int @@ -11608,9 +11623,15 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu } if asList, ok := obj1.(*MShellList); ok { + if desc := asList.StdoutDestinationDesc(); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } asList.StdoutBehavior = STDOUT_COMPLETE stack.Push(asList) } else if asPipe, ok := obj1.(*MShellPipe); ok { + if desc := asPipe.StdoutDestinationDesc(); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } asPipe.StdoutBehavior = STDOUT_COMPLETE stack.Push(asPipe) } else { @@ -11648,8 +11669,14 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu switch objTyped := obj1.(type) { case *MShellList: + if desc := objTyped.StdoutDestinationDesc(); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } objTyped.StdoutBehavior = STDOUT_BINARY case *MShellPipe: + if desc := objTyped.StdoutDestinationDesc(); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } objTyped.StdoutBehavior = STDOUT_BINARY default: return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot capture binary stdout for a %s.\n", t.Line, t.Column, obj1.TypeName())) @@ -11665,8 +11692,14 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu switch objTyped := obj1.(type) { case *MShellList: + if desc := objTyped.StderrDestinationDesc(); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } objTyped.StderrBehavior = STDERR_COMPLETE case *MShellPipe: + if desc := objTyped.StderrDestinationDesc(); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } objTyped.StderrBehavior = STDERR_COMPLETE default: return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot capture stderr for a %s.\n", t.Line, t.Column, obj1.TypeName())) @@ -11680,8 +11713,14 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu switch objTyped := obj1.(type) { case *MShellList: + if desc := objTyped.StderrDestinationDesc(); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } objTyped.StderrBehavior = STDERR_BINARY case *MShellPipe: + if desc := objTyped.StderrDestinationDesc(); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } objTyped.StderrBehavior = STDERR_BINARY default: return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot capture binary stderr for a %s.\n", t.Line, t.Column, obj1.TypeName())) @@ -11704,10 +11743,16 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu switch obj2Typed := obj2.(type) { case *MShellList: + if desc := obj2Typed.StdoutDestinationDesc(); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } obj2Typed.StandardOutputFile = path obj2Typed.AppendOutput = true stack.Push(obj2Typed) case *MShellQuotation: + if desc := obj2Typed.StdoutDestinationDesc(); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } obj2Typed.StandardOutputFile = path obj2Typed.AppendOutput = true stack.Push(obj2Typed) @@ -12110,6 +12155,11 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s' across numeric types %s and %s. Use 'toFloat' / 'toInt' to convert explicitly.\n", t.Line, t.Column, t.Lexeme, obj2.TypeName(), obj1.TypeName())) } } else { + if t.Type == GREATERTHAN { + if desc := stdoutDestinationDescOf(obj2); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } + } switch obj1.(type) { case MShellString: path := obj1.(MShellString).Content @@ -12247,10 +12297,16 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu switch obj2Typed := obj2.(type) { case *MShellList: + if desc := obj2Typed.StderrDestinationDesc(); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } obj2Typed.StandardErrorFile = redirectFile obj2Typed.AppendError = t.Type == STDERRAPPEND stack.Push(obj2Typed) case *MShellQuotation: + if desc := obj2Typed.StderrDestinationDesc(); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } obj2Typed.StandardErrorFile = redirectFile obj2Typed.AppendError = t.Type == STDERRAPPEND stack.Push(obj2Typed) @@ -12278,6 +12334,13 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu appendMode := t.Type == STDOUTANDSTDERRAPPEND + if desc := stdoutDestinationDescOf(obj2); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } + if desc := stderrDestinationDescOf(obj2); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } + switch obj2Typed := obj2.(type) { case *MShellList: obj2Typed.StandardOutputFile = redirectFile @@ -12314,6 +12377,15 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu t.Line, t.Column, obj2.TypeName())) } + if desc := list.StdoutDestinationDesc(); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } + // stderr merged into stdout would write error text back into + // the edited file; reject the combination. + if list.StderrToStdout { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr is merged into stdout ('2>&1'), which would write stderr back into the edited file.\n", t.Line, t.Column, t.Lexeme)) + } + // Read file contents immediately fileContents, err := os.ReadFile(pathObj.Path) if err != nil { @@ -12326,6 +12398,59 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu list.StandardInputBinary = fileContents list.InPlaceFile = pathObj.Path stack.Push(list) + } else if t.Type == STDERRTOSTDOUT || t.Type == STDOUTTOSTDERR { // Token Type + obj, err := stack.Pop() + if err != nil { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot do '%s' operation on an empty stack.\n", t.Line, t.Column, t.Lexeme)) + } + + if t.Type == STDERRTOSTDOUT { + if desc := stderrDestinationDescOf(obj); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } + } else { + if desc := stdoutDestinationDescOf(obj); desc != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc)) + } + } + + // The two merges together are circular: neither stream would + // have a real destination. + circularMsg := "%d:%d: Cannot apply '%s': the other stream is already merged with '%s'; merging both streams into each other is circular.\n" + + switch objTyped := obj.(type) { + case *MShellList: + if t.Type == STDERRTOSTDOUT { + if objTyped.StdoutToStderr { + return state.FailWithMessage(fmt.Sprintf(circularMsg, t.Line, t.Column, t.Lexeme, "1>&2")) + } + if objTyped.InPlaceFile != "" { + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s' with an in-place redirect ('<>'): stderr would be written back into the edited file.\n", t.Line, t.Column, t.Lexeme)) + } + objTyped.StderrToStdout = true + } else { + if objTyped.StderrToStdout { + return state.FailWithMessage(fmt.Sprintf(circularMsg, t.Line, t.Column, t.Lexeme, "2>&1")) + } + objTyped.StdoutToStderr = true + } + stack.Push(objTyped) + case *MShellQuotation: + if t.Type == STDERRTOSTDOUT { + if objTyped.StdoutToStderr { + return state.FailWithMessage(fmt.Sprintf(circularMsg, t.Line, t.Column, t.Lexeme, "1>&2")) + } + objTyped.StderrToStdout = true + } else { + if objTyped.StderrToStdout { + return state.FailWithMessage(fmt.Sprintf(circularMsg, t.Line, t.Column, t.Lexeme, "2>&1")) + } + objTyped.StdoutToStderr = true + } + stack.Push(objTyped) + default: + return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s' to a %s; expected a list or quotation.\n", t.Line, t.Column, t.Lexeme, obj.TypeName())) + } } else if t.Type == ENVSTORE { // Token Type obj, err := stack.Pop() if err != nil { @@ -12402,41 +12527,15 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu return state.FailWithMessage(fmt.Sprintf("%d:%d: Loop quotation needs a minimum of one token.\n", t.Line, t.Column)) } - loopContext := ExecuteContext{ - StandardInput: nil, - StandardOutput: nil, - Variables: context.Variables, - Pbm: context.Pbm, - } - - if quotation.StdinBehavior != STDIN_NONE { - switch quotation.StdinBehavior { - case STDIN_CONTENT: - loopContext.StandardInput = strings.NewReader(quotation.StandardInputContents) - case STDIN_BINARY: - loopContext.StandardInput = bytes.NewReader(quotation.StandardInputBinary) - case STDIN_FILE: - file, err := os.Open(quotation.StandardInputFile) - if err != nil { - return state.FailWithMessage(fmt.Sprintf("%d:%d: Error opening file %s for reading: %s\n", t.Line, t.Column, quotation.StandardInputFile, err.Error())) - } - loopContext.StandardInput = file - // TODO: This probably shouldn't be done here like this - defer file.Close() - default: - panic("Unknown stdin behavior") - } - } - - if quotation.StandardOutputFile != "" { - file, err := os.Create(quotation.StandardOutputFile) - if err != nil { - return state.FailWithMessage(fmt.Sprintf("%d:%d: Error opening file %s for writing: %s\n", t.Line, t.Column, quotation.StandardOutputFile, err.Error())) - } - loopContext.StandardOutput = file - // TODO: This probably shouldn't be done here like this - defer file.Close() + // BuildExecutionContext handles all the quotation's + // redirections (stdin, stdout, stderr, merges). + builtContext, err := quotation.BuildExecutionContext(&context) + if err != nil { + return state.FailWithMessage(err.Error()) } + builtContext.Variables = context.Variables + loopContext := *builtContext + defer loopContext.Close() maxLoops := 15000000 loopCount := 0 diff --git a/mshell/Lexer.go b/mshell/Lexer.go index 7a641f4b..14dcc35a 100644 --- a/mshell/Lexer.go +++ b/mshell/Lexer.go @@ -99,6 +99,8 @@ const ( PREFIXQUOTE // .functionName MATCH VER + STDERRTOSTDOUT // 2>&1, merge stderr into stdout's destination + STDOUTTOSTDERR // 1>&2, merge stdout into stderr's destination // Reserved for the static type checker (Phase 5/10) and Phase 2 of the // effect system. AS and TYPE are user-visible from Phase 10 onward; @@ -269,6 +271,10 @@ func (t TokenType) String() string { return "STDOUTANDSTDERRAPPEND" case INPLACEREDIRECT: return "INPLACEREDIRECT" + case STDERRTOSTDOUT: + return "STDERRTOSTDOUT" + case STDOUTTOSTDERR: + return "STDOUTTOSTDERR" case GRID_OPEN: return "GRID_OPEN" case GRID_CLOSE: @@ -424,6 +430,13 @@ func (l *Lexer) peekNext() rune { return l.input[l.current+1] } +func (l *Lexer) peekAt(offset int) rune { + if l.current+offset >= len(l.input) { + return 0 + } + return l.input[l.current+offset] +} + // Increments line, resets col. Make sure this is called after the newline has been consumed. // Otherwise, when the newline is consumed, the column will be incremented to 2 on the l.Advance() func (l *Lexer) handleNewline() { @@ -997,6 +1010,23 @@ func (l *Lexer) parseNumberOrStartIndexer() Token { l.advance() return l.makeToken(STDERRAPPEND) } + // Merge redirects are complete tokens with no internal spaces: + // exactly "2>&1" (stderr to stdout) or "1>&2" (stdout to stderr). + if l.peekNext() == '&' { + intPart := l.curLexeme() + if intPart == "2" && l.peekAt(2) == '1' { + l.advance() + l.advance() + l.advance() + return l.makeToken(STDERRTOSTDOUT) + } + if intPart == "1" && l.peekAt(2) == '2' { + l.advance() + l.advance() + l.advance() + return l.makeToken(STDOUTTOSTDERR) + } + } l.advance() return l.makeToken(STDERRREDIRECT) } else if peek == '.' { diff --git a/mshell/MShellObject.go b/mshell/MShellObject.go index f68309f6..c237d434 100644 --- a/mshell/MShellObject.go +++ b/mshell/MShellObject.go @@ -613,6 +613,8 @@ type MShellQuotation struct { AppendOutput bool StandardErrorFile string AppendError bool + StdoutToStderr bool // 1>&2: stdout goes to stderr's destination + StderrToStdout bool // 2>&1: stderr goes to stdout's destination Variables map[string]MShellObject } @@ -718,6 +720,17 @@ func (q *MShellQuotation) BuildExecutionContext(context *ExecuteContext) (*Execu quoteContext.StandardError = os.Stderr } + // Merge redirects: at most one is set (enforced when the token is + // applied). The merged stream aliases the other stream's resolved + // destination. The merged side never opened its own file (a file + // redirect on it would have been a conflict), so the ShouldClose flags + // are already correct. + if q.StdoutToStderr { + quoteContext.StandardOutput = quoteContext.StandardError + } else if q.StderrToStdout { + quoteContext.StandardError = quoteContext.StandardOutput + } + return "eContext, nil } @@ -772,6 +785,8 @@ type MShellList struct { StderrBehavior StderrBehavior RunInBackground bool InPlaceFile string // File path for in-place modification with <> + StdoutToStderr bool // 1>&2: stdout goes to stderr's destination + StderrToStdout bool // 2>&1: stderr goes to stdout's destination } // initLength creates list like: make([]MShellObject, initLength) @@ -853,11 +868,67 @@ func CopyListParams(copyFromList *MShellList, copyToList *MShellList) { copyToList.StandardInputBinary = copyFromList.StandardInputBinary copyToList.StandardInputFile = copyFromList.StandardInputFile copyToList.StandardOutputFile = copyFromList.StandardOutputFile + copyToList.AppendOutput = copyFromList.AppendOutput copyToList.StandardErrorFile = copyFromList.StandardErrorFile copyToList.AppendError = copyFromList.AppendError copyToList.StdoutBehavior = copyFromList.StdoutBehavior copyToList.StderrBehavior = copyFromList.StderrBehavior copyToList.InPlaceFile = copyFromList.InPlaceFile + copyToList.StdoutToStderr = copyFromList.StdoutToStderr + copyToList.StderrToStdout = copyFromList.StderrToStdout +} + +// StdoutDestinationDesc describes the operator that already claimed stdout, +// or "" when stdout is still unclaimed. Each stream has exactly one +// destination; redirect ops use this to reject a second claim. +func (list *MShellList) StdoutDestinationDesc() string { + switch { + case list.StdoutBehavior != STDOUT_NONE: + return "a capture ('*')" + case list.InPlaceFile != "": + return "an in-place redirect ('<>')" + case list.StandardOutputFile != "": + return "a file redirect ('>')" + case list.StdoutToStderr: + return "a merge to stderr ('1>&2')" + } + return "" +} + +// StderrDestinationDesc describes the operator that already claimed stderr, +// or "" when stderr is still unclaimed. +func (list *MShellList) StderrDestinationDesc() string { + switch { + case list.StderrBehavior != STDERR_NONE: + return "a capture ('^')" + case list.StandardErrorFile != "": + return "a file redirect ('2>')" + case list.StderrToStdout: + return "a merge to stdout ('2>&1')" + } + return "" +} + +// StdoutDestinationDesc for quotations; quotations support file redirects +// and merges but not captures or in-place redirects. +func (q *MShellQuotation) StdoutDestinationDesc() string { + switch { + case q.StandardOutputFile != "": + return "a file redirect ('>')" + case q.StdoutToStderr: + return "a merge to stderr ('1>&2')" + } + return "" +} + +func (q *MShellQuotation) StderrDestinationDesc() string { + switch { + case q.StandardErrorFile != "": + return "a file redirect ('2>')" + case q.StderrToStdout: + return "a merge to stdout ('2>&1')" + } + return "" } type MShellString struct { @@ -874,6 +945,21 @@ type MShellPipe struct { StderrBehavior StderrBehavior } +// Pipes only support captures as stream destinations. +func (p *MShellPipe) StdoutDestinationDesc() string { + if p.StdoutBehavior != STDOUT_NONE { + return "a capture ('*')" + } + return "" +} + +func (p *MShellPipe) StderrDestinationDesc() string { + if p.StderrBehavior != STDERR_NONE { + return "a capture ('^')" + } + return "" +} + type MShellInt struct { Value int } diff --git a/mshell/Type.go b/mshell/Type.go index 674a9e03..f2075db6 100644 --- a/mshell/Type.go +++ b/mshell/Type.go @@ -118,6 +118,12 @@ type TypeNode struct { Extra uint32 } +// CommandCaptureMode is really a per-stream *destination state*: unset, +// captured to the stack (str/bytes/lines), redirected to a file, used for an +// in-place edit, or merged into the other stream. The checker uses it to +// enforce that each stream has exactly one destination, mirroring the +// runtime's conflict errors. Only the capture states produce stack outputs +// at `;` / `!` / `?`. type CommandCaptureMode uint32 const ( @@ -125,6 +131,9 @@ const ( CommandCaptureStr CommandCaptureBytes CommandCaptureLines + CommandDestFile // stream redirected to a file (>, >>, 2>, 2>>, &>, &>>) + CommandDestInPlace // stdout claimed by an in-place redirect (<>) + CommandDestMerged // stream merged into the other stream (2>&1 / 1>&2) ) // TypeVarId identifies a generic type variable. Fresh ids are issued at diff --git a/mshell/TypeChecker.go b/mshell/TypeChecker.go index 2ac50e88..1322e479 100644 --- a/mshell/TypeChecker.go +++ b/mshell/TypeChecker.go @@ -325,6 +325,13 @@ func (c *Checker) checkOne(tok Token) { return } + // Merge redirects are nullary state transforms on the command value; + // they have no sig representation. + if tok.Type == STDERRTOSTDOUT || tok.Type == STDOUTTOSTDERR { + c.applyMergeRedirect(tok) + return + } + if sigs, ok := c.builtins[tok.Type]; ok { switch tok.Type { case IFF: @@ -348,7 +355,7 @@ func (c *Checker) checkOne(tok Token) { if tok.Type == LOOP && c.tryLoop(tok) { return } - if tok.Type == PIPE && c.stack.Len() > 0 { + if (tok.Type == PIPE || tok.Type == AMPERSAND) && c.stack.Len() > 0 { top := c.subst.Apply(c.arena, c.stack.Top()) if c.arena.Kind(top) == TKCommand { return @@ -357,6 +364,14 @@ func (c *Checker) checkOne(tok Token) { if c.tryRedirect(tok) { return } + if c.tryCapture(tok) { + return + } + if tok.Type == EXECUTE || tok.Type == BANG || tok.Type == QUESTION { + if c.tryExecCommand(tok) { + return + } + } c.resolveAndApply(sigs, tok) return } @@ -764,12 +779,65 @@ func (c *Checker) tryPivot(tok Token) bool { return true } -// tryRedirect handles a redirect op (`>`, `<`, `>e`, ...) applied to a -// ` ` stack, where operand is a quote (single or overloaded) -// or a command-like value (list/command) and target is a writable path -// (str/path/bytes). It pops the target, leaving the operand on top. Returns -// false (no effect) when the op or stack shape doesn't match, so the caller -// falls through to ordinary builtin dispatch. +// commandParts decomposes a command-like operand into its argv type and +// per-stream destination states. A plain list is a command with both +// streams unclaimed. +func (c *Checker) commandParts(t TypeId) (argv TypeId, stdout, stderr CommandCaptureMode, ok bool) { + n := c.arena.Node(t) + switch n.Kind { + case TKList: + return t, CommandCaptureNone, CommandCaptureNone, true + case TKCommand: + return TypeId(n.A), CommandCaptureMode(n.B), CommandCaptureMode(n.Extra), true + } + return 0, 0, 0, false +} + +// streamStateDesc mirrors the runtime's StdoutDestinationDesc / +// StderrDestinationDesc strings so static and runtime conflict errors read +// identically. Returns "" for an unclaimed stream. +func streamStateDesc(mode CommandCaptureMode, isStdout bool) string { + switch mode { + case CommandCaptureStr, CommandCaptureBytes, CommandCaptureLines: + if isStdout { + return "a capture ('*')" + } + return "a capture ('^')" + case CommandDestFile: + if isStdout { + return "a file redirect ('>')" + } + return "a file redirect ('2>')" + case CommandDestInPlace: + return "an in-place redirect ('<>')" + case CommandDestMerged: + if isStdout { + return "a merge to stderr ('1>&2')" + } + return "a merge to stdout ('2>&1')" + } + return "" +} + +func (c *Checker) streamConflictError(tok Token, stream string, desc string) { + c.errors = append(c.errors, TypeError{ + Kind: TErrTypeMismatch, + Pos: tok, + Hint: fmt.Sprintf("Cannot apply '%s': %s already has %s. Each stream has exactly one destination.", tok.Lexeme, stream, desc), + }) +} + +// tryRedirect handles a redirect op (`>`, `<`, `2>`, `&>`, `<>`, ...) applied +// to a ` ` stack, where operand is a quote (single or +// overloaded) or a command-like value (list/command) and target is a writable +// path (str/path/bytes). It pops the target and replaces the operand with a +// command type whose stream destination states reflect the redirect, erroring +// on a stream that already has a destination (mirroring the runtime). +// Returns false (no effect) when the op or stack shape doesn't match, so the +// caller falls through to ordinary builtin dispatch. +// +// Quote operands are still a known gap: the target is popped but no +// destination state is tracked, so conflicts on quotations are runtime-only. func (c *Checker) tryRedirect(tok Token) bool { switch tok.Type { case LESSTHAN, GREATERTHAN, STDERRREDIRECT, STDERRAPPEND, @@ -785,23 +853,215 @@ func (c *Checker) tryRedirect(tok Token) bool { return false } operand := c.subst.Apply(c.arena, c.stack.items[c.stack.Len()-2]) - if !isQuoteKind(c.arena.Kind(operand)) && !c.isCommandLike(operand) { + if isQuoteKind(c.arena.Kind(operand)) { + c.stack.items = c.stack.items[:c.stack.Len()-1] + return true + } + argv, stdoutState, stderrState, ok := c.commandParts(operand) + if !ok { return false } + + conflict := false + switch tok.Type { + case GREATERTHAN, STDAPPEND: + if desc := streamStateDesc(stdoutState, true); desc != "" { + c.streamConflictError(tok, "stdout", desc) + conflict = true + } + stdoutState = CommandDestFile + case STDERRREDIRECT, STDERRAPPEND: + if desc := streamStateDesc(stderrState, false); desc != "" { + c.streamConflictError(tok, "stderr", desc) + conflict = true + } + stderrState = CommandDestFile + case STDOUTANDSTDERRREDIRECT, STDOUTANDSTDERRAPPEND: + if desc := streamStateDesc(stdoutState, true); desc != "" { + c.streamConflictError(tok, "stdout", desc) + conflict = true + } else if desc := streamStateDesc(stderrState, false); desc != "" { + c.streamConflictError(tok, "stderr", desc) + conflict = true + } + stdoutState = CommandDestFile + stderrState = CommandDestFile + case INPLACEREDIRECT: + // The runtime requires a Path for <>. + if target != TidPath { + c.errors = append(c.errors, TypeError{ + Kind: TErrTypeMismatch, + Pos: tok, + Hint: "In-place redirect (<>) requires a Path target (`...`).", + }) + } + if desc := streamStateDesc(stdoutState, true); desc != "" { + c.streamConflictError(tok, "stdout", desc) + conflict = true + } else if stderrState == CommandDestMerged { + c.errors = append(c.errors, TypeError{ + Kind: TErrTypeMismatch, + Pos: tok, + Hint: fmt.Sprintf("Cannot apply '%s' with '2>&1': stderr would be written back into the edited file.", tok.Lexeme), + }) + conflict = true + } + stdoutState = CommandDestInPlace + case LESSTHAN: + // stdin redirect: no stream destination state change. + } + c.stack.items = c.stack.items[:c.stack.Len()-1] + if !conflict && tok.Type != LESSTHAN { + c.stack.items[c.stack.Len()-1] = c.arena.MakeCommand(argv, stdoutState, stderrState) + } return true } -func (c *Checker) isCommandLike(t TypeId) bool { - n := c.arena.Node(t) - switch n.Kind { - case TKList: - return true - case TKCommand: - return true +// tryCapture handles `*` / `*b` / `^` / `^b` applied to a command-like +// operand, transforming its stream destination state and erroring when the +// stream already has a destination. Returns false for non-command operands +// (e.g. numeric `*` multiplication) so the caller falls through to overload +// dispatch. +func (c *Checker) tryCapture(tok Token) bool { + var isStdout bool + var mode CommandCaptureMode + switch tok.Type { + case ASTERISK: + isStdout, mode = true, CommandCaptureStr + case ASTERISKBINARY: + isStdout, mode = true, CommandCaptureBytes + case CARET: + isStdout, mode = false, CommandCaptureStr + case CARETBINARY: + isStdout, mode = false, CommandCaptureBytes default: return false } + if c.stack.Len() < 1 { + return false + } + operand := c.subst.Apply(c.arena, c.stack.items[c.stack.Len()-1]) + argv, stdoutState, stderrState, ok := c.commandParts(operand) + if !ok { + return false + } + + if isStdout { + if desc := streamStateDesc(stdoutState, true); desc != "" { + c.streamConflictError(tok, "stdout", desc) + return true + } + stdoutState = mode + } else { + if desc := streamStateDesc(stderrState, false); desc != "" { + c.streamConflictError(tok, "stderr", desc) + return true + } + stderrState = mode + } + c.stack.items[c.stack.Len()-1] = c.arena.MakeCommand(argv, stdoutState, stderrState) + return true +} + +// applyMergeRedirect handles the nullary merge tokens `2>&1` and `1>&2`, +// mirroring the runtime conflict rules: the merged stream must be unclaimed, +// the two merges are mutually exclusive (circular), and `2>&1` cannot be +// combined with an in-place redirect. +func (c *Checker) applyMergeRedirect(tok Token) { + if c.stack.Len() < 1 { + c.errors = append(c.errors, TypeError{ + Kind: TErrStackUnderflow, + Pos: tok, + }) + return + } + operand := c.subst.Apply(c.arena, c.stack.items[c.stack.Len()-1]) + if isQuoteKind(c.arena.Kind(operand)) { + // Known gap, same as tryRedirect: no destination tracking on quotes. + return + } + argv, stdoutState, stderrState, ok := c.commandParts(operand) + if !ok { + c.errors = append(c.errors, TypeError{ + Kind: TErrTypeMismatch, + Pos: tok, + Hint: fmt.Sprintf("Cannot apply '%s' to a %s; expected a list or quotation.", tok.Lexeme, FormatType(c.arena, c.names, operand)), + }) + return + } + + if tok.Type == STDERRTOSTDOUT { + if stdoutState == CommandDestMerged { + c.errors = append(c.errors, TypeError{ + Kind: TErrTypeMismatch, + Pos: tok, + Hint: fmt.Sprintf("Cannot apply '%s': the other stream is already merged with '1>&2'; merging both streams into each other is circular.", tok.Lexeme), + }) + return + } + if stdoutState == CommandDestInPlace { + c.errors = append(c.errors, TypeError{ + Kind: TErrTypeMismatch, + Pos: tok, + Hint: fmt.Sprintf("Cannot apply '%s' with an in-place redirect ('<>'): stderr would be written back into the edited file.", tok.Lexeme), + }) + return + } + if desc := streamStateDesc(stderrState, false); desc != "" { + c.streamConflictError(tok, "stderr", desc) + return + } + stderrState = CommandDestMerged + } else { + if stderrState == CommandDestMerged { + c.errors = append(c.errors, TypeError{ + Kind: TErrTypeMismatch, + Pos: tok, + Hint: fmt.Sprintf("Cannot apply '%s': the other stream is already merged with '2>&1'; merging both streams into each other is circular.", tok.Lexeme), + }) + return + } + if desc := streamStateDesc(stdoutState, true); desc != "" { + c.streamConflictError(tok, "stdout", desc) + return + } + stdoutState = CommandDestMerged + } + c.stack.items[c.stack.Len()-1] = c.arena.MakeCommand(argv, stdoutState, stderrState) +} + +// tryExecCommand types `;` / `!` / `?` applied to a command value with +// destination states. Stack outputs come only from capture states; file, +// in-place, and merge destinations produce nothing. `?` additionally pushes +// the exit code. Returns false when the top of stack is not a command, so +// plain lists and Maybe (`?` as fromJust) fall through to the sig overloads. +func (c *Checker) tryExecCommand(tok Token) bool { + if c.stack.Len() < 1 { + return false + } + top := c.subst.Apply(c.arena, c.stack.items[c.stack.Len()-1]) + n := c.arena.Node(top) + if n.Kind != TKCommand { + return false + } + c.stack.items = c.stack.items[:c.stack.Len()-1] + pushCapture := func(mode CommandCaptureMode) { + switch mode { + case CommandCaptureLines: + c.stack.Push(c.arena.MakeList(TidStr)) + case CommandCaptureStr: + c.stack.Push(TidStr) + case CommandCaptureBytes: + c.stack.Push(TidBytes) + } + } + pushCapture(CommandCaptureMode(n.B)) + pushCapture(CommandCaptureMode(n.Extra)) + if tok.Type == QUESTION { + c.stack.Push(TidInt) + } + return true } // applySig is the hot path. It validates arity, unifies each input against diff --git a/mshell/TypeError.go b/mshell/TypeError.go index 9eabbf53..d9979b48 100644 --- a/mshell/TypeError.go +++ b/mshell/TypeError.go @@ -294,6 +294,12 @@ func formatCommandCapture(mode CommandCaptureMode) string { return "bytes" case CommandCaptureLines: return "[str]" + case CommandDestFile: + return "file" + case CommandDestInPlace: + return "in-place" + case CommandDestMerged: + return "merged" default: return "none" } diff --git a/tests/fail/redirect_conflict_both_redirect.msh b/tests/fail/redirect_conflict_both_redirect.msh new file mode 100644 index 00000000..8154bfed --- /dev/null +++ b/tests/fail/redirect_conflict_both_redirect.msh @@ -0,0 +1,2 @@ +# Conflict: &> then 2>&1 must error +[ls] `conflict_both.txt` &> 2>&1 ; diff --git a/tests/fail/redirect_conflict_both_redirect.msh.stderr b/tests/fail/redirect_conflict_both_redirect.msh.stderr new file mode 100644 index 00000000..cc6389f7 --- /dev/null +++ b/tests/fail/redirect_conflict_both_redirect.msh.stderr @@ -0,0 +1 @@ +2:29: Cannot apply '2>&1': stderr already has a file redirect ('2>'). Each stream has exactly one destination. diff --git a/tests/fail/redirect_conflict_capture_file.msh b/tests/fail/redirect_conflict_capture_file.msh new file mode 100644 index 00000000..f5b5d969 --- /dev/null +++ b/tests/fail/redirect_conflict_capture_file.msh @@ -0,0 +1,2 @@ +# Conflict: stdout capture then file redirect must error +[ls] * `conflict_out.txt` > ; diff --git a/tests/fail/redirect_conflict_capture_file.msh.stderr b/tests/fail/redirect_conflict_capture_file.msh.stderr new file mode 100644 index 00000000..7ab76684 --- /dev/null +++ b/tests/fail/redirect_conflict_capture_file.msh.stderr @@ -0,0 +1 @@ +2:27: Cannot apply '>': stdout already has a capture ('*'). Each stream has exactly one destination. diff --git a/tests/fail/redirect_conflict_circular_merge.msh b/tests/fail/redirect_conflict_circular_merge.msh new file mode 100644 index 00000000..d885d837 --- /dev/null +++ b/tests/fail/redirect_conflict_circular_merge.msh @@ -0,0 +1,2 @@ +# Conflict: merging both streams into each other is circular +[ls] 2>&1 1>&2 ; diff --git a/tests/fail/redirect_conflict_circular_merge.msh.stderr b/tests/fail/redirect_conflict_circular_merge.msh.stderr new file mode 100644 index 00000000..56697d58 --- /dev/null +++ b/tests/fail/redirect_conflict_circular_merge.msh.stderr @@ -0,0 +1 @@ +2:11: Cannot apply '1>&2': the other stream is already merged with '2>&1'; merging both streams into each other is circular. diff --git a/tests/fail/redirect_conflict_double_file.msh b/tests/fail/redirect_conflict_double_file.msh new file mode 100644 index 00000000..60763680 --- /dev/null +++ b/tests/fail/redirect_conflict_double_file.msh @@ -0,0 +1,2 @@ +# Conflict: two stdout file redirects must error +[ls] `conflict_f.txt` > `conflict_g.txt` > ; diff --git a/tests/fail/redirect_conflict_double_file.msh.stderr b/tests/fail/redirect_conflict_double_file.msh.stderr new file mode 100644 index 00000000..859f43b1 --- /dev/null +++ b/tests/fail/redirect_conflict_double_file.msh.stderr @@ -0,0 +1 @@ +2:42: Cannot apply '>': stdout already has a file redirect ('>'). Each stream has exactly one destination. diff --git a/tests/fail/redirect_conflict_merge_capture.msh b/tests/fail/redirect_conflict_merge_capture.msh new file mode 100644 index 00000000..f26a4e46 --- /dev/null +++ b/tests/fail/redirect_conflict_merge_capture.msh @@ -0,0 +1,2 @@ +# Conflict: stderr merged to stdout, then stderr capture must error +[ls] 2>&1 ^ ; diff --git a/tests/fail/redirect_conflict_merge_capture.msh.stderr b/tests/fail/redirect_conflict_merge_capture.msh.stderr new file mode 100644 index 00000000..8470cfa8 --- /dev/null +++ b/tests/fail/redirect_conflict_merge_capture.msh.stderr @@ -0,0 +1 @@ +2:11: Cannot apply '^': stderr already has a merge to stdout ('2>&1'). Each stream has exactly one destination. diff --git a/tests/success/merge_file.msh b/tests/success/merge_file.msh new file mode 100644 index 00000000..90faa050 --- /dev/null +++ b/tests/success/merge_file.msh @@ -0,0 +1,3 @@ +# FILE:merge_file.txt +# Test 2>&1 with a stdout file redirect: both streams into the file +[sh -c "echo file-out; echo file-err 1>&2"] `merge_file.txt` > 2>&1 ! diff --git a/tests/success/merge_file.txt b/tests/success/merge_file.txt new file mode 100644 index 00000000..a8f49049 --- /dev/null +++ b/tests/success/merge_file.txt @@ -0,0 +1,2 @@ +file-out +file-err diff --git a/tests/success/merge_file.txt.expected b/tests/success/merge_file.txt.expected new file mode 100644 index 00000000..a8f49049 --- /dev/null +++ b/tests/success/merge_file.txt.expected @@ -0,0 +1,2 @@ +file-out +file-err diff --git a/tests/success/merge_pipeline.msh b/tests/success/merge_pipeline.msh new file mode 100644 index 00000000..06272b2b --- /dev/null +++ b/tests/success/merge_pipeline.msh @@ -0,0 +1,2 @@ +# Test 2>&1 on a pipeline stage: stderr flows through the pipe +[[sh -c "echo keep-ERROR 1>&2; echo drop"] 2>&1 [grep ERROR]] | * ; chomp wl diff --git a/tests/success/merge_pipeline.msh.stdout b/tests/success/merge_pipeline.msh.stdout new file mode 100644 index 00000000..42317cc8 --- /dev/null +++ b/tests/success/merge_pipeline.msh.stdout @@ -0,0 +1 @@ +keep-ERROR diff --git a/tests/success/merge_stderr_capture.msh b/tests/success/merge_stderr_capture.msh new file mode 100644 index 00000000..db6eefb9 --- /dev/null +++ b/tests/success/merge_stderr_capture.msh @@ -0,0 +1,2 @@ +# Test 2>&1: combined interleaved capture of stdout and stderr +[sh -c "echo out1; echo err1 1>&2; echo out2"] 2>&1 * ; chomp wl diff --git a/tests/success/merge_stderr_capture.msh.stdout b/tests/success/merge_stderr_capture.msh.stdout new file mode 100644 index 00000000..df6e0532 --- /dev/null +++ b/tests/success/merge_stderr_capture.msh.stdout @@ -0,0 +1,3 @@ +out1 +err1 +out2 diff --git a/tests/success/merge_stdout_to_stderr.msh b/tests/success/merge_stdout_to_stderr.msh new file mode 100644 index 00000000..548f810b --- /dev/null +++ b/tests/success/merge_stdout_to_stderr.msh @@ -0,0 +1,2 @@ +# Test 1>&2: stdout merged into stderr's destination (captured via ^) +[sh -c "echo through-stderr"] 1>&2 ^ ; chomp wl diff --git a/tests/success/merge_stdout_to_stderr.msh.stdout b/tests/success/merge_stdout_to_stderr.msh.stdout new file mode 100644 index 00000000..d4f24447 --- /dev/null +++ b/tests/success/merge_stdout_to_stderr.msh.stdout @@ -0,0 +1 @@ +through-stderr diff --git a/tests/success/quote_merge.msh b/tests/success/quote_merge.msh new file mode 100644 index 00000000..1bdd76e1 --- /dev/null +++ b/tests/success/quote_merge.msh @@ -0,0 +1,2 @@ +# Test 2>&1 on a quotation: wle output joins stdout +( "q-out" wl "q-err" wle ) 2>&1 x diff --git a/tests/success/quote_merge.msh.stdout b/tests/success/quote_merge.msh.stdout new file mode 100644 index 00000000..be49eb56 --- /dev/null +++ b/tests/success/quote_merge.msh.stdout @@ -0,0 +1,2 @@ +q-out +q-err diff --git a/tests/success/quote_merge_err.txt b/tests/success/quote_merge_err.txt new file mode 100644 index 00000000..cc86ee6c --- /dev/null +++ b/tests/success/quote_merge_err.txt @@ -0,0 +1,2 @@ +q2-out +q2-err diff --git a/tests/success/quote_merge_err.txt.expected b/tests/success/quote_merge_err.txt.expected new file mode 100644 index 00000000..cc86ee6c --- /dev/null +++ b/tests/success/quote_merge_err.txt.expected @@ -0,0 +1,2 @@ +q2-out +q2-err diff --git a/tests/success/quote_merge_to_stderr.msh b/tests/success/quote_merge_to_stderr.msh new file mode 100644 index 00000000..786acb08 --- /dev/null +++ b/tests/success/quote_merge_to_stderr.msh @@ -0,0 +1,3 @@ +# FILE:quote_merge_err.txt +# Test 1>&2 on a quotation with a stderr file redirect: wl output follows +( "q2-out" wl "q2-err" wle ) 1>&2 `quote_merge_err.txt` 2> x diff --git a/tests/typecheck_fail/inplace_merge_conflict.msh b/tests/typecheck_fail/inplace_merge_conflict.msh new file mode 100644 index 00000000..f4ac84af --- /dev/null +++ b/tests/typecheck_fail/inplace_merge_conflict.msh @@ -0,0 +1,2 @@ +# Static conflict: each stream has exactly one destination +[cat] 2>&1 `p.txt` <> ; diff --git a/tests/typecheck_fail/merge_conflict_capture.msh b/tests/typecheck_fail/merge_conflict_capture.msh new file mode 100644 index 00000000..52327f4b --- /dev/null +++ b/tests/typecheck_fail/merge_conflict_capture.msh @@ -0,0 +1,2 @@ +# Static conflict: each stream has exactly one destination +[ls] 2>&1 ^ ; diff --git a/tests/typecheck_fail/merge_conflict_circular.msh b/tests/typecheck_fail/merge_conflict_circular.msh new file mode 100644 index 00000000..04188225 --- /dev/null +++ b/tests/typecheck_fail/merge_conflict_circular.msh @@ -0,0 +1,2 @@ +# Static conflict: each stream has exactly one destination +[ls] 2>&1 1>&2 ; diff --git a/tests/typecheck_fail/merge_conflict_stdout.msh b/tests/typecheck_fail/merge_conflict_stdout.msh new file mode 100644 index 00000000..4b273b24 --- /dev/null +++ b/tests/typecheck_fail/merge_conflict_stdout.msh @@ -0,0 +1,2 @@ +# Static conflict: each stream has exactly one destination +[ls] 1>&2 * ; diff --git a/tests/typecheck_fail/redirect_conflict_both.msh b/tests/typecheck_fail/redirect_conflict_both.msh new file mode 100644 index 00000000..1cb4e24b --- /dev/null +++ b/tests/typecheck_fail/redirect_conflict_both.msh @@ -0,0 +1,2 @@ +# Static conflict: each stream has exactly one destination +[ls] `f.txt` &> 2>&1 ; diff --git a/tests/typecheck_fail/redirect_conflict_capture_file.msh b/tests/typecheck_fail/redirect_conflict_capture_file.msh new file mode 100644 index 00000000..1a9502bc --- /dev/null +++ b/tests/typecheck_fail/redirect_conflict_capture_file.msh @@ -0,0 +1,2 @@ +# Static conflict: each stream has exactly one destination +[ls] * `f.txt` > ; diff --git a/tests/typecheck_fail/redirect_conflict_double_file.msh b/tests/typecheck_fail/redirect_conflict_double_file.msh new file mode 100644 index 00000000..da797a1f --- /dev/null +++ b/tests/typecheck_fail/redirect_conflict_double_file.msh @@ -0,0 +1,2 @@ +# Static conflict: each stream has exactly one destination +[ls] `f.txt` > `g.txt` > ; diff --git a/tests/typecheck_fail/redirect_conflict_stderr_file.msh b/tests/typecheck_fail/redirect_conflict_stderr_file.msh new file mode 100644 index 00000000..ea59fc26 --- /dev/null +++ b/tests/typecheck_fail/redirect_conflict_stderr_file.msh @@ -0,0 +1,2 @@ +# Static conflict: each stream has exactly one destination +[ls] ^ `g.txt` 2> ; From 70a5e065a639bb509e813fe47498de3957438a6a Mon Sep 17 00:00:00 2001 From: Mitchell Paulus Date: Fri, 10 Jul 2026 12:06:21 -0500 Subject: [PATCH 2/2] Restrict quotation redirects to stack-neutral operators Quotations accept only the redirects that don't change their stack effect: file redirects (>, >>, 2>, 2>>, &>, &>>), stdin (<), and the merges (2>&1, 1>&2). The captures (*, *b, ^, ^b) and the in-place redirect (<>) are rejected on quotations with a clear message at both runtime and in the static type checker. Previously a capture on a quotation fell into the multiplication branch at runtime ("Cannot do '*' operation on a stack with only one item"), and the type checker wrongly accepted <> on a quotation that the runtime then rejected. Capture individual command lists instead, e.g. [[cmd1] [cmd2]] (* !) map. Docs now state the allowed/excluded operator sets, that destination conflicts on quotations are enforced at runtime (redirects never change a quotation's stack effect, so they are invisible to the checker), and the open-per-execution rule: a >-redirected quotation truncates its file on every execution (use >> to accumulate), except loop, which opens the file once for all iterations. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 +++++ doc/execution.inc.html | 27 ++++++++++++++++++++++- doc/mshell.md | 17 +++++++++++++- mshell/Evaluator.go | 8 +++++++ mshell/TypeChecker.go | 24 ++++++++++++++++++++ tests/fail/capture_on_quote.msh | 2 ++ tests/fail/capture_on_quote.msh.stderr | 1 + tests/typecheck_fail/capture_on_quote.msh | 2 ++ tests/typecheck_fail/inplace_on_quote.msh | 2 ++ 9 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 tests/fail/capture_on_quote.msh create mode 100644 tests/fail/capture_on_quote.msh.stderr create mode 100644 tests/typecheck_fail/capture_on_quote.msh create mode 100644 tests/typecheck_fail/inplace_on_quote.msh diff --git a/CHANGELOG.md b/CHANGELOG.md index da1fff25..eb86e14d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `loop` quotations now honor stderr redirects, append mode, and merges, and inherit the enclosing quotation's redirected streams (previously a loop body wrote straight to the terminal even inside a redirected quotation). +- Quotations accept only the redirects that don't change the stack: file + redirects, stdin, and the merges. Captures (`*`, `*b`, `^`, `^b`) on a + quotation now give a clear error at both layers instead of falling into + the multiplication error, and the type checker now rejects `<>` on a + quotation (the runtime always did). Capture individual command lists + instead, e.g. `[[cmd1] [cmd2]] (* !) map`. ### Added diff --git a/doc/execution.inc.html b/doc/execution.inc.html index 7812d7b1..e47bab2b 100644 --- a/doc/execution.inc.html +++ b/doc/execution.inc.html @@ -202,11 +202,36 @@

Each stream has exactly one destination

Redirection on quotations

-All of the redirection operators above also work on quotations. +The redirection operators that don't change the stack also work on quotations: +file redirects (>, >>, 2>, 2>>, &>, &>>), +stdin (<), and the merges (2>&1, 1>&2). This is useful when you want to redirect the output of mshell code that uses wl, wle, or other built-in functions that write to stdout or stderr. It is also useful when you have many commands that you want to run while appending all the outputs to a single file, without having to put the redirection on each command invocation.

+

+The captures (*, *b, ^, ^b) and the in-place redirect (<>) are not allowed on quotations, +because they would change the quotation's stack effect. +Capture the individual command lists inside the quotation instead — for example, run a list of commands and collect each stdout: +

+ +
+[[cmd1] [cmd2]] (* !) map # Run each command, collect stdouts as a list of strings.
+
+
+ +

+Destination conflicts on quotations (e.g. two > redirects, or 2>&1 plus 2>) are caught at runtime. +Since redirects never change a quotation's stack effect, they are invisible to the static type checker. +

+ +

+A quotation's redirect is resolved each time the quotation executes. +So a >-redirected quotation run repeatedly — stored and executed with x several times, or passed to each or map — truncates the file on every execution, leaving only the last run's output. +Use >> when a repeatedly-executed quotation should accumulate output. +The exception is loop: the file is opened once when the loop starts, so all iterations write to the same open file. +

+
 (
     "Hello from stdout" wl
diff --git a/doc/mshell.md b/doc/mshell.md
index 3c4e7af0..3e87699d 100644
--- a/doc/mshell.md
+++ b/doc/mshell.md
@@ -155,7 +155,22 @@ Operator | Effect on external commands                | Notes
 
 ### Redirection on quotations
 
-All of the redirection operators above also work on quotations. This is useful when you want to redirect the output of mshell code that uses `wl`, `wle`, or other built-in functions that write to stdout or stderr. It is also useful when you have many commands that you want to run while appending all the outputs to a single file, without having to put the redirection on each command invocation.
+The redirection operators that don't change the stack also work on quotations:
+file redirects (`>`, `>>`, `2>`, `2>>`, `&>`, `&>>`), stdin (`<`), and the merges (`2>&1`, `1>&2`).
+This is useful when you want to redirect the output of mshell code that uses `wl`, `wle`, or other built-in functions that write to stdout or stderr.
+It is also useful when you have many commands that you want to run while appending all the outputs to a single file, without having to put the redirection on each command invocation.
+
+The captures (`*`, `*b`, `^`, `^b`) and the in-place redirect (`<>`) are *not* allowed on quotations,
+because they would change the quotation's stack effect.
+Capture the individual command lists inside the quotation instead, e.g. `[[cmd1] [cmd2]] (* !) map` to run each command and collect stdouts.
+
+Destination conflicts on quotations (e.g. two `>` redirects, or `2>&1` plus `2>`) are caught at runtime.
+Since redirects never change a quotation's stack effect, they are invisible to the static type checker.
+
+A quotation's redirect is resolved each time the quotation executes.
+So a `>`-redirected quotation run repeatedly — stored and executed with `x` several times, or passed to `each` or `map` — truncates the file on every execution, leaving only the last run's output.
+Use `>>` when a repeatedly-executed quotation should accumulate output.
+The exception is `loop`: the file is opened once when the loop starts, so all iterations write to the same open file.
 
 ```mshell
 (
diff --git a/mshell/Evaluator.go b/mshell/Evaluator.go
index 42a7ebf3..dd151dba 100644
--- a/mshell/Evaluator.go
+++ b/mshell/Evaluator.go
@@ -11634,6 +11634,8 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 					}
 					asPipe.StdoutBehavior = STDOUT_COMPLETE
 					stack.Push(asPipe)
+				} else if _, ok := obj1.(*MShellQuotation); ok {
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: '%s' capture is not supported on quotations; it would change the quotation's stack effect. Capture the individual command lists inside instead, or redirect the quotation to a file with '>'.\n", t.Line, t.Column, t.Lexeme))
 				} else {
 					obj2, err := stack.Pop()
 					if err != nil {
@@ -11678,6 +11680,8 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
 					}
 					objTyped.StdoutBehavior = STDOUT_BINARY
+				case *MShellQuotation:
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: '%s' capture is not supported on quotations; it would change the quotation's stack effect. Capture the individual command lists inside instead, or redirect the quotation to a file with '>'.\n", t.Line, t.Column, t.Lexeme))
 				default:
 					return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot capture binary stdout for a %s.\n", t.Line, t.Column, obj1.TypeName()))
 				}
@@ -11701,6 +11705,8 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
 					}
 					objTyped.StderrBehavior = STDERR_COMPLETE
+				case *MShellQuotation:
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: '%s' capture is not supported on quotations; it would change the quotation's stack effect. Capture the individual command lists inside instead, or redirect the quotation to a file with '2>'.\n", t.Line, t.Column, t.Lexeme))
 				default:
 					return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot capture stderr for a %s.\n", t.Line, t.Column, obj1.TypeName()))
 				}
@@ -11722,6 +11728,8 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
 					}
 					objTyped.StderrBehavior = STDERR_BINARY
+				case *MShellQuotation:
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: '%s' capture is not supported on quotations; it would change the quotation's stack effect. Capture the individual command lists inside instead, or redirect the quotation to a file with '2>'.\n", t.Line, t.Column, t.Lexeme))
 				default:
 					return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot capture binary stderr for a %s.\n", t.Line, t.Column, obj1.TypeName()))
 				}
diff --git a/mshell/TypeChecker.go b/mshell/TypeChecker.go
index 1322e479..57a48236 100644
--- a/mshell/TypeChecker.go
+++ b/mshell/TypeChecker.go
@@ -854,6 +854,16 @@ func (c *Checker) tryRedirect(tok Token) bool {
 	}
 	operand := c.subst.Apply(c.arena, c.stack.items[c.stack.Len()-2])
 	if isQuoteKind(c.arena.Kind(operand)) {
+		// Quotations accept only the redirects that don't change their
+		// stack effect; destination conflicts on quotations are enforced
+		// at runtime. In-place redirects are list-only, as at runtime.
+		if tok.Type == INPLACEREDIRECT {
+			c.errors = append(c.errors, TypeError{
+				Kind: TErrTypeMismatch,
+				Pos:  tok,
+				Hint: "In-place redirect (<>) requires a List, found a quotation.",
+			})
+		}
 		c.stack.items = c.stack.items[:c.stack.Len()-1]
 		return true
 	}
@@ -942,6 +952,20 @@ func (c *Checker) tryCapture(tok Token) bool {
 		return false
 	}
 	operand := c.subst.Apply(c.arena, c.stack.items[c.stack.Len()-1])
+	if isQuoteKind(c.arena.Kind(operand)) {
+		// Captures are not allowed on quotations: they would change the
+		// quotation's stack effect. Mirrors the runtime rejection.
+		redirectOp := "'>'"
+		if !isStdout {
+			redirectOp = "'2>'"
+		}
+		c.errors = append(c.errors, TypeError{
+			Kind: TErrTypeMismatch,
+			Pos:  tok,
+			Hint: fmt.Sprintf("'%s' capture is not supported on quotations; it would change the quotation's stack effect. Capture the individual command lists inside instead, or redirect the quotation to a file with %s.", tok.Lexeme, redirectOp),
+		})
+		return true
+	}
 	argv, stdoutState, stderrState, ok := c.commandParts(operand)
 	if !ok {
 		return false
diff --git a/tests/fail/capture_on_quote.msh b/tests/fail/capture_on_quote.msh
new file mode 100644
index 00000000..9d4293ea
--- /dev/null
+++ b/tests/fail/capture_on_quote.msh
@@ -0,0 +1,2 @@
+# Captures are not allowed on quotations; they would change the stack effect
+( "a" wl ) * x
diff --git a/tests/fail/capture_on_quote.msh.stderr b/tests/fail/capture_on_quote.msh.stderr
new file mode 100644
index 00000000..2b57ad6e
--- /dev/null
+++ b/tests/fail/capture_on_quote.msh.stderr
@@ -0,0 +1 @@
+2:12: '*' capture is not supported on quotations; it would change the quotation's stack effect. Capture the individual command lists inside instead, or redirect the quotation to a file with '>'.
diff --git a/tests/typecheck_fail/capture_on_quote.msh b/tests/typecheck_fail/capture_on_quote.msh
new file mode 100644
index 00000000..e781ee9b
--- /dev/null
+++ b/tests/typecheck_fail/capture_on_quote.msh
@@ -0,0 +1,2 @@
+# Static: captures are not allowed on quotations
+( "a" wl ) * x
diff --git a/tests/typecheck_fail/inplace_on_quote.msh b/tests/typecheck_fail/inplace_on_quote.msh
new file mode 100644
index 00000000..57d5e918
--- /dev/null
+++ b/tests/typecheck_fail/inplace_on_quote.msh
@@ -0,0 +1,2 @@
+# Static: in-place redirect requires a List
+( "a" wl ) `f.txt` <> x
Summary of external command operators
&>> Redirect both stdout and stderr to a file. Appends to the file.
^ Capture stderr to the stack. As a string.
^b Capture stderr to the stack. As binary.
2>&1 Merge stderr into stdout's destination. Single token, no spaces. Not order-sensitive: stderr follows stdout's final destination.
1>&2 Merge stdout into stderr's destination. Single token, no spaces.
< Feed stdin from a value. String, path, or binary.
<> In-place file modification. Reads file to stdin, writes stdout back on success.
& Mark the command list to run asynchronously. Marks the list; the trailing ;/! starts the subprocess and returns immediately without waiting. Stdout and stderr default to discarded.