// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// One directive of a filter: a target and the level it lets through, or `None`
/// for `off`.
struct Rule {
target : String
glob : Bool
level : Level?
}
///|
/// Which records pass, by target, level and optionally message.
///
/// Comma-separated directives: a bare level for the default, `app=debug` for a
/// target, `app` for everything a target writes, `off` for
/// nothing, `*` inside a target, and a `/regex` at the end that the message must
/// match. A target matches itself and everything under it, split at `::`, `.`,
/// `:` or `/`, and the most specific directive wins.
pub struct Filter {
mut base : Level?
rules : Array[Rule]
mut pattern : @string.Regex?
mut pattern_text : String
}
///|
/// A filter that lets `min` and above through for every target.
pub fn Filter::at(min : Level) -> Filter {
{ base: Some(min), rules: [], pattern: None, pattern_text: "", }
}
///|
/// A filter that lets nothing through.
pub fn Filter::off() -> Filter {
{ base: None, rules: [], pattern: None, pattern_text: "", }
}
///|
/// Reads the directives described on `Filter`; a malformed one is refused with
/// its offset.
pub fn Filter::parse(spec : StringView) -> Filter raise Refused {
let text = spec.to_owned()
let (body, pattern_text) = match text.find(("/" : StringView)) {
Some(i) => (text[0:i].to_owned(), text[i + 1:].to_owned())
None => (text, "")
}
let filter = if body.trim().length() == 0 {
Filter::at(error)
} else {
Filter::off()
}
for directive in split_directives(body) {
let at = position_of(body, directive)
match directive.find(("=" : StringView)) {
Some(i) => {
let target = directive[0:i].trim()
let word = directive[i + 1:].trim()
let level = if word == ("off" : StringView) {
None
} else {
Some(
Level::parse(word) catch {
_ =>
raise refuse(
at,
directive.to_owned(),
"`target=level`, with a level name, a number or `off`",
)
},
)
}
filter.rules.push({
target: target.to_owned(),
glob: target.contains(("*" : StringView)),
level,
})
}
None =>
if directive == ("off" : StringView) {
filter.base = None
} else {
match level_or_none(directive) {
Some(level) => filter.base = Some(level)
None =>
filter.rules.push({
target: directive.to_owned(),
glob: false,
level: Some({ name: "all", value: 0, colour: Colour::Plain, }),
})
}
}
}
}
filter.rules.sort_by(fn(a, b) { rule_order(a, b) })
if pattern_text.length() > 0 {
filter.pattern = Some(pattern_of(pattern_text, text))
filter.pattern_text = pattern_text
}
filter
}
///|
/// A level by name, or `None` when the word is a target instead.
fn level_or_none(word : StringView) -> Level? {
Some(Level::parse(word)) catch {
_ => None
}
}
///|
fn pattern_of(pattern : String, text : String) -> @string.Regex raise Refused {
@string.Regex::Regex(pattern) catch {
_ =>
raise refuse(
text.length() - pattern.length() - 1,
pattern,
"a regular expression",
)
}
}
///|
/// The directive split at commas, trimmed, empties left out.
fn split_directives(body : String) -> Array[StringView] {
let out : Array[StringView] = []
for part in body.split(",") {
let word = part.trim()
if word.length() > 0 {
out.push(word)
}
}
out
}
///|
/// Where `part` starts inside `body`, so a refusal points at the directive that
/// could not be read.
fn position_of(body : String, part : StringView) -> Int32 {
match body.find(part) {
Some(i) => i
None => 0
}
}
///|
/// Literal targets before wildcards, longer before shorter: the most specific
/// directive is the first one that matches.
fn rule_order(a : Rule, b : Rule) -> Int32 {
let by_kind = a.glob.compare(b.glob)
if by_kind != 0 {
return by_kind
}
literal_length(b) - literal_length(a)
}
///|
fn literal_length(rule : Rule) -> Int32 {
let mut n = 0
for c in rule.target {
if c != '*' {
n = n + 1
}
}
n
}
///|
/// Whether a record at `level` from `target` passes, before its message is read.
pub fn Filter::allows(self : Filter, level : Level, target : String) -> Bool {
let min = match first_match(self.rules, target) {
Some(rule) => rule.level
None => self.base
}
match min {
Some(min) => level.at_least(min)
None => false
}
}
///|
/// Whether the message passes the `/regex` part, when there is one.
pub fn Filter::admits(
self : Filter,
level : Level,
target : String,
msg : String,
) -> Bool {
if !self.allows(level, target) {
return false
}
match self.pattern {
Some(pattern) => pattern.execute(msg) is Some(_)
None => true
}
}
///|
/// The lowest level any target lets through, or `None` when everything is off.
pub fn Filter::lowest(self : Filter) -> Level? {
let mut lowest = self.base
for rule in self.rules {
match rule.level {
Some(level) =>
lowest = match lowest {
Some(current) => {
let name = if level.value < current.value { level } else { current }
Some(name)
}
None => Some(level)
}
None => ()
}
}
lowest
}
///|
/// Whether there is a `/regex` part.
pub fn Filter::has_pattern(self : Filter) -> Bool {
self.pattern is Some(_)
}
///|
/// The spec this filter reads back as.
pub fn Filter::to_text(self : Filter) -> String {
let out = StringBuilder()
match self.base {
Some(level) => out.write_string(level.name())
None => out.write_string("off")
}
for rule in self.rules {
out.write_char(',')
out.write_string(rule.target)
match rule.level {
Some(level) =>
if level.value != 0 {
out.write_char('=')
out.write_string(level.name())
}
None => out.write_string("=off")
}
}
if self.pattern_text.length() > 0 {
out.write_char('/')
out.write_string(self.pattern_text)
}
out.to_string()
}
///|
/// The first directive that covers `target`, which is the most specific one
/// because the list is sorted that way.
fn first_match(rules : Array[Rule], target : String) -> Rule? {
for rule in rules {
if rule_matches(rule, target) {
return Some(rule)
}
}
None
}
///|
fn rule_matches(rule : Rule, target : String) -> Bool {
if rule.glob {
return glob(rule.target, target)
}
if !target.has_prefix(rule.target) {
return false
}
let rest = target[rule.target.length():]
if rest.length() == 0 {
return true
}
rest.has_prefix("::") ||
rest.has_prefix(".") ||
rest.has_prefix(":") ||
rest.has_prefix("/")
}
///|
/// `*` matches any run of characters, including none.
fn glob(pattern : String, text : String) -> Bool {
let p = pattern.to_array()
let t = text.to_array()
glob_at(p, 0, t, 0)
}
///|
fn glob_at(
pattern : Array[Char],
pi : Int32,
text : Array[Char],
ti : Int32,
) -> Bool {
if pi == pattern.length() {
return ti == text.length()
}
if pattern[pi] == '*' {
let mut i = ti
while i <= text.length() {
if glob_at(pattern, pi + 1, text, i) {
return true
}
i = i + 1
}
return false
}
if ti == text.length() || pattern[pi] != text[ti] {
return false
}
glob_at(pattern, pi + 1, text, ti + 1)
}