From 46fd0362bce11d709e5efe6d540358533985d363 Mon Sep 17 00:00:00 2001 From: EuAndreh Date: Fri, 12 Jun 2026 09:19:28 -0300 Subject: 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 --- src/main.go | 7 ++ src/remembering.go | 331 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/remembering.in | 165 -------------------------- 3 files changed, 338 insertions(+), 165 deletions(-) create mode 100644 src/main.go create mode 100644 src/remembering.go delete mode 100755 src/remembering.in (limited to 'src') diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..e462b0a --- /dev/null +++ b/src/main.go @@ -0,0 +1,7 @@ +package main + +import "remembering" + +func main() { + remembering.Main() +} 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, + })) +} diff --git a/src/remembering.in b/src/remembering.in deleted file mode 100755 index 89e9453..0000000 --- a/src/remembering.in +++ /dev/null @@ -1,165 +0,0 @@ -#!/bin/sh -set -eu - - -usage() { - cat <<-'EOF' - Usage: - @NAME@ [-p PROFILE] -- COMMAND... - @NAME@ -h|-V - EOF -} - -help() { - cat <<-'EOF' - - Options: - -p PROFILE profile to be used for gathering and storing - data (default: create one based on $PWD) - -h, --help show this message - -V, --version print the version number - - COMMAND command to be ran, reading from - STDIN, writing to STDOUT - - - Explanation FIXME. - - See "man @NAME@" for more information. - - - Examples: - - FIXME: - - $ FIXME - EOF -} - -version() { - printf '%s %s %s\n' '@NAME@' '@VERSION@' '@DATE@' -} - - -uuid() { - od -xN20 /dev/urandom | - head -n1 | - awk '{OFS="-"; print $2$3,$4,$5,$6,$7$8$9}' -} - -tmpname() { - echo "${TMPDIR:-/tmp}/@NAME@.tmpfile.$(uuid)" -} - -mkstemp() { - name="$(tmpname)" - touch "$name" - echo "$name" -} - - -for flag in "$@"; do - case "$flag" in - --) - break - ;; - --help) - usage - help - exit - ;; - --version) - version - exit - ;; - *) - ;; - esac -done - -PROFILE_NAME="$(pwd | tr '/' '!')" -while getopts 'p:hV' flag; do - case "$flag" in - p) - PROFILE_NAME="$OPTARG" - ;; - h) - usage - help - exit - ;; - V) - version - exit - ;; - *) - usage >&2 - exit 2 - ;; - esac -done -shift $((OPTIND - 1)) - - -if [ $# = 0 ]; then - printf 'Missing "-- COMMAND"\n' >&2 - usage >&2 - exit 2 -fi - -NAME='@NAME@' -PROFILE="${XDG_DATA_HOME:-$HOME/.local/share}"/$NAME/"$PROFILE_NAME" - -if [ ! -e "$PROFILE" ]; then - mkdir -p "$(dirname "$PROFILE")" - touch "$PROFILE" -fi - -NEXT_PROFILE="$PROFILE".tmp -MERGED="$(mkstemp)" -FILTERED="$(mkstemp)" -trap 'rm -f "$NEXT_PROFILE" "$MERGED" "$FILTERED"' EXIT -CHOICE="$( - cat - | - sed 's/^/0 stdin /' | - sort -k3 -k1nr - "$PROFILE" | - tee "$MERGED" | - awk ' - { rest = substr($0, 3 + length($1) + length($2)) } - $2 == "profile" { seen[rest] += $1 } - $2 == "stdin" { printf "%s %s\n", seen[rest]+0, rest } - ' | - sort -k1nr | - cut -d' ' -f2- | - "$@" -)" - -if [ -z "$CHOICE" ]; then - exit -fi - -< "$MERGED" \ - cut -d' ' -f1,3- | - uniq -f1 | - awk -vCHOICE="$CHOICE" ' - BEGIN { inc = 1 } - - { rest = substr($0, 2 + length($1)) } - - rest == CHOICE { - printf "%s profile %s\n", $1 + inc, rest - found = 1 - next - } - - { printf "%s profile %s\n", $1, rest } - - END { - if (!found) { - printf "%s profile %s\n", 0 + inc, CHOICE - } - } - ' > "$NEXT_PROFILE" - -mv "$NEXT_PROFILE" "$PROFILE" -printf '%s\n' "$CHOICE" -- cgit v1.3