Skip to main content

wowlab_tidy/languages/rust/rules/style/
aligned.rs

1#[cfg(test)]
2use googletest::prelude::*;
3use winnow::token::take_until;
4use wowlab_types::sim::FastMap;
5
6use crate::{Example, FileCtx, Fix, Violation, infra::parse, violation};
7
8const MARKER: &str = "// #t:aligned";
9const MIN_ALIGNED_ROWS: usize = 2;
10const MAJORITY_DIVISOR: usize = 2;
11
12#[rustfmt::skip]
13const EXAMPLES: &[Example] = &[
14    Example {
15        label: "arrow aligned",
16        code: "// #t:aligned\nSpells    => \"a\";\nTraits    => \"b\";\nItems     => \"c\";",
17        pass: true,
18    },
19    Example {
20        label: "arrow misaligned",
21        code: "// #t:aligned\nSpells    => \"a\";\nTraits => \"b\";\nItems     => \"c\";",
22        pass: false,
23    },
24    Example {
25        label: "comma aligned",
26        code: "// #t:aligned\ncall(a,  \"x\",  TypeA);\ncall(b,  \"y\",  TypeB);",
27        pass: true,
28    },
29    Example {
30        label: "no marker",
31        code: "Spells    => \"a\";\nTraits => \"b\";",
32        pass: true,
33    },
34    Example {
35        label: "comma misaligned",
36        code: "// #t:aligned\ncall(a,  \"x\",  TypeA);\ncall(long_name, \"y\", TypeB);",
37        pass: false,
38    },
39    Example {
40        label: "trailing comments aligned",
41        code: "// #t:aligned\n(A, 1), // first\n(B, 2), // second",
42        pass: true,
43    },
44    Example {
45        label: "trailing comments misaligned",
46        code: "// #t:aligned\n(A, 1),      // first\n(B, 2), // second",
47        pass: false,
48    },
49];
50
51crate::line_rule!(
52    aligned,
53    "Enforce column alignment in regions marked with `// #t:aligned`.",
54    "Consistent column alignment in marked regions makes tabular data and match arms easier to scan.",
55    Low,
56    fix_aligned,
57);
58
59fn check_aligned(ctx: &FileCtx<'_>) -> Vec<Violation> {
60    let mut out = Vec::new();
61    let mut i = 0;
62
63    while i < ctx.lines.len() {
64        if ctx.lines.get(i).is_some_and(|line| line.contains(MARKER)) {
65            let marker_line = i + 1;
66            let block = collect_block(ctx.lines, i + 1);
67
68            if block.len() >= MIN_ALIGNED_ROWS {
69                let texts: Vec<&str> = block.iter().map(|&(_, s)| s).collect();
70
71                match detect_separator(&texts) {
72                    Some(Sep::Arrow) => {
73                        check_arrow(ctx.rel, &block, marker_line, &mut out);
74                    }
75                    Some(Sep::Comma) => {
76                        check_comma(ctx.rel, &block, marker_line, &mut out);
77                    }
78                    None => {}
79                }
80
81                check_comments(ctx.rel, &block, marker_line, &mut out);
82            }
83
84            i = block.last().map_or(i + 1, |&(idx, _)| idx + 1);
85        } else {
86            i += 1;
87        }
88    }
89
90    out
91}
92
93fn collect_block<'a>(lines: &[&'a str], start: usize) -> Vec<(usize, &'a str)> {
94    let mut block = Vec::new();
95
96    for (i, &line) in lines.iter().enumerate().skip(start) {
97        let trimmed = line.trim();
98
99        if trimmed.is_empty()
100            || matches!(
101                trimmed,
102                ")" | ");" | "]" | "];" | "]," | "])" | "]);" | "}" | "};" | "},"
103            )
104            || trimmed.contains(MARKER)
105        {
106            break;
107        }
108
109        if parse::is_comment(trimmed) {
110            continue;
111        }
112
113        block.push((i, line));
114    }
115
116    block
117}
118
119enum Sep {
120    Arrow,
121    Comma,
122}
123
124fn detect_separator(block: &[&str]) -> Option<Sep> {
125    let arrows = block.iter().filter(|l| l.contains("=>")).count();
126
127    if arrows > block.len() / MAJORITY_DIVISOR {
128        return Some(Sep::Arrow);
129    }
130
131    let commas = block.iter().filter(|l| l.contains(',')).count();
132
133    if commas > block.len() / MAJORITY_DIVISOR {
134        return Some(Sep::Comma);
135    }
136
137    None
138}
139
140fn check_arrow(rel: &str, block: &[(usize, &str)], marker_line: usize, out: &mut Vec<Violation>) {
141    let positions: Vec<(usize, usize)> = block
142        .iter()
143        .filter_map(|&(idx, line)| {
144            let mut input = line;
145            let before: &str = parse::try_parse(&mut input, take_until(0.., "=>"))?;
146
147            Some((idx, before.len()))
148        })
149        .collect();
150
151    if positions.len() < MIN_ALIGNED_ROWS {
152        return;
153    }
154
155    let expected = majority(positions.iter().map(|&(_, p)| p));
156
157    for &(idx, pos) in &positions {
158        if pos != expected {
159            out.push(violation(
160                rel,
161                idx + 1,
162                format!(
163                    "`=>` at column {pos}, expected {expected} \
164                     (aligned block at line {marker_line})"
165                ),
166            ));
167        }
168    }
169}
170
171fn check_comma(rel: &str, block: &[(usize, &str)], marker_line: usize, out: &mut Vec<Violation>) {
172    let mut all: Vec<(usize, Vec<usize>)> = Vec::new();
173
174    for &(idx, line) in block {
175        let positions = comma_positions(line);
176
177        if !positions.is_empty() {
178            all.push((idx, positions));
179        }
180    }
181
182    if all.len() < MIN_ALIGNED_ROWS {
183        return;
184    }
185
186    let expected_count = majority(all.iter().map(|(_, p)| p.len()));
187
188    for &(idx, ref positions) in &all {
189        if positions.len() != expected_count {
190            out.push(violation(
191                rel,
192                idx + 1,
193                format!(
194                    "{} commas, expected {expected_count} \
195                     (aligned block at line {marker_line})",
196                    positions.len()
197                ),
198            ));
199        }
200    }
201
202    let matching: Vec<&(usize, Vec<usize>)> = all
203        .iter()
204        .filter(|(_, p)| p.len() == expected_count)
205        .collect();
206
207    if matching.len() < MIN_ALIGNED_ROWS {
208        return;
209    }
210
211    for col in 0..expected_count {
212        let expected_pos = majority(
213            matching
214                .iter()
215                .filter_map(|(_, positions)| positions.get(col).copied()),
216        );
217
218        for &&(idx, ref positions) in &matching {
219            let Some(actual) = positions.get(col).copied() else {
220                continue;
221            };
222
223            if actual != expected_pos {
224                out.push(violation(
225                    rel,
226                    idx + 1,
227                    format!(
228                        "comma {} at column {}, expected {expected_pos} \
229                         (aligned block at line {marker_line})",
230                        col + 1,
231                        actual
232                    ),
233                ));
234                break;
235            }
236        }
237    }
238}
239
240fn comma_positions(line: &str) -> Vec<usize> {
241    crate::infra::scanner::char_positions(line, ',')
242}
243
244fn comment_position(line: &str) -> Option<usize> {
245    crate::infra::scanner::line_comment_starts(line)
246        .into_iter()
247        .next()
248        .flatten()
249}
250
251fn check_comments(
252    rel: &str,
253    block: &[(usize, &str)],
254    marker_line: usize,
255    out: &mut Vec<Violation>,
256) {
257    let positions: Vec<(usize, usize)> = block
258        .iter()
259        .filter_map(|&(index, line)| comment_position(line).map(|position| (index, position)))
260        .collect();
261
262    if positions.len() < MIN_ALIGNED_ROWS {
263        return;
264    }
265
266    let expected = majority(positions.iter().map(|&(_, position)| position));
267
268    if let Some(&(index, actual)) = positions
269        .iter()
270        .find(|&&(_, position)| position != expected)
271    {
272        out.push(violation(
273            rel,
274            index + 1,
275            format!(
276                "trailing comment at column {actual}, expected {expected} \
277                 (aligned block at line {marker_line})"
278            ),
279        ));
280    }
281}
282
283fn majority<T>(iter: impl Iterator<Item = T>) -> T
284where
285    T: Eq + std::hash::Hash + Copy,
286{
287    let mut counts: FastMap<T, usize> = FastMap::default();
288
289    for v in iter {
290        *counts.entry(v).or_default() += 1;
291    }
292
293    counts
294        .into_iter()
295        .max_by_key(|&(_, c)| c)
296        .expect("majority called on empty iterator")
297        .0
298}
299
300fn fix_aligned(ctx: &FileCtx<'_>, violation: &Violation) -> Option<Fix> {
301    let (start, block) = containing_block(ctx, violation.line)?;
302    let texts: Vec<&str> = block.iter().map(|(_, line)| *line).collect();
303    let end = block.last()?.0;
304    let mut replacement: Vec<String> = ctx
305        .lines
306        .get(start..=end)?
307        .iter()
308        .map(|line| (*line).to_owned())
309        .collect();
310
311    match detect_separator(&texts)? {
312        Sep::Arrow => align_arrows(&block, start, &mut replacement),
313        Sep::Comma => align_commas(&block, start, &mut replacement)?,
314    }
315
316    align_comments(&block, start, &mut replacement);
317
318    Some(Fix::replace_lines(
319        start + 1,
320        block.last()?.0 + 1,
321        replacement.join("\n"),
322    ))
323}
324
325fn containing_block<'a>(
326    ctx: &'a FileCtx<'_>,
327    violation_line: usize,
328) -> Option<(usize, Vec<(usize, &'a str)>)> {
329    ctx.lines.iter().enumerate().find_map(|(index, line)| {
330        if !line.contains(MARKER) {
331            return None;
332        }
333
334        let block = collect_block(ctx.lines, index + 1);
335
336        block
337            .iter()
338            .any(|(line_index, _)| *line_index + 1 == violation_line)
339            .then_some((index + 1, block))
340    })
341}
342
343// #t(fn: rust_unchecked_indexing) block indices originate from the same bounded replacement range
344fn align_arrows(block: &[(usize, &str)], start: usize, replacement: &mut [String]) {
345    let target = block
346        .iter()
347        .filter_map(|(_, line)| line.find("=>"))
348        .max()
349        .unwrap_or_default();
350
351    for (index, line) in block {
352        let Some(position) = line.find("=>") else {
353            continue;
354        };
355
356        replacement[*index - start].insert_str(position, &" ".repeat(target - position));
357    }
358}
359
360// #t(fn: rust_alloc_in_loop, rust_collection_new_in_loop) each row needs an independently rebuilt aligned representation
361// #t(fn: rust_unchecked_indexing) row and column indices are derived from validated equal-width segment tables
362fn align_commas(block: &[(usize, &str)], start: usize, replacement: &mut [String]) -> Option<()> {
363    let segments: Vec<Vec<&str>> = block
364        .iter()
365        .map(|(_, line)| split_at_commas(line))
366        .collect();
367    let count = segments.first()?.len();
368
369    if count < MIN_ALIGNED_ROWS || segments.iter().any(|row| row.len() != count) {
370        return None;
371    }
372
373    let widths: Vec<usize> = (0..count - 1)
374        .map(|column| {
375            segments
376                .iter()
377                .map(|row| row[column].trim_end().len())
378                .max()
379                .unwrap_or_default()
380        })
381        .collect();
382
383    for ((index, _), row) in block.iter().zip(&segments) {
384        let mut line = String::new();
385
386        for (column, segment) in row.iter().enumerate() {
387            if column == row.len() - 1 {
388                line.push_str(segment);
389            } else {
390                let segment = segment.trim_end();
391
392                line.push_str(segment);
393                line.push_str(&" ".repeat(widths[column] - segment.len()));
394                line.push(',');
395            }
396        }
397
398        replacement[*index - start] = line;
399    }
400
401    Some(())
402}
403
404// #t(fn: rust_alloc_in_loop) each commented row needs an independently rebuilt aligned representation
405fn align_comments(block: &[(usize, &str)], start: usize, replacement: &mut [String]) {
406    let commented: Vec<(usize, usize)> = block
407        .iter()
408        .filter_map(|(index, _)| {
409            let row = replacement.get(*index - start)?;
410
411            comment_position(row).map(|position| (*index, position))
412        })
413        .collect();
414
415    if commented.len() < MIN_ALIGNED_ROWS {
416        return;
417    }
418
419    let target = commented
420        .iter()
421        .filter_map(|(index, position)| {
422            replacement
423                .get(*index - start)
424                .and_then(|row| row.get(..*position))
425                .map(str::trim_end)
426                .map(str::len)
427        })
428        .max()
429        .unwrap_or_default()
430        + 1;
431
432    for (index, position) in commented {
433        let Some(row) = replacement.get_mut(index - start) else {
434            continue;
435        };
436        let Some(comment) = row.get(position..).map(str::to_owned) else {
437            continue;
438        };
439
440        row.truncate(position);
441        let code_len = row.trim_end().len();
442
443        row.truncate(code_len);
444        row.push_str(&" ".repeat(target - code_len));
445        row.push_str(&comment);
446    }
447}
448
449// #t(fn: rust_unchecked_indexing) scanner positions are valid UTF-8 byte boundaries within the source line
450fn split_at_commas(line: &str) -> Vec<&str> {
451    let mut segments = Vec::new();
452    let mut start = 0;
453
454    for position in comma_positions(line) {
455        segments.push(&line[start..position]);
456        start = position + 1;
457    }
458
459    segments.push(&line[start..]);
460
461    segments
462}
463
464crate::tidy_test!(check_aligned, {
465    crate::example_tests!(EXAMPLES, check_aligned);
466    crate::fix_tests!(line, check_aligned, fix_aligned);
467
468    #[gtest]
469    fn arrow_aligned_passes() -> Result<()> {
470        let v = run("// #t:aligned\n\
471             Spells    => \"cleanup_spells.sql\";\n\
472             Traits    => \"cleanup_traits.sql\";\n\
473             Items     => \"cleanup_items.sql\";");
474        verify_true!(v.is_empty())?;
475
476        Ok(())
477    }
478
479    #[gtest]
480    fn arrow_misaligned_fails() -> Result<()> {
481        let v = run("// #t:aligned\n\
482             Spells    => \"cleanup_spells.sql\";\n\
483             Traits => \"cleanup_traits.sql\";\n\
484             Items     => \"cleanup_items.sql\";");
485        verify_eq!(v.len(), 1)?;
486        verify_eq!(v[0].line, 3)?;
487        verify_true!(v[0].message.contains("=>"))?;
488
489        Ok(())
490    }
491
492    #[gtest]
493    fn comma_aligned_passes() -> Result<()> {
494        let v = run("// #t:aligned\n\
495             register!(A,  \"a\",  TypeA);\n\
496             register!(B,  \"b\",  TypeB);\n\
497             register!(C,  \"c\",  TypeC);");
498        verify_true!(v.is_empty())?;
499
500        Ok(())
501    }
502
503    #[gtest]
504    fn comma_misaligned_fails() -> Result<()> {
505        let v = run("// #t:aligned\n\
506             register!(A,  \"a\",  TypeA);\n\
507             register!(B, \"b\", TypeB);\n\
508             register!(C,  \"c\",  TypeC);");
509        verify_false!(v.is_empty())?;
510
511        Ok(())
512    }
513
514    #[gtest]
515    fn trailing_commas_are_aligned() -> Result<()> {
516        let source = "// #t:aligned\n\
517             \"id\": Int,\n\
518             \"patch_version\": Text,\n\
519             \"cloth_modifier\": Float,";
520        let fixed = crate::apply_line_fixes(source, check_aligned, fix_aligned);
521
522        verify_true!(run(&fixed).is_empty())?;
523
524        Ok(())
525    }
526
527    #[gtest]
528    fn skips_comments_in_block() -> Result<()> {
529        let v = run("// #t:aligned\n\
530             Spells => \"a\";\n\
531             // comment\n\
532             Traits => \"b\";");
533        verify_true!(v.is_empty())?;
534
535        Ok(())
536    }
537
538    #[gtest]
539    fn block_ends_at_blank_line() -> Result<()> {
540        let v = run("// #t:aligned\n\
541             Spells => \"a\";\n\
542             Traits => \"b\";\n\
543             \n\
544             Misaligned => \"c\";");
545        verify_true!(v.is_empty())?;
546
547        Ok(())
548    }
549
550    #[gtest]
551    fn block_ends_at_closing_delim() -> Result<()> {
552        let v = run("// #t:aligned\n\
553             Spells => \"a\";\n\
554             Traits => \"b\";\n\
555             );");
556        verify_true!(v.is_empty())?;
557
558        Ok(())
559    }
560
561    #[gtest]
562    fn block_ends_at_array_closing_delim() -> Result<()> {
563        let v = run("// #t:aligned\n\
564             (1, 2),\n\
565             (3, 4),\n\
566             ];\n\
567             call(a, b, c);");
568        verify_true!(v.is_empty())?;
569
570        Ok(())
571    }
572
573    #[gtest]
574    fn block_ends_at_macro_array_closing_delim() -> Result<()> {
575        let v = run("// #t:aligned\n\
576             \"id\": Int,\n\
577             \"x\": Text,\n\
578             ]);");
579        verify_true!(v.is_empty())?;
580
581        Ok(())
582    }
583
584    #[gtest]
585    fn no_marker_no_violations() -> Result<()> {
586        let v = run("Spells    => \"a\";\n\
587             Traits => \"b\";");
588        verify_true!(v.is_empty())?;
589
590        Ok(())
591    }
592
593    #[gtest]
594    fn comma_inside_string_ignored() -> Result<()> {
595        let v = run("// #t:aligned\n\
596             call(a, \"hello, world\", b);\n\
597             call(c, \"hello, world\", d);");
598        verify_true!(v.is_empty())?;
599
600        Ok(())
601    }
602
603    #[gtest]
604    fn mismatched_comma_count() -> Result<()> {
605        let v = run("// #t:aligned\n\
606             call(a, b, c);\n\
607             call(a, b, c);\n\
608             call(a, b);");
609        verify_eq!(v.len(), 1)?;
610        verify_true!(v[0].message.contains("commas"))?;
611
612        Ok(())
613    }
614});