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