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