Add recipe aliases (#390)

Recipe aliases may be defined with `alias f = foo`, allowing recipes to be called by shorter names on the command line.
This commit is contained in:
ryloric 2019-04-12 01:00:29 +05:30 committed by Casey Rodarmor
parent 37639d68d7
commit f64f07a0cc
17 changed files with 491 additions and 44 deletions

2
Cargo.lock generated
View File

@ -1,3 +1,5 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
[[package]]
name = "aho-corasick"
version = "0.6.4"

View File

@ -198,6 +198,24 @@ $ just --summary
build test deploy lint
```
=== Aliases
Aliases allow recipes to be invoked with alternative names:
```make
alias b = build
build:
echo 'Building!'
```
```sh
$ just b
build
echo 'Building!'
Building!
```
=== Documentation Comments
Comments immediately preceding a recipe will appear in `just --list`:

View File

@ -1,3 +1,3 @@
cyclomatic-complexity-threshold = 1337
cognitive-complexity-threshold = 1337
doc-valid-idents = ["FreeBSD"]

13
src/alias.rs Normal file
View File

@ -0,0 +1,13 @@
use common::*;
pub struct Alias<'a> {
pub name: &'a str,
pub target: &'a str,
pub line_number: usize,
}
impl<'a> Display for Alias<'a> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "alias {} = {}", self.name, self.target)
}
}

58
src/alias_resolver.rs Normal file
View File

@ -0,0 +1,58 @@
use common::*;
use CompilationErrorKind::*;
pub struct AliasResolver<'a, 'b>
where
'a: 'b,
{
aliases: &'b Map<&'a str, Alias<'a>>,
recipes: &'b Map<&'a str, Recipe<'a>>,
alias_tokens: &'b Map<&'a str, Token<'a>>,
}
impl<'a: 'b, 'b> AliasResolver<'a, 'b> {
pub fn resolve_aliases(
aliases: &Map<&'a str, Alias<'a>>,
recipes: &Map<&'a str, Recipe<'a>>,
alias_tokens: &Map<&'a str, Token<'a>>,
) -> CompilationResult<'a, ()> {
let resolver = AliasResolver {
aliases,
recipes,
alias_tokens,
};
resolver.resolve()?;
Ok(())
}
fn resolve(&self) -> CompilationResult<'a, ()> {
for alias in self.aliases.values() {
self.resolve_alias(alias)?;
}
Ok(())
}
fn resolve_alias(&self, alias: &Alias<'a>) -> CompilationResult<'a, ()> {
let token = self.alias_tokens.get(&alias.name).unwrap();
// Make sure the alias doesn't conflict with any recipe
if let Some(recipe) = self.recipes.get(alias.name) {
return Err(token.error(AliasShadowsRecipe {
alias: alias.name,
recipe_line: recipe.line_number,
}));
}
// Make sure the target recipe exists
if self.recipes.get(alias.target).is_none() {
return Err(token.error(UnknownAliasTarget {
alias: alias.name,
target: alias.target,
}));
}
Ok(())
}
}

View File

@ -2,7 +2,7 @@ pub use std::borrow::Cow;
pub use std::collections::{BTreeMap as Map, BTreeSet as Set};
pub use std::fmt::Display;
pub use std::io::prelude::*;
pub use std::ops::Range;
pub use std::ops::{Range, RangeInclusive};
pub use std::path::{Path, PathBuf};
pub use std::process::Command;
pub use std::sync::{Mutex, MutexGuard};
@ -13,6 +13,8 @@ pub use libc::{EXIT_FAILURE, EXIT_SUCCESS};
pub use regex::Regex;
pub use tempdir::TempDir;
pub use alias::Alias;
pub use alias_resolver::AliasResolver;
pub use assignment_evaluator::AssignmentEvaluator;
pub use assignment_resolver::AssignmentResolver;
pub use command_ext::CommandExt;

View File

@ -16,6 +16,10 @@ pub struct CompilationError<'a> {
#[derive(Debug, PartialEq)]
pub enum CompilationErrorKind<'a> {
AliasShadowsRecipe {
alias: &'a str,
recipe_line: usize,
},
CircularRecipeDependency {
recipe: &'a str,
circle: Vec<&'a str>,
@ -28,6 +32,10 @@ pub enum CompilationErrorKind<'a> {
recipe: &'a str,
dependency: &'a str,
},
DuplicateAlias {
alias: &'a str,
first: usize,
},
DuplicateDependency {
recipe: &'a str,
dependency: &'a str,
@ -78,6 +86,10 @@ pub enum CompilationErrorKind<'a> {
expected: Vec<TokenKind>,
found: TokenKind,
},
UnknownAliasTarget {
alias: &'a str,
target: &'a str,
},
UnknownDependency {
recipe: &'a str,
unknown: &'a str,
@ -99,6 +111,15 @@ impl<'a> Display for CompilationError<'a> {
write!(f, "{} {}", error.paint("error:"), message.prefix())?;
match self.kind {
AliasShadowsRecipe { alias, recipe_line } => {
writeln!(
f,
"Alias `{}` defined on `{}` shadows recipe defined on `{}`",
alias,
self.line + 1,
recipe_line + 1,
)?;
}
CircularRecipeDependency { recipe, ref circle } => {
if circle.len() == 2 {
writeln!(f, "Recipe `{}` depends on itself", recipe)?;
@ -153,6 +174,15 @@ impl<'a> Display for CompilationError<'a> {
} => {
writeln!(f, "Expected {}, but found {}", Or(expected), found)?;
}
DuplicateAlias { alias, first } => {
writeln!(
f,
"Alias `{}` first defined on line `{}` is redefined on line `{}`",
alias,
first + 1,
self.line + 1,
)?;
}
DuplicateDependency { recipe, dependency } => {
writeln!(
f,
@ -228,6 +258,9 @@ impl<'a> Display for CompilationError<'a> {
show_whitespace(found)
)?;
}
UnknownAliasTarget { alias, target } => {
writeln!(f, "Alias `{}` has an unknown target `{}`", alias, target)?;
}
UnknownDependency { recipe, unknown } => {
writeln!(
f,

View File

@ -29,7 +29,7 @@ impl<'a> CookedString<'a> {
other => {
return Err(
token.error(CompilationErrorKind::InvalidEscapeSequence { character: other }),
)
);
}
}
escape = false;

View File

@ -6,6 +6,7 @@ pub struct Justfile<'a> {
pub recipes: Map<&'a str, Recipe<'a>>,
pub assignments: Map<&'a str, Expression<'a>>,
pub exports: Set<&'a str>,
pub aliases: Map<&'a str, Alias<'a>>,
}
impl<'a> Justfile<'a> where {
@ -90,13 +91,22 @@ impl<'a> Justfile<'a> where {
let mut rest = arguments;
while let Some((argument, mut tail)) = rest.split_first() {
if let Some(recipe) = self.recipes.get(argument) {
let get_recipe = |name| {
if let Some(recipe) = self.recipes.get(name) {
Some(recipe)
} else if let Some(alias) = self.aliases.get(name) {
self.recipes.get(alias.target)
} else {
None
}
};
if let Some(recipe) = get_recipe(argument) {
if recipe.parameters.is_empty() {
grouped.push((recipe, &tail[0..0]));
} else {
let argument_range = recipe.argument_range();
let argument_count = cmp::min(tail.len(), recipe.max_arguments());
if !argument_range.range_contains(argument_count) {
if !argument_range.range_contains(&argument_count) {
return Err(RuntimeError::ArgumentCountMismatch {
recipe: recipe.name,
parameters: recipe.parameters.iter().collect(),
@ -161,7 +171,7 @@ impl<'a> Justfile<'a> where {
impl<'a> Display for Justfile<'a> {
fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
let mut items = self.recipes.len() + self.assignments.len();
let mut items = self.recipes.len() + self.assignments.len() + self.aliases.len();
for (name, expression) in &self.assignments {
if self.exports.contains(name) {
write!(f, "export ")?;
@ -172,6 +182,13 @@ impl<'a> Display for Justfile<'a> {
write!(f, "\n\n")?;
}
}
for alias in self.aliases.values() {
write!(f, "{}",alias)?;
items -= 1;
if items != 0 {
write!(f, "\n\n")?;
}
}
for recipe in self.recipes.values() {
write!(f, "{}", recipe)?;
items -= 1;

View File

@ -321,7 +321,7 @@ impl<'a> Lexer<'a> {
if escape || content_end >= self.rest.len() {
return Err(self.error(UnterminatedString));
}
(prefix, &self.rest[start..content_end + 1], StringToken)
(prefix, &self.rest[start..=content_end], StringToken)
} else {
return Err(self.error(UnknownStartOfToken));
};

View File

@ -27,6 +27,8 @@ pub mod fuzzing;
#[macro_use]
mod die;
mod alias;
mod alias_resolver;
mod assignment_evaluator;
mod assignment_resolver;
mod color;

View File

@ -11,6 +11,8 @@ pub struct Parser<'a> {
assignments: Map<&'a str, Expression<'a>>,
assignment_tokens: Map<&'a str, Token<'a>>,
exports: Set<&'a str>,
aliases: Map<&'a str, Alias<'a>>,
alias_tokens: Map<&'a str, Token<'a>>,
}
impl<'a> Parser<'a> {
@ -27,6 +29,8 @@ impl<'a> Parser<'a> {
assignments: empty(),
assignment_tokens: empty(),
exports: empty(),
aliases: empty(),
alias_tokens: empty(),
text,
}
}
@ -342,6 +346,41 @@ impl<'a> Parser<'a> {
Ok(())
}
fn alias(&mut self, name: Token<'a>) -> CompilationResult<'a, ()> {
// Make sure alias doesn't already exist
if let Some(alias) = self.aliases.get(name.lexeme) {
return Err(name.error(DuplicateAlias {
alias: alias.name,
first: alias.line_number,
}));
}
// Make sure the next token is of kind Name and keep it
let target = if let Some(next) = self.accept(Name) {
next.lexeme
} else {
let unexpected = self.tokens.next().unwrap();
return Err(self.unexpected_token(&unexpected, &[Name]));
};
// Make sure this is where the line or file ends without any unexpected tokens.
if let Some(token) = self.expect_eol() {
return Err(self.unexpected_token(&token, &[Eol, Eof]));
}
self.aliases.insert(
name.lexeme,
Alias {
name: name.lexeme,
line_number: name.line,
target,
},
);
self.alias_tokens.insert(name.lexeme, name);
Ok(())
}
pub fn justfile(mut self) -> CompilationResult<'a, Justfile<'a>> {
let mut doc = None;
loop {
@ -380,6 +419,16 @@ impl<'a> Parser<'a> {
self.recipe(&token, doc, false)?;
doc = None;
}
} else if token.lexeme == "alias" {
let next = self.tokens.next().unwrap();
if next.kind == Name && self.accepted(Equals) {
self.alias(next)?;
doc = None;
} else {
self.tokens.put_back(next);
self.recipe(&token, doc, false)?;
doc = None;
}
} else if self.accepted(Equals) {
self.assignment(token, false)?;
doc = None;
@ -400,7 +449,7 @@ impl<'a> Parser<'a> {
kind: Internal {
message: "unexpected end of token stream".to_string(),
},
})
});
}
}
}
@ -435,12 +484,15 @@ impl<'a> Parser<'a> {
}
}
AliasResolver::resolve_aliases(&self.aliases, &self.recipes, &self.alias_tokens)?;
AssignmentResolver::resolve_assignments(&self.assignments, &self.assignment_tokens)?;
Ok(Justfile {
recipes: self.recipes,
assignments: self.assignments,
exports: self.exports,
aliases: self.aliases,
})
}
}
@ -532,6 +584,45 @@ export a = "hello"
r#"export a = "hello""#,
}
summary_test! {
parse_alias_after_target,
r#"
foo:
echo a
alias f = foo
"#,
r#"alias f = foo
foo:
echo a"#
}
summary_test! {
parse_alias_before_target,
r#"
alias f = foo
foo:
echo a
"#,
r#"alias f = foo
foo:
echo a"#
}
summary_test! {
parse_alias_with_comment,
r#"
alias f = foo #comment
foo:
echo a
"#,
r#"alias f = foo
foo:
echo a"#
}
summary_test! {
parse_complex,
"
@ -680,6 +771,66 @@ a:
{{env_var_or_default("foo" + "bar", "baz")}} {{env_var(env_var("baz"))}}"#,
}
compilation_error_test! {
name: duplicate_alias,
input: "alias foo = bar\nalias foo = baz",
index: 22,
line: 1,
column: 6,
width: Some(3),
kind: DuplicateAlias { alias: "foo", first: 0 },
}
compilation_error_test! {
name: alias_syntax_multiple_rhs,
input: "alias foo = bar baz",
index: 16,
line: 0,
column: 16,
width: Some(3),
kind: UnexpectedToken { expected: vec![Eol, Eof], found: Name },
}
compilation_error_test! {
name: alias_syntax_no_rhs,
input: "alias foo = \n",
index: 12,
line: 0,
column: 12,
width: Some(1),
kind: UnexpectedToken {expected: vec![Name], found:Eol},
}
compilation_error_test! {
name: unknown_alias_target,
input: "alias foo = bar\n",
index: 6,
line: 0,
column: 6,
width: Some(3),
kind: UnknownAliasTarget {alias: "foo", target: "bar"},
}
compilation_error_test! {
name: alias_shadows_recipe_before,
input: "bar: \n echo bar\nalias foo = bar\nfoo:\n echo foo",
index: 23,
line: 2,
column: 6,
width: Some(3),
kind: AliasShadowsRecipe {alias: "foo", recipe_line: 3},
}
compilation_error_test! {
name: alias_shadows_recipe_after,
input: "foo:\n echo foo\nalias foo = bar\nbar:\n echo bar",
index: 22,
line: 2,
column: 6,
width: Some(3),
kind: AliasShadowsRecipe { alias: "foo", recipe_line: 0 },
}
compilation_error_test! {
name: missing_colon,
input: "a b c\nd e f",

View File

@ -1,15 +1,24 @@
use common::*;
pub trait RangeExt<T> {
fn range_contains(&self, i: T) -> bool;
fn range_contains(&self, i: &T) -> bool;
}
impl<T> RangeExt<T> for Range<T>
where
T: PartialOrd + Copy,
{
fn range_contains(&self, i: T) -> bool {
i >= self.start && i < self.end
fn range_contains(&self, i: &T) -> bool {
i >= &self.start && i < &self.end
}
}
impl<T> RangeExt<T> for RangeInclusive<T>
where
T: PartialOrd + Copy,
{
fn range_contains(&self, i: &T) -> bool {
i >= self.start() && i <= self.end()
}
}
@ -19,10 +28,19 @@ mod test {
#[test]
fn range() {
assert!((0..1).range_contains(0));
assert!((10..20).range_contains(15));
assert!(!(0..0).range_contains(0));
assert!(!(1..10).range_contains(0));
assert!(!(1..10).range_contains(10));
assert!((0..1).range_contains(&0));
assert!((10..20).range_contains(&15));
assert!(!(0..0).range_contains(&0));
assert!(!(1..10).range_contains(&0));
assert!(!(1..10).range_contains(&10));
}
#[test]
fn range_inclusive() {
assert!((0..=10).range_contains(&0));
assert!((0..=10).range_contains(&7));
assert!((0..=10).range_contains(&10));
assert!(!(0..=10).range_contains(&11));
assert!(!(5..=10).range_contains(&4));
}
}

View File

@ -45,8 +45,8 @@ pub struct RecipeContext<'a> {
}
impl<'a> Recipe<'a> {
pub fn argument_range(&self) -> Range<usize> {
self.min_arguments()..self.max_arguments() + 1
pub fn argument_range(&self) -> RangeInclusive<usize> {
self.min_arguments()..=self.max_arguments()
}
pub fn min_arguments(&self) -> usize {
@ -94,7 +94,7 @@ impl<'a> Recipe<'a> {
None => {
return Err(RuntimeError::Internal {
message: "missing parameter without default".to_string(),
})
});
}
}
} else if parameter.variadic {
@ -226,7 +226,7 @@ impl<'a> Recipe<'a> {
command: interpreter.to_string(),
argument: argument.map(String::from),
io_error,
})
});
}
};
} else {
@ -305,7 +305,7 @@ impl<'a> Recipe<'a> {
return Err(RuntimeError::IoError {
recipe: self.name,
io_error,
})
});
}
};
}

View File

@ -113,7 +113,7 @@ impl<'a, 'b> RecipeResolver<'a, 'b> {
return Err(dependency_token.error(UnknownDependency {
recipe: recipe.name,
unknown: dependency_token.lexeme,
}))
}));
}
}
}

View File

@ -356,6 +356,17 @@ pub fn run() {
}
if matches.is_present("LIST") {
// Construct a target to alias map.
let mut recipe_aliases: Map<&str, Vec<&str>> = Map::new();
for alias in justfile.aliases.values() {
if !recipe_aliases.contains_key(alias.target) {
recipe_aliases.insert(alias.target, vec![alias.name]);
} else {
let aliases = recipe_aliases.get_mut(alias.target).unwrap();
aliases.push(alias.name);
}
}
let mut line_widths: Map<&str, usize> = Map::new();
for (name, recipe) in &justfile.recipes {
@ -363,14 +374,16 @@ pub fn run() {
continue;
}
let mut line_width = UnicodeWidthStr::width(*name);
for name in iter::once(name).chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) {
let mut line_width = UnicodeWidthStr::width(*name);
for parameter in &recipe.parameters {
line_width += UnicodeWidthStr::width(format!(" {}", parameter).as_str());
}
for parameter in &recipe.parameters {
line_width += UnicodeWidthStr::width(format!(" {}", parameter).as_str());
}
if line_width <= 30 {
line_widths.insert(name, line_width);
if line_width <= 30 {
line_widths.insert(name, line_width);
}
}
}
@ -378,30 +391,47 @@ pub fn run() {
let doc_color = color.stdout().doc();
println!("Available recipes:");
for (name, recipe) in &justfile.recipes {
if recipe.private {
continue;
}
print!(" {}", name);
for parameter in &recipe.parameters {
if color.stdout().active() {
print!(" {:#}", parameter);
} else {
print!(" {}", parameter);
let alias_doc = format!("alias for `{}`", recipe.name);
for (i, name) in iter::once(name).chain(recipe_aliases.get(name).unwrap_or(&Vec::new())).enumerate() {
print!(" {}", name);
for parameter in &recipe.parameters {
if color.stdout().active() {
print!(" {:#}", parameter);
} else {
print!(" {}", parameter);
}
}
}
if let Some(doc) = recipe.doc {
print!(
" {:padding$}{} {}",
"",
doc_color.paint("#"),
doc_color.paint(doc),
padding =
// Declaring this outside of the nested loops will probably be more efficient, but
// it creates all sorts of lifetime issues with variables inside the loops.
// If this is inlined like the docs say, it shouldn't make any difference.
let print_doc = |doc| {
print!(
" {:padding$}{} {}",
"",
doc_color.paint("#"),
doc_color.paint(doc),
padding =
max_line_width.saturating_sub(line_widths.get(name).cloned().unwrap_or(max_line_width))
);
);
};
match (i, recipe.doc) {
(0, Some(doc)) => print_doc(doc),
(0, None) => (),
_ => print_doc(&alias_doc),
}
println!();
}
println!();
}
process::exit(EXIT_SUCCESS);
}

View File

@ -101,6 +101,109 @@ fn integration_test(
}
}
integration_test! {
name: alias_listing,
justfile: "foo:\n echo foo\nalias f = foo",
args: ("--list"),
stdout: "Available recipes:
foo
f # alias for `foo`
",
stderr: "",
status: EXIT_SUCCESS,
}
integration_test! {
name: alias_listing_multiple_aliases,
justfile: "foo:\n echo foo\nalias f = foo\nalias fo = foo",
args: ("--list"),
stdout: "Available recipes:
foo
f # alias for `foo`
fo # alias for `foo`
",
stderr: "",
status: EXIT_SUCCESS,
}
integration_test! {
name: alias_listing_parameters,
justfile: "foo PARAM='foo':\n echo {{PARAM}}\nalias f = foo",
args: ("--list"),
stdout: "Available recipes:
foo PARAM='foo'
f PARAM='foo' # alias for `foo`
",
stderr: "",
status: EXIT_SUCCESS,
}
integration_test! {
name: alias,
justfile: "foo:\n echo foo\nalias f = foo",
args: ("f"),
stdout: "foo\n",
stderr: "echo foo\n",
status: EXIT_SUCCESS,
}
integration_test! {
name: alias_with_parameters,
justfile: "foo value='foo':\n echo {{value}}\nalias f = foo",
args: ("f", "bar"),
stdout: "bar\n",
stderr: "echo bar\n",
status: EXIT_SUCCESS,
}
integration_test! {
name: alias_with_dependencies,
justfile: "foo:\n echo foo\nbar: foo\nalias b = bar",
args: ("b"),
stdout: "foo\n",
stderr: "echo foo\n",
status: EXIT_SUCCESS,
}
integration_test! {
name: duplicate_alias,
justfile: "alias foo = bar\nalias foo = baz\n",
args: (),
stdout: "" ,
stderr: "error: Alias `foo` first defined on line `1` is redefined on line `2`
|
2 | alias foo = baz
| ^^^
",
status: EXIT_FAILURE,
}
integration_test! {
name: unknown_alias_target,
justfile: "alias foo = bar\n",
args: (),
stdout: "",
stderr: "error: Alias `foo` has an unknown target `bar`
|
1 | alias foo = bar
| ^^^
",
status: EXIT_FAILURE,
}
integration_test! {
name: alias_shadows_recipe,
justfile: "bar:\n echo bar\nalias foo = bar\nfoo:\n echo foo",
args: (),
stdout: "",
stderr: "error: Alias `foo` defined on `3` shadows recipe defined on `4`
|
3 | alias foo = bar
| ^^^
",
status: EXIT_FAILURE,
}
integration_test! {
name: default,
justfile: "default:\n echo hello\nother: \n echo bar",