Auto-link Story Bible mentions in the draft, with a hover card to the entry

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 11:01:54 -05:00
parent ef8032b300
commit ab9aa2afce
6 changed files with 436 additions and 84 deletions
+106 -1
View File
@@ -47,7 +47,7 @@ impl App {
let bible_ranges: Vec<(usize, usize)> = self let bible_ranges: Vec<(usize, usize)> = self
.current_references .current_references
.iter() .iter()
.map(|r| (r.column, r.column + r.length)) .map(|r| (r.start, r.end))
.collect(); .collect();
let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| { let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| {
@@ -335,6 +335,48 @@ impl App {
} else { } else {
self.autocomplete = None; 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. // Header-field autocomplete popup, floating just below the caret.
@@ -358,6 +400,69 @@ impl App {
if let Some(field) = clicked_field { if let Some(field) = clicked_field {
self.apply_field_completion(ui.ctx(), &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<String> = 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;
}
} }
} }
+16
View File
@@ -383,6 +383,18 @@ pub struct App {
selected_bible_item: Option<String>, selected_bible_item: Option<String>,
/// References found in the current draft file. /// References found in the current draft file.
current_references: Vec<crate::story_bible::StoryBibleReference>, current_references: Vec<crate::story_bible::StoryBibleReference>,
/// 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<String>,
/// 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<Instant>,
/// 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<egui::Rect>,
/// Whether the Story Bible breakout editor window is open. /// Whether the Story Bible breakout editor window is open.
show_story_bible_editor: bool, show_story_bible_editor: bool,
/// The content being edited in the Story Bible editor. /// The content being edited in the Story Bible editor.
@@ -502,6 +514,10 @@ impl App {
show_story_bible: false, show_story_bible: false,
selected_bible_item: None, selected_bible_item: None,
current_references: Vec::new(), 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, show_story_bible_editor: false,
story_bible_editor_content: String::new(), story_bible_editor_content: String::new(),
story_bible_editor_path: None, story_bible_editor_path: None,
+3
View File
@@ -241,6 +241,7 @@ impl App {
// Scroll area for tree // Scroll area for tree
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.id_salt("story_bible_editor_tree")
.auto_shrink(false) .auto_shrink(false)
.show(ui, |ui| { .show(ui, |ui| {
if tree.is_empty() { if tree.is_empty() {
@@ -271,9 +272,11 @@ impl App {
// Editor area // Editor area
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.id_salt("story_bible_editor_content")
.auto_shrink(false) .auto_shrink(false)
.show(ui, |ui| { .show(ui, |ui| {
let text_edit = egui::TextEdit::multiline(&mut self.story_bible_editor_content) let text_edit = egui::TextEdit::multiline(&mut self.story_bible_editor_content)
.id_salt("story_bible_editor_textedit")
.code_editor() .code_editor()
.desired_width(f32::INFINITY) .desired_width(f32::INFINITY)
.desired_rows(20); .desired_rows(20);
+2 -5
View File
@@ -95,10 +95,7 @@ impl App {
// Find references to this item // Find references to this item
let item_refs: Vec<_> = current_refs let item_refs: Vec<_> = current_refs
.iter() .iter()
.filter(|r| { .filter(|r| r.item_id == item.id)
r.identifier.eq_ignore_ascii_case(&item.id)
|| r.identifier.eq_ignore_ascii_case(&item.name)
})
.collect(); .collect();
if !item_refs.is_empty() { if !item_refs.is_empty() {
@@ -137,6 +134,6 @@ impl App {
/// Jump to a Story Bible reference in the editor. /// Jump to a Story Bible reference in the editor.
pub(super) fn jump_to_reference(&mut self, ref_info: &crate::story_bible::StoryBibleReference) { 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));
} }
} }
+16 -15
View File
@@ -341,37 +341,38 @@ impl App {
} }
/// Build the Story Bible index from the Story Bible directory. /// 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) { pub(super) fn build_story_bible_index(&mut self) {
use std::path::Path; use std::path::Path;
let ws = self.config.workspace.clone(); if let Some(dir_path) = self.find_story_bible_directory() {
// 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); self.story_bible_index = crate::story_bible::StoryBibleIndex::build(&dir_path);
return; return;
} }
} self.story_bible_index = crate::story_bible::StoryBibleIndex::default();
// If no specific Story Bible directory found, try to find all character/location/lore dirs // 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"]; let possible_dirs = ["03-Characters", "04-Locations", "05-Lore", "Characters", "Locations", "Lore"];
for dir_name in possible_dirs.iter() { for dir_name in possible_dirs.iter() {
let dir_path = Path::new(&ws).join(dir_name); let dir_path = Path::new(&ws).join(dir_name);
if dir_path.exists() && dir_path.is_dir() { if dir_path.exists() && dir_path.is_dir() {
let index = crate::story_bible::StoryBibleIndex::build(&dir_path); let index = crate::story_bible::StoryBibleIndex::build(&dir_path);
// Merge with existing index
for (id, item) in index.items_by_id { for (id, item) in index.items_by_id {
self.story_bible_index.items_by_id.insert(id, item.clone()); self.story_bible_index.items_by_id.insert(id, item.clone());
} }
for (name, item) in index.items_by_name { for (name, item) in index.items_by_name {
self.story_bible_index.items_by_name.insert(name, item); 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);
}
} }
} }
} }
+285 -55
View File
@@ -14,11 +14,13 @@ pub struct StoryBibleReference {
pub file_path: String, pub file_path: String,
/// The line number where the reference appears (1-indexed). /// The line number where the reference appears (1-indexed).
pub line_number: usize, pub line_number: usize,
/// The character position in the line. /// Byte offset of the reference's start in the whole buffer.
pub column: usize, pub start: usize,
/// The length of the reference text. /// Byte offset just past the reference's end in the whole buffer.
pub length: usize, pub end: usize,
/// The matched identifier. /// 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, pub identifier: String,
} }
@@ -37,6 +39,9 @@ pub struct StoryBibleItem {
pub aliases: Vec<String>, pub aliases: Vec<String>,
/// Line in the file where the name/ID is defined. /// Line in the file where the name/ID is defined.
pub definition_line: usize, 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<String>,
} }
/// Index of all Story Bible items for quick lookup. /// Index of all Story Bible items for quick lookup.
@@ -176,6 +181,7 @@ fn extract_story_bible_item(relative_path: &str, content: &str) -> Option<StoryB
item_type, item_type,
aliases: Vec::new(), aliases: Vec::new(),
definition_line: 0, definition_line: 0,
summary: None,
}) })
} }
@@ -198,6 +204,8 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
let mut id: Option<String> = None; let mut id: Option<String> = None;
let mut item_type: Option<String> = None; let mut item_type: Option<String> = None;
let mut aliases: Vec<String> = Vec::new(); let mut aliases: Vec<String> = Vec::new();
let mut summary: Option<String> = None;
let mut description: Option<String> = None;
for line in &lines[start + 1..end] { for line in &lines[start + 1..end] {
let trimmed = line.trim(); let trimmed = line.trim();
@@ -211,8 +219,20 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
// Parse aliases as a YAML list: aliases: ["Alias 1", "Alias 2"] or aliases: Alias1, Alias2 // Parse aliases as a YAML list: aliases: ["Alias 1", "Alias 2"] or aliases: Alias1, Alias2
let value = trimmed["aliases:".len()..].trim(); let value = trimmed["aliases:".len()..].trim();
aliases = parse_aliases(value); aliases = parse_aliases(value);
} else if trimmed.starts_with("summary:") {
let value = trimmed["summary:".len()..].trim().trim_matches('"').trim();
if !value.is_empty() {
summary = Some(value.to_string());
}
} else if trimmed.starts_with("description:") {
let value = trimmed["description:".len()..].trim().trim_matches('"').trim();
if !value.is_empty() {
description = Some(value.to_string());
} }
} }
}
// `summary:` wins if a sheet somehow has both.
let summary = summary.or(description);
// Use name or filename as fallback // Use name or filename as fallback
let name = name.unwrap_or_else(|| { let name = name.unwrap_or_else(|| {
@@ -255,6 +275,7 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
item_type, item_type,
aliases, aliases,
definition_line: start + 1, definition_line: start + 1,
summary,
}) })
} }
@@ -324,72 +345,141 @@ fn parse_aliases(value: &str) -> Vec<String> {
.collect() .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<StoryBibleReference> { pub fn find_references(text: &str, index: &StoryBibleIndex) -> Vec<StoryBibleReference> {
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() { // 1. Explicit [[...]] and @Name markup. Walked one *char* at a time
let line_num = line_idx + 1; // (manuscript prose is full of accented letters and em dashes, and a
let mut pos = 0; // byte-at-a-time walk can land mid-character and panic on the next slice).
let mut pos = 0usize;
while pos < line.len() { while pos < text.len() {
// Check for wiki-style links: [[...]] if text[pos..].starts_with("[[") {
if line[pos..].starts_with("[[") { // `]]` only counts on the same line — an unterminated `[[` should
let end_pos = line[pos + 2..] // not swallow the rest of the document looking for a closer.
.find("]]") let line_end = text[pos..]
.map(|p| pos + 2 + p + 2); .find('\n')
if let Some(end) = end_pos { .map(|o| pos + o)
let identifier = line[pos + 2..end - 2].trim().to_string(); .unwrap_or(text.len());
// Check if this matches any Story Bible item (by ID, name, or alias) if let Some(rel_end) = text[pos + 2..line_end].find("]]") {
if index.find_by_id(&identifier).is_some() let end = pos + 2 + rel_end + 2;
|| index.find_by_name(&identifier).is_some() let identifier = text[pos + 2..end - 2].trim().to_string();
|| index.find_by_alias(&identifier).is_some() if let Some(item) = resolve(index, &identifier) {
{ accepted.push((pos, end, item.id.clone(), identifier));
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; pos = end;
continue; continue;
} }
} } else if text.as_bytes()[pos] == b'@' && pos + 1 < text.len() {
let rest = &text[pos + 1..];
// Check for mention-style: @Name let word_end = pos
if line[pos..].starts_with('@') && pos + 1 < line.len() { + 1
// Extract word after @ - stop at space or other non-word characters + rest
let word_end = line[pos + 1..]
.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-') .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
.map(|p| pos + 1 + p) .unwrap_or(rest.len());
.unwrap_or(line.len()); let identifier = text[pos + 1..word_end].to_string();
let identifier = line[pos + 1..word_end].trim().to_string(); if !identifier.is_empty() {
if !identifier.is_empty() if let Some(item) = resolve(index, &identifier) {
&& (index.find_by_id(&identifier).is_some() accepted.push((pos, word_end, item.id.clone(), identifier));
|| 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 = word_end;
continue; 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<usize> {
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. /// 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) 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::<String>() + chars.as_str(),
None => "Entry".to_string(),
}
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -417,6 +526,43 @@ Some content here."#;
assert_eq!(item.item_type, "character"); 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] #[test]
fn test_find_references_wiki_links() { fn test_find_references_wiki_links() {
let index = { let index = {
@@ -430,6 +576,7 @@ Some content here."#;
item_type: "character".to_string(), item_type: "character".to_string(),
aliases: Vec::new(), aliases: Vec::new(),
definition_line: 0, definition_line: 0,
summary: None,
}, },
); );
idx.items_by_name.insert( idx.items_by_name.insert(
@@ -441,6 +588,7 @@ Some content here."#;
item_type: "character".to_string(), item_type: "character".to_string(),
aliases: Vec::new(), aliases: Vec::new(),
definition_line: 0, definition_line: 0,
summary: None,
}, },
); );
idx idx
@@ -462,6 +610,7 @@ Some content here."#;
item_type: "character".to_string(), item_type: "character".to_string(),
aliases: Vec::new(), aliases: Vec::new(),
definition_line: 0, definition_line: 0,
summary: None,
}; };
idx.items_by_name.insert("jane".to_string(), item.clone()); idx.items_by_name.insert("jane".to_string(), item.clone());
idx.items_by_id.insert("jane".to_string(), item); idx.items_by_id.insert("jane".to_string(), item);
@@ -483,6 +632,7 @@ Some content here."#;
item_type: "character".to_string(), item_type: "character".to_string(),
aliases: vec!["Johnny".to_string(), "JS".to_string()], aliases: vec!["Johnny".to_string(), "JS".to_string()],
definition_line: 0, definition_line: 0,
summary: None,
}; };
index.items_by_id.insert("john_smith".to_string(), item.clone()); index.items_by_id.insert("john_smith".to_string(), item.clone());
index.items_by_name.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(), item_type: "character".to_string(),
aliases: vec!["Johnny".to_string(), "JS".to_string()], aliases: vec!["Johnny".to_string(), "JS".to_string()],
definition_line: 0, definition_line: 0,
summary: None,
}; };
index.items_by_name.insert("john smith".to_string(), item); index.items_by_name.insert("john smith".to_string(), item);
@@ -527,6 +678,7 @@ Some content here."#;
item_type: "character".to_string(), item_type: "character".to_string(),
aliases: vec!["Johnny".to_string()], aliases: vec!["Johnny".to_string()],
definition_line: 0, definition_line: 0,
summary: None,
}; };
index.items_by_name.insert("john smith".to_string(), item.clone()); index.items_by_name.insert("john smith".to_string(), item.clone());
index.items_by_id.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"); 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] #[test]
fn test_parse_aliases_yaml_list() { fn test_parse_aliases_yaml_list() {
let aliases = parse_aliases(r#"["Alias 1", "Alias 2", "Alias 3"]"#); let aliases = parse_aliases(r#"["Alias 1", "Alias 2", "Alias 3"]"#);