diff --git a/Cargo.lock b/Cargo.lock index aa03e8f..9c28714 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/README.adoc b/README.adoc index fa2b7b4..d6df2c8 100644 --- a/README.adoc +++ b/README.adoc @@ -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`: diff --git a/clippy.toml b/clippy.toml index c65f45a..5616f26 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,3 +1,3 @@ -cyclomatic-complexity-threshold = 1337 +cognitive-complexity-threshold = 1337 doc-valid-idents = ["FreeBSD"] diff --git a/src/alias.rs b/src/alias.rs new file mode 100644 index 0000000..35ba6c6 --- /dev/null +++ b/src/alias.rs @@ -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) + } +} diff --git a/src/alias_resolver.rs b/src/alias_resolver.rs new file mode 100644 index 0000000..bc71136 --- /dev/null +++ b/src/alias_resolver.rs @@ -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(()) + } +} diff --git a/src/common.rs b/src/common.rs index d0db758..eaa0d9a 100644 --- a/src/common.rs +++ b/src/common.rs @@ -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; diff --git a/src/compilation_error.rs b/src/compilation_error.rs index 01383bf..545d10f 100644 --- a/src/compilation_error.rs +++ b/src/compilation_error.rs @@ -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, 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, diff --git a/src/cooked_string.rs b/src/cooked_string.rs index 52a5cff..a691221 100644 --- a/src/cooked_string.rs +++ b/src/cooked_string.rs @@ -29,7 +29,7 @@ impl<'a> CookedString<'a> { other => { return Err( token.error(CompilationErrorKind::InvalidEscapeSequence { character: other }), - ) + ); } } escape = false; diff --git a/src/justfile.rs b/src/justfile.rs index 893eb4c..63dff63 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -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; diff --git a/src/lexer.rs b/src/lexer.rs index abc2ea0..6f10282 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -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)); }; diff --git a/src/lib.rs b/src/lib.rs index 182aec4..b6fdd91 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,8 @@ pub mod fuzzing; #[macro_use] mod die; +mod alias; +mod alias_resolver; mod assignment_evaluator; mod assignment_resolver; mod color; diff --git a/src/parser.rs b/src/parser.rs index e6ec475..35e8fd7 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -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", diff --git a/src/range_ext.rs b/src/range_ext.rs index e5fa976..3de2c16 100644 --- a/src/range_ext.rs +++ b/src/range_ext.rs @@ -1,15 +1,24 @@ use common::*; pub trait RangeExt { - fn range_contains(&self, i: T) -> bool; + fn range_contains(&self, i: &T) -> bool; } impl RangeExt for Range 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 RangeExt for RangeInclusive +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)); } } diff --git a/src/recipe.rs b/src/recipe.rs index 07bff18..623b0d3 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -45,8 +45,8 @@ pub struct RecipeContext<'a> { } impl<'a> Recipe<'a> { - pub fn argument_range(&self) -> Range { - self.min_arguments()..self.max_arguments() + 1 + pub fn argument_range(&self) -> RangeInclusive { + 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, - }) + }); } }; } diff --git a/src/recipe_resolver.rs b/src/recipe_resolver.rs index b624d3e..53c87d6 100644 --- a/src/recipe_resolver.rs +++ b/src/recipe_resolver.rs @@ -113,7 +113,7 @@ impl<'a, 'b> RecipeResolver<'a, 'b> { return Err(dependency_token.error(UnknownDependency { recipe: recipe.name, unknown: dependency_token.lexeme, - })) + })); } } } diff --git a/src/run.rs b/src/run.rs index 8a4592e..b074bec 100644 --- a/src/run.rs +++ b/src/run.rs @@ -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); } diff --git a/tests/integration.rs b/tests/integration.rs index 044c848..5c8a23e 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -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",