1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
use std::cell::{Ref, RefCell, RefMut};
use std::path::{Component, Path, PathBuf};
use std::sync::mpsc;
use notify::RecommendedWatcher;
use crate::backup::BackupStore;
use crate::callgraph::CallGraph;
use crate::checkpoint::CheckpointStore;
use crate::config::Config;
use crate::language::LanguageProvider;
use crate::lsp::manager::LspManager;
use crate::search_index::SearchIndex;
use crate::semantic_index::SemanticIndex;
#[derive(Debug, Clone)]
pub enum SemanticIndexStatus {
Disabled,
Building {
stage: String,
files: Option<usize>,
entries_done: Option<usize>,
entries_total: Option<usize>,
},
Ready,
Failed(String),
}
pub enum SemanticIndexEvent {
Progress {
stage: String,
files: Option<usize>,
entries_done: Option<usize>,
entries_total: Option<usize>,
},
Ready(SemanticIndex),
Failed(String),
}
/// Normalize a path by resolving `.` and `..` components lexically,
/// without touching the filesystem. This prevents path traversal
/// attacks when `fs::canonicalize` fails (e.g. for non-existent paths).
fn normalize_path(path: &Path) -> PathBuf {
let mut result = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
// Pop the last component unless we're at root or have no components
if !result.pop() {
result.push(component);
}
}
Component::CurDir => {} // Skip `.`
_ => result.push(component),
}
}
result
}
fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
let mut existing = path.to_path_buf();
let mut tail_segments = Vec::new();
while !existing.exists() {
if let Some(name) = existing.file_name() {
tail_segments.push(name.to_owned());
} else {
break;
}
existing = match existing.parent() {
Some(parent) => parent.to_path_buf(),
None => break,
};
}
let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
for segment in tail_segments.into_iter().rev() {
resolved.push(segment);
}
resolved
}
/// Shared application context threaded through all command handlers.
///
/// Holds the language provider, backup/checkpoint stores, configuration,
/// and call graph engine. Constructed once at startup and passed by
/// reference to `dispatch`.
///
/// Stores use `RefCell` for interior mutability — the binary is single-threaded
/// (one request at a time on the stdin read loop) so runtime borrow checking
/// is safe and never contended.
pub struct AppContext {
provider: Box<dyn LanguageProvider>,
backup: RefCell<BackupStore>,
checkpoint: RefCell<CheckpointStore>,
config: RefCell<Config>,
callgraph: RefCell<Option<CallGraph>>,
search_index: RefCell<Option<SearchIndex>>,
search_index_rx:
RefCell<Option<crossbeam_channel::Receiver<(SearchIndex, crate::parser::SymbolCache)>>>,
semantic_index: RefCell<Option<SemanticIndex>>,
semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
semantic_index_status: RefCell<SemanticIndexStatus>,
semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
watcher: RefCell<Option<RecommendedWatcher>>,
watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
lsp_manager: RefCell<LspManager>,
}
impl AppContext {
pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
AppContext {
provider,
backup: RefCell::new(BackupStore::new()),
checkpoint: RefCell::new(CheckpointStore::new()),
config: RefCell::new(config),
callgraph: RefCell::new(None),
search_index: RefCell::new(None),
search_index_rx: RefCell::new(None),
semantic_index: RefCell::new(None),
semantic_index_rx: RefCell::new(None),
semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
semantic_embedding_model: RefCell::new(None),
watcher: RefCell::new(None),
watcher_rx: RefCell::new(None),
lsp_manager: RefCell::new(LspManager::new()),
}
}
/// Access the language provider.
pub fn provider(&self) -> &dyn LanguageProvider {
self.provider.as_ref()
}
/// Access the backup store.
pub fn backup(&self) -> &RefCell<BackupStore> {
&self.backup
}
/// Access the checkpoint store.
pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
&self.checkpoint
}
/// Access the configuration (shared borrow).
pub fn config(&self) -> Ref<'_, Config> {
self.config.borrow()
}
/// Access the configuration (mutable borrow).
pub fn config_mut(&self) -> RefMut<'_, Config> {
self.config.borrow_mut()
}
/// Access the call graph engine.
pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
&self.callgraph
}
/// Access the search index.
pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
&self.search_index
}
/// Access the search-index build receiver (returns index + pre-warmed symbol cache).
pub fn search_index_rx(
&self,
) -> &RefCell<Option<crossbeam_channel::Receiver<(SearchIndex, crate::parser::SymbolCache)>>>
{
&self.search_index_rx
}
/// Access the semantic search index.
pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
&self.semantic_index
}
/// Access the semantic-index build receiver.
pub fn semantic_index_rx(
&self,
) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
&self.semantic_index_rx
}
pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
&self.semantic_index_status
}
/// Access the cached semantic embedding model.
pub fn semantic_embedding_model(
&self,
) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
&self.semantic_embedding_model
}
/// Access the file watcher handle (kept alive to continue watching).
pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
&self.watcher
}
/// Access the watcher event receiver.
pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
&self.watcher_rx
}
/// Access the LSP manager.
pub fn lsp(&self) -> RefMut<'_, LspManager> {
self.lsp_manager.borrow_mut()
}
/// Notify LSP servers that a file was written.
/// Call this after write_format_validate in command handlers.
pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
let config = self.config();
if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
log::warn!("sync error for {}: {}", file_path.display(), e);
}
}
}
/// Notify LSP and optionally wait for diagnostics.
///
/// Call this after `write_format_validate` when the request has `"diagnostics": true`.
/// Sends didChange to the server, waits briefly for publishDiagnostics, and returns
/// any diagnostics for the file. If no server is running, returns empty immediately.
///
/// v0.17.3: this is the version-aware path. Pre-edit cached diagnostics
/// are NEVER returned — only entries whose `version` matches the
/// post-edit document version (or, for unversioned servers, whose
/// `epoch` advanced past the pre-edit snapshot).
pub fn lsp_notify_and_collect_diagnostics(
&self,
file_path: &Path,
content: &str,
timeout: std::time::Duration,
) -> crate::lsp::manager::PostEditWaitOutcome {
let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
return crate::lsp::manager::PostEditWaitOutcome::default();
};
// Clear any queued notifications before this write so the wait loop only
// observes diagnostics triggered by the current change.
lsp.drain_events();
// Snapshot per-server epochs BEFORE sending didChange so the wait
// loop can detect freshness via epoch-delta for servers that don't
// echo `version` on publishDiagnostics.
let pre_snapshot = lsp.snapshot_diagnostic_epochs(file_path);
// Send didChange/didOpen and capture per-server target version.
let config = self.config();
let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
{
Ok(v) => v,
Err(e) => {
log::warn!("sync error for {}: {}", file_path.display(), e);
return crate::lsp::manager::PostEditWaitOutcome::default();
}
};
// No server matched this file — return an empty outcome that's
// honestly `complete: true` (nothing to wait for).
if expected_versions.is_empty() {
return crate::lsp::manager::PostEditWaitOutcome::default();
}
lsp.wait_for_post_edit_diagnostics(
file_path,
&config,
&expected_versions,
&pre_snapshot,
timeout,
)
}
/// Post-write LSP hook: notify server and optionally collect diagnostics.
///
/// This is the single call site for all command handlers after `write_format_validate`.
/// Behavior:
/// - When `diagnostics: true` is in `params`, notifies the server, waits
/// until matching diagnostics arrive or the timeout expires, and returns
/// `Some(outcome)` with the verified-fresh diagnostics + per-server
/// status.
/// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
/// and returns `None`. Callers must NOT wrap this in `Some(...)`; the
/// `None` is what tells the response builder to omit the LSP fields
/// entirely (preserves the no-diagnostics-requested response shape).
///
/// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
/// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
/// capped at 10000ms.
pub fn lsp_post_write(
&self,
file_path: &Path,
content: &str,
params: &serde_json::Value,
) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
let wants_diagnostics = params
.get("diagnostics")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !wants_diagnostics {
self.lsp_notify_file_changed(file_path, content);
return None;
}
let wait_ms = params
.get("wait_ms")
.and_then(|v| v.as_u64())
.unwrap_or(3000)
.min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
Some(self.lsp_notify_and_collect_diagnostics(
file_path,
content,
std::time::Duration::from_millis(wait_ms),
))
}
/// Validate that a file path falls within the configured project root.
///
/// When `project_root` is configured (normal plugin usage), this resolves the
/// path and checks it starts with the root. Returns the canonicalized path on
/// success, or an error response on violation.
///
/// When no `project_root` is configured (direct CLI usage), all paths pass
/// through unrestricted for backward compatibility.
pub fn validate_path(
&self,
req_id: &str,
path: &Path,
) -> Result<std::path::PathBuf, crate::protocol::Response> {
let config = self.config();
// When restrict_to_project_root is false (default), allow all paths
if !config.restrict_to_project_root {
return Ok(path.to_path_buf());
}
let root = match &config.project_root {
Some(r) => r.clone(),
None => return Ok(path.to_path_buf()), // No root configured, allow all
};
drop(config);
// Resolve the path (follow symlinks, normalize ..)
let resolved = std::fs::canonicalize(path)
.unwrap_or_else(|_| resolve_with_existing_ancestors(&normalize_path(path)));
let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
if !resolved.starts_with(&resolved_root) {
return Err(crate::protocol::Response::error(
req_id,
"path_outside_root",
format!(
"path '{}' is outside the project root '{}'",
path.display(),
resolved_root.display()
),
));
}
Ok(resolved)
}
/// Count active LSP server instances.
pub fn lsp_server_count(&self) -> usize {
self.lsp_manager
.try_borrow()
.map(|lsp| lsp.server_count())
.unwrap_or(0)
}
/// Symbol cache statistics from the language provider.
pub fn symbol_cache_stats(&self) -> serde_json::Value {
if let Some(tsp) = self
.provider
.as_any()
.downcast_ref::<crate::parser::TreeSitterProvider>()
{
let (local, warm) = tsp.symbol_cache_stats();
serde_json::json!({
"local_entries": local,
"warm_entries": warm,
})
} else {
serde_json::json!({
"local_entries": 0,
"warm_entries": 0,
})
}
}
}