offgrep 0.3.0__tar.gz → 0.3.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: offgrep
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A lousy ripgrep rip-off in pure Python
5
5
  Keywords: grep,recursive
6
6
  Author: Rainer Schwarzbach
@@ -5,7 +5,7 @@ build-backend = "uv_build"
5
5
 
6
6
  [project]
7
7
  name = "offgrep"
8
- version = "0.3.0"
8
+ version = "0.3.1"
9
9
  description = "A lousy ripgrep rip-off in pure Python"
10
10
  readme = "README.md"
11
11
  authors = [
@@ -8,7 +8,8 @@ import sys
8
8
 
9
9
  from .arguments import parse_arguments
10
10
  from .filesystem import FileSelectionOptions, iter_selected_files
11
- from .pattern_matching import PatternOptions, OutputOptions, find_in_files
11
+ from .output import OutputOptions
12
+ from .pattern_matching import PatternOptions, find_in_files
12
13
 
13
14
 
14
15
  def grep_files(
@@ -46,5 +47,3 @@ def main() -> int:
46
47
 
47
48
  if __name__ == "__main__":
48
49
  sys.exit(main()) # pragma: no cover
49
-
50
- # vim: fileencoding=utf-8 ts=4 sts=4 sw=4 autoindent expandtab syntax=python:
@@ -8,20 +8,33 @@ from argparse import (
8
8
  ArgumentParser,
9
9
  Namespace,
10
10
  RawDescriptionHelpFormatter,
11
- REMAINDER,
12
11
  SUPPRESS,
13
12
  )
14
13
  from importlib.metadata import PackageNotFoundError, version as metadata_version
15
- from logging import DEBUG, WARNING, basicConfig, debug, warning
14
+ from logging import CRITICAL, DEBUG, INFO, WARNING, basicConfig, debug, warning
16
15
  from pathlib import Path
17
16
  from sys import stdin
18
17
  from tomllib import loads as tomli_loads
19
18
 
20
19
  from .commons import EMPTY, UTF_8, split_without_empty_strings
21
- from .filesystem import FileSelectionOptions, IgnoreFileMode, Priority, VCSDirectory
22
- from .pattern_matching import Highlighter, PatternOptions, OutputOptions
20
+ from .filesystem import (
21
+ FileSelectionOptions,
22
+ IgnoreFileMode,
23
+ Priority,
24
+ VCSDirectory,
25
+ is_an_eligible_path,
26
+ )
27
+ from .output import (
28
+ OutputOptions,
29
+ HIGHLIGHTERS,
30
+ SUPPORTED_COLORS,
31
+ print_highlighter_examples,
32
+ )
33
+ from .pattern_matching import PatternOptions
34
+
23
35
 
24
36
  DEFAULT_PRE_IGNORE = ",".join(f"{dirname}/" for dirname in VCSDirectory)
37
+ DEFAULT_HIGHLIGHTER_KEY = "u"
25
38
 
26
39
  PACKAGE_NAME = "offgrep"
27
40
  """The package name for metadata inspection"""
@@ -106,62 +119,120 @@ def print_supported_file_tyoes() -> None:
106
119
  warning("--type-list option not implemented yet")
107
120
 
108
121
 
109
- def build_argument_parser() -> ArgumentParser:
110
- """build the commandline argument parser"""
111
- try:
112
- version = metadata_version(PACKAGE_NAME)
113
- except PackageNotFoundError:
114
- version = get_metadata_version()
122
+ def _add_output_options(parser: ArgumentParser) -> None:
123
+ """Add output options to a parser instance"""
124
+ output_group = parser.add_argument_group("Output options")
125
+ loglevel_mutex = output_group.add_mutually_exclusive_group()
126
+ loglevel_mutex.add_argument(
127
+ "--verbose",
128
+ action="store_true",
129
+ help="log with level INFO",
130
+ )
131
+ loglevel_mutex.add_argument(
132
+ "--debug",
133
+ action="store_true",
134
+ help="log with level DEBUG",
135
+ )
136
+ loglevel_mutex.add_argument(
137
+ "-q",
138
+ "--quiet",
139
+ action="store_true",
140
+ help="no output except for CRITICAL errors",
141
+ )
142
+ match_highlight_mutex = output_group.add_mutually_exclusive_group()
143
+ match_highlight_mutex.add_argument(
144
+ "--hi",
145
+ "--highlight-matches",
146
+ nargs="?",
147
+ choices=sorted(HIGHLIGHTERS),
148
+ const=DEFAULT_HIGHLIGHTER_KEY,
149
+ help="Highlight matches with the provided profile."
150
+ " Default ist %(default)r (no highlighting) if not specified,"
151
+ " or %(const)r if provided without a value."
152
+ " Use the --highlighters option for an overview with examples.",
153
+ )
154
+ for color in SUPPORTED_COLORS:
155
+ match_highlight_mutex.add_argument(
156
+ f"--{color}",
157
+ action="store_const",
158
+ dest="hi",
159
+ const=color[0],
160
+ help=f"Highlight matches in {color}.",
161
+ )
115
162
  #
116
- main_parser = ArgumentParser(
117
- prog="offgrep",
118
- description="searches text in (text) files recursively",
119
- epilog=_EPILOG,
120
- formatter_class=RawDescriptionHelpFormatter,
163
+ match_highlight_mutex.add_argument(
164
+ "--highlighters",
165
+ action="store_true",
166
+ help="Show highlighter effects on examples.",
121
167
  )
122
- main_parser.set_defaults(
123
- loglevel=WARNING,
124
- encoding=UTF_8,
125
- pre_ignore=DEFAULT_PRE_IGNORE,
126
- post_ignore=EMPTY,
168
+ output_group.add_argument(
169
+ "--stats",
170
+ action="store_true",
171
+ help="Collect and show statistics.",
127
172
  )
128
- main_parser.add_argument("-V", "--version", action="version", version=version)
129
- main_parser.add_argument(
130
- "--debug",
131
- action="store_const",
132
- const=DEBUG,
133
- dest="loglevel",
134
- help="log with debug level",
173
+ output_group.add_argument(
174
+ "--files",
175
+ action="store_true",
176
+ help="Print the path of each file that would be searched,"
177
+ " without actually performing the search.",
135
178
  )
136
- main_parser.add_argument(
179
+ output_group.add_argument(
180
+ "--type-list",
181
+ action="store_true",
182
+ help="NOT IMPLEMTED YET: print the list of supported file types.",
183
+ )
184
+
185
+
186
+ def _add_file_options(parser: ArgumentParser) -> None:
187
+ """Add file options to a parser instance"""
188
+ file_group = parser.add_argument_group("File selection options")
189
+ file_group.add_argument(
137
190
  "--default-ignore-dirs",
138
- help="Always ignore the directories named here, separated by commas."
139
- " DEPRECATED, please use --pre-ignore or --post-ignore instead.",
191
+ metavar="DIRECTORY-NAMES",
192
+ help="DEPRECATED, please use --pre-ignore or --post-ignore instead."
193
+ " Original purpose: always ignore the directories named here,"
194
+ " separated by commas.",
140
195
  )
141
- main_parser.add_argument(
196
+ file_group.add_argument(
142
197
  "--default-ignore-priority",
143
198
  choices=list(Priority),
144
199
  type=Priority,
145
- help="Priority for the ignore rules specified through -default-ignore-dirs."
200
+ help="DEPRECATED, please use --pre-ignore or --post-ignore instead."
201
+ " Original purpose: priority for the ignore rules specified through"
202
+ " --default-ignore-dirs."
146
203
  f" Choices: %(choices)s ({str(Priority.LOW)!r} is applied first but might"
147
204
  " be overridden by eg. directory defined rules,"
148
205
  f" while {str(Priority.HIGH)!r} is applied last but cannot be overridden anymore)."
149
- f" The preset is {str(Priority.LOW)!r}."
150
- " DEPRECATED, please use --pre-ignore or --post-ignore instead.",
206
+ f" The preset is {str(Priority.LOW)!r}.",
151
207
  )
152
- main_parser.add_argument(
208
+ file_group.add_argument(
153
209
  "--pre-ignore",
210
+ metavar="PATTERNS",
154
211
  help="gitignore-style patterns for files and/or directories ignored"
155
212
  " before applying found ignore file rules, joined with commas (,)."
156
- " Defaults to VCS repository directories (%(default)s).",
213
+ " Defaults to VCS repository directories (%(default)r).",
157
214
  )
158
- main_parser.add_argument(
215
+ file_group.add_argument(
159
216
  "--post-ignore",
217
+ metavar="PATTERNS",
160
218
  help="gitignore-style patterns for files and/or directories ignored"
161
219
  " after applying found ignore file rules, joined with commas (,)."
162
- " Empty by default (%(default)s).",
220
+ " Empty by default (%(default)r).",
163
221
  )
164
- main_parser.add_argument(
222
+ file_group.add_argument(
223
+ "-t",
224
+ "--type",
225
+ nargs="+",
226
+ metavar="FILETYPE",
227
+ help="NOT IMPLEMTED YET: search only in files of type %(metavar)s.",
228
+ # See --type-list output for supported file types.",
229
+ )
230
+
231
+
232
+ def _add_pattern_options(parser: ArgumentParser) -> None:
233
+ """Add pattern options to a parser instance"""
234
+ pattern_group = parser.add_argument_group("Pattern options")
235
+ pattern_group.add_argument(
165
236
  "-e",
166
237
  "--expression",
167
238
  nargs="+",
@@ -171,7 +242,7 @@ def build_argument_parser() -> ArgumentParser:
171
242
  " Use this option with multiple arguments if you want to seatch for"
172
243
  " multpile patterns at once.",
173
244
  )
174
- main_parser.add_argument(
245
+ pattern_group.add_argument(
175
246
  "-f",
176
247
  "--file",
177
248
  nargs="+",
@@ -180,7 +251,7 @@ def build_argument_parser() -> ArgumentParser:
180
251
  metavar="PATTERNFILE",
181
252
  help="Files containing patterns (by default, regular expressions",
182
253
  )
183
- main_parser.add_argument(
254
+ pattern_group.add_argument(
184
255
  "-F",
185
256
  "--fixed-strings",
186
257
  action="store_true",
@@ -188,73 +259,51 @@ def build_argument_parser() -> ArgumentParser:
188
259
  " expressions so special characters – .+*()[]{} – do not need to be escaped,"
189
260
  " also saving some energy.",
190
261
  )
191
- main_parser.add_argument(
262
+ pattern_group.add_argument(
192
263
  "-i",
193
264
  "--ignore-case",
194
265
  action="store_false",
195
266
  dest="case_sensitive",
196
267
  help="case-insensitive matching",
197
268
  )
198
- main_parser.add_argument(
199
- "-t",
200
- "--type",
201
- nargs="+",
202
- help="search only in files of type %(metavar)s. NOT IMPLEMENTED YET.",
203
- # See --type-list output for supported file types.",
269
+
270
+
271
+ def build_argument_parser() -> ArgumentParser:
272
+ """build the commandline argument parser"""
273
+ try:
274
+ version = metadata_version(PACKAGE_NAME)
275
+ except PackageNotFoundError:
276
+ version = get_metadata_version()
277
+ #
278
+ main_parser = ArgumentParser(
279
+ prog="offgrep",
280
+ description="searches text in (text) files recursively",
281
+ epilog=_EPILOG,
282
+ formatter_class=RawDescriptionHelpFormatter,
283
+ )
284
+ main_parser.set_defaults(
285
+ encoding=UTF_8,
286
+ hi="z",
287
+ pre_ignore=DEFAULT_PRE_IGNORE,
288
+ post_ignore=EMPTY,
204
289
  )
290
+ main_parser.add_argument("-V", "--version", action="version", version=version)
205
291
  main_parser.add_argument(
206
292
  "--encoding",
207
293
  help="Encoding of all files (default: %(default)s)",
208
294
  )
295
+ _add_output_options(main_parser)
296
+ _add_file_options(main_parser)
297
+ _add_pattern_options(main_parser)
209
298
  main_parser.add_argument(
210
- "--highlight-matches",
211
- "--hm",
212
- nargs="?",
213
- type=str.lower,
214
- choices=[hi.name.lower() for hi in Highlighter],
215
- default=Highlighter.NONE.name.lower(),
216
- const=Highlighter.RED.name.lower(),
217
- help="Highlight matches with the provided profile."
218
- f" Defaults to {Highlighter.RED.value.enclose('%(const)s')}"
219
- " if provided without a value.",
220
- )
221
- main_parser.add_argument(
222
- "--files",
223
- action="store_true",
224
- help="Print the path of each file that would be searched,"
225
- " without actually performing the search.",
226
- )
227
- main_parser.add_argument(
228
- "--stats",
229
- action="store_true",
230
- help="Show statistics.",
231
- )
232
- main_parser.add_argument(
233
- "--type-list",
234
- action="store_true",
235
- help="Print the list of supported file types. NOT IMPLEMENTED YET.",
236
- )
237
- main_parser.add_argument(
238
- "positional_args",
239
- nargs=REMAINDER,
299
+ "positional",
300
+ nargs="*",
240
301
  help=SUPPRESS,
241
302
  )
242
303
  return main_parser
243
304
 
244
305
 
245
- def remove_options_end_marker(args: Namespace, path_candidates: list[str]) -> list[str]:
246
- """Remove an optins end marke if it is at the start of the paths list
247
- after multiple-value options
248
- """
249
- if not path_candidates:
250
- return []
251
- if any((args.patterns, args.patternfiles)) and path_candidates[0] == "--":
252
- return path_candidates[1:]
253
- #
254
- return path_candidates[:]
255
-
256
-
257
- def make_file_selecton_options(args: Namespace) -> FileSelectionOptions:
306
+ def make_file_selection_options(args: Namespace) -> FileSelectionOptions:
258
307
  """Initailize an FSO instance from the arguments"""
259
308
  pre_ignore = split_without_empty_strings(args.pre_ignore)
260
309
  post_ignore = split_without_empty_strings(args.post_ignore)
@@ -269,9 +318,7 @@ def make_file_selecton_options(args: Namespace) -> FileSelectionOptions:
269
318
  #
270
319
  #
271
320
  if args.files:
272
- paths: list[Path] = list(
273
- map(Path, remove_options_end_marker(args, args.positional_args))
274
- )
321
+ paths: list[Path] = list(map(Path, args.positional))
275
322
  if not paths:
276
323
  paths.append(Path.cwd())
277
324
  #
@@ -294,6 +341,19 @@ def make_file_selecton_options(args: Namespace) -> FileSelectionOptions:
294
341
  )
295
342
 
296
343
 
344
+ def initialize_logging(args: Namespace) -> None:
345
+ """Initialize logging"""
346
+ loglevel = WARNING
347
+ if args.debug:
348
+ loglevel = DEBUG
349
+ elif args.verbose:
350
+ loglevel = INFO
351
+ elif args.quiet:
352
+ loglevel = CRITICAL
353
+ #
354
+ basicConfig(level=loglevel)
355
+
356
+
297
357
  def parse_arguments() -> tuple[
298
358
  FileSelectionOptions, PatternOptions | None, OutputOptions
299
359
  ]:
@@ -304,17 +364,26 @@ def parse_arguments() -> tuple[
304
364
  """
305
365
  main_parser = build_argument_parser()
306
366
  args = main_parser.parse_args()
307
- basicConfig(level=args.loglevel)
367
+ initialize_logging(args)
308
368
  debug(repr(args))
369
+ if args.highlighters:
370
+ print_highlighter_examples()
371
+ main_parser.exit()
372
+ #
309
373
  if args.type_list:
310
374
  print_supported_file_tyoes()
311
375
  main_parser.exit()
312
376
  #
377
+ produce_output = not args.quiet
313
378
  if args.files:
314
- return make_file_selecton_options(args), None, OutputOptions()
379
+ return (
380
+ make_file_selection_options(args),
381
+ None,
382
+ OutputOptions(show_files=produce_output),
383
+ )
315
384
  #
316
- fso = make_file_selecton_options(args)
317
- path_candidates = args.positional_args[:]
385
+ fso = make_file_selection_options(args)
386
+ path_candidates = args.positional[:]
318
387
  patterns: list[str] = []
319
388
  if args.patterns:
320
389
  patterns.extend(args.patterns)
@@ -328,15 +397,13 @@ def parse_arguments() -> tuple[
328
397
  except IndexError:
329
398
  main_parser.exit(
330
399
  status=1,
331
- message="Using offgrep requires at least one pattern"
400
+ message="This command variant requires at least one pattern"
332
401
  " as a positional argument.",
333
402
  )
334
403
  #
335
404
  #
336
405
  if stdin.isatty():
337
- paths: list[Path] = list(
338
- map(Path, remove_options_end_marker(args, path_candidates))
339
- )
406
+ paths: list[Path] = list(map(Path, path_candidates))
340
407
  if not paths:
341
408
  paths.append(Path.cwd())
342
409
  #
@@ -348,12 +415,12 @@ def parse_arguments() -> tuple[
348
415
  case_sensitive=args.case_sensitive,
349
416
  )
350
417
  suppress_file_prefix = not fso.paths or (
351
- len(fso.paths) == 1 and fso.paths[0].is_file()
418
+ len(fso.paths) == 1 and is_an_eligible_path(fso.paths[0])
352
419
  )
353
420
  oo = OutputOptions(
354
- highlighter=Highlighter[args.highlight_matches.upper()],
421
+ highlighter=HIGHLIGHTERS[args.hi],
355
422
  show_files=not suppress_file_prefix,
356
- show_results=True,
423
+ show_results=produce_output,
357
424
  show_stats=args.stats,
358
425
  )
359
426
  return fso, po, oo
@@ -187,8 +187,11 @@ class DirectoryWalker:
187
187
  effective_ignore_rulesets.extend(self.post_rulesets)
188
188
  for contained_path in directory_contents:
189
189
  # name = contained_path.name
190
- if not (contained_path.is_dir() or contained_path.is_file()):
191
- debug("Skipping %s, not a directory or regular file", contained_path)
190
+ if not (contained_path.is_dir() or is_an_eligible_path(contained_path)):
191
+ debug(
192
+ "Skipping %s, not a directory, regular file, or named pipe",
193
+ contained_path,
194
+ )
192
195
  continue
193
196
  #
194
197
  is_excluded = False
@@ -201,7 +204,7 @@ class DirectoryWalker:
201
204
  debug("Ignoring %s", contained_path)
202
205
  continue
203
206
  #
204
- if contained_path.is_file():
207
+ if is_an_eligible_path(contained_path):
205
208
  if self.use_relative_paths:
206
209
  yield contained_path.relative_to(self.cwd)
207
210
  else:
@@ -402,6 +405,11 @@ def determine_vcs_rules(
402
405
  )
403
406
 
404
407
 
408
+ def is_an_eligible_path(path: Path) -> bool:
409
+ """Allow regular files or named pipes"""
410
+ return path.is_file() or path.is_fifo()
411
+
412
+
405
413
  def is_repository_root_path(directory: Path, repo_type: VCSDirectory) -> bool:
406
414
  """Check if path is the repository root path of the proviede type"""
407
415
  path_to_check = directory / str(repo_type)
@@ -410,17 +418,17 @@ def is_repository_root_path(directory: Path, repo_type: VCSDirectory) -> bool:
410
418
 
411
419
  def iter_selected_files(file_selection_opts: FileSelectionOptions) -> Iterator[Path]:
412
420
  """Select files"""
413
- for single_path in file_selection_opts.paths:
414
- if single_path.is_file():
415
- debug("Omitting ignore rules for file %s", single_path)
416
- yield single_path
417
- elif single_path.is_dir():
421
+ for current_path in file_selection_opts.paths:
422
+ if is_an_eligible_path(current_path):
423
+ debug("Omitting ignore rules for file %s", current_path)
424
+ yield current_path
425
+ elif current_path.is_dir():
418
426
  walker = DirectoryWalker(
419
- single_path,
427
+ current_path,
420
428
  file_selection_opts,
421
429
  )
422
430
  yield from walker.iter_files()
423
431
  else:
424
- warning("Ignoring %s, not a regular file", single_path)
432
+ warning("Ignoring %s, not a regular file", current_path)
425
433
  #
426
434
  #
@@ -0,0 +1,185 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ Output
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from functools import cached_property
9
+ from secrets import choice
10
+ from sys import stdout
11
+ from textwrap import indent
12
+
13
+ from .commons import EMPTY
14
+
15
+
16
+ STDOUT_IS_A_TTY = stdout.isatty()
17
+
18
+ _CSI = "\x1b["
19
+
20
+ ## Keys
21
+
22
+ RED = "red"
23
+ GREEN = "green"
24
+ YELLOW = "yellow"
25
+ BLUE = "blue"
26
+ PURPLE = "purple"
27
+ CYAN = "cyan"
28
+
29
+ SUPPORTED_COLORS = (RED, GREEN, YELLOW, BLUE, PURPLE, CYAN)
30
+
31
+ LOREM_IPSUM = """Lorem ipsum dolor sit amet, consetetur sadipscing elitr,
32
+ sed diam nonumy eirmod tempor invidunt
33
+ ut labore et dolore magna aliquyam erat,
34
+ sed diam voluptua.
35
+ At vero eos et accusam et justo duo dolores et ea rebum.
36
+ Stet clita kasd gubergren, no sea takimata sanctus
37
+ est Lorem ipsum dolor sit amet."""
38
+
39
+
40
+ def sgr(content: str | int) -> str:
41
+ """The ANSI SGR sequence"""
42
+ return f"{_CSI}{content}m"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Highlighter:
47
+ """Highlighting profile"""
48
+
49
+ short: str
50
+ description: str
51
+ start: str = EMPTY
52
+ end: str = sgr(0)
53
+ apply_in_pipeline: bool = True
54
+ apply_on_tty: bool = True
55
+
56
+ @cached_property
57
+ def is_applicable(self) -> bool:
58
+ """Indicate if the highlighter may be used"""
59
+ if STDOUT_IS_A_TTY:
60
+ return self.apply_on_tty
61
+ #
62
+ return self.apply_in_pipeline
63
+
64
+ @cached_property
65
+ def does_anything(self) -> bool:
66
+ """Indicate if the highlighter defines a start and/or an end"""
67
+ return bool(f"{self.start}{self.end}")
68
+
69
+ def __bool__(self) -> bool:
70
+ """Booelan evaluation: applicable and not empty"""
71
+ return self.is_applicable and self.does_anything
72
+
73
+ def enclose(self, text: str) -> str:
74
+ """Return the enclosed text if possible"""
75
+ if self:
76
+ return f"{self.start}{text}{self.end}"
77
+ #
78
+ return text
79
+
80
+ def emphasize_matches(
81
+ self,
82
+ line: str,
83
+ *match_areas: tuple[int, int],
84
+ ) -> str:
85
+ """Return a line mith the match areas emphasized"""
86
+ if not self or not match_areas:
87
+ return line
88
+ #
89
+ pos = 0
90
+ collector: list[str] = []
91
+ marked = False
92
+ for a_start, a_end in sorted(match_areas):
93
+ if a_start > pos:
94
+ if pos > 0 and marked:
95
+ collector.append(self.end)
96
+ marked = False
97
+ #
98
+ collector.append(line[pos:a_start])
99
+ #
100
+ if a_start < pos:
101
+ a_start = pos
102
+ if a_end <= pos:
103
+ continue
104
+ #
105
+ #
106
+ if not marked:
107
+ collector.append(self.start)
108
+ marked = True
109
+ #
110
+ collector.append(line[a_start:a_end])
111
+ pos = a_end
112
+ #
113
+ if marked:
114
+ collector.append(self.end)
115
+ #
116
+ if pos < len(line):
117
+ collector.append(line[pos:])
118
+ #
119
+ return EMPTY.join(collector)
120
+
121
+
122
+ NO_HIGHLIGHTING = Highlighter(
123
+ "z", "default: Zero highlighting", apply_on_tty=False, apply_in_pipeline=False
124
+ )
125
+ HIGHLIGHT_STRESSED = Highlighter(
126
+ "s", "Stressed text", start=sgr(1), apply_in_pipeline=False
127
+ )
128
+ HIGHLIGHT_UNDERLINE = Highlighter(
129
+ "u", "Underlined text", start=sgr(4), apply_in_pipeline=False
130
+ )
131
+
132
+ HIGHLIGHTERS: dict[str, Highlighter] = {
133
+ instance.short: instance
134
+ for instance in [
135
+ NO_HIGHLIGHTING,
136
+ Highlighter("a", "text enclosed in Arrows", start="→", end="←"),
137
+ Highlighter(".", "text enclosed in POINTing hand emojis", start="👉", end="👈"),
138
+ HIGHLIGHT_STRESSED,
139
+ HIGHLIGHT_UNDERLINE,
140
+ ]
141
+ + [
142
+ Highlighter(
143
+ color_name[0],
144
+ f"{color_name.title()} colored text",
145
+ sgr(sgr_index),
146
+ apply_in_pipeline=False,
147
+ )
148
+ for sgr_index, color_name in enumerate(SUPPORTED_COLORS, start=31)
149
+ ]
150
+ }
151
+
152
+
153
+ @dataclass
154
+ class OutputOptions:
155
+ """Options for pattern matching"""
156
+
157
+ highlighter: Highlighter = NO_HIGHLIGHTING
158
+ show_files: bool = True
159
+ show_results: bool = True
160
+ show_stats: bool = False
161
+
162
+
163
+ def single_example(highlighter: Highlighter, text: str) -> str:
164
+ """split text into words, and apply highlighting to a randow word"""
165
+ words = text.split()
166
+ random_word = choice(words).strip(",.")
167
+ splitted_text = text.split(random_word)
168
+ return highlighter.enclose(random_word).join(splitted_text)
169
+
170
+
171
+ def print_highlighter_examples() -> None:
172
+ """Print examples of all highlighters"""
173
+ print(HIGHLIGHT_STRESSED.enclose("– Highlighters showcase –\n"))
174
+ lorem_ipsum_lines = LOREM_IPSUM.splitlines()
175
+ for key, highlighter in sorted(HIGHLIGHTERS.items()):
176
+ random_line = choice(lorem_ipsum_lines)
177
+ stressed = HIGHLIGHT_STRESSED.enclose(repr(key))
178
+ addition = (
179
+ EMPTY
180
+ if highlighter.apply_in_pipeline or not highlighter
181
+ else ", not applied if stdout is redirected"
182
+ )
183
+ print(f"{stressed} ({highlighter.description}{addition}):")
184
+ print(indent(f"\n{single_example(highlighter, random_line)}\n", " > "))
185
+ #
@@ -6,26 +6,22 @@ Pattern matching
6
6
 
7
7
  from collections import Counter
8
8
  from dataclasses import dataclass, field
9
- from enum import Enum
10
9
  from logging import debug, error
11
10
  from pathlib import Path
12
11
  from re import compile as re_compile, Pattern, IGNORECASE
13
- from sys import stdin, stdout
12
+ from sys import stdin
14
13
  from time import monotonic
15
14
  from typing import TextIO, Any
16
15
 
17
- from .commons import EMPTY, UTF_8
16
+ from .commons import UTF_8
17
+ from .output import (
18
+ OutputOptions,
19
+ Highlighter,
20
+ HIGHLIGHT_STRESSED,
21
+ NO_HIGHLIGHTING,
22
+ )
18
23
 
19
24
 
20
- STDOUT_IS_A_TTY = stdout.isatty()
21
-
22
- PACKAGE_NAME = "offgrep"
23
- """The package name for metadata inspection"""
24
- ANSI_HI = "\x1b[1m"
25
- ANSI_NORMAL = "\x1b[0m"
26
- ANSI_COLOR_RED = "\x1b[31m"
27
- ANSI_COLOR_GREEN = "\x1b[32m"
28
-
29
25
  _UNDERLINE = "_"
30
26
  _SPACE = " "
31
27
 
@@ -33,53 +29,6 @@ _FILES_WITH_MATCHES = "files_with_matches"
33
29
  _TOTAL_MATCHES = "total_matches"
34
30
 
35
31
 
36
- @dataclass(frozen=True)
37
- class HighlightProfile:
38
- """Highlighting profile"""
39
-
40
- start: str = EMPTY
41
- end: str = EMPTY
42
- requires_tty: bool = True
43
-
44
- @property
45
- def is_applicable(self) -> bool:
46
- """Indicate if the highlighter can be used"""
47
- return not self.requires_tty or STDOUT_IS_A_TTY
48
-
49
- def __bool__(self) -> bool:
50
- """Booelan evaluation: not empty"""
51
- return bool(f"{self.start}{self.end}")
52
-
53
- def enclose(self, text: str) -> str:
54
- """Return the enclosed text if possible"""
55
- if self.is_applicable:
56
- return f"{self.start}{text}{self.end}"
57
- #
58
- return text
59
-
60
-
61
- class Highlighter(Enum):
62
- """Highlighting profiles container"""
63
-
64
- ARROWS = HighlightProfile(start="➡️", end="⬅️", requires_tty=False)
65
- GREEN = HighlightProfile(start=ANSI_COLOR_GREEN, end=ANSI_NORMAL)
66
- NONE = HighlightProfile()
67
- POINTING = HighlightProfile(start="👉", end="👈", requires_tty=False)
68
- PUSHING = HighlightProfile(start="🫸", end="🫷", requires_tty=False)
69
- RED = HighlightProfile(start=ANSI_COLOR_RED, end=ANSI_NORMAL)
70
- STRESSED = HighlightProfile(start=ANSI_HI, end=ANSI_NORMAL)
71
-
72
-
73
- @dataclass
74
- class OutputOptions:
75
- """Options for pattern matching"""
76
-
77
- highlighter: Highlighter = Highlighter.NONE
78
- show_files: bool = True
79
- show_results: bool = True
80
- show_stats: bool = True
81
-
82
-
83
32
  @dataclass
84
33
  class PatternOptions:
85
34
  """Options for pattern matching"""
@@ -230,53 +179,11 @@ class MultiPatternsMatcher:
230
179
  return []
231
180
 
232
181
 
233
- def highlight_line_matches(
234
- line: str,
235
- line_areas: list[tuple[int, int]],
236
- highlighter: Highlighter = Highlighter.RED,
237
- ) -> str:
238
- """Return a colorized line"""
239
- if not highlighter.value.is_applicable:
240
- return line
241
- #
242
- pos = 0
243
- collector: list[str] = []
244
- marked = False
245
- for a_start, a_end in line_areas:
246
- if a_start > pos:
247
- if pos > 0 and marked:
248
- collector.append(highlighter.value.end)
249
- marked = False
250
- #
251
- collector.append(line[pos:a_start])
252
- #
253
- if a_start < pos:
254
- a_start = pos
255
- if a_end <= pos:
256
- continue
257
- #
258
- #
259
- if not marked:
260
- collector.append(highlighter.value.start)
261
- marked = True
262
- #
263
- collector.append(line[a_start:a_end])
264
- pos = a_end
265
- #
266
- if marked:
267
- collector.append(highlighter.value.end)
268
- #
269
- if pos < len(line):
270
- collector.append(line[pos:])
271
- #
272
- return EMPTY.join(collector)
273
-
274
-
275
182
  def find_in_stream(
276
183
  input_stream: TextIO | Any,
277
184
  mpm: MultiPatternsMatcher,
278
185
  collect_stats: bool = False,
279
- highlighter: Highlighter = Highlighter.NONE,
186
+ highlighter: Highlighter = NO_HIGHLIGHTING,
280
187
  ) -> tuple[int, list[tuple[int, str]]]:
281
188
  """find the patterns in a single file"""
282
189
  count = 0
@@ -290,8 +197,9 @@ def find_in_stream(
290
197
  findings.append(
291
198
  (
292
199
  lineno,
293
- highlight_line_matches(
294
- line.rstrip(), line_result, highlighter=highlighter
200
+ highlighter.emphasize_matches(
201
+ line.rstrip(),
202
+ *sorted(set(line_result)),
295
203
  ),
296
204
  )
297
205
  )
@@ -326,6 +234,7 @@ def process_results(
326
234
  """process single stream matching results:
327
235
  modify stats in place, and print findings
328
236
  """
237
+ stats.update(files_processed=1)
329
238
  if count:
330
239
  previously_matched_files = stats[_FILES_WITH_MATCHES]
331
240
  stats.update(
@@ -340,7 +249,7 @@ def process_results(
340
249
  if previously_matched_files:
341
250
  print()
342
251
  #
343
- print(Highlighter.STRESSED.value.enclose(f"{file_name} ::"))
252
+ print(HIGHLIGHT_STRESSED.enclose(f"{file_name} ::"))
344
253
  #
345
254
  for lineno, line in findings:
346
255
  print(f"{lineno:{number_width}d}: {line}")
@@ -353,7 +262,7 @@ def show_stats(stats: Counter[str], duration: float) -> None:
353
262
  if stats[_TOTAL_MATCHES]:
354
263
  print()
355
264
  #
356
- print(Highlighter.STRESSED.value.enclose("⁘ Statistics ⁘"))
265
+ print(HIGHLIGHT_STRESSED.enclose("⁘ Statistics ⁘"))
357
266
  for key, value in sorted(stats.items()):
358
267
  print(f"{value:>6d} {key.replace(_UNDERLINE, _SPACE)}")
359
268
  #
@@ -407,7 +316,6 @@ def find_in_files(
407
316
  stats.update(errors=1)
408
317
  continue
409
318
  #
410
- stats.update(files_processed=1)
411
319
  process_results(output_opts, stats, str(single_file), count, findings)
412
320
  #
413
321
  #
File without changes
File without changes
File without changes
File without changes
File without changes