@@ -161,6 +191,8 @@
&>> | 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. |
@@ -170,11 +202,36 @@
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
+
+
+
+
+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 4b64c3e1..3e87699d 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,13 +147,30 @@ 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.
### 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 ab2f8854..dd151dba 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,11 +11623,19 @@ 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 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 {
@@ -11648,9 +11671,17 @@ 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
+ 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()))
}
@@ -11665,9 +11696,17 @@ 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
+ 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()))
}
@@ -11680,9 +11719,17 @@ 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
+ 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()))
}
@@ -11704,10 +11751,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 +12163,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 +12305,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 +12342,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 +12385,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 +12406,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 +12535,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..57a48236 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,239 @@ 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)) {
+ // 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
+ }
+ 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])
+ 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
+ }
+
+ 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/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/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/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_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/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
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> ;