Skip to main content

wowlab_parsers/parsers/spell_desc/
lexer.rs

1// #t(file: rust_unchecked_indexing) lexer slices token strings from regex-matched logos output with guaranteed structure
2
3use logos::{Lexer as LogosLexer, Logos};
4
5const DOLLAR_PREFIX_LEN: usize = 2;
6const GLOBAL_STRING_PREFIX_LEN: usize = 2;
7
8// Trailing decimal format specifier (e.g. `.2`) stays in the slice for the parser to extract.
9fn parse_expression_block<'a>(lex: &mut LogosLexer<'a, Token<'a>>) -> &'a str {
10    let remainder = lex.remainder();
11    let mut depth = 1;
12    let mut end = 0;
13
14    for (i, c) in remainder.char_indices() {
15        match c {
16            '{' => depth += 1,
17            '}' => {
18                depth -= 1;
19
20                if depth == 0 {
21                    end = i;
22                    break;
23                }
24            }
25            _ => {}
26        }
27    }
28
29    let after_brace = &remainder[end + 1..];
30    let format_len = if let Some(after_dot) = after_brace.strip_prefix('.') {
31        let digits: usize = after_dot.chars().take_while(char::is_ascii_digit).count();
32
33        if digits > 0 { 1 + digits } else { 0 }
34    } else {
35        0
36    };
37
38    lex.bump(end + 1 + format_len);
39
40    &remainder[..=(end + format_len)]
41}
42
43// #t(block: rust_unchecked_indexing) regex guarantees $[lL]...; format with len >= 3
44fn parse_pluralization<'a>(lex: &mut LogosLexer<'a, Token<'a>>) -> (&'a str, bool) {
45    let slice = lex.slice();
46    let capitalized = slice.chars().nth(1) == Some('L');
47    let content = &slice[DOLLAR_PREFIX_LEN..slice.len() - 1];
48
49    (content, capitalized)
50}
51
52// #t(block: rust_unchecked_indexing) regex guarantees |4...; format with len >= 4
53fn parse_global_pluralization<'a>(lex: &mut LogosLexer<'a, Token<'a>>) -> (&'a str, bool) {
54    let slice = lex.slice();
55    let content = &slice[GLOBAL_STRING_PREFIX_LEN..slice.len() - 1];
56
57    (content, false)
58}
59
60// #t(block: rust_unchecked_indexing) regex guarantees $[gG]...; format with len >= 3
61fn parse_gender<'a>(lex: &mut LogosLexer<'a, Token<'a>>) -> (&'a str, bool) {
62    let slice = lex.slice();
63    let capitalized = slice.chars().nth(1) == Some('G');
64    let content = &slice[DOLLAR_PREFIX_LEN..slice.len() - 1];
65
66    (content, capitalized)
67}
68
69fn parse_cond_func<'a>(lex: &mut LogosLexer<'a, Token<'a>>) -> &'a str {
70    lex.slice()
71}
72
73/// Lexer tokens for `WoW` spell description strings.
74#[derive(Clone, Debug, Logos, PartialEq)]
75pub(crate) enum Token<'a> {
76    /// Must be matched before bare `$` variables.
77    #[regex(r"\$\{", parse_expression_block)]
78    ExpressionBlock(&'a str),
79
80    #[regex(r"\$<[a-zA-Z][a-zA-Z0-9]*>", priority = 10)]
81    CustomVariable(&'a str),
82
83    #[regex(r"\$@[a-zA-Z]+\d*", priority = 10)]
84    AtVariable(&'a str),
85
86    #[regex(r"\$(?:[sS]|m|M|o|t|a|A|e|w|W|x|bc|q|sw)[1-9]", priority = 10)]
87    EffectVariable(&'a str),
88
89    #[regex(r"\$(?:d[1-3]?|n|u|h|r|i|p\d?|z|c\d)", priority = 10)]
90    SpellLevelVariable(&'a str),
91
92    #[regex(r"\$(?:SP|sp|AP|ap|RAP|MHP|mhp|SPS|PL|pl|INT)", priority = 10)]
93    PlayerVariable(&'a str),
94
95    #[regex(r"\$(?:ec[12](?:s\d)?|ecix|ecim|ecd)", priority = 10)]
96    EnchantVariable(&'a str),
97
98    #[regex(r"\$(?:maxcast|pctD|W|B|ctrmax\d+|mws|mwb|MWS|MWB|ows|OWB|lpoint|mastery|mas|proccooldown|procrppm|pri|rolemult)", priority = 10)]
99    MiscVariable(&'a str),
100
101    #[token("$?", priority = 10)]
102    ConditionalStart,
103
104    #[regex(r"\$[lL][^:;]*:[^;]*;", parse_pluralization, priority = 10)]
105    #[regex(r"\|4[^:;]*:[^;]*;", parse_global_pluralization, priority = 10)]
106    Pluralization((&'a str, bool)),
107
108    #[regex(r"\$[gG][^:;]*:[^;]*;", parse_gender, priority = 10)]
109    Gender((&'a str, bool)),
110
111    #[regex(r"\$\d+[a-zA-Z]+\d*", priority = 10)]
112    CrossSpellRef(&'a str),
113
114    #[regex(r"\$[a-zA-Z_][a-zA-Z0-9_]*", priority = 1)]
115    SimpleVariable(&'a str),
116
117    #[token("$")]
118    Dollar,
119
120    #[regex(r"\|c[0-9a-fA-F]{8}|\|r", priority = 5)]
121    ColorCode(&'a str),
122
123    #[token("|")]
124    Pipe,
125
126    #[token("[")]
127    LBracket,
128
129    #[token("]")]
130    RBracket,
131
132    #[regex(r"\$[a-zA-Z]+\([^)]*\)", parse_cond_func, priority = 10)]
133    CondFuncCall(&'a str),
134
135    #[token("?")]
136    Question,
137
138    #[regex(r"[^$|\[\]?]+")]
139    Text(&'a str),
140}
141
142/// Concrete logos lexer alias for spell description [`Token`]s.
143type Lexer<'a> = LogosLexer<'a, Token<'a>>;
144
145/// Build a lexer for the given spell description source.
146pub(crate) fn lex(input: &str) -> Lexer<'_> {
147    Token::lexer(input)
148}
149
150/// Tokenize a spell description string, discarding lexer errors.
151pub(crate) fn tokenize(input: &str) -> Vec<Token<'_>> {
152    lex(input).filter_map(Result::ok).collect()
153}
154
155/// Tokens for expression parsing inside ${...} blocks.
156#[derive(Clone, Debug, Logos, PartialEq)]
157#[logos(skip r"[ \t]+")]
158pub(crate) enum ExprToken<'a> {
159    #[regex(r"\.\d", |lex| lex.slice().chars().nth(1).and_then(|c| c.to_digit(10)).and_then(|digit| u8::try_from(digit).ok()))]
160    DecimalFormat(u8),
161
162    #[regex(r"\$<[a-zA-Z][a-zA-Z0-9]*>", priority = 10)]
163    CustomVar(&'a str),
164
165    #[regex(r"\$(?:[sS]|m|M|o|t|a|A|e|w|W|x|bc|q|sw)[1-9]", priority = 10)]
166    EffectVar(&'a str),
167
168    #[regex(r"\$(?:d[1-3]?|n|u|h|r|i|p\d?|z|c\d)", priority = 10)]
169    SpellLevelVar(&'a str),
170
171    #[regex(r"\$(?:SP|sp|AP|ap|RAP|MHP|mhp|SPS|PL|pl|INT)", priority = 10)]
172    PlayerVar(&'a str),
173
174    #[regex(r"\$(?:ec[12](?:s\d)?|ecix|ecim|ecd)", priority = 10)]
175    EnchantVar(&'a str),
176
177    #[regex(r"\$(?:maxcast|pctD|W|B|ctrmax\d+|mws|mwb|MWS|MWB|ows|OWB|lpoint|mastery|mas|proccooldown|procrppm|pri|rolemult)", priority = 10)]
178    MiscVar(&'a str),
179
180    #[regex(r"\$@[a-zA-Z]+\d*", priority = 10)]
181    AtVar(&'a str),
182
183    #[regex(r"\$\d+[a-zA-Z]+\d*", priority = 10)]
184    CrossSpellRef(&'a str),
185
186    #[regex(r"\$(?:cond|gte|gt|lte|lt|max|min|clamp|floor)", priority = 10)]
187    DollarFunc(&'a str),
188
189    #[regex(r"\$[a-zA-Z_][a-zA-Z0-9_]*", priority = 1)]
190    SimpleVar(&'a str),
191
192    #[regex(r"\d+(?:\.\d+)?", |lex| lex.slice().parse::<f64>().ok())]
193    Number(f64),
194
195    #[regex(r"[a-zA-Z_][a-zA-Z0-9_]*")]
196    Ident(&'a str),
197
198    #[token("(")]
199    LParen,
200
201    #[token(")")]
202    RParen,
203
204    #[token("+")]
205    Plus,
206
207    #[token("-")]
208    Minus,
209
210    #[token("*")]
211    Star,
212
213    #[token("/")]
214    Slash,
215
216    #[token(",")]
217    Comma,
218}
219
220/// Build an expression-token lexer for the contents of a `${...}` block.
221pub(crate) fn lex_expr(input: &str) -> LogosLexer<'_, ExprToken<'_>> {
222    ExprToken::lexer(input)
223}
224
225#[cfg(feature = "wasm")]
226use wowlab_types::spell_desc::SpellDescFragment;
227
228#[cfg(feature = "wasm")]
229const COLOR_CODE_PREFIX_LEN: usize = 2;
230
231// #t(block: rust_unchecked_indexing) spans come from logos lexer and are always valid
232#[cfg(feature = "wasm")]
233fn token_to_fragment(
234    token: &Token<'_>,
235    input: &str,
236    span: std::ops::Range<usize>,
237) -> SpellDescFragment {
238    match token {
239        Token::ColorCode(code) => {
240            if code.to_lowercase() == "|r" {
241                SpellDescFragment::ColorEnd
242            } else {
243                SpellDescFragment::ColorStart {
244                    color: code[COLOR_CODE_PREFIX_LEN..].to_uppercase(),
245                }
246            }
247        }
248
249        Token::ExpressionBlock(_) => SpellDescFragment::RawToken {
250            value: format!("${{{}}}", &input[span.start + 2..span.end]),
251        },
252
253        Token::Pluralization((content, cap)) => SpellDescFragment::RawToken {
254            value: format!("{}{};\u{200B}", if *cap { "$L" } else { "$l" }, content),
255        },
256        Token::Gender((content, cap)) => SpellDescFragment::RawToken {
257            value: format!("{}{};\u{200B}", if *cap { "$G" } else { "$g" }, content),
258        },
259
260        Token::CustomVariable(_)
261        | Token::AtVariable(_)
262        | Token::EffectVariable(_)
263        | Token::SpellLevelVariable(_)
264        | Token::PlayerVariable(_)
265        | Token::EnchantVariable(_)
266        | Token::MiscVariable(_)
267        | Token::CrossSpellRef(_)
268        | Token::SimpleVariable(_)
269        | Token::ConditionalStart
270        | Token::CondFuncCall(_) => SpellDescFragment::RawToken {
271            value: input[span].to_string(),
272        },
273
274        _ => SpellDescFragment::Text {
275            value: input[span].to_string(),
276        },
277    }
278}
279
280/// Tokenize input into fragments for debug display.
281#[cfg(feature = "wasm")]
282pub(crate) fn tokenize_to_fragments(input: &str) -> Vec<SpellDescFragment> {
283    let mut lexer = lex(input);
284    let mut fragments = Vec::new();
285
286    while let Some(result) = lexer.next() {
287        if let Ok(token) = result {
288            let fragment = token_to_fragment(&token, input, lexer.span());
289
290            // #t(block: rust_alloc_in_loop) intentional in-place merge of adjacent text tokens
291            if let SpellDescFragment::Text { value } = &fragment {
292                if let Some(SpellDescFragment::Text { value: existing }) = fragments.last_mut() {
293                    existing.push_str(value);
294                    continue;
295                }
296            }
297
298            fragments.push(fragment);
299        }
300    }
301
302    fragments
303}