diff options
| author | EuAndreh <eu@euandre.org> | 2026-06-12 09:19:28 -0300 |
|---|---|---|
| committer | EuAndreh <eu@euandre.org> | 2026-06-12 09:19:28 -0300 |
| commit | 46fd0362bce11d709e5efe6d540358533985d363 (patch) | |
| tree | f77d2ed33c4f3fb6e85353e436efca4e19028f73 /src/remembering.go | |
| parent | rm .tdrc COPYING (diff) | |
| download | remembering-46fd0362bce11d709e5efe6d540358533985d363.tar.gz remembering-46fd0362bce11d709e5efe6d540358533985d363.tar.xz | |
Rewrite remembering in Go
The shell pipeline (sed | sort | tee | awk | sort | cut | "$@"
plus the cut | uniq | awk profile rewrite) becomes a single static
binary with the same observable behaviour, pinned by the original
ranking.sh, signals.sh and cli-opts.sh suites, now aimed at
remembering.bin:
- the profile keeps the exact on-disk format, COUNT profile TEXT,
byte-sorted with new picks appended at 1 and offered-but-never-
picked entries persisted at 0;
- the menu stays count-descending with byte-order ties, stdin
alone defines what is offered, and duplicate profile lines sum
for ranking but collapse to the highest count on rewrite, as
sort | uniq -f1 did;
- the wrapped command's exit status is forwarded as-is (128+sig
for signal deaths), its stderr passes through, and an empty pick
learns nothing;
- the profile rewrite stays atomic via .tmp plus rename.
Per the house CLI conventions, -h/-V/--help/--version are gone
(the manpage is the documentation; bad options print the usage on
stderr and exit 2), and getopts-style attached option arguments
(-pNAME) are not accepted any more --- no script in the wild used
them.
The project layout follows rot: raw go tool compile/link Makefile,
mkdeps.sh-generated deps.mk, white-box unit suite, fuzz target
over the profile parse/serialize roundtrip, functional pick
roundtrip, a 1M-line ranking benchmark, and a single English
asciidoc manpage absorbing the old .5 page; the po4a/aux release
machinery goes away.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat (limited to 'src/remembering.go')
| -rw-r--r-- | src/remembering.go | 331 |
1 files changed, 331 insertions, 0 deletions
diff --git a/src/remembering.go b/src/remembering.go new file mode 100644 index 0000000..8d2a017 --- /dev/null +++ b/src/remembering.go @@ -0,0 +1,331 @@ +package remembering + +import ( + "bytes" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" +) + + + +type envT struct { + allArgs []string + in io.Reader + out io.Writer + err io.Writer +} + +// entryT is one line of a profile file. Its on-disk form is +// "COUNT profile TEXT", and TEXT runs to the end of the line, so +// anything a menu can show --- spaces, UTF-8, leading blanks --- +// round-trips byte-for-byte. +type entryT struct { + count int + text string +} + + + +// splitLines turns raw input into menu lines: the final newline +// doesn't open a last empty line, but blank lines in the middle +// are kept, since a menu tool would show them. +func splitLines(data string) []string { + if data == "" { + return []string{} + } + return strings.Split(strings.TrimSuffix(data, "\n"), "\n") +} + +// parseProfile reads the on-disk "COUNT profile TEXT" lines. +// TEXT is recovered positionally, like the original awk substr() +// did, so blanks inside it survive. Lines without the "profile" +// tag are dropped; a non-numeric count reads as 0. +func parseProfile(content string) []entryT { + entries := []entryT{} + for _, line := range splitLines(content) { + fields := strings.Fields(line) + if len(fields) < 2 || fields[1] != "profile" { + continue + } + count, _ := strconv.Atoi(fields[0]) + idx := len(fields[0]) + len(fields[1]) + 2 + text := "" + if idx <= len(line) { + text = line[idx:] + } + entries = append(entries, entryT{count, text}) + } + return entries +} + +func serializeProfile(entries []entryT) string { + str := strings.Builder{} + for _, entry := range entries { + fmt.Fprintf( + &str, "%d profile %s\n", + entry.count, entry.text, + ) + } + return str.String() +} + +// menuFor ranks the stdin lines: most-picked first, ties in byte +// order, never-picked entries at the bottom. Counts of duplicate +// profile lines for the same text are summed. Only stdin defines +// what is offered --- profile entries absent from stdin are +// remembered, not shown. +func menuFor(profile []entryT, lines []string) []entryT { + counts := map[string]int{} + for _, entry := range profile { + counts[entry.text] += entry.count + } + + menu := make([]entryT, 0, len(lines)) + for _, line := range lines { + menu = append(menu, entryT{counts[line], line}) + } + sort.SliceStable(menu, func(i int, j int) bool { + if menu[i].count != menu[j].count { + return menu[i].count > menu[j].count + } + return menu[i].text < menu[j].text + }) + return menu +} + +func menuText(menu []entryT) string { + str := strings.Builder{} + for _, entry := range menu { + str.WriteString(entry.text) + str.WriteByte('\n') + } + return str.String() +} + +// nextProfile is the profile after choice was picked: the union +// of what the profile knew and what stdin offered, in byte order, +// with the pick's count bumped. A pick the profile has never +// seen is appended at the end, starting at 1; texts offered but +// never picked enter at 0, so the profile accumulates the +// universe of choices it has seen. Duplicate profile lines for +// the same text collapse to the highest count. +func nextProfile( + profile []entryT, + lines []string, + choice string, +) []entryT { + counts := map[string]int{} + for _, entry := range profile { + prev, seen := counts[entry.text] + if !seen || entry.count > prev { + counts[entry.text] = entry.count + } + } + for _, line := range lines { + _, seen := counts[line] + if !seen { + counts[line] = 0 + } + } + + texts := make([]string, 0, len(counts)) + for text := range counts { + texts = append(texts, text) + } + sort.Strings(texts) + + next := make([]entryT, 0, len(texts)+1) + found := false + for _, text := range texts { + count := counts[text] + if text == choice { + count++ + found = true + } + next = append(next, entryT{count, text}) + } + if !found { + next = append(next, entryT{1, choice}) + } + return next +} + + + +// profilePath is where a profile lives: +// $XDG_DATA_HOME/remembering/NAME, honouring the XDG default of +// ~/.local/share. The default profile name is the current +// directory with slashes turned into "!", so each directory gets +// its own ranking without any setup. +func profilePath(name string) (string, error) { + if name == "" { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + name = strings.ReplaceAll(cwd, "/", "!") + } + data := os.Getenv("XDG_DATA_HOME") + if data == "" { + data = filepath.Join( + os.Getenv("HOME"), ".local", "share", + ) + } + return filepath.Join(data, Name, name), nil +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// loadProfile reads the profile, creating an empty one (and its +// directory) on first use, so the file is in place even when this +// run ends up picking nothing. +func loadProfile(path string) ([]entryT, error) { + if !exists(path) { + err := os.MkdirAll(filepath.Dir(path), 0755) + if err != nil { + return nil, err + } + err = os.WriteFile(path, []byte{}, 0644) + if err != nil { + return nil, err + } + } + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return parseProfile(string(content)), nil +} + +// saveProfile writes the new ranking atomically --- a sibling +// .tmp plus rename --- so a crash never leaves a half-written +// profile behind. +func saveProfile(path string, entries []entryT) error { + tmp := path + ".tmp" + err := os.WriteFile( + tmp, []byte(serializeProfile(entries)), 0644, + ) + if err != nil { + return err + } + return os.Rename(tmp, path) +} + +// forwardedStatus mirrors how sh(1) reports a child: its exit +// status as-is, or 128 plus the signal number when it was killed. +func forwardedStatus(err *exec.ExitError) int { + status, ok := err.Sys().(syscall.WaitStatus) + if ok && status.Signaled() { + return 128 + int(status.Signal()) + } + return err.ExitCode() +} + + + +func usage(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s [-p PROFILE] -- COMMAND...\n", + Name, + ) +} + +func run(env envT) int { + flags := flag.NewFlagSet("", flag.ContinueOnError) + flags.Usage = func() {} + flags.SetOutput(env.err) + profileName := flags.String("p", "", "") + + if flags.Parse(env.allArgs[1:]) != nil { + usage(env.err) + return 2 + } + + command := flags.Args() + if len(command) == 0 { + fmt.Fprintf(env.err, "Missing \"-- COMMAND\"\n") + usage(env.err) + return 2 + } + + path, err := profilePath(*profileName) + if err != nil { + fmt.Fprintf(env.err, "%s: %v\n", Name, err) + return 1 + } + profile, err := loadProfile(path) + if err != nil { + fmt.Fprintf(env.err, "%s: %v\n", Name, err) + return 1 + } + + stdin, err := io.ReadAll(env.in) + if err != nil { + fmt.Fprintf(env.err, "%s: %v\n", Name, err) + return 1 + } + lines := splitLines(string(stdin)) + + // the command is transparent in pipelines: its stderr is + // passed through (that's where dmenu and fzf draw), its + // stdout is the pick, and a nonzero exit --- a cancelled + // menu --- aborts the run with the same status, leaving the + // profile untouched + cmd := exec.Command(command[0], command[1:]...) + cmd.Stdin = strings.NewReader( + menuText(menuFor(profile, lines)), + ) + picked := bytes.Buffer{} + cmd.Stdout = &picked + cmd.Stderr = env.err + err = cmd.Run() + if err != nil { + exitErr := (*exec.ExitError)(nil) + if errors.As(err, &exitErr) { + return forwardedStatus(exitErr) + } + fmt.Fprintf(env.err, "%s: %v\n", Name, err) + if errors.Is(err, fs.ErrPermission) { + return 126 + } + return 127 + } + + choice := strings.TrimRight(picked.String(), "\n") + if choice == "" { + return 0 + } + + err = saveProfile(path, nextProfile(profile, lines, choice)) + if err != nil { + fmt.Fprintf(env.err, "%s: %v\n", Name, err) + return 1 + } + fmt.Fprintf(env.out, "%s\n", choice) + return 0 +} + + + +func Main() { + os.Exit(run(envT{ + allArgs: os.Args, + in: os.Stdin, + out: os.Stdout, + err: os.Stderr, + })) +} |
