Skip to main content

flowscope_cli/
fix_engine.rs

1//! Deterministic patch planning helpers for lint fixes.
2//!
3//! This module intentionally keeps the API small and explicit:
4//! - `plan_fixes()` selects compatible fixes and records blocked reasons.
5//! - `apply_edits()` applies byte-range replacements end-to-start.
6//! - protected range helpers mark SQL comments/string literals and template tags.
7
8use flowscope_core::{issue_codes, Dialect};
9use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
10use std::cmp::Ordering;
11use std::collections::{BTreeSet, HashMap, HashSet};
12
13/// How safe it is to apply a fix automatically.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
15pub enum FixApplicability {
16    Safe,
17    Unsafe,
18    DisplayOnly,
19}
20
21/// A single text replacement in byte offsets `[start_byte, end_byte)`.
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct Edit {
24    pub start_byte: usize,
25    pub end_byte: usize,
26    pub replacement: String,
27}
28
29impl Edit {
30    #[must_use]
31    pub fn replace(start_byte: usize, end_byte: usize, replacement: impl Into<String>) -> Self {
32        Self {
33            start_byte,
34            end_byte,
35            replacement: replacement.into(),
36        }
37    }
38}
39
40/// A fix proposal, potentially containing multiple edits.
41///
42/// `priority` is sorted ascending, so lower numbers win first.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Fix {
45    pub edits: Vec<Edit>,
46    pub applicability: FixApplicability,
47    pub isolation_group: Option<String>,
48    pub rule_code: String,
49    pub priority: i32,
50}
51
52impl Fix {
53    #[must_use]
54    pub fn new(
55        rule_code: impl Into<String>,
56        applicability: FixApplicability,
57        edits: Vec<Edit>,
58    ) -> Self {
59        Self {
60            edits,
61            applicability,
62            isolation_group: None,
63            rule_code: rule_code.into(),
64            priority: 0,
65        }
66    }
67
68    #[must_use]
69    pub fn first_start_byte(&self) -> usize {
70        self.edits
71            .iter()
72            .map(|edit| edit.start_byte)
73            .min()
74            .unwrap_or(usize::MAX)
75    }
76}
77
78/// Why a range is protected from automatic edits.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
80pub enum ProtectedRangeKind {
81    SqlComment,
82    SqlStringLiteral,
83    TemplateTag,
84}
85
86/// Byte range that should not be changed by automatic fix application.
87#[derive(Debug, Clone, PartialEq, Eq, Hash)]
88pub struct ProtectedRange {
89    pub start_byte: usize,
90    pub end_byte: usize,
91    pub kind: ProtectedRangeKind,
92}
93
94impl ProtectedRange {
95    #[must_use]
96    pub fn new(start_byte: usize, end_byte: usize, kind: ProtectedRangeKind) -> Self {
97        Self {
98            start_byte,
99            end_byte,
100            kind,
101        }
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum BlockedReason {
107    ApplicabilityNotAllowed {
108        applicability: FixApplicability,
109    },
110    InvalidEditRange {
111        edit_index: usize,
112        start_byte: usize,
113        end_byte: usize,
114    },
115    InternalEditOverlap {
116        left_edit: usize,
117        right_edit: usize,
118    },
119    OverlapWithSelectedFix {
120        selected_rule_code: String,
121    },
122    IsolationGroupConflict {
123        isolation_group: String,
124        selected_rule_code: String,
125    },
126    TouchesProtectedRange {
127        kind: ProtectedRangeKind,
128        start_byte: usize,
129        end_byte: usize,
130    },
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct BlockedFix {
135    pub fix: Fix,
136    pub reasons: Vec<BlockedReason>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Default)]
140pub struct PlanResult {
141    pub accepted: Vec<Fix>,
142    pub blocked: Vec<BlockedFix>,
143}
144
145impl PlanResult {
146    #[must_use]
147    pub fn accepted_edits(&self) -> Vec<Edit> {
148        let mut edits: Vec<Edit> = self
149            .accepted
150            .iter()
151            .flat_map(|fix| fix.edits.iter().cloned())
152            .collect();
153        sort_edits_deterministically(&mut edits);
154        edits
155    }
156
157    #[must_use]
158    pub fn apply(&self, source: &str) -> String {
159        apply_edits(source, &self.accepted_edits())
160    }
161}
162
163/// Derive protected ranges from SQL comments/string literals and template tags.
164#[must_use]
165pub fn derive_protected_ranges(sql: &str, dialect: Dialect) -> Vec<ProtectedRange> {
166    let mut ranges = protected_ranges_from_tokenizer(sql, dialect);
167    ranges.extend(protected_ranges_from_templates(sql));
168    normalize_protected_ranges(ranges)
169}
170
171/// Derive protected ranges by tokenizing SQL and collecting comment + string tokens.
172#[must_use]
173pub fn protected_ranges_from_tokenizer(sql: &str, dialect: Dialect) -> Vec<ProtectedRange> {
174    let dialect = dialect.to_sqlparser_dialect();
175    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
176    let Ok(tokens) = tokenizer.tokenize_with_location() else {
177        return Vec::new();
178    };
179
180    let mut ranges = Vec::new();
181    for token in tokens {
182        let kind = match &token.token {
183            Token::Whitespace(
184                Whitespace::SingleLineComment { .. } | Whitespace::MultiLineComment(_),
185            ) => Some(ProtectedRangeKind::SqlComment),
186            token if is_string_literal_token(token) => Some(ProtectedRangeKind::SqlStringLiteral),
187            _ => None,
188        };
189
190        let Some(kind) = kind else {
191            continue;
192        };
193        let Some((start_byte, end_byte)) = token_with_span_offsets(sql, &token) else {
194            continue;
195        };
196        if start_byte < end_byte {
197            // Exclude trailing newlines from single-line comment protection.
198            // The newline is a line separator, not comment content — other rules
199            // may need to adjust it when rearranging lines.
200            let mut adjusted_end = end_byte;
201            if matches!(
202                &token.token,
203                Token::Whitespace(Whitespace::SingleLineComment { .. })
204            ) {
205                while adjusted_end > start_byte
206                    && matches!(sql.as_bytes().get(adjusted_end - 1), Some(b'\n' | b'\r'))
207                {
208                    adjusted_end -= 1;
209                }
210            }
211            if start_byte < adjusted_end {
212                ranges.push(ProtectedRange::new(start_byte, adjusted_end, kind));
213            }
214        }
215    }
216
217    normalize_protected_ranges(ranges)
218}
219
220/// Derive protected ranges for Jinja-style templated spans.
221///
222/// This scanner handles:
223/// - `{{ ... }}`, `{% ... %}`, `{# ... #}`
224/// - trim markers (`{{-`, `-}}`, `{%-`, `-%}`, `{#-`, `-#}`)
225/// - quoted strings inside `{{ ... }}` and `{% ... %}` so embedded `}}`/`%}`
226///   inside string literals do not terminate a tag early.
227#[must_use]
228pub fn protected_ranges_from_templates(sql: &str) -> Vec<ProtectedRange> {
229    let bytes = sql.as_bytes();
230    let mut ranges = Vec::new();
231    let mut index = 0usize;
232
233    while index + 1 < bytes.len() {
234        let Some(open_kind) = template_open_kind(bytes, index) else {
235            index += 1;
236            continue;
237        };
238
239        let close_lead = template_close_lead(open_kind);
240        let start = index;
241        let mut cursor = index + 2;
242
243        if cursor < bytes.len() && bytes[cursor] == b'-' {
244            cursor += 1;
245        }
246
247        let mut in_single_quote = false;
248        let mut in_double_quote = false;
249        let mut escaped = false;
250        let mut end = None;
251
252        while cursor < bytes.len() {
253            let byte = bytes[cursor];
254
255            if in_single_quote {
256                if escaped {
257                    escaped = false;
258                } else if byte == b'\\' {
259                    escaped = true;
260                } else if byte == b'\'' {
261                    in_single_quote = false;
262                }
263                cursor += 1;
264                continue;
265            }
266
267            if in_double_quote {
268                if escaped {
269                    escaped = false;
270                } else if byte == b'\\' {
271                    escaped = true;
272                } else if byte == b'"' {
273                    in_double_quote = false;
274                }
275                cursor += 1;
276                continue;
277            }
278
279            // Jinja comments are opaque; for expression and statement tags we
280            // preserve quote state to avoid prematurely closing on `}}`/`%}`
281            // in quoted content.
282            if open_kind != b'#' {
283                if byte == b'\'' {
284                    in_single_quote = true;
285                    cursor += 1;
286                    continue;
287                }
288                if byte == b'"' {
289                    in_double_quote = true;
290                    cursor += 1;
291                    continue;
292                }
293            }
294
295            if is_template_close(bytes, cursor, close_lead) {
296                end = Some(cursor + 2);
297                break;
298            }
299            if is_template_trimmed_close(bytes, cursor, close_lead) {
300                end = Some(cursor + 3);
301                break;
302            }
303
304            cursor += 1;
305        }
306
307        let end = end.unwrap_or(bytes.len());
308        ranges.push(ProtectedRange::new(
309            start,
310            end,
311            ProtectedRangeKind::TemplateTag,
312        ));
313        index = end;
314    }
315
316    normalize_protected_ranges(ranges)
317}
318
319/// Plan fixes deterministically and collect blocked reasons.
320///
321/// Order is deterministic by: `priority`, first edit start, `rule_code`, then
322/// additional stable tie-breakers.
323///
324/// If `allowed_applicability` is empty, all applicability classes are allowed.
325#[must_use]
326pub fn plan_fixes(
327    source: &str,
328    mut fixes: Vec<Fix>,
329    allowed_applicability: &[FixApplicability],
330    protected_ranges: &[ProtectedRange],
331) -> PlanResult {
332    sort_fixes_deterministically(&mut fixes);
333
334    let allowed: HashSet<FixApplicability> = allowed_applicability.iter().copied().collect();
335    let allow_all = allowed.is_empty();
336    let normalized_protected_ranges = normalize_protected_ranges(protected_ranges.to_vec());
337
338    let mut accepted = Vec::new();
339    let mut blocked = Vec::new();
340    let mut selected_edits: Vec<(Edit, String)> = Vec::new();
341    let mut selected_groups: HashMap<String, String> = HashMap::new();
342
343    for fix in fixes {
344        let mut reasons = Vec::new();
345
346        if !allow_all && !allowed.contains(&fix.applicability) {
347            reasons.push(BlockedReason::ApplicabilityNotAllowed {
348                applicability: fix.applicability,
349            });
350        }
351
352        for (edit_index, edit) in fix.edits.iter().enumerate() {
353            if !is_edit_range_valid_for_source(source, edit) {
354                reasons.push(BlockedReason::InvalidEditRange {
355                    edit_index,
356                    start_byte: edit.start_byte,
357                    end_byte: edit.end_byte,
358                });
359            }
360        }
361
362        for (left_edit, right_edit) in overlapping_edit_pairs(&fix.edits) {
363            reasons.push(BlockedReason::InternalEditOverlap {
364                left_edit,
365                right_edit,
366            });
367        }
368
369        for touched in touched_protected_ranges(&fix.edits, &normalized_protected_ranges) {
370            if touched.kind == ProtectedRangeKind::TemplateTag
371                && template_edits_allowed(&fix.rule_code)
372            {
373                continue;
374            }
375            reasons.push(BlockedReason::TouchesProtectedRange {
376                kind: touched.kind,
377                start_byte: touched.start_byte,
378                end_byte: touched.end_byte,
379            });
380        }
381
382        if let Some(group) = normalized_isolation_group(&fix.isolation_group) {
383            if let Some(selected_rule_code) = selected_groups.get(group) {
384                reasons.push(BlockedReason::IsolationGroupConflict {
385                    isolation_group: group.to_string(),
386                    selected_rule_code: selected_rule_code.clone(),
387                });
388            }
389        }
390
391        let mut overlapping_rules = BTreeSet::new();
392        for edit in &fix.edits {
393            for (selected_edit, selected_rule_code) in &selected_edits {
394                if edits_overlap(edit, selected_edit) {
395                    overlapping_rules.insert(selected_rule_code.clone());
396                }
397            }
398        }
399        for selected_rule_code in overlapping_rules {
400            reasons.push(BlockedReason::OverlapWithSelectedFix { selected_rule_code });
401        }
402
403        dedup_reasons(&mut reasons);
404        if reasons.is_empty() {
405            if let Some(group) = normalized_isolation_group(&fix.isolation_group) {
406                selected_groups.insert(group.to_string(), fix.rule_code.clone());
407            }
408            for edit in &fix.edits {
409                selected_edits.push((edit.clone(), fix.rule_code.clone()));
410            }
411            accepted.push(fix);
412        } else {
413            blocked.push(BlockedFix { fix, reasons });
414        }
415    }
416
417    PlanResult { accepted, blocked }
418}
419
420/// Sort fixes in deterministic planning order.
421pub fn sort_fixes_deterministically(fixes: &mut [Fix]) {
422    fixes.sort_by(compare_fixes_for_planning);
423}
424
425/// Sort edits in deterministic order.
426pub fn sort_edits_deterministically(edits: &mut [Edit]) {
427    edits.sort_by(compare_edits);
428}
429
430/// Return overlapping edit index pairs `(left, right)`.
431#[must_use]
432pub fn overlapping_edit_pairs(edits: &[Edit]) -> Vec<(usize, usize)> {
433    let mut overlaps = Vec::new();
434    for left in 0..edits.len() {
435        for right in (left + 1)..edits.len() {
436            if edits_overlap(&edits[left], &edits[right]) {
437                overlaps.push((left, right));
438            }
439        }
440    }
441    overlaps
442}
443
444/// Return protected ranges touched by the provided edits.
445#[must_use]
446pub fn touched_protected_ranges(
447    edits: &[Edit],
448    protected_ranges: &[ProtectedRange],
449) -> Vec<ProtectedRange> {
450    let mut touched = Vec::new();
451    for protected in protected_ranges {
452        if edits
453            .iter()
454            .any(|edit| edit_touches_protected_range(edit, protected))
455        {
456            touched.push(protected.clone());
457        }
458    }
459    normalize_protected_ranges(touched)
460}
461
462/// Apply a set of fixes to the source.
463#[must_use]
464pub fn apply_fixes(source: &str, fixes: &[Fix]) -> String {
465    let edits: Vec<Edit> = fixes
466        .iter()
467        .flat_map(|fix| fix.edits.iter().cloned())
468        .collect();
469    apply_edits(source, &edits)
470}
471
472/// Apply edits to source by processing from end to start.
473#[must_use]
474pub fn apply_edits(source: &str, edits: &[Edit]) -> String {
475    if edits.is_empty() {
476        return source.to_string();
477    }
478
479    let mut ordered = edits.to_vec();
480    sort_edits_deterministically(&mut ordered);
481
482    let mut out = source.to_string();
483    for edit in ordered.into_iter().rev() {
484        if !is_edit_range_valid_for_source(&out, &edit) {
485            continue;
486        }
487        out.replace_range(edit.start_byte..edit.end_byte, &edit.replacement);
488    }
489
490    out
491}
492
493fn compare_fixes_for_planning(left: &Fix, right: &Fix) -> Ordering {
494    left.priority
495        .cmp(&right.priority)
496        .then_with(|| left.first_start_byte().cmp(&right.first_start_byte()))
497        .then_with(|| left.rule_code.cmp(&right.rule_code))
498        .then_with(|| {
499            applicability_rank(left.applicability).cmp(&applicability_rank(right.applicability))
500        })
501        .then_with(|| {
502            left.isolation_group
503                .as_deref()
504                .cmp(&right.isolation_group.as_deref())
505        })
506        .then_with(|| compare_edit_sets(&left.edits, &right.edits))
507}
508
509fn compare_edits(left: &Edit, right: &Edit) -> Ordering {
510    left.start_byte
511        .cmp(&right.start_byte)
512        .then_with(|| left.end_byte.cmp(&right.end_byte))
513        .then_with(|| left.replacement.cmp(&right.replacement))
514}
515
516fn compare_edit_sets(left: &[Edit], right: &[Edit]) -> Ordering {
517    let mut left_sorted = left.to_vec();
518    let mut right_sorted = right.to_vec();
519    sort_edits_deterministically(&mut left_sorted);
520    sort_edits_deterministically(&mut right_sorted);
521
522    for (left_edit, right_edit) in left_sorted.iter().zip(right_sorted.iter()) {
523        let ordering = compare_edits(left_edit, right_edit);
524        if ordering != Ordering::Equal {
525            return ordering;
526        }
527    }
528    left_sorted.len().cmp(&right_sorted.len())
529}
530
531fn applicability_rank(applicability: FixApplicability) -> u8 {
532    match applicability {
533        FixApplicability::Safe => 0,
534        FixApplicability::Unsafe => 1,
535        FixApplicability::DisplayOnly => 2,
536    }
537}
538
539fn dedup_reasons(reasons: &mut Vec<BlockedReason>) {
540    let mut unique = Vec::with_capacity(reasons.len());
541    for reason in reasons.drain(..) {
542        if !unique.contains(&reason) {
543            unique.push(reason);
544        }
545    }
546    *reasons = unique;
547}
548
549fn normalized_isolation_group(group: &Option<String>) -> Option<&str> {
550    group
551        .as_deref()
552        .map(str::trim)
553        .filter(|value| !value.is_empty())
554}
555
556fn normalize_protected_ranges(mut ranges: Vec<ProtectedRange>) -> Vec<ProtectedRange> {
557    ranges.retain(|range| range.start_byte < range.end_byte);
558    ranges.sort_by(|left, right| {
559        left.start_byte
560            .cmp(&right.start_byte)
561            .then_with(|| left.end_byte.cmp(&right.end_byte))
562            .then_with(|| left.kind.cmp(&right.kind))
563    });
564    ranges.dedup();
565    ranges
566}
567
568fn template_edits_allowed(rule_code: &str) -> bool {
569    rule_code.eq_ignore_ascii_case(issue_codes::LINT_LT_005)
570}
571
572fn is_edit_range_valid_for_source(source: &str, edit: &Edit) -> bool {
573    edit.start_byte <= edit.end_byte
574        && edit.end_byte <= source.len()
575        && source.is_char_boundary(edit.start_byte)
576        && source.is_char_boundary(edit.end_byte)
577}
578
579fn edits_overlap(left: &Edit, right: &Edit) -> bool {
580    let left_insert = left.start_byte == left.end_byte;
581    let right_insert = right.start_byte == right.end_byte;
582
583    if left_insert && right_insert {
584        return left.start_byte == right.start_byte;
585    }
586    if left_insert {
587        return left.start_byte >= right.start_byte && left.start_byte < right.end_byte;
588    }
589    if right_insert {
590        return right.start_byte >= left.start_byte && right.start_byte < left.end_byte;
591    }
592
593    left.start_byte < right.end_byte && right.start_byte < left.end_byte
594}
595
596fn edit_touches_protected_range(edit: &Edit, protected: &ProtectedRange) -> bool {
597    if edit.start_byte == edit.end_byte {
598        return edit.start_byte >= protected.start_byte && edit.start_byte < protected.end_byte;
599    }
600    edit.start_byte < protected.end_byte && edit.end_byte > protected.start_byte
601}
602
603fn is_string_literal_token(token: &Token) -> bool {
604    matches!(
605        token,
606        Token::SingleQuotedString(_)
607            | Token::DoubleQuotedString(_)
608            | Token::TripleSingleQuotedString(_)
609            | Token::TripleDoubleQuotedString(_)
610            | Token::DollarQuotedString(_)
611            | Token::SingleQuotedByteStringLiteral(_)
612            | Token::DoubleQuotedByteStringLiteral(_)
613            | Token::TripleSingleQuotedByteStringLiteral(_)
614            | Token::TripleDoubleQuotedByteStringLiteral(_)
615            | Token::SingleQuotedRawStringLiteral(_)
616            | Token::DoubleQuotedRawStringLiteral(_)
617            | Token::TripleSingleQuotedRawStringLiteral(_)
618            | Token::TripleDoubleQuotedRawStringLiteral(_)
619            | Token::NationalStringLiteral(_)
620            | Token::EscapedStringLiteral(_)
621            | Token::UnicodeStringLiteral(_)
622            | Token::HexStringLiteral(_)
623    )
624}
625
626fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
627    let start_byte = line_col_to_offset(
628        sql,
629        token.span.start.line as usize,
630        token.span.start.column as usize,
631    )?;
632    let end_byte = line_col_to_offset(
633        sql,
634        token.span.end.line as usize,
635        token.span.end.column as usize,
636    )?;
637    Some((start_byte, end_byte))
638}
639
640fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
641    if line == 0 || column == 0 {
642        return None;
643    }
644
645    let mut current_line = 1usize;
646    let mut current_col = 1usize;
647
648    for (offset, ch) in sql.char_indices() {
649        if current_line == line && current_col == column {
650            return Some(offset);
651        }
652
653        if ch == '\n' {
654            current_line += 1;
655            current_col = 1;
656        } else {
657            current_col += 1;
658        }
659    }
660
661    if current_line == line && current_col == column {
662        return Some(sql.len());
663    }
664
665    None
666}
667
668fn template_open_kind(bytes: &[u8], index: usize) -> Option<u8> {
669    if index + 1 >= bytes.len() || bytes[index] != b'{' {
670        return None;
671    }
672
673    match bytes[index + 1] {
674        b'{' | b'%' | b'#' => Some(bytes[index + 1]),
675        _ => None,
676    }
677}
678
679fn template_close_lead(open_kind: u8) -> u8 {
680    match open_kind {
681        b'{' => b'}',
682        b'%' => b'%',
683        b'#' => b'#',
684        _ => unreachable!("unsupported template open marker"),
685    }
686}
687
688fn is_template_close(bytes: &[u8], index: usize, close_lead: u8) -> bool {
689    index + 1 < bytes.len() && bytes[index] == close_lead && bytes[index + 1] == b'}'
690}
691
692fn is_template_trimmed_close(bytes: &[u8], index: usize, close_lead: u8) -> bool {
693    index + 2 < bytes.len()
694        && bytes[index] == b'-'
695        && bytes[index + 1] == close_lead
696        && bytes[index + 2] == b'}'
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    fn safe_fix(
704        rule_code: &str,
705        priority: i32,
706        isolation_group: Option<&str>,
707        start_byte: usize,
708        end_byte: usize,
709        replacement: &str,
710    ) -> Fix {
711        Fix {
712            edits: vec![Edit::replace(start_byte, end_byte, replacement)],
713            applicability: FixApplicability::Safe,
714            isolation_group: isolation_group.map(ToOwned::to_owned),
715            rule_code: rule_code.to_string(),
716            priority,
717        }
718    }
719
720    #[test]
721    fn planner_rejects_overlap_against_selected_fix() {
722        let source = "abcdefghij";
723        let fix_a = safe_fix("LINT_A", 0, None, 2, 6, "WXYZ");
724        let fix_b = safe_fix("LINT_B", 0, None, 4, 8, "QRST");
725
726        let plan = plan_fixes(
727            source,
728            vec![fix_b.clone(), fix_a.clone()],
729            &[FixApplicability::Safe],
730            &[],
731        );
732
733        assert_eq!(plan.accepted.len(), 1);
734        assert_eq!(plan.accepted[0].rule_code, "LINT_A");
735        assert_eq!(plan.blocked.len(), 1);
736        assert!(plan.blocked[0].reasons.iter().any(|reason| matches!(
737            reason,
738            BlockedReason::OverlapWithSelectedFix { selected_rule_code }
739                if selected_rule_code == "LINT_A"
740        )));
741    }
742
743    #[test]
744    fn planner_enforces_isolation_groups() {
745        let source = "abcdefghij";
746        let fix_a = safe_fix("LINT_A", 0, Some("group-1"), 0, 1, "A");
747        let fix_b = safe_fix("LINT_B", 1, Some("group-1"), 8, 9, "Z");
748
749        let plan = plan_fixes(
750            source,
751            vec![fix_b.clone(), fix_a.clone()],
752            &[FixApplicability::Safe],
753            &[],
754        );
755
756        assert_eq!(plan.accepted.len(), 1);
757        assert_eq!(plan.accepted[0].rule_code, "LINT_A");
758        assert_eq!(plan.blocked.len(), 1);
759        assert!(plan.blocked[0].reasons.iter().any(|reason| matches!(
760            reason,
761            BlockedReason::IsolationGroupConflict {
762                isolation_group,
763                selected_rule_code
764            } if isolation_group == "group-1" && selected_rule_code == "LINT_A"
765        )));
766    }
767
768    #[test]
769    fn apply_edits_is_deterministic() {
770        let source = "0123456789";
771        let edits = vec![Edit::replace(6, 8, "B"), Edit::replace(2, 4, "AA")];
772
773        let forward = apply_edits(source, &edits);
774        let reverse = apply_edits(source, &[edits[1].clone(), edits[0].clone()]);
775
776        assert_eq!(forward, "01AA45B89");
777        assert_eq!(reverse, "01AA45B89");
778    }
779
780    #[test]
781    fn planner_blocks_edits_touching_protected_ranges() {
782        let source = "SELECT 'literal' AS s -- note\nFROM {{ ref('users') }}";
783        let protected = derive_protected_ranges(source, Dialect::Generic);
784
785        assert!(protected
786            .iter()
787            .any(|range| range.kind == ProtectedRangeKind::SqlStringLiteral));
788        assert!(protected
789            .iter()
790            .any(|range| range.kind == ProtectedRangeKind::SqlComment));
791        assert!(protected
792            .iter()
793            .any(|range| range.kind == ProtectedRangeKind::TemplateTag));
794
795        let users_start = source.find("users").expect("template target");
796        let fix = safe_fix(
797            "LINT_TP_001",
798            0,
799            None,
800            users_start,
801            users_start + 5,
802            "orders",
803        );
804
805        let plan = plan_fixes(source, vec![fix], &[FixApplicability::Safe], &protected);
806        assert!(plan.accepted.is_empty());
807        assert_eq!(plan.blocked.len(), 1);
808        assert!(plan.blocked[0].reasons.iter().any(|reason| matches!(
809            reason,
810            BlockedReason::TouchesProtectedRange {
811                kind: ProtectedRangeKind::TemplateTag,
812                ..
813            }
814        )));
815    }
816
817    #[test]
818    fn planner_allows_lt05_edits_that_move_template_tags() {
819        let source = "SELECT {{ foo }} FROM tbl";
820        let protected = derive_protected_ranges(source, Dialect::Generic);
821        let fix = safe_fix(
822            "LINT_LT_005",
823            0,
824            None,
825            0,
826            source.len(),
827            "SELECT {{ foo }}\nFROM tbl",
828        );
829
830        let plan = plan_fixes(source, vec![fix], &[FixApplicability::Safe], &protected);
831        assert_eq!(plan.accepted.len(), 1);
832        assert!(plan.blocked.is_empty());
833    }
834}