patchloom 0.34.0

Structured file editing library and CLI for AI agents: parser-backed JSON/YAML/TOML edits, AST-aware code operations, multi-file batching, markdown operations, and MCP server
Documentation
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
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
//! AST-aware symbol rename: replace only identifier nodes, skipping
//! strings, comments, and documentation.

use std::path::Path;

use super::{Language, ParseFailure, try_parse_source};

/// Node kinds that represent identifier tokens (rename targets).
const IDENTIFIER_KINDS: &[&str] = &[
    "identifier",
    "type_identifier",
    "field_identifier",
    "property_identifier",
    "simple_identifier",
    "shorthand_field_identifier",
    // Shell/Bash: function names and command names are `word` nodes
    "word",
];

/// Node kinds whose subtrees should be skipped entirely during rename.
///
/// Container kinds like `template_string`, `string`, and `concatenated_string`
/// are intentionally absent so the traversal enters them and finds identifiers
/// inside interpolation expressions (`${foo}` in JS/TS, `{foo}` in Python
/// f-strings, `#{foo}` in Ruby). The leaf text kinds below still prevent
/// renaming inside literal text fragments.
const SKIP_KINDS: &[&str] = &[
    "string_literal",
    "raw_string_literal",
    "string_content",
    "string_fragment",
    "line_comment",
    "block_comment",
    "comment",
    "doc_comment",
    // Python
    "string_start",
    "string_end",
];

/// Result of an AST-aware rename operation.
#[derive(Debug)]
pub struct RenameResult {
    /// The new file content after renaming.
    pub content: String,
    /// Number of replacements made.
    pub replacements: usize,
}

/// Rename all identifier occurrences of `old_name` to `new_name` in source code,
/// skipping strings and comments.
///
/// Distinguishes a parse deadline from a missing grammar: timeout is
/// [`crate::exit::ParseTimeoutError`]; unknown languages return `Ok(None)`
/// so callers may still word-boundary-fallback.
///
/// Empty `old` / `new` must be refused before the word-boundary fallback.
/// Library [`crate::api::ast_rename`] already peels; CLI/tx/MCP used to
/// apply `--new ''` as `fn ()`.
#[cfg_attr(
    not(any(feature = "cli", feature = "files", feature = "mcp")),
    allow(dead_code)
)]
pub(crate) fn reject_empty_rename_names(old: &str, new: &str) -> anyhow::Result<()> {
    if old.trim().is_empty() || new.trim().is_empty() {
        return Err(anyhow::Error::new(crate::exit::InvalidInputError {
            msg: "ast rename old/new must not be empty".into(),
        }));
    }
    Ok(())
}

pub(crate) fn try_rename_in_source(
    source: &str,
    old_name: &str,
    new_name: &str,
    lang: Language,
) -> anyhow::Result<Option<RenameResult>> {
    let (tree, _) = match try_parse_source(source, lang) {
        Ok(parsed) => parsed,
        Err(ParseFailure::DeadlineExceeded) => {
            return Err(crate::exit::ParseTimeoutError {
                msg: format!("parse deadline exceeded for {lang}"),
            }
            .into());
        }
        Err(ParseFailure::NoGrammar) => return Ok(None),
    };

    // Collect byte ranges to replace (in reverse order for offset stability)
    let mut replacements = Vec::new();
    collect_rename_nodes(tree.root_node(), source, old_name, &mut replacements);

    // Sort by start byte descending so we can replace from end to start
    replacements.sort_by_key(|&(start, _)| std::cmp::Reverse(start));

    let replacements_count = replacements.len();
    let mut result = source.to_string();
    for (start, end) in &replacements {
        result.replace_range(*start..*end, new_name);
    }

    Ok(Some(RenameResult {
        content: result,
        replacements: replacements_count,
    }))
}

/// Rename all identifier occurrences of `old_name` to `new_name` in source code,
/// skipping strings and comments.
///
/// Returns `None` if the language has no grammar, parsing fails, or the
/// parse deadline fires. Prefer [`try_rename_in_source`] when timeout must
/// fail closed instead of looking like a missing grammar.
pub fn rename_in_source(
    source: &str,
    old_name: &str,
    new_name: &str,
    lang: Language,
) -> Option<RenameResult> {
    try_rename_in_source(source, old_name, new_name, lang)
        .ok()
        .flatten()
}

/// One parse: AST rename match, else word-boundary. Timeout fails closed
/// so the fallback cannot select the file (#2432).
#[cfg_attr(not(any(feature = "cli", feature = "mcp")), allow(dead_code))]
pub(crate) fn source_has_rename_match(
    source: &str,
    old_name: &str,
    new_name: &str,
    lang: Language,
) -> anyhow::Result<bool> {
    let has_ast = match try_rename_in_source(source, old_name, new_name, lang) {
        Err(e) if crate::exit::is_parse_timeout(&e) => return Err(e),
        Ok(Some(r)) => r.replacements > 0,
        Ok(None) | Err(_) => false,
    };
    if has_ast {
        return Ok(true);
    }
    Ok(
        crate::ops::replace::compile_replace_regex(old_name, false, false, false, true)
            .ok()
            .flatten()
            .is_some_and(|re| re.is_match(source)),
    )
}

/// Rename identifiers in a file. Falls back to word-boundary replace if
/// tree-sitter cannot parse the language.
pub fn rename_in_file(
    path: &Path,
    old_name: &str,
    new_name: &str,
    lang_hint: Option<Language>,
) -> anyhow::Result<Option<RenameResult>> {
    let lang = lang_hint.unwrap_or_else(|| Language::from_path(path));
    // Strict sole-path (#1894): binary / invalid UTF-8 → Binary / InvalidEncoding.
    let source = crate::files::load_text_strict(path, &path.display().to_string())?;
    Ok(rename_in_source(&source, old_name, new_name, lang))
}

fn collect_rename_nodes(
    node: tree_sitter_lib::Node,
    source: &str,
    old_name: &str,
    results: &mut Vec<(usize, usize)>,
) {
    // Skip string/comment subtrees entirely
    if SKIP_KINDS.contains(&node.kind()) {
        return;
    }

    // Check if this is a matching identifier node
    if IDENTIFIER_KINDS.contains(&node.kind())
        && let Ok(text) = node.utf8_text(source.as_bytes())
        && text == old_name
    {
        results.push((node.start_byte(), node.end_byte()));
        return; // Leaf node, no children to visit
    }

    // Recurse into children
    let mut cursor = node.walk();
    if cursor.goto_first_child() {
        loop {
            collect_rename_nodes(cursor.node(), source, old_name, results);
            if !cursor.goto_next_sibling() {
                break;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rename_rust_identifier_skips_strings() {
        let source = r#"
fn setup_file() -> &str {
    let name = "setup_file";
    // setup_file is important
    setup_file
}
"#;
        let result = rename_in_source(source, "setup_file", "init_file", Language::Rust).unwrap();
        // Should rename the function name and the trailing expression
        // but NOT the string literal or comment
        assert!(result.content.contains("fn init_file()"));
        assert!(result.content.contains("\"setup_file\"")); // string untouched
        assert!(result.content.contains("// setup_file")); // comment untouched
        assert!(result.replacements >= 2);
    }

    #[test]
    fn rename_rust_type_identifier() {
        let source = r#"
struct SetupFile {
    name: String,
}

impl SetupFile {
    fn new() -> SetupFile {
        SetupFile { name: String::new() }
    }
}
"#;
        let result = rename_in_source(source, "SetupFile", "ConfigFile", Language::Rust).unwrap();
        assert!(result.content.contains("struct ConfigFile"));
        assert!(result.content.contains("impl ConfigFile"));
        assert!(result.content.contains("-> ConfigFile"));
        assert!(!result.content.contains("SetupFile"));
    }

    #[test]
    fn rename_python_skips_strings_and_comments() {
        let source = r#"
def setup_file():
    """setup_file docs"""
    name = "setup_file"
    # setup_file comment
    return setup_file()
"#;
        let result = rename_in_source(source, "setup_file", "init_file", Language::Python).unwrap();
        assert!(result.content.contains("def init_file():"));
        // Strings and comments should be untouched
        assert!(result.content.contains("\"setup_file\""));
        assert!(result.content.contains("# setup_file"));
    }

    #[test]
    fn rename_no_matches_returns_zero() {
        let source = "fn main() {}\n";
        let result =
            rename_in_source(source, "nonexistent", "replacement", Language::Rust).unwrap();
        assert_eq!(result.replacements, 0);
        assert_eq!(result.content, source);
    }

    #[test]
    fn rename_unknown_language_returns_none() {
        let result = rename_in_source("anything", "x", "y", Language::Unknown);
        assert!(result.is_none());
    }

    #[test]
    fn rename_typescript_identifier_skips_strings() {
        let source = r#"
function processItem(item: string): string {
    const label = "processItem handler";
    // processItem does the work
    console.log(item);
    return processItem(item.trim());
}

class Worker {
    processItem(data: any): void {
        const result = processItem(data);
        console.log(result);
    }
}
"#;
        let result =
            rename_in_source(source, "processItem", "handleItem", Language::TypeScript).unwrap();
        // Function declaration and calls should be renamed
        assert!(result.content.contains("function handleItem("));
        assert!(result.content.contains("return handleItem("));
        // String literal and comment should NOT be renamed
        assert!(result.content.contains("\"processItem handler\""));
        assert!(result.content.contains("// processItem"));
        assert!(result.replacements >= 3);
    }

    #[test]
    fn rename_typescript_type_identifier() {
        let source = r#"
interface UserConfig {
    name: string;
    age: number;
}

function createConfig(): UserConfig {
    const cfg: UserConfig = { name: "test", age: 30 };
    return cfg;
}
"#;
        let result =
            rename_in_source(source, "UserConfig", "AppConfig", Language::TypeScript).unwrap();
        assert!(result.content.contains("interface AppConfig"));
        assert!(result.content.contains("): AppConfig"));
        assert!(result.content.contains("cfg: AppConfig"));
        assert!(!result.content.contains("UserConfig"));
    }

    #[test]
    fn rename_java_method_skips_strings() {
        let source = r#"
public class Service {
    public void processOrder(String id) {
        System.out.println("processOrder called");
        // processOrder handles business logic
        String result = processOrder(id);
    }

    private String processOrder(int count) {
        return "done";
    }
}
"#;
        let result =
            rename_in_source(source, "processOrder", "handleOrder", Language::Java).unwrap();
        // Method declarations and calls should be renamed
        assert!(result.content.contains("void handleOrder("));
        assert!(result.content.contains("String handleOrder("));
        // String literal and comment should NOT be renamed
        assert!(result.content.contains("\"processOrder called\""));
        assert!(result.content.contains("// processOrder"));
        assert!(result.replacements >= 3);
    }

    #[test]
    fn rename_typescript_template_string_interpolation() {
        let source = r#"
function greet(name: string): string {
    return `Hello, ${name}! Welcome ${name}.`;
}
"#;
        let result = rename_in_source(source, "name", "userName", Language::TypeScript).unwrap();
        // Identifiers inside ${} should be renamed
        assert!(result.content.contains("${userName}"));
        // But the function parameter should also be renamed
        assert!(result.content.contains("greet(userName:"));
        // The literal text "Hello, " should be untouched
        assert!(result.content.contains("Hello, "));
        assert!(result.replacements >= 3);
    }

    #[test]
    fn rename_python_fstring_interpolation() {
        let source = r#"
def greet(name):
    msg = "name is a label"
    return f"Hello, {name}!"
"#;
        let result = rename_in_source(source, "name", "user_name", Language::Python).unwrap();
        // The parameter and f-string interpolation should be renamed
        assert!(result.content.contains("def greet(user_name)"));
        assert!(result.content.contains("{user_name}"));
        // The regular string should NOT be renamed
        assert!(result.content.contains("\"name is a label\""));
    }

    #[test]
    fn rename_go_identifier() {
        let source = r#"
package main

func SetupFile() string {
    return "SetupFile"
}
"#;
        let result = rename_in_source(source, "SetupFile", "InitFile", Language::Go).unwrap();
        assert!(result.content.contains("func InitFile()"));
        assert!(result.content.contains("\"SetupFile\"")); // string untouched
    }

    /// Regression: bash/shell function names use `word` nodes in tree-sitter,
    /// not `identifier`. Without `word` in `IDENTIFIER_KINDS`, ast rename
    /// found 0 matches even though the function existed, and fell through
    /// to the "tree-sitter parsed successfully but 0 matches" error path.
    #[test]
    fn rename_bash_function() {
        let source = r#"#!/usr/bin/env bash
build_image() {
    docker build -t app .
}
push_image() {
    docker push app
}
main() {
    build_image
    push_image
}
"#;
        let result = rename_in_source(source, "build_image", "build_container", Language::Shell)
            .expect("shell parse should succeed");
        assert!(
            result.content.contains("build_container()"),
            "function definition should be renamed: {}",
            result.content
        );
        assert!(
            result.content.contains("    build_container"),
            "function call should be renamed: {}",
            result.content
        );
        assert!(
            !result.content.contains("build_image"),
            "old name should be gone: {}",
            result.content
        );
        assert!(
            result.replacements >= 2,
            "should rename definition + call site, got {}",
            result.replacements
        );
    }

    #[test]
    fn rename_returns_zero_when_name_only_in_strings_and_comments() {
        // #1187: tree-sitter parses successfully but name only appears in
        // strings/comments, so replacements should be 0 (not None).
        let source = r#"
fn main() {
    // target is mentioned here
    let s = "target";
    println!("{}", s);
}
"#;
        let result = rename_in_source(source, "target", "renamed", Language::Rust).unwrap();
        assert_eq!(
            result.replacements, 0,
            "should be 0 when name is only in strings/comments"
        );
        // Source should be unchanged
        assert_eq!(result.content, source);
    }

    #[test]
    fn reject_empty_rename_names_is_invalid_input() {
        for (old, new) in [
            ("", "bar"),
            ("foo", ""),
            ("", ""),
            ("   ", "bar"),
            ("foo", "   "),
            ("\t", "bar"),
        ] {
            let err = reject_empty_rename_names(old, new).expect_err("empty must fail");
            assert!(
                crate::exit::is_invalid_input(&err),
                "empty old/new must be invalid_input, got {err}"
            );
        }
        reject_empty_rename_names("foo", "bar").expect("non-empty names");
    }

    #[test]
    // Unique: Option wrapper must not turn a deadline into a word-boundary RenameResult.
    fn try_rename_in_source_timeout_is_parse_timeout() {
        let source = crate::ast::nested_rust_source_for_timeout(80_000);
        let _guard = crate::ast::ParseTimeoutGuard::set(std::time::Duration::from_millis(1));
        let err = try_rename_in_source(&source, "x", "y", Language::Rust)
            .expect_err("deadline must be Err, not a RenameResult");
        assert!(
            crate::exit::is_parse_timeout(&err),
            "expected parse_timeout, got {err}"
        );
        // Option wrapper must not return a write that replaced comments/strings.
        assert!(
            rename_in_source(&source, "x", "y", Language::Rust).is_none(),
            "timeout must not become a word-boundary RenameResult"
        );
    }
}