Skip to content

Support for implicitly pipelined subcommandsΒ #14864

Open
@ajuckel

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 defs 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 of sub. 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 of main | 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 with main to preserve backwards compatibility.
  • If there is no main | main defined, then the evaluation of main 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 from main to main | main if it encountered a main 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:

  1. main cannot have optional positional parameters, without significantly complicating the dispatch logic.
  2. 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.
  3. 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.

  1. I can't call ./script | sub directly, as it seems to look up sub outside the context of ./script.
  2. No auto-printed help possible if a subcommand wasn't given.
  3. 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.

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestneeds-triageAn issue that hasn't had any proper look

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions