Description
Related problem
To support building out more complex command line interfaces based on nushell scripting, I'm proposing adding implicitly-pipelined subcommands to the language. These implicit pipelines would allow shared logic for a set of subcommands to be handled by their containing command.
For example, in my case I'm writing some simple nu scripts to handle setup and validation for a cluster application. I want to be able to support things like:
./script validate
# validates dev environment config
./script -e prod validate
# validates prod environment config
./script -e prod validate -f
# fix validation issues in the prod environment
./script -e prod url
# print the URL for accessing the prod environment
To support this, I'd like the handling/validation of shared arguments (environment, cluster, parsing files to determine defaults, etc) to be handled by main
, which builds up a known valid $context
which gets piped to the subcommand. The subcommand can then use that valid context directly, without having to worry about replicating shared arguments across subcommands, and calling to some setup function.
Describe the solution you'd like
Proposed Syntax
cat - <<- 'EOF' > script
#!/usr/bin/env nu
def main [
--flag (-f): string = "flag-default"
arg: string = "arg-default"
] {
{
flag: $flag,
arg: $arg
}
}
def "main | main" [
] {
print "No subcommand given"
}
def "main | sub" [
sub_arg: string
] {
let context = $in
print $"Flag: ($context.flag); Arg: ($context.arg); Sub: ($sub_arg)"
}
EOF
nu -c './script'
nu -c './script sub foo'
nu -c './script -f bar sub foo'
nu -c './script -f bar baz sub foo'
Results:
No subcommand given
Flag: flag-default; Arg: arg-default; Sub: foo
Flag: bar; Arg: arg-default; Sub: foo
Flag: bar; Arg: baz; Sub: foo
Guess at implementation
I'm a nubie, so am certainly glossing over some complexity. It is already possible to define commands with a pipe in the name, but as far as I can tell, those commands cannot be executed. I've been trying to find a way to execute a command via name or decl_id
, but haven't found anything yet.
Therefore, the addition to the language would be an AST transform of a given command line into an implicit pipe, with the requisite updates to command validation. Namely, it seems this has to happen after we know all the def
s in scope, so can determine whether we've encountered an implicit pipeline, but before argument validation for a command line.
Raw command AST
echo "Raw commandline"
nu -c 'ast "main -f flag arg sub subarg" --json --flatten'
[
{
"content": "main",
"shape": "shape_external",
"span": {
"start": 0,
"end": 4
}
},
{
"content": "-f",
"shape": "shape_externalarg",
"span": {
"start": 5,
"end": 7
}
},
{
"content": "flag",
"shape": "shape_externalarg",
"span": {
"start": 8,
"end": 12
}
},
{
"content": "arg",
"shape": "shape_externalarg",
"span": {
"start": 13,
"end": 16
}
},
{
"content": "sub",
"shape": "shape_externalarg",
"span": {
"start": 17,
"end": 20
}
},
{
"content": "subarg",
"shape": "shape_externalarg",
"span": {
"start": 21,
"end": 27
}
}
]
Translated to pipeline
echo "After translation"
nu -c 'ast "main -f flag arg | sub subarg" --json --flatten'
[
{
"content": "main",
"shape": "shape_external",
"span": {
"start": 0,
"end": 4
}
},
{
"content": "-f",
"shape": "shape_externalarg",
"span": {
"start": 5,
"end": 7
}
},
{
"content": "flag",
"shape": "shape_externalarg",
"span": {
"start": 8,
"end": 12
}
},
{
"content": "arg",
"shape": "shape_externalarg",
"span": {
"start": 13,
"end": 16
}
},
{
"content": "|",
"shape": "shape_pipe",
"span": {
"start": 17,
"end": 18
}
},
{
"content": "sub",
"shape": "shape_external",
"span": {
"start": 19,
"end": 22
}
},
{
"content": "subarg",
"shape": "shape_externalarg",
"span": {
"start": 23,
"end": 29
}
}
]
The decl_id
for the sub
token would be translated to the decl_id
for main | sub
. This implicit translation would require walking the AST, finding those nodes which have implicitly pipelined subcommands defined, and checking their list for the appropriate subcommand-triggering tokens.
I imagine something similar is done now (though I'm not sure where), to support ./script sub
where script
has a def "main sub"
definition. In that case, the script file has to be read, and the command line has to be reinterpreted to use the decl_id
for "main sub"
not just main
, because the original command line contained the adjacent tokens main
and sub
. In the implicit pipeline case, we can't simply stop after reading the first two tokens, as there may be flags/arguments to add to main.
Notes
- It should be a syntax error to define both
"main {sub}"
and"main | {sub}"
for a given value ofsub
. This would cause an ambiguity, and I think it would be better to reject rather than trusting precedence rules to resolve. Other than that, I see no reason why"main sub"
and"main | piped"
couldn't both be defined and properly dispatched. - It should be a compile-time error if the type for the output of
main
doesn't agree with the input ofmain | sub
, at least for explicitly-typed functions. I'm not sure how much type inference is going on at compile time, so not sure whether there might be implications there. - New syntax is proposed because I don't believe this change should alter the way the current
"main sub"
subcommands are dispatched/evaluated. They would not be implicitly pipelined withmain
to preserve backwards compatibility. - If there is no
main | main
defined, then the evaluation ofmain
should be equivalent to if this syntax had never been added. Namely, it simply returns its result, which can be piped to any other command as usual. - If
main | main
is defined, then the AST transformation I describe above would have to inject a pipeline frommain
tomain | main
if it encountered amain
call site without finding any of its implicitly pipelined subcommands.
Describe alternatives you've considered
I've tried a couple ways to replicate this desired code arrangement with nu 0.101.0.
Manually wire/dispatch
cat - <<- 'EOF' > dispatch
#!/usr/bin/env nu
def main [
--flag (-f): string = "flag-default"
arg: string = "arg-default"
sub?: string
arg?: string
] {
let subs = {
"sub": { sub $arg }
}
{
flag: $flag,
arg: $arg
} | do ($subs | get $sub)
}
def "main | main" [
] {
print "No subcommand given"
}
def "sub" [
sub_arg: string
] {
let context = $in
print $"Flag: ($context.flag); Arg: ($context.arg); Sub: ($sub_arg)"
}
EOF
nu -c 'source dispatch; main -f bar boo sub foo'
This is the route I originally went down, but it has significant issues that led me to quickly abandon it:
main
cannot have optional positional parameters, without significantly complicating the dispatch logic.- Every subcommand requires double entry, as the command itself is needed, then the dispatch record in main needs to be updated to be given a closure to call it by name.
main
needs to have a single parameter list that encodes all parameters to all subcommands. In order to give subcommands optional parameters would again significantly complicate the dispatch logic.
Source into repl and explicitly pipeline
cat - <<- 'EOF' > script
#!/usr/bin/env nu
def main [
--flag (-f): string = "flag-default"
arg: string = "arg-default"
] {
{
flag: $flag,
arg: $arg
}
}
def "main | main" [
] {
print "No subcommand given"
}
def "sub" [
sub_arg: string
] {
let context = $in
print $"Flag: ($context.flag); Arg: ($context.arg); Sub: ($sub_arg)"
}
EOF
nu -c 'source script; main' # prints context rather than help
nu -c 'source script; main | sub foo'
nu -c 'source script; main -f bar | sub foo'
nu -c 'source script; main -f bar baz | sub foo'
This is how I'm currently solving my problem. It works well enough, but I think users who didn't write it would like the help system to make it more clear that sub
is expecting to be given the context defined by main
.
- I can't call
./script | sub
directly, as it seems to look upsub
outside the context of./script
. - No auto-printed help possible if a subcommand wasn't given.
- Adds subcommands directly to the scope, so it's possible to call
sub
outside of a pipeline, giving either no context, or an incorrectly-constructed context.
Additional context and details
When I asked in discord while tinkering with the earlier solutions, I was referred to #12363. That certainly seems related to this, but I'm not sure if this proposal would satisfy their goal. If not, I'd rather change/reject this proposal than advocate for two separate ways to solve a highly related issue.