pseek 2.3.2__tar.gz → 2.5.0__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.4
2
2
  Name: pseek
3
- Version: 2.3.2
3
+ Version: 2.5.0
4
4
  Summary: Pseek is a Python library to search files, folders, and text
5
5
  Author-email: Arian <ariannasiri86@gmail.com>
6
6
  License-Expression: MIT
@@ -10,6 +10,9 @@ Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
11
  Requires-Dist: click==8.1.8
12
12
  Requires-Dist: lark==1.2.2
13
+ Requires-Dist: py7zr==1.0.0
14
+ Requires-Dist: rarfile==4.2
15
+ Requires-Dist: rapidfuzz==3.13.0
13
16
  Dynamic: license-file
14
17
 
15
18
  # Pseek
@@ -24,17 +27,19 @@ A powerful command-line tool for searching files, directories, and content insid
24
27
  * **Search inside file contents**
25
28
  * **Highlight matches** in terminal output
26
29
  * **Optimized for speed** with ThreadPoolExecutor
30
+ * Support **logical expression** for search queries
31
+ * Search inside **archive files** (e.g. `zip`, `rar`, `7z`, `gz`, `bz2`, `xz`, `tar`, `tar.gz`, `tar.bz2`, `tar.xz`)
27
32
  * **Cross-platform** (Linux, macOS, Windows)
28
33
 
29
34
  ## Installation
30
35
 
31
- ### **1️⃣ Install via `pip` (Recommended)**
36
+ ### **`1` Install via `pip` (Recommended)**
32
37
 
33
38
  ```sh
34
39
  pip install pseek
35
40
  ```
36
41
 
37
- ### **2️⃣ Install from source**
42
+ ### **`2` Install from source**
38
43
 
39
44
  ```sh
40
45
  git clone https://github.com/ArianN8610/pysearch.git
@@ -77,20 +82,32 @@ pseek "error\d+" --regex
77
82
 
78
83
  ## Command Options
79
84
 
80
- | Option | Description |
81
- |--------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
82
- | `--path` | Base directory to search in (default: current directory `.`) |
83
- | `--file` | Search only in file names |
84
- | `--directory` | Search only in directory names |
85
- | `--content` | Search inside file contents |
86
- | `--ext`, `--exclude-ext` | Filter by file extension (e.g., `.txt`, `.log`) |
87
- | `--case-sensitive` | Make the search case-sensitive (except when --expr is enabled, in which case you can make it case sensitive by putting c before term: c"foo") |
88
- | `--regex` | Use regular expressions to search (except when --expr is enabled, in which case you can make it regex by putting r before term: r"foo") |
89
- | `--include`, `--exclude` | Limit search results to specific set of directories or files |
90
- | `--re-include`, `--re-exclude` | Limit search results to specific directories or files with regex |
91
- | `--word` | Match the whole word only (except when --expr is enabled, in which case you can make it match whole word by putting w before term: w"foo") |
92
- | `--expr` | Enable to write conditions in the query. Example: r"foo.*bar" and ("bar" or "baz") and not "qux" (To use regex, word, and case-sensitive features, you can use the prefixes r, w, and c before terms. Allowed modes: r, w, c, wc, cw, rc, cr. Examples: r"foo.*bar", wc"Foo", cr".*Foo", ...) |
93
- | `--timeout` | To stop the search after a specified period of time (Seconds) |
94
- | `--max-size`, `--min-size` | Specify maximum and minimum sizes for files and directories |
95
- | `--full-path` | Display full path of files and directories |
96
- | `--no-content` | Only display files path for content search |
85
+ | Option | Description |
86
+ |--------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
87
+ | `--path` | Base directory to search in (default: current directory `.`) |
88
+ | `--file` | Search only in file names |
89
+ | `--directory` | Search only in directory names |
90
+ | `--content` | Search inside file contents |
91
+ | `--ext`, `--exclude-ext` | Filter by file extension (e.g., `txt`, `log`) |
92
+ | `--case-sensitive` | Make the search case-sensitive (except when `--expr` is enabled, in which case you can make it case sensitive by putting `c` before term: `c"foo"`) |
93
+ | `--regex` | Use regular expressions to search (except when `--expr` is enabled, in which case you can make it regex by putting `r` before term: `r"foo"`) |
94
+ | `--include`, `--exclude` | Limit search results to specific set of directories or files |
95
+ | `--re-include`, `--re-exclude` | Limit search results to specific directories or files with regex |
96
+ | `--word` | Match the whole word only (except when `--expr` is enabled, in which case you can make it match whole word by putting `w` before term: `w"foo"`) |
97
+ | `--expr` | Enable to write conditions in the query. Example: `r"foo.*bar" and ("bar" or "baz") and not "qux"` (To use regex, word, and case-sensitive features, you can use the prefixes `r`, `w`, and `c` before terms. Allowed modes: `r`, `w`, `c`, `wc`, `cw`, `rc`, `cr`. Examples: `r"foo.*bar"`, `wc"Foo"`, `cr".*Foo"`, ...) |
98
+ | `--timeout` | To stop the search after a specified period of time (Seconds) |
99
+ | `--fuzzy` | Enable fuzzy search (Highlighting and counting matches are disabled in this mode if `--word` is not enabled to prevent the program from slowing down) |
100
+ | `--fuzzy_level` | Similarity threshold from 0 to 99 for fuzzy search (default: `80`) |
101
+ | `--max-size`, `--min-size` | Specify maximum and minimum sizes for files and directories |
102
+ | `--archive` | Enable search within archive files (e.g. `zip`, `rar`, `7z`, `gz`, `bz2`, `xz`, `tar`, `tar.gz`, `tar.bz2`, `tar.xz`) |
103
+ | `--arc-ext`, `--arc-ee` | Filter by file extension inside archive files |
104
+ | `--arc-inc`, `--arc-exc` | Limit search results to specific set of directories or files inside archive files |
105
+ | `--arc-max`, `--arc-min` | Specify maximum and minimum sizes for files inside archive files (It doesn't work for directories because their size is zero in archive files) |
106
+ | `--rarfb` | Path to RAR backend tool (e.g. UnRAR.exe, ...) |
107
+ | `--full-path` | Display full path of files and directories |
108
+ | `--no-content` | Only display files path for content search |
109
+
110
+ ## Requirements
111
+
112
+ * Python 3.6 or higher
113
+ * `unrar`, `bsdtar`, `unar` or `7zip` for the rarfile library to support searching inside `.rar` files (optional)
@@ -10,17 +10,19 @@ A powerful command-line tool for searching files, directories, and content insid
10
10
  * **Search inside file contents**
11
11
  * **Highlight matches** in terminal output
12
12
  * **Optimized for speed** with ThreadPoolExecutor
13
+ * Support **logical expression** for search queries
14
+ * Search inside **archive files** (e.g. `zip`, `rar`, `7z`, `gz`, `bz2`, `xz`, `tar`, `tar.gz`, `tar.bz2`, `tar.xz`)
13
15
  * **Cross-platform** (Linux, macOS, Windows)
14
16
 
15
17
  ## Installation
16
18
 
17
- ### **1️⃣ Install via `pip` (Recommended)**
19
+ ### **`1` Install via `pip` (Recommended)**
18
20
 
19
21
  ```sh
20
22
  pip install pseek
21
23
  ```
22
24
 
23
- ### **2️⃣ Install from source**
25
+ ### **`2` Install from source**
24
26
 
25
27
  ```sh
26
28
  git clone https://github.com/ArianN8610/pysearch.git
@@ -63,20 +65,32 @@ pseek "error\d+" --regex
63
65
 
64
66
  ## Command Options
65
67
 
66
- | Option | Description |
67
- |--------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
68
- | `--path` | Base directory to search in (default: current directory `.`) |
69
- | `--file` | Search only in file names |
70
- | `--directory` | Search only in directory names |
71
- | `--content` | Search inside file contents |
72
- | `--ext`, `--exclude-ext` | Filter by file extension (e.g., `.txt`, `.log`) |
73
- | `--case-sensitive` | Make the search case-sensitive (except when --expr is enabled, in which case you can make it case sensitive by putting c before term: c"foo") |
74
- | `--regex` | Use regular expressions to search (except when --expr is enabled, in which case you can make it regex by putting r before term: r"foo") |
75
- | `--include`, `--exclude` | Limit search results to specific set of directories or files |
76
- | `--re-include`, `--re-exclude` | Limit search results to specific directories or files with regex |
77
- | `--word` | Match the whole word only (except when --expr is enabled, in which case you can make it match whole word by putting w before term: w"foo") |
78
- | `--expr` | Enable to write conditions in the query. Example: r"foo.*bar" and ("bar" or "baz") and not "qux" (To use regex, word, and case-sensitive features, you can use the prefixes r, w, and c before terms. Allowed modes: r, w, c, wc, cw, rc, cr. Examples: r"foo.*bar", wc"Foo", cr".*Foo", ...) |
79
- | `--timeout` | To stop the search after a specified period of time (Seconds) |
80
- | `--max-size`, `--min-size` | Specify maximum and minimum sizes for files and directories |
81
- | `--full-path` | Display full path of files and directories |
82
- | `--no-content` | Only display files path for content search |
68
+ | Option | Description |
69
+ |--------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
70
+ | `--path` | Base directory to search in (default: current directory `.`) |
71
+ | `--file` | Search only in file names |
72
+ | `--directory` | Search only in directory names |
73
+ | `--content` | Search inside file contents |
74
+ | `--ext`, `--exclude-ext` | Filter by file extension (e.g., `txt`, `log`) |
75
+ | `--case-sensitive` | Make the search case-sensitive (except when `--expr` is enabled, in which case you can make it case sensitive by putting `c` before term: `c"foo"`) |
76
+ | `--regex` | Use regular expressions to search (except when `--expr` is enabled, in which case you can make it regex by putting `r` before term: `r"foo"`) |
77
+ | `--include`, `--exclude` | Limit search results to specific set of directories or files |
78
+ | `--re-include`, `--re-exclude` | Limit search results to specific directories or files with regex |
79
+ | `--word` | Match the whole word only (except when `--expr` is enabled, in which case you can make it match whole word by putting `w` before term: `w"foo"`) |
80
+ | `--expr` | Enable to write conditions in the query. Example: `r"foo.*bar" and ("bar" or "baz") and not "qux"` (To use regex, word, and case-sensitive features, you can use the prefixes `r`, `w`, and `c` before terms. Allowed modes: `r`, `w`, `c`, `wc`, `cw`, `rc`, `cr`. Examples: `r"foo.*bar"`, `wc"Foo"`, `cr".*Foo"`, ...) |
81
+ | `--timeout` | To stop the search after a specified period of time (Seconds) |
82
+ | `--fuzzy` | Enable fuzzy search (Highlighting and counting matches are disabled in this mode if `--word` is not enabled to prevent the program from slowing down) |
83
+ | `--fuzzy_level` | Similarity threshold from 0 to 99 for fuzzy search (default: `80`) |
84
+ | `--max-size`, `--min-size` | Specify maximum and minimum sizes for files and directories |
85
+ | `--archive` | Enable search within archive files (e.g. `zip`, `rar`, `7z`, `gz`, `bz2`, `xz`, `tar`, `tar.gz`, `tar.bz2`, `tar.xz`) |
86
+ | `--arc-ext`, `--arc-ee` | Filter by file extension inside archive files |
87
+ | `--arc-inc`, `--arc-exc` | Limit search results to specific set of directories or files inside archive files |
88
+ | `--arc-max`, `--arc-min` | Specify maximum and minimum sizes for files inside archive files (It doesn't work for directories because their size is zero in archive files) |
89
+ | `--rarfb` | Path to RAR backend tool (e.g. UnRAR.exe, ...) |
90
+ | `--full-path` | Display full path of files and directories |
91
+ | `--no-content` | Only display files path for content search |
92
+
93
+ ## Requirements
94
+
95
+ * Python 3.6 or higher
96
+ * `unrar`, `bsdtar`, `unar` or `7zip` for the rarfile library to support searching inside `.rar` files (optional)
@@ -1,9 +1,10 @@
1
1
  import click
2
2
  from .searcher import Search
3
3
  from multiprocessing import Process
4
+ from .utils import check_rar_backend
4
5
 
5
6
 
6
- def run_search_process(file, directory, content, ext, exclude_ext, search_instance):
7
+ def run_search_process(file, directory, content, search_instance):
7
8
  """Performs the basic search operation"""
8
9
  total_results = 0
9
10
 
@@ -11,7 +12,7 @@ def run_search_process(file, directory, content, ext, exclude_ext, search_instan
11
12
  if file:
12
13
  total_results += search_instance.search('file').echo('Files', 'file')
13
14
  # Search for directories if requested and extension filters are not active.
14
- if directory and not (ext or exclude_ext):
15
+ if directory:
15
16
  total_results += search_instance.search('directory').echo('Directories', 'directory')
16
17
  # Search for content inside files if requested.
17
18
  if content:
@@ -22,7 +23,6 @@ def run_search_process(file, directory, content, ext, exclude_ext, search_instan
22
23
  click.echo(click.style(message, fg='red'))
23
24
 
24
25
 
25
-
26
26
  @click.command()
27
27
  @click.argument('query')
28
28
  @click.option('-p', '--path', type=click.Path(exists=True, file_okay=False, dir_okay=True),
@@ -51,6 +51,9 @@ def run_search_process(file, directory, content, ext, exclude_ext, search_instan
51
51
  'Examples: r"foo.*bar", wc"Foo", cr".*Foo", ...)')
52
52
  @click.option('--timeout', type=click.INT,
53
53
  help='To stop the search after a specified period of time (Seconds)')
54
+ @click.option('--fuzzy', is_flag=True, help='Enable fuzzy search (approximate matching).')
55
+ @click.option('--fuzzy-level', type=click.IntRange(0, 99), default=80, show_default=True,
56
+ help='Similarity threshold from 0 to 99 for fuzzy search.')
54
57
  # Extension filters
55
58
  @click.option('--ext', multiple=True, type=click.STRING,
56
59
  help='Include files with these extensions. Example: --ext py --ext js')
@@ -68,12 +71,49 @@ def run_search_process(file, directory, content, ext, exclude_ext, search_instan
68
71
  # Size filters
69
72
  @click.option('--max-size', type=click.FLOAT, help='Maximum file/directory size (in MB).')
70
73
  @click.option('--min-size', type=click.FLOAT, help='Minimum file/directory size (in MB).')
74
+ # Archive options
75
+ @click.option('--archive', is_flag=True,
76
+ help='Enable search within archive files (e.g. zip, rar, 7z, gz, bz2, xz, tar, tar.gz, tar.bz2, tar.xz)')
77
+ @click.option('--arc-ext', multiple=True, type=click.STRING,
78
+ help='Include files with these extensions inside archive files. Example: --arc-ext py --arc-ext js')
79
+ @click.option('--arc-ee', multiple=True, type=click.STRING,
80
+ help='Exclude files with these extensions inside archive files. Example: --arc-ee jpg --arc-ee exe')
81
+ @click.option('--arc-inc', type=click.Path(file_okay=True, dir_okay=True),
82
+ multiple=True, help='Directories or files to include in search for inside archive files.')
83
+ @click.option('--arc-exc', type=click.Path(file_okay=True, dir_okay=True),
84
+ multiple=True, help='Directories or files to exclude from search for inside archive files.')
85
+ @click.option('--arc-max', type=click.FLOAT, help='Maximum size of files in the archive (in MB).')
86
+ @click.option('--arc-min', type=click.FLOAT, help='Minimum size of files in the archive (in MB).')
87
+ @click.option('--rarfb', type=click.Path(exists=True, file_okay=True, dir_okay=False),
88
+ help='Path to RAR backend tool (e.g. UnRAR.exe, ...). '
89
+ 'Enter the file type in the query (e.g. unrar, bsdtar, unar, 7z).')
71
90
  # Output option
72
91
  @click.option('--full-path', is_flag=True, help='Display full paths for results.')
73
92
  @click.option('--no-content', is_flag=True, help='Only display files path for content search.')
74
93
  def search(query, path, file, directory, content, case_sensitive, ext, exclude_ext, regex, include, exclude,
75
- re_include, re_exclude, word, expr, timeout, max_size, min_size, full_path, no_content):
94
+ re_include, re_exclude, word, expr, timeout, fuzzy, fuzzy_level, max_size, min_size, archive, arc_ext,
95
+ arc_ee, arc_inc, arc_exc, arc_max, arc_min, rarfb, full_path, no_content):
76
96
  """Search for files, directories, and file content based on the query."""
97
+
98
+ check_rar_backend(archive, rarfb, query)
99
+
100
+ if not expr and fuzzy:
101
+ if not word:
102
+ click.echo(
103
+ click.style(
104
+ "Warning: Fuzzy substring highlighting and counting matches are disabled to improve performance.\n",
105
+ fg="yellow"
106
+ )
107
+ )
108
+ else:
109
+ click.echo(
110
+ click.style(
111
+ 'Warning: When using "--fuzzy" and "--word", it is better to have the query be a word and '
112
+ 'not a phrase, as this will cause errors in the results.\n',
113
+ fg="yellow"
114
+ )
115
+ )
116
+
77
117
  # If no search type is specified, search in all types.
78
118
  if not any((file, directory, content)):
79
119
  file = directory = content = True
@@ -92,8 +132,17 @@ def search(query, path, file, directory, content, case_sensitive, ext, exclude_e
92
132
  re_exclude=re_exclude,
93
133
  whole_word=word,
94
134
  expr=expr,
135
+ fuzzy=fuzzy,
136
+ fuzzy_level=fuzzy_level,
95
137
  max_size=max_size,
96
138
  min_size=min_size,
139
+ archive=archive,
140
+ arc_ext=arc_ext,
141
+ arc_ee=arc_ee,
142
+ arc_inc=arc_inc,
143
+ arc_exc=arc_exc,
144
+ arc_max=arc_max,
145
+ arc_min=arc_min,
97
146
  full_path=full_path,
98
147
  no_content=no_content
99
148
  )
@@ -112,7 +161,7 @@ def search(query, path, file, directory, content, case_sensitive, ext, exclude_e
112
161
  p.join()
113
162
  click.echo(click.style(f"\nTimeout! Search exceeded {timeout} seconds and was stopped.", fg="red"))
114
163
  else:
115
- run_search_process(file, directory, content, ext, exclude_ext, search_instance)
164
+ run_search_process(file, directory, content, search_instance)
116
165
 
117
166
 
118
167
  if __name__ == "__main__":
@@ -3,6 +3,7 @@ import sys
3
3
  import click
4
4
  from lark import Lark, Transformer
5
5
  from .utils import compile_regex
6
+ from rapidfuzz import fuzz
6
7
 
7
8
 
8
9
  class ExprNode:
@@ -13,31 +14,64 @@ class ExprNode:
13
14
 
14
15
  class TermNode(ExprNode):
15
16
  """Node representing a single search term"""
16
- def __init__(self, term: str, regex, whole_word, case_sensitive):
17
+ def __init__(self, term: str, regex, whole_word, case_sensitive, fuzzy, fuzzy_level):
17
18
  self.raw_term = term
18
19
  self.whole_word = whole_word
19
20
  self.case_sensitive = case_sensitive
21
+ self.fuzzy = fuzzy
22
+ self.fuzzy_level = fuzzy_level
20
23
 
21
- flags = 0 if case_sensitive else re.IGNORECASE # Adjust case sensitivity
24
+ if not fuzzy:
25
+ flags = 0 if case_sensitive else re.IGNORECASE # Adjust case sensitivity
22
26
 
23
- # Build the regex pattern
24
- if not regex:
25
- term = re.escape(term) # Escape if not regex
26
- # Apply whole-word matching only if not using regex (or if desired behavior is defined)
27
- if whole_word:
28
- term = r'\b' + term + r'\b'
27
+ # Build the regex pattern
28
+ if not regex:
29
+ term = re.escape(term) # Escape if not regex
30
+ # Apply whole-word matching only if not using regex (or if desired behavior is defined)
31
+ if whole_word:
32
+ term = r'\b' + term + r'\b'
29
33
 
30
- self.pattern = compile_regex(term, flags) # Precompile the regex pattern for performance
34
+ self.pattern = compile_regex(term, flags) # Precompile the regex pattern for performance
31
35
 
32
36
  def evaluate(self, text: str) -> bool:
33
- return bool(self.pattern.search(text))
37
+ if not self.fuzzy:
38
+ return bool(self.pattern.search(text))
39
+
40
+ # Fuzzy search mode
41
+ text_cmp = text if self.case_sensitive else text.lower()
42
+ term = self.raw_term if self.case_sensitive else self.raw_term.lower()
43
+
44
+ if self.whole_word:
45
+ words = re.findall(r'\w+', text_cmp)
46
+ return any(fuzz.ratio(term, word) >= self.fuzzy_level for word in words)
47
+ else:
48
+ # Use the correct method depending on the len of str to increase accuracy and avoid illogical matching
49
+ if len(text_cmp) > len(term):
50
+ score = fuzz.partial_ratio(term, text_cmp)
51
+ else:
52
+ score = fuzz.ratio(term, text_cmp)
53
+ return score >= self.fuzzy_level
54
+
34
55
 
35
56
  def count_matches(self, text: str) -> int:
36
- """Count how many times the pattern appears in the text"""
37
- return len(list(self.pattern.finditer(text)))
57
+ """Count how many times the pattern or fuzzy term appears in the text"""
58
+ if not self.fuzzy:
59
+ return len(list(self.pattern.finditer(text)))
60
+
61
+ text_cmp = text if self.case_sensitive else text.lower()
62
+ term = self.raw_term if self.case_sensitive else self.raw_term.lower()
63
+
64
+ if self.whole_word:
65
+ words = re.findall(r'\w+', text_cmp)
66
+ return sum(1 for word in words if fuzz.ratio(term, word) >= self.fuzzy_level)
67
+
68
+ # Optionally could implement a sliding window here, but it's expensive
69
+ return 0
38
70
 
39
71
  def get_binary_pattern(self) -> re.Pattern:
40
72
  """Return a compiled binary regex pattern"""
73
+ if self.fuzzy:
74
+ raise NotImplementedError("Binary pattern is not supported for fuzzy matching.")
41
75
  return re.compile(self.pattern.pattern.encode("utf-8"), self.pattern.flags)
42
76
 
43
77
 
@@ -99,8 +133,10 @@ PREFIXED_STRING: /(r|c|w|rc|cr|cw|wc)"([^"\\]|\\.)*"/
99
133
 
100
134
  class TreeToExpr(Transformer):
101
135
  """Transform parsed tree into expression tree (ExprNode subclasses)"""
102
- def __init__(self):
136
+ def __init__(self, fuzzy, fuzzy_level):
103
137
  super().__init__()
138
+ self.fuzzy = fuzzy
139
+ self.fuzzy_level = fuzzy_level
104
140
 
105
141
  def string(self, s):
106
142
  """ Match normal quoted string: "foo" """
@@ -109,7 +145,9 @@ class TreeToExpr(Transformer):
109
145
  term,
110
146
  False,
111
147
  False,
112
- False
148
+ False,
149
+ self.fuzzy,
150
+ self.fuzzy_level
113
151
  )
114
152
 
115
153
  def prefixed_string(self, s):
@@ -122,6 +160,8 @@ class TreeToExpr(Transformer):
122
160
  regex='r' in prefix,
123
161
  whole_word='w' in prefix,
124
162
  case_sensitive='c' in prefix,
163
+ fuzzy=self.fuzzy,
164
+ fuzzy_level=self.fuzzy_level
125
165
  )
126
166
 
127
167
  def and_expr(self, args):
@@ -134,48 +174,72 @@ class TreeToExpr(Transformer):
134
174
  return NotNode(args[0])
135
175
 
136
176
 
137
- def parse_query_expression(query: str, expr, regex, whole_word, case_sensitive) -> ExprNode:
177
+ def parse_query_expression(query: str, expr, regex, whole_word, case_sensitive, fuzzy, fuzzy_level) -> ExprNode:
138
178
  """
139
179
  Function to parse the query and return expression tree.
140
180
  If expr is False, treat the whole query as a single term.
141
181
  """
142
182
 
143
183
  if not expr:
144
- return TermNode(query, regex, whole_word, case_sensitive)
184
+ return TermNode(query, regex, whole_word, case_sensitive, fuzzy, fuzzy_level)
145
185
 
146
186
  # Otherwise, parse using Lark
147
187
  parser = Lark(query_grammar, parser="lalr")
148
188
  try:
149
189
  tree = parser.parse(query)
150
- return TreeToExpr().transform(tree)
190
+ return TreeToExpr(fuzzy, fuzzy_level).transform(tree)
151
191
  except Exception as e:
152
192
  click.echo(click.style("Query parser error:\n\n", fg='red') + str(e))
153
193
  sys.exit(1)
154
194
 
155
195
 
156
- def highlight_text_safe(expr: ExprNode, text: str) -> str:
196
+ def highlight_text(expr: ExprNode, text: str, fuzzy: bool) -> str:
197
+ """
198
+ Highlight matching parts of the text.
199
+ Only highlights fuzzy matches when whole_word=True.
200
+ """
157
201
  matches = []
158
202
 
159
203
  def collect_matches(node):
160
204
  if isinstance(node, TermNode):
161
- for match in node.pattern.finditer(text):
162
- matches.append((match.start(), match.end()))
163
- elif isinstance(node, (AndNode, OrNode)):
205
+ # Skip fuzzy highlight if whole_word is False
206
+ if node.fuzzy:
207
+ if not node.whole_word:
208
+ return # skip highlighting
209
+ text_cmp = text if node.case_sensitive else text.lower()
210
+ term = node.raw_term if node.case_sensitive else node.raw_term.lower()
211
+
212
+ # collect word matches
213
+ for match in re.finditer(r'\w+', text_cmp):
214
+ word = match.group()
215
+ if fuzz.ratio(term, word) >= node.fuzzy_level:
216
+ matches.append((match.start(), match.end()))
217
+ else:
218
+ for match in node.pattern.finditer(text):
219
+ matches.append((match.start(), match.end()))
220
+ elif isinstance(node, AndNode) or isinstance(node, OrNode):
164
221
  collect_matches(node.left)
165
222
  collect_matches(node.right)
166
223
  elif isinstance(node, NotNode):
167
224
  collect_matches(node.child)
168
225
 
226
+ # If fuzzy is enabled but whole_word is False, return plain text
227
+ if fuzzy and isinstance(expr, TermNode) and not expr.whole_word:
228
+ return text
229
+
169
230
  collect_matches(expr)
170
231
 
171
- # Remove overlaps (for example, if one match was inside another match)
232
+ # Sort and merge overlapping matches (for example, if one match was inside another match)
172
233
  matches.sort() # sort by start position
173
234
  merged = []
174
235
  for start, end in matches:
175
236
  if not merged or start >= merged[-1][1]: # no overlap
176
237
  merged.append((start, end))
238
+ else:
239
+ # Merge overlapping
240
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
177
241
 
178
- # Create the final text with highlight
242
+ # Build highlighted text
179
243
  result = []
180
244
  last = 0
181
245
  for start, end in merged: