1use 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#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct CompletionCandidate {
34 pub label: String,
36 pub detail: Option<String>,
38 pub kind: CompletionItemKind,
40 pub insert_text: Option<String>,
42}
43
44#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum CompletionContext {
78 Reference,
79 VerbatimLabel,
80 VerbatimSrc,
81 General,
82}
83
84pub 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 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 if is_at_potential_verbatim_start(document, position, current_line)
122 || is_inside_verbatim_label(document, position)
123 {
124 return verbatim_label_completions(document);
125 }
126 return Vec::new();
127 }
128 }
129
130 match detect_context(document, position, current_line) {
131 CompletionContext::VerbatimLabel => verbatim_label_completions(document),
132 CompletionContext::VerbatimSrc => verbatim_path_completions(document, workspace),
133 CompletionContext::Reference => reference_completions(document, workspace),
134 CompletionContext::General => reference_completions(document, workspace),
135 }
136}
137
138fn macro_completions(_document: &Document) -> Vec<CompletionCandidate> {
139 vec![
140 CompletionCandidate::new("@table", CompletionItemKind::SNIPPET)
141 .with_detail("Insert table snippet")
142 .with_insert_text(":: doc.table ::\n| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |\n::\n"),
143 CompletionCandidate::new("@image", CompletionItemKind::SNIPPET)
144 .with_detail("Insert image snippet")
145 .with_insert_text(":: doc.image src=\"$1\" ::\n"),
146 CompletionCandidate::new("@note", CompletionItemKind::SNIPPET)
147 .with_detail("Insert note reference")
148 .with_insert_text("[^$1]"),
149 ]
150}
151
152fn table_row_completions(_document: &Document, _position: Position) -> Vec<CompletionCandidate> {
153 vec![
157 CompletionCandidate::new("New Row", CompletionItemKind::SNIPPET)
158 .with_detail("Insert table row")
159 .with_insert_text("| | |"),
160 ]
161}
162
163fn 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 let Some(text) = current_line {
206 let trimmed = text.trim();
207 if trimmed == "::" || trimmed == ":::" {
208 return true;
209 }
210 if trimmed.starts_with("::") && trimmed.len() <= 3 {
212 return true;
213 }
214 }
215 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 ¶graph.lines {
331 collect_definitions_in_item(line, subjects);
332 }
333 }
334 ContentItem::VerbatimBlock(_)
335 | ContentItem::Table(_)
336 | ContentItem::TextLine(_)
337 | ContentItem::VerbatimLine(_) => {}
338 ContentItem::BlankLineGroup(_) => {}
339 }
340}
341
342fn collect_session_identifiers(document: &Document) -> BTreeSet<String> {
343 let mut identifiers = BTreeSet::new();
344 collect_session_ids_recursive(&document.root, &mut identifiers, true);
345 identifiers
346}
347
348fn collect_session_ids_recursive(
349 session: &Session,
350 identifiers: &mut BTreeSet<String>,
351 is_root: bool,
352) {
353 if !is_root {
354 if let Some(id) = session_identifier(session) {
355 identifiers.insert(id);
356 }
357 let title = session.title_text().trim();
358 if !title.is_empty() {
359 identifiers.insert(title.to_string());
360 }
361 }
362
363 for item in session.iter_items() {
364 if let ContentItem::Session(child) = item {
365 collect_session_ids_recursive(child, identifiers, false);
366 }
367 }
368}
369
370fn collect_document_verbatim_labels(document: &Document) -> BTreeSet<String> {
371 let mut labels = BTreeSet::new();
372 for (item, _) in document.root.iter_all_nodes_with_depth() {
373 if let ContentItem::VerbatimBlock(verbatim) = item {
374 labels.insert(verbatim.closing_data.label.value.clone());
375 }
376 }
377 labels
378}
379
380fn path_completion_candidates(
381 document: &Document,
382 workspace: Option<&CompletionWorkspace>,
383 detail: &'static str,
384) -> Vec<CompletionCandidate> {
385 collect_path_completion_entries(document, workspace)
386 .into_iter()
387 .map(|entry| {
388 CompletionCandidate::new(&entry.label, CompletionItemKind::FILE)
389 .with_detail(detail)
390 .with_insert_text(entry.insert_text)
391 })
392 .collect()
393}
394
395#[derive(Debug, Clone, PartialEq, Eq)]
396struct PathCompletion {
397 label: String,
398 insert_text: String,
399}
400
401fn collect_path_completion_entries(
402 document: &Document,
403 workspace: Option<&CompletionWorkspace>,
404) -> Vec<PathCompletion> {
405 let mut entries = Vec::new();
406 let mut seen_labels = BTreeSet::new();
407
408 if let Some(workspace) = workspace {
409 for entry in workspace_path_completion_entries(workspace) {
410 if seen_labels.insert(entry.label.clone()) {
411 entries.push(entry);
412 }
413 }
414 }
415
416 for path in collect_document_path_targets(document) {
417 if seen_labels.insert(path.clone()) {
418 entries.push(PathCompletion {
419 label: path.clone(),
420 insert_text: path,
421 });
422 }
423 }
424
425 entries
426}
427
428fn collect_document_path_targets(document: &Document) -> BTreeSet<String> {
429 document
430 .find_all_links()
431 .into_iter()
432 .filter(|link| matches!(link.link_type, LinkType::File | LinkType::VerbatimSrc))
433 .map(|link| link.target)
434 .collect()
435}
436
437const MAX_WORKSPACE_PATH_COMPLETIONS: usize = 256;
438
439fn workspace_path_completion_entries(workspace: &CompletionWorkspace) -> Vec<PathCompletion> {
440 if !workspace.project_root.is_dir() {
441 return Vec::new();
442 }
443
444 let document_directory = workspace
445 .document_path
446 .parent()
447 .map(|path| path.to_path_buf())
448 .unwrap_or_else(|| workspace.project_root.clone());
449
450 let mut entries = Vec::new();
451 let mut walker = WalkBuilder::new(&workspace.project_root);
452 walker
453 .git_ignore(true)
454 .git_global(true)
455 .git_exclude(true)
456 .ignore(true)
457 .add_custom_ignore_filename(".gitignore")
458 .hidden(false)
459 .follow_links(false)
460 .standard_filters(true);
461
462 for result in walker.build() {
463 let entry = match result {
464 Ok(entry) => entry,
465 Err(_) => continue,
466 };
467
468 let file_type = match entry.file_type() {
469 Some(file_type) => file_type,
470 None => continue,
471 };
472
473 if !file_type.is_file() {
474 continue;
475 }
476
477 if entry.path() == workspace.document_path {
478 continue;
479 }
480
481 if let Some(candidate) = path_completion_from_file(
482 workspace.project_root.as_path(),
483 document_directory.as_path(),
484 entry.path(),
485 ) {
486 entries.push(candidate);
487 if entries.len() >= MAX_WORKSPACE_PATH_COMPLETIONS {
488 break;
489 }
490 }
491 }
492
493 entries.sort_by(|a, b| a.label.cmp(&b.label));
494 entries
495}
496
497fn path_completion_from_file(
498 project_root: &Path,
499 document_directory: &Path,
500 file_path: &Path,
501) -> Option<PathCompletion> {
502 let label_path = diff_paths(file_path, project_root).unwrap_or_else(|| file_path.to_path_buf());
503 let insert_path =
504 diff_paths(file_path, document_directory).unwrap_or_else(|| file_path.to_path_buf());
505
506 let label = normalize_path(&label_path)?;
507 let insert_text = normalize_path(&insert_path)?;
508
509 if label.is_empty() || insert_text.is_empty() {
510 return None;
511 }
512
513 Some(PathCompletion { label, insert_text })
514}
515
516fn normalize_path(path: &Path) -> Option<String> {
517 path.components().next()?;
518 let mut value = path.to_string_lossy().replace('\\', "/");
519 while value.starts_with("./") {
520 value = value[2..].to_string();
521 }
522 if value == "." {
523 return None;
524 }
525 Some(value)
526}
527
528fn is_inside_verbatim_label(document: &Document, position: Position) -> bool {
529 document.root.iter_all_nodes().any(|item| match item {
530 ContentItem::VerbatimBlock(verbatim) => {
531 verbatim.closing_data.label.location.contains(position)
532 }
533 _ => false,
534 })
535}
536
537fn is_inside_verbatim_src_parameter(document: &Document, position: Position) -> bool {
538 document.root.iter_all_nodes().any(|item| match item {
539 ContentItem::VerbatimBlock(verbatim) => verbatim
540 .closing_data
541 .parameters
542 .iter()
543 .any(|param| param.key == "src" && param.location.contains(position)),
544 _ => false,
545 })
546}
547
548const STANDARD_VERBATIM_LABELS: &[&str] = &[
549 "doc.code",
550 "doc.data",
551 "doc.image",
552 "doc.table",
553 "doc.video",
554 "doc.audio",
555 "doc.note",
556];
557
558const COMMON_CODE_LANGUAGES: &[&str] = &[
559 "bash",
560 "c",
561 "cpp",
562 "css",
563 "go",
564 "html",
565 "java",
566 "javascript",
567 "json",
568 "kotlin",
569 "latex",
570 "lex",
571 "markdown",
572 "python",
573 "ruby",
574 "rust",
575 "scala",
576 "sql",
577 "swift",
578 "toml",
579 "typescript",
580 "yaml",
581];
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586 use lex_core::lex::ast::SourceLocation;
587 use lex_core::lex::ast::Verbatim;
588 use lex_core::lex::parsing;
589 use std::fs;
590 use tempfile::tempdir;
591
592 const SAMPLE_DOC: &str = r#":: note ::
593 Document level note.
594
595Cache:
596 Definition body.
597
5981. Intro
599
600 See [Cache], [^note], and [./images/chart.png].
601
602Image placeholder:
603
604 diagram placeholder
605:: doc.image src=./images/chart.png title="Usage" ::
606
607Code sample:
608
609 fn main() {}
610:: rust ::
611"#;
612
613 fn parse_sample() -> Document {
614 parsing::parse_document(SAMPLE_DOC).expect("fixture parses")
615 }
616
617 fn position_at(offset: usize) -> Position {
618 SourceLocation::new(SAMPLE_DOC).byte_to_position(offset)
619 }
620
621 fn find_verbatim<'a>(document: &'a Document, label: &str) -> &'a Verbatim {
622 for (item, _) in document.root.iter_all_nodes_with_depth() {
623 if let ContentItem::VerbatimBlock(verbatim) = item {
624 if verbatim.closing_data.label.value == label {
625 return verbatim;
626 }
627 }
628 }
629 panic!("verbatim {label} not found");
630 }
631
632 #[test]
633 fn reference_completions_expose_labels_definitions_sessions_and_paths() {
634 let document = parse_sample();
635 let cursor = SAMPLE_DOC.find("[Cache]").expect("reference present") + 2;
636 let completions = completion_items(&document, position_at(cursor), None, None, None);
637 let labels: BTreeSet<_> = completions.iter().map(|c| c.label.as_str()).collect();
638 assert!(labels.contains("Cache"));
639 assert!(labels.contains("note"));
640 assert!(labels.contains("1"));
641 assert!(labels.contains("./images/chart.png"));
642 }
643
644 #[test]
645 fn verbatim_label_completions_include_standard_labels() {
646 let document = parse_sample();
647 let verbatim = find_verbatim(&document, "rust");
648 let mut pos = verbatim.closing_data.label.location.start;
649 pos.column += 1; let completions = completion_items(&document, pos, None, None, None);
651 assert!(completions.iter().any(|c| c.label == "doc.image"));
652 assert!(completions.iter().any(|c| c.label == "rust"));
653 }
654
655 #[test]
656 fn verbatim_src_completion_offers_known_paths() {
657 let document = parse_sample();
658 let verbatim = find_verbatim(&document, "doc.image");
659 let param = verbatim
660 .closing_data
661 .parameters
662 .iter()
663 .find(|p| p.key == "src")
664 .expect("src parameter exists");
665 let mut pos = param.location.start;
666 pos.column += 5; let completions = completion_items(&document, pos, None, None, None);
668 assert!(completions.iter().any(|c| c.label == "./images/chart.png"));
669 }
670
671 #[test]
672 fn workspace_file_completion_uses_root_label_and_document_relative_insert() {
673 let document = parse_sample();
674 let cursor = SAMPLE_DOC.find("[Cache]").expect("reference present") + 2;
675
676 let temp = tempdir().expect("temp dir");
677 let root = temp.path();
678 fs::create_dir_all(root.join("images")).unwrap();
679 fs::write(root.join("images/chart.png"), "img").unwrap();
680 fs::create_dir_all(root.join("docs")).unwrap();
681 let document_path = root.join("docs/chapter.lex");
682 fs::write(&document_path, SAMPLE_DOC).unwrap();
683
684 let workspace = CompletionWorkspace {
685 project_root: root.to_path_buf(),
686 document_path,
687 };
688
689 let completions =
690 completion_items(&document, position_at(cursor), None, Some(&workspace), None);
691
692 let candidate = completions
693 .iter()
694 .find(|item| item.label == "images/chart.png")
695 .expect("workspace path present");
696 assert_eq!(
697 candidate.insert_text.as_deref(),
698 Some("../images/chart.png")
699 );
700 }
701
702 #[test]
703 fn workspace_file_completion_respects_gitignore() {
704 let document = parse_sample();
705 let temp = tempdir().expect("temp dir");
706 let root = temp.path();
707 fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
708 fs::create_dir_all(root.join("assets")).unwrap();
709 fs::write(root.join("assets/visible.png"), "data").unwrap();
710 fs::create_dir_all(root.join("ignored")).unwrap();
711 fs::write(root.join("ignored/secret.png"), "nope").unwrap();
712 let document_path = root.join("doc.lex");
713 fs::write(&document_path, SAMPLE_DOC).unwrap();
714
715 let workspace = CompletionWorkspace {
716 project_root: root.to_path_buf(),
717 document_path,
718 };
719
720 let completions = completion_items(&document, position_at(0), None, Some(&workspace), None);
721
722 assert!(completions
723 .iter()
724 .any(|item| item.label == "assets/visible.png"));
725 assert!(!completions
726 .iter()
727 .any(|item| item.label.contains("ignored/secret.png")));
728 }
729
730 #[test]
731 fn at_trigger_returns_only_file_paths() {
732 let document = parse_sample();
733 let temp = tempdir().expect("temp dir");
734 let root = temp.path();
735 fs::create_dir_all(root.join("images")).unwrap();
736 fs::write(root.join("images/photo.jpg"), "img").unwrap();
737 fs::write(root.join("script.py"), "code").unwrap();
738 let document_path = root.join("doc.lex");
739 fs::write(&document_path, SAMPLE_DOC).unwrap();
740
741 let workspace = CompletionWorkspace {
742 project_root: root.to_path_buf(),
743 document_path,
744 };
745
746 let completions =
748 completion_items(&document, position_at(0), None, Some(&workspace), Some("@"));
749
750 assert!(completions
752 .iter()
753 .any(|item| item.label == "images/photo.jpg"));
754 assert!(completions.iter().any(|item| item.label == "script.py"));
755
756 assert!(!completions.iter().any(|item| item.label == "note"));
758 assert!(!completions.iter().any(|item| item.label == "Cache"));
759 }
760
761 #[test]
762 fn macro_completions_suggested_on_at() {
763 let document = parse_sample();
764 let temp = tempdir().expect("temp dir");
765 let root = temp.path();
766 let document_path = root.join("doc.lex");
767 let workspace = CompletionWorkspace {
769 project_root: root.to_path_buf(),
770 document_path,
771 };
772
773 let completions =
774 completion_items(&document, position_at(0), None, Some(&workspace), Some("@"));
775 assert!(completions.iter().any(|c| c.label == "@table"));
776 assert!(completions.iter().any(|c| c.label == "@note"));
777 assert!(completions.iter().any(|c| c.label == "@image"));
778 }
779
780 #[test]
781 fn trigger_colon_at_block_start_suggests_standard_labels() {
782 let text = "::";
783 let document = parsing::parse_document(text).expect("parses");
784 println!("AST: {document:#?}");
785 let pos = Position::new(0, 2);
787
788 let completions = completion_items(&document, pos, Some("::"), None, Some(":"));
790
791 assert!(completions.iter().any(|c| c.label == "doc.code"));
792 assert!(completions.iter().any(|c| c.label == "rust"));
793 }
794
795 #[test]
796 fn colon_trigger_in_definition_subject_returns_nothing() {
797 let text = "Ideas:";
798 let document = parsing::parse_document(text).expect("parses");
799 let pos = Position::new(0, 6); let completions = completion_items(&document, pos, Some("Ideas:"), None, Some(":"));
801 assert!(
802 completions.is_empty(),
803 "colon in definition subject should not trigger completions, got: {:?}",
804 completions.iter().map(|c| &c.label).collect::<Vec<_>>()
805 );
806 }
807
808 #[test]
809 fn trigger_at_suggests_macros() {
810 let text = "";
811 let document = parsing::parse_document(text).expect("parses");
812 let pos = Position::new(0, 0);
813 let completions = completion_items(&document, pos, None, None, Some("@"));
814
815 assert!(completions.iter().any(|c| c.label == "@table"));
816 assert!(completions.iter().any(|c| c.label == "@note"));
817 }
818}