view icf/icf.go @ 8:2ffb8028ccbb

add loggingmodes and fix path matching
author Atarwn Gard <a@qwa.su>
date Tue, 17 Mar 2026 19:55:07 +0500
parents 54ab94198677
children
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.
package icf

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

type Directive struct {
	Key  string
	Args []string
}

type Config struct {
	vars         map[string]string
	abstract     map[string][]Directive
	abstractMixin map[string]string // mixin name for each abstract block
	Blocks       []ParsedBlock
}

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

type rawBlock struct {
	id         string
	mixin      string
	directives []rawDirective
}

type rawDirective struct {
	key  string
	args []string // after brace expansion, before var substitution
}

func Parse(r io.Reader) (*Config, error) {
	lines, err := readLines(r)
	if err != nil {
		return nil, err
	}

	// --- Pass 1: collect variables ---
	vars := make(map[string]string)
	subst := makeSubst(vars)

	for _, line := range lines {
		if i := strings.Index(line, "="); i > 0 && !strings.HasPrefix(line, "|>") {
			key := line[:i]
			if isVarName(key) {
				vars[key] = subst(strings.TrimSpace(line[i+1:]), nil)
			}
		}
	}

	// --- Pass 2: parse blocks (raw, no capture substitution yet) ---
	var raws []rawBlock
	var cur *rawBlock

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

	for _, line := range lines {
		if i := strings.Index(line, "="); i > 0 && !strings.HasPrefix(line, "|>") {
			if isVarName(line[:i]) {
				continue
			}
		}

		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, rawDirective{
				key:  parts[0],
				args: braceExpand(parts[1:]),
			})
			continue
		}

		flush()
		parts := strings.Fields(line)
		rb := rawBlock{id: subst(parts[0], nil)}
		if len(parts) >= 2 && strings.HasPrefix(parts[1], "@") {
			rb.mixin = subst(parts[1][1:], nil)
		}
		cur = &rb
	}
	flush()

	// --- Pass 3: separate abstract from concrete, apply var substitution ---
	c := &Config{
		vars:          vars,
		abstract:      make(map[string][]Directive),
		abstractMixin: make(map[string]string),
	}

	for _, rb := range raws {
		dirs := make([]Directive, len(rb.directives))
		for i, rd := range rb.directives {
			args := make([]string, len(rd.args))
			for j, a := range rd.args {
				args[j] = subst(a, nil)
			}
			dirs[i] = Directive{Key: rd.key, Args: args}
		}

		if strings.HasPrefix(rb.id, "@") {
			name := rb.id[1:]
			c.abstract[name] = dirs
			if rb.mixin != "" {
				c.abstractMixin[name] = rb.mixin
			}
		} else {
			c.Blocks = append(c.Blocks, ParsedBlock{
				ID:         rb.id,
				Mixin:      rb.mixin,
				Directives: dirs,
			})
		}
	}

	return c, nil
}

// Abstract returns the directives of a named abstract block.
// Returns nil if not found.
func (c *Config) Abstract(name string) []Directive {
	return c.abstract[name]
}

// Match finds the most specific block matching input (e.g. "host/path") and
// returns resolved directives plus named captures.
// Domain is matched exactly; path 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 directives (lower priority) with block directives,
// then substitutes capture variables.
func (c *Config) ResolveBlock(b ParsedBlock, caps map[string]string) []Directive {
	var merged []Directive
	if b.Mixin != "" {
		merged = append(merged, c.resolveAbstract(b.Mixin, make(map[string]bool))...)
	}
	merged = append(merged, b.Directives...)

	if len(caps) == 0 {
		return merged
	}

	// Substitute capture variables into a copy
	subst := makeSubst(c.vars)
	out := make([]Directive, len(merged))
	for i, d := range merged {
		out[i].Key = d.Key
		out[i].Args = make([]string, len(d.Args))
		for j, a := range d.Args {
			out[i].Args[j] = subst(a, caps)
		}
	}
	return out
}

// resolveAbstract recursively expands an abstract block and its mixin chain.
// visited guards against circular references.
func (c *Config) resolveAbstract(name string, visited map[string]bool) []Directive {
	if visited[name] {
		return nil
	}
	visited[name] = true

	var out []Directive
	if parent, ok := c.abstractMixin[name]; ok {
		out = append(out, c.resolveAbstract(parent, visited)...)
	}
	out = append(out, c.abstract[name]...)
	return out
}

// makeSubst returns a function that substitutes $VAR in s,
// checking caps first, then vars.
func makeSubst(vars map[string]string) func(s string, caps map[string]string) string {
	return func(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 caps != nil {
				if v, ok := caps[name]; ok {
					b.WriteString(v)
					i = j
					continue
				}
			}
			if v, ok := vars[name]; ok {
				b.WriteString(v)
			} else {
				b.WriteString(s[i:j])
			}
			i = j
		}
		return b.String()
	}
}

func readLines(r io.Reader) ([]string, error) {
	var out []string
	scanner := bufio.NewScanner(r)
	for scanner.Scan() {
		line := stripComment(strings.TrimSpace(scanner.Text()))
		if line != "" {
			out = append(out, line)
		}
	}
	return out, scanner.Err()
}

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 := len(inp); split >= 1; split-- {
				score, finalRem, ok := matchCaptures(rest, inp[split:], caps)
				if ok {
					if capName != "_" {
						caps[capName] = inp[:split]
					}
					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 == '_'
}