use kb::ast::{Block, Document, ListItem, TableCell};
use serde_json::Value;
use crate::google::drive::Anchor;
use crate::project::{DOCUMENT_PARENT, PositionMap, heading_id, inline_text};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Location {
Section(String),
Document,
}
pub struct Resolver<'a> {
positions: &'a PositionMap,
sections: Vec<(String, String)>,
}
impl<'a> Resolver<'a> {
#[must_use]
pub fn new(positions: &'a PositionMap, document: &Document) -> Self {
let mut sections: Vec<(String, String)> = Vec::new();
collect_sections(&document.blocks, DOCUMENT_PARENT, &mut sections);
Self {
positions,
sections,
}
}
#[must_use]
pub fn resolve(&self, anchor: Option<&Anchor>, quoted_text: Option<&str>) -> Location {
if let Some(start) = anchor.and_then(anchor_start)
&& let Some(section) = self.section_for_index(start)
{
return Location::Section(section);
}
if let Some(quote) = quoted_text
&& let Some(section) = self.section_for_quote(quote)
{
return Location::Section(section);
}
Location::Document
}
fn section_for_index(&self, start: u32) -> Option<String> {
self.positions
.iter()
.filter(|(_, pos)| pos.index <= start)
.max_by_key(|(_, pos)| pos.index)
.map(|(id, _)| containing_section(id))
.filter(|section| *section != DOCUMENT_PARENT)
.map(str::to_owned)
}
fn section_for_quote(&self, quote: &str) -> Option<String> {
let needle = normalize_ws(quote);
if needle.is_empty() {
return None;
}
self.sections
.iter()
.find(|(_, text)| text.contains(&needle))
.map(|(id, _)| id.clone())
}
}
fn containing_section(id: &str) -> &str {
match id.split_once('/') {
Some((section, _)) => section,
None => id,
}
}
fn collect_sections(blocks: &[Block], parent: &str, out: &mut Vec<(String, String)>) {
for block in blocks {
if let Block::Heading {
title, children, ..
} = block
{
let id = heading_id(title, children);
append_text(out, &id, title.as_str());
collect_sections(children, &id, out);
} else if parent != DOCUMENT_PARENT {
let text = block_text(block);
if !text.is_empty() {
append_text(out, parent, &text);
}
}
}
}
fn block_text(block: &Block) -> String {
match block {
Block::Paragraph { inlines } => normalize_ws(&inline_text(inlines)),
Block::SrcBlock { content, .. } | Block::ExampleBlock { content } => normalize_ws(content),
Block::QuoteBlock { children } => join_block_text(children),
Block::List { items, .. } => join_item_text(items),
Block::Table { rows } => join_cell_text(rows),
Block::Heading { .. }
| Block::HorizontalRule
| Block::PropertyDrawer { .. }
| Block::LogbookDrawer { .. }
| Block::Planning { .. }
| Block::Comment { .. }
| Block::Keyword { .. }
| Block::BlankLine => String::new(),
}
}
fn join_block_text(blocks: &[Block]) -> String {
join_nonempty(blocks.iter().map(block_text))
}
fn join_item_text(items: &[ListItem]) -> String {
join_nonempty(items.iter().map(|item| join_block_text(&item.content)))
}
fn join_cell_text(rows: &[Vec<TableCell>]) -> String {
join_nonempty(
rows.iter()
.flatten()
.map(|cell| normalize_ws(&inline_text(&cell.inlines))),
)
}
fn join_nonempty<I: Iterator<Item = String>>(parts: I) -> String {
let kept: Vec<String> = parts.filter(|part| !part.is_empty()).collect();
kept.join(" ")
}
fn append_text(out: &mut Vec<(String, String)>, id: &str, text: &str) {
let normalized = normalize_ws(text);
if normalized.is_empty() {
return;
}
if let Some(entry) = out.iter_mut().find(|(key, _)| key == id) {
entry.1.push(' ');
entry.1.push_str(&normalized);
} else {
out.push((id.to_owned(), normalized));
}
}
fn normalize_ws(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn anchor_start(anchor: &Anchor) -> Option<u32> {
let trimmed = anchor.0.trim();
if trimmed.is_empty() {
return None;
}
let value: Value = serde_json::from_str(trimmed).ok()?;
u32::try_from(find_start(&value)?).ok()
}
fn find_start(value: &Value) -> Option<i64> {
match value {
Value::Object(map) => {
let start = int_for_keys(map, &["startIndex", "start", "s"]);
let end = int_for_keys(map, &["endIndex", "end", "e"]);
if let (Some(start), Some(_)) = (start, end) {
return Some(start);
}
map.values().find_map(find_start)
}
Value::Array(items) => items.iter().find_map(find_start),
_ => None,
}
}
fn int_for_keys(map: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<i64> {
keys.iter()
.find_map(|key| map.get(*key).and_then(Value::as_i64))
}
#[cfg(test)]
mod tests {
use super::{Location, Resolver};
use crate::google::drive::Anchor;
use crate::project::{ElementKind, Position, PositionMap};
use kb::ast::{Block, Document, Inline, Title};
fn heading(title: &str, custom_id: &str, children: Vec<Block>) -> Block {
let mut all = vec![Block::PropertyDrawer {
entries: vec![("CUSTOM_ID".to_owned(), format!(" {custom_id}"))],
}];
all.extend(children);
Block::Heading {
level: 1,
title: Title(title.to_owned()),
tags: vec![],
children: all,
}
}
fn paragraph(text: &str) -> Block {
Block::Paragraph {
inlines: vec![Inline::Plain(text.to_owned())],
}
}
fn sample_doc() -> Document {
Document {
blocks: vec![
heading(
"Introduction",
"sec-intro",
vec![paragraph("The quick brown fox jumps over the lazy dog.")],
),
heading(
"Body",
"sec-body",
vec![paragraph("A wholly unrelated second paragraph.")],
),
],
}
}
fn sample_positions() -> PositionMap {
let mut map = PositionMap::new();
let at = |index, kind| Position { index, kind };
map.insert("sec-intro".to_owned(), at(1, ElementKind::Heading));
map.insert(
"sec-intro/paragraph-1".to_owned(),
at(14, ElementKind::Paragraph),
);
map.insert("sec-body".to_owned(), at(60, ElementKind::Heading));
map.insert(
"sec-body/paragraph-1".to_owned(),
at(66, ElementKind::Paragraph),
);
map
}
#[test]
fn anchor_index_maps_to_containing_section() {
let positions = sample_positions();
let document = sample_doc();
let resolver = Resolver::new(&positions, &document);
let anchor = Anchor(r#"{"startIndex":20,"endIndex":25}"#.to_owned());
assert_eq!(
resolver.resolve(Some(&anchor), None),
Location::Section("sec-intro".to_owned())
);
}
#[test]
fn anchor_index_in_later_section_resolves_there() {
let positions = sample_positions();
let document = sample_doc();
let resolver = Resolver::new(&positions, &document);
let anchor = Anchor(r#"{"a":[{"docs":{"startIndex":70,"endIndex":75}}]}"#.to_owned());
assert_eq!(
resolver.resolve(Some(&anchor), None),
Location::Section("sec-body".to_owned())
);
}
#[test]
fn garbage_anchor_falls_back_to_quoted_text() {
let positions = sample_positions();
let document = sample_doc();
let resolver = Resolver::new(&positions, &document);
let anchor = Anchor("kix.totally-opaque-undecodable-blob".to_owned());
let location = resolver.resolve(Some(&anchor), Some("quick brown fox"));
assert_eq!(location, Location::Section("sec-intro".to_owned()));
}
#[test]
fn valid_json_anchor_without_indices_falls_back() {
let positions = sample_positions();
let document = sample_doc();
let resolver = Resolver::new(&positions, &document);
let anchor = Anchor(r#"{"type":"workspace.comment.anchor.v1","client":"docs"}"#.to_owned());
let location = resolver.resolve(Some(&anchor), Some("unrelated second paragraph"));
assert_eq!(location, Location::Section("sec-body".to_owned()));
}
#[test]
fn quote_matches_across_rewrapped_whitespace() {
let positions = sample_positions();
let document = sample_doc();
let resolver = Resolver::new(&positions, &document);
let location = resolver.resolve(None, Some("quick brown\n fox jumps"));
assert_eq!(location, Location::Section("sec-intro".to_owned()));
}
#[test]
fn no_anchor_and_no_matching_quote_is_document_level() {
let positions = sample_positions();
let document = sample_doc();
let resolver = Resolver::new(&positions, &document);
assert_eq!(
resolver.resolve(None, Some("text that appears in no section")),
Location::Document
);
assert_eq!(resolver.resolve(None, None), Location::Document);
}
#[test]
fn empty_anchor_is_treated_as_absent() {
let positions = sample_positions();
let document = sample_doc();
let resolver = Resolver::new(&positions, &document);
let anchor = Anchor(" ".to_owned());
assert_eq!(
resolver.resolve(Some(&anchor), Some("lazy dog")),
Location::Section("sec-intro".to_owned())
);
}
#[test]
fn index_below_first_position_falls_back_to_quote() {
let mut positions = PositionMap::new();
positions.insert(
"sec-intro".to_owned(),
Position {
index: 100,
kind: ElementKind::Heading,
},
);
let document = sample_doc();
let resolver = Resolver::new(&positions, &document);
let anchor = Anchor(r#"{"startIndex":5,"endIndex":9}"#.to_owned());
assert_eq!(
resolver.resolve(Some(&anchor), Some("lazy dog")),
Location::Section("sec-intro".to_owned())
);
}
#[test]
fn negative_start_index_is_rejected() {
let positions = sample_positions();
let document = sample_doc();
let resolver = Resolver::new(&positions, &document);
let anchor = Anchor(r#"{"startIndex":-3,"endIndex":-1}"#.to_owned());
assert_eq!(resolver.resolve(Some(&anchor), None), Location::Document);
}
}