completion_provider.rs

   1use std::cell::RefCell;
   2use std::ops::Range;
   3use std::path::Path;
   4use std::rc::Rc;
   5use std::sync::Arc;
   6use std::sync::atomic::AtomicBool;
   7
   8use anyhow::Result;
   9use editor::{CompletionProvider, Editor, ExcerptId};
  10use file_icons::FileIcons;
  11use fuzzy::{StringMatch, StringMatchCandidate};
  12use gpui::{App, Entity, Task, WeakEntity};
  13use http_client::HttpClientWithUrl;
  14use language::{Buffer, CodeLabel, HighlightId};
  15use lsp::CompletionContext;
  16use project::{Completion, CompletionIntent, ProjectPath, Symbol, WorktreeId};
  17use rope::Point;
  18use text::{Anchor, ToPoint};
  19use ui::prelude::*;
  20use workspace::Workspace;
  21
  22use crate::context_picker::file_context_picker::search_files;
  23use crate::context_picker::symbol_context_picker::search_symbols;
  24use crate::context_store::ContextStore;
  25use crate::thread_store::ThreadStore;
  26
  27use super::fetch_context_picker::fetch_url_content;
  28use super::file_context_picker::FileMatch;
  29use super::symbol_context_picker::SymbolMatch;
  30use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads};
  31use super::{
  32    ContextPickerMode, MentionLink, RecentEntry, recent_context_picker_entries,
  33    supported_context_picker_modes,
  34};
  35
  36pub(crate) enum Match {
  37    Symbol(SymbolMatch),
  38    File(FileMatch),
  39    Thread(ThreadMatch),
  40    Fetch(SharedString),
  41    Mode(ModeMatch),
  42}
  43
  44pub struct ModeMatch {
  45    mat: Option<StringMatch>,
  46    mode: ContextPickerMode,
  47}
  48
  49impl Match {
  50    pub fn score(&self) -> f64 {
  51        match self {
  52            Match::File(file) => file.mat.score,
  53            Match::Mode(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
  54            Match::Thread(_) => 1.,
  55            Match::Symbol(_) => 1.,
  56            Match::Fetch(_) => 1.,
  57        }
  58    }
  59}
  60
  61fn search(
  62    mode: Option<ContextPickerMode>,
  63    query: String,
  64    cancellation_flag: Arc<AtomicBool>,
  65    recent_entries: Vec<RecentEntry>,
  66    thread_store: Option<WeakEntity<ThreadStore>>,
  67    workspace: Entity<Workspace>,
  68    cx: &mut App,
  69) -> Task<Vec<Match>> {
  70    match mode {
  71        Some(ContextPickerMode::File) => {
  72            let search_files_task =
  73                search_files(query.clone(), cancellation_flag.clone(), &workspace, cx);
  74            cx.background_spawn(async move {
  75                search_files_task
  76                    .await
  77                    .into_iter()
  78                    .map(Match::File)
  79                    .collect()
  80            })
  81        }
  82        Some(ContextPickerMode::Symbol) => {
  83            let search_symbols_task =
  84                search_symbols(query.clone(), cancellation_flag.clone(), &workspace, cx);
  85            cx.background_spawn(async move {
  86                search_symbols_task
  87                    .await
  88                    .into_iter()
  89                    .map(Match::Symbol)
  90                    .collect()
  91            })
  92        }
  93        Some(ContextPickerMode::Thread) => {
  94            if let Some(thread_store) = thread_store.as_ref().and_then(|t| t.upgrade()) {
  95                let search_threads_task =
  96                    search_threads(query.clone(), cancellation_flag.clone(), thread_store, cx);
  97                cx.background_spawn(async move {
  98                    search_threads_task
  99                        .await
 100                        .into_iter()
 101                        .map(Match::Thread)
 102                        .collect()
 103                })
 104            } else {
 105                Task::ready(Vec::new())
 106            }
 107        }
 108        Some(ContextPickerMode::Fetch) => {
 109            if !query.is_empty() {
 110                Task::ready(vec![Match::Fetch(query.into())])
 111            } else {
 112                Task::ready(Vec::new())
 113            }
 114        }
 115        None => {
 116            if query.is_empty() {
 117                let mut matches = recent_entries
 118                    .into_iter()
 119                    .map(|entry| match entry {
 120                        super::RecentEntry::File {
 121                            project_path,
 122                            path_prefix,
 123                        } => Match::File(FileMatch {
 124                            mat: fuzzy::PathMatch {
 125                                score: 1.,
 126                                positions: Vec::new(),
 127                                worktree_id: project_path.worktree_id.to_usize(),
 128                                path: project_path.path,
 129                                path_prefix,
 130                                is_dir: false,
 131                                distance_to_relative_ancestor: 0,
 132                            },
 133                            is_recent: true,
 134                        }),
 135                        super::RecentEntry::Thread(thread_context_entry) => {
 136                            Match::Thread(ThreadMatch {
 137                                thread: thread_context_entry,
 138                                is_recent: true,
 139                            })
 140                        }
 141                    })
 142                    .collect::<Vec<_>>();
 143
 144                matches.extend(
 145                    supported_context_picker_modes(&thread_store)
 146                        .into_iter()
 147                        .map(|mode| Match::Mode(ModeMatch { mode, mat: None })),
 148                );
 149
 150                Task::ready(matches)
 151            } else {
 152                let executor = cx.background_executor().clone();
 153
 154                let search_files_task =
 155                    search_files(query.clone(), cancellation_flag.clone(), &workspace, cx);
 156
 157                let modes = supported_context_picker_modes(&thread_store);
 158                let mode_candidates = modes
 159                    .iter()
 160                    .enumerate()
 161                    .map(|(ix, mode)| StringMatchCandidate::new(ix, mode.mention_prefix()))
 162                    .collect::<Vec<_>>();
 163
 164                cx.background_spawn(async move {
 165                    let mut matches = search_files_task
 166                        .await
 167                        .into_iter()
 168                        .map(Match::File)
 169                        .collect::<Vec<_>>();
 170
 171                    let mode_matches = fuzzy::match_strings(
 172                        &mode_candidates,
 173                        &query,
 174                        false,
 175                        100,
 176                        &Arc::new(AtomicBool::default()),
 177                        executor,
 178                    )
 179                    .await;
 180
 181                    matches.extend(mode_matches.into_iter().map(|mat| {
 182                        Match::Mode(ModeMatch {
 183                            mode: modes[mat.candidate_id],
 184                            mat: Some(mat),
 185                        })
 186                    }));
 187
 188                    matches.sort_by(|a, b| {
 189                        b.score()
 190                            .partial_cmp(&a.score())
 191                            .unwrap_or(std::cmp::Ordering::Equal)
 192                    });
 193
 194                    matches
 195                })
 196            }
 197        }
 198    }
 199}
 200
 201pub struct ContextPickerCompletionProvider {
 202    workspace: WeakEntity<Workspace>,
 203    context_store: WeakEntity<ContextStore>,
 204    thread_store: Option<WeakEntity<ThreadStore>>,
 205    editor: WeakEntity<Editor>,
 206}
 207
 208impl ContextPickerCompletionProvider {
 209    pub fn new(
 210        workspace: WeakEntity<Workspace>,
 211        context_store: WeakEntity<ContextStore>,
 212        thread_store: Option<WeakEntity<ThreadStore>>,
 213        editor: WeakEntity<Editor>,
 214    ) -> Self {
 215        Self {
 216            workspace,
 217            context_store,
 218            thread_store,
 219            editor,
 220        }
 221    }
 222
 223    fn completion_for_mode(source_range: Range<Anchor>, mode: ContextPickerMode) -> Completion {
 224        Completion {
 225            replace_range: source_range.clone(),
 226            new_text: format!("@{} ", mode.mention_prefix()),
 227            label: CodeLabel::plain(mode.label().to_string(), None),
 228            icon_path: Some(mode.icon().path().into()),
 229            documentation: None,
 230            source: project::CompletionSource::Custom,
 231            insert_text_mode: None,
 232            // This ensures that when a user accepts this completion, the
 233            // completion menu will still be shown after "@category " is
 234            // inserted
 235            confirm: Some(Arc::new(|_, _, _| true)),
 236        }
 237    }
 238
 239    fn completion_for_thread(
 240        thread_entry: ThreadContextEntry,
 241        excerpt_id: ExcerptId,
 242        source_range: Range<Anchor>,
 243        recent: bool,
 244        editor: Entity<Editor>,
 245        context_store: Entity<ContextStore>,
 246        thread_store: Entity<ThreadStore>,
 247    ) -> Completion {
 248        let icon_for_completion = if recent {
 249            IconName::HistoryRerun
 250        } else {
 251            IconName::MessageBubbles
 252        };
 253        let new_text = MentionLink::for_thread(&thread_entry);
 254        let new_text_len = new_text.len();
 255        Completion {
 256            replace_range: source_range.clone(),
 257            new_text,
 258            label: CodeLabel::plain(thread_entry.summary.to_string(), None),
 259            documentation: None,
 260            insert_text_mode: None,
 261            source: project::CompletionSource::Custom,
 262            icon_path: Some(icon_for_completion.path().into()),
 263            confirm: Some(confirm_completion_callback(
 264                IconName::MessageBubbles.path().into(),
 265                thread_entry.summary.clone(),
 266                excerpt_id,
 267                source_range.start,
 268                new_text_len,
 269                editor.clone(),
 270                move |cx| {
 271                    let thread_id = thread_entry.id.clone();
 272                    let context_store = context_store.clone();
 273                    let thread_store = thread_store.clone();
 274                    cx.spawn(async move |cx| {
 275                        let thread = thread_store
 276                            .update(cx, |thread_store, cx| {
 277                                thread_store.open_thread(&thread_id, cx)
 278                            })?
 279                            .await?;
 280                        context_store.update(cx, |context_store, cx| {
 281                            context_store.add_thread(thread, false, cx)
 282                        })
 283                    })
 284                    .detach_and_log_err(cx);
 285                },
 286            )),
 287        }
 288    }
 289
 290    fn completion_for_fetch(
 291        source_range: Range<Anchor>,
 292        url_to_fetch: SharedString,
 293        excerpt_id: ExcerptId,
 294        editor: Entity<Editor>,
 295        context_store: Entity<ContextStore>,
 296        http_client: Arc<HttpClientWithUrl>,
 297    ) -> Completion {
 298        let new_text = MentionLink::for_fetch(&url_to_fetch);
 299        let new_text_len = new_text.len();
 300        Completion {
 301            replace_range: source_range.clone(),
 302            new_text,
 303            label: CodeLabel::plain(url_to_fetch.to_string(), None),
 304            documentation: None,
 305            source: project::CompletionSource::Custom,
 306            icon_path: Some(IconName::Globe.path().into()),
 307            insert_text_mode: None,
 308            confirm: Some(confirm_completion_callback(
 309                IconName::Globe.path().into(),
 310                url_to_fetch.clone(),
 311                excerpt_id,
 312                source_range.start,
 313                new_text_len,
 314                editor.clone(),
 315                move |cx| {
 316                    let context_store = context_store.clone();
 317                    let http_client = http_client.clone();
 318                    let url_to_fetch = url_to_fetch.clone();
 319                    cx.spawn(async move |cx| {
 320                        if context_store.update(cx, |context_store, _| {
 321                            context_store.includes_url(&url_to_fetch).is_some()
 322                        })? {
 323                            return Ok(());
 324                        }
 325                        let content = cx
 326                            .background_spawn(fetch_url_content(
 327                                http_client,
 328                                url_to_fetch.to_string(),
 329                            ))
 330                            .await?;
 331                        context_store.update(cx, |context_store, cx| {
 332                            context_store.add_fetched_url(url_to_fetch.to_string(), content, cx)
 333                        })
 334                    })
 335                    .detach_and_log_err(cx);
 336                },
 337            )),
 338        }
 339    }
 340
 341    fn completion_for_path(
 342        project_path: ProjectPath,
 343        path_prefix: &str,
 344        is_recent: bool,
 345        is_directory: bool,
 346        excerpt_id: ExcerptId,
 347        source_range: Range<Anchor>,
 348        editor: Entity<Editor>,
 349        context_store: Entity<ContextStore>,
 350        cx: &App,
 351    ) -> Completion {
 352        let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory(
 353            &project_path.path,
 354            path_prefix,
 355        );
 356
 357        let label =
 358            build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx);
 359        let full_path = if let Some(directory) = directory {
 360            format!("{}{}", directory, file_name)
 361        } else {
 362            file_name.to_string()
 363        };
 364
 365        let crease_icon_path = if is_directory {
 366            FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into())
 367        } else {
 368            FileIcons::get_icon(Path::new(&full_path), cx)
 369                .unwrap_or_else(|| IconName::File.path().into())
 370        };
 371        let completion_icon_path = if is_recent {
 372            IconName::HistoryRerun.path().into()
 373        } else {
 374            crease_icon_path.clone()
 375        };
 376
 377        let new_text = MentionLink::for_file(&file_name, &full_path);
 378        let new_text_len = new_text.len();
 379        Completion {
 380            replace_range: source_range.clone(),
 381            new_text,
 382            label,
 383            documentation: None,
 384            source: project::CompletionSource::Custom,
 385            icon_path: Some(completion_icon_path),
 386            insert_text_mode: None,
 387            confirm: Some(confirm_completion_callback(
 388                crease_icon_path,
 389                file_name,
 390                excerpt_id,
 391                source_range.start,
 392                new_text_len,
 393                editor,
 394                move |cx| {
 395                    context_store.update(cx, |context_store, cx| {
 396                        let task = if is_directory {
 397                            context_store.add_directory(project_path.clone(), false, cx)
 398                        } else {
 399                            context_store.add_file_from_path(project_path.clone(), false, cx)
 400                        };
 401                        task.detach_and_log_err(cx);
 402                    })
 403                },
 404            )),
 405        }
 406    }
 407
 408    fn completion_for_symbol(
 409        symbol: Symbol,
 410        excerpt_id: ExcerptId,
 411        source_range: Range<Anchor>,
 412        editor: Entity<Editor>,
 413        context_store: Entity<ContextStore>,
 414        workspace: Entity<Workspace>,
 415        cx: &mut App,
 416    ) -> Option<Completion> {
 417        let path_prefix = workspace
 418            .read(cx)
 419            .project()
 420            .read(cx)
 421            .worktree_for_id(symbol.path.worktree_id, cx)?
 422            .read(cx)
 423            .root_name();
 424
 425        let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory(
 426            &symbol.path.path,
 427            path_prefix,
 428        );
 429        let full_path = if let Some(directory) = directory {
 430            format!("{}{}", directory, file_name)
 431        } else {
 432            file_name.to_string()
 433        };
 434
 435        let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
 436        let mut label = CodeLabel::plain(symbol.name.clone(), None);
 437        label.push_str(" ", None);
 438        label.push_str(&file_name, comment_id);
 439
 440        let new_text = MentionLink::for_symbol(&symbol.name, &full_path);
 441        let new_text_len = new_text.len();
 442        Some(Completion {
 443            replace_range: source_range.clone(),
 444            new_text,
 445            label,
 446            documentation: None,
 447            source: project::CompletionSource::Custom,
 448            icon_path: Some(IconName::Code.path().into()),
 449            insert_text_mode: None,
 450            confirm: Some(confirm_completion_callback(
 451                IconName::Code.path().into(),
 452                symbol.name.clone().into(),
 453                excerpt_id,
 454                source_range.start,
 455                new_text_len,
 456                editor.clone(),
 457                move |cx| {
 458                    let symbol = symbol.clone();
 459                    let context_store = context_store.clone();
 460                    let workspace = workspace.clone();
 461                    super::symbol_context_picker::add_symbol(
 462                        symbol.clone(),
 463                        false,
 464                        workspace.clone(),
 465                        context_store.downgrade(),
 466                        cx,
 467                    )
 468                    .detach_and_log_err(cx);
 469                },
 470            )),
 471        })
 472    }
 473}
 474
 475fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel {
 476    let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
 477    let mut label = CodeLabel::default();
 478
 479    label.push_str(&file_name, None);
 480    label.push_str(" ", None);
 481
 482    if let Some(directory) = directory {
 483        label.push_str(&directory, comment_id);
 484    }
 485
 486    label.filter_range = 0..label.text().len();
 487
 488    label
 489}
 490
 491impl CompletionProvider for ContextPickerCompletionProvider {
 492    fn completions(
 493        &self,
 494        excerpt_id: ExcerptId,
 495        buffer: &Entity<Buffer>,
 496        buffer_position: Anchor,
 497        _trigger: CompletionContext,
 498        _window: &mut Window,
 499        cx: &mut Context<Editor>,
 500    ) -> Task<Result<Option<Vec<Completion>>>> {
 501        let state = buffer.update(cx, |buffer, _cx| {
 502            let position = buffer_position.to_point(buffer);
 503            let line_start = Point::new(position.row, 0);
 504            let offset_to_line = buffer.point_to_offset(line_start);
 505            let mut lines = buffer.text_for_range(line_start..position).lines();
 506            let line = lines.next()?;
 507            MentionCompletion::try_parse(line, offset_to_line)
 508        });
 509        let Some(state) = state else {
 510            return Task::ready(Ok(None));
 511        };
 512
 513        let Some((workspace, context_store)) =
 514            self.workspace.upgrade().zip(self.context_store.upgrade())
 515        else {
 516            return Task::ready(Ok(None));
 517        };
 518
 519        let snapshot = buffer.read(cx).snapshot();
 520        let source_range = snapshot.anchor_before(state.source_range.start)
 521            ..snapshot.anchor_before(state.source_range.end);
 522
 523        let thread_store = self.thread_store.clone();
 524        let editor = self.editor.clone();
 525        let http_client = workspace.read(cx).client().http_client().clone();
 526
 527        let MentionCompletion { mode, argument, .. } = state;
 528        let query = argument.unwrap_or_else(|| "".to_string());
 529
 530        let recent_entries = recent_context_picker_entries(
 531            context_store.clone(),
 532            thread_store.clone(),
 533            workspace.clone(),
 534            cx,
 535        );
 536
 537        let search_task = search(
 538            mode,
 539            query,
 540            Arc::<AtomicBool>::default(),
 541            recent_entries,
 542            thread_store.clone(),
 543            workspace.clone(),
 544            cx,
 545        );
 546
 547        cx.spawn(async move |_, cx| {
 548            let matches = search_task.await;
 549            let Some(editor) = editor.upgrade() else {
 550                return Ok(None);
 551            };
 552
 553            Ok(Some(cx.update(|cx| {
 554                matches
 555                    .into_iter()
 556                    .filter_map(|mat| match mat {
 557                        Match::File(FileMatch { mat, is_recent }) => {
 558                            Some(Self::completion_for_path(
 559                                ProjectPath {
 560                                    worktree_id: WorktreeId::from_usize(mat.worktree_id),
 561                                    path: mat.path.clone(),
 562                                },
 563                                &mat.path_prefix,
 564                                is_recent,
 565                                mat.is_dir,
 566                                excerpt_id,
 567                                source_range.clone(),
 568                                editor.clone(),
 569                                context_store.clone(),
 570                                cx,
 571                            ))
 572                        }
 573                        Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol(
 574                            symbol,
 575                            excerpt_id,
 576                            source_range.clone(),
 577                            editor.clone(),
 578                            context_store.clone(),
 579                            workspace.clone(),
 580                            cx,
 581                        ),
 582                        Match::Thread(ThreadMatch {
 583                            thread, is_recent, ..
 584                        }) => {
 585                            let thread_store = thread_store.as_ref().and_then(|t| t.upgrade())?;
 586                            Some(Self::completion_for_thread(
 587                                thread,
 588                                excerpt_id,
 589                                source_range.clone(),
 590                                is_recent,
 591                                editor.clone(),
 592                                context_store.clone(),
 593                                thread_store,
 594                            ))
 595                        }
 596                        Match::Fetch(url) => Some(Self::completion_for_fetch(
 597                            source_range.clone(),
 598                            url,
 599                            excerpt_id,
 600                            editor.clone(),
 601                            context_store.clone(),
 602                            http_client.clone(),
 603                        )),
 604                        Match::Mode(ModeMatch { mode, .. }) => {
 605                            Some(Self::completion_for_mode(source_range.clone(), mode))
 606                        }
 607                    })
 608                    .collect()
 609            })?))
 610        })
 611    }
 612
 613    fn resolve_completions(
 614        &self,
 615        _buffer: Entity<Buffer>,
 616        _completion_indices: Vec<usize>,
 617        _completions: Rc<RefCell<Box<[Completion]>>>,
 618        _cx: &mut Context<Editor>,
 619    ) -> Task<Result<bool>> {
 620        Task::ready(Ok(true))
 621    }
 622
 623    fn is_completion_trigger(
 624        &self,
 625        buffer: &Entity<language::Buffer>,
 626        position: language::Anchor,
 627        _: &str,
 628        _: bool,
 629        cx: &mut Context<Editor>,
 630    ) -> bool {
 631        let buffer = buffer.read(cx);
 632        let position = position.to_point(buffer);
 633        let line_start = Point::new(position.row, 0);
 634        let offset_to_line = buffer.point_to_offset(line_start);
 635        let mut lines = buffer.text_for_range(line_start..position).lines();
 636        if let Some(line) = lines.next() {
 637            MentionCompletion::try_parse(line, offset_to_line)
 638                .map(|completion| {
 639                    completion.source_range.start <= offset_to_line + position.column as usize
 640                        && completion.source_range.end >= offset_to_line + position.column as usize
 641                })
 642                .unwrap_or(false)
 643        } else {
 644            false
 645        }
 646    }
 647
 648    fn sort_completions(&self) -> bool {
 649        false
 650    }
 651
 652    fn filter_completions(&self) -> bool {
 653        false
 654    }
 655}
 656
 657fn confirm_completion_callback(
 658    crease_icon_path: SharedString,
 659    crease_text: SharedString,
 660    excerpt_id: ExcerptId,
 661    start: Anchor,
 662    content_len: usize,
 663    editor: Entity<Editor>,
 664    add_context_fn: impl Fn(&mut App) -> () + Send + Sync + 'static,
 665) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
 666    Arc::new(move |_, _, cx| {
 667        add_context_fn(cx);
 668
 669        let crease_text = crease_text.clone();
 670        let crease_icon_path = crease_icon_path.clone();
 671        let editor = editor.clone();
 672        cx.defer(move |cx| {
 673            crate::context_picker::insert_fold_for_mention(
 674                excerpt_id,
 675                start,
 676                content_len,
 677                crease_text,
 678                crease_icon_path,
 679                editor,
 680                cx,
 681            );
 682        });
 683        false
 684    })
 685}
 686
 687#[derive(Debug, Default, PartialEq)]
 688struct MentionCompletion {
 689    source_range: Range<usize>,
 690    mode: Option<ContextPickerMode>,
 691    argument: Option<String>,
 692}
 693
 694impl MentionCompletion {
 695    fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
 696        let last_mention_start = line.rfind('@')?;
 697        if last_mention_start >= line.len() {
 698            return Some(Self::default());
 699        }
 700        if last_mention_start > 0
 701            && line
 702                .chars()
 703                .nth(last_mention_start - 1)
 704                .map_or(false, |c| !c.is_whitespace())
 705        {
 706            return None;
 707        }
 708
 709        let rest_of_line = &line[last_mention_start + 1..];
 710
 711        let mut mode = None;
 712        let mut argument = None;
 713
 714        let mut parts = rest_of_line.split_whitespace();
 715        let mut end = last_mention_start + 1;
 716        if let Some(mode_text) = parts.next() {
 717            end += mode_text.len();
 718
 719            if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok() {
 720                mode = Some(parsed_mode);
 721            } else {
 722                argument = Some(mode_text.to_string());
 723            }
 724            match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) {
 725                Some(whitespace_count) => {
 726                    if let Some(argument_text) = parts.next() {
 727                        argument = Some(argument_text.to_string());
 728                        end += whitespace_count + argument_text.len();
 729                    }
 730                }
 731                None => {
 732                    // Rest of line is entirely whitespace
 733                    end += rest_of_line.len() - mode_text.len();
 734                }
 735            }
 736        }
 737
 738        Some(Self {
 739            source_range: last_mention_start + offset_to_line..end + offset_to_line,
 740            mode,
 741            argument,
 742        })
 743    }
 744}
 745
 746#[cfg(test)]
 747mod tests {
 748    use super::*;
 749    use editor::AnchorRangeExt;
 750    use gpui::{EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext};
 751    use project::{Project, ProjectPath};
 752    use serde_json::json;
 753    use settings::SettingsStore;
 754    use std::ops::Deref;
 755    use util::{path, separator};
 756    use workspace::{AppState, Item};
 757
 758    #[test]
 759    fn test_mention_completion_parse() {
 760        assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None);
 761
 762        assert_eq!(
 763            MentionCompletion::try_parse("Lorem @", 0),
 764            Some(MentionCompletion {
 765                source_range: 6..7,
 766                mode: None,
 767                argument: None,
 768            })
 769        );
 770
 771        assert_eq!(
 772            MentionCompletion::try_parse("Lorem @file", 0),
 773            Some(MentionCompletion {
 774                source_range: 6..11,
 775                mode: Some(ContextPickerMode::File),
 776                argument: None,
 777            })
 778        );
 779
 780        assert_eq!(
 781            MentionCompletion::try_parse("Lorem @file ", 0),
 782            Some(MentionCompletion {
 783                source_range: 6..12,
 784                mode: Some(ContextPickerMode::File),
 785                argument: None,
 786            })
 787        );
 788
 789        assert_eq!(
 790            MentionCompletion::try_parse("Lorem @file main.rs", 0),
 791            Some(MentionCompletion {
 792                source_range: 6..19,
 793                mode: Some(ContextPickerMode::File),
 794                argument: Some("main.rs".to_string()),
 795            })
 796        );
 797
 798        assert_eq!(
 799            MentionCompletion::try_parse("Lorem @file main.rs ", 0),
 800            Some(MentionCompletion {
 801                source_range: 6..19,
 802                mode: Some(ContextPickerMode::File),
 803                argument: Some("main.rs".to_string()),
 804            })
 805        );
 806
 807        assert_eq!(
 808            MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0),
 809            Some(MentionCompletion {
 810                source_range: 6..19,
 811                mode: Some(ContextPickerMode::File),
 812                argument: Some("main.rs".to_string()),
 813            })
 814        );
 815
 816        assert_eq!(
 817            MentionCompletion::try_parse("Lorem @main", 0),
 818            Some(MentionCompletion {
 819                source_range: 6..11,
 820                mode: None,
 821                argument: Some("main".to_string()),
 822            })
 823        );
 824
 825        assert_eq!(MentionCompletion::try_parse("test@", 0), None);
 826    }
 827
 828    struct AtMentionEditor(Entity<Editor>);
 829
 830    impl Item for AtMentionEditor {
 831        type Event = ();
 832
 833        fn include_in_nav_history() -> bool {
 834            false
 835        }
 836    }
 837
 838    impl EventEmitter<()> for AtMentionEditor {}
 839
 840    impl Focusable for AtMentionEditor {
 841        fn focus_handle(&self, cx: &App) -> FocusHandle {
 842            self.0.read(cx).focus_handle(cx).clone()
 843        }
 844    }
 845
 846    impl Render for AtMentionEditor {
 847        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
 848            self.0.clone().into_any_element()
 849        }
 850    }
 851
 852    #[gpui::test]
 853    async fn test_context_completion_provider(cx: &mut TestAppContext) {
 854        init_test(cx);
 855
 856        let app_state = cx.update(AppState::test);
 857
 858        cx.update(|cx| {
 859            language::init(cx);
 860            editor::init(cx);
 861            workspace::init(app_state.clone(), cx);
 862            Project::init_settings(cx);
 863        });
 864
 865        app_state
 866            .fs
 867            .as_fake()
 868            .insert_tree(
 869                path!("/dir"),
 870                json!({
 871                    "editor": "",
 872                    "a": {
 873                        "one.txt": "",
 874                        "two.txt": "",
 875                        "three.txt": "",
 876                        "four.txt": ""
 877                    },
 878                    "b": {
 879                        "five.txt": "",
 880                        "six.txt": "",
 881                        "seven.txt": "",
 882                    }
 883                }),
 884            )
 885            .await;
 886
 887        let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
 888        let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
 889        let workspace = window.root(cx).unwrap();
 890
 891        let worktree = project.update(cx, |project, cx| {
 892            let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
 893            assert_eq!(worktrees.len(), 1);
 894            worktrees.pop().unwrap()
 895        });
 896        let worktree_id = worktree.update(cx, |worktree, _| worktree.id());
 897
 898        let mut cx = VisualTestContext::from_window(*window.deref(), cx);
 899
 900        let paths = vec![
 901            separator!("a/one.txt"),
 902            separator!("a/two.txt"),
 903            separator!("a/three.txt"),
 904            separator!("a/four.txt"),
 905            separator!("b/five.txt"),
 906            separator!("b/six.txt"),
 907            separator!("b/seven.txt"),
 908        ];
 909        for path in paths {
 910            workspace
 911                .update_in(&mut cx, |workspace, window, cx| {
 912                    workspace.open_path(
 913                        ProjectPath {
 914                            worktree_id,
 915                            path: Path::new(path).into(),
 916                        },
 917                        None,
 918                        false,
 919                        window,
 920                        cx,
 921                    )
 922                })
 923                .await
 924                .unwrap();
 925        }
 926
 927        let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
 928            let editor = cx.new(|cx| {
 929                Editor::new(
 930                    editor::EditorMode::full(),
 931                    multi_buffer::MultiBuffer::build_simple("", cx),
 932                    None,
 933                    window,
 934                    cx,
 935                )
 936            });
 937            workspace.active_pane().update(cx, |pane, cx| {
 938                pane.add_item(
 939                    Box::new(cx.new(|_| AtMentionEditor(editor.clone()))),
 940                    true,
 941                    true,
 942                    None,
 943                    window,
 944                    cx,
 945                );
 946            });
 947            editor
 948        });
 949
 950        let context_store = cx.new(|_| ContextStore::new(project.downgrade(), None));
 951
 952        let editor_entity = editor.downgrade();
 953        editor.update_in(&mut cx, |editor, window, cx| {
 954            window.focus(&editor.focus_handle(cx));
 955            editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new(
 956                workspace.downgrade(),
 957                context_store.downgrade(),
 958                None,
 959                editor_entity,
 960            ))));
 961        });
 962
 963        cx.simulate_input("Lorem ");
 964
 965        editor.update(&mut cx, |editor, cx| {
 966            assert_eq!(editor.text(cx), "Lorem ");
 967            assert!(!editor.has_visible_completions_menu());
 968        });
 969
 970        cx.simulate_input("@");
 971
 972        editor.update(&mut cx, |editor, cx| {
 973            assert_eq!(editor.text(cx), "Lorem @");
 974            assert!(editor.has_visible_completions_menu());
 975            assert_eq!(
 976                current_completion_labels(editor),
 977                &[
 978                    "seven.txt dir/b/",
 979                    "six.txt dir/b/",
 980                    "five.txt dir/b/",
 981                    "four.txt dir/a/",
 982                    "Files & Directories",
 983                    "Symbols",
 984                    "Fetch"
 985                ]
 986            );
 987        });
 988
 989        // Select and confirm "File"
 990        editor.update_in(&mut cx, |editor, window, cx| {
 991            assert!(editor.has_visible_completions_menu());
 992            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
 993            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
 994            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
 995            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
 996            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
 997        });
 998
 999        cx.run_until_parked();
1000
1001        editor.update(&mut cx, |editor, cx| {
1002            assert_eq!(editor.text(cx), "Lorem @file ");
1003            assert!(editor.has_visible_completions_menu());
1004        });
1005
1006        cx.simulate_input("one");
1007
1008        editor.update(&mut cx, |editor, cx| {
1009            assert_eq!(editor.text(cx), "Lorem @file one");
1010            assert!(editor.has_visible_completions_menu());
1011            assert_eq!(current_completion_labels(editor), vec!["one.txt dir/a/"]);
1012        });
1013
1014        editor.update_in(&mut cx, |editor, window, cx| {
1015            assert!(editor.has_visible_completions_menu());
1016            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1017        });
1018
1019        editor.update(&mut cx, |editor, cx| {
1020            assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt)",);
1021            assert!(!editor.has_visible_completions_menu());
1022            assert_eq!(
1023                fold_ranges(editor, cx),
1024                vec![Point::new(0, 6)..Point::new(0, 37)]
1025            );
1026        });
1027
1028        cx.simulate_input(" ");
1029
1030        editor.update(&mut cx, |editor, cx| {
1031            assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) ",);
1032            assert!(!editor.has_visible_completions_menu());
1033            assert_eq!(
1034                fold_ranges(editor, cx),
1035                vec![Point::new(0, 6)..Point::new(0, 37)]
1036            );
1037        });
1038
1039        cx.simulate_input("Ipsum ");
1040
1041        editor.update(&mut cx, |editor, cx| {
1042            assert_eq!(
1043                editor.text(cx),
1044                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum ",
1045            );
1046            assert!(!editor.has_visible_completions_menu());
1047            assert_eq!(
1048                fold_ranges(editor, cx),
1049                vec![Point::new(0, 6)..Point::new(0, 37)]
1050            );
1051        });
1052
1053        cx.simulate_input("@file ");
1054
1055        editor.update(&mut cx, |editor, cx| {
1056            assert_eq!(
1057                editor.text(cx),
1058                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum @file ",
1059            );
1060            assert!(editor.has_visible_completions_menu());
1061            assert_eq!(
1062                fold_ranges(editor, cx),
1063                vec![Point::new(0, 6)..Point::new(0, 37)]
1064            );
1065        });
1066
1067        editor.update_in(&mut cx, |editor, window, cx| {
1068            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1069        });
1070
1071        cx.run_until_parked();
1072
1073        editor.update(&mut cx, |editor, cx| {
1074            assert_eq!(
1075                editor.text(cx),
1076                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt)"
1077            );
1078            assert!(!editor.has_visible_completions_menu());
1079            assert_eq!(
1080                fold_ranges(editor, cx),
1081                vec![
1082                    Point::new(0, 6)..Point::new(0, 37),
1083                    Point::new(0, 44)..Point::new(0, 79)
1084                ]
1085            );
1086        });
1087
1088        cx.simulate_input("\n@");
1089
1090        editor.update(&mut cx, |editor, cx| {
1091            assert_eq!(
1092                editor.text(cx),
1093                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt)\n@"
1094            );
1095            assert!(editor.has_visible_completions_menu());
1096            assert_eq!(
1097                fold_ranges(editor, cx),
1098                vec![
1099                    Point::new(0, 6)..Point::new(0, 37),
1100                    Point::new(0, 44)..Point::new(0, 79)
1101                ]
1102            );
1103        });
1104
1105        editor.update_in(&mut cx, |editor, window, cx| {
1106            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1107        });
1108
1109        cx.run_until_parked();
1110
1111        editor.update(&mut cx, |editor, cx| {
1112            assert_eq!(
1113                editor.text(cx),
1114                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt)\n[@six.txt](@file:dir/b/six.txt)"
1115            );
1116            assert!(!editor.has_visible_completions_menu());
1117            assert_eq!(
1118                fold_ranges(editor, cx),
1119                vec![
1120                    Point::new(0, 6)..Point::new(0, 37),
1121                    Point::new(0, 44)..Point::new(0, 79),
1122                    Point::new(1, 0)..Point::new(1, 31)
1123                ]
1124            );
1125        });
1126    }
1127
1128    fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
1129        let snapshot = editor.buffer().read(cx).snapshot(cx);
1130        editor.display_map.update(cx, |display_map, cx| {
1131            display_map
1132                .snapshot(cx)
1133                .folds_in_range(0..snapshot.len())
1134                .map(|fold| fold.range.to_point(&snapshot))
1135                .collect()
1136        })
1137    }
1138
1139    fn current_completion_labels(editor: &Editor) -> Vec<String> {
1140        let completions = editor.current_completions().expect("Missing completions");
1141        completions
1142            .into_iter()
1143            .map(|completion| completion.label.text.to_string())
1144            .collect::<Vec<_>>()
1145    }
1146
1147    pub(crate) fn init_test(cx: &mut TestAppContext) {
1148        cx.update(|cx| {
1149            let store = SettingsStore::test(cx);
1150            cx.set_global(store);
1151            theme::init(theme::LoadThemes::JustBase, cx);
1152            client::init_settings(cx);
1153            language::init(cx);
1154            Project::init_settings(cx);
1155            workspace::init_settings(cx);
1156            editor::init_settings(cx);
1157        });
1158    }
1159}