use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use dashmap::DashMap;
use log::debug;
use serde::{Deserialize, Serialize};
use tower_lsp::jsonrpc::Result as RpcResult;
use tower_lsp::lsp_types::*;
use tower_lsp::{Client, LanguageServer, LspService, Server};
use crate::config::Config;
use crate::exit_codes::*;
use crate::output::{FileCentrality, FunctionInfo, IrFinding};
use crate::session::{WorkspaceScanner, build_ir_cached, compute_workspace_id, get_git_remote};
use unfault_core::IntermediateRepresentation;
use unfault_core::graph::GraphNode;
use unfault_core::types::{FilePatch, PatchRange};
pub struct LspArgs {
pub verbose: bool,
}
#[derive(Clone, Debug)]
struct CachedFinding {
finding: IrFinding,
#[allow(dead_code)]
version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileCentralityNotification {
pub path: String,
pub in_degree: i32,
pub out_degree: i32,
pub importance_score: i32,
pub total_files: i32,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileDependenciesNotification {
pub path: String,
pub direct_dependents: Vec<String>,
pub all_dependents: Vec<String>,
pub total_count: i32,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionImpactCaller {
pub name: String,
pub file: String,
pub depth: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionImpactRoute {
pub method: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionImpactFinding {
pub severity: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "learnMore")]
pub learn_more: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetFunctionImpactRequest {
pub uri: String,
#[serde(rename = "functionName")]
pub function_name: String,
pub position: Position,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetFunctionImpactResponse {
pub name: String,
pub callers: Vec<FunctionImpactCaller>,
pub routes: Vec<FunctionImpactRoute>,
pub findings: Vec<FunctionImpactFinding>,
}
#[cfg(test)]
fn normalize_severity(severity: &str) -> String {
match severity.to_lowercase().as_str() {
"critical" | "high" => "error".to_string(),
"medium" => "warning".to_string(),
_ => "info".to_string(),
}
}
struct UnfaultLsp {
client: Client,
config: Option<Config>,
workspace_root: Arc<tokio::sync::RwLock<Option<PathBuf>>>,
workspace_id: Arc<tokio::sync::RwLock<Option<String>>>,
findings_cache: DashMap<Url, Vec<CachedFinding>>,
document_cache: scc::HashMap<Url, String>,
function_cache: DashMap<Url, Vec<FunctionInfo>>,
centrality_cache: Arc<tokio::sync::RwLock<Option<Vec<FileCentrality>>>>,
verbose: bool,
}
impl UnfaultLsp {
fn new(client: Client, verbose: bool) -> Self {
let config = crate::config::Config::load().ok();
if verbose {
eprintln!("[unfault-lsp] Config loaded: {}", config.is_some());
}
Self {
client,
config,
workspace_root: Arc::new(tokio::sync::RwLock::new(None)),
workspace_id: Arc::new(tokio::sync::RwLock::new(None)),
findings_cache: DashMap::new(),
document_cache: scc::HashMap::new(),
function_cache: DashMap::new(),
centrality_cache: Arc::new(tokio::sync::RwLock::new(None)),
verbose,
}
}
fn log_debug(&self, message: &str) {
if self.verbose {
eprintln!("[unfault-lsp] {}", message);
}
}
async fn analyze_document(
&self,
uri: &Url,
text: &str,
version: i32,
prebuilt_ir: Option<IntermediateRepresentation>,
) {
self.log_debug(&format!("Analyzing document: {}", uri));
let file_path = match uri.to_file_path() {
Ok(path) => path,
Err(_) => {
self.log_debug("Could not convert URI to file path, skipping analysis");
return;
}
};
let ide_workspace_root = {
let root = self.workspace_root.read().await;
root.clone()
};
let project_root = find_project_root(&file_path, ide_workspace_root.as_ref())
.unwrap_or_else(|| {
ide_workspace_root
.clone()
.unwrap_or_else(|| file_path.parent().unwrap_or(&file_path).to_path_buf())
});
self.log_debug(&format!("Using project root: {:?}", project_root));
let ir = match prebuilt_ir {
Some(ir) => {
self.log_debug("Using pre-built IR (skipping rebuild)");
ir
}
None => match build_ir_cached(&project_root, None, self.verbose) {
Ok(result) => result.ir,
Err(e) => {
let msg = format!("Failed to build IR: {}", e);
self.log_debug(&msg);
self.client.show_message(MessageType::WARNING, msg).await;
return;
}
},
};
let ir_size = ir.semantics.len();
if ir_size > 500 {
self.log_debug(&format!("Large project: {} files", ir_size));
}
debug!("[LSP] === IR Statistics for Analysis ===");
debug!("[LSP] Semantics count: {}", ir.semantics.len());
let graph_stats = ir.graph.stats();
debug!("[LSP] Graph file_count: {}", graph_stats.file_count);
debug!("[LSP] Graph function_count: {}", graph_stats.function_count);
debug!("[LSP] Graph class_count: {}", graph_stats.class_count);
debug!(
"[LSP] Graph external_module_count: {}",
graph_stats.external_module_count
);
debug!(
"[LSP] Graph import_edge_count: {}",
graph_stats.import_edge_count
);
debug!(
"[LSP] Graph contains_edge_count: {}",
graph_stats.contains_edge_count
);
debug!(
"[LSP] Graph uses_library_edge_count: {}",
graph_stats.uses_library_edge_count
);
debug!(
"[LSP] Graph calls_edge_count: {}",
graph_stats.calls_edge_count
);
debug!("[LSP] Project root: {:?}", project_root);
debug!("[LSP] File being analyzed: {:?}", file_path);
let serialize_start = std::time::Instant::now();
let ir_json = match serde_json::to_string(&ir) {
Ok(json) => {
let json_size = json.len();
let json_size_mb = json_size as f64 / (1024.0 * 1024.0);
let serialize_ms = serialize_start.elapsed().as_millis();
debug!("[LSP] Serialization time: {}ms", serialize_ms);
debug!(
"[LSP] IR JSON payload size: {} bytes ({:.2} MB)",
json_size, json_size_mb
);
json
}
Err(e) => {
let msg = format!("Failed to serialize IR: {}", e);
self.log_debug(&msg);
self.client.show_message(MessageType::WARNING, msg).await;
return;
}
};
let payload_size_mb = ir_json.len() as f64 / (1024.0 * 1024.0);
debug!(
"[LSP] IR JSON payload size: {} bytes ({:.2} MB)",
ir_json.len(),
payload_size_mb
);
if payload_size_mb > 5.0 {
let msg = format!(
"Project is large ({:.1}MB). Analysis may be slow or fail. Consider using a smaller workspace.",
payload_size_mb
);
self.log_debug(&msg);
self.client.show_message(MessageType::WARNING, msg).await;
}
let git_remote = get_git_remote(&project_root);
let workspace_label = project_root
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string());
let workspace_id =
compute_workspace_id(git_remote.as_deref(), None, workspace_label.as_deref())
.map(|r| r.id)
.unwrap_or_else(|| format!("wks_{}", uuid::Uuid::new_v4().simple()));
debug!("[LSP] Workspace ID: {}", workspace_id);
let mut scanner = WorkspaceScanner::new(&project_root);
let workspace_info = match scanner.scan() {
Ok(info) => info,
Err(e) => {
let msg = format!("Failed to scan project: {}", e);
self.log_debug(&msg);
self.client.show_message(MessageType::WARNING, msg).await;
return;
}
};
let profiles: Vec<String> = workspace_info
.to_workspace_descriptor()
.profiles
.iter()
.map(|p| p.id.clone())
.collect();
debug!("[LSP] Profiles to use: {:?}", profiles);
debug!("[LSP] Running local analysis...");
let response = match crate::analysis::analyze_ir_locally(
ir_json,
&profiles,
Some(&project_root),
)
.await
{
Ok(response) => response,
Err(e) => {
debug!("[LSP] Analysis error: {:?}", e);
self.log_debug(&format!("Analysis error: {:?}", e));
self.client
.show_message(MessageType::WARNING, format!("Analysis failed: {}", e))
.await;
return;
}
};
self.log_debug(&format!(
"Analysis returned {} findings",
response.findings.len()
));
let relative_file_path = file_path
.strip_prefix(&project_root)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| file_path.to_string_lossy().to_string());
let mut diagnostics = Vec::new();
let mut cached_findings = Vec::new();
self.log_debug(&format!(
"Looking for findings matching file: {}",
relative_file_path
));
for finding in &response.findings {
if !finding.file_path.ends_with(&relative_file_path)
&& !relative_file_path.ends_with(&finding.file_path)
{
continue;
}
let diagnostic = self.finding_to_diagnostic(&finding, text);
diagnostics.push(diagnostic);
cached_findings.push(CachedFinding {
finding: finding.clone(),
version,
});
}
self.findings_cache.insert(uri.clone(), cached_findings);
let diagnostics_count = diagnostics.len();
self.client
.publish_diagnostics(uri.clone(), diagnostics, Some(version))
.await;
self.log_debug(&format!(
"Published {} diagnostics for {}",
diagnostics_count, uri
));
}
fn finding_to_diagnostic(&self, finding: &IrFinding, source_text: &str) -> Diagnostic {
let start_line = if finding.line > 0 {
finding.line.saturating_sub(1)
} else {
0
};
let start_col = if finding.column > 0 {
finding.column.saturating_sub(1)
} else {
0
};
let end_line = finding
.end_line
.map(|l| l.saturating_sub(1))
.unwrap_or(start_line);
let end_col = finding
.end_column
.map(|c| c.saturating_sub(1))
.unwrap_or_else(|| {
let lines: Vec<&str> = source_text.lines().collect();
lines
.get(end_line as usize)
.map(|l| l.len() as u32)
.unwrap_or(start_col + 10)
});
let range = Range {
start: Position {
line: start_line,
character: start_col,
},
end: Position {
line: end_line,
character: end_col,
},
};
let severity = match finding.severity.to_lowercase().as_str() {
"critical" | "high" => DiagnosticSeverity::ERROR,
"medium" => DiagnosticSeverity::WARNING,
"low" | "info" => DiagnosticSeverity::INFORMATION,
_ => DiagnosticSeverity::WARNING,
};
let message = if finding.message.is_empty() {
format!("{}: {}", finding.title, finding.description)
} else {
finding.message.clone()
};
Diagnostic {
range,
severity: Some(severity),
code: Some(NumberOrString::String(finding.rule_id.clone())),
code_description: Some(CodeDescription {
href: Url::parse(&format!(
"https://docs.unfault.dev/rules/{}",
finding.rule_id.replace('.', "/")
))
.unwrap_or_else(|_| Url::parse("https://unfault.dev").unwrap()),
}),
source: Some("unfault".to_string()),
message,
related_information: None,
tags: None,
data: Some(serde_json::json!({
"rule_id": finding.rule_id,
"dimension": finding.dimension,
"has_fix": finding.patch.is_some() || finding.patch_json.is_some(),
})),
}
}
fn get_code_actions_for_range(
&self,
uri: &Url,
range: &Range,
source_text: &str,
) -> Vec<CodeAction> {
let mut actions = Vec::new();
let findings = match self.findings_cache.get(uri) {
Some(f) => f.clone(),
None => return actions,
};
for cached in findings.iter() {
let finding = &cached.finding;
let finding_start = Position {
line: finding.line.saturating_sub(1),
character: finding.column.saturating_sub(1),
};
let finding_end = Position {
line: finding.end_line.unwrap_or(finding.line).saturating_sub(1),
character: finding
.end_column
.unwrap_or(finding.column + 10)
.saturating_sub(1),
};
let finding_range = Range {
start: finding_start,
end: finding_end,
};
if !ranges_overlap(range, &finding_range) {
continue;
}
if let Some(patch) = &finding.patch {
if let Some(edit) = self.parse_unified_diff(uri, patch, source_text) {
let action = CodeAction {
title: format!("Fix: {}", finding.title),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![self.finding_to_diagnostic(finding, source_text)]),
edit: Some(edit),
command: None,
is_preferred: Some(true),
disabled: None,
data: None,
};
actions.push(action);
}
}
if let Some(patch_json) = &finding.patch_json {
if let Some(edit) = self.create_edit_from_patch_json(uri, patch_json, source_text) {
let action = CodeAction {
title: format!("Fix: {}", finding.title),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![self.finding_to_diagnostic(finding, source_text)]),
edit: Some(edit),
command: None,
is_preferred: Some(true),
disabled: None,
data: None,
};
actions.push(action);
} else if let Some(preview) = &finding.fix_preview {
if let Some(edit) =
self.create_edit_from_preview(uri, finding, preview, source_text)
{
let action = CodeAction {
title: format!("Fix: {}", finding.title),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![
self.finding_to_diagnostic(finding, source_text),
]),
edit: Some(edit),
command: None,
is_preferred: Some(true),
disabled: None,
data: None,
};
actions.push(action);
}
}
}
}
actions
}
fn parse_unified_diff(
&self,
uri: &Url,
diff: &str,
source_text: &str,
) -> Option<WorkspaceEdit> {
let mut text_edits = Vec::new();
let lines: Vec<&str> = diff.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if line.starts_with("@@") {
if let Some(hunk) = parse_hunk_header(line) {
let (_old_start, new_content) = parse_hunk_content(&lines[i + 1..]);
let start_line = hunk.old_start.saturating_sub(1);
let end_line = start_line + hunk.old_count;
let source_lines: Vec<&str> = source_text.lines().collect();
let end_char = source_lines
.get(end_line as usize)
.map(|l| l.len() as u32)
.unwrap_or(0);
let edit = TextEdit {
range: Range {
start: Position {
line: start_line,
character: 0,
},
end: Position {
line: end_line,
character: end_char,
},
},
new_text: new_content,
};
text_edits.push(edit);
}
}
i += 1;
}
if text_edits.is_empty() {
return None;
}
let mut changes = HashMap::new();
changes.insert(uri.clone(), text_edits);
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
fn create_edit_from_patch_json(
&self,
uri: &Url,
patch_json: &str,
source_text: &str,
) -> Option<WorkspaceEdit> {
let patch: FilePatch = match serde_json::from_str(patch_json) {
Ok(p) => p,
Err(e) => {
self.log_debug(&format!("Failed to parse patch_json: {}", e));
return None;
}
};
if patch.hunks.is_empty() {
return None;
}
let line_starts: Vec<usize> = std::iter::once(0)
.chain(
source_text
.char_indices()
.filter_map(|(idx, ch)| if ch == '\n' { Some(idx + 1) } else { None }),
)
.chain(std::iter::once(source_text.len()))
.collect();
let source_lines: Vec<&str> = source_text.lines().collect();
let mut text_edits = Vec::new();
for hunk in &patch.hunks {
match &hunk.range {
PatchRange::InsertAfterLine { line } => {
if *line == 0 {
let edit = TextEdit {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 0,
},
},
new_text: hunk.replacement.clone(),
};
text_edits.push(edit);
} else {
let target_line = *line as u32;
let edit = TextEdit {
range: Range {
start: Position {
line: target_line,
character: 0,
},
end: Position {
line: target_line,
character: 0,
},
},
new_text: hunk.replacement.clone(),
};
text_edits.push(edit);
}
}
PatchRange::InsertBeforeLine { line } => {
let target_line = line.saturating_sub(1) as u32;
let edit = TextEdit {
range: Range {
start: Position {
line: target_line,
character: 0,
},
end: Position {
line: target_line,
character: 0,
},
},
new_text: hunk.replacement.clone(),
};
text_edits.push(edit);
}
PatchRange::InsertAt { byte_offset } => {
if let Some((line, col)) =
byte_offset_to_position(&line_starts, &source_lines, *byte_offset)
{
let edit = TextEdit {
range: Range {
start: Position {
line: line as u32,
character: col as u32,
},
end: Position {
line: line as u32,
character: col as u32,
},
},
new_text: hunk.replacement.clone(),
};
text_edits.push(edit);
}
}
PatchRange::ReplaceBytes { start, end } => {
if let (Some((start_line, start_col)), Some((end_line, end_col))) = (
byte_offset_to_position(&line_starts, &source_lines, *start),
byte_offset_to_position(&line_starts, &source_lines, *end),
) {
let edit = TextEdit {
range: Range {
start: Position {
line: start_line as u32,
character: start_col as u32,
},
end: Position {
line: end_line as u32,
character: end_col as u32,
},
},
new_text: hunk.replacement.clone(),
};
text_edits.push(edit);
}
}
}
}
if text_edits.is_empty() {
return None;
}
let mut changes = HashMap::new();
changes.insert(uri.clone(), text_edits);
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
fn create_edit_from_preview(
&self,
uri: &Url,
finding: &IrFinding,
preview: &str,
_source_text: &str,
) -> Option<WorkspaceEdit> {
let start_line = finding.line.saturating_sub(1);
let start_col = finding.column.saturating_sub(1);
let end_line = finding.end_line.unwrap_or(finding.line).saturating_sub(1);
let end_col = finding
.end_column
.unwrap_or(finding.column + 10)
.saturating_sub(1);
let edit = TextEdit {
range: Range {
start: Position {
line: start_line,
character: start_col,
},
end: Position {
line: end_line,
character: end_col,
},
},
new_text: preview.to_string(),
};
let mut changes = HashMap::new();
changes.insert(uri.clone(), vec![edit]);
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
async fn get_file_centrality(&self, file_path: &str) -> Option<FileCentralityNotification> {
{
let cache = self.centrality_cache.read().await;
if let Some(centralities) = cache.as_ref() {
if let Some(centrality) = centralities
.iter()
.find(|c| c.path.ends_with(file_path) || file_path.ends_with(&c.path))
{
let total_files = centralities.len() as i32;
return Some(self.centrality_to_notification(centrality, total_files));
}
}
}
let workspace_root = self.workspace_root.read().await;
let root = workspace_root.as_ref()?;
let graph = crate::local_graph::build_analysis_graph(root, false).ok()?;
let centrality = unfault_analysis::graph::traversal::get_centrality(&graph, 50);
let file_centralities: Vec<FileCentrality> = centrality
.central_files
.iter()
.map(|(path, score)| FileCentrality {
path: path.clone(),
in_degree: *score as i32,
out_degree: 0,
total_degree: *score as i32,
library_usage: 0,
importance_score: *score as i32,
})
.collect();
let total_files = graph.file_nodes.len() as i32;
{
let mut cache = self.centrality_cache.write().await;
*cache = Some(file_centralities.clone());
}
file_centralities
.iter()
.find(|c| c.path.ends_with(file_path) || file_path.ends_with(&c.path))
.map(|c| self.centrality_to_notification(c, total_files))
}
fn centrality_to_notification(
&self,
centrality: &FileCentrality,
total_files: i32,
) -> FileCentralityNotification {
let label = if centrality.in_degree > 10 {
format!("Hub file ({} importers)", centrality.in_degree)
} else if centrality.in_degree > 5 {
format!("Important file ({} importers)", centrality.in_degree)
} else if centrality.in_degree > 0 {
format!("{} importers", centrality.in_degree)
} else if centrality.out_degree > 5 {
format!("Leaf file ({} imports)", centrality.out_degree)
} else {
"Leaf file".to_string()
};
FileCentralityNotification {
path: centrality.path.clone(),
in_degree: centrality.in_degree,
out_degree: centrality.out_degree,
importance_score: centrality.importance_score,
total_files,
label,
}
}
fn compute_local_dependencies(
&self,
ir: &IntermediateRepresentation,
file_path: &str,
) -> Option<FileDependenciesNotification> {
let graph = &ir.graph;
debug!("[LSP] === Computing Local Dependencies ===");
debug!("[LSP] Looking for file: {}", file_path);
debug!("[LSP] Graph has {} files", graph.stats().file_count);
let file_idx = graph.find_file_by_path(file_path)?;
let file_id = match &graph.graph[file_idx] {
GraphNode::File { file_id, .. } => *file_id,
_ => {
debug!("[LSP] Node is not a File node, skipping");
return None;
}
};
debug!("[LSP] File ID: {:?}", file_id);
let direct_importer_ids = graph.get_importers(file_id);
debug!("[LSP] Direct importer IDs: {:?}", direct_importer_ids);
let direct_dependents: Vec<String> = direct_importer_ids
.iter()
.filter_map(|&fid| {
let idx = graph.file_nodes.get(&fid)?;
if let GraphNode::File { path, .. } = &graph.graph[*idx] {
Some(path.clone())
} else {
None
}
})
.collect();
debug!("[LSP] Direct dependent files: {:?}", direct_dependents);
let transitive = graph.get_transitive_importers(file_id, 3);
debug!(
"[LSP] Transitive importers (depth 3): {} entries",
transitive.len()
);
let all_dependents: Vec<String> = transitive
.iter()
.filter_map(|&(fid, _depth)| {
let idx = graph.file_nodes.get(&fid)?;
if let GraphNode::File { path, .. } = &graph.graph[*idx] {
Some(path.clone())
} else {
None
}
})
.collect();
debug!(
"[LSP] All dependent files (including transitive): {:?}",
all_dependents
);
let direct_count = direct_dependents.len();
let total_count = all_dependents.len() as i32;
let summary = if total_count == 0 {
"No other files depend on this file".to_string()
} else if direct_count == 1 && total_count == 1 {
format!("1 file depends on this file: {}", direct_dependents[0])
} else if direct_count == total_count as usize {
format!("{} files depend on this file", total_count)
} else {
format!(
"{} files directly import this file ({} total affected)",
direct_count, total_count
)
};
debug!("[LSP] Dependency summary: {}", summary);
debug!(
"[LSP] Direct dependents: {}, Total affected: {}",
direct_count, total_count
);
self.log_debug(&format!(
"Local graph: {} direct dependents, {} total for {}",
direct_count, total_count, file_path
));
Some(FileDependenciesNotification {
path: file_path.to_string(),
direct_dependents,
all_dependents,
total_count,
summary,
})
}
fn extract_functions_from_ir(
&self,
ir: &IntermediateRepresentation,
file_path: &str,
) -> Vec<FunctionInfo> {
use unfault_core::semantics::SourceSemantics;
let mut functions = Vec::new();
for sem in &ir.semantics {
if sem.file_path() != file_path {
continue;
}
match sem {
SourceSemantics::Python(py_sem) => {
for func in &py_sem.functions {
let range = Range {
start: Position {
line: func.location.range.start_line,
character: func.location.range.start_col,
},
end: Position {
line: func.location.range.end_line,
character: func.location.range.end_col,
},
};
let qualified_name = match &func.class_name {
Some(class) => format!("{}.{}", class, func.name),
None => func.name.clone(),
};
functions.push(FunctionInfo {
name: qualified_name,
range,
});
}
}
SourceSemantics::Go(go_sem) => {
for func in &go_sem.functions {
let range = Range {
start: Position {
line: func.location.range.start_line,
character: func.location.range.start_col,
},
end: Position {
line: func.location.range.end_line,
character: func.location.range.end_col,
},
};
functions.push(FunctionInfo {
name: func.name.clone(),
range,
});
}
for method in &go_sem.methods {
let range = Range {
start: Position {
line: method.location.range.start_line,
character: method.location.range.start_col,
},
end: Position {
line: method.location.range.end_line,
character: method.location.range.end_col,
},
};
let qualified_name = format!("{}.{}", method.receiver_type, method.name);
functions.push(FunctionInfo {
name: qualified_name,
range,
});
}
}
SourceSemantics::Typescript(ts_sem) => {
for func in &ts_sem.functions {
let range = Range {
start: Position {
line: func.location.range.start_line,
character: func.location.range.start_col,
},
end: Position {
line: func.location.range.end_line,
character: func.location.range.end_col,
},
};
functions.push(FunctionInfo {
name: func.name.clone(),
range,
});
}
for class in &ts_sem.classes {
for method in &class.methods {
let range = Range {
start: Position {
line: method.location.range.start_line,
character: method.location.range.start_col,
},
end: Position {
line: method.location.range.end_line,
character: method.location.range.end_col,
},
};
let qualified_name = format!("{}.{}", class.name, method.name);
functions.push(FunctionInfo {
name: qualified_name,
range,
});
}
}
}
SourceSemantics::Rust(rust_sem) => {
for func in &rust_sem.functions {
let range = Range {
start: Position {
line: func.location.range.start_line,
character: func.location.range.start_col,
},
end: Position {
line: func.location.range.end_line,
character: func.location.range.end_col,
},
};
functions.push(FunctionInfo {
name: func.name.clone(),
range,
});
}
for impl_block in &rust_sem.impls {
for method in &impl_block.methods {
let range = Range {
start: Position {
line: method.location.range.start_line,
character: method.location.range.start_col,
},
end: Position {
line: method.location.range.end_line,
character: method.location.range.end_col,
},
};
let qualified_name =
format!("{}.{}", impl_block.self_type, method.name);
functions.push(FunctionInfo {
name: qualified_name,
range,
});
}
}
}
}
}
functions
}
async fn get_function_at_position(&self, uri: &Url, position: Position) -> Option<String> {
let functions = match self.function_cache.get(uri) {
Some(f) => f.clone(),
None => return None,
};
for func in functions.iter() {
if position.line >= func.range.start.line
&& position.line <= func.range.end.line
&& (position.line > func.range.start.line
|| position.character >= func.range.start.character)
&& (position.line < func.range.end.line
|| position.character <= func.range.end.character)
{
return Some(func.name.clone());
}
}
None
}
async fn get_function_impact(&self, file_path: &str, function_name: &str) -> Option<String> {
self.log_debug(&format!(
"Fetching impact for function: {} in {}",
function_name, file_path
));
let workspace_root = self.workspace_root.read().await;
let root = workspace_root.as_ref()?;
let graph = crate::local_graph::build_analysis_graph(root, false).ok()?;
let flow = unfault_analysis::graph::traversal::extract_flow(&graph, function_name, None, 5);
let mut markdown = String::new();
markdown.push_str(&format!("**Function Impact:** {}\n\n", function_name));
if flow.paths.is_empty() {
markdown.push_str("No call paths found from this function.\n");
return Some(markdown);
}
markdown.push_str(&format!("Found {} call path(s)\n\n", flow.paths.len()));
for (i, path) in flow.paths.iter().enumerate() {
markdown.push_str(&format!("**Path {}:**\n", i + 1));
for node in path {
let indent = " ".repeat(node.depth);
let file_info = node.file_path.as_deref().unwrap_or("");
markdown.push_str(&format!("{}→ `{}` _{}_\n", indent, node.name, file_info));
}
markdown.push('\n');
}
Some(markdown)
}
}
fn byte_offset_to_position(
line_starts: &[usize],
source_lines: &[&str],
offset: usize,
) -> Option<(usize, usize)> {
let line_idx = match line_starts.binary_search(&offset) {
Ok(idx) => idx, Err(idx) => idx.saturating_sub(1), };
if line_idx >= source_lines.len() {
if offset >= *line_starts.last().unwrap_or(&0) {
return Some((
source_lines.len().saturating_sub(1),
source_lines.last().map(|l| l.len()).unwrap_or(0),
));
}
return None;
}
let line_start = line_starts[line_idx];
let col = offset.saturating_sub(line_start);
let line_len = source_lines.get(line_idx).map(|l| l.len()).unwrap_or(0);
let col = col.min(line_len);
Some((line_idx, col))
}
fn ranges_overlap(a: &Range, b: &Range) -> bool {
!(a.end.line < b.start.line
|| (a.end.line == b.start.line && a.end.character < b.start.character)
|| b.end.line < a.start.line
|| (b.end.line == a.start.line && b.end.character < a.start.character))
}
const PROJECT_MARKERS: &[&str] = &[
"pyproject.toml",
"setup.py",
"requirements.txt",
"package.json",
"Cargo.toml",
"go.mod",
"pom.xml",
"build.gradle",
"build.gradle.kts",
".unfault.toml",
"unfault.toml",
];
fn find_project_root(file_path: &Path, ide_workspace_root: Option<&PathBuf>) -> Option<PathBuf> {
let mut current = file_path.parent()?;
let stop_at = ide_workspace_root.map(|p| p.as_path());
loop {
for marker in PROJECT_MARKERS {
let marker_path = current.join(marker);
if marker_path.exists() {
return Some(current.to_path_buf());
}
}
let git_path = current.join(".git");
if git_path.exists() {
return Some(current.to_path_buf());
}
if let Some(stop) = stop_at {
if current == stop {
break;
}
}
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
None
}
#[allow(dead_code)]
struct HunkHeader {
old_start: u32,
old_count: u32,
new_start: u32,
new_count: u32,
}
fn parse_hunk_header(line: &str) -> Option<HunkHeader> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
return None;
}
let old_part = parts[1].trim_start_matches('-');
let new_part = parts[2].trim_start_matches('+');
let (old_start, old_count) = parse_range_spec(old_part)?;
let (new_start, new_count) = parse_range_spec(new_part)?;
Some(HunkHeader {
old_start,
old_count,
new_start,
new_count,
})
}
fn parse_range_spec(spec: &str) -> Option<(u32, u32)> {
if let Some((start, count)) = spec.split_once(',') {
Some((start.parse().ok()?, count.parse().ok()?))
} else {
Some((spec.parse().ok()?, 1))
}
}
fn parse_hunk_content(lines: &[&str]) -> (u32, String) {
let mut new_content = Vec::new();
let old_start = 0u32;
for line in lines {
if line.starts_with("@@") {
break;
}
if line.starts_with('+') && !line.starts_with("+++") {
new_content.push(&line[1..]);
} else if line.starts_with('-') && !line.starts_with("---") {
} else if line.starts_with(' ') {
new_content.push(&line[1..]);
}
}
(old_start, new_content.join("\n"))
}
#[tower_lsp::async_trait]
impl LanguageServer for UnfaultLsp {
async fn initialize(&self, params: InitializeParams) -> RpcResult<InitializeResult> {
self.log_debug("LSP initialize called");
if let Some(root_uri) = params.root_uri {
if let Ok(path) = root_uri.to_file_path() {
self.log_debug(&format!("Workspace root: {:?}", path));
let git_remote = get_git_remote(&path);
let workspace_label = path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string());
let workspace_id_result =
compute_workspace_id(git_remote.as_deref(), None, workspace_label.as_deref());
let workspace_id = workspace_id_result
.map(|r| r.id)
.unwrap_or_else(|| format!("wks_{}", uuid::Uuid::new_v4().simple()));
self.log_debug(&format!("Workspace ID: {}", workspace_id));
{
let mut root = self.workspace_root.write().await;
*root = Some(path);
}
{
let mut id = self.workspace_id.write().await;
*id = Some(workspace_id);
}
}
}
Ok(InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::FULL,
)),
code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
hover_provider: Some(HoverProviderCapability::Simple(true)),
execute_command_provider: Some(ExecuteCommandOptions {
commands: vec!["unfault/getFunctionImpact".to_string()],
..Default::default()
}),
..Default::default()
},
server_info: Some(ServerInfo {
name: "unfault-lsp".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}),
})
}
async fn initialized(&self, _: InitializedParams) {
self.log_debug("LSP initialized");
if self.config.is_some() {
self.client
.log_message(MessageType::INFO, "Unfault LSP ready")
.await;
} else {
self.client
.log_message(
MessageType::WARNING,
"Unfault: No API key configured. Run 'unfault login' to authenticate.",
)
.await;
}
}
async fn shutdown(&self) -> RpcResult<()> {
self.log_debug("LSP shutdown");
Ok(())
}
async fn did_open(&self, params: DidOpenTextDocumentParams) {
self.log_debug(&format!("Document opened: {}", params.text_document.uri));
let _ = self
.document_cache
.insert_async(
params.text_document.uri.clone(),
params.text_document.text.clone(),
)
.await;
let workspace_root = {
let root = self.workspace_root.read().await;
root.clone()
};
let abs_path = match params.text_document.uri.to_file_path() {
Ok(abs) => Some(abs),
Err(_) => None,
};
let (project_root, relative_path) = if let Some(ref abs) = abs_path {
let ide_ws = workspace_root.as_ref();
let proj_root = find_project_root(abs, ide_ws).unwrap_or_else(|| {
ide_ws
.cloned()
.unwrap_or_else(|| abs.parent().unwrap_or(abs).to_path_buf())
});
let rel = abs
.strip_prefix(&proj_root)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| {
abs.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string()
});
(Some(proj_root), rel)
} else {
(None, String::new())
};
let local_ir = if let Some(ref proj_root) = project_root {
match build_ir_cached(proj_root, None, self.verbose) {
Ok(result) => Some(result.ir),
Err(e) => {
self.log_debug(&format!("Failed to build IR for local graph: {}", e));
None
}
}
} else {
None
};
if let Some(ref ir) = local_ir {
if !relative_path.is_empty() {
self.log_debug(&format!(
"Looking for file in graph: '{}' (graph has {} files)",
relative_path,
ir.graph.stats().file_count
));
if let Some(dependencies) = self.compute_local_dependencies(ir, &relative_path) {
self.log_debug(&format!(
"Sending local dependencies notification: {} direct, {} total for {}",
dependencies.direct_dependents.len(),
dependencies.total_count,
relative_path
));
let _ = self
.client
.send_notification::<FileDependenciesNotificationType>(dependencies)
.await;
} else {
self.log_debug(&format!(
"No file found in graph for path: '{}'",
relative_path
));
}
let functions = self.extract_functions_from_ir(ir, &relative_path);
if !functions.is_empty() {
self.log_debug(&format!(
"Caching {} functions for hover from {}",
functions.len(),
relative_path
));
self.function_cache
.insert(params.text_document.uri.clone(), functions);
}
}
}
self.analyze_document(
¶ms.text_document.uri,
¶ms.text_document.text,
params.text_document.version,
local_ir,
)
.await;
if !relative_path.is_empty() {
if let Some(centrality) = self.get_file_centrality(&relative_path).await {
let _ = self
.client
.send_notification::<FileCentralityNotificationType>(centrality)
.await;
}
}
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
self.log_debug(&format!("Document changed: {}", params.text_document.uri));
if let Some(change) = params.content_changes.last() {
let _ = self
.document_cache
.insert_async(params.text_document.uri.clone(), change.text.clone())
.await;
self.analyze_document(
¶ms.text_document.uri,
&change.text,
params.text_document.version,
None, )
.await;
}
}
async fn did_save(&self, params: DidSaveTextDocumentParams) {
self.log_debug(&format!("Document saved: {}", params.text_document.uri));
if let Some(text) = params.text {
self.analyze_document(¶ms.text_document.uri, &text, 0, None)
.await;
}
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
self.log_debug(&format!("Document closed: {}", params.text_document.uri));
self.findings_cache.remove(¶ms.text_document.uri);
let _ = self
.document_cache
.remove_async(¶ms.text_document.uri)
.await;
self.function_cache.remove(¶ms.text_document.uri);
self.client
.publish_diagnostics(params.text_document.uri, vec![], None)
.await;
}
async fn code_action(&self, params: CodeActionParams) -> RpcResult<Option<CodeActionResponse>> {
self.log_debug(&format!(
"Code action requested for: {}",
params.text_document.uri
));
let source_text = self
.document_cache
.get_async(¶ms.text_document.uri)
.await
.map(|entry| entry.get().clone())
.unwrap_or_default();
let actions =
self.get_code_actions_for_range(¶ms.text_document.uri, ¶ms.range, &source_text);
if actions.is_empty() {
Ok(None)
} else {
Ok(Some(
actions
.into_iter()
.map(CodeActionOrCommand::CodeAction)
.collect(),
))
}
}
async fn hover(&self, params: HoverParams) -> RpcResult<Option<Hover>> {
let uri = ¶ms.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
self.log_debug(&format!(
"Hover requested at {}:{}:{}",
uri, position.line, position.character
));
let file_path = match uri.to_file_path() {
Ok(path) => path,
Err(_) => {
self.log_debug("Could not convert URI to file path for hover");
return Ok(None);
}
};
let workspace_root = {
let root = self.workspace_root.read().await;
root.clone()
};
self.log_debug(&format!("Workspace root: {:?}", workspace_root));
self.log_debug(&format!("File path: {:?}", file_path));
let relative_path = if let Some(ws_root) = workspace_root.as_ref() {
if let Ok(rel) = file_path.strip_prefix(ws_root) {
rel.to_string_lossy().to_string()
} else {
self.log_debug(&format!("Failed to strip workspace root, using full path"));
file_path.to_string_lossy().to_string()
}
} else {
self.log_debug(&format!("No workspace root available, using full path"));
file_path.to_string_lossy().to_string()
};
if let Some(function_name) = self.get_function_at_position(uri, position).await {
self.log_debug(&format!("Found function at position: {}", function_name));
self.log_debug(&format!("Using relative path: {}", relative_path));
if let Some(markdown) = self
.get_function_impact(&relative_path, &function_name)
.await
{
return Ok(Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: markdown,
}),
range: None,
}));
}
}
if let Some(findings) = self.findings_cache.get(uri) {
for cached in findings.iter() {
let finding = &cached.finding;
let finding_start_line = finding.line.saturating_sub(1);
let finding_end_line = finding.end_line.unwrap_or(finding.line).saturating_sub(1);
if position.line >= finding_start_line && position.line <= finding_end_line {
let mut markdown = String::new();
markdown.push_str(&format!("**{}**\n\n", finding.title));
markdown.push_str(&format!("{}\n\n", finding.description));
if !finding.dimension.is_empty() {
markdown.push_str(&format!("*Dimension:* {}\n", finding.dimension));
}
if finding.patch.is_some() || finding.patch_json.is_some() {
markdown.push_str("\n💡 *Quick fix available*\n");
}
return Ok(Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: markdown,
}),
range: Some(Range {
start: Position {
line: finding_start_line,
character: finding.column.saturating_sub(1),
},
end: Position {
line: finding_end_line,
character: finding
.end_column
.unwrap_or(finding.column + 10)
.saturating_sub(1),
},
}),
}));
}
}
}
Ok(None)
}
async fn execute_command(
&self,
params: ExecuteCommandParams,
) -> RpcResult<Option<serde_json::Value>> {
if params.command != "unfault/getFunctionImpact" {
return Err(tower_lsp::jsonrpc::Error::method_not_found());
}
if params.arguments.is_empty() {
return Ok(None);
}
let args_value = params
.arguments
.first()
.cloned()
.unwrap_or(serde_json::Value::Null);
let req: GetFunctionImpactRequest = serde_json::from_value(args_value)
.map_err(|e| tower_lsp::jsonrpc::Error::invalid_params(e.to_string()))?;
let uri = Url::parse(&req.uri).ok();
let file_path = uri.as_ref().and_then(|u| u.to_file_path().ok());
let workspace_root = { self.workspace_root.read().await.clone() };
let _relative_path = match (&file_path, &workspace_root) {
(Some(fp), Some(ws)) => fp
.strip_prefix(ws)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| fp.to_string_lossy().to_string()),
(Some(fp), None) => fp.to_string_lossy().to_string(),
_ => return Ok(None),
};
let graph = match &workspace_root {
Some(root) => crate::local_graph::build_analysis_graph(root, false).ok(),
None => None,
};
let graph = match graph {
Some(g) => g,
None => return Ok(None),
};
let flow = unfault_analysis::graph::traversal::extract_flow(&graph, &req.function_name, None, 5);
let callers: Vec<FunctionImpactCaller> = flow
.paths
.iter()
.flat_map(|path| {
path.iter().skip(1).map(|node| FunctionImpactCaller {
name: node.name.clone(),
file: node.file_path.clone().unwrap_or_default(),
depth: node.depth as i32,
})
})
.collect();
Ok(Some(
serde_json::to_value(GetFunctionImpactResponse {
name: req.function_name.clone(),
callers,
routes: vec![],
findings: vec![],
})
.unwrap(),
))
}
}
struct FileCentralityNotificationType;
impl tower_lsp::lsp_types::notification::Notification for FileCentralityNotificationType {
type Params = FileCentralityNotification;
const METHOD: &'static str = "unfault/fileCentrality";
}
struct FileDependenciesNotificationType;
impl tower_lsp::lsp_types::notification::Notification for FileDependenciesNotificationType {
type Params = FileDependenciesNotification;
const METHOD: &'static str = "unfault/fileDependencies";
}
impl UnfaultLsp {
async fn handle_get_function_impact(
&self,
params: serde_json::Value,
) -> RpcResult<Option<GetFunctionImpactResponse>> {
let req: GetFunctionImpactRequest = serde_json::from_value(params)
.map_err(|e| tower_lsp::jsonrpc::Error::invalid_params(e.to_string()))?;
let uri = Url::parse(&req.uri).ok();
let file_path = uri.as_ref().and_then(|u| u.to_file_path().ok());
let workspace_root = { self.workspace_root.read().await.clone() };
let _relative_path = match (&file_path, &workspace_root) {
(Some(fp), Some(ws)) => fp
.strip_prefix(ws)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| fp.to_string_lossy().to_string()),
(Some(fp), None) => fp.to_string_lossy().to_string(),
_ => return Ok(None),
};
let graph = match &workspace_root {
Some(root) => crate::local_graph::build_analysis_graph(root, false).ok(),
None => None,
};
let graph = match graph {
Some(g) => g,
None => return Ok(None),
};
let flow = unfault_analysis::graph::traversal::extract_flow(&graph, &req.function_name, None, 5);
let callers: Vec<FunctionImpactCaller> = flow
.paths
.iter()
.flat_map(|path| {
path.iter().skip(1).map(|node| FunctionImpactCaller {
name: node.name.clone(),
file: node.file_path.clone().unwrap_or_default(),
depth: node.depth as i32,
})
})
.collect();
Ok(Some(GetFunctionImpactResponse {
name: req.function_name.clone(),
callers,
routes: vec![],
findings: vec![],
}))
}
}
pub async fn execute(args: LspArgs) -> anyhow::Result<i32> {
if args.verbose {
eprintln!("[unfault-lsp] Starting LSP server");
}
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::build(|client| UnfaultLsp::new(client, args.verbose))
.custom_method(
"unfault/getFunctionImpact",
UnfaultLsp::handle_get_function_impact,
)
.finish();
Server::new(stdin, stdout, socket).serve(service).await;
Ok(EXIT_SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_find_project_root_with_pyproject() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("myproject");
fs::create_dir_all(&project_dir).unwrap();
fs::write(
project_dir.join("pyproject.toml"),
"[project]\nname = \"test\"",
)
.unwrap();
let src_dir = project_dir.join("src");
fs::create_dir_all(&src_dir).unwrap();
let file_path = src_dir.join("main.py");
fs::write(&file_path, "print('hello')").unwrap();
let result = find_project_root(&file_path, None);
assert_eq!(result, Some(project_dir));
}
#[test]
fn test_find_project_root_with_package_json() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("myproject");
fs::create_dir_all(&project_dir).unwrap();
fs::write(project_dir.join("package.json"), r#"{"name": "test"}"#).unwrap();
let src_dir = project_dir.join("src");
fs::create_dir_all(&src_dir).unwrap();
let file_path = src_dir.join("index.ts");
fs::write(&file_path, "console.log('hello')").unwrap();
let result = find_project_root(&file_path, None);
assert_eq!(result, Some(project_dir));
}
#[test]
fn test_find_project_root_monorepo() {
let temp_dir = TempDir::new().unwrap();
let monorepo_root = temp_dir.path();
fs::write(
monorepo_root.join("package.json"),
r#"{"name": "monorepo"}"#,
)
.unwrap();
let subproject_dir = monorepo_root.join("packages").join("myapp");
fs::create_dir_all(&subproject_dir).unwrap();
fs::write(
subproject_dir.join("pyproject.toml"),
"[project]\nname = \"myapp\"",
)
.unwrap();
let src_dir = subproject_dir.join("src");
fs::create_dir_all(&src_dir).unwrap();
let file_path = src_dir.join("main.py");
fs::write(&file_path, "print('hello')").unwrap();
let result = find_project_root(&file_path, Some(&monorepo_root.to_path_buf()));
assert_eq!(result, Some(subproject_dir));
}
#[test]
fn test_find_project_root_no_marker() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("random.py");
fs::write(&file_path, "print('hello')").unwrap();
let result = find_project_root(&file_path, None);
assert_eq!(result, None);
}
#[test]
fn test_find_project_root_with_git() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("myproject");
fs::create_dir_all(&project_dir).unwrap();
fs::create_dir_all(project_dir.join(".git")).unwrap();
let file_path = project_dir.join("main.py");
fs::write(&file_path, "print('hello')").unwrap();
let result = find_project_root(&file_path, None);
assert_eq!(result, Some(project_dir));
}
#[test]
fn test_byte_offset_to_position_start_of_first_line() {
let source = "line1\nline2\nline3";
let line_starts: Vec<usize> = std::iter::once(0)
.chain(
source
.char_indices()
.filter_map(|(idx, ch)| if ch == '\n' { Some(idx + 1) } else { None }),
)
.chain(std::iter::once(source.len()))
.collect();
let source_lines: Vec<&str> = source.lines().collect();
let result = byte_offset_to_position(&line_starts, &source_lines, 0);
assert_eq!(result, Some((0, 0)));
}
#[test]
fn test_byte_offset_to_position_middle_of_first_line() {
let source = "line1\nline2\nline3";
let line_starts: Vec<usize> = std::iter::once(0)
.chain(
source
.char_indices()
.filter_map(|(idx, ch)| if ch == '\n' { Some(idx + 1) } else { None }),
)
.chain(std::iter::once(source.len()))
.collect();
let source_lines: Vec<&str> = source.lines().collect();
let result = byte_offset_to_position(&line_starts, &source_lines, 3);
assert_eq!(result, Some((0, 3)));
}
#[test]
fn test_byte_offset_to_position_start_of_second_line() {
let source = "line1\nline2\nline3";
let line_starts: Vec<usize> = std::iter::once(0)
.chain(
source
.char_indices()
.filter_map(|(idx, ch)| if ch == '\n' { Some(idx + 1) } else { None }),
)
.chain(std::iter::once(source.len()))
.collect();
let source_lines: Vec<&str> = source.lines().collect();
let result = byte_offset_to_position(&line_starts, &source_lines, 6);
assert_eq!(result, Some((1, 0)));
}
#[test]
fn test_byte_offset_to_position_middle_of_second_line() {
let source = "line1\nline2\nline3";
let line_starts: Vec<usize> = std::iter::once(0)
.chain(
source
.char_indices()
.filter_map(|(idx, ch)| if ch == '\n' { Some(idx + 1) } else { None }),
)
.chain(std::iter::once(source.len()))
.collect();
let source_lines: Vec<&str> = source.lines().collect();
let result = byte_offset_to_position(&line_starts, &source_lines, 8);
assert_eq!(result, Some((1, 2)));
}
#[test]
fn test_byte_offset_to_position_end_of_file() {
let source = "line1\nline2\nline3";
let line_starts: Vec<usize> = std::iter::once(0)
.chain(
source
.char_indices()
.filter_map(|(idx, ch)| if ch == '\n' { Some(idx + 1) } else { None }),
)
.chain(std::iter::once(source.len()))
.collect();
let source_lines: Vec<&str> = source.lines().collect();
let result = byte_offset_to_position(&line_starts, &source_lines, source.len());
assert_eq!(result, Some((2, 5))); }
#[test]
fn test_parse_file_patch_json_insert_after_line() {
use unfault_core::parse::ast::FileId;
use unfault_core::types::{FilePatch, PatchHunk, PatchRange};
let patch = FilePatch {
file_id: FileId(1),
hunks: vec![
PatchHunk {
range: PatchRange::InsertAfterLine { line: 1 },
replacement: "from fastapi.middleware.cors import CORSMiddleware\n".to_string(),
},
PatchHunk {
range: PatchRange::InsertAfterLine { line: 5 },
replacement: "\napp.add_middleware(CORSMiddleware)\n".to_string(),
},
],
};
let json = serde_json::to_string(&patch).unwrap();
let parsed: FilePatch = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.hunks.len(), 2);
match &parsed.hunks[0].range {
PatchRange::InsertAfterLine { line } => assert_eq!(*line, 1),
_ => panic!("Expected InsertAfterLine"),
}
match &parsed.hunks[1].range {
PatchRange::InsertAfterLine { line } => assert_eq!(*line, 5),
_ => panic!("Expected InsertAfterLine"),
}
}
#[test]
fn test_parse_file_patch_json_insert_before_line() {
use unfault_core::parse::ast::FileId;
use unfault_core::types::{FilePatch, PatchHunk, PatchRange};
let patch = FilePatch {
file_id: FileId(1),
hunks: vec![PatchHunk {
range: PatchRange::InsertBeforeLine { line: 3 },
replacement: "# Comment\n".to_string(),
}],
};
let json = serde_json::to_string(&patch).unwrap();
let parsed: FilePatch = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.hunks.len(), 1);
match &parsed.hunks[0].range {
PatchRange::InsertBeforeLine { line } => assert_eq!(*line, 3),
_ => panic!("Expected InsertBeforeLine"),
}
}
#[test]
fn test_parse_file_patch_json_replace_bytes() {
use unfault_core::parse::ast::FileId;
use unfault_core::types::{FilePatch, PatchHunk, PatchRange};
let patch = FilePatch {
file_id: FileId(1),
hunks: vec![PatchHunk {
range: PatchRange::ReplaceBytes { start: 10, end: 20 },
replacement: "new_content".to_string(),
}],
};
let json = serde_json::to_string(&patch).unwrap();
let parsed: FilePatch = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.hunks.len(), 1);
match &parsed.hunks[0].range {
PatchRange::ReplaceBytes { start, end } => {
assert_eq!(*start, 10);
assert_eq!(*end, 20);
}
_ => panic!("Expected ReplaceBytes"),
}
}
#[test]
fn test_get_function_impact_request_serialization() {
let req = GetFunctionImpactRequest {
uri: "file:///path/to/file.py".to_string(),
function_name: "my_func".to_string(),
position: Position {
line: 10,
character: 0,
},
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"functionName\":\"my_func\""));
assert!(json.contains("\"uri\":\"file:///path/to/file.py\""));
}
#[test]
fn test_get_function_impact_response_serialization() {
let resp = GetFunctionImpactResponse {
name: "my_func".to_string(),
callers: vec![FunctionImpactCaller {
name: "caller_func".to_string(),
file: "main.py".to_string(),
depth: 1,
}],
routes: vec![FunctionImpactRoute {
method: "POST".to_string(),
path: "/api/test".to_string(),
}],
findings: vec![FunctionImpactFinding {
severity: "warning".to_string(),
message: "Test finding".to_string(),
learn_more: Some("https://example.com".to_string()),
}],
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"name\":\"my_func\""));
assert!(json.contains("\"callers\""));
assert!(json.contains("\"caller_func\""));
assert!(json.contains("\"routes\""));
assert!(json.contains("\"POST\""));
assert!(json.contains("\"findings\""));
assert!(json.contains("\"learnMore\""));
}
#[test]
fn test_get_function_impact_response_deserialization() {
let json = r#"{
"name": "add",
"callers": [{"name": "main", "file": "app.py", "depth": 1}],
"routes": [{"method": "GET", "path": "/api"}],
"findings": [{"severity": "error", "message": "issue", "learnMore": "http://x"}]
}"#;
let resp: GetFunctionImpactResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.name, "add");
assert_eq!(resp.callers.len(), 1);
assert_eq!(resp.callers[0].name, "main");
assert_eq!(resp.routes.len(), 1);
assert_eq!(resp.routes[0].method, "GET");
assert_eq!(resp.findings.len(), 1);
assert_eq!(resp.findings[0].learn_more, Some("http://x".to_string()));
}
#[test]
fn test_normalize_severity() {
assert_eq!(normalize_severity("critical"), "error");
assert_eq!(normalize_severity("high"), "error");
assert_eq!(normalize_severity("HIGH"), "error");
assert_eq!(normalize_severity("medium"), "warning");
assert_eq!(normalize_severity("MEDIUM"), "warning");
assert_eq!(normalize_severity("low"), "info");
assert_eq!(normalize_severity("info"), "info");
assert_eq!(normalize_severity("unknown"), "info");
}
}