Skip to main content

diffguard_lsp/
server.rs

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    // Use the lower-level initialize_start/initialize_finish methods
166    // to send a custom InitializeResult with server_info.
167    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    // Send InitializeResult with server_info
177    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) = &params.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) = &params.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    // When using initialize_finish(), we send the full InitializeResult
254    // including server_info. The lsp-server library doesn't wrap this
255    // in a capabilities object.
256    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, &params);
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 &params.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(&params.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(&params.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(&params.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(&params.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    // Spawn with a 10-second timeout to avoid blocking the LSP indefinitely
921    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                // Process has exited; wait_with_output() returns immediately
929                break child.wait_with_output().context("wait for git diff")?;
930            }
931            Ok(None) => {
932                // Still running
933                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, &params);
1036        assert!(!actions.is_empty());
1037    }
1038}