Skip to main content

opendev_tools_impl/
insert_symbol.rs

1//! Insert content before or after a symbol found via AST-based symbol navigation.
2//!
3//! Uses `opendev-tools-symbol` to locate a symbol's position in a file, then
4//! inserts user-provided content immediately before or after the symbol's range.
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
10
11use crate::path_utils::{resolve_file_path, validate_path_access};
12
13/// Shared insertion logic used by both tools.
14///
15/// `position` is either `"before"` or `"after"`, controlling where the content
16/// is placed relative to the symbol.
17fn execute_insert(
18    args: &HashMap<String, serde_json::Value>,
19    ctx: &ToolContext,
20    position: InsertPosition,
21) -> ToolResult {
22    let file_path_str = match args.get("file_path").and_then(|v| v.as_str()) {
23        Some(p) if !p.is_empty() => p,
24        _ => return ToolResult::fail("file_path is required"),
25    };
26
27    let symbol_name = match args.get("symbol_name").and_then(|v| v.as_str()) {
28        Some(s) if !s.is_empty() => s,
29        _ => return ToolResult::fail("symbol_name is required"),
30    };
31
32    let content = match args.get("content").and_then(|v| v.as_str()) {
33        Some(c) if !c.is_empty() => c,
34        _ => return ToolResult::fail("content is required"),
35    };
36
37    let path = resolve_file_path(file_path_str, &ctx.working_dir);
38
39    if let Err(msg) = validate_path_access(&path, &ctx.working_dir) {
40        return ToolResult::fail(msg);
41    }
42
43    if !path.exists() {
44        return ToolResult::fail(format!("File not found: {file_path_str}"));
45    }
46
47    let file_content = match std::fs::read_to_string(&path) {
48        Ok(c) => c,
49        Err(e) => return ToolResult::fail(format!("Failed to read file: {e}")),
50    };
51
52    // Find the symbol by scanning for a line that defines it.
53    // This uses a simple heuristic: look for common definition patterns
54    // (fn, struct, enum, class, def, const, let, pub, impl, trait, type, interface, etc.)
55    // that contain the symbol name.
56    let lines: Vec<&str> = file_content.lines().collect();
57
58    let symbol_range = match find_symbol_range(&lines, symbol_name) {
59        Some(range) => range,
60        None => {
61            return ToolResult::fail(format!(
62                "Symbol '{}' not found in {}",
63                symbol_name, file_path_str
64            ));
65        }
66    };
67
68    // Build the new file content with insertion
69    let new_content = insert_content(&file_content, &lines, content, &symbol_range, position);
70
71    // Write back atomically
72    let dir = path.parent().unwrap_or(Path::new("."));
73    let tmp_path = dir.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
74
75    if let Err(e) = std::fs::write(&tmp_path, &new_content) {
76        return ToolResult::fail(format!("Failed to write temp file: {e}"));
77    }
78    if let Err(e) = std::fs::rename(&tmp_path, &path) {
79        let _ = std::fs::remove_file(&tmp_path);
80        return ToolResult::fail(format!("Failed to rename temp file: {e}"));
81    }
82
83    let label = match position {
84        InsertPosition::Before => "before",
85        InsertPosition::After => "after",
86    };
87
88    ToolResult::ok(format!(
89        "Inserted content {label} symbol '{}' in {}",
90        symbol_name, file_path_str
91    ))
92}
93
94#[derive(Debug, Clone, Copy)]
95enum InsertPosition {
96    Before,
97    After,
98}
99
100/// Range of a symbol in a file (0-indexed line numbers, inclusive).
101#[derive(Debug)]
102struct SymbolRange {
103    /// First line of the symbol definition.
104    start_line: usize,
105    /// Last line of the symbol definition (inclusive).
106    end_line: usize,
107}
108
109/// Find a symbol's line range using pattern matching on definition keywords.
110///
111/// Supports common patterns across languages: `fn`, `pub fn`, `struct`, `enum`,
112/// `trait`, `impl`, `type`, `const`, `static`, `let`, `class`, `def`, `interface`,
113/// `function`, `var`, `export`.
114fn find_symbol_range(lines: &[&str], symbol_name: &str) -> Option<SymbolRange> {
115    // Definition keywords that typically precede a symbol name.
116    let keywords = [
117        "fn ",
118        "pub fn ",
119        "pub(crate) fn ",
120        "pub(super) fn ",
121        "struct ",
122        "pub struct ",
123        "pub(crate) struct ",
124        "enum ",
125        "pub enum ",
126        "pub(crate) enum ",
127        "trait ",
128        "pub trait ",
129        "pub(crate) trait ",
130        "impl ",
131        "pub impl ",
132        "type ",
133        "pub type ",
134        "pub(crate) type ",
135        "const ",
136        "pub const ",
137        "pub(crate) const ",
138        "static ",
139        "pub static ",
140        "pub(crate) static ",
141        "let ",
142        "let mut ",
143        "class ",
144        "def ",
145        "interface ",
146        "function ",
147        "var ",
148        "export ",
149        "async fn ",
150        "pub async fn ",
151        "pub(crate) async fn ",
152        "unsafe fn ",
153        "pub unsafe fn ",
154        "macro_rules! ",
155    ];
156
157    let mut start_line = None;
158
159    for (i, line) in lines.iter().enumerate() {
160        let trimmed = line.trim();
161
162        // Check if this line contains a symbol definition matching the name.
163        let is_definition = keywords.iter().any(|kw| {
164            if let Some(rest) = trimmed.strip_prefix(kw) {
165                // The symbol name should appear at the start of what follows the keyword.
166                // Handle cases like `fn foo(`, `fn foo {`, `fn foo:`, `fn foo<`, `fn foo `
167                let name_part = rest
168                    .split(|c: char| !c.is_alphanumeric() && c != '_')
169                    .next()
170                    .unwrap_or("");
171                name_part == symbol_name
172            } else {
173                false
174            }
175        });
176
177        if is_definition {
178            start_line = Some(i);
179            break;
180        }
181    }
182
183    let start = start_line?;
184
185    // Find the end of the symbol by tracking brace/indent depth.
186    let end = find_symbol_end(lines, start);
187
188    Some(SymbolRange {
189        start_line: start,
190        end_line: end,
191    })
192}
193
194/// Find the end line of a symbol starting at `start_line`.
195///
196/// Uses brace matching for C-like languages, or indentation for Python-style.
197fn find_symbol_end(lines: &[&str], start_line: usize) -> usize {
198    let start_trimmed = lines[start_line].trim();
199
200    // Check if this looks like a Python-style definition (ends with colon or has `def`/`class`)
201    let is_python_style = start_trimmed.starts_with("def ") || start_trimmed.starts_with("class ");
202
203    if is_python_style {
204        return find_symbol_end_by_indent(lines, start_line);
205    }
206
207    // For C-like: track brace depth
208    let mut depth: i32 = 0;
209    let mut found_open_brace = false;
210
211    for (i, line) in lines.iter().enumerate().skip(start_line) {
212        for ch in line.chars() {
213            if ch == '{' {
214                depth += 1;
215                found_open_brace = true;
216            } else if ch == '}' {
217                depth -= 1;
218                if found_open_brace && depth == 0 {
219                    return i;
220                }
221            }
222        }
223        // If line ends with `;` and we haven't seen a brace, it's a single-line definition
224        if !found_open_brace && line.trim().ends_with(';') {
225            return i;
226        }
227    }
228
229    // Fallback: return start line if we can't determine the end
230    start_line
231}
232
233/// Find symbol end by indentation (Python-style).
234fn find_symbol_end_by_indent(lines: &[&str], start_line: usize) -> usize {
235    let start_indent = lines[start_line].len() - lines[start_line].trim_start().len();
236    let mut last_body_line = start_line;
237
238    for (i, line) in lines.iter().enumerate().skip(start_line + 1) {
239        if line.trim().is_empty() {
240            continue; // Skip blank lines
241        }
242        let indent = line.len() - line.trim_start().len();
243        if indent <= start_indent {
244            break; // Back to same or lower indent level
245        }
246        last_body_line = i;
247    }
248
249    last_body_line
250}
251
252/// Insert content before or after the symbol range.
253fn insert_content(
254    original: &str,
255    lines: &[&str],
256    content: &str,
257    range: &SymbolRange,
258    position: InsertPosition,
259) -> String {
260    let mut result = String::with_capacity(original.len() + content.len() + 2);
261
262    match position {
263        InsertPosition::Before => {
264            // Add all lines before the symbol
265            for line in &lines[..range.start_line] {
266                result.push_str(line);
267                result.push('\n');
268            }
269            // Add the inserted content
270            result.push_str(content);
271            if !content.ends_with('\n') {
272                result.push('\n');
273            }
274            // Add the symbol and everything after
275            for line in &lines[range.start_line..] {
276                result.push_str(line);
277                result.push('\n');
278            }
279        }
280        InsertPosition::After => {
281            // Add all lines up to and including the symbol
282            for line in &lines[..=range.end_line] {
283                result.push_str(line);
284                result.push('\n');
285            }
286            // Add the inserted content
287            result.push_str(content);
288            if !content.ends_with('\n') {
289                result.push('\n');
290            }
291            // Add remaining lines after the symbol
292            if range.end_line + 1 < lines.len() {
293                for line in &lines[range.end_line + 1..] {
294                    result.push_str(line);
295                    result.push('\n');
296                }
297            }
298        }
299    }
300
301    // Preserve original trailing newline behavior
302    if !original.ends_with('\n') && result.ends_with('\n') {
303        result.pop();
304    }
305
306    result
307}
308
309// ---------------------------------------------------------------------------
310// InsertBeforeSymbolTool
311// ---------------------------------------------------------------------------
312
313/// Tool for inserting content before a symbol in a file.
314#[derive(Debug)]
315pub struct InsertBeforeSymbolTool;
316
317#[async_trait::async_trait]
318impl BaseTool for InsertBeforeSymbolTool {
319    fn name(&self) -> &str {
320        "insert_before_symbol"
321    }
322
323    fn description(&self) -> &str {
324        "Insert content before a symbol (function, class, struct, etc.) in a file. \
325         The symbol is located by name using pattern matching on common definition keywords."
326    }
327
328    fn parameter_schema(&self) -> serde_json::Value {
329        serde_json::json!({
330            "type": "object",
331            "properties": {
332                "file_path": {
333                    "type": "string",
334                    "description": "Absolute path to the file containing the symbol"
335                },
336                "symbol_name": {
337                    "type": "string",
338                    "description": "Name of the symbol to insert before (e.g. function name, struct name)"
339                },
340                "content": {
341                    "type": "string",
342                    "description": "The text content to insert before the symbol"
343                }
344            },
345            "required": ["file_path", "symbol_name", "content"]
346        })
347    }
348
349    async fn execute(
350        &self,
351        args: HashMap<String, serde_json::Value>,
352        ctx: &ToolContext,
353    ) -> ToolResult {
354        execute_insert(&args, ctx, InsertPosition::Before)
355    }
356}
357
358// ---------------------------------------------------------------------------
359// InsertAfterSymbolTool
360// ---------------------------------------------------------------------------
361
362/// Tool for inserting content after a symbol in a file.
363#[derive(Debug)]
364pub struct InsertAfterSymbolTool;
365
366#[async_trait::async_trait]
367impl BaseTool for InsertAfterSymbolTool {
368    fn name(&self) -> &str {
369        "insert_after_symbol"
370    }
371
372    fn description(&self) -> &str {
373        "Insert content after a symbol (function, class, struct, etc.) in a file. \
374         The symbol is located by name using pattern matching on common definition keywords."
375    }
376
377    fn parameter_schema(&self) -> serde_json::Value {
378        serde_json::json!({
379            "type": "object",
380            "properties": {
381                "file_path": {
382                    "type": "string",
383                    "description": "Absolute path to the file containing the symbol"
384                },
385                "symbol_name": {
386                    "type": "string",
387                    "description": "Name of the symbol to insert after (e.g. function name, struct name)"
388                },
389                "content": {
390                    "type": "string",
391                    "description": "The text content to insert after the symbol"
392                }
393            },
394            "required": ["file_path", "symbol_name", "content"]
395        })
396    }
397
398    async fn execute(
399        &self,
400        args: HashMap<String, serde_json::Value>,
401        ctx: &ToolContext,
402    ) -> ToolResult {
403        execute_insert(&args, ctx, InsertPosition::After)
404    }
405}
406
407#[cfg(test)]
408#[path = "insert_symbol_tests.rs"]
409mod tests;