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}