Skip to main content

shuck_server/
lint.rs

1use std::path::Path;
2
3use lsp_types as types;
4use rustc_hash::FxHashMap;
5use serde::{Deserialize, Serialize};
6use shuck_indexer::LineIndex;
7use shuck_linter::{
8    AnalysisRequest, Applicability, Diagnostic as ShuckDiagnostic, Edit as ShuckEdit, Fix,
9    Severity, ShellCheckCodeMap, ShellDialect,
10};
11
12use crate::edit::{LanguageId, RangeExt};
13use crate::session::{DocumentSnapshot, ShuckSettings};
14use crate::{DIAGNOSTIC_NAME, PositionEncoding};
15
16#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
17pub(crate) enum DiagnosticApplicability {
18    Safe,
19    Unsafe,
20}
21
22impl From<Applicability> for DiagnosticApplicability {
23    fn from(value: Applicability) -> Self {
24        match value {
25            Applicability::Safe => Self::Safe,
26            Applicability::Unsafe => Self::Unsafe,
27        }
28    }
29}
30
31#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
32pub(crate) struct AssociatedDiagnosticData {
33    pub(crate) title: String,
34    pub(crate) code: String,
35    pub(crate) edits: Vec<types::TextEdit>,
36    pub(crate) directive_edit: Option<types::TextEdit>,
37    pub(crate) applicability: DiagnosticApplicability,
38}
39
40#[derive(Clone)]
41pub(crate) struct RawDocumentDiagnostics {
42    pub(crate) shell_diagnostics: Vec<ShuckDiagnostic>,
43    pub(crate) parse_error: Option<ParseErrorDiagnostic>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub(crate) struct ParseErrorDiagnostic {
48    pub(crate) line: usize,
49    pub(crate) column: usize,
50    pub(crate) message: String,
51}
52
53/// Generate LSP diagnostics for a document snapshot.
54pub fn generate_diagnostics(snapshot: &DocumentSnapshot) -> Vec<types::Diagnostic> {
55    let Some(analysis) = snapshot.analysis() else {
56        return Vec::new();
57    };
58
59    let source = analysis.source();
60    let raw = analysis.raw_diagnostics(snapshot);
61    let directive_edits = directive_edits_by_line(
62        snapshot,
63        source,
64        analysis.line_index(),
65        analysis.path(),
66        &raw.shell_diagnostics,
67    );
68    let mut diagnostics = raw
69        .shell_diagnostics
70        .iter()
71        .map(|diagnostic| {
72            let directive_edit = directive_edits
73                .get(&diagnostic.span.start.line)
74                .cloned()
75                .flatten();
76            to_lsp_diagnostic(
77                snapshot,
78                diagnostic,
79                source,
80                analysis.line_index(),
81                directive_edit,
82            )
83        })
84        .collect::<Vec<_>>();
85
86    if snapshot.client_settings().show_syntax_errors()
87        && let Some(parse_error) = raw.parse_error.clone()
88    {
89        diagnostics.insert(
90            0,
91            parse_error_to_lsp(snapshot, source, analysis.line_index(), parse_error),
92        );
93    }
94
95    diagnostics
96}
97
98pub(crate) fn collect_raw_diagnostics_for_snapshot(
99    snapshot: &DocumentSnapshot,
100) -> Option<RawDocumentDiagnostics> {
101    snapshot
102        .analysis()
103        .map(|analysis| analysis.raw_diagnostics(snapshot).clone())
104}
105
106pub(crate) fn fix_all_document_edits(
107    snapshot: &DocumentSnapshot,
108    applicability: Applicability,
109) -> Vec<types::TextEdit> {
110    let Some(raw) = collect_raw_diagnostics_for_snapshot(snapshot) else {
111        return Vec::new();
112    };
113
114    let source = snapshot.query().document().contents();
115    let fixable_diagnostics = raw
116        .shell_diagnostics
117        .iter()
118        .filter(|diagnostic| {
119            snapshot
120                .shuck_settings()
121                .fixable_rules()
122                .contains(diagnostic.rule)
123        })
124        .cloned()
125        .collect::<Vec<_>>();
126    let applied = shuck_linter::apply_fixes(source, &fixable_diagnostics, applicability);
127    if applied.fixes_applied == 0 || applied.code == source {
128        return Vec::new();
129    }
130
131    crate::edit::single_replacement_edit(
132        source,
133        &applied.code,
134        snapshot.query().document().index(),
135        snapshot.encoding(),
136    )
137    .into_iter()
138    .collect()
139}
140
141pub(crate) fn directive_edit_for_line(
142    snapshot: &DocumentSnapshot,
143    line: usize,
144) -> Option<types::TextEdit> {
145    let query = snapshot.query();
146    let source = query.document().contents();
147    let path = query.file_path();
148    let edit = shuck_linter::build_ignore_edit_for_line(
149        source,
150        snapshot.shuck_settings().linter(),
151        line,
152        None,
153        path.as_deref(),
154    )?;
155    Some(to_lsp_text_edit(
156        &edit,
157        source,
158        query.document().index(),
159        snapshot.encoding(),
160    ))
161}
162
163pub(crate) fn associated_diagnostic_data(
164    _snapshot: &DocumentSnapshot,
165    diagnostic: &types::Diagnostic,
166) -> Option<AssociatedDiagnosticData> {
167    diagnostic
168        .data
169        .clone()
170        .and_then(|value| serde_json::from_value(value).ok())
171}
172
173pub(crate) fn collect_raw_diagnostics_for_analysis(
174    snapshot: &DocumentSnapshot,
175    analysis: &crate::analysis::DocumentAnalysis,
176) -> RawDocumentDiagnostics {
177    let shellcheck_map = ShellCheckCodeMap::default();
178    let shell_diagnostics = AnalysisRequest::from_parse_result(
179        analysis.parse_result(),
180        analysis.source(),
181        snapshot.shuck_settings().linter(),
182    )
183    .with_optional_source_path(analysis.path())
184    .with_shellcheck_map(&shellcheck_map)
185    .lint();
186    let parse_error = analysis.parse_result().is_err().then(|| {
187        let shuck_parser::Error::Parse {
188            message,
189            line,
190            column,
191        } = analysis.parse_result().strict_error();
192        ParseErrorDiagnostic {
193            line,
194            column,
195            message: message.clone(),
196        }
197    });
198
199    RawDocumentDiagnostics {
200        shell_diagnostics,
201        parse_error,
202    }
203}
204
205fn to_lsp_diagnostic(
206    snapshot: &DocumentSnapshot,
207    diagnostic: &ShuckDiagnostic,
208    source: &str,
209    line_index: &LineIndex,
210    directive_edit: Option<types::TextEdit>,
211) -> types::Diagnostic {
212    let code = diagnostic.code().to_owned();
213    let data = associated_diagnostic_data_for_shuck(
214        snapshot,
215        diagnostic,
216        source,
217        line_index,
218        directive_edit,
219    );
220
221    types::Diagnostic {
222        range: crate::edit::to_lsp_range(
223            diagnostic.span.to_range(),
224            source,
225            line_index,
226            snapshot.encoding(),
227        ),
228        severity: Some(diagnostic_severity(diagnostic.severity)),
229        code: Some(types::NumberOrString::String(code)),
230        code_description: None,
231        source: Some(DIAGNOSTIC_NAME.into()),
232        message: diagnostic.message.clone(),
233        related_information: None,
234        tags: None,
235        data,
236    }
237}
238
239fn associated_diagnostic_data_for_shuck(
240    snapshot: &DocumentSnapshot,
241    diagnostic: &ShuckDiagnostic,
242    source: &str,
243    line_index: &LineIndex,
244    directive_edit: Option<types::TextEdit>,
245) -> Option<serde_json::Value> {
246    let edits = diagnostic
247        .fix
248        .as_ref()
249        .into_iter()
250        .flat_map(Fix::edits)
251        .map(|edit| to_lsp_text_edit(edit, source, line_index, snapshot.encoding()))
252        .collect();
253    let applicability = diagnostic
254        .fix
255        .as_ref()
256        .map_or(DiagnosticApplicability::Safe, |fix| {
257            fix.applicability().into()
258        });
259    let title = diagnostic
260        .fix_title
261        .clone()
262        .unwrap_or_else(|| diagnostic.message.clone());
263    match serde_json::to_value(AssociatedDiagnosticData {
264        title,
265        code: diagnostic.code().to_owned(),
266        edits,
267        directive_edit,
268        applicability,
269    }) {
270        Ok(data) => Some(data),
271        Err(error) => {
272            tracing::error!("failed to serialize associated diagnostic data: {error}");
273            None
274        }
275    }
276}
277
278fn directive_edits_by_line(
279    snapshot: &DocumentSnapshot,
280    source: &str,
281    line_index: &LineIndex,
282    path: Option<&Path>,
283    diagnostics: &[ShuckDiagnostic],
284) -> FxHashMap<usize, Option<types::TextEdit>> {
285    let mut edits = FxHashMap::default();
286
287    for line in diagnostics
288        .iter()
289        .map(|diagnostic| diagnostic.span.start.line)
290    {
291        edits.entry(line).or_insert_with(|| {
292            shuck_linter::build_ignore_edit_for_line(
293                source,
294                snapshot.shuck_settings().linter(),
295                line,
296                None,
297                path,
298            )
299            .map(|edit| to_lsp_text_edit(&edit, source, line_index, snapshot.encoding()))
300        });
301    }
302
303    edits
304}
305
306fn parse_error_to_lsp(
307    snapshot: &DocumentSnapshot,
308    source: &str,
309    line_index: &LineIndex,
310    parse_error: ParseErrorDiagnostic,
311) -> types::Diagnostic {
312    let line = parse_error.line.saturating_sub(1) as u32;
313    let character = parse_error.column.saturating_sub(1) as u32;
314    let start = types::Position::new(line, character);
315    let end = types::Position::new(line, character);
316    let range = types::Range { start, end };
317    let adjusted_range = range.to_text_range(source, line_index, snapshot.encoding());
318
319    types::Diagnostic {
320        range: crate::edit::to_lsp_range(adjusted_range, source, line_index, snapshot.encoding()),
321        severity: Some(types::DiagnosticSeverity::ERROR),
322        code: None,
323        code_description: None,
324        source: Some(DIAGNOSTIC_NAME.into()),
325        message: format!("parse error {}", parse_error.message),
326        related_information: None,
327        tags: None,
328        data: None,
329    }
330}
331
332fn to_lsp_text_edit(
333    edit: &ShuckEdit,
334    source: &str,
335    line_index: &LineIndex,
336    encoding: PositionEncoding,
337) -> types::TextEdit {
338    types::TextEdit {
339        range: crate::edit::to_lsp_range(edit.range(), source, line_index, encoding),
340        new_text: edit.content().to_owned(),
341    }
342}
343
344fn diagnostic_severity(severity: Severity) -> types::DiagnosticSeverity {
345    match severity {
346        Severity::Hint => types::DiagnosticSeverity::HINT,
347        Severity::Warning => types::DiagnosticSeverity::WARNING,
348        Severity::Error => types::DiagnosticSeverity::ERROR,
349    }
350}
351
352pub(crate) fn infer_document_shell_from_parts(
353    settings: &ShuckSettings,
354    language_id: Option<LanguageId>,
355    query_source: &str,
356    path: Option<&Path>,
357) -> Option<ShellDialect> {
358    if settings.linter().shell != ShellDialect::Unknown {
359        return Some(settings.linter().shell);
360    }
361
362    if let Some(shell) = infer_source_declared_shell(query_source) {
363        return Some(shell);
364    }
365
366    let shell = ShellDialect::infer(query_source, path);
367
368    match language_id_preference(language_id) {
369        LanguageIdPreference::Concrete(shell) => Some(shell),
370        LanguageIdPreference::GenericShell => Some(match shell {
371            ShellDialect::Unknown => ShellDialect::Sh,
372            shell => shell,
373        }),
374        LanguageIdPreference::Unknown => (shell != ShellDialect::Unknown).then_some(shell),
375    }
376}
377
378fn infer_source_declared_shell(source: &str) -> Option<ShellDialect> {
379    infer_shellcheck_header(source).or_else(|| infer_shebang_shell(source))
380}
381
382fn infer_shebang_shell(source: &str) -> Option<ShellDialect> {
383    let interpreter = shuck_parser::shebang::interpreter_name(source.lines().next()?)?;
384    let shell = ShellDialect::from_name(interpreter);
385    (shell != ShellDialect::Unknown).then_some(shell)
386}
387
388fn infer_shellcheck_header(source: &str) -> Option<ShellDialect> {
389    for line in source.lines() {
390        let trimmed = line.trim_start();
391        if trimmed.is_empty() || trimmed.starts_with("#!") {
392            continue;
393        }
394
395        let Some(comment) = trimmed.strip_prefix('#') else {
396            break;
397        };
398        let body = comment.trim_start().to_ascii_lowercase();
399        let Some(shell_name) = body.strip_prefix("shellcheck shell=") else {
400            continue;
401        };
402
403        let shell =
404            ShellDialect::from_name(shell_name.split_whitespace().next().unwrap_or_default());
405        if shell != ShellDialect::Unknown {
406            return Some(shell);
407        }
408    }
409
410    None
411}
412
413enum LanguageIdPreference {
414    Concrete(ShellDialect),
415    GenericShell,
416    Unknown,
417}
418
419fn language_id_preference(language_id: Option<LanguageId>) -> LanguageIdPreference {
420    match language_id {
421        Some(LanguageId::Bash) => LanguageIdPreference::Concrete(ShellDialect::Bash),
422        Some(LanguageId::Sh) => LanguageIdPreference::Concrete(ShellDialect::Sh),
423        Some(LanguageId::Zsh) => LanguageIdPreference::Concrete(ShellDialect::Zsh),
424        Some(LanguageId::Ksh) => LanguageIdPreference::Concrete(ShellDialect::Ksh),
425        Some(LanguageId::ShellScript) => LanguageIdPreference::GenericShell,
426        Some(LanguageId::Other) | None => LanguageIdPreference::Unknown,
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use crossbeam::channel;
433    use lsp_types::{ClientCapabilities, Url};
434
435    use super::*;
436    use crate::{
437        Client, ClientOptions, GlobalOptions, Session, TextDocument, Workspace, Workspaces,
438    };
439
440    fn make_snapshot(
441        path: &Path,
442        source: &str,
443        language_id: &str,
444        encoding: PositionEncoding,
445        settings: ClientOptions,
446    ) -> DocumentSnapshot {
447        let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
448        let (client_sender, _client_receiver) = channel::unbounded();
449        let client = Client::new(main_loop_sender, client_sender);
450        let workspaces = Workspaces::new(vec![Workspace::default(
451            Url::from_file_path(std::env::temp_dir())
452                .expect("temporary directory should convert to a file URL"),
453        )]);
454        let global = GlobalOptions::default().into_settings(client.clone());
455        let mut session = Session::new(
456            &ClientCapabilities::default(),
457            encoding,
458            global,
459            &workspaces,
460            &client,
461        )
462        .expect("test session should initialize");
463
464        let uri = Url::from_file_path(path).expect("test path should convert to a file URL");
465        session.update_client_options(settings);
466        session.open_text_document(
467            uri.clone(),
468            TextDocument::new(source.to_owned(), 1).with_language_id(language_id),
469        );
470
471        session
472            .take_snapshot(uri)
473            .expect("test document should produce a snapshot")
474    }
475
476    #[test]
477    fn reports_native_shuck_diagnostic_with_fix_data() {
478        let snapshot = make_snapshot(
479            &std::env::temp_dir().join("unused-assignment.sh"),
480            "foo=1\n",
481            "shellscript",
482            PositionEncoding::UTF16,
483            ClientOptions::default(),
484        );
485
486        let diagnostics = generate_diagnostics(&snapshot);
487        assert_eq!(diagnostics.len(), 1);
488
489        let diagnostic = &diagnostics[0];
490        assert_eq!(diagnostic.source.as_deref(), Some(DIAGNOSTIC_NAME));
491        assert_eq!(
492            diagnostic.code,
493            Some(types::NumberOrString::String("C001".to_owned()))
494        );
495
496        let data: AssociatedDiagnosticData = serde_json::from_value(
497            diagnostic
498                .data
499                .clone()
500                .expect("diagnostic payload should be serialized"),
501        )
502        .expect("diagnostic payload should deserialize");
503        assert_eq!(data.title, "rename the unused assignment target to `_`");
504        assert_eq!(data.code, "C001");
505        assert!(data.directive_edit.is_some());
506        assert_eq!(data.applicability, DiagnosticApplicability::Unsafe);
507        assert_eq!(data.edits.len(), 1);
508        assert_eq!(data.edits[0].new_text, "_");
509        assert_eq!(data.edits[0].range.start.line, 0);
510        assert_eq!(data.edits[0].range.start.character, 0);
511        assert_eq!(data.edits[0].range.end.character, 3);
512    }
513
514    #[test]
515    fn skips_non_shell_documents() {
516        let snapshot = make_snapshot(
517            &std::env::temp_dir().join("README.md"),
518            "# Heading\n",
519            "markdown",
520            PositionEncoding::UTF16,
521            ClientOptions::default(),
522        );
523
524        assert!(generate_diagnostics(&snapshot).is_empty());
525    }
526
527    #[test]
528    fn surfaces_parse_errors_when_requested() {
529        let snapshot = make_snapshot(
530            &std::env::temp_dir().join("parse-error.sh"),
531            "if true\n",
532            "shellscript",
533            PositionEncoding::UTF16,
534            ClientOptions {
535                show_syntax_errors: Some(true),
536                ..ClientOptions::default()
537            },
538        );
539
540        let diagnostics = generate_diagnostics(&snapshot);
541        assert!(
542            diagnostics
543                .iter()
544                .any(|diagnostic| diagnostic.message.contains("parse error"))
545        );
546    }
547
548    #[test]
549    fn uses_utf16_ranges_for_diagnostics_and_fix_edits() {
550        let snapshot = make_snapshot(
551            &std::env::temp_dir().join("emoji.sh"),
552            "printf '🙂'\nfoo=1\n",
553            "shellscript",
554            PositionEncoding::UTF16,
555            ClientOptions::default(),
556        );
557
558        let diagnostics = generate_diagnostics(&snapshot);
559        let data: AssociatedDiagnosticData = serde_json::from_value(
560            diagnostics[0]
561                .data
562                .clone()
563                .expect("diagnostic payload should serialize"),
564        )
565        .expect("diagnostic payload should deserialize");
566        assert_eq!(data.edits[0].range.start.line, 1);
567        assert_eq!(data.edits[0].range.start.character, 0);
568    }
569}