///|
/// A boolean expression over tags, supporting `and`, `or`, `not`,
/// parentheses, and bare `@tag` literals.
pub(all) enum TagExpression {
TagLiteral(String)
Not(TagExpression)
And(TagExpression, TagExpression)
Or(TagExpression, TagExpression)
Always // matches everything (empty expression)
} derive(Show, Eq)
///|
/// Parse a tag expression string into a TagExpression AST.
/// An empty string yields `Always` which matches any set of tags.
pub fn TagExpression::parse(input : String) -> TagExpression raise Error {
let trimmed = trim_string(input)
if trimmed.is_empty() {
return Always
}
let tokens = tokenize_tags(trimmed)
let result = parse_or(tokens, 0)
result.0
}
///|
/// Evaluate whether this expression matches a given set of tags.
pub fn TagExpression::matches(
self : TagExpression,
tags : Array[String],
) -> Bool {
match self {
Always => true
TagLiteral(name) => array_contains(tags, name)
Not(inner) => not(inner.matches(tags))
And(left, right) => left.matches(tags) && right.matches(tags)
Or(left, right) => left.matches(tags) || right.matches(tags)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
///|
fn trim_string(s : String) -> String {
let chars = s.to_array()
let len = chars.length()
let mut start = 0
let mut end = len
while start < end && is_whitespace(chars[start]) {
start = start + 1
}
while end > start && is_whitespace(chars[end - 1]) {
end = end - 1
}
if start == 0 && end == len {
return s
}
let buf = StringBuilder::new()
for i = start; i < end; i = i + 1 {
buf.write_char(chars[i])
}
buf.to_string()
}
///|
fn is_whitespace(c : Char) -> Bool {
c == ' ' || c == '\t' || c == '\n' || c == '\r'
}
///|
fn array_contains(arr : Array[String], value : String) -> Bool {
for item in arr {
if item == value {
return true
}
}
false
}
// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------
///|
fn tokenize_tags(input : String) -> Array[String] {
let tokens : Array[String] = []
let chars = input.to_array()
let len = chars.length()
let mut i = 0
while i < len {
let c = chars[i]
if is_whitespace(c) {
i = i + 1
continue
}
if c == '(' {
tokens.push("(")
i = i + 1
continue
}
if c == ')' {
tokens.push(")")
i = i + 1
continue
}
// Read a word (tag or keyword)
let buf = StringBuilder::new()
while i < len &&
chars[i] != ' ' &&
chars[i] != '\t' &&
chars[i] != '\n' &&
chars[i] != '\r' &&
chars[i] != '(' &&
chars[i] != ')' {
buf.write_char(chars[i])
i = i + 1
}
tokens.push(buf.to_string())
}
tokens
}
// ---------------------------------------------------------------------------
// Recursive descent parser
// Precedence (low to high): or < and < not < atom
// ---------------------------------------------------------------------------
///|
fn parse_or(tokens : Array[String], pos : Int) -> (TagExpression, Int) {
let result = parse_and(tokens, pos)
let mut left = result.0
let mut p = result.1
while p < tokens.length() && tokens[p] == "or" {
let next_result = parse_and(tokens, p + 1)
left = Or(left, next_result.0)
p = next_result.1
}
(left, p)
}
///|
fn parse_and(tokens : Array[String], pos : Int) -> (TagExpression, Int) {
let result = parse_not(tokens, pos)
let mut left = result.0
let mut p = result.1
while p < tokens.length() && tokens[p] == "and" {
let next_result = parse_not(tokens, p + 1)
left = And(left, next_result.0)
p = next_result.1
}
(left, p)
}
///|
fn parse_not(tokens : Array[String], pos : Int) -> (TagExpression, Int) {
if pos < tokens.length() && tokens[pos] == "not" {
let result = parse_not(tokens, pos + 1)
(Not(result.0), result.1)
} else {
parse_atom(tokens, pos)
}
}
///|
fn parse_atom(tokens : Array[String], pos : Int) -> (TagExpression, Int) {
if pos >= tokens.length() {
// Unexpected end; return Always as a safe fallback
return (Always, pos)
}
let token = tokens[pos]
if token == "(" {
let result = parse_or(tokens, pos + 1)
// Consume closing ')'
let close = if result.1 < tokens.length() && tokens[result.1] == ")" {
result.1 + 1
} else {
result.1
}
(result.0, close)
} else {
(TagLiteral(token), pos + 1)
}
}