janito 1.10.0__py3-none-any.whl → 1.11.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. janito/__init__.py +1 -1
  2. janito/agent/conversation_api.py +178 -90
  3. janito/agent/conversation_ui.py +1 -1
  4. janito/agent/llm_conversation_history.py +12 -0
  5. janito/agent/templates/profiles/system_prompt_template_base.txt.j2 +19 -4
  6. janito/agent/tools/__init__.py +2 -0
  7. janito/agent/tools/create_directory.py +1 -1
  8. janito/agent/tools/create_file.py +1 -1
  9. janito/agent/tools/fetch_url.py +1 -1
  10. janito/agent/tools/find_files.py +26 -13
  11. janito/agent/tools/get_file_outline/core.py +1 -1
  12. janito/agent/tools/get_file_outline/python_outline.py +139 -95
  13. janito/agent/tools/get_lines.py +92 -63
  14. janito/agent/tools/move_file.py +58 -32
  15. janito/agent/tools/open_url.py +31 -0
  16. janito/agent/tools/python_command_runner.py +85 -86
  17. janito/agent/tools/python_file_runner.py +85 -86
  18. janito/agent/tools/python_stdin_runner.py +87 -88
  19. janito/agent/tools/remove_directory.py +1 -1
  20. janito/agent/tools/remove_file.py +1 -1
  21. janito/agent/tools/replace_file.py +2 -2
  22. janito/agent/tools/replace_text_in_file.py +193 -149
  23. janito/agent/tools/run_bash_command.py +1 -1
  24. janito/agent/tools/run_powershell_command.py +4 -0
  25. janito/agent/tools/search_text/__init__.py +1 -0
  26. janito/agent/tools/search_text/core.py +176 -0
  27. janito/agent/tools/search_text/match_lines.py +58 -0
  28. janito/agent/tools/search_text/pattern_utils.py +65 -0
  29. janito/agent/tools/search_text/traverse_directory.py +132 -0
  30. janito/agent/tools/validate_file_syntax/core.py +41 -30
  31. janito/agent/tools/validate_file_syntax/html_validator.py +21 -5
  32. janito/agent/tools/validate_file_syntax/markdown_validator.py +77 -34
  33. janito/agent/tools_utils/gitignore_utils.py +25 -2
  34. janito/agent/tools_utils/utils.py +7 -1
  35. janito/cli/config_commands.py +112 -109
  36. janito/shell/main.py +51 -8
  37. janito/shell/session/config.py +83 -75
  38. janito/shell/ui/interactive.py +97 -73
  39. janito/termweb/static/editor.css +32 -29
  40. janito/termweb/static/editor.css.bak +140 -22
  41. janito/termweb/static/editor.html +12 -7
  42. janito/termweb/static/editor.html.bak +16 -11
  43. janito/termweb/static/editor.js +94 -40
  44. janito/termweb/static/editor.js.bak +97 -65
  45. janito/termweb/static/index.html +1 -2
  46. janito/termweb/static/index.html.bak +1 -1
  47. janito/termweb/static/termweb.css +1 -22
  48. janito/termweb/static/termweb.css.bak +6 -4
  49. janito/termweb/static/termweb.js +0 -6
  50. janito/termweb/static/termweb.js.bak +1 -2
  51. {janito-1.10.0.dist-info → janito-1.11.1.dist-info}/METADATA +1 -1
  52. {janito-1.10.0.dist-info → janito-1.11.1.dist-info}/RECORD +56 -51
  53. {janito-1.10.0.dist-info → janito-1.11.1.dist-info}/WHEEL +1 -1
  54. janito/agent/tools/search_text.py +0 -254
  55. {janito-1.10.0.dist-info → janito-1.11.1.dist-info}/entry_points.txt +0 -0
  56. {janito-1.10.0.dist-info → janito-1.11.1.dist-info}/licenses/LICENSE +0 -0
  57. {janito-1.10.0.dist-info → janito-1.11.1.dist-info}/top_level.txt +0 -0
@@ -2,6 +2,8 @@ from janito.agent.tool_base import ToolBase
2
2
  from janito.agent.tools_utils.action_type import ActionType
3
3
  from janito.agent.tool_registry import register_tool
4
4
  from janito.i18n import tr
5
+ import shutil
6
+ import re
5
7
 
6
8
 
7
9
  @register_tool(name="replace_text_in_file")
@@ -9,6 +11,10 @@ class ReplaceTextInFileTool(ToolBase):
9
11
  """
10
12
  Replace exact occurrences of a given text in a file.
11
13
 
14
+ Note:
15
+ To avoid syntax errors, ensure your replacement text is pre-indented as needed, matching the indentation of the
16
+ search text in its original location.
17
+
12
18
  Args:
13
19
  file_path (str): Path to the file to modify.
14
20
  search_text (str): The exact text to search for (including indentation).
@@ -36,9 +42,151 @@ class ReplaceTextInFileTool(ToolBase):
36
42
  action = "(all)" if replace_all else ""
37
43
  search_lines = len(search_text.splitlines())
38
44
  replace_lines = len(replacement_text.splitlines())
45
+ info_msg = self._format_info_msg(
46
+ disp_path,
47
+ search_lines,
48
+ replace_lines,
49
+ action,
50
+ search_text,
51
+ replacement_text,
52
+ file_path,
53
+ )
54
+ self.report_info(ActionType.WRITE, info_msg)
55
+ try:
56
+ content = self._read_file_content(file_path)
57
+ match_lines = self._find_match_lines(content, search_text)
58
+ occurrences = content.count(search_text)
59
+ replaced_count, new_content = self._replace_content(
60
+ content, search_text, replacement_text, replace_all, occurrences
61
+ )
62
+ file_changed = new_content != content
63
+ backup_path = file_path + ".bak"
64
+ if backup and file_changed:
65
+ self._backup_file(file_path, backup_path)
66
+ if file_changed:
67
+ self._write_file_content(file_path, new_content)
68
+ warning, concise_warning = self._handle_warnings(
69
+ replaced_count, file_changed, occurrences
70
+ )
71
+ if warning:
72
+ self.report_warning(warning)
73
+ if concise_warning:
74
+ return concise_warning
75
+ self._report_success(match_lines)
76
+ line_delta_str = self._get_line_delta_str(content, new_content)
77
+ match_info, details = self._format_match_details(
78
+ replaced_count,
79
+ match_lines,
80
+ search_lines,
81
+ replace_lines,
82
+ line_delta_str,
83
+ replace_all,
84
+ )
85
+ return self._format_final_msg(
86
+ file_path, warning, backup_path, match_info, details
87
+ )
88
+ except Exception as e:
89
+ self.report_error(tr(" \u274c Error"))
90
+ return tr("Error replacing text: {error}", error=e)
91
+
92
+ def _read_file_content(self, file_path):
93
+ """Read the entire content of the file."""
94
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
95
+ return f.read()
96
+
97
+ def _find_match_lines(self, content, search_text):
98
+ """Find all line numbers where search_text occurs in content."""
99
+ lines = content.splitlines(keepends=True)
100
+ joined = "".join(lines)
101
+ match_lines = []
102
+ idx = 0
103
+ while True:
104
+ idx = joined.find(search_text, idx)
105
+ if idx == -1:
106
+ break
107
+ upto = joined[:idx]
108
+ line_no = upto.count("\n") + 1
109
+ match_lines.append(line_no)
110
+ idx += 1 if not search_text else len(search_text)
111
+ return match_lines
112
+
113
+ def _replace_content(
114
+ self, content, search_text, replacement_text, replace_all, occurrences
115
+ ):
116
+ """Replace occurrences of search_text with replacement_text in content."""
117
+ if replace_all:
118
+ replaced_count = content.count(search_text)
119
+ new_content = content.replace(search_text, replacement_text)
120
+ else:
121
+ if occurrences > 1:
122
+ return 0, content # No changes made, not unique
123
+ replaced_count = 1 if occurrences == 1 else 0
124
+ new_content = content.replace(search_text, replacement_text, 1)
125
+ return replaced_count, new_content
126
+
127
+ def _backup_file(self, file_path, backup_path):
128
+ """Create a backup of the file."""
129
+ shutil.copy2(file_path, backup_path)
130
+
131
+ def _write_file_content(self, file_path, content):
132
+ """Write content to the file."""
133
+ with open(file_path, "w", encoding="utf-8", errors="replace") as f:
134
+ f.write(content)
135
+
136
+ def _handle_warnings(self, replaced_count, file_changed, occurrences):
137
+ """Handle and return warnings and concise warnings if needed."""
138
+ warning = ""
139
+ concise_warning = None
140
+ if replaced_count == 0:
141
+ warning = tr(" [Warning: Search text not found in file]")
142
+ if not file_changed:
143
+ self.report_warning(tr(" \u2139\ufe0f No changes made. [not found]"))
144
+ concise_warning = tr(
145
+ "No changes made. The search text was not found. Expand your search context with surrounding lines if needed."
146
+ )
147
+ if occurrences > 1 and replaced_count == 0:
148
+ self.report_warning(tr(" \u2139\ufe0f No changes made. [not unique]"))
149
+ concise_warning = tr(
150
+ "No changes made. The search text is not unique. Expand your search context with surrounding lines to ensure uniqueness."
151
+ )
152
+ return warning, concise_warning
153
+
154
+ def _report_success(self, match_lines):
155
+ """Report success with line numbers where replacements occurred."""
156
+ if match_lines:
157
+ lines_str = ", ".join(str(line_no) for line_no in match_lines)
158
+ self.report_success(
159
+ tr(" \u2705 replaced at {lines_str}", lines_str=lines_str)
160
+ )
161
+ else:
162
+ self.report_success(tr(" \u2705 replaced (lines unknown)"))
163
+
164
+ def _get_line_delta_str(self, content, new_content):
165
+ """Return a string describing the net line change after replacement."""
166
+ total_lines_before = content.count("\n") + 1
167
+ total_lines_after = new_content.count("\n") + 1
168
+ line_delta = total_lines_after - total_lines_before
169
+ if line_delta > 0:
170
+ return f" (+{line_delta} lines)"
171
+ elif line_delta < 0:
172
+ return f" ({line_delta} lines)"
173
+ else:
174
+ return " (no net line change)"
175
+
176
+ def _format_info_msg(
177
+ self,
178
+ disp_path,
179
+ search_lines,
180
+ replace_lines,
181
+ action,
182
+ search_text,
183
+ replacement_text,
184
+ file_path,
185
+ ):
186
+ """Format the info message for the operation."""
39
187
  if replace_lines == 0:
40
- info_msg = tr(
41
- "📝 Replacing in {disp_path} del {search_lines} lines {action}",
188
+ return tr(
189
+ "📝 Replace in {disp_path} del {search_lines} lines {action}",
42
190
  disp_path=disp_path,
43
191
  search_lines=search_lines,
44
192
  action=action,
@@ -48,7 +196,7 @@ class ReplaceTextInFileTool(ToolBase):
48
196
  with open(file_path, "r", encoding="utf-8", errors="replace") as f:
49
197
  _content = f.read()
50
198
  _new_content = _content.replace(
51
- search_text, replacement_text, -1 if replace_all else 1
199
+ search_text, replacement_text, -1 if action else 1
52
200
  )
53
201
  _total_lines_before = _content.count("\n") + 1
54
202
  _total_lines_after = _new_content.count("\n") + 1
@@ -61,158 +209,54 @@ class ReplaceTextInFileTool(ToolBase):
61
209
  delta_str = f"{_line_delta} lines"
62
210
  else:
63
211
  delta_str = "+0"
64
- info_msg = tr(
65
- "📝 Replacing in {disp_path} {delta_str} {action}",
212
+ return tr(
213
+ "📝 Replace in {disp_path} {delta_str} {action}",
66
214
  disp_path=disp_path,
67
215
  delta_str=delta_str,
68
216
  action=action,
69
217
  )
70
- self.report_info(
71
- ActionType.WRITE,
72
- info_msg + (" ..." if not info_msg.rstrip().endswith("...") else ""),
73
- )
74
- try:
75
- with open(file_path, "r", encoding="utf-8", errors="replace") as f:
76
- content = f.read()
77
-
78
- def find_match_lines(content, search_text):
79
- lines = content.splitlines(keepends=True)
80
- joined = "".join(lines)
81
- match_lines = []
82
- idx = 0
83
- while True:
84
- idx = joined.find(search_text, idx)
85
- if idx == -1:
86
- break
87
- upto = joined[:idx]
88
- line_no = upto.count("\n") + 1
89
- match_lines.append(line_no)
90
- idx += 1 if not search_text else len(search_text)
91
- return match_lines
92
-
93
- match_lines = find_match_lines(content, search_text)
94
- if replace_all:
95
- replaced_count = content.count(search_text)
96
- new_content = content.replace(search_text, replacement_text)
97
- else:
98
- occurrences = content.count(search_text)
99
- if occurrences > 1:
100
- self.report_warning(tr(" ℹ️ No changes made. [not unique]"))
101
- warning_detail = tr(
102
- "The search text is not unique. Expand your search context with surrounding lines to ensure uniqueness."
103
- )
104
- return tr(
105
- "No changes made. {warning_detail}",
106
- warning_detail=warning_detail,
107
- )
108
- replaced_count = 1 if occurrences == 1 else 0
109
- new_content = content.replace(search_text, replacement_text, 1)
110
- import shutil
111
-
112
- backup_path = file_path + ".bak"
113
- if backup and new_content != content:
114
- shutil.copy2(file_path, backup_path)
115
- if new_content != content:
116
- with open(file_path, "w", encoding="utf-8", errors="replace") as f:
117
- f.write(new_content)
118
- file_changed = True
119
- else:
120
- file_changed = False
121
- warning = ""
122
- if replaced_count == 0:
123
- warning = tr(" [Warning: Search text not found in file]")
124
- if not file_changed:
125
- self.report_warning(tr(" ℹ️ No changes made. [not found]"))
126
- concise_warning = tr(
127
- "The search text was not found. Expand your search context with surrounding lines if needed."
128
- )
129
- return tr(
130
- "No changes made. {concise_warning}",
131
- concise_warning=concise_warning,
132
- )
133
- if match_lines:
134
- lines_str = ", ".join(str(line_no) for line_no in match_lines)
135
- self.report_success(
136
- tr(" ✅ replaced at {lines_str}", lines_str=lines_str)
137
- )
138
- else:
139
- self.report_success(tr(" ✅ replaced (lines unknown)"))
140
218
 
141
- def leading_ws(line):
142
- import re
143
-
144
- m = re.match(r"^\s*", line)
145
- return m.group(0) if m else ""
146
-
147
- search_indent = (
148
- leading_ws(search_text.splitlines()[0])
149
- if search_text.splitlines()
150
- else ""
151
- )
152
- replace_indent = (
153
- leading_ws(replacement_text.splitlines()[0])
154
- if replacement_text.splitlines()
155
- else ""
156
- )
157
- indent_warning = ""
158
- if search_indent != replace_indent:
159
- indent_warning = tr(
160
- " [Warning: Indentation mismatch between search and replacement text: '{search_indent}' vs '{replace_indent}']",
161
- search_indent=search_indent,
162
- replace_indent=replace_indent,
163
- )
164
- total_lines_before = content.count("\n") + 1
165
- total_lines_after = new_content.count("\n") + 1
166
- line_delta = total_lines_after - total_lines_before
167
- line_delta_str = (
168
- f" (+{line_delta} lines)"
169
- if line_delta > 0
170
- else (
171
- f" ({line_delta} lines)"
172
- if line_delta < 0
173
- else " (no net line change)"
174
- )
175
- )
176
- if replaced_count > 0:
177
- if replace_all:
178
- match_info = tr(
179
- "Matches found at lines: {lines}. ",
180
- lines=", ".join(str(line) for line in match_lines),
181
- )
182
- else:
183
- match_info = (
184
- tr("Match found at line {line}. ", line=match_lines[0])
185
- if match_lines
186
- else ""
187
- )
188
- details = tr(
189
- "Replaced {replaced_count} occurrence(s) at above line(s): {search_lines} lines replaced with {replace_lines} lines each.{line_delta_str}",
190
- replaced_count=replaced_count,
191
- search_lines=search_lines,
192
- replace_lines=replace_lines,
193
- line_delta_str=line_delta_str,
219
+ def _format_match_details(
220
+ self,
221
+ replaced_count,
222
+ match_lines,
223
+ search_lines,
224
+ replace_lines,
225
+ line_delta_str,
226
+ replace_all,
227
+ ):
228
+ """Format match info and details for the final message."""
229
+ if replaced_count > 0:
230
+ if replace_all:
231
+ match_info = tr(
232
+ "Matches found at lines: {lines}. ",
233
+ lines=", ".join(str(line) for line in match_lines),
194
234
  )
195
235
  else:
196
- match_info = ""
197
- details = ""
198
- if "warning_detail" in locals():
199
- return tr(
200
- "Text replaced in {file_path}{warning}{indent_warning} (backup at {backup_path})\n{warning_detail}",
201
- file_path=file_path,
202
- warning=warning,
203
- indent_warning=indent_warning,
204
- backup_path=backup_path,
205
- warning_detail=warning_detail,
236
+ match_info = (
237
+ tr("Match found at line {line}. ", line=match_lines[0])
238
+ if match_lines
239
+ else ""
206
240
  )
207
- return tr(
208
- "Text replaced in {file_path}{warning}{indent_warning} (backup at {backup_path}). {match_info}{details}",
209
- file_path=file_path,
210
- warning=warning,
211
- indent_warning=indent_warning,
212
- backup_path=backup_path,
213
- match_info=match_info,
214
- details=details,
241
+ details = tr(
242
+ "Replaced {replaced_count} occurrence(s) at above line(s): {search_lines} lines replaced with {replace_lines} lines each.{line_delta_str}",
243
+ replaced_count=replaced_count,
244
+ search_lines=search_lines,
245
+ replace_lines=replace_lines,
246
+ line_delta_str=line_delta_str,
215
247
  )
216
- except Exception as e:
217
- self.report_error(tr(" Error"))
218
- return tr("Error replacing text: {error}", error=e)
248
+ else:
249
+ match_info = ""
250
+ details = ""
251
+ return match_info, details
252
+
253
+ def _format_final_msg(self, file_path, warning, backup_path, match_info, details):
254
+ """Format the final status message."""
255
+ return tr(
256
+ "Text replaced in {file_path}{warning} (backup at {backup_path}). {match_info}{details}",
257
+ file_path=file_path,
258
+ warning=warning,
259
+ backup_path=backup_path,
260
+ match_info=match_info,
261
+ details=details,
262
+ )
@@ -34,7 +34,7 @@ class RunBashCommandTool(ToolBase):
34
34
  return tr("Warning: Empty command provided. Operation skipped.")
35
35
  self.report_info(
36
36
  ActionType.EXECUTE,
37
- tr("🖥️ Running bash command: {command} ...\n", command=command),
37
+ tr("🖥️ Run bash command: {command} ...\n", command=command),
38
38
  )
39
39
  if requires_user_input:
40
40
  self.report_warning(
@@ -3,6 +3,7 @@ from janito.agent.tools_utils.action_type import ActionType
3
3
  from janito.agent.tool_registry import register_tool
4
4
  from janito.i18n import tr
5
5
  import subprocess
6
+ import os
6
7
  import tempfile
7
8
  import threading
8
9
 
@@ -44,6 +45,8 @@ class RunPowerShellCommandTool(ToolBase):
44
45
  return True
45
46
 
46
47
  def _launch_process(self, shell_exe, command_with_encoding):
48
+ env = os.environ.copy()
49
+ env["PYTHONIOENCODING"] = "utf-8"
47
50
  return subprocess.Popen(
48
51
  [
49
52
  shell_exe,
@@ -59,6 +62,7 @@ class RunPowerShellCommandTool(ToolBase):
59
62
  bufsize=1,
60
63
  universal_newlines=True,
61
64
  encoding="utf-8",
65
+ env=env,
62
66
  )
63
67
 
64
68
  def _stream_output(self, stream, file_obj, report_func, count_func, counter):
@@ -0,0 +1 @@
1
+ from .core import SearchTextTool
@@ -0,0 +1,176 @@
1
+ from janito.agent.tool_base import ToolBase
2
+ from janito.agent.tools_utils.action_type import ActionType
3
+ from janito.agent.tool_registry import register_tool
4
+ from janito.agent.tools_utils.utils import pluralize, display_path
5
+ from janito.i18n import tr
6
+ import os
7
+ from .pattern_utils import prepare_pattern, format_result, summarize_total
8
+ from .match_lines import read_file_lines
9
+ from .traverse_directory import traverse_directory
10
+
11
+
12
+ @register_tool(name="search_text")
13
+ class SearchTextTool(ToolBase):
14
+ """
15
+ Search for a text pattern (regex or plain string) in all files within one or more directories or file paths and return matching lines or counts. Respects .gitignore.
16
+ Args:
17
+ paths (str): String of one or more paths (space-separated) to search in. Each path can be a directory or a file.
18
+ pattern (str): Regex pattern or plain text substring to search for in files. Must not be empty. Tries regex first, falls back to substring if regex is invalid.
19
+ is_regex (bool): If True, treat pattern as a regular expression. If False, treat as plain text (default).
20
+ max_depth (int, optional): Maximum directory depth to search. If 0 (default), search is recursive with no depth limit. If >0, limits recursion to that depth. Setting max_depth=1 disables recursion (only top-level directory). Ignored for file paths.
21
+ max_results (int, optional): Maximum number of results to return. Defaults to 100. 0 means no limit.
22
+ count_only (bool): If True, return only the count of matches per file and total, not the matching lines. Default is False.
23
+ Returns:
24
+ str: If count_only is False, matching lines from files as a newline-separated string, each formatted as 'filepath:lineno: line'.
25
+ If count_only is True, returns per-file and total match counts.
26
+ If max_results is reached, appends a note to the output.
27
+ """
28
+
29
+ def _handle_file(
30
+ self,
31
+ search_path,
32
+ pattern,
33
+ regex,
34
+ use_regex,
35
+ max_results,
36
+ total_results,
37
+ count_only,
38
+ ):
39
+ if count_only:
40
+ match_count, dir_limit_reached, _ = read_file_lines(
41
+ search_path,
42
+ pattern,
43
+ regex,
44
+ use_regex,
45
+ True,
46
+ max_results,
47
+ total_results,
48
+ )
49
+ per_file_counts = [(search_path, match_count)] if match_count > 0 else []
50
+ return [], dir_limit_reached, per_file_counts
51
+ else:
52
+ dir_output, dir_limit_reached, match_count_list = read_file_lines(
53
+ search_path,
54
+ pattern,
55
+ regex,
56
+ use_regex,
57
+ False,
58
+ max_results,
59
+ total_results,
60
+ )
61
+ per_file_counts = (
62
+ [(search_path, len(match_count_list))]
63
+ if match_count_list and len(match_count_list) > 0
64
+ else []
65
+ )
66
+ return dir_output, dir_limit_reached, per_file_counts
67
+
68
+ def _handle_path(
69
+ self,
70
+ search_path,
71
+ pattern,
72
+ regex,
73
+ use_regex,
74
+ max_depth,
75
+ max_results,
76
+ total_results,
77
+ count_only,
78
+ ):
79
+ info_str = tr(
80
+ "\U0001f50d Search {search_type} '{pattern}' in '{disp_path}'",
81
+ search_type=("regex" if use_regex else "text"),
82
+ pattern=pattern,
83
+ disp_path=display_path(search_path),
84
+ )
85
+ if max_depth > 0:
86
+ info_str += tr(" [max_depth={max_depth}]", max_depth=max_depth)
87
+ if count_only:
88
+ info_str += " [count]"
89
+ self.report_info(ActionType.READ, info_str)
90
+ if os.path.isfile(search_path):
91
+ dir_output, dir_limit_reached, per_file_counts = self._handle_file(
92
+ search_path,
93
+ pattern,
94
+ regex,
95
+ use_regex,
96
+ max_results,
97
+ total_results,
98
+ count_only,
99
+ )
100
+ else:
101
+ if count_only:
102
+ per_file_counts, dir_limit_reached, _ = traverse_directory(
103
+ search_path,
104
+ pattern,
105
+ regex,
106
+ use_regex,
107
+ max_depth,
108
+ max_results,
109
+ total_results,
110
+ True,
111
+ )
112
+ dir_output = []
113
+ else:
114
+ dir_output, dir_limit_reached, per_file_counts = traverse_directory(
115
+ search_path,
116
+ pattern,
117
+ regex,
118
+ use_regex,
119
+ max_depth,
120
+ max_results,
121
+ total_results,
122
+ False,
123
+ )
124
+ count = sum(count for _, count in per_file_counts)
125
+ file_word = pluralize("match", count)
126
+ self.report_success(
127
+ tr(" \u2705 {count} {file_word}", count=count, file_word=file_word)
128
+ )
129
+ return info_str, dir_output, dir_limit_reached, per_file_counts
130
+
131
+ def run(
132
+ self,
133
+ paths: str,
134
+ pattern: str,
135
+ is_regex: bool = False,
136
+ max_depth: int = 0,
137
+ max_results: int = 100,
138
+ count_only: bool = False,
139
+ ) -> str:
140
+ regex, use_regex, error_msg = prepare_pattern(
141
+ pattern, is_regex, self.report_error, self.report_warning
142
+ )
143
+ if error_msg:
144
+ return error_msg
145
+ paths_list = paths.split()
146
+ results = []
147
+ all_per_file_counts = []
148
+ for search_path in paths_list:
149
+ info_str, dir_output, dir_limit_reached, per_file_counts = (
150
+ self._handle_path(
151
+ search_path,
152
+ pattern,
153
+ regex,
154
+ use_regex,
155
+ max_depth,
156
+ max_results,
157
+ 0,
158
+ count_only,
159
+ )
160
+ )
161
+ if count_only:
162
+ all_per_file_counts.extend(per_file_counts)
163
+ result_str = format_result(
164
+ pattern,
165
+ use_regex,
166
+ dir_output,
167
+ dir_limit_reached,
168
+ count_only,
169
+ per_file_counts,
170
+ )
171
+ results.append(info_str + "\n" + result_str)
172
+ if dir_limit_reached:
173
+ break
174
+ if count_only:
175
+ results.append(summarize_total(all_per_file_counts))
176
+ return "\n\n".join(results)
@@ -0,0 +1,58 @@
1
+ import re
2
+ from janito.agent.tools_utils.gitignore_utils import GitignoreFilter
3
+ import os
4
+
5
+
6
+ def is_binary_file(path, blocksize=1024):
7
+ try:
8
+ with open(path, "rb") as f:
9
+ chunk = f.read(blocksize)
10
+ if b"\0" in chunk:
11
+ return True
12
+ text_characters = bytearray(
13
+ {7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x100))
14
+ )
15
+ nontext = chunk.translate(None, text_characters)
16
+ if len(nontext) / max(1, len(chunk)) > 0.3:
17
+ return True
18
+ except Exception:
19
+ return True
20
+ return False
21
+
22
+
23
+ def match_line(line, pattern, regex, use_regex):
24
+ if use_regex:
25
+ return regex and regex.search(line)
26
+ return pattern in line
27
+
28
+
29
+ def should_limit(max_results, total_results, match_count, count_only, dir_output):
30
+ if max_results > 0:
31
+ current_count = total_results + (match_count if count_only else len(dir_output))
32
+ return current_count >= max_results
33
+ return False
34
+
35
+
36
+ def read_file_lines(
37
+ path, pattern, regex, use_regex, count_only, max_results, total_results
38
+ ):
39
+ dir_output = []
40
+ dir_limit_reached = False
41
+ match_count = 0
42
+ if not is_binary_file(path):
43
+ try:
44
+ open_kwargs = {"mode": "r", "encoding": "utf-8"}
45
+ with open(path, **open_kwargs) as f:
46
+ for lineno, line in enumerate(f, 1):
47
+ if match_line(line, pattern, regex, use_regex):
48
+ match_count += 1
49
+ if not count_only:
50
+ dir_output.append(f"{path}:{lineno}: {line.rstrip()}")
51
+ if should_limit(
52
+ max_results, total_results, match_count, count_only, dir_output
53
+ ):
54
+ dir_limit_reached = True
55
+ break
56
+ except Exception:
57
+ pass
58
+ return match_count, dir_limit_reached, dir_output