just-bash 0.1.5__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.
- just_bash/__init__.py +55 -0
- just_bash/ast/__init__.py +213 -0
- just_bash/ast/factory.py +320 -0
- just_bash/ast/types.py +953 -0
- just_bash/bash.py +220 -0
- just_bash/commands/__init__.py +23 -0
- just_bash/commands/argv/__init__.py +5 -0
- just_bash/commands/argv/argv.py +21 -0
- just_bash/commands/awk/__init__.py +5 -0
- just_bash/commands/awk/awk.py +1168 -0
- just_bash/commands/base64/__init__.py +5 -0
- just_bash/commands/base64/base64.py +138 -0
- just_bash/commands/basename/__init__.py +5 -0
- just_bash/commands/basename/basename.py +72 -0
- just_bash/commands/bash/__init__.py +5 -0
- just_bash/commands/bash/bash.py +188 -0
- just_bash/commands/cat/__init__.py +5 -0
- just_bash/commands/cat/cat.py +173 -0
- just_bash/commands/checksum/__init__.py +5 -0
- just_bash/commands/checksum/checksum.py +179 -0
- just_bash/commands/chmod/__init__.py +5 -0
- just_bash/commands/chmod/chmod.py +216 -0
- just_bash/commands/column/__init__.py +5 -0
- just_bash/commands/column/column.py +180 -0
- just_bash/commands/comm/__init__.py +5 -0
- just_bash/commands/comm/comm.py +150 -0
- just_bash/commands/compression/__init__.py +5 -0
- just_bash/commands/compression/compression.py +298 -0
- just_bash/commands/cp/__init__.py +5 -0
- just_bash/commands/cp/cp.py +149 -0
- just_bash/commands/curl/__init__.py +5 -0
- just_bash/commands/curl/curl.py +801 -0
- just_bash/commands/cut/__init__.py +5 -0
- just_bash/commands/cut/cut.py +327 -0
- just_bash/commands/date/__init__.py +5 -0
- just_bash/commands/date/date.py +258 -0
- just_bash/commands/diff/__init__.py +5 -0
- just_bash/commands/diff/diff.py +118 -0
- just_bash/commands/dirname/__init__.py +5 -0
- just_bash/commands/dirname/dirname.py +56 -0
- just_bash/commands/du/__init__.py +5 -0
- just_bash/commands/du/du.py +150 -0
- just_bash/commands/echo/__init__.py +5 -0
- just_bash/commands/echo/echo.py +125 -0
- just_bash/commands/env/__init__.py +5 -0
- just_bash/commands/env/env.py +163 -0
- just_bash/commands/expand/__init__.py +5 -0
- just_bash/commands/expand/expand.py +299 -0
- just_bash/commands/expr/__init__.py +5 -0
- just_bash/commands/expr/expr.py +273 -0
- just_bash/commands/file/__init__.py +5 -0
- just_bash/commands/file/file.py +274 -0
- just_bash/commands/find/__init__.py +5 -0
- just_bash/commands/find/find.py +623 -0
- just_bash/commands/fold/__init__.py +5 -0
- just_bash/commands/fold/fold.py +160 -0
- just_bash/commands/grep/__init__.py +5 -0
- just_bash/commands/grep/grep.py +418 -0
- just_bash/commands/head/__init__.py +5 -0
- just_bash/commands/head/head.py +167 -0
- just_bash/commands/help/__init__.py +5 -0
- just_bash/commands/help/help.py +67 -0
- just_bash/commands/hostname/__init__.py +5 -0
- just_bash/commands/hostname/hostname.py +21 -0
- just_bash/commands/html_to_markdown/__init__.py +5 -0
- just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
- just_bash/commands/join/__init__.py +5 -0
- just_bash/commands/join/join.py +252 -0
- just_bash/commands/jq/__init__.py +5 -0
- just_bash/commands/jq/jq.py +280 -0
- just_bash/commands/ln/__init__.py +5 -0
- just_bash/commands/ln/ln.py +127 -0
- just_bash/commands/ls/__init__.py +5 -0
- just_bash/commands/ls/ls.py +280 -0
- just_bash/commands/mkdir/__init__.py +5 -0
- just_bash/commands/mkdir/mkdir.py +92 -0
- just_bash/commands/mv/__init__.py +5 -0
- just_bash/commands/mv/mv.py +142 -0
- just_bash/commands/nl/__init__.py +5 -0
- just_bash/commands/nl/nl.py +180 -0
- just_bash/commands/od/__init__.py +5 -0
- just_bash/commands/od/od.py +157 -0
- just_bash/commands/paste/__init__.py +5 -0
- just_bash/commands/paste/paste.py +100 -0
- just_bash/commands/printf/__init__.py +5 -0
- just_bash/commands/printf/printf.py +157 -0
- just_bash/commands/pwd/__init__.py +5 -0
- just_bash/commands/pwd/pwd.py +23 -0
- just_bash/commands/read/__init__.py +5 -0
- just_bash/commands/read/read.py +185 -0
- just_bash/commands/readlink/__init__.py +5 -0
- just_bash/commands/readlink/readlink.py +86 -0
- just_bash/commands/registry.py +844 -0
- just_bash/commands/rev/__init__.py +5 -0
- just_bash/commands/rev/rev.py +74 -0
- just_bash/commands/rg/__init__.py +5 -0
- just_bash/commands/rg/rg.py +1048 -0
- just_bash/commands/rm/__init__.py +5 -0
- just_bash/commands/rm/rm.py +106 -0
- just_bash/commands/search_engine/__init__.py +13 -0
- just_bash/commands/search_engine/matcher.py +170 -0
- just_bash/commands/search_engine/regex.py +159 -0
- just_bash/commands/sed/__init__.py +5 -0
- just_bash/commands/sed/sed.py +863 -0
- just_bash/commands/seq/__init__.py +5 -0
- just_bash/commands/seq/seq.py +190 -0
- just_bash/commands/shell/__init__.py +5 -0
- just_bash/commands/shell/shell.py +206 -0
- just_bash/commands/sleep/__init__.py +5 -0
- just_bash/commands/sleep/sleep.py +62 -0
- just_bash/commands/sort/__init__.py +5 -0
- just_bash/commands/sort/sort.py +411 -0
- just_bash/commands/split/__init__.py +5 -0
- just_bash/commands/split/split.py +237 -0
- just_bash/commands/sqlite3/__init__.py +5 -0
- just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
- just_bash/commands/stat/__init__.py +5 -0
- just_bash/commands/stat/stat.py +150 -0
- just_bash/commands/strings/__init__.py +5 -0
- just_bash/commands/strings/strings.py +150 -0
- just_bash/commands/tac/__init__.py +5 -0
- just_bash/commands/tac/tac.py +158 -0
- just_bash/commands/tail/__init__.py +5 -0
- just_bash/commands/tail/tail.py +180 -0
- just_bash/commands/tar/__init__.py +5 -0
- just_bash/commands/tar/tar.py +1067 -0
- just_bash/commands/tee/__init__.py +5 -0
- just_bash/commands/tee/tee.py +63 -0
- just_bash/commands/timeout/__init__.py +5 -0
- just_bash/commands/timeout/timeout.py +188 -0
- just_bash/commands/touch/__init__.py +5 -0
- just_bash/commands/touch/touch.py +91 -0
- just_bash/commands/tr/__init__.py +5 -0
- just_bash/commands/tr/tr.py +297 -0
- just_bash/commands/tree/__init__.py +5 -0
- just_bash/commands/tree/tree.py +139 -0
- just_bash/commands/true/__init__.py +5 -0
- just_bash/commands/true/true.py +32 -0
- just_bash/commands/uniq/__init__.py +5 -0
- just_bash/commands/uniq/uniq.py +323 -0
- just_bash/commands/wc/__init__.py +5 -0
- just_bash/commands/wc/wc.py +169 -0
- just_bash/commands/which/__init__.py +5 -0
- just_bash/commands/which/which.py +52 -0
- just_bash/commands/xan/__init__.py +5 -0
- just_bash/commands/xan/xan.py +1663 -0
- just_bash/commands/xargs/__init__.py +5 -0
- just_bash/commands/xargs/xargs.py +136 -0
- just_bash/commands/yq/__init__.py +5 -0
- just_bash/commands/yq/yq.py +848 -0
- just_bash/fs/__init__.py +29 -0
- just_bash/fs/in_memory_fs.py +621 -0
- just_bash/fs/mountable_fs.py +504 -0
- just_bash/fs/overlay_fs.py +894 -0
- just_bash/fs/read_write_fs.py +455 -0
- just_bash/interpreter/__init__.py +37 -0
- just_bash/interpreter/builtins/__init__.py +92 -0
- just_bash/interpreter/builtins/alias.py +154 -0
- just_bash/interpreter/builtins/cd.py +76 -0
- just_bash/interpreter/builtins/control.py +127 -0
- just_bash/interpreter/builtins/declare.py +336 -0
- just_bash/interpreter/builtins/export.py +56 -0
- just_bash/interpreter/builtins/let.py +44 -0
- just_bash/interpreter/builtins/local.py +57 -0
- just_bash/interpreter/builtins/mapfile.py +152 -0
- just_bash/interpreter/builtins/misc.py +378 -0
- just_bash/interpreter/builtins/readonly.py +80 -0
- just_bash/interpreter/builtins/set.py +234 -0
- just_bash/interpreter/builtins/shopt.py +201 -0
- just_bash/interpreter/builtins/source.py +136 -0
- just_bash/interpreter/builtins/test.py +290 -0
- just_bash/interpreter/builtins/unset.py +53 -0
- just_bash/interpreter/conditionals.py +387 -0
- just_bash/interpreter/control_flow.py +381 -0
- just_bash/interpreter/errors.py +116 -0
- just_bash/interpreter/expansion.py +1156 -0
- just_bash/interpreter/interpreter.py +813 -0
- just_bash/interpreter/types.py +134 -0
- just_bash/network/__init__.py +1 -0
- just_bash/parser/__init__.py +39 -0
- just_bash/parser/lexer.py +948 -0
- just_bash/parser/parser.py +2162 -0
- just_bash/py.typed +0 -0
- just_bash/query_engine/__init__.py +83 -0
- just_bash/query_engine/builtins/__init__.py +1283 -0
- just_bash/query_engine/evaluator.py +578 -0
- just_bash/query_engine/parser.py +525 -0
- just_bash/query_engine/tokenizer.py +329 -0
- just_bash/query_engine/types.py +373 -0
- just_bash/types.py +180 -0
- just_bash-0.1.5.dist-info/METADATA +410 -0
- just_bash-0.1.5.dist-info/RECORD +193 -0
- just_bash-0.1.5.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
"""Rg (ripgrep) command implementation.
|
|
2
|
+
|
|
3
|
+
A feature-complete ripgrep emulation with:
|
|
4
|
+
- Recursive search by default
|
|
5
|
+
- Smart case sensitivity
|
|
6
|
+
- .gitignore support
|
|
7
|
+
- File type filtering
|
|
8
|
+
- Glob patterns
|
|
9
|
+
- Context lines
|
|
10
|
+
- Multiple output formats
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import fnmatch
|
|
14
|
+
import re
|
|
15
|
+
from typing import Optional
|
|
16
|
+
from ...types import CommandContext, ExecResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# File type definitions (subset of ripgrep's types)
|
|
20
|
+
FILE_TYPES: dict[str, list[str]] = {
|
|
21
|
+
"py": ["*.py", "*.pyi", "*.pyw"],
|
|
22
|
+
"python": ["*.py", "*.pyi", "*.pyw"],
|
|
23
|
+
"js": ["*.js", "*.mjs", "*.cjs", "*.jsx"],
|
|
24
|
+
"javascript": ["*.js", "*.mjs", "*.cjs", "*.jsx"],
|
|
25
|
+
"ts": ["*.ts", "*.tsx", "*.mts", "*.cts"],
|
|
26
|
+
"typescript": ["*.ts", "*.tsx", "*.mts", "*.cts"],
|
|
27
|
+
"json": ["*.json", "*.jsonl", "*.geojson"],
|
|
28
|
+
"yaml": ["*.yaml", "*.yml"],
|
|
29
|
+
"yml": ["*.yaml", "*.yml"],
|
|
30
|
+
"xml": ["*.xml", "*.xsl", "*.xslt", "*.svg"],
|
|
31
|
+
"html": ["*.html", "*.htm", "*.xhtml"],
|
|
32
|
+
"css": ["*.css", "*.scss", "*.sass", "*.less"],
|
|
33
|
+
"md": ["*.md", "*.markdown", "*.mdown"],
|
|
34
|
+
"markdown": ["*.md", "*.markdown", "*.mdown"],
|
|
35
|
+
"txt": ["*.txt", "*.text"],
|
|
36
|
+
"c": ["*.c", "*.h"],
|
|
37
|
+
"cpp": ["*.cpp", "*.cc", "*.cxx", "*.hpp", "*.hh", "*.hxx", "*.c++", "*.h++"],
|
|
38
|
+
"java": ["*.java"],
|
|
39
|
+
"go": ["*.go"],
|
|
40
|
+
"rust": ["*.rs"],
|
|
41
|
+
"rs": ["*.rs"],
|
|
42
|
+
"rb": ["*.rb", "*.ruby", "*.gemspec", "Rakefile"],
|
|
43
|
+
"ruby": ["*.rb", "*.ruby", "*.gemspec", "Rakefile"],
|
|
44
|
+
"sh": ["*.sh", "*.bash", "*.zsh", "*.fish"],
|
|
45
|
+
"shell": ["*.sh", "*.bash", "*.zsh", "*.fish"],
|
|
46
|
+
"sql": ["*.sql"],
|
|
47
|
+
"r": ["*.r", "*.R", "*.Rmd"],
|
|
48
|
+
"php": ["*.php", "*.php3", "*.php4", "*.php5", "*.phtml"],
|
|
49
|
+
"swift": ["*.swift"],
|
|
50
|
+
"kotlin": ["*.kt", "*.kts"],
|
|
51
|
+
"scala": ["*.scala", "*.sc"],
|
|
52
|
+
"lua": ["*.lua"],
|
|
53
|
+
"perl": ["*.pl", "*.pm", "*.t"],
|
|
54
|
+
"toml": ["*.toml"],
|
|
55
|
+
"ini": ["*.ini", "*.cfg", "*.conf"],
|
|
56
|
+
"make": ["Makefile", "*.mk", "GNUmakefile"],
|
|
57
|
+
"cmake": ["CMakeLists.txt", "*.cmake"],
|
|
58
|
+
"docker": ["Dockerfile", "*.dockerfile"],
|
|
59
|
+
"tf": ["*.tf", "*.tfvars"],
|
|
60
|
+
"terraform": ["*.tf", "*.tfvars"],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def format_type_list() -> str:
|
|
65
|
+
"""Format the type list for --type-list output."""
|
|
66
|
+
lines = []
|
|
67
|
+
seen = set()
|
|
68
|
+
for name, patterns in sorted(FILE_TYPES.items()):
|
|
69
|
+
if name not in seen:
|
|
70
|
+
lines.append(f"{name}: {', '.join(patterns)}")
|
|
71
|
+
seen.add(name)
|
|
72
|
+
return "\n".join(lines) + "\n"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RgOptions:
|
|
76
|
+
"""Parsed options for rg command."""
|
|
77
|
+
|
|
78
|
+
def __init__(self):
|
|
79
|
+
# Patterns
|
|
80
|
+
self.patterns: list[str] = []
|
|
81
|
+
self.pattern_file: Optional[str] = None
|
|
82
|
+
|
|
83
|
+
# Case sensitivity
|
|
84
|
+
self.ignore_case = False
|
|
85
|
+
self.case_sensitive = False
|
|
86
|
+
self.smart_case = False
|
|
87
|
+
|
|
88
|
+
# Pattern matching
|
|
89
|
+
self.fixed_strings = False
|
|
90
|
+
self.word_regexp = False
|
|
91
|
+
self.line_regexp = False
|
|
92
|
+
self.invert_match = False
|
|
93
|
+
self.multiline = False
|
|
94
|
+
self.multiline_dotall = False
|
|
95
|
+
|
|
96
|
+
# Output modes
|
|
97
|
+
self.count = False
|
|
98
|
+
self.count_matches = False
|
|
99
|
+
self.files_with_matches = False
|
|
100
|
+
self.files_without_match = False
|
|
101
|
+
self.files_only = False # --files
|
|
102
|
+
self.only_matching = False
|
|
103
|
+
self.quiet = False
|
|
104
|
+
self.stats = False
|
|
105
|
+
|
|
106
|
+
# Line/column display
|
|
107
|
+
self.line_numbers = True # On by default
|
|
108
|
+
self.explicit_line_numbers = False
|
|
109
|
+
self.no_filename = False
|
|
110
|
+
self.column = False
|
|
111
|
+
self.byte_offset = False
|
|
112
|
+
self.null_separator = False
|
|
113
|
+
|
|
114
|
+
# Output formats
|
|
115
|
+
self.json = False
|
|
116
|
+
self.vimgrep = False
|
|
117
|
+
self.heading = False
|
|
118
|
+
|
|
119
|
+
# Context
|
|
120
|
+
self.after_context = 0
|
|
121
|
+
self.before_context = 0
|
|
122
|
+
|
|
123
|
+
# Replacement
|
|
124
|
+
self.replace: Optional[str] = None
|
|
125
|
+
|
|
126
|
+
# Limits
|
|
127
|
+
self.max_count: Optional[int] = None
|
|
128
|
+
self.max_depth: Optional[int] = None
|
|
129
|
+
|
|
130
|
+
# File selection
|
|
131
|
+
self.hidden = False
|
|
132
|
+
self.no_ignore = False
|
|
133
|
+
self.unrestricted_level = 0
|
|
134
|
+
self.globs: list[str] = []
|
|
135
|
+
self.types: list[str] = []
|
|
136
|
+
self.types_not: list[str] = []
|
|
137
|
+
self.search_binary = False
|
|
138
|
+
|
|
139
|
+
# Other
|
|
140
|
+
self.sort_by: Optional[str] = None
|
|
141
|
+
self.passthru = False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class RgCommand:
|
|
145
|
+
"""The rg (ripgrep) command - recursive grep."""
|
|
146
|
+
|
|
147
|
+
name = "rg"
|
|
148
|
+
|
|
149
|
+
async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
|
|
150
|
+
"""Execute the rg command."""
|
|
151
|
+
# Parse arguments
|
|
152
|
+
opts, paths, error = self._parse_args(args, ctx)
|
|
153
|
+
if error:
|
|
154
|
+
return error
|
|
155
|
+
|
|
156
|
+
# Handle special modes
|
|
157
|
+
if opts.files_only:
|
|
158
|
+
return await self._list_files(ctx, paths, opts)
|
|
159
|
+
|
|
160
|
+
# Load patterns from file if specified
|
|
161
|
+
if opts.pattern_file:
|
|
162
|
+
try:
|
|
163
|
+
path = ctx.fs.resolve_path(ctx.cwd, opts.pattern_file)
|
|
164
|
+
content = await ctx.fs.read_file(path)
|
|
165
|
+
for line in content.splitlines():
|
|
166
|
+
if line.strip():
|
|
167
|
+
opts.patterns.append(line.strip())
|
|
168
|
+
except Exception as e:
|
|
169
|
+
return ExecResult(
|
|
170
|
+
stdout="",
|
|
171
|
+
stderr=f"rg: {opts.pattern_file}: {e}\n",
|
|
172
|
+
exit_code=2,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if not opts.patterns:
|
|
176
|
+
return ExecResult(
|
|
177
|
+
stdout="",
|
|
178
|
+
stderr="rg: no pattern given\n",
|
|
179
|
+
exit_code=2,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Build regex
|
|
183
|
+
try:
|
|
184
|
+
regex = self._build_regex(opts)
|
|
185
|
+
except re.error as e:
|
|
186
|
+
return ExecResult(
|
|
187
|
+
stdout="",
|
|
188
|
+
stderr=f"rg: regex error: {e}\n",
|
|
189
|
+
exit_code=2,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Execute search
|
|
193
|
+
return await self._search(ctx, paths, regex, opts)
|
|
194
|
+
|
|
195
|
+
def _parse_args(self, args: list[str], ctx: CommandContext) -> tuple[RgOptions, list[str], Optional[ExecResult]]:
|
|
196
|
+
"""Parse command line arguments."""
|
|
197
|
+
opts = RgOptions()
|
|
198
|
+
paths: list[str] = []
|
|
199
|
+
i = 0
|
|
200
|
+
|
|
201
|
+
while i < len(args):
|
|
202
|
+
arg = args[i]
|
|
203
|
+
|
|
204
|
+
if arg == "--":
|
|
205
|
+
# Everything after -- is paths
|
|
206
|
+
paths.extend(args[i + 1:])
|
|
207
|
+
break
|
|
208
|
+
elif arg == "--help":
|
|
209
|
+
return opts, paths, self._show_help()
|
|
210
|
+
elif arg == "--type-list":
|
|
211
|
+
return opts, paths, ExecResult(
|
|
212
|
+
stdout=format_type_list(),
|
|
213
|
+
stderr="",
|
|
214
|
+
exit_code=0,
|
|
215
|
+
)
|
|
216
|
+
elif arg == "--version":
|
|
217
|
+
return opts, paths, ExecResult(
|
|
218
|
+
stdout="rg (just-bash) 0.1.0\n",
|
|
219
|
+
stderr="",
|
|
220
|
+
exit_code=0,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Long options with values
|
|
224
|
+
elif arg.startswith("--") and "=" in arg:
|
|
225
|
+
key, value = arg.split("=", 1)
|
|
226
|
+
if key in ("--regexp", "-e"):
|
|
227
|
+
opts.patterns.append(value)
|
|
228
|
+
elif key in ("--file", "-f"):
|
|
229
|
+
opts.pattern_file = value
|
|
230
|
+
elif key in ("--replace", "-r"):
|
|
231
|
+
opts.replace = value
|
|
232
|
+
elif key in ("--max-count", "-m"):
|
|
233
|
+
opts.max_count = int(value)
|
|
234
|
+
elif key in ("--max-depth", "-d"):
|
|
235
|
+
opts.max_depth = int(value)
|
|
236
|
+
elif key in ("--glob", "-g"):
|
|
237
|
+
opts.globs.append(value)
|
|
238
|
+
elif key in ("--type", "-t"):
|
|
239
|
+
opts.types.append(value)
|
|
240
|
+
elif key in ("--type-not", "-T"):
|
|
241
|
+
opts.types_not.append(value)
|
|
242
|
+
elif key == "--context-separator":
|
|
243
|
+
pass # Accept but ignore
|
|
244
|
+
elif key == "--sort":
|
|
245
|
+
opts.sort_by = value
|
|
246
|
+
elif key in ("-A", "--after-context"):
|
|
247
|
+
opts.after_context = int(value)
|
|
248
|
+
elif key in ("-B", "--before-context"):
|
|
249
|
+
opts.before_context = int(value)
|
|
250
|
+
elif key in ("-C", "--context"):
|
|
251
|
+
opts.before_context = opts.after_context = int(value)
|
|
252
|
+
|
|
253
|
+
# Long options
|
|
254
|
+
elif arg.startswith("--"):
|
|
255
|
+
if arg == "--ignore-case":
|
|
256
|
+
opts.ignore_case = True
|
|
257
|
+
elif arg == "--case-sensitive":
|
|
258
|
+
opts.case_sensitive = True
|
|
259
|
+
elif arg == "--smart-case":
|
|
260
|
+
opts.smart_case = True
|
|
261
|
+
elif arg == "--fixed-strings":
|
|
262
|
+
opts.fixed_strings = True
|
|
263
|
+
elif arg == "--word-regexp":
|
|
264
|
+
opts.word_regexp = True
|
|
265
|
+
elif arg == "--line-regexp":
|
|
266
|
+
opts.line_regexp = True
|
|
267
|
+
elif arg == "--invert-match":
|
|
268
|
+
opts.invert_match = True
|
|
269
|
+
elif arg == "--multiline":
|
|
270
|
+
opts.multiline = True
|
|
271
|
+
elif arg == "--multiline-dotall":
|
|
272
|
+
opts.multiline_dotall = True
|
|
273
|
+
elif arg == "--count":
|
|
274
|
+
opts.count = True
|
|
275
|
+
elif arg == "--count-matches":
|
|
276
|
+
opts.count_matches = True
|
|
277
|
+
elif arg == "--files-with-matches":
|
|
278
|
+
opts.files_with_matches = True
|
|
279
|
+
elif arg == "--files-without-match":
|
|
280
|
+
opts.files_without_match = True
|
|
281
|
+
elif arg == "--files":
|
|
282
|
+
opts.files_only = True
|
|
283
|
+
elif arg == "--only-matching":
|
|
284
|
+
opts.only_matching = True
|
|
285
|
+
elif arg == "--quiet":
|
|
286
|
+
opts.quiet = True
|
|
287
|
+
elif arg == "--stats":
|
|
288
|
+
opts.stats = True
|
|
289
|
+
elif arg == "--line-number":
|
|
290
|
+
opts.line_numbers = True
|
|
291
|
+
opts.explicit_line_numbers = True
|
|
292
|
+
elif arg == "--no-line-number":
|
|
293
|
+
opts.line_numbers = False
|
|
294
|
+
elif arg == "--with-filename":
|
|
295
|
+
opts.no_filename = False
|
|
296
|
+
elif arg == "--no-filename":
|
|
297
|
+
opts.no_filename = True
|
|
298
|
+
elif arg == "--column":
|
|
299
|
+
opts.column = True
|
|
300
|
+
elif arg == "--no-column":
|
|
301
|
+
opts.column = False
|
|
302
|
+
elif arg == "--byte-offset":
|
|
303
|
+
opts.byte_offset = True
|
|
304
|
+
elif arg == "--null":
|
|
305
|
+
opts.null_separator = True
|
|
306
|
+
elif arg == "--json":
|
|
307
|
+
opts.json = True
|
|
308
|
+
elif arg == "--vimgrep":
|
|
309
|
+
opts.vimgrep = True
|
|
310
|
+
opts.line_numbers = True
|
|
311
|
+
opts.column = True
|
|
312
|
+
elif arg == "--heading":
|
|
313
|
+
opts.heading = True
|
|
314
|
+
elif arg == "--passthru":
|
|
315
|
+
opts.passthru = True
|
|
316
|
+
elif arg == "--hidden":
|
|
317
|
+
opts.hidden = True
|
|
318
|
+
elif arg == "--no-ignore":
|
|
319
|
+
opts.no_ignore = True
|
|
320
|
+
elif arg == "--no-ignore-dot":
|
|
321
|
+
opts.no_ignore = True
|
|
322
|
+
elif arg == "--no-ignore-vcs":
|
|
323
|
+
opts.no_ignore = True
|
|
324
|
+
elif arg == "--text":
|
|
325
|
+
opts.search_binary = True
|
|
326
|
+
elif arg == "--sort":
|
|
327
|
+
# Next arg is value
|
|
328
|
+
if i + 1 < len(args):
|
|
329
|
+
i += 1
|
|
330
|
+
opts.sort_by = args[i]
|
|
331
|
+
elif arg in ("--regexp", "--file", "--replace", "--max-count",
|
|
332
|
+
"--max-depth", "--glob", "--type", "--type-not",
|
|
333
|
+
"--after-context", "--before-context", "--context"):
|
|
334
|
+
# These need values
|
|
335
|
+
if i + 1 < len(args):
|
|
336
|
+
i += 1
|
|
337
|
+
value = args[i]
|
|
338
|
+
if arg == "--regexp":
|
|
339
|
+
opts.patterns.append(value)
|
|
340
|
+
elif arg == "--file":
|
|
341
|
+
opts.pattern_file = value
|
|
342
|
+
elif arg == "--replace":
|
|
343
|
+
opts.replace = value
|
|
344
|
+
elif arg == "--max-count":
|
|
345
|
+
opts.max_count = int(value)
|
|
346
|
+
elif arg == "--max-depth":
|
|
347
|
+
opts.max_depth = int(value)
|
|
348
|
+
elif arg == "--glob":
|
|
349
|
+
opts.globs.append(value)
|
|
350
|
+
elif arg == "--type":
|
|
351
|
+
opts.types.append(value)
|
|
352
|
+
elif arg == "--type-not":
|
|
353
|
+
opts.types_not.append(value)
|
|
354
|
+
elif arg == "--after-context":
|
|
355
|
+
opts.after_context = int(value)
|
|
356
|
+
elif arg == "--before-context":
|
|
357
|
+
opts.before_context = int(value)
|
|
358
|
+
elif arg == "--context":
|
|
359
|
+
opts.before_context = opts.after_context = int(value)
|
|
360
|
+
# Ignore other unknown long options
|
|
361
|
+
|
|
362
|
+
# Short options
|
|
363
|
+
elif arg.startswith("-") and arg != "-":
|
|
364
|
+
j = 1
|
|
365
|
+
while j < len(arg):
|
|
366
|
+
c = arg[j]
|
|
367
|
+
if c == "i":
|
|
368
|
+
opts.ignore_case = True
|
|
369
|
+
elif c == "s":
|
|
370
|
+
opts.case_sensitive = True
|
|
371
|
+
elif c == "S":
|
|
372
|
+
opts.smart_case = True
|
|
373
|
+
elif c == "F":
|
|
374
|
+
opts.fixed_strings = True
|
|
375
|
+
elif c == "w":
|
|
376
|
+
opts.word_regexp = True
|
|
377
|
+
elif c == "x":
|
|
378
|
+
opts.line_regexp = True
|
|
379
|
+
elif c == "v":
|
|
380
|
+
opts.invert_match = True
|
|
381
|
+
elif c == "U":
|
|
382
|
+
opts.multiline = True
|
|
383
|
+
elif c == "c":
|
|
384
|
+
opts.count = True
|
|
385
|
+
elif c == "l":
|
|
386
|
+
opts.files_with_matches = True
|
|
387
|
+
elif c == "o":
|
|
388
|
+
opts.only_matching = True
|
|
389
|
+
elif c == "q":
|
|
390
|
+
opts.quiet = True
|
|
391
|
+
elif c == "n":
|
|
392
|
+
opts.line_numbers = True
|
|
393
|
+
opts.explicit_line_numbers = True
|
|
394
|
+
elif c == "N":
|
|
395
|
+
opts.line_numbers = False
|
|
396
|
+
elif c == "H":
|
|
397
|
+
opts.no_filename = False
|
|
398
|
+
elif c == "I":
|
|
399
|
+
opts.no_filename = True
|
|
400
|
+
elif c == "b":
|
|
401
|
+
opts.byte_offset = True
|
|
402
|
+
elif c == "0":
|
|
403
|
+
opts.null_separator = True
|
|
404
|
+
elif c == "a":
|
|
405
|
+
opts.search_binary = True
|
|
406
|
+
elif c == "u":
|
|
407
|
+
opts.unrestricted_level += 1
|
|
408
|
+
if opts.unrestricted_level >= 1:
|
|
409
|
+
opts.no_ignore = True
|
|
410
|
+
if opts.unrestricted_level >= 2:
|
|
411
|
+
opts.hidden = True
|
|
412
|
+
if opts.unrestricted_level >= 3:
|
|
413
|
+
opts.search_binary = True
|
|
414
|
+
elif c in ("e", "f", "r", "m", "d", "g", "t", "T", "A", "B", "C"):
|
|
415
|
+
# These need values - either rest of arg or next arg
|
|
416
|
+
if j + 1 < len(arg):
|
|
417
|
+
value = arg[j + 1:]
|
|
418
|
+
elif i + 1 < len(args):
|
|
419
|
+
i += 1
|
|
420
|
+
value = args[i]
|
|
421
|
+
else:
|
|
422
|
+
value = ""
|
|
423
|
+
if c == "e":
|
|
424
|
+
opts.patterns.append(value)
|
|
425
|
+
elif c == "f":
|
|
426
|
+
opts.pattern_file = value
|
|
427
|
+
elif c == "r":
|
|
428
|
+
opts.replace = value
|
|
429
|
+
elif c == "m":
|
|
430
|
+
opts.max_count = int(value)
|
|
431
|
+
elif c == "d":
|
|
432
|
+
opts.max_depth = int(value)
|
|
433
|
+
elif c == "g":
|
|
434
|
+
opts.globs.append(value)
|
|
435
|
+
elif c == "t":
|
|
436
|
+
opts.types.append(value)
|
|
437
|
+
elif c == "T":
|
|
438
|
+
opts.types_not.append(value)
|
|
439
|
+
elif c == "A":
|
|
440
|
+
opts.after_context = int(value)
|
|
441
|
+
elif c == "B":
|
|
442
|
+
opts.before_context = int(value)
|
|
443
|
+
elif c == "C":
|
|
444
|
+
opts.before_context = opts.after_context = int(value)
|
|
445
|
+
break
|
|
446
|
+
j += 1
|
|
447
|
+
|
|
448
|
+
# Positional arguments
|
|
449
|
+
# In --files mode, all positional args are paths (no pattern needed)
|
|
450
|
+
# With -f (pattern file), all positional args are also paths
|
|
451
|
+
elif opts.files_only or opts.pattern_file:
|
|
452
|
+
paths.append(arg)
|
|
453
|
+
elif not opts.patterns:
|
|
454
|
+
opts.patterns.append(arg)
|
|
455
|
+
else:
|
|
456
|
+
paths.append(arg)
|
|
457
|
+
i += 1
|
|
458
|
+
|
|
459
|
+
# Default to current directory
|
|
460
|
+
if not paths:
|
|
461
|
+
paths = ["."]
|
|
462
|
+
|
|
463
|
+
return opts, paths, None
|
|
464
|
+
|
|
465
|
+
def _build_regex(self, opts: RgOptions) -> re.Pattern:
|
|
466
|
+
"""Build the regex pattern from options."""
|
|
467
|
+
# Combine patterns with OR
|
|
468
|
+
if len(opts.patterns) == 1:
|
|
469
|
+
pattern = opts.patterns[0]
|
|
470
|
+
else:
|
|
471
|
+
# Escape for combining if using fixed strings
|
|
472
|
+
if opts.fixed_strings:
|
|
473
|
+
parts = [re.escape(p) for p in opts.patterns]
|
|
474
|
+
else:
|
|
475
|
+
parts = [f"(?:{p})" for p in opts.patterns]
|
|
476
|
+
pattern = "|".join(parts)
|
|
477
|
+
|
|
478
|
+
# Handle fixed strings
|
|
479
|
+
if opts.fixed_strings and len(opts.patterns) == 1:
|
|
480
|
+
pattern = re.escape(pattern)
|
|
481
|
+
|
|
482
|
+
# Handle word/line matching
|
|
483
|
+
if opts.word_regexp:
|
|
484
|
+
pattern = r"\b(?:" + pattern + r")\b"
|
|
485
|
+
if opts.line_regexp:
|
|
486
|
+
pattern = "^(?:" + pattern + ")$"
|
|
487
|
+
|
|
488
|
+
# Determine case sensitivity
|
|
489
|
+
flags = 0
|
|
490
|
+
if opts.case_sensitive:
|
|
491
|
+
pass # Case sensitive
|
|
492
|
+
elif opts.ignore_case:
|
|
493
|
+
flags |= re.IGNORECASE
|
|
494
|
+
elif opts.smart_case:
|
|
495
|
+
# Smart case: case insensitive unless pattern has uppercase
|
|
496
|
+
has_upper = any(c.isupper() for c in pattern if c.isalpha())
|
|
497
|
+
if not has_upper:
|
|
498
|
+
flags |= re.IGNORECASE
|
|
499
|
+
|
|
500
|
+
# Multiline
|
|
501
|
+
if opts.multiline:
|
|
502
|
+
flags |= re.MULTILINE
|
|
503
|
+
if opts.multiline_dotall:
|
|
504
|
+
flags |= re.DOTALL
|
|
505
|
+
|
|
506
|
+
return re.compile(pattern, flags)
|
|
507
|
+
|
|
508
|
+
async def _search(self, ctx: CommandContext, paths: list[str], regex: re.Pattern, opts: RgOptions) -> ExecResult:
|
|
509
|
+
"""Execute the search."""
|
|
510
|
+
results: list[str] = []
|
|
511
|
+
stats = {"files": 0, "matches": 0, "lines": 0}
|
|
512
|
+
found_any = False
|
|
513
|
+
|
|
514
|
+
for path in paths:
|
|
515
|
+
if path == "-":
|
|
516
|
+
# Search stdin
|
|
517
|
+
found = await self._search_content(
|
|
518
|
+
ctx, "(standard input)", ctx.stdin, regex, opts, results, stats
|
|
519
|
+
)
|
|
520
|
+
if found:
|
|
521
|
+
found_any = True
|
|
522
|
+
else:
|
|
523
|
+
try:
|
|
524
|
+
resolved = ctx.fs.resolve_path(ctx.cwd, path)
|
|
525
|
+
stat = await ctx.fs.stat(resolved)
|
|
526
|
+
|
|
527
|
+
if stat.is_directory:
|
|
528
|
+
found = await self._search_directory(
|
|
529
|
+
ctx, resolved, path, regex, opts, results, stats, depth=0
|
|
530
|
+
)
|
|
531
|
+
if found:
|
|
532
|
+
found_any = True
|
|
533
|
+
else:
|
|
534
|
+
found = await self._search_file(
|
|
535
|
+
ctx, resolved, path, regex, opts, results, stats
|
|
536
|
+
)
|
|
537
|
+
if found:
|
|
538
|
+
found_any = True
|
|
539
|
+
except FileNotFoundError:
|
|
540
|
+
pass
|
|
541
|
+
except Exception:
|
|
542
|
+
pass
|
|
543
|
+
|
|
544
|
+
# Handle quiet mode
|
|
545
|
+
if opts.quiet:
|
|
546
|
+
return ExecResult(
|
|
547
|
+
stdout="",
|
|
548
|
+
stderr="",
|
|
549
|
+
exit_code=0 if found_any else 1,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# Build output
|
|
553
|
+
output = "\n".join(results)
|
|
554
|
+
if output:
|
|
555
|
+
output += "\n"
|
|
556
|
+
|
|
557
|
+
# Add stats if requested
|
|
558
|
+
if opts.stats:
|
|
559
|
+
output += f"\n{stats['matches']} matches\n"
|
|
560
|
+
output += f"{stats['lines']} matched lines\n"
|
|
561
|
+
output += f"{stats['files']} files contained matches\n"
|
|
562
|
+
|
|
563
|
+
return ExecResult(
|
|
564
|
+
stdout=output,
|
|
565
|
+
stderr="",
|
|
566
|
+
exit_code=0 if found_any else 1,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
async def _search_directory(
|
|
570
|
+
self, ctx: CommandContext, path: str, display_path: str,
|
|
571
|
+
regex: re.Pattern, opts: RgOptions, results: list[str],
|
|
572
|
+
stats: dict, depth: int
|
|
573
|
+
) -> bool:
|
|
574
|
+
"""Search a directory recursively."""
|
|
575
|
+
# Check max depth
|
|
576
|
+
if opts.max_depth is not None and depth >= opts.max_depth:
|
|
577
|
+
return False
|
|
578
|
+
|
|
579
|
+
found_any = False
|
|
580
|
+
|
|
581
|
+
try:
|
|
582
|
+
entries = await ctx.fs.readdir(path)
|
|
583
|
+
if opts.sort_by == "path":
|
|
584
|
+
entries = sorted(entries)
|
|
585
|
+
|
|
586
|
+
for entry in entries:
|
|
587
|
+
# Skip hidden files/directories unless --hidden
|
|
588
|
+
if entry.startswith(".") and not opts.hidden:
|
|
589
|
+
continue
|
|
590
|
+
|
|
591
|
+
entry_path = f"{path}/{entry}"
|
|
592
|
+
entry_display = f"{display_path}/{entry}" if display_path != "." else entry
|
|
593
|
+
|
|
594
|
+
try:
|
|
595
|
+
stat = await ctx.fs.stat(entry_path)
|
|
596
|
+
|
|
597
|
+
if stat.is_directory:
|
|
598
|
+
# Skip .git directories unless --hidden
|
|
599
|
+
if entry == ".git" and not opts.hidden:
|
|
600
|
+
continue
|
|
601
|
+
|
|
602
|
+
found = await self._search_directory(
|
|
603
|
+
ctx, entry_path, entry_display, regex, opts, results, stats, depth + 1
|
|
604
|
+
)
|
|
605
|
+
if found:
|
|
606
|
+
found_any = True
|
|
607
|
+
else:
|
|
608
|
+
found = await self._search_file(
|
|
609
|
+
ctx, entry_path, entry_display, regex, opts, results, stats
|
|
610
|
+
)
|
|
611
|
+
if found:
|
|
612
|
+
found_any = True
|
|
613
|
+
except Exception:
|
|
614
|
+
pass
|
|
615
|
+
except Exception:
|
|
616
|
+
pass
|
|
617
|
+
|
|
618
|
+
return found_any
|
|
619
|
+
|
|
620
|
+
async def _search_file(
|
|
621
|
+
self, ctx: CommandContext, path: str, display_path: str,
|
|
622
|
+
regex: re.Pattern, opts: RgOptions, results: list[str], stats: dict
|
|
623
|
+
) -> bool:
|
|
624
|
+
"""Search a single file."""
|
|
625
|
+
# Check if file matches type filters
|
|
626
|
+
if not self._matches_type_filters(display_path, opts):
|
|
627
|
+
return False
|
|
628
|
+
|
|
629
|
+
# Check if file matches glob filters
|
|
630
|
+
if not self._matches_glob_filters(display_path, opts):
|
|
631
|
+
return False
|
|
632
|
+
|
|
633
|
+
# Check .gitignore
|
|
634
|
+
if not opts.no_ignore:
|
|
635
|
+
if await self._is_ignored(ctx, path, display_path):
|
|
636
|
+
return False
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
content = await ctx.fs.read_file(path)
|
|
640
|
+
except Exception:
|
|
641
|
+
return False
|
|
642
|
+
|
|
643
|
+
# Check for binary content
|
|
644
|
+
if not opts.search_binary and self._is_binary(content):
|
|
645
|
+
return False
|
|
646
|
+
|
|
647
|
+
return await self._search_content(ctx, display_path, content, regex, opts, results, stats)
|
|
648
|
+
|
|
649
|
+
async def _search_content(
|
|
650
|
+
self, ctx: CommandContext, display_path: str, content: str,
|
|
651
|
+
regex: re.Pattern, opts: RgOptions, results: list[str], stats: dict
|
|
652
|
+
) -> bool:
|
|
653
|
+
"""Search content and add results."""
|
|
654
|
+
lines = content.splitlines()
|
|
655
|
+
matches: list[tuple[int, str, list[re.Match]]] = []
|
|
656
|
+
match_count = 0
|
|
657
|
+
line_match_count = 0
|
|
658
|
+
|
|
659
|
+
# Multiline mode: search entire content
|
|
660
|
+
if opts.multiline:
|
|
661
|
+
content_matches = list(regex.finditer(content))
|
|
662
|
+
if content_matches:
|
|
663
|
+
match_count = len(content_matches)
|
|
664
|
+
# For each match, determine which line(s) it spans
|
|
665
|
+
line_offsets = [0]
|
|
666
|
+
for line in lines:
|
|
667
|
+
line_offsets.append(line_offsets[-1] + len(line) + 1)
|
|
668
|
+
|
|
669
|
+
for m in content_matches:
|
|
670
|
+
# Find the starting line
|
|
671
|
+
start_line = 1
|
|
672
|
+
for i, offset in enumerate(line_offsets):
|
|
673
|
+
if offset > m.start():
|
|
674
|
+
start_line = i
|
|
675
|
+
break
|
|
676
|
+
matched_text = m.group(0).replace('\n', '\\n')
|
|
677
|
+
matches.append((start_line, matched_text, [m]))
|
|
678
|
+
line_match_count += 1
|
|
679
|
+
|
|
680
|
+
if opts.max_count is not None and line_match_count >= opts.max_count:
|
|
681
|
+
break
|
|
682
|
+
else:
|
|
683
|
+
# Normal line-by-line search
|
|
684
|
+
for line_num, line in enumerate(lines, 1):
|
|
685
|
+
line_matches = list(regex.finditer(line))
|
|
686
|
+
|
|
687
|
+
if opts.invert_match:
|
|
688
|
+
if not line_matches:
|
|
689
|
+
matches.append((line_num, line, []))
|
|
690
|
+
line_match_count += 1
|
|
691
|
+
elif line_matches:
|
|
692
|
+
matches.append((line_num, line, line_matches))
|
|
693
|
+
match_count += len(line_matches)
|
|
694
|
+
line_match_count += 1
|
|
695
|
+
|
|
696
|
+
# Check max count
|
|
697
|
+
if opts.max_count is not None and line_match_count >= opts.max_count:
|
|
698
|
+
break
|
|
699
|
+
|
|
700
|
+
if not matches and not opts.passthru:
|
|
701
|
+
# For files-without-match, we want to list files with NO matches
|
|
702
|
+
if opts.files_without_match:
|
|
703
|
+
results.append(display_path)
|
|
704
|
+
return True
|
|
705
|
+
return False
|
|
706
|
+
|
|
707
|
+
# Handle passthru mode - include all lines
|
|
708
|
+
if opts.passthru:
|
|
709
|
+
for line_num, line in enumerate(lines, 1):
|
|
710
|
+
line_matches = list(regex.finditer(line))
|
|
711
|
+
if any(m[0] == line_num for m in matches):
|
|
712
|
+
continue
|
|
713
|
+
matches.append((line_num, line, line_matches))
|
|
714
|
+
matches.sort(key=lambda x: x[0])
|
|
715
|
+
|
|
716
|
+
# Update stats
|
|
717
|
+
stats["files"] += 1
|
|
718
|
+
stats["matches"] += match_count
|
|
719
|
+
stats["lines"] += line_match_count
|
|
720
|
+
|
|
721
|
+
# Handle files-with-matches mode
|
|
722
|
+
if opts.files_with_matches:
|
|
723
|
+
sep = "\0" if opts.null_separator else ""
|
|
724
|
+
results.append(f"{display_path}{sep}")
|
|
725
|
+
return True
|
|
726
|
+
|
|
727
|
+
# Handle files-without-match mode (handled at caller level)
|
|
728
|
+
if opts.files_without_match:
|
|
729
|
+
return True # File has matches, don't list it
|
|
730
|
+
|
|
731
|
+
# Handle count mode
|
|
732
|
+
if opts.count:
|
|
733
|
+
results.append(f"{display_path}:{line_match_count}")
|
|
734
|
+
return True
|
|
735
|
+
|
|
736
|
+
# Handle count-matches mode
|
|
737
|
+
if opts.count_matches:
|
|
738
|
+
results.append(f"{display_path}:{match_count}")
|
|
739
|
+
return True
|
|
740
|
+
|
|
741
|
+
# Handle heading mode
|
|
742
|
+
if opts.heading:
|
|
743
|
+
results.append(display_path)
|
|
744
|
+
|
|
745
|
+
# Collect lines to output (including context if needed)
|
|
746
|
+
if opts.before_context > 0 or opts.after_context > 0:
|
|
747
|
+
# Build set of lines to include
|
|
748
|
+
lines_to_show: dict[int, tuple[str, bool]] = {} # line_num -> (content, is_match)
|
|
749
|
+
for line_num, line, line_matches in matches:
|
|
750
|
+
# Add context before
|
|
751
|
+
for ctx_num in range(max(1, line_num - opts.before_context), line_num):
|
|
752
|
+
if ctx_num not in lines_to_show:
|
|
753
|
+
lines_to_show[ctx_num] = (lines[ctx_num - 1], False)
|
|
754
|
+
# Add the match line
|
|
755
|
+
lines_to_show[line_num] = (line, True)
|
|
756
|
+
# Add context after
|
|
757
|
+
for ctx_num in range(line_num + 1, min(len(lines) + 1, line_num + opts.after_context + 1)):
|
|
758
|
+
if ctx_num not in lines_to_show:
|
|
759
|
+
lines_to_show[ctx_num] = (lines[ctx_num - 1], False)
|
|
760
|
+
|
|
761
|
+
# Output in order
|
|
762
|
+
for line_num in sorted(lines_to_show.keys()):
|
|
763
|
+
content, is_match = lines_to_show[line_num]
|
|
764
|
+
# Use '-' separator for context lines in some formats
|
|
765
|
+
sep = ":" if is_match else "-"
|
|
766
|
+
if not opts.no_filename:
|
|
767
|
+
if opts.line_numbers:
|
|
768
|
+
results.append(f"{display_path}{sep}{line_num}{sep}{content}")
|
|
769
|
+
else:
|
|
770
|
+
results.append(f"{display_path}{sep}{content}")
|
|
771
|
+
else:
|
|
772
|
+
if opts.line_numbers:
|
|
773
|
+
results.append(f"{line_num}{sep}{content}")
|
|
774
|
+
else:
|
|
775
|
+
results.append(content)
|
|
776
|
+
else:
|
|
777
|
+
# Normal output without context
|
|
778
|
+
for line_num, line, line_matches in matches:
|
|
779
|
+
# Handle only-matching mode
|
|
780
|
+
if opts.only_matching and line_matches:
|
|
781
|
+
for m in line_matches:
|
|
782
|
+
self._format_result(
|
|
783
|
+
results, opts, display_path, line_num, m.group(0),
|
|
784
|
+
column=m.start() + 1, byte_offset=self._calc_byte_offset(lines, line_num, m.start())
|
|
785
|
+
)
|
|
786
|
+
else:
|
|
787
|
+
# Handle replacement
|
|
788
|
+
output_line = line
|
|
789
|
+
if opts.replace is not None and line_matches:
|
|
790
|
+
# Convert ripgrep-style $1 to Python-style \1
|
|
791
|
+
py_replace = re.sub(r'\$(\d+)', r'\\\1', opts.replace)
|
|
792
|
+
output_line = regex.sub(py_replace, line)
|
|
793
|
+
|
|
794
|
+
col = line_matches[0].start() + 1 if line_matches else 1
|
|
795
|
+
byte_off = self._calc_byte_offset(lines, line_num, col - 1)
|
|
796
|
+
self._format_result(results, opts, display_path, line_num, output_line, column=col, byte_offset=byte_off)
|
|
797
|
+
|
|
798
|
+
return True
|
|
799
|
+
|
|
800
|
+
def _format_result(
|
|
801
|
+
self, results: list[str], opts: RgOptions, filename: str,
|
|
802
|
+
line_num: int, content: str, column: int = 1, byte_offset: int = 0
|
|
803
|
+
) -> None:
|
|
804
|
+
"""Format a result line."""
|
|
805
|
+
if opts.heading:
|
|
806
|
+
# Heading mode: line number and content only
|
|
807
|
+
parts = []
|
|
808
|
+
if opts.line_numbers:
|
|
809
|
+
parts.append(str(line_num))
|
|
810
|
+
if opts.column:
|
|
811
|
+
parts.append(str(column))
|
|
812
|
+
if opts.byte_offset:
|
|
813
|
+
parts.append(str(byte_offset))
|
|
814
|
+
if parts:
|
|
815
|
+
results.append(":".join(parts) + ":" + content)
|
|
816
|
+
else:
|
|
817
|
+
results.append(content)
|
|
818
|
+
elif opts.vimgrep:
|
|
819
|
+
# vimgrep format: file:line:column:content
|
|
820
|
+
results.append(f"{filename}:{line_num}:{column}:{content}")
|
|
821
|
+
elif opts.json:
|
|
822
|
+
# Simple JSON-like output
|
|
823
|
+
import json
|
|
824
|
+
results.append(json.dumps({
|
|
825
|
+
"type": "match",
|
|
826
|
+
"data": {
|
|
827
|
+
"path": {"text": filename},
|
|
828
|
+
"lines": {"text": content},
|
|
829
|
+
"line_number": line_num,
|
|
830
|
+
"submatches": []
|
|
831
|
+
}
|
|
832
|
+
}))
|
|
833
|
+
else:
|
|
834
|
+
# Normal format
|
|
835
|
+
parts = []
|
|
836
|
+
if not opts.no_filename:
|
|
837
|
+
parts.append(filename)
|
|
838
|
+
if opts.line_numbers:
|
|
839
|
+
parts.append(str(line_num))
|
|
840
|
+
if opts.column:
|
|
841
|
+
parts.append(str(column))
|
|
842
|
+
if opts.byte_offset:
|
|
843
|
+
parts.append(str(byte_offset))
|
|
844
|
+
|
|
845
|
+
if parts:
|
|
846
|
+
results.append(":".join(parts) + ":" + content)
|
|
847
|
+
else:
|
|
848
|
+
results.append(content)
|
|
849
|
+
|
|
850
|
+
def _calc_byte_offset(self, lines: list[str], line_num: int, col: int) -> int:
|
|
851
|
+
"""Calculate byte offset from start of file."""
|
|
852
|
+
offset = sum(len(l) + 1 for l in lines[:line_num - 1])
|
|
853
|
+
return offset + col
|
|
854
|
+
|
|
855
|
+
def _matches_type_filters(self, path: str, opts: RgOptions) -> bool:
|
|
856
|
+
"""Check if file matches type filters."""
|
|
857
|
+
if not opts.types and not opts.types_not:
|
|
858
|
+
return True
|
|
859
|
+
|
|
860
|
+
filename = path.split("/")[-1]
|
|
861
|
+
|
|
862
|
+
# Check type exclusions first
|
|
863
|
+
for type_name in opts.types_not:
|
|
864
|
+
patterns = FILE_TYPES.get(type_name, [])
|
|
865
|
+
for pattern in patterns:
|
|
866
|
+
if fnmatch.fnmatch(filename, pattern):
|
|
867
|
+
return False
|
|
868
|
+
|
|
869
|
+
# If no include types, allow all (that weren't excluded)
|
|
870
|
+
if not opts.types:
|
|
871
|
+
return True
|
|
872
|
+
|
|
873
|
+
# Check type inclusions
|
|
874
|
+
for type_name in opts.types:
|
|
875
|
+
patterns = FILE_TYPES.get(type_name, [])
|
|
876
|
+
for pattern in patterns:
|
|
877
|
+
if fnmatch.fnmatch(filename, pattern):
|
|
878
|
+
return True
|
|
879
|
+
|
|
880
|
+
return False
|
|
881
|
+
|
|
882
|
+
def _matches_glob_filters(self, path: str, opts: RgOptions) -> bool:
|
|
883
|
+
"""Check if file matches glob filters."""
|
|
884
|
+
if not opts.globs:
|
|
885
|
+
return True
|
|
886
|
+
|
|
887
|
+
filename = path.split("/")[-1]
|
|
888
|
+
|
|
889
|
+
for glob in opts.globs:
|
|
890
|
+
# Negated glob
|
|
891
|
+
if glob.startswith("!"):
|
|
892
|
+
pattern = glob[1:]
|
|
893
|
+
if fnmatch.fnmatch(filename, pattern) or fnmatch.fnmatch(path, pattern):
|
|
894
|
+
return False
|
|
895
|
+
else:
|
|
896
|
+
# Positive glob - at least one must match
|
|
897
|
+
if fnmatch.fnmatch(filename, glob) or fnmatch.fnmatch(path, glob):
|
|
898
|
+
return True
|
|
899
|
+
|
|
900
|
+
# If only negated globs, allow; if positive globs, require match
|
|
901
|
+
has_positive = any(not g.startswith("!") for g in opts.globs)
|
|
902
|
+
return not has_positive
|
|
903
|
+
|
|
904
|
+
async def _is_ignored(self, ctx: CommandContext, path: str, display_path: str) -> bool:
|
|
905
|
+
"""Check if file is ignored by .gitignore."""
|
|
906
|
+
# Walk up directories looking for .gitignore
|
|
907
|
+
parts = path.split("/")
|
|
908
|
+
filename = parts[-1]
|
|
909
|
+
|
|
910
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
911
|
+
dir_path = "/".join(parts[:i])
|
|
912
|
+
gitignore_path = f"{dir_path}/.gitignore"
|
|
913
|
+
|
|
914
|
+
try:
|
|
915
|
+
content = await ctx.fs.read_file(gitignore_path)
|
|
916
|
+
for line in content.splitlines():
|
|
917
|
+
line = line.strip()
|
|
918
|
+
if not line or line.startswith("#"):
|
|
919
|
+
continue
|
|
920
|
+
# Simple pattern matching
|
|
921
|
+
if fnmatch.fnmatch(filename, line) or fnmatch.fnmatch(display_path, line):
|
|
922
|
+
return True
|
|
923
|
+
except Exception:
|
|
924
|
+
pass
|
|
925
|
+
|
|
926
|
+
return False
|
|
927
|
+
|
|
928
|
+
def _is_binary(self, content: str) -> bool:
|
|
929
|
+
"""Check if content appears to be binary."""
|
|
930
|
+
# Check for null bytes or high ratio of non-printable chars
|
|
931
|
+
if "\x00" in content:
|
|
932
|
+
return True
|
|
933
|
+
if len(content) > 0:
|
|
934
|
+
non_printable = sum(1 for c in content[:1000] if ord(c) < 32 and c not in "\n\r\t")
|
|
935
|
+
if non_printable / min(len(content), 1000) > 0.1:
|
|
936
|
+
return True
|
|
937
|
+
return False
|
|
938
|
+
|
|
939
|
+
async def _list_files(self, ctx: CommandContext, paths: list[str], opts: RgOptions) -> ExecResult:
|
|
940
|
+
"""List files that would be searched (--files mode)."""
|
|
941
|
+
files: list[str] = []
|
|
942
|
+
|
|
943
|
+
for path in paths:
|
|
944
|
+
try:
|
|
945
|
+
resolved = ctx.fs.resolve_path(ctx.cwd, path)
|
|
946
|
+
stat = await ctx.fs.stat(resolved)
|
|
947
|
+
|
|
948
|
+
if stat.is_directory:
|
|
949
|
+
await self._collect_files(ctx, resolved, path, opts, files, depth=0)
|
|
950
|
+
else:
|
|
951
|
+
if self._matches_type_filters(path, opts) and self._matches_glob_filters(path, opts):
|
|
952
|
+
files.append(path)
|
|
953
|
+
except Exception:
|
|
954
|
+
pass
|
|
955
|
+
|
|
956
|
+
if opts.sort_by == "path":
|
|
957
|
+
files.sort()
|
|
958
|
+
|
|
959
|
+
output = "\n".join(files)
|
|
960
|
+
if output:
|
|
961
|
+
output += "\n"
|
|
962
|
+
|
|
963
|
+
return ExecResult(stdout=output, stderr="", exit_code=0)
|
|
964
|
+
|
|
965
|
+
async def _collect_files(
|
|
966
|
+
self, ctx: CommandContext, path: str, display_path: str,
|
|
967
|
+
opts: RgOptions, files: list[str], depth: int
|
|
968
|
+
) -> None:
|
|
969
|
+
"""Collect files for --files mode."""
|
|
970
|
+
if opts.max_depth is not None and depth >= opts.max_depth:
|
|
971
|
+
return
|
|
972
|
+
|
|
973
|
+
try:
|
|
974
|
+
entries = await ctx.fs.readdir(path)
|
|
975
|
+
|
|
976
|
+
for entry in sorted(entries):
|
|
977
|
+
if entry.startswith(".") and not opts.hidden:
|
|
978
|
+
continue
|
|
979
|
+
|
|
980
|
+
entry_path = f"{path}/{entry}"
|
|
981
|
+
entry_display = f"{display_path}/{entry}" if display_path != "." else entry
|
|
982
|
+
|
|
983
|
+
try:
|
|
984
|
+
stat = await ctx.fs.stat(entry_path)
|
|
985
|
+
|
|
986
|
+
if stat.is_directory:
|
|
987
|
+
await self._collect_files(ctx, entry_path, entry_display, opts, files, depth + 1)
|
|
988
|
+
else:
|
|
989
|
+
if self._matches_type_filters(entry_display, opts) and self._matches_glob_filters(entry_display, opts):
|
|
990
|
+
files.append(entry_display)
|
|
991
|
+
except Exception:
|
|
992
|
+
pass
|
|
993
|
+
except Exception:
|
|
994
|
+
pass
|
|
995
|
+
|
|
996
|
+
def _show_help(self) -> ExecResult:
|
|
997
|
+
"""Show help message."""
|
|
998
|
+
help_text = """rg - recursively search for a pattern
|
|
999
|
+
|
|
1000
|
+
USAGE:
|
|
1001
|
+
rg [OPTIONS] PATTERN [PATH ...]
|
|
1002
|
+
|
|
1003
|
+
OPTIONS:
|
|
1004
|
+
-e, --regexp PATTERN Pattern to search for (can be repeated)
|
|
1005
|
+
-f, --file FILE Read patterns from file
|
|
1006
|
+
-i, --ignore-case Case insensitive search
|
|
1007
|
+
-s, --case-sensitive Case sensitive search
|
|
1008
|
+
-S, --smart-case Smart case (default)
|
|
1009
|
+
-F, --fixed-strings Treat pattern as literal string
|
|
1010
|
+
-w, --word-regexp Match whole words only
|
|
1011
|
+
-x, --line-regexp Match whole lines only
|
|
1012
|
+
-v, --invert-match Select non-matching lines
|
|
1013
|
+
-c, --count Print count of matches per file
|
|
1014
|
+
--count-matches Print count of individual matches
|
|
1015
|
+
-l, --files-with-matches Print only filenames with matches
|
|
1016
|
+
--files-without-match Print filenames without matches
|
|
1017
|
+
--files Print files that would be searched
|
|
1018
|
+
-o, --only-matching Print only matched parts
|
|
1019
|
+
-r, --replace TEXT Replace matches with TEXT
|
|
1020
|
+
-q, --quiet Suppress output
|
|
1021
|
+
-n, --line-number Show line numbers (default)
|
|
1022
|
+
-N, --no-line-number Hide line numbers
|
|
1023
|
+
-I, --no-filename Hide filenames
|
|
1024
|
+
--column Show column numbers
|
|
1025
|
+
-b, --byte-offset Show byte offsets
|
|
1026
|
+
--vimgrep Output in vimgrep format
|
|
1027
|
+
--json Output in JSON format
|
|
1028
|
+
--heading Show filename above matches
|
|
1029
|
+
-A NUM Show NUM lines after match
|
|
1030
|
+
-B NUM Show NUM lines before match
|
|
1031
|
+
-C NUM Show NUM lines of context
|
|
1032
|
+
-m, --max-count NUM Stop after NUM matches per file
|
|
1033
|
+
-d, --max-depth NUM Maximum directory depth
|
|
1034
|
+
-g, --glob PATTERN Include files matching glob
|
|
1035
|
+
-t, --type TYPE Search only TYPE files
|
|
1036
|
+
-T, --type-not TYPE Exclude TYPE files
|
|
1037
|
+
--type-list List available file types
|
|
1038
|
+
--hidden Search hidden files
|
|
1039
|
+
--no-ignore Don't respect .gitignore
|
|
1040
|
+
-u Unrestricted mode (stacks)
|
|
1041
|
+
-U, --multiline Enable multiline matching
|
|
1042
|
+
--stats Show search statistics
|
|
1043
|
+
--sort TYPE Sort results (path, none)
|
|
1044
|
+
--passthru Print all lines
|
|
1045
|
+
-a, --text Search binary files as text
|
|
1046
|
+
--help Show this help
|
|
1047
|
+
"""
|
|
1048
|
+
return ExecResult(stdout=help_text, stderr="", exit_code=0)
|