Skip to main content

aft/
context.rs

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