Mercurial Hosting > d2o
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 } |
