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(":: 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(":: image src=\"$1\" ::\n"),
146        CompletionCandidate::new("@note", CompletionItemKind::SNIPPET)
147            .with_detail("Insert annotation 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
548/// Verbatim labels offered as completion suggestions. Mirrors the
549/// blessed shortcut set in `comms/specs/general.lex` §4.2 — completions
550/// suggest the shortest accepted form per the documentation-voice rule.
551const STANDARD_VERBATIM_LABELS: &[&str] = &["table", "image", "video", "audio", "include"];
552
553const COMMON_CODE_LANGUAGES: &[&str] = &[
554    "bash",
555    "c",
556    "cpp",
557    "css",
558    "go",
559    "html",
560    "java",
561    "javascript",
562    "json",
563    "kotlin",
564    "latex",
565    "lex",
566    "markdown",
567    "python",
568    "ruby",
569    "rust",
570    "scala",
571    "sql",
572    "swift",
573    "toml",
574    "typescript",
575    "yaml",
576];
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use lex_core::lex::ast::SourceLocation;
582    use lex_core::lex::ast::Verbatim;
583    use lex_core::lex::parsing;
584    use std::fs;
585    use tempfile::tempdir;
586
587    const SAMPLE_DOC: &str = r#":: test.note ::
588    Document level note.
589
590Cache:
591    Definition body.
592
5931. Intro
594
595    See [Cache], [::note], and [./images/chart.png].
596
597Image placeholder:
598
599    diagram placeholder
600:: image src=./images/chart.png title="Usage" ::
601
602Code sample:
603
604    fn main() {}
605:: rust ::
606"#;
607
608    fn parse_sample() -> Document {
609        parsing::parse_document(SAMPLE_DOC).expect("fixture parses")
610    }
611
612    fn position_at(offset: usize) -> Position {
613        SourceLocation::new(SAMPLE_DOC).byte_to_position(offset)
614    }
615
616    fn find_verbatim<'a>(document: &'a Document, label: &str) -> &'a Verbatim {
617        for (item, _) in document.root.iter_all_nodes_with_depth() {
618            if let ContentItem::VerbatimBlock(verbatim) = item {
619                if verbatim.closing_data.label.value == label {
620                    return verbatim;
621                }
622            }
623        }
624        panic!("verbatim {label} not found");
625    }
626
627    #[test]
628    fn reference_completions_expose_labels_definitions_sessions_and_paths() {
629        let document = parse_sample();
630        let cursor = SAMPLE_DOC.find("[Cache]").expect("reference present") + 2;
631        let completions = completion_items(&document, position_at(cursor), None, None, None);
632        let labels: BTreeSet<_> = completions.iter().map(|c| c.label.as_str()).collect();
633        assert!(labels.contains("Cache"));
634        assert!(labels.contains("test.note"));
635        assert!(labels.contains("1"));
636        assert!(labels.contains("./images/chart.png"));
637    }
638
639    #[test]
640    fn verbatim_label_completions_include_standard_labels() {
641        let document = parse_sample();
642        let verbatim = find_verbatim(&document, "rust");
643        let mut pos = verbatim.closing_data.label.location.start;
644        pos.column += 1; // inside the label text
645        let completions = completion_items(&document, pos, None, None, None);
646        assert!(completions.iter().any(|c| c.label == "image"));
647        assert!(completions.iter().any(|c| c.label == "rust"));
648    }
649
650    #[test]
651    fn verbatim_src_completion_offers_known_paths() {
652        // NormalizeLabels rewrites the source-level `:: image ::`
653        // shortcut to its canonical `lex.media.image` form before
654        // this test sees the parsed AST.
655        let document = parse_sample();
656        let verbatim = find_verbatim(&document, "lex.media.image");
657        let param = verbatim
658            .closing_data
659            .parameters
660            .iter()
661            .find(|p| p.key == "src")
662            .expect("src parameter exists");
663        let mut pos = param.location.start;
664        pos.column += 5; // after `src=`
665        let completions = completion_items(&document, pos, None, None, None);
666        assert!(completions.iter().any(|c| c.label == "./images/chart.png"));
667    }
668
669    #[test]
670    fn workspace_file_completion_uses_root_label_and_document_relative_insert() {
671        let document = parse_sample();
672        let cursor = SAMPLE_DOC.find("[Cache]").expect("reference present") + 2;
673
674        let temp = tempdir().expect("temp dir");
675        let root = temp.path();
676        fs::create_dir_all(root.join("images")).unwrap();
677        fs::write(root.join("images/chart.png"), "img").unwrap();
678        fs::create_dir_all(root.join("docs")).unwrap();
679        let document_path = root.join("docs/chapter.lex");
680        fs::write(&document_path, SAMPLE_DOC).unwrap();
681
682        let workspace = CompletionWorkspace {
683            project_root: root.to_path_buf(),
684            document_path,
685        };
686
687        let completions =
688            completion_items(&document, position_at(cursor), None, Some(&workspace), None);
689
690        let candidate = completions
691            .iter()
692            .find(|item| item.label == "images/chart.png")
693            .expect("workspace path present");
694        assert_eq!(
695            candidate.insert_text.as_deref(),
696            Some("../images/chart.png")
697        );
698    }
699
700    #[test]
701    fn workspace_file_completion_respects_gitignore() {
702        let document = parse_sample();
703        let temp = tempdir().expect("temp dir");
704        let root = temp.path();
705        fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
706        fs::create_dir_all(root.join("assets")).unwrap();
707        fs::write(root.join("assets/visible.png"), "data").unwrap();
708        fs::create_dir_all(root.join("ignored")).unwrap();
709        fs::write(root.join("ignored/secret.png"), "nope").unwrap();
710        let document_path = root.join("doc.lex");
711        fs::write(&document_path, SAMPLE_DOC).unwrap();
712
713        let workspace = CompletionWorkspace {
714            project_root: root.to_path_buf(),
715            document_path,
716        };
717
718        let completions = completion_items(&document, position_at(0), None, Some(&workspace), None);
719
720        assert!(completions
721            .iter()
722            .any(|item| item.label == "assets/visible.png"));
723        assert!(!completions
724            .iter()
725            .any(|item| item.label.contains("ignored/secret.png")));
726    }
727
728    #[test]
729    fn at_trigger_returns_only_file_paths() {
730        let document = parse_sample();
731        let temp = tempdir().expect("temp dir");
732        let root = temp.path();
733        fs::create_dir_all(root.join("images")).unwrap();
734        fs::write(root.join("images/photo.jpg"), "img").unwrap();
735        fs::write(root.join("script.py"), "code").unwrap();
736        let document_path = root.join("doc.lex");
737        fs::write(&document_path, SAMPLE_DOC).unwrap();
738
739        let workspace = CompletionWorkspace {
740            project_root: root.to_path_buf(),
741            document_path,
742        };
743
744        // With @ trigger, should return only file paths (no annotation labels, etc.)
745        let completions =
746            completion_items(&document, position_at(0), None, Some(&workspace), Some("@"));
747
748        // Should have file paths
749        assert!(completions
750            .iter()
751            .any(|item| item.label == "images/photo.jpg"));
752        assert!(completions.iter().any(|item| item.label == "script.py"));
753
754        // Should NOT have annotation labels or definition subjects
755        assert!(!completions.iter().any(|item| item.label == "note"));
756        assert!(!completions.iter().any(|item| item.label == "Cache"));
757    }
758
759    #[test]
760    fn macro_completions_suggested_on_at() {
761        let document = parse_sample();
762        let temp = tempdir().expect("temp dir");
763        let root = temp.path();
764        let document_path = root.join("doc.lex");
765        // We need a workspace to call asset_path_completions (which is called by @ trigger)
766        let workspace = CompletionWorkspace {
767            project_root: root.to_path_buf(),
768            document_path,
769        };
770
771        let completions =
772            completion_items(&document, position_at(0), None, Some(&workspace), Some("@"));
773        assert!(completions.iter().any(|c| c.label == "@table"));
774        assert!(completions.iter().any(|c| c.label == "@note"));
775        assert!(completions.iter().any(|c| c.label == "@image"));
776    }
777
778    #[test]
779    fn trigger_colon_at_block_start_suggests_standard_labels() {
780        let text = "::";
781        let document = parsing::parse_document(text).expect("parses");
782        println!("AST: {document:#?}");
783        // Cursor at col 2 (after "::")
784        let pos = Position::new(0, 2);
785
786        // Pass "::" as current line content
787        let completions = completion_items(&document, pos, Some("::"), None, Some(":"));
788
789        // STANDARD_VERBATIM_LABELS now ships the blessed shortcuts;
790        // `table` is the most stable check (it's the verbatim closer
791        // for tables). Language hints like `rust` come from
792        // COMMON_CODE_LANGUAGES regardless.
793        assert!(completions.iter().any(|c| c.label == "table"));
794        assert!(completions.iter().any(|c| c.label == "rust"));
795    }
796
797    #[test]
798    fn colon_trigger_in_definition_subject_returns_nothing() {
799        let text = "Ideas:";
800        let document = parsing::parse_document(text).expect("parses");
801        let pos = Position::new(0, 6); // after "Ideas:"
802        let completions = completion_items(&document, pos, Some("Ideas:"), None, Some(":"));
803        assert!(
804            completions.is_empty(),
805            "colon in definition subject should not trigger completions, got: {:?}",
806            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
807        );
808    }
809
810    #[test]
811    fn trigger_at_suggests_macros() {
812        let text = "";
813        let document = parsing::parse_document(text).expect("parses");
814        let pos = Position::new(0, 0);
815        let completions = completion_items(&document, pos, None, None, Some("@"));
816
817        assert!(completions.iter().any(|c| c.label == "@table"));
818        assert!(completions.iter().any(|c| c.label == "@note"));
819    }
820}