Skip to main content

wowlab_tidy/languages/toml/rules/manifest/
field_order.rs

1#[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});