Skip to content

Commit f9c4744

Browse files
committed
feat: add tag filter parsing with NormalizeTag and ParseTagFilters
Moves tag filter syntax parsing from zaparoo-core into go-zapscript where it belongs as part of the ZapScript language specification. - ParseTagFilters: parses comma-separated tag strings with operator prefixes (+, -, ~) into structured TagFilter slices - NormalizeTag: normalizes tag strings for consistent matching (lowercase, spaces→dashes, periods→dashes, remove special chars) - Includes deduplication and validation
1 parent 2567882 commit f9c4744

File tree

2 files changed

+439
-0
lines changed

2 files changed

+439
-0
lines changed

tagfilter.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright 2026 The Zaparoo Project Contributors.
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package zapscript
17+
18+
import (
19+
"fmt"
20+
"regexp"
21+
"strings"
22+
)
23+
24+
var (
25+
reColonSpacing = regexp.MustCompile(`\s*:\s*`)
26+
reSpecialChars = regexp.MustCompile(`[^a-z0-9:,+\-]`)
27+
)
28+
29+
// NormalizeTag normalizes a tag string for consistent matching.
30+
// Applied to BOTH type and value parts separately.
31+
// Rules: trim whitespace, normalize colon spacing, lowercase, spaces→dashes,
32+
// periods→dashes, and remove special chars (except colon, dash, and comma).
33+
func NormalizeTag(s string) string {
34+
// 1. Trim whitespace
35+
s = strings.TrimSpace(s)
36+
37+
// 2. Normalize colon spacing - remove spaces around colons first
38+
s = reColonSpacing.ReplaceAllString(s, ":")
39+
40+
// 3. Convert to lowercase
41+
s = strings.ToLower(s)
42+
43+
// 4. Replace remaining spaces with dashes
44+
s = strings.ReplaceAll(s, " ", "-")
45+
46+
// 5. Convert periods to dashes (for version numbers like "1.2.3" → "1-2-3")
47+
s = strings.ReplaceAll(s, ".", "-")
48+
49+
// 6. Remove other special chars (except colon, dash, and comma)
50+
// Keep: a-z, 0-9, dash, colon, comma
51+
s = reSpecialChars.ReplaceAllString(s, "")
52+
53+
return s
54+
}
55+
56+
// ParseTagFilters parses a comma-separated tag filter string into TagFilter structs.
57+
// Supports operator prefixes:
58+
// - "+" or no prefix: AND (default) - must have tag
59+
// - "-": NOT - must not have tag
60+
// - "~": OR - at least one OR tag must match
61+
//
62+
// Format: "type:value" or "+type:value" (AND), "-type:value" (NOT), "~type:value" (OR)
63+
// Example: "region:usa,-unfinished:demo,~lang:en,~lang:es"
64+
// Returns normalized, deduplicated filters.
65+
func ParseTagFilters(raw string) ([]TagFilter, error) {
66+
if raw == "" {
67+
return []TagFilter{}, nil
68+
}
69+
70+
parts := strings.Split(raw, ",")
71+
72+
// Use map for deduplication while maintaining order
73+
type filterKey struct {
74+
typ string
75+
value string
76+
operator TagOperator
77+
}
78+
seenFilters := make(map[filterKey]bool)
79+
result := make([]TagFilter, 0, len(parts))
80+
81+
for _, tagStr := range parts {
82+
trimmedTag := strings.TrimSpace(tagStr)
83+
if trimmedTag == "" {
84+
continue
85+
}
86+
87+
// Parse operator prefix
88+
operator := TagOperatorAND // default
89+
switch trimmedTag[0] {
90+
case '+':
91+
operator = TagOperatorAND
92+
trimmedTag = trimmedTag[1:]
93+
case '-':
94+
operator = TagOperatorNOT
95+
trimmedTag = trimmedTag[1:]
96+
case '~':
97+
operator = TagOperatorOR
98+
trimmedTag = trimmedTag[1:]
99+
}
100+
101+
// Validate type:value format
102+
colonIdx := strings.Index(trimmedTag, ":")
103+
if colonIdx == -1 {
104+
return nil, fmt.Errorf("invalid tag format for %q: must be in 'type:value' format", tagStr)
105+
}
106+
107+
tagType := strings.TrimSpace(trimmedTag[:colonIdx])
108+
tagValue := strings.TrimSpace(trimmedTag[colonIdx+1:])
109+
110+
// Apply normalization
111+
normalizedType := NormalizeTag(tagType)
112+
normalizedValue := NormalizeTag(tagValue)
113+
114+
// Validate after normalization
115+
if normalizedType == "" || normalizedValue == "" {
116+
return nil, fmt.Errorf("invalid tag %q: type and value cannot be empty after normalization", tagStr)
117+
}
118+
119+
filter := TagFilter{
120+
Type: normalizedType,
121+
Value: normalizedValue,
122+
Operator: operator,
123+
}
124+
125+
// Deduplicate by normalized key (including operator), preserving order
126+
key := filterKey{
127+
typ: filter.Type,
128+
value: filter.Value,
129+
operator: operator,
130+
}
131+
if !seenFilters[key] {
132+
seenFilters[key] = true
133+
result = append(result, filter)
134+
}
135+
}
136+
137+
return result, nil
138+
}

0 commit comments

Comments
 (0)