use std::panic::AssertUnwindSafe;
use std::path::Path;
use std::str::FromStr;
use std::sync::{Arc, LazyLock};
use lsp_types::{
CodeDescription, Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString, Range, Uri,
};
use crate::config::LintConfig;
use crate::incremental::Analysis;
use crate::linter::docs::rule_doc_url;
use crate::linter::rules::{RESOLUTION_RULES, ResolutionContext, is_shipped_rule};
use crate::linter::{self, ResolvedRules, Severity, lint_parsed};
use crate::parser::parse;
use crate::semantic::SemanticModel;
use crate::text::{LineIndex, PositionEncoding, TextBuffer};
pub(crate) fn lint_diagnostics_via_db(
snapshot: &Analysis,
path: &Path,
text: &TextBuffer,
encoding: PositionEncoding,
rules: &ServerRules,
) -> Vec<Diagnostic> {
findings_to_lsp(
lint_findings_via_db(snapshot, path, text, rules),
text,
encoding,
)
}
pub fn compute_lint_diagnostics(text: &str, encoding: PositionEncoding) -> Vec<Diagnostic> {
findings_to_lsp(
lint_findings(text, &ServerRules::defaults()),
text,
encoding,
)
}
pub(crate) fn lint_findings_via_db(
snapshot: &Analysis,
path: &Path,
text: &TextBuffer,
rules: &ServerRules,
) -> Vec<linter::Diagnostic> {
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
if !snapshot.parse_diagnostics(file).is_empty() {
return Some(Vec::new());
}
let root = snapshot.parsed_tree(file);
let model = snapshot.semantic_model(file);
let workspace = snapshot.workspace_member(path);
let rules = rules.get(workspace.is_some());
let declared_deps = workspace
.as_ref()
.and_then(|(pkg, _)| snapshot.declared_deps(&pkg.name));
let resolution = Some(ResolutionContext {
packages: snapshot,
workspace,
declared_deps,
});
Some(lint_parsed(
Some(path),
&root,
model,
rules,
resolution,
&[],
))
}));
match cached {
Ok(Some(findings)) => findings,
Ok(None) | Err(_) => lint_findings(text, rules),
}
}
fn lint_findings(text: &str, rules: &ServerRules) -> Vec<linter::Diagnostic> {
let parsed = parse(text);
if !parsed.diagnostics.is_empty() {
return Vec::new();
}
let model = SemanticModel::build(&parsed.cst);
lint_parsed(None, &parsed.cst, &model, rules.get(false), None, &[])
}
const WORKSPACE_MEMBER_RULES: &[&str] = RESOLUTION_RULES;
pub(crate) struct ServerRules {
plain: ResolvedRules,
member: ResolvedRules,
}
impl ServerRules {
pub(crate) fn from_config(lint: &LintConfig) -> (Self, Vec<String>) {
let (plain, unknown) = ResolvedRules::resolve(lint);
let mut member_config = lint.clone();
member_config
.extend_select
.extend(WORKSPACE_MEMBER_RULES.iter().map(|id| id.to_string()));
let (member, _) = ResolvedRules::resolve(&member_config);
(Self { plain, member }, unknown)
}
pub(crate) fn get(&self, workspace_member: bool) -> &ResolvedRules {
if workspace_member {
&self.member
} else {
&self.plain
}
}
pub(crate) fn defaults() -> Arc<ServerRules> {
static DEFAULTS: LazyLock<Arc<ServerRules>> =
LazyLock::new(|| Arc::new(ServerRules::from_config(&LintConfig::default()).0));
Arc::clone(&DEFAULTS)
}
}
fn findings_to_lsp(
findings: Vec<linter::Diagnostic>,
text: &str,
encoding: PositionEncoding,
) -> Vec<Diagnostic> {
let line_index = LineIndex::new(text);
findings
.into_iter()
.map(|finding| finding_to_lsp(&finding, &line_index, encoding))
.collect()
}
pub(crate) fn finding_to_lsp(
finding: &linter::Diagnostic,
line_index: &LineIndex,
encoding: PositionEncoding,
) -> Diagnostic {
let tags = finding
.rule
.starts_with("unused-")
.then(|| vec![DiagnosticTag::UNNECESSARY]);
Diagnostic {
range: Range::new(
line_index.byte_to_position(finding.range.start().into(), encoding),
line_index.byte_to_position(finding.range.end().into(), encoding),
),
severity: Some(severity_to_lsp(finding.severity)),
code: Some(NumberOrString::String(finding.rule.to_string())),
code_description: is_shipped_rule(finding.rule)
.then(|| Uri::from_str(&rule_doc_url(finding.rule)).ok())
.flatten()
.map(|href| CodeDescription { href }),
source: Some("fatou".to_string()),
message: finding.message.body.clone(),
tags,
..Default::default()
}
}
fn severity_to_lsp(severity: Severity) -> DiagnosticSeverity {
match severity {
Severity::Error => DiagnosticSeverity::ERROR,
Severity::Warning => DiagnosticSeverity::WARNING,
Severity::Info => DiagnosticSeverity::INFORMATION,
Severity::Hint => DiagnosticSeverity::HINT,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::incremental::IncrementalDatabase;
use lsp_types::Position;
const UNUSED_LOCAL: &str = "function f(x)\n tmp = x + 1\n return x\nend\n";
#[test]
fn unused_binding_becomes_a_tagged_warning() {
let diags = compute_lint_diagnostics(UNUSED_LOCAL, PositionEncoding::Utf16);
assert_eq!(diags.len(), 1);
let diag = &diags[0];
assert_eq!(
diag.code,
Some(NumberOrString::String("unused-binding".to_string()))
);
assert_eq!(diag.severity, Some(DiagnosticSeverity::WARNING));
assert_eq!(diag.source.as_deref(), Some("fatou"));
assert_eq!(diag.tags, Some(vec![DiagnosticTag::UNNECESSARY]));
assert_eq!(
diag.range,
Range::new(Position::new(1, 4), Position::new(1, 7)),
"the diagnostic must cover `tmp`"
);
assert!(diag.message.contains("tmp"));
}
#[test]
fn a_finding_carries_a_link_to_its_reference_section() {
let diags = compute_lint_diagnostics(UNUSED_LOCAL, PositionEncoding::Utf16);
let href = diags[0]
.code_description
.as_ref()
.expect("finding should carry a code description")
.href
.as_str()
.to_string();
assert_eq!(
href,
"https://fatou.dev/reference/rules.html#unused-binding"
);
}
#[test]
fn non_unused_rules_are_untagged() {
let diags = compute_lint_diagnostics("if x = 1\nend\n", PositionEncoding::Utf16);
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].code,
Some(NumberOrString::String(
"assignment-in-condition".to_string()
))
);
assert_eq!(diags[0].tags, None);
}
#[test]
fn suppression_comments_are_honored() {
let suppressed = "function f(x)\n # fatou-ignore unused-binding\n tmp = x + 1\n return x\nend\n";
assert_eq!(
compute_lint_diagnostics(suppressed, PositionEncoding::Utf16),
Vec::new()
);
}
#[test]
fn a_parse_broken_document_yields_no_lint_findings() {
let broken = "function f(x)\n tmp = x + 1\n return x\n";
assert_eq!(
compute_lint_diagnostics(broken, PositionEncoding::Utf16),
Vec::new()
);
}
#[test]
fn undefined_name_runs_only_for_workspace_members() {
use super::super::cross_file::test_support::{member_path, workspace_db};
let src = "f() = helper() + helprr()\n";
let (db, _) = workspace_db(&["helper"], &[("a.jl", src)]);
let diags = lint_diagnostics_via_db(
&db.snapshot(),
&member_path("a.jl"),
&TextBuffer::new(src.to_string()),
PositionEncoding::Utf16,
&ServerRules::defaults(),
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(
diags[0].code,
Some(NumberOrString::String("undefined-name".to_string()))
);
assert!(diags[0].message.contains("helprr"));
let path = Path::new("/work/loose.jl");
let mut plain = IncrementalDatabase::default();
plain.upsert_file(path, src.to_string());
assert_eq!(
lint_diagnostics_via_db(
&plain.snapshot(),
path,
&TextBuffer::new(src.to_string()),
PositionEncoding::Utf16,
&ServerRules::defaults()
),
Vec::new()
);
}
#[test]
fn extend_select_preserves_default_diagnostics_for_loose_and_member_files() {
use super::super::cross_file::test_support::{member_path, workspace_db};
let src = "function f(x, spare)\n unused = x\n return missing_name\nend\n";
let config = LintConfig {
extend_select: vec!["undefined-name".to_string(), "unused-argument".to_string()],
severity: [("undefined-name".to_string(), Severity::Hint)].into(),
..Default::default()
};
let (rules, unknown) = ServerRules::from_config(&config);
assert!(unknown.is_empty());
let (member_db, _) = workspace_db(&[], &[("a.jl", src)]);
let dir = tempfile::tempdir().unwrap();
let plain_path = dir.path().join("loose.jl");
let mut plain_db = IncrementalDatabase::default();
plain_db.upsert_file(&plain_path, src.to_string());
for (db, path) in [(plain_db, plain_path), (member_db, member_path("a.jl"))] {
let diags = lint_diagnostics_via_db(
&db.snapshot(),
&path,
&TextBuffer::new(src.to_string()),
PositionEncoding::Utf16,
&rules,
);
let mut codes: Vec<_> = diags
.iter()
.map(|diag| match diag.code.as_ref().unwrap() {
NumberOrString::String(id) => id.as_str(),
NumberOrString::Number(_) => panic!("expected a rule ID"),
})
.collect();
codes.sort_unstable();
assert_eq!(
codes,
["undefined-name", "unused-argument", "unused-binding"]
);
assert_eq!(
diags
.iter()
.find(|diag| diag.code == Some(NumberOrString::String("undefined-name".into())))
.unwrap()
.severity,
Some(DiagnosticSeverity::HINT)
);
}
}
#[test]
fn extend_select_honors_ignore_in_both_server_rule_sets() {
let config = LintConfig {
select: Some(vec![]),
extend_select: vec![
"undefined-name".to_string(),
"unused-argument".to_string(),
"future-rule".to_string(),
],
ignore: vec!["undefined-name".to_string()],
..Default::default()
};
let (rules, unknown) = ServerRules::from_config(&config);
assert_eq!(unknown, ["future-rule"]);
for member in [false, true] {
let enabled = rules.get(member).enabled();
assert!(enabled.contains("unused-argument"));
assert!(!enabled.contains("undefined-name"));
assert!(!enabled.contains("unused-binding"));
}
}
#[test]
fn lint_via_db_matches_compute_and_falls_back() {
let encoding = PositionEncoding::Utf16;
let path = Path::new("/work/a.jl");
let expected = compute_lint_diagnostics(UNUSED_LOCAL, encoding);
assert_eq!(expected.len(), 1, "fixture must produce a finding");
let mut db = IncrementalDatabase::default();
db.upsert_file(path, UNUSED_LOCAL.to_string());
assert_eq!(
lint_diagnostics_via_db(
&db.snapshot(),
path,
&TextBuffer::new(UNUSED_LOCAL.to_string()),
encoding,
&ServerRules::defaults()
),
expected,
"cached-tree lint must match the re-parse path"
);
let mut stale = IncrementalDatabase::default();
stale.upsert_file(path, "y = 1\n".to_string());
assert_eq!(
lint_diagnostics_via_db(
&stale.snapshot(),
path,
&TextBuffer::new(UNUSED_LOCAL.to_string()),
encoding,
&ServerRules::defaults()
),
expected,
"version skew must fall back to the buffer text"
);
let empty = IncrementalDatabase::default();
assert_eq!(
lint_diagnostics_via_db(
&empty.snapshot(),
path,
&TextBuffer::new(UNUSED_LOCAL.to_string()),
encoding,
&ServerRules::defaults()
),
expected,
"untracked path must fall back to the buffer text"
);
}
}