comparison icf/icf.go @ 0:48bdab3eec8a

Initial
author Atarwn Gard <a@qwa.su>
date Mon, 09 Mar 2026 00:37:49 +0500
parents
children d19133be91ba
comparison
equal deleted inserted replaced
-1:000000000000 0:48bdab3eec8a
1 // Package icf implements the Inherited Configuration Format parser.
2 //
3 // ICF is a rule-based configuration format with variables, abstract blocks
4 // (mixins), pattern matching with named capture groups, and brace expansion.
5 //
6 // Syntax:
7 //
8 // ; comment
9 // KEY=value variable
10 // @name abstract block (mixin)
11 // |> directive arg {a,b} directive with optional brace expansion
12 // block.id @mixin concrete block inheriting a mixin
13 // <cap>.example.com block with named capture group
14 // <_>.example.com anonymous wildcard
15 package icf
16
17 import (
18 "bufio"
19 "fmt"
20 "io"
21 "strings"
22 )
23
24 // Directive is a single key + arguments line inside a block.
25 type Directive struct {
26 Key string
27 Args []string
28 }
29
30 // Config holds the parsed and fully resolved state of an ICF file.
31 type Config struct {
32 vars map[string]string
33 abstract map[string][]Directive
34 Blocks []ParsedBlock
35 }
36
37 type ParsedBlock struct {
38 ID string
39 Mixin string
40 Directives []Directive
41 }
42
43 // Parse reads an ICF document and returns a ready Config.
44 func Parse(r io.Reader) (*Config, error) {
45 c := &Config{
46 vars: make(map[string]string),
47 abstract: make(map[string][]Directive),
48 }
49
50 var raw []ParsedBlock
51 var cur *ParsedBlock
52
53 flush := func() {
54 if cur != nil {
55 raw = append(raw, *cur)
56 cur = nil
57 }
58 }
59
60 scanner := bufio.NewScanner(r)
61 for scanner.Scan() {
62 line := stripComment(strings.TrimSpace(scanner.Text()))
63 if line == "" {
64 continue
65 }
66
67 // Variable assignment: KEY=value
68 if i := strings.Index(line, "="); i > 0 && !strings.HasPrefix(line, "|>") {
69 key := line[:i]
70 if isVarName(key) {
71 c.vars[key] = strings.TrimSpace(line[i+1:])
72 continue
73 }
74 }
75
76 // Directive: |> key args...
77 if strings.HasPrefix(line, "|>") {
78 if cur == nil {
79 return nil, fmt.Errorf("icf: directive outside block: %q", line)
80 }
81 parts := strings.Fields(strings.TrimSpace(line[2:]))
82 if len(parts) == 0 {
83 continue
84 }
85 cur.Directives = append(cur.Directives, Directive{
86 Key: parts[0],
87 Args: braceExpand(parts[1:]),
88 })
89 continue
90 }
91
92 // Block header: id [@mixin]
93 flush()
94 parts := strings.Fields(line)
95 pb := ParsedBlock{ID: parts[0]}
96 if len(parts) >= 2 && strings.HasPrefix(parts[1], "@") {
97 pb.Mixin = parts[1][1:]
98 }
99 cur = &pb
100 }
101 flush()
102
103 if err := scanner.Err(); err != nil {
104 return nil, err
105 }
106
107 // Separate abstract from concrete blocks
108 for _, b := range raw {
109 if strings.HasPrefix(b.ID, "@") {
110 c.abstract[b.ID[1:]] = b.Directives
111 } else {
112 c.Blocks = append(c.Blocks, b)
113 }
114 }
115
116 return c, nil
117 }
118
119 // Abstract returns the directives of a named abstract block with variables
120 // substituted. Returns nil if not found.
121 func (c *Config) Abstract(name string) []Directive {
122 dirs, ok := c.abstract[name]
123 if !ok {
124 return nil
125 }
126 return c.applyVars(dirs, nil)
127 }
128
129 // Match finds the most specific block matching input (e.g. "host/path") and
130 // returns resolved directives plus named captures.
131 // Domain part is matched exactly (with captures); path part uses prefix match.
132 func (c *Config) Match(input string) ([]Directive, map[string]string) {
133 inHost, inPath, _ := strings.Cut(input, "/")
134
135 type hit struct {
136 block ParsedBlock
137 captures map[string]string
138 score int
139 }
140 var best *hit
141
142 for _, b := range c.Blocks {
143 patHost, patPath, hasPath := strings.Cut(b.ID, "/")
144
145 caps := make(map[string]string)
146
147 domScore, ok := matchExact(patHost, inHost, caps)
148 if !ok {
149 continue
150 }
151
152 pathScore := 0
153 if hasPath {
154 pathScore, ok = matchPrefix(patPath, inPath, caps)
155 if !ok {
156 continue
157 }
158 }
159
160 score := domScore*1000 + pathScore
161 if best == nil || score > best.score {
162 best = &hit{block: b, captures: caps, score: score}
163 }
164 }
165
166 if best == nil {
167 return nil, nil
168 }
169
170 return c.ResolveBlock(best.block, best.captures), best.captures
171 }
172
173 // ResolveBlock merges mixin then block directives, substituting vars+captures.
174 func (c *Config) ResolveBlock(b ParsedBlock, caps map[string]string) []Directive {
175 var merged []Directive
176 if b.Mixin != "" {
177 merged = append(merged, c.abstract[b.Mixin]...)
178 }
179 merged = append(merged, b.Directives...)
180 return c.applyVars(merged, caps)
181 }
182
183 func (c *Config) applyVars(dirs []Directive, caps map[string]string) []Directive {
184 out := make([]Directive, len(dirs))
185 for i, d := range dirs {
186 out[i].Key = c.subst(d.Key, caps)
187 out[i].Args = make([]string, len(d.Args))
188 for j, a := range d.Args {
189 out[i].Args[j] = c.subst(a, caps)
190 }
191 }
192 return out
193 }
194
195 func (c *Config) subst(s string, caps map[string]string) string {
196 if !strings.Contains(s, "$") {
197 return s
198 }
199 var b strings.Builder
200 i := 0
201 for i < len(s) {
202 if s[i] != '$' {
203 b.WriteByte(s[i])
204 i++
205 continue
206 }
207 j := i + 1
208 for j < len(s) && isVarChar(s[j]) {
209 j++
210 }
211 name := s[i+1 : j]
212 if v, ok := caps[name]; ok {
213 b.WriteString(v)
214 } else if v, ok := c.vars[name]; ok {
215 b.WriteString(v)
216 } else {
217 b.WriteString(s[i:j])
218 }
219 i = j
220 }
221 return b.String()
222 }
223
224 func matchExact(pat, s string, caps map[string]string) (int, bool) {
225 score, rem, ok := matchCaptures(pat, s, caps)
226 if !ok || rem != "" {
227 return 0, false
228 }
229 return score, true
230 }
231
232 func matchPrefix(pat, s string, caps map[string]string) (int, bool) {
233 score, _, ok := matchCaptures(pat, s, caps)
234 return score, ok
235 }
236
237 func matchCaptures(pat, inp string, caps map[string]string) (int, string, bool) {
238 for {
239 if pat == "" {
240 return 0, inp, true
241 }
242
243 if strings.HasPrefix(pat, "<") {
244 end := strings.Index(pat, ">")
245 if end == -1 {
246 return 0, "", false
247 }
248 capName := pat[1:end]
249 rest := pat[end+1:]
250
251 for split := 1; split <= len(inp); split++ {
252 candidate := inp[:split]
253 remaining := inp[split:]
254 score, finalRem, ok := matchCaptures(rest, remaining, caps)
255 if ok {
256 if capName != "_" {
257 caps[capName] = candidate
258 }
259 return score, finalRem, true
260 }
261 }
262 return 0, "", false
263 }
264
265 if inp == "" || pat[0] != inp[0] {
266 return 0, "", false
267 }
268 score, rem, ok := matchCaptures(pat[1:], inp[1:], caps)
269 return score + 1, rem, ok
270 }
271 }
272
273 func braceExpand(args []string) []string {
274 var out []string
275 for _, a := range args {
276 out = append(out, expandOne(a)...)
277 }
278 return out
279 }
280
281 func expandOne(s string) []string {
282 start := strings.Index(s, "{")
283 end := strings.Index(s, "}")
284 if start == -1 || end == -1 || end < start {
285 return []string{s}
286 }
287 prefix := s[:start]
288 suffix := s[end+1:]
289 var out []string
290 for _, v := range strings.Split(s[start+1:end], ",") {
291 out = append(out, prefix+strings.TrimSpace(v)+suffix)
292 }
293 return out
294 }
295
296 func stripComment(line string) string {
297 if strings.HasPrefix(line, ";") {
298 return ""
299 }
300 if i := strings.Index(line, " ;"); i != -1 {
301 return strings.TrimSpace(line[:i])
302 }
303 return line
304 }
305
306 func isVarName(s string) bool {
307 if s == "" {
308 return false
309 }
310 for i := 0; i < len(s); i++ {
311 if !isVarChar(s[i]) {
312 return false
313 }
314 }
315 return true
316 }
317
318 func isVarChar(c byte) bool {
319 return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
320 (c >= '0' && c <= '9') || c == '_'
321 }