Skip to main content

wowlab_parsers/parsers/simc/
parser.rs

1// #t(file: rust_alloc_in_loop) parser builds owned strings per token during profile parsing, unavoidable
2
3use std::collections::BTreeMap;
4
5use wowlab_types::{constants::MAX_PLAYER_LEVEL, game::GearSlot, sim::FastMap};
6
7use super::{
8    errors::SimcParseError,
9    lexer::{Token, lex},
10    types::{
11        Character, CharacterInfo, CurrencyEntry, Item, Loadout, Profession, Profile, SlotWatermark,
12        Talents, UpgradeCurrency, WowClass,
13    },
14};
15use crate::parsers::TokenStream;
16
17const UPGRADE_CURRENCY_PARTS: usize = 3;
18const SLOT_WATERMARK_PARTS: usize = 3;
19
20/// Scalar assignments the profile builder consumes directly.
21const PROFILE_SCALAR_KEYS: [&str; 9] = [
22    "level",
23    "race",
24    "spec",
25    "region",
26    "server",
27    "role",
28    "talents",
29    "professions",
30    "zandalari_loa",
31];
32
33/// Parse a `SimC` profile string into structured data.
34///
35/// # Errors
36///
37/// Returns [`SimcParseError`] when the tokenized profile is incomplete or contains invalid values.
38pub fn parse(input: &str) -> Result<Profile, SimcParseError> {
39    let mut tokens = Vec::new();
40
41    for (result, span) in lex(input).spanned() {
42        match result {
43            Ok(token) => tokens.push(token),
44            Err(()) => return Err(SimcParseError::invalid_token(span.start)),
45        }
46    }
47
48    let loadouts = extract_loadouts(&tokens);
49    let bag_items = extract_bag_items(&tokens);
50    let weekly_rewards = extract_weekly_rewards(&tokens);
51    let character_info = extract_character_info(&tokens);
52    let loot_spec = extract_loot_spec(&tokens);
53
54    let parser = Parser {
55        stream: TokenStream::new(&tokens),
56        class: None,
57        name: None,
58        assignments: FastMap::default(),
59        equipment: Vec::new(),
60        bag_items,
61        weekly_rewards,
62        loadouts,
63        character_info,
64        loot_spec,
65    };
66
67    parser.parse()
68}
69
70struct Parser<'a> {
71    stream: TokenStream<'a, Token<'a>>,
72
73    class: Option<WowClass>,
74    name: Option<String>,
75    assignments: FastMap<String, String>,
76    equipment: Vec<Item>,
77    bag_items: Vec<Item>,
78    weekly_rewards: Vec<Item>,
79    loadouts: Vec<Loadout>,
80    character_info: Option<CharacterInfo>,
81    loot_spec: Option<String>,
82}
83
84impl<'a> Parser<'a> {
85    fn peek(&self) -> Option<&Token<'a>> {
86        self.stream.peek()
87    }
88
89    fn advance(&mut self) -> Option<&Token<'a>> {
90        self.stream.advance()
91    }
92
93    fn skip_newlines(&mut self) {
94        while matches!(self.peek(), Some(Token::Newline)) {
95            self.advance();
96        }
97    }
98
99    fn skip_to_line_end(&mut self) {
100        while !matches!(self.peek(), Some(Token::Newline) | None) {
101            self.advance();
102        }
103    }
104
105    fn parse(mut self) -> Result<Profile, SimcParseError> {
106        self.skip_newlines();
107
108        while !self.stream.is_empty() {
109            self.parse_line();
110            self.skip_newlines();
111        }
112
113        self.build_profile()
114    }
115
116    fn parse_line(&mut self) {
117        if matches!(self.peek(), Some(Token::Ident(_))) {
118            self.parse_assignment();
119        } else {
120            self.advance();
121        }
122    }
123
124    fn parse_assignment(&mut self) {
125        let key = match self.advance() {
126            Some(Token::Ident(k)) => *k,
127            _ => return,
128        };
129
130        let Some(append) = self.parse_assignment_operator() else {
131            self.skip_to_line_end();
132
133            return;
134        };
135
136        if key == "actions" || key.starts_with("actions.") {
137            self.skip_to_line_end();
138
139            return;
140        }
141
142        if let Some(class) = WowClass::parse(key) {
143            if append {
144                self.skip_to_line_end();
145
146                return;
147            }
148
149            self.class = Some(class);
150            self.name = Some(self.parse_string_value());
151
152            return;
153        }
154
155        if GearSlot::from_slug(key).is_some() {
156            if append {
157                self.skip_to_line_end();
158
159                return;
160            }
161
162            self.parse_equipment(key);
163
164            return;
165        }
166
167        let value = self.parse_value();
168
169        self.store_assignment(key, value, append);
170    }
171
172    fn parse_assignment_operator(&mut self) -> Option<bool> {
173        match self.peek() {
174            Some(Token::Eq) => {
175                self.advance();
176
177                Some(false)
178            }
179            Some(Token::Plus) => {
180                self.advance();
181
182                if !matches!(self.peek(), Some(Token::Eq)) {
183                    return None;
184                }
185
186                self.advance();
187
188                Some(true)
189            }
190            _ => None,
191        }
192    }
193
194    fn store_assignment(&mut self, key: &str, value: String, append: bool) {
195        if append {
196            self.assignments
197                .entry(key.to_string())
198                .or_default()
199                .push_str(&value);
200        } else {
201            self.assignments.insert(key.to_string(), value);
202        }
203    }
204
205    fn parse_string_value(&mut self) -> String {
206        match self.advance() {
207            Some(Token::String(s) | Token::SingleString(s) | Token::Ident(s)) => s.to_string(),
208            Some(Token::Number(n)) => n.to_string(),
209            _ => String::new(),
210        }
211    }
212
213    fn parse_value(&mut self) -> String {
214        let mut parts = Vec::new();
215
216        loop {
217            match self.peek() {
218                Some(
219                    Token::String(s) | Token::SingleString(s) | Token::Ident(s) | Token::Float(s),
220                ) => {
221                    parts.push((*s).to_string());
222                    self.advance();
223                }
224                Some(Token::Number(n)) => {
225                    parts.push((*n).to_string());
226                    self.advance();
227                }
228                Some(
229                    token @ (Token::Slash
230                    | Token::Plus
231                    | Token::Minus
232                    | Token::Star
233                    | Token::Percent
234                    | Token::Eq
235                    | Token::Colon
236                    | Token::Bang
237                    | Token::Amp
238                    | Token::Pipe
239                    | Token::Lt
240                    | Token::Gt
241                    | Token::Question
242                    | Token::LParen
243                    | Token::RParen),
244                ) => {
245                    parts.push(operator_text(token).to_string());
246                    self.advance();
247                }
248                _ => break,
249            }
250        }
251
252        parts.join("")
253    }
254
255    fn parse_equipment(&mut self, slot_name: &str) {
256        let Some(slot) = GearSlot::from_slug(slot_name) else {
257            return;
258        };
259
260        if !matches!(self.peek(), Some(Token::Comma)) {
261            self.parse_value();
262        }
263
264        if matches!(self.peek(), Some(Token::Comma)) {
265            self.advance();
266        }
267
268        let kv = self.parse_kv_pairs();
269
270        let id = kv.get("id").and_then(|s| s.parse().ok()).unwrap_or(0);
271
272        if id == 0 {
273            return;
274        }
275
276        self.equipment.push(Item {
277            gear: wowlab_types::game::GearEntry {
278                slot,
279                id,
280                bonus_ids: parse_id_list(kv.get("bonus_id")),
281                enchant_id: kv.get("enchant_id").and_then(|s| s.parse().ok()),
282                gem_ids: parse_id_list(kv.get("gem_id")),
283                crafted_stats: parse_id_list(kv.get("crafted_stats")),
284                crafting_quality: kv.get("crafting_quality").and_then(|s| s.parse().ok()),
285                drop_level: kv.get("drop_level").and_then(|s| s.parse().ok()),
286                ilevel: kv.get("ilevel").and_then(|s| s.parse().ok()),
287            },
288            enchant: kv.get("enchant").cloned(),
289            suffix: kv.get("suffix").and_then(|s| s.parse().ok()),
290            gem_bonus_ids: parse_id_list(kv.get("gem_bonus_id")),
291            titan_disc_id: kv.get("titan_disc_id").and_then(|s| s.parse().ok()),
292        });
293    }
294
295    fn parse_kv_pairs(&mut self) -> FastMap<String, String> {
296        let mut result = FastMap::default();
297
298        while let Some(Token::Ident(k)) = self.peek() {
299            let key = k.to_string();
300
301            self.advance();
302
303            if !matches!(self.peek(), Some(Token::Eq)) {
304                break;
305            }
306
307            self.advance();
308
309            let value = self.parse_kv_value();
310
311            result.insert(key, value);
312
313            if matches!(self.peek(), Some(Token::Comma)) {
314                self.advance();
315            } else {
316                break;
317            }
318        }
319
320        result
321    }
322
323    fn parse_kv_value(&mut self) -> String {
324        let mut parts = Vec::new();
325
326        loop {
327            match self.peek() {
328                Some(Token::Number(n)) => {
329                    parts.push((*n).to_string());
330                    self.advance();
331                }
332                Some(Token::Slash) => {
333                    parts.push("/".to_string());
334                    self.advance();
335                }
336                Some(Token::Ident(s)) => {
337                    parts.push((*s).to_string());
338                    self.advance();
339                }
340                _ => break,
341            }
342        }
343
344        parts.join("")
345    }
346
347    fn build_profile(&self) -> Result<Profile, SimcParseError> {
348        let class = self.class.ok_or_else(SimcParseError::missing_class)?;
349        let name = self.name.clone().ok_or_else(SimcParseError::missing_name)?;
350
351        let character = Character {
352            name,
353            level: self
354                .get_u32("level")
355                .unwrap_or_else(|| u32::from(MAX_PLAYER_LEVEL)),
356            race: self
357                .get_title_case("race")
358                .unwrap_or_else(|| "Unknown".to_string()),
359            class,
360            spec: self.get_title_case("spec"),
361            region: self.assignments.get("region").map(|s| s.to_uppercase()),
362            server: self.assignments.get("server").cloned(),
363            role: self.assignments.get("role").cloned(),
364            professions: self.parse_professions(),
365            zandalari_loa: self.get_title_case("zandalari_loa"),
366            loot_spec: self.loot_spec.clone().map(|s| title_case(&s)),
367        };
368
369        let talents = Talents {
370            encoded: self.assignments.get("talents").cloned().unwrap_or_default(),
371            loadouts: self.loadouts.clone(),
372        };
373        let expansion_talents = self.parse_expansion_talents();
374
375        let mut extra = self.assignments.clone();
376
377        for key in PROFILE_SCALAR_KEYS {
378            extra.remove(key);
379        }
380
381        extra.retain(|key, _| expansion_talent_system(key).is_none());
382
383        Ok(Profile {
384            character,
385            equipment: self.equipment.clone(),
386            bag_items: self.bag_items.clone(),
387            weekly_rewards: self.weekly_rewards.clone(),
388            talents,
389            expansion_talents,
390            extra,
391            character_info: self.character_info.clone(),
392        })
393    }
394
395    fn get_u32(&self, key: &str) -> Option<u32> {
396        self.assignments.get(key).and_then(|s| s.parse().ok())
397    }
398
399    fn get_title_case(&self, key: &str) -> Option<String> {
400        self.assignments.get(key).map(|s| title_case(s))
401    }
402
403    fn parse_professions(&self) -> Vec<Profession> {
404        let Some(s) = self.assignments.get("professions") else {
405            return Vec::new();
406        };
407
408        s.split('/')
409            .filter_map(|part| {
410                let (name, rank) = part.split_once('=')?;
411
412                Some(Profession {
413                    name: title_case(name),
414                    rank: rank.parse().ok()?,
415                })
416            })
417            .collect()
418    }
419
420    fn parse_expansion_talents(&self) -> BTreeMap<String, Vec<String>> {
421        self.assignments
422            .iter()
423            .filter_map(|(key, value)| {
424                let system = expansion_talent_system(key)?;
425                let selections: Vec<_> = value
426                    .split('/')
427                    .filter(|selection| !selection.is_empty())
428                    .map(str::to_string)
429                    .collect();
430
431                (!selections.is_empty()).then(|| (system.to_string(), selections))
432            })
433            .collect()
434    }
435}
436
437const fn operator_text(token: &Token<'_>) -> &'static str {
438    match token {
439        Token::Slash => "/",
440        Token::Plus => "+",
441        Token::Minus => "-",
442        Token::Star => "*",
443        Token::Percent => "%",
444        Token::Eq => "=",
445        Token::Colon => ":",
446        Token::Bang => "!",
447        Token::Amp => "&",
448        Token::Pipe => "|",
449        Token::Lt => "<",
450        Token::Gt => ">",
451        Token::Question => "?",
452        Token::LParen => "(",
453        Token::RParen => ")",
454        _ => "",
455    }
456}
457
458fn expansion_talent_system(key: &str) -> Option<&str> {
459    let system = key.strip_suffix("_talents")?;
460
461    (!system.is_empty() && !matches!(system, "class" | "spec" | "hero")).then_some(system)
462}
463
464fn extract_loadouts(tokens: &[Token<'_>]) -> Vec<Loadout> {
465    let mut loadouts = Vec::new();
466    let mut pending_name: Option<String> = None;
467
468    for token in tokens {
469        if let Token::Comment(c) = token {
470            let text = c.strip_prefix('#').unwrap_or(c).trim();
471
472            if let Some(name) = text
473                .strip_prefix("Saved Loadout:")
474                .or_else(|| text.strip_prefix("Saved loadout:"))
475            {
476                pending_name = Some(name.trim().to_string());
477            } else if let Some(rest) = text.strip_prefix("talents=") {
478                if let Some(name) = pending_name.take() {
479                    loadouts.push(Loadout {
480                        name,
481                        encoded: rest.trim().to_string(),
482                    });
483                }
484            }
485        }
486    }
487
488    loadouts
489}
490
491fn extract_commented_items(
492    tokens: &[Token<'_>],
493    start_marker: &str,
494    end_marker: &str,
495) -> Vec<Item> {
496    let mut items = Vec::new();
497    let mut in_section = false;
498
499    for token in tokens {
500        if let Token::Comment(c) = token {
501            let text = c.strip_prefix('#').unwrap_or(c).trim();
502
503            if text.starts_with(start_marker) {
504                in_section = true;
505                continue;
506            }
507
508            if text.starts_with(end_marker) {
509                break;
510            }
511
512            if !in_section {
513                continue;
514            }
515
516            if text.is_empty() || !text.contains('=') {
517                continue;
518            }
519
520            if let Some(item) = parse_bag_item_line(text) {
521                items.push(item);
522            }
523        }
524    }
525
526    items
527}
528
529fn extract_bag_items(tokens: &[Token<'_>]) -> Vec<Item> {
530    extract_commented_items(tokens, "## Gear from Bags", "## Additional Character Info")
531}
532
533fn extract_weekly_rewards(tokens: &[Token<'_>]) -> Vec<Item> {
534    extract_commented_items(
535        tokens,
536        "## Weekly Reward Choices",
537        "## End of Weekly Reward Choices",
538    )
539}
540
541fn extract_loot_spec(tokens: &[Token<'_>]) -> Option<String> {
542    for token in tokens {
543        if let Token::Comment(c) = token {
544            let text = c.strip_prefix('#').unwrap_or(c).trim();
545
546            if let Some(value) = text.strip_prefix("loot_spec=") {
547                return Some(value.to_string());
548            }
549        }
550    }
551
552    None
553}
554
555fn parse_bag_item_line(line: &str) -> Option<Item> {
556    let (slot_part, rest) = line.split_once('=')?;
557    let slot = GearSlot::from_slug(slot_part)?;
558
559    let kv = parse_comment_kv_pairs(rest);
560
561    let id = kv.get("id").and_then(|s| s.parse().ok())?;
562
563    if id == 0 {
564        return None;
565    }
566
567    Some(Item {
568        gear: wowlab_types::game::GearEntry {
569            slot,
570            id,
571            bonus_ids: parse_id_list(kv.get("bonus_id")),
572            enchant_id: kv.get("enchant_id").and_then(|s| s.parse().ok()),
573            gem_ids: parse_id_list(kv.get("gem_id")),
574            crafted_stats: parse_id_list(kv.get("crafted_stats")),
575            crafting_quality: kv.get("crafting_quality").and_then(|s| s.parse().ok()),
576            drop_level: kv.get("drop_level").and_then(|s| s.parse().ok()),
577            ilevel: kv.get("ilevel").and_then(|s| s.parse().ok()),
578        },
579        enchant: kv.get("enchant").cloned(),
580        suffix: kv.get("suffix").and_then(|s| s.parse().ok()),
581        gem_bonus_ids: parse_id_list(kv.get("gem_bonus_id")),
582        titan_disc_id: kv.get("titan_disc_id").and_then(|s| s.parse().ok()),
583    })
584}
585
586fn parse_comment_kv_pairs(input: &str) -> FastMap<String, String> {
587    let mut result = FastMap::default();
588
589    let input = input.strip_prefix(',').unwrap_or(input);
590
591    for part in input.split(',') {
592        if let Some((key, value)) = part.split_once('=') {
593            result.insert(key.to_string(), value.to_string());
594        }
595    }
596
597    result
598}
599
600fn parse_id_list(value: Option<&String>) -> Option<Vec<u32>> {
601    let s = value?;
602    let nums: Vec<u32> = s.split('/').filter_map(|p| p.parse().ok()).collect();
603
604    if nums.is_empty() { None } else { Some(nums) }
605}
606
607fn title_case(s: &str) -> String {
608    s.split('_')
609        .map(|w| {
610            let mut c = w.chars();
611
612            match c.next() {
613                Some(first) => first.to_uppercase().chain(c).collect(),
614                None => String::new(),
615            }
616        })
617        .collect::<Vec<_>>()
618        .join(" ")
619}
620
621fn extract_character_info(tokens: &[Token<'_>]) -> Option<CharacterInfo> {
622    let mut info = CharacterInfo::default();
623    let mut in_section = false;
624    let mut has_data = false;
625
626    for token in tokens {
627        if let Token::Comment(c) = token {
628            let text = c.strip_prefix('#').unwrap_or(c).trim();
629
630            if text.starts_with("## Additional Character Info") {
631                in_section = true;
632                continue;
633            }
634
635            if let Some(checksum) = text.strip_prefix("Checksum:") {
636                info.checksum = Some(checksum.trim().to_string());
637                has_data = true;
638                continue;
639            }
640
641            if !in_section {
642                continue;
643            }
644
645            if let Some(value) = text.strip_prefix("catalyst_currencies=") {
646                info.catalyst_currencies = parse_catalyst_currencies(value);
647                has_data = true;
648            } else if let Some(value) = text.strip_prefix("upgrade_currencies=") {
649                info.upgrade_currencies = parse_upgrade_currencies(value);
650                has_data = true;
651            } else if let Some(value) = text.strip_prefix("slot_high_watermarks=") {
652                info.slot_high_watermarks = parse_slot_watermarks(value);
653                has_data = true;
654            } else if let Some(value) = text.strip_prefix("upgrade_achievements=") {
655                info.upgrade_achievements =
656                    value.split('/').filter_map(|s| s.parse().ok()).collect();
657                has_data = true;
658            }
659        }
660    }
661
662    has_data.then_some(info)
663}
664
665fn parse_catalyst_currencies(value: &str) -> Vec<CurrencyEntry> {
666    value
667        .split('/')
668        .filter_map(|part| {
669            let (id, amount) = part.split_once(':')?;
670
671            Some(CurrencyEntry {
672                id: id.parse().ok()?,
673                amount: amount.parse().ok()?,
674            })
675        })
676        .collect()
677}
678
679fn parse_upgrade_currencies(value: &str) -> Vec<UpgradeCurrency> {
680    value
681        .split('/')
682        .filter_map(|part| {
683            let mut parts = part.splitn(UPGRADE_CURRENCY_PARTS, ':');
684            let currency_type = parts.next()?.to_string();
685            let id = parts.next()?.parse().ok()?;
686            let amount = parts.next()?.parse().ok()?;
687
688            Some(UpgradeCurrency {
689                currency_type,
690                id,
691                amount,
692            })
693        })
694        .collect()
695}
696
697fn parse_slot_watermarks(value: &str) -> Vec<SlotWatermark> {
698    value
699        .split('/')
700        .filter_map(|part| {
701            let mut parts = part.splitn(SLOT_WATERMARK_PARTS, ':');
702            let slot = parts.next()?.parse().ok()?;
703            let current = parts.next()?.parse().ok()?;
704            let max = parts.next()?.parse().ok()?;
705
706            Some(SlotWatermark { slot, current, max })
707        })
708        .collect()
709}
710
711#[cfg(test)]
712mod tests;