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

Support including a justfile from another justfile #1470

Merged
merged 28 commits into from
Jan 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ jobs:

- uses: Swatinem/rust-cache@v1

- name: Check Lockfile
run: cargo update --locked --package just

- name: Clippy
run: cargo clippy --all --all-targets

Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2175,6 +2175,47 @@ But they must match:
$ just foo/a bar/b
error: Conflicting path arguments: `foo/` and `bar/`
```
### Include Directives

The `!include` directive, currently unstable, can be used to include the
verbatim text of another file.

If you have the following `justfile`:

```mf
!include foo/bar.just

a: b
@echo A

```

And the following text in `foo/bar.just`:

```mf
b:
@echo B
```

`foo/bar.just` will be included in `justfile` and recipe `b` will be defined:

```sh
$ just --unstable b
B
$ just --unstable a
B
A
```

The `!include` directive path can be absolute or relative to the location of
the justfile containing it. `!include` directives must appear at the beginning
of a line. line.

`!include` directives are only processed before the first non-blank,
non-comment line.

Included files can themselves contain `!include` directives, which are
processed recursively.

### Hiding `justfile`s

Expand Down
32 changes: 32 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ pub(crate) enum Error<'src> {
chooser: OsString,
io_error: io::Error,
},
CircularInclude {
current: PathBuf,
include: PathBuf,
},
Code {
recipe: &'src str,
line_number: Option<usize>,
Expand Down Expand Up @@ -84,12 +88,19 @@ pub(crate) enum Error<'src> {
function: Name<'src>,
message: String,
},
IncludeMissingPath {
file: PathBuf,
line: usize,
},
InitExists {
justfile: PathBuf,
},
Internal {
message: String,
},
InvalidDirective {
line: String,
},
Io {
recipe: &'src str,
io_error: io::Error,
Expand Down Expand Up @@ -330,6 +341,12 @@ impl<'src> ColorDisplay for Error<'src> {
io_error
)?;
}
CircularInclude { current, include } => {
write!(
f,
"Include `{}` in `{}` is a circular include", include.display(), current.display()
)?;
},
Code {
recipe,
line_number,
Expand Down Expand Up @@ -482,6 +499,18 @@ impl<'src> ColorDisplay for Error<'src> {
message
)?;
}
IncludeMissingPath {
file: justfile, line
} => {

write!(
f,
"!include directive on line {} of `{}` has no argument",
line.ordinal(),
justfile.display(),
)?;

},
InitExists { justfile } => {
write!(f, "Justfile `{}` already exists", justfile.display())?;
}
Expand All @@ -493,6 +522,9 @@ impl<'src> ColorDisplay for Error<'src> {
message
)?;
}
InvalidDirective { line } => {
write!(f, "Invalid directive: {line}")?;
}
Io { recipe, io_error } => {
match io_error.kind() {
io::ErrorKind::NotFound => write!(
Expand Down
203 changes: 199 additions & 4 deletions src/loader.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,216 @@
use super::*;
use std::collections::HashSet;

struct LinesWithEndings<'a> {
input: &'a str,
}

impl<'a> LinesWithEndings<'a> {
fn new(input: &'a str) -> Self {
Self { input }
}
}

impl<'a> Iterator for LinesWithEndings<'a> {
type Item = &'a str;

fn next(&mut self) -> Option<&'a str> {
if self.input.is_empty() {
return None;
}
let split = self.input.find('\n').map_or(self.input.len(), |i| i + 1);
let (line, rest) = self.input.split_at(split);
self.input = rest;
Some(line)
}
}

pub(crate) struct Loader {
arena: Arena<String>,
unstable: bool,
}

impl Loader {
pub(crate) fn new() -> Self {
pub(crate) fn new(unstable: bool) -> Self {
Loader {
arena: Arena::new(),
unstable,
}
}

pub(crate) fn load<'src>(&'src self, path: &Path) -> RunResult<&'src str> {
let src = fs::read_to_string(path).map_err(|io_error| Error::Load {
let src = self.load_recursive(path, HashSet::new())?;
Ok(self.arena.alloc(src))
}

fn load_file<'a>(path: &Path) -> RunResult<'a, String> {
fs::read_to_string(path).map_err(|io_error| Error::Load {
path: path.to_owned(),
io_error,
})?;
Ok(self.arena.alloc(src))
})
}

fn load_recursive(&self, file: &Path, seen: HashSet<PathBuf>) -> RunResult<String> {
let src = Self::load_file(file)?;

let mut output = String::new();

let mut seen_content = false;

for (i, line) in LinesWithEndings::new(&src).enumerate() {
if !seen_content && line.starts_with('!') {
let include = line
.strip_prefix("!include")
.ok_or_else(|| Error::InvalidDirective { line: line.into() })?;

if !self.unstable {
return Err(Error::Unstable {
message: "The !include directive is currently unstable.".into(),
});
}

let argument = include.trim();

if argument.is_empty() {
return Err(Error::IncludeMissingPath {
file: file.to_owned(),
line: i,
});
}

let contents = self.process_include(file, Path::new(argument), &seen)?;

output.push_str(&contents);
} else {
if !(line.trim().is_empty() || line.trim().starts_with('#')) {
seen_content = true;
}
output.push_str(line);
}
}

Ok(output)
}

fn process_include(
&self,
file: &Path,
include: &Path,
seen: &HashSet<PathBuf>,
) -> RunResult<String> {
let canonical_path = if include.is_relative() {
let current_dir = file.parent().ok_or(Error::Internal {
message: format!(
"Justfile path `{}` has no parent directory",
include.display()
),
})?;
current_dir.join(include)
} else {
include.to_owned()
};

let canonical_path = canonical_path.lexiclean();

if seen.contains(&canonical_path) {
return Err(Error::CircularInclude {
current: file.to_owned(),
include: canonical_path,
});
}

let mut seen_paths = seen.clone();
seen_paths.insert(file.lexiclean());

self.load_recursive(&canonical_path, seen_paths)
}
}

#[cfg(test)]
mod tests {
use super::{Error, Lexiclean, Loader};
use temptree::temptree;

#[test]
fn include_justfile() {
let justfile_a = r#"
# A comment at the top of the file
!include ./justfile_b

some_recipe: recipe_b
echo "some recipe"
"#;

let justfile_b = r#"!include ./subdir/justfile_c

recipe_b: recipe_c
echo "recipe b"
"#;

let justfile_c = r#"recipe_c:
echo "recipe c"
"#;

let tmp = temptree! {
justfile: justfile_a,
justfile_b: justfile_b,
subdir: {
justfile_c: justfile_c
}
};

let full_concatenated_output = r#"
# A comment at the top of the file
recipe_c:
echo "recipe c"

recipe_b: recipe_c
echo "recipe b"

some_recipe: recipe_b
echo "some recipe"
"#;

let loader = Loader::new(true);

let justfile_a_path = tmp.path().join("justfile");
let loader_output = loader.load(&justfile_a_path).unwrap();

assert_eq!(loader_output, full_concatenated_output);
}

#[test]
fn recursive_includes_fail() {
let justfile_a = r#"
# A comment at the top of the file
!include ./subdir/justfile_b

some_recipe: recipe_b
echo "some recipe"

"#;

let justfile_b = r#"
!include ../justfile

recipe_b:
echo "recipe b"
"#;
let tmp = temptree! {
justfile: justfile_a,
subdir: {
justfile_b: justfile_b
}
};

let loader = Loader::new(true);

let justfile_a_path = tmp.path().join("justfile");
let loader_output = loader.load(&justfile_a_path).unwrap_err();

assert_matches!(loader_output, Error::CircularInclude { current, include }
if current == tmp.path().join("subdir").join("justfile_b").lexiclean() &&
include == tmp.path().join("justfile").lexiclean()
);
}
}
10 changes: 5 additions & 5 deletions src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ pub fn run() -> Result<(), i32> {
info!("Parsing command line arguments…");
let matches = app.get_matches();

let loader = Loader::new();

let config = Config::from_matches(&matches).map_err(Error::from);

let (color, verbosity) = config
let (color, verbosity, unstable) = config
.as_ref()
.map(|config| (config.color, config.verbosity))
.unwrap_or((Color::auto(), Verbosity::default()));
.map(|config| (config.color, config.verbosity, config.unstable))
.unwrap_or((Color::auto(), Verbosity::default(), false));

let loader = Loader::new(unstable);

config
.and_then(|config| config.run(&loader))
Expand Down
2 changes: 1 addition & 1 deletion tests/choose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ fn invoke_error_function() {
echo bar
",
)
.stderr_regex("error: Chooser `/ -cu fzf` invocation failed: .*")
.stderr_regex("error: Chooser `/ -cu fzf` invocation failed: .*\n")
.status(EXIT_FAILURE)
.shell(false)
.args(["--shell", "/", "--choose"])
Expand Down
6 changes: 3 additions & 3 deletions tests/error_messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ test! {

test! {
name: unexpected_character,
justfile: "!~",
justfile: "&~",
stderr: "
error: Expected character `=`
error: Expected character `&`
|
1 | !~
1 | &~
| ^
",
status: EXIT_FAILURE,
Expand Down
Loading