xref: /aosp_15_r20/external/pigweed/pw_console/py/pw_console/log_filter.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1# Copyright 2021 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""LogFilters define how to search log lines in LogViews."""
15
16from __future__ import annotations
17import logging
18import re
19from dataclasses import dataclass
20from enum import Enum
21
22from prompt_toolkit.formatted_text import StyleAndTextTuples
23from prompt_toolkit.formatted_text.utils import fragment_list_to_text
24from prompt_toolkit.layout.utils import explode_text_fragments
25from prompt_toolkit.validation import ValidationError, Validator
26
27from pw_console.log_line import LogLine
28
29_LOG = logging.getLogger(__package__)
30
31_UPPERCASE_REGEX = re.compile(r'[A-Z]')
32
33
34class SearchMatcher(Enum):
35    """Possible search match methods."""
36
37    FUZZY = 'FUZZY'
38    REGEX = 'REGEX'
39    STRING = 'STRING'
40
41
42DEFAULT_SEARCH_MATCHER = SearchMatcher.REGEX
43
44
45def preprocess_search_regex(
46    text, matcher: SearchMatcher = DEFAULT_SEARCH_MATCHER
47):
48    # Ignorecase unless the text has capital letters in it.
49    regex_flags = re.IGNORECASE
50    if _UPPERCASE_REGEX.search(text):
51        regex_flags = re.RegexFlag(0)
52
53    if matcher == SearchMatcher.FUZZY:
54        # Fuzzy match replace spaces with .*
55        text_tokens = text.split(' ')
56        if len(text_tokens) > 1:
57            text = '(.*?)'.join(
58                ['({})'.format(re.escape(text)) for text in text_tokens]
59            )
60    elif matcher == SearchMatcher.STRING:
61        # Escape any regex specific characters to match the string literal.
62        text = re.escape(text)
63    elif matcher == SearchMatcher.REGEX:
64        # Don't modify search text input.
65        pass
66
67    return text, regex_flags
68
69
70class RegexValidator(Validator):
71    """Validation of regex input."""
72
73    def validate(self, document):
74        """Check search input for regex syntax errors."""
75        regex_text, regex_flags = preprocess_search_regex(document.text)
76        try:
77            re.compile(regex_text, regex_flags)
78        except re.error as error:
79            raise ValidationError(
80                error.pos, "Regex Error: %s" % error
81            ) from error
82
83
84@dataclass
85class LogFilter:
86    """Log Filter Dataclass."""
87
88    regex: re.Pattern
89    input_text: str | None = None
90    invert: bool = False
91    field: str | None = None
92
93    def pattern(self):
94        return self.regex.pattern  # pylint: disable=no-member
95
96    def matches(self, log: LogLine):
97        field = log.ansi_stripped_log
98        if self.field:
99            if hasattr(log, 'metadata') and hasattr(log.metadata, 'fields'):
100                field = log.metadata.fields.get(
101                    self.field, log.ansi_stripped_log
102                )
103            if hasattr(log.record, 'extra_metadata_fields'):  # type: ignore
104                field = log.record.extra_metadata_fields.get(  # type: ignore
105                    self.field, log.ansi_stripped_log
106                )
107            if self.field == 'lvl':
108                field = log.record.levelname
109            elif self.field == 'time':
110                field = log.record.asctime
111
112        match = self.regex.search(field)  # pylint: disable=no-member
113
114        if self.invert:
115            return not match
116        return match
117
118    def highlight_search_matches(
119        self, line_fragments, selected=False
120    ) -> StyleAndTextTuples:
121        """Highlight search matches in the current line_fragment."""
122        line_text = fragment_list_to_text(line_fragments)
123        exploded_fragments = explode_text_fragments(line_fragments)
124
125        def apply_highlighting(fragments, i):
126            # Expand all fragments and apply the highlighting style.
127            old_style, _text, *_ = fragments[i]
128            if selected:
129                fragments[i] = (
130                    old_style + ' class:search.current ',
131                    fragments[i][1],
132                )
133            else:
134                fragments[i] = (
135                    old_style + ' class:search ',
136                    fragments[i][1],
137                )
138
139        if self.invert:
140            # Highlight the whole line
141            for i, _fragment in enumerate(exploded_fragments):
142                apply_highlighting(exploded_fragments, i)
143        else:
144            # Highlight each non-overlapping search match.
145            for match in self.regex.finditer(  # pylint: disable=no-member
146                line_text
147            ):  # pylint: disable=no-member
148                for fragment_i in range(match.start(), match.end()):
149                    apply_highlighting(exploded_fragments, fragment_i)
150
151        return exploded_fragments
152