Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Question: How to forward *args? #1988

Open
gabyx opened this issue Apr 3, 2024 · 7 comments
Open

Question: How to forward *args? #1988

gabyx opened this issue Apr 3, 2024 · 7 comments

Comments

@gabyx
Copy link

gabyx commented Apr 3, 2024

When I have

run *args:
   echo "$1, $2,..."

do *args: (run args)

How can I forward arguments properly such that $2 is b in just do a b c?

@casey
Copy link
Owner

casey commented May 15, 2024

Unfortunately this currently isn't possible. args will be passed to run as a single space-separated argument.

The best way to support this would by some kind of splat syntax:

run *args:
   echo "$1, $2,..."

do *args: (run *args)

@casey casey closed this as completed May 15, 2024
@casey
Copy link
Owner

casey commented May 15, 2024

Whoops, didn't mean to close.

@casey casey reopened this May 15, 2024
@drmacdon
Copy link

A work-around today might be to call back into self.

set positional-arguments

run *args:
   echo "$1, $2,..."

@do *args:
   {{just_executable()}} -f {{justfile()}} run "$@"

@latk
Copy link

latk commented Jan 9, 2025

Unfortunately this currently isn't possible. args will be passed to run as a single space-separated argument.

This currently makes it impossible to use more complicated arguments to pass shell arguments safely to a dependency (i.e., without extra word-splitting or interpolation).

I understand why arguments must be immediately stringified, because the Just data model consists of just String. Changing that would be tedious, but in principle possible. I think the trick to do this in a backwards-compatible way would be to implement something like Perl's "dualvar", where a value is both a string value and possibly another value at the same time.

I'd be interested in prototyping that approach if there will be interest in merging it.

Sketch of such a data structure that could replace String in bindings:

struct Value {
  /// String representation of the value.
  stringy: Box<str>,
  /// Optionally, array representation of this value.
  items: Option<Box[Box<str>]>,
}

impl Value {
  pub fn new(s: String) -> Self {
    Self { stringy: s, items: None }
  }
  pub fn from_vec(items: Vec<Box<str>>) -> Self {
    Self { stringy: items.join(" "), items: Some(items.into()) }
  }
}

impl Deref<str> for Value { ... } // convenience compatibility with String

Just's values are immutable, so the more compact Box<str> can be used instead of String, and Box<[...]> instead of Vec.

Single-value strings s would subsequently be represented with items: None, whereas multi-value variadics could retain the correctly split items, while still being compatible with string-like use.

Once that is done, a dependency splat syntax caller *args: (callee *args) should be implementable. Whereas the current evaluator produces a single value, a list-context evaluator might be necessary. Something like:

pub(crate) fn evaluate_expression(
  &mut self,
  expression: &Expression<'src>,
) -> RunResult<'src, Value> {
  match expression {
    ...
    Expression::Splat => Err(Error::SplatNotAllowedInSingleValueContext),
  }
}

pub(crate) fn evaluate_list_expression(
  &mut self,
  expression: &Expression<'src>,
) -> RunResult<'src, Vec<Value>> {
  match expression {
    Expression::Splat(inner) => match self.evaluate_expression(inner)? {
      Value { items: Some(items), .. } => Ok(items.map(s => s.into()).collect()),
      value => Ok(vec![value]),
    },
    other => Ok(vec![self.evaluate_expression(expr)?]),
  }
}

Then in run_recipe():

         let arguments = arguments
           .iter()
-          .map(|argument| evaluator.evaluate_expression(argument))
+          .flat_map(|argument| evaluator.evaluate_list_expression(argument))
           .collect::<RunResult<Vec<Value>>>()?;

         Self::run_recipe(&arguments, context, ran, recipe, true)?;

The advantage of this approach is that it would be fully backwards-compatible: variadic recipe parameters will continue to be joined, unless an explicit splat expression is used. The downside is that it will complicate the Just data model by introducing strings-that-are-not-just-strings and a list context in which expressions can use different operators.

A more conventional approach would be an enum Value { String(String), Vec(Vec<String>) } and deferring the vec.join(" ") until it is needed in a string-like context. But since most places expect strings, that change would likely require more work.

In any case, such a dependency splat syntax would conflict with this open issue that suggests running a recipe for each splatted argument:

@casey
Copy link
Owner

casey commented Jan 9, 2025

@latk I definitely agree with the idea and the approach. I think in practice, you could just do:

struct Value {
  items: Vec<String>,
}

And then .join(" ") items whenever you need the actual value, so that you avoid an enum or a more complicated type.

There's also #2458, which would make all values lists of strings, with single strings just being a list with one item, and using this pervasively. I think this is a good idea. just suffers from not being able to represent lists of things, and all the noraml shell headaches. The rc shell had it right that if you have a shell with a single data type, that type should be lists of strings, since that just gets rid of so many shell headaches, like quoting, splitting, and joining.

So yeah, I would love to see this happen, and starting with making variadic arguments nicer would be great. We should think about how to forward args and how to dispatch variadic arguments to multiple dependencies with splats at the same time, and make sure that we have syntax that works for both.

Later, we can think about #2458, which would allow users to actually manipulate lists in more useful ways. All that gets easier once the codebase is converted from using strings to some kind of Value type that can be a list. (I lean towards calling it Val since it's short and this type will be pervasive in the codebase.)

@latk
Copy link

latk commented Jan 9, 2025

Ok. I've read through that context and will take a jab at transitioning the data model to a new Val type over the weekend, and will report back with my findings. My goal is to have that first step be a purely internal refactoring, with zero changes in syntax or semantics.

I agree that using something like a Vec<String> as the sole data type is more elegant than a String, dualvar, or enum. However, I expect the necessary changes for "everything is a Vec" to be more involved, as the Val would not be able to deref to a &str. But implementing Display for Val would give us a to_string() method, which is almost as convenient.

Once we have list-typed values and lossless forwarding of variadic arguments to dependencies, the one feature that I really want is a list-aware quote(*args) function, which would avoid the need for many positional-arguments use cases.

@casey
Copy link
Owner

casey commented Jan 9, 2025

Sounds good! I agree a first pass that just introduces the new Val type would be great, even if it doesn't change any behavior or add new features.

latk added a commit to latk/just.rs that referenced this issue Jan 11, 2025
In support of:

* casey#2458
* casey#1988

A `Val` is a list of strings, but at each point of usage we must decide whether
to view it as a singular joined string or as a list of its parts. Previously,
Just values were single strings, so in most places we have to invoke
`to_joined()` in order to maintain compatibility. In particular, recipes,
functions, and operators like `+` or `/` operate solely on strings. This
includes logical operators like `&&`, which continue to be defined on strings.
That means, the values `[]` and `['']` are currently completely equivalent.

So far, this is a purely internal change without externally visible effects.
Only the `Bindings`/`Scope`/`Evaluator` had API changes.

No new syntax is implemented. However, in expectation of expressions that build
lists, a new `evaluate_list_expression() -> Vec<String>` method is introduced
that could be used to implement splat or generator expressions. It is already
referenced wherever we have lists of arguments, e.g. variadic functions like
`shell()` and dependency arguments. But because singular expressions are
equivalent to a joined string, this is an unobservable detail for now.

For experimenting with lists of strings, variadic recipe parameters like `*args`
now produce a multi-part Val, and default to an empty list (not a list with an
empty string). Because all operators use `to_joined()`, this is an unobservable
implementation detail. However, if any operator becomes list-aware, then this
detail should be reverted, or moved behind an unstable setting.

For better understanding of the current behavior, I added a bunch of tests.
These will help detect regressions if functions or operators become list-aware.
No existing tests had to be touched.

Next steps: This change is just the foundation for other work, but some ideas
are mutually exclusive. Relevant aspects:

* list syntax in casey#2458
* list aware operators in casey#2458
* lossless forwarding of variadics: casey#1988
* invoking dependencies multiple times: casey#558

The preparatory work like `evaluate_list_expression()` is biased towards
implementing a splat operator that would enable casey#2458 list syntax and casey#1988 list
forwarding, but doesn't commit us to any particular design yet.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants