• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
21from typing import Optional
22
23from prompt_toolkit.formatted_text import StyleAndTextTuples
24from prompt_toolkit.formatted_text.utils import fragment_list_to_text
25from prompt_toolkit.layout.utils import explode_text_fragments
26from prompt_toolkit.validation import ValidationError, Validator
27
28from pw_console.log_line import LogLine
29
30_LOG = logging.getLogger(__package__)
31
32_UPPERCASE_REGEX = re.compile(r'[A-Z]')
33
34
35class SearchMatcher(Enum):
36    """Possible search match methods."""
37
38    FUZZY = 'FUZZY'
39    REGEX = 'REGEX'
40    STRING = 'STRING'
41
42
43DEFAULT_SEARCH_MATCHER = SearchMatcher.REGEX
44
45
46def preprocess_search_regex(
47    text, matcher: SearchMatcher = DEFAULT_SEARCH_MATCHER
48):
49    # Ignorecase unless the text has capital letters in it.
50    regex_flags = re.IGNORECASE
51    if _UPPERCASE_REGEX.search(text):
52        regex_flags = re.RegexFlag(0)
53
54    if matcher == SearchMatcher.FUZZY:
55        # Fuzzy match replace spaces with .*
56        text_tokens = text.split(' ')
57        if len(text_tokens) > 1:
58            text = '(.*?)'.join(
59                ['({})'.format(re.escape(text)) for text in text_tokens]
60            )
61    elif matcher == SearchMatcher.STRING:
62        # Escape any regex specific characters to match the string literal.
63        text = re.escape(text)
64    elif matcher == SearchMatcher.REGEX:
65        # Don't modify search text input.
66        pass
67
68    return text, regex_flags
69
70
71class RegexValidator(Validator):
72    """Validation of regex input."""
73
74    def validate(self, document):
75        """Check search input for regex syntax errors."""
76        regex_text, regex_flags = preprocess_search_regex(document.text)
77        try:
78            re.compile(regex_text, regex_flags)
79        except re.error as error:
80            raise ValidationError(
81                error.pos, "Regex Error: %s" % error
82            ) from error
83
84
85@dataclass
86class LogFilter:
87    """Log Filter Dataclass."""
88
89    regex: re.Pattern
90    input_text: Optional[str] = None
91    invert: bool = False
92    field: Optional[str] = None
93
94    def pattern(self):
95        return self.regex.pattern  # pylint: disable=no-member
96
97    def matches(self, log: LogLine):
98        field = log.ansi_stripped_log
99        if self.field:
100            if hasattr(log, 'metadata') and hasattr(log.metadata, 'fields'):
101                field = log.metadata.fields.get(
102                    self.field, log.ansi_stripped_log
103                )
104            if hasattr(log.record, 'extra_metadata_fields'):  # type: ignore
105                field = log.record.extra_metadata_fields.get(  # type: ignore
106                    self.field, log.ansi_stripped_log
107                )
108            if self.field == 'lvl':
109                field = log.record.levelname
110            elif self.field == 'time':
111                field = log.record.asctime
112
113        match = self.regex.search(field)  # pylint: disable=no-member
114
115        if self.invert:
116            return not match
117        return match
118
119    def highlight_search_matches(
120        self, line_fragments, selected=False
121    ) -> StyleAndTextTuples:
122        """Highlight search matches in the current line_fragment."""
123        line_text = fragment_list_to_text(line_fragments)
124        exploded_fragments = explode_text_fragments(line_fragments)
125
126        def apply_highlighting(fragments, i):
127            # Expand all fragments and apply the highlighting style.
128            old_style, _text, *_ = fragments[i]
129            if selected:
130                fragments[i] = (
131                    old_style + ' class:search.current ',
132                    fragments[i][1],
133                )
134            else:
135                fragments[i] = (
136                    old_style + ' class:search ',
137                    fragments[i][1],
138                )
139
140        if self.invert:
141            # Highlight the whole line
142            for i, _fragment in enumerate(exploded_fragments):
143                apply_highlighting(exploded_fragments, i)
144        else:
145            # Highlight each non-overlapping search match.
146            for match in self.regex.finditer(  # pylint: disable=no-member
147                line_text
148            ):  # pylint: disable=no-member
149                for fragment_i in range(match.start(), match.end()):
150                    apply_highlighting(exploded_fragments, fragment_i)
151
152        return exploded_fragments
153