docs_command.rs

  1use std::path::Path;
  2use std::sync::atomic::AtomicBool;
  3use std::sync::Arc;
  4use std::time::Duration;
  5
  6use anyhow::{anyhow, bail, Result};
  7use assistant_slash_command::{
  8    ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
  9};
 10use feature_flags::FeatureFlag;
 11use gpui::{AppContext, BackgroundExecutor, Model, Task, WeakView};
 12use indexed_docs::{
 13    DocsDotRsProvider, IndexedDocsRegistry, IndexedDocsStore, LocalRustdocProvider, PackageName,
 14    ProviderId,
 15};
 16use language::LspAdapterDelegate;
 17use project::{Project, ProjectPath};
 18use ui::prelude::*;
 19use util::{maybe, ResultExt};
 20use workspace::Workspace;
 21
 22pub(crate) struct DocsSlashCommandFeatureFlag;
 23
 24impl FeatureFlag for DocsSlashCommandFeatureFlag {
 25    const NAME: &'static str = "docs-slash-command";
 26}
 27
 28pub(crate) struct DocsSlashCommand;
 29
 30impl DocsSlashCommand {
 31    pub const NAME: &'static str = "docs";
 32
 33    fn path_to_cargo_toml(project: Model<Project>, cx: &mut AppContext) -> Option<Arc<Path>> {
 34        let worktree = project.read(cx).worktrees(cx).next()?;
 35        let worktree = worktree.read(cx);
 36        let entry = worktree.entry_for_path("Cargo.toml")?;
 37        let path = ProjectPath {
 38            worktree_id: worktree.id(),
 39            path: entry.path.clone(),
 40        };
 41        Some(Arc::from(
 42            project.read(cx).absolute_path(&path, cx)?.as_path(),
 43        ))
 44    }
 45
 46    /// Ensures that the indexed doc providers for Rust are registered.
 47    ///
 48    /// Ideally we would do this sooner, but we need to wait until we're able to
 49    /// access the workspace so we can read the project.
 50    fn ensure_rust_doc_providers_are_registered(
 51        &self,
 52        workspace: Option<WeakView<Workspace>>,
 53        cx: &mut AppContext,
 54    ) {
 55        let indexed_docs_registry = IndexedDocsRegistry::global(cx);
 56        if indexed_docs_registry
 57            .get_provider_store(LocalRustdocProvider::id())
 58            .is_none()
 59        {
 60            let index_provider_deps = maybe!({
 61                let workspace = workspace.clone().ok_or_else(|| anyhow!("no workspace"))?;
 62                let workspace = workspace
 63                    .upgrade()
 64                    .ok_or_else(|| anyhow!("workspace was dropped"))?;
 65                let project = workspace.read(cx).project().clone();
 66                let fs = project.read(cx).fs().clone();
 67                let cargo_workspace_root = Self::path_to_cargo_toml(project, cx)
 68                    .and_then(|path| path.parent().map(|path| path.to_path_buf()))
 69                    .ok_or_else(|| anyhow!("no Cargo workspace root found"))?;
 70
 71                anyhow::Ok((fs, cargo_workspace_root))
 72            });
 73
 74            if let Some((fs, cargo_workspace_root)) = index_provider_deps.log_err() {
 75                indexed_docs_registry.register_provider(Box::new(LocalRustdocProvider::new(
 76                    fs,
 77                    cargo_workspace_root,
 78                )));
 79            }
 80        }
 81
 82        if indexed_docs_registry
 83            .get_provider_store(DocsDotRsProvider::id())
 84            .is_none()
 85        {
 86            let http_client = maybe!({
 87                let workspace = workspace.ok_or_else(|| anyhow!("no workspace"))?;
 88                let workspace = workspace
 89                    .upgrade()
 90                    .ok_or_else(|| anyhow!("workspace was dropped"))?;
 91                let project = workspace.read(cx).project().clone();
 92                anyhow::Ok(project.read(cx).client().http_client().clone())
 93            });
 94
 95            if let Some(http_client) = http_client.log_err() {
 96                indexed_docs_registry
 97                    .register_provider(Box::new(DocsDotRsProvider::new(http_client)));
 98            }
 99        }
100    }
101
102    /// Runs just-in-time indexing for a given package, in case the slash command
103    /// is run without any entries existing in the index.
104    fn run_just_in_time_indexing(
105        store: Arc<IndexedDocsStore>,
106        key: String,
107        package: PackageName,
108        executor: BackgroundExecutor,
109    ) -> Task<()> {
110        executor.clone().spawn(async move {
111            let (prefix, needs_full_index) = if let Some((prefix, _)) = key.split_once('*') {
112                // If we have a wildcard in the search, we want to wait until
113                // we've completely finished indexing so we get a full set of
114                // results for the wildcard.
115                (prefix.to_string(), true)
116            } else {
117                (key, false)
118            };
119
120            // If we already have some entries, we assume that we've indexed the package before
121            // and don't need to do it again.
122            let has_any_entries = store
123                .any_with_prefix(prefix.clone())
124                .await
125                .unwrap_or_default();
126            if has_any_entries {
127                return ();
128            };
129
130            let index_task = store.clone().index(package.clone());
131
132            if needs_full_index {
133                _ = index_task.await;
134            } else {
135                loop {
136                    executor.timer(Duration::from_millis(200)).await;
137
138                    if store
139                        .any_with_prefix(prefix.clone())
140                        .await
141                        .unwrap_or_default()
142                        || !store.is_indexing(&package)
143                    {
144                        break;
145                    }
146                }
147            }
148        })
149    }
150}
151
152impl SlashCommand for DocsSlashCommand {
153    fn name(&self) -> String {
154        Self::NAME.into()
155    }
156
157    fn description(&self) -> String {
158        "insert docs".into()
159    }
160
161    fn menu_text(&self) -> String {
162        "Insert Documentation".into()
163    }
164
165    fn requires_argument(&self) -> bool {
166        true
167    }
168
169    fn complete_argument(
170        self: Arc<Self>,
171        query: String,
172        _cancel: Arc<AtomicBool>,
173        workspace: Option<WeakView<Workspace>>,
174        cx: &mut AppContext,
175    ) -> Task<Result<Vec<ArgumentCompletion>>> {
176        self.ensure_rust_doc_providers_are_registered(workspace, cx);
177
178        let indexed_docs_registry = IndexedDocsRegistry::global(cx);
179        let args = DocsSlashCommandArgs::parse(&query);
180        let store = args
181            .provider()
182            .ok_or_else(|| anyhow!("no docs provider specified"))
183            .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
184        cx.background_executor().spawn(async move {
185            fn build_completions(
186                provider: ProviderId,
187                items: Vec<String>,
188            ) -> Vec<ArgumentCompletion> {
189                items
190                    .into_iter()
191                    .map(|item| ArgumentCompletion {
192                        label: item.clone(),
193                        new_text: format!("{provider} {item}"),
194                        run_command: true,
195                    })
196                    .collect()
197            }
198
199            match args {
200                DocsSlashCommandArgs::NoProvider => {
201                    let providers = indexed_docs_registry.list_providers();
202                    if providers.is_empty() {
203                        return Ok(vec![ArgumentCompletion {
204                            label: "No available docs providers.".to_string(),
205                            new_text: String::new(),
206                            run_command: false,
207                        }]);
208                    }
209
210                    Ok(providers
211                        .into_iter()
212                        .map(|provider| ArgumentCompletion {
213                            label: provider.to_string(),
214                            new_text: provider.to_string(),
215                            run_command: false,
216                        })
217                        .collect())
218                }
219                DocsSlashCommandArgs::SearchPackageDocs {
220                    provider,
221                    package,
222                    index,
223                } => {
224                    let store = store?;
225
226                    if index {
227                        // We don't need to hold onto this task, as the `IndexedDocsStore` will hold it
228                        // until it completes.
229                        drop(store.clone().index(package.as_str().into()));
230                    }
231
232                    let items = store.search(package).await;
233                    Ok(build_completions(provider, items))
234                }
235                DocsSlashCommandArgs::SearchItemDocs {
236                    provider,
237                    item_path,
238                    ..
239                } => {
240                    let store = store?;
241                    let items = store.search(item_path).await;
242                    Ok(build_completions(provider, items))
243                }
244            }
245        })
246    }
247
248    fn run(
249        self: Arc<Self>,
250        argument: Option<&str>,
251        _workspace: WeakView<Workspace>,
252        _delegate: Option<Arc<dyn LspAdapterDelegate>>,
253        cx: &mut WindowContext,
254    ) -> Task<Result<SlashCommandOutput>> {
255        let Some(argument) = argument else {
256            return Task::ready(Err(anyhow!("missing argument")));
257        };
258
259        let args = DocsSlashCommandArgs::parse(argument);
260        let executor = cx.background_executor().clone();
261        let task = cx.background_executor().spawn({
262            let store = args
263                .provider()
264                .ok_or_else(|| anyhow!("no docs provider specified"))
265                .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
266            async move {
267                let (provider, key) = match args.clone() {
268                    DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"),
269                    DocsSlashCommandArgs::SearchPackageDocs {
270                        provider, package, ..
271                    } => (provider, package),
272                    DocsSlashCommandArgs::SearchItemDocs {
273                        provider,
274                        item_path,
275                        ..
276                    } => (provider, item_path),
277                };
278
279                let store = store?;
280
281                if let Some(package) = args.package() {
282                    Self::run_just_in_time_indexing(store.clone(), key.clone(), package, executor)
283                        .await;
284                }
285
286                let (text, ranges) = if let Some((prefix, _)) = key.split_once('*') {
287                    let docs = store.load_many_by_prefix(prefix.to_string()).await?;
288
289                    let mut text = String::new();
290                    let mut ranges = Vec::new();
291
292                    for (key, docs) in docs {
293                        let prev_len = text.len();
294
295                        text.push_str(&docs.0);
296                        text.push_str("\n");
297                        ranges.push((key, prev_len..text.len()));
298                        text.push_str("\n");
299                    }
300
301                    (text, ranges)
302                } else {
303                    let item_docs = store.load(key.clone()).await?;
304                    let text = item_docs.to_string();
305                    let range = 0..text.len();
306
307                    (text, vec![(key, range)])
308                };
309
310                anyhow::Ok((provider, text, ranges))
311            }
312        });
313
314        cx.foreground_executor().spawn(async move {
315            let (provider, text, ranges) = task.await?;
316            Ok(SlashCommandOutput {
317                text,
318                sections: ranges
319                    .into_iter()
320                    .map(|(key, range)| SlashCommandOutputSection {
321                        range,
322                        icon: IconName::FileDoc,
323                        label: format!("docs ({provider}): {key}",).into(),
324                    })
325                    .collect(),
326                run_commands_in_text: false,
327            })
328        })
329    }
330}
331
332fn is_item_path_delimiter(char: char) -> bool {
333    !char.is_alphanumeric() && char != '-' && char != '_'
334}
335
336#[derive(Debug, PartialEq, Clone)]
337pub(crate) enum DocsSlashCommandArgs {
338    NoProvider,
339    SearchPackageDocs {
340        provider: ProviderId,
341        package: String,
342        index: bool,
343    },
344    SearchItemDocs {
345        provider: ProviderId,
346        package: String,
347        item_path: String,
348    },
349}
350
351impl DocsSlashCommandArgs {
352    pub fn parse(argument: &str) -> Self {
353        let Some((provider, argument)) = argument.split_once(' ') else {
354            return Self::NoProvider;
355        };
356
357        let provider = ProviderId(provider.into());
358
359        if let Some((package, rest)) = argument.split_once(is_item_path_delimiter) {
360            if rest.trim().is_empty() {
361                Self::SearchPackageDocs {
362                    provider,
363                    package: package.to_owned(),
364                    index: true,
365                }
366            } else {
367                Self::SearchItemDocs {
368                    provider,
369                    package: package.to_owned(),
370                    item_path: argument.to_owned(),
371                }
372            }
373        } else {
374            Self::SearchPackageDocs {
375                provider,
376                package: argument.to_owned(),
377                index: false,
378            }
379        }
380    }
381
382    pub fn provider(&self) -> Option<ProviderId> {
383        match self {
384            Self::NoProvider => None,
385            Self::SearchPackageDocs { provider, .. } | Self::SearchItemDocs { provider, .. } => {
386                Some(provider.clone())
387            }
388        }
389    }
390
391    pub fn package(&self) -> Option<PackageName> {
392        match self {
393            Self::NoProvider => None,
394            Self::SearchPackageDocs { package, .. } | Self::SearchItemDocs { package, .. } => {
395                Some(package.as_str().into())
396            }
397        }
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn test_parse_docs_slash_command_args() {
407        assert_eq!(
408            DocsSlashCommandArgs::parse(""),
409            DocsSlashCommandArgs::NoProvider
410        );
411        assert_eq!(
412            DocsSlashCommandArgs::parse("rustdoc"),
413            DocsSlashCommandArgs::NoProvider
414        );
415
416        assert_eq!(
417            DocsSlashCommandArgs::parse("rustdoc "),
418            DocsSlashCommandArgs::SearchPackageDocs {
419                provider: ProviderId("rustdoc".into()),
420                package: "".into(),
421                index: false
422            }
423        );
424        assert_eq!(
425            DocsSlashCommandArgs::parse("gleam "),
426            DocsSlashCommandArgs::SearchPackageDocs {
427                provider: ProviderId("gleam".into()),
428                package: "".into(),
429                index: false
430            }
431        );
432
433        assert_eq!(
434            DocsSlashCommandArgs::parse("rustdoc gpui"),
435            DocsSlashCommandArgs::SearchPackageDocs {
436                provider: ProviderId("rustdoc".into()),
437                package: "gpui".into(),
438                index: false,
439            }
440        );
441        assert_eq!(
442            DocsSlashCommandArgs::parse("gleam gleam_stdlib"),
443            DocsSlashCommandArgs::SearchPackageDocs {
444                provider: ProviderId("gleam".into()),
445                package: "gleam_stdlib".into(),
446                index: false
447            }
448        );
449
450        // Adding an item path delimiter indicates we can start indexing.
451        assert_eq!(
452            DocsSlashCommandArgs::parse("rustdoc gpui:"),
453            DocsSlashCommandArgs::SearchPackageDocs {
454                provider: ProviderId("rustdoc".into()),
455                package: "gpui".into(),
456                index: true,
457            }
458        );
459        assert_eq!(
460            DocsSlashCommandArgs::parse("gleam gleam_stdlib/"),
461            DocsSlashCommandArgs::SearchPackageDocs {
462                provider: ProviderId("gleam".into()),
463                package: "gleam_stdlib".into(),
464                index: true
465            }
466        );
467
468        assert_eq!(
469            DocsSlashCommandArgs::parse("rustdoc gpui::foo::bar::Baz"),
470            DocsSlashCommandArgs::SearchItemDocs {
471                provider: ProviderId("rustdoc".into()),
472                package: "gpui".into(),
473                item_path: "gpui::foo::bar::Baz".into()
474            }
475        );
476        assert_eq!(
477            DocsSlashCommandArgs::parse("gleam gleam_stdlib/gleam/int"),
478            DocsSlashCommandArgs::SearchItemDocs {
479                provider: ProviderId("gleam".into()),
480                package: "gleam_stdlib".into(),
481                item_path: "gleam_stdlib/gleam/int".into()
482            }
483        );
484    }
485}