From ab9aa2afce32cdb4856885bbb7f365ba66af68cc Mon Sep 17 00:00:00 2001 From: landon Date: Sat, 19 Sep 2026 11:01:54 -0500 Subject: [PATCH] Auto-link Story Bible mentions in the draft, with a hover card to the entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare proper nouns in prose ("Bixby walked in") now resolve against the Story Bible automatically, the same as the existing [[wiki-links]] and @mentions markup — no manual linking required. Matching is case-sensitive and requires a capitalized, 3+ character match to a sheet's name or alias, which is what keeps a character named "Hope" or "Will" from lighting up on every unrelated use of the word; the longest phrase wins an overlap ("Bixby Reynolds" over "Bixby"), and a bare match never overrides the author's own explicit markup. Hovering a highlighted mention shows a card with the item's name, type (Person/Place/Thing) and a summary (from a new summary:/description: frontmatter field), with a link that opens the entry in the Story Bible editor. The card appears after a short delay and stays open while the pointer crosses onto it to reach the link. Along the way: - StoryBibleReference now carries absolute byte offsets (start/end) and the resolved item_id, replacing column/length, which were actually per-line and were being used as whole-buffer offsets — every reference past a file's first line highlighted and jumped-to in the wrong place. find_references() also now walks the buffer one char at a time rather than one byte, which could previously land mid-character and panic on any manuscript with accented letters or an em dash. - build_story_bible_index() had its own, separately hardcoded list of Story Bible directory names that had drifted from find_story_bible_directory()'s list and no longer included 03-Story-Bible, the bundled template's actual folder — so the index, and everything built on it, was silently empty for every project made from that template. It now reuses the one list. - Story Bible Editor window: its tree and content ScrollAreas (and the content TextEdit) had no id_salt, so egui treated them as the same widget — fixed with explicit salts. Co-Authored-By: Claude Sonnet 5 --- src/app/editor.rs | 107 ++++++++++- src/app/mod.rs | 16 ++ src/app/story_bible_editor.rs | 3 + src/app/story_bible_ui.rs | 7 +- src/app/workspace.rs | 35 ++-- src/story_bible.rs | 352 ++++++++++++++++++++++++++++------ 6 files changed, 436 insertions(+), 84 deletions(-) diff --git a/src/app/editor.rs b/src/app/editor.rs index 783310f..2a4acd9 100644 --- a/src/app/editor.rs +++ b/src/app/editor.rs @@ -47,7 +47,7 @@ impl App { let bible_ranges: Vec<(usize, usize)> = self .current_references .iter() - .map(|r| (r.column, r.column + r.length)) + .map(|r| (r.start, r.end)) .collect(); let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| { @@ -335,6 +335,48 @@ impl App { } else { self.autocomplete = None; } + + // Which Story Bible mention (if any) is the pointer over right + // now, and where would its hover card anchor? + let hovered_ref = output.response.hover_pos().and_then(|pos| { + let local = pos - output.galley_pos; + let cursor = output.galley.cursor_from_pos(local); + let byte = char_to_byte(&self.buffer, cursor.ccursor.index); + self.current_references + .iter() + .find(|r| byte >= r.start && byte < r.end) + }); + match hovered_ref { + Some(r) if self.bible_hover_item.as_deref() != Some(r.item_id.as_str()) => { + let (cs, ce) = ( + byte_to_char(&self.buffer, r.start), + byte_to_char(&self.buffer, r.end), + ); + let rect = output + .galley + .pos_from_ccursor(egui::text::CCursor::new(cs)) + .union(output.galley.pos_from_ccursor(egui::text::CCursor::new(ce))) + .translate(output.galley_pos.to_vec2()); + self.bible_hover_item = Some(r.item_id.clone()); + self.bible_hover_since = Some(Instant::now()); + self.bible_hover_anchor = rect; + } + Some(_) => {} // the same word is still hovered — keep its timer/anchor + None => { + // Not over a mention — only drop the card if the + // pointer isn't over the card itself either (the user + // moving toward its "Open" link). + let over_card = self + .bible_hover_card_rect + .zip(ui.ctx().pointer_hover_pos()) + .is_some_and(|(rect, pos)| rect.contains(pos)); + if !over_card { + self.bible_hover_item = None; + self.bible_hover_since = None; + self.bible_hover_card_rect = None; + } + } + } }); // Header-field autocomplete popup, floating just below the caret. @@ -358,6 +400,69 @@ impl App { if let Some(field) = clicked_field { self.apply_field_completion(ui.ctx(), &field); } + + // Story Bible hover card: name, type and summary for the mentioned + // item, with a link to open its full entry. Shown after a short + // delay so it doesn't flash across every name the pointer passes + // over on its way somewhere else, and it stays open while the + // pointer crosses onto the card to reach that link (tracked via + // `bible_hover_card_rect`, set below and checked above). + const HOVER_DELAY: std::time::Duration = std::time::Duration::from_millis(350); + let mut open_bible_item: Option = None; + let hover = self.bible_hover_item.clone().zip(self.bible_hover_since); + self.bible_hover_card_rect = None; + if let Some((item_id, since)) = hover { + let elapsed = since.elapsed(); + if elapsed < HOVER_DELAY { + ui.ctx().request_repaint_after(HOVER_DELAY - elapsed); + } else if let Some(item) = self.story_bible_index.find_by_id(&item_id).cloned() { + let resp = egui::Area::new(egui::Id::new("bible_hover_card")) + .order(egui::Order::Foreground) + .fixed_pos(self.bible_hover_anchor.left_bottom() + egui::vec2(0.0, 4.0)) + .constrain(true) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_max_width(280.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&item.name).strong()); + ui.label( + egui::RichText::new(crate::story_bible::type_label( + &item.item_type, + )) + .weak() + .small(), + ); + }); + match &item.summary { + Some(s) => { + ui.label(s); + } + None => { + ui.label( + egui::RichText::new("No summary yet.").weak().italics(), + ); + } + } + ui.separator(); + if ui.link("Open in Story Bible \u{2192}").clicked() { + open_bible_item = Some(item.path.clone()); + } + }); + }); + self.bible_hover_card_rect = Some(resp.response.rect); + } else { + // The item vanished (sheet deleted, index gone stale) — stop trying. + self.bible_hover_item = None; + self.bible_hover_since = None; + } + } + if let Some(path) = open_bible_item { + self.show_story_bible_editor = true; + self.load_story_bible_item(&path); + self.bible_hover_item = None; + self.bible_hover_since = None; + self.bible_hover_card_rect = None; + } } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 11d278e..1a54878 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -383,6 +383,18 @@ pub struct App { selected_bible_item: Option, /// References found in the current draft file. current_references: Vec, + /// The Story Bible item whose hover card is (or was just) showing over the + /// editor, so the card can stay open while the pointer crosses from the + /// mentioned word onto the card itself to click its link. + bible_hover_item: Option, + /// When the current hover began, so the card only appears after a short + /// delay rather than flickering across every name the pointer passes over. + bible_hover_since: Option, + /// On-screen rect of the mentioned word, to anchor the hover card under. + bible_hover_anchor: egui::Rect, + /// On-screen rect the hover card itself occupied last frame, so hovering + /// the card (to reach its link) counts as still hovering the reference. + bible_hover_card_rect: Option, /// Whether the Story Bible breakout editor window is open. show_story_bible_editor: bool, /// The content being edited in the Story Bible editor. @@ -502,6 +514,10 @@ impl App { show_story_bible: false, selected_bible_item: None, current_references: Vec::new(), + bible_hover_item: None, + bible_hover_since: None, + bible_hover_anchor: egui::Rect::NOTHING, + bible_hover_card_rect: None, show_story_bible_editor: false, story_bible_editor_content: String::new(), story_bible_editor_path: None, diff --git a/src/app/story_bible_editor.rs b/src/app/story_bible_editor.rs index 40e0b1b..1b94ea8 100644 --- a/src/app/story_bible_editor.rs +++ b/src/app/story_bible_editor.rs @@ -241,6 +241,7 @@ impl App { // Scroll area for tree egui::ScrollArea::vertical() + .id_salt("story_bible_editor_tree") .auto_shrink(false) .show(ui, |ui| { if tree.is_empty() { @@ -271,9 +272,11 @@ impl App { // Editor area egui::ScrollArea::vertical() + .id_salt("story_bible_editor_content") .auto_shrink(false) .show(ui, |ui| { let text_edit = egui::TextEdit::multiline(&mut self.story_bible_editor_content) + .id_salt("story_bible_editor_textedit") .code_editor() .desired_width(f32::INFINITY) .desired_rows(20); diff --git a/src/app/story_bible_ui.rs b/src/app/story_bible_ui.rs index fb99964..a3121e6 100644 --- a/src/app/story_bible_ui.rs +++ b/src/app/story_bible_ui.rs @@ -95,10 +95,7 @@ impl App { // Find references to this item let item_refs: Vec<_> = current_refs .iter() - .filter(|r| { - r.identifier.eq_ignore_ascii_case(&item.id) - || r.identifier.eq_ignore_ascii_case(&item.name) - }) + .filter(|r| r.item_id == item.id) .collect(); if !item_refs.is_empty() { @@ -137,6 +134,6 @@ impl App { /// Jump to a Story Bible reference in the editor. pub(super) fn jump_to_reference(&mut self, ref_info: &crate::story_bible::StoryBibleReference) { - self.issue_jump = Some((ref_info.column, ref_info.column + ref_info.length)); + self.issue_jump = Some((ref_info.start, ref_info.end)); } } diff --git a/src/app/workspace.rs b/src/app/workspace.rs index 1957134..1b9b5a9 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -341,37 +341,38 @@ impl App { } /// Build the Story Bible index from the Story Bible directory. + /// + /// Uses [`Self::find_story_bible_directory`] for the primary lookup — + /// this used to keep its own, separately-hardcoded list of directory + /// names, which had drifted out of sync and no longer included + /// `03-Story-Bible`, the bundled template's actual folder. That silently + /// left the index empty (no highlighting, no hover cards, no hits in the + /// Story Bible window) for every project made from the snowflake + /// template, so there is now exactly one place that name is spelled. pub(super) fn build_story_bible_index(&mut self) { use std::path::Path; - let ws = self.config.workspace.clone(); - // Look for Story Bible directory - common locations - let story_bible_dirs = [ - "03-Characters", - "02-Story Bible", - "Story Bible", - "Characters", - ]; - - for dir_name in story_bible_dirs.iter() { - let dir_path = Path::new(&ws).join(dir_name); - if dir_path.exists() && dir_path.is_dir() { - self.story_bible_index = crate::story_bible::StoryBibleIndex::build(&dir_path); - return; - } + if let Some(dir_path) = self.find_story_bible_directory() { + self.story_bible_index = crate::story_bible::StoryBibleIndex::build(&dir_path); + return; } - // If no specific Story Bible directory found, try to find all character/location/lore dirs + self.story_bible_index = crate::story_bible::StoryBibleIndex::default(); + // No single Story Bible directory found — fall back to merging + // whatever scattered character/location/lore folders exist. + let ws = self.config.workspace.clone(); let possible_dirs = ["03-Characters", "04-Locations", "05-Lore", "Characters", "Locations", "Lore"]; for dir_name in possible_dirs.iter() { let dir_path = Path::new(&ws).join(dir_name); if dir_path.exists() && dir_path.is_dir() { let index = crate::story_bible::StoryBibleIndex::build(&dir_path); - // Merge with existing index for (id, item) in index.items_by_id { self.story_bible_index.items_by_id.insert(id, item.clone()); } for (name, item) in index.items_by_name { self.story_bible_index.items_by_name.insert(name, item); } + for (alias, item) in index.items_by_alias { + self.story_bible_index.items_by_alias.insert(alias, item); + } } } } diff --git a/src/story_bible.rs b/src/story_bible.rs index f768d78..236ddd7 100644 --- a/src/story_bible.rs +++ b/src/story_bible.rs @@ -14,11 +14,13 @@ pub struct StoryBibleReference { pub file_path: String, /// The line number where the reference appears (1-indexed). pub line_number: usize, - /// The character position in the line. - pub column: usize, - /// The length of the reference text. - pub length: usize, - /// The matched identifier. + /// Byte offset of the reference's start in the whole buffer. + pub start: usize, + /// Byte offset just past the reference's end in the whole buffer. + pub end: usize, + /// The id of the Story Bible item this reference resolves to. + pub item_id: String, + /// The matched text, exactly as it appears in the draft. pub identifier: String, } @@ -37,6 +39,9 @@ pub struct StoryBibleItem { pub aliases: Vec, /// Line in the file where the name/ID is defined. pub definition_line: usize, + /// A short summary for the hover card, from a `summary:`/`description:` + /// frontmatter field. `None` when the sheet doesn't have one. + pub summary: Option, } /// Index of all Story Bible items for quick lookup. @@ -176,6 +181,7 @@ fn extract_story_bible_item(relative_path: &str, content: &str) -> Option Option = None; let mut item_type: Option = None; let mut aliases: Vec = Vec::new(); + let mut summary: Option = None; + let mut description: Option = None; for line in &lines[start + 1..end] { let trimmed = line.trim(); @@ -211,8 +219,20 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option Option Vec { .collect() } -/// Find all Story Bible references in a text. +/// Find all Story Bible references in `text`, absolute byte offsets into the +/// whole buffer (not per-line), so callers can map them straight onto a galley. /// -/// Looks for patterns like `[[Character Name]]` or `@CharacterName`. +/// Two kinds, in priority order — an overlap always keeps the higher one: +/// +/// 1. **Explicit markup** — `[[Name]]` or `@Name`, resolved by ID, name or +/// alias. The author asked for this link by hand, so it always wins. +/// 2. **Bare mentions** — a known item's name or an alias, spelled exactly as +/// it appears in its Story Bible sheet, found in ordinary prose with no +/// markup at all. This is what makes the linking automatic: plan the cast +/// once in the Story Bible, then just write their names. +/// +/// Bare matching is case-sensitive and requires the candidate to start with +/// an uppercase letter — names are written that way both in the sheet and as +/// proper nouns in prose, and that's what keeps a character named "Hope" or +/// "Will" from lighting up on every unrelated use of the word. A phrase under +/// three characters is skipped too, since a two-letter alias (initials, say) +/// matches too much of everyday prose to be worth auto-detecting; it still +/// works through the explicit `[[...]]`/`@` markup above. pub fn find_references(text: &str, index: &StoryBibleIndex) -> Vec { - let mut references = Vec::new(); + // (start, end, item_id, identifier) — accepted spans, explicit first. + let mut accepted: Vec<(usize, usize, String, String)> = Vec::new(); - for (line_idx, line) in text.lines().enumerate() { - let line_num = line_idx + 1; - let mut pos = 0; - - while pos < line.len() { - // Check for wiki-style links: [[...]] - if line[pos..].starts_with("[[") { - let end_pos = line[pos + 2..] - .find("]]") - .map(|p| pos + 2 + p + 2); - if let Some(end) = end_pos { - let identifier = line[pos + 2..end - 2].trim().to_string(); - // Check if this matches any Story Bible item (by ID, name, or alias) - if index.find_by_id(&identifier).is_some() - || index.find_by_name(&identifier).is_some() - || index.find_by_alias(&identifier).is_some() - { - references.push(StoryBibleReference { - file_path: String::new(), // Will be set by caller - line_number: line_num, - column: pos, - length: end - pos, - identifier: identifier.clone(), - }); - } - pos = end; - continue; + // 1. Explicit [[...]] and @Name markup. Walked one *char* at a time + // (manuscript prose is full of accented letters and em dashes, and a + // byte-at-a-time walk can land mid-character and panic on the next slice). + let mut pos = 0usize; + while pos < text.len() { + if text[pos..].starts_with("[[") { + // `]]` only counts on the same line — an unterminated `[[` should + // not swallow the rest of the document looking for a closer. + let line_end = text[pos..] + .find('\n') + .map(|o| pos + o) + .unwrap_or(text.len()); + if let Some(rel_end) = text[pos + 2..line_end].find("]]") { + let end = pos + 2 + rel_end + 2; + let identifier = text[pos + 2..end - 2].trim().to_string(); + if let Some(item) = resolve(index, &identifier) { + accepted.push((pos, end, item.id.clone(), identifier)); } - } - - // Check for mention-style: @Name - if line[pos..].starts_with('@') && pos + 1 < line.len() { - // Extract word after @ - stop at space or other non-word characters - let word_end = line[pos + 1..] - .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-') - .map(|p| pos + 1 + p) - .unwrap_or(line.len()); - let identifier = line[pos + 1..word_end].trim().to_string(); - if !identifier.is_empty() - && (index.find_by_id(&identifier).is_some() - || index.find_by_name(&identifier).is_some() - || index.find_by_alias(&identifier).is_some()) - { - references.push(StoryBibleReference { - file_path: String::new(), - line_number: line_num, - column: pos, - length: word_end - pos, - identifier, - }); - } - pos = word_end; + pos = end; continue; } + } else if text.as_bytes()[pos] == b'@' && pos + 1 < text.len() { + let rest = &text[pos + 1..]; + let word_end = pos + + 1 + + rest + .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-') + .unwrap_or(rest.len()); + let identifier = text[pos + 1..word_end].to_string(); + if !identifier.is_empty() { + if let Some(item) = resolve(index, &identifier) { + accepted.push((pos, word_end, item.id.clone(), identifier)); + } + } + pos = word_end; + continue; + } + pos += text[pos..].chars().next().map(char::len_utf8).unwrap_or(1); + } - pos += 1; + // 2. Bare mentions. Every item's name and aliases, longest phrase first + // so "Bixby Reynolds" claims its span before "Bixby" alone gets a chance. + let mut candidates: Vec<(&str, &str)> = Vec::new(); // (phrase, item_id) + for item in index.items_by_id.values() { + candidates.push((item.name.as_str(), item.id.as_str())); + for alias in &item.aliases { + candidates.push((alias.as_str(), item.id.as_str())); + } + } + candidates.retain(|(phrase, _)| { + phrase.len() >= 3 && phrase.chars().next().is_some_and(char::is_uppercase) + }); + candidates.sort_by_key(|(phrase, _)| std::cmp::Reverse(phrase.len())); + + let is_word_char = |c: char| c.is_alphanumeric() || c == '_'; + for (phrase, item_id) in candidates { + for (start, matched) in text.match_indices(phrase) { + let end = start + matched.len(); + let before_ok = text[..start] + .chars() + .next_back() + .map_or(true, |c| !is_word_char(c)); + let after_ok = text[end..].chars().next().map_or(true, |c| !is_word_char(c)); + if !before_ok || !after_ok { + continue; + } + if accepted.iter().any(|&(s, e, ..)| start < e && end > s) { + continue; // overlaps an explicit link or a longer bare match + } + accepted.push((start, end, item_id.to_string(), phrase.to_string())); } } - references + accepted.sort_by_key(|&(start, ..)| start); + + let line_starts = line_start_offsets(text); + accepted + .into_iter() + .map(|(start, end, item_id, identifier)| StoryBibleReference { + file_path: String::new(), // set by the caller + line_number: line_number_at(&line_starts, start), + start, + end, + item_id, + identifier, + }) + .collect() +} + +/// Look up an identifier by ID, then name, then alias — whichever matches +/// first, the same fallback order the old separate call sites each used. +fn resolve<'a>(index: &'a StoryBibleIndex, identifier: &str) -> Option<&'a StoryBibleItem> { + index + .find_by_id(identifier) + .or_else(|| index.find_by_name(identifier)) + .or_else(|| index.find_by_alias(identifier)) +} + +/// Byte offset of the start of each line, `text[0]` included, in order. +fn line_start_offsets(text: &str) -> Vec { + std::iter::once(0) + .chain(text.match_indices('\n').map(|(i, _)| i + 1)) + .collect() +} + +/// 1-indexed line number containing `byte_offset`, from a table built by +/// [`line_start_offsets`]. +fn line_number_at(line_starts: &[usize], byte_offset: usize) -> usize { + match line_starts.binary_search(&byte_offset) { + Ok(i) => i + 1, + Err(i) => i, + } } /// Generate a markdown link to a Story Bible item. @@ -398,6 +488,25 @@ pub fn link_to_item(item: &StoryBibleItem, display_text: Option<&str>) -> String format!("[{}]({})", text, item.path) } +/// A short, reader-friendly label for an item's type, for a hover card or +/// list row — "Person" for a character sheet, "Place" for a location, and +/// Title Case of anything else (lore, object, theme, …), since those don't +/// reduce to one generic word. +pub fn type_label(item_type: &str) -> String { + match item_type.to_lowercase().as_str() { + "character" | "characters" => "Person".to_string(), + "location" | "locations" => "Place".to_string(), + "object" | "objects" => "Thing".to_string(), + other => { + let mut chars = other.chars(); + match chars.next() { + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + None => "Entry".to_string(), + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -417,6 +526,43 @@ Some content here."#; assert_eq!(item.item_type, "character"); } + #[test] + fn frontmatter_summary_feeds_the_hover_card() { + let content = r#"--- +id: character_01 +title: "John Doe" +type: character +summary: A retired locksmith with one unfinished job. +--- + +Some content here."#; + let item = extract_from_frontmatter("test.md", content).unwrap(); + assert_eq!( + item.summary.as_deref(), + Some("A retired locksmith with one unfinished job.") + ); + } + + #[test] + fn frontmatter_falls_back_to_description_and_prefers_summary() { + let description_only = r#"--- +title: "Jane" +description: Appears in Act 2. +--- +"#; + let item = extract_from_frontmatter("test.md", description_only).unwrap(); + assert_eq!(item.summary.as_deref(), Some("Appears in Act 2.")); + + let both = r#"--- +title: "Jane" +summary: The short version. +description: The long version. +--- +"#; + let item = extract_from_frontmatter("test.md", both).unwrap(); + assert_eq!(item.summary.as_deref(), Some("The short version.")); + } + #[test] fn test_find_references_wiki_links() { let index = { @@ -430,6 +576,7 @@ Some content here."#; item_type: "character".to_string(), aliases: Vec::new(), definition_line: 0, + summary: None, }, ); idx.items_by_name.insert( @@ -441,6 +588,7 @@ Some content here."#; item_type: "character".to_string(), aliases: Vec::new(), definition_line: 0, + summary: None, }, ); idx @@ -462,6 +610,7 @@ Some content here."#; item_type: "character".to_string(), aliases: Vec::new(), definition_line: 0, + summary: None, }; idx.items_by_name.insert("jane".to_string(), item.clone()); idx.items_by_id.insert("jane".to_string(), item); @@ -483,6 +632,7 @@ Some content here."#; item_type: "character".to_string(), aliases: vec!["Johnny".to_string(), "JS".to_string()], definition_line: 0, + summary: None, }; index.items_by_id.insert("john_smith".to_string(), item.clone()); index.items_by_name.insert("john smith".to_string(), item.clone()); @@ -505,6 +655,7 @@ Some content here."#; item_type: "character".to_string(), aliases: vec!["Johnny".to_string(), "JS".to_string()], definition_line: 0, + summary: None, }; index.items_by_name.insert("john smith".to_string(), item); @@ -527,6 +678,7 @@ Some content here."#; item_type: "character".to_string(), aliases: vec!["Johnny".to_string()], definition_line: 0, + summary: None, }; index.items_by_name.insert("john smith".to_string(), item.clone()); index.items_by_id.insert("john_smith".to_string(), item.clone()); @@ -541,6 +693,84 @@ Some content here."#; assert_eq!(refs[0].identifier, "Johnny"); } + /// A one-item index for the bare-mention tests below, keyed by name and + /// (if given) aliases — the same shape a real `StoryBibleIndex::build` + /// produces for a single character sheet. + fn index_with(id: &str, name: &str, aliases: &[&str]) -> StoryBibleIndex { + let item = StoryBibleItem { + path: format!("Characters/{id}.md"), + name: name.to_string(), + id: id.to_string(), + item_type: "character".to_string(), + aliases: aliases.iter().map(|a| a.to_string()).collect(), + definition_line: 0, + summary: None, + }; + let mut index = StoryBibleIndex::new(); + index.items_by_id.insert(id.to_string(), item.clone()); + index.items_by_name.insert(name.to_lowercase(), item.clone()); + for alias in aliases { + index.items_by_alias.insert(alias.to_lowercase(), item.clone()); + } + index + } + + #[test] + fn a_bare_mention_is_found_without_any_markup() { + let index = index_with("bixby", "Bixby", &[]); + let refs = find_references("Bixby walked into the room.", &index); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].identifier, "Bixby"); + assert_eq!(refs[0].item_id, "bixby"); + assert_eq!(&"Bixby walked into the room."[refs[0].start..refs[0].end], "Bixby"); + } + + #[test] + fn a_bare_mention_respects_word_boundaries() { + let index = index_with("bixby", "Bixby", &[]); + let refs = find_references("Bixbys and Bixbyish things aren't Bixby.", &index); + assert_eq!(refs.len(), 1, "only the standalone \"Bixby\" should match"); + assert_eq!(refs[0].start, 34); + } + + #[test] + fn a_bare_mention_is_case_sensitive_to_dodge_common_words() { + // A character named "Hope" must not light up every ordinary use of + // the word "hope" — only the capitalized, name-spelled uses. + let index = index_with("hope", "Hope", &[]); + let refs = find_references("She had no hope left, until Hope arrived.", &index); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].start, 28); + } + + #[test] + fn the_longest_bare_mention_wins_an_overlap() { + let index = index_with("bixby_reynolds", "Bixby Reynolds", &["Bixby"]); + let refs = find_references("Bixby Reynolds frowned.", &index); + assert_eq!(refs.len(), 1, "the full name should claim the span, not just \"Bixby\""); + assert_eq!(refs[0].identifier, "Bixby Reynolds"); + } + + #[test] + fn a_bare_mention_does_not_duplicate_an_explicit_link() { + let index = index_with("bixby", "Bixby", &[]); + let refs = find_references("Meet [[Bixby]] properly.", &index); + assert_eq!(refs.len(), 1, "the explicit [[...]] span should win, not add a second match"); + } + + #[test] + fn reference_offsets_are_absolute_into_the_whole_buffer() { + // A regression check for the bug where offsets were relative to each + // line, so anything past line 1 highlighted (and jumped to) the wrong + // place in the editor. + let index = index_with("bixby", "Bixby", &[]); + let text = "First line.\nSecond line has Bixby in it."; + let refs = find_references(text, &index); + assert_eq!(refs.len(), 1); + assert_eq!(&text[refs[0].start..refs[0].end], "Bixby"); + assert_eq!(refs[0].line_number, 2); + } + #[test] fn test_parse_aliases_yaml_list() { let aliases = parse_aliases(r#"["Alias 1", "Alias 2", "Alias 3"]"#);