Skip to main content

aft/
context.rs

1use std::cell::{Ref, RefCell, RefMut};
2use std::path::{Component, Path, PathBuf};
3use std::sync::{mpsc, Arc, Mutex};
4
5use lsp_types::FileChangeType;
6use notify::RecommendedWatcher;
7
8use crate::backup::BackupStore;
9use crate::bash_background::{BgCompletion, BgTaskRegistry};
10use crate::callgraph::CallGraph;
11use crate::checkpoint::CheckpointStore;
12use crate::config::Config;
13use crate::language::LanguageProvider;
14use crate::lsp::manager::LspManager;
15use crate::lsp::registry::is_config_file_path_with_custom;
16use crate::protocol::{ProgressFrame, PushFrame};
17
18pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
19pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
20use crate::search_index::SearchIndex;
21use crate::semantic_index::SemanticIndex;
22
23#[derive(Debug, Clone)]
24pub enum SemanticIndexStatus {
25    Disabled,
26    Building {
27        stage: String,
28        files: Option<usize>,
29        entries_done: Option<usize>,
30        entries_total: Option<usize>,
31    },
32    Ready,
33    Failed(String),
34}
35
36pub enum SemanticIndexEvent {
37    Progress {
38        stage: String,
39        files: Option<usize>,
40        entries_done: Option<usize>,
41        entries_total: Option<usize>,
42    },
43    Ready(SemanticIndex),
44    Failed(String),
45}
46
47/// Normalize a path by resolving `.` and `..` components lexically,
48/// without touching the filesystem. This prevents path traversal
49/// attacks when `fs::canonicalize` fails (e.g. for non-existent paths).
50fn normalize_path(path: &Path) -> PathBuf {
51    let mut result = PathBuf::new();
52    for component in path.components() {
53        match component {
54            Component::ParentDir => {
55                // Pop the last component unless we're at root or have no components
56                if !result.pop() {
57                    result.push(component);
58                }
59            }
60            Component::CurDir => {} // Skip `.`
61            _ => result.push(component),
62        }
63    }
64    result
65}
66
67fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
68    let mut existing = path.to_path_buf();
69    let mut tail_segments = Vec::new();
70
71    while !existing.exists() {
72        if let Some(name) = existing.file_name() {
73            tail_segments.push(name.to_owned());
74        } else {
75            break;
76        }
77
78        existing = match existing.parent() {
79            Some(parent) => parent.to_path_buf(),
80            None => break,
81        };
82    }
83
84    let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
85    for segment in tail_segments.into_iter().rev() {
86        resolved.push(segment);
87    }
88
89    resolved
90}
91
92fn path_error_response(
93    req_id: &str,
94    path: &Path,
95    resolved_root: &Path,
96) -> crate::protocol::Response {
97    crate::protocol::Response::error(
98        req_id,
99        "path_outside_root",
100        format!(
101            "path '{}' is outside the project root '{}'",
102            path.display(),
103            resolved_root.display()
104        ),
105    )
106}
107
108/// Walk `candidate` component-by-component. For any component that is a
109/// symlink on disk, iteratively follow the full chain (up to 40 hops) and
110/// reject if any hop's resolved target lies outside `resolved_root`.
111///
112/// This is the fallback path used when `fs::canonicalize` fails (e.g. on
113/// Linux with broken symlink chains pointing to non-existent destinations).
114/// On macOS `canonicalize` also fails for broken symlinks but the returned
115/// `/var/...` tempdir paths diverge from `resolved_root`'s `/private/var/...`
116/// form, so we must accept either form when deciding which symlinks to check.
117fn reject_escaping_symlink(
118    req_id: &str,
119    original_path: &Path,
120    candidate: &Path,
121    resolved_root: &Path,
122    raw_root: &Path,
123) -> Result<(), crate::protocol::Response> {
124    let mut current = PathBuf::new();
125
126    for component in candidate.components() {
127        current.push(component);
128
129        let Ok(metadata) = std::fs::symlink_metadata(&current) else {
130            continue;
131        };
132
133        if !metadata.file_type().is_symlink() {
134            continue;
135        }
136
137        // Only check symlinks that live inside the project root. This skips
138        // OS-level prefix symlinks (macOS /var → /private/var) that are not
139        // inside our project directory and whose "escaping" is harmless.
140        //
141        // We compare against BOTH the canonicalized root (resolved_root, e.g.
142        // /private/var/.../project) AND the raw root (e.g. /var/.../project)
143        // because tempdir() returns raw paths while fs::canonicalize returns
144        // the resolved form — and our `current` may be in either form.
145        let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
146        if !inside_root {
147            continue;
148        }
149
150        iterative_follow_chain(req_id, original_path, &current, resolved_root)?;
151    }
152
153    Ok(())
154}
155
156/// Iteratively follow a symlink chain from `link` and reject if any hop's
157/// resolved target is outside `resolved_root`. Depth-capped at 40 hops.
158fn iterative_follow_chain(
159    req_id: &str,
160    original_path: &Path,
161    start: &Path,
162    resolved_root: &Path,
163) -> Result<(), crate::protocol::Response> {
164    let mut link = start.to_path_buf();
165    let mut depth = 0usize;
166
167    loop {
168        if depth > 40 {
169            return Err(path_error_response(req_id, original_path, resolved_root));
170        }
171
172        let target = match std::fs::read_link(&link) {
173            Ok(t) => t,
174            Err(_) => {
175                // Can't read the link — treat as escaping to be safe.
176                return Err(path_error_response(req_id, original_path, resolved_root));
177            }
178        };
179
180        let resolved_target = if target.is_absolute() {
181            normalize_path(&target)
182        } else {
183            let parent = link.parent().unwrap_or_else(|| Path::new(""));
184            normalize_path(&parent.join(&target))
185        };
186
187        // Check boundary: use canonicalized target when available (handles
188        // macOS /var → /private/var aliasing), fall back to the normalized
189        // path when canonicalize fails (e.g. broken symlink on Linux).
190        let canonical_target =
191            std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
192
193        if !canonical_target.starts_with(resolved_root)
194            && !resolved_target.starts_with(resolved_root)
195        {
196            return Err(path_error_response(req_id, original_path, resolved_root));
197        }
198
199        // If the target is itself a symlink, follow the next hop.
200        match std::fs::symlink_metadata(&resolved_target) {
201            Ok(meta) if meta.file_type().is_symlink() => {
202                link = resolved_target;
203                depth += 1;
204            }
205            _ => break, // Non-symlink or non-existent target — chain ends here.
206        }
207    }
208
209    Ok(())
210}
211
212/// Shared application context threaded through all command handlers.
213///
214/// Holds the language provider, backup/checkpoint stores, configuration,
215/// and call graph engine. Constructed once at startup and passed by
216/// reference to `dispatch`.
217///
218/// Stores use `RefCell` for interior mutability — the binary is single-threaded
219/// (one request at a time on the stdin read loop) so runtime borrow checking
220/// is safe and never contended.
221pub struct AppContext {
222    provider: Box<dyn LanguageProvider>,
223    backup: RefCell<BackupStore>,
224    checkpoint: RefCell<CheckpointStore>,
225    config: RefCell<Config>,
226    callgraph: RefCell<Option<CallGraph>>,
227    search_index: RefCell<Option<SearchIndex>>,
228    search_index_rx:
229        RefCell<Option<crossbeam_channel::Receiver<(SearchIndex, crate::parser::SymbolCache)>>>,
230    semantic_index: RefCell<Option<SemanticIndex>>,
231    semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
232    semantic_index_status: RefCell<SemanticIndexStatus>,
233    semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
234    watcher: RefCell<Option<RecommendedWatcher>>,
235    watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
236    lsp_manager: RefCell<LspManager>,
237    progress_sender: SharedProgressSender,
238    bash_background: BgTaskRegistry,
239}
240
241impl AppContext {
242    pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
243        let progress_sender = Arc::new(Mutex::new(None));
244        AppContext {
245            provider,
246            backup: RefCell::new(BackupStore::new()),
247            checkpoint: RefCell::new(CheckpointStore::new()),
248            config: RefCell::new(config),
249            callgraph: RefCell::new(None),
250            search_index: RefCell::new(None),
251            search_index_rx: RefCell::new(None),
252            semantic_index: RefCell::new(None),
253            semantic_index_rx: RefCell::new(None),
254            semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
255            semantic_embedding_model: RefCell::new(None),
256            watcher: RefCell::new(None),
257            watcher_rx: RefCell::new(None),
258            lsp_manager: RefCell::new(LspManager::new()),
259            progress_sender: Arc::clone(&progress_sender),
260            bash_background: BgTaskRegistry::new(progress_sender),
261        }
262    }
263
264    pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
265        if let Ok(mut progress_sender) = self.progress_sender.lock() {
266            *progress_sender = sender;
267        }
268    }
269
270    pub fn emit_progress(&self, frame: ProgressFrame) {
271        let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
272            return;
273        };
274        if let Some(sender) = progress_sender.as_ref() {
275            sender(PushFrame::Progress(frame));
276        }
277    }
278
279    pub fn bash_background(&self) -> &BgTaskRegistry {
280        &self.bash_background
281    }
282
283    pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
284        self.bash_background.drain_completions()
285    }
286
287    /// Access the language provider.
288    pub fn provider(&self) -> &dyn LanguageProvider {
289        self.provider.as_ref()
290    }
291
292    /// Access the backup store.
293    pub fn backup(&self) -> &RefCell<BackupStore> {
294        &self.backup
295    }
296
297    /// Access the checkpoint store.
298    pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
299        &self.checkpoint
300    }
301
302    /// Access the configuration (shared borrow).
303    pub fn config(&self) -> Ref<'_, Config> {
304        self.config.borrow()
305    }
306
307    /// Access the configuration (mutable borrow).
308    pub fn config_mut(&self) -> RefMut<'_, Config> {
309        self.config.borrow_mut()
310    }
311
312    /// Access the call graph engine.
313    pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
314        &self.callgraph
315    }
316
317    /// Access the search index.
318    pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
319        &self.search_index
320    }
321
322    /// Access the search-index build receiver (returns index + pre-warmed symbol cache).
323    pub fn search_index_rx(
324        &self,
325    ) -> &RefCell<Option<crossbeam_channel::Receiver<(SearchIndex, crate::parser::SymbolCache)>>>
326    {
327        &self.search_index_rx
328    }
329
330    /// Access the semantic search index.
331    pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
332        &self.semantic_index
333    }
334
335    /// Access the semantic-index build receiver.
336    pub fn semantic_index_rx(
337        &self,
338    ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
339        &self.semantic_index_rx
340    }
341
342    pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
343        &self.semantic_index_status
344    }
345
346    /// Access the cached semantic embedding model.
347    pub fn semantic_embedding_model(
348        &self,
349    ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
350        &self.semantic_embedding_model
351    }
352
353    /// Access the file watcher handle (kept alive to continue watching).
354    pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
355        &self.watcher
356    }
357
358    /// Access the watcher event receiver.
359    pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
360        &self.watcher_rx
361    }
362
363    /// Access the LSP manager.
364    pub fn lsp(&self) -> RefMut<'_, LspManager> {
365        self.lsp_manager.borrow_mut()
366    }
367
368    /// Notify LSP servers that a file was written.
369    /// Call this after write_format_validate in command handlers.
370    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
371        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
372            let config = self.config();
373            if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
374                log::warn!("sync error for {}: {}", file_path.display(), e);
375            }
376        }
377    }
378
379    /// Notify LSP and optionally wait for diagnostics.
380    ///
381    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
382    /// Sends didChange to the server, waits briefly for publishDiagnostics, and returns
383    /// any diagnostics for the file. If no server is running, returns empty immediately.
384    ///
385    /// v0.17.3: this is the version-aware path. Pre-edit cached diagnostics
386    /// are NEVER returned — only entries whose `version` matches the
387    /// post-edit document version (or, for unversioned servers, whose
388    /// `epoch` advanced past the pre-edit snapshot).
389    pub fn lsp_notify_and_collect_diagnostics(
390        &self,
391        file_path: &Path,
392        content: &str,
393        timeout: std::time::Duration,
394    ) -> crate::lsp::manager::PostEditWaitOutcome {
395        let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
396            return crate::lsp::manager::PostEditWaitOutcome::default();
397        };
398
399        // Clear any queued notifications before this write so the wait loop only
400        // observes diagnostics triggered by the current change.
401        lsp.drain_events();
402
403        // Snapshot per-server epochs BEFORE sending didChange so the wait
404        // loop can detect freshness via epoch-delta for servers that don't
405        // echo `version` on publishDiagnostics.
406        let pre_snapshot = lsp.snapshot_diagnostic_epochs(file_path);
407
408        // Send didChange/didOpen and capture per-server target version.
409        let config = self.config();
410        let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
411        {
412            Ok(v) => v,
413            Err(e) => {
414                log::warn!("sync error for {}: {}", file_path.display(), e);
415                return crate::lsp::manager::PostEditWaitOutcome::default();
416            }
417        };
418
419        // No server matched this file — return an empty outcome that's
420        // honestly `complete: true` (nothing to wait for).
421        if expected_versions.is_empty() {
422            return crate::lsp::manager::PostEditWaitOutcome::default();
423        }
424
425        lsp.wait_for_post_edit_diagnostics(
426            file_path,
427            &config,
428            &expected_versions,
429            &pre_snapshot,
430            timeout,
431        )
432    }
433
434    /// Collect custom server root_markers from user config for use in
435    /// `is_config_file_path_with_custom` checks (#25).
436    fn custom_lsp_root_markers(&self) -> Vec<String> {
437        self.config()
438            .lsp_servers
439            .iter()
440            .flat_map(|s| s.root_markers.iter().cloned())
441            .collect()
442    }
443
444    fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
445        let custom_markers = self.custom_lsp_root_markers();
446        let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
447            .iter()
448            .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
449            .cloned()
450            .map(|path| {
451                let change_type = if path.exists() {
452                    FileChangeType::CHANGED
453                } else {
454                    FileChangeType::DELETED
455                };
456                (path, change_type)
457            })
458            .collect();
459
460        self.notify_watched_config_events(&config_paths);
461    }
462
463    fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
464        let paths = params
465            .get("multi_file_write_paths")
466            .and_then(|value| value.as_array())?
467            .iter()
468            .filter_map(|value| value.as_str())
469            .map(PathBuf::from)
470            .collect::<Vec<_>>();
471
472        (!paths.is_empty()).then_some(paths)
473    }
474
475    /// Parse config-file watched events from `multi_file_write_paths` when the
476    /// array contains object entries `{ "path": "...", "type": "created|changed|deleted" }`.
477    ///
478    /// This handles the OBJECT variant of `multi_file_write_paths`. The STRING
479    /// variant (bare path strings) is handled by `multi_file_write_paths()` and
480    /// `notify_watched_config_files()`. Both variants read the same JSON key but
481    /// with different per-entry schemas — they are NOT redundant.
482    ///
483    /// #18 note: in older code this function also existed alongside `multi_file_write_paths()`
484    /// and was reachable via the `else if` branch when all entries were objects.
485    /// Restoring both is correct.
486    fn watched_file_events_from_params(
487        params: &serde_json::Value,
488        extra_markers: &[String],
489    ) -> Option<Vec<(PathBuf, FileChangeType)>> {
490        let events = params
491            .get("multi_file_write_paths")
492            .and_then(|value| value.as_array())?
493            .iter()
494            .filter_map(|entry| {
495                // Only handle object entries — string entries go through multi_file_write_paths()
496                let path = entry
497                    .get("path")
498                    .and_then(|value| value.as_str())
499                    .map(PathBuf::from)?;
500
501                if !is_config_file_path_with_custom(&path, extra_markers) {
502                    return None;
503                }
504
505                let change_type = entry
506                    .get("type")
507                    .and_then(|value| value.as_str())
508                    .and_then(Self::parse_file_change_type)
509                    .unwrap_or_else(|| Self::change_type_from_current_state(&path));
510
511                Some((path, change_type))
512            })
513            .collect::<Vec<_>>();
514
515        (!events.is_empty()).then_some(events)
516    }
517
518    fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
519        match value {
520            "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
521            "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
522            "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
523            _ => None,
524        }
525    }
526
527    fn change_type_from_current_state(path: &Path) -> FileChangeType {
528        if path.exists() {
529            FileChangeType::CHANGED
530        } else {
531            FileChangeType::DELETED
532        }
533    }
534
535    fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
536        if config_paths.is_empty() {
537            return;
538        }
539
540        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
541            let config = self.config();
542            if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
543                log::warn!("watched-file sync error: {}", e);
544            }
545        }
546    }
547
548    pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
549        let custom_markers = self.custom_lsp_root_markers();
550        if !is_config_file_path_with_custom(file_path, &custom_markers) {
551            return;
552        }
553
554        self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
555    }
556
557    /// Post-write LSP hook for multi-file edits. When the patch includes
558    /// config-file edits, notify active workspace servers via
559    /// `workspace/didChangeWatchedFiles` before sending the per-document
560    /// didOpen/didChange for the current file.
561    pub fn lsp_post_multi_file_write(
562        &self,
563        file_path: &Path,
564        content: &str,
565        file_paths: &[PathBuf],
566        params: &serde_json::Value,
567    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
568        self.notify_watched_config_files(file_paths);
569
570        let wants_diagnostics = params
571            .get("diagnostics")
572            .and_then(|v| v.as_bool())
573            .unwrap_or(false);
574
575        if !wants_diagnostics {
576            self.lsp_notify_file_changed(file_path, content);
577            return None;
578        }
579
580        let wait_ms = params
581            .get("wait_ms")
582            .and_then(|v| v.as_u64())
583            .unwrap_or(3000)
584            .min(10_000);
585
586        Some(self.lsp_notify_and_collect_diagnostics(
587            file_path,
588            content,
589            std::time::Duration::from_millis(wait_ms),
590        ))
591    }
592
593    /// Post-write LSP hook: notify server and optionally collect diagnostics.
594    ///
595    /// This is the single call site for all command handlers after `write_format_validate`.
596    /// Behavior:
597    /// - When `diagnostics: true` is in `params`, notifies the server, waits
598    ///   until matching diagnostics arrive or the timeout expires, and returns
599    ///   `Some(outcome)` with the verified-fresh diagnostics + per-server
600    ///   status.
601    /// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
602    ///   and returns `None`. Callers must NOT wrap this in `Some(...)`; the
603    ///   `None` is what tells the response builder to omit the LSP fields
604    ///   entirely (preserves the no-diagnostics-requested response shape).
605    ///
606    /// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
607    /// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
608    /// capped at 10000ms.
609    pub fn lsp_post_write(
610        &self,
611        file_path: &Path,
612        content: &str,
613        params: &serde_json::Value,
614    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
615        let wants_diagnostics = params
616            .get("diagnostics")
617            .and_then(|v| v.as_bool())
618            .unwrap_or(false);
619
620        let custom_markers = self.custom_lsp_root_markers();
621
622        if !wants_diagnostics {
623            if let Some(file_paths) = Self::multi_file_write_paths(params) {
624                self.notify_watched_config_files(&file_paths);
625            } else if let Some(config_events) =
626                Self::watched_file_events_from_params(params, &custom_markers)
627            {
628                self.notify_watched_config_events(&config_events);
629            }
630            self.lsp_notify_file_changed(file_path, content);
631            return None;
632        }
633
634        let wait_ms = params
635            .get("wait_ms")
636            .and_then(|v| v.as_u64())
637            .unwrap_or(3000)
638            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
639
640        if let Some(file_paths) = Self::multi_file_write_paths(params) {
641            return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
642        }
643
644        if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
645        {
646            self.notify_watched_config_events(&config_events);
647        }
648
649        Some(self.lsp_notify_and_collect_diagnostics(
650            file_path,
651            content,
652            std::time::Duration::from_millis(wait_ms),
653        ))
654    }
655
656    /// Validate that a file path falls within the configured project root.
657    ///
658    /// When `project_root` is configured (normal plugin usage), this resolves the
659    /// path and checks it starts with the root. Returns the canonicalized path on
660    /// success, or an error response on violation.
661    ///
662    /// When no `project_root` is configured (direct CLI usage), all paths pass
663    /// through unrestricted for backward compatibility.
664    pub fn validate_path(
665        &self,
666        req_id: &str,
667        path: &Path,
668    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
669        let config = self.config();
670        // When restrict_to_project_root is false (default), allow all paths
671        if !config.restrict_to_project_root {
672            return Ok(path.to_path_buf());
673        }
674        let root = match &config.project_root {
675            Some(r) => r.clone(),
676            None => return Ok(path.to_path_buf()), // No root configured, allow all
677        };
678        drop(config);
679
680        // Keep the raw root for symlink-guard comparisons. On macOS, tempdir()
681        // returns /var/... paths while canonicalize gives /private/var/...; we
682        // need both forms so reject_escaping_symlink can recognise in-root
683        // symlinks regardless of which prefix form `current` happens to have.
684        let raw_root = root.clone();
685        let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
686
687        // Resolve the path (follow symlinks, normalize ..). If canonicalization
688        // fails (e.g. path does not exist or traverses a broken symlink), inspect
689        // every existing component with lstat before falling back lexically so a
690        // broken in-root symlink cannot be used to write outside project_root.
691        let resolved = match std::fs::canonicalize(path) {
692            Ok(resolved) => resolved,
693            Err(_) => {
694                let normalized = normalize_path(path);
695                reject_escaping_symlink(req_id, path, &normalized, &resolved_root, &raw_root)?;
696                resolve_with_existing_ancestors(&normalized)
697            }
698        };
699
700        if !resolved.starts_with(&resolved_root) {
701            return Err(path_error_response(req_id, path, &resolved_root));
702        }
703
704        Ok(resolved)
705    }
706
707    /// Count active LSP server instances.
708    pub fn lsp_server_count(&self) -> usize {
709        self.lsp_manager
710            .try_borrow()
711            .map(|lsp| lsp.server_count())
712            .unwrap_or(0)
713    }
714
715    /// Symbol cache statistics from the language provider.
716    pub fn symbol_cache_stats(&self) -> serde_json::Value {
717        if let Some(tsp) = self
718            .provider
719            .as_any()
720            .downcast_ref::<crate::parser::TreeSitterProvider>()
721        {
722            let (local, warm) = tsp.symbol_cache_stats();
723            serde_json::json!({
724                "local_entries": local,
725                "warm_entries": warm,
726            })
727        } else {
728            serde_json::json!({
729                "local_entries": 0,
730                "warm_entries": 0,
731            })
732        }
733    }
734}