1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
//! Log-view filter matching: substring, regex, and inverse.
//!
//! The `/` filter in the logs view accepts three forms, so a busy stream can be
//! narrowed the way you'd expect from `grep`:
//!
//! - `text` — case-insensitive substring (the default)
//! - `/re/` — a regular expression (case-insensitive)
//! - `!text`,`!/re/` — inverse: keep the lines that *don't* match
//!
//! An empty filter matches everything. A malformed regex matches nothing and is
//! flagged via [`LogMatcher::is_error`] so the view can say so instead of
//! silently hiding the whole buffer.
/// A compiled log filter. Cheap to query per line; build once when the filter
/// text changes.
pub struct LogMatcher {
negate: bool,
kind: Kind,
}
enum Kind {
/// Empty filter — everything matches.
All,
/// Case-insensitive substring (stored lowercased).
Substr(String),
Regex(regex::Regex),
/// A `/…/` that failed to compile.
BadRegex,
}
impl Default for LogMatcher {
fn default() -> Self {
LogMatcher {
negate: false,
kind: Kind::All,
}
}
}
impl LogMatcher {
/// Compile `input` into a matcher. Never fails — a bad regex becomes a
/// [`Kind::BadRegex`] that matches nothing.
pub fn new(input: &str) -> Self {
let (negate, rest) = match input.strip_prefix('!') {
Some(r) => (true, r),
None => (false, input),
};
if rest.is_empty() {
// `` → All; `!` alone → negate All (matches nothing), which reads
// as "hide everything", a reasonable literal interpretation.
return LogMatcher {
negate,
kind: Kind::All,
};
}
// `/pattern/` (at least the two slashes) is a regex.
let kind = if rest.len() >= 2 && rest.starts_with('/') && rest.ends_with('/') {
let pattern = &rest[1..rest.len() - 1];
match regex::RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
{
Ok(re) => Kind::Regex(re),
Err(_) => Kind::BadRegex,
}
} else {
Kind::Substr(rest.to_lowercase())
};
LogMatcher { negate, kind }
}
/// Whether `line` passes the filter.
pub fn matches(&self, line: &str) -> bool {
// A broken regex hides everything (and `is_error` lets the UI explain),
// regardless of negation — negating a typo shouldn't reveal the buffer.
if matches!(self.kind, Kind::BadRegex) {
return false;
}
let base = match &self.kind {
Kind::All => true,
// The whole visible buffer is re-tested per frame, so the common
// (ASCII) case must not allocate. ASCII bytes can't appear inside
// a multi-byte UTF-8 sequence, so the byte-window compare is exact
// on any line; only a non-ASCII *pattern* needs Unicode folding.
Kind::Substr(s) if s.is_ascii() => {
let pat = s.as_bytes();
line.as_bytes()
.windows(pat.len())
.any(|w| w.eq_ignore_ascii_case(pat))
}
Kind::Substr(s) => line.to_lowercase().contains(s),
Kind::Regex(re) => re.is_match(line),
Kind::BadRegex => false,
};
base ^ self.negate
}
/// True when the filter is a `/…/` regex that didn't compile.
pub fn is_error(&self) -> bool {
matches!(self.kind, Kind::BadRegex)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_matches_everything() {
let m = LogMatcher::new("");
assert!(m.matches("anything"));
assert!(!m.is_error());
}
#[test]
fn substring_is_case_insensitive() {
let m = LogMatcher::new("Error");
assert!(m.matches("an ERROR happened"));
assert!(!m.matches("all good"));
}
#[test]
fn inverse_substring_hides_matches() {
let m = LogMatcher::new("!health");
assert!(!m.matches("GET /healthz 200"));
assert!(m.matches("GET /api 500"));
}
#[test]
fn regex_matches_and_is_case_insensitive() {
let m = LogMatcher::new("/level=(warn|error)/");
assert!(m.matches("ts=1 level=ERROR msg=boom"));
assert!(!m.matches("ts=1 level=info msg=ok"));
}
#[test]
fn inverse_regex() {
let m = LogMatcher::new("!/2\\d\\d/");
assert!(!m.matches("status 200"));
assert!(m.matches("status 503"));
}
#[test]
fn bad_regex_matches_nothing_and_flags_error() {
let m = LogMatcher::new("/[unclosed/");
assert!(m.is_error());
assert!(!m.matches("anything"));
// Even negated, a broken regex hides everything.
let n = LogMatcher::new("!/[unclosed/");
assert!(!n.matches("anything"));
}
#[test]
fn slashes_need_both_ends_to_be_a_regex() {
// A single leading slash is a literal substring, not a regex.
let m = LogMatcher::new("/api");
assert!(m.matches("GET /api/v1"));
assert!(!m.is_error());
}
}