1use std::cmp::Reverse;
2use std::ops::Range;
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::sync::atomic::AtomicBool;
6
7use acp_thread::MentionUri;
8use agent::{HistoryEntry, HistoryStore};
9use anyhow::Result;
10use editor::{CompletionProvider, Editor, ExcerptId};
11use fuzzy::{PathMatch, StringMatch, StringMatchCandidate};
12use gpui::{App, Entity, Task, WeakEntity};
13use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId};
14use lsp::CompletionContext;
15use ordered_float::OrderedFloat;
16use project::lsp_store::{CompletionDocumentation, SymbolLocation};
17use project::{
18 Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse,
19 PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId,
20};
21use prompt_store::{PromptId, PromptStore, UserPromptId};
22use rope::Point;
23use text::{Anchor, ToPoint as _};
24use ui::prelude::*;
25use util::ResultExt as _;
26use util::paths::PathStyle;
27use util::rel_path::RelPath;
28use workspace::Workspace;
29
30use crate::AgentPanel;
31use crate::mention_set::MentionSet;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub(crate) enum PromptContextEntry {
35 Mode(PromptContextType),
36 Action(PromptContextAction),
37}
38
39impl PromptContextEntry {
40 pub fn keyword(&self) -> &'static str {
41 match self {
42 Self::Mode(mode) => mode.keyword(),
43 Self::Action(action) => action.keyword(),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub(crate) enum PromptContextType {
50 File,
51 Symbol,
52 Fetch,
53 Thread,
54 Rules,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub(crate) enum PromptContextAction {
59 AddSelections,
60}
61
62impl PromptContextAction {
63 pub fn keyword(&self) -> &'static str {
64 match self {
65 Self::AddSelections => "selection",
66 }
67 }
68
69 pub fn label(&self) -> &'static str {
70 match self {
71 Self::AddSelections => "Selection",
72 }
73 }
74
75 pub fn icon(&self) -> IconName {
76 match self {
77 Self::AddSelections => IconName::Reader,
78 }
79 }
80}
81
82impl TryFrom<&str> for PromptContextType {
83 type Error = String;
84
85 fn try_from(value: &str) -> Result<Self, Self::Error> {
86 match value {
87 "file" => Ok(Self::File),
88 "symbol" => Ok(Self::Symbol),
89 "fetch" => Ok(Self::Fetch),
90 "thread" => Ok(Self::Thread),
91 "rule" => Ok(Self::Rules),
92 _ => Err(format!("Invalid context picker mode: {}", value)),
93 }
94 }
95}
96
97impl PromptContextType {
98 pub fn keyword(&self) -> &'static str {
99 match self {
100 Self::File => "file",
101 Self::Symbol => "symbol",
102 Self::Fetch => "fetch",
103 Self::Thread => "thread",
104 Self::Rules => "rule",
105 }
106 }
107
108 pub fn label(&self) -> &'static str {
109 match self {
110 Self::File => "Files & Directories",
111 Self::Symbol => "Symbols",
112 Self::Fetch => "Fetch",
113 Self::Thread => "Threads",
114 Self::Rules => "Rules",
115 }
116 }
117
118 pub fn icon(&self) -> IconName {
119 match self {
120 Self::File => IconName::File,
121 Self::Symbol => IconName::Code,
122 Self::Fetch => IconName::ToolWeb,
123 Self::Thread => IconName::Thread,
124 Self::Rules => IconName::Reader,
125 }
126 }
127}
128
129pub(crate) enum Match {
130 File(FileMatch),
131 Symbol(SymbolMatch),
132 Thread(HistoryEntry),
133 RecentThread(HistoryEntry),
134 Fetch(SharedString),
135 Rules(RulesContextEntry),
136 Entry(EntryMatch),
137}
138
139impl Match {
140 pub fn score(&self) -> f64 {
141 match self {
142 Match::File(file) => file.mat.score,
143 Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
144 Match::Thread(_) => 1.,
145 Match::RecentThread(_) => 1.,
146 Match::Symbol(_) => 1.,
147 Match::Rules(_) => 1.,
148 Match::Fetch(_) => 1.,
149 }
150 }
151}
152
153pub struct EntryMatch {
154 mat: Option<StringMatch>,
155 entry: PromptContextEntry,
156}
157
158#[derive(Debug, Clone)]
159pub struct RulesContextEntry {
160 pub prompt_id: UserPromptId,
161 pub title: SharedString,
162}
163
164#[derive(Debug, Clone)]
165pub struct AvailableCommand {
166 pub name: Arc<str>,
167 pub description: Arc<str>,
168 pub requires_argument: bool,
169}
170
171pub trait PromptCompletionProviderDelegate: Send + Sync + 'static {
172 fn supports_context(&self, mode: PromptContextType, cx: &App) -> bool {
173 self.supported_modes(cx).contains(&mode)
174 }
175 fn supported_modes(&self, cx: &App) -> Vec<PromptContextType>;
176 fn supports_images(&self, cx: &App) -> bool;
177
178 fn available_commands(&self, cx: &App) -> Vec<AvailableCommand>;
179 fn confirm_command(&self, cx: &mut App);
180}
181
182pub struct PromptCompletionProvider<T: PromptCompletionProviderDelegate> {
183 source: Arc<T>,
184 mention_set: Entity<MentionSet>,
185 history_store: Entity<HistoryStore>,
186 prompt_store: Option<Entity<PromptStore>>,
187 workspace: WeakEntity<Workspace>,
188}
189
190impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
191 pub fn new(
192 source: T,
193 mention_set: Entity<MentionSet>,
194 history_store: Entity<HistoryStore>,
195 prompt_store: Option<Entity<PromptStore>>,
196 workspace: WeakEntity<Workspace>,
197 ) -> Self {
198 Self {
199 source: Arc::new(source),
200 mention_set,
201 workspace,
202 history_store,
203 prompt_store,
204 }
205 }
206
207 fn completion_for_entry(
208 entry: PromptContextEntry,
209 source_range: Range<Anchor>,
210 mention_set: WeakEntity<MentionSet>,
211 workspace: &Entity<Workspace>,
212 cx: &mut App,
213 ) -> Option<Completion> {
214 match entry {
215 PromptContextEntry::Mode(mode) => Some(Completion {
216 replace_range: source_range,
217 new_text: format!("@{} ", mode.keyword()),
218 label: CodeLabel::plain(mode.label().to_string(), None),
219 icon_path: Some(mode.icon().path().into()),
220 documentation: None,
221 source: project::CompletionSource::Custom,
222 match_start: None,
223 snippet_deduplication_key: None,
224 insert_text_mode: None,
225 // This ensures that when a user accepts this completion, the
226 // completion menu will still be shown after "@category " is
227 // inserted
228 confirm: Some(Arc::new(|_, _, _| true)),
229 }),
230 PromptContextEntry::Action(action) => {
231 Self::completion_for_action(action, source_range, mention_set, workspace, cx)
232 }
233 }
234 }
235
236 fn completion_for_thread(
237 thread_entry: HistoryEntry,
238 source_range: Range<Anchor>,
239 recent: bool,
240 source: Arc<T>,
241 mention_set: WeakEntity<MentionSet>,
242 workspace: Entity<Workspace>,
243 cx: &mut App,
244 ) -> Completion {
245 let uri = thread_entry.mention_uri();
246
247 let icon_for_completion = if recent {
248 IconName::HistoryRerun.path().into()
249 } else {
250 uri.icon_path(cx)
251 };
252
253 let new_text = format!("{} ", uri.as_link());
254
255 let new_text_len = new_text.len();
256 Completion {
257 replace_range: source_range.clone(),
258 new_text,
259 label: CodeLabel::plain(thread_entry.title().to_string(), None),
260 documentation: None,
261 insert_text_mode: None,
262 source: project::CompletionSource::Custom,
263 match_start: None,
264 snippet_deduplication_key: None,
265 icon_path: Some(icon_for_completion),
266 confirm: Some(confirm_completion_callback(
267 thread_entry.title().clone(),
268 source_range.start,
269 new_text_len - 1,
270 uri,
271 source,
272 mention_set,
273 workspace,
274 )),
275 }
276 }
277
278 fn completion_for_rules(
279 rule: RulesContextEntry,
280 source_range: Range<Anchor>,
281 source: Arc<T>,
282 mention_set: WeakEntity<MentionSet>,
283 workspace: Entity<Workspace>,
284 cx: &mut App,
285 ) -> Completion {
286 let uri = MentionUri::Rule {
287 id: rule.prompt_id.into(),
288 name: rule.title.to_string(),
289 };
290 let new_text = format!("{} ", uri.as_link());
291 let new_text_len = new_text.len();
292 let icon_path = uri.icon_path(cx);
293 Completion {
294 replace_range: source_range.clone(),
295 new_text,
296 label: CodeLabel::plain(rule.title.to_string(), None),
297 documentation: None,
298 insert_text_mode: None,
299 source: project::CompletionSource::Custom,
300 match_start: None,
301 snippet_deduplication_key: None,
302 icon_path: Some(icon_path),
303 confirm: Some(confirm_completion_callback(
304 rule.title,
305 source_range.start,
306 new_text_len - 1,
307 uri,
308 source,
309 mention_set,
310 workspace,
311 )),
312 }
313 }
314
315 pub(crate) fn completion_for_path(
316 project_path: ProjectPath,
317 path_prefix: &RelPath,
318 is_recent: bool,
319 is_directory: bool,
320 source_range: Range<Anchor>,
321 source: Arc<T>,
322 mention_set: WeakEntity<MentionSet>,
323 workspace: Entity<Workspace>,
324 project: Entity<Project>,
325 cx: &mut App,
326 ) -> Option<Completion> {
327 let path_style = project.read(cx).path_style(cx);
328 let (file_name, directory) =
329 extract_file_name_and_directory(&project_path.path, path_prefix, path_style);
330
331 let label =
332 build_code_label_for_path(&file_name, directory.as_ref().map(|s| s.as_ref()), None, cx);
333
334 let abs_path = project.read(cx).absolute_path(&project_path, cx)?;
335
336 let uri = if is_directory {
337 MentionUri::Directory { abs_path }
338 } else {
339 MentionUri::File { abs_path }
340 };
341
342 let crease_icon_path = uri.icon_path(cx);
343 let completion_icon_path = if is_recent {
344 IconName::HistoryRerun.path().into()
345 } else {
346 crease_icon_path
347 };
348
349 let new_text = format!("{} ", uri.as_link());
350 let new_text_len = new_text.len();
351 Some(Completion {
352 replace_range: source_range.clone(),
353 new_text,
354 label,
355 documentation: None,
356 source: project::CompletionSource::Custom,
357 icon_path: Some(completion_icon_path),
358 match_start: None,
359 snippet_deduplication_key: None,
360 insert_text_mode: None,
361 confirm: Some(confirm_completion_callback(
362 file_name,
363 source_range.start,
364 new_text_len - 1,
365 uri,
366 source,
367 mention_set,
368 workspace,
369 )),
370 })
371 }
372
373 fn completion_for_symbol(
374 symbol: Symbol,
375 source_range: Range<Anchor>,
376 source: Arc<T>,
377 mention_set: WeakEntity<MentionSet>,
378 workspace: Entity<Workspace>,
379 cx: &mut App,
380 ) -> Option<Completion> {
381 let project = workspace.read(cx).project().clone();
382
383 let (abs_path, file_name) = match &symbol.path {
384 SymbolLocation::InProject(project_path) => (
385 project.read(cx).absolute_path(&project_path, cx)?,
386 project_path.path.file_name()?.to_string().into(),
387 ),
388 SymbolLocation::OutsideProject {
389 abs_path,
390 signature: _,
391 } => (
392 PathBuf::from(abs_path.as_ref()),
393 abs_path.file_name().map(|f| f.to_string_lossy())?,
394 ),
395 };
396
397 let label = build_code_label_for_path(
398 &symbol.name,
399 Some(&file_name),
400 Some(symbol.range.start.0.row + 1),
401 cx,
402 );
403
404 let uri = MentionUri::Symbol {
405 abs_path,
406 name: symbol.name.clone(),
407 line_range: symbol.range.start.0.row..=symbol.range.end.0.row,
408 };
409 let new_text = format!("{} ", uri.as_link());
410 let new_text_len = new_text.len();
411 let icon_path = uri.icon_path(cx);
412 Some(Completion {
413 replace_range: source_range.clone(),
414 new_text,
415 label,
416 documentation: None,
417 source: project::CompletionSource::Custom,
418 icon_path: Some(icon_path),
419 match_start: None,
420 snippet_deduplication_key: None,
421 insert_text_mode: None,
422 confirm: Some(confirm_completion_callback(
423 symbol.name.into(),
424 source_range.start,
425 new_text_len - 1,
426 uri,
427 source,
428 mention_set,
429 workspace,
430 )),
431 })
432 }
433
434 fn completion_for_fetch(
435 source_range: Range<Anchor>,
436 url_to_fetch: SharedString,
437 source: Arc<T>,
438 mention_set: WeakEntity<MentionSet>,
439 workspace: Entity<Workspace>,
440 cx: &mut App,
441 ) -> Option<Completion> {
442 let new_text = format!("@fetch {} ", url_to_fetch);
443 let url_to_fetch = url::Url::parse(url_to_fetch.as_ref())
444 .or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}")))
445 .ok()?;
446 let mention_uri = MentionUri::Fetch {
447 url: url_to_fetch.clone(),
448 };
449 let icon_path = mention_uri.icon_path(cx);
450 Some(Completion {
451 replace_range: source_range.clone(),
452 new_text: new_text.clone(),
453 label: CodeLabel::plain(url_to_fetch.to_string(), None),
454 documentation: None,
455 source: project::CompletionSource::Custom,
456 icon_path: Some(icon_path),
457 match_start: None,
458 snippet_deduplication_key: None,
459 insert_text_mode: None,
460 confirm: Some(confirm_completion_callback(
461 url_to_fetch.to_string().into(),
462 source_range.start,
463 new_text.len() - 1,
464 mention_uri,
465 source,
466 mention_set,
467 workspace,
468 )),
469 })
470 }
471
472 pub(crate) fn completion_for_action(
473 action: PromptContextAction,
474 source_range: Range<Anchor>,
475 mention_set: WeakEntity<MentionSet>,
476 workspace: &Entity<Workspace>,
477 cx: &mut App,
478 ) -> Option<Completion> {
479 let (new_text, on_action) = match action {
480 PromptContextAction::AddSelections => {
481 const PLACEHOLDER: &str = "selection ";
482 let selections = selection_ranges(workspace, cx)
483 .into_iter()
484 .enumerate()
485 .map(|(ix, (buffer, range))| {
486 (
487 buffer,
488 range,
489 (PLACEHOLDER.len() * ix)..(PLACEHOLDER.len() * (ix + 1) - 1),
490 )
491 })
492 .collect::<Vec<_>>();
493
494 let new_text: String = PLACEHOLDER.repeat(selections.len());
495
496 let callback = Arc::new({
497 let source_range = source_range.clone();
498 move |_, window: &mut Window, cx: &mut App| {
499 let selections = selections.clone();
500 let mention_set = mention_set.clone();
501 let source_range = source_range.clone();
502 window.defer(cx, move |window, cx| {
503 mention_set
504 .update(cx, |store, cx| {
505 store.confirm_mention_for_selection(
506 source_range,
507 selections,
508 window,
509 cx,
510 )
511 })
512 .ok();
513 });
514 false
515 }
516 });
517
518 (new_text, callback)
519 }
520 };
521
522 Some(Completion {
523 replace_range: source_range,
524 new_text,
525 label: CodeLabel::plain(action.label().to_string(), None),
526 icon_path: Some(action.icon().path().into()),
527 documentation: None,
528 source: project::CompletionSource::Custom,
529 match_start: None,
530 snippet_deduplication_key: None,
531 insert_text_mode: None,
532 // This ensures that when a user accepts this completion, the
533 // completion menu will still be shown after "@category " is
534 // inserted
535 confirm: Some(on_action),
536 })
537 }
538
539 fn search_slash_commands(&self, query: String, cx: &mut App) -> Task<Vec<AvailableCommand>> {
540 let commands = self.source.available_commands(cx);
541 if commands.is_empty() {
542 return Task::ready(Vec::new());
543 }
544
545 cx.spawn(async move |cx| {
546 let candidates = commands
547 .iter()
548 .enumerate()
549 .map(|(id, command)| StringMatchCandidate::new(id, &command.name))
550 .collect::<Vec<_>>();
551
552 let matches = fuzzy::match_strings(
553 &candidates,
554 &query,
555 false,
556 true,
557 100,
558 &Arc::new(AtomicBool::default()),
559 cx.background_executor().clone(),
560 )
561 .await;
562
563 matches
564 .into_iter()
565 .map(|mat| commands[mat.candidate_id].clone())
566 .collect()
567 })
568 }
569
570 fn search_mentions(
571 &self,
572 mode: Option<PromptContextType>,
573 query: String,
574 cancellation_flag: Arc<AtomicBool>,
575 cx: &mut App,
576 ) -> Task<Vec<Match>> {
577 let Some(workspace) = self.workspace.upgrade() else {
578 return Task::ready(Vec::default());
579 };
580 match mode {
581 Some(PromptContextType::File) => {
582 let search_files_task = search_files(query, cancellation_flag, &workspace, cx);
583 cx.background_spawn(async move {
584 search_files_task
585 .await
586 .into_iter()
587 .map(Match::File)
588 .collect()
589 })
590 }
591
592 Some(PromptContextType::Symbol) => {
593 let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx);
594 cx.background_spawn(async move {
595 search_symbols_task
596 .await
597 .into_iter()
598 .map(Match::Symbol)
599 .collect()
600 })
601 }
602
603 Some(PromptContextType::Thread) => {
604 let search_threads_task =
605 search_threads(query, cancellation_flag, &self.history_store, cx);
606 cx.background_spawn(async move {
607 search_threads_task
608 .await
609 .into_iter()
610 .map(Match::Thread)
611 .collect()
612 })
613 }
614
615 Some(PromptContextType::Fetch) => {
616 if !query.is_empty() {
617 Task::ready(vec![Match::Fetch(query.into())])
618 } else {
619 Task::ready(Vec::new())
620 }
621 }
622
623 Some(PromptContextType::Rules) => {
624 if let Some(prompt_store) = self.prompt_store.as_ref() {
625 let search_rules_task =
626 search_rules(query, cancellation_flag, prompt_store, cx);
627 cx.background_spawn(async move {
628 search_rules_task
629 .await
630 .into_iter()
631 .map(Match::Rules)
632 .collect::<Vec<_>>()
633 })
634 } else {
635 Task::ready(Vec::new())
636 }
637 }
638
639 None if query.is_empty() => {
640 let mut matches = self.recent_context_picker_entries(&workspace, cx);
641
642 matches.extend(
643 self.available_context_picker_entries(&workspace, cx)
644 .into_iter()
645 .map(|mode| {
646 Match::Entry(EntryMatch {
647 entry: mode,
648 mat: None,
649 })
650 }),
651 );
652
653 Task::ready(matches)
654 }
655 None => {
656 let executor = cx.background_executor().clone();
657
658 let search_files_task =
659 search_files(query.clone(), cancellation_flag, &workspace, cx);
660
661 let entries = self.available_context_picker_entries(&workspace, cx);
662 let entry_candidates = entries
663 .iter()
664 .enumerate()
665 .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
666 .collect::<Vec<_>>();
667
668 cx.background_spawn(async move {
669 let mut matches = search_files_task
670 .await
671 .into_iter()
672 .map(Match::File)
673 .collect::<Vec<_>>();
674
675 let entry_matches = fuzzy::match_strings(
676 &entry_candidates,
677 &query,
678 false,
679 true,
680 100,
681 &Arc::new(AtomicBool::default()),
682 executor,
683 )
684 .await;
685
686 matches.extend(entry_matches.into_iter().map(|mat| {
687 Match::Entry(EntryMatch {
688 entry: entries[mat.candidate_id],
689 mat: Some(mat),
690 })
691 }));
692
693 matches.sort_by(|a, b| {
694 b.score()
695 .partial_cmp(&a.score())
696 .unwrap_or(std::cmp::Ordering::Equal)
697 });
698
699 matches
700 })
701 }
702 }
703 }
704
705 fn recent_context_picker_entries(
706 &self,
707 workspace: &Entity<Workspace>,
708 cx: &mut App,
709 ) -> Vec<Match> {
710 let mut recent = Vec::with_capacity(6);
711
712 let mut mentions = self
713 .mention_set
714 .read_with(cx, |store, _cx| store.mentions());
715 let workspace = workspace.read(cx);
716 let project = workspace.project().read(cx);
717 let include_root_name = workspace.visible_worktrees(cx).count() > 1;
718
719 if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx)
720 && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx)
721 {
722 let thread = thread.read(cx);
723 mentions.insert(MentionUri::Thread {
724 id: thread.session_id().clone(),
725 name: thread.title().into(),
726 });
727 }
728
729 recent.extend(
730 workspace
731 .recent_navigation_history_iter(cx)
732 .filter(|(_, abs_path)| {
733 abs_path.as_ref().is_none_or(|path| {
734 !mentions.contains(&MentionUri::File {
735 abs_path: path.clone(),
736 })
737 })
738 })
739 .take(4)
740 .filter_map(|(project_path, _)| {
741 project
742 .worktree_for_id(project_path.worktree_id, cx)
743 .map(|worktree| {
744 let path_prefix = if include_root_name {
745 worktree.read(cx).root_name().into()
746 } else {
747 RelPath::empty().into()
748 };
749 Match::File(FileMatch {
750 mat: fuzzy::PathMatch {
751 score: 1.,
752 positions: Vec::new(),
753 worktree_id: project_path.worktree_id.to_usize(),
754 path: project_path.path,
755 path_prefix,
756 is_dir: false,
757 distance_to_relative_ancestor: 0,
758 },
759 is_recent: true,
760 })
761 })
762 }),
763 );
764
765 if self.source.supports_context(PromptContextType::Thread, cx) {
766 const RECENT_COUNT: usize = 2;
767 let threads = self
768 .history_store
769 .read(cx)
770 .recently_opened_entries(cx)
771 .into_iter()
772 .filter(|thread| !mentions.contains(&thread.mention_uri()))
773 .take(RECENT_COUNT)
774 .collect::<Vec<_>>();
775
776 recent.extend(threads.into_iter().map(Match::RecentThread));
777 }
778
779 recent
780 }
781
782 fn available_context_picker_entries(
783 &self,
784 workspace: &Entity<Workspace>,
785 cx: &mut App,
786 ) -> Vec<PromptContextEntry> {
787 let mut entries = vec![
788 PromptContextEntry::Mode(PromptContextType::File),
789 PromptContextEntry::Mode(PromptContextType::Symbol),
790 ];
791
792 if self.source.supports_context(PromptContextType::Thread, cx) {
793 entries.push(PromptContextEntry::Mode(PromptContextType::Thread));
794 }
795
796 let has_selection = workspace
797 .read(cx)
798 .active_item(cx)
799 .and_then(|item| item.downcast::<Editor>())
800 .is_some_and(|editor| {
801 editor.update(cx, |editor, cx| {
802 editor.has_non_empty_selection(&editor.display_snapshot(cx))
803 })
804 });
805 if has_selection {
806 entries.push(PromptContextEntry::Action(
807 PromptContextAction::AddSelections,
808 ));
809 }
810
811 if self.prompt_store.is_some() && self.source.supports_context(PromptContextType::Rules, cx)
812 {
813 entries.push(PromptContextEntry::Mode(PromptContextType::Rules));
814 }
815
816 if self.source.supports_context(PromptContextType::Fetch, cx) {
817 entries.push(PromptContextEntry::Mode(PromptContextType::Fetch));
818 }
819
820 entries
821 }
822}
823
824impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletionProvider<T> {
825 fn completions(
826 &self,
827 _excerpt_id: ExcerptId,
828 buffer: &Entity<Buffer>,
829 buffer_position: Anchor,
830 _trigger: CompletionContext,
831 _window: &mut Window,
832 cx: &mut Context<Editor>,
833 ) -> Task<Result<Vec<CompletionResponse>>> {
834 let state = buffer.update(cx, |buffer, cx| {
835 let position = buffer_position.to_point(buffer);
836 let line_start = Point::new(position.row, 0);
837 let offset_to_line = buffer.point_to_offset(line_start);
838 let mut lines = buffer.text_for_range(line_start..position).lines();
839 let line = lines.next()?;
840 ContextCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx))
841 });
842 let Some(state) = state else {
843 return Task::ready(Ok(Vec::new()));
844 };
845
846 let Some(workspace) = self.workspace.upgrade() else {
847 return Task::ready(Ok(Vec::new()));
848 };
849
850 let project = workspace.read(cx).project().clone();
851 let snapshot = buffer.read(cx).snapshot();
852 let source_range = snapshot.anchor_before(state.source_range().start)
853 ..snapshot.anchor_after(state.source_range().end);
854
855 let source = self.source.clone();
856 let mention_set = self.mention_set.downgrade();
857 match state {
858 ContextCompletion::SlashCommand(SlashCommandCompletion {
859 command, argument, ..
860 }) => {
861 let search_task = self.search_slash_commands(command.unwrap_or_default(), cx);
862 cx.background_spawn(async move {
863 let completions = search_task
864 .await
865 .into_iter()
866 .map(|command| {
867 let new_text = if let Some(argument) = argument.as_ref() {
868 format!("/{} {}", command.name, argument)
869 } else {
870 format!("/{} ", command.name)
871 };
872
873 let is_missing_argument =
874 command.requires_argument && argument.is_none();
875 Completion {
876 replace_range: source_range.clone(),
877 new_text,
878 label: CodeLabel::plain(command.name.to_string(), None),
879 documentation: Some(CompletionDocumentation::MultiLinePlainText(
880 command.description.into(),
881 )),
882 source: project::CompletionSource::Custom,
883 icon_path: None,
884 match_start: None,
885 snippet_deduplication_key: None,
886 insert_text_mode: None,
887 confirm: Some(Arc::new({
888 let source = source.clone();
889 move |intent, _window, cx| {
890 if !is_missing_argument {
891 cx.defer({
892 let source = source.clone();
893 move |cx| match intent {
894 CompletionIntent::Complete
895 | CompletionIntent::CompleteWithInsert
896 | CompletionIntent::CompleteWithReplace => {
897 source.confirm_command(cx);
898 }
899 CompletionIntent::Compose => {}
900 }
901 });
902 }
903 false
904 }
905 })),
906 }
907 })
908 .collect();
909
910 Ok(vec![CompletionResponse {
911 completions,
912 display_options: CompletionDisplayOptions {
913 dynamic_width: true,
914 },
915 // Since this does its own filtering (see `filter_completions()` returns false),
916 // there is no benefit to computing whether this set of completions is incomplete.
917 is_incomplete: true,
918 }])
919 })
920 }
921 ContextCompletion::Mention(MentionCompletion { mode, argument, .. }) => {
922 let query = argument.unwrap_or_default();
923 let search_task =
924 self.search_mentions(mode, query, Arc::<AtomicBool>::default(), cx);
925
926 cx.spawn(async move |_, cx| {
927 let matches = search_task.await;
928
929 let completions = cx.update(|cx| {
930 matches
931 .into_iter()
932 .filter_map(|mat| match mat {
933 Match::File(FileMatch { mat, is_recent }) => {
934 let project_path = ProjectPath {
935 worktree_id: WorktreeId::from_usize(mat.worktree_id),
936 path: mat.path.clone(),
937 };
938
939 // If path is empty, this means we're matching with the root directory itself
940 // so we use the path_prefix as the name
941 let path_prefix = if mat.path.is_empty() {
942 project
943 .read(cx)
944 .worktree_for_id(project_path.worktree_id, cx)
945 .map(|wt| wt.read(cx).root_name().into())
946 .unwrap_or_else(|| mat.path_prefix.clone())
947 } else {
948 mat.path_prefix.clone()
949 };
950
951 Self::completion_for_path(
952 project_path,
953 &path_prefix,
954 is_recent,
955 mat.is_dir,
956 source_range.clone(),
957 source.clone(),
958 mention_set.clone(),
959 workspace.clone(),
960 project.clone(),
961 cx,
962 )
963 }
964
965 Match::Symbol(SymbolMatch { symbol, .. }) => {
966 Self::completion_for_symbol(
967 symbol,
968 source_range.clone(),
969 source.clone(),
970 mention_set.clone(),
971 workspace.clone(),
972 cx,
973 )
974 }
975
976 Match::Thread(thread) => Some(Self::completion_for_thread(
977 thread,
978 source_range.clone(),
979 false,
980 source.clone(),
981 mention_set.clone(),
982 workspace.clone(),
983 cx,
984 )),
985
986 Match::RecentThread(thread) => Some(Self::completion_for_thread(
987 thread,
988 source_range.clone(),
989 true,
990 source.clone(),
991 mention_set.clone(),
992 workspace.clone(),
993 cx,
994 )),
995
996 Match::Rules(user_rules) => Some(Self::completion_for_rules(
997 user_rules,
998 source_range.clone(),
999 source.clone(),
1000 mention_set.clone(),
1001 workspace.clone(),
1002 cx,
1003 )),
1004
1005 Match::Fetch(url) => Self::completion_for_fetch(
1006 source_range.clone(),
1007 url,
1008 source.clone(),
1009 mention_set.clone(),
1010 workspace.clone(),
1011 cx,
1012 ),
1013
1014 Match::Entry(EntryMatch { entry, .. }) => {
1015 Self::completion_for_entry(
1016 entry,
1017 source_range.clone(),
1018 mention_set.clone(),
1019 &workspace,
1020 cx,
1021 )
1022 }
1023 })
1024 .collect()
1025 })?;
1026
1027 Ok(vec![CompletionResponse {
1028 completions,
1029 display_options: CompletionDisplayOptions {
1030 dynamic_width: true,
1031 },
1032 // Since this does its own filtering (see `filter_completions()` returns false),
1033 // there is no benefit to computing whether this set of completions is incomplete.
1034 is_incomplete: true,
1035 }])
1036 })
1037 }
1038 }
1039 }
1040
1041 fn is_completion_trigger(
1042 &self,
1043 buffer: &Entity<language::Buffer>,
1044 position: language::Anchor,
1045 _text: &str,
1046 _trigger_in_words: bool,
1047 _menu_is_open: bool,
1048 cx: &mut Context<Editor>,
1049 ) -> bool {
1050 let buffer = buffer.read(cx);
1051 let position = position.to_point(buffer);
1052 let line_start = Point::new(position.row, 0);
1053 let offset_to_line = buffer.point_to_offset(line_start);
1054 let mut lines = buffer.text_for_range(line_start..position).lines();
1055 if let Some(line) = lines.next() {
1056 ContextCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx))
1057 .filter(|completion| {
1058 // Right now we don't support completing arguments of slash commands
1059 let is_slash_command_with_argument = matches!(
1060 completion,
1061 ContextCompletion::SlashCommand(SlashCommandCompletion {
1062 argument: Some(_),
1063 ..
1064 })
1065 );
1066 !is_slash_command_with_argument
1067 })
1068 .map(|completion| {
1069 completion.source_range().start <= offset_to_line + position.column as usize
1070 && completion.source_range().end
1071 >= offset_to_line + position.column as usize
1072 })
1073 .unwrap_or(false)
1074 } else {
1075 false
1076 }
1077 }
1078
1079 fn sort_completions(&self) -> bool {
1080 false
1081 }
1082
1083 fn filter_completions(&self) -> bool {
1084 false
1085 }
1086}
1087
1088fn confirm_completion_callback<T: PromptCompletionProviderDelegate>(
1089 crease_text: SharedString,
1090 start: Anchor,
1091 content_len: usize,
1092 mention_uri: MentionUri,
1093 source: Arc<T>,
1094 mention_set: WeakEntity<MentionSet>,
1095 workspace: Entity<Workspace>,
1096) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
1097 Arc::new(move |_, window, cx| {
1098 let source = source.clone();
1099 let mention_set = mention_set.clone();
1100 let crease_text = crease_text.clone();
1101 let mention_uri = mention_uri.clone();
1102 let workspace = workspace.clone();
1103 window.defer(cx, move |window, cx| {
1104 mention_set
1105 .clone()
1106 .update(cx, |mention_set, cx| {
1107 mention_set
1108 .confirm_mention_completion(
1109 crease_text,
1110 start,
1111 content_len,
1112 mention_uri,
1113 source.supports_images(cx),
1114 &workspace,
1115 window,
1116 cx,
1117 )
1118 .detach();
1119 })
1120 .ok();
1121 });
1122 false
1123 })
1124}
1125
1126enum ContextCompletion {
1127 SlashCommand(SlashCommandCompletion),
1128 Mention(MentionCompletion),
1129}
1130
1131impl ContextCompletion {
1132 fn source_range(&self) -> Range<usize> {
1133 match self {
1134 Self::SlashCommand(completion) => completion.source_range.clone(),
1135 Self::Mention(completion) => completion.source_range.clone(),
1136 }
1137 }
1138
1139 fn try_parse(
1140 line: &str,
1141 offset_to_line: usize,
1142 supported_modes: &[PromptContextType],
1143 ) -> Option<Self> {
1144 if let Some(command) = SlashCommandCompletion::try_parse(line, offset_to_line) {
1145 Some(Self::SlashCommand(command))
1146 } else if let Some(mention) =
1147 MentionCompletion::try_parse(line, offset_to_line, supported_modes)
1148 {
1149 Some(Self::Mention(mention))
1150 } else {
1151 None
1152 }
1153 }
1154}
1155
1156#[derive(Debug, Default, PartialEq)]
1157pub struct SlashCommandCompletion {
1158 pub source_range: Range<usize>,
1159 pub command: Option<String>,
1160 pub argument: Option<String>,
1161}
1162
1163impl SlashCommandCompletion {
1164 pub fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
1165 // If we decide to support commands that are not at the beginning of the prompt, we can remove this check
1166 if !line.starts_with('/') || offset_to_line != 0 {
1167 return None;
1168 }
1169
1170 let (prefix, last_command) = line.rsplit_once('/')?;
1171 if prefix.chars().last().is_some_and(|c| !c.is_whitespace())
1172 || last_command.starts_with(char::is_whitespace)
1173 {
1174 return None;
1175 }
1176
1177 let mut argument = None;
1178 let mut command = None;
1179 if let Some((command_text, args)) = last_command.split_once(char::is_whitespace) {
1180 if !args.is_empty() {
1181 argument = Some(args.trim_end().to_string());
1182 }
1183 command = Some(command_text.to_string());
1184 } else if !last_command.is_empty() {
1185 command = Some(last_command.to_string());
1186 };
1187
1188 Some(Self {
1189 source_range: prefix.len() + offset_to_line
1190 ..line
1191 .rfind(|c: char| !c.is_whitespace())
1192 .unwrap_or_else(|| line.len())
1193 + 1
1194 + offset_to_line,
1195 command,
1196 argument,
1197 })
1198 }
1199}
1200
1201#[derive(Debug, Default, PartialEq)]
1202struct MentionCompletion {
1203 source_range: Range<usize>,
1204 mode: Option<PromptContextType>,
1205 argument: Option<String>,
1206}
1207
1208impl MentionCompletion {
1209 fn try_parse(
1210 line: &str,
1211 offset_to_line: usize,
1212 supported_modes: &[PromptContextType],
1213 ) -> Option<Self> {
1214 let last_mention_start = line.rfind('@')?;
1215
1216 // No whitespace immediately after '@'
1217 if line[last_mention_start + 1..]
1218 .chars()
1219 .next()
1220 .is_some_and(|c| c.is_whitespace())
1221 {
1222 return None;
1223 }
1224
1225 // Must be a word boundary before '@'
1226 if last_mention_start > 0
1227 && line[..last_mention_start]
1228 .chars()
1229 .last()
1230 .is_some_and(|c| !c.is_whitespace())
1231 {
1232 return None;
1233 }
1234
1235 let rest_of_line = &line[last_mention_start + 1..];
1236
1237 let mut mode = None;
1238 let mut argument = None;
1239
1240 let mut parts = rest_of_line.split_whitespace();
1241 let mut end = last_mention_start + 1;
1242
1243 if let Some(mode_text) = parts.next() {
1244 // Safe since we check no leading whitespace above
1245 end += mode_text.len();
1246
1247 if let Some(parsed_mode) = PromptContextType::try_from(mode_text).ok()
1248 && supported_modes.contains(&parsed_mode)
1249 {
1250 mode = Some(parsed_mode);
1251 } else {
1252 argument = Some(mode_text.to_string());
1253 }
1254 match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) {
1255 Some(whitespace_count) => {
1256 if let Some(argument_text) = parts.next() {
1257 // If mode wasn't recognized but we have an argument, don't suggest completions
1258 // (e.g. '@something word')
1259 if mode.is_none() && !argument_text.is_empty() {
1260 return None;
1261 }
1262
1263 argument = Some(argument_text.to_string());
1264 end += whitespace_count + argument_text.len();
1265 }
1266 }
1267 None => {
1268 // Rest of line is entirely whitespace
1269 end += rest_of_line.len() - mode_text.len();
1270 }
1271 }
1272 }
1273
1274 Some(Self {
1275 source_range: last_mention_start + offset_to_line..end + offset_to_line,
1276 mode,
1277 argument,
1278 })
1279 }
1280}
1281
1282pub(crate) fn search_files(
1283 query: String,
1284 cancellation_flag: Arc<AtomicBool>,
1285 workspace: &Entity<Workspace>,
1286 cx: &App,
1287) -> Task<Vec<FileMatch>> {
1288 if query.is_empty() {
1289 let workspace = workspace.read(cx);
1290 let project = workspace.project().read(cx);
1291 let visible_worktrees = workspace.visible_worktrees(cx).collect::<Vec<_>>();
1292 let include_root_name = visible_worktrees.len() > 1;
1293
1294 let recent_matches = workspace
1295 .recent_navigation_history(Some(10), cx)
1296 .into_iter()
1297 .map(|(project_path, _)| {
1298 let path_prefix = if include_root_name {
1299 project
1300 .worktree_for_id(project_path.worktree_id, cx)
1301 .map(|wt| wt.read(cx).root_name().into())
1302 .unwrap_or_else(|| RelPath::empty().into())
1303 } else {
1304 RelPath::empty().into()
1305 };
1306
1307 FileMatch {
1308 mat: PathMatch {
1309 score: 0.,
1310 positions: Vec::new(),
1311 worktree_id: project_path.worktree_id.to_usize(),
1312 path: project_path.path,
1313 path_prefix,
1314 distance_to_relative_ancestor: 0,
1315 is_dir: false,
1316 },
1317 is_recent: true,
1318 }
1319 });
1320
1321 let file_matches = visible_worktrees.into_iter().flat_map(|worktree| {
1322 let worktree = worktree.read(cx);
1323 let path_prefix: Arc<RelPath> = if include_root_name {
1324 worktree.root_name().into()
1325 } else {
1326 RelPath::empty().into()
1327 };
1328 worktree.entries(false, 0).map(move |entry| FileMatch {
1329 mat: PathMatch {
1330 score: 0.,
1331 positions: Vec::new(),
1332 worktree_id: worktree.id().to_usize(),
1333 path: entry.path.clone(),
1334 path_prefix: path_prefix.clone(),
1335 distance_to_relative_ancestor: 0,
1336 is_dir: entry.is_dir(),
1337 },
1338 is_recent: false,
1339 })
1340 });
1341
1342 Task::ready(recent_matches.chain(file_matches).collect())
1343 } else {
1344 let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
1345 let include_root_name = worktrees.len() > 1;
1346 let candidate_sets = worktrees
1347 .into_iter()
1348 .map(|worktree| {
1349 let worktree = worktree.read(cx);
1350
1351 PathMatchCandidateSet {
1352 snapshot: worktree.snapshot(),
1353 include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored),
1354 include_root_name,
1355 candidates: project::Candidates::Entries,
1356 }
1357 })
1358 .collect::<Vec<_>>();
1359
1360 let executor = cx.background_executor().clone();
1361 cx.foreground_executor().spawn(async move {
1362 fuzzy::match_path_sets(
1363 candidate_sets.as_slice(),
1364 query.as_str(),
1365 &None,
1366 false,
1367 100,
1368 &cancellation_flag,
1369 executor,
1370 )
1371 .await
1372 .into_iter()
1373 .map(|mat| FileMatch {
1374 mat,
1375 is_recent: false,
1376 })
1377 .collect::<Vec<_>>()
1378 })
1379 }
1380}
1381
1382pub(crate) fn search_symbols(
1383 query: String,
1384 cancellation_flag: Arc<AtomicBool>,
1385 workspace: &Entity<Workspace>,
1386 cx: &mut App,
1387) -> Task<Vec<SymbolMatch>> {
1388 let symbols_task = workspace.update(cx, |workspace, cx| {
1389 workspace
1390 .project()
1391 .update(cx, |project, cx| project.symbols(&query, cx))
1392 });
1393 let project = workspace.read(cx).project().clone();
1394 cx.spawn(async move |cx| {
1395 let Some(symbols) = symbols_task.await.log_err() else {
1396 return Vec::new();
1397 };
1398 let Some((visible_match_candidates, external_match_candidates)): Option<(Vec<_>, Vec<_>)> =
1399 project
1400 .update(cx, |project, cx| {
1401 symbols
1402 .iter()
1403 .enumerate()
1404 .map(|(id, symbol)| {
1405 StringMatchCandidate::new(id, symbol.label.filter_text())
1406 })
1407 .partition(|candidate| match &symbols[candidate.id].path {
1408 SymbolLocation::InProject(project_path) => project
1409 .entry_for_path(project_path, cx)
1410 .is_some_and(|e| !e.is_ignored),
1411 SymbolLocation::OutsideProject { .. } => false,
1412 })
1413 })
1414 .log_err()
1415 else {
1416 return Vec::new();
1417 };
1418
1419 const MAX_MATCHES: usize = 100;
1420 let mut visible_matches = cx.background_executor().block(fuzzy::match_strings(
1421 &visible_match_candidates,
1422 &query,
1423 false,
1424 true,
1425 MAX_MATCHES,
1426 &cancellation_flag,
1427 cx.background_executor().clone(),
1428 ));
1429 let mut external_matches = cx.background_executor().block(fuzzy::match_strings(
1430 &external_match_candidates,
1431 &query,
1432 false,
1433 true,
1434 MAX_MATCHES - visible_matches.len().min(MAX_MATCHES),
1435 &cancellation_flag,
1436 cx.background_executor().clone(),
1437 ));
1438 let sort_key_for_match = |mat: &StringMatch| {
1439 let symbol = &symbols[mat.candidate_id];
1440 (Reverse(OrderedFloat(mat.score)), symbol.label.filter_text())
1441 };
1442
1443 visible_matches.sort_unstable_by_key(sort_key_for_match);
1444 external_matches.sort_unstable_by_key(sort_key_for_match);
1445 let mut matches = visible_matches;
1446 matches.append(&mut external_matches);
1447
1448 matches
1449 .into_iter()
1450 .map(|mut mat| {
1451 let symbol = symbols[mat.candidate_id].clone();
1452 let filter_start = symbol.label.filter_range.start;
1453 for position in &mut mat.positions {
1454 *position += filter_start;
1455 }
1456 SymbolMatch { symbol }
1457 })
1458 .collect()
1459 })
1460}
1461
1462pub(crate) fn search_threads(
1463 query: String,
1464 cancellation_flag: Arc<AtomicBool>,
1465 thread_store: &Entity<HistoryStore>,
1466 cx: &mut App,
1467) -> Task<Vec<HistoryEntry>> {
1468 let threads = thread_store.read(cx).entries().collect();
1469 if query.is_empty() {
1470 return Task::ready(threads);
1471 }
1472
1473 let executor = cx.background_executor().clone();
1474 cx.background_spawn(async move {
1475 let candidates = threads
1476 .iter()
1477 .enumerate()
1478 .map(|(id, thread)| StringMatchCandidate::new(id, thread.title()))
1479 .collect::<Vec<_>>();
1480 let matches = fuzzy::match_strings(
1481 &candidates,
1482 &query,
1483 false,
1484 true,
1485 100,
1486 &cancellation_flag,
1487 executor,
1488 )
1489 .await;
1490
1491 matches
1492 .into_iter()
1493 .map(|mat| threads[mat.candidate_id].clone())
1494 .collect()
1495 })
1496}
1497
1498pub(crate) fn search_rules(
1499 query: String,
1500 cancellation_flag: Arc<AtomicBool>,
1501 prompt_store: &Entity<PromptStore>,
1502 cx: &mut App,
1503) -> Task<Vec<RulesContextEntry>> {
1504 let search_task = prompt_store.read(cx).search(query, cancellation_flag, cx);
1505 cx.background_spawn(async move {
1506 search_task
1507 .await
1508 .into_iter()
1509 .flat_map(|metadata| {
1510 // Default prompts are filtered out as they are automatically included.
1511 if metadata.default {
1512 None
1513 } else {
1514 match metadata.id {
1515 PromptId::EditWorkflow => None,
1516 PromptId::User { uuid } => Some(RulesContextEntry {
1517 prompt_id: uuid,
1518 title: metadata.title?,
1519 }),
1520 }
1521 }
1522 })
1523 .collect::<Vec<_>>()
1524 })
1525}
1526
1527pub struct SymbolMatch {
1528 pub symbol: Symbol,
1529}
1530
1531pub struct FileMatch {
1532 pub mat: PathMatch,
1533 pub is_recent: bool,
1534}
1535
1536pub fn extract_file_name_and_directory(
1537 path: &RelPath,
1538 path_prefix: &RelPath,
1539 path_style: PathStyle,
1540) -> (SharedString, Option<SharedString>) {
1541 // If path is empty, this means we're matching with the root directory itself
1542 // so we use the path_prefix as the name
1543 if path.is_empty() && !path_prefix.is_empty() {
1544 return (path_prefix.display(path_style).to_string().into(), None);
1545 }
1546
1547 let full_path = path_prefix.join(path);
1548 let file_name = full_path.file_name().unwrap_or_default();
1549 let display_path = full_path.display(path_style);
1550 let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len());
1551 (
1552 file_name.to_string().into(),
1553 Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()),
1554 )
1555}
1556
1557fn build_code_label_for_path(
1558 file: &str,
1559 directory: Option<&str>,
1560 line_number: Option<u32>,
1561 cx: &App,
1562) -> CodeLabel {
1563 let variable_highlight_id = cx
1564 .theme()
1565 .syntax()
1566 .highlight_id("variable")
1567 .map(HighlightId);
1568 let mut label = CodeLabelBuilder::default();
1569
1570 label.push_str(file, None);
1571 label.push_str(" ", None);
1572
1573 if let Some(directory) = directory {
1574 label.push_str(directory, variable_highlight_id);
1575 }
1576 if let Some(line_number) = line_number {
1577 label.push_str(&format!(" L{}", line_number), variable_highlight_id);
1578 }
1579 label.build()
1580}
1581
1582fn selection_ranges(
1583 workspace: &Entity<Workspace>,
1584 cx: &mut App,
1585) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
1586 let Some(editor) = workspace
1587 .read(cx)
1588 .active_item(cx)
1589 .and_then(|item| item.act_as::<Editor>(cx))
1590 else {
1591 return Vec::new();
1592 };
1593
1594 editor.update(cx, |editor, cx| {
1595 let selections = editor.selections.all_adjusted(&editor.display_snapshot(cx));
1596
1597 let buffer = editor.buffer().clone().read(cx);
1598 let snapshot = buffer.snapshot(cx);
1599
1600 selections
1601 .into_iter()
1602 .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end))
1603 .flat_map(|range| {
1604 let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?;
1605 let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?;
1606 if start_buffer != end_buffer {
1607 return None;
1608 }
1609 Some((start_buffer, start..end))
1610 })
1611 .collect::<Vec<_>>()
1612 })
1613}
1614
1615#[cfg(test)]
1616mod tests {
1617 use super::*;
1618
1619 #[test]
1620 fn test_slash_command_completion_parse() {
1621 assert_eq!(
1622 SlashCommandCompletion::try_parse("/", 0),
1623 Some(SlashCommandCompletion {
1624 source_range: 0..1,
1625 command: None,
1626 argument: None,
1627 })
1628 );
1629
1630 assert_eq!(
1631 SlashCommandCompletion::try_parse("/help", 0),
1632 Some(SlashCommandCompletion {
1633 source_range: 0..5,
1634 command: Some("help".to_string()),
1635 argument: None,
1636 })
1637 );
1638
1639 assert_eq!(
1640 SlashCommandCompletion::try_parse("/help ", 0),
1641 Some(SlashCommandCompletion {
1642 source_range: 0..5,
1643 command: Some("help".to_string()),
1644 argument: None,
1645 })
1646 );
1647
1648 assert_eq!(
1649 SlashCommandCompletion::try_parse("/help arg1", 0),
1650 Some(SlashCommandCompletion {
1651 source_range: 0..10,
1652 command: Some("help".to_string()),
1653 argument: Some("arg1".to_string()),
1654 })
1655 );
1656
1657 assert_eq!(
1658 SlashCommandCompletion::try_parse("/help arg1 arg2", 0),
1659 Some(SlashCommandCompletion {
1660 source_range: 0..15,
1661 command: Some("help".to_string()),
1662 argument: Some("arg1 arg2".to_string()),
1663 })
1664 );
1665
1666 assert_eq!(
1667 SlashCommandCompletion::try_parse("/拿不到命令 拿不到命令 ", 0),
1668 Some(SlashCommandCompletion {
1669 source_range: 0..30,
1670 command: Some("拿不到命令".to_string()),
1671 argument: Some("拿不到命令".to_string()),
1672 })
1673 );
1674
1675 assert_eq!(SlashCommandCompletion::try_parse("Lorem Ipsum", 0), None);
1676
1677 assert_eq!(SlashCommandCompletion::try_parse("Lorem /", 0), None);
1678
1679 assert_eq!(SlashCommandCompletion::try_parse("Lorem /help", 0), None);
1680
1681 assert_eq!(SlashCommandCompletion::try_parse("Lorem/", 0), None);
1682
1683 assert_eq!(SlashCommandCompletion::try_parse("/ ", 0), None);
1684 }
1685
1686 #[test]
1687 fn test_mention_completion_parse() {
1688 let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol];
1689
1690 assert_eq!(
1691 MentionCompletion::try_parse("Lorem Ipsum", 0, &supported_modes),
1692 None
1693 );
1694
1695 assert_eq!(
1696 MentionCompletion::try_parse("Lorem @", 0, &supported_modes),
1697 Some(MentionCompletion {
1698 source_range: 6..7,
1699 mode: None,
1700 argument: None,
1701 })
1702 );
1703
1704 assert_eq!(
1705 MentionCompletion::try_parse("Lorem @file", 0, &supported_modes),
1706 Some(MentionCompletion {
1707 source_range: 6..11,
1708 mode: Some(PromptContextType::File),
1709 argument: None,
1710 })
1711 );
1712
1713 assert_eq!(
1714 MentionCompletion::try_parse("Lorem @file ", 0, &supported_modes),
1715 Some(MentionCompletion {
1716 source_range: 6..12,
1717 mode: Some(PromptContextType::File),
1718 argument: None,
1719 })
1720 );
1721
1722 assert_eq!(
1723 MentionCompletion::try_parse("Lorem @file main.rs", 0, &supported_modes),
1724 Some(MentionCompletion {
1725 source_range: 6..19,
1726 mode: Some(PromptContextType::File),
1727 argument: Some("main.rs".to_string()),
1728 })
1729 );
1730
1731 assert_eq!(
1732 MentionCompletion::try_parse("Lorem @file main.rs ", 0, &supported_modes),
1733 Some(MentionCompletion {
1734 source_range: 6..19,
1735 mode: Some(PromptContextType::File),
1736 argument: Some("main.rs".to_string()),
1737 })
1738 );
1739
1740 assert_eq!(
1741 MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0, &supported_modes),
1742 Some(MentionCompletion {
1743 source_range: 6..19,
1744 mode: Some(PromptContextType::File),
1745 argument: Some("main.rs".to_string()),
1746 })
1747 );
1748
1749 assert_eq!(
1750 MentionCompletion::try_parse("Lorem @main", 0, &supported_modes),
1751 Some(MentionCompletion {
1752 source_range: 6..11,
1753 mode: None,
1754 argument: Some("main".to_string()),
1755 })
1756 );
1757
1758 assert_eq!(
1759 MentionCompletion::try_parse("Lorem @main ", 0, &supported_modes),
1760 Some(MentionCompletion {
1761 source_range: 6..12,
1762 mode: None,
1763 argument: Some("main".to_string()),
1764 })
1765 );
1766
1767 assert_eq!(
1768 MentionCompletion::try_parse("Lorem @main m", 0, &supported_modes),
1769 None
1770 );
1771
1772 assert_eq!(
1773 MentionCompletion::try_parse("test@", 0, &supported_modes),
1774 None
1775 );
1776
1777 // Allowed non-file mentions
1778
1779 assert_eq!(
1780 MentionCompletion::try_parse("Lorem @symbol main", 0, &supported_modes),
1781 Some(MentionCompletion {
1782 source_range: 6..18,
1783 mode: Some(PromptContextType::Symbol),
1784 argument: Some("main".to_string()),
1785 })
1786 );
1787
1788 // Disallowed non-file mentions
1789 assert_eq!(
1790 MentionCompletion::try_parse("Lorem @symbol main", 0, &[PromptContextType::File]),
1791 None
1792 );
1793
1794 assert_eq!(
1795 MentionCompletion::try_parse("Lorem@symbol", 0, &supported_modes),
1796 None,
1797 "Should not parse mention inside word"
1798 );
1799
1800 assert_eq!(
1801 MentionCompletion::try_parse("Lorem @ file", 0, &supported_modes),
1802 None,
1803 "Should not parse with a space after @"
1804 );
1805
1806 assert_eq!(
1807 MentionCompletion::try_parse("@ file", 0, &supported_modes),
1808 None,
1809 "Should not parse with a space after @ at the start of the line"
1810 );
1811 }
1812}