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