Skip to main content

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    current_line: Option<&str>,
102    workspace: Option<&CompletionWorkspace>,
103    trigger_char: Option<&str>,
104) -> Vec<CompletionCandidate> {
105    // Handle explicit trigger characters first
106    if let Some(trigger) = trigger_char {
107        if trigger == "@" {
108            let mut items = asset_path_completions(workspace);
109            items.extend(macro_completions(document));
110            return items;
111        }
112    }
113
114    if let Some(trigger) = trigger_char {
115        if trigger == "|" {
116            return table_row_completions(document, position);
117        }
118        if trigger == ":" {
119            // Only offer verbatim labels when actually at a verbatim block start or
120            // inside an existing verbatim label. Don't pollute completion for
121            // arbitrary colons (e.g. definition subjects like "Ideas:").
122            if is_at_potential_verbatim_start(document, position, current_line)
123                || is_inside_verbatim_label(document, position)
124            {
125                return verbatim_label_completions(document);
126            }
127            return Vec::new();
128        }
129    }
130
131    match detect_context(document, position, current_line) {
132        CompletionContext::VerbatimLabel => verbatim_label_completions(document),
133        CompletionContext::VerbatimSrc => verbatim_path_completions(document, workspace),
134        CompletionContext::Reference => reference_completions(document, workspace),
135        CompletionContext::General => reference_completions(document, workspace),
136    }
137}
138
139fn macro_completions(_document: &Document) -> Vec<CompletionCandidate> {
140    vec![
141        CompletionCandidate::new("@table", CompletionItemKind::SNIPPET)
142            .with_detail("Insert table snippet")
143            .with_insert_text(":: doc.table ::\n| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1   | Cell 2   |\n::\n"),
144        CompletionCandidate::new("@image", CompletionItemKind::SNIPPET)
145            .with_detail("Insert image snippet")
146            .with_insert_text(":: doc.image src=\"$1\" ::\n"),
147        CompletionCandidate::new("@note", CompletionItemKind::SNIPPET)
148            .with_detail("Insert note reference")
149            .with_insert_text("[^$1]"),
150    ]
151}
152
153fn table_row_completions(_document: &Document, _position: Position) -> Vec<CompletionCandidate> {
154    // Basic implementation: if we are on a line starting with |, suggest a row structure?
155    // For now, just a generic row snippet.
156    // In a real implementation, we would count pipes in the previous line.
157    vec![
158        CompletionCandidate::new("New Row", CompletionItemKind::SNIPPET)
159            .with_detail("Insert table row")
160            .with_insert_text("|  |  |"),
161    ]
162}
163
164/// Returns only file path completions for asset references (@-triggered).
165fn asset_path_completions(workspace: Option<&CompletionWorkspace>) -> Vec<CompletionCandidate> {
166    let Some(workspace) = workspace else {
167        return Vec::new();
168    };
169
170    workspace_path_completion_entries(workspace)
171        .into_iter()
172        .map(|entry| {
173            CompletionCandidate::new(&entry.label, CompletionItemKind::FILE)
174                .with_detail("file")
175                .with_insert_text(entry.insert_text)
176        })
177        .collect()
178}
179
180fn detect_context(
181    document: &Document,
182    position: Position,
183    current_line: Option<&str>,
184) -> CompletionContext {
185    if is_inside_verbatim_label(document, position) {
186        return CompletionContext::VerbatimLabel;
187    }
188    if is_inside_verbatim_src_parameter(document, position) {
189        return CompletionContext::VerbatimSrc;
190    }
191    if is_at_potential_verbatim_start(document, position, current_line) {
192        return CompletionContext::VerbatimLabel;
193    }
194    if reference_span_at_position(document, position)
195        .map(|span| matches!(span.kind, InlineSpanKind::Reference(_)))
196        .unwrap_or(false)
197    {
198        return CompletionContext::Reference;
199    }
200    CompletionContext::General
201}
202
203fn is_at_potential_verbatim_start(
204    _document: &Document,
205    _position: Position,
206    current_line: Option<&str>,
207) -> bool {
208    // If we have the raw text line, check if it starts with "::"
209    if let Some(text) = current_line {
210        let trimmed = text.trim();
211        if trimmed == "::" || trimmed == ":::" {
212            return true;
213        }
214        // Support e.g. ":: "
215        if trimmed.starts_with("::") && trimmed.len() <= 3 {
216            return true;
217        }
218    }
219    // Fallback detection via AST is intentionally removed as AST is unreliable for incomplete blocks
220    false
221}
222
223fn reference_completions(
224    document: &Document,
225    workspace: Option<&CompletionWorkspace>,
226) -> Vec<CompletionCandidate> {
227    let mut items = Vec::new();
228
229    for label in collect_annotation_labels(document) {
230        items.push(
231            CompletionCandidate::new(label, CompletionItemKind::REFERENCE)
232                .with_detail("annotation label"),
233        );
234    }
235
236    for subject in collect_definition_subjects(document) {
237        items.push(
238            CompletionCandidate::new(subject, CompletionItemKind::TEXT)
239                .with_detail("definition subject"),
240        );
241    }
242
243    for session_id in collect_session_identifiers(document) {
244        items.push(
245            CompletionCandidate::new(session_id, CompletionItemKind::MODULE)
246                .with_detail("session identifier"),
247        );
248    }
249
250    items.extend(path_completion_candidates(
251        document,
252        workspace,
253        "path reference",
254    ));
255
256    items
257}
258
259fn verbatim_label_completions(document: &Document) -> Vec<CompletionCandidate> {
260    let mut labels: BTreeSet<String> = STANDARD_VERBATIM_LABELS
261        .iter()
262        .chain(COMMON_CODE_LANGUAGES.iter())
263        .map(|value| value.to_string())
264        .collect();
265
266    for label in collect_document_verbatim_labels(document) {
267        labels.insert(label);
268    }
269
270    labels
271        .into_iter()
272        .map(|label| {
273            CompletionCandidate::new(label, CompletionItemKind::ENUM_MEMBER)
274                .with_detail("verbatim label")
275        })
276        .collect()
277}
278
279fn verbatim_path_completions(
280    document: &Document,
281    workspace: Option<&CompletionWorkspace>,
282) -> Vec<CompletionCandidate> {
283    path_completion_candidates(document, workspace, "verbatim src")
284}
285
286fn collect_annotation_labels(document: &Document) -> BTreeSet<String> {
287    let mut labels = BTreeSet::new();
288    for_each_annotation(document, &mut |annotation| {
289        labels.insert(annotation.data.label.value.clone());
290    });
291    labels
292}
293
294fn collect_definition_subjects(document: &Document) -> BTreeSet<String> {
295    let mut subjects = BTreeSet::new();
296    collect_definitions_in_session(&document.root, &mut subjects);
297    subjects
298}
299
300fn collect_definitions_in_session(session: &Session, subjects: &mut BTreeSet<String>) {
301    for item in session.iter_items() {
302        collect_definitions_in_item(item, subjects);
303    }
304}
305
306fn collect_definitions_in_item(item: &ContentItem, subjects: &mut BTreeSet<String>) {
307    match item {
308        ContentItem::Definition(definition) => {
309            let subject = definition.subject.as_string().trim();
310            if !subject.is_empty() {
311                subjects.insert(subject.to_string());
312            }
313            for child in definition.children.iter() {
314                collect_definitions_in_item(child, subjects);
315            }
316        }
317        ContentItem::Session(session) => collect_definitions_in_session(session, subjects),
318        ContentItem::List(list) => {
319            for child in list.items.iter() {
320                collect_definitions_in_item(child, subjects);
321            }
322        }
323        ContentItem::ListItem(list_item) => {
324            for child in list_item.children.iter() {
325                collect_definitions_in_item(child, subjects);
326            }
327        }
328        ContentItem::Annotation(annotation) => {
329            for child in annotation.children.iter() {
330                collect_definitions_in_item(child, subjects);
331            }
332        }
333        ContentItem::Paragraph(paragraph) => {
334            for line in &paragraph.lines {
335                collect_definitions_in_item(line, subjects);
336            }
337        }
338        ContentItem::VerbatimBlock(_) | ContentItem::TextLine(_) | ContentItem::VerbatimLine(_) => {
339        }
340        ContentItem::BlankLineGroup(_) => {}
341    }
342}
343
344fn collect_session_identifiers(document: &Document) -> BTreeSet<String> {
345    let mut identifiers = BTreeSet::new();
346    collect_session_ids_recursive(&document.root, &mut identifiers, true);
347    identifiers
348}
349
350fn collect_session_ids_recursive(
351    session: &Session,
352    identifiers: &mut BTreeSet<String>,
353    is_root: bool,
354) {
355    if !is_root {
356        if let Some(id) = session_identifier(session) {
357            identifiers.insert(id);
358        }
359        let title = session.title_text().trim();
360        if !title.is_empty() {
361            identifiers.insert(title.to_string());
362        }
363    }
364
365    for item in session.iter_items() {
366        if let ContentItem::Session(child) = item {
367            collect_session_ids_recursive(child, identifiers, false);
368        }
369    }
370}
371
372fn collect_document_verbatim_labels(document: &Document) -> BTreeSet<String> {
373    let mut labels = BTreeSet::new();
374    for (item, _) in document.root.iter_all_nodes_with_depth() {
375        if let ContentItem::VerbatimBlock(verbatim) = item {
376            labels.insert(verbatim.closing_data.label.value.clone());
377        }
378    }
379    labels
380}
381
382fn path_completion_candidates(
383    document: &Document,
384    workspace: Option<&CompletionWorkspace>,
385    detail: &'static str,
386) -> Vec<CompletionCandidate> {
387    collect_path_completion_entries(document, workspace)
388        .into_iter()
389        .map(|entry| {
390            CompletionCandidate::new(&entry.label, CompletionItemKind::FILE)
391                .with_detail(detail)
392                .with_insert_text(entry.insert_text)
393        })
394        .collect()
395}
396
397#[derive(Debug, Clone, PartialEq, Eq)]
398struct PathCompletion {
399    label: String,
400    insert_text: String,
401}
402
403fn collect_path_completion_entries(
404    document: &Document,
405    workspace: Option<&CompletionWorkspace>,
406) -> Vec<PathCompletion> {
407    let mut entries = Vec::new();
408    let mut seen_labels = BTreeSet::new();
409
410    if let Some(workspace) = workspace {
411        for entry in workspace_path_completion_entries(workspace) {
412            if seen_labels.insert(entry.label.clone()) {
413                entries.push(entry);
414            }
415        }
416    }
417
418    for path in collect_document_path_targets(document) {
419        if seen_labels.insert(path.clone()) {
420            entries.push(PathCompletion {
421                label: path.clone(),
422                insert_text: path,
423            });
424        }
425    }
426
427    entries
428}
429
430fn collect_document_path_targets(document: &Document) -> BTreeSet<String> {
431    document
432        .find_all_links()
433        .into_iter()
434        .filter(|link| matches!(link.link_type, LinkType::File | LinkType::VerbatimSrc))
435        .map(|link| link.target)
436        .collect()
437}
438
439const MAX_WORKSPACE_PATH_COMPLETIONS: usize = 256;
440
441fn workspace_path_completion_entries(workspace: &CompletionWorkspace) -> Vec<PathCompletion> {
442    if !workspace.project_root.is_dir() {
443        return Vec::new();
444    }
445
446    let document_directory = workspace
447        .document_path
448        .parent()
449        .map(|path| path.to_path_buf())
450        .unwrap_or_else(|| workspace.project_root.clone());
451
452    let mut entries = Vec::new();
453    let mut walker = WalkBuilder::new(&workspace.project_root);
454    walker
455        .git_ignore(true)
456        .git_global(true)
457        .git_exclude(true)
458        .ignore(true)
459        .add_custom_ignore_filename(".gitignore")
460        .hidden(false)
461        .follow_links(false)
462        .standard_filters(true);
463
464    for result in walker.build() {
465        let entry = match result {
466            Ok(entry) => entry,
467            Err(_) => continue,
468        };
469
470        let file_type = match entry.file_type() {
471            Some(file_type) => file_type,
472            None => continue,
473        };
474
475        if !file_type.is_file() {
476            continue;
477        }
478
479        if entry.path() == workspace.document_path {
480            continue;
481        }
482
483        if let Some(candidate) = path_completion_from_file(
484            workspace.project_root.as_path(),
485            document_directory.as_path(),
486            entry.path(),
487        ) {
488            entries.push(candidate);
489            if entries.len() >= MAX_WORKSPACE_PATH_COMPLETIONS {
490                break;
491            }
492        }
493    }
494
495    entries.sort_by(|a, b| a.label.cmp(&b.label));
496    entries
497}
498
499fn path_completion_from_file(
500    project_root: &Path,
501    document_directory: &Path,
502    file_path: &Path,
503) -> Option<PathCompletion> {
504    let label_path = diff_paths(file_path, project_root).unwrap_or_else(|| file_path.to_path_buf());
505    let insert_path =
506        diff_paths(file_path, document_directory).unwrap_or_else(|| file_path.to_path_buf());
507
508    let label = normalize_path(&label_path)?;
509    let insert_text = normalize_path(&insert_path)?;
510
511    if label.is_empty() || insert_text.is_empty() {
512        return None;
513    }
514
515    Some(PathCompletion { label, insert_text })
516}
517
518fn normalize_path(path: &Path) -> Option<String> {
519    path.components().next()?;
520    let mut value = path.to_string_lossy().replace('\\', "/");
521    while value.starts_with("./") {
522        value = value[2..].to_string();
523    }
524    if value == "." {
525        return None;
526    }
527    Some(value)
528}
529
530fn is_inside_verbatim_label(document: &Document, position: Position) -> bool {
531    document.root.iter_all_nodes().any(|item| match item {
532        ContentItem::VerbatimBlock(verbatim) => {
533            verbatim.closing_data.label.location.contains(position)
534        }
535        _ => false,
536    })
537}
538
539fn is_inside_verbatim_src_parameter(document: &Document, position: Position) -> bool {
540    document.root.iter_all_nodes().any(|item| match item {
541        ContentItem::VerbatimBlock(verbatim) => verbatim
542            .closing_data
543            .parameters
544            .iter()
545            .any(|param| param.key == "src" && param.location.contains(position)),
546        _ => false,
547    })
548}
549
550const STANDARD_VERBATIM_LABELS: &[&str] = &[
551    "doc.code",
552    "doc.data",
553    "doc.image",
554    "doc.table",
555    "doc.video",
556    "doc.audio",
557    "doc.note",
558];
559
560const COMMON_CODE_LANGUAGES: &[&str] = &[
561    "bash",
562    "c",
563    "cpp",
564    "css",
565    "go",
566    "html",
567    "java",
568    "javascript",
569    "json",
570    "kotlin",
571    "latex",
572    "lex",
573    "markdown",
574    "python",
575    "ruby",
576    "rust",
577    "scala",
578    "sql",
579    "swift",
580    "toml",
581    "typescript",
582    "yaml",
583];
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use lex_core::lex::ast::SourceLocation;
589    use lex_core::lex::ast::Verbatim;
590    use lex_core::lex::parsing;
591    use std::fs;
592    use tempfile::tempdir;
593
594    const SAMPLE_DOC: &str = r#":: note ::
595    Document level note.
596::
597
598Cache:
599    Definition body.
600
6011. Intro
602
603    See [Cache], [^note], and [./images/chart.png].
604
605Image placeholder:
606
607    diagram placeholder
608:: doc.image src=./images/chart.png title="Usage" ::
609
610Code sample:
611
612    fn main() {}
613:: rust ::
614"#;
615
616    fn parse_sample() -> Document {
617        parsing::parse_document(SAMPLE_DOC).expect("fixture parses")
618    }
619
620    fn position_at(offset: usize) -> Position {
621        SourceLocation::new(SAMPLE_DOC).byte_to_position(offset)
622    }
623
624    fn find_verbatim<'a>(document: &'a Document, label: &str) -> &'a Verbatim {
625        for (item, _) in document.root.iter_all_nodes_with_depth() {
626            if let ContentItem::VerbatimBlock(verbatim) = item {
627                if verbatim.closing_data.label.value == label {
628                    return verbatim;
629                }
630            }
631        }
632        panic!("verbatim {label} not found");
633    }
634
635    #[test]
636    fn reference_completions_expose_labels_definitions_sessions_and_paths() {
637        let document = parse_sample();
638        let cursor = SAMPLE_DOC.find("[Cache]").expect("reference present") + 2;
639        let completions = completion_items(&document, position_at(cursor), None, None, None);
640        let labels: BTreeSet<_> = completions.iter().map(|c| c.label.as_str()).collect();
641        assert!(labels.contains("Cache"));
642        assert!(labels.contains("note"));
643        assert!(labels.contains("1"));
644        assert!(labels.contains("./images/chart.png"));
645    }
646
647    #[test]
648    fn verbatim_label_completions_include_standard_labels() {
649        let document = parse_sample();
650        let verbatim = find_verbatim(&document, "rust");
651        let mut pos = verbatim.closing_data.label.location.start;
652        pos.column += 1; // inside the label text
653        let completions = completion_items(&document, pos, None, None, None);
654        assert!(completions.iter().any(|c| c.label == "doc.image"));
655        assert!(completions.iter().any(|c| c.label == "rust"));
656    }
657
658    #[test]
659    fn verbatim_src_completion_offers_known_paths() {
660        let document = parse_sample();
661        let verbatim = find_verbatim(&document, "doc.image");
662        let param = verbatim
663            .closing_data
664            .parameters
665            .iter()
666            .find(|p| p.key == "src")
667            .expect("src parameter exists");
668        let mut pos = param.location.start;
669        pos.column += 5; // after `src=`
670        let completions = completion_items(&document, pos, None, None, None);
671        assert!(completions.iter().any(|c| c.label == "./images/chart.png"));
672    }
673
674    #[test]
675    fn workspace_file_completion_uses_root_label_and_document_relative_insert() {
676        let document = parse_sample();
677        let cursor = SAMPLE_DOC.find("[Cache]").expect("reference present") + 2;
678
679        let temp = tempdir().expect("temp dir");
680        let root = temp.path();
681        fs::create_dir_all(root.join("images")).unwrap();
682        fs::write(root.join("images/chart.png"), "img").unwrap();
683        fs::create_dir_all(root.join("docs")).unwrap();
684        let document_path = root.join("docs/chapter.lex");
685        fs::write(&document_path, SAMPLE_DOC).unwrap();
686
687        let workspace = CompletionWorkspace {
688            project_root: root.to_path_buf(),
689            document_path,
690        };
691
692        let completions =
693            completion_items(&document, position_at(cursor), None, Some(&workspace), None);
694
695        let candidate = completions
696            .iter()
697            .find(|item| item.label == "images/chart.png")
698            .expect("workspace path present");
699        assert_eq!(
700            candidate.insert_text.as_deref(),
701            Some("../images/chart.png")
702        );
703    }
704
705    #[test]
706    fn workspace_file_completion_respects_gitignore() {
707        let document = parse_sample();
708        let temp = tempdir().expect("temp dir");
709        let root = temp.path();
710        fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
711        fs::create_dir_all(root.join("assets")).unwrap();
712        fs::write(root.join("assets/visible.png"), "data").unwrap();
713        fs::create_dir_all(root.join("ignored")).unwrap();
714        fs::write(root.join("ignored/secret.png"), "nope").unwrap();
715        let document_path = root.join("doc.lex");
716        fs::write(&document_path, SAMPLE_DOC).unwrap();
717
718        let workspace = CompletionWorkspace {
719            project_root: root.to_path_buf(),
720            document_path,
721        };
722
723        let completions = completion_items(&document, position_at(0), None, Some(&workspace), None);
724
725        assert!(completions
726            .iter()
727            .any(|item| item.label == "assets/visible.png"));
728        assert!(!completions
729            .iter()
730            .any(|item| item.label.contains("ignored/secret.png")));
731    }
732
733    #[test]
734    fn at_trigger_returns_only_file_paths() {
735        let document = parse_sample();
736        let temp = tempdir().expect("temp dir");
737        let root = temp.path();
738        fs::create_dir_all(root.join("images")).unwrap();
739        fs::write(root.join("images/photo.jpg"), "img").unwrap();
740        fs::write(root.join("script.py"), "code").unwrap();
741        let document_path = root.join("doc.lex");
742        fs::write(&document_path, SAMPLE_DOC).unwrap();
743
744        let workspace = CompletionWorkspace {
745            project_root: root.to_path_buf(),
746            document_path,
747        };
748
749        // With @ trigger, should return only file paths (no annotation labels, etc.)
750        let completions =
751            completion_items(&document, position_at(0), None, Some(&workspace), Some("@"));
752
753        // Should have file paths
754        assert!(completions
755            .iter()
756            .any(|item| item.label == "images/photo.jpg"));
757        assert!(completions.iter().any(|item| item.label == "script.py"));
758
759        // Should NOT have annotation labels or definition subjects
760        assert!(!completions.iter().any(|item| item.label == "note"));
761        assert!(!completions.iter().any(|item| item.label == "Cache"));
762    }
763
764    #[test]
765    fn macro_completions_suggested_on_at() {
766        let document = parse_sample();
767        let temp = tempdir().expect("temp dir");
768        let root = temp.path();
769        let document_path = root.join("doc.lex");
770        // We need a workspace to call asset_path_completions (which is called by @ trigger)
771        let workspace = CompletionWorkspace {
772            project_root: root.to_path_buf(),
773            document_path,
774        };
775
776        let completions =
777            completion_items(&document, position_at(0), None, Some(&workspace), Some("@"));
778        assert!(completions.iter().any(|c| c.label == "@table"));
779        assert!(completions.iter().any(|c| c.label == "@note"));
780        assert!(completions.iter().any(|c| c.label == "@image"));
781    }
782
783    #[test]
784    fn trigger_colon_at_block_start_suggests_standard_labels() {
785        let text = "::";
786        let document = parsing::parse_document(text).expect("parses");
787        println!("AST: {document:#?}");
788        // Cursor at col 2 (after "::")
789        let pos = Position::new(0, 2);
790
791        // Pass "::" as current line content
792        let completions = completion_items(&document, pos, Some("::"), None, Some(":"));
793
794        assert!(completions.iter().any(|c| c.label == "doc.code"));
795        assert!(completions.iter().any(|c| c.label == "rust"));
796    }
797
798    #[test]
799    fn colon_trigger_in_definition_subject_returns_nothing() {
800        let text = "Ideas:";
801        let document = parsing::parse_document(text).expect("parses");
802        let pos = Position::new(0, 6); // after "Ideas:"
803        let completions = completion_items(&document, pos, Some("Ideas:"), None, Some(":"));
804        assert!(
805            completions.is_empty(),
806            "colon in definition subject should not trigger completions, got: {:?}",
807            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
808        );
809    }
810
811    #[test]
812    fn trigger_at_suggests_macros() {
813        let text = "";
814        let document = parsing::parse_document(text).expect("parses");
815        let pos = Position::new(0, 0);
816        let completions = completion_items(&document, pos, None, None, Some("@"));
817
818        assert!(completions.iter().any(|c| c.label == "@table"));
819        assert!(completions.iter().any(|c| c.label == "@note"));
820    }
821}