use std::{collections::HashSet, path::Path};
use lsp_types::{
CodeDescription, Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, Location,
NumberOrString, Position, Range, Uri,
};
use miette::Severity;
use crate::violation::Violation;
#[allow(
clippy::cast_possible_truncation,
reason = "LSP uses u32, files larger than 4GB lines/columns are unrealistic"
)]
#[must_use]
const fn to_lsp_u32(value: usize) -> u32 {
if value > u32::MAX as usize {
u32::MAX
} else {
value as u32
}
}
pub struct LineIndex {
line_offsets: Vec<usize>,
}
impl LineIndex {
pub fn new(source: &str) -> Self {
let mut line_offsets = vec![0];
for (pos, ch) in source.char_indices() {
if ch == '\n' {
line_offsets.push(pos + 1);
}
}
Self { line_offsets }
}
pub fn offset_to_position(&self, offset: usize, source: &str) -> Position {
let line = self
.line_offsets
.partition_point(|&line_start| line_start <= offset)
.saturating_sub(1);
let line_start = self.line_offsets.get(line).copied().unwrap_or(0);
let end_offset = offset.min(source.len());
let column = source
.get(line_start..end_offset)
.map_or(0, |s| s.chars().count());
Position {
line: to_lsp_u32(line),
character: to_lsp_u32(column),
}
}
pub fn span_to_range(&self, source: &str, start: usize, end: usize) -> Range {
Range {
start: self.offset_to_position(start, source),
end: self.offset_to_position(end, source),
}
}
pub fn offset_to_line(&self, offset: usize) -> usize {
self.line_offsets
.partition_point(|&start| start <= offset)
.saturating_sub(1)
}
pub fn line_start(&self, line: usize) -> usize {
self.line_offsets.get(line).copied().unwrap_or(0)
}
pub fn line_content<'a>(&self, source: &'a str, line: usize) -> &'a str {
let start = self.line_start(line);
let end = self
.line_offsets
.get(line + 1)
.copied()
.unwrap_or(source.len());
source.get(start..end).unwrap_or("").trim_end_matches('\n')
}
}
const fn lint_level_to_severity(level: Severity) -> DiagnosticSeverity {
match level {
Severity::Error => DiagnosticSeverity::ERROR,
Severity::Warning => DiagnosticSeverity::WARNING,
Severity::Advice => DiagnosticSeverity::HINT,
}
}
type RangeKey = (u32, u32, u32, u32);
const fn range_key(r: &Range) -> RangeKey {
(r.start.line, r.start.character, r.end.line, r.end.character)
}
pub fn violation_to_diagnostic(
violation: &Violation,
source: &str,
line_index: &LineIndex,
file_uri: &Uri,
) -> Diagnostic {
let related_info = collect_related_info(violation, source, line_index, file_uri);
let file_span = violation.file_span();
Diagnostic {
range: line_index.span_to_range(source, file_span.start, file_span.end),
severity: Some(lint_level_to_severity(violation.lint_level)),
code: violation
.rule_id
.as_deref()
.map(|id| NumberOrString::String(id.to_string())),
code_description: violation
.doc_url
.and_then(|url| url.parse().ok())
.map(|href| CodeDescription { href }),
source: Some(String::from("nu-lint")),
message: violation.message.to_string(),
related_information: (!related_info.is_empty()).then_some(related_info),
tags: (!violation.diagnostic_tags.is_empty()).then(|| violation.diagnostic_tags.clone()),
..Default::default()
}
}
fn collect_related_info(
violation: &Violation,
source: &str,
line_index: &LineIndex,
file_uri: &Uri,
) -> Vec<DiagnosticRelatedInformation> {
let primary = violation
.primary_label
.as_ref()
.filter(|l| !l.is_empty())
.map(|label| (violation.file_span(), label.as_ref()));
let extras = violation.extra_labels.iter().filter_map(|(span, label)| {
label
.as_deref()
.filter(|l| !l.is_empty())
.map(|l| (span.file_span(), l))
});
primary
.into_iter()
.chain(extras)
.scan(HashSet::new(), |seen, (file_span, label)| {
let range = line_index.span_to_range(source, file_span.start, file_span.end);
Some(
seen.insert(range_key(&range))
.then(|| DiagnosticRelatedInformation {
location: Location {
uri: file_uri.clone(),
range,
},
message: label.to_string(),
}),
)
})
.flatten()
.collect()
}
pub fn extra_labels_to_hint_diagnostics(
violation: &Violation,
source: &str,
line_index: &LineIndex,
) -> Vec<Diagnostic> {
let primary_span = violation.file_span();
let primary_range = line_index.span_to_range(source, primary_span.start, primary_span.end);
let mut seen_ranges: HashSet<RangeKey> = HashSet::new();
violation
.extra_labels
.iter()
.filter_map(|(span, label)| {
let file_span = span.file_span();
let range = line_index.span_to_range(source, file_span.start, file_span.end);
if range == primary_range || !seen_ranges.insert(range_key(&range)) {
return None;
}
let message = label
.as_deref()
.map_or_else(|| String::from("Related location"), ToString::to_string);
Some(Diagnostic {
range,
severity: Some(DiagnosticSeverity::HINT),
code: violation
.rule_id
.as_deref()
.map(|id| NumberOrString::String(id.to_string())),
source: Some(String::from("nu-lint")),
message,
..Default::default()
})
})
.collect()
}
#[must_use]
pub const fn ranges_overlap(a: &Range, b: &Range) -> bool {
a.start.line <= b.end.line && b.start.line <= a.end.line
}
#[must_use]
pub const fn is_nushell_language_id(language_id: &str) -> bool {
language_id.eq_ignore_ascii_case("nushell") || language_id.eq_ignore_ascii_case("nu")
}
#[must_use]
pub fn is_nushell_uri(uri: &Uri) -> bool {
uri.scheme().is_some_and(|s| s.as_str() == "repl")
|| Path::new(uri.path().as_str())
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("nu"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{span::FileSpan, violation::Detection};
#[test]
fn line_index_multiple_lines() {
let source = "line1\nline2\nline3";
let index = LineIndex::new(source);
assert_eq!(index.line_offsets, vec![0, 6, 12]);
}
#[test]
fn offset_to_position_multiple_lines() {
let source = "line1\nline2\nline3";
let index = LineIndex::new(source);
let pos = index.offset_to_position(12, source);
assert_eq!(pos.line, 2);
assert_eq!(pos.character, 0);
let pos = index.offset_to_position(14, source);
assert_eq!(pos.line, 2);
assert_eq!(pos.character, 2);
}
#[test]
fn offset_to_position_beyond_end() {
let source = "hello";
let index = LineIndex::new(source);
let pos = index.offset_to_position(100, source);
assert_eq!(pos.line, 0);
assert_eq!(pos.character, 5);
}
#[test]
fn span_to_range_single_line() {
let source = "let x = 5";
let index = LineIndex::new(source);
let range = index.span_to_range(source, 4, 5);
assert_eq!(range.start.line, 0);
assert_eq!(range.start.character, 4);
assert_eq!(range.end.line, 0);
assert_eq!(range.end.character, 5);
}
#[test]
fn span_to_range_multiline() {
let source = "def foo [] {\n bar\n}";
let index = LineIndex::new(source);
let range = index.span_to_range(source, 0, 22);
assert_eq!(range.start.line, 0);
assert_eq!(range.start.character, 0);
assert_eq!(range.end.line, 2);
}
#[test]
fn diagnostic_includes_related_information() {
let source = "if $x {\n if $y {\n foo\n }\n}";
let line_index = LineIndex::new(source);
let file_uri: Uri = "file:///test.nu".parse().unwrap();
let mut violation =
Detection::from_file_span("Nested if can be collapsed", FileSpan::new(0, 39))
.with_primary_label("outer if");
violation
.extra_labels
.push((FileSpan::new(12, 35).into(), Some("inner if".to_string())));
let mut violation = Violation::from_detected(violation, None, Some("Combine with 'and'"));
violation.lint_level = Severity::Warning;
let diagnostic = violation_to_diagnostic(&violation, source, &line_index, &file_uri);
assert_eq!(diagnostic.message, "Nested if can be collapsed");
let related = diagnostic
.related_information
.expect("should have related_information");
assert_eq!(related.len(), 2);
assert_eq!(related[0].message, "outer if");
assert_eq!(related[1].message, "inner if");
}
#[test]
fn ranges_overlap_different_lines() {
let a = Range {
start: Position {
line: 1,
character: 0,
},
end: Position {
line: 3,
character: 0,
},
};
let b = Range {
start: Position {
line: 2,
character: 0,
},
end: Position {
line: 4,
character: 0,
},
};
assert!(ranges_overlap(&a, &b));
}
#[test]
fn ranges_no_overlap() {
let a = Range {
start: Position {
line: 1,
character: 0,
},
end: Position {
line: 2,
character: 0,
},
};
let b = Range {
start: Position {
line: 5,
character: 0,
},
end: Position {
line: 6,
character: 0,
},
};
assert!(!ranges_overlap(&a, &b));
}
#[test]
fn extra_labels_with_same_span_as_primary_are_filtered() {
let source = "let x = (";
let line_index = LineIndex::new(source);
let primary_span = FileSpan::new(0, 9);
let mut violation = Detection::from_file_span("Unclosed parenthesis", primary_span)
.with_primary_label("expected closing )");
violation
.extra_labels
.push((primary_span.into(), Some("here".to_string())));
violation.extra_labels.push((
FileSpan::new(5, 6).into(),
Some("different location".to_string()),
));
let violation = Violation::from_detected(violation, None, None);
let hints = extra_labels_to_hint_diagnostics(&violation, source, &line_index);
assert_eq!(hints.len(), 1);
assert_eq!(hints[0].message, "different location");
}
#[test]
fn duplicate_extra_label_ranges_are_filtered() {
let source = "mut result = []\n$result = $result ++ [1]";
let line_index = LineIndex::new(source);
let mut violation =
Detection::from_file_span("Capture of mutable variable", FileSpan::new(0, 15));
for _ in 0..3 {
violation.extra_labels.push((
FileSpan::new(16, 23).into(),
Some("Capture of mutable variable".to_string()),
));
}
let violation = Violation::from_detected(violation, None, None);
let hints = extra_labels_to_hint_diagnostics(&violation, source, &line_index);
assert_eq!(hints.len(), 1);
assert_eq!(hints[0].message, "Capture of mutable variable");
}
#[test]
fn extra_labels_create_hint_diagnostics() {
let source = "def foo [] {\n [1, 2, 3]\n}";
let line_index = LineIndex::new(source);
let mut violation =
Detection::from_file_span("Function missing output type", FileSpan::new(0, 9))
.with_primary_label("add output type");
violation.extra_labels.push((
FileSpan::new(17, 26).into(),
Some("returned here".to_string()),
));
let violation = Violation::from_detected(violation, None, None);
let hints = extra_labels_to_hint_diagnostics(&violation, source, &line_index);
assert_eq!(hints.len(), 1);
assert_eq!(hints[0].severity, Some(DiagnosticSeverity::HINT));
assert_eq!(hints[0].message, "returned here");
assert_eq!(hints[0].range.start.line, 1);
}
}