Skip to main content

lex_analysis/
references.rs

1use crate::inline::extract_references;
2use crate::reference_targets::{
3    targets_from_annotation, targets_from_definition, targets_from_reference_type,
4    targets_from_session, ReferenceTarget,
5};
6use crate::utils::{
7    find_annotation_at_position, find_definition_at_position, find_definitions_by_subject,
8    find_session_at_position, find_sessions_by_identifier, for_each_text_content,
9    reference_at_position,
10};
11use lex_core::lex::ast::traits::AstNode;
12use lex_core::lex::ast::{Document, Position, Range};
13
14pub fn find_references(
15    document: &Document,
16    position: Position,
17    include_declaration: bool,
18) -> Vec<Range> {
19    let targets = determine_targets(document, position);
20    if targets.is_empty() {
21        return Vec::new();
22    }
23
24    let mut ranges = Vec::new();
25    if include_declaration {
26        ranges.extend(declaration_ranges(document, &targets));
27    }
28    ranges.extend(reference_occurrences(document, &targets));
29    dedup_ranges(&mut ranges);
30    ranges
31}
32
33fn determine_targets(document: &Document, position: Position) -> Vec<ReferenceTarget> {
34    if let Some(reference) = reference_at_position(document, position) {
35        let targets = targets_from_reference_type(&reference.reference_type);
36        if !targets.is_empty() {
37            return targets;
38        }
39    }
40
41    if let Some(annotation) = find_annotation_at_position(document, position) {
42        let targets = targets_from_annotation(annotation);
43        if !targets.is_empty() {
44            return targets;
45        }
46    }
47
48    if let Some(definition) = find_definition_at_position(document, position) {
49        let targets = targets_from_definition(definition);
50        if !targets.is_empty() {
51            return targets;
52        }
53    }
54
55    if let Some(session) = find_session_at_position(document, position) {
56        let targets = targets_from_session(session);
57        if !targets.is_empty() {
58            return targets;
59        }
60    }
61
62    Vec::new()
63}
64
65fn declaration_ranges(document: &Document, targets: &[ReferenceTarget]) -> Vec<Range> {
66    let mut ranges = Vec::new();
67    for target in targets {
68        match target {
69            ReferenceTarget::AnnotationLabel(label) => {
70                for annotation in document.find_annotations_by_label(label) {
71                    ranges.push(annotation.header_location().clone());
72                }
73            }
74            ReferenceTarget::CitationKey(key) => {
75                let annotations = document.find_annotations_by_label(key);
76                if annotations.is_empty() {
77                    ranges.extend(definition_ranges(document, key));
78                } else {
79                    for annotation in annotations {
80                        ranges.push(annotation.header_location().clone());
81                    }
82                }
83            }
84            ReferenceTarget::DefinitionSubject(subject) => {
85                ranges.extend(definition_ranges(document, subject));
86            }
87            ReferenceTarget::Session(identifier) => {
88                for session in find_sessions_by_identifier(document, identifier) {
89                    if let Some(header) = session.header_location() {
90                        ranges.push(header.clone());
91                    } else {
92                        ranges.push(session.range().clone());
93                    }
94                }
95            }
96        }
97    }
98    ranges
99}
100
101fn definition_ranges(document: &Document, subject: &str) -> Vec<Range> {
102    find_definitions_by_subject(document, subject)
103        .into_iter()
104        .map(|definition| {
105            definition
106                .header_location()
107                .cloned()
108                .unwrap_or_else(|| definition.range().clone())
109        })
110        .collect()
111}
112
113/// Does `target` resolve to at least one declaration anywhere in
114/// `document`? This is the boolean form of [`declaration_ranges`] — same
115/// resolution rules (case-insensitive throughout, citation keys fall
116/// back from annotation labels to definition subjects) — used by the
117/// opt-in `check --references` pass to decide whether a reference is
118/// dangling. Because it runs over the merged tree it resolves
119/// bidirectionally: the target may live in any included fragment or in
120/// the master, regardless of where the reference sits.
121pub fn target_resolves(document: &Document, target: &ReferenceTarget) -> bool {
122    // Trim each query so resolution matches `reference_matches`'
123    // trimmed, case-insensitive comparison and never false-positives on
124    // an untrimmed target. (The `find_*` helpers normalize internally
125    // via `normalize_key`; trimming here makes the contract explicit and
126    // self-contained rather than relying on the callee.)
127    match target {
128        ReferenceTarget::AnnotationLabel(label) => annotation_label_exists(document, label.trim()),
129        ReferenceTarget::CitationKey(key) => {
130            let trimmed = key.trim();
131            annotation_label_exists(document, trimmed)
132                || !find_definitions_by_subject(document, trimmed).is_empty()
133        }
134        ReferenceTarget::DefinitionSubject(subject) => {
135            !find_definitions_by_subject(document, subject.trim()).is_empty()
136        }
137        ReferenceTarget::Session(identifier) => {
138            !find_sessions_by_identifier(document, identifier.trim()).is_empty()
139        }
140    }
141}
142
143/// Case-insensitive existence check for an annotation label anywhere in
144/// the document (document-level annotations plus every annotation nested
145/// in the root session). `find_annotations_by_label` matches exactly;
146/// reference resolution is case-insensitive (see [`reference_matches`]),
147/// so this scans rather than delegating.
148fn annotation_label_exists(document: &Document, label: &str) -> bool {
149    let needle = label.trim();
150    document
151        .annotations()
152        .iter()
153        .chain(document.root.iter_annotations_recursive())
154        .any(|ann| ann.data.label.value.trim().eq_ignore_ascii_case(needle))
155}
156
157pub fn reference_occurrences(document: &Document, targets: &[ReferenceTarget]) -> Vec<Range> {
158    let mut matches = Vec::new();
159    for_each_text_content(document, &mut |text| {
160        for reference in extract_references(text) {
161            if targets
162                .iter()
163                .any(|target| reference_matches(&reference.reference_type, target))
164            {
165                matches.push(reference.range);
166            }
167        }
168    });
169    matches
170}
171
172fn reference_matches(
173    reference: &lex_core::lex::inlines::ReferenceType,
174    target: &ReferenceTarget,
175) -> bool {
176    use lex_core::lex::inlines::ReferenceType;
177    match (reference, target) {
178        (
179            ReferenceType::AnnotationReference { label },
180            ReferenceTarget::AnnotationLabel(expected),
181        ) => label.eq_ignore_ascii_case(expected),
182        (ReferenceType::FootnoteNumber { number }, ReferenceTarget::AnnotationLabel(expected)) => {
183            expected == &number.to_string()
184        }
185        (ReferenceType::Citation(data), ReferenceTarget::CitationKey(key)) => data
186            .keys
187            .iter()
188            .any(|candidate| candidate.eq_ignore_ascii_case(key)),
189        (ReferenceType::Citation(data), ReferenceTarget::AnnotationLabel(label)) => data
190            .keys
191            .iter()
192            .any(|candidate| candidate.eq_ignore_ascii_case(label)),
193        (ReferenceType::General { target: value }, ReferenceTarget::DefinitionSubject(subject)) => {
194            normalize(value) == normalize(subject)
195        }
196        (
197            ReferenceType::ToCome {
198                identifier: Some(value),
199            },
200            ReferenceTarget::DefinitionSubject(subject),
201        ) => normalize(value) == normalize(subject),
202        (ReferenceType::Session { target }, ReferenceTarget::Session(identifier)) => {
203            target.eq_ignore_ascii_case(identifier)
204        }
205        _ => false,
206    }
207}
208
209fn normalize(text: &str) -> String {
210    text.trim().to_ascii_lowercase()
211}
212
213fn dedup_ranges(ranges: &mut Vec<Range>) {
214    ranges.sort_by_key(|range| (range.span.start, range.span.end));
215    ranges.dedup_by(|a, b| a.span == b.span && a.start == b.start && a.end == b.end);
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use lex_core::lex::parsing;
222
223    fn fixture() -> (Document, String) {
224        let source = r#":: test.note ::
225    Something.
226
227Cache:
228    Definition body.
229
2301. Intro
231
232    First reference [Cache].
233    Second reference [Cache] and annotation [::note].
234"#;
235        let document = parsing::parse_document(source).expect("fixture parses");
236        (document, source.to_string())
237    }
238
239    fn position_of(source: &str, needle: &str) -> Position {
240        let offset = source
241            .find(needle)
242            .unwrap_or_else(|| panic!("needle not found: {needle}"));
243        let mut line = 0;
244        let mut col = 0;
245        for ch in source[..offset].chars() {
246            if ch == '\n' {
247                line += 1;
248                col = 0;
249            } else {
250                col += ch.len_utf8();
251            }
252        }
253        Position::new(line, col)
254    }
255
256    #[test]
257    fn finds_references_from_usage() {
258        let (document, source) = fixture();
259        let position = position_of(&source, "Cache]");
260        let ranges = find_references(&document, position, false);
261        assert_eq!(ranges.len(), 2);
262    }
263
264    #[test]
265    fn finds_references_from_definition() {
266        let (document, source) = fixture();
267        let position = position_of(&source, "Cache:");
268        let ranges = find_references(&document, position, false);
269        assert_eq!(ranges.len(), 2);
270    }
271
272    #[test]
273    fn includes_declaration_when_requested() {
274        let (document, source) = fixture();
275        let position = position_of(&source, "Cache:");
276        let ranges = find_references(&document, position, true);
277        assert!(ranges.len() >= 3);
278        let definition_header = document
279            .root
280            .children
281            .iter()
282            .find_map(|item| match item {
283                lex_core::lex::ast::ContentItem::Definition(def) => def
284                    .header_location()
285                    .cloned()
286                    .or_else(|| Some(def.range().clone())),
287                _ => None,
288            })
289            .expect("definition header available");
290        assert!(ranges.contains(&definition_header));
291    }
292
293    #[test]
294    fn finds_annotation_references() {
295        let (document, source) = fixture();
296        let position = position_of(&source, "::note]");
297        let ranges = find_references(&document, position, false);
298        assert_eq!(ranges.len(), 1);
299        assert!(ranges[0].contains(position));
300    }
301}