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