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 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 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(¶ms.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(¶ms.text_document.uri, &change.text);
128 }
129 }
130
131 async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
132 let uri = ¶ms.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, ¶ms.context.diagnostics, doc_text);
138 collect_selection_actions(&mut actions, uri, ¶ms.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
152fn 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 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 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
186fn 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
203fn 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
277pub 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}