wowlab_tidy/languages/toml/rules/manifest/
field_order.rs1#[cfg(test)]
2use googletest::prelude::*;
3
4use super::MANIFEST_PREFIX;
5use crate::{Example, Fix, TomlCtx, Violation, violation};
6
7#[rustfmt::skip]
8const EXAMPLES: &[Example] = &[
9 Example { label: "identity field first", code: "[spec]\nid = 1\ncustom_handler = \"handler\"\n\n[auras.TEST]\nid = 2\non = \"player\"\n", pass: true },
10 Example { label: "spec id after handler", code: "[spec]\ncustom_handler = \"handler\"\nid = 1\n", pass: false },
11 Example { label: "aura id after behavior", code: "[spec]\nid = 1\n\n[auras.TEST]\non = \"player\"\nid = 2\n", pass: false },
12];
13
14crate::toml_rule!(
15 toml_manifest_field_order,
16 "Require identity fields to appear first in manifest definition tables.",
17 "IDs and names identify a definition and must be visible before its behavior and tuning fields.",
18 Low,
19 fix_toml_manifest_field_order,
20);
21
22fn check_toml_manifest_field_order(ctx: &TomlCtx<'_>) -> Vec<Violation> {
23 if !ctx.file.rel.starts_with(MANIFEST_PREFIX) || !ctx.parse.errors.is_empty() {
24 return Vec::new();
25 }
26
27 first_misordered_identity(ctx.file.lines).map_or_else(Vec::new, |(line, expected, actual)| {
28 vec![violation(
29 ctx.file.rel,
30 line,
31 format!("`{expected}` must be the first field in this definition, before `{actual}`"),
32 )]
33 })
34}
35
36#[expect(
37 clippy::unnecessary_wraps,
38 reason = "rule fix callbacks share an optional-fix signature"
39)]
40fn fix_toml_manifest_field_order(ctx: &TomlCtx<'_>, _: &Violation) -> Option<Fix> {
41 Some(Fix {
42 start_line: 1,
43 end_line: ctx.file.lines.len().max(1),
44 replacement: normalize_identity_fields(ctx.file.contents),
45 })
46}
47
48fn first_misordered_identity<'a>(lines: &'a [&'a str]) -> Option<(usize, &'static str, &'a str)> {
49 let mut index = 0;
50
51 while index < lines.len() {
52 let Some(expected) = lines
53 .get(index)
54 .and_then(|line| identity_field(line.trim()))
55 else {
56 index += 1;
57 continue;
58 };
59 let mut field = index + 1;
60
61 while field < lines.len() {
62 let line = lines.get(field)?.trim();
63
64 if line.starts_with('[') {
65 break;
66 }
67
68 if let Some(actual) = assignment_key(line) {
69 if actual != expected {
70 return Some((field + 1, expected, actual));
71 }
72
73 break;
74 }
75
76 field += 1;
77 }
78
79 index = field.max(index + 1);
80 }
81
82 None
83}
84
85fn normalize_identity_fields(source: &str) -> String {
86 let trailing_newline = source.ends_with('\n');
87 let mut lines: Vec<String> = source.lines().map(String::from).collect();
88 let mut index = 0;
89
90 while index < lines.len() {
91 let Some(expected) = lines
92 .get(index)
93 .and_then(|line| identity_field(line.trim()))
94 else {
95 index += 1;
96 continue;
97 };
98 let table_end = lines
99 .get(index + 1..)
100 .unwrap_or_default()
101 .iter()
102 .position(|line| line.trim().starts_with('['))
103 .map_or(lines.len(), |offset| index + offset + 1);
104 let first_field = (index + 1..table_end).find(|line| {
105 lines
106 .get(*line)
107 .and_then(|line| assignment_key(line.trim()))
108 .is_some()
109 });
110 let identity = (index + 1..table_end).find(|line| {
111 lines
112 .get(*line)
113 .and_then(|line| assignment_key(line.trim()))
114 == Some(expected)
115 });
116
117 if let (Some(first_field), Some(identity)) = (first_field, identity) {
118 if identity != first_field {
119 let line = lines.remove(identity);
120
121 lines.insert(first_field, line);
122 }
123 }
124
125 index = table_end;
126 }
127
128 let mut normalized = lines.join("\n");
129
130 if trailing_newline {
131 normalized.push('\n');
132 }
133
134 normalized
135}
136
137fn identity_field(header: &str) -> Option<&'static str> {
138 match header {
139 "[spec]" => Some("id"),
140 "[mastery]" => Some("spell_id"),
141 "[resource]" | "[secondary_resource]" => Some("name"),
142 _ if entity_header(header, "auras")
143 || entity_header(header, "spells")
144 || entity_header(header, "items") =>
145 {
146 Some("id")
147 }
148 _ if entity_header(header, "auto_attacks") || entity_header(header, "effects") => {
149 Some("spell_id")
150 }
151 _ => None,
152 }
153}
154
155fn entity_header(header: &str, section: &str) -> bool {
156 let Some(inner) = header
157 .strip_prefix('[')
158 .and_then(|value| value.strip_suffix(']'))
159 else {
160 return false;
161 };
162 let Some(entity) = inner
163 .strip_prefix(section)
164 .and_then(|value| value.strip_prefix('.'))
165 else {
166 return false;
167 };
168
169 !entity.is_empty() && !entity.contains('.')
170}
171
172fn assignment_key(line: &str) -> Option<&str> {
173 if line.is_empty() || line.starts_with('#') {
174 return None;
175 }
176
177 line.split_once('=').map(|(key, _)| key.trim())
178}
179
180crate::tidy_toml_test!(check_toml_manifest_field_order, {
181 crate::example_tests!(EXAMPLES, check_toml_manifest_field_order);
182
183 #[gtest]
184 fn normalizer_moves_every_identity_field_without_reordering_definitions() -> Result<()> {
185 let source = "[spec]\ncustom_handler = \"handler\"\nid = 1\n\n[auras.SECOND]\non = \"player\"\nid = 3\n\n[auras.FIRST]\nduration_ms = 1000\nid = 2\n";
186 let normalized = normalize_identity_fields(source);
187 verify_eq!(
188 normalized,
189 "[spec]\nid = 1\ncustom_handler = \"handler\"\n\n[auras.SECOND]\nid = 3\non = \"player\"\n\n[auras.FIRST]\nid = 2\nduration_ms = 1000\n"
190 )?;
191
192 Ok(())
193 }
194});