From 0e45ef39aeb9e3f1380379a5a3cf380c0935da20 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Mon, 3 Aug 2026 21:16:49 -0400 Subject: [PATCH] Render wikilinks to display text in a stub's seed quote A stub's seed line quoted narration with only the [[ ]] delimiters stripped, so a piped link leaked its address and pipe into the note: "Devoted to gods/Seriel|Seriel, the Lady of the Hearth". Every wikilink in the quoted window now renders to its display form, the same form the player sees, and the window centers on the first link whose address is the stub's own, so an entity linked only under an alias display still quotes correctly. The window arithmetic also clamps to UTF-8 character boundaries; the old byte offsets could land inside a multi-byte character near the window edge and panic the turn. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HvctyUUkzw7PcNrjCGG6dF --- src/entities/entities_stub_tests.rs | 80 +++++++++++++++++++++ src/entities/mod.rs | 108 ++++++++++++++++++++++++---- 2 files changed, 173 insertions(+), 15 deletions(-) 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 -- 2.51.2