diff --git a/src/srd/verify/field_lines.rs b/src/srd/verify/field_lines.rs index c14ada3..dba8466 100644 --- a/src/srd/verify/field_lines.rs +++ b/src/srd/verify/field_lines.rs @@ -31,15 +31,18 @@ use super::Failure; use super::magic_item::MagicItemFields; use super::spell::SpellFields; -// Not anchored to the start of a line: the vendored source sometimes -// puts two fields on one line with no separator between them -// ("**Range:** Touch **Component:** V, S"), so a match has to be able to -// start wherever the previous field's value ends. A value stops at the -// next `*` (the start of the next bold marker, field or otherwise) or -// end of line, whichever comes first, rather than running to the end of -// the line the old line-anchored pattern used. -static FIELD_LINE: LazyLock = - LazyLock::new(|| Regex::new(r"\*\*([^*:]+):\*\*\s*([^*\n]*)").unwrap()); +// Matches only a field label's own markup ("**Casting Time:** ", say), +// not its value. A value can legitimately contain its own emphasis +// markers (Shield's source line reads "...targeted by the *Magic +// Missile*spell"), so a regex that captured the value too and stopped +// at the first `*` would truncate it there. Instead `field_lines` below +// computes each value's span in code: from where this match ends to +// wherever the next boundary falls, which is either the start of the +// next label match, when another field shares the same line +// ("**Range:** Touch **Component:** V, S"), or the next newline, +// whichever comes first. +static FIELD_LABEL: LazyLock = + LazyLock::new(|| Regex::new(r"\*\*([^*:\n]+):\*\*\s*").unwrap()); static MAGIC_ITEM_FIELD_LINE: LazyLock = LazyLock::new(|| Regex::new(r"(?m)^\*\*(?:Category|Rarity|Attunement):\*\*.*\n?").unwrap()); @@ -82,13 +85,35 @@ fn body_attunement(attunement: bool, note: Option<&str>) -> String { } /// Every `**Label:** value` line in `body`, keyed by label, plus the set -/// of labels that appeared more than once. +/// of labels that appeared more than once. A value runs to the next +/// field label or the next newline, whichever comes first. Any `*` +/// emphasis marker inside it turns into a space rather than being +/// deleted outright (the vendored source sometimes runs an italicized +/// word straight into the next with no space, `*Magic Missile*spell`), +/// and the resulting whitespace collapses to single spaces. fn field_lines(body: &str) -> (HashMap, HashSet) { let mut lines: HashMap = HashMap::new(); let mut duplicates = HashSet::new(); - for captures in FIELD_LINE.captures_iter(body) { + let labels: Vec<_> = FIELD_LABEL.captures_iter(body).collect(); + for (index, captures) in labels.iter().enumerate() { let label = captures[1].trim().to_string(); - let value = captures[2].trim().to_string(); + let value_start = captures.get(0).unwrap().end(); + let next_label_start = labels + .get(index + 1) + .map(|next| next.get(0).unwrap().start()); + let next_newline = body[value_start..] + .find('\n') + .map(|offset| value_start + offset); + let value_end = [next_label_start, next_newline] + .into_iter() + .flatten() + .min() + .unwrap_or(body.len()); + let value = body[value_start..value_end] + .replace('*', " ") + .split_whitespace() + .collect::>() + .join(" "); if lines.insert(label.clone(), value).is_some() { duplicates.insert(label); } diff --git a/src/srd/verify/field_lines_tests.rs b/src/srd/verify/field_lines_tests.rs index 915fe4a..dee2706 100644 --- a/src/srd/verify/field_lines_tests.rs +++ b/src/srd/verify/field_lines_tests.rs @@ -49,6 +49,26 @@ fn field_lines_reads_every_bold_labeled_line() { assert_eq!(duplicates, HashSet::new()); } +#[test] +fn field_lines_captures_a_value_containing_italic_emphasis() { + // Shield's exact line shape: the Casting Time value legitimately + // contains an italicized spell name mid-sentence, with no space + // between the closing '*' and the next word. The old + // value-capturing regex stopped at the first '*', truncating the + // value before "Magic Missile"; simply deleting '*' instead of + // turning it into a space would fuse "Missile" and "spell" together. + let body = "**Casting Time:** Reaction, which you take when you are hit by an attack roll or targeted by the *Magic Missile*spell\n\n**Range:** Self\n"; + let (lines, duplicates) = field_lines(body); + assert_eq!( + lines.get("Casting Time"), + Some( + &"Reaction, which you take when you are hit by an attack roll or targeted by the Magic Missile spell" + .to_string() + ) + ); + assert_eq!(duplicates, HashSet::new()); +} + #[test] fn field_lines_flags_a_repeated_label() { let body = "**Rarity:** Rare\n\n**Rarity:** made up prose\n"; @@ -74,6 +94,25 @@ fn check_spell_passes_when_every_field_matches() { assert_eq!(failures, vec![]); } +#[test] +fn check_spell_accepts_a_casting_time_value_containing_italic_emphasis() { + let body = "**Casting Time:** Reaction, which you take when you are hit by an attack roll or targeted by the *Magic Missile*spell\n\n**Range:** 150 feet\n\n**Components:** V, S, M\n\n**Duration:** Instantaneous\n"; + let mut fields = spell_fields(); + fields.casting_time = + "Reaction, which you take when you are hit by an attack roll or targeted by the Magic Missile spell" + .to_string(); + let mut failures = Vec::new(); + + check_spell( + body, + &fields, + &PathBuf::from("spells/shield.md"), + &mut failures, + ); + + assert_eq!(failures, vec![]); +} + #[test] fn check_spell_reports_a_missing_field_line() { let body = "**Range:** 150 feet\n\n**Components:** V, S, M\n\n**Duration:** Instantaneous\n";