1use std::collections::BTreeSet;
2use std::fs;
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use rustc_hash::FxHashMap;
7use shuck_ast::{TextRange, TextSize};
8use shuck_indexer::Indexer;
9use shuck_parser::{
10 Error as ParseError,
11 parser::{ParseResult, Parser},
12};
13
14use crate::{
15 AnalysisRequest, Diagnostic, LinterSettings, PluginResolver, Rule, ShellDialect,
16 SourcePathResolver,
17};
18
19use super::{
20 ShellCheckCodeMap, SuppressionAction, SuppressionDirective, SuppressionSource, parse_directives,
21};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct AddIgnoreResult {
25 pub directives_added: usize,
26 pub diagnostics: Vec<Diagnostic>,
27 pub parse_error: Option<AddIgnoreParseError>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct AddIgnoreParseError {
32 pub message: String,
33 pub line: usize,
34 pub column: usize,
35}
36
37pub fn add_ignores_to_path(
38 path: &Path,
39 settings: &LinterSettings,
40 reason: Option<&str>,
41) -> Result<AddIgnoreResult> {
42 add_ignores_to_path_with_resolvers(path, settings, reason, None, None)
43}
44
45pub fn add_ignores_to_path_with_resolvers(
46 path: &Path,
47 settings: &LinterSettings,
48 reason: Option<&str>,
49 source_path_resolver: Option<&(dyn SourcePathResolver + Send + Sync)>,
50 plugin_resolver: Option<&(dyn PluginResolver + Send + Sync)>,
51) -> Result<AddIgnoreResult> {
52 let shellcheck_map = ShellCheckCodeMap::default();
53 let mut source =
54 fs::read_to_string(path).with_context(|| format!("read source from {}", path.display()))?;
55 let mut analysis = analyze_source(
56 &source,
57 settings,
58 &shellcheck_map,
59 Some(path),
60 source_path_resolver,
61 plugin_resolver,
62 );
63 let mut directives_added = 0usize;
64
65 let target_lines = analysis
66 .diagnostics
67 .iter()
68 .map(|diagnostic| diagnostic.span.start.line)
69 .collect::<BTreeSet<_>>();
70
71 for line in target_lines {
72 analysis = analyze_source(
73 &source,
74 settings,
75 &shellcheck_map,
76 Some(path),
77 source_path_resolver,
78 plugin_resolver,
79 );
80 let line_diagnostics = analysis
81 .diagnostics
82 .iter()
83 .filter(|diagnostic| diagnostic.span.start.line == line)
84 .cloned()
85 .collect::<Vec<_>>();
86 if line_diagnostics.is_empty() {
87 continue;
88 }
89
90 let Some(edit) = build_ignore_edit(
91 &source,
92 &analysis,
93 line,
94 &line_diagnostics,
95 reason,
96 &shellcheck_map,
97 ) else {
98 continue;
99 };
100
101 let candidate_source = apply_edit(&source, &edit);
102 let candidate_analysis = analyze_source(
103 &candidate_source,
104 settings,
105 &shellcheck_map,
106 Some(path),
107 source_path_resolver,
108 plugin_resolver,
109 );
110 if !edit_is_valid(&analysis, &candidate_analysis, line, &line_diagnostics) {
111 continue;
112 }
113
114 source = candidate_source;
115 analysis = candidate_analysis;
116 directives_added += 1;
117 }
118
119 if directives_added > 0 {
120 fs::write(path, source.as_bytes())
121 .with_context(|| format!("write updated source to {}", path.display()))?;
122 }
123
124 Ok(AddIgnoreResult {
125 directives_added,
126 diagnostics: analysis.diagnostics,
127 parse_error: analysis.parse_error,
128 })
129}
130
131pub fn build_ignore_edit_for_line(
132 source: &str,
133 settings: &LinterSettings,
134 line: usize,
135 reason: Option<&str>,
136 source_path: Option<&Path>,
137) -> Option<crate::Edit> {
138 let shellcheck_map = ShellCheckCodeMap::default();
139 let analysis = analyze_source(source, settings, &shellcheck_map, source_path, None, None);
140 let line_diagnostics = analysis
141 .diagnostics
142 .iter()
143 .filter(|diagnostic| diagnostic.span.start.line == line)
144 .cloned()
145 .collect::<Vec<_>>();
146 if line_diagnostics.is_empty() {
147 return None;
148 }
149
150 let edit = build_ignore_edit(
151 source,
152 &analysis,
153 line,
154 &line_diagnostics,
155 reason,
156 &shellcheck_map,
157 )?;
158 let candidate_source = apply_edit(source, &edit);
159 let candidate_analysis = analyze_source(
160 &candidate_source,
161 settings,
162 &shellcheck_map,
163 source_path,
164 None,
165 None,
166 );
167 edit_is_valid(&analysis, &candidate_analysis, line, &line_diagnostics).then_some(
168 crate::Edit::replacement_at(
169 usize::from(edit.range.start()),
170 usize::from(edit.range.end()),
171 edit.replacement,
172 ),
173 )
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
177struct AnalyzedSource {
178 directives: Vec<SuppressionDirective>,
179 diagnostics: Vec<Diagnostic>,
180 strict_parse_error: Option<AddIgnoreParseError>,
181 parse_error: Option<AddIgnoreParseError>,
182 indexer: Indexer,
183}
184
185fn analyze_source(
186 source: &str,
187 settings: &LinterSettings,
188 shellcheck_map: &ShellCheckCodeMap,
189 source_path: Option<&Path>,
190 source_path_resolver: Option<&(dyn SourcePathResolver + Send + Sync)>,
191 plugin_resolver: Option<&(dyn PluginResolver + Send + Sync)>,
192) -> AnalyzedSource {
193 let shell = resolve_shell(settings, source, source_path);
194 let parse_result = parse_for_lint(source, shell);
195 let indexer = Indexer::new(source, &parse_result);
196 let directives = parse_directives(source, indexer.comment_index(), shellcheck_map);
197 let diagnostics = AnalysisRequest::from_parse_result(&parse_result, source, settings)
198 .with_optional_source_path(source_path)
199 .with_directives(&directives)
200 .with_optional_source_path_resolver(source_path_resolver)
201 .with_optional_plugin_resolver(plugin_resolver)
202 .lint();
203 let strict_parse_error = strict_parse_error(&parse_result);
204 let parse_error = strict_parse_error
205 .clone()
206 .filter(|_| diagnostics.is_empty());
207
208 AnalyzedSource {
209 directives,
210 diagnostics,
211 strict_parse_error,
212 parse_error,
213 indexer,
214 }
215}
216
217fn strict_parse_error(parse_result: &ParseResult) -> Option<AddIgnoreParseError> {
218 if !parse_result.is_err() {
219 return None;
220 }
221
222 let ParseError::Parse {
223 message,
224 line,
225 column,
226 } = parse_result.strict_error();
227 Some(AddIgnoreParseError {
228 message: message.clone(),
229 line,
230 column,
231 })
232}
233
234fn resolve_shell(
235 settings: &LinterSettings,
236 source: &str,
237 source_path: Option<&Path>,
238) -> ShellDialect {
239 if settings.shell == ShellDialect::Unknown {
240 ShellDialect::infer(source, source_path)
241 } else {
242 settings.shell
243 }
244}
245
246fn parse_for_lint(source: &str, shell: ShellDialect) -> ParseResult {
247 Parser::with_dialect(source, shell.parser_dialect()).parse()
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
251struct IgnoreEdit {
252 range: TextRange,
253 replacement: String,
254}
255
256fn build_ignore_edit(
257 source: &str,
258 analysis: &AnalyzedSource,
259 line: usize,
260 diagnostics: &[Diagnostic],
261 reason: Option<&str>,
262 shellcheck_map: &ShellCheckCodeMap,
263) -> Option<IgnoreEdit> {
264 let line_range = analysis.indexer.line_index().line_range(line, source)?;
265 let mut line_rules = diagnostics
266 .iter()
267 .map(|diagnostic| diagnostic.rule)
268 .collect::<Vec<_>>();
269 line_rules.sort_unstable_by_key(|rule| rule.code());
270 line_rules.dedup();
271 let existing_ignore = analysis
272 .directives
273 .iter()
274 .find(|directive| directive_matches_ignore_line(directive, line));
275
276 let mut merged_rules = existing_ignore
277 .map(|directive| directive.codes.clone())
278 .unwrap_or_default();
279 for rule in line_rules {
280 if !merged_rules.contains(&rule) {
281 merged_rules.push(rule);
282 }
283 }
284 merged_rules.sort_unstable_by_key(|rule| rule.code());
285 merged_rules.dedup();
286
287 let comment_reason = reason
288 .filter(|reason| !reason.is_empty())
289 .map(str::trim)
290 .filter(|reason| !reason.is_empty())
291 .or_else(|| {
292 existing_ignore.and_then(|directive| {
293 existing_ignore_reason(directive.range.slice(source), shellcheck_map)
294 })
295 });
296
297 let mut comment = match existing_ignore.map(|directive| directive.source) {
298 Some(SuppressionSource::ShellCheck) => {
299 format!(
300 "# shellcheck disable={}",
301 join_shellcheck_codes(&merged_rules, shellcheck_map)
302 )
303 }
304 _ => format!("# shuck: ignore={}", join_codes(&merged_rules)),
305 };
306 if let Some(comment_reason) = comment_reason {
307 comment.push_str(" # ");
308 comment.push_str(comment_reason);
309 }
310
311 if let Some(directive) = existing_ignore {
312 let replacement = preserve_trailing_carriage_return(directive.range.slice(source), comment);
313 return (directive.range.slice(source) != replacement).then_some(IgnoreEdit {
314 range: directive.range,
315 replacement,
316 });
317 }
318
319 if line_ends_with_continuation(line_range, source) {
320 return None;
321 }
322
323 let insertion_offset = inline_comment_insertion_offset(line_range, source);
324 Some(IgnoreEdit {
325 range: TextRange::new(insertion_offset, insertion_offset),
326 replacement: format!(" {comment}"),
327 })
328}
329
330fn preserve_trailing_carriage_return(existing: &str, mut replacement: String) -> String {
331 if existing.ends_with('\r') {
332 replacement.push('\r');
333 }
334 replacement
335}
336
337fn existing_ignore_reason<'a>(
338 comment_text: &'a str,
339 shellcheck_map: &ShellCheckCodeMap,
340) -> Option<&'a str> {
341 if let Some(remainder) =
342 strip_prefix_ignore_ascii_case(strip_comment_prefix(comment_text), "shuck:")
343 {
344 let (without_reason, reason) = remainder
345 .split_once('#')
346 .map_or((remainder, None), |(before, after)| {
347 (before, Some(after.trim()))
348 });
349 let (action, codes) = without_reason.split_once('=')?;
350 if parse_shuck_action(action.trim()) != Some(SuppressionAction::Ignore)
351 || parse_codes(codes, |code| resolve_suppression_code(code, shellcheck_map)).is_empty()
352 {
353 return None;
354 }
355
356 return reason.filter(|reason| !reason.is_empty());
357 }
358
359 let remainder = shellcheck_comment_remainder(comment_text)?;
360 let (without_reason, reason) = remainder
361 .split_once('#')
362 .map_or((remainder, None), |(before, after)| {
363 (before, Some(after.trim()))
364 });
365 let has_disable_codes = without_reason.split_ascii_whitespace().any(|part| {
366 let Some(codes) = strip_prefix_ignore_ascii_case(part, "disable=") else {
367 return false;
368 };
369 !parse_codes(codes, |code| resolve_suppression_code(code, shellcheck_map)).is_empty()
370 });
371 has_disable_codes
372 .then_some(reason)
373 .flatten()
374 .filter(|reason| !reason.is_empty())
375}
376
377fn edit_is_valid(
378 current: &AnalyzedSource,
379 candidate: &AnalyzedSource,
380 line: usize,
381 line_diagnostics: &[Diagnostic],
382) -> bool {
383 if candidate.strict_parse_error != current.strict_parse_error {
384 return false;
385 }
386
387 let line = match u32::try_from(line) {
388 Ok(line) => line,
389 Err(_) => return false,
390 };
391 let mut target_rules = line_diagnostics
392 .iter()
393 .map(|diagnostic| diagnostic.rule)
394 .collect::<Vec<_>>();
395 target_rules.sort_unstable_by_key(|rule| rule.code());
396 target_rules.dedup();
397 let recognized = candidate.directives.iter().any(|directive| {
398 directive_matches_ignore_line(directive, usize::try_from(line).unwrap_or_default())
399 && directive.line == line
400 && target_rules
401 .iter()
402 .all(|rule| directive.codes.iter().any(|candidate| candidate == rule))
403 });
404 if !recognized {
405 return false;
406 }
407
408 if candidate.diagnostics.iter().any(|diagnostic| {
409 diagnostic.span.start.line == usize::try_from(line).unwrap_or_default()
410 && target_rules.contains(&diagnostic.rule)
411 }) {
412 return false;
413 }
414
415 diagnostics_match_after_removing_targets(
416 ¤t.diagnostics,
417 &candidate.diagnostics,
418 line_diagnostics,
419 )
420}
421
422fn diagnostics_match_after_removing_targets(
423 current: &[Diagnostic],
424 candidate: &[Diagnostic],
425 removed: &[Diagnostic],
426) -> bool {
427 let mut current_counts = diagnostic_counts(current);
428 let removed_counts = diagnostic_counts(removed);
429
430 for (key, removed_count) in removed_counts {
431 let Some(current_count) = current_counts.get_mut(&key) else {
432 return false;
433 };
434 if *current_count < removed_count {
435 return false;
436 }
437 *current_count -= removed_count;
438 }
439
440 current_counts.retain(|_, count| *count > 0);
441 current_counts == diagnostic_counts(candidate)
442}
443
444fn diagnostic_counts(diagnostics: &[Diagnostic]) -> FxHashMap<DiagnosticKey, usize> {
445 let mut counts = FxHashMap::default();
446 for key in diagnostics.iter().map(DiagnosticKey::new) {
447 *counts.entry(key).or_insert(0usize) += 1;
448 }
449 counts
450}
451
452#[derive(Debug, Clone, PartialEq, Eq, Hash)]
453struct DiagnosticKey {
454 rule: Rule,
455 start_line: usize,
456 start_column: usize,
457 end_line: usize,
458 end_column: usize,
459 message: String,
460}
461
462impl DiagnosticKey {
463 fn new(diagnostic: &Diagnostic) -> Self {
464 Self {
465 rule: diagnostic.rule,
466 start_line: diagnostic.span.start.line,
467 start_column: diagnostic.span.start.column,
468 end_line: diagnostic.span.end.line,
469 end_column: diagnostic.span.end.column,
470 message: diagnostic.message.clone(),
471 }
472 }
473}
474
475fn apply_edit(source: &str, edit: &IgnoreEdit) -> String {
476 let mut output = String::with_capacity(source.len() + edit.replacement.len());
477 let start = usize::from(edit.range.start());
478 let end = usize::from(edit.range.end());
479 output.push_str(&source[..start]);
480 output.push_str(&edit.replacement);
481 output.push_str(&source[end..]);
482 output
483}
484
485fn inline_comment_insertion_offset(line_range: TextRange, source: &str) -> TextSize {
486 let mut end = line_range.end();
487 if usize::from(end) > usize::from(line_range.start())
488 && source.as_bytes()[usize::from(end) - 1] == b'\r'
489 {
490 end = TextSize::new(end.to_u32() - 1);
491 }
492 end
493}
494
495fn line_ends_with_continuation(line_range: TextRange, source: &str) -> bool {
496 line_range
497 .slice(source)
498 .strip_suffix('\r')
499 .unwrap_or(line_range.slice(source))
500 .ends_with('\\')
501}
502
503fn join_codes(rules: &[Rule]) -> String {
504 let mut rendered = String::new();
505 for (index, rule) in rules.iter().enumerate() {
506 if index > 0 {
507 rendered.push_str(", ");
508 }
509 rendered.push_str(rule.code());
510 }
511 rendered
512}
513
514fn join_shellcheck_codes(rules: &[Rule], shellcheck_map: &ShellCheckCodeMap) -> String {
515 let mut rendered = String::new();
516 for (index, rule) in rules.iter().enumerate() {
517 if index > 0 {
518 rendered.push_str(", ");
519 }
520 if let Some(code) = shellcheck_map.code_for_rule(*rule) {
521 rendered.push_str(&format!("SC{code:04}"));
522 } else {
523 rendered.push_str(rule.code());
524 }
525 }
526 rendered
527}
528
529fn directive_matches_ignore_line(directive: &SuppressionDirective, line: usize) -> bool {
530 usize::try_from(directive.line).ok() == Some(line)
531 && matches!(
532 (directive.source, directive.action),
533 (SuppressionSource::Shuck, SuppressionAction::Ignore)
534 | (SuppressionSource::ShellCheck, SuppressionAction::Disable)
535 )
536}
537
538fn strip_comment_prefix(text: &str) -> &str {
539 text.trim_start().trim_start_matches('#').trim_start()
540}
541
542fn strip_prefix_ignore_ascii_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
543 let candidate = text.get(..prefix.len())?;
544 candidate
545 .eq_ignore_ascii_case(prefix)
546 .then(|| &text[prefix.len()..])
547}
548
549fn shellcheck_comment_remainder(text: &str) -> Option<&str> {
550 let body = strip_comment_prefix(text);
551 strip_prefix_ignore_ascii_case(body, "shellcheck")
552}
553
554fn parse_shuck_action(value: &str) -> Option<SuppressionAction> {
555 if value.eq_ignore_ascii_case("disable") {
556 Some(SuppressionAction::Disable)
557 } else if value.eq_ignore_ascii_case("disable-file") {
558 Some(SuppressionAction::DisableFile)
559 } else if value.eq_ignore_ascii_case("ignore") {
560 Some(SuppressionAction::Ignore)
561 } else {
562 None
563 }
564}
565
566fn parse_codes(value: &str, mut resolve: impl FnMut(&str) -> Vec<Rule>) -> Vec<Rule> {
567 value
568 .split(',')
569 .flat_map(|code| {
570 let code = code.trim();
571 if code.is_empty() {
572 Vec::new()
573 } else {
574 resolve(code)
575 }
576 })
577 .collect()
578}
579
580fn resolve_suppression_code(code: &str, shellcheck_map: &ShellCheckCodeMap) -> Vec<Rule> {
581 let mut rules = resolve_rule_code(code).into_iter().collect::<Vec<_>>();
582 for rule in shellcheck_map.resolve_all(code) {
583 if !rules.contains(&rule) {
584 rules.push(rule);
585 }
586 }
587 rules
588}
589
590fn resolve_rule_code(code: &str) -> Option<Rule> {
591 crate::code_to_rule(code).or_else(|| {
592 let upper = code.to_ascii_uppercase();
593 (upper != code)
594 .then(|| crate::code_to_rule(&upper))
595 .flatten()
596 })
597}
598
599#[cfg(test)]
600mod tests {
601 use std::fs;
602
603 use tempfile::tempdir;
604
605 use super::*;
606 use crate::Rule;
607
608 fn run_add_ignore_with_settings(
609 source: &str,
610 settings: &LinterSettings,
611 reason: Option<&str>,
612 ) -> (AddIgnoreResult, String) {
613 let tempdir = tempdir().unwrap();
614 let path = tempdir.path().join("script.sh");
615 fs::write(&path, source).unwrap();
616
617 let result = add_ignores_to_path(&path, settings, reason).unwrap();
618 let updated = fs::read_to_string(&path).unwrap();
619
620 (result, updated)
621 }
622
623 fn run_add_ignore(source: &str, reason: Option<&str>) -> (AddIgnoreResult, String) {
624 run_add_ignore_with_settings(source, &LinterSettings::default(), reason)
625 }
626
627 #[test]
628 fn adds_inline_ignore_for_single_line_diagnostic() {
629 let (result, updated) = run_add_ignore("#!/bin/bash\necho $foo\n", None);
630
631 assert_eq!(result.directives_added, 1);
632 assert!(result.diagnostics.is_empty());
633 assert!(result.parse_error.is_none());
634 assert_eq!(updated, "#!/bin/bash\necho $foo # shuck: ignore=C006\n");
635 }
636
637 #[test]
638 fn merges_multiple_codes_on_a_single_line() {
639 let settings =
640 LinterSettings::for_rules([Rule::UndefinedVariable, Rule::UnquotedExpansion]);
641 let source = "#!/bin/bash\necho $foo\n";
642 let (result, updated) = run_add_ignore_with_settings(source, &settings, None);
643
644 assert_eq!(result.directives_added, 1);
645 assert_eq!(
646 updated,
647 "#!/bin/bash\necho $foo # shuck: ignore=C006, S001\n"
648 );
649 }
650
651 #[test]
652 fn merges_with_existing_ignore_and_preserves_reason() {
653 let settings =
654 LinterSettings::for_rules([Rule::UndefinedVariable, Rule::UnquotedExpansion]);
655 let source = "#!/bin/bash\necho $foo # shuck: ignore=S001 # legacy\n";
656 let (result, updated) = run_add_ignore_with_settings(source, &settings, None);
657
658 assert_eq!(result.directives_added, 1);
659 assert_eq!(
660 updated,
661 "#!/bin/bash\necho $foo # shuck: ignore=C006, S001 # legacy\n"
662 );
663 }
664
665 #[test]
666 fn skips_existing_shellcheck_disable_after_regular_code() {
667 let source = "#!/bin/bash\necho $foo # shellcheck disable=SC2086 # legacy\n";
668 let (result, updated) = run_add_ignore(source, None);
669
670 assert_eq!(result.directives_added, 0);
671 assert_eq!(updated, source);
672 }
673
674 #[test]
675 fn builds_in_memory_ignore_edit_for_existing_ignore() {
676 let settings =
677 LinterSettings::for_rules([Rule::UndefinedVariable, Rule::UnquotedExpansion]);
678 let source = "#!/bin/bash\necho $foo # shuck: ignore=S001 # reason\n";
679 let edit =
680 build_ignore_edit_for_line(source, &settings, 2, None, Some(Path::new("script.sh")))
681 .expect("line should produce an ignore edit");
682 let updated = crate::Edit::replacement_at(
683 usize::from(edit.range().start()),
684 usize::from(edit.range().end()),
685 edit.content(),
686 );
687 let applied = apply_edit(
688 source,
689 &IgnoreEdit {
690 range: updated.range(),
691 replacement: updated.content().to_owned(),
692 },
693 );
694
695 assert_eq!(
696 applied,
697 "#!/bin/bash\necho $foo # shuck: ignore=C006, S001 # reason\n"
698 );
699 }
700
701 #[test]
702 fn replaces_existing_reason_when_cli_reason_is_provided() {
703 let settings =
704 LinterSettings::for_rules([Rule::UndefinedVariable, Rule::UnquotedExpansion]);
705 let source = "#!/bin/bash\necho $foo # shuck: ignore=S001 # legacy\n";
706 let (result, updated) =
707 run_add_ignore_with_settings(source, &settings, Some("intentional"));
708
709 assert_eq!(result.directives_added, 1);
710 assert_eq!(
711 updated,
712 "#!/bin/bash\necho $foo # shuck: ignore=C006, S001 # intentional\n"
713 );
714 }
715
716 #[test]
717 fn is_idempotent_after_adding_ignore() {
718 let tempdir = tempdir().unwrap();
719 let path = tempdir.path().join("script.sh");
720 fs::write(&path, "#!/bin/bash\necho $foo\n").unwrap();
721
722 let first = add_ignores_to_path(&path, &LinterSettings::default(), None).unwrap();
723 let second = add_ignores_to_path(&path, &LinterSettings::default(), None).unwrap();
724 let updated = fs::read_to_string(&path).unwrap();
725
726 assert_eq!(first.directives_added, 1);
727 assert_eq!(second.directives_added, 0);
728 assert!(second.diagnostics.is_empty());
729 assert_eq!(updated, "#!/bin/bash\necho $foo # shuck: ignore=C006\n");
730 }
731
732 #[test]
733 fn leaves_existing_trailing_comments_unsupported() {
734 let source = "#!/bin/bash\necho $foo # existing comment\n";
735 let (result, updated) = run_add_ignore(source, None);
736
737 assert_eq!(result.directives_added, 0);
738 assert_eq!(result.diagnostics.len(), 1);
739 assert_eq!(updated, source);
740 }
741
742 #[test]
743 fn leaves_continuation_lines_unsupported() {
744 let source = "#!/bin/bash\necho $foo \\\n&& echo ok\n";
745 let (result, updated) = run_add_ignore(source, None);
746
747 assert_eq!(result.directives_added, 0);
748 assert_eq!(result.diagnostics.len(), 1);
749 assert_eq!(updated, source);
750 }
751
752 #[test]
753 fn keeps_raw_parse_errors_as_remaining_failures() {
754 let (result, updated) = run_add_ignore("#!/bin/bash\necho \"unterminated\n", None);
755
756 assert_eq!(result.directives_added, 0);
757 assert!(result.diagnostics.is_empty());
758 assert!(result.parse_error.is_some());
759 assert_eq!(updated, "#!/bin/bash\necho \"unterminated\n");
760 }
761
762 #[test]
763 fn parses_sh_files_with_shared_lint_dialect_policy() {
764 let source = "# shellcheck shell=sh\narr=(one)\necho $foo\n";
765 let (result, updated) = run_add_ignore(source, None);
766
767 assert!(result.directives_added > 0);
768 assert!(result.parse_error.is_none());
769 assert!(updated.contains("echo $foo # shuck: ignore=C006\n"));
770 }
771
772 #[test]
773 fn preserves_crlf_line_endings_when_appending_ignores() {
774 let source = "#!/bin/bash\r\necho $foo\r\n";
775 let (result, updated) = run_add_ignore(source, None);
776
777 assert_eq!(result.directives_added, 1);
778 assert_eq!(
779 updated,
780 "#!/bin/bash\r\necho $foo # shuck: ignore=C006\r\n"
781 );
782 }
783
784 #[test]
785 fn preserves_crlf_line_endings_when_rewriting_existing_ignores() {
786 let settings =
787 LinterSettings::for_rules([Rule::UndefinedVariable, Rule::UnquotedExpansion]);
788 let source = "#!/bin/bash\r\necho $foo # shuck: ignore=S001 # legacy\r\n";
789 let (result, updated) = run_add_ignore_with_settings(source, &settings, None);
790
791 assert_eq!(result.directives_added, 1);
792 assert_eq!(
793 updated,
794 "#!/bin/bash\r\necho $foo # shuck: ignore=C006, S001 # legacy\r\n"
795 );
796 }
797
798 #[test]
799 fn rejects_candidate_edits_that_introduce_parse_errors() {
800 let settings = LinterSettings::for_rule(Rule::UndefinedVariable);
801 let shellcheck_map = ShellCheckCodeMap::default();
802 let path = Path::new("script.sh");
803 let current_source = "#!/bin/bash\necho $foo\necho $bar\n";
804 let candidate_source =
805 "#!/bin/bash\necho $foo # shuck: ignore=C006\necho $bar\necho \"unterminated\n";
806
807 let current = analyze_source(
808 current_source,
809 &settings,
810 &shellcheck_map,
811 Some(path),
812 None,
813 None,
814 );
815 let candidate = analyze_source(
816 candidate_source,
817 &settings,
818 &shellcheck_map,
819 Some(path),
820 None,
821 None,
822 );
823 let line_diagnostics = current
824 .diagnostics
825 .iter()
826 .filter(|diagnostic| diagnostic.span.start.line == 2)
827 .cloned()
828 .collect::<Vec<_>>();
829
830 assert!(current.strict_parse_error.is_none());
831 assert!(candidate.parse_error.is_none());
832 assert!(candidate.strict_parse_error.is_some());
833 assert!(!edit_is_valid(¤t, &candidate, 2, &line_diagnostics));
834 }
835
836 #[test]
837 fn rejects_candidate_edits_that_change_unrelated_diagnostics() {
838 let settings = LinterSettings::for_rule(Rule::UndefinedVariable);
839 let shellcheck_map = ShellCheckCodeMap::default();
840 let path = Path::new("script.sh");
841 let current_source = "#!/bin/bash\necho $foo\necho $bar\n";
842 let candidate_source = "#!/bin/bash\necho $foo # shuck: ignore=C006\necho ok\n";
843
844 let current = analyze_source(
845 current_source,
846 &settings,
847 &shellcheck_map,
848 Some(path),
849 None,
850 None,
851 );
852 let candidate = analyze_source(
853 candidate_source,
854 &settings,
855 &shellcheck_map,
856 Some(path),
857 None,
858 None,
859 );
860 let line_diagnostics = current
861 .diagnostics
862 .iter()
863 .filter(|diagnostic| diagnostic.span.start.line == 2)
864 .cloned()
865 .collect::<Vec<_>>();
866
867 assert_eq!(current.diagnostics.len(), 2);
868 assert_eq!(candidate.diagnostics.len(), 0);
869 assert!(!edit_is_valid(¤t, &candidate, 2, &line_diagnostics));
870 }
871}