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(":: 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 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] = &["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; 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 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; 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 let completions =
746 completion_items(&document, position_at(0), None, Some(&workspace), Some("@"));
747
748 assert!(completions
750 .iter()
751 .any(|item| item.label == "images/photo.jpg"));
752 assert!(completions.iter().any(|item| item.label == "script.py"));
753
754 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 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 let pos = Position::new(0, 2);
785
786 let completions = completion_items(&document, pos, Some("::"), None, Some(":"));
788
789 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); 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}