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