Skip to main content

acorn/io/document/
index.rs

1//! Immutable indexing of positions within loaded documents.
2use super::matching::{aligned_score, unique_max, UniqueMatch};
3use super::SourceDocument;
4use crate::prelude::{String, ToString, Vec};
5use crate::util::{frontmatter_and_body, MimeType};
6use core::fmt;
7use core::ops::{Add, Range};
8use jsonc_parser::ast::Value as JsonValue;
9use jsonc_parser::{parse_to_ast, CollectOptions, ParseOptions};
10use serde_json::Value;
11
12pub(crate) trait DocumentParser {
13    fn try_entries(&self, document: &SourceDocument) -> Option<Vec<DocumentEntry>>;
14    fn entries(&self, document: &SourceDocument) -> Vec<DocumentEntry>;
15    fn line(path: &str, start: usize, line: &str, prefix: &str) -> Option<DocumentEntry>
16    where
17        Self: Sized;
18    fn lists(path: &str, lines: &[(usize, &str)]) -> Vec<DocumentEntry>
19    where
20        Self: Sized;
21    fn scalar(content: &str, path: &str, lines: &[(usize, &str)]) -> Option<DocumentEntry>
22    where
23        Self: Sized;
24    fn sections(content: &str, name: &str, lines: &[(usize, &str)]) -> Vec<DocumentEntry>
25    where
26        Self: Sized;
27}
28/// Result of resolving a [`DocumentQuery`]
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub enum DocumentMatch {
31    /// Exactly one source span matched.
32    Unique(DocumentSpan),
33    /// No source span matched safely.
34    Missing,
35    /// More than one source span matched.
36    Ambiguous,
37}
38/// One component of a structured path within a document
39#[derive(Clone, Debug, Eq, Hash, PartialEq)]
40pub enum DocumentPathSegment {
41    /// Object or mapping key.
42    Key(String),
43    /// Array or sequence index.
44    Index(usize),
45}
46/// Structured path to a value within a document
47#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
48pub struct DocumentPath(Vec<DocumentPathSegment>);
49/// Byte position and corresponding display coordinates within a document
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct DocumentPosition {
52    /// Zero-based UTF-8 byte offset.
53    pub byte: usize,
54    /// One-based physical line number.
55    pub line: usize,
56    /// One-based character column.
57    pub column: usize,
58}
59/// Half-open UTF-8 byte range within a document
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct DocumentSpan(pub Range<usize>);
62/// Display-ready excerpt retaining its original physical line numbering
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct DocumentExcerpt {
65    /// Source content with blank placeholders for omitted physical lines
66    pub content: String,
67    /// Highlight span relative to [`Self::content`]
68    pub span: DocumentSpan,
69}
70/// Format-neutral criteria for finding a value in a document.
71#[derive(Clone, Debug, Default, Eq, PartialEq)]
72pub struct DocumentQuery {
73    paths: Vec<DocumentPath>,
74    value: Option<String>,
75    needle: Option<String>,
76}
77#[derive(Clone, Debug)]
78pub(crate) struct DocumentEntry {
79    path: DocumentPath,
80    value: Option<String>,
81    span: DocumentSpan,
82}
83#[derive(Clone, Debug)]
84struct SemanticEntry {
85    path: DocumentPath,
86    value: Option<String>,
87}
88/// Lexicographic confidence, ordered from hard index agreement through ancestral key evidence.
89#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
90struct Confidence {
91    exact_indices: usize,
92    exact_keys: usize,
93    normalized_keys: usize,
94    matching_ancestry: usize,
95}
96/// Immutable lookup index over a loaded [`SourceDocument`].
97#[derive(Clone, Debug)]
98pub struct DocumentIndex {
99    document: SourceDocument,
100    entries: Vec<DocumentEntry>,
101    line_starts: Vec<usize>,
102    physical: bool,
103    semantic: Vec<SemanticEntry>,
104}
105impl DocumentPath {
106    /// Parse dotted keys and bracketed array indexes into a structured path.
107    pub fn parse(value: &str) -> Self {
108        let (mut segments, key) = value
109            .replace("r#", "")
110            .chars()
111            .fold((Vec::new(), String::new()), |(mut segments, mut key), character| {
112                match character {
113                    | '.' => {
114                        if !key.is_empty() {
115                            segments.push(DocumentPathSegment::Key(core::mem::take(&mut key)));
116                        }
117                    }
118                    | '[' => {
119                        if !key.is_empty() {
120                            segments.push(DocumentPathSegment::Key(core::mem::take(&mut key)));
121                        }
122                        key.push(character);
123                    }
124                    | ']' if key.starts_with('[') => {
125                        let index = key.trim_start_matches('[').parse::<usize>().ok();
126                        if let Some(index) = index {
127                            segments.push(DocumentPathSegment::Index(index));
128                        }
129                        key.clear();
130                    }
131                    | _ => key.push(character),
132                }
133                (segments, key)
134            });
135        if !key.is_empty() {
136            segments.push(DocumentPathSegment::Key(key));
137        }
138        Self(segments)
139    }
140    fn with(&self, segment: DocumentPathSegment) -> Self {
141        Self(self.0.iter().cloned().chain(core::iter::once(segment)).collect())
142    }
143    fn confidence(&self, actual: &Self) -> Option<(Confidence, bool, bool)> {
144        let depth = self.0.len();
145        let confidence = aligned_score(&self.0, &actual.0, |index, expected, actual| match (expected, actual) {
146            | (DocumentPathSegment::Index(expected), DocumentPathSegment::Index(actual)) if expected == actual => {
147                let confidence = Confidence {
148                    exact_indices: 1,
149                    ..Confidence::default()
150                };
151                Some(confidence)
152            }
153            | (DocumentPathSegment::Key(expected), DocumentPathSegment::Key(actual)) if expected == actual => {
154                let confidence = Confidence {
155                    exact_keys: 1,
156                    matching_ancestry: usize::from(index.saturating_add(1) < depth),
157                    ..Confidence::default()
158                };
159                Some(confidence)
160            }
161            | (DocumentPathSegment::Key(expected), DocumentPathSegment::Key(actual)) if field_names_match(expected, actual) => {
162                let confidence = Confidence {
163                    normalized_keys: 1,
164                    matching_ancestry: usize::from(index.saturating_add(1) < depth),
165                    ..Confidence::default()
166                };
167                Some(confidence)
168            }
169            | (DocumentPathSegment::Key(_), DocumentPathSegment::Key(_)) => Some(Confidence::default()),
170            | _ => None,
171        })?;
172        let mechanical = self.0.iter().zip(&actual.0).all(|(expected, actual)| match (expected, actual) {
173            | (DocumentPathSegment::Key(expected), DocumentPathSegment::Key(actual)) => field_names_match(expected, actual),
174            | (DocumentPathSegment::Index(expected), DocumentPathSegment::Index(actual)) => expected == actual,
175            | _ => false,
176        });
177        let anchored = confidence.exact_keys.saturating_add(confidence.normalized_keys) > 0;
178        Some((confidence, mechanical, anchored))
179    }
180    fn semantic_entries(&self, value: &Value) -> Vec<SemanticEntry> {
181        match value {
182            | Value::Object(object) => object
183                .iter()
184                .flat_map(|(key, value)| self.with(DocumentPathSegment::Key(key.clone())).semantic_entries(value))
185                .collect(),
186            | Value::Array(array) => array
187                .iter()
188                .enumerate()
189                .flat_map(|(index, value)| self.with(DocumentPathSegment::Index(index)).semantic_entries(value))
190                .collect(),
191            | value => vec![SemanticEntry {
192                path: self.clone(),
193                value: Some(scalar(value)),
194            }],
195        }
196    }
197    fn collect_entries(&self, value: &JsonValue<'_>) -> Vec<DocumentEntry> {
198        match value {
199            | JsonValue::Object(object) => object
200                .properties
201                .iter()
202                .flat_map(|property| {
203                    self.with(DocumentPathSegment::Key(property.name.as_str().to_string()))
204                        .collect_entries(&property.value)
205                })
206                .collect(),
207            | JsonValue::Array(array) => array
208                .elements
209                .iter()
210                .enumerate()
211                .flat_map(|(index, value)| self.with(DocumentPathSegment::Index(index)).collect_entries(value))
212                .collect(),
213            | JsonValue::StringLit(value) => vec![DocumentEntry {
214                path: self.clone(),
215                value: Some(value.value.to_string()),
216                span: DocumentSpan(value.range.start..value.range.end),
217            }],
218            | JsonValue::NumberLit(value) => vec![DocumentEntry {
219                path: self.clone(),
220                value: Some(value.value.to_string()),
221                span: DocumentSpan(value.range.start..value.range.end),
222            }],
223            | JsonValue::BooleanLit(value) => vec![DocumentEntry {
224                path: self.clone(),
225                value: Some(value.value.to_string()),
226                span: DocumentSpan(value.range.start..value.range.end),
227            }],
228            | JsonValue::NullKeyword(value) => vec![DocumentEntry {
229                path: self.clone(),
230                value: None,
231                span: DocumentSpan(value.range.start..value.range.end),
232            }],
233        }
234    }
235}
236impl DocumentQuery {
237    /// Create an empty document query.
238    pub fn new() -> Self {
239        Self::default()
240    }
241    /// Add a candidate structured path.
242    pub fn with_path(mut self, path: DocumentPath) -> Self {
243        self.paths.push(path);
244        self
245    }
246    /// Add an exact decoded value.
247    pub fn with_value(mut self, value: impl Into<String>) -> Self {
248        self.value = Some(value.into());
249        self
250    }
251    /// Add a substring to highlight within a matched value or document.
252    pub fn with_needle(mut self, needle: impl Into<String>) -> Self {
253        self.needle = Some(needle.into());
254        self
255    }
256    fn matches(&self, value: Option<&str>) -> bool {
257        let value_matches = self.value.as_deref().is_none_or(|expected| value == Some(expected));
258        let needle_matches = self
259            .needle
260            .as_deref()
261            .is_none_or(|needle| value.is_some_and(|value| value.contains(needle)));
262        value_matches && needle_matches
263    }
264}
265impl DocumentIndex {
266    /// Index a loaded document without changing its content.
267    pub fn new(document: SourceDocument) -> Self {
268        Self::with_parsers(document, &[])
269    }
270    pub(crate) fn with_parsers(document: SourceDocument, parsers: &[&dyn DocumentParser]) -> Self {
271        let markdown_entries = parsers.iter().find_map(|parser| parser.try_entries(&document)).unwrap_or_default();
272        let entries = document.json_entries().into_iter().chain(markdown_entries).collect();
273        let line_starts = core::iter::once(0)
274            .chain(document.content.match_indices('\n').map(|(index, _)| index.saturating_add(1)))
275            .collect();
276        let semantic = document.semantic_entries();
277        Self {
278            physical: document.is_physical_text(),
279            document,
280            entries,
281            line_starts,
282            semantic,
283        }
284    }
285    /// Return the indexed document.
286    pub fn document(&self) -> &SourceDocument {
287        &self.document
288    }
289    /// Convert a byte offset to one-based physical line and character column.
290    pub fn position(&self, byte: usize) -> Option<DocumentPosition> {
291        self.document.content.is_char_boundary(byte).then(|| {
292            let line_index = self.line_starts.partition_point(|start| *start <= byte).saturating_sub(1);
293            let line_start = self.line_starts.get(line_index).copied().unwrap_or_default();
294            let column = self.document.content[line_start..byte].chars().count().saturating_add(1);
295            DocumentPosition {
296                byte,
297                line: line_index.saturating_add(1),
298                column,
299            }
300        })
301    }
302    /// Resolve a query to its one-based physical line and character column
303    pub fn locate(&self, query: &DocumentQuery) -> Option<DocumentPosition> {
304        match self.resolve(query) {
305            | DocumentMatch::Unique(span) => self.position(span.0.start),
306            | DocumentMatch::Missing | DocumentMatch::Ambiguous => None,
307        }
308    }
309    /// Create an excerpt whose highlighted span has at most `max_prefix` bytes of leading context.
310    pub fn excerpt(&self, span: &DocumentSpan, max_prefix: usize) -> Option<DocumentExcerpt> {
311        let DocumentSpan(range) = span;
312        let content = &self.document.content;
313        let ordered = range.start <= range.end;
314        let in_bounds = range.end <= content.len();
315        let boundaries = content.is_char_boundary(range.start) && content.is_char_boundary(range.end);
316        let valid = ordered && in_bounds && boundaries;
317        valid.then(|| self.position(range.start)).flatten().and_then(|position| {
318            let line_offset = position.line.saturating_sub(1);
319            self.line_starts.get(line_offset).copied().and_then(|line_start| {
320                let should_truncate = range.start.saturating_sub(line_start) > max_prefix;
321                let (excerpt_start, ellipsis) = if should_truncate {
322                    (
323                        prefix_boundary(&self.document.content, range.start.saturating_sub(max_prefix), range.start),
324                        "...",
325                    )
326                } else {
327                    (line_start, "")
328                };
329                self.document.content.get(excerpt_start..).map(|suffix| {
330                    let content = format!("{}{ellipsis}{suffix}", "\n".repeat(line_offset));
331                    let adjustment = line_offset.saturating_add(ellipsis.len());
332                    let adjusted_start = range.start.saturating_sub(excerpt_start).saturating_add(adjustment);
333                    let adjusted_end = range.end.saturating_sub(excerpt_start).saturating_add(adjustment);
334                    let span = DocumentSpan(adjusted_start..adjusted_end);
335                    DocumentExcerpt { content, span }
336                })
337            })
338        })
339    }
340    /// Resolve a query only when its physical span can be determined safely.
341    pub fn resolve(&self, query: &DocumentQuery) -> DocumentMatch {
342        match (self.physical, query.paths.is_empty()) {
343            | (false, _) => DocumentMatch::Missing,
344            | (true, true) => self.resolve_text(query),
345            | (true, false) => match resolve_candidate(
346                &self.entries,
347                query,
348                |entry| &entry.path,
349                |entry| entry.value.as_deref(),
350                |entry| {
351                    query.needle.as_deref().is_none_or(|needle| {
352                        self.document
353                            .content
354                            .get(entry.span.0.clone())
355                            .is_some_and(|value| value.contains(needle))
356                    })
357                },
358            ) {
359                | UniqueMatch::Unique(entry) => DocumentMatch::Unique(narrow_span(&self.document.content, &entry.span, query.needle.as_deref())),
360                | UniqueMatch::Ambiguous => DocumentMatch::Ambiguous,
361                | UniqueMatch::Missing => {
362                    match resolve_candidate(&self.semantic, query, |entry| &entry.path, |entry| entry.value.as_deref(), |_| true) {
363                        | UniqueMatch::Unique(_) => self.resolve_text(query),
364                        | UniqueMatch::Ambiguous => DocumentMatch::Ambiguous,
365                        | UniqueMatch::Missing => DocumentMatch::Missing,
366                    }
367                }
368            },
369        }
370    }
371    fn resolve_text(&self, query: &DocumentQuery) -> DocumentMatch {
372        let sought = query.needle.as_ref().or(query.value.as_ref());
373        sought.map_or(DocumentMatch::Missing, |sought| {
374            unique_span(
375                self.document
376                    .content
377                    .match_indices(sought)
378                    .map(|(start, value)| DocumentSpan(start..start.saturating_add(value.len())))
379                    .collect(),
380            )
381        })
382    }
383}
384impl Add for Confidence {
385    type Output = Self;
386    fn add(self, other: Self) -> Self {
387        Self {
388            exact_indices: self.exact_indices.saturating_add(other.exact_indices),
389            exact_keys: self.exact_keys.saturating_add(other.exact_keys),
390            normalized_keys: self.normalized_keys.saturating_add(other.normalized_keys),
391            matching_ancestry: self.matching_ancestry.saturating_add(other.matching_ancestry),
392        }
393    }
394}
395impl fmt::Display for DocumentPosition {
396    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
397        write!(formatter, "{}:{}", self.line, self.column)
398    }
399}
400impl DocumentEntry {
401    pub(crate) fn markdown(path: &str, value: String, span: Range<usize>) -> Self {
402        DocumentEntry {
403            path: DocumentPath::parse(path),
404            value: Some(value),
405            span: DocumentSpan(span),
406        }
407    }
408}
409impl SourceDocument {
410    fn frontmatter(&self) -> Option<String> {
411        self.is_markdown().then(|| frontmatter_and_body(&self.content).0).flatten()
412    }
413    fn semantic_entries(&self) -> Vec<SemanticEntry> {
414        let yaml = self.is_yaml().then(|| self.content.clone()).or_else(|| self.frontmatter());
415        yaml.and_then(|content| serde_norway::from_str::<Value>(&content).ok())
416            .map(|value| DocumentPath::default().semantic_entries(&value))
417            .unwrap_or_default()
418    }
419    fn json_entries(&self) -> Vec<DocumentEntry> {
420        let mime = MimeType::from(self.source.as_str());
421        if mime.is_json() || mime.is_jsonc() {
422            {
423                parse_to_ast(&self.content, &CollectOptions::default(), &ParseOptions::default())
424                    .ok()
425                    .and_then(|result| result.value)
426                    .map(|value| DocumentPath::default().collect_entries(&value))
427                    .unwrap_or_default()
428            }
429        } else {
430            Default::default()
431        }
432    }
433    fn is_physical_text(&self) -> bool {
434        let mime = MimeType::from(self.format.as_str());
435        !(mime.is_doc()
436            || mime.is_docx()
437            || mime.is_epub()
438            || mime.is_odp()
439            || mime.is_ods()
440            || mime.is_odt()
441            || mime.is_pdf()
442            || mime.is_ppt()
443            || mime.is_powerpoint()
444            || mime.is_rtf())
445    }
446}
447fn field_names_match(left: &str, right: &str) -> bool {
448    normalized_bytes(left).eq(normalized_bytes(right))
449}
450fn normalized_bytes(value: &str) -> impl Iterator<Item = u8> + '_ {
451    value
452        .bytes()
453        .filter(|byte| !matches!(byte, b'_' | b'-'))
454        .map(|byte| byte.to_ascii_lowercase())
455}
456fn prefix_boundary(content: &str, candidate: usize, span_start: usize) -> usize {
457    let candidate = (0..=candidate.min(content.len()))
458        .rev()
459        .find(|index| content.is_char_boundary(*index))
460        .unwrap_or_default();
461    content
462        .get(candidate..span_start)
463        .and_then(|prefix| {
464            prefix
465                .char_indices()
466                .find(|(_, character)| character.is_whitespace())
467                .map(|(index, character)| candidate.saturating_add(index).saturating_add(character.len_utf8()))
468        })
469        .unwrap_or(candidate)
470}
471fn resolve_candidate<'a, T>(
472    candidates: &'a [T],
473    query: &DocumentQuery,
474    path: impl Copy + Fn(&T) -> &DocumentPath,
475    value: impl Copy + Fn(&T) -> Option<&str>,
476    additional_constraint: impl Copy + Fn(&T) -> bool,
477) -> UniqueMatch<&'a T> {
478    let matches = |candidate: &T| query.matches(value(candidate)) && additional_constraint(candidate);
479    let exact = unique_candidate(query.paths.iter().flat_map(|expected| {
480        candidates
481            .iter()
482            .filter(move |candidate| path(candidate) == expected && matches(candidate))
483    }));
484    match exact {
485        | UniqueMatch::Missing => {
486            let matching_values = candidates.iter().filter(|candidate| matches(candidate)).count();
487            let scored = |mechanical_only: bool| {
488                query.paths.iter().flat_map(move |expected| {
489                    candidates.iter().filter_map(move |candidate| {
490                        matches(candidate)
491                            .then(|| expected.confidence(path(candidate)))
492                            .flatten()
493                            .filter(|(_, mechanical, anchored)| {
494                                let convention_matches = !mechanical_only || *mechanical;
495                                let anchor_matches = *anchored || matching_values == 1;
496                                convention_matches && anchor_matches
497                            })
498                            .map(|(confidence, _, _)| (confidence, candidate))
499                    })
500                })
501            };
502            match unique_max(scored(true)) {
503                | UniqueMatch::Missing => unique_max(scored(false)),
504                | result => result,
505            }
506        }
507        | result => result,
508    }
509}
510fn unique_candidate<T>(mut candidates: impl Iterator<Item = T>) -> UniqueMatch<T> {
511    match (candidates.next(), candidates.next()) {
512        | (None, _) => UniqueMatch::Missing,
513        | (Some(candidate), None) => UniqueMatch::Unique(candidate),
514        | (Some(_), Some(_)) => UniqueMatch::Ambiguous,
515    }
516}
517fn scalar(value: &Value) -> String {
518    match value {
519        | Value::String(value) => value.clone(),
520        | Value::Null => "null".to_string(),
521        | _ => value.to_string(),
522    }
523}
524fn narrow_span(content: &str, span: &DocumentSpan, needle: Option<&str>) -> DocumentSpan {
525    needle
526        .and_then(|needle| {
527            content
528                .get(span.0.clone())
529                .and_then(|value| value.find(needle))
530                .map(|offset| (offset, needle.len()))
531        })
532        .map_or_else(
533            || span.clone(),
534            |(offset, length)| DocumentSpan(span.0.start.saturating_add(offset)..span.0.start.saturating_add(offset).saturating_add(length)),
535        )
536}
537fn unique_span(spans: Vec<DocumentSpan>) -> DocumentMatch {
538    match spans.as_slice() {
539        | [] => DocumentMatch::Missing,
540        | [span] => DocumentMatch::Unique(span.clone()),
541        | _ => DocumentMatch::Ambiguous,
542    }
543}