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