Skip to content

Commit aace6f2

Browse files
yarikopticclaude
andcommitted
Add multi-file support and --filename annotation option to show-paths
Accept multiple files as positional arguments (nargs="*") and add --filename option (auto/prefix/name) to control how filenames appear in output. 'auto' (default) adds filename: prefix only for multiple files, preserving backward compatibility. 'prefix' always prepends filename: to lines. 'name' prints filename as a header before hits. Co-Authored-By: Claude Code 2.1.63 / Claude Opus 4.6 <noreply@anthropic.com>
1 parent c152dc7 commit aace6f2

2 files changed

Lines changed: 225 additions & 35 deletions

File tree

bin/show-paths

Lines changed: 64 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -136,42 +136,60 @@ def get_paths(lines):
136136
return paths
137137

138138

139-
def print_inline(paths, lines, use_color):
139+
def print_inline(paths, lines, use_color, prefix=""):
140140
"""Print paths and matched lines in inline format."""
141141
for line_num, path in paths:
142142
path_str = ".".join([p[0] for p in path[:-1]])
143143
line = lines[line_num].rstrip()
144144
if use_color:
145145
path_str = colored(path_str, attrs=["dark"])
146146
line = colored(line, "red")
147-
print(f"{line_num}: {path_str} {line}")
147+
print(f"{prefix}{line_num}: {path_str} {line}")
148148

149149

150-
def print_full_lines(paths, lines, use_color):
150+
def print_full_lines(paths, lines, use_color, prefix=""):
151151
"""Print paths and matched lines in full-lines format."""
152152
printed = set()
153153
for line_num, path in paths:
154154
# in simplest case -- print all lines of the path
155155
for key, indent, i in path[:-1]:
156156
line_colored = colored(lines[i], attrs=["dark"]) if use_color else lines[i]
157157
if i not in printed:
158-
print(f"{i} {line_colored}")
158+
print(f"{prefix}{i} {line_colored}")
159159
printed.add(i)
160160
line_colored = colored(lines[line_num], "red") if use_color else lines[line_num]
161-
print(f"{line_num}: {line_colored}")
161+
print(f"{prefix}{line_num}: {line_colored}")
162162
printed.add(line_num)
163163

164164

165+
def filter_paths(lines, regex=None, line_numbers=None):
166+
"""Get and filter indentation-based paths for given lines."""
167+
paths = get_paths(lines)
168+
169+
if regex:
170+
paths = [
171+
(line_num, path)
172+
for line_num, path in paths
173+
if regex.search(lines[line_num])
174+
]
175+
176+
if line_numbers:
177+
nums = set(line_numbers)
178+
paths = [
179+
(line_num, path) for line_num, path in paths if line_num in nums
180+
]
181+
182+
return paths
183+
184+
165185
def main():
166186
parser = argparse.ArgumentParser(
167187
description=__doc__, formatter_class=ColoredHelpFormatter
168188
)
169189
parser.add_argument(
170190
"file",
171-
nargs="?",
172-
type=argparse.FileType("r"),
173-
default=sys.stdin,
174-
help="Input file to process (default: stdin)",
191+
nargs="*",
192+
help="Input file(s) to process (default: stdin)",
175193
)
176194
parser.add_argument(
177195
"-e", "--regex", type=str, help="Regular expression to match lines"
@@ -196,6 +214,13 @@ def main():
196214
default="auto",
197215
help="Color output: 'auto' (default), 'on', or 'off'",
198216
)
217+
parser.add_argument(
218+
"--filename",
219+
choices=["auto", "prefix", "name"],
220+
default="auto",
221+
help="How to annotate output with filename: 'auto' (prefix when multiple files),"
222+
" 'prefix' (prepend filename: to each line), 'name' (print filename header)",
223+
)
199224

200225
args = parser.parse_args()
201226

@@ -209,36 +234,40 @@ def main():
209234
# Compile regex if provided
210235
regex = re.compile(args.regex) if args.regex else None
211236

212-
# Read lines from the file
213-
lines = args.file.readlines()
214-
lines = [line.rstrip() for line in lines]
237+
# Determine file list: use provided files or stdin
238+
files = args.file if args.file else [None] # None means stdin
215239

216-
# Get paths for all lines
217-
paths = get_paths(lines)
240+
# Resolve filename display mode
241+
filename_mode = args.filename
242+
if filename_mode == "auto":
243+
filename_mode = "prefix" if len(files) > 1 else None
218244

219-
# Filter by regex if provided
220-
if args.regex:
221-
regex = re.compile(args.regex)
222-
paths = [
223-
(line_num, path)
224-
for line_num, path in paths
225-
if regex.search(lines[line_num])
226-
]
245+
for filepath in files:
246+
if filepath is None:
247+
lines = sys.stdin.readlines()
248+
fname = "<stdin>"
249+
else:
250+
with open(filepath) as f:
251+
lines = f.readlines()
252+
fname = filepath
227253

228-
# Filter by line numbers if provided
229-
if args.line_number:
230-
line_numbers = set(args.line_number)
231-
paths = [
232-
(line_num, path) for line_num, path in paths if line_num in line_numbers
233-
]
254+
lines = [line.rstrip() for line in lines]
255+
256+
paths = filter_paths(lines, regex=regex, line_numbers=args.line_number)
257+
if not paths:
258+
continue
259+
260+
prefix = f"{fname}:" if filename_mode == "prefix" else ""
261+
262+
if filename_mode == "name":
263+
print(fname)
234264

235-
# Print based on the chosen format
236-
if args.format == "inline":
237-
print_inline(paths, lines, use_color)
238-
elif args.format == "full-lines":
239-
print_full_lines(paths, lines, use_color)
240-
else:
241-
raise ValueError(args.format)
265+
if args.format == "inline":
266+
print_inline(paths, lines, use_color, prefix=prefix)
267+
elif args.format == "full-lines":
268+
print_full_lines(paths, lines, use_color, prefix=prefix)
269+
else:
270+
raise ValueError(args.format)
242271

243272

244273
if __name__ == "__main__":

tests/test_show_paths.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,3 +507,164 @@ def test_show_paths_regex_case_sensitive():
507507
assert result_lower.stdout.strip() == ""
508508
# Correct case should find the class
509509
assert "MyClass" in result_correct.stdout
510+
511+
512+
@pytest.mark.ai_generated
513+
def test_show_paths_multiple_files():
514+
"""Test show-paths with multiple files."""
515+
result = run_show_paths(
516+
str(DATA_DIR / "sample.py"),
517+
str(DATA_DIR / "sample.json"),
518+
"-e",
519+
"target_field",
520+
"--color",
521+
"off",
522+
)
523+
assert result.returncode == 0
524+
output = result.stdout
525+
# Only sample.json has target_field, so output should have its prefix
526+
assert "sample.json:" in output
527+
assert "target_field" in output
528+
# sample.py has no match, so it shouldn't appear
529+
assert "sample.py:" not in output
530+
531+
532+
@pytest.mark.ai_generated
533+
def test_show_paths_multiple_files_both_match():
534+
"""Test show-paths with multiple files where both have matches."""
535+
result = run_show_paths(
536+
str(DATA_DIR / "sample.py"),
537+
str(DATA_DIR / "sample.xml"),
538+
"-e",
539+
"target",
540+
"--color",
541+
"off",
542+
)
543+
assert result.returncode == 0
544+
output = result.stdout
545+
# Both files have "target" somewhere
546+
assert "sample.py:" in output
547+
assert "sample.xml:" in output
548+
549+
550+
@pytest.mark.ai_generated
551+
def test_show_paths_multiple_files_auto_prefix():
552+
"""Test that auto mode adds filename prefix for multiple files."""
553+
result = run_show_paths(
554+
str(DATA_DIR / "sample.py"),
555+
str(DATA_DIR / "sample.json"),
556+
"-e",
557+
"def",
558+
"--color",
559+
"off",
560+
)
561+
assert result.returncode == 0
562+
output = result.stdout
563+
# Auto mode with multiple files should prefix with filename
564+
for line in output.strip().split("\n"):
565+
assert "sample.py:" in line
566+
567+
568+
@pytest.mark.ai_generated
569+
def test_show_paths_single_file_auto_no_prefix():
570+
"""Test that auto mode does NOT add filename prefix for single file."""
571+
result = run_show_paths(
572+
str(DATA_DIR / "sample.py"),
573+
"-e",
574+
"def",
575+
"--color",
576+
"off",
577+
)
578+
assert result.returncode == 0
579+
output = result.stdout
580+
# Auto mode with single file: no filename prefix
581+
for line in output.strip().split("\n"):
582+
assert not line.startswith("sample.py:")
583+
584+
585+
@pytest.mark.ai_generated
586+
def test_show_paths_filename_prefix_single_file():
587+
"""Test --filename=prefix forces prefix even with single file."""
588+
result = run_show_paths(
589+
str(DATA_DIR / "sample.py"),
590+
"-e",
591+
"find_me",
592+
"--color",
593+
"off",
594+
"--filename",
595+
"prefix",
596+
)
597+
assert result.returncode == 0
598+
output = result.stdout
599+
assert "sample.py:" in output
600+
assert "find_me" in output
601+
602+
603+
@pytest.mark.ai_generated
604+
def test_show_paths_filename_name_mode():
605+
"""Test --filename=name prints filename header before hits."""
606+
result = run_show_paths(
607+
str(DATA_DIR / "sample.py"),
608+
str(DATA_DIR / "sample.json"),
609+
"-e",
610+
"target",
611+
"--color",
612+
"off",
613+
"--filename",
614+
"name",
615+
)
616+
assert result.returncode == 0
617+
output = result.stdout
618+
lines = output.strip().split("\n")
619+
# Should have filename as standalone header line
620+
py_path = str(DATA_DIR / "sample.py")
621+
json_path = str(DATA_DIR / "sample.json")
622+
assert py_path in lines
623+
assert json_path in lines
624+
# After each header, lines should NOT have filename prefix
625+
# Find the line after the header
626+
py_idx = lines.index(py_path)
627+
assert ":" in lines[py_idx + 1] # line number format
628+
assert not lines[py_idx + 1].startswith(py_path + ":")
629+
630+
631+
@pytest.mark.ai_generated
632+
def test_show_paths_filename_name_skips_no_hits():
633+
"""Test --filename=name does not print header for files with no hits."""
634+
result = run_show_paths(
635+
str(DATA_DIR / "sample.py"),
636+
str(DATA_DIR / "sample.json"),
637+
"-e",
638+
"target_field",
639+
"--color",
640+
"off",
641+
"--filename",
642+
"name",
643+
)
644+
assert result.returncode == 0
645+
output = result.stdout
646+
# Only sample.json has target_field
647+
assert str(DATA_DIR / "sample.json") in output
648+
assert str(DATA_DIR / "sample.py") not in output
649+
650+
651+
@pytest.mark.ai_generated
652+
def test_show_paths_multiple_files_full_lines():
653+
"""Test multiple files with full-lines format and prefix."""
654+
result = run_show_paths(
655+
str(DATA_DIR / "sample.json"),
656+
str(DATA_DIR / "sample.xml"),
657+
"-e",
658+
"target",
659+
"-f",
660+
"full-lines",
661+
"--color",
662+
"off",
663+
)
664+
assert result.returncode == 0
665+
output = result.stdout
666+
# Both context lines and match lines should have the prefix
667+
json_lines = [l for l in output.split("\n") if l.startswith(str(DATA_DIR / "sample.json:"))]
668+
xml_lines = [l for l in output.split("\n") if l.startswith(str(DATA_DIR / "sample.xml:"))]
669+
assert len(json_lines) > 0
670+
assert len(xml_lines) > 0

0 commit comments

Comments
 (0)