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