primeiro commit

This commit is contained in:
2026-08-31 15:08:33 -03:00
commit bf46c56b36
13 changed files with 2947 additions and 0 deletions
+451
View File
@@ -0,0 +1,451 @@
// Package ops implements the file name transformations that a bulk rename is
// built from. Operations are pure functions of a name and its position in the
// file list, which makes a rename easy to preview before anything is renamed
// on disk.
package ops
import (
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// Kind identifies a type of rename operation.
type Kind string
// The supported operation kinds.
const (
KindReplace Kind = "Replace"
KindRemove Kind = "Remove"
KindInsert Kind = "Insert"
KindIncrement Kind = "Increment"
KindLower Kind = "To lowercase"
KindUpper Kind = "To uppercase"
KindTruncate Kind = "Truncate"
)
// Kinds lists every supported operation kind, in menu order.
var Kinds = []Kind{
KindReplace,
KindRemove,
KindInsert,
KindIncrement,
KindLower,
KindUpper,
KindTruncate,
}
// ToLast is the sentinel accepted by index fields to address the last
// character of a name instead of a fixed position.
const ToLast = "to-last"
// Config holds the raw, user-entered settings of a single operation. Values
// are kept as typed by the user so that an operation can be re-opened for
// editing exactly as it was created. Fields not used by Config.Kind are
// ignored by [New].
type Config struct {
Kind Kind
Target string // Replace, Remove: the text or pattern to look for.
Replacement string // Replace: the text to put in its place.
Limit string // Replace, Remove: max number of matches, empty or 0 for all.
Text string // Insert: the text to insert.
Index string // Insert: character position, or ToLast for the end.
Prefix string // Increment: text placed before the counter.
Start string // Increment: first counter value.
Step string // Increment: counter increment per file.
First string // Truncate: first character index, or ToLast.
Last string // Truncate: last character index, or ToLast.
FromStart bool // Replace, Remove: apply Limit to the first matches instead of the last.
CaseSensitive bool // Replace, Remove: match letter case exactly.
Regex bool // Replace, Remove: treat Target as a regular expression.
KeepBetween bool // Truncate: keep the selected range instead of removing it.
PreserveExt bool // All kinds: leave the file extension untouched.
}
// DefaultConfig returns the configuration a new operation of the given kind
// starts with, including the documented default values.
func DefaultConfig(kind Kind) Config {
cfg := Config{Kind: kind, FromStart: true, PreserveExt: true}
switch kind {
case KindInsert:
cfg.Index = "0"
case KindIncrement:
cfg.Start = "0"
cfg.Step = "1"
case KindTruncate:
cfg.First = "0"
cfg.Last = "0"
}
return cfg
}
// Operation transforms file names as one step of a rename pipeline.
type Operation interface {
// Config returns the settings the operation was built from.
Config() Config
// Summary returns a short one-line description for the operation list.
Summary() string
// Apply returns name transformed. index is the zero-based position of the
// file within the list being renamed; only counter-based operations use it.
Apply(name string, index int) string
}
// New builds an operation from cfg. It returns an error if a numeric field
// cannot be parsed or a regular expression does not compile.
func New(cfg Config) (Operation, error) {
switch cfg.Kind {
case KindReplace, KindRemove:
return newSubstitute(cfg)
case KindInsert:
return newInsert(cfg)
case KindIncrement:
return newIncrement(cfg)
case KindLower, KindUpper:
return &caseOp{cfg: cfg}, nil
case KindTruncate:
return newTruncate(cfg)
default:
return nil, fmt.Errorf("unknown operation kind %q", cfg.Kind)
}
}
// Preview applies every operation, in order, to each of names and returns the
// resulting names. The input slice is not modified.
func Preview(names []string, list []Operation) []string {
out := make([]string, len(names))
for i, name := range names {
for _, op := range list {
name = op.Apply(name, i)
}
out[i] = name
}
return out
}
// splitExt splits name into a stem and an extension. When preserve is false,
// or when name has no extension to speak of (such as ".bashrc"), the whole
// name is returned as the stem.
func splitExt(name string, preserve bool) (stem, ext string) {
if !preserve {
return name, ""
}
ext = filepath.Ext(name)
if ext == "" || ext == name {
return name, ""
}
return name[:len(name)-len(ext)], ext
}
// parseInt parses a decimal integer, returning def for an empty string. The
// field name is used to give the error context.
func parseInt(field, s string, def int) (int, error) {
s = strings.TrimSpace(s)
if s == "" {
return def, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("%s: %q is not a number", field, s)
}
return n, nil
}
// parseIndex parses a character index that may also be the [ToLast] sentinel,
// in which case toLast is true and n is meaningless.
func parseIndex(field, s string, def int) (n int, toLast bool, err error) {
if strings.EqualFold(strings.TrimSpace(s), ToLast) {
return 0, true, nil
}
n, err = parseInt(field, s, def)
return n, false, err
}
// compileTarget turns the user's search text into a regular expression,
// quoting it unless cfg asks for regex mode. It returns a nil expression for
// an empty target, which makes the operation a no-op.
func compileTarget(cfg Config) (*regexp.Regexp, error) {
if cfg.Target == "" {
return nil, nil
}
pattern := cfg.Target
if !cfg.Regex {
pattern = regexp.QuoteMeta(pattern)
}
if !cfg.CaseSensitive {
pattern = "(?i)" + pattern
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid regular expression: %w", err)
}
return re, nil
}
// substituteOp implements both [KindReplace] and [KindRemove]; a removal is a
// replacement with empty text.
type substituteOp struct {
cfg Config
re *regexp.Regexp
repl string
limit int
}
func newSubstitute(cfg Config) (Operation, error) {
re, err := compileTarget(cfg)
if err != nil {
return nil, err
}
limit, err := parseInt("limit", cfg.Limit, 0)
if err != nil {
return nil, err
}
if limit < 0 {
return nil, fmt.Errorf("limit: must not be negative")
}
op := &substituteOp{cfg: cfg, re: re, limit: limit}
if cfg.Kind == KindReplace {
op.repl = cfg.Replacement
}
return op, nil
}
func (o *substituteOp) Config() Config { return o.cfg }
func (o *substituteOp) Apply(name string, _ int) string {
if o.re == nil {
return name
}
stem, ext := splitExt(name, o.cfg.PreserveExt)
return o.substitute(stem) + ext
}
// substitute replaces the selected matches in s. In regex mode the
// replacement may refer to capture groups with $1 or ${name}.
func (o *substituteOp) substitute(s string) string {
matches := o.re.FindAllStringSubmatchIndex(s, -1)
if len(matches) == 0 {
return s
}
if o.limit > 0 && o.limit < len(matches) {
if o.cfg.FromStart {
matches = matches[:o.limit]
} else {
matches = matches[len(matches)-o.limit:]
}
}
var b []byte
last := 0
for _, m := range matches {
b = append(b, s[last:m[0]]...)
if o.cfg.Regex {
b = o.re.ExpandString(b, o.repl, s, m)
} else {
b = append(b, o.repl...)
}
last = m[1]
}
return string(append(b, s[last:]...))
}
func (o *substituteOp) Summary() string {
var b strings.Builder
if o.cfg.Kind == KindRemove {
fmt.Fprintf(&b, "Remove %q", o.cfg.Target)
} else {
fmt.Fprintf(&b, "Replace %q with %q", o.cfg.Target, o.cfg.Replacement)
}
flags := []string{}
if o.limit > 0 {
where := "last"
if o.cfg.FromStart {
where = "first"
}
flags = append(flags, fmt.Sprintf("%s %d", where, o.limit))
}
if o.cfg.CaseSensitive {
flags = append(flags, "case sensitive")
}
if o.cfg.Regex {
flags = append(flags, "regex")
}
return b.String() + flagSuffix(flags, o.cfg.PreserveExt)
}
// insertOp implements [KindInsert].
type insertOp struct {
cfg Config
at int
toLast bool
}
func newInsert(cfg Config) (Operation, error) {
at, toLast, err := parseIndex("insert index", cfg.Index, 0)
if err != nil {
return nil, err
}
return &insertOp{cfg: cfg, at: at, toLast: toLast}, nil
}
func (o *insertOp) Config() Config { return o.cfg }
func (o *insertOp) Apply(name string, _ int) string {
stem, ext := splitExt(name, o.cfg.PreserveExt)
r := []rune(stem)
at := o.at
if o.toLast || at > len(r) {
at = len(r)
}
if at < 0 {
at = 0
}
return string(r[:at]) + o.cfg.Text + string(r[at:]) + ext
}
func (o *insertOp) Summary() string {
where := strconv.Itoa(o.at)
if o.toLast {
where = "the end"
}
return fmt.Sprintf("Insert %q at %s", o.cfg.Text, where) + flagSuffix(nil, o.cfg.PreserveExt)
}
// incrementOp implements [KindIncrement]. The counter is appended to the name,
// preceded by the configured prefix.
type incrementOp struct {
cfg Config
start int
step int
}
func newIncrement(cfg Config) (Operation, error) {
start, err := parseInt("number to start", cfg.Start, 0)
if err != nil {
return nil, err
}
step, err := parseInt("incremental step", cfg.Step, 1)
if err != nil {
return nil, err
}
return &incrementOp{cfg: cfg, start: start, step: step}, nil
}
func (o *incrementOp) Config() Config { return o.cfg }
func (o *incrementOp) Apply(name string, index int) string {
stem, ext := splitExt(name, o.cfg.PreserveExt)
return stem + o.cfg.Prefix + strconv.Itoa(o.start+index*o.step) + ext
}
func (o *incrementOp) Summary() string {
return fmt.Sprintf("Append %q + counter from %d step %d", o.cfg.Prefix, o.start, o.step) +
flagSuffix(nil, o.cfg.PreserveExt)
}
// caseOp implements [KindLower] and [KindUpper].
type caseOp struct {
cfg Config
}
func (o *caseOp) Config() Config { return o.cfg }
func (o *caseOp) Apply(name string, _ int) string {
stem, ext := splitExt(name, o.cfg.PreserveExt)
if o.cfg.Kind == KindUpper {
return strings.ToUpper(stem) + ext
}
return strings.ToLower(stem) + ext
}
func (o *caseOp) Summary() string {
return string(o.cfg.Kind) + flagSuffix(nil, o.cfg.PreserveExt)
}
// truncateOp implements [KindTruncate].
type truncateOp struct {
cfg Config
first int
last int
firstLast bool
lastIsLast bool
}
func newTruncate(cfg Config) (Operation, error) {
first, firstLast, err := parseIndex("first character index", cfg.First, 0)
if err != nil {
return nil, err
}
last, lastIsLast, err := parseIndex("last character index", cfg.Last, 0)
if err != nil {
return nil, err
}
return &truncateOp{cfg: cfg, first: first, last: last, firstLast: firstLast, lastIsLast: lastIsLast}, nil
}
func (o *truncateOp) Config() Config { return o.cfg }
func (o *truncateOp) Apply(name string, _ int) string {
stem, ext := splitExt(name, o.cfg.PreserveExt)
r := []rune(stem)
if len(r) == 0 {
return name
}
first, last := o.first, o.last
if o.firstLast {
first = len(r) - 1
}
if o.lastIsLast {
last = len(r) - 1
}
first = clamp(first, 0, len(r)-1)
last = clamp(last, 0, len(r)-1)
if first > last {
first, last = last, first
}
if o.cfg.KeepBetween {
return string(r[first:last+1]) + ext
}
return string(r[:first]) + string(r[last+1:]) + ext
}
func (o *truncateOp) Summary() string {
verb := "Remove"
if o.cfg.KeepBetween {
verb = "Keep"
}
return fmt.Sprintf("%s characters %s..%s", verb, indexLabel(o.first, o.firstLast), indexLabel(o.last, o.lastIsLast)) +
flagSuffix(nil, o.cfg.PreserveExt)
}
// indexLabel renders a character index for display.
func indexLabel(n int, toLast bool) string {
if toLast {
return ToLast
}
return strconv.Itoa(n)
}
// clamp confines n to the inclusive range [lo, hi].
func clamp(n, lo, hi int) int {
if n < lo {
return lo
}
if n > hi {
return hi
}
return n
}
// flagSuffix renders the parenthesised flag list shown after a summary.
func flagSuffix(flags []string, preserveExt bool) string {
if preserveExt {
flags = append(flags, "keep extension")
}
if len(flags) == 0 {
return ""
}
return " (" + strings.Join(flags, ", ") + ")"
}