Skip to main content

tablegen/
resolver.rs

1//! Generates the studio `GameDataResolver` TypeScript interface from the `DataResolver` trait.
2
3use anyhow::{Context, Result};
4use ra_ap_syntax::{
5    Edition, SourceFile,
6    ast::{self, HasModuleItem, HasName},
7};
8use wowlab_common::snake_to_camel;
9use wowlab_fs::{file, path::Path};
10
11const TRAIT_NAME: &str = "DataResolver";
12
13#[rustfmt::skip]
14const SKIP: &[&str] = &[
15    "get_spells",
16    "get_spell_effect",
17    "get_spell_effects",
18    "get_spell_overrides",
19    "get_specialization_spells",
20    "get_racial_spells",
21    "find_consumable_items",
22    "find_permanent_enchant",
23    "search_spells",
24    "decode_traits",
25];
26
27const RESOLVER_SRC: &str = concat!(
28    env!("CARGO_MANIFEST_DIR"),
29    "/../engine-ports/src/resolver.rs"
30);
31
32const HEADER: &str = "// @generated by tablegen from the DataResolver trait (crates/engine-ports) -- do not edit by hand.";
33
34/// Render the `GameDataResolver` interface `.ts` from the trait source.
35pub(super) fn generate() -> Result<String> {
36    let src =
37        file::read_text(Path::new(RESOLVER_SRC)).with_context(|| format!("read {RESOLVER_SRC}"))?;
38
39    generate_from_source(&src)
40}
41
42fn generate_from_source(src: &str) -> Result<String> {
43    let parse = SourceFile::parse(src, Edition::Edition2024);
44
45    if let Some(error) = parse.errors().into_iter().next() {
46        return Err(error).context("parse resolver.rs");
47    }
48
49    let file = parse.tree();
50    let item = file
51        .items()
52        .find_map(|item| match item {
53            ast::Item::Trait(trait_def)
54                if trait_def
55                    .name()
56                    .is_some_and(|name| name.text() == TRAIT_NAME) =>
57            {
58                Some(trait_def)
59            }
60            _ => None,
61        })
62        .with_context(|| format!("trait {TRAIT_NAME} not found in resolver.rs"))?;
63
64    let mut lines = interface_lines(&item);
65
66    lines.sort();
67
68    Ok(format!(
69        "{HEADER}\n\nexport type GameDataResolver = {{\n{}\n}};\n",
70        lines.join("\n")
71    ))
72}
73
74fn interface_lines(item: &ast::Trait) -> Vec<String> {
75    item.assoc_item_list()
76        .into_iter()
77        .flat_map(|list| list.assoc_items())
78        .filter_map(|entry| match entry {
79            ast::AssocItem::Fn(method) => {
80                let name = method.name()?.text().to_string();
81
82                (!SKIP.contains(&name.as_str())).then(|| method_line(&name, &method))
83            }
84            _ => None,
85        })
86        .collect()
87}
88
89fn method_line(name: &str, method: &ast::Fn) -> String {
90    let args: Vec<String> = method
91        .param_list()
92        .into_iter()
93        .flat_map(|params| params.params())
94        .map(|param| {
95            let arg = match param.pat() {
96                Some(ast::Pat::IdentPat(ident)) => ident
97                    .name()
98                    .map_or_else(|| "arg".to_string(), |name| snake_to_camel(&name.text())),
99                _ => "arg".to_string(),
100            };
101            let ty = param.ty().as_ref().map_or("unknown", ts_type);
102
103            format!("{arg}: {ty}")
104        })
105        .collect();
106
107    format!(
108        "  {}({}): Promise<unknown>;",
109        snake_to_camel(name),
110        args.join(", ")
111    )
112}
113
114fn ts_type(ty: &ast::Type) -> &'static str {
115    match ty {
116        ast::Type::RefType(reference) => reference.ty().as_ref().map_or("unknown", ts_type),
117        ast::Type::PathType(path) => {
118            let name_ref = path
119                .path()
120                .and_then(|path| path.segment())
121                .and_then(|segment| segment.name_ref());
122            let type_name = name_ref
123                .map(|name| name.text().to_string())
124                .unwrap_or_default();
125
126            match type_name.as_str() {
127                "SpellId" | "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64"
128                | "usize" | "isize" | "f32" | "f64" => "number",
129                "String" | "str" => "string",
130                "bool" => "boolean",
131                _ => "unknown",
132            }
133        }
134        _ => "unknown",
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use googletest::prelude::*;
141    use ra_ap_syntax::{Edition, SourceFile, SyntaxError, ast, ast::HasModuleItem as _};
142
143    use super::{generate, generate_from_source, ts_type};
144
145    fn parsed_type(input: &str) -> Result<ast::Type> {
146        let source = format!("type Test = {input};");
147        let parse = SourceFile::parse(&source, Edition::Edition2024);
148
149        verify_that!(parse.errors(), is_empty())?;
150
151        parse
152            .tree()
153            .items()
154            .find_map(|item| match item {
155                ast::Item::TypeAlias(alias) => alias.ty(),
156                _ => None,
157            })
158            .or_fail()
159    }
160
161    #[gtest]
162    fn ts_type_maps_rust_types_to_ts() -> Result<()> {
163        let cases = [
164            ("u32", "number"),
165            ("SpellId", "number"),
166            ("crate::types::SpellId", "number"),
167            ("f64", "number"),
168            ("String", "string"),
169            ("&str", "string"),
170            ("&mut str", "string"),
171            ("bool", "boolean"),
172            ("MyCustomStruct", "unknown"),
173            ("Vec<u32>", "unknown"),
174            ("(u32, u32)", "unknown"),
175        ];
176
177        for (input, expected) in cases {
178            let ty = parsed_type(input)?;
179
180            verify_that!(ts_type(&ty), eq(expected))?;
181        }
182
183        Ok(())
184    }
185
186    #[gtest]
187    fn generates_the_js_facing_methods_from_the_trait() -> Result<()> {
188        let ts = generate().or_fail()?;
189
190        verify_that!(ts, contains_substring("export type GameDataResolver = {"))?;
191        verify_that!(
192            ts,
193            contains_substring("getSpell(spellId: number): Promise<unknown>;")
194        )?;
195        verify_that!(
196            ts,
197            contains_substring(
198                "getItemDamageScaling(itemLevel: number, weaponType: string): Promise<unknown>;"
199            )
200        )?;
201        verify_that!(
202            ts,
203            contains_substring(
204                "getExpectedStats(expansionId: number, lvl: number): Promise<unknown>;"
205            )
206        )?;
207        verify_that!(
208            ts,
209            contains_substring("getEnchantment(enchantmentId: number): Promise<unknown>;")
210        )?;
211        verify_that!(
212            ts,
213            contains_substring("getScalingData(): Promise<unknown>;")
214        )?;
215
216        Ok(())
217    }
218
219    #[gtest]
220    fn excludes_bridge_derived_methods() -> Result<()> {
221        let ts = generate().or_fail()?;
222
223        verify_that!(ts, not(contains_substring("getSpellEffect")))?;
224        verify_that!(ts, not(contains_substring("getSpellEffects")))?;
225        verify_that!(ts, not(contains_substring("getSpellOverrides")))?;
226        verify_that!(ts, not(contains_substring("findPermanentEnchant")))?;
227        verify_that!(ts, not(contains_substring("searchSpells")))?;
228
229        Ok(())
230    }
231
232    #[gtest]
233    fn preserves_parse_and_missing_trait_diagnostics() -> Result<()> {
234        let parse_error = generate_from_source("pub trait DataResolver { fn broken( }")
235            .err()
236            .or_fail()?;
237
238        verify_that!(parse_error.to_string(), eq("parse resolver.rs"))?;
239        verify_true!(
240            parse_error
241                .source()
242                .is_some_and(<dyn std::error::Error>::is::<SyntaxError>)
243        )?;
244
245        let missing = generate_from_source("pub trait Other {}").err().or_fail()?;
246
247        verify_that!(
248            missing.to_string(),
249            eq("trait DataResolver not found in resolver.rs")
250        )
251    }
252
253    #[gtest]
254    fn preserves_parameter_fallback_and_sorted_method_output() -> Result<()> {
255        let ts = generate_from_source(
256            r"
257pub trait DataResolver {
258    fn z_method(&self, (left, right): (u32, u32));
259    fn a_method(&self, item_id: &i32, custom: Vec<u32>);
260}
261",
262        )
263        .or_fail()?;
264
265        verify_that!(
266            ts,
267            contains_substring(
268                "  aMethod(itemId: number, custom: unknown): Promise<unknown>;\n  zMethod(arg: unknown): Promise<unknown>;"
269            )
270        )
271    }
272}