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