From b9f8c3ca4c84e8f95dd09f429db0fcd46f764b4a Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Thu, 30 Jul 2026 11:25:15 -0400 Subject: [PATCH] Stop truncating field-line values at embedded emphasis markers check_spell and check_magic_item compare each mechanical frontmatter field against its bold field line in the corpus body, but the regex that captured a line's value stopped at the first '*'. Shield's Casting Time value legitimately contains an italicized spell name mid-sentence ("...targeted by the *Magic Missile*spell"), so the old regex captured only the text before that asterisk. That forced the corpus's shield.md frontmatter to carry the same truncated value just to pass the check. field_lines now finds only each label's own markup, then computes the value's span in code: from the end of that match to the start of the next field label sharing the line, or the next newline, whichever comes first. Any '*' inside the span turns into a space rather than being deleted outright, since the vendored source sometimes runs an italicized word straight into the next with no space in between, and the resulting whitespace collapses to single spaces. Running verify against the real corpus now correctly flags rules/srd-5.2/spells/shield.md: its casting_time frontmatter value is still the old truncated text, so it no longer matches the full body field line. That file is being tracked separately and was not touched here. Co-Authored-By: Claude Fable 5 --- src/srd/verify/field_lines.rs | 49 ++++++++++++++++++++++------- src/srd/verify/field_lines_tests.rs | 39 +++++++++++++++++++++++ 2 files changed, 76 insertions(+), 12 deletions(-) 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"; -- 2.51.2