Optional modules and imports (#1797)

This commit is contained in:
Casey Rodarmor 2023-12-29 12:16:31 -08:00 committed by GitHub
parent 177bb682b8
commit e2c0d86bdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1046 additions and 352 deletions

View File

@ -43,12 +43,14 @@ grammar
``` ```
justfile : item* EOF justfile : item* EOF
item : recipe item : alias
| alias
| assignment | assignment
| export
| setting
| eol | eol
| export
| import
| module
| recipe
| setting
eol : NEWLINE eol : NEWLINE
| COMMENT NEWLINE | COMMENT NEWLINE
@ -72,6 +74,10 @@ setting : 'set' 'allow-duplicate-recipes' boolean?
| 'set' 'windows-powershell' boolean? | 'set' 'windows-powershell' boolean?
| 'set' 'windows-shell' ':=' '[' string (',' string)* ','? ']' | 'set' 'windows-shell' ':=' '[' string (',' string)* ','? ']'
import : 'import' '?'? string?
module : 'mod' '?'? NAME string?
boolean : ':=' ('true' | 'false') boolean : ':=' ('true' | 'false')
expression : 'if' condition '{' expression '}' 'else' '{' expression '}' expression : 'if' condition '{' expression '}' 'else' '{' expression '}'

929
README.md

File diff suppressed because it is too large Load Diff

View File

@ -38,7 +38,7 @@ impl<'src> Analyzer<'src> {
let mut define = |name: Name<'src>, let mut define = |name: Name<'src>,
second_type: &'static str, second_type: &'static str,
duplicates_allowed: bool| duplicates_allowed: bool|
-> CompileResult<'src, ()> { -> CompileResult<'src> {
if let Some((first_type, original)) = definitions.get(name.lexeme()) { if let Some((first_type, original)) = definitions.get(name.lexeme()) {
if !(*first_type == second_type && duplicates_allowed) { if !(*first_type == second_type && duplicates_allowed) {
let (original, redefinition) = if name.line < original.line { let (original, redefinition) = if name.line < original.line {
@ -75,17 +75,18 @@ impl<'src> Analyzer<'src> {
} }
Item::Comment(_) => (), Item::Comment(_) => (),
Item::Import { absolute, .. } => { Item::Import { absolute, .. } => {
stack.push(asts.get(absolute.as_ref().unwrap()).unwrap()); if let Some(absolute) = absolute {
stack.push(asts.get(absolute).unwrap());
}
} }
Item::Mod { absolute, name, .. } => { Item::Module { absolute, name, .. } => {
define(*name, "module", false)?; if let Some(absolute) = absolute {
modules.insert( define(*name, "module", false)?;
name.to_string(), modules.insert(
( name.to_string(),
*name, (*name, Self::analyze(loaded, paths, asts, absolute)?),
Self::analyze(loaded, paths, asts, absolute.as_ref().unwrap())?, );
), }
);
} }
Item::Recipe(recipe) => { Item::Recipe(recipe) => {
if recipe.enabled() { if recipe.enabled() {
@ -153,7 +154,7 @@ impl<'src> Analyzer<'src> {
}) })
} }
fn analyze_recipe(recipe: &UnresolvedRecipe<'src>) -> CompileResult<'src, ()> { fn analyze_recipe(recipe: &UnresolvedRecipe<'src>) -> CompileResult<'src> {
let mut parameters = BTreeSet::new(); let mut parameters = BTreeSet::new();
let mut passed_default = false; let mut passed_default = false;
@ -198,7 +199,7 @@ impl<'src> Analyzer<'src> {
Ok(()) Ok(())
} }
fn analyze_assignment(&self, assignment: &Assignment<'src>) -> CompileResult<'src, ()> { fn analyze_assignment(&self, assignment: &Assignment<'src>) -> CompileResult<'src> {
if self.assignments.contains_key(assignment.name.lexeme()) { if self.assignments.contains_key(assignment.name.lexeme()) {
return Err(assignment.name.token().error(DuplicateVariable { return Err(assignment.name.token().error(DuplicateVariable {
variable: assignment.name.lexeme(), variable: assignment.name.lexeme(),
@ -207,7 +208,7 @@ impl<'src> Analyzer<'src> {
Ok(()) Ok(())
} }
fn analyze_alias(alias: &Alias<'src, Name<'src>>) -> CompileResult<'src, ()> { fn analyze_alias(alias: &Alias<'src, Name<'src>>) -> CompileResult<'src> {
let name = alias.name.lexeme(); let name = alias.name.lexeme();
for attr in &alias.attributes { for attr in &alias.attributes {
@ -222,7 +223,7 @@ impl<'src> Analyzer<'src> {
Ok(()) Ok(())
} }
fn analyze_set(&self, set: &Set<'src>) -> CompileResult<'src, ()> { fn analyze_set(&self, set: &Set<'src>) -> CompileResult<'src> {
if let Some(original) = self.sets.get(set.name.lexeme()) { if let Some(original) = self.sets.get(set.name.lexeme()) {
return Err(set.name.error(DuplicateSet { return Err(set.name.error(DuplicateSet {
setting: original.name.lexeme(), setting: original.name.lexeme(),

View File

@ -9,7 +9,7 @@ pub(crate) struct AssignmentResolver<'src: 'run, 'run> {
impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> { impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
pub(crate) fn resolve_assignments( pub(crate) fn resolve_assignments(
assignments: &'run Table<'src, Assignment<'src>>, assignments: &'run Table<'src, Assignment<'src>>,
) -> CompileResult<'src, ()> { ) -> CompileResult<'src> {
let mut resolver = Self { let mut resolver = Self {
stack: Vec::new(), stack: Vec::new(),
evaluated: BTreeSet::new(), evaluated: BTreeSet::new(),
@ -23,7 +23,7 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
Ok(()) Ok(())
} }
fn resolve_assignment(&mut self, name: &'src str) -> CompileResult<'src, ()> { fn resolve_assignment(&mut self, name: &'src str) -> CompileResult<'src> {
if self.evaluated.contains(name) { if self.evaluated.contains(name) {
return Ok(()); return Ok(());
} }
@ -52,7 +52,7 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
Ok(()) Ok(())
} }
fn resolve_expression(&mut self, expression: &Expression<'src>) -> CompileResult<'src, ()> { fn resolve_expression(&mut self, expression: &Expression<'src>) -> CompileResult<'src> {
match expression { match expression {
Expression::Variable { name } => { Expression::Variable { name } => {
let variable = name.lexeme(); let variable = name.lexeme();

View File

@ -27,10 +27,11 @@ impl Compiler {
for item in &mut ast.items { for item in &mut ast.items {
match item { match item {
Item::Mod { Item::Module {
name,
absolute, absolute,
path, name,
optional,
relative,
} => { } => {
if !unstable { if !unstable {
return Err(Error::Unstable { return Err(Error::Unstable {
@ -40,29 +41,49 @@ impl Compiler {
let parent = current.parent().unwrap(); let parent = current.parent().unwrap();
let import = if let Some(path) = path { let import = if let Some(relative) = relative {
parent.join(Self::expand_tilde(&path.cooked)?) let path = parent.join(Self::expand_tilde(&relative.cooked)?);
if path.is_file() {
Some(path)
} else {
None
}
} else { } else {
Self::find_module_file(parent, *name)? Self::find_module_file(parent, *name)?
}; };
if srcs.contains_key(&import) { if let Some(import) = import {
return Err(Error::CircularImport { current, import }); if srcs.contains_key(&import) {
return Err(Error::CircularImport { current, import });
}
*absolute = Some(import.clone());
stack.push((import, depth + 1));
} else if !*optional {
return Err(Error::MissingModuleFile { module: *name });
} }
*absolute = Some(import.clone());
stack.push((import, depth + 1));
} }
Item::Import { relative, absolute } => { Item::Import {
relative,
absolute,
optional,
path,
} => {
let import = current let import = current
.parent() .parent()
.unwrap() .unwrap()
.join(Self::expand_tilde(&relative.cooked)?) .join(Self::expand_tilde(&relative.cooked)?)
.lexiclean(); .lexiclean();
if srcs.contains_key(&import) {
return Err(Error::CircularImport { current, import }); if import.is_file() {
if srcs.contains_key(&import) {
return Err(Error::CircularImport { current, import });
}
*absolute = Some(import.clone());
stack.push((import, depth + 1));
} else if !*optional {
return Err(Error::MissingImportFile { path: *path });
} }
*absolute = Some(import.clone());
stack.push((import, depth + 1));
} }
_ => {} _ => {}
} }
@ -81,7 +102,7 @@ impl Compiler {
}) })
} }
fn find_module_file<'src>(parent: &Path, module: Name<'src>) -> RunResult<'src, PathBuf> { fn find_module_file<'src>(parent: &Path, module: Name<'src>) -> RunResult<'src, Option<PathBuf>> {
let mut candidates = vec![format!("{module}.just"), format!("{module}/mod.just")] let mut candidates = vec![format!("{module}.just"), format!("{module}/mod.just")]
.into_iter() .into_iter()
.filter(|path| parent.join(path).is_file()) .filter(|path| parent.join(path).is_file())
@ -112,8 +133,8 @@ impl Compiler {
} }
match candidates.as_slice() { match candidates.as_slice() {
[] => Err(Error::MissingModuleFile { module }), [] => Ok(None),
[file] => Ok(parent.join(file).lexiclean()), [file] => Ok(Some(parent.join(file).lexiclean())),
found => Err(Error::AmbiguousModuleFile { found => Err(Error::AmbiguousModuleFile {
found: found.into(), found: found.into(),
module, module,

View File

@ -110,6 +110,9 @@ pub(crate) enum Error<'src> {
path: PathBuf, path: PathBuf,
io_error: io::Error, io_error: io::Error,
}, },
MissingImportFile {
path: Token<'src>,
},
MissingModuleFile { MissingModuleFile {
module: Name<'src>, module: Name<'src>,
}, },
@ -181,6 +184,7 @@ impl<'src> Error<'src> {
Self::Backtick { token, .. } => Some(*token), Self::Backtick { token, .. } => Some(*token),
Self::Compile { compile_error } => Some(compile_error.context()), Self::Compile { compile_error } => Some(compile_error.context()),
Self::FunctionCall { function, .. } => Some(function.token()), Self::FunctionCall { function, .. } => Some(function.token()),
Self::MissingImportFile { path } => Some(*path),
_ => None, _ => None,
} }
} }
@ -369,6 +373,7 @@ impl<'src> ColorDisplay for Error<'src> {
let path = path.display(); let path = path.display();
write!(f, "Failed to read justfile at `{path}`: {io_error}")?; write!(f, "Failed to read justfile at `{path}`: {io_error}")?;
} }
MissingImportFile { .. } => write!(f, "Could not find source file for import.")?,
MissingModuleFile { module } => write!(f, "Could not find source file for module `{module}`.")?, MissingModuleFile { module } => write!(f, "Could not find source file for module `{module}`.")?,
NoChoosableRecipes => write!(f, "Justfile contains no choosable recipes.")?, NoChoosableRecipes => write!(f, "Justfile contains no choosable recipes.")?,
NoDefaultRecipe => write!(f, "Justfile contains no default recipe.")?, NoDefaultRecipe => write!(f, "Justfile contains no default recipe.")?,

View File

@ -7,13 +7,16 @@ pub(crate) enum Item<'src> {
Assignment(Assignment<'src>), Assignment(Assignment<'src>),
Comment(&'src str), Comment(&'src str),
Import { Import {
absolute: Option<PathBuf>,
optional: bool,
path: Token<'src>,
relative: StringLiteral<'src>, relative: StringLiteral<'src>,
absolute: Option<PathBuf>,
}, },
Mod { Module {
name: Name<'src>,
absolute: Option<PathBuf>, absolute: Option<PathBuf>,
path: Option<StringLiteral<'src>>, name: Name<'src>,
optional: bool,
relative: Option<StringLiteral<'src>>,
}, },
Recipe(UnresolvedRecipe<'src>), Recipe(UnresolvedRecipe<'src>),
Set(Set<'src>), Set(Set<'src>),
@ -25,11 +28,32 @@ impl<'src> Display for Item<'src> {
Item::Alias(alias) => write!(f, "{alias}"), Item::Alias(alias) => write!(f, "{alias}"),
Item::Assignment(assignment) => write!(f, "{assignment}"), Item::Assignment(assignment) => write!(f, "{assignment}"),
Item::Comment(comment) => write!(f, "{comment}"), Item::Comment(comment) => write!(f, "{comment}"),
Item::Import { relative, .. } => write!(f, "import {relative}"), Item::Import {
Item::Mod { name, path, .. } => { relative, optional, ..
write!(f, "mod {name}")?; } => {
write!(f, "import")?;
if let Some(path) = path { if *optional {
write!(f, "?")?;
}
write!(f, " {relative}")
}
Item::Module {
name,
relative,
optional,
..
} => {
write!(f, "mod")?;
if *optional {
write!(f, "?")?;
}
write!(f, " {name}")?;
if let Some(path) = relative {
write!(f, " {path}")?; write!(f, " {path}")?;
} }

View File

@ -116,7 +116,7 @@ impl<'src> Justfile<'src> {
search: &Search, search: &Search,
overrides: &BTreeMap<String, String>, overrides: &BTreeMap<String, String>,
arguments: &[String], arguments: &[String],
) -> RunResult<'src, ()> { ) -> RunResult<'src> {
let unknown_overrides = overrides let unknown_overrides = overrides
.keys() .keys()
.filter(|name| !self.assignments.contains_key(name.as_str())) .filter(|name| !self.assignments.contains_key(name.as_str()))
@ -393,7 +393,7 @@ impl<'src> Justfile<'src> {
dotenv: &BTreeMap<String, String>, dotenv: &BTreeMap<String, String>,
search: &Search, search: &Search,
ran: &mut BTreeSet<Vec<String>>, ran: &mut BTreeSet<Vec<String>>,
) -> RunResult<'src, ()> { ) -> RunResult<'src> {
let mut invocation = vec![recipe.name().to_owned()]; let mut invocation = vec![recipe.name().to_owned()];
for argument in arguments { for argument in arguments {
invocation.push((*argument).to_string()); invocation.push((*argument).to_string());

View File

@ -75,7 +75,7 @@ impl<'src> Lexer<'src> {
/// Advance over the character in `self.next`, updating `self.token_end` /// Advance over the character in `self.next`, updating `self.token_end`
/// accordingly. /// accordingly.
fn advance(&mut self) -> CompileResult<'src, ()> { fn advance(&mut self) -> CompileResult<'src> {
match self.next { match self.next {
Some(c) => { Some(c) => {
let len_utf8 = c.len_utf8(); let len_utf8 = c.len_utf8();
@ -97,7 +97,7 @@ impl<'src> Lexer<'src> {
} }
/// Advance over N characters. /// Advance over N characters.
fn skip(&mut self, n: usize) -> CompileResult<'src, ()> { fn skip(&mut self, n: usize) -> CompileResult<'src> {
for _ in 0..n { for _ in 0..n {
self.advance()?; self.advance()?;
} }
@ -124,7 +124,7 @@ impl<'src> Lexer<'src> {
} }
} }
fn presume(&mut self, c: char) -> CompileResult<'src, ()> { fn presume(&mut self, c: char) -> CompileResult<'src> {
if !self.next_is(c) { if !self.next_is(c) {
return Err(self.internal_error(format!("Lexer presumed character `{c}`"))); return Err(self.internal_error(format!("Lexer presumed character `{c}`")));
} }
@ -134,7 +134,7 @@ impl<'src> Lexer<'src> {
Ok(()) Ok(())
} }
fn presume_str(&mut self, s: &str) -> CompileResult<'src, ()> { fn presume_str(&mut self, s: &str) -> CompileResult<'src> {
for c in s.chars() { for c in s.chars() {
self.presume(c)?; self.presume(c)?;
} }
@ -328,7 +328,7 @@ impl<'src> Lexer<'src> {
} }
/// Handle blank lines and indentation /// Handle blank lines and indentation
fn lex_line_start(&mut self) -> CompileResult<'src, ()> { fn lex_line_start(&mut self) -> CompileResult<'src> {
enum Indentation<'src> { enum Indentation<'src> {
// Line only contains whitespace // Line only contains whitespace
Blank, Blank,
@ -478,7 +478,7 @@ impl<'src> Lexer<'src> {
} }
/// Lex token beginning with `start` outside of a recipe body /// Lex token beginning with `start` outside of a recipe body
fn lex_normal(&mut self, start: char) -> CompileResult<'src, ()> { fn lex_normal(&mut self, start: char) -> CompileResult<'src> {
match start { match start {
' ' | '\t' => self.lex_whitespace(), ' ' | '\t' => self.lex_whitespace(),
'!' if self.rest().starts_with("!include") => Err(self.error(Include)), '!' if self.rest().starts_with("!include") => Err(self.error(Include)),
@ -493,10 +493,11 @@ impl<'src> Lexer<'src> {
',' => self.lex_single(Comma), ',' => self.lex_single(Comma),
'/' => self.lex_single(Slash), '/' => self.lex_single(Slash),
':' => self.lex_colon(), ':' => self.lex_colon(),
'\\' => self.lex_escape(),
'=' => self.lex_choices('=', &[('=', EqualsEquals), ('~', EqualsTilde)], Equals), '=' => self.lex_choices('=', &[('=', EqualsEquals), ('~', EqualsTilde)], Equals),
'?' => self.lex_single(QuestionMark),
'@' => self.lex_single(At), '@' => self.lex_single(At),
'[' => self.lex_delimiter(BracketL), '[' => self.lex_delimiter(BracketL),
'\\' => self.lex_escape(),
'\n' | '\r' => self.lex_eol(), '\n' | '\r' => self.lex_eol(),
'\u{feff}' => self.lex_single(ByteOrderMark), '\u{feff}' => self.lex_single(ByteOrderMark),
']' => self.lex_delimiter(BracketR), ']' => self.lex_delimiter(BracketR),
@ -516,7 +517,7 @@ impl<'src> Lexer<'src> {
&mut self, &mut self,
interpolation_start: Token<'src>, interpolation_start: Token<'src>,
start: char, start: char,
) -> CompileResult<'src, ()> { ) -> CompileResult<'src> {
if self.rest_starts_with("}}") { if self.rest_starts_with("}}") {
// end current interpolation // end current interpolation
if self.interpolation_stack.pop().is_none() { if self.interpolation_stack.pop().is_none() {
@ -539,7 +540,7 @@ impl<'src> Lexer<'src> {
} }
/// Lex token while in recipe body /// Lex token while in recipe body
fn lex_body(&mut self) -> CompileResult<'src, ()> { fn lex_body(&mut self) -> CompileResult<'src> {
enum Terminator { enum Terminator {
Newline, Newline,
NewlineCarriageReturn, NewlineCarriageReturn,
@ -602,14 +603,14 @@ impl<'src> Lexer<'src> {
} }
/// Lex a single-character token /// Lex a single-character token
fn lex_single(&mut self, kind: TokenKind) -> CompileResult<'src, ()> { fn lex_single(&mut self, kind: TokenKind) -> CompileResult<'src> {
self.advance()?; self.advance()?;
self.token(kind); self.token(kind);
Ok(()) Ok(())
} }
/// Lex a double-character token /// Lex a double-character token
fn lex_double(&mut self, kind: TokenKind) -> CompileResult<'src, ()> { fn lex_double(&mut self, kind: TokenKind) -> CompileResult<'src> {
self.advance()?; self.advance()?;
self.advance()?; self.advance()?;
self.token(kind); self.token(kind);
@ -624,7 +625,7 @@ impl<'src> Lexer<'src> {
first: char, first: char,
choices: &[(char, TokenKind)], choices: &[(char, TokenKind)],
otherwise: TokenKind, otherwise: TokenKind,
) -> CompileResult<'src, ()> { ) -> CompileResult<'src> {
self.presume(first)?; self.presume(first)?;
for (second, then) in choices { for (second, then) in choices {
@ -640,7 +641,7 @@ impl<'src> Lexer<'src> {
} }
/// Lex an opening or closing delimiter /// Lex an opening or closing delimiter
fn lex_delimiter(&mut self, kind: TokenKind) -> CompileResult<'src, ()> { fn lex_delimiter(&mut self, kind: TokenKind) -> CompileResult<'src> {
use Delimiter::*; use Delimiter::*;
match kind { match kind {
@ -669,7 +670,7 @@ impl<'src> Lexer<'src> {
} }
/// Pop a delimiter from the open delimiter stack and error if incorrect type /// Pop a delimiter from the open delimiter stack and error if incorrect type
fn close_delimiter(&mut self, close: Delimiter) -> CompileResult<'src, ()> { fn close_delimiter(&mut self, close: Delimiter) -> CompileResult<'src> {
match self.open_delimiters.pop() { match self.open_delimiters.pop() {
Some((open, _)) if open == close => Ok(()), Some((open, _)) if open == close => Ok(()),
Some((open, open_line)) => Err(self.error(MismatchedClosingDelimiter { Some((open, open_line)) => Err(self.error(MismatchedClosingDelimiter {
@ -687,7 +688,7 @@ impl<'src> Lexer<'src> {
} }
/// Lex a two-character digraph /// Lex a two-character digraph
fn lex_digraph(&mut self, left: char, right: char, token: TokenKind) -> CompileResult<'src, ()> { fn lex_digraph(&mut self, left: char, right: char, token: TokenKind) -> CompileResult<'src> {
self.presume(left)?; self.presume(left)?;
if self.accepted(right)? { if self.accepted(right)? {
@ -710,7 +711,7 @@ impl<'src> Lexer<'src> {
} }
/// Lex a token starting with ':' /// Lex a token starting with ':'
fn lex_colon(&mut self) -> CompileResult<'src, ()> { fn lex_colon(&mut self) -> CompileResult<'src> {
self.presume(':')?; self.presume(':')?;
if self.accepted('=')? { if self.accepted('=')? {
@ -724,7 +725,7 @@ impl<'src> Lexer<'src> {
} }
/// Lex an token starting with '\' escape /// Lex an token starting with '\' escape
fn lex_escape(&mut self) -> CompileResult<'src, ()> { fn lex_escape(&mut self) -> CompileResult<'src> {
self.presume('\\')?; self.presume('\\')?;
// Treat newline escaped with \ as whitespace // Treat newline escaped with \ as whitespace
@ -749,7 +750,7 @@ impl<'src> Lexer<'src> {
} }
/// Lex a carriage return and line feed /// Lex a carriage return and line feed
fn lex_eol(&mut self) -> CompileResult<'src, ()> { fn lex_eol(&mut self) -> CompileResult<'src> {
if self.accepted('\r')? { if self.accepted('\r')? {
if !self.accepted('\n')? { if !self.accepted('\n')? {
return Err(self.error(UnpairedCarriageReturn)); return Err(self.error(UnpairedCarriageReturn));
@ -770,7 +771,7 @@ impl<'src> Lexer<'src> {
} }
/// Lex name: [a-zA-Z_][a-zA-Z0-9_]* /// Lex name: [a-zA-Z_][a-zA-Z0-9_]*
fn lex_identifier(&mut self) -> CompileResult<'src, ()> { fn lex_identifier(&mut self) -> CompileResult<'src> {
self.advance()?; self.advance()?;
while let Some(c) = self.next { while let Some(c) = self.next {
@ -787,7 +788,7 @@ impl<'src> Lexer<'src> {
} }
/// Lex comment: #[^\r\n] /// Lex comment: #[^\r\n]
fn lex_comment(&mut self) -> CompileResult<'src, ()> { fn lex_comment(&mut self) -> CompileResult<'src> {
self.presume('#')?; self.presume('#')?;
while !self.at_eol_or_eof() { while !self.at_eol_or_eof() {
@ -800,7 +801,7 @@ impl<'src> Lexer<'src> {
} }
/// Lex whitespace: [ \t]+ /// Lex whitespace: [ \t]+
fn lex_whitespace(&mut self) -> CompileResult<'src, ()> { fn lex_whitespace(&mut self) -> CompileResult<'src> {
while self.next_is_whitespace() { while self.next_is_whitespace() {
self.advance()?; self.advance()?;
} }
@ -815,7 +816,7 @@ impl<'src> Lexer<'src> {
/// Backtick: ``[^`]*`` /// Backtick: ``[^`]*``
/// Cooked string: "[^"]*" # also processes escape sequences /// Cooked string: "[^"]*" # also processes escape sequences
/// Raw string: '[^']*' /// Raw string: '[^']*'
fn lex_string(&mut self) -> CompileResult<'src, ()> { fn lex_string(&mut self) -> CompileResult<'src> {
let kind = if let Some(kind) = StringKind::from_token_start(self.rest()) { let kind = if let Some(kind) = StringKind::from_token_start(self.rest()) {
kind kind
} else { } else {
@ -975,6 +976,7 @@ mod tests {
ParenL => "(", ParenL => "(",
ParenR => ")", ParenR => ")",
Plus => "+", Plus => "+",
QuestionMark => "?",
Slash => "/", Slash => "/",
Whitespace => " ", Whitespace => " ",

View File

@ -83,9 +83,9 @@ pub use crate::run::run;
#[doc(hidden)] #[doc(hidden)]
pub use unindent::unindent; pub use unindent::unindent;
pub(crate) type CompileResult<'a, T> = Result<T, CompileError<'a>>; pub(crate) type CompileResult<'a, T = ()> = Result<T, CompileError<'a>>;
pub(crate) type ConfigResult<T> = Result<T, ConfigError>; pub(crate) type ConfigResult<T> = Result<T, ConfigError>;
pub(crate) type RunResult<'a, T> = Result<T, Error<'a>>; pub(crate) type RunResult<'a, T = ()> = Result<T, Error<'a>>;
pub(crate) type SearchResult<T> = Result<T, SearchError>; pub(crate) type SearchResult<T> = Result<T, SearchError>;
#[cfg(test)] #[cfg(test)]

View File

@ -21,8 +21,37 @@ impl<'src> Node<'src> for Item<'src> {
Item::Alias(alias) => alias.tree(), Item::Alias(alias) => alias.tree(),
Item::Assignment(assignment) => assignment.tree(), Item::Assignment(assignment) => assignment.tree(),
Item::Comment(comment) => comment.tree(), Item::Comment(comment) => comment.tree(),
Item::Import { relative, .. } => Tree::atom("import").push(format!("{relative}")), Item::Import {
Item::Mod { name, .. } => Tree::atom("mod").push(name.lexeme()), relative, optional, ..
} => {
let mut tree = Tree::atom("import");
if *optional {
tree = tree.push("?");
}
tree.push(format!("{relative}"))
}
Item::Module {
name,
optional,
relative,
..
} => {
let mut tree = Tree::atom("mod");
if *optional {
tree = tree.push("?");
}
tree = tree.push(name.lexeme());
if let Some(relative) = relative {
tree = tree.push(format!("{relative}"));
}
tree
}
Item::Recipe(recipe) => recipe.tree(), Item::Recipe(recipe) => recipe.tree(),
Item::Set(set) => set.tree(), Item::Set(set) => set.tree(),
} }

View File

@ -150,7 +150,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
} }
/// Return an unexpected token error if the next token is not an EOL /// Return an unexpected token error if the next token is not an EOL
fn expect_eol(&mut self) -> CompileResult<'src, ()> { fn expect_eol(&mut self) -> CompileResult<'src> {
self.accept(Comment)?; self.accept(Comment)?;
if self.next_is(Eof) { if self.next_is(Eof) {
@ -160,7 +160,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
self.expect(Eol).map(|_| ()) self.expect(Eol).map(|_| ())
} }
fn expect_keyword(&mut self, expected: Keyword) -> CompileResult<'src, ()> { fn expect_keyword(&mut self, expected: Keyword) -> CompileResult<'src> {
let found = self.advance()?; let found = self.advance()?;
if found.kind == Identifier && expected == found.lexeme() { if found.kind == Identifier && expected == found.lexeme() {
@ -175,7 +175,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
/// Return an internal error if the next token is not of kind `Identifier` /// Return an internal error if the next token is not of kind `Identifier`
/// with lexeme `lexeme`. /// with lexeme `lexeme`.
fn presume_keyword(&mut self, keyword: Keyword) -> CompileResult<'src, ()> { fn presume_keyword(&mut self, keyword: Keyword) -> CompileResult<'src> {
let next = self.advance()?; let next = self.advance()?;
if next.kind != Identifier { if next.kind != Identifier {
@ -231,7 +231,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
} }
/// Return an error if the next token is of kind `forbidden` /// Return an error if the next token is of kind `forbidden`
fn forbid<F>(&self, forbidden: TokenKind, error: F) -> CompileResult<'src, ()> fn forbid<F>(&self, forbidden: TokenKind, error: F) -> CompileResult<'src>
where where
F: FnOnce(Token) -> CompileError, F: FnOnce(Token) -> CompileError,
{ {
@ -334,31 +334,43 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
self.presume_keyword(Keyword::Export)?; self.presume_keyword(Keyword::Export)?;
items.push(Item::Assignment(self.parse_assignment(true)?)); items.push(Item::Assignment(self.parse_assignment(true)?));
} }
Some(Keyword::Import) if self.next_are(&[Identifier, StringToken]) => { Some(Keyword::Import)
if self.next_are(&[Identifier, StringToken])
|| self.next_are(&[Identifier, QuestionMark]) =>
{
self.presume_keyword(Keyword::Import)?; self.presume_keyword(Keyword::Import)?;
let optional = self.accepted(QuestionMark)?;
let (path, relative) = self.parse_string_literal_token()?;
items.push(Item::Import { items.push(Item::Import {
relative: self.parse_string_literal()?,
absolute: None, absolute: None,
optional,
path,
relative,
}); });
} }
Some(Keyword::Mod) Some(Keyword::Mod)
if self.next_are(&[Identifier, Identifier, StringToken]) if self.next_are(&[Identifier, Identifier, StringToken])
|| self.next_are(&[Identifier, Identifier, Eof]) || self.next_are(&[Identifier, Identifier, Eof])
|| self.next_are(&[Identifier, Identifier, Eol]) => || self.next_are(&[Identifier, Identifier, Eol])
|| self.next_are(&[Identifier, QuestionMark]) =>
{ {
self.presume_keyword(Keyword::Mod)?; self.presume_keyword(Keyword::Mod)?;
let optional = self.accepted(QuestionMark)?;
let name = self.parse_name()?; let name = self.parse_name()?;
let path = if self.next_is(StringToken) { let relative = if self.next_is(StringToken) {
Some(self.parse_string_literal()?) Some(self.parse_string_literal()?)
} else { } else {
None None
}; };
items.push(Item::Mod { items.push(Item::Module {
name,
absolute: None, absolute: None,
path, name,
optional,
relative,
}); });
} }
Some(Keyword::Set) Some(Keyword::Set)
@ -574,8 +586,10 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
} }
} }
/// Parse a string literal, e.g. `"FOO"` /// Parse a string literal, e.g. `"FOO"`, returning the string literal and the string token
fn parse_string_literal(&mut self) -> CompileResult<'src, StringLiteral<'src>> { fn parse_string_literal_token(
&mut self,
) -> CompileResult<'src, (Token<'src>, StringLiteral<'src>)> {
let token = self.expect(StringToken)?; let token = self.expect(StringToken)?;
let kind = StringKind::from_string_or_backtick(token)?; let kind = StringKind::from_string_or_backtick(token)?;
@ -620,7 +634,13 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
unindented unindented
}; };
Ok(StringLiteral { kind, raw, cooked }) Ok((token, StringLiteral { kind, raw, cooked }))
}
/// Parse a string literal, e.g. `"FOO"`
fn parse_string_literal(&mut self) -> CompileResult<'src, StringLiteral<'src>> {
let (_token, string_literal) = self.parse_string_literal_token()?;
Ok(string_literal)
} }
/// Parse a name from an identifier token /// Parse a name from an identifier token
@ -2000,6 +2020,36 @@ mod tests {
tree: (justfile (import "some/file/path.txt")), tree: (justfile (import "some/file/path.txt")),
} }
test! {
name: optional_import,
text: "import? \"some/file/path.txt\" \n",
tree: (justfile (import ? "some/file/path.txt")),
}
test! {
name: module_with,
text: "mod foo",
tree: (justfile (mod foo )),
}
test! {
name: optional_module,
text: "mod? foo",
tree: (justfile (mod ? foo)),
}
test! {
name: module_with_path,
text: "mod foo \"some/file/path.txt\" \n",
tree: (justfile (mod foo "some/file/path.txt")),
}
test! {
name: optional_module_with_path,
text: "mod? foo \"some/file/path.txt\" \n",
tree: (justfile (mod ? foo "some/file/path.txt")),
}
error! { error! {
name: alias_syntax_multiple_rhs, name: alias_syntax_multiple_rhs,
input: "alias foo := bar baz", input: "alias foo := bar baz",

View File

@ -56,7 +56,7 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> {
&self, &self,
variable: &Token<'src>, variable: &Token<'src>,
parameters: &[Parameter], parameters: &[Parameter],
) -> CompileResult<'src, ()> { ) -> CompileResult<'src> {
let name = variable.lexeme(); let name = variable.lexeme();
let undefined = let undefined =
!self.assignments.contains_key(name) && !parameters.iter().any(|p| p.name.lexeme() == name); !self.assignments.contains_key(name) && !parameters.iter().any(|p| p.name.lexeme() == name);

View File

@ -30,6 +30,7 @@ pub(crate) enum TokenKind {
ParenL, ParenL,
ParenR, ParenR,
Plus, Plus,
QuestionMark,
Slash, Slash,
StringToken, StringToken,
Text, Text,
@ -72,6 +73,7 @@ impl Display for TokenKind {
ParenL => "'('", ParenL => "'('",
ParenR => "')'", ParenR => "')'",
Plus => "'+'", Plus => "'+'",
QuestionMark => "?",
Slash => "'/'", Slash => "'/'",
StringToken => "string", StringToken => "string",
Text => "command text", Text => "command text",

View File

@ -23,6 +23,10 @@ macro_rules! tree {
$crate::tree::Tree::atom("#") $crate::tree::Tree::atom("#")
}; };
{ ? } => {
$crate::tree::Tree::atom("?")
};
{ + } => { { + } => {
$crate::tree::Tree::atom("+") $crate::tree::Tree::atom("+")
}; };

View File

@ -23,6 +23,49 @@ fn import_succeeds() {
.run(); .run();
} }
#[test]
fn missing_import_file_error() {
Test::new()
.justfile(
"
import './import.justfile'
a:
@echo A
",
)
.test_round_trip(false)
.arg("a")
.status(EXIT_FAILURE)
.stderr(
"
error: Could not find source file for import.
--> justfile:1:8
|
1 | import './import.justfile'
| ^^^^^^^^^^^^^^^^^^^
",
)
.run();
}
#[test]
fn missing_optional_imports_are_ignored() {
Test::new()
.justfile(
"
import? './import.justfile'
a:
@echo A
",
)
.test_round_trip(false)
.arg("a")
.stdout("A\n")
.run();
}
#[test] #[test]
fn trailing_spaces_after_import_are_ignored() { fn trailing_spaces_after_import_are_ignored() {
Test::new() Test::new()
@ -169,3 +212,33 @@ fn import_paths_beginning_with_tilde_are_expanded_to_homdir() {
.env("HOME", "foobar") .env("HOME", "foobar")
.run(); .run();
} }
#[test]
fn imports_dump_correctly() {
Test::new()
.write("import.justfile", "")
.justfile(
"
import './import.justfile'
",
)
.test_round_trip(false)
.arg("--dump")
.stdout("import './import.justfile'\n")
.run();
}
#[test]
fn optional_imports_dump_correctly() {
Test::new()
.write("import.justfile", "")
.justfile(
"
import? './import.justfile'
",
)
.test_round_trip(false)
.arg("--dump")
.stdout("import? './import.justfile'\n")
.run();
}

View File

@ -552,13 +552,13 @@ test! {
??? ^^^
"#, "#,
stdout: "", stdout: "",
stderr: "error: Unknown start of token: stderr: "error: Unknown start of token:
--> justfile:10:1 --> justfile:10:1
| |
10 | ??? 10 | ^^^
| ^ | ^
", ",
status: EXIT_FAILURE, status: EXIT_FAILURE,

View File

@ -266,6 +266,22 @@ fn modules_are_dumped_correctly() {
.run(); .run();
} }
#[test]
fn optional_modules_are_dumped_correctly() {
Test::new()
.write("foo.just", "foo:\n @echo FOO")
.justfile(
"
mod? foo
",
)
.test_round_trip(false)
.arg("--unstable")
.arg("--dump")
.stdout("mod? foo\n")
.run();
}
#[test] #[test]
fn modules_can_be_in_subdirectory() { fn modules_can_be_in_subdirectory() {
Test::new() Test::new()
@ -382,6 +398,42 @@ fn missing_module_file_error() {
.run(); .run();
} }
#[test]
fn missing_optional_modules_do_not_trigger_error() {
Test::new()
.justfile(
"
mod? foo
bar:
@echo BAR
",
)
.test_round_trip(false)
.arg("--unstable")
.stdout("BAR\n")
.run();
}
#[test]
fn missing_optional_modules_do_not_conflict() {
Test::new()
.justfile(
"
mod? foo
mod? foo
mod foo 'baz.just'
",
)
.write("baz.just", "baz:\n @echo BAZ")
.test_round_trip(false)
.arg("--unstable")
.arg("foo")
.arg("baz")
.stdout("BAZ\n")
.run();
}
#[test] #[test]
fn list_displays_recipes_in_submodules() { fn list_displays_recipes_in_submodules() {
Test::new() Test::new()
@ -478,6 +530,22 @@ fn modules_with_paths_are_dumped_correctly() {
.run(); .run();
} }
#[test]
fn optional_modules_with_paths_are_dumped_correctly() {
Test::new()
.write("commands/foo.just", "foo:\n @echo FOO")
.justfile(
"
mod? foo 'commands/foo.just'
",
)
.test_round_trip(false)
.arg("--unstable")
.arg("--dump")
.stdout("mod? foo 'commands/foo.just'\n")
.run();
}
#[test] #[test]
fn recipes_may_be_named_mod() { fn recipes_may_be_named_mod() {
Test::new() Test::new()