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
343fn 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
360fn 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
404fn 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
449fn 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});