lex_analysis/
completion.rs

1//! Context-aware completion for Lex documents.
2//!
3//! Provides intelligent completion suggestions based on cursor position:
4//!
5//! - **Reference context**: Inside `[...]` brackets, offers annotation labels,
6//!   definition subjects, session identifiers, and file paths found in the document.
7//!
8//! - **Verbatim label context**: At a verbatim block's closing label, offers
9//!   standard labels (`doc.image`, `doc.code`, etc.) and common programming languages.
10//!
11//! - **Verbatim src context**: Inside a `src=` parameter, offers file paths
12//!   referenced elsewhere in the document.
13//!
14//! The completion provider is document-scoped: it only suggests items that exist
15//! in the current document. For cross-document completion (e.g., bibliography
16//! entries), the LSP layer would need to aggregate from multiple sources.
17
18use crate::inline::InlineSpanKind;
19use crate::utils::{for_each_annotation, reference_span_at_position, session_identifier};
20use ignore::WalkBuilder;
21use lex_core::lex::ast::links::LinkType;
22use lex_core::lex::ast::{ContentItem, Document, Position, Session};
23use lsp_types::CompletionItemKind;
24use pathdiff::diff_paths;
25use std::collections::BTreeSet;
26use std::path::{Path, PathBuf};
27
28/// A completion suggestion with display metadata.
29///
30/// Maps to LSP `CompletionItem` but remains protocol-agnostic. The LSP layer
31/// converts these to the wire format. Uses [`lsp_types::CompletionItemKind`]
32/// directly for semantic classification (reference, file, module, etc.).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct CompletionCandidate {
35    /// The text shown in the completion menu and inserted by default.
36    pub label: String,
37    /// Optional description shown alongside the label (e.g., "annotation label").
38    pub detail: Option<String>,
39    /// Semantic category for icon display and sorting.
40    pub kind: CompletionItemKind,
41    /// Alternative text to insert if different from label (e.g., quoted paths).
42    pub insert_text: Option<String>,
43}
44
45/// File-system context for completion requests.
46///
47/// Provides the project root and on-disk path to the active document so path
48/// completions can scan the repository and compute proper relative insert text.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct CompletionWorkspace {
51    pub project_root: PathBuf,
52    pub document_path: PathBuf,
53}
54
55impl CompletionCandidate {
56    fn new(label: impl Into<String>, kind: CompletionItemKind) -> Self {
57        Self {
58            label: label.into(),
59            detail: None,
60            kind,
61            insert_text: None,
62        }
63    }
64
65    fn with_detail(mut self, detail: impl Into<String>) -> Self {
66        self.detail = Some(detail.into());
67        self
68    }
69
70    fn with_insert_text(mut self, text: impl Into<String>) -> Self {
71        self.insert_text = Some(text.into());
72        self
73    }
74}
75
76/// Internal classification of completion trigger context.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum CompletionContext {
79    Reference,
80    VerbatimLabel,
81    VerbatimSrc,
82    General,
83}
84
85/// Returns completion candidates appropriate for the cursor position.
86///
87/// Analyzes the position to determine context (reference, verbatim label, etc.)
88/// and returns relevant suggestions. The candidates are deduplicated but not
89/// sorted—the LSP layer may apply additional ordering based on user preferences.
90///
91/// The optional `trigger_char` allows special handling for specific triggers:
92/// - `@`: Returns only file path completions (asset references)
93/// - `[`: Returns reference completions (annotations, definitions, sessions, paths)
94/// - `:`: Returns verbatim label completions
95/// - `=`: Returns path completions for src= parameters
96///
97/// Returns an empty vector if no completions are available.
98pub fn completion_items(
99    document: &Document,
100    position: Position,
101    workspace: Option<&CompletionWorkspace>,
102    trigger_char: Option<&str>,
103) -> Vec<CompletionCandidate> {
104    // Handle explicit trigger characters first
105    if let Some(trigger) = trigger_char {
106        if trigger == "@" {
107            return asset_path_completions(workspace);
108        }
109    }
110
111    match detect_context(document, position) {
112        CompletionContext::VerbatimLabel => verbatim_label_completions(document),
113        CompletionContext::VerbatimSrc => verbatim_path_completions(document, workspace),
114        CompletionContext::Reference => reference_completions(document, workspace),
115        CompletionContext::General => reference_completions(document, workspace),
116    }
117}
118
119/// Returns only file path completions for asset references (@-triggered).
120fn asset_path_completions(workspace: Option<&CompletionWorkspace>) -> Vec<CompletionCandidate> {
121    let Some(workspace) = workspace else {
122        return Vec::new();
123    };
124
125    workspace_path_completion_entries(workspace)
126        .into_iter()
127        .map(|entry| {
128            CompletionCandidate::new(&entry.label, CompletionItemKind::FILE)
129                .with_detail("file")
130                .with_insert_text(entry.insert_text)
131        })
132        .collect()
133}
134
135fn detect_context(document: &Document, position: Position) -> CompletionContext {
136    if is_inside_verbatim_label(document, position) {
137        return CompletionContext::VerbatimLabel;
138    }
139    if is_inside_verbatim_src_parameter(document, position) {
140        return CompletionContext::VerbatimSrc;
141    }
142    if reference_span_at_position(document, position)
143        .map(|span| matches!(span.kind, InlineSpanKind::Reference(_)))
144        .unwrap_or(false)
145    {
146        return CompletionContext::Reference;
147    }
148    CompletionContext::General
149}
150
151fn reference_completions(
152    document: &Document,
153    workspace: Option<&CompletionWorkspace>,
154) -> Vec<CompletionCandidate> {
155    let mut items = Vec::new();
156
157    for label in collect_annotation_labels(document) {
158        items.push(
159            CompletionCandidate::new(label, CompletionItemKind::REFERENCE)
160                .with_detail("annotation label"),
161        );
162    }
163
164    for subject in collect_definition_subjects(document) {
165        items.push(
166            CompletionCandidate::new(subject, CompletionItemKind::TEXT)
167                .with_detail("definition subject"),
168        );
169    }
170
171    for session_id in collect_session_identifiers(document) {
172        items.push(
173            CompletionCandidate::new(session_id, CompletionItemKind::MODULE)
174                .with_detail("session identifier"),
175        );
176    }
177
178    items.extend(path_completion_candidates(
179        document,
180        workspace,
181        "path reference",
182    ));
183
184    items
185}
186
187fn verbatim_label_completions(document: &Document) -> Vec<CompletionCandidate> {
188    let mut labels: BTreeSet<String> = STANDARD_VERBATIM_LABELS
189        .iter()
190        .chain(COMMON_CODE_LANGUAGES.iter())
191        .map(|value| value.to_string())
192        .collect();
193
194    for label in collect_document_verbatim_labels(document) {
195        labels.insert(label);
196    }
197
198    labels
199        .into_iter()
200        .map(|label| {
201            CompletionCandidate::new(label, CompletionItemKind::ENUM_MEMBER)
202                .with_detail("verbatim label")
203        })
204        .collect()
205}
206
207fn verbatim_path_completions(
208    document: &Document,
209    workspace: Option<&CompletionWorkspace>,
210) -> Vec<CompletionCandidate> {
211    path_completion_candidates(document, workspace, "verbatim src")
212}
213
214fn collect_annotation_labels(document: &Document) -> BTreeSet<String> {
215    let mut labels = BTreeSet::new();
216    for_each_annotation(document, &mut |annotation| {
217        labels.insert(annotation.data.label.value.clone());
218    });
219    labels
220}
221
222fn collect_definition_subjects(document: &Document) -> BTreeSet<String> {
223    let mut subjects = BTreeSet::new();
224    collect_definitions_in_session(&document.root, &mut subjects);
225    subjects
226}
227
228fn collect_definitions_in_session(session: &Session, subjects: &mut BTreeSet<String>) {
229    for item in session.iter_items() {
230        collect_definitions_in_item(item, subjects);
231    }
232}
233
234fn collect_definitions_in_item(item: &ContentItem, subjects: &mut BTreeSet<String>) {
235    match item {
236        ContentItem::Definition(definition) => {
237            let subject = definition.subject.as_string().trim();
238            if !subject.is_empty() {
239                subjects.insert(subject.to_string());
240            }
241            for child in definition.children.iter() {
242                collect_definitions_in_item(child, subjects);
243            }
244        }
245        ContentItem::Session(session) => collect_definitions_in_session(session, subjects),
246        ContentItem::List(list) => {
247            for child in list.items.iter() {
248                collect_definitions_in_item(child, subjects);
249            }
250        }
251        ContentItem::ListItem(list_item) => {
252            for child in list_item.children.iter() {
253                collect_definitions_in_item(child, subjects);
254            }
255        }
256        ContentItem::Annotation(annotation) => {
257            for child in annotation.children.iter() {
258                collect_definitions_in_item(child, subjects);
259            }
260        }
261        ContentItem::Paragraph(paragraph) => {
262            for line in &paragraph.lines {
263                collect_definitions_in_item(line, subjects);
264            }
265        }
266        ContentItem::VerbatimBlock(_) | ContentItem::TextLine(_) | ContentItem::VerbatimLine(_) => {
267        }
268        ContentItem::BlankLineGroup(_) => {}
269    }
270}
271
272fn collect_session_identifiers(document: &Document) -> BTreeSet<String> {
273    let mut identifiers = BTreeSet::new();
274    collect_session_ids_recursive(&document.root, &mut identifiers, true);
275    identifiers
276}
277
278fn collect_session_ids_recursive(
279    session: &Session,
280    identifiers: &mut BTreeSet<String>,
281    is_root: bool,
282) {
283    if !is_root {
284        if let Some(id) = session_identifier(session) {
285            identifiers.insert(id);
286        }
287        let title = session.title_text().trim();
288        if !title.is_empty() {
289            identifiers.insert(title.to_string());
290        }
291    }
292
293    for item in session.iter_items() {
294        if let ContentItem::Session(child) = item {
295            collect_session_ids_recursive(child, identifiers, false);
296        }
297    }
298}
299
300fn collect_document_verbatim_labels(document: &Document) -> BTreeSet<String> {
301    let mut labels = BTreeSet::new();
302    for (item, _) in document.root.iter_all_nodes_with_depth() {
303        if let ContentItem::VerbatimBlock(verbatim) = item {
304            labels.insert(verbatim.closing_data.label.value.clone());
305        }
306    }
307    labels
308}
309
310fn path_completion_candidates(
311    document: &Document,
312    workspace: Option<&CompletionWorkspace>,
313    detail: &'static str,
314) -> Vec<CompletionCandidate> {
315    collect_path_completion_entries(document, workspace)
316        .into_iter()
317        .map(|entry| {
318            CompletionCandidate::new(&entry.label, CompletionItemKind::FILE)
319                .with_detail(detail)
320                .with_insert_text(entry.insert_text)
321        })
322        .collect()
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
326struct PathCompletion {
327    label: String,
328    insert_text: String,
329}
330
331fn collect_path_completion_entries(
332    document: &Document,
333    workspace: Option<&CompletionWorkspace>,
334) -> Vec<PathCompletion> {
335    let mut entries = Vec::new();
336    let mut seen_labels = BTreeSet::new();
337
338    if let Some(workspace) = workspace {
339        for entry in workspace_path_completion_entries(workspace) {
340            if seen_labels.insert(entry.label.clone()) {
341                entries.push(entry);
342            }
343        }
344    }
345
346    for path in collect_document_path_targets(document) {
347        if seen_labels.insert(path.clone()) {
348            entries.push(PathCompletion {
349                label: path.clone(),
350                insert_text: path,
351            });
352        }
353    }
354
355    entries
356}
357
358fn collect_document_path_targets(document: &Document) -> BTreeSet<String> {
359    document
360        .find_all_links()
361        .into_iter()
362        .filter(|link| matches!(link.link_type, LinkType::File | LinkType::VerbatimSrc))
363        .map(|link| link.target)
364        .collect()
365}
366
367const MAX_WORKSPACE_PATH_COMPLETIONS: usize = 256;
368
369fn workspace_path_completion_entries(workspace: &CompletionWorkspace) -> Vec<PathCompletion> {
370    if !workspace.project_root.is_dir() {
371        return Vec::new();
372    }
373
374    let document_directory = workspace
375        .document_path
376        .parent()
377        .map(|path| path.to_path_buf())
378        .unwrap_or_else(|| workspace.project_root.clone());
379
380    let mut entries = Vec::new();
381    let mut walker = WalkBuilder::new(&workspace.project_root);
382    walker
383        .git_ignore(true)
384        .git_global(true)
385        .git_exclude(true)
386        .ignore(true)
387        .add_custom_ignore_filename(".gitignore")
388        .hidden(false)
389        .follow_links(false)
390        .standard_filters(true);
391
392    for result in walker.build() {
393        let entry = match result {
394            Ok(entry) => entry,
395            Err(_) => continue,
396        };
397
398        let file_type = match entry.file_type() {
399            Some(file_type) => file_type,
400            None => continue,
401        };
402
403        if !file_type.is_file() {
404            continue;
405        }
406
407        if entry.path() == workspace.document_path {
408            continue;
409        }
410
411        if let Some(candidate) = path_completion_from_file(
412            workspace.project_root.as_path(),
413            document_directory.as_path(),
414            entry.path(),
415        ) {
416            entries.push(candidate);
417            if entries.len() >= MAX_WORKSPACE_PATH_COMPLETIONS {
418                break;
419            }
420        }
421    }
422
423    entries.sort_by(|a, b| a.label.cmp(&b.label));
424    entries
425}
426
427fn path_completion_from_file(
428    project_root: &Path,
429    document_directory: &Path,
430    file_path: &Path,
431) -> Option<PathCompletion> {
432    let label_path = diff_paths(file_path, project_root).unwrap_or_else(|| file_path.to_path_buf());
433    let insert_path =
434        diff_paths(file_path, document_directory).unwrap_or_else(|| file_path.to_path_buf());
435
436    let label = normalize_path(&label_path)?;
437    let insert_text = normalize_path(&insert_path)?;
438
439    if label.is_empty() || insert_text.is_empty() {
440        return None;
441    }
442
443    Some(PathCompletion { label, insert_text })
444}
445
446fn normalize_path(path: &Path) -> Option<String> {
447    path.components().next()?;
448    let mut value = path.to_string_lossy().replace('\\', "/");
449    while value.starts_with("./") {
450        value = value[2..].to_string();
451    }
452    if value == "." {
453        return None;
454    }
455    Some(value)
456}
457
458fn is_inside_verbatim_label(document: &Document, position: Position) -> bool {
459    document.root.iter_all_nodes().any(|item| match item {
460        ContentItem::VerbatimBlock(verbatim) => {
461            verbatim.closing_data.label.location.contains(position)
462        }
463        _ => false,
464    })
465}
466
467fn is_inside_verbatim_src_parameter(document: &Document, position: Position) -> bool {
468    document.root.iter_all_nodes().any(|item| match item {
469        ContentItem::VerbatimBlock(verbatim) => verbatim
470            .closing_data
471            .parameters
472            .iter()
473            .any(|param| param.key == "src" && param.location.contains(position)),
474        _ => false,
475    })
476}
477
478const STANDARD_VERBATIM_LABELS: &[&str] = &[
479    "doc.code",
480    "doc.data",
481    "doc.image",
482    "doc.table",
483    "doc.video",
484    "doc.audio",
485    "doc.note",
486];
487
488const COMMON_CODE_LANGUAGES: &[&str] = &[
489    "bash",
490    "c",
491    "cpp",
492    "css",
493    "go",
494    "html",
495    "java",
496    "javascript",
497    "json",
498    "kotlin",
499    "latex",
500    "lex",
501    "markdown",
502    "python",
503    "ruby",
504    "rust",
505    "scala",
506    "sql",
507    "swift",
508    "toml",
509    "typescript",
510    "yaml",
511];
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use lex_core::lex::ast::SourceLocation;
517    use lex_core::lex::ast::Verbatim;
518    use lex_core::lex::parsing;
519    use std::fs;
520    use tempfile::tempdir;
521
522    const SAMPLE_DOC: &str = r#":: note ::
523    Document level note.
524::
525
526Cache:
527    Definition body.
528
5291. Intro
530
531    See [Cache], [^note], and [./images/chart.png].
532
533Image placeholder:
534
535    diagram placeholder
536:: doc.image src=./images/chart.png title="Usage"
537
538Code sample:
539
540    fn main() {}
541:: rust
542"#;
543
544    fn parse_sample() -> Document {
545        parsing::parse_document(SAMPLE_DOC).expect("fixture parses")
546    }
547
548    fn position_at(offset: usize) -> Position {
549        SourceLocation::new(SAMPLE_DOC).byte_to_position(offset)
550    }
551
552    fn find_verbatim<'a>(document: &'a Document, label: &str) -> &'a Verbatim {
553        for (item, _) in document.root.iter_all_nodes_with_depth() {
554            if let ContentItem::VerbatimBlock(verbatim) = item {
555                if verbatim.closing_data.label.value == label {
556                    return verbatim;
557                }
558            }
559        }
560        panic!("verbatim {label} not found");
561    }
562
563    #[test]
564    fn reference_completions_expose_labels_definitions_sessions_and_paths() {
565        let document = parse_sample();
566        let cursor = SAMPLE_DOC.find("[Cache]").expect("reference present") + 2;
567        let completions = completion_items(&document, position_at(cursor), None, None);
568        let labels: BTreeSet<_> = completions.iter().map(|c| c.label.as_str()).collect();
569        assert!(labels.contains("Cache"));
570        assert!(labels.contains("note"));
571        assert!(labels.contains("1"));
572        assert!(labels.contains("./images/chart.png"));
573    }
574
575    #[test]
576    fn verbatim_label_completions_include_standard_labels() {
577        let document = parse_sample();
578        let verbatim = find_verbatim(&document, "rust");
579        let mut pos = verbatim.closing_data.label.location.start;
580        pos.column += 1; // inside the label text
581        let completions = completion_items(&document, pos, None, None);
582        assert!(completions.iter().any(|c| c.label == "doc.image"));
583        assert!(completions.iter().any(|c| c.label == "rust"));
584    }
585
586    #[test]
587    fn verbatim_src_completion_offers_known_paths() {
588        let document = parse_sample();
589        let verbatim = find_verbatim(&document, "doc.image");
590        let param = verbatim
591            .closing_data
592            .parameters
593            .iter()
594            .find(|p| p.key == "src")
595            .expect("src parameter exists");
596        let mut pos = param.location.start;
597        pos.column += 5; // after `src=`
598        let completions = completion_items(&document, pos, None, None);
599        assert!(completions.iter().any(|c| c.label == "./images/chart.png"));
600    }
601
602    #[test]
603    fn workspace_file_completion_uses_root_label_and_document_relative_insert() {
604        let document = parse_sample();
605        let cursor = SAMPLE_DOC.find("[Cache]").expect("reference present") + 2;
606
607        let temp = tempdir().expect("temp dir");
608        let root = temp.path();
609        fs::create_dir_all(root.join("images")).unwrap();
610        fs::write(root.join("images/chart.png"), "img").unwrap();
611        fs::create_dir_all(root.join("docs")).unwrap();
612        let document_path = root.join("docs/chapter.lex");
613        fs::write(&document_path, SAMPLE_DOC).unwrap();
614
615        let workspace = CompletionWorkspace {
616            project_root: root.to_path_buf(),
617            document_path,
618        };
619
620        let completions = completion_items(&document, position_at(cursor), Some(&workspace), None);
621
622        let candidate = completions
623            .iter()
624            .find(|item| item.label == "images/chart.png")
625            .expect("workspace path present");
626        assert_eq!(
627            candidate.insert_text.as_deref(),
628            Some("../images/chart.png")
629        );
630    }
631
632    #[test]
633    fn workspace_file_completion_respects_gitignore() {
634        let document = parse_sample();
635        let temp = tempdir().expect("temp dir");
636        let root = temp.path();
637        fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
638        fs::create_dir_all(root.join("assets")).unwrap();
639        fs::write(root.join("assets/visible.png"), "data").unwrap();
640        fs::create_dir_all(root.join("ignored")).unwrap();
641        fs::write(root.join("ignored/secret.png"), "nope").unwrap();
642        let document_path = root.join("doc.lex");
643        fs::write(&document_path, SAMPLE_DOC).unwrap();
644
645        let workspace = CompletionWorkspace {
646            project_root: root.to_path_buf(),
647            document_path,
648        };
649
650        let completions = completion_items(&document, position_at(0), Some(&workspace), None);
651
652        assert!(completions
653            .iter()
654            .any(|item| item.label == "assets/visible.png"));
655        assert!(!completions
656            .iter()
657            .any(|item| item.label.contains("ignored/secret.png")));
658    }
659
660    #[test]
661    fn at_trigger_returns_only_file_paths() {
662        let document = parse_sample();
663        let temp = tempdir().expect("temp dir");
664        let root = temp.path();
665        fs::create_dir_all(root.join("images")).unwrap();
666        fs::write(root.join("images/photo.jpg"), "img").unwrap();
667        fs::write(root.join("script.py"), "code").unwrap();
668        let document_path = root.join("doc.lex");
669        fs::write(&document_path, SAMPLE_DOC).unwrap();
670
671        let workspace = CompletionWorkspace {
672            project_root: root.to_path_buf(),
673            document_path,
674        };
675
676        // With @ trigger, should return only file paths (no annotation labels, etc.)
677        let completions = completion_items(&document, position_at(0), Some(&workspace), Some("@"));
678
679        // Should have file paths
680        assert!(completions
681            .iter()
682            .any(|item| item.label == "images/photo.jpg"));
683        assert!(completions.iter().any(|item| item.label == "script.py"));
684
685        // Should NOT have annotation labels or definition subjects
686        assert!(!completions.iter().any(|item| item.label == "note"));
687        assert!(!completions.iter().any(|item| item.label == "Cache"));
688    }
689}