Repository: casey/just
Pull request #2632 (2025-02-17 22:10:12)

feat: module path aliases
Implements #2629 .

The usage of `NamePath` feels clunky. Maybe I could make it so that it's guaranteed that `NamePath` is not empty and that all tokens sit after each other separated by ColonColon. Then, we could generate a `&str` and avoid the usage of `String` in `UnknownAliasTarget`

corvusrabus: Sorry I've been a bit busy. Will follow up on the weekend
corvusrabus: @casey I 
1. removed the `NamePath::new` so that len >=1 is again guaranteed
2. Needed to add new `PartialEq` for `NamePath` because namepaths should be considered equal if they lex to the same path

Once we merge this I'd be happy to implement dependencies on recipes in other modules, which I think is 60% done with this MR already (that's the feature that I came here for in the first place :) )

Patch:
diff --git a/README.md b/README.md
index b7380078..c10c3654 100644
--- a/README.md
+++ b/README.md
@@ -953,6 +953,14 @@ echo 'Building!'
 Building!
 ```
 
+The target of an alias may be a recipe in a submodule:
+
+```justfile
+mod foo
+
+alias baz := foo::bar
+```
+
 ### Settings
 
 Settings control interpretation and execution. Each setting may be specified at
diff --git a/src/alias.rs b/src/alias.rs
index 18280546..b8535d2a 100644
--- a/src/alias.rs
+++ b/src/alias.rs
@@ -12,9 +12,9 @@ pub(crate) struct Alias<'src, T = Rc<Recipe<'src>>> {
   pub(crate) target: T,
 }
 
-impl<'src> Alias<'src, Name<'src>> {
+impl<'src> Alias<'src, Namepath<'src>> {
   pub(crate) fn resolve(self, target: Rc<Recipe<'src>>) -> Alias<'src> {
-    assert_eq!(self.target.lexeme(), target.name.lexeme());
+    assert!(self.target.last().lexeme() == target.namepath.last().lexeme());
 
     Alias {
       attributes: self.attributes,
@@ -36,14 +36,9 @@ impl<'src, T> Keyed<'src> for Alias<'src, T> {
   }
 }
 
-impl<'src> Display for Alias<'src, Name<'src>> {
+impl<'src> Display for Alias<'src, Namepath<'src>> {
   fn fmt(&self, f: &mut Formatter) -> fmt::Result {
-    write!(
-      f,
-      "alias {} := {}",
-      self.name.lexeme(),
-      self.target.lexeme()
-    )
+    write!(f, "alias {} := {}", self.name.lexeme(), self.target)
   }
 }
 
diff --git a/src/analyzer.rs b/src/analyzer.rs
index e4985072..d366cff3 100644
--- a/src/analyzer.rs
+++ b/src/analyzer.rs
@@ -2,7 +2,7 @@ use {super::*, CompileErrorKind::*};
 
 #[derive(Default)]
 pub(crate) struct Analyzer<'run, 'src> {
-  aliases: Table<'src, Alias<'src, Name<'src>>>,
+  aliases: Table<'src, Alias<'src, Namepath<'src>>>,
   assignments: Vec<&'run Binding<'src, Expression<'src>>>,
   modules: Table<'src, Justfile<'src>>,
   recipes: Vec<&'run Recipe<'src, UnresolvedDependency<'src>>>,
@@ -149,7 +149,7 @@ impl<'run, 'src> Analyzer<'run, 'src> {
 
     let mut aliases = Table::new();
     while let Some(alias) = self.aliases.pop() {
-      aliases.insert(Self::resolve_alias(&recipes, alias)?);
+      aliases.insert(Self::resolve_alias(&self.modules, &recipes, alias)?);
     }
 
     for recipe in recipes.values() {
@@ -290,19 +290,35 @@ impl<'run, 'src> Analyzer<'run, 'src> {
     Ok(())
   }
 
-  fn resolve_alias(
-    recipes: &Table<'src, Rc<Recipe<'src>>>,
-    alias: Alias<'src, Name<'src>>,
+  fn resolve_alias<'a>(
+    modules: &'a Table<'src, Justfile<'src>>,
+    recipes: &'a Table<'src, Rc<Recipe<'src>>>,
+    alias: Alias<'src, Namepath<'src>>,
   ) -> CompileResult<'src, Alias<'src>> {
-    // Make sure the target recipe exists
-    match recipes.get(alias.target.lexeme()) {
-      Some(target) => Ok(alias.resolve(Rc::clone(target))),
+    match Self::alias_target(&alias.target, modules, recipes) {
+      Some(target) => Ok(alias.resolve(target)),
       None => Err(alias.name.token.error(UnknownAliasTarget {
         alias: alias.name.lexeme(),
-        target: alias.target.lexeme(),
+        target: alias.target,
       })),
     }
   }
+
+  fn alias_target<'a>(
+    path: &Namepath<'src>,
+    mut modules: &'a Table<'src, Justfile<'src>>,
+    mut recipes: &'a Table<'src, Rc<Recipe<'src>>>,
+  ) -> Option<Rc<Recipe<'src>>> {
+    let (name, path) = path.split_last();
+
+    for name in path {
+      let module = modules.get(name.lexeme())?;
+      modules = &module.modules;
+      recipes = &module.recipes;
+    }
+
+    recipes.get(name.lexeme()).cloned()
+  }
 }
 
 #[cfg(test)]
@@ -326,7 +342,20 @@ mod tests {
     line: 0,
     column: 6,
     width: 3,
-    kind: UnknownAliasTarget {alias: "foo", target: "bar"},
+    kind: UnknownAliasTarget {
+      alias: "foo",
+      target: Namepath::from(Name::from_identifier(
+        Token{
+          column: 13,
+          kind: TokenKind::Identifier,
+          length: 3,
+          line: 0,
+          offset: 13,
+          path: Path::new("justfile"),
+          src: "alias foo := bar\n",
+        }
+      ))
+    },
   }
 
   analysis_error! {
diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs
index cd42c41a..f3ea32ac 100644
--- a/src/compile_error_kind.rs
+++ b/src/compile_error_kind.rs
@@ -138,7 +138,7 @@ pub(crate) enum CompileErrorKind<'src> {
   UnicodeEscapeUnterminated,
   UnknownAliasTarget {
     alias: &'src str,
-    target: &'src str,
+    target: Namepath<'src>,
   },
   UnknownAttribute {
     attribute: &'src str,
diff --git a/src/item.rs b/src/item.rs
index e6036e7a..89f95dc5 100644
--- a/src/item.rs
+++ b/src/item.rs
@@ -3,7 +3,7 @@ use super::*;
 /// A single top-level item
 #[derive(Debug, Clone)]
 pub(crate) enum Item<'src> {
-  Alias(Alias<'src, Name<'src>>),
+  Alias(Alias<'src, Namepath<'src>>),
   Assignment(Assignment<'src>),
   Comment(&'src str),
   Import {
diff --git a/src/lexer.rs b/src/lexer.rs
index 24661520..cd2fe821 100644
--- a/src/lexer.rs
+++ b/src/lexer.rs
@@ -735,6 +735,8 @@ impl<'src> Lexer<'src> {
 
     if self.accepted('=')? {
       self.token(ColonEquals);
+    } else if self.accepted(':')? {
+      self.token(ColonColon);
     } else {
       self.token(Colon);
       self.recipe_body_pending = true;
@@ -982,6 +984,7 @@ mod tests {
       BracketR => "]",
       ByteOrderMark => "\u{feff}",
       Colon => ":",
+      ColonColon => "::",
       ColonEquals => ":=",
       Comma => ",",
       Dollar => "$",
diff --git a/src/lib.rs b/src/lib.rs
index 6820a3c7..04f046dc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -139,7 +139,10 @@ pub(crate) use {
 };
 
 #[cfg(test)]
-pub(crate) use crate::{node::Node, tree::Tree};
+pub(crate) use {
+  crate::{node::Node, tree::Tree},
+  std::slice,
+};
 
 pub use crate::run::run;
 
diff --git a/src/namepath.rs b/src/namepath.rs
index c6cd2bf4..48c809b9 100644
--- a/src/namepath.rs
+++ b/src/namepath.rs
@@ -1,6 +1,6 @@
 use super::*;
 
-#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
+#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd)]
 pub(crate) struct Namepath<'src>(Vec<Name<'src>>);
 
 impl<'src> Namepath<'src> {
@@ -14,6 +14,28 @@ impl<'src> Namepath<'src> {
       spaced: true,
     }
   }
+
+  pub(crate) fn push(&mut self, name: Name<'src>) {
+    self.0.push(name);
+  }
+
+  pub(crate) fn last(&self) -> &Name<'src> {
+    self.0.last().unwrap()
+  }
+
+  pub(crate) fn split_last(&self) -> (&Name<'src>, &[Name<'src>]) {
+    self.0.split_last().unwrap()
+  }
+
+  #[cfg(test)]
+  pub(crate) fn iter(&self) -> slice::Iter<'_, Name<'src>> {
+    self.0.iter()
+  }
+
+  #[cfg(test)]
+  pub(crate) fn len(&self) -> usize {
+    self.0.len()
+  }
 }
 
 impl Display for Namepath<'_> {
diff --git a/src/node.rs b/src/node.rs
index c39dc074..64607824 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -63,11 +63,26 @@ impl<'src> Node<'src> for Item<'src> {
   }
 }
 
-impl<'src> Node<'src> for Alias<'src, Name<'src>> {
+impl<'src> Node<'src> for Namepath<'src> {
   fn tree(&self) -> Tree<'src> {
+    match self.len() {
+      1 => Tree::atom(self.last().lexeme()),
+      _ => Tree::list(
+        self
+          .iter()
+          .map(|name| Tree::atom(Cow::Borrowed(name.lexeme()))),
+      ),
+    }
+  }
+}
+
+impl<'src> Node<'src> for Alias<'src, Namepath<'src>> {
+  fn tree(&self) -> Tree<'src> {
+    let target = self.target.tree();
+
     Tree::atom(Keyword::Alias.lexeme())
       .push(self.name.lexeme())
-      .push(self.target.lexeme())
+      .push(target)
   }
 }
 
diff --git a/src/parser.rs b/src/parser.rs
index 1767ef9b..a72003bf 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -485,11 +485,11 @@ impl<'run, 'src> Parser<'run, 'src> {
   fn parse_alias(
     &mut self,
     attributes: AttributeSet<'src>,
-  ) -> CompileResult<'src, Alias<'src, Name<'src>>> {
+  ) -> CompileResult<'src, Alias<'src, Namepath<'src>>> {
     self.presume_keyword(Keyword::Alias)?;
     let name = self.parse_name()?;
     self.presume_any(&[Equals, ColonEquals])?;
-    let target = self.parse_name()?;
+    let target = self.parse_namepath()?;
     self.expect_eol()?;
 
     attributes.ensure_valid_attributes("Alias", *name, &[AttributeDiscriminant::Private])?;
@@ -863,6 +863,19 @@ impl<'run, 'src> Parser<'run, 'src> {
     self.expect(Identifier).map(Name::from_identifier)
   }
 
+  /// Parse a path of `::` separated names
+  fn parse_namepath(&mut self) -> CompileResult<'src, Namepath<'src>> {
+    let first = self.parse_name()?;
+    let mut path = Namepath::from(first);
+
+    while self.accepted(ColonColon)? {
+      let name = self.parse_name()?;
+      path.push(name);
+    }
+
+    Ok(path)
+  }
+
   /// Parse sequence of comma-separated expressions
   fn parse_sequence(&mut self) -> CompileResult<'src, Vec<Expression<'src>>> {
     self.presume(ParenL)?;
@@ -1351,6 +1364,12 @@ mod tests {
     tree: (justfile (alias t test)),
   }
 
+  test! {
+    name: alias_module_path,
+    text: "alias fbb := foo::bar::baz",
+    tree: (justfile (alias fbb (foo bar baz))),
+  }
+
   test! {
     name: single_argument_attribute_shorthand,
     text: "[group: 'foo']\nbar:",
@@ -2424,7 +2443,7 @@ mod tests {
     line:   0,
     column: 17,
     width:  3,
-    kind:   UnexpectedToken { expected: vec![Comment, Eof, Eol], found: Identifier },
+    kind:   UnexpectedToken { expected: vec![ColonColon, Comment, Eof, Eol], found: Identifier },
   }
 
   error! {
@@ -2437,6 +2456,26 @@ mod tests {
     kind:   UnexpectedToken {expected: vec![Identifier], found:Eol},
   }
 
+  error! {
+    name:   alias_syntax_colon_end,
+    input:  "alias foo := bar::\n",
+    offset: 18,
+    line:   0,
+    column: 18,
+    width:  1,
+    kind:   UnexpectedToken {expected: vec![Identifier], found:Eol},
+  }
+
+  error! {
+    name:   alias_syntax_single_colon,
+    input:  "alias foo := bar:baz",
+    offset: 16,
+    line:   0,
+    column: 16,
+    width:  1,
+    kind:   UnexpectedToken {expected: vec![ColonColon, Comment, Eof, Eol], found:Colon},
+  }
+
   error! {
     name:   missing_colon,
     input:  "a b c\nd e f",
diff --git a/src/token_kind.rs b/src/token_kind.rs
index be8af19f..b7abf2fc 100644
--- a/src/token_kind.rs
+++ b/src/token_kind.rs
@@ -15,6 +15,7 @@ pub(crate) enum TokenKind {
   BracketR,
   ByteOrderMark,
   Colon,
+  ColonColon,
   ColonEquals,
   Comma,
   Comment,
@@ -60,6 +61,7 @@ impl Display for TokenKind {
         BracketR => "']'",
         ByteOrderMark => "byte order mark",
         Colon => "':'",
+        ColonColon => "'::'",
         ColonEquals => "':='",
         Comma => "','",
         Comment => "comment",


Test patch:
diff --git a/tests/alias.rs b/tests/alias.rs
new file mode 100644
index 00000000..6885621c
--- /dev/null
+++ b/tests/alias.rs
@@ -0,0 +1,68 @@
+use super::*;
+
+#[test]
+fn alias_nested_module() {
+  Test::new()
+    .write("foo.just", "mod bar\nbaz: \n @echo FOO")
+    .write("bar.just", "baz:\n @echo BAZ")
+    .justfile(
+      "
+      mod foo
+
+      alias b := foo::bar::baz
+
+      baz:
+        @echo 'HERE'
+      ",
+    )
+    .arg("b")
+    .stdout("BAZ\n")
+    .run();
+}
+
+#[test]
+fn unknown_nested_alias() {
+  Test::new()
+    .write("foo.just", "baz: \n @echo FOO")
+    .justfile(
+      "
+      mod foo
+
+      alias b := foo::bar::baz
+      ",
+    )
+    .arg("b")
+    .stderr(
+      "\
+        error: Alias `b` has an unknown target `foo::bar::baz`
+ ——▶ justfile:3:7
+  │
+3 │ alias b := foo::bar::baz
+  │       ^
+",
+    )
+    .status(EXIT_FAILURE)
+    .run();
+}
+
+#[test]
+fn alias_in_submodule() {
+  Test::new()
+    .write(
+      "foo.just",
+      "
+alias b := bar
+
+bar:
+  @echo BAR
+",
+    )
+    .justfile(
+      "
+        mod foo
+      ",
+    )
+    .arg("foo::b")
+    .stdout("BAR\n")
+    .run();
+}
diff --git a/tests/lib.rs b/tests/lib.rs
index 04133b9f..fb8d6d01 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -36,6 +36,7 @@ fn default<T: Default>() -> T {
 #[macro_use]
 mod test;
 
+mod alias;
 mod alias_style;
 mod allow_duplicate_recipes;
 mod allow_duplicate_variables;