diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 12:58:14 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 12:58:14 +0200 |
| commit | 24a84671ace373ae331fa83a1ff484990f4dff0e (patch) | |
| tree | a6b6e3949d7dd241f1d13e079dfb982d758c89a2 /internal/plan/placeholder.go | |
| parent | 3b36a48b7ce5a53a9366f3b31f94311f178e2553 (diff) | |
| download | krino-24a84671ace373ae331fa83a1ff484990f4dff0e.tar.gz krino-24a84671ace373ae331fa83a1ff484990f4dff0e.zip | |
krino: planning — chains, placeholders, conflicts, JSON
Diffstat (limited to 'internal/plan/placeholder.go')
| -rw-r--r-- | internal/plan/placeholder.go | 155 |
1 files changed, 155 insertions, 0 deletions
diff --git a/internal/plan/placeholder.go b/internal/plan/placeholder.go new file mode 100644 index 0000000..b947236 --- /dev/null +++ b/internal/plan/placeholder.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package plan turns matched rules into concrete actions: placeholder +// expansion, chains, conflict resolution and the JSON plan representation. +package plan + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +// Facts are the values a placeholder can reference. +type Facts struct { + Name string // the file's current base name + Captures []string // [0] whole match, [1:] groups, from the rule's first true name test + ModTime time.Time + Now time.Time +} + +// splitExt splits name on its last dot, which does not count when it is the +// first character: "a.tar.gz" -> "a.tar", ".gz"; ".bashrc" -> ".bashrc", "". +func splitExt(name string) (stem, ext string) { + i := strings.LastIndexByte(name, '.') + if i <= 0 { + return name, "" + } + return name[:i], name[i:] +} + +// Expand replaces every placeholder in s. It returns an error naming the +// first placeholder it could not expand. It scans s once: "{{" and "}}" +// each emit one literal brace, and any other "{" opens a placeholder that +// runs to the next "}". +func Expand(s string, f Facts) (string, error) { + var b strings.Builder + for i := 0; i < len(s); { + switch { + case s[i] == '{' && i+1 < len(s) && s[i+1] == '{': + b.WriteByte('{') + i += 2 + case s[i] == '}' && i+1 < len(s) && s[i+1] == '}': + b.WriteByte('}') + i += 2 + case s[i] == '{': + end := strings.IndexByte(s[i+1:], '}') + if end < 0 { + return "", errors.New("unclosed placeholder") + } + body := s[i+1 : i+1+end] + val, err := expandOne(body, f) + if err != nil { + return "", err + } + // D2: val is appended with WriteString, after the scan has + // already moved past this placeholder - it is never re-passed + // through this loop, so a "}}" or "{{" inside a capture's own + // text is never collapsed the way one written in the template + // itself would be. A refactor to scan-then-replace would + // silently undo this; TestExpandCaptureNotRescanned pins it. + b.WriteString(val) + i += end + 2 + default: + b.WriteByte(s[i]) + i++ + } + } + return b.String(), nil +} + +// expandOne expands the body of a single {...} placeholder (without the +// braces) into its replacement text. +func expandOne(body string, f Facts) (string, error) { + switch body { + case "name": + return f.Name, nil + case "stem": + stem, _ := splitExt(f.Name) + return stem, nil + case "ext": + _, ext := splitExt(f.Name) + return ext, nil + } + + if verb, format, ok := strings.Cut(body, ":"); ok { + switch verb { + case "mtime": + return strftime(verb, format, f.ModTime) + case "now": + return strftime(verb, format, f.Now) + } + return "", fmt.Errorf("unknown placeholder {%s}", body) + } + + if n, err := strconv.Atoi(body); err == nil { + if n == 0 { + return "", errors.New("capture groups are numbered from 1") + } + // D3: spec §7.3 defines the syntax as {1}...{9}, the same window + // MaxIndex enforces; without this check {10} and up bypass + // checkCaptures entirely (MaxIndex never sees them as capture + // uses) and fail only here, at expansion time. + if n > 9 { + return "", fmt.Errorf("unknown placeholder {%s}", body) + } + if n < 0 || n >= len(f.Captures) { + return "", fmt.Errorf("no capture group %d", n) + } + return f.Captures[n], nil + } + + return "", fmt.Errorf("unknown placeholder {%s}", body) +} + +// strftime renders a strftime subset (%Y %m %d %H %M %S %j %%) of t. verb is +// the placeholder's own verb ("mtime" or "now"), named in error messages so +// they point at what the config author actually wrote (B3) instead of +// hardcoding "mtime" for a {now:...} format error. +func strftime(verb, format string, t time.Time) (string, error) { + var b strings.Builder + for i := 0; i < len(format); i++ { + c := format[i] + if c != '%' { + b.WriteByte(c) + continue + } + i++ + if i >= len(format) { + return "", fmt.Errorf("unknown time format %%%c in {%s:...}", format[i-1], verb) + } + switch format[i] { + case 'Y': + fmt.Fprintf(&b, "%04d", t.Year()) + case 'm': + fmt.Fprintf(&b, "%02d", int(t.Month())) + case 'd': + fmt.Fprintf(&b, "%02d", t.Day()) + case 'H': + fmt.Fprintf(&b, "%02d", t.Hour()) + case 'M': + fmt.Fprintf(&b, "%02d", t.Minute()) + case 'S': + fmt.Fprintf(&b, "%02d", t.Second()) + case 'j': + fmt.Fprintf(&b, "%03d", t.YearDay()) + case '%': + b.WriteByte('%') + default: + return "", fmt.Errorf("unknown time format %%%c in {%s:...}", format[i], verb) + } + } + return b.String(), nil +} |
