use std::path::Path;
use lsp_types as types;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use shuck_indexer::LineIndex;
use shuck_linter::{
AnalysisRequest, Applicability, Diagnostic as ShuckDiagnostic, Edit as ShuckEdit, Fix,
Severity, ShellCheckCodeMap, ShellDialect,
};
use crate::edit::{LanguageId, RangeExt};
use crate::session::{DocumentSnapshot, ShuckSettings};
use crate::{DIAGNOSTIC_NAME, PositionEncoding};
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DiagnosticApplicability {
Safe,
Unsafe,
}
impl From<Applicability> for DiagnosticApplicability {
fn from(value: Applicability) -> Self {
match value {
Applicability::Safe => Self::Safe,
Applicability::Unsafe => Self::Unsafe,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub(crate) struct AssociatedDiagnosticData {
pub(crate) title: String,
pub(crate) code: String,
pub(crate) edits: Vec<types::TextEdit>,
pub(crate) directive_edit: Option<types::TextEdit>,
pub(crate) applicability: DiagnosticApplicability,
}
#[derive(Clone)]
pub(crate) struct RawDocumentDiagnostics {
pub(crate) shell_diagnostics: Vec<ShuckDiagnostic>,
pub(crate) parse_error: Option<ParseErrorDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ParseErrorDiagnostic {
pub(crate) line: usize,
pub(crate) column: usize,
pub(crate) message: String,
}
pub fn generate_diagnostics(snapshot: &DocumentSnapshot) -> Vec<types::Diagnostic> {
let Some(analysis) = snapshot.analysis() else {
return Vec::new();
};
let source = analysis.source();
let raw = analysis.raw_diagnostics(snapshot);
let directive_edits = directive_edits_by_line(
snapshot,
source,
analysis.line_index(),
analysis.path(),
&raw.shell_diagnostics,
);
let mut diagnostics = raw
.shell_diagnostics
.iter()
.map(|diagnostic| {
let directive_edit = directive_edits
.get(&diagnostic.span.start.line)
.cloned()
.flatten();
to_lsp_diagnostic(
snapshot,
diagnostic,
source,
analysis.line_index(),
directive_edit,
)
})
.collect::<Vec<_>>();
if snapshot.client_settings().show_syntax_errors()
&& let Some(parse_error) = raw.parse_error.clone()
{
diagnostics.insert(
0,
parse_error_to_lsp(snapshot, source, analysis.line_index(), parse_error),
);
}
diagnostics
}
pub(crate) fn collect_raw_diagnostics_for_snapshot(
snapshot: &DocumentSnapshot,
) -> Option<RawDocumentDiagnostics> {
snapshot
.analysis()
.map(|analysis| analysis.raw_diagnostics(snapshot).clone())
}
pub(crate) fn fix_all_document_edits(
snapshot: &DocumentSnapshot,
applicability: Applicability,
) -> Vec<types::TextEdit> {
let Some(raw) = collect_raw_diagnostics_for_snapshot(snapshot) else {
return Vec::new();
};
let source = snapshot.query().document().contents();
let fixable_diagnostics = raw
.shell_diagnostics
.iter()
.filter(|diagnostic| {
snapshot
.shuck_settings()
.fixable_rules()
.contains(diagnostic.rule)
})
.cloned()
.collect::<Vec<_>>();
let applied = shuck_linter::apply_fixes(source, &fixable_diagnostics, applicability);
if applied.fixes_applied == 0 || applied.code == source {
return Vec::new();
}
crate::edit::single_replacement_edit(
source,
&applied.code,
snapshot.query().document().index(),
snapshot.encoding(),
)
.into_iter()
.collect()
}
pub(crate) fn directive_edit_for_line(
snapshot: &DocumentSnapshot,
line: usize,
) -> Option<types::TextEdit> {
let query = snapshot.query();
let source = query.document().contents();
let path = query.file_path();
let edit = shuck_linter::build_ignore_edit_for_line(
source,
snapshot.shuck_settings().linter(),
line,
None,
path.as_deref(),
)?;
Some(to_lsp_text_edit(
&edit,
source,
query.document().index(),
snapshot.encoding(),
))
}
pub(crate) fn associated_diagnostic_data(
_snapshot: &DocumentSnapshot,
diagnostic: &types::Diagnostic,
) -> Option<AssociatedDiagnosticData> {
diagnostic
.data
.clone()
.and_then(|value| serde_json::from_value(value).ok())
}
pub(crate) fn collect_raw_diagnostics_for_analysis(
snapshot: &DocumentSnapshot,
analysis: &crate::analysis::DocumentAnalysis,
) -> RawDocumentDiagnostics {
let shellcheck_map = ShellCheckCodeMap::default();
let shell_diagnostics = AnalysisRequest::from_parse_result(
analysis.parse_result(),
analysis.source(),
snapshot.shuck_settings().linter(),
)
.with_optional_source_path(analysis.path())
.with_shellcheck_map(&shellcheck_map)
.lint();
let parse_error = analysis.parse_result().is_err().then(|| {
let shuck_parser::Error::Parse {
message,
line,
column,
} = analysis.parse_result().strict_error();
ParseErrorDiagnostic {
line,
column,
message: message.clone(),
}
});
RawDocumentDiagnostics {
shell_diagnostics,
parse_error,
}
}
fn to_lsp_diagnostic(
snapshot: &DocumentSnapshot,
diagnostic: &ShuckDiagnostic,
source: &str,
line_index: &LineIndex,
directive_edit: Option<types::TextEdit>,
) -> types::Diagnostic {
let code = diagnostic.code().to_owned();
let data = associated_diagnostic_data_for_shuck(
snapshot,
diagnostic,
source,
line_index,
directive_edit,
);
types::Diagnostic {
range: crate::edit::to_lsp_range(
diagnostic.span.to_range(),
source,
line_index,
snapshot.encoding(),
),
severity: Some(diagnostic_severity(diagnostic.severity)),
code: Some(types::NumberOrString::String(code)),
code_description: None,
source: Some(DIAGNOSTIC_NAME.into()),
message: diagnostic.message.clone(),
related_information: None,
tags: None,
data,
}
}
fn associated_diagnostic_data_for_shuck(
snapshot: &DocumentSnapshot,
diagnostic: &ShuckDiagnostic,
source: &str,
line_index: &LineIndex,
directive_edit: Option<types::TextEdit>,
) -> Option<serde_json::Value> {
let edits = diagnostic
.fix
.as_ref()
.into_iter()
.flat_map(Fix::edits)
.map(|edit| to_lsp_text_edit(edit, source, line_index, snapshot.encoding()))
.collect();
let applicability = diagnostic
.fix
.as_ref()
.map_or(DiagnosticApplicability::Safe, |fix| {
fix.applicability().into()
});
let title = diagnostic
.fix_title
.clone()
.unwrap_or_else(|| diagnostic.message.clone());
match serde_json::to_value(AssociatedDiagnosticData {
title,
code: diagnostic.code().to_owned(),
edits,
directive_edit,
applicability,
}) {
Ok(data) => Some(data),
Err(error) => {
tracing::error!("failed to serialize associated diagnostic data: {error}");
None
}
}
}
fn directive_edits_by_line(
snapshot: &DocumentSnapshot,
source: &str,
line_index: &LineIndex,
path: Option<&Path>,
diagnostics: &[ShuckDiagnostic],
) -> FxHashMap<usize, Option<types::TextEdit>> {
let mut edits = FxHashMap::default();
for line in diagnostics
.iter()
.map(|diagnostic| diagnostic.span.start.line)
{
edits.entry(line).or_insert_with(|| {
shuck_linter::build_ignore_edit_for_line(
source,
snapshot.shuck_settings().linter(),
line,
None,
path,
)
.map(|edit| to_lsp_text_edit(&edit, source, line_index, snapshot.encoding()))
});
}
edits
}
fn parse_error_to_lsp(
snapshot: &DocumentSnapshot,
source: &str,
line_index: &LineIndex,
parse_error: ParseErrorDiagnostic,
) -> types::Diagnostic {
let line = parse_error.line.saturating_sub(1) as u32;
let character = parse_error.column.saturating_sub(1) as u32;
let start = types::Position::new(line, character);
let end = types::Position::new(line, character);
let range = types::Range { start, end };
let adjusted_range = range.to_text_range(source, line_index, snapshot.encoding());
types::Diagnostic {
range: crate::edit::to_lsp_range(adjusted_range, source, line_index, snapshot.encoding()),
severity: Some(types::DiagnosticSeverity::ERROR),
code: None,
code_description: None,
source: Some(DIAGNOSTIC_NAME.into()),
message: format!("parse error {}", parse_error.message),
related_information: None,
tags: None,
data: None,
}
}
fn to_lsp_text_edit(
edit: &ShuckEdit,
source: &str,
line_index: &LineIndex,
encoding: PositionEncoding,
) -> types::TextEdit {
types::TextEdit {
range: crate::edit::to_lsp_range(edit.range(), source, line_index, encoding),
new_text: edit.content().to_owned(),
}
}
fn diagnostic_severity(severity: Severity) -> types::DiagnosticSeverity {
match severity {
Severity::Hint => types::DiagnosticSeverity::HINT,
Severity::Warning => types::DiagnosticSeverity::WARNING,
Severity::Error => types::DiagnosticSeverity::ERROR,
}
}
pub(crate) fn infer_document_shell_from_parts(
settings: &ShuckSettings,
language_id: Option<LanguageId>,
query_source: &str,
path: Option<&Path>,
) -> Option<ShellDialect> {
if settings.linter().shell != ShellDialect::Unknown {
return Some(settings.linter().shell);
}
if let Some(shell) = infer_source_declared_shell(query_source) {
return Some(shell);
}
let shell = ShellDialect::infer(query_source, path);
match language_id_preference(language_id) {
LanguageIdPreference::Concrete(shell) => Some(shell),
LanguageIdPreference::GenericShell => Some(match shell {
ShellDialect::Unknown => ShellDialect::Sh,
shell => shell,
}),
LanguageIdPreference::Unknown => (shell != ShellDialect::Unknown).then_some(shell),
}
}
fn infer_source_declared_shell(source: &str) -> Option<ShellDialect> {
infer_shellcheck_header(source).or_else(|| infer_shebang_shell(source))
}
fn infer_shebang_shell(source: &str) -> Option<ShellDialect> {
let interpreter = shuck_parser::shebang::interpreter_name(source.lines().next()?)?;
let shell = ShellDialect::from_name(interpreter);
(shell != ShellDialect::Unknown).then_some(shell)
}
fn infer_shellcheck_header(source: &str) -> Option<ShellDialect> {
for line in source.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with("#!") {
continue;
}
let Some(comment) = trimmed.strip_prefix('#') else {
break;
};
let body = comment.trim_start().to_ascii_lowercase();
let Some(shell_name) = body.strip_prefix("shellcheck shell=") else {
continue;
};
let shell =
ShellDialect::from_name(shell_name.split_whitespace().next().unwrap_or_default());
if shell != ShellDialect::Unknown {
return Some(shell);
}
}
None
}
enum LanguageIdPreference {
Concrete(ShellDialect),
GenericShell,
Unknown,
}
fn language_id_preference(language_id: Option<LanguageId>) -> LanguageIdPreference {
match language_id {
Some(LanguageId::Bash) => LanguageIdPreference::Concrete(ShellDialect::Bash),
Some(LanguageId::Sh) => LanguageIdPreference::Concrete(ShellDialect::Sh),
Some(LanguageId::Zsh) => LanguageIdPreference::Concrete(ShellDialect::Zsh),
Some(LanguageId::Ksh) => LanguageIdPreference::Concrete(ShellDialect::Ksh),
Some(LanguageId::ShellScript) => LanguageIdPreference::GenericShell,
Some(LanguageId::Other) | None => LanguageIdPreference::Unknown,
}
}
#[cfg(test)]
mod tests {
use crossbeam::channel;
use lsp_types::{ClientCapabilities, Url};
use super::*;
use crate::{
Client, ClientOptions, GlobalOptions, Session, TextDocument, Workspace, Workspaces,
};
fn make_snapshot(
path: &Path,
source: &str,
language_id: &str,
encoding: PositionEncoding,
settings: ClientOptions,
) -> DocumentSnapshot {
let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
let (client_sender, _client_receiver) = channel::unbounded();
let client = Client::new(main_loop_sender, client_sender);
let workspaces = Workspaces::new(vec![Workspace::default(
Url::from_file_path(std::env::temp_dir())
.expect("temporary directory should convert to a file URL"),
)]);
let global = GlobalOptions::default().into_settings(client.clone());
let mut session = Session::new(
&ClientCapabilities::default(),
encoding,
global,
&workspaces,
&client,
)
.expect("test session should initialize");
let uri = Url::from_file_path(path).expect("test path should convert to a file URL");
session.update_client_options(settings);
session.open_text_document(
uri.clone(),
TextDocument::new(source.to_owned(), 1).with_language_id(language_id),
);
session
.take_snapshot(uri)
.expect("test document should produce a snapshot")
}
#[test]
fn reports_native_shuck_diagnostic_with_fix_data() {
let snapshot = make_snapshot(
&std::env::temp_dir().join("unused-assignment.sh"),
"foo=1\n",
"shellscript",
PositionEncoding::UTF16,
ClientOptions::default(),
);
let diagnostics = generate_diagnostics(&snapshot);
assert_eq!(diagnostics.len(), 1);
let diagnostic = &diagnostics[0];
assert_eq!(diagnostic.source.as_deref(), Some(DIAGNOSTIC_NAME));
assert_eq!(
diagnostic.code,
Some(types::NumberOrString::String("C001".to_owned()))
);
let data: AssociatedDiagnosticData = serde_json::from_value(
diagnostic
.data
.clone()
.expect("diagnostic payload should be serialized"),
)
.expect("diagnostic payload should deserialize");
assert_eq!(data.title, "rename the unused assignment target to `_`");
assert_eq!(data.code, "C001");
assert!(data.directive_edit.is_some());
assert_eq!(data.applicability, DiagnosticApplicability::Unsafe);
assert_eq!(data.edits.len(), 1);
assert_eq!(data.edits[0].new_text, "_");
assert_eq!(data.edits[0].range.start.line, 0);
assert_eq!(data.edits[0].range.start.character, 0);
assert_eq!(data.edits[0].range.end.character, 3);
}
#[test]
fn skips_non_shell_documents() {
let snapshot = make_snapshot(
&std::env::temp_dir().join("README.md"),
"# Heading\n",
"markdown",
PositionEncoding::UTF16,
ClientOptions::default(),
);
assert!(generate_diagnostics(&snapshot).is_empty());
}
#[test]
fn surfaces_parse_errors_when_requested() {
let snapshot = make_snapshot(
&std::env::temp_dir().join("parse-error.sh"),
"if true\n",
"shellscript",
PositionEncoding::UTF16,
ClientOptions {
show_syntax_errors: Some(true),
..ClientOptions::default()
},
);
let diagnostics = generate_diagnostics(&snapshot);
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("parse error"))
);
}
#[test]
fn uses_utf16_ranges_for_diagnostics_and_fix_edits() {
let snapshot = make_snapshot(
&std::env::temp_dir().join("emoji.sh"),
"printf '🙂'\nfoo=1\n",
"shellscript",
PositionEncoding::UTF16,
ClientOptions::default(),
);
let diagnostics = generate_diagnostics(&snapshot);
let data: AssociatedDiagnosticData = serde_json::from_value(
diagnostics[0]
.data
.clone()
.expect("diagnostic payload should serialize"),
)
.expect("diagnostic payload should deserialize");
assert_eq!(data.edits[0].range.start.line, 1);
assert_eq!(data.edits[0].range.start.character, 0);
}
}