1pub mod ast_edit;
16pub mod edit;
17pub mod fuzzy;
18#[cfg(test)]
19mod fuzzy_equivalence;
20pub mod read;
21pub mod replace;
22pub mod rollback;
23#[cfg(test)]
24mod test;
25pub mod types;
26pub mod write;
27
28use std::{path::PathBuf, sync::Arc, time::Duration};
29
30pub use edit::{EditBatchError, EditResult, edit};
31pub use read::{ReadResult, read};
32pub use rollback::{RollbackResult, rollback};
33pub use types::{
34 AstEditOp, EditTarget, FsAstEdit, FsContentEdit, FsEdit, FsMetadata, FsRead, FsRollback,
35 FsRollbackInput, FsWrite, LspNote, LspNotes, ReplaceEdit, Target, TargetFile,
36};
37pub use write::{WriteResult, write};
38
39use crate::{ToolDescription, lsp::Lsp};
40use cosh_sdk::lsp::lsp_types::DiagnosticSeverity;
41
42const LSP_SETTLE: Duration = Duration::from_secs(2);
44const LSP_REACTION: Duration = Duration::from_secs(15);
47const LSP_PULL_TIMEOUT_SECS: u64 = 10;
49const LSP_MAX_NOTES: usize = 20;
51
52pub struct Fs {
58 root: PathBuf,
59
60 allowlist: Option<Vec<PathBuf>>,
63 blocklist: Option<Vec<PathBuf>>,
64
65 read_allowlist: Option<Vec<PathBuf>>,
70 read_blocklist: Option<Vec<PathBuf>>,
72
73 lsp: Option<Arc<Lsp>>,
77
78 pub description_read: ToolDescription,
80 pub description_write: ToolDescription,
82 pub description_edit: ToolDescription,
84 pub description_edit_lines: ToolDescription,
86 pub description_ast_edit: ToolDescription,
88 pub description_rollback: ToolDescription,
90}
91
92impl Default for Fs {
93 fn default() -> Self {
94 Self::new()
95 }
96}
97
98impl Fs {
99 #[must_use]
103 pub fn new() -> Self {
104 Self {
105 root: PathBuf::new(),
106 allowlist: None,
107 blocklist: None,
108 read_allowlist: None,
109 read_blocklist: None,
110 lsp: None,
111 description_read: serde_json::json!({
112 "name": "fs_read",
113 "description": concat!(
114 "Read one file, named symbols, or a range of lines from ",
115 "the project. The call can specify: a path with an optional ",
116 "`offset` (1-based line number to start reading from — reads ",
117 "the syntactic block containing that line when used without ",
118 "`limit`), an optional `limit` (number of lines to read; only ",
119 "provide together with `offset`, if the file is too large to ",
120 "read at once), or an optional `symbol` name (function, class, ",
121 "variable) to look up. `symbol` takes precedence over ",
122 "`offset`/`limit`. Every result carries a \u{00b6}path#TAG ",
123 "header; lines are numbered `N| text` so edits can anchor ",
124 "directly. After an fs_edit the response also carries the ",
125 "updated header — you only need to re-read when you want ",
126 "to SEE new content, not to edit again. Blocks larger ",
127 "than 24 lines are elided to their head/tail with a footer ",
128 "columns are truncated with `...` and flagged. After a range ",
129 "read a footer reports how many lines remain and how to ",
130 "continue — read only what you need instead of whole files."
131 ),
132 "inputSchema": {
133 "type": "object",
134 "additionalProperties": false,
135 "properties": {
136 "path": {
137 "type": "string",
138 "description": "Path to the file to read, relative to the project root"
139 },
140 "offset": {
141 "type": "integer",
142 "description": concat!(
143 "Optional 1-based line number to start reading from. Only ",
144 "provide if the file is too large to read at once. Without ",
145 "`limit`, reads the syntactic block containing that line; ",
146 "with `limit`, reads exactly offset..offset+limit-1"
147 )
148 },
149 "limit": {
150 "type": "integer",
151 "description": concat!(
152 "Optional number of lines to read. Only provide together ",
153 "with `offset`, if the file is too large to read at once"
154 )
155 },
156 "symbol": {
157 "type": "string",
158 "description": concat!(
159 "Optional symbol name to look up ",
160 "(function, class, variable) within the file"
161 )
162 }
163 },
164 "required": ["path"]
165 }
166 }),
167 description_write: serde_json::json!({
168 "name": "fs_write",
169 "description": concat!(
170 "Write content to one file. Creates new files or overwrites ",
171 "existing ones entirely.\n\n",
172 "IMPORTANT: When overwriting an existing file, you MUST include the ",
173 "`file_hash` from a previous `fs_read` call. This proves you have ",
174 "read the file before overwriting it. If you omit `file_hash` on an ",
175 "existing file, the write will be rejected. For new files (that do ",
176 "not yet exist), `file_hash` is not needed."
177 ),
178 "inputSchema": {
179 "type": "object",
180 "properties": {
181 "path": {
182 "type": "string",
183 "description": "Path to the file to write, relative to the project root"
184 },
185 "content": {
186 "type": "string",
187 "description": "Full text content to write to the file"
188 },
189 "file_hash": {
190 "type": ["string", "null"],
191 "description": concat!(
192 "4-hex content hash tag from `fs_read` OR a previous ",
193 "fs_edit result (the \u{00B6}path#TAG header). REQUIRED when ",
194 "overwriting an existing file to prove you have read its ",
195 "current content. Omit or set to null for new files."
196 )
197 }
198 },
199 "required": ["path", "content"]
200 }
201 }),
202 description_edit: Self::description_edit(),
203 description_edit_lines: Self::description_edit_lines(),
204 description_ast_edit: Self::description_edit_ast(),
205 description_rollback: serde_json::json!({
206 "name": "fs_rollback",
207 "description": concat!(
208 "Roll back a file to a previously recorded session version: ",
209 "restore the state identified by the given `hash` (a 4-hex tag ",
210 "from an earlier fs_read/fs_edit/rollback result for that path). ",
211 "Pass an empty `hash` to restore the version immediately ",
212 "preceding the current content. Returns an error if the path ",
213 "has no rollback history, if the `hash` is not found in it, or ",
214 "if the file was modified externally since the snapshot."
215 ),
216 "inputSchema": {
217 "type": "object",
218 "properties": {
219 "path": {
220 "type": "string",
221 "description": "Path to the file to roll back, relative to the project root"
222 },
223 "hash": {
224 "type": "string",
225 "description": "Session hash identifying which version to restore"
226 }
227 },
228 "required": ["path", "hash"]
229 }
230 }),
231 }
232 }
233
234 #[must_use]
238 pub fn with_lsp(mut self, lsp: Arc<Lsp>) -> Self {
239 self.lsp = Some(lsp);
240 self
241 }
242
243 #[must_use]
246 pub fn without_lsp(&self) -> FsCall<'_> {
247 FsCall {
248 fs: self,
249 lsp: None,
250 include_warnings: false,
251 }
252 }
253
254 #[must_use]
257 pub fn warnings(&self) -> FsCall<'_> {
258 FsCall {
259 fs: self,
260 lsp: self.lsp.as_ref(),
261 include_warnings: true,
262 }
263 }
264
265 #[must_use]
267 pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
268 self.root = path.into();
269 self
270 }
271
272 #[must_use]
274 pub fn allowlist(mut self, paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
275 self.allowlist = Some(paths.into_iter().map(Into::into).collect());
276 self
277 }
278
279 #[must_use]
281 pub fn blocklist(mut self, paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
282 self.blocklist = Some(paths.into_iter().map(Into::into).collect());
283 self
284 }
285
286 #[must_use]
288 pub fn read_allowlist(mut self, paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
289 self.read_allowlist = Some(paths.into_iter().map(Into::into).collect());
290 self
291 }
292
293 #[must_use]
295 pub fn read_blocklist(mut self, paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
296 self.read_blocklist = Some(paths.into_iter().map(Into::into).collect());
297 self
298 }
299
300 fn metadata(&self) -> FsMetadata {
302 FsMetadata {
303 root: self.root.clone(),
304 allowlist: self.allowlist.clone(),
305 blocklist: self.blocklist.clone(),
306 }
307 }
308
309 fn read_metadata(&self) -> FsMetadata {
314 FsMetadata {
315 root: self.root.clone(),
316 allowlist: self
317 .read_allowlist
318 .clone()
319 .or_else(|| self.allowlist.clone()),
320 blocklist: self
321 .read_blocklist
322 .clone()
323 .or_else(|| self.blocklist.clone()),
324 }
325 }
326
327 pub async fn read(&self, targets: Vec<Target>) -> Vec<ReadResult> {
332 self.read_op(self.lsp.as_ref(), targets).await
333 }
334
335 async fn read_op(&self, lsp: Option<&Arc<Lsp>>, targets: Vec<Target>) -> Vec<ReadResult> {
336 if let Some(lsp) = lsp {
337 for t in &targets {
338 Self::warm_lsp(lsp, &self.root, &t.path).await;
339 }
340 }
341 read(self.read_metadata(), FsRead { targets }).await
342 }
343
344 pub async fn write(&self, targets: Vec<TargetFile>) -> Result<Vec<WriteResult>, String> {
352 self.write_op(self.lsp.as_ref(), false, targets).await
353 }
354
355 async fn write_op(
356 &self,
357 lsp: Option<&Arc<Lsp>>,
358 include_warnings: bool,
359 targets: Vec<TargetFile>,
360 ) -> Result<Vec<WriteResult>, String> {
361 let mut results = write(self.metadata(), FsWrite { targets }).await?;
362 if let Some(lsp) = lsp {
363 for result in &mut results {
364 result.lsp_notes =
365 Self::passive_notes(lsp, &self.root, &result.path, include_warnings).await;
366 }
367 }
368 Ok(results)
369 }
370
371 pub async fn edit(&self, args: serde_json::Value) -> Result<Vec<EditResult>, String> {
381 self.edit_op(self.lsp.as_ref(), false, args).await
382 }
383
384 pub async fn edit_lines(&self, args: serde_json::Value) -> Result<Vec<EditResult>, String> {
396 self.edit_lines_op(self.lsp.as_ref(), false, args).await
397 }
398
399 pub async fn edit_ast(&self, args: serde_json::Value) -> Result<Vec<EditResult>, String> {
409 self.edit_ast_op(self.lsp.as_ref(), false, args).await
410 }
411
412 async fn edit_op(
413 &self,
414 lsp: Option<&Arc<Lsp>>,
415 include_warnings: bool,
416 args: serde_json::Value,
417 ) -> Result<Vec<EditResult>, String> {
418 let metadata = self.metadata();
419 let mut results = self.edit_content(&metadata, &args).await?;
420 Self::attach_edit_notes(self, lsp, include_warnings, &mut results).await;
421 Ok(results)
422 }
423
424 async fn edit_lines_op(
425 &self,
426 lsp: Option<&Arc<Lsp>>,
427 include_warnings: bool,
428 args: serde_json::Value,
429 ) -> Result<Vec<EditResult>, String> {
430 let metadata = self.metadata();
431 let mut results = self.edit_replace(&metadata, &args).await?;
432 Self::attach_edit_notes(self, lsp, include_warnings, &mut results).await;
433 Ok(results)
434 }
435
436 async fn edit_ast_op(
437 &self,
438 lsp: Option<&Arc<Lsp>>,
439 include_warnings: bool,
440 args: serde_json::Value,
441 ) -> Result<Vec<EditResult>, String> {
442 let metadata = self.metadata();
443 let mut results = self.edit_ast_engine(&metadata, &args).await?;
444 Self::attach_edit_notes(self, lsp, include_warnings, &mut results).await;
445 Ok(results)
446 }
447
448 async fn attach_edit_notes(
451 &self,
452 lsp: Option<&Arc<Lsp>>,
453 include_warnings: bool,
454 results: &mut [EditResult],
455 ) {
456 let Some(lsp) = lsp else { return };
457 for result in results {
458 if result.dry_run == Some(true) {
459 continue;
460 }
461 result.lsp_notes =
462 Self::passive_notes(lsp, &self.root, &result.path, include_warnings).await;
463 }
464 }
465
466 pub async fn replace_edits(&self, edits: Vec<ReplaceEdit>) -> Result<Vec<EditResult>, String> {
476 self.replace_edits_op(self.lsp.as_ref(), false, edits).await
477 }
478
479 async fn replace_edits_op(
480 &self,
481 lsp: Option<&Arc<Lsp>>,
482 include_warnings: bool,
483 edits: Vec<ReplaceEdit>,
484 ) -> Result<Vec<EditResult>, String> {
485 let metadata = self.metadata();
486 let mut results = replace::content_edit(&metadata, &edits, false)
487 .await
488 .map_err(|e| e.to_string())?;
489 if let Some(lsp) = lsp {
490 for result in &mut results {
491 result.lsp_notes =
492 Self::passive_notes(lsp, &self.root, &result.path, include_warnings).await;
493 }
494 }
495 Ok(results)
496 }
497
498 fn description_edit_lines() -> ToolDescription {
503 serde_json::json!({
504 "name": "fs_edit_lines",
505 "description": concat!(
506 "Apply targeted line/block edits to ONE file per call. Edits are ",
507 "anchored by the file's content hash for safety. ",
508 "Supports replace, delete, insert (before/after/head/tail), and ",
509 "syntactic block operations. A successful edit returns the ",
510 "updated \u{00B6}path#TAG header — use it directly for follow-up ",
511 "edits on the same file without re-reading. ",
512 "Example: {\"path\": \"src/main.rs\", \"file_hash\": \"3C4D\", ",
513 "\"ops\": \"replace 5..7:\\n+fn hello() {\\n+ println!(\\\"hi\\\");\\n+}\"}"
514 ),
515 "inputSchema": {
516 "type": "object",
517 "properties": {
518 "path": {
519 "type": "string",
520 "description": "Path to the file to edit, relative to the project root"
521 },
522 "file_hash": {
523 "type": "string",
524 "description": concat!(
525 "4-hex content hash tag: the \u{00B6}path#TAG anchor. ",
526 "Get it from your last fs_read OR from a previous ",
527 "fs_edit result (the `header` field carries the ",
528 "updated tag). Copy verbatim."
529 )
530 },
531 "ops": {
532 "type": "string",
533 "description": concat!(
534 "Hashline edit operations. Each operation is on its own line:\n",
535 "- replace N..M: replace lines N through M with new content\n",
536 " (prefix each replacement line with +)\n",
537 "- delete N..M delete lines N through M\n",
538 "- insert before N: insert lines before line N\n",
539 "- insert after N: insert lines after line N\n",
540 "- insert head: insert at start of file\n",
541 "- insert tail: insert at end of file\n",
542 "- replace block N: replace syntactic block at line N\n",
543 "Example:\n",
544 " replace 5..7:\n",
545 " +fn hello() {\n",
546 " + println!(\"hi\");\n",
547 " +}\n",
548 " delete 10..12\n",
549 " insert after 15:\n",
550 " +// new comment"
551 )
552 },
553 "dry_run": {
554 "type": "boolean",
555 "description": concat!(
556 "Preview mode: the edit is applied in memory only and the ",
557 "result carries the unified diff plus the syntax-probe ",
558 "verdict without writing anything. Re-issue without dry_run ",
559 "to apply the exact same edit."
560 )
561 }
562 },
563 "required": ["path", "file_hash", "ops"]
564 }
565 })
566 }
567
568 fn description_edit_ast() -> ToolDescription {
570 serde_json::json!({
571 "name": "fs_ast_edit",
572 "description": concat!(
573 "Apply structural syntax-tree edits to ONE file per call. ",
574 "Rewrite each match of a pattern (`pat`) to a template (`out`). ",
575 "Patterns must be full, structurally valid source snippets; they support ",
576 "metavariables $NAME (matches exactly one node) and $$$NAME (matches zero or ",
577 "more nodes, e.g. an argument list), and the same metavariable must capture ",
578 "identical text in every occurrence to match. A string literal pattern must ",
579 "include its quotes, and a metavariable captures a whole node — it does not ",
580 "match a substring inside a literal. So to change the text of a string, match ",
581 "the whole literal. ",
582 "Example: {\"path\": \"src/app.js\", \"ops\": [{\"pat\": ",
583 "\"console.log(\\\"$M\\\")\", \"out\": \"console.log([\\\"$M\\'])\"}]}",
584 " A successful edit returns the updated \u{00B6}path#TAG header — ",
585 "use it directly for follow-up edits on the same file without ",
586 "re-reading."
587 ),
588 "inputSchema": {
589 "type": "object",
590 "properties": {
591 "path": {
592 "type": "string",
593 "description": "Path to the file to edit, relative to the project root"
594 },
595 "ops": {
596 "type": "array",
597 "description": "Rewrite ops applied in order",
598 "items": {
599 "type": "object",
600 "properties": {
601 "pat": {
602 "type": "string",
603 "description": concat!(
604 "Complete, structurally valid AST pattern. ",
605 "Supports metavariables $NAME (exactly one node) ",
606 "and $$$NAME (zero or more nodes, e.g. an argument ",
607 "list); the same metavariable must capture identical ",
608 "text. Include quotes around string literals; a ",
609 "metavariable matches a whole node, never a substring ",
610 "inside a literal."
611 )
612 },
613 "out": {
614 "type": "string",
615 "description": "Replacement template; captured metavariables may be referenced"
616 }
617 },
618 "required": ["pat", "out"]
619 }
620 }
621 },
622 "required": ["path", "ops"]
623 }
624 })
625 }
626
627 fn description_edit() -> ToolDescription {
633 serde_json::json!({
634 "name": "fs_edit",
635 "description": concat!(
636 "Performs exact string replacement in a file: replaces an ",
637 "exact `old_string` (which must occur exactly once, unless ",
638 "`replace_all`) with `new_string`. Use it when the text ",
639 "itself identifies the location, with the smallest unique ",
640 "snippet. Copy `old_string` verbatim including whitespace ",
641 "and newlines. A successful edit returns the updated ",
642 "\u{00B6}path#TAG header — use it directly for follow-up edits ",
643 "on the same file without re-reading. ",
644 "Example: {\"path\": \"src/main.rs\", \"file_hash\": \"3C4D\", ",
645 "\"old_string\": \"old_computation(x)\", \"new_string\": ",
646 "\"new_computation(x)\"}"
647 ),
648 "inputSchema": {
649 "type": "object",
650 "properties": {
651 "path": {
652 "type": "string",
653 "description": "Path to the file to edit, relative to the project root"
654 },
655 "file_hash": {
656 "type": "string",
657 "description": concat!(
658 "4-hex content hash tag: the \u{00B6}path#TAG anchor ",
659 "from your last read of this file (or a previous ",
660 "edit result's `header`). Copy verbatim. Optional ",
661 "for the first edit of the call: follow-up edits ",
662 "to the same file chain on the fresh tag produced ",
663 "by the previous edit."
664 )
665 },
666 "old_string": {
667 "type": "string",
668 "description": "The exact text to replace"
669 },
670 "new_string": {
671 "type": "string",
672 "description": concat!(
673 "The text to replace it with (empty deletes the ",
674 "matched text)"
675 )
676 },
677 "replace_all": {
678 "type": "boolean",
679 "description": concat!(
680 "Replace all occurrences of old_string ",
681 "(default false)"
682 )
683 },
684 "dry_run": {
685 "type": "boolean",
686 "description": concat!(
687 "Preview mode: the edit is applied in memory only and the ",
688 "result carries the unified diff plus the syntax-probe ",
689 "verdict (a warning when the edit would break parsing) ",
690 "without writing anything. Re-issue without dry_run to ",
691 "apply the exact same edit."
692 )
693 }
694 },
695 "required": ["path", "old_string", "new_string"]
696 }
697 })
698 }
699
700 async fn edit_replace(
701 &self,
702 metadata: &FsMetadata,
703 args: &serde_json::Value,
704 ) -> Result<Vec<EditResult>, String> {
705 let dry_run = args
706 .get("dry_run")
707 .and_then(serde_json::Value::as_bool)
708 .unwrap_or(false);
709 let targets_value = if let Some(t) = args.get("targets") {
710 t.clone()
712 } else if args.get("path").is_some() && args.get("ops").is_some() {
713 serde_json::Value::Array(vec![args.clone()])
715 } else {
716 return Err(
717 "the replace engine requires {path, file_hash, ops} — one file per call"
718 .to_string(),
719 );
720 };
721 let targets: Vec<EditTarget> = serde_json::from_value(targets_value)
722 .map_err(|e| format!("invalid `path`/`file_hash`/`ops` for the replace engine: {e}"))?;
723 edit(metadata.clone(), FsEdit { targets, dry_run })
724 .await
725 .map_err(|e| e.to_string())
726 }
727
728 async fn edit_ast_engine(
731 &self,
732 metadata: &FsMetadata,
733 args: &serde_json::Value,
734 ) -> Result<Vec<EditResult>, String> {
735 let ast_value = if let Some(ast) = args.get("ast") {
739 ast.clone()
740 } else if args.get("ops").is_some_and(|v| v.is_array()) {
741 let mut flat = args.clone();
742 if let Some(path) = flat.get("path").cloned()
743 && let Some(obj) = flat.as_object_mut()
744 {
745 obj.insert("paths".into(), serde_json::json!([path]));
746 }
747 flat
748 } else {
749 return Err(
750 "the AST engine requires {path, ops: [{pat, out}]} — one file per call".to_string(),
751 );
752 };
753 let fs_ast: FsAstEdit = serde_json::from_value(ast_value)
754 .map_err(|e| format!("invalid `ops`/`path` arguments for the AST engine: {e}"))?;
755 crate::fs::ast_edit::ast_edit(metadata.clone(), fs_ast).await
756 }
757
758 async fn edit_content(
759 &self,
760 metadata: &FsMetadata,
761 args: &serde_json::Value,
762 ) -> Result<Vec<EditResult>, String> {
763 let edits_value = if let Some(arr) = args.get("edits").filter(|v| v.is_array()) {
767 arr.clone()
768 } else if args.get("old_string").is_some() {
769 serde_json::Value::Array(vec![args.clone()])
770 } else {
771 return Err(
772 "the content replace engine requires {path, file_hash?, old_string, \
773 new_string, replace_all?} — one file per call"
774 .to_string(),
775 );
776 };
777 let dry_run = args
778 .get("dry_run")
779 .and_then(serde_json::Value::as_bool)
780 .unwrap_or(false);
781 let fs_edits: FsContentEdit =
782 serde_json::from_value(serde_json::json!({ "edits": edits_value, "dry_run": dry_run }))
783 .map_err(|e| {
784 format!("invalid edit arguments for the content replace engine: {e}")
785 })?;
786 replace::content_edit(metadata, &fs_edits.edits, fs_edits.dry_run)
787 .await
788 .map_err(|e| e.to_string())
789 }
790
791 pub async fn rollback(&self, path: &str, hash: &str) -> Result<RollbackResult, String> {
800 self.rollback_op(self.lsp.as_ref(), false, path, hash).await
801 }
802
803 async fn rollback_op(
804 &self,
805 lsp: Option<&Arc<Lsp>>,
806 include_warnings: bool,
807 path: &str,
808 hash: &str,
809 ) -> Result<RollbackResult, String> {
810 let mut result = rollback(&FsRollback, self.metadata(), path, hash).await?;
811 if let Some(lsp) = lsp {
812 result.lsp_notes =
813 Self::passive_notes(lsp, &self.root, &result.path, include_warnings).await;
814 }
815 Ok(result)
816 }
817
818 async fn warm_lsp(lsp: &Lsp, root: &std::path::Path, rel: &str) {
821 let path = root.join(rel);
822 if let Ok(handles) = lsp.manager().ensure_for_file(&path).await {
823 for handle in &handles {
824 if let Err(err) = handle.touch_file(&path).await {
825 log::debug!("lsp warm-up of `{}` failed: {err}", path.display());
826 }
827 }
828 }
829 }
830
831 async fn passive_notes(
836 lsp: &Lsp,
837 root: &std::path::Path,
838 rel: &str,
839 include_warnings: bool,
840 ) -> Option<LspNotes> {
841 let path = root.join(rel);
842 let display = std::path::Path::new(rel)
843 .strip_prefix(root)
844 .unwrap_or(std::path::Path::new(rel))
845 .display()
846 .to_string();
847
848 let Ok(handles) = lsp.manager().ensure_for_file(&path).await else {
849 return None;
850 };
851 for handle in &handles {
852 if let Err(err) = handle.touch_file(&path).await {
853 log::debug!(
854 "passive diagnostics: could not open `{}` on {}: {err}",
855 path.display(),
856 handle.name()
857 );
858 }
859 if let Err(err) = handle.save_file(&path) {
864 log::debug!(
865 "passive diagnostics: could not save `{}` on {}: {err}",
866 path.display(),
867 handle.name()
868 );
869 }
870 }
871
872 let engine = lsp.diagnostics_engine();
873
874 let pull_deadline = std::time::Instant::now() + LSP_REACTION;
887 for handle in &handles {
888 loop {
889 match handle
890 .pull_diagnostics(&path, Duration::from_secs(LSP_PULL_TIMEOUT_SECS))
891 .await
892 {
893 Ok(Some(params)) => {
894 let pull_source = format!("{}/pull", handle.name());
900 engine.ingest(&pull_source, ¶ms);
901 break;
902 }
903 Ok(None) => break,
904 Err(cosh_sdk::lsp::LspError::Rpc { code: -32802, .. })
905 if std::time::Instant::now() < pull_deadline =>
906 {
907 tokio::time::sleep(Duration::from_millis(300)).await;
908 }
909 Err(err) => {
910 log::debug!(
911 "passive diagnostics: pull from {} failed: {err}",
912 handle.name()
913 );
914 break;
915 }
916 }
917 }
918 }
919
920 engine.wait_for_settle(LSP_SETTLE).await;
922
923 let mut notes = LspNotes::default();
924 for diag in engine.snapshot_for(&path) {
925 let note = LspNote {
926 path: display.clone(),
927 line: diag.range.start.line + 1,
928 message: diag.message,
929 source: diag.source,
930 };
931 if diag.severity.is_none_or(|s| s <= DiagnosticSeverity::ERROR) {
932 if notes.errors.len() < LSP_MAX_NOTES {
933 notes.errors.push(note);
934 }
935 } else if diag.severity == Some(DiagnosticSeverity::WARNING)
936 && include_warnings
937 && notes.warnings.len() < LSP_MAX_NOTES
938 {
939 notes.warnings.push(note);
940 }
941 }
942
943 (!notes.errors.is_empty() || !notes.warnings.is_empty()).then_some(notes)
944 }
945
946 #[must_use]
948 pub const fn root(&self) -> &PathBuf {
949 &self.root
950 }
951
952 #[must_use]
954 pub fn allowlist_ref(&self) -> Option<&[PathBuf]> {
955 self.allowlist.as_deref()
956 }
957
958 #[must_use]
960 pub fn blocklist_ref(&self) -> Option<&[PathBuf]> {
961 self.blocklist.as_deref()
962 }
963
964 pub fn add_allowlist_path(&mut self, path: PathBuf) {
968 let list = self.allowlist.get_or_insert_with(Vec::new);
969 if !list.contains(&path) {
970 list.push(path);
971 }
972 }
973
974 pub fn remove_allowlist_path(&mut self, path: &std::path::Path) {
978 if let Some(list) = self.allowlist.as_mut() {
979 list.retain(|p| p.as_path() != path);
980 }
981 if let Some(list) = self.read_allowlist.as_mut() {
982 list.retain(|p| p.as_path() != path);
983 }
984 }
985}
986
987pub struct FsCall<'a> {
994 fs: &'a Fs,
995 lsp: Option<&'a Arc<Lsp>>,
996 include_warnings: bool,
997}
998
999impl FsCall<'_> {
1000 pub async fn read(&self, targets: Vec<Target>) -> Vec<ReadResult> {
1002 self.fs.read_op(self.lsp, targets).await
1003 }
1004
1005 pub async fn write(&self, targets: Vec<TargetFile>) -> Result<Vec<WriteResult>, String> {
1011 self.fs
1012 .write_op(self.lsp, self.include_warnings, targets)
1013 .await
1014 }
1015
1016 pub async fn edit(&self, args: serde_json::Value) -> Result<Vec<EditResult>, String> {
1022 self.fs.edit_op(self.lsp, self.include_warnings, args).await
1023 }
1024
1025 pub async fn edit_lines(&self, args: serde_json::Value) -> Result<Vec<EditResult>, String> {
1031 self.fs
1032 .edit_lines_op(self.lsp, self.include_warnings, args)
1033 .await
1034 }
1035
1036 pub async fn edit_ast(&self, args: serde_json::Value) -> Result<Vec<EditResult>, String> {
1042 self.fs
1043 .edit_ast_op(self.lsp, self.include_warnings, args)
1044 .await
1045 }
1046
1047 pub async fn rollback(&self, path: &str, hash: &str) -> Result<RollbackResult, String> {
1053 self.fs
1054 .rollback_op(self.lsp, self.include_warnings, path, hash)
1055 .await
1056 }
1057}