use std::panic::AssertUnwindSafe;
use std::path::Path;
use std::sync::LazyLock;
use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString, Range};
use crate::config::LintConfig;
use crate::incremental::Analysis;
use crate::linter::rules::ResolutionContext;
use crate::linter::{self, ResolvedRules, Severity, all_rules, lint_parsed};
use crate::parser::parse;
use crate::semantic::SemanticModel;
use crate::text::{LineIndex, PositionEncoding};
pub(crate) fn lint_diagnostics_via_db(
snapshot: &Analysis,
path: &Path,
text: &str,
encoding: PositionEncoding,
) -> Vec<Diagnostic> {
findings_to_lsp(lint_findings_via_db(snapshot, path, text), text, encoding)
}
pub fn compute_lint_diagnostics(text: &str, encoding: PositionEncoding) -> Vec<Diagnostic> {
findings_to_lsp(lint_findings(text), text, encoding)
}
pub(crate) fn lint_findings_via_db(
snapshot: &Analysis,
path: &Path,
text: &str,
) -> 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 = server_rules(workspace.is_some());
let resolution = Some(ResolutionContext {
packages: snapshot,
workspace,
});
Some(lint_parsed(
Some(path),
text,
&root,
model,
rules,
resolution,
))
}));
match cached {
Ok(Some(findings)) => findings,
Ok(None) | Err(_) => lint_findings(text),
}
}
fn lint_findings(text: &str) -> Vec<linter::Diagnostic> {
let parsed = parse(text);
if !parsed.diagnostics.is_empty() {
return Vec::new();
}
let model = SemanticModel::build(&parsed.cst);
lint_parsed(None, text, &parsed.cst, &model, server_rules(false), None)
}
fn server_rules(workspace_member: bool) -> &'static ResolvedRules {
static DEFAULT_RULES: LazyLock<ResolvedRules> =
LazyLock::new(|| ResolvedRules::resolve(&LintConfig::default()).0);
static WORKSPACE_RULES: LazyLock<ResolvedRules> = LazyLock::new(|| {
let config = LintConfig {
select: Some(
all_rules()
.iter()
.filter(|rule| rule.default_enabled() || rule.id() == "undefined-name")
.map(|rule| rule.id().to_string())
.collect(),
),
..Default::default()
};
ResolvedRules::resolve(&config).0
});
if workspace_member {
&WORKSPACE_RULES
} else {
&DEFAULT_RULES
}
}
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())),
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 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"),
src,
PositionEncoding::Utf16,
);
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, src, PositionEncoding::Utf16),
Vec::new()
);
}
#[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, UNUSED_LOCAL, encoding),
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, UNUSED_LOCAL, encoding),
expected,
"version skew must fall back to the buffer text"
);
let empty = IncrementalDatabase::default();
assert_eq!(
lint_diagnostics_via_db(&empty.snapshot(), path, UNUSED_LOCAL, encoding),
expected,
"untracked path must fall back to the buffer text"
);
}
}