Allow mod path to be directory containing module source (#2238)

This commit is contained in:
Casey Rodarmor 2024-07-08 15:38:25 -07:00 committed by GitHub
parent d6669e0b97
commit 458805e283
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 188 additions and 36 deletions

View File

@ -3204,7 +3204,10 @@ mod foo 'PATH'
Which loads the module's source file from `PATH`, instead of from the usual Which loads the module's source file from `PATH`, instead of from the usual
locations. A leading `~/` in `PATH` is replaced with the current user's home locations. A leading `~/` in `PATH` is replaced with the current user's home
directory. directory. `PATH` may point to the module source file itself, or to a directory
containing the module source file with the name `mod.just`, `justfile`, or
`.justfile`. In the latter two cases, the module file may have any
capitalization.
Environment files are only loaded for the root justfile, and loaded environment Environment files are only loaded for the root justfile, and loaded environment
variables are available in submodules. Settings in submodules that affect variables are available in submodules. Settings in submodules that affect

View File

@ -43,17 +43,12 @@ impl Compiler {
} => { } => {
let parent = current.path.parent().unwrap(); let parent = current.path.parent().unwrap();
let import = if let Some(relative) = relative { let relative = relative
let path = parent.join(Self::expand_tilde(&relative.cooked)?); .as_ref()
.map(|relative| Self::expand_tilde(&relative.cooked))
.transpose()?;
if path.is_file() { let import = Self::find_module_file(parent, *name, relative.as_deref())?;
Some(path)
} else {
None
}
} else {
Self::find_module_file(parent, *name)?
};
if let Some(import) = import { if let Some(import) = import {
if current.file_path.contains(&import) { if current.file_path.contains(&import) {
@ -111,19 +106,63 @@ impl Compiler {
}) })
} }
fn find_module_file<'src>(parent: &Path, module: Name<'src>) -> RunResult<'src, Option<PathBuf>> { fn find_module_file<'src>(
let mut candidates = vec![format!("{module}.just"), format!("{module}/mod.just")] parent: &Path,
.into_iter() module: Name<'src>,
.filter(|path| parent.join(path).is_file()) path: Option<&Path>,
.collect::<Vec<String>>(); ) -> RunResult<'src, Option<PathBuf>> {
let mut candidates = Vec::new();
let directory = parent.join(module.lexeme()); if let Some(path) = path {
let full = parent.join(path);
if directory.exists() { if full.is_file() {
let entries = fs::read_dir(&directory).map_err(|io_error| SearchError::Io { return Ok(Some(full));
}
candidates.push((path.join("mod.just"), true));
for name in search::JUSTFILE_NAMES {
candidates.push((path.join(name), false));
}
} else {
candidates.push((format!("{module}.just").into(), true));
candidates.push((format!("{module}/mod.just").into(), true));
for name in search::JUSTFILE_NAMES {
candidates.push((format!("{module}/{name}").into(), false));
}
}
let mut grouped = BTreeMap::<PathBuf, Vec<(PathBuf, bool)>>::new();
for (candidate, case_sensitive) in candidates {
let candidate = parent.join(candidate).lexiclean();
grouped
.entry(candidate.parent().unwrap().into())
.or_default()
.push((candidate, case_sensitive));
}
let mut found = Vec::new();
for (directory, candidates) in grouped {
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(io_error) => {
if io_error.kind() == io::ErrorKind::NotFound {
continue;
}
return Err(
SearchError::Io {
io_error, io_error,
directory: directory.clone(), directory,
})?; }
.into(),
);
}
};
for entry in entries { for entry in entries {
let entry = entry.map_err(|io_error| SearchError::Io { let entry = entry.map_err(|io_error| SearchError::Io {
@ -132,22 +171,34 @@ impl Compiler {
})?; })?;
if let Some(name) = entry.file_name().to_str() { if let Some(name) = entry.file_name().to_str() {
for justfile_name in search::JUSTFILE_NAMES { for (candidate, case_sensitive) in &candidates {
if name.eq_ignore_ascii_case(justfile_name) { let candidate_name = candidate.file_name().unwrap().to_str().unwrap();
candidates.push(format!("{module}/{name}"));
let eq = if *case_sensitive {
name == candidate_name
} else {
name.eq_ignore_ascii_case(candidate_name)
};
if eq {
found.push(candidate.parent().unwrap().join(name));
} }
} }
} }
} }
} }
match candidates.as_slice() { if found.len() > 1 {
[] => Ok(None), found.sort();
[file] => Ok(Some(parent.join(file).lexiclean())), Err(Error::AmbiguousModuleFile {
found => Err(Error::AmbiguousModuleFile { found: found
found: found.into(), .into_iter()
.map(|found| found.strip_prefix(parent).unwrap().into())
.collect(),
module, module,
}), })
} else {
Ok(found.into_iter().next())
} }
} }
@ -242,4 +293,84 @@ recipe_b: recipe_c
import == tmp.path().join("justfile").lexiclean() import == tmp.path().join("justfile").lexiclean()
); );
} }
#[test]
fn find_module_file() {
#[track_caller]
fn case(path: Option<&str>, files: &[&str], expected: Result<Option<&str>, &[&str]>) {
let module = Name {
token: Token {
column: 0,
kind: TokenKind::Identifier,
length: 3,
line: 0,
offset: 0,
path: Path::new(""),
src: "foo",
},
};
let tempdir = tempfile::tempdir().unwrap();
for file in files {
if let Some(parent) = Path::new(file).parent() {
fs::create_dir_all(tempdir.path().join(parent)).unwrap();
}
fs::write(tempdir.path().join(file), "").unwrap();
}
let actual = Compiler::find_module_file(tempdir.path(), module, path.map(Path::new));
match expected {
Err(expected) => match actual.unwrap_err() {
Error::AmbiguousModuleFile { found, .. } => {
assert_eq!(
found,
expected
.iter()
.map(|expected| expected.replace('/', std::path::MAIN_SEPARATOR_STR).into())
.collect::<Vec<PathBuf>>()
);
}
_ => panic!("unexpected error"),
},
Ok(Some(expected)) => assert_eq!(
actual.unwrap().unwrap(),
tempdir
.path()
.join(expected.replace('/', std::path::MAIN_SEPARATOR_STR))
),
Ok(None) => assert_eq!(actual.unwrap(), None),
}
}
case(None, &["foo.just"], Ok(Some("foo.just")));
case(None, &["FOO.just"], Ok(None));
case(None, &["foo/mod.just"], Ok(Some("foo/mod.just")));
case(None, &["foo/MOD.just"], Ok(None));
case(None, &["foo/justfile"], Ok(Some("foo/justfile")));
case(None, &["foo/JUSTFILE"], Ok(Some("foo/JUSTFILE")));
case(None, &["foo/.justfile"], Ok(Some("foo/.justfile")));
case(None, &["foo/.JUSTFILE"], Ok(Some("foo/.JUSTFILE")));
case(
None,
&["foo/.justfile", "foo/justfile"],
Err(&["foo/.justfile", "foo/justfile"]),
);
case(None, &["foo/JUSTFILE"], Ok(Some("foo/JUSTFILE")));
case(Some("bar"), &["bar"], Ok(Some("bar")));
case(Some("bar"), &["bar/mod.just"], Ok(Some("bar/mod.just")));
case(Some("bar"), &["bar/justfile"], Ok(Some("bar/justfile")));
case(Some("bar"), &["bar/JUSTFILE"], Ok(Some("bar/JUSTFILE")));
case(Some("bar"), &["bar/.justfile"], Ok(Some("bar/.justfile")));
case(Some("bar"), &["bar/.JUSTFILE"], Ok(Some("bar/.JUSTFILE")));
case(
Some("bar"),
&["bar/justfile", "bar/mod.just"],
Err(&["bar/justfile", "bar/mod.just"]),
);
}
} }

View File

@ -4,7 +4,7 @@ use super::*;
pub(crate) enum Error<'src> { pub(crate) enum Error<'src> {
AmbiguousModuleFile { AmbiguousModuleFile {
module: Name<'src>, module: Name<'src>,
found: Vec<String>, found: Vec<PathBuf>,
}, },
ArgumentCountMismatch { ArgumentCountMismatch {
recipe: &'src str, recipe: &'src str,
@ -262,7 +262,7 @@ impl<'src> ColorDisplay for Error<'src> {
AmbiguousModuleFile { module, found } => AmbiguousModuleFile { module, found } =>
write!(f, write!(f,
"Found multiple source files for module `{module}`: {}", "Found multiple source files for module `{module}`: {}",
List::and_ticked(found), List::and_ticked(found.iter().map(|path| path.display())),
)?, )?,
ArgumentCountMismatch { recipe, found, min, max, .. } => { ArgumentCountMismatch { recipe, found, min, max, .. } => {
let count = Count("argument", *found); let count = Count("argument", *found);

View File

@ -19,7 +19,7 @@ pub(crate) use {
fs, fs,
io::Write, io::Write,
iter, iter,
path::{Path, PathBuf, MAIN_SEPARATOR}, path::{Path, PathBuf, MAIN_SEPARATOR, MAIN_SEPARATOR_STR},
process::{Command, Stdio}, process::{Command, Stdio},
str, str,
}, },

View File

@ -439,12 +439,13 @@ fn modules_require_unambiguous_file() {
.status(EXIT_FAILURE) .status(EXIT_FAILURE)
.stderr( .stderr(
" "
error: Found multiple source files for module `foo`: `foo.just` and `foo/justfile` error: Found multiple source files for module `foo`: `foo/justfile` and `foo.just`
justfile:1:5 justfile:1:5
1 mod foo 1 mod foo
^^^ ^^^
", "
.replace('/', MAIN_SEPARATOR_STR),
) )
.run(); .run();
} }
@ -564,6 +565,23 @@ fn modules_may_specify_path() {
.run(); .run();
} }
#[test]
fn modules_may_specify_path_to_directory() {
Test::new()
.write("commands/bar/mod.just", "foo:\n @echo FOO")
.justfile(
"
mod foo 'commands/bar'
",
)
.test_round_trip(false)
.arg("--unstable")
.arg("foo")
.arg("foo")
.stdout("FOO\n")
.run();
}
#[test] #[test]
fn modules_with_paths_are_dumped_correctly() { fn modules_with_paths_are_dumped_correctly() {
Test::new() Test::new()