1use flowscope_core::{issue_codes, Dialect};
9use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
10use std::cmp::Ordering;
11use std::collections::{BTreeSet, HashMap, HashSet};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
15pub enum FixApplicability {
16 Safe,
17 Unsafe,
18 DisplayOnly,
19}
20
21#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
80pub enum ProtectedRangeKind {
81 SqlComment,
82 SqlStringLiteral,
83 TemplateTag,
84}
85
86#[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#[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#[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 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#[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 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#[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
420pub fn sort_fixes_deterministically(fixes: &mut [Fix]) {
422 fixes.sort_by(compare_fixes_for_planning);
423}
424
425pub fn sort_edits_deterministically(edits: &mut [Edit]) {
427 edits.sort_by(compare_edits);
428}
429
430#[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#[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#[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#[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}