Introduce `DiagnosticsTool` (#26670)

Antonio Scandurra created

Release Notes:

- N/A

Change summary

crates/assistant_tools/src/assistant_tools.rs              |  14 
crates/assistant_tools/src/diagnostics_tool.rs             | 127 ++++++++
crates/assistant_tools/src/diagnostics_tool/description.md |  16 +
crates/assistant_tools/src/edit_files_tool.rs              |  22 -
crates/assistant_tools/src/list_directory_tool.rs          |  17 
crates/assistant_tools/src/read_file_tool.rs               |  16 
6 files changed, 165 insertions(+), 47 deletions(-)

Detailed changes

crates/assistant_tools/src/assistant_tools.rs 🔗

@@ -1,5 +1,6 @@
 mod bash_tool;
 mod delete_path_tool;
+mod diagnostics_tool;
 mod edit_files_tool;
 mod list_directory_tool;
 mod now_tool;
@@ -12,6 +13,7 @@ use gpui::App;
 
 use crate::bash_tool::BashTool;
 use crate::delete_path_tool::DeletePathTool;
+use crate::diagnostics_tool::DiagnosticsTool;
 use crate::edit_files_tool::EditFilesTool;
 use crate::list_directory_tool::ListDirectoryTool;
 use crate::now_tool::NowTool;
@@ -24,13 +26,13 @@ pub fn init(cx: &mut App) {
     crate::edit_files_tool::log::init(cx);
 
     let registry = ToolRegistry::global(cx);
-    registry.register_tool(NowTool);
-    registry.register_tool(ReadFileTool);
-    registry.register_tool(ListDirectoryTool);
+    registry.register_tool(BashTool);
+    registry.register_tool(DeletePathTool);
+    registry.register_tool(DiagnosticsTool);
     registry.register_tool(EditFilesTool);
+    registry.register_tool(ListDirectoryTool);
+    registry.register_tool(NowTool);
     registry.register_tool(PathSearchTool);
+    registry.register_tool(ReadFileTool);
     registry.register_tool(RegexSearchTool);
-
-    registry.register_tool(DeletePathTool);
-    registry.register_tool(BashTool);
 }

crates/assistant_tools/src/diagnostics_tool.rs 🔗

@@ -0,0 +1,127 @@
+use anyhow::{anyhow, Result};
+use assistant_tool::Tool;
+use gpui::{App, Entity, Task};
+use language::{DiagnosticSeverity, OffsetRangeExt};
+use language_model::LanguageModelRequestMessage;
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::{
+    fmt::Write,
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct DiagnosticsToolInput {
+    /// The path to get diagnostics for. If not provided, returns a project-wide summary.
+    ///
+    /// This path should never be absolute, and the first component
+    /// of the path should always be a root directory in a project.
+    ///
+    /// <example>
+    /// If the project has the following root directories:
+    ///
+    /// - lorem
+    /// - ipsum
+    ///
+    /// If you wanna access diagnostics for `dolor.txt` in `ipsum`, you should use the path `ipsum/dolor.txt`.
+    /// </example>
+    pub path: Option<PathBuf>,
+}
+
+pub struct DiagnosticsTool;
+
+impl Tool for DiagnosticsTool {
+    fn name(&self) -> String {
+        "diagnostics".into()
+    }
+
+    fn description(&self) -> String {
+        include_str!("./diagnostics_tool/description.md").into()
+    }
+
+    fn input_schema(&self) -> serde_json::Value {
+        let schema = schemars::schema_for!(DiagnosticsToolInput);
+        serde_json::to_value(&schema).unwrap()
+    }
+
+    fn run(
+        self: Arc<Self>,
+        input: serde_json::Value,
+        _messages: &[LanguageModelRequestMessage],
+        project: Entity<Project>,
+        cx: &mut App,
+    ) -> Task<Result<String>> {
+        let input = match serde_json::from_value::<DiagnosticsToolInput>(input) {
+            Ok(input) => input,
+            Err(err) => return Task::ready(Err(anyhow!(err))),
+        };
+
+        if let Some(path) = input.path {
+            let Some(project_path) = project.read(cx).find_project_path(&path, cx) else {
+                return Task::ready(Err(anyhow!("Could not find path in project")));
+            };
+            let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx));
+
+            cx.spawn(|cx| async move {
+                let mut output = String::new();
+                let buffer = buffer.await?;
+                let snapshot = buffer.read_with(&cx, |buffer, _cx| buffer.snapshot())?;
+
+                for (_, group) in snapshot.diagnostic_groups(None) {
+                    let entry = &group.entries[group.primary_ix];
+                    let range = entry.range.to_point(&snapshot);
+                    let severity = match entry.diagnostic.severity {
+                        DiagnosticSeverity::ERROR => "error",
+                        DiagnosticSeverity::WARNING => "warning",
+                        _ => continue,
+                    };
+
+                    writeln!(
+                        output,
+                        "{} at line {}: {}",
+                        severity,
+                        range.start.row + 1,
+                        entry.diagnostic.message
+                    )?;
+                }
+
+                if output.is_empty() {
+                    Ok("File doesn't have errors or warnings!".to_string())
+                } else {
+                    Ok(output)
+                }
+            })
+        } else {
+            let project = project.read(cx);
+            let mut output = String::new();
+            let mut has_diagnostics = false;
+
+            for (project_path, _, summary) in project.diagnostic_summaries(true, cx) {
+                if summary.error_count > 0 || summary.warning_count > 0 {
+                    let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx)
+                    else {
+                        continue;
+                    };
+
+                    has_diagnostics = true;
+                    output.push_str(&format!(
+                        "{}: {} error(s), {} warning(s)\n",
+                        Path::new(worktree.read(cx).root_name())
+                            .join(project_path.path)
+                            .display(),
+                        summary.error_count,
+                        summary.warning_count
+                    ));
+                }
+            }
+
+            if has_diagnostics {
+                Task::ready(Ok(output))
+            } else {
+                Task::ready(Ok("No errors or warnings found in the project.".to_string()))
+            }
+        }
+    }
+}

crates/assistant_tools/src/diagnostics_tool/description.md 🔗

@@ -0,0 +1,16 @@
+Get errors and warnings for the project or a specific file.
+
+This tool can be invoked after a series of edits to determine if further edits are necessary, or if the user asks to fix errors or warnings in their codebase.
+
+When a path is provided, shows all diagnostics for that specific file.
+When no path is provided, shows a summary of error and warning counts for all files in the project.
+
+<example>
+To get diagnostics for a specific file:
+{
+    "path": "src/main.rs"
+}
+
+To get a project-wide diagnostic summary:
+{}
+</example>

crates/assistant_tools/src/edit_files_tool.rs 🔗

@@ -11,7 +11,7 @@ use language_model::{
     LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
 };
 use log::{EditToolLog, EditToolRequestId};
-use project::{Project, ProjectPath};
+use project::Project;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::fmt::Write;
@@ -178,23 +178,9 @@ impl EditFilesTool {
 
                 for action in new_actions {
                     let project_path = project.read_with(&cx, |project, cx| {
-                        let worktree_root_name = action
-                            .file_path()
-                            .components()
-                            .next()
-                            .context("Invalid path")?;
-                        let worktree = project
-                            .worktree_for_root_name(
-                                &worktree_root_name.as_os_str().to_string_lossy(),
-                                cx,
-                            )
-                            .context("Directory not found in project")?;
-                        anyhow::Ok(ProjectPath {
-                            worktree_id: worktree.read(cx).id(),
-                            path: Arc::from(
-                                action.file_path().strip_prefix(worktree_root_name).unwrap(),
-                            ),
-                        })
+                        project
+                            .find_project_path(action.file_path(), cx)
+                            .context("Path not found in project")
                     })??;
 
                     let buffer = project

crates/assistant_tools/src/list_directory_tool.rs 🔗

@@ -62,19 +62,18 @@ impl Tool for ListDirectoryTool {
             Err(err) => return Task::ready(Err(anyhow!(err))),
         };
 
-        let Some(worktree_root_name) = input.path.components().next() else {
-            return Task::ready(Err(anyhow!("Invalid path")));
+        let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else {
+            return Task::ready(Err(anyhow!("Path not found in project")));
         };
         let Some(worktree) = project
             .read(cx)
-            .worktree_for_root_name(&worktree_root_name.as_os_str().to_string_lossy(), cx)
+            .worktree_for_id(project_path.worktree_id, cx)
         else {
-            return Task::ready(Err(anyhow!("Directory not found in the project")));
+            return Task::ready(Err(anyhow!("Worktree not found")));
         };
-        let path = input.path.strip_prefix(worktree_root_name).unwrap();
         let worktree = worktree.read(cx);
 
-        let Some(entry) = worktree.entry_for_path(path) else {
+        let Some(entry) = worktree.entry_for_path(&project_path.path) else {
             return Task::ready(Err(anyhow!("Path not found: {}", input.path.display())));
         };
 
@@ -83,13 +82,11 @@ impl Tool for ListDirectoryTool {
         }
 
         let mut output = String::new();
-        for entry in worktree.child_entries(path) {
+        for entry in worktree.child_entries(&project_path.path) {
             writeln!(
                 output,
                 "{}",
-                Path::new(worktree_root_name.as_os_str())
-                    .join(&entry.path)
-                    .display(),
+                Path::new(worktree.root_name()).join(&entry.path).display(),
             )
             .unwrap();
         }

crates/assistant_tools/src/read_file_tool.rs 🔗

@@ -5,7 +5,7 @@ use anyhow::{anyhow, Result};
 use assistant_tool::Tool;
 use gpui::{App, Entity, Task};
 use language_model::LanguageModelRequestMessage;
-use project::{Project, ProjectPath};
+use project::Project;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 
@@ -56,18 +56,8 @@ impl Tool for ReadFileTool {
             Err(err) => return Task::ready(Err(anyhow!(err))),
         };
 
-        let Some(worktree_root_name) = input.path.components().next() else {
-            return Task::ready(Err(anyhow!("Invalid path")));
-        };
-        let Some(worktree) = project
-            .read(cx)
-            .worktree_for_root_name(&worktree_root_name.as_os_str().to_string_lossy(), cx)
-        else {
-            return Task::ready(Err(anyhow!("Directory not found in the project")));
-        };
-        let project_path = ProjectPath {
-            worktree_id: worktree.read(cx).id(),
-            path: Arc::from(input.path.strip_prefix(worktree_root_name).unwrap()),
+        let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else {
+            return Task::ready(Err(anyhow!("Path not found in project")));
         };
         cx.spawn(|cx| async move {
             let buffer = cx