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