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 notify::RecommendedWatcher;
6
7use crate::backup::BackupStore;
8use crate::callgraph::CallGraph;
9use crate::checkpoint::CheckpointStore;
10use crate::config::Config;
11use crate::language::LanguageProvider;
12use crate::lsp::manager::LspManager;
13use crate::search_index::SearchIndex;
14
15/// Normalize a path by resolving `.` and `..` components lexically,
16/// without touching the filesystem. This prevents path traversal
17/// attacks when `fs::canonicalize` fails (e.g. for non-existent paths).
18fn normalize_path(path: &Path) -> PathBuf {
19    let mut result = PathBuf::new();
20    for component in path.components() {
21        match component {
22            Component::ParentDir => {
23                // Pop the last component unless we're at root or have no components
24                if !result.pop() {
25                    result.push(component);
26                }
27            }
28            Component::CurDir => {} // Skip `.`
29            _ => result.push(component),
30        }
31    }
32    result
33}
34
35fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
36    let mut existing = path.to_path_buf();
37    let mut tail_segments = Vec::new();
38
39    while !existing.exists() {
40        if let Some(name) = existing.file_name() {
41            tail_segments.push(name.to_owned());
42        } else {
43            break;
44        }
45
46        existing = match existing.parent() {
47            Some(parent) => parent.to_path_buf(),
48            None => break,
49        };
50    }
51
52    let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
53    for segment in tail_segments.into_iter().rev() {
54        resolved.push(segment);
55    }
56
57    resolved
58}
59
60/// Shared application context threaded through all command handlers.
61///
62/// Holds the language provider, backup/checkpoint stores, configuration,
63/// and call graph engine. Constructed once at startup and passed by
64/// reference to `dispatch`.
65///
66/// Stores use `RefCell` for interior mutability — the binary is single-threaded
67/// (one request at a time on the stdin read loop) so runtime borrow checking
68/// is safe and never contended.
69pub struct AppContext {
70    provider: Box<dyn LanguageProvider>,
71    backup: RefCell<BackupStore>,
72    checkpoint: RefCell<CheckpointStore>,
73    config: RefCell<Config>,
74    callgraph: RefCell<Option<CallGraph>>,
75    search_index: RefCell<Option<SearchIndex>>,
76    search_index_rx:
77        RefCell<Option<crossbeam_channel::Receiver<(SearchIndex, crate::parser::SymbolCache)>>>,
78    watcher: RefCell<Option<RecommendedWatcher>>,
79    watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
80    lsp_manager: RefCell<LspManager>,
81}
82
83impl AppContext {
84    pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
85        AppContext {
86            provider,
87            backup: RefCell::new(BackupStore::new()),
88            checkpoint: RefCell::new(CheckpointStore::new()),
89            config: RefCell::new(config),
90            callgraph: RefCell::new(None),
91            search_index: RefCell::new(None),
92            search_index_rx: RefCell::new(None),
93            watcher: RefCell::new(None),
94            watcher_rx: RefCell::new(None),
95            lsp_manager: RefCell::new(LspManager::new()),
96        }
97    }
98
99    /// Access the language provider.
100    pub fn provider(&self) -> &dyn LanguageProvider {
101        self.provider.as_ref()
102    }
103
104    /// Access the backup store.
105    pub fn backup(&self) -> &RefCell<BackupStore> {
106        &self.backup
107    }
108
109    /// Access the checkpoint store.
110    pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
111        &self.checkpoint
112    }
113
114    /// Access the configuration (shared borrow).
115    pub fn config(&self) -> Ref<'_, Config> {
116        self.config.borrow()
117    }
118
119    /// Access the configuration (mutable borrow).
120    pub fn config_mut(&self) -> RefMut<'_, Config> {
121        self.config.borrow_mut()
122    }
123
124    /// Access the call graph engine.
125    pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
126        &self.callgraph
127    }
128
129    /// Access the search index.
130    pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
131        &self.search_index
132    }
133
134    /// Access the search-index build receiver (returns index + pre-warmed symbol cache).
135    pub fn search_index_rx(
136        &self,
137    ) -> &RefCell<Option<crossbeam_channel::Receiver<(SearchIndex, crate::parser::SymbolCache)>>>
138    {
139        &self.search_index_rx
140    }
141
142    /// Access the file watcher handle (kept alive to continue watching).
143    pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
144        &self.watcher
145    }
146
147    /// Access the watcher event receiver.
148    pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
149        &self.watcher_rx
150    }
151
152    /// Access the LSP manager.
153    pub fn lsp(&self) -> RefMut<'_, LspManager> {
154        self.lsp_manager.borrow_mut()
155    }
156
157    /// Notify LSP servers that a file was written.
158    /// Call this after write_format_validate in command handlers.
159    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
160        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
161            if let Err(e) = lsp.notify_file_changed(file_path, content) {
162                log::warn!("sync error for {}: {}", file_path.display(), e);
163            }
164        }
165    }
166
167    /// Notify LSP and optionally wait for diagnostics.
168    ///
169    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
170    /// Sends didChange to the server, waits briefly for publishDiagnostics, and returns
171    /// any diagnostics for the file. If no server is running, returns empty immediately.
172    pub fn lsp_notify_and_collect_diagnostics(
173        &self,
174        file_path: &Path,
175        content: &str,
176        timeout: std::time::Duration,
177    ) -> Vec<crate::lsp::diagnostics::StoredDiagnostic> {
178        let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
179            return Vec::new();
180        };
181
182        // Clear any queued notifications before this write so the wait loop only
183        // observes diagnostics triggered by the current change.
184        lsp.drain_events();
185
186        // Send didChange/didOpen
187        if let Err(e) = lsp.notify_file_changed(file_path, content) {
188            log::warn!("sync error for {}: {}", file_path.display(), e);
189            return Vec::new();
190        }
191
192        // Wait for diagnostics to arrive
193        lsp.wait_for_diagnostics(file_path, timeout)
194    }
195
196    /// Post-write LSP hook: notify server and optionally collect diagnostics.
197    ///
198    /// This is the single call site for all command handlers after `write_format_validate`.
199    /// When `diagnostics` is true, it notifies the server, waits until matching
200    /// diagnostics arrive or the timeout expires, and returns diagnostics for the file.
201    /// When false, it just notifies (fire-and-forget).
202    pub fn lsp_post_write(
203        &self,
204        file_path: &Path,
205        content: &str,
206        params: &serde_json::Value,
207    ) -> Vec<crate::lsp::diagnostics::StoredDiagnostic> {
208        let wants_diagnostics = params
209            .get("diagnostics")
210            .and_then(|v| v.as_bool())
211            .unwrap_or(false);
212
213        if !wants_diagnostics {
214            self.lsp_notify_file_changed(file_path, content);
215            return Vec::new();
216        }
217
218        let wait_ms = params
219            .get("wait_ms")
220            .and_then(|v| v.as_u64())
221            .unwrap_or(1500)
222            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
223
224        self.lsp_notify_and_collect_diagnostics(
225            file_path,
226            content,
227            std::time::Duration::from_millis(wait_ms),
228        )
229    }
230
231    /// Validate that a file path falls within the configured project root.
232    ///
233    /// When `project_root` is configured (normal plugin usage), this resolves the
234    /// path and checks it starts with the root. Returns the canonicalized path on
235    /// success, or an error response on violation.
236    ///
237    /// When no `project_root` is configured (direct CLI usage), all paths pass
238    /// through unrestricted for backward compatibility.
239    pub fn validate_path(
240        &self,
241        req_id: &str,
242        path: &Path,
243    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
244        let config = self.config();
245        // When restrict_to_project_root is false (default), allow all paths
246        if !config.restrict_to_project_root {
247            return Ok(path.to_path_buf());
248        }
249        let root = match &config.project_root {
250            Some(r) => r.clone(),
251            None => return Ok(path.to_path_buf()), // No root configured, allow all
252        };
253        drop(config);
254
255        // Resolve the path (follow symlinks, normalize ..)
256        let resolved = std::fs::canonicalize(path)
257            .unwrap_or_else(|_| resolve_with_existing_ancestors(&normalize_path(path)));
258
259        let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
260
261        if !resolved.starts_with(&resolved_root) {
262            return Err(crate::protocol::Response::error(
263                req_id,
264                "path_outside_root",
265                format!(
266                    "path '{}' is outside the project root '{}'",
267                    path.display(),
268                    resolved_root.display()
269                ),
270            ));
271        }
272
273        Ok(resolved)
274    }
275}