use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Range};
use crate::incremental::ProjectGraph;
use crate::project::include_call_sites;
use crate::project::scripts::ScriptAnalysis;
use crate::syntax::SyntaxNode;
use crate::text::{PositionEncoding, TextBuffer};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GraphSource {
Package,
Script { version: Option<i32> },
}
struct Problem {
path: String,
message: String,
severity: DiagnosticSeverity,
code: &'static str,
}
pub(crate) fn graph_diagnostics(
graph: &ProjectGraph,
encoding: PositionEncoding,
mut source: impl FnMut(&Path) -> Option<(String, SyntaxNode)>,
) -> BTreeMap<PathBuf, Vec<Diagnostic>> {
let mut by_file: BTreeMap<PathBuf, Vec<Problem>> = BTreeMap::new();
for unresolved in &graph.unresolved {
by_file
.entry(unresolved.from.clone())
.or_default()
.push(Problem {
path: unresolved.path.clone(),
message: format!(
"cannot resolve include: \"{}\" was not found",
unresolved.path
),
severity: DiagnosticSeverity::ERROR,
code: "missing-include-file",
});
}
for cycle in &graph.cycles {
by_file
.entry(cycle.from.clone())
.or_default()
.push(Problem {
path: cycle.path.clone(),
message: format!(
"include cycle: \"{}\" transitively includes this file",
cycle.path
),
severity: DiagnosticSeverity::WARNING,
code: "include-cycle",
});
}
render_problems(by_file, encoding, |path| {
source(path).map(|(text, tree)| (Arc::new(TextBuffer::new(text)), tree))
})
}
pub(crate) fn script_graph_diagnostics(
scripts: &ScriptAnalysis,
encoding: PositionEncoding,
source: impl FnMut(&Path) -> Option<(Arc<TextBuffer>, SyntaxNode)>,
) -> BTreeMap<PathBuf, Vec<Diagnostic>> {
let problems = scripts
.failed_includes()
.into_iter()
.map(|(from, paths)| {
(
from,
paths
.into_iter()
.map(|path| Problem {
message: format!(
"cannot read include: \"{path}\" is missing or unreadable"
),
path,
severity: DiagnosticSeverity::ERROR,
code: "missing-include-file",
})
.collect(),
)
})
.collect();
render_problems(problems, encoding, source)
}
pub(crate) fn merge_script_diagnostics(
mut package: BTreeMap<PathBuf, Vec<Diagnostic>>,
scripts: &BTreeMap<PathBuf, Vec<Diagnostic>>,
sources: &BTreeSet<PathBuf>,
) -> BTreeMap<PathBuf, Vec<Diagnostic>> {
for (path, diagnostics) in &mut package {
if sources.contains(path) {
diagnostics.retain(|diag| {
diag.code != Some(NumberOrString::String("missing-include-file".into()))
});
}
}
for (path, diagnostics) in scripts {
package
.entry(path.clone())
.or_default()
.extend(diagnostics.iter().cloned());
}
package.retain(|_, diagnostics| !diagnostics.is_empty());
package
}
fn render_problems(
by_file: BTreeMap<PathBuf, Vec<Problem>>,
encoding: PositionEncoding,
mut source: impl FnMut(&Path) -> Option<(Arc<TextBuffer>, SyntaxNode)>,
) -> BTreeMap<PathBuf, Vec<Diagnostic>> {
let mut out = BTreeMap::new();
for (from, problems) in by_file {
let Some((text, tree)) = source(&from) else {
continue;
};
let line_index = text.line_index();
let sites = include_call_sites(&tree);
let mut diagnostics = Vec::new();
for problem in &problems {
for (path, range) in &sites {
if *path == problem.path {
diagnostics.push(Diagnostic {
range: Range::new(
line_index.byte_to_position(range.start().into(), encoding),
line_index.byte_to_position(range.end().into(), encoding),
),
severity: Some(problem.severity),
source: Some("fatou".to_string()),
code: Some(NumberOrString::String(problem.code.into())),
message: problem.message.clone(),
..Default::default()
});
}
}
}
if !diagnostics.is_empty() {
out.insert(from, diagnostics);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::incremental::{CycleEdge, UnresolvedInclude};
use crate::parser::parse;
fn tree_of(src: &str) -> SyntaxNode {
parse(src).cst
}
#[test]
fn marks_the_unresolved_include_call() {
let graph = ProjectGraph {
unresolved: vec![UnresolvedInclude {
from: PathBuf::from("/pkg/src/Pkg.jl"),
path: "missing.jl".to_string(),
}],
..Default::default()
};
let text = "include(\"a.jl\")\ninclude(\"missing.jl\")\n";
let out = graph_diagnostics(&graph, PositionEncoding::Utf16, |path| {
(path == Path::new("/pkg/src/Pkg.jl")).then(|| (text.to_string(), tree_of(text)))
});
let diags = &out[&PathBuf::from("/pkg/src/Pkg.jl")];
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
assert_eq!(diags[0].range.start.line, 1);
}
#[test]
fn marks_the_cycle_back_edge() {
let graph = ProjectGraph {
cycles: vec![CycleEdge {
from: PathBuf::from("/pkg/src/b.jl"),
path: "a.jl".to_string(),
to: PathBuf::from("/pkg/src/a.jl"),
}],
..Default::default()
};
let text = "include(\"a.jl\")\n";
let out = graph_diagnostics(&graph, PositionEncoding::Utf16, |_| {
Some((text.to_string(), tree_of(text)))
});
let diags = &out[&PathBuf::from("/pkg/src/b.jl")];
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING));
}
#[test]
fn unsourceable_file_is_skipped() {
let graph = ProjectGraph {
unresolved: vec![UnresolvedInclude {
from: PathBuf::from("/pkg/src/gone.jl"),
path: "x.jl".to_string(),
}],
..Default::default()
};
let out = graph_diagnostics(&graph, PositionEncoding::Utf16, |_| None);
assert!(out.is_empty());
}
#[test]
fn script_failures_supersede_package_failures_and_deduplicate_entries() {
let dir = tempfile::tempdir().unwrap();
let entry = dir.path().join("main.jl");
let other = dir.path().join("other.jl");
let helper = dir.path().join("helper.jl");
let text = Arc::new(TextBuffer::new("include(\"\")\n"));
let sources = BTreeMap::from([
(entry.clone(), Ok(Arc::from("include(\"helper.jl\")\n"))),
(other.clone(), Ok(Arc::from("include(\"helper.jl\")\n"))),
(helper.clone(), Ok(text.text_arc())),
(dir.path().to_path_buf(), Err("directory".into())),
]);
let scripts = ScriptAnalysis::from_sources(&[entry, other], &sources);
let script = script_graph_diagnostics(&scripts, PositionEncoding::Utf16, |_| {
Some((Arc::clone(&text), tree_of(&text)))
});
assert_eq!(script[&helper].len(), 1);
assert_eq!(
script[&helper][0].range,
Range::new(
lsp_types::Position::new(0, 0),
lsp_types::Position::new(0, 11)
)
);
let graph = ProjectGraph {
unresolved: vec![UnresolvedInclude {
from: helper.clone(),
path: "".into(),
}],
..Default::default()
};
let package = graph_diagnostics(&graph, PositionEncoding::Utf16, |_| {
Some((text.to_string(), tree_of(&text)))
});
let members = sources.keys().cloned().collect();
let merged = merge_script_diagnostics(package.clone(), &script, &members);
assert_eq!(merged[&helper].len(), 1);
assert!(merge_script_diagnostics(package, &BTreeMap::new(), &members).is_empty());
}
}