|
0
|
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 }
|