1use std::collections::{BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::thread;
5use std::time::{Duration, Instant};
6
7use anyhow::{Context, Result, bail};
8use diffguard_core::{CheckPlan, run_check};
9use diffguard_types::{ConfigFile, FailOn, Finding, Scope, Severity};
10use lsp_server::{Connection, Message, Notification, Request, RequestId, Response, ResponseError};
11use lsp_types::notification::{
12 DidChangeConfiguration, DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument,
13 DidSaveTextDocument, Exit, Notification as LspNotification, PublishDiagnostics, ShowMessage,
14};
15use lsp_types::request::{CodeActionRequest, ExecuteCommand, Request as LspRequest};
16use lsp_types::{
17 CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams,
18 CodeActionProviderCapability, Command as LspCommand, Diagnostic, DiagnosticSeverity,
19 ExecuteCommandOptions, ExecuteCommandParams, InitializeParams, InitializeResult, MessageType,
20 NumberOrString, Position, PublishDiagnosticsParams, Range, ServerCapabilities, ServerInfo,
21 TextDocumentContentChangeEvent, TextDocumentSyncCapability, TextDocumentSyncKind, Uri,
22};
23use serde::Deserialize;
24use serde_json::json;
25
26use crate::config::{
27 extract_rule_id, find_rule, find_similar_rules, format_rule_explanation,
28 load_directory_overrides_for_file, load_effective_config, paths_match, resolve_config_path,
29 to_workspace_relative_path,
30};
31use crate::text::{
32 apply_incremental_change, build_synthetic_diff, changed_lines_between, utf16_length,
33};
34
35const DEFAULT_MAX_FINDINGS: usize = 200;
36const DEFAULT_CONFIG_NAME: &str = "diffguard.toml";
37const METHOD_NOT_FOUND: i32 = -32601;
38const INVALID_PARAMS: i32 = -32602;
39
40const CMD_EXPLAIN_RULE: &str = "diffguard.explainRule";
41const CMD_RELOAD_CONFIG: &str = "diffguard.reloadConfig";
42const CMD_SHOW_RULE_URL: &str = "diffguard.showRuleUrl";
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum GitSupport {
46 Unknown,
47 Available,
48 Unavailable,
49}
50
51#[derive(Debug, Clone)]
52struct DocumentState {
53 path: PathBuf,
54 version: i32,
55 baseline_text: String,
56 text: String,
57 changed_lines: BTreeSet<u32>,
58}
59
60impl DocumentState {
61 fn new(path: PathBuf, version: i32, text: String) -> Self {
62 Self {
63 path,
64 version,
65 baseline_text: text.clone(),
66 text,
67 changed_lines: BTreeSet::new(),
68 }
69 }
70
71 fn apply_changes(&mut self, changes: &[TextDocumentContentChangeEvent]) -> Result<()> {
72 if changes.is_empty() {
73 return Ok(());
74 }
75
76 if let Some(full_change) = changes.iter().rev().find(|change| change.range.is_none()) {
77 self.text = full_change.text.clone();
78 self.changed_lines = changed_lines_between(&self.baseline_text, &self.text);
79 return Ok(());
80 }
81
82 for change in changes {
83 apply_incremental_change(&mut self.text, change)?;
84 }
85
86 self.changed_lines = changed_lines_between(&self.baseline_text, &self.text);
87 Ok(())
88 }
89
90 fn mark_saved(&mut self, new_text: Option<String>) {
91 if let Some(text) = new_text {
92 self.text = text;
93 }
94 self.baseline_text = self.text.clone();
95 self.changed_lines.clear();
96 }
97}
98
99#[derive(Debug, Default, Deserialize)]
100#[serde(default, rename_all = "camelCase")]
101struct InitOptions {
102 config_path: Option<String>,
103 no_default_rules: bool,
104 max_findings: Option<usize>,
105 force_language: Option<String>,
106}
107
108#[derive(Debug)]
109struct ServerState {
110 workspace_root: Option<PathBuf>,
111 config_path: Option<PathBuf>,
112 no_default_rules: bool,
113 max_findings: usize,
114 force_language: Option<String>,
115 config: ConfigFile,
116 documents: HashMap<Uri, DocumentState>,
117 git_support: GitSupport,
118}
119
120impl ServerState {
121 fn from_initialize(params: &InitializeParams) -> (Self, Option<String>) {
122 let options = parse_init_options(params.initialization_options.as_ref());
123 let workspace_root = extract_workspace_root(params);
124 let config_path = resolve_config_path(
125 workspace_root.as_deref(),
126 options.config_path,
127 DEFAULT_CONFIG_NAME,
128 );
129 let max_findings = options.max_findings.unwrap_or(DEFAULT_MAX_FINDINGS).max(1);
130 let force_language = normalize_option_string(options.force_language);
131
132 let (config, warning) =
133 match load_effective_config(config_path.as_deref(), options.no_default_rules) {
134 Ok(config) => (config, None),
135 Err(err) => {
136 let config_label = config_path
137 .as_ref()
138 .map(|p| p.display().to_string())
139 .unwrap_or_else(|| "<built-in>".to_string());
140 let warning = format!(
141 "diffguard-lsp: failed to load config from {} (using built-in rules): {}",
142 config_label, err
143 );
144 (ConfigFile::built_in(), Some(warning))
145 }
146 };
147
148 (
149 Self {
150 workspace_root,
151 config_path,
152 no_default_rules: options.no_default_rules,
153 max_findings,
154 force_language,
155 config,
156 documents: HashMap::new(),
157 git_support: GitSupport::Unknown,
158 },
159 warning,
160 )
161 }
162}
163
164pub fn run_server(connection: Connection) -> Result<()> {
165 let (id, init_params) = connection.initialize_start()?;
168 let init_params: InitializeParams =
169 serde_json::from_value(init_params).context("parse initialize params")?;
170
171 let (mut state, startup_warning) = ServerState::from_initialize(&init_params);
172 if let Some(message) = startup_warning {
173 show_message(&connection, MessageType::WARNING, &message)?;
174 }
175
176 let init_response = initialize_payload()?;
178 connection.initialize_finish(id, init_response)?;
179
180 for message in &connection.receiver {
181 match message {
182 Message::Request(request) => {
183 if connection.handle_shutdown(&request)? {
184 break;
185 }
186 handle_request(&connection, &mut state, request.clone())?;
187 }
188 Message::Notification(notification) => {
189 if handle_notification(&connection, &mut state, notification.clone())? {
190 break;
191 }
192 }
193 Message::Response(_) => {}
194 }
195 }
196
197 Ok(())
198}
199
200fn parse_init_options(value: Option<&serde_json::Value>) -> InitOptions {
201 value
202 .and_then(|v| serde_json::from_value(v.clone()).ok())
203 .unwrap_or_default()
204}
205
206fn normalize_option_string(value: Option<String>) -> Option<String> {
207 value.and_then(|s| {
208 let trimmed = s.trim();
209 if trimmed.is_empty() {
210 None
211 } else {
212 Some(trimmed.to_string())
213 }
214 })
215}
216
217#[allow(deprecated)]
218fn extract_workspace_root(params: &InitializeParams) -> Option<PathBuf> {
219 if let Some(folders) = ¶ms.workspace_folders {
220 for folder in folders {
221 if let Some(path) = uri_to_file_path(&folder.uri) {
222 return Some(path);
223 }
224 }
225 }
226
227 if let Some(root_uri) = ¶ms.root_uri
228 && let Some(path) = uri_to_file_path(root_uri)
229 {
230 return Some(path);
231 }
232
233 params.root_path.as_ref().map(PathBuf::from)
234}
235
236fn server_capabilities() -> ServerCapabilities {
237 ServerCapabilities {
238 text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
239 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
240 execute_command_provider: Some(ExecuteCommandOptions {
241 commands: vec![
242 CMD_EXPLAIN_RULE.to_string(),
243 CMD_RELOAD_CONFIG.to_string(),
244 CMD_SHOW_RULE_URL.to_string(),
245 ],
246 ..ExecuteCommandOptions::default()
247 }),
248 ..ServerCapabilities::default()
249 }
250}
251
252fn initialize_payload() -> Result<serde_json::Value> {
253 let result = InitializeResult {
257 capabilities: server_capabilities(),
258 server_info: Some(ServerInfo {
259 name: "diffguard-lsp".to_string(),
260 version: Some(env!("CARGO_PKG_VERSION").to_string()),
261 }),
262 };
263 Ok(serde_json::to_value(result)?)
264}
265
266fn handle_request(
267 connection: &Connection,
268 state: &mut ServerState,
269 request: Request,
270) -> Result<()> {
271 match request.method.as_str() {
272 method if method == CodeActionRequest::METHOD => {
273 handle_code_action_request(connection, state, request)
274 }
275 method if method == ExecuteCommand::METHOD => {
276 handle_execute_command_request(connection, state, request)
277 }
278 _ => send_error_response(
279 connection,
280 request.id,
281 METHOD_NOT_FOUND,
282 format!("unsupported request method '{}'", request.method),
283 ),
284 }
285}
286
287fn handle_code_action_request(
288 connection: &Connection,
289 state: &ServerState,
290 request: Request,
291) -> Result<()> {
292 let params: CodeActionParams = match serde_json::from_value(request.params) {
293 Ok(params) => params,
294 Err(err) => {
295 return send_error_response(
296 connection,
297 request.id,
298 INVALID_PARAMS,
299 format!("invalid CodeActionParams: {}", err),
300 );
301 }
302 };
303
304 let actions = build_code_actions(&state.config, ¶ms);
305 send_ok_response(connection, request.id, serde_json::to_value(actions)?)
306}
307
308fn build_code_actions(config: &ConfigFile, params: &CodeActionParams) -> Vec<CodeActionOrCommand> {
309 let mut actions = Vec::new();
310 let mut seen_explain = BTreeSet::new();
311 let mut seen_urls = BTreeSet::new();
312
313 for diagnostic in ¶ms.context.diagnostics {
314 let Some(rule_id) = extract_rule_id(diagnostic) else {
315 continue;
316 };
317
318 if seen_explain.insert(rule_id.clone()) {
319 let command = LspCommand {
320 title: format!("Explain {}", rule_id),
321 command: CMD_EXPLAIN_RULE.to_string(),
322 arguments: Some(vec![json!(rule_id.clone())]),
323 };
324
325 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
326 title: format!("diffguard: Explain {}", rule_id),
327 kind: Some(CodeActionKind::QUICKFIX),
328 command: Some(command),
329 data: Some(json!({ "ruleId": rule_id })),
330 ..CodeAction::default()
331 }));
332 }
333
334 if let Some(rule) = find_rule(config, &rule_id)
335 && let Some(url) = rule.url.as_ref()
336 && seen_urls.insert(url.clone())
337 {
338 let command = LspCommand {
339 title: format!("Open docs for {}", rule.id),
340 command: CMD_SHOW_RULE_URL.to_string(),
341 arguments: Some(vec![json!(url), json!(rule.id)]),
342 };
343 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
344 title: format!("diffguard: Open docs for {}", rule.id),
345 kind: Some(CodeActionKind::QUICKFIX),
346 command: Some(command),
347 data: Some(json!({ "ruleId": rule.id, "url": url })),
348 ..CodeAction::default()
349 }));
350 }
351 }
352
353 actions
354}
355
356fn handle_execute_command_request(
357 connection: &Connection,
358 state: &mut ServerState,
359 request: Request,
360) -> Result<()> {
361 let params: ExecuteCommandParams = match serde_json::from_value(request.params) {
362 Ok(params) => params,
363 Err(err) => {
364 return send_error_response(
365 connection,
366 request.id,
367 INVALID_PARAMS,
368 format!("invalid ExecuteCommandParams: {}", err),
369 );
370 }
371 };
372
373 match params.command.as_str() {
374 CMD_EXPLAIN_RULE => {
375 let Some(rule_id) = nth_string_arg(¶ms.arguments, 0) else {
376 return send_error_response(
377 connection,
378 request.id,
379 INVALID_PARAMS,
380 "missing rule ID argument".to_string(),
381 );
382 };
383
384 let (message, found) = explain_rule_message(&state.config, &rule_id);
385 let message_type = if found {
386 MessageType::INFO
387 } else {
388 MessageType::WARNING
389 };
390 show_message(connection, message_type, &message)?;
391
392 send_ok_response(
393 connection,
394 request.id,
395 json!({
396 "ruleId": rule_id,
397 "found": found,
398 "message": message
399 }),
400 )
401 }
402 CMD_RELOAD_CONFIG => {
403 let (ok, message) = match reload_config(state) {
404 Ok(msg) => (true, msg),
405 Err(err) => (false, err.to_string()),
406 };
407 let message_type = if ok {
408 MessageType::INFO
409 } else {
410 MessageType::WARNING
411 };
412 show_message(connection, message_type, &message)?;
413 refresh_all_documents(connection, state)?;
414
415 send_ok_response(
416 connection,
417 request.id,
418 json!({
419 "ok": ok,
420 "message": message,
421 "rules": state.config.rule.len()
422 }),
423 )
424 }
425 CMD_SHOW_RULE_URL => {
426 let Some(url) = nth_string_arg(¶ms.arguments, 0) else {
427 return send_error_response(
428 connection,
429 request.id,
430 INVALID_PARAMS,
431 "missing URL argument".to_string(),
432 );
433 };
434 let rule_id = nth_string_arg(¶ms.arguments, 1).unwrap_or_default();
435 let label = if rule_id.is_empty() {
436 "diffguard documentation".to_string()
437 } else {
438 format!("diffguard rule {}", rule_id)
439 };
440 show_message(
441 connection,
442 MessageType::INFO,
443 &format!("{}: {}", label, url),
444 )?;
445
446 send_ok_response(
447 connection,
448 request.id,
449 json!({
450 "url": url,
451 "ruleId": rule_id
452 }),
453 )
454 }
455 _ => send_error_response(
456 connection,
457 request.id,
458 INVALID_PARAMS,
459 format!("unsupported command '{}'", params.command),
460 ),
461 }
462}
463
464fn explain_rule_message(config: &ConfigFile, rule_id: &str) -> (String, bool) {
465 if let Some(rule) = find_rule(config, rule_id) {
466 return (format_rule_explanation(rule), true);
467 }
468
469 let suggestions = find_similar_rules(rule_id, &config.rule);
470 let mut message = format!("Rule '{}' not found.", rule_id);
471 if !suggestions.is_empty() {
472 message.push_str("\nDid you mean:");
473 for suggestion in suggestions {
474 message.push_str(&format!("\n- {}", suggestion));
475 }
476 }
477 (message, false)
478}
479
480fn handle_notification(
481 connection: &Connection,
482 state: &mut ServerState,
483 notification: Notification,
484) -> Result<bool> {
485 match notification.method.as_str() {
486 method if method == DidOpenTextDocument::METHOD => {
487 let params: lsp_types::DidOpenTextDocumentParams =
488 match serde_json::from_value(notification.params) {
489 Ok(params) => params,
490 Err(err) => {
491 show_message(
492 connection,
493 MessageType::WARNING,
494 &format!("invalid didOpen params: {}", err),
495 )?;
496 return Ok(false);
497 }
498 };
499
500 let uri = params.text_document.uri;
501 if let Some(path) = uri_to_file_path(&uri) {
502 let document = DocumentState::new(
503 path,
504 params.text_document.version,
505 params.text_document.text,
506 );
507 state.documents.insert(uri.clone(), document);
508 refresh_document_diagnostics(connection, state, &uri)?;
509 }
510 }
511 method if method == DidChangeTextDocument::METHOD => {
512 let params: lsp_types::DidChangeTextDocumentParams =
513 match serde_json::from_value(notification.params) {
514 Ok(params) => params,
515 Err(err) => {
516 show_message(
517 connection,
518 MessageType::WARNING,
519 &format!("invalid didChange params: {}", err),
520 )?;
521 return Ok(false);
522 }
523 };
524
525 let uri = params.text_document.uri;
526 if let Some(document) = state.documents.get_mut(&uri) {
527 document.version = params.text_document.version;
528 if let Err(err) = document.apply_changes(¶ms.content_changes) {
529 show_message(
530 connection,
531 MessageType::WARNING,
532 &format!("failed to apply text changes for {}: {}", uri.as_str(), err),
533 )?;
534 }
535 refresh_document_diagnostics(connection, state, &uri)?;
536 }
537 }
538 method if method == DidSaveTextDocument::METHOD => {
539 let params: lsp_types::DidSaveTextDocumentParams =
540 match serde_json::from_value(notification.params) {
541 Ok(params) => params,
542 Err(err) => {
543 show_message(
544 connection,
545 MessageType::WARNING,
546 &format!("invalid didSave params: {}", err),
547 )?;
548 return Ok(false);
549 }
550 };
551
552 let uri = params.text_document.uri;
553 if let Some(document) = state.documents.get_mut(&uri) {
554 document.mark_saved(params.text);
555 }
556
557 if is_config_uri(state, &uri) {
558 let (ok, message) = match reload_config(state) {
559 Ok(msg) => (true, msg),
560 Err(err) => (false, err.to_string()),
561 };
562 let message_type = if ok {
563 MessageType::INFO
564 } else {
565 MessageType::WARNING
566 };
567 show_message(connection, message_type, &message)?;
568 refresh_all_documents(connection, state)?;
569 } else {
570 refresh_document_diagnostics(connection, state, &uri)?;
571 }
572 }
573 method if method == DidCloseTextDocument::METHOD => {
574 let params: lsp_types::DidCloseTextDocumentParams =
575 match serde_json::from_value(notification.params) {
576 Ok(params) => params,
577 Err(err) => {
578 show_message(
579 connection,
580 MessageType::WARNING,
581 &format!("invalid didClose params: {}", err),
582 )?;
583 return Ok(false);
584 }
585 };
586
587 let uri = params.text_document.uri;
588 state.documents.remove(&uri);
589 publish_diagnostics(connection, uri, None, Vec::new())?;
590 }
591 method if method == DidChangeConfiguration::METHOD => {
592 let _: lsp_types::DidChangeConfigurationParams =
593 match serde_json::from_value(notification.params) {
594 Ok(params) => params,
595 Err(err) => {
596 show_message(
597 connection,
598 MessageType::WARNING,
599 &format!("invalid didChangeConfiguration params: {}", err),
600 )?;
601 return Ok(false);
602 }
603 };
604
605 let (ok, message) = match reload_config(state) {
606 Ok(msg) => (true, msg),
607 Err(err) => (false, err.to_string()),
608 };
609 let message_type = if ok {
610 MessageType::INFO
611 } else {
612 MessageType::WARNING
613 };
614 show_message(connection, message_type, &message)?;
615 refresh_all_documents(connection, state)?;
616 }
617 method if method == Exit::METHOD => return Ok(true),
618 _ => {}
619 }
620
621 Ok(false)
622}
623
624fn is_config_uri(state: &ServerState, uri: &Uri) -> bool {
625 let Some(config_path) = state.config_path.as_deref() else {
626 return false;
627 };
628 let Some(uri_path) = uri_to_file_path(uri) else {
629 return false;
630 };
631 paths_match(&uri_path, config_path)
632}
633
634fn reload_config(state: &mut ServerState) -> Result<String> {
635 match load_effective_config(state.config_path.as_deref(), state.no_default_rules) {
636 Ok(config) => {
637 let rules = config.rule.len();
638 state.config = config;
639 Ok(format!(
640 "diffguard-lsp: config reloaded ({} rule(s)).",
641 rules
642 ))
643 }
644 Err(err) => {
645 state.config = ConfigFile::built_in();
646 state.git_support = GitSupport::Unknown;
647 bail!(
648 "diffguard-lsp: failed to reload config (using built-in rules): {}",
649 err
650 )
651 }
652 }
653}
654
655fn refresh_all_documents(connection: &Connection, state: &mut ServerState) -> Result<()> {
656 let mut uris: Vec<Uri> = state.documents.keys().cloned().collect();
657 uris.sort();
658 for uri in uris {
659 refresh_document_diagnostics(connection, state, &uri)?;
660 }
661 Ok(())
662}
663
664fn refresh_document_diagnostics(
665 connection: &Connection,
666 state: &mut ServerState,
667 uri: &Uri,
668) -> Result<()> {
669 let Some(document) = state.documents.get(uri).cloned() else {
670 return Ok(());
671 };
672
673 let relative_path = to_workspace_relative_path(state.workspace_root.as_deref(), &document.path);
674 if relative_path.is_empty() {
675 publish_diagnostics(connection, uri.clone(), Some(document.version), Vec::new())?;
676 return Ok(());
677 }
678
679 let mut allowed_lines = None;
680 let diff_text = if !document.changed_lines.is_empty() {
681 let synthetic =
682 build_synthetic_diff(&relative_path, &document.text, &document.changed_lines);
683 let mut scoped_lines = BTreeSet::new();
684 for line in &document.changed_lines {
685 scoped_lines.insert((relative_path.clone(), *line));
686 }
687 if !scoped_lines.is_empty() {
688 allowed_lines = Some(scoped_lines);
689 }
690 synthetic
691 } else if let Some(workspace_root) = state.workspace_root.as_deref() {
692 match git_diff_for_path(workspace_root, &relative_path) {
693 Ok(diff) => {
694 state.git_support = GitSupport::Available;
695 diff
696 }
697 Err(err) => {
698 if state.git_support != GitSupport::Unavailable {
699 show_message(
700 connection,
701 MessageType::WARNING,
702 &format!(
703 "diffguard-lsp: git diff unavailable (falling back to in-memory changes only): {}",
704 err
705 ),
706 )?;
707 }
708 state.git_support = GitSupport::Unavailable;
709 String::new()
710 }
711 }
712 } else {
713 String::new()
714 };
715
716 if diff_text.trim().is_empty() {
717 publish_diagnostics(connection, uri.clone(), Some(document.version), Vec::new())?;
718 return Ok(());
719 }
720
721 let directory_overrides = if let Some(workspace_root) = state.workspace_root.as_deref() {
722 match load_directory_overrides_for_file(workspace_root, &relative_path) {
723 Ok(overrides) => overrides,
724 Err(err) => {
725 show_message(
726 connection,
727 MessageType::WARNING,
728 &format!("diffguard-lsp: failed to load directory overrides: {}", err),
729 )?;
730 Vec::new()
731 }
732 }
733 } else {
734 Vec::new()
735 };
736
737 let plan = CheckPlan {
738 base: "workspace".to_string(),
739 head: "working-tree".to_string(),
740 scope: Scope::Added,
741 diff_context: 0,
742 fail_on: FailOn::Never,
743 max_findings: state.max_findings,
744 path_filters: vec![relative_path.clone()],
745 only_tags: vec![],
746 enable_tags: vec![],
747 disable_tags: vec![],
748 directory_overrides,
749 force_language: state.force_language.clone(),
750 allowed_lines,
751 false_positive_fingerprints: BTreeSet::new(),
752 };
753
754 let run = match run_check(&plan, &state.config, &diff_text) {
755 Ok(run) => run,
756 Err(err) => {
757 show_message(
758 connection,
759 MessageType::ERROR,
760 &format!("diffguard-lsp: check failed for {}: {}", relative_path, err),
761 )?;
762 publish_diagnostics(connection, uri.clone(), Some(document.version), Vec::new())?;
763 return Ok(());
764 }
765 };
766
767 let diagnostics = findings_to_diagnostics(&run.receipt.findings);
768 publish_diagnostics(connection, uri.clone(), Some(document.version), diagnostics)
769}
770
771fn findings_to_diagnostics(findings: &[Finding]) -> Vec<Diagnostic> {
772 let mut diagnostics: Vec<Diagnostic> = findings
773 .iter()
774 .map(|finding| {
775 let line = finding.line.saturating_sub(1);
776 let start_char = finding.column.unwrap_or(1).saturating_sub(1);
777 let span = utf16_length(&finding.match_text).max(1);
778 let end_char = start_char.saturating_add(span);
779
780 Diagnostic {
781 range: Range::new(
782 Position::new(line, start_char),
783 Position::new(line, end_char),
784 ),
785 severity: Some(match finding.severity {
786 Severity::Info => DiagnosticSeverity::INFORMATION,
787 Severity::Warn => DiagnosticSeverity::WARNING,
788 Severity::Error => DiagnosticSeverity::ERROR,
789 }),
790 code: Some(NumberOrString::String(finding.rule_id.clone())),
791 source: Some("diffguard".to_string()),
792 message: finding.message.clone(),
793 data: Some(json!({
794 "ruleId": finding.rule_id,
795 "path": finding.path,
796 "line": finding.line
797 })),
798 ..Diagnostic::default()
799 }
800 })
801 .collect();
802
803 diagnostics.sort_by(|left, right| {
804 left.range
805 .start
806 .line
807 .cmp(&right.range.start.line)
808 .then_with(|| left.range.start.character.cmp(&right.range.start.character))
809 .then_with(|| left.message.cmp(&right.message))
810 });
811
812 diagnostics
813}
814
815fn nth_string_arg(arguments: &[serde_json::Value], index: usize) -> Option<String> {
816 arguments
817 .get(index)
818 .and_then(|value| value.as_str())
819 .map(|value| value.to_string())
820}
821
822fn send_ok_response(
823 connection: &Connection,
824 id: RequestId,
825 result: serde_json::Value,
826) -> Result<()> {
827 let response = Response {
828 id,
829 result: Some(result),
830 error: None,
831 };
832 send_response(connection, response)
833}
834
835fn send_error_response(
836 connection: &Connection,
837 id: RequestId,
838 code: i32,
839 message: String,
840) -> Result<()> {
841 let response = Response {
842 id,
843 result: None,
844 error: Some(ResponseError {
845 code,
846 message,
847 data: None,
848 }),
849 };
850 send_response(connection, response)
851}
852
853fn send_response(connection: &Connection, response: Response) -> Result<()> {
854 connection
855 .sender
856 .send(Message::Response(response))
857 .context("send LSP response")?;
858 Ok(())
859}
860
861fn show_message(connection: &Connection, typ: MessageType, message: &str) -> Result<()> {
862 let params = lsp_types::ShowMessageParams {
863 typ,
864 message: message.to_string(),
865 };
866 let notification = Notification::new(ShowMessage::METHOD.to_string(), params);
867 connection
868 .sender
869 .send(Message::Notification(notification))
870 .context("send showMessage notification")?;
871 Ok(())
872}
873
874fn publish_diagnostics(
875 connection: &Connection,
876 uri: Uri,
877 version: Option<i32>,
878 diagnostics: Vec<Diagnostic>,
879) -> Result<()> {
880 let params = PublishDiagnosticsParams {
881 uri,
882 diagnostics,
883 version,
884 };
885 let notification = Notification::new(PublishDiagnostics::METHOD.to_string(), params);
886 connection
887 .sender
888 .send(Message::Notification(notification))
889 .context("publish diagnostics")?;
890 Ok(())
891}
892
893fn git_diff_for_path(workspace_root: &Path, relative_path: &str) -> Result<String> {
894 let unstaged = run_git_diff(workspace_root, relative_path, false)?;
895 let staged = run_git_diff(workspace_root, relative_path, true)?;
896
897 if unstaged.is_empty() {
898 return Ok(staged);
899 }
900 if staged.is_empty() {
901 return Ok(unstaged);
902 }
903
904 let mut combined = unstaged;
905 if !combined.ends_with('\n') {
906 combined.push('\n');
907 }
908 combined.push_str(&staged);
909 Ok(combined)
910}
911
912fn run_git_diff(workspace_root: &Path, relative_path: &str, staged: bool) -> Result<String> {
913 let mut command = Command::new("git");
914 command.current_dir(workspace_root).arg("diff");
915 if staged {
916 command.arg("--cached");
917 }
918 command.arg("--unified=0").arg("--").arg(relative_path);
919
920 const GIT_DIFF_TIMEOUT: Duration = Duration::from_secs(10);
922 let mut child = command.spawn().context("spawn git diff")?;
923 let deadline = Instant::now() + GIT_DIFF_TIMEOUT;
924
925 let output = loop {
926 match child.try_wait() {
927 Ok(Some(_)) => {
928 break child.wait_with_output().context("wait for git diff")?;
930 }
931 Ok(None) => {
932 if Instant::now() >= deadline {
934 let _ = child.kill();
935 bail!("git diff timed out after {}s", GIT_DIFF_TIMEOUT.as_secs());
936 }
937 thread::sleep(Duration::from_millis(100));
938 }
939 Err(e) => bail!("checking git diff status: {e}"),
940 }
941 };
942
943 if !output.status.success() {
944 bail!(
945 "git diff failed (exit={}): {}",
946 output.status,
947 String::from_utf8_lossy(&output.stderr).trim()
948 );
949 }
950
951 Ok(String::from_utf8_lossy(&output.stdout).to_string())
952}
953
954fn uri_to_file_path(uri: &Uri) -> Option<PathBuf> {
955 let parsed = url::Url::parse(uri.as_str()).ok()?;
956 parsed.to_file_path().ok()
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962
963 #[test]
964 fn server_capabilities_include_text_sync_and_actions() {
965 let capabilities = server_capabilities();
966 assert!(matches!(
967 capabilities.text_document_sync,
968 Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL))
969 ));
970 assert!(capabilities.code_action_provider.is_some());
971 assert!(capabilities.execute_command_provider.is_some());
972 }
973
974 #[test]
975 fn initialize_payload_contains_server_name() {
976 let value = initialize_payload().expect("payload");
977 let info = value
978 .get("serverInfo")
979 .and_then(|v| v.as_object())
980 .expect("server info");
981 assert_eq!(
982 info.get("name").and_then(|v| v.as_str()),
983 Some("diffguard-lsp")
984 );
985 }
986
987 #[test]
988 fn build_code_actions_contains_explain_action() {
989 let config = ConfigFile {
990 includes: vec![],
991 defaults: diffguard_types::Defaults::default(),
992 rule: vec![diffguard_types::RuleConfig {
993 id: "rust.no_unwrap".to_string(),
994 severity: Severity::Warn,
995 message: "Avoid unwrap".to_string(),
996 languages: vec![],
997 patterns: vec!["unwrap".to_string()],
998 paths: vec![],
999 exclude_paths: vec![],
1000 ignore_comments: false,
1001 ignore_strings: false,
1002 match_mode: diffguard_types::MatchMode::Any,
1003 multiline: false,
1004 multiline_window: None,
1005 context_patterns: vec![],
1006 context_window: None,
1007 escalate_patterns: vec![],
1008 escalate_window: None,
1009 escalate_to: None,
1010 depends_on: vec![],
1011 help: None,
1012 url: Some("https://example.com/rule".to_string()),
1013 tags: vec![],
1014 test_cases: vec![],
1015 }],
1016 };
1017
1018 let params = CodeActionParams {
1019 text_document: lsp_types::TextDocumentIdentifier {
1020 uri: "file:///tmp/test.rs".parse().expect("uri"),
1021 },
1022 range: Range::new(Position::new(0, 0), Position::new(0, 10)),
1023 context: lsp_types::CodeActionContext {
1024 diagnostics: vec![Diagnostic {
1025 code: Some(NumberOrString::String("rust.no_unwrap".to_string())),
1026 ..Diagnostic::default()
1027 }],
1028 only: None,
1029 trigger_kind: None,
1030 },
1031 work_done_progress_params: Default::default(),
1032 partial_result_params: Default::default(),
1033 };
1034
1035 let actions = build_code_actions(&config, ¶ms);
1036 assert!(!actions.is_empty());
1037 }
1038}