Skip to main content

shuck_linter/suppression/
rewrite.rs

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        &current.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(&current, &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(&current, &candidate, 2, &line_diagnostics));
870    }
871}