diff --git a/src/entities/entities_stub_tests.rs b/src/entities/entities_stub_tests.rs index 7715d12..4c048cc 100644 --- a/src/entities/entities_stub_tests.rs +++ b/src/entities/entities_stub_tests.rs @@ -122,3 +122,83 @@ nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; assert!(!snippet.ends_with(|character: char| character.is_whitespace())); assert!(snippet.len() <= SEED_SNIPPET_CHARS + 20); } + +#[test] +fn snippet_around_renders_a_piped_wikilink_to_only_its_display_text() { + let narration = "The [[gods/Seriel|Lady of the Hearth]] blesses you."; + assert_eq!( + snippet_around(narration, "gods/Seriel"), + "The Lady of the Hearth blesses you." + ); +} + +#[test] +fn snippet_around_renders_a_scoped_bare_link_to_its_last_segment() { + let narration = "A [[gods/Seriel]] shrine stands here."; + assert_eq!( + snippet_around(narration, "gods/Seriel"), + "A Seriel shrine stands here." + ); +} + +#[test] +fn snippet_around_centers_on_the_entitys_own_link_even_when_its_display_text_is_an_alias() { + // The display text never says "Seriel", so only centering on the + // matching address, not a text search for the name, finds this. + let narration = "The stranger [[gods/Seriel|the Lady of the Hearth]] never gives her name."; + assert_eq!( + snippet_around(narration, "gods/Seriel"), + "The stranger the Lady of the Hearth never gives her name." + ); +} + +#[test] +fn snippet_around_renders_a_second_entitys_link_in_the_window_clean_too() { + let narration = "[[gods/Seriel|Seriel]] speaks with [[people/Joseph Black|Old Joe]] nearby."; + let snippet = snippet_around(narration, "gods/Seriel"); + + assert!(snippet.contains("Old Joe")); + assert!(!snippet.contains('|')); + assert!(!snippet.contains("people/")); +} + +#[test] +fn snippet_around_treats_an_unclosed_wikilink_as_plain_text() { + let narration = "A strange rift opens: [[gods/Seriel never closes."; + // No closing `]]`, so this must not panic; the raw text still carries + // the name for the text-search fallback to find. + assert!(snippet_around(narration, "gods/Seriel").contains("Seriel")); +} + +#[test] +fn snippet_around_reproduces_the_original_bug_report_clean() { + let narration = "**Ashveil** — Level 3 Cleric, **Life Domain**\n\ + Devoted to [[gods/Seriel|Seriel]], the Lady of the Hearth, \ + goddess of healing and the hearth."; + let snippet = snippet_around(narration, "gods/Seriel"); + + assert!(snippet.contains("Seriel")); + assert!(!snippet.contains('|')); + assert!(!snippet.contains("gods/")); +} + +#[test] +fn snippet_around_does_not_split_a_multibyte_character_at_the_window_start() { + // The em dash sits one byte into the computed window start, so the + // trim must step back to the dash's own boundary instead of slicing + // through the middle of it. + let filler = "a".repeat(SEED_SNIPPET_CHARS / 2 - 2); + let narration = format!("—{filler}[[X]] tail"); + + assert!(snippet_around(&narration, "X").contains('X')); +} + +#[test] +fn snippet_around_does_not_split_a_multibyte_character_at_the_window_end() { + // Same as the window-start case, but the em dash sits inside the + // computed window end instead. + let filler = "a".repeat(SEED_SNIPPET_CHARS / 2 - 2); + let narration = format!("[[X]]{filler}— more text after it"); + + assert!(snippet_around(&narration, "X").contains('X')); +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 5cbe830..a703556 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -343,8 +343,7 @@ fn body_of(path: &Path) -> Option { /// Every stub carries `stub: true` and starts with no aliases. fn stub_body(address: &str, time: GameTime, narration: &str) -> String { let kind = stub_kind(address); - let name = name_suffix(address); - let snippet = snippet_around(narration, name); + let snippet = snippet_around(narration, address); format!("---\nkind: {kind}\naliases: []\nstub: true\n---\n\nFirst seen {time}: \"{snippet}\"\n") } @@ -358,26 +357,105 @@ fn stub_kind(address: &str) -> &str { } } -/// The narration window around `name`, trimmed to word boundaries and -/// stripped of wikilink delimiters, for a stub's seed line. An empty -/// string when `name` is not in `narration`. -fn snippet_around(narration: &str, name: &str) -> String { - let lower = narration.to_lowercase(); - let needle = name.to_lowercase(); - let Some(position) = lower.find(&needle) else { +/// The narration window around `address`'s entity, trimmed to word +/// boundaries, for a stub's seed line. +/// +/// Every wikilink in `narration` renders to its display form first, so a +/// quoted `[[gods/Seriel|Seriel]]` reads as plain "Seriel" rather than +/// leaking its address and pipe. The window centers on the first +/// wikilink whose own address matches `address`; when the narration +/// never links the entity directly, for example an alias-only mention +/// like `[[gods/Seriel|the Lady of the Hearth]]`, it falls back to +/// searching the rendered text for the entity's name. An empty string +/// when neither is found. +fn snippet_around(narration: &str, address: &str) -> String { + let (rendered, target) = render_wikilinks(narration, address); + let target = target.or_else(|| { + let name = name_suffix(address).to_lowercase(); + let position = rendered.to_lowercase().find(&name)?; + Some((position, name.len())) + }); + let Some((position, length)) = target else { return String::new(); }; - let start = position.saturating_sub(SEED_SNIPPET_CHARS / 2); - let end = (position + needle.len() + SEED_SNIPPET_CHARS / 2).min(narration.len()); - let start = narration[..start] + window_around(&rendered, position, length) +} + +/// Renders every wikilink in `narration` to its display form: +/// `[[address|display]]` becomes `display`, and a bare `[[address]]` +/// becomes the address's last `/`-segment. An unclosed `[[` has no link +/// to close, so it and everything after it is kept as plain text. +/// +/// Also reports where the display text of the first wikilink whose own +/// address matches `target_address`, case insensitively, landed in the +/// rendered string, and that display text's byte length. `None` when no +/// wikilink names `target_address`. +fn render_wikilinks(narration: &str, target_address: &str) -> (String, Option<(usize, usize)>) { + let needle = target_address.to_lowercase(); + let mut rendered = String::with_capacity(narration.len()); + let mut target = None; + let mut rest = narration; + + while let Some(start) = rest.find("[[") { + rendered.push_str(&rest[..start]); + let after_open = &rest[start + 2..]; + let Some(end) = after_open.find("]]") else { + rendered.push_str(&rest[start..]); + rest = ""; + break; + }; + let inner = &after_open[..end]; + let (link_address, display) = match inner.split_once('|') { + Some((link_address, display)) => (link_address, display.to_string()), + None => (inner, name_suffix(inner).to_string()), + }; + if target.is_none() && link_address.to_lowercase() == needle { + target = Some((rendered.len(), display.len())); + } + rendered.push_str(&display); + rest = &after_open[end + 2..]; + } + rendered.push_str(rest); + (rendered, target) +} + +/// The [`SEED_SNIPPET_CHARS`] window of `text` around the byte range +/// `[position, position + length)`, trimmed to word boundaries. +fn window_around(text: &str, position: usize, length: usize) -> String { + let start = floor_char_boundary(text, position.saturating_sub(SEED_SNIPPET_CHARS / 2)); + let end = ceil_char_boundary( + text, + (position + length + SEED_SNIPPET_CHARS / 2).min(text.len()), + ); + let start = text[..start] .rfind(|character: char| character.is_whitespace()) .map(|index| index + 1) .unwrap_or(start); - let end = narration[end..] + let end = text[end..] .find(|character: char| character.is_whitespace()) .map(|index| end + index) - .unwrap_or(narration.len()); - narration[start..end].replace("[[", "").replace("]]", "") + .unwrap_or(text.len()); + text[start..end].to_string() +} + +/// The nearest character boundary in `text` at or before `index`. A +/// display text's rendered position always lands on one, but padding +/// `index` out by a fixed byte count can land inside a multi-byte +/// character, which this steps back out of. +fn floor_char_boundary(text: &str, mut index: usize) -> usize { + while index > 0 && !text.is_char_boundary(index) { + index -= 1; + } + index +} + +/// The nearest character boundary in `text` at or after `index`, the +/// same correction as [`floor_char_boundary`] but stepping forward. +fn ceil_char_boundary(text: &str, mut index: usize) -> usize { + while index < text.len() && !text.is_char_boundary(index) { + index += 1; + } + index } /// Registers every note under `root` into `state`, so a bare wikilink