use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use rowan::{TextRange, TextSize};
use toml::Spanned;
use crate::environment::{
Environment, EnvironmentError, PackageKind, ProjectFile, Uuid, is_project_file,
manifest_path_entries, parse_project_str, parse_project_text,
};
use crate::linter::Severity;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectFinding {
pub check: &'static str,
pub severity: Severity,
pub path: PathBuf,
pub range: TextRange,
pub message: String,
}
pub fn syntax_findings(path: &Path, text: &str) -> Vec<ProjectFinding> {
let parsed = if is_project_file(path) {
parse_project_text(path, text).map(|_| ())
} else {
toml::from_str::<toml::Table>(text)
.map(|_| ())
.map_err(|err| EnvironmentError::Parse {
path: path.to_path_buf(),
message: err.message().to_string(),
span: err.span(),
})
};
let Err(error) = parsed else {
return Vec::new();
};
vec![syntax_finding(path, text, &error)]
}
pub fn syntax_finding(path: &Path, text: &str, error: &EnvironmentError) -> ProjectFinding {
let (message, span) = match error {
EnvironmentError::Read { message, .. } => (message.clone(), None),
EnvironmentError::Parse { message, span, .. } => (message.clone(), span.clone()),
};
ProjectFinding {
check: "toml-syntax",
severity: Severity::Error,
path: path.to_path_buf(),
range: span.map_or_else(|| first_line(text), |span| to_range(span.start, span.end)),
message,
}
}
pub fn semantic_findings(env: &Environment, project_text: &str) -> Vec<ProjectFinding> {
let Ok(project) = parse_project_text(&env.project_file, project_text) else {
return Vec::new();
};
let mut findings = Vec::new();
let mut warn = |check, range, message| {
findings.push(ProjectFinding {
check,
severity: Severity::Warning,
path: env.project_file.clone(),
range,
message,
});
};
let is_package = project.name.is_some() && project.uuid.is_some();
if is_package
&& let Some(entry) = env.entry_file()
&& !entry.is_file()
&& let (Some(name), Some(file_name)) = (&project.name, entry.file_name())
{
warn(
"missing-entry-file",
span(name),
format!(
"package `{}` has no entry file `src/{}`",
name.as_ref(),
file_name.to_string_lossy()
),
);
}
if is_package && !project.compat.contains_key("julia") {
let anchor = project
.compat
.keys()
.next()
.or(project.name.as_ref())
.map_or_else(|| first_line(project_text), span);
warn(
"missing-julia-compat",
anchor,
"no `[compat]` bound on `julia`".to_string(),
);
}
for name in project.compat.keys().filter(|name| {
name.as_ref() != "julia"
&& ![&project.deps, &project.extras, &project.weakdeps]
.iter()
.any(|table| table.contains_key(name.as_ref().as_str()))
}) {
warn(
"unknown-compat",
span(name),
format!(
"compat entry `{}` names nothing in `[deps]`, `[extras]`, or `[weakdeps]`",
name.as_ref()
),
);
}
if is_package && !project.compat.is_empty() {
let stdlib = StdlibOracle::of(env);
for name in project.deps.keys().filter(|name| {
let name = name.as_ref().as_str();
!project.compat.contains_key(name)
&& !project.sources.contains_key(name)
&& stdlib.is_stdlib(name) == Some(false)
}) {
warn(
"missing-compat",
span(name),
format!("dependency `{}` has no `[compat]` bound", name.as_ref()),
);
}
}
findings.extend(manifest_findings(env, &project));
findings.sort_by_key(|finding| finding.range.start());
findings
}
struct StdlibOracle {
manifest: HashMap<String, bool>,
install: Option<HashSet<String>>,
}
impl StdlibOracle {
fn of(env: &Environment) -> Self {
let manifest = env
.packages
.iter()
.map(|package| (package.name.clone(), package.kind == PackageKind::Stdlib))
.collect();
let install = env.install.as_ref().and_then(|install| {
let entries = std::fs::read_dir(&install.stdlib_dir).ok()?;
Some(
entries
.filter_map(|entry| {
let entry = entry.ok()?;
entry
.file_type()
.ok()?
.is_dir()
.then(|| entry.file_name().to_string_lossy().into_owned())
})
.collect(),
)
});
Self { manifest, install }
}
fn is_stdlib(&self, name: &str) -> Option<bool> {
if let Some(&stdlib) = self.manifest.get(name) {
return Some(stdlib);
}
Some(self.install.as_ref()?.contains(name))
}
}
fn manifest_findings(env: &Environment, project: &ProjectFile) -> Vec<ProjectFinding> {
if env.manifest_file.is_none() || env.packages.is_empty() {
return Vec::new();
}
let mut findings = Vec::new();
for (name, declared) in &project.deps {
let mut entries = env
.packages
.iter()
.filter(|package| package.name == *name.as_ref())
.peekable();
let Some(first) = entries.peek().copied() else {
findings.push(ProjectFinding {
check: "missing-from-manifest",
severity: Severity::Warning,
path: env.project_file.clone(),
range: span(name),
message: format!(
"dependency `{}` is not in the manifest; run `Pkg.instantiate()`",
name.as_ref()
),
});
continue;
};
let Ok(declared_uuid) = declared.as_ref().parse::<Uuid>() else {
continue;
};
if entries.any(|package| package.uuid == declared_uuid) {
continue;
}
findings.push(ProjectFinding {
check: "uuid-mismatch",
severity: Severity::Warning,
path: env.project_file.clone(),
range: span(declared),
message: format!(
"UUID for `{}` disagrees with the manifest: project has `{}`, manifest has `{}`",
name.as_ref(),
declared_uuid,
first.uuid
),
});
}
findings
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DepTable {
Deps,
WeakDeps,
Extras,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DepEntry {
pub name: String,
pub table: DepTable,
pub name_range: TextRange,
pub uuid_range: TextRange,
}
pub fn dep_entries(text: &str) -> Vec<DepEntry> {
let Some(project) = parse_project_str(text) else {
return Vec::new();
};
let tables = [
(DepTable::Deps, &project.deps),
(DepTable::WeakDeps, &project.weakdeps),
(DepTable::Extras, &project.extras),
];
let mut entries: Vec<DepEntry> = tables
.into_iter()
.flat_map(|(table, map)| {
map.iter().map(move |(name, uuid)| DepEntry {
name: name.as_ref().clone(),
table,
name_range: span(name),
uuid_range: span(uuid),
})
})
.collect();
entries.sort_by_key(|entry| entry.name_range.start());
entries
}
pub fn dep_at(text: &str, offset: usize) -> Option<DepEntry> {
let offset = TextSize::new(u32::try_from(offset).ok()?);
dep_entries(text)
.into_iter()
.find(|entry| entry.name_range.contains_inclusive(offset))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestPath {
pub name: String,
pub path: String,
pub range: TextRange,
}
pub fn manifest_paths(text: &str) -> Vec<ManifestPath> {
let mut entries: Vec<ManifestPath> = manifest_path_entries(text)
.into_iter()
.map(|(name, path)| ManifestPath {
name,
range: unquoted(text, span(&path)),
path: path.into_inner(),
})
.collect();
entries.sort_by_key(|entry| entry.range.start());
entries
}
fn unquoted(text: &str, range: TextRange) -> TextRange {
let mut chars = text[range].chars();
let (Some(open), Some(close)) = (chars.next(), chars.next_back()) else {
return range;
};
if open != close || !matches!(open, '"' | '\'') || chars.as_str().starts_with(open) {
return range;
}
let quote = TextSize::of(open);
TextRange::new(range.start() + quote, range.end() - quote)
}
fn span<T>(spanned: &Spanned<T>) -> TextRange {
let span = spanned.span();
to_range(span.start, span.end)
}
fn first_line(text: &str) -> TextRange {
to_range(0, text.find('\n').unwrap_or(text.len()))
}
fn to_range(start: usize, end: usize) -> TextRange {
let clamp = |offset: usize| TextSize::new(u32::try_from(offset).unwrap_or(u32::MAX));
TextRange::new(clamp(start), clamp(end.max(start)))
}
#[cfg(test)]
mod tests {
use super::*;
fn project(text: &str) -> Vec<ProjectFinding> {
syntax_findings(Path::new("Project.toml"), text)
}
#[test]
fn a_valid_project_file_has_no_findings() {
assert!(project("name = \"Demo\"\n\n[deps]\n").is_empty());
}
#[test]
fn a_syntax_error_is_reported_at_its_span() {
let text = "name = \"Demo\"\nuuid = \n";
let findings = project(text);
let [finding] = &findings[..] else {
panic!("expected exactly one finding, got {findings:?}");
};
assert_eq!(finding.check, "toml-syntax");
assert_eq!(finding.severity, Severity::Error);
assert!(
usize::from(finding.range.start()) >= text.find("uuid").unwrap(),
"the range points at the offending line, not the file head: {:?}",
finding.range
);
}
#[test]
fn a_wrong_typed_value_is_a_finding() {
let findings = project("name = 42\n");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].check, "toml-syntax");
}
#[test]
fn a_manifest_is_checked_for_syntax_only() {
let manifest = Path::new("Manifest.toml");
let valid = "julia_version = \"1.11.7\"\nmanifest_format = \"2.0\"\n\n\
[[deps.AbstractTrees]]\nuuid = \"1520ce14-60c1-5f80-bbc7-55ef81b5835c\"\n";
assert!(syntax_findings(manifest, valid).is_empty());
assert!(syntax_findings(manifest, "name = 42\n").is_empty());
assert_eq!(syntax_findings(manifest, "[[deps\n").len(), 1);
}
const TABLES: &str = "\
name = \"Demo\"
[deps]
AbstractTrees = \"1520ce14-60c1-5f80-bbc7-55ef81b5835c\"
JSON = \"682c06a0-de6a-54ab-a142-c8b1cf79cde6\"
[compat]
julia = \"1.10\"
[weakdeps]
Plots = \"91a5bcdd-55d7-5caf-9e0b-520d859cae80\"
[extras]
Test = \"8dfed614-e22c-5e08-85e1-65c5234f0b40\"
";
fn at(text: &str, needle: &str) -> usize {
text.find(needle).expect("needle in text")
}
#[test]
fn dep_entries_cover_the_three_dependency_tables() {
let entries: Vec<(String, DepTable)> = dep_entries(TABLES)
.into_iter()
.map(|entry| (entry.name, entry.table))
.collect();
assert_eq!(
entries,
vec![
("AbstractTrees".to_string(), DepTable::Deps),
("JSON".to_string(), DepTable::Deps),
("Plots".to_string(), DepTable::WeakDeps),
("Test".to_string(), DepTable::Extras),
],
"in source order, and no `[compat]` key"
);
}
#[test]
fn a_dep_entry_spans_its_name_and_its_uuid() {
let entries = dep_entries(TABLES);
let trees = entries.first().expect("the first entry");
assert_eq!(&TABLES[trees.name_range], "AbstractTrees");
assert_eq!(
&TABLES[trees.uuid_range],
"\"1520ce14-60c1-5f80-bbc7-55ef81b5835c\""
);
}
#[test]
fn a_project_file_that_does_not_parse_has_no_dep_entries() {
assert!(dep_entries("[deps\nAbstractTrees = \"x\"\n").is_empty());
assert_eq!(dep_at("[deps\nAbstractTrees = \"x\"\n", 8), None);
}
#[test]
fn dep_at_matches_the_name_and_nothing_else() {
let start = at(TABLES, "AbstractTrees");
let end = start + "AbstractTrees".len();
assert_eq!(
dep_at(TABLES, start).map(|d| d.name),
Some("AbstractTrees".into())
);
assert_eq!(
dep_at(TABLES, end - 1).map(|d| d.name),
Some("AbstractTrees".into())
);
assert_eq!(
dep_at(TABLES, end).map(|d| d.name),
Some("AbstractTrees".into())
);
assert_eq!(dep_at(TABLES, end + 1), None);
assert_eq!(dep_at(TABLES, at(TABLES, "1520ce14")), None);
assert_eq!(dep_at(TABLES, at(TABLES, "julia = ")), None);
}
#[test]
fn a_project_file_with_no_dependencies_has_no_entries() {
assert!(dep_entries("name = \"Demo\"\n\n[deps]\n").is_empty());
}
const MANIFEST_V2: &str = "\
julia_version = \"1.11.7\"
manifest_format = \"2.0\"
[[deps.AbstractTrees]]
uuid = \"1520ce14-60c1-5f80-bbc7-55ef81b5835c\"
version = \"0.4.5\"
[[deps.Greetings]]
uuid = \"682c06a0-de6a-54ab-a142-c8b1cf79cde6\"
path = \"../Greetings\"
[[deps.Neighbor]]
uuid = \"8dfed614-e22c-5e08-85e1-65c5234f0b40\"
path = \"/abs/Neighbor\"
";
const MANIFEST_V1: &str = "\
[[Greetings]]
uuid = \"682c06a0-de6a-54ab-a142-c8b1cf79cde6\"
path = \"../Greetings\"
[[AbstractTrees]]
uuid = \"1520ce14-60c1-5f80-bbc7-55ef81b5835c\"
version = \"0.4.5\"
";
#[test]
fn manifest_paths_read_both_layouts() {
let v2: Vec<(String, String)> = manifest_paths(MANIFEST_V2)
.into_iter()
.map(|entry| (entry.name, entry.path))
.collect();
assert_eq!(
v2,
vec![
("Greetings".to_string(), "../Greetings".to_string()),
("Neighbor".to_string(), "/abs/Neighbor".to_string()),
],
"in source order, and no entry without a path"
);
let v1: Vec<(String, String)> = manifest_paths(MANIFEST_V1)
.into_iter()
.map(|entry| (entry.name, entry.path))
.collect();
assert_eq!(
v1,
vec![("Greetings".to_string(), "../Greetings".to_string())]
);
}
#[test]
fn a_manifest_path_spans_the_path_text_without_its_quotes() {
let entries = manifest_paths(MANIFEST_V2);
let greetings = entries.first().expect("the first entry");
assert_eq!(&MANIFEST_V2[greetings.range], "../Greetings");
let literal = "[[deps.Greetings]]\npath = '../Greetings'\n";
assert_eq!(&literal[manifest_paths(literal)[0].range], "../Greetings");
let empty = "[[deps.Greetings]]\npath = \"\"\n";
assert!(manifest_paths(empty)[0].range.is_empty());
}
#[test]
fn a_manifest_that_does_not_parse_has_no_paths() {
assert!(manifest_paths("[[deps.Greetings\npath = \"x\"\n").is_empty());
assert!(manifest_paths(TABLES).is_empty());
}
const DATES: &str = "ade2ca70-3891-5945-98fb-dc099432e06a";
const TREES: &str = "1520ce14-60c1-5f80-bbc7-55ef81b5835c";
fn check(project: &str, manifest: Option<&str>, entry: Option<&str>) -> Vec<ProjectFinding> {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("Project.toml"), project).unwrap();
if let Some(manifest) = manifest {
std::fs::write(root.join("Manifest.toml"), manifest).unwrap();
}
if let Some(entry) = entry {
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src").join(entry), "module M\nend\n").unwrap();
}
let ctx = crate::environment::EnvContext {
workspace_root: root.to_path_buf(),
julia_project: None,
julia_depot_path: Some(root.join("depot").to_string_lossy().into_owned()),
home: None,
julia_bindir: None,
path: None,
};
let env = crate::environment::resolve(&ctx)
.expect("resolves")
.expect("an environment");
semantic_findings(&env, project)
}
fn checks(findings: &[ProjectFinding]) -> Vec<&'static str> {
findings.iter().map(|finding| finding.check).collect()
}
fn only<'a>(findings: &'a [ProjectFinding], check: &str) -> &'a ProjectFinding {
let matching: Vec<_> = findings.iter().filter(|f| f.check == check).collect();
assert_eq!(matching.len(), 1, "expected one {check}, got {findings:?}");
matching[0]
}
#[test]
fn a_package_without_its_entry_file_is_reported() {
let project =
format!("name = \"Demo\"\nuuid = \"{DATES}\"\n\n[compat]\njulia = \"1.10\"\n");
let findings = check(&project, None, None);
let finding = only(&findings, "missing-entry-file");
assert_eq!(finding.severity, Severity::Warning);
assert!(finding.message.contains("src/Demo.jl"), "{finding:?}");
assert_eq!(
&project[finding.range], "\"Demo\"",
"anchored on the name value"
);
}
#[test]
fn a_package_with_its_entry_file_is_not_reported() {
let project =
format!("name = \"Demo\"\nuuid = \"{DATES}\"\n\n[compat]\njulia = \"1.10\"\n");
assert!(!checks(&check(&project, None, Some("Demo.jl"))).contains(&"missing-entry-file"));
}
#[test]
fn a_nameless_environment_is_not_held_to_package_rules() {
let project = format!("[deps]\nDates = \"{DATES}\"\n");
assert!(check(&project, None, None).is_empty());
}
#[test]
fn a_uuidless_project_is_not_held_to_package_rules() {
let project = format!("name = \"Demo\"\n\n[deps]\nDates = \"{DATES}\"\n");
assert!(check(&project, None, None).is_empty());
}
#[test]
fn a_package_without_julia_compat_is_reported() {
let project = format!("name = \"Demo\"\nuuid = \"{DATES}\"\n\n[compat]\nFoo = \"1\"\n");
let findings = check(&project, None, Some("Demo.jl"));
let finding = only(&findings, "missing-julia-compat");
assert_eq!(
&project[finding.range], "Foo",
"anchored on the first compat key, where the missing entry goes"
);
}
#[test]
fn a_missing_compat_table_anchors_on_the_name() {
let project = format!("name = \"Demo\"\nuuid = \"{DATES}\"\n");
let findings = check(&project, None, Some("Demo.jl"));
let finding = only(&findings, "missing-julia-compat");
assert_eq!(&project[finding.range], "\"Demo\"");
}
#[test]
fn an_absent_compat_table_suppresses_the_per_dependency_findings() {
let project =
format!("name = \"Demo\"\nuuid = \"{DATES}\"\n\n[deps]\nAbstractTrees = \"{TREES}\"\n");
assert_eq!(
checks(&check(&project, None, Some("Demo.jl"))),
vec!["missing-julia-compat"]
);
}
#[test]
fn a_compat_entry_naming_nothing_is_reported() {
let project = format!(
"name = \"Demo\"\nuuid = \"{DATES}\"\n\n[compat]\njulia = \"1.10\"\nGhost = \"1\"\n"
);
let findings = check(&project, None, Some("Demo.jl"));
let finding = only(&findings, "unknown-compat");
assert_eq!(&project[finding.range], "Ghost");
}
#[test]
fn compat_may_name_an_extra_or_a_weakdep() {
let project = format!(
"name = \"Demo\"\nuuid = \"{DATES}\"\n\n\
[extras]\nAbstractTrees = \"{TREES}\"\n\n\
[weakdeps]\nGhost = \"{DATES}\"\n\n\
[compat]\njulia = \"1.10\"\nAbstractTrees = \"0.4\"\nGhost = \"1\"\n"
);
assert!(!checks(&check(&project, None, Some("Demo.jl"))).contains(&"unknown-compat"));
}
#[test]
fn manifest_checks_stay_quiet_without_a_manifest() {
let project = format!(
"name = \"Demo\"\nuuid = \"{DATES}\"\n\n\
[deps]\nAbstractTrees = \"{TREES}\"\n\n[compat]\njulia = \"1.10\"\n"
);
let reported = checks(&check(&project, None, Some("Demo.jl")));
assert!(!reported.contains(&"missing-from-manifest"), "{reported:?}");
assert!(!reported.contains(&"uuid-mismatch"), "{reported:?}");
}
#[test]
fn a_dependency_absent_from_the_manifest_is_reported() {
let project = format!(
"name = \"Demo\"\nuuid = \"{DATES}\"\n\n\
[deps]\nGhost = \"{DATES}\"\n\n[compat]\njulia = \"1.10\"\nGhost = \"1\"\n"
);
let manifest = format!(
"manifest_format = \"2.0\"\n\n[[deps.AbstractTrees]]\nuuid = \"{TREES}\"\nversion = \"0.4.5\"\ngit-tree-sha1 = \"abc\"\n"
);
let findings = check(&project, Some(&manifest), Some("Demo.jl"));
let finding = only(&findings, "missing-from-manifest");
assert_eq!(&project[finding.range], "Ghost");
assert!(finding.message.contains("Pkg.instantiate"), "{finding:?}");
}
#[test]
fn a_uuid_disagreeing_with_the_manifest_is_reported() {
let project = format!(
"name = \"Demo\"\nuuid = \"{DATES}\"\n\n\
[deps]\nAbstractTrees = \"{DATES}\"\n\n[compat]\njulia = \"1.10\"\nAbstractTrees = \"0.4\"\n"
);
let manifest = format!(
"manifest_format = \"2.0\"\n\n[[deps.AbstractTrees]]\nuuid = \"{TREES}\"\nversion = \"0.4.5\"\ngit-tree-sha1 = \"abc\"\n"
);
let findings = check(&project, Some(&manifest), Some("Demo.jl"));
let finding = only(&findings, "uuid-mismatch");
assert_eq!(
&project[finding.range],
&format!("\"{DATES}\""),
"anchored on the value, which is the wrong part"
);
assert!(finding.message.contains(TREES), "names both: {finding:?}");
assert!(
!checks(&findings).contains(&"missing-from-manifest"),
"a mismatch is not also a disappearance"
);
}
#[test]
fn a_uuid_matching_any_manifest_entry_is_accepted() {
let project = format!(
"name = \"Demo\"\nuuid = \"{DATES}\"\n\n\
[deps]\nAbstractTrees = \"{TREES}\"\n\n[compat]\njulia = \"1.10\"\nAbstractTrees = \"0.4\"\n"
);
let manifest = format!(
"manifest_format = \"2.0\"\n\n\
[[deps.AbstractTrees]]\nuuid = \"{DATES}\"\nversion = \"0.1.0\"\ngit-tree-sha1 = \"abc\"\n\n\
[[deps.AbstractTrees]]\nuuid = \"{TREES}\"\nversion = \"0.4.5\"\ngit-tree-sha1 = \"def\"\n"
);
assert!(
!checks(&check(&project, Some(&manifest), Some("Demo.jl"))).contains(&"uuid-mismatch")
);
}
#[test]
fn a_dependency_without_a_compat_bound_is_reported() {
let project = format!(
"name = \"Demo\"\nuuid = \"{DATES}\"\n\n\
[deps]\nAbstractTrees = \"{TREES}\"\n\n[compat]\njulia = \"1.10\"\n"
);
let manifest = format!(
"manifest_format = \"2.0\"\n\n[[deps.AbstractTrees]]\nuuid = \"{TREES}\"\nversion = \"0.4.5\"\ngit-tree-sha1 = \"abc\"\n"
);
let findings = check(&project, Some(&manifest), Some("Demo.jl"));
let finding = only(&findings, "missing-compat");
assert_eq!(&project[finding.range], "AbstractTrees");
}
#[test]
fn a_stdlib_dependency_needs_no_compat_bound() {
let project = format!(
"name = \"Demo\"\nuuid = \"{TREES}\"\n\n\
[deps]\nDates = \"{DATES}\"\n\n[compat]\njulia = \"1.10\"\n"
);
let manifest = format!("manifest_format = \"2.0\"\n\n[[deps.Dates]]\nuuid = \"{DATES}\"\n");
assert!(
!checks(&check(&project, Some(&manifest), Some("Demo.jl"))).contains(&"missing-compat")
);
}
#[test]
fn missing_compat_stays_quiet_without_a_stdlib_oracle() {
let project = format!(
"name = \"Demo\"\nuuid = \"{TREES}\"\n\n\
[deps]\nDates = \"{DATES}\"\n\n[compat]\njulia = \"1.10\"\n"
);
assert!(!checks(&check(&project, None, Some("Demo.jl"))).contains(&"missing-compat"));
}
#[test]
fn a_sourced_dependency_needs_no_compat_bound() {
let project = format!(
"name = \"Demo\"\nuuid = \"{DATES}\"\n\n\
[deps]\nAbstractTrees = \"{TREES}\"\n\n\
[sources]\nAbstractTrees = {{ path = \"vendor/AbstractTrees\" }}\n\n\
[compat]\njulia = \"1.10\"\n"
);
let manifest = format!(
"manifest_format = \"2.0\"\n\n[[deps.AbstractTrees]]\nuuid = \"{TREES}\"\nversion = \"0.4.5\"\ngit-tree-sha1 = \"abc\"\n"
);
assert!(
!checks(&check(&project, Some(&manifest), Some("Demo.jl"))).contains(&"missing-compat")
);
}
#[test]
fn findings_are_ordered_by_position() {
let project = format!(
"name = \"Demo\"\nuuid = \"{DATES}\"\n\n\
[deps]\nGhost = \"{DATES}\"\n\n[compat]\nGhost = \"1\"\nStray = \"2\"\n"
);
let manifest = format!(
"manifest_format = \"2.0\"\n\n[[deps.AbstractTrees]]\nuuid = \"{TREES}\"\nversion = \"0.4.5\"\ngit-tree-sha1 = \"abc\"\n"
);
let findings = check(&project, Some(&manifest), None);
let starts: Vec<_> = findings.iter().map(|f| f.range.start()).collect();
let mut sorted = starts.clone();
sorted.sort();
assert_eq!(starts, sorted, "{findings:?}");
}
#[test]
fn this_repos_own_environment_is_clean() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let ctx = crate::environment::EnvContext {
workspace_root: root.to_path_buf(),
julia_project: None,
julia_depot_path: Some(root.join("target/nonexistent-depot").display().to_string()),
home: None,
julia_bindir: None,
path: None,
};
let env = crate::environment::resolve(&ctx)
.expect("the repo's own project resolves")
.expect("an environment");
let text = std::fs::read_to_string(&env.project_file).unwrap();
assert_eq!(env.project_file, root.join("Project.toml"));
assert!(env.name.is_none(), "a bare environment, not a package");
assert!(!env.direct_deps.is_empty(), "it declares dependencies");
assert!(!env.packages.is_empty(), "its manifest resolved");
let findings = semantic_findings(&env, &text);
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn semantic_checks_are_silent_on_an_unparseable_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Project.toml"), "name = \"Demo\"\n").unwrap();
let ctx = crate::environment::EnvContext {
workspace_root: dir.path().to_path_buf(),
julia_project: None,
julia_depot_path: Some(dir.path().join("depot").to_string_lossy().into_owned()),
home: None,
julia_bindir: None,
path: None,
};
let env = crate::environment::resolve(&ctx).unwrap().unwrap();
assert!(semantic_findings(&env, "uuid = \n").is_empty());
}
#[test]
fn a_spanless_failure_falls_back_to_the_first_line() {
let error = EnvironmentError::Read {
path: PathBuf::from("Project.toml"),
message: "permission denied".to_string(),
};
let finding = syntax_finding(
Path::new("Project.toml"),
"name = \"Demo\"\nx = 1\n",
&error,
);
assert_eq!(finding.range, TextRange::new(0.into(), 13.into()));
assert_eq!(finding.message, "permission denied");
}
}