1use std::cell::{Ref, RefCell, RefMut};
2use std::io::{self, BufWriter};
3use std::path::{Component, Path, PathBuf};
4use std::sync::{mpsc, Arc, Mutex};
5
6use lsp_types::FileChangeType;
7use notify::RecommendedWatcher;
8
9use crate::backup::BackupStore;
10use crate::bash_background::{BgCompletion, BgTaskRegistry};
11use crate::callgraph::CallGraph;
12use crate::checkpoint::CheckpointStore;
13use crate::config::Config;
14use crate::language::LanguageProvider;
15use crate::lsp::manager::LspManager;
16use crate::lsp::registry::is_config_file_path_with_custom;
17use crate::parser::{SharedSymbolCache, SymbolCache};
18use crate::protocol::{ProgressFrame, PushFrame};
19
20pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
21pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
22pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
23use crate::search_index::SearchIndex;
24use crate::semantic_index::SemanticIndex;
25
26#[derive(Debug, Clone)]
27pub enum SemanticIndexStatus {
28 Disabled,
29 Building {
30 stage: String,
31 files: Option<usize>,
32 entries_done: Option<usize>,
33 entries_total: Option<usize>,
34 },
35 Ready,
36 Failed(String),
37}
38
39pub enum SemanticIndexEvent {
40 Progress {
41 stage: String,
42 files: Option<usize>,
43 entries_done: Option<usize>,
44 entries_total: Option<usize>,
45 },
46 Ready(SemanticIndex),
47 Failed(String),
48}
49
50fn normalize_path(path: &Path) -> PathBuf {
54 let mut result = PathBuf::new();
55 for component in path.components() {
56 match component {
57 Component::ParentDir => {
58 if !result.pop() {
60 result.push(component);
61 }
62 }
63 Component::CurDir => {} _ => result.push(component),
65 }
66 }
67 result
68}
69
70fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
71 let mut existing = path.to_path_buf();
72 let mut tail_segments = Vec::new();
73
74 while !existing.exists() {
75 if let Some(name) = existing.file_name() {
76 tail_segments.push(name.to_owned());
77 } else {
78 break;
79 }
80
81 existing = match existing.parent() {
82 Some(parent) => parent.to_path_buf(),
83 None => break,
84 };
85 }
86
87 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
88 for segment in tail_segments.into_iter().rev() {
89 resolved.push(segment);
90 }
91
92 resolved
93}
94
95fn path_error_response(
96 req_id: &str,
97 path: &Path,
98 resolved_root: &Path,
99) -> crate::protocol::Response {
100 crate::protocol::Response::error(
101 req_id,
102 "path_outside_root",
103 format!(
104 "path '{}' is outside the project root '{}'",
105 path.display(),
106 resolved_root.display()
107 ),
108 )
109}
110
111fn reject_escaping_symlink(
121 req_id: &str,
122 original_path: &Path,
123 candidate: &Path,
124 resolved_root: &Path,
125 raw_root: &Path,
126) -> Result<(), crate::protocol::Response> {
127 let mut current = PathBuf::new();
128
129 for component in candidate.components() {
130 current.push(component);
131
132 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
133 continue;
134 };
135
136 if !metadata.file_type().is_symlink() {
137 continue;
138 }
139
140 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
149 if !inside_root {
150 continue;
151 }
152
153 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
154 }
155
156 Ok(())
157}
158
159fn iterative_follow_chain(
162 req_id: &str,
163 original_path: &Path,
164 start: &Path,
165 resolved_root: &Path,
166) -> Result<(), crate::protocol::Response> {
167 let mut link = start.to_path_buf();
168 let mut depth = 0usize;
169
170 loop {
171 if depth > 40 {
172 return Err(path_error_response(req_id, original_path, resolved_root));
173 }
174
175 let target = match std::fs::read_link(&link) {
176 Ok(t) => t,
177 Err(_) => {
178 return Err(path_error_response(req_id, original_path, resolved_root));
180 }
181 };
182
183 let resolved_target = if target.is_absolute() {
184 normalize_path(&target)
185 } else {
186 let parent = link.parent().unwrap_or_else(|| Path::new(""));
187 normalize_path(&parent.join(&target))
188 };
189
190 let canonical_target =
194 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
195
196 if !canonical_target.starts_with(resolved_root)
197 && !resolved_target.starts_with(resolved_root)
198 {
199 return Err(path_error_response(req_id, original_path, resolved_root));
200 }
201
202 match std::fs::symlink_metadata(&resolved_target) {
204 Ok(meta) if meta.file_type().is_symlink() => {
205 link = resolved_target;
206 depth += 1;
207 }
208 _ => break, }
210 }
211
212 Ok(())
213}
214
215pub struct AppContext {
225 provider: Box<dyn LanguageProvider>,
226 backup: RefCell<BackupStore>,
227 checkpoint: RefCell<CheckpointStore>,
228 config: RefCell<Config>,
229 callgraph: RefCell<Option<CallGraph>>,
230 search_index: RefCell<Option<SearchIndex>>,
231 search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
232 symbol_cache: SharedSymbolCache,
233 semantic_index: RefCell<Option<SemanticIndex>>,
234 semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
235 semantic_index_status: RefCell<SemanticIndexStatus>,
236 semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
237 watcher: RefCell<Option<RecommendedWatcher>>,
238 watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
239 lsp_manager: RefCell<LspManager>,
240 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
244 stdout_writer: SharedStdoutWriter,
245 progress_sender: SharedProgressSender,
246 bash_background: BgTaskRegistry,
247 filter_registry: crate::compress::SharedFilterRegistry,
254 filter_registry_loaded: std::sync::atomic::AtomicBool,
257 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
262 gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
269}
270
271impl AppContext {
272 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
273 let bash_compress_enabled = config.experimental_bash_compress;
274 let progress_sender = Arc::new(Mutex::new(None));
275 let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
276 let symbol_cache = provider
277 .as_any()
278 .downcast_ref::<crate::parser::TreeSitterProvider>()
279 .map(|provider| provider.symbol_cache())
280 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
281 let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
282 let mut lsp_manager = LspManager::new();
283 lsp_manager.set_child_registry(lsp_child_registry.clone());
284 AppContext {
285 provider,
286 backup: RefCell::new(BackupStore::new()),
287 checkpoint: RefCell::new(CheckpointStore::new()),
288 config: RefCell::new(config),
289 callgraph: RefCell::new(None),
290 search_index: RefCell::new(None),
291 search_index_rx: RefCell::new(None),
292 symbol_cache,
293 semantic_index: RefCell::new(None),
294 semantic_index_rx: RefCell::new(None),
295 semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
296 semantic_embedding_model: RefCell::new(None),
297 watcher: RefCell::new(None),
298 watcher_rx: RefCell::new(None),
299 lsp_manager: RefCell::new(lsp_manager),
300 lsp_child_registry,
301 stdout_writer,
302 progress_sender: Arc::clone(&progress_sender),
303 bash_background: BgTaskRegistry::new(progress_sender),
304 filter_registry: Arc::new(std::sync::RwLock::new(
305 crate::compress::toml_filter::FilterRegistry::default(),
306 )),
307 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
308 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
309 gitignore: RefCell::new(None),
310 }
311 }
312
313 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
316 self.gitignore.borrow().clone()
317 }
318
319 pub fn rebuild_gitignore(&self) {
334 use ignore::gitignore::GitignoreBuilder;
335 use std::path::Path;
336 let root_raw = match self.config().project_root.clone() {
337 Some(r) => r,
338 None => {
339 *self.gitignore.borrow_mut() = None;
340 return;
341 }
342 };
343 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
351 let mut builder = GitignoreBuilder::new(&root);
352 let root_ignore = Path::new(&root).join(".gitignore");
354 if root_ignore.exists() {
355 if let Some(err) = builder.add(&root_ignore) {
356 crate::slog_warn!(
357 "gitignore parse error in {}: {}",
358 root_ignore.display(),
359 err
360 );
361 }
362 }
363 let info_exclude = Path::new(&root).join(".git").join("info").join("exclude");
366 if info_exclude.exists() {
367 if let Some(err) = builder.add(&info_exclude) {
368 crate::slog_warn!(
369 "gitignore parse error in {}: {}",
370 info_exclude.display(),
371 err
372 );
373 }
374 }
375 let walker = ignore::WalkBuilder::new(&root)
381 .standard_filters(true)
382 .hidden(false)
385 .max_depth(Some(8))
386 .filter_entry(|entry| {
387 let name = entry.file_name().to_string_lossy();
388 !matches!(
389 name.as_ref(),
390 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
391 )
392 })
393 .build();
394 for entry in walker.flatten() {
395 if entry.file_name() == ".gitignore" && entry.path() != root_ignore {
396 if let Some(err) = builder.add(entry.path()) {
397 crate::slog_warn!(
398 "nested gitignore parse error in {}: {}",
399 entry.path().display(),
400 err
401 );
402 }
403 }
404 }
405 match builder.build() {
406 Ok(gi) => {
407 let count = gi.num_ignores();
408 if count > 0 {
409 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
410 *self.gitignore.borrow_mut() = Some(Arc::new(gi));
411 } else {
412 *self.gitignore.borrow_mut() = None;
413 }
414 }
415 Err(err) => {
416 crate::slog_warn!("gitignore matcher build failed: {}", err);
417 *self.gitignore.borrow_mut() = None;
418 }
419 }
420 }
421
422 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
425 Arc::clone(&self.bash_compress_flag)
426 }
427
428 pub fn sync_bash_compress_flag(&self) {
432 let value = self.config().experimental_bash_compress;
433 self.bash_compress_flag
434 .store(value, std::sync::atomic::Ordering::Relaxed);
435 }
436
437 pub fn set_bash_compress_enabled(&self, enabled: bool) {
438 self.config_mut().experimental_bash_compress = enabled;
439 self.bash_compress_flag
440 .store(enabled, std::sync::atomic::Ordering::Relaxed);
441 }
442
443 pub fn filter_registry(
447 &self,
448 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
449 self.ensure_filter_registry_loaded();
450 match self.filter_registry.read() {
451 Ok(g) => g,
452 Err(poisoned) => poisoned.into_inner(),
453 }
454 }
455
456 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
460 self.ensure_filter_registry_loaded();
461 Arc::clone(&self.filter_registry)
462 }
463
464 pub fn reset_filter_registry(&self) {
468 let new_registry = crate::compress::build_registry_for_context(self);
469 match self.filter_registry.write() {
470 Ok(mut slot) => *slot = new_registry,
471 Err(poisoned) => *poisoned.into_inner() = new_registry,
472 }
473 self.filter_registry_loaded
474 .store(true, std::sync::atomic::Ordering::Release);
475 }
476
477 fn ensure_filter_registry_loaded(&self) {
478 use std::sync::atomic::Ordering;
479 if self.filter_registry_loaded.load(Ordering::Acquire) {
480 return;
481 }
482 let new_registry = crate::compress::build_registry_for_context(self);
485 if let Ok(mut slot) = self.filter_registry.write() {
486 *slot = new_registry;
487 self.filter_registry_loaded.store(true, Ordering::Release);
488 }
489 }
490
491 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
494 self.lsp_child_registry.clone()
495 }
496
497 pub fn stdout_writer(&self) -> SharedStdoutWriter {
498 Arc::clone(&self.stdout_writer)
499 }
500
501 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
502 if let Ok(mut progress_sender) = self.progress_sender.lock() {
503 *progress_sender = sender;
504 }
505 }
506
507 pub fn emit_progress(&self, frame: ProgressFrame) {
508 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
509 return;
510 };
511 if let Some(sender) = progress_sender.as_ref() {
512 sender(PushFrame::Progress(frame));
513 }
514 }
515
516 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
524 self.progress_sender
525 .lock()
526 .ok()
527 .and_then(|sender| sender.clone())
528 }
529
530 pub fn bash_background(&self) -> &BgTaskRegistry {
531 &self.bash_background
532 }
533
534 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
535 self.bash_background.drain_completions()
536 }
537
538 pub fn provider(&self) -> &dyn LanguageProvider {
540 self.provider.as_ref()
541 }
542
543 pub fn backup(&self) -> &RefCell<BackupStore> {
545 &self.backup
546 }
547
548 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
550 &self.checkpoint
551 }
552
553 pub fn config(&self) -> Ref<'_, Config> {
555 self.config.borrow()
556 }
557
558 pub fn config_mut(&self) -> RefMut<'_, Config> {
560 self.config.borrow_mut()
561 }
562
563 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
565 &self.callgraph
566 }
567
568 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
570 &self.search_index
571 }
572
573 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
575 &self.search_index_rx
576 }
577
578 pub fn symbol_cache(&self) -> SharedSymbolCache {
580 Arc::clone(&self.symbol_cache)
581 }
582
583 pub fn reset_symbol_cache(&self) -> u64 {
585 self.symbol_cache
586 .write()
587 .map(|mut cache| cache.reset())
588 .unwrap_or(0)
589 }
590
591 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
593 &self.semantic_index
594 }
595
596 pub fn semantic_index_rx(
598 &self,
599 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
600 &self.semantic_index_rx
601 }
602
603 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
604 &self.semantic_index_status
605 }
606
607 pub fn semantic_embedding_model(
609 &self,
610 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
611 &self.semantic_embedding_model
612 }
613
614 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
616 &self.watcher
617 }
618
619 pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
621 &self.watcher_rx
622 }
623
624 pub fn lsp(&self) -> RefMut<'_, LspManager> {
626 self.lsp_manager.borrow_mut()
627 }
628
629 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
632 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
633 let config = self.config();
634 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
635 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
636 }
637 }
638 }
639
640 pub fn lsp_notify_and_collect_diagnostics(
651 &self,
652 file_path: &Path,
653 content: &str,
654 timeout: std::time::Duration,
655 ) -> crate::lsp::manager::PostEditWaitOutcome {
656 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
657 return crate::lsp::manager::PostEditWaitOutcome::default();
658 };
659
660 lsp.drain_events();
663
664 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
668
669 let config = self.config();
671 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
672 {
673 Ok(v) => v,
674 Err(e) => {
675 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
676 return crate::lsp::manager::PostEditWaitOutcome::default();
677 }
678 };
679
680 if expected_versions.is_empty() {
683 return crate::lsp::manager::PostEditWaitOutcome::default();
684 }
685
686 lsp.wait_for_post_edit_diagnostics(
687 file_path,
688 &config,
689 &expected_versions,
690 &pre_snapshot,
691 timeout,
692 )
693 }
694
695 fn custom_lsp_root_markers(&self) -> Vec<String> {
698 self.config()
699 .lsp_servers
700 .iter()
701 .flat_map(|s| s.root_markers.iter().cloned())
702 .collect()
703 }
704
705 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
706 let custom_markers = self.custom_lsp_root_markers();
707 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
708 .iter()
709 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
710 .cloned()
711 .map(|path| {
712 let change_type = if path.exists() {
713 FileChangeType::CHANGED
714 } else {
715 FileChangeType::DELETED
716 };
717 (path, change_type)
718 })
719 .collect();
720
721 self.notify_watched_config_events(&config_paths);
722 }
723
724 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
725 let paths = params
726 .get("multi_file_write_paths")
727 .and_then(|value| value.as_array())?
728 .iter()
729 .filter_map(|value| value.as_str())
730 .map(PathBuf::from)
731 .collect::<Vec<_>>();
732
733 (!paths.is_empty()).then_some(paths)
734 }
735
736 fn watched_file_events_from_params(
748 params: &serde_json::Value,
749 extra_markers: &[String],
750 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
751 let events = params
752 .get("multi_file_write_paths")
753 .and_then(|value| value.as_array())?
754 .iter()
755 .filter_map(|entry| {
756 let path = entry
758 .get("path")
759 .and_then(|value| value.as_str())
760 .map(PathBuf::from)?;
761
762 if !is_config_file_path_with_custom(&path, extra_markers) {
763 return None;
764 }
765
766 let change_type = entry
767 .get("type")
768 .and_then(|value| value.as_str())
769 .and_then(Self::parse_file_change_type)
770 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
771
772 Some((path, change_type))
773 })
774 .collect::<Vec<_>>();
775
776 (!events.is_empty()).then_some(events)
777 }
778
779 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
780 match value {
781 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
782 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
783 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
784 _ => None,
785 }
786 }
787
788 fn change_type_from_current_state(path: &Path) -> FileChangeType {
789 if path.exists() {
790 FileChangeType::CHANGED
791 } else {
792 FileChangeType::DELETED
793 }
794 }
795
796 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
797 if config_paths.is_empty() {
798 return;
799 }
800
801 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
802 let config = self.config();
803 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
804 crate::slog_warn!("watched-file sync error: {}", e);
805 }
806 }
807 }
808
809 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
810 let custom_markers = self.custom_lsp_root_markers();
811 if !is_config_file_path_with_custom(file_path, &custom_markers) {
812 return;
813 }
814
815 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
816 }
817
818 pub fn lsp_post_multi_file_write(
823 &self,
824 file_path: &Path,
825 content: &str,
826 file_paths: &[PathBuf],
827 params: &serde_json::Value,
828 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
829 self.notify_watched_config_files(file_paths);
830
831 let wants_diagnostics = params
832 .get("diagnostics")
833 .and_then(|v| v.as_bool())
834 .unwrap_or(false);
835
836 if !wants_diagnostics {
837 self.lsp_notify_file_changed(file_path, content);
838 return None;
839 }
840
841 let wait_ms = params
842 .get("wait_ms")
843 .and_then(|v| v.as_u64())
844 .unwrap_or(3000)
845 .min(10_000);
846
847 Some(self.lsp_notify_and_collect_diagnostics(
848 file_path,
849 content,
850 std::time::Duration::from_millis(wait_ms),
851 ))
852 }
853
854 pub fn lsp_post_write(
871 &self,
872 file_path: &Path,
873 content: &str,
874 params: &serde_json::Value,
875 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
876 let wants_diagnostics = params
877 .get("diagnostics")
878 .and_then(|v| v.as_bool())
879 .unwrap_or(false);
880
881 let custom_markers = self.custom_lsp_root_markers();
882
883 if !wants_diagnostics {
884 if let Some(file_paths) = Self::multi_file_write_paths(params) {
885 self.notify_watched_config_files(&file_paths);
886 } else if let Some(config_events) =
887 Self::watched_file_events_from_params(params, &custom_markers)
888 {
889 self.notify_watched_config_events(&config_events);
890 }
891 self.lsp_notify_file_changed(file_path, content);
892 return None;
893 }
894
895 let wait_ms = params
896 .get("wait_ms")
897 .and_then(|v| v.as_u64())
898 .unwrap_or(3000)
899 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
902 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
903 }
904
905 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
906 {
907 self.notify_watched_config_events(&config_events);
908 }
909
910 Some(self.lsp_notify_and_collect_diagnostics(
911 file_path,
912 content,
913 std::time::Duration::from_millis(wait_ms),
914 ))
915 }
916
917 pub fn validate_path(
926 &self,
927 req_id: &str,
928 path: &Path,
929 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
930 let config = self.config();
931 if !config.restrict_to_project_root {
933 return Ok(path.to_path_buf());
934 }
935 let root = match &config.project_root {
936 Some(r) => r.clone(),
937 None => return Ok(path.to_path_buf()), };
939 drop(config);
940
941 let raw_root = root.clone();
946 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
947
948 let resolved = match std::fs::canonicalize(path) {
953 Ok(resolved) => resolved,
954 Err(_) => {
955 let normalized = normalize_path(path);
956 reject_escaping_symlink(req_id, path, &normalized, &resolved_root, &raw_root)?;
957 resolve_with_existing_ancestors(&normalized)
958 }
959 };
960
961 if !resolved.starts_with(&resolved_root) {
962 return Err(path_error_response(req_id, path, &resolved_root));
963 }
964
965 Ok(resolved)
966 }
967
968 pub fn lsp_server_count(&self) -> usize {
970 self.lsp_manager
971 .try_borrow()
972 .map(|lsp| lsp.server_count())
973 .unwrap_or(0)
974 }
975
976 pub fn symbol_cache_stats(&self) -> serde_json::Value {
978 let entries = self
979 .symbol_cache
980 .read()
981 .map(|cache| cache.len())
982 .unwrap_or(0);
983 serde_json::json!({
984 "local_entries": entries,
985 "warm_entries": 0,
986 })
987 }
988}
989
990#[cfg(test)]
991mod gitignore_tests {
992 use super::*;
993 use std::fs;
994 use std::path::Path;
995 use tempfile::TempDir;
996
997 fn make_ctx_with_root(root: &Path) -> AppContext {
998 let provider = Box::new(crate::parser::TreeSitterProvider::new());
999 let config = Config {
1000 project_root: Some(root.to_path_buf()),
1001 ..Config::default()
1002 };
1003 AppContext::new(provider, config)
1004 }
1005
1006 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
1013 let Some(matcher) = ctx.gitignore() else {
1014 return false;
1015 };
1016 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1017 if !canonical.starts_with(matcher.path()) {
1018 return false;
1019 }
1020 let is_dir = canonical.is_dir();
1021 matcher
1022 .matched_path_or_any_parents(&canonical, is_dir)
1023 .is_ignore()
1024 }
1025
1026 #[test]
1027 fn rebuild_gitignore_returns_none_without_project_root() {
1028 let provider = Box::new(crate::parser::TreeSitterProvider::new());
1029 let ctx = AppContext::new(provider, Config::default());
1030 ctx.rebuild_gitignore();
1031 assert!(ctx.gitignore().is_none());
1032 }
1033
1034 #[test]
1035 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
1036 let tmp = TempDir::new().unwrap();
1037 let ctx = make_ctx_with_root(tmp.path());
1038 ctx.rebuild_gitignore();
1039 assert!(ctx.gitignore().is_none());
1040 }
1041
1042 #[test]
1043 fn matcher_filters_files_in_ignored_dist_dir() {
1044 let tmp = TempDir::new().unwrap();
1045 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
1046 fs::create_dir_all(tmp.path().join("dist")).unwrap();
1047 fs::create_dir_all(tmp.path().join("src")).unwrap();
1048 let dist_file = tmp.path().join("dist").join("bundle.js");
1049 let src_file = tmp.path().join("src").join("app.ts");
1050 fs::write(&dist_file, "x").unwrap();
1051 fs::write(&src_file, "y").unwrap();
1052
1053 let ctx = make_ctx_with_root(tmp.path());
1054 ctx.rebuild_gitignore();
1055
1056 assert!(ctx.gitignore().is_some());
1057 assert!(
1058 is_ignored(&ctx, &dist_file),
1059 "dist/bundle.js should be ignored"
1060 );
1061 assert!(
1062 !is_ignored(&ctx, &src_file),
1063 "src/app.ts should NOT be ignored"
1064 );
1065 }
1066
1067 #[test]
1068 fn matcher_handles_node_modules_and_target() {
1069 let tmp = TempDir::new().unwrap();
1070 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
1071 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
1072 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
1073 let nm_file = tmp.path().join("node_modules/foo/index.js");
1074 let target_file = tmp.path().join("target/debug/aft");
1075 fs::write(&nm_file, "x").unwrap();
1076 fs::write(&target_file, "x").unwrap();
1077
1078 let ctx = make_ctx_with_root(tmp.path());
1079 ctx.rebuild_gitignore();
1080
1081 assert!(is_ignored(&ctx, &nm_file));
1082 assert!(is_ignored(&ctx, &target_file));
1083 }
1084
1085 #[test]
1086 fn matcher_honors_negation_pattern() {
1087 let tmp = TempDir::new().unwrap();
1089 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
1090 let random_log = tmp.path().join("random.log");
1091 let important_log = tmp.path().join("important.log");
1092 fs::write(&random_log, "x").unwrap();
1093 fs::write(&important_log, "y").unwrap();
1094
1095 let ctx = make_ctx_with_root(tmp.path());
1096 ctx.rebuild_gitignore();
1097
1098 assert!(is_ignored(&ctx, &random_log));
1099 assert!(
1100 !is_ignored(&ctx, &important_log),
1101 "negation pattern should un-ignore important.log"
1102 );
1103 }
1104
1105 #[test]
1106 fn rebuild_picks_up_gitignore_changes() {
1107 let tmp = TempDir::new().unwrap();
1108 let ignore_path = tmp.path().join(".gitignore");
1109 fs::write(&ignore_path, "foo.txt\n").unwrap();
1110 let foo = tmp.path().join("foo.txt");
1111 let bar = tmp.path().join("bar.txt");
1112 fs::write(&foo, "").unwrap();
1113 fs::write(&bar, "").unwrap();
1114
1115 let ctx = make_ctx_with_root(tmp.path());
1116 ctx.rebuild_gitignore();
1117 assert!(is_ignored(&ctx, &foo));
1118 assert!(!is_ignored(&ctx, &bar));
1119
1120 fs::write(&ignore_path, "bar.txt\n").unwrap();
1122 ctx.rebuild_gitignore();
1123 assert!(!is_ignored(&ctx, &foo));
1124 assert!(is_ignored(&ctx, &bar));
1125 }
1126
1127 #[test]
1128 fn gitignore_loads_info_exclude_when_present() {
1129 let tmp = TempDir::new().unwrap();
1130 let info_dir = tmp.path().join(".git/info");
1131 fs::create_dir_all(&info_dir).unwrap();
1132 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
1133 let secrets = tmp.path().join("secrets.txt");
1134 let public = tmp.path().join("public.txt");
1135 fs::write(&secrets, "token").unwrap();
1136 fs::write(&public, "ok").unwrap();
1137
1138 let ctx = make_ctx_with_root(tmp.path());
1139 ctx.rebuild_gitignore();
1140
1141 assert!(is_ignored(&ctx, &secrets));
1142 assert!(!is_ignored(&ctx, &public));
1143 }
1144
1145 #[test]
1146 fn matcher_picks_up_nested_gitignore() {
1147 let tmp = TempDir::new().unwrap();
1148 fs::write(tmp.path().join(".gitignore"), "").unwrap();
1150 let sub = tmp.path().join("packages/foo");
1151 fs::create_dir_all(&sub).unwrap();
1152 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
1153 let generated_file = sub.join("generated").join("out.js");
1154 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
1155 fs::write(&generated_file, "x").unwrap();
1156
1157 let ctx = make_ctx_with_root(tmp.path());
1158 ctx.rebuild_gitignore();
1159
1160 assert!(
1161 is_ignored(&ctx, &generated_file),
1162 "nested gitignore in packages/foo/.gitignore should ignore generated/"
1163 );
1164 }
1165}