1
+ from .models import Tool , Toolkit
2
+ from jupyter_ai_tools .toolkits .code_execution import bash
3
+
4
+ import pathlib
5
+
6
+
7
+ def read (file_path : str , offset : int , limit : int ) -> str :
8
+ """
9
+ Read a subset of lines from a text file.
10
+
11
+ Parameters
12
+ ----------
13
+ file_path : str
14
+ Absolute path to the file that should be read.
15
+ offset : int
16
+ The line number at which to start reading (1-based indexing).
17
+ limit : int
18
+ Number of lines to read starting from *offset*.
19
+ If *offset + limit* exceeds the number of lines in the file,
20
+ all available lines after *offset* are returned.
21
+
22
+ Returns
23
+ -------
24
+ List[str]
25
+ List of lines (including line-ending characters) that were read.
26
+
27
+ Examples
28
+ --------
29
+ >>> # Suppose ``/tmp/example.txt`` contains 10 lines
30
+ >>> read('/tmp/example.txt', offset=3, limit=4)
31
+ ['third line\n ', 'fourth line\n ', 'fifth line\n ', 'sixth line\n ']
32
+ """
33
+ path = pathlib .Path (file_path )
34
+ if not path .is_file ():
35
+ raise FileNotFoundError (f"File not found: { file_path } " )
36
+
37
+ # Normalize arguments
38
+ offset = max (1 , int (offset ))
39
+ limit = max (0 , int (limit ))
40
+ lines : list [str ] = []
41
+
42
+ with path .open (encoding = 'utf-8' , errors = 'replace' ) as f :
43
+ # Skip to offset
44
+ line_no = 0
45
+ # Loop invariant: line_no := last read line
46
+ # After the loop exits, line_no == offset - 1, meaning the
47
+ # next line starts at `offset`
48
+ while line_no < offset - 1 :
49
+ line = f .readline ()
50
+ # Return early if offset exceeds number of lines in file
51
+ if line == "" :
52
+ return ""
53
+ line_no += 1
54
+
55
+ # Append lines until limit is reached
56
+ while len (lines ) < limit :
57
+ line = f .readline ()
58
+ if line == "" :
59
+ break
60
+ lines .append (line )
61
+
62
+ return "" .join (lines )
63
+
64
+
65
+ def edit (
66
+ file_path : str ,
67
+ old_string : str ,
68
+ new_string : str ,
69
+ replace_all : bool = False ,
70
+ ) -> None :
71
+ """
72
+ Replace occurrences of a substring in a file.
73
+
74
+ Parameters
75
+ ----------
76
+ file_path : str
77
+ Absolute path to the file that should be edited.
78
+ old_string : str
79
+ Text that should be replaced.
80
+ new_string : str
81
+ Text that will replace *old_string*.
82
+ replace_all : bool, optional
83
+ If ``True`` all occurrences of *old_string* are replaced.
84
+ If ``False`` (default), only the first occurrence in the file is replaced.
85
+
86
+ Returns
87
+ -------
88
+ None
89
+
90
+ Raises
91
+ ------
92
+ FileNotFoundError
93
+ If *file_path* does not exist.
94
+ ValueError
95
+ If *old_string* is empty (replacing an empty string is ambiguous).
96
+
97
+ Notes
98
+ -----
99
+ The file is overwritten atomically: it is first read into memory,
100
+ the substitution is performed, and the file is written back.
101
+ This keeps the operation safe for short to medium-sized files.
102
+
103
+ Examples
104
+ --------
105
+ >>> # Replace only the first occurrence
106
+ >>> edit('/tmp/test.txt', 'foo', 'bar', replace_all=False)
107
+ >>> # Replace all occurrences
108
+ >>> edit('/tmp/test.txt', 'foo', 'bar', replace_all=True)
109
+ """
110
+ path = pathlib .Path (file_path )
111
+ if not path .is_file ():
112
+ raise FileNotFoundError (f"File not found: { file_path } " )
113
+
114
+ if old_string == "" :
115
+ raise ValueError ("old_string must not be empty" )
116
+
117
+ # Read the entire file
118
+ content = path .read_text (encoding = "utf-8" , errors = "replace" )
119
+
120
+ # Perform replacement
121
+ if replace_all :
122
+ new_content = content .replace (old_string , new_string )
123
+ else :
124
+ new_content = content .replace (old_string , new_string , 1 )
125
+
126
+ # Write back
127
+ path .write_text (new_content , encoding = "utf-8" )
128
+
129
+
130
+ def write (file_path : str , content : str ) -> None :
131
+ """
132
+ Write content to a file, creating it if it doesn't exist.
133
+
134
+ Parameters
135
+ ----------
136
+ file_path : str
137
+ Absolute path to the file that should be written.
138
+ content : str
139
+ Content to write to the file.
140
+
141
+ Returns
142
+ -------
143
+ None
144
+
145
+ Raises
146
+ ------
147
+ OSError
148
+ If the file cannot be written (e.g., permission denied, invalid path).
149
+
150
+ Notes
151
+ -----
152
+ This function will overwrite the file if it already exists.
153
+ The parent directory must exist; this function does not create directories.
154
+
155
+ Examples
156
+ --------
157
+ >>> write('/tmp/example.txt', 'Hello, world!')
158
+ >>> write('/tmp/data.json', '{"key": "value"}')
159
+ """
160
+ path = pathlib .Path (file_path )
161
+
162
+ # Write the content to the file
163
+ path .write_text (content , encoding = "utf-8" )
164
+
165
+
166
+ async def search_grep (pattern : str , include : str = "*" ) -> str :
167
+ """
168
+ Search for text patterns in files using ripgrep.
169
+
170
+ This function uses ripgrep (rg) to perform fast regex-based text searching
171
+ across files, with optional file filtering based on glob patterns.
172
+
173
+ Parameters
174
+ ----------
175
+ pattern : str
176
+ A regular expression pattern to search for. Ripgrep uses Rust regex
177
+ syntax which supports:
178
+ - Basic regex features: ., *, +, ?, ^, $, [], (), |
179
+ - Character classes: \w, \d, \s, \W, \D, \S
180
+ - Unicode categories: \p{L}, \p{N}, \p{P}, etc.
181
+ - Word boundaries: \b , \B
182
+ - Anchors: ^, $, \A, \z
183
+ - Quantifiers: {n}, {n,}, {n,m}
184
+ - Groups: (pattern), (?:pattern), (?P<name>pattern)
185
+ - Lookahead/lookbehind: (?=pattern), (?!pattern), (?<=pattern), (?<!pattern)
186
+ - Flags: (?i), (?m), (?s), (?x), (?U)
187
+
188
+ Note: Ripgrep uses Rust's regex engine, which does NOT support:
189
+ - Backreferences (use --pcre2 flag for this)
190
+ - Some advanced PCRE features
191
+ include : str, optional
192
+ A glob pattern to filter which files to search. Defaults to "*" (all files).
193
+ Glob patterns follow gitignore syntax:
194
+ - * matches any sequence of characters except /
195
+ - ? matches any single character except /
196
+ - ** matches any sequence of characters including /
197
+ - [abc] matches any character in the set
198
+ - {a,b} matches either "a" or "b"
199
+ - ! at start negates the pattern
200
+ Examples: "*.py", "**/*.js", "src/**/*.{ts,tsx}", "!*.test.*"
201
+
202
+ Returns
203
+ -------
204
+ str
205
+ The raw output from ripgrep, including file paths, line numbers,
206
+ and matching lines. Empty string if no matches found.
207
+
208
+ Raises
209
+ ------
210
+ RuntimeError
211
+ If ripgrep command fails or encounters an error (non-zero exit code).
212
+ This includes cases where:
213
+ - Pattern syntax is invalid
214
+ - Include glob pattern is malformed
215
+ - Ripgrep binary is not available
216
+ - File system errors occur
217
+
218
+ Examples
219
+ --------
220
+ >>> search_grep(r"def\s+\w+", "*.py")
221
+ 'file.py:10:def my_function():'
222
+
223
+ >>> search_grep(r"TODO|FIXME", "**/*.{py,js}")
224
+ 'app.py:25:# TODO: implement this
225
+ script.js:15:// FIXME: handle edge case'
226
+
227
+ >>> search_grep(r"class\s+(\w+)", "src/**/*.py")
228
+ 'src/models.py:1:class User:'
229
+ """
230
+ # Use bash tool to execute ripgrep
231
+ cmd_parts = ["rg" , "--color=never" , "--line-number" , "--with-filename" ]
232
+
233
+ # Add glob pattern if specified
234
+ if include != "*" :
235
+ cmd_parts .extend (["-g" , include ])
236
+
237
+ # Add the pattern (always quote it to handle special characters)
238
+ cmd_parts .append (pattern )
239
+
240
+ # Join command with proper shell escaping
241
+ command = " " .join (f'"{ part } "' if " " in part or any (c in part for c in "!*?[]{}()" ) else part for part in cmd_parts )
242
+
243
+ try :
244
+ result = await bash (command )
245
+ return result
246
+ except Exception as e :
247
+ raise RuntimeError (f"Ripgrep search failed: { str (e )} " ) from e
248
+
249
+
250
+ DEFAULT_TOOLKIT = Toolkit (name = "jupyter-ai-default-toolkit" )
251
+ DEFAULT_TOOLKIT .add_tool (Tool (callable = bash ))
252
+ DEFAULT_TOOLKIT .add_tool (Tool (callable = read ))
253
+ DEFAULT_TOOLKIT .add_tool (Tool (callable = edit ))
254
+ DEFAULT_TOOLKIT .add_tool (Tool (callable = write ))
255
+ DEFAULT_TOOLKIT .add_tool (Tool (callable = search_grep ))
0 commit comments