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