use super::text_edit::build_validation_outcome;
use super::{FinalizeEditParams, ValidationOutcome};
use crate::server::helpers::{io_error_data, pathfinder_to_error_data};
use crate::server::types::{EditResponse, EditValidation};
use pathfinder_common::error::{compute_lines_changed, PathfinderError};
use pathfinder_common::types::{SemanticPath, VersionHash};
use pathfinder_lsp::types::{FileChangeType, FileEvent};
use pathfinder_lsp::LspError;
use rmcp::handler::server::wrapper::Json;
use rmcp::model::ErrorData;
use std::path::Path;
impl crate::server::PathfinderServer {
pub(crate) fn lsp_error_to_skip_reason(e: &LspError) -> &'static str {
match e {
LspError::NoLspAvailable => "no_lsp",
LspError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => {
"lsp_not_on_path"
}
LspError::Io(_) => "lsp_start_failed",
LspError::ConnectionLost => "lsp_crash",
LspError::Timeout { .. } => "lsp_timeout",
LspError::UnsupportedCapability { .. } => "pull_diagnostics_unsupported",
LspError::Protocol(_) => "lsp_protocol_error",
}
}
pub(crate) async fn lsp_open_and_pre_diags(
&self,
workspace: &Path,
relative: &Path,
original_content: &str,
) -> Result<Vec<pathfinder_lsp::types::LspDiagnostic>, &'static str> {
if let Err(e) = self
.lawyer
.did_open(workspace, relative, original_content)
.await
{
let skipped_reason = Self::lsp_error_to_skip_reason(&e);
let should_log = !matches!(
&e,
LspError::NoLspAvailable | LspError::UnsupportedCapability { .. }
);
if should_log {
tracing::warn!(error = %e, "validation: did_open failed");
}
return Err(skipped_reason);
}
let mut pre_diags = match self.lawyer.pull_diagnostics(workspace, relative).await {
Ok(d) => d,
Err(LspError::UnsupportedCapability { .. }) => {
let _ = self.lawyer.did_close(workspace, relative).await;
return Err("pull_diagnostics_unsupported");
}
Err(e) => {
let skipped_reason = Self::lsp_error_to_skip_reason(&e);
tracing::warn!(error = %e, "validation: pre-edit pull_diagnostics failed");
let _ = self.lawyer.did_close(workspace, relative).await;
return Err(skipped_reason);
}
};
match self
.lawyer
.pull_workspace_diagnostics(workspace, relative)
.await
{
Ok(workspace_diags) => pre_diags.extend(workspace_diags),
Err(LspError::UnsupportedCapability { .. } | LspError::NoLspAvailable) => {
}
Err(e) => {
tracing::warn!(error = %e, "validation: pre-edit pull_workspace_diagnostics failed, continuing with single-file diags");
}
}
Ok(pre_diags)
}
pub(crate) async fn lsp_change_and_post_diags(
&self,
workspace: &Path,
relative: &Path,
new_content: &str,
) -> Result<Vec<pathfinder_lsp::types::LspDiagnostic>, &'static str> {
if let Err(e) = self
.lawyer
.did_change(workspace, relative, new_content, 2)
.await
{
let skipped_reason = Self::lsp_error_to_skip_reason(&e);
tracing::warn!(error = %e, "validation: did_change failed");
let _ = self.lawyer.did_close(workspace, relative).await;
return Err(skipped_reason);
}
let mut post_diags = match self.lawyer.pull_diagnostics(workspace, relative).await {
Ok(d) => d,
Err(e) => {
let skipped_reason = Self::lsp_error_to_skip_reason(&e);
tracing::warn!(error = %e, "validation: post-edit pull_diagnostics failed");
let _ = self.lawyer.did_close(workspace, relative).await;
return Err(skipped_reason);
}
};
match self
.lawyer
.pull_workspace_diagnostics(workspace, relative)
.await
{
Ok(workspace_diags) => post_diags.extend(workspace_diags),
Err(LspError::UnsupportedCapability { .. } | LspError::NoLspAvailable) => {}
Err(e) => {
tracing::warn!(error = %e, "validation: post-edit pull_workspace_diagnostics failed, continuing with single-file diags");
}
}
Ok(post_diags)
}
pub(crate) async fn lsp_revert_and_close(
&self,
workspace: &Path,
relative: &Path,
original_content: &str,
) {
let _ = self
.lawyer
.did_change(workspace, relative, original_content, 3)
.await;
let _ = self.lawyer.did_close(workspace, relative).await;
}
pub(crate) async fn run_lsp_validation(
&self,
file_path: &Path,
original_content: &str,
new_content: &str,
ignore_validation_failures: bool,
) -> ValidationOutcome {
let relative = file_path;
let workspace = self.workspace_root.path();
let return_skip = |reason: &str| -> ValidationOutcome {
let ext = relative.extension().and_then(|e| e.to_str()).unwrap_or("");
let lang = pathfinder_lsp::client::language_id_for_extension(ext).unwrap_or("unknown");
tracing::debug!(
file = %relative.display(),
skip_reason = reason,
language = lang,
"validation skip"
);
ValidationOutcome {
validation: EditValidation::skipped(),
skipped: true,
skipped_reason: Some(reason.to_owned()),
should_block: false,
}
};
let pre_diags = match self
.lsp_open_and_pre_diags(workspace, relative, original_content)
.await
{
Ok(d) => d,
Err(reason) => return return_skip(reason),
};
let post_diags = match self
.lsp_change_and_post_diags(workspace, relative, new_content)
.await
{
Ok(d) => d,
Err(reason) => {
self.lsp_revert_and_close(workspace, relative, original_content)
.await;
return return_skip(reason);
}
};
self.lsp_revert_and_close(workspace, relative, original_content)
.await;
build_validation_outcome(
&pre_diags,
&post_diags,
ignore_validation_failures,
file_path,
)
}
pub(crate) async fn flush_edit_with_toctou(
&self,
semantic_path: &SemanticPath,
current_hash: &VersionHash,
source: &[u8],
new_bytes: &[u8],
) -> Result<VersionHash, ErrorData> {
let absolute_path = self.workspace_root.resolve(&semantic_path.file_path);
let disk_bytes = tokio::fs::read(&absolute_path)
.await
.map_err(|e| io_error_data(format!("TOCTOU re-read failed: {e}")))?;
let disk_hash = VersionHash::compute(&disk_bytes);
if disk_hash != *current_hash {
let prior_str = String::from_utf8_lossy(source);
let late_str = String::from_utf8_lossy(&disk_bytes);
let delta = compute_lines_changed(&prior_str, &late_str);
let err = PathfinderError::VersionMismatch {
path: semantic_path.file_path.clone(),
current_version_hash: disk_hash.as_str().to_owned(),
lines_changed: Some(delta),
};
return Err(pathfinder_to_error_data(&err));
}
tokio::fs::write(&absolute_path, new_bytes)
.await
.map_err(|e| io_error_data(format!("write failed: {e}")))?;
if let Ok(uri) = url::Url::from_file_path(&absolute_path) {
let event = FileEvent {
uri: uri.to_string(),
change_type: FileChangeType::Changed,
};
if let Err(e) = self.lawyer.did_change_watched_files(vec![event]).await {
tracing::warn!(error = %e, "Failed to broadcast didChangeWatchedFiles on edit");
}
}
self.surgeon.invalidate_cache(&semantic_path.file_path);
Ok(VersionHash::compute(new_bytes))
}
pub(crate) async fn finalize_edit(
&self,
params: FinalizeEditParams<'_>,
) -> Result<Json<EditResponse>, ErrorData> {
let validate_start = std::time::Instant::now();
let original_str = std::str::from_utf8(params.source);
let new_str = std::str::from_utf8(¶ms.new_content);
let validation_outcome = match (original_str, new_str) {
(Ok(orig), Ok(new)) => {
self.run_lsp_validation(
¶ms.semantic_path.file_path,
orig,
new,
params.ignore_validation_failures,
)
.await
}
_ => ValidationOutcome {
validation: EditValidation::skipped(),
skipped: true,
skipped_reason: Some("utf8_error".to_owned()),
should_block: false,
},
};
let validate_ms = validate_start.elapsed().as_millis();
if validation_outcome.should_block {
let introduced = validation_outcome.validation.introduced_errors.clone();
let err = PathfinderError::ValidationFailed {
count: introduced.len(),
introduced_errors: introduced,
};
return Err(pathfinder_to_error_data(&err));
}
let flush_start = std::time::Instant::now();
let new_hash = self
.flush_edit_with_toctou(
params.semantic_path,
params.original_hash,
params.source,
¶ms.new_content,
)
.await?;
let flush_ms = flush_start.elapsed().as_millis();
let engines_used = if validation_outcome.skipped {
vec!["tree-sitter"]
} else {
vec!["tree-sitter", "lsp"]
};
let duration_ms = params.start_time.elapsed().as_millis();
tracing::info!(
tool = params.tool_name,
semantic_path = %params.raw_semantic_path_str,
duration_ms,
resolve_ms = params.resolve_ms,
validate_ms,
flush_ms,
new_version_hash = new_hash.as_str(),
engines_used = ?engines_used,
ignore_validation_failures = params.ignore_validation_failures,
"{}: complete",
params.tool_name
);
Ok(Json(EditResponse {
success: true,
new_version_hash: Some(new_hash.as_str().to_owned()),
formatted: false,
validation: validation_outcome.validation,
validation_skipped: validation_outcome.skipped,
validation_skipped_reason: validation_outcome.skipped_reason,
}))
}
}