// 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:] } // StaticPrefix returns the literal text of s before its first placeholder, // with "{{" and "}}" read as the braces they stand for, and whether s has a // placeholder at all. It scans like Expand, so a literal brace never counts // as a placeholder (triage 28b). func StaticPrefix(s string) (prefix string, templated bool) { 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] == '{': return b.String(), true default: b.WriteByte(s[i]) i++ } } return b.String(), false } // 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) { if format == "" { return "", fmt.Errorf("{%s:} needs a format, like {%s:%%Y}", verb, verb) } 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 }