Skip to main content

forge_card_script/
lib.rs

1use std::ops::Range;
2
3use smallvec::SmallVec;
4use winnow::prelude::*;
5use winnow::token::take_till;
6use winnow::Result;
7
8const AB: &str = "AB";
9const SP: &str = "SP";
10const DB: &str = "DB";
11const ST: &str = "ST";
12
13pub type ParamEntries<'a> = SmallVec<[ParamEntry<'a>; 8]>;
14pub type ParamDiagnostics<'a> = SmallVec<[ParamDiagnostic<'a>; 4]>;
15pub type ScriptLines<'a> = SmallVec<[ScriptLine<'a>; 32]>;
16pub type ScriptDiagnostics<'a> = SmallVec<[ScriptDiagnostic<'a>; 8]>;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ParamEntry<'a> {
20    pub key: &'a str,
21    pub value: &'a str,
22    pub key_span: Range<usize>,
23    pub value_span: Range<usize>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct SemanticParam<'a> {
28    pub key: &'a str,
29    pub raw_value: &'a str,
30    pub value: SemanticParamValue<'a>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum SemanticParamValue<'a> {
35    AbilityRecord(&'a str),
36    Symbol(&'a str),
37    ProducedMana(SemanticProducedMana<'a>),
38    Boolean(bool),
39    Integer(i32),
40    Amount(SemanticAmount<'a>),
41    ZoneList(SmallVec<[&'a str; 4]>),
42    Selector(SemanticSelector<'a>),
43    Reference(SemanticSelector<'a>),
44    SVarReference(SmallVec<[&'a str; 4]>),
45    Cost(&'a str),
46    Text(&'a str),
47    DelimitedList(SmallVec<[&'a str; 4]>),
48    Transform(SemanticTransform<'a>),
49    Comparison(SemanticComparison<'a>),
50    Expression(&'a str),
51    Raw(&'a str),
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum SemanticParamValueKind {
56    AbilityRecord,
57    Symbol,
58    ProducedMana,
59    Boolean,
60    Integer,
61    Amount,
62    ZoneList,
63    Selector,
64    Reference,
65    SVarReference,
66    Cost,
67    Text,
68    DelimitedList,
69    Transform,
70    Comparison,
71    Expression,
72    Raw,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum SemanticAmount<'a> {
77    Literal(i32),
78    X,
79    Any,
80    All,
81    SVar(&'a str),
82    Expression(&'a str),
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum SemanticProducedMana<'a> {
87    Any,
88    Chosen,
89    Combo(SemanticProducedManaCombo<'a>),
90    Special(&'a str),
91    Fixed(SmallVec<[&'a str; 4]>),
92    Raw(&'a str),
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum SemanticProducedManaCombo<'a> {
97    Any,
98    Chosen,
99    ColorIdentity,
100    Colors(SmallVec<[&'a str; 4]>),
101    Raw(&'a str),
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct SemanticSelector<'a> {
106    pub alternatives: SmallVec<[SemanticSelectorAlternative<'a>; 2]>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct SemanticSelectorAlternative<'a> {
111    pub raw: &'a str,
112    pub parts: SmallVec<[SemanticSelectorPart<'a>; 4]>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct SemanticSelectorPart<'a> {
117    pub separator: Option<char>,
118    pub value: &'a str,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct SemanticTransform<'a> {
123    pub from: &'a str,
124    pub to: &'a str,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct SemanticComparison<'a> {
129    pub left: &'a str,
130    pub operator: SemanticComparisonOperator,
131    pub right: &'a str,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum SemanticComparisonOperator {
136    Eq,
137    Ne,
138    Gt,
139    Ge,
140    Lt,
141    Le,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct ParsedParams<'a> {
146    raw: &'a str,
147    entries: ParamEntries<'a>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct ParsedParamsReport<'a> {
152    pub params: ParsedParams<'a>,
153    pub diagnostics: ParamDiagnostics<'a>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct ParamDiagnostic<'a> {
158    pub kind: ParamDiagnosticKind,
159    pub span: Range<usize>,
160    pub segment: &'a str,
161    pub key: Option<&'a str>,
162    pub previous_value: Option<&'a str>,
163    pub value: Option<&'a str>,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum ParamDiagnosticKind {
168    MissingDelimiter,
169    EmptyKey,
170    DuplicateKeySameValue,
171    DuplicateKeyDifferentValue,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct ParsedCardScript<'a> {
176    raw: &'a str,
177    lines: ScriptLines<'a>,
178    diagnostics: ScriptDiagnostics<'a>,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct ScriptLine<'a> {
183    pub line_no: usize,
184    pub span: Range<usize>,
185    pub raw: &'a str,
186    pub kind: ScriptLineKind<'a>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum ScriptLineKind<'a> {
191    Blank,
192    Comment(&'a str),
193    Field(ScriptField<'a>),
194    Keyword(&'a str),
195    Ability(ScriptAbility<'a>),
196    Trigger(ScriptParamRecord<'a>),
197    StaticAbility(ScriptParamRecord<'a>),
198    Replacement(ScriptParamRecord<'a>),
199    SVar(ScriptSVar<'a>),
200    AlternateFace,
201    AlternateMode(&'a str),
202    SpecializeFace { color: &'a str },
203    IgnoredMetadata(ScriptField<'a>),
204    Unknown(ScriptField<'a>),
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct ScriptField<'a> {
209    pub key: &'a str,
210    pub value: Option<&'a str>,
211    pub key_span: Range<usize>,
212    pub value_span: Option<Range<usize>>,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct ScriptAbility<'a> {
217    pub record: Option<ScriptAbilityRecord>,
218    pub api_raw: Option<&'a str>,
219    pub params: ParsedParams<'a>,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum ScriptAbilityRecord {
224    Activated,
225    Spell,
226    SubAbility,
227    StaticAbility,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct ScriptParamRecord<'a> {
232    pub params: ParsedParams<'a>,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct ScriptSVar<'a> {
237    pub name: &'a str,
238    pub value: &'a str,
239    pub name_span: Range<usize>,
240    pub value_span: Range<usize>,
241    pub value_kind: ScriptSVarValue<'a>,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub enum ScriptSVarValue<'a> {
246    Ability(ScriptAbility<'a>),
247    Params(ScriptParamRecord<'a>),
248    NumericExpression(ScriptSVarNumericExpression<'a>),
249    Raw(&'a str),
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum ScriptSVarNumericExpression<'a> {
254    Number(&'a str),
255    Count(&'a str),
256    PlayerCount(&'a str),
257    TriggerCount(&'a str),
258    SVarReference {
259        name: &'a str,
260        operators: &'a str,
261    },
262    Remembered {
263        property: &'a str,
264    },
265    RememberedSize {
266        operators: &'a str,
267    },
268    DiscardedValid {
269        filter: &'a str,
270        times: i32,
271    },
272    ObjectProperty {
273        object: ScriptSVarObjectRef<'a>,
274        property: &'a str,
275    },
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub enum ScriptSVarObjectRef<'a> {
280    Sacrificed,
281    TriggeredCard,
282    CardList(&'a str),
283    PlayerList(&'a str),
284    SpellAbility(&'a str),
285    PaidHash(&'a str),
286    ReplaceCount,
287    RuntimeValue(&'a str),
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub enum OwnedSVarNumericExpression {
292    Number(String),
293    Count(String),
294    PlayerCount(String),
295    TriggerCount(String),
296    SVarReference {
297        name: String,
298        operators: String,
299    },
300    Remembered {
301        property: String,
302    },
303    RememberedSize {
304        operators: String,
305    },
306    DiscardedValid {
307        filter: String,
308        times: i32,
309    },
310    ObjectProperty {
311        object: OwnedSVarObjectRef,
312        property: String,
313    },
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub enum OwnedSVarObjectRef {
318    Sacrificed,
319    TriggeredCard,
320    CardList(String),
321    PlayerList(String),
322    SpellAbility(String),
323    PaidHash(String),
324    ReplaceCount,
325    RuntimeValue(String),
326}
327
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct ScriptDiagnostic<'a> {
330    pub kind: ScriptDiagnosticKind,
331    pub span: Range<usize>,
332    pub line_no: usize,
333    pub segment: &'a str,
334    pub key: Option<&'a str>,
335    pub previous_value: Option<&'a str>,
336    pub value: Option<&'a str>,
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub enum ScriptDiagnosticKind {
341    MissingColon,
342    EmptyKey,
343    UnknownField,
344    MissingAbilityRecord,
345    MissingSVarName,
346    Param(ParamDiagnosticKind),
347}
348
349impl<'a> ParsedCardScript<'a> {
350    pub fn parse(raw: &'a str) -> Self {
351        let mut lines = SmallVec::new();
352        let mut diagnostics = SmallVec::new();
353        let mut offset = 0;
354
355        for (idx, segment) in raw.split_inclusive('\n').enumerate() {
356            let line = segment
357                .strip_suffix('\n')
358                .map(|s| s.strip_suffix('\r').unwrap_or(s))
359                .unwrap_or(segment);
360            let line_no = idx + 1;
361            let parsed = parse_script_line(line, line_no, offset, &mut diagnostics);
362            lines.push(parsed);
363            offset += segment.len();
364        }
365
366        Self {
367            raw,
368            lines,
369            diagnostics,
370        }
371    }
372
373    pub fn raw(&self) -> &'a str {
374        self.raw
375    }
376
377    pub fn lines(&self) -> &[ScriptLine<'a>] {
378        &self.lines
379    }
380
381    pub fn diagnostics(&self) -> &[ScriptDiagnostic<'a>] {
382        &self.diagnostics
383    }
384
385    pub fn abilities(&self) -> impl Iterator<Item = &ScriptAbility<'a>> {
386        self.lines.iter().filter_map(|line| match &line.kind {
387            ScriptLineKind::Ability(ability) => Some(ability),
388            ScriptLineKind::SVar(svar) => match &svar.value_kind {
389                ScriptSVarValue::Ability(ability) => Some(ability),
390                _ => None,
391            },
392            _ => None,
393        })
394    }
395}
396
397impl ScriptAbilityRecord {
398    pub fn key(self) -> &'static str {
399        match self {
400            Self::Activated => AB,
401            Self::Spell => SP,
402            Self::SubAbility => DB,
403            Self::StaticAbility => ST,
404        }
405    }
406}
407
408impl SemanticParamValue<'_> {
409    pub fn kind(&self) -> SemanticParamValueKind {
410        match self {
411            Self::AbilityRecord(_) => SemanticParamValueKind::AbilityRecord,
412            Self::Symbol(_) => SemanticParamValueKind::Symbol,
413            Self::ProducedMana(_) => SemanticParamValueKind::ProducedMana,
414            Self::Boolean(_) => SemanticParamValueKind::Boolean,
415            Self::Integer(_) => SemanticParamValueKind::Integer,
416            Self::Amount(_) => SemanticParamValueKind::Amount,
417            Self::ZoneList(_) => SemanticParamValueKind::ZoneList,
418            Self::Selector(_) => SemanticParamValueKind::Selector,
419            Self::Reference(_) => SemanticParamValueKind::Reference,
420            Self::SVarReference(_) => SemanticParamValueKind::SVarReference,
421            Self::Cost(_) => SemanticParamValueKind::Cost,
422            Self::Text(_) => SemanticParamValueKind::Text,
423            Self::DelimitedList(_) => SemanticParamValueKind::DelimitedList,
424            Self::Transform(_) => SemanticParamValueKind::Transform,
425            Self::Comparison(_) => SemanticParamValueKind::Comparison,
426            Self::Expression(_) => SemanticParamValueKind::Expression,
427            Self::Raw(_) => SemanticParamValueKind::Raw,
428        }
429    }
430}
431
432impl<'a> ParamEntry<'a> {
433    pub fn semantic(&self) -> SemanticParam<'a> {
434        SemanticParam {
435            key: self.key,
436            raw_value: self.value,
437            value: parse_semantic_param_value(self.key, self.value),
438        }
439    }
440}
441
442impl ScriptSVarNumericExpression<'_> {
443    pub fn to_owned_expression(&self) -> OwnedSVarNumericExpression {
444        match self {
445            Self::Number(value) => OwnedSVarNumericExpression::Number((*value).to_string()),
446            Self::Count(raw) => OwnedSVarNumericExpression::Count((*raw).to_string()),
447            Self::PlayerCount(raw) => OwnedSVarNumericExpression::PlayerCount((*raw).to_string()),
448            Self::TriggerCount(raw) => OwnedSVarNumericExpression::TriggerCount((*raw).to_string()),
449            Self::SVarReference { name, operators } => OwnedSVarNumericExpression::SVarReference {
450                name: (*name).to_string(),
451                operators: (*operators).to_string(),
452            },
453            Self::Remembered { property } => OwnedSVarNumericExpression::Remembered {
454                property: (*property).to_string(),
455            },
456            Self::RememberedSize { operators } => OwnedSVarNumericExpression::RememberedSize {
457                operators: (*operators).to_string(),
458            },
459            Self::DiscardedValid { filter, times } => OwnedSVarNumericExpression::DiscardedValid {
460                filter: (*filter).to_string(),
461                times: *times,
462            },
463            Self::ObjectProperty { object, property } => {
464                OwnedSVarNumericExpression::ObjectProperty {
465                    object: object.to_owned_ref(),
466                    property: (*property).to_string(),
467                }
468            }
469        }
470    }
471}
472
473impl ScriptSVarObjectRef<'_> {
474    pub fn to_owned_ref(&self) -> OwnedSVarObjectRef {
475        match self {
476            Self::Sacrificed => OwnedSVarObjectRef::Sacrificed,
477            Self::TriggeredCard => OwnedSVarObjectRef::TriggeredCard,
478            Self::CardList(defined) => OwnedSVarObjectRef::CardList((*defined).to_string()),
479            Self::PlayerList(defined) => OwnedSVarObjectRef::PlayerList((*defined).to_string()),
480            Self::SpellAbility(defined) => OwnedSVarObjectRef::SpellAbility((*defined).to_string()),
481            Self::PaidHash(key) => OwnedSVarObjectRef::PaidHash((*key).to_string()),
482            Self::ReplaceCount => OwnedSVarObjectRef::ReplaceCount,
483            Self::RuntimeValue(key) => OwnedSVarObjectRef::RuntimeValue((*key).to_string()),
484        }
485    }
486}
487
488impl<'a> ParsedParams<'a> {
489    pub fn parse(raw: &'a str) -> Self {
490        let mut entries = SmallVec::new();
491        let mut offset = 0;
492
493        for part in raw.split('|') {
494            if let Some(entry) = parse_entry(part, offset) {
495                entries.push(entry);
496            }
497            offset += part.len() + 1;
498        }
499
500        Self { raw, entries }
501    }
502
503    pub fn parse_with_diagnostics(raw: &'a str) -> ParsedParamsReport<'a> {
504        let mut entries: ParamEntries<'a> = SmallVec::new();
505        let mut diagnostics = SmallVec::new();
506        let mut offset = 0;
507
508        for part in raw.split('|') {
509            let (trimmed, trimmed_offset) = trim_with_offset(part, offset);
510            if !trimmed.is_empty() {
511                if !trimmed.contains('$') {
512                    diagnostics.push(ParamDiagnostic {
513                        kind: ParamDiagnosticKind::MissingDelimiter,
514                        span: trimmed_offset..trimmed_offset + trimmed.len(),
515                        segment: trimmed,
516                        key: None,
517                        previous_value: None,
518                        value: None,
519                    });
520                } else if let Some(entry) = parse_entry(part, offset) {
521                    if entry.key.is_empty() {
522                        diagnostics.push(ParamDiagnostic {
523                            kind: ParamDiagnosticKind::EmptyKey,
524                            span: entry.key_span.clone(),
525                            segment: trimmed,
526                            key: None,
527                            previous_value: None,
528                            value: Some(entry.value),
529                        });
530                    }
531                    if let Some(existing) =
532                        entries.iter().rfind(|existing| existing.key == entry.key)
533                    {
534                        let kind = if existing.value == entry.value {
535                            ParamDiagnosticKind::DuplicateKeySameValue
536                        } else {
537                            ParamDiagnosticKind::DuplicateKeyDifferentValue
538                        };
539                        diagnostics.push(ParamDiagnostic {
540                            kind,
541                            span: entry.key_span.clone(),
542                            segment: trimmed,
543                            key: Some(entry.key),
544                            previous_value: Some(existing.value),
545                            value: Some(entry.value),
546                        });
547                    }
548                    entries.push(entry);
549                }
550            }
551            offset += part.len() + 1;
552        }
553
554        ParsedParamsReport {
555            params: Self { raw, entries },
556            diagnostics,
557        }
558    }
559
560    pub fn raw(&self) -> &'a str {
561        self.raw
562    }
563
564    pub fn entries(&self) -> &[ParamEntry<'a>] {
565        &self.entries
566    }
567
568    pub fn semantic_entries(&self) -> impl Iterator<Item = SemanticParam<'a>> + '_ {
569        self.entries.iter().map(ParamEntry::semantic)
570    }
571
572    pub fn semantic_get(&self, key: &str) -> Option<SemanticParam<'a>> {
573        self.entries
574            .iter()
575            .rfind(|entry| entry.key == key)
576            .map(ParamEntry::semantic)
577    }
578
579    pub fn get(&self, key: &str) -> Option<&'a str> {
580        self.entries
581            .iter()
582            .rfind(|entry| entry.key == key)
583            .map(|entry| entry.value)
584    }
585
586    pub fn has(&self, key: &str) -> bool {
587        self.get(key).is_some()
588    }
589
590    pub fn has_any(&self, keys: &[&str]) -> bool {
591        self.entries.iter().any(|entry| keys.contains(&entry.key))
592    }
593
594    pub fn duplicates(&self) -> impl Iterator<Item = &ParamEntry<'a>> {
595        self.entries.iter().enumerate().filter_map(|(idx, entry)| {
596            if self.entries[..idx]
597                .iter()
598                .any(|existing| existing.key == entry.key)
599            {
600                Some(entry)
601            } else {
602                None
603            }
604        })
605    }
606}
607
608pub fn raw_get<'a>(raw: &'a str, key: &str) -> Option<&'a str> {
609    raw_entries(raw).fold(None, |found, entry| {
610        if entry.key == key {
611            Some(entry.value)
612        } else {
613            found
614        }
615    })
616}
617
618pub fn raw_has_key(raw: &str, key: &str) -> bool {
619    raw_get(raw, key).is_some()
620}
621
622pub fn raw_has_any(raw: &str, keys: &[&str]) -> bool {
623    raw_entries(raw).any(|entry| keys.contains(&entry.key))
624}
625
626fn raw_entries(raw: &str) -> impl Iterator<Item = ParamEntry<'_>> {
627    let mut offset = 0;
628    raw.split('|').filter_map(move |part| {
629        let entry = parse_entry(part, offset);
630        offset += part.len() + 1;
631        entry
632    })
633}
634
635fn parse_script_line<'a>(
636    line: &'a str,
637    line_no: usize,
638    line_offset: usize,
639    diagnostics: &mut ScriptDiagnostics<'a>,
640) -> ScriptLine<'a> {
641    let (trimmed, trimmed_offset) = trim_with_offset(line, line_offset);
642    let span = line_offset..line_offset + line.len();
643
644    if trimmed.is_empty() {
645        return ScriptLine {
646            line_no,
647            span,
648            raw: line,
649            kind: ScriptLineKind::Blank,
650        };
651    }
652
653    if let Some(comment) = trimmed.strip_prefix('#') {
654        return ScriptLine {
655            line_no,
656            span,
657            raw: line,
658            kind: ScriptLineKind::Comment(comment.trim()),
659        };
660    }
661
662    if trimmed == "ALTERNATE" {
663        return ScriptLine {
664            line_no,
665            span,
666            raw: line,
667            kind: ScriptLineKind::AlternateFace,
668        };
669    }
670
671    let Some(field) = parse_script_field(trimmed, trimmed_offset) else {
672        diagnostics.push(ScriptDiagnostic {
673            kind: ScriptDiagnosticKind::MissingColon,
674            span: trimmed_offset..trimmed_offset + trimmed.len(),
675            line_no,
676            segment: trimmed,
677            key: None,
678            previous_value: None,
679            value: None,
680        });
681        return ScriptLine {
682            line_no,
683            span,
684            raw: line,
685            kind: ScriptLineKind::Unknown(ScriptField {
686                key: trimmed,
687                value: None,
688                key_span: trimmed_offset..trimmed_offset + trimmed.len(),
689                value_span: None,
690            }),
691        };
692    };
693
694    if field.key.is_empty() {
695        diagnostics.push(ScriptDiagnostic {
696            kind: ScriptDiagnosticKind::EmptyKey,
697            span: field.key_span.clone(),
698            line_no,
699            segment: trimmed,
700            key: None,
701            previous_value: None,
702            value: field.value,
703        });
704    }
705
706    let kind = classify_script_field(field, line_no, diagnostics);
707    ScriptLine {
708        line_no,
709        span,
710        raw: line,
711        kind,
712    }
713}
714
715fn parse_script_field<'a>(line: &'a str, line_offset: usize) -> Option<ScriptField<'a>> {
716    let colon = line.find(':')?;
717    if colon == 0 {
718        return None;
719    }
720
721    let key_raw = &line[..colon];
722    let value_raw = &line[colon + 1..];
723    let key_leading = key_raw.len() - key_raw.trim_start().len();
724    let key = key_raw.trim();
725    let key_start = line_offset + key_leading;
726
727    let value_leading = value_raw.len() - value_raw.trim_start().len();
728    let value = value_raw.trim();
729    let value_start = line_offset + colon + 1 + value_leading;
730
731    Some(ScriptField {
732        key,
733        value: Some(value),
734        key_span: key_start..key_start + key.len(),
735        value_span: Some(value_start..value_start + value.len()),
736    })
737}
738
739fn classify_script_field<'a>(
740    field: ScriptField<'a>,
741    line_no: usize,
742    diagnostics: &mut ScriptDiagnostics<'a>,
743) -> ScriptLineKind<'a> {
744    match field.key {
745        "A" => ScriptLineKind::Ability(parse_script_ability(
746            field.value.unwrap_or(""),
747            line_no,
748            field
749                .value_span
750                .clone()
751                .unwrap_or_else(|| field.key_span.end..field.key_span.end),
752            diagnostics,
753        )),
754        "T" => ScriptLineKind::Trigger(parse_script_param_record(
755            field.value.unwrap_or(""),
756            line_no,
757            field
758                .value_span
759                .clone()
760                .unwrap_or_else(|| field.key_span.end..field.key_span.end),
761            diagnostics,
762        )),
763        "S" => ScriptLineKind::StaticAbility(parse_script_param_record(
764            field.value.unwrap_or(""),
765            line_no,
766            field
767                .value_span
768                .clone()
769                .unwrap_or_else(|| field.key_span.end..field.key_span.end),
770            diagnostics,
771        )),
772        "R" => ScriptLineKind::Replacement(parse_script_param_record(
773            field.value.unwrap_or(""),
774            line_no,
775            field
776                .value_span
777                .clone()
778                .unwrap_or_else(|| field.key_span.end..field.key_span.end),
779            diagnostics,
780        )),
781        "SVar" => ScriptLineKind::SVar(parse_script_svar(field, line_no, diagnostics)),
782        "K" => ScriptLineKind::Keyword(field.value.unwrap_or("")),
783        "ALTERNATE" => ScriptLineKind::AlternateFace,
784        "AlternateMode" => ScriptLineKind::AlternateMode(field.value.unwrap_or("")),
785        key if key.starts_with("SPECIALIZE") => ScriptLineKind::SpecializeFace {
786            color: field.value.unwrap_or(""),
787        },
788        "Name" | "ManaCost" | "Types" | "PT" | "Colors" | "Defense" | "Draft" | "FlavorName"
789        | "Loyalty" | "Lights" | "MeldPair" | "Oracle" | "Text" | "Variant" => {
790            ScriptLineKind::Field(field)
791        }
792        "AI" | "DeckHints" | "DeckNeeds" | "DeckHas" | "HandLifeModifier" => {
793            ScriptLineKind::IgnoredMetadata(field)
794        }
795        key if key.starts_with("SETCOLORID") => ScriptLineKind::IgnoredMetadata(field),
796        _ => {
797            diagnostics.push(ScriptDiagnostic {
798                kind: ScriptDiagnosticKind::UnknownField,
799                span: field.key_span.clone(),
800                line_no,
801                segment: field.value.unwrap_or(field.key),
802                key: Some(field.key),
803                previous_value: None,
804                value: field.value,
805            });
806            ScriptLineKind::Unknown(field)
807        }
808    }
809}
810
811fn parse_script_ability<'a>(
812    raw: &'a str,
813    line_no: usize,
814    value_span: Range<usize>,
815    diagnostics: &mut ScriptDiagnostics<'a>,
816) -> ScriptAbility<'a> {
817    let report = ParsedParams::parse_with_diagnostics(raw);
818    record_param_report_diagnostics(&report, line_no, value_span.start, diagnostics);
819    let ParsedParamsReport {
820        params: parsed_params,
821        diagnostics: _,
822    } = report;
823
824    let record = ability_record(&parsed_params);
825    if record.is_none() && !raw.trim().is_empty() {
826        diagnostics.push(ScriptDiagnostic {
827            kind: ScriptDiagnosticKind::MissingAbilityRecord,
828            span: value_span,
829            line_no,
830            segment: raw,
831            key: None,
832            previous_value: None,
833            value: None,
834        });
835    }
836
837    let api_raw = record.and_then(|record| parsed_params.get(record.key()));
838
839    ScriptAbility {
840        record,
841        api_raw,
842        params: parsed_params,
843    }
844}
845
846fn parse_script_param_record<'a>(
847    raw: &'a str,
848    line_no: usize,
849    value_span: Range<usize>,
850    diagnostics: &mut ScriptDiagnostics<'a>,
851) -> ScriptParamRecord<'a> {
852    let report = ParsedParams::parse_with_diagnostics(raw);
853    record_param_report_diagnostics(&report, line_no, value_span.start, diagnostics);
854    let ParsedParamsReport {
855        params: parsed_params,
856        diagnostics: _,
857    } = report;
858    ScriptParamRecord {
859        params: parsed_params,
860    }
861}
862
863fn parse_script_svar<'a>(
864    field: ScriptField<'a>,
865    line_no: usize,
866    diagnostics: &mut ScriptDiagnostics<'a>,
867) -> ScriptSVar<'a> {
868    let raw = field.value.unwrap_or("");
869    let value_span = field
870        .value_span
871        .clone()
872        .unwrap_or_else(|| field.key_span.end..field.key_span.end);
873    let (name, value, name_span, nested_value_span) = if let Some(colon) = raw.find(':') {
874        let name_raw = &raw[..colon];
875        let value_raw = &raw[colon + 1..];
876        let name_leading = name_raw.len() - name_raw.trim_start().len();
877        let name = name_raw.trim();
878        let name_start = value_span.start + name_leading;
879        let value_leading = value_raw.len() - value_raw.trim_start().len();
880        let value = value_raw.trim();
881        let nested_value_start = value_span.start + colon + 1 + value_leading;
882        (
883            name,
884            value,
885            name_start..name_start + name.len(),
886            nested_value_start..nested_value_start + value.len(),
887        )
888    } else {
889        let name = raw.trim();
890        (name, "", value_span.clone(), value_span.end..value_span.end)
891    };
892
893    if name.is_empty() {
894        diagnostics.push(ScriptDiagnostic {
895            kind: ScriptDiagnosticKind::MissingSVarName,
896            span: name_span.clone(),
897            line_no,
898            segment: raw,
899            key: Some(field.key),
900            previous_value: None,
901            value: None,
902        });
903    }
904
905    let value_kind = if looks_like_ability_record(value) {
906        ScriptSVarValue::Ability(parse_script_ability(
907            value,
908            line_no,
909            nested_value_span.clone(),
910            diagnostics,
911        ))
912    } else if let Some(expression) = parse_script_svar_numeric_expression(value) {
913        ScriptSVarValue::NumericExpression(expression)
914    } else if looks_like_param_record(value) {
915        ScriptSVarValue::Params(parse_script_param_record(
916            value,
917            line_no,
918            nested_value_span.clone(),
919            diagnostics,
920        ))
921    } else {
922        ScriptSVarValue::Raw(value)
923    };
924
925    ScriptSVar {
926        name,
927        value,
928        name_span,
929        value_span: nested_value_span,
930        value_kind,
931    }
932}
933
934fn ability_record(params: &ParsedParams<'_>) -> Option<ScriptAbilityRecord> {
935    if params.has(AB) {
936        Some(ScriptAbilityRecord::Activated)
937    } else if params.has(SP) {
938        Some(ScriptAbilityRecord::Spell)
939    } else if params.has(DB) {
940        Some(ScriptAbilityRecord::SubAbility)
941    } else if params.has(ST) {
942        Some(ScriptAbilityRecord::StaticAbility)
943    } else {
944        None
945    }
946}
947
948pub fn parse_script_svar_numeric_expression<'a>(
949    value: &'a str,
950) -> Option<ScriptSVarNumericExpression<'a>> {
951    let value = value.trim();
952    if let Some(rest) = value.strip_prefix("Number$") {
953        return Some(ScriptSVarNumericExpression::Number(rest.trim()));
954    }
955    if value.starts_with("Count$") {
956        return Some(ScriptSVarNumericExpression::Count(value));
957    }
958    if value.starts_with("PlayerCount") && value.contains('$') {
959        return Some(ScriptSVarNumericExpression::PlayerCount(value));
960    }
961    if value.starts_with("TriggerCount$") || value.starts_with("TriggerCountMax$") {
962        return Some(ScriptSVarNumericExpression::TriggerCount(value));
963    }
964    if let Some(property) = value.strip_prefix("Remembered$") {
965        return Some(ScriptSVarNumericExpression::Remembered { property });
966    }
967    if let Some(rest) = value.strip_prefix("RememberedSize") {
968        return Some(ScriptSVarNumericExpression::RememberedSize {
969            operators: rest.strip_prefix('/').unwrap_or(rest),
970        });
971    }
972    if let Some(rest) = value.strip_prefix("Discarded$Valid ") {
973        let mut parts = rest.split("/Times.");
974        let filter = parts.next().unwrap_or("").trim();
975        let times = parts
976            .next()
977            .and_then(|raw| raw.trim().parse::<i32>().ok())
978            .unwrap_or(0);
979        return Some(ScriptSVarNumericExpression::DiscardedValid { filter, times });
980    }
981
982    let (object, property) = value.split_once('$')?;
983    if object.is_empty() || property.is_empty() {
984        return None;
985    }
986    if object == "SVar" {
987        let (name, operators) = property.split_once('/').unwrap_or((property, ""));
988        let name = name.trim();
989        if name.is_empty() {
990            return None;
991        }
992        return Some(ScriptSVarNumericExpression::SVarReference { name, operators });
993    }
994    let object = match object {
995        "Sacrificed" => ScriptSVarObjectRef::Sacrificed,
996        "TriggeredCard" => ScriptSVarObjectRef::TriggeredCard,
997        _ if is_player_property_svar_object(object)
998            && is_player_property_svar_property(property) =>
999        {
1000            ScriptSVarObjectRef::PlayerList(object)
1001        }
1002        _ if is_card_property_svar_object(object) => ScriptSVarObjectRef::CardList(object),
1003        _ if is_player_property_svar_object(object) => ScriptSVarObjectRef::PlayerList(object),
1004        _ if is_spell_ability_property_svar_object(object) => {
1005            ScriptSVarObjectRef::SpellAbility(object)
1006        }
1007        _ if is_paid_hash_property_svar_object(object) => ScriptSVarObjectRef::PaidHash(object),
1008        "ReplaceCount" => ScriptSVarObjectRef::ReplaceCount,
1009        _ if is_runtime_value_svar_object(object) => ScriptSVarObjectRef::RuntimeValue(object),
1010        _ => return None,
1011    };
1012    Some(ScriptSVarNumericExpression::ObjectProperty { object, property })
1013}
1014
1015fn is_card_property_svar_object(object: &str) -> bool {
1016    matches!(
1017        object,
1018        "Targeted"
1019            | "TargetedCard"
1020            | "ThisTargetedCard"
1021            | "ParentTargeted"
1022            | "Remembered"
1023            | "RememberedLKI"
1024            | "DelayTriggerRemembered"
1025            | "DelayTriggerRememberedLKI"
1026            | "TriggerRemembered"
1027            | "Imprinted"
1028            | "Discarded"
1029            | "TriggeredAttacker"
1030            | "TriggeredAttackers"
1031            | "TriggeredBlocker"
1032            | "TriggeredTarget"
1033            | "TriggeredTargets"
1034            | "TriggeredNewCard"
1035            | "TriggeredNewCardLKICopy"
1036            | "ReplacedCard"
1037            | "ReplacedCardLKI"
1038            | "ReplacedSource"
1039            | "SpellTargeted"
1040            | "AllTargeted"
1041            | "Revealed"
1042            | "Enchanted"
1043            | "Equipped"
1044            | "ExiledWith"
1045            | "TargetedObjects"
1046            | "TargetedObjectsDistinct"
1047            | "TargetedByTarget"
1048            | "ChosenCard"
1049            | "Collected"
1050            | "Crewed"
1051            | "Emerged"
1052            | "ExiledCards"
1053            | "ImprintedLKI"
1054            | "OriginalHost"
1055            | "TriggeredCardLKI"
1056            | "TriggeredDevoured"
1057            | "TriggeredExploited"
1058            | "TriggeredSource"
1059            | "Explorer"
1060            | "Explored"
1061    )
1062}
1063
1064fn is_player_property_svar_object(object: &str) -> bool {
1065    matches!(
1066        object,
1067        "Player"
1068            | "Players"
1069            | "Opponent"
1070            | "Opponents"
1071            | "You"
1072            | "Controller"
1073            | "TargetedPlayer"
1074            | "ThisTargetedPlayer"
1075            | "TriggeredPlayer"
1076            | "TargetedController"
1077            | "ThisTargetedController"
1078            | "ParentTargetedController"
1079            | "TargetedOwner"
1080            | "ThisTargetedOwner"
1081            | "TriggeredTarget"
1082            | "TriggeredTargets"
1083            | "TriggeredTargetController"
1084            | "TriggeredTargetsController"
1085            | "TriggeredAttackerController"
1086            | "TriggeredBlockerController"
1087            | "TriggeredActivator"
1088            | "TriggeredCardController"
1089            | "DefendingPlayer"
1090            | "TriggeredDefendingPlayer"
1091    )
1092}
1093
1094fn is_player_property_svar_property(property: &str) -> bool {
1095    let property = property.split('/').next().unwrap_or(property);
1096    property.starts_with("CardsIn")
1097        || property.starts_with("CreaturesIn")
1098        || property.starts_with("Life")
1099        || property.starts_with("Valid")
1100        || property.starts_with("Counters.")
1101        || property.starts_with("HasProperty")
1102        || property.starts_with("Condition")
1103        || matches!(
1104            property,
1105            "StartingLife"
1106                | "Speed"
1107                | "TopOfLibraryCMC"
1108                | "LandsPlayed"
1109                | "SpellsCastThisTurn"
1110                | "CardsDrawn"
1111                | "CardsDiscardedThisTurn"
1112                | "ExploredThisTurn"
1113                | "AttackersDeclared"
1114                | "DamageToOppsThisTurn"
1115                | "NonCombatDamageDealtThisTurn"
1116                | "PoisonCounters"
1117                | "EnergyCounters"
1118                | "ManaExpendedThisTurn"
1119                | "RingTemptedYou"
1120                | "OpponentsAttackedThisTurn"
1121                | "OpponentsAttackedThisCombat"
1122                | "BeenDealtCombatDamageSinceLastTurn"
1123                | "AttractionsVisitedThisTurn"
1124        )
1125}
1126
1127fn is_spell_ability_property_svar_object(object: &str) -> bool {
1128    matches!(
1129        object,
1130        "Self"
1131            | "Parent"
1132            | "Remembered"
1133            | "Imprinted"
1134            | "EffectSource"
1135            | "TriggeredSpellAbility"
1136            | "TriggeredAbility"
1137            | "SpellAbility"
1138    )
1139}
1140
1141fn is_paid_hash_property_svar_object(object: &str) -> bool {
1142    matches!(
1143        object,
1144        "SacCost"
1145            | "DiscardCost"
1146            | "Exiled"
1147            | "Tapped"
1148            | "Untapped"
1149            | "TappedForConvoke"
1150            | "Convoked"
1151    )
1152}
1153
1154fn is_runtime_value_svar_object(object: &str) -> bool {
1155    matches!(object, "DungeonsCompleted" | "ManaFrom")
1156        || object.starts_with("TriggerObjects")
1157        || object.starts_with("TriggeredPlayers")
1158        || object.contains('>')
1159}
1160
1161pub fn parse_semantic_param_value<'a>(key: &str, value: &'a str) -> SemanticParamValue<'a> {
1162    let value = value.trim();
1163
1164    if matches!(key, AB | SP | DB | ST) {
1165        return SemanticParamValue::AbilityRecord(value);
1166    }
1167    if matches!(key, "Mode" | "Event") || key.ends_with("Mode") || key.ends_with("Logic") {
1168        return SemanticParamValue::Symbol(value);
1169    }
1170    if is_text_key(key) {
1171        return SemanticParamValue::Text(value);
1172    }
1173    if is_cost_key(key) {
1174        return SemanticParamValue::Cost(value);
1175    }
1176    if let Some(transform) = parse_transform(value) {
1177        return SemanticParamValue::Transform(transform);
1178    }
1179    if let Some(value) = parse_bool(value) {
1180        return SemanticParamValue::Boolean(value);
1181    }
1182    if is_post_bool_text_key(key) {
1183        return SemanticParamValue::Text(value);
1184    }
1185    if key == "Produced" {
1186        return SemanticParamValue::ProducedMana(parse_produced_mana(value));
1187    }
1188    if is_symbol_key(key) {
1189        return SemanticParamValue::Symbol(value);
1190    }
1191    if is_zone_key(key) {
1192        return SemanticParamValue::ZoneList(split_csv(value));
1193    }
1194    if is_svar_reference_key(key) {
1195        return SemanticParamValue::SVarReference(split_csv(value));
1196    }
1197    if is_amount_key(key) {
1198        return SemanticParamValue::Amount(parse_amount(value));
1199    }
1200    if is_reference_key(key) {
1201        return SemanticParamValue::Reference(parse_selector(value));
1202    }
1203    if is_selector_key(key) {
1204        return SemanticParamValue::Selector(parse_selector(value));
1205    }
1206    if let Ok(number) = value.parse::<i32>() {
1207        return SemanticParamValue::Integer(number);
1208    }
1209    if let Some(comparison) = parse_comparison(value) {
1210        return SemanticParamValue::Comparison(comparison);
1211    }
1212    if looks_like_expression_key(key) || looks_like_expression_value(value) {
1213        return SemanticParamValue::Expression(value);
1214    }
1215    if is_delimited_list_key(key) || value.contains(',') {
1216        return SemanticParamValue::DelimitedList(split_csv(value));
1217    }
1218
1219    SemanticParamValue::Raw(value)
1220}
1221
1222fn parse_bool(value: &str) -> Option<bool> {
1223    if value.eq_ignore_ascii_case("true") {
1224        Some(true)
1225    } else if value.eq_ignore_ascii_case("false") {
1226        Some(false)
1227    } else {
1228        None
1229    }
1230}
1231
1232fn parse_transform(value: &str) -> Option<SemanticTransform<'_>> {
1233    let (from, to) = value.split_once("->")?;
1234    let from = from.trim();
1235    let to = to.trim();
1236    if from.is_empty() || to.is_empty() {
1237        return None;
1238    }
1239    Some(SemanticTransform { from, to })
1240}
1241
1242fn parse_comparison(value: &str) -> Option<SemanticComparison<'_>> {
1243    let value = value.trim();
1244    if value.is_empty() {
1245        return None;
1246    }
1247
1248    if let Some((left, op, right)) = parse_spaced_comparison(value) {
1249        return Some(SemanticComparison {
1250            left,
1251            operator: op,
1252            right,
1253        });
1254    }
1255
1256    if let Some((left, op, right)) = parse_split_compact_comparison(value) {
1257        return Some(SemanticComparison {
1258            left,
1259            operator: op,
1260            right,
1261        });
1262    }
1263
1264    if let Some((op, right)) = parse_compact_operator_rhs(value) {
1265        return Some(SemanticComparison {
1266            left: "",
1267            operator: op,
1268            right,
1269        });
1270    }
1271
1272    None
1273}
1274
1275fn parse_spaced_comparison(value: &str) -> Option<(&str, SemanticComparisonOperator, &str)> {
1276    let mut parts = value.split_whitespace();
1277    let left = parts.next()?;
1278    let op = parse_comparison_operator(parts.next()?)?;
1279    let right = parts.next()?;
1280    if parts.next().is_some() {
1281        return None;
1282    }
1283    Some((left, op, right))
1284}
1285
1286fn parse_split_compact_comparison(value: &str) -> Option<(&str, SemanticComparisonOperator, &str)> {
1287    let mut parts = value.split_whitespace();
1288    let left = parts.next()?;
1289    let op_rhs = parts.next()?;
1290    if parts.next().is_some() {
1291        return None;
1292    }
1293    let (op, right) = parse_compact_operator_rhs(op_rhs)?;
1294    Some((left, op, right))
1295}
1296
1297fn parse_compact_operator_rhs(value: &str) -> Option<(SemanticComparisonOperator, &str)> {
1298    for (raw_op, op) in [
1299        ("GTE", SemanticComparisonOperator::Ge),
1300        ("LTE", SemanticComparisonOperator::Le),
1301        ("GE", SemanticComparisonOperator::Ge),
1302        ("LE", SemanticComparisonOperator::Le),
1303        ("GT", SemanticComparisonOperator::Gt),
1304        ("LT", SemanticComparisonOperator::Lt),
1305        ("NE", SemanticComparisonOperator::Ne),
1306        ("EQ", SemanticComparisonOperator::Eq),
1307    ] {
1308        if let Some(right) = value.strip_prefix(raw_op) {
1309            let right = right.trim();
1310            if !right.is_empty() {
1311                return Some((op, right));
1312            }
1313        }
1314    }
1315
1316    None
1317}
1318
1319fn parse_comparison_operator(value: &str) -> Option<SemanticComparisonOperator> {
1320    match value {
1321        "EQ" | "==" => Some(SemanticComparisonOperator::Eq),
1322        "NE" | "!=" => Some(SemanticComparisonOperator::Ne),
1323        "GT" | ">" => Some(SemanticComparisonOperator::Gt),
1324        "GE" | ">=" => Some(SemanticComparisonOperator::Ge),
1325        "LT" | "<" => Some(SemanticComparisonOperator::Lt),
1326        "LE" | "<=" => Some(SemanticComparisonOperator::Le),
1327        _ => None,
1328    }
1329}
1330
1331fn parse_amount(value: &str) -> SemanticAmount<'_> {
1332    if let Ok(number) = value.parse::<i32>() {
1333        SemanticAmount::Literal(number)
1334    } else if value == "X" {
1335        SemanticAmount::X
1336    } else if value.eq_ignore_ascii_case("Any") {
1337        SemanticAmount::Any
1338    } else if value.eq_ignore_ascii_case("All") {
1339        SemanticAmount::All
1340    } else if is_svar_name(value) {
1341        SemanticAmount::SVar(value)
1342    } else {
1343        SemanticAmount::Expression(value)
1344    }
1345}
1346
1347fn parse_produced_mana(value: &str) -> SemanticProducedMana<'_> {
1348    if value.eq_ignore_ascii_case("Any") {
1349        SemanticProducedMana::Any
1350    } else if value.eq_ignore_ascii_case("Chosen") {
1351        SemanticProducedMana::Chosen
1352    } else if let Some(rest) = value.strip_prefix("Special ") {
1353        SemanticProducedMana::Special(rest)
1354    } else if value.starts_with("Combo") {
1355        let rest = value.strip_prefix("Combo").unwrap_or("").trim();
1356        if rest.eq_ignore_ascii_case("Any") {
1357            SemanticProducedMana::Combo(SemanticProducedManaCombo::Any)
1358        } else if rest.eq_ignore_ascii_case("Chosen") {
1359            SemanticProducedMana::Combo(SemanticProducedManaCombo::Chosen)
1360        } else if rest.eq_ignore_ascii_case("ColorIdentity") {
1361            SemanticProducedMana::Combo(SemanticProducedManaCombo::ColorIdentity)
1362        } else {
1363            let colors: SmallVec<[&str; 4]> = rest
1364                .split_whitespace()
1365                .filter(|part| is_produced_mana_atom(part))
1366                .collect();
1367            if !colors.is_empty() && colors.len() == rest.split_whitespace().count() {
1368                SemanticProducedMana::Combo(SemanticProducedManaCombo::Colors(colors))
1369            } else {
1370                SemanticProducedMana::Combo(SemanticProducedManaCombo::Raw(rest))
1371            }
1372        }
1373    } else {
1374        let tokens: SmallVec<[&str; 4]> = value
1375            .split_whitespace()
1376            .filter(|part| is_produced_mana_atom(part))
1377            .collect();
1378        if !tokens.is_empty() && tokens.len() == value.split_whitespace().count() {
1379            SemanticProducedMana::Fixed(tokens)
1380        } else {
1381            SemanticProducedMana::Raw(value)
1382        }
1383    }
1384}
1385
1386fn is_produced_mana_atom(value: &str) -> bool {
1387    matches!(value.trim(), "W" | "U" | "B" | "R" | "G" | "C")
1388}
1389
1390fn parse_selector(raw: &str) -> SemanticSelector<'_> {
1391    let alternatives = split_csv(raw)
1392        .into_iter()
1393        .flat_map(|alternative| split_spaced_ampersand(alternative).into_iter())
1394        .map(|alternative| {
1395            let alternative = alternative.trim();
1396            SemanticSelectorAlternative {
1397                raw: alternative,
1398                parts: parse_selector_parts(alternative),
1399            }
1400        })
1401        .collect();
1402    SemanticSelector { alternatives }
1403}
1404
1405fn split_spaced_ampersand(raw: &str) -> SmallVec<[&str; 4]> {
1406    if raw.contains(" & ") {
1407        split_on(raw, '&')
1408    } else {
1409        let mut parts = SmallVec::new();
1410        parts.push(raw);
1411        parts
1412    }
1413}
1414
1415fn parse_selector_parts(raw: &str) -> SmallVec<[SemanticSelectorPart<'_>; 4]> {
1416    let mut parts = SmallVec::new();
1417    let mut start = 0;
1418    let mut separator = None;
1419
1420    for (idx, ch) in raw.char_indices() {
1421        if ch == '.' || ch == '+' {
1422            push_selector_part(raw, start, idx, separator, &mut parts);
1423            start = idx + ch.len_utf8();
1424            separator = Some(ch);
1425        }
1426    }
1427    push_selector_part(raw, start, raw.len(), separator, &mut parts);
1428    parts
1429}
1430
1431fn push_selector_part<'a>(
1432    raw: &'a str,
1433    start: usize,
1434    end: usize,
1435    separator: Option<char>,
1436    parts: &mut SmallVec<[SemanticSelectorPart<'a>; 4]>,
1437) {
1438    let part = raw[start..end].trim();
1439    if !part.is_empty() {
1440        parts.push(SemanticSelectorPart {
1441            separator,
1442            value: part,
1443        });
1444    }
1445}
1446
1447fn split_csv(raw: &str) -> SmallVec<[&str; 4]> {
1448    split_on(raw, ',')
1449}
1450
1451fn split_on(raw: &str, delimiter: char) -> SmallVec<[&str; 4]> {
1452    raw.split(delimiter)
1453        .map(str::trim)
1454        .filter(|part| !part.is_empty())
1455        .collect()
1456}
1457
1458fn is_text_key(key: &str) -> bool {
1459    key.ends_with("Description")
1460        || key.ends_with("Desc")
1461        || key.ends_with("Prompt")
1462        || key.ends_with("Message")
1463        || key.ends_with("Title")
1464        || matches!(
1465            key,
1466            "Description"
1467                | "ChangeColorWord"
1468                | "ChangeTypeWord"
1469                | "ChoiceTitle"
1470                | "ChoiceTitleAppend"
1471                | "SpellDescription"
1472                | "StackDescription"
1473                | "TriggerDescription"
1474                | "CostDesc"
1475                | "FromDraftNotes"
1476                | "ListTitle"
1477                | "MayPlayText"
1478                | "PrecostDesc"
1479                | "Name"
1480                | "NewName"
1481                | "DefinedName"
1482                | "SpellbookName"
1483                | "Image"
1484                | "OptionQuestion"
1485                | "OrString"
1486                | "RoomName"
1487                | "RememberedDescription"
1488        )
1489}
1490
1491fn is_post_bool_text_key(key: &str) -> bool {
1492    matches!(key, "Primary" | "Secondary")
1493}
1494
1495fn is_cost_key(key: &str) -> bool {
1496    key == "Cost" || key == "Incorporate" || key.ends_with("Cost") || key.ends_with("CostDesc")
1497}
1498
1499fn is_symbol_key(key: &str) -> bool {
1500    matches!(
1501        key,
1502        "AILogic"
1503            | "AIManaPref"
1504            | "Activation"
1505            | "ActivationPhases"
1506            | "AfterPhase"
1507            | "AtRandom"
1508            | "Attributes"
1509            | "ActivePhases"
1510            | "Announce"
1511            | "AddsKeywordsUntil"
1512            | "AIPhyrexianPayment"
1513            | "AtEOTTrig"
1514            | "ConditionManaSpent"
1515            | "DayTime"
1516            | "DividedAsYouChoose"
1517            | "ExtraPhase"
1518            | "FollowedBy"
1519            | "Duration"
1520            | "HasColorCreatureInPlay"
1521            | "Layer"
1522            | "Step"
1523            | "Modifier"
1524            | "Phase"
1525            | "PreventionEffect"
1526            | "Produced"
1527            | "ChoiceRestriction"
1528            | "CounterType2"
1529            | "ReplacementResult"
1530            | "ReflectProperty"
1531            | "ReplaceWith"
1532            | "Replacement"
1533            | "Result"
1534            | "LoseControl"
1535            | "Exclude"
1536            | "ChangeColorWordsTo"
1537            | "ManaSpent"
1538            | "ManaNotSpent"
1539            | "Reveal"
1540            | "FaceDown"
1541            | "NewState"
1542            | "Phases"
1543            | "RemovePhase"
1544            | "Revolt"
1545            | "ShowChoice"
1546            | "UnlessAI"
1547            | "UnlessSwitched"
1548    ) || key.ends_with("Duration")
1549        || key.ends_with("Effect")
1550        || key.ends_with("Logic")
1551        || key.starts_with("Remember")
1552}
1553
1554fn is_zone_key(key: &str) -> bool {
1555    key.ends_with("Zone")
1556        || key.ends_with("Zones")
1557        || key.contains("Zone")
1558        || key.ends_with("Destination")
1559        || matches!(
1560            key,
1561            "AtEOT"
1562                | "ExcludedDestinations"
1563                | "ExcludedOrigins"
1564                | "ExileOnMoved"
1565                | "ForgetOnMoved"
1566                | "LeaveBattlefield"
1567                | "ReplaceGraveyard"
1568                | "OriginAlternative"
1569                | "Origin"
1570                | "Destination"
1571                | "DestinationAlternative"
1572                | "NewDestination"
1573                | "ActivationZone"
1574                | "ActiveZones"
1575                | "TriggerZones"
1576                | "ChoiceZone"
1577                | "PresentZone"
1578                | "EffectZone"
1579        )
1580}
1581
1582fn is_selector_key(key: &str) -> bool {
1583    key.starts_with("Valid")
1584        || key.contains("Valid")
1585        || key.ends_with("Valid")
1586        || key.ends_with("Type")
1587        || key.ends_with("Types")
1588        || key.ends_with("Cards")
1589        || key.ends_with("Choices")
1590        || key.ends_with("Players")
1591        || key.ends_with("Restrictions")
1592        || key.ends_with("Tgts")
1593        || key.ends_with("Objects")
1594        || matches!(
1595            key,
1596            "Affected"
1597                | "AffectedZone"
1598                | "AttachedTo"
1599                | "AISearchGoal"
1600                | "ChangeType"
1601                | "ChangeValid"
1602                | "ConditionNotPresent"
1603                | "ConditionPresent"
1604                | "ConditionPresent2"
1605                | "Choices"
1606                | "DefinedCard"
1607                | "Filter"
1608                | "GainsAbilitiesOf"
1609                | "GainsTriggerAbsOf"
1610                | "IsPresent"
1611                | "IsPresent2"
1612                | "ManaRestriction"
1613                | "RepeatPresent"
1614                | "RepeatTypesFrom"
1615                | "SacValid"
1616                | "SharedRestrictions"
1617                | "Target"
1618                | "TargetRelativeToCause"
1619                | "TargetRestriction"
1620                | "TargetsWithControllerProperty"
1621                | "Type"
1622                | "Types"
1623                | "VoteCard"
1624        )
1625        || key.starts_with("IsPresent")
1626}
1627
1628fn is_reference_key(key: &str) -> bool {
1629    key == "Defined"
1630        || key.starts_with("Defined")
1631        || key.ends_with("Defined")
1632        || key.ends_with("DefinedPlayer")
1633        || key.ends_with("Controller")
1634        || key.ends_with("Owner")
1635        || key.ends_with("Payer")
1636        || key.ends_with("Player")
1637        || key.ends_with("Source")
1638        || key.ends_with("Target")
1639        || key.ends_with("Decider")
1640        || key.ends_with("Defender")
1641        || key.ends_with("Damage")
1642        || matches!(
1643            key,
1644            "Activator"
1645                | "Attacked"
1646                | "Caster"
1647                | "Attacking"
1648                | "AttachAfter"
1649                | "Blocking"
1650                | "Choser"
1651                | "Chooser"
1652                | "DefinedPlayer"
1653                | "Flipper"
1654                | "ForgetImprinted"
1655                | "ForgetOnCast"
1656                | "ForceReveal"
1657                | "GainControl"
1658                | "MustAttack"
1659                | "Object"
1660                | "Optional"
1661                | "OptionalDecider"
1662                | "MayLookAt"
1663                | "Player"
1664                | "Placer"
1665                | "PresentPlayer2"
1666                | "Separator"
1667                | "ShowCurrentCard"
1668                | "StartingWith"
1669                | "TargetingPlayer"
1670                | "Tapper"
1671                | "TempRemember"
1672                | "AddTriggersFrom"
1673                | "DeclaresBlockers"
1674                | "TokenBlocking"
1675                | "TokenAttacking"
1676                | "TokenOwner"
1677                | "TokenRemembered"
1678                | "ToEachOther"
1679                | "UnblockCreaturesBlockedOnlyBy"
1680                | "UnlessPayer"
1681                | "Guesser"
1682                | "ConditionLifeTotal"
1683                | "ConditionPlayerContains"
1684        )
1685}
1686
1687fn is_svar_reference_key(key: &str) -> bool {
1688    key == "Execute"
1689        || key == "sVars"
1690        || key.ends_with("SubAbility")
1691        || key.ends_with("SubAbilities")
1692        || key.ends_with("SVar")
1693        || key.ends_with("SVarName")
1694        || key.ends_with("Abilities")
1695        || key.ends_with("Ability")
1696        || key.ends_with("Pile")
1697        || key.ends_with("Subs")
1698        || matches!(
1699            key,
1700            "AddAbility"
1701                | "AddSVars"
1702                | "AddStaticAbility"
1703                | "AddTrigger"
1704                | "AddTriggers"
1705                | "FalseSubAbility"
1706                | "GuessCorrect"
1707                | "GuessWrong"
1708                | "Highest"
1709                | "Lowest"
1710                | "MustHaveInHand"
1711                | "NotLowest"
1712                | "NextRoom"
1713                | "RepeatSubAbility"
1714                | "Replacements"
1715                | "ReplacementEffects"
1716                | "StaticAbilities"
1717                | "SubAbility"
1718                | "TokenScript"
1719                | "Trigger"
1720                | "Triggers"
1721                | "TriggersWhenSpent"
1722                | "TrueSubAbility"
1723        )
1724}
1725
1726fn is_amount_key(key: &str) -> bool {
1727    key.ends_with("Amount")
1728        || key.ends_with("CMC")
1729        || key.ends_with("HandSize")
1730        || key.ends_with("Power")
1731        || key.ends_with("Toughness")
1732        || key.starts_with("Num")
1733        || key.ends_with("Num")
1734        || key.ends_with("Limit")
1735        || key.ends_with("Min")
1736        || key.ends_with("Max")
1737        || matches!(
1738            key,
1739            "Amount"
1740                | "Additional"
1741                | "AdjustLandPlays"
1742                | "ChangeNum"
1743                | "CounterNum"
1744                | "DigNum"
1745                | "LifeAmount"
1746                | "LibraryPosition"
1747                | "Max"
1748                | "Min"
1749                | "MaxRevealed"
1750                | "NumAtt"
1751                | "NumCards"
1752                | "NumDmg"
1753                | "NumDef"
1754                | "Power"
1755                | "PowerUp"
1756                | "RevealNumber"
1757                | "SetPower"
1758                | "SetToughness"
1759                | "MaxRepeat"
1760                | "SetChosenNumber"
1761                | "TokenPower"
1762                | "TokenToughness"
1763                | "Toughness"
1764                | "Value"
1765                | "VarValue"
1766        )
1767}
1768
1769fn is_delimited_list_key(key: &str) -> bool {
1770    key.ends_with("List")
1771        || key.ends_with("Names")
1772        || key.ends_with("Colors")
1773        || key.ends_with("Color")
1774        || key.ends_with("Keyword")
1775        || key.ends_with("Keywords")
1776        || key.ends_with("KWs")
1777        || key.ends_with("Counters")
1778        || matches!(
1779            key,
1780            "AddKeyword"
1781                | "AddColor"
1782                | "Attributes"
1783                | "ChooseEach"
1784                | "Color"
1785                | "Colors"
1786                | "ClearNotedCardsFor"
1787                | "ForgetCounter"
1788                | "Gains"
1789                | "KW"
1790                | "Keywords"
1791                | "Names"
1792                | "NoteCardsFor"
1793                | "PumpKeywords"
1794                | "SetColor"
1795                | "XColor"
1796                | "VarName"
1797                | "WithCounters"
1798        )
1799}
1800
1801fn looks_like_expression_key(key: &str) -> bool {
1802    key.ends_with("Compare")
1803        || key.ends_with("Condition")
1804        || key.ends_with("Formula")
1805        || key.contains("ThisTurn")
1806        || key.starts_with("CheckOn")
1807        || key == "Expression"
1808        || key == "LifeTotal"
1809        || key == "Condition"
1810}
1811
1812fn looks_like_expression_value(value: &str) -> bool {
1813    value.starts_with("Count$")
1814        || value.starts_with("Remembered$")
1815        || value.starts_with("Triggered$")
1816        || value.contains("/Plus.")
1817        || value.contains("/Minus.")
1818        || value.contains("/Times.")
1819        || value.contains("/Twice")
1820        || value.contains("/Half")
1821}
1822
1823fn is_svar_name(raw: &str) -> bool {
1824    let mut chars = raw.chars();
1825    let Some(first) = chars.next() else {
1826        return false;
1827    };
1828    (first == '_' || first.is_ascii_alphabetic())
1829        && chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
1830}
1831
1832fn looks_like_ability_record(raw: &str) -> bool {
1833    raw_has_any(raw, &[AB, SP, DB, ST])
1834}
1835
1836fn looks_like_param_record(raw: &str) -> bool {
1837    raw.contains('$')
1838}
1839
1840fn record_param_report_diagnostics<'a>(
1841    report: &ParsedParamsReport<'a>,
1842    line_no: usize,
1843    base_offset: usize,
1844    diagnostics: &mut ScriptDiagnostics<'a>,
1845) {
1846    for diagnostic in &report.diagnostics {
1847        diagnostics.push(ScriptDiagnostic {
1848            kind: ScriptDiagnosticKind::Param(diagnostic.kind),
1849            span: base_offset + diagnostic.span.start..base_offset + diagnostic.span.end,
1850            line_no,
1851            segment: diagnostic.segment,
1852            key: diagnostic.key,
1853            previous_value: diagnostic.previous_value,
1854            value: diagnostic.value,
1855        });
1856    }
1857}
1858
1859fn parse_entry<'a>(part: &'a str, part_offset: usize) -> Option<ParamEntry<'a>> {
1860    let (trimmed, trimmed_offset) = trim_with_offset(part, part_offset);
1861    if trimmed.is_empty() {
1862        return None;
1863    }
1864
1865    let mut input = trimmed;
1866    let key_raw = key_text.parse_next(&mut input).ok()?;
1867    let key_end = trimmed.len() - input.len();
1868    dollar.parse_next(&mut input).ok()?;
1869
1870    let value_start_in_trimmed = if let Some(rest) = input.strip_prefix(' ') {
1871        input = rest;
1872        key_end + 2
1873    } else {
1874        key_end + 1
1875    };
1876
1877    let key_leading = key_raw.len() - key_raw.trim_start().len();
1878    let key = key_raw.trim();
1879    let key_start = trimmed_offset + key_leading;
1880    let key_span = key_start..key_start + key.len();
1881
1882    let value_raw = input;
1883    let value_leading = value_raw.len() - value_raw.trim_start().len();
1884    let value = value_raw.trim();
1885    let value_start = trimmed_offset + value_start_in_trimmed + value_leading;
1886    let value_span = value_start..value_start + value.len();
1887
1888    Some(ParamEntry {
1889        key,
1890        value,
1891        key_span,
1892        value_span,
1893    })
1894}
1895
1896fn key_text<'a>(input: &mut &'a str) -> Result<&'a str> {
1897    take_till(0.., '$').parse_next(input)
1898}
1899
1900fn dollar(input: &mut &str) -> Result<char> {
1901    '$'.parse_next(input)
1902}
1903
1904fn trim_with_offset(s: &str, offset: usize) -> (&str, usize) {
1905    let leading = s.len() - s.trim_start().len();
1906    (s.trim(), offset + leading)
1907}