1use 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
34pub(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}