1use kb::ast::{Block, Document, ListItem, TableCell};
26use serde_json::Value;
27
28use crate::google::drive::Anchor;
29use crate::project::{DOCUMENT_PARENT, PositionMap, heading_id, inline_text};
30
31#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Location {
38 Section(String),
40 Document,
42}
43
44pub struct Resolver<'a> {
51 positions: &'a PositionMap,
52 sections: Vec<(String, String)>,
55}
56
57impl<'a> Resolver<'a> {
58 #[must_use]
60 pub fn new(positions: &'a PositionMap, document: &Document) -> Self {
61 let mut sections: Vec<(String, String)> = Vec::new();
62 collect_sections(&document.blocks, DOCUMENT_PARENT, &mut sections);
63 Self {
64 positions,
65 sections,
66 }
67 }
68
69 #[must_use]
74 pub fn resolve(&self, anchor: Option<&Anchor>, quoted_text: Option<&str>) -> Location {
75 if let Some(start) = anchor.and_then(anchor_start)
76 && let Some(section) = self.section_for_index(start)
77 {
78 return Location::Section(section);
79 }
80 if let Some(quote) = quoted_text
81 && let Some(section) = self.section_for_quote(quote)
82 {
83 return Location::Section(section);
84 }
85 Location::Document
86 }
87
88 fn section_for_index(&self, start: u32) -> Option<String> {
91 self.positions
92 .iter()
93 .filter(|(_, pos)| pos.index <= start)
94 .max_by_key(|(_, pos)| pos.index)
95 .map(|(id, _)| containing_section(id))
96 .filter(|section| *section != DOCUMENT_PARENT)
97 .map(str::to_owned)
98 }
99
100 fn section_for_quote(&self, quote: &str) -> Option<String> {
103 let needle = normalize_ws(quote);
104 if needle.is_empty() {
105 return None;
106 }
107 self.sections
108 .iter()
109 .find(|(_, text)| text.contains(&needle))
110 .map(|(id, _)| id.clone())
111 }
112}
113
114fn containing_section(id: &str) -> &str {
120 match id.split_once('/') {
121 Some((section, _)) => section,
122 None => id,
123 }
124}
125
126fn collect_sections(blocks: &[Block], parent: &str, out: &mut Vec<(String, String)>) {
132 for block in blocks {
133 if let Block::Heading {
134 title, children, ..
135 } = block
136 {
137 let id = heading_id(title, children);
138 append_text(out, &id, title.as_str());
139 collect_sections(children, &id, out);
140 } else if parent != DOCUMENT_PARENT {
141 let text = block_text(block);
142 if !text.is_empty() {
143 append_text(out, parent, &text);
144 }
145 }
146 }
147}
148
149fn block_text(block: &Block) -> String {
151 match block {
152 Block::Paragraph { inlines } => normalize_ws(&inline_text(inlines)),
153 Block::SrcBlock { content, .. } | Block::ExampleBlock { content } => normalize_ws(content),
154 Block::QuoteBlock { children } => join_block_text(children),
155 Block::List { items, .. } => join_item_text(items),
156 Block::Table { rows } => join_cell_text(rows),
157 Block::Heading { .. }
158 | Block::HorizontalRule
159 | Block::PropertyDrawer { .. }
160 | Block::LogbookDrawer { .. }
161 | Block::Planning { .. }
162 | Block::Comment { .. }
163 | Block::Keyword { .. }
164 | Block::BlankLine => String::new(),
165 }
166}
167
168fn join_block_text(blocks: &[Block]) -> String {
169 join_nonempty(blocks.iter().map(block_text))
170}
171
172fn join_item_text(items: &[ListItem]) -> String {
173 join_nonempty(items.iter().map(|item| join_block_text(&item.content)))
174}
175
176fn join_cell_text(rows: &[Vec<TableCell>]) -> String {
177 join_nonempty(
178 rows.iter()
179 .flatten()
180 .map(|cell| normalize_ws(&inline_text(&cell.inlines))),
181 )
182}
183
184fn join_nonempty<I: Iterator<Item = String>>(parts: I) -> String {
186 let kept: Vec<String> = parts.filter(|part| !part.is_empty()).collect();
187 kept.join(" ")
188}
189
190fn append_text(out: &mut Vec<(String, String)>, id: &str, text: &str) {
193 let normalized = normalize_ws(text);
194 if normalized.is_empty() {
195 return;
196 }
197 if let Some(entry) = out.iter_mut().find(|(key, _)| key == id) {
198 entry.1.push(' ');
199 entry.1.push_str(&normalized);
200 } else {
201 out.push((id.to_owned(), normalized));
202 }
203}
204
205fn normalize_ws(text: &str) -> String {
208 text.split_whitespace().collect::<Vec<_>>().join(" ")
209}
210
211fn anchor_start(anchor: &Anchor) -> Option<u32> {
220 let trimmed = anchor.0.trim();
221 if trimmed.is_empty() {
222 return None;
223 }
224 let value: Value = serde_json::from_str(trimmed).ok()?;
225 u32::try_from(find_start(&value)?).ok()
226}
227
228fn find_start(value: &Value) -> Option<i64> {
234 match value {
235 Value::Object(map) => {
236 let start = int_for_keys(map, &["startIndex", "start", "s"]);
237 let end = int_for_keys(map, &["endIndex", "end", "e"]);
238 if let (Some(start), Some(_)) = (start, end) {
239 return Some(start);
240 }
241 map.values().find_map(find_start)
242 }
243 Value::Array(items) => items.iter().find_map(find_start),
244 _ => None,
245 }
246}
247
248fn int_for_keys(map: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<i64> {
250 keys.iter()
251 .find_map(|key| map.get(*key).and_then(Value::as_i64))
252}
253
254#[cfg(test)]
255mod tests {
256 use super::{Location, Resolver};
257 use crate::google::drive::Anchor;
258 use crate::project::{ElementKind, Position, PositionMap};
259 use kb::ast::{Block, Document, Inline, Title};
260
261 fn heading(title: &str, custom_id: &str, children: Vec<Block>) -> Block {
262 let mut all = vec![Block::PropertyDrawer {
263 entries: vec![("CUSTOM_ID".to_owned(), format!(" {custom_id}"))],
264 }];
265 all.extend(children);
266 Block::Heading {
267 level: 1,
268 title: Title(title.to_owned()),
269 tags: vec![],
270 children: all,
271 }
272 }
273
274 fn paragraph(text: &str) -> Block {
275 Block::Paragraph {
276 inlines: vec![Inline::Plain(text.to_owned())],
277 }
278 }
279
280 fn sample_doc() -> Document {
282 Document {
283 blocks: vec![
284 heading(
285 "Introduction",
286 "sec-intro",
287 vec![paragraph("The quick brown fox jumps over the lazy dog.")],
288 ),
289 heading(
290 "Body",
291 "sec-body",
292 vec![paragraph("A wholly unrelated second paragraph.")],
293 ),
294 ],
295 }
296 }
297
298 fn sample_positions() -> PositionMap {
301 let mut map = PositionMap::new();
302 let at = |index, kind| Position { index, kind };
303 map.insert("sec-intro".to_owned(), at(1, ElementKind::Heading));
304 map.insert(
305 "sec-intro/paragraph-1".to_owned(),
306 at(14, ElementKind::Paragraph),
307 );
308 map.insert("sec-body".to_owned(), at(60, ElementKind::Heading));
309 map.insert(
310 "sec-body/paragraph-1".to_owned(),
311 at(66, ElementKind::Paragraph),
312 );
313 map
314 }
315
316 #[test]
317 fn anchor_index_maps_to_containing_section() {
318 let positions = sample_positions();
319 let document = sample_doc();
320 let resolver = Resolver::new(&positions, &document);
321
322 let anchor = Anchor(r#"{"startIndex":20,"endIndex":25}"#.to_owned());
324 assert_eq!(
325 resolver.resolve(Some(&anchor), None),
326 Location::Section("sec-intro".to_owned())
327 );
328 }
329
330 #[test]
331 fn anchor_index_in_later_section_resolves_there() {
332 let positions = sample_positions();
333 let document = sample_doc();
334 let resolver = Resolver::new(&positions, &document);
335
336 let anchor = Anchor(r#"{"a":[{"docs":{"startIndex":70,"endIndex":75}}]}"#.to_owned());
337 assert_eq!(
338 resolver.resolve(Some(&anchor), None),
339 Location::Section("sec-body".to_owned())
340 );
341 }
342
343 #[test]
344 fn garbage_anchor_falls_back_to_quoted_text() {
345 let positions = sample_positions();
347 let document = sample_doc();
348 let resolver = Resolver::new(&positions, &document);
349
350 let anchor = Anchor("kix.totally-opaque-undecodable-blob".to_owned());
351 let location = resolver.resolve(Some(&anchor), Some("quick brown fox"));
352 assert_eq!(location, Location::Section("sec-intro".to_owned()));
353 }
354
355 #[test]
356 fn valid_json_anchor_without_indices_falls_back() {
357 let positions = sample_positions();
358 let document = sample_doc();
359 let resolver = Resolver::new(&positions, &document);
360
361 let anchor = Anchor(r#"{"type":"workspace.comment.anchor.v1","client":"docs"}"#.to_owned());
363 let location = resolver.resolve(Some(&anchor), Some("unrelated second paragraph"));
364 assert_eq!(location, Location::Section("sec-body".to_owned()));
365 }
366
367 #[test]
368 fn quote_matches_across_rewrapped_whitespace() {
369 let positions = sample_positions();
370 let document = sample_doc();
371 let resolver = Resolver::new(&positions, &document);
372
373 let location = resolver.resolve(None, Some("quick brown\n fox jumps"));
375 assert_eq!(location, Location::Section("sec-intro".to_owned()));
376 }
377
378 #[test]
379 fn no_anchor_and_no_matching_quote_is_document_level() {
380 let positions = sample_positions();
381 let document = sample_doc();
382 let resolver = Resolver::new(&positions, &document);
383
384 assert_eq!(
385 resolver.resolve(None, Some("text that appears in no section")),
386 Location::Document
387 );
388 assert_eq!(resolver.resolve(None, None), Location::Document);
389 }
390
391 #[test]
392 fn empty_anchor_is_treated_as_absent() {
393 let positions = sample_positions();
394 let document = sample_doc();
395 let resolver = Resolver::new(&positions, &document);
396
397 let anchor = Anchor(" ".to_owned());
398 assert_eq!(
399 resolver.resolve(Some(&anchor), Some("lazy dog")),
400 Location::Section("sec-intro".to_owned())
401 );
402 }
403
404 #[test]
405 fn index_below_first_position_falls_back_to_quote() {
406 let mut positions = PositionMap::new();
407 positions.insert(
408 "sec-intro".to_owned(),
409 Position {
410 index: 100,
411 kind: ElementKind::Heading,
412 },
413 );
414 let document = sample_doc();
415 let resolver = Resolver::new(&positions, &document);
416
417 let anchor = Anchor(r#"{"startIndex":5,"endIndex":9}"#.to_owned());
419 assert_eq!(
420 resolver.resolve(Some(&anchor), Some("lazy dog")),
421 Location::Section("sec-intro".to_owned())
422 );
423 }
424
425 #[test]
426 fn negative_start_index_is_rejected() {
427 let positions = sample_positions();
428 let document = sample_doc();
429 let resolver = Resolver::new(&positions, &document);
430
431 let anchor = Anchor(r#"{"startIndex":-3,"endIndex":-1}"#.to_owned());
432 assert_eq!(resolver.resolve(Some(&anchor), None), Location::Document);
434 }
435}