Просмотр исходного кода

Give each CLI flag a named parse function

init() registered nine flags as closures in 104 lines (R5). Each flag
now has its own parse function (parsePadOp, parseTuneOp, ...),
registered from a short table, and main() hands opening and exporting
to openProject and write. Flag names, usage text, error messages and
output are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 1 час назад
Родитель
Сommit
f71684ed42
1 измененных файлов с 187 добавлено и 117 удалено
  1. 187 117
      cmd/mpckit-cli/main.go

+ 187 - 117
cmd/mpckit-cli/main.go

@@ -18,9 +18,15 @@ import (
 	"git.monoexp.com/bajdev/mpckit/internal/project"
 	"git.monoexp.com/bajdev/mpckit/internal/project"
 )
 )
 
 
+// opFunc is one change to the project, made from a flag's value.
+type opFunc func(p *project.Project) error
+
+// parseFunc turns a flag's value into an opFunc, or explains what is wrong.
+type parseFunc func(arg string) (opFunc, error)
+
 type op struct {
 type op struct {
 	flag, arg string
 	flag, arg string
-	run       func(p *project.Project) error
+	run       opFunc
 }
 }
 
 
 var ops []op
 var ops []op
@@ -28,7 +34,7 @@ var ops []op
 // opFlag is a repeatable flag whose values become ops.
 // opFlag is a repeatable flag whose values become ops.
 type opFlag struct {
 type opFlag struct {
 	name  string
 	name  string
-	parse func(arg string) (func(p *project.Project) error, error)
+	parse parseFunc
 }
 }
 
 
 func (f opFlag) String() string { return "" }
 func (f opFlag) String() string { return "" }
@@ -41,7 +47,7 @@ func (f opFlag) Set(arg string) error {
 	return nil
 	return nil
 }
 }
 
 
-func addOp(name, usage string, parse func(arg string) (func(p *project.Project) error, error)) {
+func addOp(name, usage string, parse parseFunc) {
 	flag.Var(opFlag{name: name, parse: parse}, name, usage)
 	flag.Var(opFlag{name: name, parse: parse}, name, usage)
 }
 }
 
 
@@ -80,108 +86,158 @@ func parseSeqNum(s string) (int, error) {
 }
 }
 
 
 func init() {
 func init() {
-	addOp("pad", "assign a WAV: `PAD=FILE` (e.g. A1=kick.wav)", func(arg string) (func(*project.Project) error, error) {
-		label, file, ok := strings.Cut(arg, "=")
-		if !ok {
-			return nil, errors.New("want PAD=FILE")
-		}
-		slot, err := parseSlot(label)
-		if err != nil {
-			return nil, err
-		}
-		return func(p *project.Project) error { return p.AssignSample(slot, file) }, nil
-	})
-	addOp("tune", "tune a pad: `PAD=SEMITONES,CENTS` (e.g. B1=-24,0)", func(arg string) (func(*project.Project) error, error) {
-		label, val, ok := strings.Cut(arg, "=")
-		c, f, ok2 := strings.Cut(val, ",")
-		if !ok || !ok2 {
-			return nil, errors.New("want PAD=SEMITONES,CENTS")
-		}
-		slot, err := parseSlot(label)
-		if err != nil {
-			return nil, err
-		}
-		coarse, err1 := strconv.Atoi(strings.TrimSpace(c))
-		fine, err2 := strconv.Atoi(strings.TrimSpace(f))
-		if err1 != nil || err2 != nil {
-			return nil, errors.New("semitones and cents must be integers")
-		}
-		if coarse < project.MinCoarse || coarse > project.MaxCoarse || fine < project.MinFine || fine > project.MaxFine {
-			return nil, fmt.Errorf("tuning out of range (%d..%d semitones, %d..%d cents)", project.MinCoarse, project.MaxCoarse, project.MinFine, project.MaxFine)
-		}
-		return func(p *project.Project) error { return p.SetTune(slot, coarse, fine) }, nil
-	})
-	addOp("mute", "set a pad's mute group: `PAD=GROUP` (0 is off)", func(arg string) (func(*project.Project) error, error) {
-		label, val, ok := strings.Cut(arg, "=")
-		if !ok {
-			return nil, errors.New("want PAD=GROUP")
-		}
-		slot, err := parseSlot(label)
-		if err != nil {
-			return nil, err
+	for _, f := range []struct {
+		name, usage string
+		parse       parseFunc
+	}{
+		{"pad", "assign a WAV: `PAD=FILE` (e.g. A1=kick.wav)", parsePadOp},
+		{"tune", "tune a pad: `PAD=SEMITONES,CENTS` (e.g. B1=-24,0)", parseTuneOp},
+		{"mute", "set a pad's mute group: `PAD=GROUP` (0 is off)", parseMuteOp},
+		{"clear", "clear a pad: `PAD`", parseClearOp},
+		{"copy", "copy pads: `FROM:TO`", parseCopyOp},
+		{"move", "move pads: `FROM:TO`", parseMoveOp},
+		{"swap", "swap pads: `FROM:TO`", parseSwapOp},
+		{"seq", "set a sequence, adding it if needed: `N=NAME,BPM,BARS` (N is 1-128)", parseSeqOp},
+		{"rmseq", "remove a sequence: `N`", parseRmseqOp},
+	} {
+		addOp(f.name, f.usage, f.parse)
+	}
+}
+
+// parsePadOp reads -pad PAD=FILE.
+func parsePadOp(arg string) (opFunc, error) {
+	label, file, ok := strings.Cut(arg, "=")
+	if !ok {
+		return nil, errors.New("want PAD=FILE")
+	}
+	slot, err := parseSlot(label)
+	if err != nil {
+		return nil, err
+	}
+	return func(p *project.Project) error { return p.AssignSample(slot, file) }, nil
+}
+
+// parseTuneOp reads -tune PAD=SEMITONES,CENTS.
+func parseTuneOp(arg string) (opFunc, error) {
+	label, val, ok := strings.Cut(arg, "=")
+	c, f, ok2 := strings.Cut(val, ",")
+	if !ok || !ok2 {
+		return nil, errors.New("want PAD=SEMITONES,CENTS")
+	}
+	slot, err := parseSlot(label)
+	if err != nil {
+		return nil, err
+	}
+	coarse, err1 := strconv.Atoi(strings.TrimSpace(c))
+	fine, err2 := strconv.Atoi(strings.TrimSpace(f))
+	if err1 != nil || err2 != nil {
+		return nil, errors.New("semitones and cents must be integers")
+	}
+	if coarse < project.MinCoarse || coarse > project.MaxCoarse || fine < project.MinFine || fine > project.MaxFine {
+		return nil, fmt.Errorf("tuning out of range (%d..%d semitones, %d..%d cents)",
+			project.MinCoarse, project.MaxCoarse, project.MinFine, project.MaxFine)
+	}
+	return func(p *project.Project) error { return p.SetTune(slot, coarse, fine) }, nil
+}
+
+// parseMuteOp reads -mute PAD=GROUP.
+func parseMuteOp(arg string) (opFunc, error) {
+	label, val, ok := strings.Cut(arg, "=")
+	if !ok {
+		return nil, errors.New("want PAD=GROUP")
+	}
+	slot, err := parseSlot(label)
+	if err != nil {
+		return nil, err
+	}
+	group, err := strconv.Atoi(val)
+	if err != nil {
+		return nil, errors.New("group must be an integer")
+	}
+	return func(p *project.Project) error { return p.SetMuteGroup(slot, group) }, nil
+}
+
+// parseClearOp reads -clear PAD.
+func parseClearOp(arg string) (opFunc, error) {
+	slot, err := parseSlot(arg)
+	if err != nil {
+		return nil, err
+	}
+	return func(p *project.Project) error { return p.ClearPad(slot) }, nil
+}
+
+// parseCopyOp reads -copy FROM:TO.
+func parseCopyOp(arg string) (opFunc, error) {
+	from, to, err := parsePair(arg)
+	if err != nil {
+		return nil, err
+	}
+	return func(p *project.Project) error { return p.CopyPad(from, to) }, nil
+}
+
+// parseMoveOp reads -move FROM:TO. Recorded notes stay on FROM, as in the
+// app, so it warns when there are any.
+func parseMoveOp(arg string) (opFunc, error) {
+	from, to, err := parsePair(arg)
+	if err != nil {
+		return nil, err
+	}
+	return func(p *project.Project) error {
+		if p.PadHasNotes(from) {
+			fmt.Fprintf(os.Stderr, "warning: %s has recorded notes; they stay on %s\n",
+				project.SlotName(from), project.SlotName(from))
 		}
 		}
-		g, err := strconv.Atoi(val)
-		if err != nil {
-			return nil, errors.New("group must be an integer")
+		return p.MovePad(from, to)
+	}, nil
+}
+
+// parseSwapOp reads -swap FROM:TO.
+func parseSwapOp(arg string) (opFunc, error) {
+	from, to, err := parsePair(arg)
+	if err != nil {
+		return nil, err
+	}
+	return func(p *project.Project) error { return p.SwapPads(from, to) }, nil
+}
+
+// parseSeqOp reads -seq N=NAME,BPM,BARS. The sequence is added first if
+// slot N is empty.
+func parseSeqOp(arg string) (opFunc, error) {
+	num, val, ok := strings.Cut(arg, "=")
+	parts := strings.Split(val, ",")
+	if !ok || len(parts) != 3 {
+		return nil, errors.New("want N=NAME,BPM,BARS")
+	}
+	slot, err := parseSeqNum(num)
+	if err != nil {
+		return nil, err
+	}
+	bpm, err1 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
+	bars, err2 := strconv.Atoi(strings.TrimSpace(parts[2]))
+	if err1 != nil || err2 != nil {
+		return nil, errors.New("BPM must be a number and BARS an integer")
+	}
+	return func(p *project.Project) error {
+		exists := false
+		for _, seq := range p.Sequences() {
+			exists = exists || seq.Slot == slot
 		}
 		}
-		return func(p *project.Project) error { return p.SetMuteGroup(slot, g) }, nil
-	})
-	addOp("clear", "clear a pad: `PAD`", func(arg string) (func(*project.Project) error, error) {
-		slot, err := parseSlot(arg)
-		return func(p *project.Project) error { return p.ClearPad(slot) }, err
-	})
-	for _, name := range []string{"copy", "move", "swap"} {
-		addOp(name, name+" pads: `FROM:TO`", func(arg string) (func(*project.Project) error, error) {
-			from, to, err := parsePair(arg)
-			if err != nil {
-				return nil, err
+		if !exists {
+			if err := p.AddSequence(slot); err != nil {
+				return err
 			}
 			}
-			return func(p *project.Project) error {
-				switch name {
-				case "copy":
-					return p.CopyPad(from, to)
-				case "move":
-					if p.PadHasNotes(from) {
-						fmt.Fprintf(os.Stderr, "warning: %s has recorded notes; they stay on %s\n", project.SlotName(from), project.SlotName(from))
-					}
-					return p.MovePad(from, to)
-				}
-				return p.SwapPads(from, to)
-			}, nil
-		})
-	}
-	addOp("seq", "set a sequence, adding it if needed: `N=NAME,BPM,BARS` (N is 1-128)", func(arg string) (func(*project.Project) error, error) {
-		num, val, ok := strings.Cut(arg, "=")
-		parts := strings.Split(val, ",")
-		if !ok || len(parts) != 3 {
-			return nil, errors.New("want N=NAME,BPM,BARS")
-		}
-		slot, err := parseSeqNum(num)
-		if err != nil {
-			return nil, err
-		}
-		bpm, err1 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
-		bars, err2 := strconv.Atoi(strings.TrimSpace(parts[2]))
-		if err1 != nil || err2 != nil {
-			return nil, errors.New("BPM must be a number and BARS an integer")
 		}
 		}
-		return func(p *project.Project) error {
-			exists := false
-			for _, s := range p.Sequences() {
-				exists = exists || s.Slot == slot
-			}
-			if !exists {
-				if err := p.AddSequence(slot); err != nil {
-					return err
-				}
-			}
-			return p.SetSequence(slot, parts[0], bpm, bars)
-		}, nil
-	})
-	addOp("rmseq", "remove a sequence: `N`", func(arg string) (func(*project.Project) error, error) {
-		slot, err := parseSeqNum(arg)
-		return func(p *project.Project) error { return p.RemoveSequence(slot) }, err
-	})
+		return p.SetSequence(slot, parts[0], bpm, bars)
+	}, nil
+}
+
+// parseRmseqOp reads -rmseq N.
+func parseRmseqOp(arg string) (opFunc, error) {
+	slot, err := parseSeqNum(arg)
+	if err != nil {
+		return nil, err
+	}
+	return func(p *project.Project) error { return p.RemoveSequence(slot) }, nil
 }
 }
 
 
 func main() {
 func main() {
@@ -202,39 +258,54 @@ func main() {
 		fail(errors.New("give exactly one of -out or -zip"))
 		fail(errors.New("give exactly one of -out or -zip"))
 	}
 	}
 
 
+	p := openProject(*load, *name)
+	for _, o := range ops {
+		if err := o.run(p); err != nil {
+			fail(fmt.Errorf("-%s %s: %w", o.flag, o.arg, err))
+		}
+	}
+	dest := write(p, *out, *zipPath, *force)
+	summary(p)
+	fmt.Println("wrote", dest)
+}
+
+// openProject loads the project at load, or starts a blank one, and names
+// it. It exits on error.
+func openProject(load, name string) *project.Project {
 	var p *project.Project
 	var p *project.Project
 	var err error
 	var err error
-	if *load != "" {
+	if load != "" {
 		var warnings []string
 		var warnings []string
-		p, warnings, err = project.Load(*load)
+		p, warnings, err = project.Load(load)
 		for _, w := range warnings {
 		for _, w := range warnings {
 			fmt.Fprintln(os.Stderr, "warning:", w)
 			fmt.Fprintln(os.Stderr, "warning:", w)
 		}
 		}
 	} else {
 	} else {
-		if *name == "" {
+		if name == "" {
 			fail(errors.New("-name is required for a new project"))
 			fail(errors.New("-name is required for a new project"))
 		}
 		}
-		p, err = project.New(*name)
+		p, err = project.New(name)
 	}
 	}
 	if err != nil {
 	if err != nil {
 		fail(err)
 		fail(err)
 	}
 	}
-	if *name != "" {
-		if err := p.SetName(*name); err != nil {
+	if name != "" {
+		if err := p.SetName(name); err != nil {
 			fail(err)
 			fail(err)
 		}
 		}
 	}
 	}
-	for _, o := range ops {
-		if err := o.run(p); err != nil {
-			fail(fmt.Errorf("-%s %s: %w", o.flag, o.arg, err))
-		}
-	}
+	return p
+}
 
 
+// write exports p to the folder out or the zip file zipPath, whichever is
+// set, and returns where it went. It exits on error.
+func write(p *project.Project, out, zipPath string, force bool) string {
 	var dest string
 	var dest string
-	if *out != "" {
-		dest, err = export.Folder(p, *out, *force)
+	var err error
+	if out != "" {
+		dest, err = export.Folder(p, out, force)
 	} else {
 	} else {
-		dest, err = *zipPath, export.Zip(p, *zipPath, *force)
+		dest, err = zipPath, export.Zip(p, zipPath, force)
 	}
 	}
 	var exists *export.ExistsError
 	var exists *export.ExistsError
 	if errors.As(err, &exists) {
 	if errors.As(err, &exists) {
@@ -243,8 +314,7 @@ func main() {
 	if err != nil {
 	if err != nil {
 		fail(err)
 		fail(err)
 	}
 	}
-	summary(p)
-	fmt.Println("wrote", dest)
+	return dest
 }
 }
 
 
 func summary(p *project.Project) {
 func summary(p *project.Project) {