Skip to main content

cha_lsp/
lib.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4use tokio::sync::RwLock;
5
6use cha_core::{AnalysisContext, Config, Finding, PluginRegistry, Severity, SourceFile};
7use tower_lsp::jsonrpc::Result;
8use tower_lsp::lsp_types::*;
9use tower_lsp::{Client, LanguageServer, LspService, Server};
10
11struct ChaLsp {
12    client: Client,
13    registry: Arc<PluginRegistry>,
14    docs: Arc<RwLock<HashMap<Url, String>>>,
15}
16
17impl ChaLsp {
18    fn analyze_and_publish(&self, uri: &Url, text: &str) {
19        let path = uri
20            .to_file_path()
21            .unwrap_or_else(|_| PathBuf::from(uri.path()));
22        let file = SourceFile::new(path, text.to_string());
23
24        let diagnostics = self.collect_diagnostics(&file);
25        self.publish(uri.clone(), diagnostics);
26    }
27
28    // Run all plugins on a single file and convert findings to diagnostics.
29    fn collect_diagnostics(&self, file: &SourceFile) -> Vec<Diagnostic> {
30        let model = match cha_parser::parse_file(file) {
31            Some(m) => m,
32            None => return vec![],
33        };
34        let ctx = AnalysisContext {
35            file,
36            model: &model,
37        };
38        self.registry
39            .plugins()
40            .iter()
41            .flat_map(|p| p.analyze(&ctx))
42            .map(|f| finding_to_diagnostic(&f))
43            .collect()
44    }
45
46    // Spawn an async task to publish diagnostics to the client.
47    fn publish(&self, uri: Url, diagnostics: Vec<Diagnostic>) {
48        let client = self.client.clone();
49        tokio::spawn(async move {
50            client.publish_diagnostics(uri, diagnostics, None).await;
51        });
52    }
53}
54
55fn finding_to_diagnostic(f: &Finding) -> Diagnostic {
56    let severity = match f.severity {
57        Severity::Error => DiagnosticSeverity::ERROR,
58        Severity::Warning => DiagnosticSeverity::WARNING,
59        Severity::Hint => DiagnosticSeverity::HINT,
60    };
61
62    let start = f.location.start_line.saturating_sub(1);
63    let end = f.location.end_line.saturating_sub(1);
64
65    Diagnostic {
66        range: Range {
67            start: Position::new(start as u32, 0),
68            end: Position::new(end as u32, 0),
69        },
70        severity: Some(severity),
71        source: Some("cha".into()),
72        code: Some(NumberOrString::String(f.smell_name.clone())),
73        message: f.message.clone(),
74        data: if f.suggested_refactorings.is_empty() {
75            None
76        } else {
77            Some(serde_json::json!(f.suggested_refactorings))
78        },
79        ..Default::default()
80    }
81}
82
83#[tower_lsp::async_trait]
84impl LanguageServer for ChaLsp {
85    async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> {
86        Ok(InitializeResult {
87            capabilities: ServerCapabilities {
88                text_document_sync: Some(TextDocumentSyncCapability::Kind(
89                    TextDocumentSyncKind::FULL,
90                )),
91                code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
92                ..Default::default()
93            },
94            ..Default::default()
95        })
96    }
97
98    async fn initialized(&self, _: InitializedParams) {
99        self.client
100            .log_message(MessageType::INFO, "cha-lsp initialized")
101            .await;
102    }
103
104    async fn did_open(&self, params: DidOpenTextDocumentParams) {
105        let uri = params.text_document.uri.clone();
106        let text = params.text_document.text.clone();
107        self.docs.write().await.insert(uri.clone(), text.clone());
108        self.analyze_and_publish(&uri, &text);
109    }
110
111    async fn did_save(&self, params: DidSaveTextDocumentParams) {
112        if let Some(text) = params.text {
113            self.docs
114                .write()
115                .await
116                .insert(params.text_document.uri.clone(), text.clone());
117            self.analyze_and_publish(&params.text_document.uri, &text);
118        }
119    }
120
121    async fn did_change(&self, params: DidChangeTextDocumentParams) {
122        if let Some(change) = params.content_changes.into_iter().last() {
123            self.docs
124                .write()
125                .await
126                .insert(params.text_document.uri.clone(), change.text.clone());
127            self.analyze_and_publish(&params.text_document.uri, &change.text);
128        }
129    }
130
131    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
132        let uri = &params.text_document.uri;
133        let docs = self.docs.read().await;
134        let doc_text = docs.get(uri);
135
136        let mut actions = Vec::new();
137        collect_diagnostic_actions(&mut actions, uri, &params.context.diagnostics, doc_text);
138        collect_selection_actions(&mut actions, uri, &params.range, doc_text);
139
140        Ok(if actions.is_empty() {
141            None
142        } else {
143            Some(actions)
144        })
145    }
146
147    async fn shutdown(&self) -> Result<()> {
148        Ok(())
149    }
150}
151
152/// Build code actions from cha diagnostics.
153fn collect_diagnostic_actions(
154    actions: &mut Vec<CodeActionOrCommand>,
155    uri: &Url,
156    diagnostics: &[Diagnostic],
157    doc_text: Option<&String>,
158) {
159    for diag in diagnostics {
160        if diag.source.as_deref() != Some("cha") {
161            continue;
162        }
163        // Extract Method for long_method
164        if let Some(text) = doc_text
165            && diag.code == Some(NumberOrString::String("long_method".into()))
166            && let Some(action) = build_extract_method(uri, &diag.range, text)
167        {
168            actions.push(CodeActionOrCommand::CodeAction(action));
169        }
170        // Suggestion-based quick fixes
171        if let Some(data) = &diag.data
172            && let Ok(suggestions) = serde_json::from_value::<Vec<String>>(data.clone())
173        {
174            for suggestion in suggestions {
175                actions.push(CodeActionOrCommand::CodeAction(CodeAction {
176                    title: format!("Refactor: {}", suggestion),
177                    kind: Some(CodeActionKind::QUICKFIX),
178                    diagnostics: Some(vec![diag.clone()]),
179                    ..Default::default()
180                }));
181            }
182        }
183    }
184}
185
186/// Offer Extract Method for user selections spanning 3+ lines.
187fn collect_selection_actions(
188    actions: &mut Vec<CodeActionOrCommand>,
189    uri: &Url,
190    range: &Range,
191    doc_text: Option<&String>,
192) {
193    if let Some(text) = doc_text {
194        let line_span = range.end.line.saturating_sub(range.start.line);
195        if line_span >= 3
196            && let Some(action) = build_extract_method(uri, range, text)
197        {
198            actions.push(CodeActionOrCommand::CodeAction(action));
199        }
200    }
201}
202
203/// Build an Extract Method code action.
204fn build_extract_method(uri: &Url, range: &Range, text: &str) -> Option<CodeAction> {
205    let lines: Vec<&str> = text.lines().collect();
206    let start = range.start.line as usize;
207    let end = (range.end.line as usize).min(lines.len());
208    if start >= end || start >= lines.len() {
209        return None;
210    }
211
212    let selected = &lines[start..end];
213    let edits = build_extract_edits(uri, range, selected, end);
214
215    Some(CodeAction {
216        title: "Extract Method".into(),
217        kind: Some(CodeActionKind::REFACTOR_EXTRACT),
218        edit: Some(WorkspaceEdit {
219            changes: Some(edits),
220            ..Default::default()
221        }),
222        ..Default::default()
223    })
224}
225
226fn build_extract_edits(
227    uri: &Url,
228    range: &Range,
229    selected: &[&str],
230    end: usize,
231) -> HashMap<Url, Vec<TextEdit>> {
232    let indent = selected
233        .first()
234        .map(|l| l.len() - l.trim_start().len())
235        .unwrap_or(0);
236    let fn_name = "extracted";
237
238    let body = selected
239        .iter()
240        .map(|l| {
241            if l.trim().is_empty() {
242                String::new()
243            } else {
244                format!("    {}", l.trim())
245            }
246        })
247        .collect::<Vec<_>>()
248        .join("\n");
249
250    let call = format!("{}{fn_name}();\n", " ".repeat(indent));
251    let new_fn = format!("\nfn {fn_name}() {{\n{body}\n}}\n");
252    let end_col = selected.last().map(|l| l.len() as u32).unwrap_or(0);
253
254    let mut changes = HashMap::new();
255    changes.insert(
256        uri.clone(),
257        vec![
258            TextEdit {
259                range: Range {
260                    start: Position::new(range.start.line, 0),
261                    end: Position::new(range.end.line, end_col),
262                },
263                new_text: call,
264            },
265            TextEdit {
266                range: Range {
267                    start: Position::new(end as u32, 0),
268                    end: Position::new(end as u32, 0),
269                },
270                new_text: new_fn,
271            },
272        ],
273    );
274    changes
275}
276
277/// Entry point for the LSP server.
278pub async fn run_lsp() {
279    let cwd = std::env::current_dir().unwrap_or_default();
280    let config = Config::load(&cwd);
281    let registry = Arc::new(PluginRegistry::from_config(&config, &cwd));
282
283    let stdin = tokio::io::stdin();
284    let stdout = tokio::io::stdout();
285
286    let (service, socket) = LspService::new(|client| ChaLsp {
287        client,
288        registry: registry.clone(),
289        docs: Arc::new(RwLock::new(HashMap::new())),
290    });
291
292    Server::new(stdin, stdout, socket).serve(service).await;
293}