aboutsummaryrefslogtreecommitdiff
path: root/internal/plan/placeholder.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 12:58:14 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 12:58:14 +0200
commit24a84671ace373ae331fa83a1ff484990f4dff0e (patch)
treea6b6e3949d7dd241f1d13e079dfb982d758c89a2 /internal/plan/placeholder.go
parent3b36a48b7ce5a53a9366f3b31f94311f178e2553 (diff)
downloadkrino-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.go155
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
+}