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
53pub 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}