view icf/icf.go @ 0:48bdab3eec8a

Initial
author Atarwn Gard <a@qwa.su>
date Mon, 09 Mar 2026 00:37:49 +0500
parents
children d19133be91ba
line wrap: on
line source

// Package icf implements the Inherited Configuration Format parser.
//
// ICF is a rule-based configuration format with variables, abstract blocks
// (mixins), pattern matching with named capture groups, and brace expansion.
//
// Syntax:
//
//	; comment
//	KEY=value                       variable
//	@name                           abstract block (mixin)
//	|> directive arg {a,b}          directive with optional brace expansion
//	block.id @mixin                 concrete block inheriting a mixin
//	<cap>.example.com               block with named capture group
//	<_>.example.com                 anonymous wildcard
package icf

import (
	"bufio"
	"fmt"
	"io"
	"strings"
)

// Directive is a single key + arguments line inside a block.
type Directive struct {
	Key  string
	Args []string
}

// Config holds the parsed and fully resolved state of an ICF file.
type Config struct {
	vars     map[string]string
	abstract map[string][]Directive
	Blocks   []ParsedBlock
}

type ParsedBlock struct {
	ID         string
	Mixin      string
	Directives []Directive
}

// Parse reads an ICF document and returns a ready Config.
func Parse(r io.Reader) (*Config, error) {
	c := &Config{
		vars:     make(map[string]string),
		abstract: make(map[string][]Directive),
	}

	var raw []ParsedBlock
	var cur *ParsedBlock

	flush := func() {
		if cur != nil {
			raw = append(raw, *cur)
			cur = nil
		}
	}

	scanner := bufio.NewScanner(r)
	for scanner.Scan() {
		line := stripComment(strings.TrimSpace(scanner.Text()))
		if line == "" {
			continue
		}

		// Variable assignment: KEY=value
		if i := strings.Index(line, "="); i > 0 && !strings.HasPrefix(line, "|>") {
			key := line[:i]
			if isVarName(key) {
				c.vars[key] = strings.TrimSpace(line[i+1:])
				continue
			}
		}

		// Directive: |> key args...
		if strings.HasPrefix(line, "|>") {
			if cur == nil {
				return nil, fmt.Errorf("icf: directive outside block: %q", line)
			}
			parts := strings.Fields(strings.TrimSpace(line[2:]))
			if len(parts) == 0 {
				continue
			}
			cur.Directives = append(cur.Directives, Directive{
				Key:  parts[0],
				Args: braceExpand(parts[1:]),
			})
			continue
		}

		// Block header: id [@mixin]
		flush()
		parts := strings.Fields(line)
		pb := ParsedBlock{ID: parts[0]}
		if len(parts) >= 2 && strings.HasPrefix(parts[1], "@") {
			pb.Mixin = parts[1][1:]
		}
		cur = &pb
	}
	flush()

	if err := scanner.Err(); err != nil {
		return nil, err
	}

	// Separate abstract from concrete blocks
	for _, b := range raw {
		if strings.HasPrefix(b.ID, "@") {
			c.abstract[b.ID[1:]] = b.Directives
		} else {
			c.Blocks = append(c.Blocks, b)
		}
	}

	return c, nil
}

// Abstract returns the directives of a named abstract block with variables
// substituted. Returns nil if not found.
func (c *Config) Abstract(name string) []Directive {
	dirs, ok := c.abstract[name]
	if !ok {
		return nil
	}
	return c.applyVars(dirs, nil)
}

// Match finds the most specific block matching input (e.g. "host/path") and
// returns resolved directives plus named captures.
// Domain part is matched exactly (with captures); path part uses prefix match.
func (c *Config) Match(input string) ([]Directive, map[string]string) {
	inHost, inPath, _ := strings.Cut(input, "/")

	type hit struct {
		block    ParsedBlock
		captures map[string]string
		score    int
	}
	var best *hit

	for _, b := range c.Blocks {
		patHost, patPath, hasPath := strings.Cut(b.ID, "/")

		caps := make(map[string]string)

		domScore, ok := matchExact(patHost, inHost, caps)
		if !ok {
			continue
		}

		pathScore := 0
		if hasPath {
			pathScore, ok = matchPrefix(patPath, inPath, caps)
			if !ok {
				continue
			}
		}

		score := domScore*1000 + pathScore
		if best == nil || score > best.score {
			best = &hit{block: b, captures: caps, score: score}
		}
	}

	if best == nil {
		return nil, nil
	}

	return c.ResolveBlock(best.block, best.captures), best.captures
}

// ResolveBlock merges mixin then block directives, substituting vars+captures.
func (c *Config) ResolveBlock(b ParsedBlock, caps map[string]string) []Directive {
	var merged []Directive
	if b.Mixin != "" {
		merged = append(merged, c.abstract[b.Mixin]...)
	}
	merged = append(merged, b.Directives...)
	return c.applyVars(merged, caps)
}

func (c *Config) applyVars(dirs []Directive, caps map[string]string) []Directive {
	out := make([]Directive, len(dirs))
	for i, d := range dirs {
		out[i].Key = c.subst(d.Key, caps)
		out[i].Args = make([]string, len(d.Args))
		for j, a := range d.Args {
			out[i].Args[j] = c.subst(a, caps)
		}
	}
	return out
}

func (c *Config) subst(s string, caps map[string]string) string {
	if !strings.Contains(s, "$") {
		return s
	}
	var b strings.Builder
	i := 0
	for i < len(s) {
		if s[i] != '$' {
			b.WriteByte(s[i])
			i++
			continue
		}
		j := i + 1
		for j < len(s) && isVarChar(s[j]) {
			j++
		}
		name := s[i+1 : j]
		if v, ok := caps[name]; ok {
			b.WriteString(v)
		} else if v, ok := c.vars[name]; ok {
			b.WriteString(v)
		} else {
			b.WriteString(s[i:j])
		}
		i = j
	}
	return b.String()
}

func matchExact(pat, s string, caps map[string]string) (int, bool) {
	score, rem, ok := matchCaptures(pat, s, caps)
	if !ok || rem != "" {
		return 0, false
	}
	return score, true
}

func matchPrefix(pat, s string, caps map[string]string) (int, bool) {
	score, _, ok := matchCaptures(pat, s, caps)
	return score, ok
}

func matchCaptures(pat, inp string, caps map[string]string) (int, string, bool) {
	for {
		if pat == "" {
			return 0, inp, true
		}

		if strings.HasPrefix(pat, "<") {
			end := strings.Index(pat, ">")
			if end == -1 {
				return 0, "", false
			}
			capName := pat[1:end]
			rest := pat[end+1:]

			for split := 1; split <= len(inp); split++ {
				candidate := inp[:split]
				remaining := inp[split:]
				score, finalRem, ok := matchCaptures(rest, remaining, caps)
				if ok {
					if capName != "_" {
						caps[capName] = candidate
					}
					return score, finalRem, true
				}
			}
			return 0, "", false
		}

		if inp == "" || pat[0] != inp[0] {
			return 0, "", false
		}
		score, rem, ok := matchCaptures(pat[1:], inp[1:], caps)
		return score + 1, rem, ok
	}
}

func braceExpand(args []string) []string {
	var out []string
	for _, a := range args {
		out = append(out, expandOne(a)...)
	}
	return out
}

func expandOne(s string) []string {
	start := strings.Index(s, "{")
	end := strings.Index(s, "}")
	if start == -1 || end == -1 || end < start {
		return []string{s}
	}
	prefix := s[:start]
	suffix := s[end+1:]
	var out []string
	for _, v := range strings.Split(s[start+1:end], ",") {
		out = append(out, prefix+strings.TrimSpace(v)+suffix)
	}
	return out
}

func stripComment(line string) string {
	if strings.HasPrefix(line, ";") {
		return ""
	}
	if i := strings.Index(line, " ;"); i != -1 {
		return strings.TrimSpace(line[:i])
	}
	return line
}

func isVarName(s string) bool {
	if s == "" {
		return false
	}
	for i := 0; i < len(s); i++ {
		if !isVarChar(s[i]) {
			return false
		}
	}
	return true
}

func isVarChar(c byte) bool {
	return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
		(c >= '0' && c <= '9') || c == '_'
}