Skip to content

Commit

Permalink
Add !include directives (#1470)
Browse files Browse the repository at this point in the history
  • Loading branch information
neunenak authored Jan 13, 2023
1 parent 5e5583d commit 912863b
Show file tree
Hide file tree
Showing 10 changed files with 391 additions and 19 deletions.
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

0 comments on commit 912863b

Please sign in to comment.