1use 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
20const 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
33pub 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;