summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorEuAndreh <eu@euandre.org>2026-06-12 09:19:28 -0300
committerEuAndreh <eu@euandre.org>2026-06-12 09:19:28 -0300
commit46fd0362bce11d709e5efe6d540358533985d363 (patch)
treef77d2ed33c4f3fb6e85353e436efca4e19028f73 /src
parentrm .tdrc COPYING (diff)
downloadremembering-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')
-rw-r--r--src/main.go7
-rw-r--r--src/remembering.go331
-rwxr-xr-xsrc/remembering.in165
3 files changed, 338 insertions, 165 deletions
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"