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