1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::time::Instant;
5
6use serde_json::json;
7use tokio::io::{AsyncReadExt, AsyncWriteExt};
8use tokio::net::{UnixListener, UnixStream};
9use tokio::sync::{watch, Mutex};
10use tracing::{debug, error, info};
11
12use crate::commands::{check, edit, find, fix, format as fmt, hover, list, read, rename};
13use crate::config::{self, ConfigSource};
14use crate::detect::{self, language_for_file, Language};
15use crate::index::builder;
16use crate::index::cache_query;
17use crate::index::store::IndexStore;
18use crate::index::watcher::{self, DirtyFiles};
19use crate::lsp::client::path_to_uri;
20use crate::lsp::diagnostics::DiagnosticStore;
21use crate::lsp::pool::LspMultiplexer;
22use crate::lsp::registry::{find_server, get_entry};
23use crate::lsp::router;
24use crate::protocol::{Request, Response};
25
26const INDEXING_GRACE_PERIOD_SECS: u64 = 60;
28
29pub struct DaemonState {
31 pub start_time: Instant,
32 pub last_activity: Arc<Mutex<Instant>>,
33 pub project_root: PathBuf,
34 pub languages: Vec<Language>,
35 pub pool: Arc<LspMultiplexer>,
36 pub config_source: ConfigSource,
37 pub package_roots: Vec<(Language, PathBuf)>,
39 pub index: std::sync::Mutex<Option<IndexStore>>,
41 pub dirty_files: DirtyFiles,
43 pub watcher_active: bool,
45 _watcher: Option<
47 notify_debouncer_full::Debouncer<
48 notify_debouncer_full::notify::RecommendedWatcher,
49 notify_debouncer_full::FileIdMap,
50 >,
51 >,
52 shutdown_tx: watch::Sender<bool>,
53 pub diagnostic_store: Arc<DiagnosticStore>,
55}
56
57impl DaemonState {
58 fn new(
59 shutdown_tx: watch::Sender<bool>,
60 project_root: PathBuf,
61 languages: Vec<Language>,
62 package_roots: Vec<(Language, PathBuf)>,
63 config_source: ConfigSource,
64 ) -> Self {
65 let now = Instant::now();
66 let index = Self::open_index(&project_root);
67 let dirty_files = DirtyFiles::new();
68
69 let diagnostic_store = Arc::new(DiagnosticStore::new());
70 let pool = Arc::new(LspMultiplexer::new(
71 project_root.clone(),
72 package_roots.clone(),
73 ));
74 pool.set_diagnostic_store(Arc::clone(&diagnostic_store));
75
76 let extensions = language_extensions(&languages);
78 let watcher_result = watcher::start_watcher(
79 &project_root,
80 &extensions,
81 dirty_files.clone(),
82 Some(Arc::clone(&diagnostic_store)),
83 );
84 let (watcher_handle, watcher_active) = match watcher_result {
85 Ok(w) => (Some(w), true),
86 Err(e) => {
87 debug!("file watcher unavailable, using BLAKE3 fallback: {e}");
88 (None, false)
89 }
90 };
91
92 Self {
93 start_time: now,
94 last_activity: Arc::new(Mutex::new(now)),
95 pool,
96 project_root,
97 languages,
98 config_source,
99 package_roots,
100 index: std::sync::Mutex::new(index),
101 dirty_files,
102 watcher_active,
103 _watcher: watcher_handle,
104 shutdown_tx,
105 diagnostic_store,
106 }
107 }
108
109 fn open_index(project_root: &Path) -> Option<IndexStore> {
111 let db_path = project_root.join(".krait/index.db");
112 if !db_path.exists() {
113 return None;
114 }
115 match IndexStore::open(&db_path) {
116 Ok(store) => Some(store),
117 Err(e) => {
118 info!("index DB corrupted ({e}), deleting for fresh rebuild");
119 let _ = std::fs::remove_file(&db_path);
120 None
121 }
122 }
123 }
124
125 fn refresh_index(&self) {
127 if let Ok(mut guard) = self.index.lock() {
128 *guard = Self::open_index(&self.project_root);
129 }
130 }
131
132 fn dirty_files_ref(&self) -> Option<&DirtyFiles> {
137 if self.watcher_active {
138 Some(&self.dirty_files)
139 } else {
140 None
141 }
142 }
143
144 async fn touch(&self) {
145 *self.last_activity.lock().await = Instant::now();
146 }
147
148 fn request_shutdown(&self) {
149 let _ = self.shutdown_tx.send(true);
150 }
151}
152
153pub async fn run_server(
158 socket_path: &Path,
159 idle_timeout: std::time::Duration,
160 project_root: &Path,
161) -> anyhow::Result<()> {
162 let _ = std::fs::remove_file(socket_path);
163
164 let listener = UnixListener::bind(socket_path)?;
165 info!("daemon listening on {}", socket_path.display());
166
167 let languages = detect::detect_languages(project_root);
168
169 let loaded = config::load(project_root);
171 let config_source = loaded.source.clone();
172 let package_roots = if let Some(ref cfg) = loaded.config {
173 let roots = config::config_to_package_roots(cfg, project_root);
174 info!(
175 "config: {} ({} workspaces)",
176 loaded.source.label(),
177 roots.len()
178 );
179 roots
180 } else {
181 detect::find_package_roots(project_root)
182 };
183
184 if package_roots.len() > 1 {
185 if loaded.config.is_none() {
186 info!("monorepo detected: {} workspace roots", package_roots.len());
187 }
188 for (lang, root) in &package_roots {
189 debug!(" workspace: {lang}:{}", root.display());
190 }
191 } else if !package_roots.is_empty() {
192 info!("project: {} workspace root(s)", package_roots.len());
193 }
194
195 let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
196 let state = DaemonState::new(
197 shutdown_tx,
198 project_root.to_path_buf(),
199 languages,
200 package_roots,
201 config_source,
202 );
203 apply_pool_config(&state, &loaded, project_root);
204 let state = Arc::new(state);
205
206 if matches!(
208 state.config_source,
209 ConfigSource::KraitToml | ConfigSource::LegacyKraitToml
210 ) {
211 spawn_background_boot(Arc::clone(&state));
212 }
213
214 let state_for_sigterm = Arc::clone(&state);
216 tokio::spawn(async move {
217 if let Ok(mut sigterm) =
218 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
219 {
220 sigterm.recv().await;
221 info!("received SIGTERM, shutting down gracefully");
222 state_for_sigterm.pool.shutdown_all().await;
223 state_for_sigterm.request_shutdown();
224 }
225 });
226
227 loop {
228 let idle_deadline = {
229 let last = *state.last_activity.lock().await;
230 last + idle_timeout
231 };
232 let sleep_dur = idle_deadline.saturating_duration_since(Instant::now());
233
234 tokio::select! {
235 result = listener.accept() => {
236 match result {
237 Ok((stream, _)) => {
238 let state = Arc::clone(&state);
239 tokio::spawn(async move {
240 if let Err(e) = handle_connection(stream, &state).await {
241 error!("connection error: {e}");
242 }
243 });
244 }
245 Err(e) => {
246 error!("accept error: {e}");
247 }
248 }
249 }
250 () = tokio::time::sleep(sleep_dur) => {
251 let last = *state.last_activity.lock().await;
252 if last.elapsed() >= idle_timeout {
253 info!("idle timeout reached, shutting down");
254 state.pool.shutdown_all().await;
255 break;
256 }
257 }
258 _ = shutdown_rx.changed() => {
259 if *shutdown_rx.borrow() {
260 info!("shutdown requested");
261 state.pool.shutdown_all().await;
262 break;
263 }
264 }
265 }
266 }
267
268 Ok(())
269}
270
271fn apply_pool_config(state: &DaemonState, loaded: &config::LoadedConfig, project_root: &Path) {
273 if let Some(ref cfg) = loaded.config {
274 if let Some(max) = cfg.max_active_sessions {
275 state.pool.set_max_lru_sessions(max);
276 info!("config: max_active_sessions={max}");
277 }
278 if let Some(max) = cfg.max_language_servers {
279 state.pool.set_max_language_servers(max);
280 info!("config: max_language_servers={max}");
281 }
282 if !cfg.primary_workspaces.is_empty() {
283 let priority: HashSet<PathBuf> = cfg
284 .primary_workspaces
285 .iter()
286 .map(|p| project_root.join(p))
287 .collect();
288 info!("config: {} primary workspaces", priority.len());
289 state.pool.set_priority_roots(priority);
290 }
291 }
292}
293
294#[allow(clippy::needless_pass_by_value)]
299fn spawn_background_boot(state: Arc<DaemonState>) {
300 let langs = state.pool.unique_languages();
301 let priority = state.pool.priority_roots();
302 info!("background boot: starting {} language servers", langs.len());
303
304 for lang in langs {
306 let pool = Arc::clone(&state.pool);
307 tokio::spawn(async move {
308 match pool.get_or_start(lang).await {
309 Ok(mut guard) => {
310 if let Err(e) = pool
311 .attach_all_workspaces_with_guard(lang, &mut guard)
312 .await
313 {
314 debug!("background boot: attach failed for {lang}: {e}");
315 }
316 info!("booted {lang}");
317 }
318 Err(e) => debug!("boot failed {lang}: {e}"),
319 }
320 });
321 }
322
323 if !priority.is_empty() {
325 let pool = Arc::clone(&state.pool);
326 tokio::spawn(async move {
327 if let Err(e) = pool.warm_priority_roots().await {
328 debug!("priority warmup failed: {e}");
329 }
330 });
331 }
332}
333
334async fn handle_connection(mut stream: UnixStream, state: &DaemonState) -> anyhow::Result<()> {
335 state.touch().await;
336
337 let len = stream.read_u32().await?;
338 if len > crate::protocol::MAX_FRAME_SIZE {
339 anyhow::bail!("oversized frame: {len} bytes");
340 }
341 let mut buf = vec![0u8; len as usize];
342 stream.read_exact(&mut buf).await?;
343
344 let request: Request = serde_json::from_slice(&buf)?;
345 debug!("received request: {request:?}");
346
347 let response = dispatch(&request, state).await;
348
349 let response_bytes = serde_json::to_vec(&response)?;
350 let response_len = u32::try_from(response_bytes.len())?;
351 stream.write_u32(response_len).await?;
352 stream.write_all(&response_bytes).await?;
353 stream.flush().await?;
354
355 Ok(())
356}
357
358async fn dispatch(request: &Request, state: &DaemonState) -> Response {
359 match request {
360 Request::Status => build_status_response(state),
361 Request::DaemonStop => {
362 state.pool.shutdown_all().await;
363 state.request_shutdown();
364 Response::ok(json!({"message": "shutting down"}))
365 }
366 Request::Check { path, errors_only } => {
367 handle_check(path.as_deref(), *errors_only, state).await
368 }
369 Request::Init => handle_init(state).await,
370 Request::FindSymbol {
371 name,
372 path_filter,
373 src_only,
374 include_body,
375 } => {
376 handle_find_symbol(
377 name,
378 path_filter.as_deref(),
379 *src_only,
380 *include_body,
381 state,
382 )
383 .await
384 }
385 Request::FindRefs { name, with_symbol } => {
386 handle_find_refs(name, *with_symbol, state).await
387 }
388 Request::FindImpl { name } => handle_find_impl(name, state).await,
389 Request::ListSymbols { path, depth } => handle_list_symbols(path, *depth, state).await,
390 Request::ReadFile {
391 path,
392 from,
393 to,
394 max_lines,
395 } => handle_read_file(path, *from, *to, *max_lines, state),
396 Request::ReadSymbol {
397 name,
398 signature_only,
399 max_lines,
400 path_filter,
401 has_body,
402 } => {
403 handle_read_symbol(
404 name,
405 *signature_only,
406 *max_lines,
407 path_filter.as_deref(),
408 *has_body,
409 state,
410 )
411 .await
412 }
413 Request::EditReplace { symbol, code } => {
414 handle_edit(symbol, code, EditKind::Replace, state).await
415 }
416 Request::EditInsertAfter { symbol, code } => {
417 handle_edit(symbol, code, EditKind::InsertAfter, state).await
418 }
419 Request::EditInsertBefore { symbol, code } => {
420 handle_edit(symbol, code, EditKind::InsertBefore, state).await
421 }
422 Request::Hover { name } => handle_hover_cmd(name, state).await,
423 Request::Format { path } => handle_format_cmd(path, state).await,
424 Request::Rename { name, new_name } => handle_rename_cmd(name, new_name, state).await,
425 Request::Fix { path } => handle_fix_cmd(path.as_deref(), state).await,
426 Request::ServerStatus => handle_server_status(state),
427 Request::ServerRestart { language } => handle_server_restart(language, state).await,
428 }
429}
430
431fn handle_server_status(state: &DaemonState) -> Response {
432 let statuses = state.pool.status();
433 let items: Vec<serde_json::Value> = statuses
434 .iter()
435 .map(|s| {
436 json!({
437 "language": s.language,
438 "server": s.server_name,
439 "status": s.status,
440 "uptime_secs": s.uptime_secs,
441 "open_files": s.open_files,
442 "attached_folders": s.attached_folders,
443 "total_folders": s.total_folders,
444 })
445 })
446 .collect();
447 Response::ok(json!({"servers": items, "count": items.len()}))
448}
449
450async fn handle_server_restart(language: &str, state: &DaemonState) -> Response {
451 let Some(lang) = crate::config::parse_language(language) else {
452 return Response::err("unknown_language", format!("unknown language: {language}"));
453 };
454
455 match state.pool.restart_language(lang).await {
456 Ok(()) => {
457 let server_name = crate::lsp::registry::get_entry(lang)
458 .map_or_else(|| "unknown".to_string(), |e| e.binary_name.to_string());
459 Response::ok(json!({
460 "restarted": language,
461 "server_name": server_name,
462 }))
463 }
464 Err(e) => Response::err("restart_failed", e.to_string()),
465 }
466}
467
468async fn handle_find_symbol(
470 name: &str,
471 path_filter: Option<&str>,
472 src_only: bool,
473 include_body: bool,
474 state: &DaemonState,
475) -> Response {
476 if let Ok(guard) = state.index.lock() {
478 if let Some(ref store) = *guard {
479 if let Some(results) = cache_query::cached_find_symbol(
480 store,
481 name,
482 &state.project_root,
483 state.dirty_files_ref(),
484 ) {
485 debug!(
486 "find_symbol cache hit for '{name}' ({} results)",
487 results.len()
488 );
489 let filtered = postprocess_symbols(
490 results,
491 path_filter,
492 src_only,
493 include_body,
494 &state.project_root,
495 );
496 return Response::ok(serde_json::to_value(filtered).unwrap_or_default());
497 }
498 }
499 }
500
501 let languages = state.pool.unique_languages();
502 if languages.is_empty() {
503 return Response::err("no_language", "No language detected in project");
504 }
505
506 let handles: Vec<_> = languages
508 .iter()
509 .map(|lang| {
510 let pool = Arc::clone(&state.pool);
511 let name = name.to_string();
512 let project_root = state.project_root.clone();
513 let lang = *lang;
514 tokio::spawn(async move {
515 let mut guard = pool.get_or_start(lang).await?;
516 pool.attach_all_workspaces_with_guard(lang, &mut guard)
517 .await?;
518 let session = guard
519 .session_mut()
520 .ok_or_else(|| anyhow::anyhow!("no session for {lang}"))?;
521 find::find_symbol(&name, &mut session.client, &project_root).await
522 })
523 })
524 .collect();
525
526 let mut all_results = Vec::new();
527 for (lang, handle) in languages.iter().zip(handles) {
528 match handle.await {
529 Ok(Ok(results)) => all_results.push(results),
530 Ok(Err(e)) => debug!("find_symbol failed for {lang}: {e}"),
531 Err(e) => debug!("find_symbol task panicked for {lang}: {e}"),
532 }
533 }
534
535 let merged = router::merge_symbol_results(all_results);
536 if merged.is_empty() {
537 let readiness = state.pool.readiness();
538 let uptime = state.start_time.elapsed().as_secs();
539 if uptime < INDEXING_GRACE_PERIOD_SECS && readiness.total > 0 && !readiness.is_all_ready() {
540 return Response::err_with_advice(
541 "indexing",
542 format!(
543 "LSP servers still indexing ({}/{} ready, daemon uptime {}s)",
544 readiness.ready, readiness.total, uptime
545 ),
546 "Wait a few seconds and try again, or run `krait daemon status`",
547 );
548 }
549
550 let name_owned = name.to_string();
553 let root = state.project_root.clone();
554 let fallback =
555 tokio::task::spawn_blocking(move || find::text_search_find_symbol(&name_owned, &root))
556 .await
557 .unwrap_or_default();
558
559 let filtered =
560 postprocess_symbols(fallback, path_filter, src_only, false, &state.project_root);
561 if filtered.is_empty() {
562 return Response::ok(json!([]));
563 }
564 let filtered = if include_body {
565 populate_bodies(filtered, &state.project_root)
566 } else {
567 filtered
568 };
569 Response::ok(serde_json::to_value(filtered).unwrap_or_default())
570 } else {
571 let filtered = postprocess_symbols(
572 merged,
573 path_filter,
574 src_only,
575 include_body,
576 &state.project_root,
577 );
578 Response::ok(serde_json::to_value(filtered).unwrap_or_default())
579 }
580}
581
582async fn handle_find_refs(name: &str, with_symbol: bool, state: &DaemonState) -> Response {
584 let languages = state.pool.unique_languages();
585 if languages.is_empty() {
586 return Response::err("no_language", "No language detected in project");
587 }
588
589 let handles: Vec<_> = languages
590 .iter()
591 .map(|lang| {
592 let pool = Arc::clone(&state.pool);
593 let name = name.to_string();
594 let project_root = state.project_root.clone();
595 let lang = *lang;
596 tokio::spawn(async move {
597 let mut guard = pool.get_or_start(lang).await?;
598 pool.attach_all_workspaces_with_guard(lang, &mut guard)
599 .await?;
600 let session = guard
601 .session_mut()
602 .ok_or_else(|| anyhow::anyhow!("no session for {lang}"))?;
603 find::find_refs(
604 &name,
605 &mut session.client,
606 &mut session.file_tracker,
607 &project_root,
608 )
609 .await
610 })
611 })
612 .collect();
613
614 let mut all_results = Vec::new();
615 for (lang, handle) in languages.iter().zip(handles) {
616 match handle.await {
617 Ok(Ok(results)) => all_results.push(results),
618 Ok(Err(e)) => {
619 let msg = e.to_string();
620 if !msg.contains("not found") {
621 debug!("find_refs failed for {lang}: {e}");
622 }
623 }
624 Err(e) => debug!("find_refs task panicked for {lang}: {e}"),
625 }
626 }
627
628 let merged = router::merge_reference_results(all_results);
629
630 let has_call_sites = merged.iter().any(|r| !r.is_definition);
636
637 if merged.is_empty() || !has_call_sites {
638 let readiness = state.pool.readiness();
639 let uptime = state.start_time.elapsed().as_secs();
640 if merged.is_empty()
641 && uptime < INDEXING_GRACE_PERIOD_SECS
642 && readiness.total > 0
643 && !readiness.is_all_ready()
644 {
645 return Response::err_with_advice(
646 "indexing",
647 format!(
648 "LSP servers still indexing ({}/{} ready, daemon uptime {}s)",
649 readiness.ready, readiness.total, uptime
650 ),
651 "Wait a few seconds and try again, or run `krait daemon status`",
652 );
653 }
654
655 let name_owned = name.to_string();
658 let root = state.project_root.clone();
659 let text_results =
660 tokio::task::spawn_blocking(move || find::text_search_find_refs(&name_owned, &root))
661 .await
662 .unwrap_or_default();
663
664 let mut combined = merged;
666 for r in text_results {
667 let already_present = combined
668 .iter()
669 .any(|m| m.path == r.path && m.line == r.line);
670 if !already_present {
671 combined.push(r);
672 }
673 }
674
675 if combined.is_empty() {
676 Response::err_with_advice(
677 "symbol_not_found",
678 format!("symbol '{name}' not found"),
679 "Check the symbol name and try again",
680 )
681 } else {
682 if with_symbol {
683 enrich_with_symbols(&mut combined, state).await;
684 }
685 Response::ok(serde_json::to_value(combined).unwrap_or_default())
686 }
687 } else {
688 let mut refs = merged;
689 if with_symbol {
690 enrich_with_symbols(&mut refs, state).await;
691 }
692 Response::ok(serde_json::to_value(refs).unwrap_or_default())
693 }
694}
695
696fn postprocess_symbols(
698 symbols: Vec<find::SymbolMatch>,
699 path_filter: Option<&str>,
700 src_only: bool,
701 include_body: bool,
702 project_root: &Path,
703) -> Vec<find::SymbolMatch> {
704 let filtered = filter_by_path(symbols, path_filter);
705 let filtered = if src_only {
706 filter_src_only(filtered, project_root)
707 } else {
708 filtered
709 };
710 if include_body {
711 populate_bodies(filtered, project_root)
712 } else {
713 filtered
714 }
715}
716
717fn filter_by_path(symbols: Vec<find::SymbolMatch>, substr: Option<&str>) -> Vec<find::SymbolMatch> {
720 match substr {
721 None => symbols,
722 Some(s) => symbols.into_iter().filter(|m| m.path.contains(s)).collect(),
723 }
724}
725
726fn populate_bodies(symbols: Vec<find::SymbolMatch>, project_root: &Path) -> Vec<find::SymbolMatch> {
731 symbols
732 .into_iter()
733 .map(|mut m| {
734 let abs = project_root.join(&m.path);
735 m.body = find::extract_symbol_body(&abs, m.line);
736 m
737 })
738 .collect()
739}
740
741fn filter_src_only(symbols: Vec<find::SymbolMatch>, project_root: &Path) -> Vec<find::SymbolMatch> {
747 use ignore::gitignore::GitignoreBuilder;
748
749 let mut builder = GitignoreBuilder::new(project_root);
750
751 let root_ignore = project_root.join(".gitignore");
753 if root_ignore.exists() {
754 let _ = builder.add(&root_ignore);
755 }
756
757 let git_exclude = project_root.join(".git/info/exclude");
759 if git_exclude.exists() {
760 let _ = builder.add(&git_exclude);
761 }
762
763 let Ok(gitignore) = builder.build() else {
764 return symbols;
765 };
766
767 symbols
768 .into_iter()
769 .filter(|m| {
770 let abs = project_root.join(&m.path);
771 !gitignore
772 .matched_path_or_any_parents(&abs, false)
773 .is_ignore()
774 })
775 .collect()
776}
777
778async fn enrich_with_symbols(refs: &mut [find::ReferenceMatch], state: &DaemonState) {
783 use crate::commands::list;
784 use std::collections::HashMap;
785
786 let files: std::collections::HashSet<String> = refs
788 .iter()
789 .filter(|r| !r.is_definition)
790 .map(|r| r.path.clone())
791 .collect();
792
793 let mut file_symbols: HashMap<String, Vec<list::SymbolEntry>> = HashMap::new();
794
795 for file_path in files {
796 let abs_path = state.project_root.join(&file_path);
797 let Ok(mut guard) = state.pool.route_for_file(&abs_path).await else {
798 continue;
799 };
800 let Some(session) = guard.session_mut() else {
801 continue;
802 };
803 if let Ok(symbols) = list::list_symbols(
804 &abs_path,
805 3,
806 &mut session.client,
807 &mut session.file_tracker,
808 &state.project_root,
809 )
810 .await
811 {
812 file_symbols.insert(file_path, symbols);
813 }
814 }
815
816 for r in refs.iter_mut() {
817 if r.is_definition {
818 continue;
819 }
820 if let Some(symbols) = file_symbols.get(&r.path) {
821 r.containing_symbol = find::find_innermost_containing(symbols, r.line);
822 }
823 }
824}
825
826async fn handle_list_symbols(path: &Path, depth: u8, state: &DaemonState) -> Response {
828 let abs_path = if path.is_absolute() {
830 path.to_path_buf()
831 } else {
832 state.project_root.join(path)
833 };
834 if abs_path.is_dir() {
835 return handle_list_symbols_dir(&abs_path, depth, state).await;
836 }
837
838 let rel_path = path.to_string_lossy();
840 if let Ok(guard) = state.index.lock() {
841 if let Some(ref store) = *guard {
842 if let Some(symbols) = cache_query::cached_list_symbols(
843 store,
844 &rel_path,
845 depth,
846 &state.project_root,
847 state.dirty_files_ref(),
848 ) {
849 debug!(
850 "list_symbols cache hit for '{rel_path}' ({} symbols)",
851 symbols.len()
852 );
853 return Response::ok(serde_json::to_value(symbols).unwrap_or_default());
854 }
855 }
856 }
857
858 let lang = language_for_file(path).or_else(|| state.languages.first().copied());
859
860 let Some(lang) = lang else {
861 return Response::err("no_language", "No language detected in project");
862 };
863
864 let mut guard = match state.pool.route_for_file(path).await {
866 Ok(g) => g,
867 Err(e) => {
868 match state.pool.get_or_start(lang).await {
870 Ok(g) => g,
871 Err(e2) => return Response::err("lsp_not_available", format!("{e}; {e2}")),
872 }
873 }
874 };
875
876 let Some(session) = guard.session_mut() else {
877 return Response::err("lsp_not_available", "No active session");
878 };
879
880 match list::list_symbols(
881 path,
882 depth,
883 &mut session.client,
884 &mut session.file_tracker,
885 &state.project_root,
886 )
887 .await
888 {
889 Ok(symbols) => Response::ok(serde_json::to_value(symbols).unwrap_or_default()),
890 Err(e) => {
891 let msg = e.to_string();
892 if msg.contains("not found") {
893 Response::err_with_advice("file_not_found", &msg, "Check the file path exists")
894 } else {
895 debug!("list_symbols error: {e:?}");
896 Response::err("list_symbols_failed", &msg)
897 }
898 }
899 }
900}
901
902async fn handle_list_symbols_dir(dir: &Path, depth: u8, state: &DaemonState) -> Response {
904 use ignore::WalkBuilder;
905
906 let valid_exts = language_extensions(&state.languages);
907
908 let mut files: Vec<PathBuf> = WalkBuilder::new(dir)
910 .hidden(true)
911 .git_ignore(true)
912 .build()
913 .filter_map(Result::ok)
914 .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
915 .map(ignore::DirEntry::into_path)
916 .filter(|p| {
917 p.extension()
918 .and_then(|ext| ext.to_str())
919 .is_some_and(|ext| valid_exts.iter().any(|v| v == ext))
920 })
921 .collect();
922 files.sort();
923
924 if files.is_empty() {
925 return Response::ok(json!({"dir": true, "files": []}));
926 }
927
928 let effective_depth = if depth == 0 { 1 } else { depth };
929 let mut file_entries = Vec::new();
930
931 for file_path in &files {
932 let Ok(mut guard) = state.pool.route_for_file(file_path).await else {
934 continue;
935 };
936 let Some(session) = guard.session_mut() else {
937 continue;
938 };
939
940 let rel = file_path
941 .strip_prefix(&state.project_root)
942 .unwrap_or(file_path)
943 .to_string_lossy()
944 .into_owned();
945
946 match list::list_symbols(
947 file_path,
948 effective_depth,
949 &mut session.client,
950 &mut session.file_tracker,
951 &state.project_root,
952 )
953 .await
954 {
955 Ok(symbols) if !symbols.is_empty() => {
956 file_entries.push(json!({
957 "file": rel,
958 "symbols": serde_json::to_value(symbols).unwrap_or_default(),
959 }));
960 }
961 _ => {} }
963 }
964
965 Response::ok(json!({"dir": true, "files": file_entries}))
966}
967
968#[allow(clippy::similar_names)]
970async fn handle_init(state: &DaemonState) -> Response {
971 let krait_dir = state.project_root.join(".krait");
972 if let Err(e) = std::fs::create_dir_all(&krait_dir) {
973 return Response::err("init_failed", format!("failed to create .krait/: {e}"));
974 }
975
976 let db_path = krait_dir.join("index.db");
977 let extensions = language_extensions(&state.languages);
978 let exts: Vec<&str> = extensions.iter().map(String::as_str).collect();
979 let init_start = Instant::now();
980
981 let (files_to_index, files_cached) = match plan_index_phase(&db_path, state, &exts) {
983 Ok(result) => result,
984 Err(resp) => return resp,
985 };
986 let files_total = files_to_index.len() + files_cached;
987 info!(
988 "init: phase 1 (plan) {:?} — {} to index, {} cached",
989 init_start.elapsed(),
990 files_to_index.len(),
991 files_cached
992 );
993
994 if let Err(e) = store_workspaces(&db_path, state) {
996 debug!("init: failed to store workspaces: {e}");
997 }
998
999 let phase2_start = Instant::now();
1001 let all_results = match collect_symbols_parallel(state, files_to_index).await {
1002 Ok(results) => results,
1003 Err(resp) => return resp,
1004 };
1005 let phase2_dur = phase2_start.elapsed();
1006 info!(
1007 "init: phase 2 (LSP) {phase2_dur:?} — {} results",
1008 all_results.len()
1009 );
1010
1011 let phase3_start = Instant::now();
1013 let (files_indexed, symbols_total) = match commit_index_phase(&db_path, &all_results) {
1014 Ok(result) => result,
1015 Err(resp) => return resp,
1016 };
1017 let phase3_dur = phase3_start.elapsed();
1018 info!("init: phase 3 (commit) {phase3_dur:?}");
1019
1020 #[allow(clippy::cast_possible_truncation)]
1023 let symbols_total = if symbols_total == 0 && files_cached > 0 {
1024 IndexStore::open(&db_path)
1025 .and_then(|s| s.count_all_symbols())
1026 .unwrap_or(0) as usize
1027 } else {
1028 symbols_total
1029 };
1030
1031 let total_ms = init_start.elapsed().as_millis();
1032 let batch_size = builder::detect_batch_size();
1033 info!(
1034 "init: total {:?} — {files_indexed} files, {symbols_total} symbols",
1035 init_start.elapsed()
1036 );
1037
1038 if let Ok(store) = crate::index::store::IndexStore::open(&db_path) {
1040 if let Err(e) = store.optimize() {
1041 debug!("init: optimize failed (non-fatal): {e}");
1042 }
1043 }
1044
1045 state.refresh_index();
1047 state.dirty_files.clear();
1049
1050 let num_workers = builder::detect_worker_count();
1051 Response::ok(json!({
1052 "message": "index built",
1053 "db_path": db_path.display().to_string(),
1054 "files_total": files_total,
1055 "files_indexed": files_indexed,
1056 "files_cached": files_cached,
1057 "symbols_total": symbols_total,
1058 "elapsed_ms": total_ms,
1059 "batch_size": batch_size,
1060 "workers": num_workers,
1061 "phase2_lsp_ms": phase2_dur.as_millis(),
1062 "phase3_commit_ms": phase3_dur.as_millis(),
1063 }))
1064}
1065
1066fn plan_index_phase(
1068 db_path: &Path,
1069 state: &DaemonState,
1070 exts: &[&str],
1071) -> Result<(Vec<builder::FileEntry>, usize), Response> {
1072 let store = IndexStore::open(db_path)
1073 .or_else(|e| {
1074 info!("index DB corrupted in plan phase ({e}), deleting");
1076 let _ = std::fs::remove_file(db_path);
1077 IndexStore::open(db_path)
1078 })
1079 .map_err(|e| Response::err("init_failed", format!("failed to open index DB: {e}")))?;
1080 builder::plan_index(&store, &state.project_root, exts)
1081 .map_err(|e| Response::err("init_failed", format!("failed to plan index: {e}")))
1082}
1083
1084async fn collect_symbols_parallel(
1089 state: &DaemonState,
1090 files_to_index: Vec<builder::FileEntry>,
1091) -> Result<Vec<(String, String, Vec<crate::index::store::CachedSymbol>)>, Response> {
1092 let num_workers = builder::detect_worker_count();
1093 info!("init: workers={num_workers} (based on system resources)");
1094
1095 let languages = {
1097 let detected = state.languages.clone();
1098 if detected.is_empty() {
1099 state.pool.unique_languages()
1100 } else {
1101 detected
1102 }
1103 };
1104 if languages.is_empty() {
1105 return Err(Response::err(
1106 "no_language",
1107 "No language detected in project",
1108 ));
1109 }
1110
1111 let mut handles = Vec::new();
1112 for lang in &languages {
1113 let lang_files: Vec<builder::FileEntry> = files_to_index
1114 .iter()
1115 .filter(|f| language_for_file(&f.abs_path) == Some(*lang))
1116 .map(|f| builder::FileEntry {
1117 abs_path: f.abs_path.clone(),
1118 rel_path: f.rel_path.clone(),
1119 hash: f.hash.clone(),
1120 })
1121 .collect();
1122
1123 if lang_files.is_empty() {
1124 continue;
1125 }
1126
1127 info!(
1128 "init: {lang} — {} files, {num_workers} workers",
1129 lang_files.len()
1130 );
1131 let lang = *lang;
1132 let root = state.project_root.clone();
1133 handles.push(tokio::spawn(async move {
1134 builder::collect_symbols_parallel(lang_files, lang, &root, num_workers).await
1135 }));
1136 }
1137
1138 let mut all_results = Vec::new();
1139 for handle in handles {
1140 match handle.await {
1141 Ok(Ok(results)) => all_results.extend(results),
1142 Ok(Err(e)) => debug!("init: worker error: {e}"),
1143 Err(e) => debug!("init: task panicked: {e}"),
1144 }
1145 }
1146
1147 Ok(all_results)
1148}
1149
1150fn commit_index_phase(
1152 db_path: &Path,
1153 results: &[(String, String, Vec<crate::index::store::CachedSymbol>)],
1154) -> Result<(usize, usize), Response> {
1155 let store = IndexStore::open(db_path)
1156 .map_err(|e| Response::err("init_failed", format!("failed to open index DB: {e}")))?;
1157 let symbols = builder::commit_index(&store, results)
1158 .map_err(|e| Response::err("init_failed", format!("failed to write index: {e}")))?;
1159 Ok((results.len(), symbols))
1160}
1161
1162fn store_workspaces(db_path: &Path, state: &DaemonState) -> anyhow::Result<()> {
1164 let store = IndexStore::open(db_path)?;
1165 store.clear_workspaces()?;
1166 for (lang, root) in &state.package_roots {
1167 let rel = root
1168 .strip_prefix(&state.project_root)
1169 .unwrap_or(root)
1170 .to_string_lossy();
1171 let path = if rel.is_empty() { "." } else { &rel };
1172 store.upsert_workspace(path, lang.name())?;
1173 }
1174 info!(
1175 "init: stored {} workspaces in index",
1176 state.package_roots.len()
1177 );
1178 Ok(())
1179}
1180
1181fn language_extensions(languages: &[Language]) -> Vec<String> {
1184 let mut exts = Vec::new();
1185 for &lang in languages {
1186 match lang {
1187 Language::TypeScript | Language::JavaScript => {
1188 for &e in Language::TypeScript
1190 .extensions()
1191 .iter()
1192 .chain(Language::JavaScript.extensions())
1193 {
1194 exts.push(e.to_string());
1195 }
1196 }
1197 _ => {
1198 for &e in lang.extensions() {
1199 exts.push(e.to_string());
1200 }
1201 }
1202 }
1203 }
1204 exts.sort();
1205 exts.dedup();
1206 exts
1207}
1208
1209fn handle_read_file(
1211 path: &Path,
1212 from: Option<u32>,
1213 to: Option<u32>,
1214 max_lines: Option<u32>,
1215 state: &DaemonState,
1216) -> Response {
1217 match read::handle_read_file(path, from, to, max_lines, &state.project_root) {
1218 Ok(data) => Response::ok(data),
1219 Err(e) => {
1220 let msg = e.to_string();
1221 if msg.contains("not found") {
1222 Response::err_with_advice("file_not_found", &msg, "Check the file path exists")
1223 } else if msg.contains("binary file") {
1224 Response::err("binary_file", &msg)
1225 } else {
1226 Response::err("read_failed", &msg)
1227 }
1228 }
1229 }
1230}
1231
1232#[allow(clippy::too_many_lines)]
1234async fn handle_read_symbol(
1235 name: &str,
1236 signature_only: bool,
1237 max_lines: Option<u32>,
1238 path_filter: Option<&str>,
1239 has_body: bool,
1240 state: &DaemonState,
1241) -> Response {
1242 if let Some(filter) = path_filter {
1246 let filtered = if let Ok(guard) = state.index.lock() {
1248 if let Some(ref store) = *guard {
1249 cache_query::cached_find_symbol(
1250 store,
1251 name,
1252 &state.project_root,
1253 state.dirty_files_ref(),
1254 )
1255 .map(|all| filter_by_path(all, Some(filter)))
1256 } else {
1257 None
1258 }
1259 } else {
1260 None
1261 };
1262
1263 if let Some(candidates) = filtered {
1264 if !candidates.is_empty() {
1265 let languages = state.pool.unique_languages();
1267 for lang in &languages {
1268 let Ok(mut guard) = state.pool.get_or_start(*lang).await else {
1269 continue;
1270 };
1271 let Some(session) = guard.session_mut() else {
1272 continue;
1273 };
1274 match read::handle_read_symbol(
1275 name,
1276 &candidates,
1277 signature_only,
1278 max_lines,
1279 has_body,
1280 &mut session.client,
1281 &mut session.file_tracker,
1282 &state.project_root,
1283 )
1284 .await
1285 {
1286 Ok(data) => return Response::ok(data),
1287 Err(e) => return Response::err("read_symbol_failed", e.to_string()),
1288 }
1289 }
1290 }
1291 }
1292
1293 }
1296
1297 if path_filter.is_none() && !has_body {
1299 if let Ok(guard) = state.index.lock() {
1300 if let Some(ref store) = *guard {
1301 if let Some(data) = cache_query::cached_read_symbol(
1302 store,
1303 name,
1304 signature_only,
1305 max_lines,
1306 &state.project_root,
1307 state.dirty_files_ref(),
1308 ) {
1309 debug!("read_symbol cache hit for '{name}'");
1310 return Response::ok(data);
1311 }
1312 }
1313 }
1314 }
1315
1316 let languages = state.pool.unique_languages();
1317 if languages.is_empty() {
1318 return Response::err("no_language", "No language detected in project");
1319 }
1320
1321 let search_name = name.split('.').next().unwrap_or(name);
1323
1324 for lang in &languages {
1326 let mut guard = match state.pool.get_or_start(*lang).await {
1327 Ok(g) => g,
1328 Err(e) => {
1329 debug!("skipping {lang}: {e}");
1330 continue;
1331 }
1332 };
1333 if let Err(e) = state
1334 .pool
1335 .attach_all_workspaces_with_guard(*lang, &mut guard)
1336 .await
1337 {
1338 debug!("skipping {lang}: {e}");
1339 continue;
1340 }
1341
1342 let Some(session) = guard.session_mut() else {
1343 continue;
1344 };
1345
1346 let raw_candidates =
1348 match find::find_symbol(search_name, &mut session.client, &state.project_root).await {
1349 Ok(s) if !s.is_empty() => s,
1350 _ => continue,
1351 };
1352 let candidates = filter_by_path(raw_candidates, path_filter);
1353 if candidates.is_empty() {
1354 continue;
1355 }
1356
1357 match read::handle_read_symbol(
1359 name,
1360 &candidates,
1361 signature_only,
1362 max_lines,
1363 has_body,
1364 &mut session.client,
1365 &mut session.file_tracker,
1366 &state.project_root,
1367 )
1368 .await
1369 {
1370 Ok(data) => return Response::ok(data),
1371 Err(e) => {
1372 let msg = e.to_string();
1373 debug!("read_symbol failed for {name}: {msg}");
1374 return Response::err("read_symbol_failed", &msg);
1375 }
1376 }
1377 }
1378
1379 let readiness = state.pool.readiness();
1381 let uptime = state.start_time.elapsed().as_secs();
1382 if uptime < INDEXING_GRACE_PERIOD_SECS && readiness.total > 0 && !readiness.is_all_ready() {
1383 Response::err_with_advice(
1384 "indexing",
1385 format!(
1386 "LSP servers still indexing ({}/{} ready, daemon uptime {}s)",
1387 readiness.ready, readiness.total, uptime
1388 ),
1389 "Wait a few seconds and try again, or run `krait daemon status`",
1390 )
1391 } else {
1392 Response::err_with_advice(
1393 "symbol_not_found",
1394 format!("symbol '{name}' not found"),
1395 "Check the symbol name and try again",
1396 )
1397 }
1398}
1399
1400async fn handle_find_impl(name: &str, state: &DaemonState) -> Response {
1402 let languages = state.pool.unique_languages();
1403 if languages.is_empty() {
1404 return Response::err("no_language", "No language detected in project");
1405 }
1406
1407 let mut all_results = Vec::new();
1408 for lang in &languages {
1409 let mut guard = match state.pool.get_or_start(*lang).await {
1410 Ok(g) => g,
1411 Err(e) => {
1412 debug!("find_impl skipping {lang}: {e}");
1413 continue;
1414 }
1415 };
1416 if let Err(e) = state
1417 .pool
1418 .attach_all_workspaces_with_guard(*lang, &mut guard)
1419 .await
1420 {
1421 debug!("find_impl skipping {lang}: {e}");
1422 continue;
1423 }
1424 let Some(session) = guard.session_mut() else {
1425 continue;
1426 };
1427 match find::find_impl(
1428 name,
1429 &mut session.client,
1430 &mut session.file_tracker,
1431 &state.project_root,
1432 )
1433 .await
1434 {
1435 Ok(results) => all_results.extend(results),
1436 Err(e) => debug!("find_impl failed for {lang}: {e}"),
1437 }
1438 }
1439
1440 all_results.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
1442 all_results.dedup_by(|a, b| a.path == b.path && a.line == b.line);
1443
1444 if all_results.is_empty() {
1445 Response::err_with_advice(
1446 "impl_not_found",
1447 format!("no implementations found for '{name}'"),
1448 "Try krait find refs <name> to see where it's called, or krait find symbol <name> for definitions",
1449 )
1450 } else {
1451 Response::ok(serde_json::to_value(all_results).unwrap_or_default())
1452 }
1453}
1454
1455enum EditKind {
1458 Replace,
1459 InsertAfter,
1460 InsertBefore,
1461}
1462
1463async fn handle_edit(name: &str, code: &str, kind: EditKind, state: &DaemonState) -> Response {
1465 let languages = state.pool.unique_languages();
1466 if languages.is_empty() {
1467 return Response::err("no_language", "No language detected in project");
1468 }
1469
1470 for lang in &languages {
1471 let mut guard = match state.pool.get_or_start(*lang).await {
1472 Ok(g) => g,
1473 Err(e) => {
1474 debug!("skipping {lang}: {e}");
1475 continue;
1476 }
1477 };
1478 if let Err(e) = state
1479 .pool
1480 .attach_all_workspaces_with_guard(*lang, &mut guard)
1481 .await
1482 {
1483 debug!("skipping {lang} attach: {e}");
1484 continue;
1485 }
1486
1487 let Some(session) = guard.session_mut() else {
1488 continue;
1489 };
1490
1491 let result = match kind {
1492 EditKind::Replace => {
1493 edit::handle_edit_replace(
1494 name,
1495 code,
1496 &mut session.client,
1497 &mut session.file_tracker,
1498 &state.project_root,
1499 &state.dirty_files,
1500 )
1501 .await
1502 }
1503 EditKind::InsertAfter => {
1504 edit::handle_edit_insert_after(
1505 name,
1506 code,
1507 &mut session.client,
1508 &mut session.file_tracker,
1509 &state.project_root,
1510 &state.dirty_files,
1511 )
1512 .await
1513 }
1514 EditKind::InsertBefore => {
1515 edit::handle_edit_insert_before(
1516 name,
1517 code,
1518 &mut session.client,
1519 &mut session.file_tracker,
1520 &state.project_root,
1521 &state.dirty_files,
1522 )
1523 .await
1524 }
1525 };
1526
1527 match result {
1528 Ok(data) => return Response::ok(data),
1529 Err(e) => {
1530 let msg = e.to_string();
1531 if msg.contains("not found") {
1532 continue; }
1534 return Response::err("edit_failed", msg);
1535 }
1536 }
1537 }
1538
1539 let readiness = state.pool.readiness();
1540 let uptime = state.start_time.elapsed().as_secs();
1541 if uptime < INDEXING_GRACE_PERIOD_SECS && readiness.total > 0 && !readiness.is_all_ready() {
1542 Response::err_with_advice(
1543 "indexing",
1544 format!(
1545 "LSP servers still indexing ({}/{} ready)",
1546 readiness.ready, readiness.total
1547 ),
1548 "Wait a few seconds and try again",
1549 )
1550 } else {
1551 Response::err_with_advice(
1552 "symbol_not_found",
1553 format!("symbol '{name}' not found"),
1554 "Check the symbol name and try again",
1555 )
1556 }
1557}
1558
1559async fn handle_check(
1565 path: Option<&std::path::Path>,
1566 errors_only: bool,
1567 state: &DaemonState,
1568) -> Response {
1569 const DIAG_WAIT_MS: u64 = 3_000;
1570
1571 if let Some(file_path) = path {
1572 let Some(lang) = language_for_file(file_path) else {
1573 let data = check::handle_check(
1575 Some(file_path),
1576 &state.diagnostic_store,
1577 &state.project_root,
1578 errors_only,
1579 );
1580 return Response::ok(data);
1581 };
1582
1583 let Ok(mut guard) = state.pool.get_or_start(lang).await else {
1585 let data = check::handle_check(
1587 path,
1588 &state.diagnostic_store,
1589 &state.project_root,
1590 errors_only,
1591 );
1592 return Response::ok(data);
1593 };
1594 if let Err(e) = state
1595 .pool
1596 .attach_all_workspaces_with_guard(lang, &mut guard)
1597 .await
1598 {
1599 return Response::err("lsp_error", e.to_string());
1600 }
1601
1602 let Some(session) = guard.session_mut() else {
1603 let data = check::handle_check(
1604 path,
1605 &state.diagnostic_store,
1606 &state.project_root,
1607 errors_only,
1608 );
1609 return Response::ok(data);
1610 };
1611
1612 let _ = session
1614 .file_tracker
1615 .reopen(file_path, session.client.transport_mut())
1616 .await;
1617
1618 let abs = if file_path.is_absolute() {
1620 file_path.to_path_buf()
1621 } else {
1622 state.project_root.join(file_path)
1623 };
1624 if let Ok(uri) = path_to_uri(&abs) {
1625 let params = serde_json::json!({
1626 "textDocument": { "uri": uri.as_str() }
1627 });
1628 if let Ok(req_id) = session
1629 .client
1630 .transport_mut()
1631 .send_request("textDocument/documentSymbol", params)
1632 .await
1633 {
1634 let timeout = std::time::Duration::from_millis(DIAG_WAIT_MS);
1635 let _ =
1636 tokio::time::timeout(timeout, session.client.wait_for_response_public(req_id))
1637 .await;
1638 }
1639 }
1640 }
1641
1642 let data = check::handle_check(
1643 path,
1644 &state.diagnostic_store,
1645 &state.project_root,
1646 errors_only,
1647 );
1648 Response::ok(data)
1649}
1650
1651async fn handle_hover_cmd(name: &str, state: &DaemonState) -> Response {
1655 let languages = state.pool.unique_languages();
1656 if languages.is_empty() {
1657 return Response::err("no_language", "No language detected in project");
1658 }
1659
1660 for lang in &languages {
1661 let mut guard = match state.pool.get_or_start(*lang).await {
1662 Ok(g) => g,
1663 Err(e) => {
1664 debug!("skipping {lang}: {e}");
1665 continue;
1666 }
1667 };
1668 if let Err(e) = state
1669 .pool
1670 .attach_all_workspaces_with_guard(*lang, &mut guard)
1671 .await
1672 {
1673 debug!("skipping {lang} attach: {e}");
1674 continue;
1675 }
1676 let Some(session) = guard.session_mut() else {
1677 continue;
1678 };
1679 match hover::handle_hover(
1680 name,
1681 &mut session.client,
1682 &mut session.file_tracker,
1683 &state.project_root,
1684 )
1685 .await
1686 {
1687 Ok(data) => return Response::ok(data),
1688 Err(e) => {
1689 if !e.to_string().contains("not found") {
1690 return Response::err("hover_failed", e.to_string());
1691 }
1692 }
1693 }
1694 }
1695
1696 Response::err_with_advice(
1697 "symbol_not_found",
1698 format!("symbol '{name}' not found"),
1699 "Check the symbol name and try again",
1700 )
1701}
1702
1703async fn handle_format_cmd(path: &std::path::Path, state: &DaemonState) -> Response {
1705 let lang = language_for_file(path).or_else(|| state.languages.first().copied());
1706 let Some(lang) = lang else {
1707 return Response::err("no_language", "Cannot detect language for file");
1708 };
1709
1710 let mut guard = match state.pool.get_or_start(lang).await {
1711 Ok(g) => g,
1712 Err(e) => return Response::err("lsp_not_available", e.to_string()),
1713 };
1714 if let Err(e) = state
1715 .pool
1716 .attach_all_workspaces_with_guard(lang, &mut guard)
1717 .await
1718 {
1719 debug!("format attach warning: {e}");
1720 }
1721 let Some(session) = guard.session_mut() else {
1722 return Response::err("lsp_not_available", "No active session");
1723 };
1724
1725 match fmt::handle_format(
1726 path,
1727 &mut session.client,
1728 &mut session.file_tracker,
1729 &state.project_root,
1730 )
1731 .await
1732 {
1733 Ok(data) => Response::ok(data),
1734 Err(e) => Response::err("format_failed", e.to_string()),
1735 }
1736}
1737
1738async fn handle_rename_cmd(name: &str, new_name: &str, state: &DaemonState) -> Response {
1740 let languages = state.pool.unique_languages();
1741 if languages.is_empty() {
1742 return Response::err("no_language", "No language detected in project");
1743 }
1744
1745 for lang in &languages {
1746 let mut guard = match state.pool.get_or_start(*lang).await {
1747 Ok(g) => g,
1748 Err(e) => {
1749 debug!("skipping {lang}: {e}");
1750 continue;
1751 }
1752 };
1753 if let Err(e) = state
1754 .pool
1755 .attach_all_workspaces_with_guard(*lang, &mut guard)
1756 .await
1757 {
1758 debug!("skipping {lang} attach: {e}");
1759 continue;
1760 }
1761 let Some(session) = guard.session_mut() else {
1762 continue;
1763 };
1764 match rename::handle_rename(
1765 name,
1766 new_name,
1767 &mut session.client,
1768 &mut session.file_tracker,
1769 &state.project_root,
1770 )
1771 .await
1772 {
1773 Ok(data) => return Response::ok(data),
1774 Err(e) => {
1775 if !e.to_string().contains("not found") {
1776 return Response::err("rename_failed", e.to_string());
1777 }
1778 }
1779 }
1780 }
1781
1782 Response::err_with_advice(
1783 "symbol_not_found",
1784 format!("symbol '{name}' not found"),
1785 "Check the symbol name and try again",
1786 )
1787}
1788
1789async fn handle_fix_cmd(path: Option<&std::path::Path>, state: &DaemonState) -> Response {
1791 let languages = state.pool.unique_languages();
1792 if languages.is_empty() {
1793 return Response::err("no_language", "No language detected in project");
1794 }
1795
1796 for lang in &languages {
1798 let mut guard = match state.pool.get_or_start(*lang).await {
1799 Ok(g) => g,
1800 Err(e) => {
1801 debug!("skipping {lang}: {e}");
1802 continue;
1803 }
1804 };
1805 if let Err(e) = state
1806 .pool
1807 .attach_all_workspaces_with_guard(*lang, &mut guard)
1808 .await
1809 {
1810 debug!("skipping {lang} attach: {e}");
1811 continue;
1812 }
1813 let Some(session) = guard.session_mut() else {
1814 continue;
1815 };
1816 match fix::handle_fix(
1817 path,
1818 &mut session.client,
1819 &mut session.file_tracker,
1820 &state.project_root,
1821 &state.diagnostic_store,
1822 )
1823 .await
1824 {
1825 Ok(data) => return Response::ok(data),
1826 Err(e) => return Response::err("fix_failed", e.to_string()),
1827 }
1828 }
1829
1830 Response::err("lsp_not_available", "No LSP server available for fix")
1831}
1832
1833fn build_status_response(state: &DaemonState) -> Response {
1834 let uptime = state.start_time.elapsed().as_secs();
1835 let language_names: Vec<&str> = state.languages.iter().map(|l| l.name()).collect();
1836
1837 let (lsp_info, workspace_count) = {
1838 let readiness = state.pool.readiness();
1839 let statuses = state.pool.status();
1840 let workspace_count = state.pool.workspace_roots().len();
1841
1842 let lsp = if readiness.total == 0 {
1843 match state.languages.first() {
1845 Some(lang) => {
1846 let entry = get_entry(*lang);
1847 let available = entry.as_ref().is_some_and(|e| find_server(e).is_some());
1848 let server = entry.as_ref().map_or("unknown", |e| e.binary_name);
1849 let advice = entry.as_ref().map(|e| e.install_advice);
1850 json!({
1851 "language": lang.name(),
1852 "status": if available { "available" } else { "not_installed" },
1853 "server": server,
1854 "advice": advice,
1855 })
1856 }
1857 None => json!(null),
1858 }
1859 } else {
1860 let status_label = if readiness.is_all_ready() {
1861 "ready"
1862 } else {
1863 "pending"
1864 };
1865 json!({
1866 "status": status_label,
1867 "sessions": readiness.total,
1868 "ready": readiness.ready,
1869 "progress": format!("{}/{}", readiness.ready, readiness.total),
1870 "servers": statuses.iter().map(|s| json!({
1871 "language": s.language,
1872 "server": s.server_name,
1873 "status": s.status,
1874 "uptime_secs": s.uptime_secs,
1875 "open_files": s.open_files,
1876 "attached_folders": s.attached_folders,
1877 "total_folders": s.total_folders,
1878 })).collect::<Vec<_>>(),
1879 })
1880 };
1881 (lsp, workspace_count)
1882 };
1883
1884 let (discovered, attached) = {
1886 let db_path = state.project_root.join(".krait/index.db");
1887 IndexStore::open(&db_path)
1888 .ok()
1889 .and_then(|store| store.workspace_counts().ok())
1890 .unwrap_or((workspace_count, 0))
1891 };
1892
1893 let config_label = state.config_source.label();
1894
1895 Response::ok(json!({
1896 "daemon": {
1897 "pid": std::process::id(),
1898 "uptime_secs": uptime,
1899 },
1900 "config": config_label,
1901 "lsp": lsp_info,
1902 "project": {
1903 "root": state.project_root.display().to_string(),
1904 "languages": language_names,
1905 "workspaces": workspace_count,
1906 "workspaces_discovered": discovered,
1907 "workspaces_attached": attached,
1908 },
1909 "index": {
1910 "dirty_files": state.dirty_files.len(),
1911 "watcher_active": state.watcher_active,
1912 }
1913 }))
1914}
1915
1916#[cfg(test)]
1917mod tests {
1918 use std::time::Duration;
1919
1920 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1921 use tokio::net::UnixStream;
1922
1923 use super::*;
1924
1925 async fn send_request(socket_path: &Path, req: &Request) -> Response {
1926 let mut stream = UnixStream::connect(socket_path).await.unwrap();
1927
1928 let json = serde_json::to_vec(req).unwrap();
1929 let len = u32::try_from(json.len()).unwrap();
1930 stream.write_u32(len).await.unwrap();
1931 stream.write_all(&json).await.unwrap();
1932 stream.flush().await.unwrap();
1933
1934 let resp_len = stream.read_u32().await.unwrap();
1935 let mut resp_buf = vec![0u8; resp_len as usize];
1936 stream.read_exact(&mut resp_buf).await.unwrap();
1937 serde_json::from_slice(&resp_buf).unwrap()
1938 }
1939
1940 fn start_server(
1941 sock: &Path,
1942 project_root: &Path,
1943 timeout_secs: u64,
1944 ) -> tokio::task::JoinHandle<()> {
1945 let sock = sock.to_path_buf();
1946 let root = project_root.to_path_buf();
1947 tokio::spawn(async move {
1948 run_server(&sock, Duration::from_secs(timeout_secs), &root)
1949 .await
1950 .unwrap();
1951 })
1952 }
1953
1954 #[tokio::test]
1955 async fn daemon_starts_and_listens() {
1956 let dir = tempfile::tempdir().unwrap();
1957 let sock = dir.path().join("test.sock");
1958
1959 let handle = start_server(&sock, dir.path(), 2);
1960 tokio::time::sleep(Duration::from_millis(50)).await;
1961 assert!(sock.exists());
1962
1963 send_request(&sock, &Request::DaemonStop).await;
1964 let _ = handle.await;
1965 }
1966
1967 #[tokio::test]
1968 async fn daemon_handles_status_request() {
1969 let dir = tempfile::tempdir().unwrap();
1970 let sock = dir.path().join("test.sock");
1971
1972 let handle = start_server(&sock, dir.path(), 5);
1973 tokio::time::sleep(Duration::from_millis(50)).await;
1974
1975 let resp = send_request(&sock, &Request::Status).await;
1976 assert!(resp.success);
1977 assert!(resp.data.is_some());
1978
1979 let data = resp.data.unwrap();
1980 assert!(data["daemon"]["pid"].is_u64());
1981 assert!(data["daemon"]["uptime_secs"].is_u64());
1982 assert!(data["project"]["root"].is_string());
1983 assert!(data["project"]["languages"].is_array());
1984
1985 send_request(&sock, &Request::DaemonStop).await;
1986 let _ = handle.await;
1987 }
1988
1989 #[tokio::test]
1990 async fn status_shows_lsp_null_when_no_languages() {
1991 let dir = tempfile::tempdir().unwrap();
1992 let sock = dir.path().join("test.sock");
1993
1994 let handle = start_server(&sock, dir.path(), 5);
1995 tokio::time::sleep(Duration::from_millis(50)).await;
1996
1997 let resp = send_request(&sock, &Request::Status).await;
1998 let data = resp.data.unwrap();
1999 assert!(data["lsp"].is_null());
2000 assert!(data["project"]["languages"].as_array().unwrap().is_empty());
2001
2002 send_request(&sock, &Request::DaemonStop).await;
2003 let _ = handle.await;
2004 }
2005
2006 #[tokio::test]
2007 async fn status_shows_language_detection() {
2008 let dir = tempfile::tempdir().unwrap();
2009 std::fs::write(
2010 dir.path().join("Cargo.toml"),
2011 "[package]\nname = \"test\"\n",
2012 )
2013 .unwrap();
2014
2015 let sock = dir.path().join("test.sock");
2016 let handle = start_server(&sock, dir.path(), 5);
2017 tokio::time::sleep(Duration::from_millis(50)).await;
2018
2019 let resp = send_request(&sock, &Request::Status).await;
2020 let data = resp.data.unwrap();
2021 assert_eq!(data["project"]["languages"][0], "rust");
2022
2023 send_request(&sock, &Request::DaemonStop).await;
2024 let _ = handle.await;
2025 }
2026
2027 #[tokio::test]
2028 async fn status_shows_workspace_count() {
2029 let dir = tempfile::tempdir().unwrap();
2030 std::fs::write(
2031 dir.path().join("Cargo.toml"),
2032 "[package]\nname = \"test\"\n",
2033 )
2034 .unwrap();
2035
2036 let sock = dir.path().join("test.sock");
2037 let handle = start_server(&sock, dir.path(), 5);
2038 tokio::time::sleep(Duration::from_millis(50)).await;
2039
2040 let resp = send_request(&sock, &Request::Status).await;
2041 let data = resp.data.unwrap();
2042 assert!(data["project"]["workspaces"].is_u64());
2043
2044 send_request(&sock, &Request::DaemonStop).await;
2045 let _ = handle.await;
2046 }
2047
2048 #[tokio::test]
2049 async fn dispatch_unknown_returns_not_implemented() {
2050 let dir = tempfile::tempdir().unwrap();
2051 let sock = dir.path().join("test.sock");
2052
2053 let handle = start_server(&sock, dir.path(), 5);
2054 tokio::time::sleep(Duration::from_millis(50)).await;
2055
2056 let resp = send_request(&sock, &Request::Init).await;
2057 assert!(!resp.success);
2058 assert!(resp.error.is_some());
2059
2060 send_request(&sock, &Request::DaemonStop).await;
2061 let _ = handle.await;
2062 }
2063
2064 #[tokio::test]
2065 async fn daemon_cleans_up_on_stop() {
2066 let dir = tempfile::tempdir().unwrap();
2067 let sock = dir.path().join("test.sock");
2068
2069 let handle = start_server(&sock, dir.path(), 5);
2070 tokio::time::sleep(Duration::from_millis(50)).await;
2071
2072 let resp = send_request(&sock, &Request::DaemonStop).await;
2073 assert!(resp.success);
2074
2075 let _ = handle.await;
2076 }
2077
2078 #[tokio::test]
2079 async fn daemon_idle_timeout() {
2080 let dir = tempfile::tempdir().unwrap();
2081 let sock = dir.path().join("test.sock");
2082
2083 let sock_clone = sock.clone();
2084 let root = dir.path().to_path_buf();
2085 let handle = tokio::spawn(async move {
2086 run_server(&sock_clone, Duration::from_millis(200), &root)
2087 .await
2088 .unwrap();
2089 });
2090
2091 let result = tokio::time::timeout(Duration::from_secs(2), handle).await;
2092 assert!(result.is_ok(), "daemon should have exited on idle timeout");
2093 }
2094
2095 #[tokio::test]
2096 async fn daemon_handles_concurrent_connections() {
2097 let dir = tempfile::tempdir().unwrap();
2098 let sock = dir.path().join("test.sock");
2099
2100 let handle = start_server(&sock, dir.path(), 5);
2101 tokio::time::sleep(Duration::from_millis(50)).await;
2102
2103 let mut tasks = Vec::new();
2104 for _ in 0..3 {
2105 let s = sock.clone();
2106 tasks.push(tokio::spawn(async move {
2107 send_request(&s, &Request::Status).await
2108 }));
2109 }
2110
2111 for task in tasks {
2112 let resp = task.await.unwrap();
2113 assert!(resp.success);
2114 }
2115
2116 send_request(&sock, &Request::DaemonStop).await;
2117 let _ = handle.await;
2118 }
2119
2120 #[tokio::test]
2121 async fn dispatch_status_returns_success() {
2122 let dir = tempfile::tempdir().unwrap();
2123 let sock = dir.path().join("test.sock");
2124
2125 let handle = start_server(&sock, dir.path(), 5);
2126 tokio::time::sleep(Duration::from_millis(50)).await;
2127
2128 let resp = send_request(&sock, &Request::Status).await;
2129 assert!(resp.success);
2130 assert!(resp.data.is_some());
2131
2132 let data = resp.data.unwrap();
2134 assert!(data["daemon"]["pid"].as_u64().is_some());
2135 assert!(data["daemon"]["uptime_secs"].as_u64().is_some());
2136 assert!(data["project"]["root"].is_string());
2137 assert!(data["index"]["watcher_active"].is_boolean());
2138
2139 send_request(&sock, &Request::DaemonStop).await;
2140 let _ = handle.await;
2141 }
2142
2143 #[tokio::test]
2144 async fn handle_connection_rejects_oversized_frame() {
2145 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2146 use tokio::net::UnixStream;
2147
2148 let dir = tempfile::tempdir().unwrap();
2149 let sock = dir.path().join("test.sock");
2150
2151 let handle = start_server(&sock, dir.path(), 5);
2152 tokio::time::sleep(Duration::from_millis(50)).await;
2153
2154 let mut stream = UnixStream::connect(&sock).await.unwrap();
2156 let oversized_len: u32 = 20 * 1024 * 1024;
2157 stream.write_u32(oversized_len).await.unwrap();
2158 stream.flush().await.unwrap();
2159
2160 let result = stream.read_u32().await;
2162 assert!(
2163 result.is_err(),
2164 "server should close connection on oversized frame"
2165 );
2166
2167 let resp = send_request(&sock, &Request::Status).await;
2169 assert!(resp.success, "daemon should still accept valid requests");
2170
2171 send_request(&sock, &Request::DaemonStop).await;
2172 let _ = handle.await;
2173 }
2174}