From 8b004ab4d27b39e3940c7842f92456b27e3a33d9 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sat, 30 May 2026 20:29:56 -0600 Subject: [PATCH 01/15] chore: refactor convert dir --- cmd/cheatmd/convert.go | 210 +---------------- {pkg => internal}/history/frecency_test.go | 0 {pkg => internal}/history/history.go | 0 {pkg => internal}/httputil/get.go | 0 {pkg => internal}/installer/git.go | 0 {pkg => internal}/installer/installer.go | 0 {pkg => internal}/installer/installer_test.go | 0 {pkg => internal}/installer/tarball.go | 0 {pkg => internal}/installer/util.go | 0 {pkg => internal}/packmanifest/manifest.go | 0 .../packmanifest/manifest_test.go | 0 pkg/convert/dir.go | 214 ++++++++++++++++++ 12 files changed, 217 insertions(+), 207 deletions(-) rename {pkg => internal}/history/frecency_test.go (100%) rename {pkg => internal}/history/history.go (100%) rename {pkg => internal}/httputil/get.go (100%) rename {pkg => internal}/installer/git.go (100%) rename {pkg => internal}/installer/installer.go (100%) rename {pkg => internal}/installer/installer_test.go (100%) rename {pkg => internal}/installer/tarball.go (100%) rename {pkg => internal}/installer/util.go (100%) rename {pkg => internal}/packmanifest/manifest.go (100%) rename {pkg => internal}/packmanifest/manifest_test.go (100%) create mode 100644 pkg/convert/dir.go diff --git a/cmd/cheatmd/convert.go b/cmd/cheatmd/convert.go index bb9d57a..4f807f0 100644 --- a/cmd/cheatmd/convert.go +++ b/cmd/cheatmd/convert.go @@ -59,213 +59,9 @@ func runConvert(cmd *cobra.Command, args []string) error { // so we need to parse every .cheat file into a shared index before // emitting any of them. The other formats are still per-file. if format == "navi" { - return convertNaviDirectory(inputAbs, outputPath) + return convert.ConvertNaviDirectory(inputAbs, outputPath) } - return convertDirectory(format, inputAbs, outputPath) + return convert.ConvertDirectory(format, inputAbs, outputPath) } - return convertFile(format, inputAbs, outputPath) -} - -// convertNaviDirectory walks every .cheat file in inputDir, parses them all -// into a shared NaviIndex (so @extends references can resolve across files), -// and writes one converted markdown file per source under outputDir. -func convertNaviDirectory(inputDir, outputDir string) error { - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("failed to create output directory %s: %w", outputDir, err) - } - - sources, rels, err := collectNaviSources(inputDir) - if err != nil { - return err - } - if len(sources) == 0 { - return nil - } - - results := convert.ConvertNaviTree(sources) - for i, res := range results { - rel := rels[i] - relBase := strings.TrimSuffix(rel, filepath.Ext(rel)) - targetFile := filepath.Join(outputDir, relBase+".md") - if err := os.MkdirAll(filepath.Dir(targetFile), 0755); err != nil { - return fmt.Errorf("failed to create directory for %s: %w", targetFile, err) - } - if err := os.WriteFile(targetFile, []byte(res.Content), 0644); err != nil { - return fmt.Errorf("failed to write converted file %s: %w", targetFile, err) - } - fmt.Printf("✓ Converted %s (navi) -> %s\n", rel, targetFile) - } - return nil -} - -// collectNaviSources walks inputDir, returning every .cheat file's content -// alongside its relative path so the writer can preserve directory structure. -func collectNaviSources(inputDir string) ([]convert.NaviSource, []string, error) { - var sources []convert.NaviSource - var rels []string - - err := filepath.WalkDir(inputDir, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - if strings.HasPrefix(d.Name(), ".") && d.Name() != "." { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(strings.ToLower(d.Name()), ".cheat") { - return nil - } - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read file %s: %w", path, err) - } - rel, err := filepath.Rel(inputDir, path) - if err != nil { - return err - } - sources = append(sources, convert.NaviSource{Path: path, Content: string(data)}) - rels = append(rels, rel) - return nil - }) - if err != nil { - return nil, nil, fmt.Errorf("error walking directory: %w", err) - } - return sources, rels, nil -} - -func convertFile(format, inputPath, outputPath string) error { - data, err := os.ReadFile(inputPath) - if err != nil { - return fmt.Errorf("failed to read file %s: %w", inputPath, err) - } - - var converted string - switch format { - case "navi": - converted, err = convert.ConvertNavi(string(data), inputPath) - case "tldr": - converted, err = convert.ConvertTldr(string(data), inputPath) - case "cheat": - converted, err = convert.ConvertCheat(string(data), inputPath) - } - - if err != nil { - return fmt.Errorf("conversion failed: %w", err) - } - - targetFile := outputPath - outInfo, err := os.Stat(outputPath) - if err == nil && outInfo.IsDir() { - // Output is an existing directory, save as .md - base := filepath.Base(inputPath) - base = strings.TrimSuffix(base, filepath.Ext(base)) - targetFile = filepath.Join(outputPath, base+".md") - } else if err != nil && (strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\")) { - // Output doesn't exist but looks like a directory path - if err := os.MkdirAll(outputPath, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", outputPath, err) - } - base := filepath.Base(inputPath) - base = strings.TrimSuffix(base, filepath.Ext(base)) - targetFile = filepath.Join(outputPath, base+".md") - } else { - // Output is a file path, ensure directory exists - dir := filepath.Dir(outputPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dir, err) - } - } - - if err := os.WriteFile(targetFile, []byte(converted), 0644); err != nil { - return fmt.Errorf("failed to write output to %s: %w", targetFile, err) - } - - fmt.Printf("✓ Converted %s (%s) -> %s\n", filepath.Base(inputPath), format, targetFile) - return nil -} - -func convertDirectory(format, inputDir, outputDir string) error { - // Create output dir if it doesn't exist - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("failed to create output directory %s: %w", outputDir, err) - } - - err := filepath.WalkDir(inputDir, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - - if d.IsDir() { - // Skip hidden dirs - if strings.HasPrefix(d.Name(), ".") && d.Name() != "." { - return filepath.SkipDir - } - return nil - } - - // Check if the file matches our format's expected extension/criteria - shouldConvert := false - switch format { - case "navi": - shouldConvert = strings.HasSuffix(strings.ToLower(d.Name()), ".cheat") - case "tldr": - shouldConvert = strings.HasSuffix(strings.ToLower(d.Name()), ".md") - case "cheat": - // cheat/cheat community files typically have no extension and aren't hidden - shouldConvert = !strings.Contains(d.Name(), ".") && !strings.HasPrefix(d.Name(), "_") - } - - if !shouldConvert { - return nil - } - - // Calculate relative path to maintain structure - rel, err := filepath.Rel(inputDir, path) - if err != nil { - return err - } - - relBase := strings.TrimSuffix(rel, filepath.Ext(rel)) - targetFile := filepath.Join(outputDir, relBase+".md") - - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read file %s: %w", path, err) - } - - var converted string - switch format { - case "navi": - converted, err = convert.ConvertNavi(string(data), path) - case "tldr": - converted, err = convert.ConvertTldr(string(data), path) - case "cheat": - converted, err = convert.ConvertCheat(string(data), path) - } - - if err != nil { - return fmt.Errorf("failed to convert file %s: %w", path, err) - } - - // Ensure parent directory exists for the output file - parentDir := filepath.Dir(targetFile) - if err := os.MkdirAll(parentDir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", parentDir, err) - } - - if err := os.WriteFile(targetFile, []byte(converted), 0644); err != nil { - return fmt.Errorf("failed to write converted file %s: %w", targetFile, err) - } - - fmt.Printf("✓ Converted %s (%s) -> %s\n", rel, format, targetFile) - return nil - }) - - if err != nil { - return fmt.Errorf("error walking directory: %w", err) - } - - return nil + return convert.ConvertFile(format, inputAbs, outputPath) } diff --git a/pkg/history/frecency_test.go b/internal/history/frecency_test.go similarity index 100% rename from pkg/history/frecency_test.go rename to internal/history/frecency_test.go diff --git a/pkg/history/history.go b/internal/history/history.go similarity index 100% rename from pkg/history/history.go rename to internal/history/history.go diff --git a/pkg/httputil/get.go b/internal/httputil/get.go similarity index 100% rename from pkg/httputil/get.go rename to internal/httputil/get.go diff --git a/pkg/installer/git.go b/internal/installer/git.go similarity index 100% rename from pkg/installer/git.go rename to internal/installer/git.go diff --git a/pkg/installer/installer.go b/internal/installer/installer.go similarity index 100% rename from pkg/installer/installer.go rename to internal/installer/installer.go diff --git a/pkg/installer/installer_test.go b/internal/installer/installer_test.go similarity index 100% rename from pkg/installer/installer_test.go rename to internal/installer/installer_test.go diff --git a/pkg/installer/tarball.go b/internal/installer/tarball.go similarity index 100% rename from pkg/installer/tarball.go rename to internal/installer/tarball.go diff --git a/pkg/installer/util.go b/internal/installer/util.go similarity index 100% rename from pkg/installer/util.go rename to internal/installer/util.go diff --git a/pkg/packmanifest/manifest.go b/internal/packmanifest/manifest.go similarity index 100% rename from pkg/packmanifest/manifest.go rename to internal/packmanifest/manifest.go diff --git a/pkg/packmanifest/manifest_test.go b/internal/packmanifest/manifest_test.go similarity index 100% rename from pkg/packmanifest/manifest_test.go rename to internal/packmanifest/manifest_test.go diff --git a/pkg/convert/dir.go b/pkg/convert/dir.go new file mode 100644 index 0000000..cc303ca --- /dev/null +++ b/pkg/convert/dir.go @@ -0,0 +1,214 @@ +package convert + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ConvertNaviDirectory walks every .cheat file in inputDir, parses them all +// into a shared NaviIndex (so @extends references can resolve across files), +// and writes one converted markdown file per source under outputDir. +func ConvertNaviDirectory(inputDir, outputDir string) error { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory %s: %w", outputDir, err) + } + + sources, rels, err := collectNaviSources(inputDir) + if err != nil { + return err + } + if len(sources) == 0 { + return nil + } + + results := ConvertNaviTree(sources) + for i, res := range results { + rel := rels[i] + relBase := strings.TrimSuffix(rel, filepath.Ext(rel)) + targetFile := filepath.Join(outputDir, relBase+".md") + if err := os.MkdirAll(filepath.Dir(targetFile), 0755); err != nil { + return fmt.Errorf("failed to create directory for %s: %w", targetFile, err) + } + if err := os.WriteFile(targetFile, []byte(res.Content), 0644); err != nil { + return fmt.Errorf("failed to write converted file %s: %w", targetFile, err) + } + fmt.Printf("✓ Converted %s (navi) -> %s\n", rel, targetFile) + } + return nil +} + +// collectNaviSources walks inputDir, returning every .cheat file's content +// alongside its relative path so the writer can preserve directory structure. +func collectNaviSources(inputDir string) ([]NaviSource, []string, error) { + var sources []NaviSource + var rels []string + + err := filepath.WalkDir(inputDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if strings.HasPrefix(d.Name(), ".") && d.Name() != "." { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(strings.ToLower(d.Name()), ".cheat") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", path, err) + } + rel, err := filepath.Rel(inputDir, path) + if err != nil { + return err + } + sources = append(sources, NaviSource{Path: path, Content: string(data)}) + rels = append(rels, rel) + return nil + }) + if err != nil { + return nil, nil, fmt.Errorf("error walking directory: %w", err) + } + return sources, rels, nil +} + +// ConvertFile converts a single file of the given format. +func ConvertFile(format, inputPath, outputPath string) error { + data, err := os.ReadFile(inputPath) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", inputPath, err) + } + + var converted string + switch format { + case "navi": + converted, err = ConvertNavi(string(data), inputPath) + case "tldr": + converted, err = ConvertTldr(string(data), inputPath) + case "cheat": + converted, err = ConvertCheat(string(data), inputPath) + } + + if err != nil { + return fmt.Errorf("conversion failed: %w", err) + } + + targetFile := outputPath + outInfo, err := os.Stat(outputPath) + if err == nil && outInfo.IsDir() { + // Output is an existing directory, save as .md + base := filepath.Base(inputPath) + base = strings.TrimSuffix(base, filepath.Ext(base)) + targetFile = filepath.Join(outputPath, base+".md") + } else if err != nil && (strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\")) { + // Output doesn't exist but looks like a directory path + if err := os.MkdirAll(outputPath, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", outputPath, err) + } + base := filepath.Base(inputPath) + base = strings.TrimSuffix(base, filepath.Ext(base)) + targetFile = filepath.Join(outputPath, base+".md") + } else { + // Output is a file path, ensure directory exists + dir := filepath.Dir(outputPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + } + + if err := os.WriteFile(targetFile, []byte(converted), 0644); err != nil { + return fmt.Errorf("failed to write output to %s: %w", targetFile, err) + } + + fmt.Printf("✓ Converted %s (%s) -> %s\n", filepath.Base(inputPath), format, targetFile) + return nil +} + +// ConvertDirectory walks the input directory and converts all matching files. +func ConvertDirectory(format, inputDir, outputDir string) error { + // Create output dir if it doesn't exist + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory %s: %w", outputDir, err) + } + + err := filepath.WalkDir(inputDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + // Skip hidden dirs + if strings.HasPrefix(d.Name(), ".") && d.Name() != "." { + return filepath.SkipDir + } + return nil + } + + // Check if the file matches our format's expected extension/criteria + shouldConvert := false + switch format { + case "navi": + shouldConvert = strings.HasSuffix(strings.ToLower(d.Name()), ".cheat") + case "tldr": + shouldConvert = strings.HasSuffix(strings.ToLower(d.Name()), ".md") + case "cheat": + // cheat/cheat community files typically have no extension and aren't hidden + shouldConvert = !strings.Contains(d.Name(), ".") && !strings.HasPrefix(d.Name(), "_") + } + + if !shouldConvert { + return nil + } + + // Calculate relative path to maintain structure + rel, err := filepath.Rel(inputDir, path) + if err != nil { + return err + } + + relBase := strings.TrimSuffix(rel, filepath.Ext(rel)) + targetFile := filepath.Join(outputDir, relBase+".md") + + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", path, err) + } + + var converted string + switch format { + case "navi": + converted, err = ConvertNavi(string(data), path) + case "tldr": + converted, err = ConvertTldr(string(data), path) + case "cheat": + converted, err = ConvertCheat(string(data), path) + } + + if err != nil { + return fmt.Errorf("failed to convert file %s: %w", path, err) + } + + // Ensure parent directory exists for the output file + parentDir := filepath.Dir(targetFile) + if err := os.MkdirAll(parentDir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", parentDir, err) + } + + if err := os.WriteFile(targetFile, []byte(converted), 0644); err != nil { + return fmt.Errorf("failed to write converted file %s: %w", targetFile, err) + } + + fmt.Printf("✓ Converted %s (%s) -> %s\n", rel, format, targetFile) + return nil + }) + + if err != nil { + return fmt.Errorf("error walking directory: %w", err) + } + + return nil +} From df2169c3905ae1be9b5363b9c01b7cb3cea14c89 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sat, 30 May 2026 20:32:56 -0600 Subject: [PATCH 02/15] chore: refactor init --- cmd/cheatmd/init.go | 4 +-- cmd/cheatmd/packs.go | 48 +++++++++++++++++------------------ internal/installer/tarball.go | 2 +- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/cmd/cheatmd/init.go b/cmd/cheatmd/init.go index 4ebfb47..f2f2241 100644 --- a/cmd/cheatmd/init.go +++ b/cmd/cheatmd/init.go @@ -12,10 +12,10 @@ import ( "github.com/spf13/cobra" "golang.org/x/term" + "github.com/cheatmd-dev/cheatmd/internal/installer" + "github.com/cheatmd-dev/cheatmd/internal/packmanifest" "github.com/cheatmd-dev/cheatmd/internal/ui" "github.com/cheatmd-dev/cheatmd/pkg/config" - "github.com/cheatmd-dev/cheatmd/pkg/installer" - "github.com/cheatmd-dev/cheatmd/pkg/packmanifest" "github.com/cheatmd-dev/cheatmd/pkg/registry" ) diff --git a/cmd/cheatmd/packs.go b/cmd/cheatmd/packs.go index a4c7b70..345db66 100644 --- a/cmd/cheatmd/packs.go +++ b/cmd/cheatmd/packs.go @@ -8,9 +8,9 @@ import ( "github.com/spf13/cobra" + "github.com/cheatmd-dev/cheatmd/internal/packmanifest" "github.com/cheatmd-dev/cheatmd/internal/ui" "github.com/cheatmd-dev/cheatmd/pkg/config" - "github.com/cheatmd-dev/cheatmd/pkg/packmanifest" "github.com/cheatmd-dev/cheatmd/pkg/registry" ) @@ -91,22 +91,15 @@ func runPacksList(cmd *cobra.Command, args []string) error { } func runPacksInstall(cmd *cobra.Command, args []string) error { - reg, err := registry.Fetch(cmd.Context(), config.GetRegistryURL()) - if err != nil { - return fmt.Errorf("fetch registry: %w", err) - } - - chosen, err := choosePacks(reg, args) + chosen, err := fetchAndChoosePacks(cmd, args) if err != nil { return err } - - out := cmd.ErrOrStderr() if len(chosen) == 0 { - fmt.Fprintln(out, "No packs selected.") return nil } + out := cmd.ErrOrStderr() dest, installed := installPacks(cmd.Context(), out, chosen) fmt.Fprintf(out, "Installed %d pack(s) into %s\n", installed, dest) if installed < len(chosen) { @@ -116,27 +109,15 @@ func runPacksInstall(cmd *cobra.Command, args []string) error { } func runPacksUpdate(cmd *cobra.Command, args []string) error { - reg, err := registry.Fetch(cmd.Context(), config.GetRegistryURL()) - if err != nil { - return fmt.Errorf("fetch registry: %w", err) - } - - chosen, err := choosePacks(reg, args) + chosen, err := fetchAndChoosePacks(cmd, args) if err != nil { return err } - - out := cmd.ErrOrStderr() if len(chosen) == 0 { - fmt.Fprintln(out, "No packs selected.") return nil } - // For update, we want to ensure we only update installed packs if no args were given? - // Actually, choosePacks shows the picker. We might be picking uninstalled packs too. - // But it's fine, `update` uses the exact same `installPacks` logic, the only difference - // is the wording and that `installPacks` + `installer.Install` now cleanly handles overwriting. - + out := cmd.ErrOrStderr() dest, installed := installPacks(cmd.Context(), out, chosen) fmt.Fprintf(out, "Updated %d pack(s) in %s\n", installed, dest) if installed < len(chosen) { @@ -179,6 +160,25 @@ func runPacksRemove(cmd *cobra.Command, args []string) error { return nil } +// fetchAndChoosePacks handles the common setup of fetching the registry +// and showing the picker (or parsing args) for both install and update. +func fetchAndChoosePacks(cmd *cobra.Command, args []string) ([]registry.Pack, error) { + reg, err := registry.Fetch(cmd.Context(), config.GetRegistryURL()) + if err != nil { + return nil, fmt.Errorf("fetch registry: %w", err) + } + + chosen, err := choosePacks(reg, args) + if err != nil { + return nil, err + } + + if len(chosen) == 0 { + fmt.Fprintln(cmd.ErrOrStderr(), "No packs selected.") + } + return chosen, nil +} + // choosePacks resolves which packs to install: the named packs when names are // given, otherwise the user's selection from the interactive picker. func choosePacks(reg *registry.Registry, names []string) ([]registry.Pack, error) { diff --git a/internal/installer/tarball.go b/internal/installer/tarball.go index 2e17a62..010ed78 100644 --- a/internal/installer/tarball.go +++ b/internal/installer/tarball.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/cheatmd-dev/cheatmd/pkg/httputil" + "github.com/cheatmd-dev/cheatmd/internal/httputil" "github.com/cheatmd-dev/cheatmd/pkg/registry" ) From 9f29ae4de5b97246a0ef097f80a049566bc14b34 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sat, 30 May 2026 20:33:38 -0600 Subject: [PATCH 03/15] chore: refactor widget generation --- cmd/cheatmd/widgets.go | 119 ++--------------------------------- internal/shellgen/widgets.go | 118 ++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 115 deletions(-) create mode 100644 internal/shellgen/widgets.go diff --git a/cmd/cheatmd/widgets.go b/cmd/cheatmd/widgets.go index 8a8dae8..b150349 100644 --- a/cmd/cheatmd/widgets.go +++ b/cmd/cheatmd/widgets.go @@ -2,9 +2,8 @@ package main import ( "fmt" - "strings" - "github.com/cheatmd-dev/cheatmd/pkg/config" + "github.com/cheatmd-dev/cheatmd/internal/shellgen" "github.com/spf13/cobra" ) @@ -27,123 +26,13 @@ func runWidget(cmd *cobra.Command, args []string) error { switch shell { case "bash": - fmt.Fprint(cmd.OutOrStdout(), bashWidget()) + fmt.Fprint(cmd.OutOrStdout(), shellgen.BashWidget()) case "zsh": - fmt.Fprint(cmd.OutOrStdout(), zshWidget()) + fmt.Fprint(cmd.OutOrStdout(), shellgen.ZshWidget()) case "fish": - fmt.Fprint(cmd.OutOrStdout(), fishWidget()) + fmt.Fprint(cmd.OutOrStdout(), shellgen.FishWidget()) default: return fmt.Errorf("unsupported shell: %s (supported: bash, zsh, fish)", shell) } return nil } - -func bashWidget() string { - keyWidget := config.GetKeyWidget() - return fmt.Sprintf(`#!/usr/bin/env bash - -_cheatmd_widget() { - local -r input="${READLINE_LINE}" - - local output - if [ -z "${input}" ]; then - output="$(cheatmd --print)" || return - else - output="$(cheatmd --print --match "$input")" || return - fi - - if [ -n "$output" ]; then - READLINE_LINE="$output" - READLINE_POINT=${#READLINE_LINE} - fi -} - -if [ ${BASH_VERSION:0:1} -lt 4 ]; then - echo "cheatmd widget requires bash 4+" >&2 -else - bind -x '"%s": _cheatmd_widget' -fi -`, keyWidget) -} - -func zshWidget() string { - keyWidget := config.GetKeyWidget() - // Convert bash-style keybinding to zsh format (e.g., \C-g -> ^g) - zshKey := convertToZshKey(keyWidget) - return fmt.Sprintf(`#!/usr/bin/env zsh - -_cheatmd_widget() { - local input="$BUFFER" - - local output - if [ -z "$input" ]; then - output="$(cheatmd --print)" || return - else - output="$(cheatmd --print --match "$input")" || return - fi - - if [ -n "$output" ]; then - BUFFER="$output" - CURSOR=${#BUFFER} - fi - - zle reset-prompt -} - -zle -N _cheatmd_widget -bindkey '%s' _cheatmd_widget -`, zshKey) -} - -func fishWidget() string { - keyWidget := config.GetKeyWidget() - // Convert bash-style keybinding to fish format (e.g., \C-g -> \cg) - fishKey := convertToFishKey(keyWidget) - return fmt.Sprintf(`function _cheatmd_widget - set -l input (commandline) - set -l output - set -l cmd_status 0 - - if test -z "$input" - set output (cheatmd --print) - set cmd_status $status - else - set output (cheatmd --print --match "$input") - set cmd_status $status - end - - if test $cmd_status -ne 0 - return - end - - if test -n "$output" - commandline -r "$output" - commandline -f end-of-line - end - - commandline -f repaint -end - -bind %s _cheatmd_widget -`, fishKey) -} - -// convertToZshKey converts a bash-style keybinding to zsh format -// e.g., \C-g -> ^g, \C-x -> ^x -func convertToZshKey(key string) string { - if strings.HasPrefix(key, "\\C-") { - return "^" + strings.ToLower(key[3:]) - } - // Already in zsh format or other format - return key -} - -// convertToFishKey converts a bash-style keybinding to fish format -// e.g., \C-g -> \cg, \C-x -> \cx -func convertToFishKey(key string) string { - if strings.HasPrefix(key, "\\C-") { - return "\\c" + strings.ToLower(key[3:]) - } - // Already in fish format or other format - return key -} diff --git a/internal/shellgen/widgets.go b/internal/shellgen/widgets.go new file mode 100644 index 0000000..e7e60ba --- /dev/null +++ b/internal/shellgen/widgets.go @@ -0,0 +1,118 @@ +package shellgen + +import ( + "fmt" + "strings" + + "github.com/cheatmd-dev/cheatmd/pkg/config" +) + +func BashWidget() string { + keyWidget := config.GetKeyWidget() + return fmt.Sprintf(`#!/usr/bin/env bash + +_cheatmd_widget() { + local -r input="${READLINE_LINE}" + + local output + if [ -z "${input}" ]; then + output="$(cheatmd --print)" || return + else + output="$(cheatmd --print --match "$input")" || return + fi + + if [ -n "$output" ]; then + READLINE_LINE="$output" + READLINE_POINT=${#READLINE_LINE} + fi +} + +if [ ${BASH_VERSION:0:1} -lt 4 ]; then + echo "cheatmd widget requires bash 4+" >&2 +else + bind -x '"%s": _cheatmd_widget' +fi +`, keyWidget) +} + +func ZshWidget() string { + keyWidget := config.GetKeyWidget() + // Convert bash-style keybinding to zsh format (e.g., \C-g -> ^g) + zshKey := convertToZshKey(keyWidget) + return fmt.Sprintf(`#!/usr/bin/env zsh + +_cheatmd_widget() { + local input="$BUFFER" + + local output + if [ -z "$input" ]; then + output="$(cheatmd --print)" || return + else + output="$(cheatmd --print --match "$input")" || return + fi + + if [ -n "$output" ]; then + BUFFER="$output" + CURSOR=${#BUFFER} + fi + + zle reset-prompt +} + +zle -N _cheatmd_widget +bindkey '%s' _cheatmd_widget +`, zshKey) +} + +func FishWidget() string { + keyWidget := config.GetKeyWidget() + // Convert bash-style keybinding to fish format (e.g., \C-g -> \cg) + fishKey := convertToFishKey(keyWidget) + return fmt.Sprintf(`function _cheatmd_widget + set -l input (commandline) + set -l output + set -l cmd_status 0 + + if test -z "$input" + set output (cheatmd --print) + set cmd_status $status + else + set output (cheatmd --print --match "$input") + set cmd_status $status + end + + if test $cmd_status -ne 0 + return + end + + if test -n "$output" + commandline -r "$output" + commandline -f end-of-line + end + + commandline -f repaint +end + +bind %s _cheatmd_widget +`, fishKey) +} + +// convertToZshKey converts a bash-style keybinding to zsh format +// e.g., \C-g -> ^g, \C-x -> ^x +func convertToZshKey(key string) string { + if strings.HasPrefix(key, "\\C-") { + return "^" + strings.ToLower(key[3:]) + } + // Already in zsh format or other format + return key +} + +// convertToFishKey converts a bash-style keybinding to fish format +// e.g., \C-g -> \cg, \C-x -> \cx +func convertToFishKey(key string) string { + if strings.HasPrefix(key, "\\C-") { + return "\\c" + strings.ToLower(key[3:]) + } + // Already in fish format or other format + return key +} From 0b9c074f24bb49e0bbb121d5a73e40ff80696d1a Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sat, 30 May 2026 20:35:05 -0600 Subject: [PATCH 04/15] chore: refactor headless --- internal/headless/headless.go | 377 -------------------------------- internal/headless/vars.go | 390 ++++++++++++++++++++++++++++++++++ 2 files changed, 390 insertions(+), 377 deletions(-) create mode 100644 internal/headless/vars.go diff --git a/internal/headless/headless.go b/internal/headless/headless.go index 0671c53..04047f3 100644 --- a/internal/headless/headless.go +++ b/internal/headless/headless.go @@ -141,383 +141,6 @@ func (s *RunnerSession) findExactHeaderMatch(cheats []*parser.Cheat, query strin return nil } -// initializeVariables extracts and pre-fills environmental and scoped variables, -// priming the parameter list for resolution. -func (s *RunnerSession) initializeVariables() { - if s.Cheat.Scope == nil { - s.Cheat.Scope = make(map[string]string) - } - - s.Vars = resolver.CollectVariables(s.Cheat, s.Index) - for i := range s.Vars { - s.primeVariable(&s.Vars[i]) - } -} - -func (s *RunnerSession) primeVariable(vs *resolver.VarState) { - varName := vs.Def.Name - vs.MultiSelectedSet = make(map[string]bool) - - if scopeVal, ok := s.Cheat.Scope[varName]; ok && scopeVal != "" { - vs.Prefill = scopeVal - vs.SkipAutoCont = true - return - } - if envVal := os.Getenv(varName); envVal != "" { - vs.Prefill = envVal - } -} - -// resolveInteractively loops through the state machine, attempting auto-resolution -// before falling back to prompting the user over JSON-RPC until all variables are resolved. -func (s *RunnerSession) resolveInteractively() error { - for !s.allVariablesResolved() { - s.autoResolveLoop() - if s.allVariablesResolved() { - break - } - - if err := s.promptNextBatch(); err != nil { - return err - } - } - - s.commitFinalizedVariables() - return nil -} - -func (s *RunnerSession) promptNextBatch() error { - promptVars, err := s.collectUnresolvedDependencies() - if err != nil { - return err - } - return s.promptClient(promptVars) -} - -func (s *RunnerSession) commitFinalizedVariables() { - for _, vs := range s.Vars { - if vs.Resolved { - s.Cheat.Scope[vs.Def.Name] = vs.Value - } - } -} - -// autoResolveLoop continuously evaluates conditions and literal dependencies, -// resolving variable states automatically whenever possible without user interaction. -func (s *RunnerSession) autoResolveLoop() { - for s.attemptAutoResolvePass() { - } -} - -func (s *RunnerSession) attemptAutoResolvePass() bool { - progress := false - scope := s.buildCurrentScope() - - for i := range s.Vars { - if s.tryResolveVariable(&s.Vars[i], scope) { - progress = true - } - } - - return progress -} - -func (s *RunnerSession) tryResolveVariable(vs *resolver.VarState, scope map[string]string) bool { - if vs.Resolved { - return false - } - if !s.areConditionDependenciesResolved(vs) { - return false - } - - s.updateVariableDefinition(vs, scope) - - if s.tryAutoContinue(vs) { - return true - } - - return s.tryLiteralResolution(vs, scope) -} - -func (s *RunnerSession) updateVariableDefinition(vs *resolver.VarState, scope map[string]string) { - selectedDef := resolver.SelectVariant(vs.Variants, scope) - if selectedDef != nil { - vs.Def = *selectedDef - return - } - - if s.allVariantsConditional(vs) { - vs.Resolved = true - vs.Value = "" - } -} - -func (s *RunnerSession) tryAutoContinue(vs *resolver.VarState) bool { - if !s.canAutoContinue(vs) { - return false - } - vs.Value = vs.Prefill - vs.Resolved = true - return true -} - -func (s *RunnerSession) tryLiteralResolution(vs *resolver.VarState, scope map[string]string) bool { - if vs.Def.Literal == "" { - return false - } - if !s.areLiteralDependenciesResolved(vs.Def.Literal) { - return false - } - vs.Value = executor.SubstituteVars(vs.Def.Literal, scope, "dollar") - vs.Resolved = true - return true -} - -// buildCurrentScope generates a temporary evaluation context representing -// all currently resolved variables. -func (s *RunnerSession) buildCurrentScope() map[string]string { - scope := make(map[string]string) - for _, v := range s.Vars { - if v.Resolved { - scope[v.Def.Name] = v.Value - } - } - return scope -} - -// allVariablesResolved determines if all variables are fully resolved. -func (s *RunnerSession) allVariablesResolved() bool { - for _, v := range s.Vars { - if !v.Resolved { - return false - } - } - return true -} - -// areConditionDependenciesResolved verifies that any variables required to evaluate -// the conditions of this variable's variants have already been resolved. -func (s *RunnerSession) areConditionDependenciesResolved(vs *resolver.VarState) bool { - for _, variant := range vs.Variants { - if !s.isVariantConditionResolved(variant) { - return false - } - } - return true -} - -func (s *RunnerSession) isVariantConditionResolved(variant parser.VarDef) bool { - if variant.Condition == "" { - return true - } - deps := executor.FindAllVars(variant.Condition, "dollar") - return s.areDependenciesResolved(deps) -} - -func (s *RunnerSession) areDependenciesResolved(deps []string) bool { - for _, dep := range deps { - if !s.isDependencyResolved(dep) { - return false - } - } - return true -} - -// isDependencyResolved checks the resolution state of a single dependency. -func (s *RunnerSession) isDependencyResolved(depName string) bool { - for _, ov := range s.Vars { - if ov.Def.Name == depName && ov.Resolved { - return true - } - } - return false -} - -// allVariantsConditional determines if a variable purely consists of conditional variants -// allowing it to safely resolve to empty if no conditions match. -func (s *RunnerSession) allVariantsConditional(vs *resolver.VarState) bool { - allConditional := true - for _, v := range vs.Variants { - if v.Condition == "" { - allConditional = false - break - } - } - return allConditional && len(vs.Variants) > 0 -} - -// canAutoContinue enforces the configuration rules for automatically advancing -// through prefilled fields without prompting. -func (s *RunnerSession) canAutoContinue(vs *resolver.VarState) bool { - autoContinue := config.GetAutoContinue() - return autoContinue && vs.Prefill != "" && !vs.SkipAutoCont -} - -// areLiteralDependenciesResolved ensures that a literal parameter transformation -// has all required dependencies before attempting evaluation. -func (s *RunnerSession) areLiteralDependenciesResolved(literal string) bool { - deps := executor.FindAllVars(literal, "dollar") - return s.areDependenciesResolved(deps) -} - -// collectUnresolvedDependencies compiles the next batch of variables requiring user input, -// evaluating any dynamic shell scripts for option enumeration. -func (s *RunnerSession) collectUnresolvedDependencies() ([]promptVar, error) { - var promptVars []promptVar - scope := s.buildCurrentScope() - - for i := range s.Vars { - pv := s.buildPromptVarIfReady(&s.Vars[i], i, scope) - if pv != nil { - promptVars = append(promptVars, *pv) - } - } - - if len(promptVars) == 0 { - return nil, fmt.Errorf("resolution deadlock: cyclical dependencies detected, resolution stopped") - } - - return promptVars, nil -} - -func (s *RunnerSession) buildPromptVarIfReady(vs *resolver.VarState, idx int, scope map[string]string) *promptVar { - if vs.Resolved { - return nil - } - if vs.Def.Literal != "" { - return nil - } - if !s.areConditionDependenciesResolved(vs) { - return nil - } - - selectOpts := resolver.ParseSelectorOpts(vs.Def.Args) - options := s.evaluateShellOptions(vs, scope, selectOpts) - - return &promptVar{ - Name: vs.Def.Name, - Header: resolver.ExtractCustomHeader(vs.Def.Args), - Placeholder: vs.Prefill, - Options: options, - Multi: selectOpts.Multi, - varIdx: idx, - } -} - -func (s *RunnerSession) evaluateShellOptions(vs *resolver.VarState, scope map[string]string, selectOpts resolver.SelectOptions) []string { - if strings.TrimSpace(vs.Def.Shell) == "" { - return nil - } - - shellCmd := executor.SubstituteVars(vs.Def.Shell, scope, "dollar") - output, err := s.Exec.RunShell(shellCmd) - if err != nil { - return nil - } - - return s.parseShellOptions(output, selectOpts) -} - -func (s *RunnerSession) parseShellOptions(output string, selectOpts resolver.SelectOptions) []string { - var options []string - lines := parser.SplitLines(output) - for _, opt := range lines { - display := resolver.GetDisplayColumn(opt, selectOpts.Delimiter, selectOpts.Column) - options = append(options, display) - } - return options -} - -// promptClient sends a JSON-RPC request over the wire, requesting the user -// to manually supply the required variable values. -func (s *RunnerSession) promptClient(promptVars []promptVar) error { - reqBytes, err := s.marshalPromptRequest(promptVars) - if err != nil { - return err - } - - fmt.Println(string(reqBytes)) - - if !s.Scanner.Scan() { - return fmt.Errorf("client connection severed unexpectedly during variable prompt") - } - - promptRes, err := s.parsePromptResponse(s.Scanner.Text()) - if err != nil { - return err - } - - s.ingestPromptValues(promptVars, promptRes) - return nil -} - -func (s *RunnerSession) marshalPromptRequest(promptVars []promptVar) ([]byte, error) { - promptReq := map[string]interface{}{ - "jsonrpc": "2.0", - "method": "prompt", - "params": map[string]interface{}{ - "variables": promptVars, - }, - "id": 1, - } - - reqBytes, err := json.Marshal(promptReq) - if err != nil { - return nil, fmt.Errorf("failed to marshal prompt request: %w", err) - } - return reqBytes, nil -} - -type promptResponse struct { - Jsonrpc string `json:"jsonrpc"` - Result struct { - Values map[string]string `json:"values"` - } `json:"result"` - Error *struct { - Code int `json:"code"` - Message string `json:"message"` - } `json:"error"` - Id int `json:"id"` -} - -func (s *RunnerSession) parsePromptResponse(line string) (*promptResponse, error) { - var promptRes promptResponse - if err := json.Unmarshal([]byte(line), &promptRes); err != nil { - return nil, fmt.Errorf("failed to parse client prompt response: %w", err) - } - - if promptRes.Error != nil { - return nil, fmt.Errorf("client aborted prompt: %s", promptRes.Error.Message) - } - - return &promptRes, nil -} - -func (s *RunnerSession) ingestPromptValues(promptVars []promptVar, promptRes *promptResponse) { - for _, pv := range promptVars { - val := s.extractPromptValue(pv, promptRes) - s.applyResolvedValue(&s.Vars[pv.varIdx], val) - } -} - -func (s *RunnerSession) extractPromptValue(pv promptVar, promptRes *promptResponse) string { - val, ok := promptRes.Result.Values[pv.Name] - if !ok { - return pv.Placeholder - } - return val -} - -func (s *RunnerSession) applyResolvedValue(vs *resolver.VarState, val string) { - selectOpts := resolver.ParseSelectorOpts(vs.Def.Args) - if selectOpts.MapCmd != "" { - val = resolver.ApplyMapTransform(val, selectOpts) - } - - vs.Value = val - vs.Resolved = true -} - // runCommand constructs the final command string, attaches any configured hooks, // executes the command on the target shell, and reports the output via JSON-RPC. func (s *RunnerSession) runCommand() error { diff --git a/internal/headless/vars.go b/internal/headless/vars.go new file mode 100644 index 0000000..f0c72c3 --- /dev/null +++ b/internal/headless/vars.go @@ -0,0 +1,390 @@ +package headless + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/cheatmd-dev/cheatmd/internal/resolver" + "github.com/cheatmd-dev/cheatmd/pkg/config" + "github.com/cheatmd-dev/cheatmd/pkg/executor" + "github.com/cheatmd-dev/cheatmd/pkg/parser" +) + +// initializeVariables extracts and pre-fills environmental and scoped variables, +// priming the parameter list for resolution. +func (s *RunnerSession) initializeVariables() { + if s.Cheat.Scope == nil { + s.Cheat.Scope = make(map[string]string) + } + + s.Vars = resolver.CollectVariables(s.Cheat, s.Index) + for i := range s.Vars { + s.primeVariable(&s.Vars[i]) + } +} + +func (s *RunnerSession) primeVariable(vs *resolver.VarState) { + varName := vs.Def.Name + vs.MultiSelectedSet = make(map[string]bool) + + if scopeVal, ok := s.Cheat.Scope[varName]; ok && scopeVal != "" { + vs.Prefill = scopeVal + vs.SkipAutoCont = true + return + } + if envVal := os.Getenv(varName); envVal != "" { + vs.Prefill = envVal + } +} + +// resolveInteractively loops through the state machine, attempting auto-resolution +// before falling back to prompting the user over JSON-RPC until all variables are resolved. +func (s *RunnerSession) resolveInteractively() error { + for !s.allVariablesResolved() { + s.autoResolveLoop() + if s.allVariablesResolved() { + break + } + + if err := s.promptNextBatch(); err != nil { + return err + } + } + + s.commitFinalizedVariables() + return nil +} + +func (s *RunnerSession) promptNextBatch() error { + promptVars, err := s.collectUnresolvedDependencies() + if err != nil { + return err + } + return s.promptClient(promptVars) +} + +func (s *RunnerSession) commitFinalizedVariables() { + for _, vs := range s.Vars { + if vs.Resolved { + s.Cheat.Scope[vs.Def.Name] = vs.Value + } + } +} + +// autoResolveLoop continuously evaluates conditions and literal dependencies, +// resolving variable states automatically whenever possible without user interaction. +func (s *RunnerSession) autoResolveLoop() { + for s.attemptAutoResolvePass() { + } +} + +func (s *RunnerSession) attemptAutoResolvePass() bool { + progress := false + scope := s.buildCurrentScope() + + for i := range s.Vars { + if s.tryResolveVariable(&s.Vars[i], scope) { + progress = true + } + } + + return progress +} + +func (s *RunnerSession) tryResolveVariable(vs *resolver.VarState, scope map[string]string) bool { + if vs.Resolved { + return false + } + if !s.areConditionDependenciesResolved(vs) { + return false + } + + s.updateVariableDefinition(vs, scope) + + if s.tryAutoContinue(vs) { + return true + } + + return s.tryLiteralResolution(vs, scope) +} + +func (s *RunnerSession) updateVariableDefinition(vs *resolver.VarState, scope map[string]string) { + selectedDef := resolver.SelectVariant(vs.Variants, scope) + if selectedDef != nil { + vs.Def = *selectedDef + return + } + + if s.allVariantsConditional(vs) { + vs.Resolved = true + vs.Value = "" + } +} + +func (s *RunnerSession) tryAutoContinue(vs *resolver.VarState) bool { + if !s.canAutoContinue(vs) { + return false + } + vs.Value = vs.Prefill + vs.Resolved = true + return true +} + +func (s *RunnerSession) tryLiteralResolution(vs *resolver.VarState, scope map[string]string) bool { + if vs.Def.Literal == "" { + return false + } + if !s.areLiteralDependenciesResolved(vs.Def.Literal) { + return false + } + vs.Value = executor.SubstituteVars(vs.Def.Literal, scope, "dollar") + vs.Resolved = true + return true +} + +// buildCurrentScope generates a temporary evaluation context representing +// all currently resolved variables. +func (s *RunnerSession) buildCurrentScope() map[string]string { + scope := make(map[string]string) + for _, v := range s.Vars { + if v.Resolved { + scope[v.Def.Name] = v.Value + } + } + return scope +} + +// allVariablesResolved determines if all variables are fully resolved. +func (s *RunnerSession) allVariablesResolved() bool { + for _, v := range s.Vars { + if !v.Resolved { + return false + } + } + return true +} + +// areConditionDependenciesResolved verifies that any variables required to evaluate +// the conditions of this variable's variants have already been resolved. +func (s *RunnerSession) areConditionDependenciesResolved(vs *resolver.VarState) bool { + for _, variant := range vs.Variants { + if !s.isVariantConditionResolved(variant) { + return false + } + } + return true +} + +func (s *RunnerSession) isVariantConditionResolved(variant parser.VarDef) bool { + if variant.Condition == "" { + return true + } + deps := executor.FindAllVars(variant.Condition, "dollar") + return s.areDependenciesResolved(deps) +} + +func (s *RunnerSession) areDependenciesResolved(deps []string) bool { + for _, dep := range deps { + if !s.isDependencyResolved(dep) { + return false + } + } + return true +} + +// isDependencyResolved checks the resolution state of a single dependency. +func (s *RunnerSession) isDependencyResolved(depName string) bool { + for _, ov := range s.Vars { + if ov.Def.Name == depName && ov.Resolved { + return true + } + } + return false +} + +// allVariantsConditional determines if a variable purely consists of conditional variants +// allowing it to safely resolve to empty if no conditions match. +func (s *RunnerSession) allVariantsConditional(vs *resolver.VarState) bool { + allConditional := true + for _, v := range vs.Variants { + if v.Condition == "" { + allConditional = false + break + } + } + return allConditional && len(vs.Variants) > 0 +} + +// canAutoContinue enforces the configuration rules for automatically advancing +// through prefilled fields without prompting. +func (s *RunnerSession) canAutoContinue(vs *resolver.VarState) bool { + autoContinue := config.GetAutoContinue() + return autoContinue && vs.Prefill != "" && !vs.SkipAutoCont +} + +// areLiteralDependenciesResolved ensures that a literal parameter transformation +// has all required dependencies before attempting evaluation. +func (s *RunnerSession) areLiteralDependenciesResolved(literal string) bool { + deps := executor.FindAllVars(literal, "dollar") + return s.areDependenciesResolved(deps) +} + +// collectUnresolvedDependencies compiles the next batch of variables requiring user input, +// evaluating any dynamic shell scripts for option enumeration. +func (s *RunnerSession) collectUnresolvedDependencies() ([]promptVar, error) { + var promptVars []promptVar + scope := s.buildCurrentScope() + + for i := range s.Vars { + pv := s.buildPromptVarIfReady(&s.Vars[i], i, scope) + if pv != nil { + promptVars = append(promptVars, *pv) + } + } + + if len(promptVars) == 0 { + return nil, fmt.Errorf("resolution deadlock: cyclical dependencies detected, resolution stopped") + } + + return promptVars, nil +} + +func (s *RunnerSession) buildPromptVarIfReady(vs *resolver.VarState, idx int, scope map[string]string) *promptVar { + if vs.Resolved { + return nil + } + if vs.Def.Literal != "" { + return nil + } + if !s.areConditionDependenciesResolved(vs) { + return nil + } + + selectOpts := resolver.ParseSelectorOpts(vs.Def.Args) + options := s.evaluateShellOptions(vs, scope, selectOpts) + + return &promptVar{ + Name: vs.Def.Name, + Header: resolver.ExtractCustomHeader(vs.Def.Args), + Placeholder: vs.Prefill, + Options: options, + Multi: selectOpts.Multi, + varIdx: idx, + } +} + +func (s *RunnerSession) evaluateShellOptions(vs *resolver.VarState, scope map[string]string, selectOpts resolver.SelectOptions) []string { + if strings.TrimSpace(vs.Def.Shell) == "" { + return nil + } + + shellCmd := executor.SubstituteVars(vs.Def.Shell, scope, "dollar") + output, err := s.Exec.RunShell(shellCmd) + if err != nil { + return nil + } + + return s.parseShellOptions(output, selectOpts) +} + +func (s *RunnerSession) parseShellOptions(output string, selectOpts resolver.SelectOptions) []string { + var options []string + lines := parser.SplitLines(output) + for _, opt := range lines { + display := resolver.GetDisplayColumn(opt, selectOpts.Delimiter, selectOpts.Column) + options = append(options, display) + } + return options +} + +// promptClient sends a JSON-RPC request over the wire, requesting the user +// to manually supply the required variable values. +func (s *RunnerSession) promptClient(promptVars []promptVar) error { + reqBytes, err := s.marshalPromptRequest(promptVars) + if err != nil { + return err + } + + fmt.Println(string(reqBytes)) + + if !s.Scanner.Scan() { + return fmt.Errorf("client connection severed unexpectedly during variable prompt") + } + + promptRes, err := s.parsePromptResponse(s.Scanner.Text()) + if err != nil { + return err + } + + s.ingestPromptValues(promptVars, promptRes) + return nil +} + +func (s *RunnerSession) marshalPromptRequest(promptVars []promptVar) ([]byte, error) { + promptReq := map[string]interface{}{ + "jsonrpc": "2.0", + "method": "prompt", + "params": map[string]interface{}{ + "variables": promptVars, + }, + "id": 1, + } + + reqBytes, err := json.Marshal(promptReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal prompt request: %w", err) + } + return reqBytes, nil +} + +type promptResponse struct { + Jsonrpc string `json:"jsonrpc"` + Result struct { + Values map[string]string `json:"values"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + Id int `json:"id"` +} + +func (s *RunnerSession) parsePromptResponse(line string) (*promptResponse, error) { + var promptRes promptResponse + if err := json.Unmarshal([]byte(line), &promptRes); err != nil { + return nil, fmt.Errorf("failed to parse client prompt response: %w", err) + } + + if promptRes.Error != nil { + return nil, fmt.Errorf("client aborted prompt: %s", promptRes.Error.Message) + } + + return &promptRes, nil +} + +func (s *RunnerSession) ingestPromptValues(promptVars []promptVar, promptRes *promptResponse) { + for _, pv := range promptVars { + val := s.extractPromptValue(pv, promptRes) + s.applyResolvedValue(&s.Vars[pv.varIdx], val) + } +} + +func (s *RunnerSession) extractPromptValue(pv promptVar, promptRes *promptResponse) string { + val, ok := promptRes.Result.Values[pv.Name] + if !ok { + return pv.Placeholder + } + return val +} + +func (s *RunnerSession) applyResolvedValue(vs *resolver.VarState, val string) { + selectOpts := resolver.ParseSelectorOpts(vs.Def.Args) + if selectOpts.MapCmd != "" { + val = resolver.ApplyMapTransform(val, selectOpts) + } + + vs.Value = val + vs.Resolved = true +} From 01c0074bccaefc9aedf0e0170019fd1ae31fe839 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sat, 30 May 2026 20:37:17 -0600 Subject: [PATCH 05/15] chore: refactor history --- internal/ui/frecency_test.go | 2 +- internal/ui/history_view.go | 2 +- internal/ui/main_model.go | 2 +- internal/ui/run.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/ui/frecency_test.go b/internal/ui/frecency_test.go index f30c9da..d77e64a 100644 --- a/internal/ui/frecency_test.go +++ b/internal/ui/frecency_test.go @@ -3,7 +3,7 @@ package ui import ( "testing" - "github.com/cheatmd-dev/cheatmd/pkg/history" + "github.com/cheatmd-dev/cheatmd/internal/history" "github.com/cheatmd-dev/cheatmd/pkg/parser" ) diff --git a/internal/ui/history_view.go b/internal/ui/history_view.go index 714f63a..0d71e32 100644 --- a/internal/ui/history_view.go +++ b/internal/ui/history_view.go @@ -6,8 +6,8 @@ import ( tea "github.com/charmbracelet/bubbletea" + "github.com/cheatmd-dev/cheatmd/internal/history" "github.com/cheatmd-dev/cheatmd/pkg/config" - "github.com/cheatmd-dev/cheatmd/pkg/history" "github.com/cheatmd-dev/cheatmd/pkg/parser" ) diff --git a/internal/ui/main_model.go b/internal/ui/main_model.go index f67341d..6f5f335 100644 --- a/internal/ui/main_model.go +++ b/internal/ui/main_model.go @@ -9,9 +9,9 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/cheatmd-dev/cheatmd/internal/history" "github.com/cheatmd-dev/cheatmd/pkg/chainstate" "github.com/cheatmd-dev/cheatmd/pkg/executor" - "github.com/cheatmd-dev/cheatmd/pkg/history" "github.com/cheatmd-dev/cheatmd/pkg/parser" ) diff --git a/internal/ui/run.go b/internal/ui/run.go index db50ca7..9d68fd7 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -10,10 +10,10 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/cheatmd-dev/cheatmd/internal/history" "github.com/cheatmd-dev/cheatmd/internal/resolver" "github.com/cheatmd-dev/cheatmd/pkg/chainstate" "github.com/cheatmd-dev/cheatmd/pkg/config" - "github.com/cheatmd-dev/cheatmd/pkg/history" "github.com/cheatmd-dev/cheatmd/pkg/parser" ) From 4e4ef015d11b74154cb6d5de80b2c70f35a7f660 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sat, 30 May 2026 20:38:03 -0600 Subject: [PATCH 06/15] chore: refactor var resolve --- internal/ui/var_resolve.go | 368 ------------------------------ internal/ui/var_resolve_keys.go | 201 ++++++++++++++++ internal/ui/var_resolve_render.go | 180 +++++++++++++++ 3 files changed, 381 insertions(+), 368 deletions(-) create mode 100644 internal/ui/var_resolve_keys.go create mode 100644 internal/ui/var_resolve_render.go diff --git a/internal/ui/var_resolve.go b/internal/ui/var_resolve.go index ba73f1b..32ba85e 100644 --- a/internal/ui/var_resolve.go +++ b/internal/ui/var_resolve.go @@ -5,7 +5,6 @@ import ( "os" "strings" - "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/cheatmd-dev/cheatmd/pkg/config" @@ -331,370 +330,3 @@ func (m *mainModel) handleShellResult(msg shellResultMsg) (tea.Model, tea.Cmd) { return m, nil } - -// handleVarResolveKey processes keyboard input during variable resolution. -func (m *mainModel) handleVarResolveKey(msg tea.KeyMsg) tea.Cmd { - switch msg.String() { - case "ctrl+c": - m.quitting = true - m.selected = nil - return tea.Quit - case "esc": - // Go back to previous var or cheat selection. - if m.varState.currentIdx > 0 { - m.varState.currentIdx-- - vs := &m.varState.vars[m.varState.currentIdx] - vs.resolved = false - vs.value = "" - vs.skipAutoCont = true - m.textInput.SetValue("") - m.picker.Cursor = 0 - m.picker.Offset = 0 - return m.prepareCurrentVar() - } - m.phase = phaseCheatSelect - m.varState = nil - m.selected = nil - m.textInput.SetValue(m.lastQuery) - m.textInput.Placeholder = "Type to search..." - m.filterCheats() - m.picker.Cursor = 0 - m.picker.Offset = 0 - return nil - case "enter": - return m.acceptVarValue(false) - case "alt+enter", "ctrl+j": - return m.acceptVarValue(true) - case "up", "ctrl+p", "down", "ctrl+n", "pgup", "pgdown": - if !m.varState.isPromptOnly && m.varState.picker != nil { - m.varState.picker.HandleKey(msg) - } - return nil - case "tab": - if m.completePathFromInput() { - return nil - } - if !m.varState.isPromptOnly && m.varState.picker != nil { - if opt, ok := m.varState.picker.Selected(); ok { - m.textInput.SetValue(opt.Display) - m.textInput.CursorEnd() - } - } - case " ": - if m.varState.selectOpts.Multi && !m.varState.isPromptOnly && m.varState.picker != nil { - if opt, ok := m.varState.picker.Selected(); ok { - vs := &m.varState.vars[m.varState.currentIdx] - original := opt.Original - if vs.multiSelectedSet[original] { - vs.multiSelectedSet[original] = false - // Remove from multiSelected list - for i, val := range vs.multiSelected { - if val == original { - vs.multiSelected = append(vs.multiSelected[:i], vs.multiSelected[i+1:]...) - break - } - } - } else { - vs.multiSelectedSet[original] = true - vs.multiSelected = append(vs.multiSelected, original) - } - // Don't pass space to text input, just return nil to re-render - return nil - } - } - default: - if msg.String() == config.GetKeyOpen() { - if m.varState != nil && m.varState.cheat != nil { - openFileInViewer(m.varState.cheat.File) - } - } - if msg.String() == config.GetKeySubstitute() { - if m.enterSubstituteSearch() { - return tea.Batch(tea.ClearScreen, textinput.Blink) - } - } - if msg.String() == config.GetKeyPreview() { - if m.varState != nil && m.varState.cheat != nil { - if m.enterPreview(m.varState.cheat) { - return tea.ClearScreen - } - } - } - } - return nil -} - -func (m *mainModel) completePathFromInput() bool { - if m.varState == nil { - return false - } - value := m.textInput.Value() - cursor := m.textInput.Position() - if !m.varState.isPromptOnly && !looksPathLikeInput(value, cursor) { - return false - } - result, ok := completePathValue(value, cursor) - if !ok { - m.clearPathCompletions() - return false - } - - m.textInput.SetValue(result.Value) - m.textInput.SetCursor(result.Cursor) - - show := len(result.Candidates) > 1 - m.varState.pathCompletions = result.Candidates - m.varState.showPathCompletions = show - if !m.varState.isPromptOnly && m.varState.picker != nil { - m.varState.picker.Filter(m.textInput.Value()) - } - return true -} - -func looksPathLikeInput(value string, cursor int) bool { - runes := []rune(value) - if cursor < 0 || cursor > len(runes) { - cursor = len(runes) - } - start := pathTokenStart(runes, cursor) - token := strings.TrimLeft(string(runes[start:cursor]), `"'`) - return strings.HasPrefix(token, "/") || - strings.HasPrefix(token, "./") || - strings.HasPrefix(token, "../") || - strings.HasPrefix(token, "~/") || - strings.HasPrefix(token, "$") || - strings.Contains(token, "/") -} - -func (m *mainModel) clearPathCompletions() { - if m.varState == nil { - return - } - m.varState.pathCompletions = nil - m.varState.showPathCompletions = false -} - -// acceptVarValue accepts the current value and moves to next variable. -func (m *mainModel) acceptVarValue(bypassSelection bool) tea.Cmd { - if m.varState == nil { - return tea.Quit - } - - vs := &m.varState.vars[m.varState.currentIdx] - var value string - - if bypassSelection || m.varState.isPromptOnly { - value = m.textInput.Value() - } else if m.varState.selectOpts.Multi && len(vs.multiSelected) > 0 { - var mapped []string - for _, selected := range vs.multiSelected { - if m.varState.selectOpts.MapCmd != "" { - selected = applyMapTransform(selected, m.varState.selectOpts) - } - mapped = append(mapped, selected) - } - delim := m.varState.selectOpts.Delimiter - if delim == "" { - delim = "," - } - value = strings.Join(mapped, delim) - } else if m.varState.picker != nil { - if opt, ok := m.varState.picker.Selected(); ok { - selected := opt.Original - if m.varState.selectOpts.MapCmd != "" { - selected = applyMapTransform(selected, m.varState.selectOpts) - } - value = selected - } else { - value = m.textInput.Value() - } - } else { - value = m.textInput.Value() - } - - vs.value = value - vs.resolved = true - m.varState.currentIdx++ - - m.textInput.SetValue("") - m.clearPathCompletions() - m.picker.Cursor = 0 - m.picker.Offset = 0 - - return m.prepareCurrentVar() -} - -// ============================================================================ -// Render -// ============================================================================ - -// renderVarResolve renders the variable resolution view. -func (m *mainModel) renderVarResolve() string { - if m.varState == nil { - return "" - } - - width := max(m.width, 80) - height := m.height - if height < 1 { - height = 24 - } - - b := getBuilder() - defer putBuilder(b) - - header := m.renderVarHeader(width) - headerLines := countLines(header) - - availableForBottom := max(height-headerLines, 5) - bottom := m.renderVarBottomWithHeight(width, availableForBottom) - bottomLines := countLines(bottom) - - padding := max(height-headerLines-bottomLines, 0) - - b.WriteString(header) - b.WriteString(strings.Repeat("\n", padding)) - b.WriteString(bottom) - - return b.String() -} - -// renderVarBottomWithHeight renders the options list and input with a max height. -func (m *mainModel) renderVarBottomWithHeight(width int, maxHeight int) string { - b := getBuilder() - defer putBuilder(b) - - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - - // Fixed lines: top divider(1) + bottom divider(1) + info line(1) + input(1) = 4 - fixedLines := 4 - - availableForList := max(maxHeight-fixedLines, 1) - - if m.varState.showPathCompletions && len(m.varState.pathCompletions) > 0 { - listHeight := min(availableForList, min(10, len(m.varState.pathCompletions))) - for i := 0; i < listHeight; i++ { - candidate := m.varState.pathCompletions[i] - b.WriteString(" ") - b.WriteString(styles.Command.Render(candidate.Display)) - b.WriteString("\n") - } - } else if !m.varState.isPromptOnly && m.varState.picker != nil && len(m.varState.picker.Filtered) > 0 { - listHeight := min(availableForList, min(10, len(m.varState.picker.Filtered))) - start, end := scrollWindow(m.varState.picker.Cursor, len(m.varState.picker.Filtered), listHeight, &m.varState.picker.Offset) - - for i := start; i < end; i++ { - opt := m.varState.picker.Filtered[i] - - checkbox := "" - if m.varState.selectOpts.Multi { - vs := &m.varState.vars[m.varState.currentIdx] - if vs.multiSelectedSet[opt.Original] { - checkbox = styles.Command.Render("[x] ") - } else { - checkbox = styles.Dim.Render("[ ] ") - } - } - - if i == m.varState.picker.Cursor { - b.WriteString(styles.Cursor.Render("▶ ")) - b.WriteString(checkbox) - b.WriteString(styles.Selected.Render(styles.Command.Render(opt.Display))) - } else { - b.WriteString(" ") - b.WriteString(checkbox) - b.WriteString(styles.Command.Render(opt.Display)) - } - b.WriteString("\n") - } - } - - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - - if m.varState.showPathCompletions && len(m.varState.pathCompletions) > 0 { - b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d path matches", len(m.varState.pathCompletions)))) - b.WriteString(" • ") - } else if !m.varState.isPromptOnly && m.varState.picker != nil && len(m.varState.picker.Filtered) > 0 { - b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d options", len(m.varState.picker.Filtered)))) - b.WriteString(" • ") - } - b.WriteString(styles.Dim.Render("ESC back")) - if m.varState.selectOpts.Multi { - b.WriteString(" • ") - b.WriteString(styles.Dim.Render("Space toggle")) - } - b.WriteString(" • ") - b.WriteString(styles.Dim.Render("Enter accept")) - if !m.varState.isPromptOnly { - b.WriteString(" • ") - b.WriteString(styles.Dim.Render("Alt+Enter bypass")) - } - b.WriteString("\n") - b.WriteString(m.textInput.View()) - - return b.String() -} - -// renderVarHeader renders the progress header for variable resolution. -func (m *mainModel) renderVarHeader(width int) string { - if m.varState == nil { - return "" - } - - b := getBuilder() - defer putBuilder(b) - - progressCmd := m.varState.cheat.Command - for i, vs := range m.varState.vars { - if vs.resolved { - progressCmd = executor.ReplaceVar(progressCmd, vs.def.Name, styles.Header.Render(vs.value), config.GetVarSyntax()) - } else if i == m.varState.currentIdx { - displayStr := formatVarName(m.varState.cheat.Command, vs.def.Name) - progressCmd = executor.ReplaceVar(progressCmd, vs.def.Name, styles.Cursor.Render(displayStr), config.GetVarSyntax()) - } - } - b.WriteString(progressCmd) - b.WriteString("\n") - - for i, vs := range m.varState.vars { - displayStr := formatVarName(m.varState.cheat.Command, vs.def.Name) - if vs.resolved { - b.WriteString(styles.Command.Render("✓")) - b.WriteString(" ") - b.WriteString(styles.Dim.Render(displayStr)) - b.WriteString(" = ") - b.WriteString(styles.Header.Render(vs.value)) - } else if i == m.varState.currentIdx { - b.WriteString(styles.Cursor.Render("▶ " + displayStr)) - } else { - b.WriteString(styles.Dim.Render("○ " + displayStr)) - } - b.WriteString("\n") - } - - if m.varState.customHeader != "" { - b.WriteString("\n") - b.WriteString(styles.Header.Render(m.varState.customHeader)) - b.WriteString("\n") - } - - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - - return b.String() -} - -// formatVarName returns the variable name formatted according to how it appears in the command, -// or defaults based on the syntax config. -func formatVarName(cmd string, name string) string { - if config.GetVarSyntax() == "angle" { - return "<" + name + ">" - } else if config.GetVarSyntax() == "both" { - if strings.Contains(cmd, "<"+name+">") { - return "<" + name + ">" - } - } - return "$" + name -} diff --git a/internal/ui/var_resolve_keys.go b/internal/ui/var_resolve_keys.go new file mode 100644 index 0000000..470d34d --- /dev/null +++ b/internal/ui/var_resolve_keys.go @@ -0,0 +1,201 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/cheatmd-dev/cheatmd/pkg/config" +) + +// handleVarResolveKey processes keyboard input during variable resolution. +func (m *mainModel) handleVarResolveKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "ctrl+c": + m.quitting = true + m.selected = nil + return tea.Quit + case "esc": + // Go back to previous var or cheat selection. + if m.varState.currentIdx > 0 { + m.varState.currentIdx-- + vs := &m.varState.vars[m.varState.currentIdx] + vs.resolved = false + vs.value = "" + vs.skipAutoCont = true + m.textInput.SetValue("") + m.picker.Cursor = 0 + m.picker.Offset = 0 + return m.prepareCurrentVar() + } + m.phase = phaseCheatSelect + m.varState = nil + m.selected = nil + m.textInput.SetValue(m.lastQuery) + m.textInput.Placeholder = "Type to search..." + m.filterCheats() + m.picker.Cursor = 0 + m.picker.Offset = 0 + return nil + case "enter": + return m.acceptVarValue(false) + case "alt+enter", "ctrl+j": + return m.acceptVarValue(true) + case "up", "ctrl+p", "down", "ctrl+n", "pgup", "pgdown": + if !m.varState.isPromptOnly && m.varState.picker != nil { + m.varState.picker.HandleKey(msg) + } + return nil + case "tab": + if m.completePathFromInput() { + return nil + } + if !m.varState.isPromptOnly && m.varState.picker != nil { + if opt, ok := m.varState.picker.Selected(); ok { + m.textInput.SetValue(opt.Display) + m.textInput.CursorEnd() + } + } + case " ": + if m.varState.selectOpts.Multi && !m.varState.isPromptOnly && m.varState.picker != nil { + if opt, ok := m.varState.picker.Selected(); ok { + vs := &m.varState.vars[m.varState.currentIdx] + original := opt.Original + if vs.multiSelectedSet[original] { + vs.multiSelectedSet[original] = false + // Remove from multiSelected list + for i, val := range vs.multiSelected { + if val == original { + vs.multiSelected = append(vs.multiSelected[:i], vs.multiSelected[i+1:]...) + break + } + } + } else { + vs.multiSelectedSet[original] = true + vs.multiSelected = append(vs.multiSelected, original) + } + // Don't pass space to text input, just return nil to re-render + return nil + } + } + default: + if msg.String() == config.GetKeyOpen() { + if m.varState != nil && m.varState.cheat != nil { + openFileInViewer(m.varState.cheat.File) + } + } + if msg.String() == config.GetKeySubstitute() { + if m.enterSubstituteSearch() { + return tea.Batch(tea.ClearScreen, textinput.Blink) + } + } + if msg.String() == config.GetKeyPreview() { + if m.varState != nil && m.varState.cheat != nil { + if m.enterPreview(m.varState.cheat) { + return tea.ClearScreen + } + } + } + } + return nil +} + +func (m *mainModel) completePathFromInput() bool { + if m.varState == nil { + return false + } + value := m.textInput.Value() + cursor := m.textInput.Position() + if !m.varState.isPromptOnly && !looksPathLikeInput(value, cursor) { + return false + } + result, ok := completePathValue(value, cursor) + if !ok { + m.clearPathCompletions() + return false + } + + m.textInput.SetValue(result.Value) + m.textInput.SetCursor(result.Cursor) + + show := len(result.Candidates) > 1 + m.varState.pathCompletions = result.Candidates + m.varState.showPathCompletions = show + if !m.varState.isPromptOnly && m.varState.picker != nil { + m.varState.picker.Filter(m.textInput.Value()) + } + return true +} + +func looksPathLikeInput(value string, cursor int) bool { + runes := []rune(value) + if cursor < 0 || cursor > len(runes) { + cursor = len(runes) + } + start := pathTokenStart(runes, cursor) + token := strings.TrimLeft(string(runes[start:cursor]), `"'`) + return strings.HasPrefix(token, "/") || + strings.HasPrefix(token, "./") || + strings.HasPrefix(token, "../") || + strings.HasPrefix(token, "~/") || + strings.HasPrefix(token, "$") || + strings.Contains(token, "/") +} + +func (m *mainModel) clearPathCompletions() { + if m.varState == nil { + return + } + m.varState.pathCompletions = nil + m.varState.showPathCompletions = false +} + +// acceptVarValue accepts the current value and moves to next variable. +func (m *mainModel) acceptVarValue(bypassSelection bool) tea.Cmd { + if m.varState == nil { + return tea.Quit + } + + vs := &m.varState.vars[m.varState.currentIdx] + var value string + + if bypassSelection || m.varState.isPromptOnly { + value = m.textInput.Value() + } else if m.varState.selectOpts.Multi && len(vs.multiSelected) > 0 { + var mapped []string + for _, selected := range vs.multiSelected { + if m.varState.selectOpts.MapCmd != "" { + selected = applyMapTransform(selected, m.varState.selectOpts) + } + mapped = append(mapped, selected) + } + delim := m.varState.selectOpts.Delimiter + if delim == "" { + delim = "," + } + value = strings.Join(mapped, delim) + } else if m.varState.picker != nil { + if opt, ok := m.varState.picker.Selected(); ok { + selected := opt.Original + if m.varState.selectOpts.MapCmd != "" { + selected = applyMapTransform(selected, m.varState.selectOpts) + } + value = selected + } else { + value = m.textInput.Value() + } + } else { + value = m.textInput.Value() + } + + vs.value = value + vs.resolved = true + m.varState.currentIdx++ + + m.textInput.SetValue("") + m.clearPathCompletions() + m.picker.Cursor = 0 + m.picker.Offset = 0 + + return m.prepareCurrentVar() +} diff --git a/internal/ui/var_resolve_render.go b/internal/ui/var_resolve_render.go new file mode 100644 index 0000000..9e8caa4 --- /dev/null +++ b/internal/ui/var_resolve_render.go @@ -0,0 +1,180 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/cheatmd-dev/cheatmd/pkg/config" + "github.com/cheatmd-dev/cheatmd/pkg/executor" +) + +// renderVarResolve renders the variable resolution view. +func (m *mainModel) renderVarResolve() string { + if m.varState == nil { + return "" + } + + width := max(m.width, 80) + height := m.height + if height < 1 { + height = 24 + } + + b := getBuilder() + defer putBuilder(b) + + header := m.renderVarHeader(width) + headerLines := countLines(header) + + availableForBottom := max(height-headerLines, 5) + bottom := m.renderVarBottomWithHeight(width, availableForBottom) + bottomLines := countLines(bottom) + + padding := max(height-headerLines-bottomLines, 0) + + b.WriteString(header) + b.WriteString(strings.Repeat("\n", padding)) + b.WriteString(bottom) + + return b.String() +} + +// renderVarBottomWithHeight renders the options list and input with a max height. +func (m *mainModel) renderVarBottomWithHeight(width int, maxHeight int) string { + b := getBuilder() + defer putBuilder(b) + + b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) + b.WriteString("\n") + + // Fixed lines: top divider(1) + bottom divider(1) + info line(1) + input(1) = 4 + fixedLines := 4 + + availableForList := max(maxHeight-fixedLines, 1) + + if m.varState.showPathCompletions && len(m.varState.pathCompletions) > 0 { + listHeight := min(availableForList, min(10, len(m.varState.pathCompletions))) + for i := 0; i < listHeight; i++ { + candidate := m.varState.pathCompletions[i] + b.WriteString(" ") + b.WriteString(styles.Command.Render(candidate.Display)) + b.WriteString("\n") + } + } else if !m.varState.isPromptOnly && m.varState.picker != nil && len(m.varState.picker.Filtered) > 0 { + listHeight := min(availableForList, min(10, len(m.varState.picker.Filtered))) + start, end := scrollWindow(m.varState.picker.Cursor, len(m.varState.picker.Filtered), listHeight, &m.varState.picker.Offset) + + for i := start; i < end; i++ { + opt := m.varState.picker.Filtered[i] + + checkbox := "" + if m.varState.selectOpts.Multi { + vs := &m.varState.vars[m.varState.currentIdx] + if vs.multiSelectedSet[opt.Original] { + checkbox = styles.Command.Render("[x] ") + } else { + checkbox = styles.Dim.Render("[ ] ") + } + } + + if i == m.varState.picker.Cursor { + b.WriteString(styles.Cursor.Render("▶ ")) + b.WriteString(checkbox) + b.WriteString(styles.Selected.Render(styles.Command.Render(opt.Display))) + } else { + b.WriteString(" ") + b.WriteString(checkbox) + b.WriteString(styles.Command.Render(opt.Display)) + } + b.WriteString("\n") + } + } + + b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) + b.WriteString("\n") + + if m.varState.showPathCompletions && len(m.varState.pathCompletions) > 0 { + b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d path matches", len(m.varState.pathCompletions)))) + b.WriteString(" • ") + } else if !m.varState.isPromptOnly && m.varState.picker != nil && len(m.varState.picker.Filtered) > 0 { + b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d options", len(m.varState.picker.Filtered)))) + b.WriteString(" • ") + } + b.WriteString(styles.Dim.Render("ESC back")) + if m.varState.selectOpts.Multi { + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("Space toggle")) + } + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("Enter accept")) + if !m.varState.isPromptOnly { + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("Alt+Enter bypass")) + } + b.WriteString("\n") + b.WriteString(m.textInput.View()) + + return b.String() +} + +// renderVarHeader renders the progress header for variable resolution. +func (m *mainModel) renderVarHeader(width int) string { + if m.varState == nil { + return "" + } + + b := getBuilder() + defer putBuilder(b) + + progressCmd := m.varState.cheat.Command + for i, vs := range m.varState.vars { + if vs.resolved { + progressCmd = executor.ReplaceVar(progressCmd, vs.def.Name, styles.Header.Render(vs.value), config.GetVarSyntax()) + } else if i == m.varState.currentIdx { + displayStr := formatVarName(m.varState.cheat.Command, vs.def.Name) + progressCmd = executor.ReplaceVar(progressCmd, vs.def.Name, styles.Cursor.Render(displayStr), config.GetVarSyntax()) + } + } + b.WriteString(progressCmd) + b.WriteString("\n") + + for i, vs := range m.varState.vars { + displayStr := formatVarName(m.varState.cheat.Command, vs.def.Name) + if vs.resolved { + b.WriteString(styles.Command.Render("✓")) + b.WriteString(" ") + b.WriteString(styles.Dim.Render(displayStr)) + b.WriteString(" = ") + b.WriteString(styles.Header.Render(vs.value)) + } else if i == m.varState.currentIdx { + b.WriteString(styles.Cursor.Render("▶ " + displayStr)) + } else { + b.WriteString(styles.Dim.Render("○ " + displayStr)) + } + b.WriteString("\n") + } + + if m.varState.customHeader != "" { + b.WriteString("\n") + b.WriteString(styles.Header.Render(m.varState.customHeader)) + b.WriteString("\n") + } + + b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) + b.WriteString("\n") + + return b.String() +} + +// formatVarName returns the variable name formatted according to how it appears in the command, +// or defaults based on the syntax config. +func formatVarName(cmd string, name string) string { + if config.GetVarSyntax() == "angle" { + return "<" + name + ">" + } else if config.GetVarSyntax() == "both" { + if strings.Contains(cmd, "<"+name+">") { + return "<" + name + ">" + } + } + return "$" + name +} From cbbf42c9028d1cd85455788fc1ab0ed394b2e2c3 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sat, 30 May 2026 20:39:37 -0600 Subject: [PATCH 07/15] chore: refactor httputil --- pkg/registry/registry.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 3376e79..e4c98dc 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -9,7 +9,7 @@ package registry import ( "context" "fmt" - "github.com/cheatmd-dev/cheatmd/pkg/httputil" + "github.com/cheatmd-dev/cheatmd/internal/httputil" "io" "strings" "time" From dd157c8285ad3226b285b6865a78b0d403f0dd50 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sat, 30 May 2026 20:40:55 -0600 Subject: [PATCH 08/15] chore: refactor linter --- pkg/linter/dsl.go | 72 +++++++ pkg/linter/lang_refs.go | 406 +++++++++++++++++++++++++++++++++++ pkg/linter/linter.go | 457 ---------------------------------------- 3 files changed, 478 insertions(+), 457 deletions(-) create mode 100644 pkg/linter/dsl.go create mode 100644 pkg/linter/lang_refs.go diff --git a/pkg/linter/dsl.go b/pkg/linter/dsl.go new file mode 100644 index 0000000..257454e --- /dev/null +++ b/pkg/linter/dsl.go @@ -0,0 +1,72 @@ +package linter + +import ( + "bufio" + "os" + "strings" + + "github.com/cheatmd-dev/cheatmd/pkg/parser" +) + +func findDSLRef(file, keyword, name string) (int, int) { + f, err := os.Open(file) + if err != nil { + return 0, 0 + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + inCheat := false + lineNo := 0 + for scanner.Scan() { + lineNo++ + raw := scanner.Text() + line := strings.TrimSpace(raw) + + if !inCheat { + if content, ok := parser.ParseCheatSingleLine(line); ok { + if dslLineMatches(content, keyword, name) { + return lineNo, stringColumn(raw, name) + } + continue + } + if parser.IsCheatStart(line) { + inCheat = true + } + continue + } + + if parser.IsCheatEnd(line) { + inCheat = false + continue + } + if dslLineMatches(line, keyword, name) { + return lineNo, stringColumn(raw, name) + } + } + + if err := scanner.Err(); err != nil { + return 0, 0 + } + + return 0, 0 +} + +func dslLineMatches(line, keyword, name string) bool { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + return false + } + gotKeyword, rest := parser.SplitFirstWord(line) + gotName, _ := parser.SplitFirstWord(rest) + return gotKeyword == keyword && gotName == name +} + +func stringColumn(line, needle string) int { + if idx := strings.Index(line, needle); idx >= 0 { + return idx + 1 + } + return 1 +} diff --git a/pkg/linter/lang_refs.go b/pkg/linter/lang_refs.go new file mode 100644 index 0000000..328fd57 --- /dev/null +++ b/pkg/linter/lang_refs.go @@ -0,0 +1,406 @@ +package linter + +import ( + "fmt" + "regexp" + "strings" + + "github.com/cheatmd-dev/cheatmd/pkg/parser" +) + +type RefKind int + +const ( + RefShellParam RefKind = iota + RefPowerShellParam + RefAngleTemplate + RefPerlParam + refUnknownParam RefKind = 99 +) + +type Ref struct { + Name string + Kind RefKind + Line int + Column int +} + +func isMissing(ref Ref, declared map[string]bool, cmd string) bool { + if declared[ref.Name] || declared[strings.ToLower(ref.Name)] { + return false + } + if ref.Kind == RefAngleTemplate { + return true + } + + if isShellSpecial(ref.Name) { + if ref.Kind == RefShellParam { + return false + } + } + if isPowerShellAutomatic(ref.Name) { + if ref.Kind == RefPowerShellParam || isLikelyPowerShellCommand(cmd) || strings.Contains(strings.ToLower(cmd), "powershell") || strings.Contains(strings.ToLower(cmd), "pwsh") { + return false + } + } + if isPerlAutomatic(ref.Name) { + if ref.Kind == RefPerlParam || strings.Contains(cmd, "perl") { + return false + } + } + + return true +} + +func referencedVars(c *parser.Cheat) []Ref { + var refs []Ref + seen := make(map[string]bool) + + cmd := c.Command + kind := dollarRefKind(c.CommandLang) + lang := lintLanguage(c.CommandLang) + + heredocBodyLines := heredocAngleSuppression(cmd) + lines := strings.Split(cmd, "\n") + for i, line := range lines { + lineNo := c.CommandStart + i + if c.CommandStart == 0 { + lineNo = 0 + } + for col := 0; col < len(line); col++ { + switch line[col] { + case '$': + if lang == "shell" && inSingleQuotedShellText(line, col) { + continue + } + ref, end, ok := scanDollarRef(line, col, kind, lineNo) + if !ok { + continue + } + key := fmt.Sprintf("%d:%s", ref.Kind, ref.Name) + if !seen[key] { + seen[key] = true + refs = append(refs, ref) + } + col = end - 1 + case '<': + if heredocBodyLines[i] { + continue + } + ref, end, ok := scanAngleRef(line, col, lineNo) + if !ok { + continue + } + key := fmt.Sprintf("%d:%s", ref.Kind, ref.Name) + if !seen[key] { + seen[key] = true + refs = append(refs, ref) + } + col = end + } + } + } + return refs +} + +func scanDollarRef(line string, pos int, kind RefKind, lineNo int) (Ref, int, bool) { + if pos > 0 && line[pos-1] == '\\' { + return Ref{}, pos + 1, false + } + if pos+1 >= len(line) { + return Ref{}, pos + 1, false + } + if line[pos+1] == '{' { + j := pos + 2 + if j >= len(line) || !isShellBracedVarChar(line[j], true) { + return Ref{}, pos + 1, false + } + j++ + for j < len(line) && isShellBracedVarChar(line[j], false) { + j++ + } + if j >= len(line) || line[j] != '}' { + return Ref{}, pos + 1, false + } + return Ref{Name: line[pos+2 : j], Kind: kind, Line: lineNo, Column: pos + 1}, j + 1, true + } + next := line[pos+1] + if kind == RefShellParam && isShellSingleSpecial(next) { + return Ref{Name: string(next), Kind: kind, Line: lineNo, Column: pos + 1}, pos + 2, true + } + if !parser.IsVarChar(next, true) { + return Ref{}, pos + 1, false + } + j := pos + 2 + for j < len(line) && parser.IsVarChar(line[j], false) { + j++ + } + if kind == RefPowerShellParam && j < len(line) && line[j] == ':' { + return Ref{}, j + 1, false + } + return Ref{Name: line[pos+1 : j], Kind: kind, Line: lineNo, Column: pos + 1}, j, true +} + +func scanAngleRef(line string, pos int, lineNo int) (Ref, int, bool) { + j := pos + 1 + if j >= len(line) || !parser.IsVarChar(line[j], true) { + return Ref{}, pos + 1, false + } + j++ + for j < len(line) && parser.IsVarChar(line[j], false) { + j++ + } + if j >= len(line) || line[j] != '>' { + return Ref{}, pos + 1, false + } + return Ref{Name: line[pos+1 : j], Kind: RefAngleTemplate, Line: lineNo, Column: pos + 1}, j, true +} + +func inSingleQuotedShellText(line string, pos int) bool { + inSingle := false + for i := 0; i < pos && i < len(line); i++ { + switch line[i] { + case '\\': + i++ + case '\'': + inSingle = !inSingle + } + } + return inSingle +} + +func dollarRefKind(lang string) RefKind { + switch strings.ToLower(lang) { + case "", "sh", "bash", "zsh", "fish", "shell": + return RefShellParam + case "powershell", "pwsh", "ps1": + return RefPowerShellParam + default: + return refUnknownParam + } +} + +var ( + localDeclRegexes = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\b(?:for|foreach)[[:space:]]+(?:\()?[[:space:]]*\$?([a-z_][a-z0-9_]*)[[:space:]]+(?:in|w)\b`), + regexp.MustCompile(`(?i)(?:^|[;&|[:space:]"'])\$?([a-z_][a-z0-9_]*)[[:space:]]*=`), + regexp.MustCompile(`(?i)\bread(?:[[:space:]]+-[a-z]+)*[[:space:]]+([a-z_][a-z0-9_]*(?:[[:space:]]+[a-z_][a-z0-9_]*)*)`), + regexp.MustCompile(`(?i)\b(?:local|declare|typeset|export|readonly)[[:space:]]+(?:-[a-z]+[[:space:]]+)*([^;&|]+)`), + regexp.MustCompile(`(?i)\b(?:set|gets(?:[[:space:]]+\$?[a-z_][a-z0-9_]*)?)[[:space:]]+([a-z_][a-z0-9_]*)\b`), + regexp.MustCompile(`(?i)\bmy[[:space:]]+\$(?:\{)?([a-z_][a-z0-9_]*)(?:\})?`), + regexp.MustCompile(`(?i)\b(?:proc_open|exec)\([^;]*,\s*\$([a-z_][a-z0-9_]*)\b`), + regexp.MustCompile(`(?i)\b(?:param|function[[:space:]]+[a-z_][a-z0-9_-]*)[[:space:]]*\(([^)]*)\)`), + regexp.MustCompile(`(?i)\$([a-z_][a-z0-9_]*)(?:(?:\.|->)[a-z_][a-z0-9_]*)+\(`), + } + psVarInParamRe = regexp.MustCompile(`(?i)\$([a-z_][a-z0-9_]*)`) +) + +func addSyntaxDeclarations(cmd string, declared map[string]bool) { + for _, re := range localDeclRegexes { + for _, m := range re.FindAllStringSubmatch(cmd, -1) { + if len(m) < 2 { + continue + } + fullMatchLower := strings.ToLower(m[0]) + if strings.HasPrefix(fullMatchLower, "param") || strings.HasPrefix(fullMatchLower, "function") { + for _, pm := range psVarInParamRe.FindAllStringSubmatch(m[1], -1) { + declared[pm[1]] = true + declared[strings.ToLower(pm[1])] = true + } + continue + } + + if strings.HasPrefix(fullMatchLower, "read") || strings.HasPrefix(fullMatchLower, "local") || strings.HasPrefix(fullMatchLower, "declare") || strings.HasPrefix(fullMatchLower, "typeset") || strings.HasPrefix(fullMatchLower, "export") || strings.HasPrefix(fullMatchLower, "readonly") { + for _, field := range strings.Fields(m[1]) { + field = strings.TrimLeft(field, "-") + if field == "" || strings.Contains(field, "=") { + field = strings.SplitN(field, "=", 2)[0] + } + if isIdentifier(field) { + declared[field] = true + declared[strings.ToLower(field)] = true + } + } + continue + } + + declared[m[1]] = true + declared[strings.ToLower(m[1])] = true + } + } +} + +func isLikelyPowerShellCommand(cmd string) bool { + trimmed := strings.TrimSpace(cmd) + if trimmed == "" { + return false + } + first, _ := parser.SplitFirstWord(trimmed) + firstLower := strings.ToLower(first) + if strings.Contains(first, "-") { + verb := strings.SplitN(firstLower, "-", 2)[0] + switch verb { + case "add", "clear", "compare", "convert", "copy", "export", "find", "for", "foreach", + "format", "get", "import", "invoke", "measure", "move", "new", "out", "read", + "remove", "rename", "resolve", "select", "set", "sort", "start", "stop", "where", + "write": + return true + } + } + lower := strings.ToLower(cmd) + return strings.Contains(cmd, "where-object") || + strings.Contains(cmd, "foreach-object") || + strings.Contains(lower, "$true") || + strings.Contains(lower, "$false") || + strings.Contains(lower, "$null") +} + +func lintLanguage(lang string) string { + switch dollarRefKind(lang) { + case RefShellParam: + return "shell" + case RefPowerShellParam: + return "powershell" + default: + return "unknown" + } +} + +func heredocAngleSuppression(cmd string) map[int]bool { + suppressed := make(map[int]bool) + lines := strings.Split(cmd, "\n") + endMarker := "" + for i, line := range lines { + if endMarker != "" { + if strings.TrimSpace(line) == endMarker { + endMarker = "" + continue + } + suppressed[i] = true + continue + } + if marker, ok := heredocMarker(line); ok { + endMarker = marker + } + } + return suppressed +} + +func heredocMarker(line string) (string, bool) { + idx := strings.Index(line, "<<") + if idx == -1 { + return "", false + } + rest := strings.TrimSpace(line[idx+2:]) + if strings.HasPrefix(rest, "-") { + rest = strings.TrimSpace(rest[1:]) + } + if rest == "" { + return "", false + } + fields := strings.Fields(rest) + if len(fields) == 0 { + return "", false + } + marker := strings.Trim(fields[0], `"'`) + if marker == "" { + return "", false + } + return marker, true +} + +func isIdentifier(s string) bool { + if s == "" || !parser.IsVarChar(s[0], true) { + return false + } + for i := 1; i < len(s); i++ { + if !parser.IsVarChar(s[i], false) { + return false + } + } + return true +} + +func isShellSpecial(name string) bool { + if name == "" { + return false + } + if allDigits(name) { + return true + } + switch name { + case "@", "*", "#", "?", "$", "!", "-", "_", + "RANDOM", "SECONDS", "LINENO", "REPLY", "PPID", + "PWD", "OLDPWD", "HOME", "USER", "UID", "EUID", "HOSTNAME", "SHELL", "PATH", "IFS": + return true + default: + return false + } +} + +func isPowerShellAutomatic(name string) bool { + switch strings.ToLower(name) { + case "true", "false", "null", "_", "psitem", "args", "input", "this", + "error", "matches", "host", "pid", "pwd", "pshome", "psversiontable": + return true + default: + return false + } +} + +func isPerlAutomatic(name string) bool { + return name == "_" +} + +func isShellBracedVarChar(c byte, first bool) bool { + if c >= '0' && c <= '9' { + return true + } + return parser.IsVarChar(c, first) +} + +func isShellSingleSpecial(c byte) bool { + return (c >= '0' && c <= '9') || strings.ContainsRune("@*#?$!-_", rune(c)) +} + +func allDigits(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + +// declaredVarNames returns the set of variable names declared for cheat c, +// counting its own `var` lines plus everything reachable through imports. +func declaredVarNames(c *parser.Cheat, index *parser.CheatIndex) map[string]bool { + declared := make(map[string]bool) + + var walk func(modName string, seen map[string]bool) + walk = func(modName string, seen map[string]bool) { + if seen[modName] { + return + } + seen[modName] = true + mod, ok := index.Modules[modName] + if !ok { + return + } + for _, v := range mod.Vars { + declared[v.Name] = true + } + for _, sub := range mod.Imports { + walk(sub, seen) + } + } + + seen := make(map[string]bool) + for _, imp := range c.Imports { + walk(imp, seen) + } + for _, v := range c.Vars { + declared[v.Name] = true + } + return declared +} diff --git a/pkg/linter/linter.go b/pkg/linter/linter.go index 78a7805..838bd7f 100644 --- a/pkg/linter/linter.go +++ b/pkg/linter/linter.go @@ -14,11 +14,9 @@ package linter import ( - "bufio" "fmt" "os" "path/filepath" - "regexp" "sort" "strings" @@ -265,458 +263,3 @@ func lintIndex(index *parser.CheatIndex, isDir bool) []Finding { } return findings } - -func findDSLRef(file, keyword, name string) (int, int) { - f, err := os.Open(file) - if err != nil { - return 0, 0 - } - defer f.Close() - - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - - inCheat := false - lineNo := 0 - for scanner.Scan() { - lineNo++ - raw := scanner.Text() - line := strings.TrimSpace(raw) - - if !inCheat { - if content, ok := parser.ParseCheatSingleLine(line); ok { - if dslLineMatches(content, keyword, name) { - return lineNo, stringColumn(raw, name) - } - continue - } - if parser.IsCheatStart(line) { - inCheat = true - } - continue - } - - if parser.IsCheatEnd(line) { - inCheat = false - continue - } - if dslLineMatches(line, keyword, name) { - return lineNo, stringColumn(raw, name) - } - } - return 0, 0 -} - -func dslLineMatches(line, keyword, name string) bool { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - return false - } - gotKeyword, rest := parser.SplitFirstWord(line) - gotName, _ := parser.SplitFirstWord(rest) - return gotKeyword == keyword && gotName == name -} - -func stringColumn(line, needle string) int { - if idx := strings.Index(line, needle); idx >= 0 { - return idx + 1 - } - return 1 -} - -// declaredVarNames returns the set of variable names declared for cheat c, -// counting its own `var` lines plus everything reachable through imports. -func declaredVarNames(c *parser.Cheat, index *parser.CheatIndex) map[string]bool { - declared := make(map[string]bool) - - var walk func(modName string, seen map[string]bool) - walk = func(modName string, seen map[string]bool) { - if seen[modName] { - return - } - seen[modName] = true - mod, ok := index.Modules[modName] - if !ok { - return - } - for _, v := range mod.Vars { - declared[v.Name] = true - } - for _, sub := range mod.Imports { - walk(sub, seen) - } - } - - seen := make(map[string]bool) - for _, imp := range c.Imports { - walk(imp, seen) - } - for _, v := range c.Vars { - declared[v.Name] = true - } - return declared -} - -type RefKind int - -const ( - RefShellParam RefKind = iota - RefPowerShellParam - RefAngleTemplate - RefPerlParam - refUnknownParam RefKind = 99 -) - -type Ref struct { - Name string - Kind RefKind - Line int - Column int -} - -func isMissing(ref Ref, declared map[string]bool, cmd string) bool { - if declared[ref.Name] || declared[strings.ToLower(ref.Name)] { - return false - } - if ref.Kind == RefAngleTemplate { - return true - } - - if isShellSpecial(ref.Name) { - if ref.Kind == RefShellParam { - return false - } - } - if isPowerShellAutomatic(ref.Name) { - if ref.Kind == RefPowerShellParam || isLikelyPowerShellCommand(cmd) || strings.Contains(strings.ToLower(cmd), "powershell") || strings.Contains(strings.ToLower(cmd), "pwsh") { - return false - } - } - if isPerlAutomatic(ref.Name) { - if ref.Kind == RefPerlParam || strings.Contains(cmd, "perl") { - return false - } - } - - return true -} - -func referencedVars(c *parser.Cheat) []Ref { - var refs []Ref - seen := make(map[string]bool) - - cmd := c.Command - kind := dollarRefKind(c.CommandLang) - lang := lintLanguage(c.CommandLang) - - heredocBodyLines := heredocAngleSuppression(cmd) - lines := strings.Split(cmd, "\n") - for i, line := range lines { - lineNo := c.CommandStart + i - if c.CommandStart == 0 { - lineNo = 0 - } - for col := 0; col < len(line); col++ { - switch line[col] { - case '$': - if lang == "shell" && inSingleQuotedShellText(line, col) { - continue - } - ref, end, ok := scanDollarRef(line, col, kind, lineNo) - if !ok { - continue - } - key := fmt.Sprintf("%d:%s", ref.Kind, ref.Name) - if !seen[key] { - seen[key] = true - refs = append(refs, ref) - } - col = end - 1 - case '<': - if heredocBodyLines[i] { - continue - } - ref, end, ok := scanAngleRef(line, col, lineNo) - if !ok { - continue - } - key := fmt.Sprintf("%d:%s", ref.Kind, ref.Name) - if !seen[key] { - seen[key] = true - refs = append(refs, ref) - } - col = end - } - } - } - return refs -} - -func scanDollarRef(line string, pos int, kind RefKind, lineNo int) (Ref, int, bool) { - if pos > 0 && line[pos-1] == '\\' { - return Ref{}, pos + 1, false - } - if pos+1 >= len(line) { - return Ref{}, pos + 1, false - } - if line[pos+1] == '{' { - j := pos + 2 - if j >= len(line) || !isShellBracedVarChar(line[j], true) { - return Ref{}, pos + 1, false - } - j++ - for j < len(line) && isShellBracedVarChar(line[j], false) { - j++ - } - if j >= len(line) || line[j] != '}' { - return Ref{}, pos + 1, false - } - return Ref{Name: line[pos+2 : j], Kind: kind, Line: lineNo, Column: pos + 1}, j + 1, true - } - next := line[pos+1] - if kind == RefShellParam && isShellSingleSpecial(next) { - return Ref{Name: string(next), Kind: kind, Line: lineNo, Column: pos + 1}, pos + 2, true - } - if !parser.IsVarChar(next, true) { - return Ref{}, pos + 1, false - } - j := pos + 2 - for j < len(line) && parser.IsVarChar(line[j], false) { - j++ - } - if kind == RefPowerShellParam && j < len(line) && line[j] == ':' { - return Ref{}, j + 1, false - } - return Ref{Name: line[pos+1 : j], Kind: kind, Line: lineNo, Column: pos + 1}, j, true -} - -func scanAngleRef(line string, pos int, lineNo int) (Ref, int, bool) { - j := pos + 1 - if j >= len(line) || !parser.IsVarChar(line[j], true) { - return Ref{}, pos + 1, false - } - j++ - for j < len(line) && parser.IsVarChar(line[j], false) { - j++ - } - if j >= len(line) || line[j] != '>' { - return Ref{}, pos + 1, false - } - return Ref{Name: line[pos+1 : j], Kind: RefAngleTemplate, Line: lineNo, Column: pos + 1}, j, true -} - -func inSingleQuotedShellText(line string, pos int) bool { - inSingle := false - for i := 0; i < pos && i < len(line); i++ { - switch line[i] { - case '\\': - i++ - case '\'': - inSingle = !inSingle - } - } - return inSingle -} - -func dollarRefKind(lang string) RefKind { - switch strings.ToLower(lang) { - case "", "sh", "bash", "zsh", "fish", "shell": - return RefShellParam - case "powershell", "pwsh", "ps1": - return RefPowerShellParam - default: - return refUnknownParam - } -} - -var ( - localDeclRegexes = []*regexp.Regexp{ - regexp.MustCompile(`(?i)\b(?:for|foreach)[[:space:]]+(?:\()?[[:space:]]*\$?([a-z_][a-z0-9_]*)[[:space:]]+(?:in|w)\b`), - regexp.MustCompile(`(?i)(?:^|[;&|[:space:]"'])\$?([a-z_][a-z0-9_]*)[[:space:]]*=`), - regexp.MustCompile(`(?i)\bread(?:[[:space:]]+-[a-z]+)*[[:space:]]+([a-z_][a-z0-9_]*(?:[[:space:]]+[a-z_][a-z0-9_]*)*)`), - regexp.MustCompile(`(?i)\b(?:local|declare|typeset|export|readonly)[[:space:]]+(?:-[a-z]+[[:space:]]+)*([^;&|]+)`), - regexp.MustCompile(`(?i)\b(?:set|gets(?:[[:space:]]+\$?[a-z_][a-z0-9_]*)?)[[:space:]]+([a-z_][a-z0-9_]*)\b`), - regexp.MustCompile(`(?i)\bmy[[:space:]]+\$(?:\{)?([a-z_][a-z0-9_]*)(?:\})?`), - regexp.MustCompile(`(?i)\b(?:proc_open|exec)\([^;]*,\s*\$([a-z_][a-z0-9_]*)\b`), - regexp.MustCompile(`(?i)\b(?:param|function[[:space:]]+[a-z_][a-z0-9_-]*)[[:space:]]*\(([^)]*)\)`), - regexp.MustCompile(`(?i)\$([a-z_][a-z0-9_]*)(?:(?:\.|->)[a-z_][a-z0-9_]*)+\(`), - } - psVarInParamRe = regexp.MustCompile(`(?i)\$([a-z_][a-z0-9_]*)`) -) - -func addSyntaxDeclarations(cmd string, declared map[string]bool) { - for _, re := range localDeclRegexes { - for _, m := range re.FindAllStringSubmatch(cmd, -1) { - if len(m) < 2 { - continue - } - fullMatchLower := strings.ToLower(m[0]) - if strings.HasPrefix(fullMatchLower, "param") || strings.HasPrefix(fullMatchLower, "function") { - for _, pm := range psVarInParamRe.FindAllStringSubmatch(m[1], -1) { - declared[pm[1]] = true - declared[strings.ToLower(pm[1])] = true - } - continue - } - - if strings.HasPrefix(fullMatchLower, "read") || strings.HasPrefix(fullMatchLower, "local") || strings.HasPrefix(fullMatchLower, "declare") || strings.HasPrefix(fullMatchLower, "typeset") || strings.HasPrefix(fullMatchLower, "export") || strings.HasPrefix(fullMatchLower, "readonly") { - for _, field := range strings.Fields(m[1]) { - field = strings.TrimLeft(field, "-") - if field == "" || strings.Contains(field, "=") { - field = strings.SplitN(field, "=", 2)[0] - } - if isIdentifier(field) { - declared[field] = true - declared[strings.ToLower(field)] = true - } - } - continue - } - - declared[m[1]] = true - declared[strings.ToLower(m[1])] = true - } - } -} - -func isLikelyPowerShellCommand(cmd string) bool { - trimmed := strings.TrimSpace(cmd) - if trimmed == "" { - return false - } - first, _ := parser.SplitFirstWord(trimmed) - firstLower := strings.ToLower(first) - if strings.Contains(first, "-") { - verb := strings.SplitN(firstLower, "-", 2)[0] - switch verb { - case "add", "clear", "compare", "convert", "copy", "export", "find", "for", "foreach", - "format", "get", "import", "invoke", "measure", "move", "new", "out", "read", - "remove", "rename", "resolve", "select", "set", "sort", "start", "stop", "where", - "write": - return true - } - } - lower := strings.ToLower(cmd) - return strings.Contains(cmd, "where-object") || - strings.Contains(cmd, "foreach-object") || - strings.Contains(lower, "$true") || - strings.Contains(lower, "$false") || - strings.Contains(lower, "$null") -} - -func lintLanguage(lang string) string { - switch dollarRefKind(lang) { - case RefShellParam: - return "shell" - case RefPowerShellParam: - return "powershell" - default: - return "unknown" - } -} - -func heredocAngleSuppression(cmd string) map[int]bool { - suppressed := make(map[int]bool) - lines := strings.Split(cmd, "\n") - endMarker := "" - for i, line := range lines { - if endMarker != "" { - if strings.TrimSpace(line) == endMarker { - endMarker = "" - continue - } - suppressed[i] = true - continue - } - if marker, ok := heredocMarker(line); ok { - endMarker = marker - } - } - return suppressed -} - -func heredocMarker(line string) (string, bool) { - idx := strings.Index(line, "<<") - if idx == -1 { - return "", false - } - rest := strings.TrimSpace(line[idx+2:]) - if strings.HasPrefix(rest, "-") { - rest = strings.TrimSpace(rest[1:]) - } - if rest == "" { - return "", false - } - fields := strings.Fields(rest) - if len(fields) == 0 { - return "", false - } - marker := strings.Trim(fields[0], `"'`) - if marker == "" { - return "", false - } - return marker, true -} - -func isIdentifier(s string) bool { - if s == "" || !parser.IsVarChar(s[0], true) { - return false - } - for i := 1; i < len(s); i++ { - if !parser.IsVarChar(s[i], false) { - return false - } - } - return true -} - -func isShellSpecial(name string) bool { - if name == "" { - return false - } - if allDigits(name) { - return true - } - switch name { - case "@", "*", "#", "?", "$", "!", "-", "_", - "RANDOM", "SECONDS", "LINENO", "REPLY", "PPID", - "PWD", "OLDPWD", "HOME", "USER", "UID", "EUID", "HOSTNAME", "SHELL", "PATH", "IFS": - return true - default: - return false - } -} - -func isPowerShellAutomatic(name string) bool { - switch strings.ToLower(name) { - case "true", "false", "null", "_", "psitem", "args", "input", "this", - "error", "matches", "host", "pid", "pwd", "pshome", "psversiontable": - return true - default: - return false - } -} - -func isPerlAutomatic(name string) bool { - return name == "_" -} - -func isShellBracedVarChar(c byte, first bool) bool { - if c >= '0' && c <= '9' { - return true - } - return parser.IsVarChar(c, first) -} - -func isShellSingleSpecial(c byte) bool { - return (c >= '0' && c <= '9') || strings.ContainsRune("@*#?$!-_", rune(c)) -} - -func allDigits(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] < '0' || s[i] > '9' { - return false - } - } - return true -} From 737c8f9c9eb472e559d7c7eda2e1b09dbc817c49 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sat, 30 May 2026 21:54:55 -0600 Subject: [PATCH 09/15] fix(bug): unicode byte slice with UTF8 width --- internal/ui/helpers.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go index 5da1058..dff4042 100644 --- a/internal/ui/helpers.go +++ b/internal/ui/helpers.go @@ -1,6 +1,9 @@ package ui -import "strings" +import ( + "strings" + "github.com/mattn/go-runewidth" +) // clamp restricts v to the range [minV, maxV]. func clamp(v, minV, maxV int) int { @@ -46,20 +49,23 @@ func scrollWindow(cursor, total, height int, offset *int) (start, end int) { // truncateString truncates a string to maxLen with ellipsis. func truncateString(s string, maxLen int) string { - if maxLen <= 3 || len(s) <= maxLen { + if maxLen <= 0 { + return "" + } + if runewidth.StringWidth(s) <= maxLen { return s } - return s[:maxLen-3] + "..." + return runewidth.Truncate(s, maxLen, "…") } // truncateLines truncates text to maxLines with optional maxLen per content. func truncateLines(text string, maxLines int, maxLen int) string { lines := strings.Split(text, "\n") if len(lines) > maxLines { - text = strings.Join(lines[:maxLines], "\n") + "..." + text = strings.Join(lines[:maxLines], "\n") + "…" } - if maxLen > 0 && len(text) > maxLen { - text = text[:maxLen-3] + "..." + if maxLen > 0 && runewidth.StringWidth(text) > maxLen { + text = runewidth.Truncate(text, maxLen, "…") } return text } From df92a1c2c750868f9cf0f80175bb6eef40cd6280 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sat, 30 May 2026 21:57:13 -0600 Subject: [PATCH 10/15] test(linter): convert to table-driven tests --- pkg/linter/linter_test.go | 748 +++++++++++++++----------------------- 1 file changed, 298 insertions(+), 450 deletions(-) diff --git a/pkg/linter/linter_test.go b/pkg/linter/linter_test.go index e139f6e..eceb465 100644 --- a/pkg/linter/linter_test.go +++ b/pkg/linter/linter_test.go @@ -7,48 +7,16 @@ import ( "testing" ) -func TestLintReportsDSLAndReferenceProblems(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "cheats.md") - writeFile(t, path, `# Cheats - -## Broken - -`+"```sh"+` -echo "$missing $ok" -`+"```"+` - -`) - - findings, err := Lint(dir) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - want := []string{ - "import \"nope\" does not resolve", - "variable \"missing\" referenced", - "unknown DSL keyword \"wat\"", - "`if` requires a condition", - "`fi` takes no arguments", - } - for _, msg := range want { - if !hasFinding(findings, msg) { - t.Fatalf("missing finding containing %q\nfindings:\n%s", msg, formatFindings(findings)) - } - } -} - -func TestLintAcceptsContinuedVarShellPipelines(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "network.md") - writeFile(t, path, `# Network +func TestLint(t *testing.T) { + tests := []struct { + name string + content string + wantErrors []string + avoidErrors []string + }{ + { + name: "TestLintAcceptsContinuedVarShellPipelines", + content: `# Network -`) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if hasFinding(findings, "unknown DSL keyword \"|\"") { - t.Fatalf("continued shell pipelines should stay part of the var line\nfindings:\n%s", formatFindings(findings)) - } -} - -func TestLintAcceptsPromptOnlyVarWithArgs(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "deploy.md") - writeFile(t, path, `# Deploy +`, + avoidErrors: []string{"unknown DSL keyword \"|\""}, + }, + { + name: "TestLintAcceptsPromptOnlyVarWithArgs", + content: `# Deploy ## Sync -`+"```sh"+` +` + "```sh" + ` rsync -a $source $dest -`+"```"+` +` + "```" + ` -`) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if hasFinding(findings, "missing an assignment operator") { - t.Fatalf("prompt-only var with args should be valid\nfindings:\n%s", formatFindings(findings)) - } -} - -func TestLintReportsDuplicateExportsAndSingleLineSyntax(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "one.md"), `## Module One - -`+"```sh"+` -: -`+"```"+` - -`) - writeFile(t, filepath.Join(dir, "two.md"), `## Module Two - -`+"```sh"+` -: -`+"```"+` - -`) - writeFile(t, filepath.Join(dir, "three.md"), `## Module Three - -`+"```sh"+` -: -`+"```"+` - -`) - - findings, err := Lint(dir) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if !hasFinding(findings, "`export` name must be a single token") { - t.Fatalf("missing single-line syntax finding\nfindings:\n%s", formatFindings(findings)) - } - if !hasFinding(findings, "duplicate export \"shared\"") { - t.Fatalf("missing duplicate export finding\nfindings:\n%s", formatFindings(findings)) - } -} - -func TestLintAcceptsChainAndReportsDuplicateSteps(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "one.md"), `## Step one - -`+"```sh"+` -echo one -`+"```"+` - -`) - writeFile(t, filepath.Join(dir, "two.md"), `## Step one duplicate - -`+"```sh"+` -echo dup -`+"```"+` - -`) - - findings, err := Lint(dir) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if hasFinding(findings, "unknown DSL keyword \"chain\"") { - t.Fatalf("chain should be a valid DSL keyword\nfindings:\n%s", formatFindings(findings)) - } - if !hasFinding(findings, "duplicate chain step \"demo\" 1") { - t.Fatalf("missing duplicate chain step finding\nfindings:\n%s", formatFindings(findings)) - } -} - -func TestLintReportsInvalidChainLine(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bad_chain.md") - writeFile(t, path, `## Bad - -`+"```sh"+` +`, + avoidErrors: []string{"missing an assignment operator"}, + }, + { + name: "TestLintReportsInvalidChainLine", + content: `## Bad + +` + "```sh" + ` echo bad -`+"```"+` +` + "```" + ` -`) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if !hasFinding(findings, "`chain` step must be a positive number") { - t.Fatalf("missing invalid chain step finding\nfindings:\n%s", formatFindings(findings)) - } -} - -func TestLintReportsChainGaps(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "gap.md") - writeFile(t, path, `## Later - -`+"```sh"+` +`, + wantErrors: []string{"`chain` step must be a positive number"}, + }, + { + name: "TestLintReportsChainGaps", + content: `## Later + +` + "```sh" + ` echo later -`+"```"+` +` + "```" + ` -`) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if !hasFinding(findings, "chain \"demo\" is missing step 1") { - t.Fatalf("missing chain gap finding\nfindings:\n%s", formatFindings(findings)) - } -} - -func TestLintReportsStructuralWarnings(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "structural.md") - writeFile(t, path, `## - -### Repeat - -Some notes. - -### Repeat - -`+"```sh"+` -echo ok -`+"```"+` -`) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - for _, msg := range []string{"empty markdown header"} { - if !hasFinding(findings, msg) { - t.Fatalf("missing structural finding containing %q\nfindings:\n%s", msg, formatFindings(findings)) - } - } -} - -func TestLintReportsDuplicateCheatNamesAtAnyHeaderLevel(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "duplicates.md") - writeFile(t, path, `# whoami - -`+"```sh"+` +`, + wantErrors: []string{"chain \"demo\" is missing step 1"}, + }, + { + name: "TestLintReportsDuplicateCheatNamesAtAnyHeaderLevel", + content: `# whoami + +` + "```sh" + ` whoami -`+"```"+` +` + "```" + ` ##### whoami -`+"```sh"+` +` + "```sh" + ` id -`+"```"+` +` + "```" + ` -`) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if !hasFinding(findings, "duplicate cheat name \"whoami\"") { - t.Fatalf("missing duplicate finding for repeated cheat names\nfindings:\n%s", formatFindings(findings)) - } -} - -func TestLintAllowsSameHeaderTextWhenOnlyOneIsACheat(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "page_title.md") - writeFile(t, path, `# Server +`, + wantErrors: []string{"duplicate cheat name \"whoami\""}, + }, + { + name: "TestLintAllowsSameHeaderTextWhenOnlyOneIsACheat", + content: `# Server -`) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if hasFinding(findings, "duplicate") { - t.Fatalf("same header text should only duplicate when both entries are cheats\nfindings:\n%s", formatFindings(findings)) - } -} - -func TestLintAllowsHeadingWithoutCodeBlock(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "aliases.md") - writeFile(t, path, `## apt +`, + avoidErrors: []string{"duplicate"}, + }, + { + name: "TestLintAllowsHeadingWithoutCodeBlock", + content: `## apt Alias of [apt-get](#apt_get). All techniques from apt-get apply. @@ -319,175 +128,310 @@ Alias of [apt-get](#apt_get). All techniques from apt-get apply. ### apt-get shell -`+"```sh"+` +` + "```sh" + ` apt-get update -`+"```"+` +` + "```" + ` -`) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if hasFinding(findings, "cheat has no code block") { - t.Fatalf("plain heading should not require a code block\nfindings:\n%s", formatFindings(findings)) - } -} - -func TestLintReportsCheatWithoutH2Header(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "missing_header.md") - writeFile(t, path, `Some intro text with no markdown header. - -`+"```sh"+` +`, + avoidErrors: []string{"cheat has no code block"}, + }, + { + name: "TestLintReportsCheatWithoutH2Header", + content: `Some intro text with no markdown header. + +` + "```sh" + ` whoami -`+"```"+` +` + "```" + ` -`+"```sh"+` +` + "```sh" + ` id -`+"```"+` +` + "```" + ` -`) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if !hasFinding(findings, "cheat has no markdown header") { - t.Fatalf("missing header finding\nfindings:\n%s", formatFindings(findings)) - } -} - -func TestLintDoesNotWarnUndeclaredVarsWithoutCheatBlock(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "scratch.md") - writeFile(t, path, `# Scratch - -`+"```sh"+` +`, + wantErrors: []string{"cheat has no markdown header"}, + }, + { + name: "TestLintDoesNotWarnUndeclaredVarsWithoutCheatBlock", + content: `# Scratch + +` + "```sh" + ` if [ "$INSTALLED" = 1 ]; then echo installed fi -`+"```"+` -`) +` + "```" + ` +`, + avoidErrors: []string{"variable \"INSTALLED\" referenced"}, + }, + { + name: "TestLintAcceptsAnyMarkdownHeaderLevelForCheat", + content: `#### Deep cheat + +` + "```sh" + ` +whoami +` + "```" + ` + +`, + avoidErrors: []string{"cheat has no markdown header"}, + }, + { + name: "TestLintDoesNotWarnForExportOnlyBlocks", + content: `# Modules - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) + +`, + avoidErrors: []string{"has no preceding code block"}, + }, + { + name: "TestLintSkipsUndeclaredCommandRefsForExportedModules", + content: `## Shell helper + +` + "```sh" + ` +echo "$provided_by_consumer" +` + "```" + ` + +`, + avoidErrors: []string{"variable \"provided_by_consumer\" referenced"}, + }, + { + name: "TestLintShellSyntaxDeclarationsAndTemplateRefs", + content: `## Loop + +` + "```sh" + ` +for i in {1..10}; do echo .$i; done +` + "```" + ` + +`, + wantErrors: []string{"variable \"a\" referenced"}, + avoidErrors: []string{"variable \"i\" referenced"}, + }, + { + name: "TestLintShellSpecialsDoNotApplyToAngleRefs", + content: `## Home + +` + "```sh" + ` +echo "$HOME" "" "$1" "${10}" +` + "```" + ` + +`, + wantErrors: []string{"variable \"HOME\" referenced", "variable \"HOME\" referenced"}, + }, + { + name: "TestLintPowerShellWarnsForUndeclaredInputButNotAssignment", + content: `## Parse + +` + "```ps1" + ` +$obj = ConvertFrom-Json $input_data +` + "```" + ` + +`, + wantErrors: []string{"variable \"input_data\" referenced"}, + avoidErrors: []string{"variable \"obj\" referenced"}, + }, + { + name: "TestLintPowerShellProviderNamespacesDoNotWarn", + content: `## AppData + +` + "```powershell" + ` +Get-ChildItem $env:APPDATA\MyApp\ +` + "```" + ` + +`, + avoidErrors: []string{"variable \"env\" referenced"}, + }, + { + name: "TestLintEmbeddedPowerShellInCmdFence", + content: `## Cmd PS + +` + "```cmd" + ` +powershell.exe -c "$e=New-Object -ComObject wscript.shell;$e.Popup('$file_out')" +` + "```" + ` + +`, + avoidErrors: []string{"variable \"e\" referenced"}, + }, + { + name: "TestLintUnknownLanguageDollarRefsAreStrict", + content: `## Unknown + +` + "```python" + ` +print($HOME) +` + "```" + ` + +`, + wantErrors: []string{"variable \"HOME\" referenced"}, + }, } - if hasFinding(findings, "variable \"INSTALLED\" referenced") { - t.Fatalf("plain code fences should not require cheat vars\nfindings:\n%s", formatFindings(findings)) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.md") + writeFile(t, path, tt.content) + + findings, err := Lint(path) + if err != nil { + findings, err = Lint(dir) // fallback to dir if it needs full scan + if err != nil { + t.Fatalf("Lint returned error: %v", err) + } + } + + for _, want := range tt.wantErrors { + if !hasFinding(findings, want) { + t.Errorf("missing finding containing %q\nfindings:\n%s", want, formatFindings(findings)) + } + } + + for _, avoid := range tt.avoidErrors { + if hasFinding(findings, avoid) { + t.Errorf("unexpected finding containing %q\nfindings:\n%s", avoid, formatFindings(findings)) + } + } + }) } } -func TestLintAcceptsAnyMarkdownHeaderLevelForCheat(t *testing.T) { +func TestLintReportsDSLAndReferenceProblems(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "deep_headers.md") - writeFile(t, path, `#### Deep cheat + path := filepath.Join(dir, "cheats.md") + writeFile(t, path, `# Cheats + +## Broken `+"```sh"+` -whoami +echo "$missing $ok" `+"```"+` `) - findings, err := Lint(path) + findings, err := Lint(dir) if err != nil { t.Fatalf("Lint returned error: %v", err) } - if hasFinding(findings, "cheat has no markdown header") { - t.Fatalf("any markdown header should name a cheat\nfindings:\n%s", formatFindings(findings)) + want := []string{ + "import \"nope\" does not resolve", + "variable \"missing\" referenced", + "unknown DSL keyword \"wat\"", + "`if` requires a condition", + "`fi` takes no arguments", + } + for _, msg := range want { + if !hasFinding(findings, msg) { + t.Fatalf("missing finding containing %q\nfindings:\n%s", msg, formatFindings(findings)) + } } } -func TestLintDoesNotWarnForExportOnlyBlocks(t *testing.T) { +func TestLintReportsDuplicateExportsAndSingleLineSyntax(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "modules.md") - writeFile(t, path, `# Modules + writeFile(t, filepath.Join(dir, "one.md"), `## Module One - +`+"```sh"+` +: +`+"```"+` + `) + writeFile(t, filepath.Join(dir, "two.md"), `## Module Two - findings, err := Lint(path) +`+"```sh"+` +: +`+"```"+` + +`) + writeFile(t, filepath.Join(dir, "three.md"), `## Module Three + +`+"```sh"+` +: +`+"```"+` + +`) + + findings, err := Lint(dir) if err != nil { t.Fatalf("Lint returned error: %v", err) } - if hasFinding(findings, "has no preceding code block") { - t.Fatalf("export-only module should not require a preceding code block\nfindings:\n%s", formatFindings(findings)) + if !hasFinding(findings, "`export` name must be a single token") { + t.Fatalf("missing single-line syntax finding\nfindings:\n%s", formatFindings(findings)) + } + if !hasFinding(findings, "duplicate export \"shared\"") { + t.Fatalf("missing duplicate export finding\nfindings:\n%s", formatFindings(findings)) } } -func TestLintSkipsUndeclaredCommandRefsForExportedModules(t *testing.T) { +func TestLintAcceptsChainAndReportsDuplicateSteps(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "module_command.md") - writeFile(t, path, `## Shell helper + writeFile(t, filepath.Join(dir, "one.md"), `## Step one `+"```sh"+` -echo "$provided_by_consumer" +echo one `+"```"+` `) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if hasFinding(findings, "variable \"provided_by_consumer\" referenced") { - t.Fatalf("exported module should not warn on consumer-provided vars\nfindings:\n%s", formatFindings(findings)) - } -} - -func TestLintShellSyntaxDeclarationsAndTemplateRefs(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "shell.md") - writeFile(t, path, `## Loop + writeFile(t, filepath.Join(dir, "two.md"), `## Step one duplicate `+"```sh"+` -for i in {1..10}; do echo .$i; done +echo dup `+"```"+` `) - findings, err := Lint(path) + findings, err := Lint(dir) if err != nil { t.Fatalf("Lint returned error: %v", err) } - if !hasFinding(findings, "variable \"a\" referenced") { - t.Fatalf("missing template ref finding\nfindings:\n%s", formatFindings(findings)) + if hasFinding(findings, "unknown DSL keyword \"chain\"") { + t.Fatalf("chain should be a valid DSL keyword\nfindings:\n%s", formatFindings(findings)) } - if hasFinding(findings, "variable \"i\" referenced") { - t.Fatalf("shell for variable should be syntax-declared\nfindings:\n%s", formatFindings(findings)) + if !hasFinding(findings, "duplicate chain step \"demo\" 1") { + t.Fatalf("missing duplicate chain step finding\nfindings:\n%s", formatFindings(findings)) } } -func TestLintShellSpecialsDoNotApplyToAngleRefs(t *testing.T) { +func TestLintReportsStructuralWarnings(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "home.md") - writeFile(t, path, `## Home + path := filepath.Join(dir, "structural.md") + writeFile(t, path, `## + +### Repeat + +Some notes. + +### Repeat `+"```sh"+` -echo "$HOME" "" "$1" "${10}" +echo ok `+"```"+` - `) findings, err := Lint(path) @@ -495,14 +439,10 @@ echo "$HOME" "" "$1" "${10}" t.Fatalf("Lint returned error: %v", err) } - if !hasFinding(findings, "variable \"HOME\" referenced") { - t.Fatalf(" should warn even though $HOME is shell-special\nfindings:\n%s", formatFindings(findings)) - } - if countFindings(findings, "variable \"HOME\" referenced") != 1 { - t.Fatalf("only should warn, not $HOME\nfindings:\n%s", formatFindings(findings)) - } - if hasFinding(findings, "variable \"1\" referenced") || hasFinding(findings, "variable \"10\" referenced") { - t.Fatalf("numeric shell positional params should be special\nfindings:\n%s", formatFindings(findings)) + for _, msg := range []string{"empty markdown header"} { + if !hasFinding(findings, msg) { + t.Fatalf("missing structural finding containing %q\nfindings:\n%s", msg, formatFindings(findings)) + } } } @@ -534,53 +474,6 @@ while($true) { } } -func TestLintPowerShellWarnsForUndeclaredInputButNotAssignment(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "ps_input.md") - writeFile(t, path, `## Parse - -`+"```ps1"+` -$obj = ConvertFrom-Json $input_data -`+"```"+` - -`) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if !hasFinding(findings, "variable \"input_data\" referenced") { - t.Fatalf("missing undeclared PowerShell input finding\nfindings:\n%s", formatFindings(findings)) - } - if hasFinding(findings, "variable \"obj\" referenced") { - t.Fatalf("assignment-declared PowerShell variable should not warn\nfindings:\n%s", formatFindings(findings)) - } -} - -func TestLintPowerShellProviderNamespacesDoNotWarn(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "ps_env.md") - writeFile(t, path, `## AppData - -`+"```powershell"+` -Get-ChildItem $env:APPDATA\MyApp\ -`+"```"+` - -`) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if hasFinding(findings, "variable \"env\" referenced") { - t.Fatalf("PowerShell provider namespace $env: should not warn\nfindings:\n%s", formatFindings(findings)) - } -} - func TestLintInfersPowerShellInShellFence(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "ps_sh.md") @@ -668,29 +561,6 @@ var cmd } } -func TestLintEmbeddedPowerShellInCmdFence(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "cmd_ps.md") - writeFile(t, path, `## Cmd PS - -`+"```cmd"+` -powershell.exe -c "$e=New-Object -ComObject wscript.shell;$e.Popup('$file_out')" -`+"```"+` - -`) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if hasFinding(findings, "variable \"e\" referenced") { - t.Fatalf("embedded PowerShell assignment should declare e\nfindings:\n%s", formatFindings(findings)) - } -} - func TestLintMethodChainsDoNotWarn(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "methods.md") @@ -772,28 +642,6 @@ var tmp_file } } -func TestLintUnknownLanguageDollarRefsAreStrict(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "unknown.md") - writeFile(t, path, `## Unknown - -`+"```python"+` -print($HOME) -`+"```"+` - -`) - - findings, err := Lint(path) - if err != nil { - t.Fatalf("Lint returned error: %v", err) - } - - if !hasFinding(findings, "variable \"HOME\" referenced") { - t.Fatalf("unknown-language $HOME should be strict, not shell-special\nfindings:\n%s", formatFindings(findings)) - } -} - func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o644); err != nil { From 5e1b9bbcf828dff1ed8b04c134eae96eeb5171a0 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sat, 30 May 2026 21:57:17 -0600 Subject: [PATCH 11/15] refactor(cli): consolidate packs modification logic --- cmd/cheatmd/packs.go | 74 +++++++++++++++++++++++--------------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/cmd/cheatmd/packs.go b/cmd/cheatmd/packs.go index 345db66..31acaa8 100644 --- a/cmd/cheatmd/packs.go +++ b/cmd/cheatmd/packs.go @@ -1,6 +1,7 @@ package main import ( +"strings" "fmt" "os" "path/filepath" @@ -15,8 +16,8 @@ import ( ) var packsCmd = &cobra.Command{ - Use: "packs", - Short: "Browse and install cheat packs from the registry", + Use: "packs", + Short: "Browse and install cheat packs from the registry", Long: `Browse and install community cheat packs from the registry. The registry is a YAML manifest of installable cheat repositories, configured @@ -25,29 +26,29 @@ via the registry_url setting. Installed packs land in the cheats directory } var packsListCmd = &cobra.Command{ - Use: "list", - Short: "List available cheat packs", - Args: cobra.NoArgs, - RunE: runPacksList, + Use: "list", + Short: "List available cheat packs", + Args: cobra.NoArgs, + RunE: runPacksList, } var packsInstallCmd = &cobra.Command{ - Use: "install [name...]", - Short: "Install cheat packs (interactive picker when no names are given)", - RunE: runPacksInstall, + Use: "install [name...]", + Short: "Install cheat packs (interactive picker when no names are given)", + RunE: runPacksInstall, } var packsUpdateCmd = &cobra.Command{ - Use: "update [name...]", - Short: "Update installed cheat packs", - RunE: runPacksUpdate, + Use: "update [name...]", + Short: "Update installed cheat packs", + RunE: runPacksUpdate, } var packsRemoveCmd = &cobra.Command{ - Use: "remove [name...]", - Short: "Remove installed cheat packs", - Args: cobra.MinimumNArgs(1), - RunE: runPacksRemove, + Use: "remove [name...]", + Short: "Remove installed cheat packs", + Args: cobra.MinimumNArgs(1), + RunE: runPacksRemove, } func init() { @@ -58,7 +59,7 @@ func init() { } func runPacksList(cmd *cobra.Command, args []string) error { - reg, err := registry.Fetch(cmd.Context(), config.GetRegistryURL()) + reg, err := registry.Fetch(cmd.Context(), config.Get().RegistryURL) if err != nil { return fmt.Errorf("fetch registry: %w", err) } @@ -90,7 +91,7 @@ func runPacksList(cmd *cobra.Command, args []string) error { return nil } -func runPacksInstall(cmd *cobra.Command, args []string) error { +func runPacksModify(cmd *cobra.Command, args []string, verb string) error { chosen, err := fetchAndChoosePacks(cmd, args) if err != nil { return err @@ -101,29 +102,30 @@ func runPacksInstall(cmd *cobra.Command, args []string) error { out := cmd.ErrOrStderr() dest, installed := installPacks(cmd.Context(), out, chosen) - fmt.Fprintf(out, "Installed %d pack(s) into %s\n", installed, dest) + + // Capitalize verb for output + capitalizedVerb := strings.ToUpper(verb[:1]) + verb[1:] + + fmt.Fprintf(out, "%s %d pack(s) in %s\n", capitalizedVerb, installed, dest) if installed < len(chosen) { - return fmt.Errorf("%d of %d pack(s) failed to install", len(chosen)-installed, len(chosen)) + // "install" -> "install", "update" -> "update" + action := verb + if verb == "updated" { + action = "update" + } else if verb == "installed" { + action = "install" + } + return fmt.Errorf("%d of %d pack(s) failed to %s", len(chosen)-installed, len(chosen), action) } return nil } -func runPacksUpdate(cmd *cobra.Command, args []string) error { - chosen, err := fetchAndChoosePacks(cmd, args) - if err != nil { - return err - } - if len(chosen) == 0 { - return nil - } +func runPacksInstall(cmd *cobra.Command, args []string) error { + return runPacksModify(cmd, args, "installed") +} - out := cmd.ErrOrStderr() - dest, installed := installPacks(cmd.Context(), out, chosen) - fmt.Fprintf(out, "Updated %d pack(s) in %s\n", installed, dest) - if installed < len(chosen) { - return fmt.Errorf("%d of %d pack(s) failed to update", len(chosen)-installed, len(chosen)) - } - return nil +func runPacksUpdate(cmd *cobra.Command, args []string) error { + return runPacksModify(cmd, args, "updated") } func runPacksRemove(cmd *cobra.Command, args []string) error { @@ -163,7 +165,7 @@ func runPacksRemove(cmd *cobra.Command, args []string) error { // fetchAndChoosePacks handles the common setup of fetching the registry // and showing the picker (or parsing args) for both install and update. func fetchAndChoosePacks(cmd *cobra.Command, args []string) ([]registry.Pack, error) { - reg, err := registry.Fetch(cmd.Context(), config.GetRegistryURL()) + reg, err := registry.Fetch(cmd.Context(), config.Get().RegistryURL) if err != nil { return nil, fmt.Errorf("fetch registry: %w", err) } From 8c5218d1fc985aa0ffb23c6d1c09d515879d3443 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Sat, 30 May 2026 21:57:27 -0600 Subject: [PATCH 12/15] refactor(config): remove getter/setter boilerplate in favor of direct access --- cmd/cheatmd/chain.go | 16 +- cmd/cheatmd/compose.go | 128 +++++---- cmd/cheatmd/dump.go | 60 ++--- cmd/cheatmd/init.go | 12 +- cmd/cheatmd/root.go | 158 ++++++----- internal/headless/headless.go | 66 ++--- internal/headless/vars.go | 36 +-- internal/resolver/helpers.go | 4 +- internal/resolver/match.go | 207 ++++++++------- internal/shellgen/widgets.go | 6 +- internal/ui/cheat_select.go | 87 +++--- internal/ui/history_view.go | 46 ++-- internal/ui/main_model.go | 10 + internal/ui/preview.go | 240 +++++++---------- internal/ui/resolve.go | 5 +- internal/ui/run.go | 158 ++++++----- internal/ui/styles.go | 80 +++--- internal/ui/substitute_search.go | 44 ++-- internal/ui/substitute_sources.go | 24 +- internal/ui/var_resolve.go | 158 +++++------ internal/ui/var_resolve_keys.go | 130 +++++---- internal/ui/var_resolve_render.go | 8 +- pkg/config/config.go | 425 +++++------------------------- pkg/convert/common.go | 75 +----- pkg/convert/dir.go | 102 +++---- pkg/convert/navi.go | 49 ++-- pkg/executor/executor.go | 26 +- pkg/executor/resolve.go | 118 +++++---- pkg/linter/lang_refs.go | 128 +++++---- pkg/linter/linter.go | 235 ++++++++++------- pkg/parser/parser.go | 180 +++++++------ pkg/parser/tags.go | 75 ++++-- pkg/parser/types.go | 21 +- pkg/parser/utils.go | 22 +- 34 files changed, 1452 insertions(+), 1687 deletions(-) diff --git a/cmd/cheatmd/chain.go b/cmd/cheatmd/chain.go index 8259ec4..b37b588 100644 --- a/cmd/cheatmd/chain.go +++ b/cmd/cheatmd/chain.go @@ -10,21 +10,21 @@ import ( ) var chainCmd = &cobra.Command{ - Use: "chain", - Short: "Manage chain progress", + Use: "chain", + Short: "Manage chain progress", } var chainResetCmd = &cobra.Command{ - Use: "reset [name]", - Short: "Reset chain progress", - Args: cobra.MaximumNArgs(1), - RunE: runChainReset, + Use: "reset [name]", + Short: "Reset chain progress", + Args: cobra.MaximumNArgs(1), + RunE: runChainReset, } func runChainReset(cmd *cobra.Command, args []string) error { root := "." - if config.GetPath() != "." { - root = config.GetPath() + if config.Get().Path != "." { + root = config.Get().Path } absRoot, err := filepath.Abs(root) if err != nil { diff --git a/cmd/cheatmd/compose.go b/cmd/cheatmd/compose.go index 873f4f4..ec4ee91 100644 --- a/cmd/cheatmd/compose.go +++ b/cmd/cheatmd/compose.go @@ -14,15 +14,15 @@ import ( ) var composeCmd = &cobra.Command{ - Use: "compose [command]", - Short: "Compose a new cheat template from a raw command line", + Use: "compose [command]", + Short: "Compose a new cheat template from a raw command line", Long: `Quickly templatize a shell command you just used into a CheatMD snippet. It automatically extracts any $var or variables and writes a complete cheat block. Examples: cheatmd compose -n "My Cheat" "curl -X POST -H 'Auth: '" echo "ssh -p $port $user@$host" | cheatmd compose -f ~/cheats.md`, - RunE: runCompose, + RunE: runCompose, } func init() { @@ -32,39 +32,70 @@ func init() { composeCmd.Flags().BoolP("print", "p", false, "Print to stdout instead of saving to a file") } -func runCompose(cmd *cobra.Command, args []string) error { +type composeOptions struct { + name string + desc string + file string + printOnly bool +} + +func parseComposeOptions(cmd *cobra.Command) composeOptions { name, _ := cmd.Flags().GetString("name") desc, _ := cmd.Flags().GetString("description") file, _ := cmd.Flags().GetString("file") printOnly, _ := cmd.Flags().GetBool("print") + return composeOptions{name, desc, file, printOnly} +} + +func runCompose(cmd *cobra.Command, args []string) error { + opts := parseComposeOptions(cmd) + + command, err := readComposeCommand(args) + if err != nil { + return err + } + + outStr := buildComposeMarkdown(opts.name, opts.desc, command) + + if opts.printOnly { + fmt.Fprint(cmd.OutOrStdout(), strings.TrimPrefix(outStr, "\n")) + return nil + } + + targetFile, err := determineComposeTargetFile(opts.file) + if err != nil { + return err + } + + return appendComposeToFile(targetFile, outStr, cmd.ErrOrStderr(), opts.name) +} - var command string +func readComposeCommand(args []string) (string, error) { if len(args) > 0 { - command = strings.Join(args, " ") - } else { - // Read from stdin - stat, err := os.Stdin.Stat() - if err != nil { - return err - } - if (stat.Mode() & os.ModeCharDevice) != 0 { - return fmt.Errorf("no command provided and no input piped. Use `cheatmd compose \"command\"`") - } - b, err := io.ReadAll(os.Stdin) - if err != nil { - return err - } - command = strings.TrimSpace(string(b)) + return strings.Join(args, " "), nil } + stat, err := os.Stdin.Stat() + if err != nil { + return "", err + } + if (stat.Mode() & os.ModeCharDevice) != 0 { + return "", fmt.Errorf("no command provided and no input piped. Use `cheatmd compose \"command\"`") + } + b, err := io.ReadAll(os.Stdin) + if err != nil { + return "", err + } + command := strings.TrimSpace(string(b)) if command == "" { - return fmt.Errorf("command cannot be empty") + return "", fmt.Errorf("command cannot be empty") } + return command, nil +} - // Extract variables +func buildComposeMarkdown(name, desc, command string) string { vars := parser.ExtractVars(command) - // Build markdown var sb strings.Builder sb.WriteString("\n# ") sb.WriteString(name) @@ -87,45 +118,38 @@ func runCompose(cmd *cobra.Command, args []string) error { sb.WriteString(command) sb.WriteString("\n```\n") - outStr := sb.String() + return sb.String() +} - if printOnly { - fmt.Fprint(cmd.OutOrStdout(), strings.TrimPrefix(outStr, "\n")) - return nil +func determineComposeTargetFile(file string) (string, error) { + if file != "" { + return file, nil } - // Determine output file - targetFile := file - if targetFile == "" { - // Use default - cfgPath := config.GetPath() - if cfgPath == "" { - return fmt.Errorf("no cheat path configured, please use -f or set cheatmd path") - } - firstPath := strings.Split(cfgPath, ",")[0] - stat, err := os.Stat(firstPath) - if err != nil { - // Path does not exist, assume it's a directory we should create? - // To be safe, let's check if it ends in .md - if strings.HasSuffix(strings.ToLower(firstPath), ".md") { - targetFile = firstPath - } else { - targetFile = filepath.Join(firstPath, "snippets.md") - } - } else if stat.IsDir() { - targetFile = filepath.Join(firstPath, "snippets.md") - } else { - targetFile = firstPath + cfgPath := config.Get().Path + if cfgPath == "" { + return "", fmt.Errorf("no cheat path configured, please use -f or set cheatmd path") + } + firstPath := strings.Split(cfgPath, ",")[0] + stat, err := os.Stat(firstPath) + if err != nil { + if strings.HasSuffix(strings.ToLower(firstPath), ".md") { + return firstPath, nil } + return filepath.Join(firstPath, "snippets.md"), nil } + if stat.IsDir() { + return filepath.Join(firstPath, "snippets.md"), nil + } + return firstPath, nil +} - // Ensure directory exists +func appendComposeToFile(targetFile, outStr string, stderr io.Writer, name string) error { targetDir := filepath.Dir(targetFile) if err := os.MkdirAll(targetDir, 0755); err != nil { return fmt.Errorf("failed to create directory for snippets: %w", err) } - // Append to file f, err := os.OpenFile(targetFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return fmt.Errorf("failed to open file %s: %w", targetFile, err) @@ -136,6 +160,6 @@ func runCompose(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to write to file %s: %w", targetFile, err) } - fmt.Fprintf(cmd.ErrOrStderr(), "✓ Cheat '%s' appended to %s\n", name, targetFile) + fmt.Fprintf(stderr, "✓ Cheat '%s' appended to %s\n", name, targetFile) return nil } diff --git a/cmd/cheatmd/dump.go b/cmd/cheatmd/dump.go index af604bd..545b642 100644 --- a/cmd/cheatmd/dump.go +++ b/cmd/cheatmd/dump.go @@ -15,10 +15,10 @@ import ( ) var dumpCmd = &cobra.Command{ - Use: "dump [path]", - Short: "Dump parsed cheat metadata", - Args: cobra.MaximumNArgs(1), - RunE: runDump, + Use: "dump [path]", + Short: "Dump parsed cheat metadata", + Args: cobra.MaximumNArgs(1), + RunE: runDump, } func init() { @@ -27,22 +27,22 @@ func init() { } type dumpEntry struct { - Filename string `json:"filename"` - Tags []string `json:"tags"` - Title string `json:"title"` - Description string `json:"description"` - Command string `json:"command"` - ChainName string `json:"chain_name,omitempty"` - ChainStep int `json:"chain_step,omitempty"` - Variables []dumpVar `json:"variables"` + Filename string `json:"filename"` + Tags []string `json:"tags"` + Title string `json:"title"` + Description string `json:"description"` + Command string `json:"command"` + ChainName string `json:"chain_name,omitempty"` + ChainStep int `json:"chain_step,omitempty"` + Variables []dumpVar `json:"variables"` } type dumpVar struct { - Name string `json:"name"` - Kind string `json:"kind"` - Value string `json:"value,omitempty"` - Args string `json:"args,omitempty"` - Condition string `json:"condition,omitempty"` + Name string `json:"name"` + Kind string `json:"kind"` + Value string `json:"value,omitempty"` + Args string `json:"args,omitempty"` + Condition string `json:"condition,omitempty"` } func runDump(cmd *cobra.Command, args []string) error { @@ -63,14 +63,14 @@ func runDump(cmd *cobra.Command, args []string) error { entries := make([]dumpEntry, 0, len(index.Cheats)) for _, cheat := range index.Cheats { entries = append(entries, dumpEntry{ - Filename: cheat.File, - Tags: cheat.Tags, - Title: cheat.Header, - Description: cheat.Description, - Command: cheat.Command, - ChainName: cheat.ChainName, - ChainStep: cheat.ChainStep, - Variables: dumpVars(cheat.Vars), + Filename: cheat.File, + Tags: cheat.Tags, + Title: cheat.Header, + Description: cheat.Description, + Command: cheat.Command, + ChainName: cheat.ChainName, + ChainStep: cheat.ChainStep, + Variables: dumpVars(cheat.Vars), }) } @@ -87,9 +87,9 @@ func dumpVars(vars []parser.VarDef) []dumpVar { out := make([]dumpVar, 0, len(vars)) for _, v := range vars { dv := dumpVar{ - Name: v.Name, - Args: v.Args, - Condition: v.Condition, + Name: v.Name, + Args: v.Args, + Condition: v.Condition, } switch { case v.Literal != "": @@ -110,8 +110,8 @@ func parseDumpIndex(args []string) (*parser.CheatIndex, error) { path := "." if len(args) > 0 { path = args[0] - } else if config.GetPath() != "." { - path = config.GetPath() + } else if config.Get().Path != "." { + path = config.Get().Path } absPath, err := filepath.Abs(path) if err != nil { diff --git a/cmd/cheatmd/init.go b/cmd/cheatmd/init.go index f2f2241..6c95fc5 100644 --- a/cmd/cheatmd/init.go +++ b/cmd/cheatmd/init.go @@ -20,8 +20,8 @@ import ( ) var initCmd = &cobra.Command{ - Use: "init", - Short: "Initialize CheatMD config and install starter cheats", + Use: "init", + Short: "Initialize CheatMD config and install starter cheats", RunE: func(cmd *cobra.Command, args []string) error { out := cmd.OutOrStdout() @@ -59,7 +59,7 @@ var initCmd = &cobra.Command{ // failures are non-fatal: they print a note and leave the user with a config // but no cheats. func installStarterPacks(cmd *cobra.Command, out io.Writer) string { - reg, err := registry.Fetch(cmd.Context(), config.GetRegistryURL()) + reg, err := registry.Fetch(cmd.Context(), config.Get().RegistryURL) if err != nil { fmt.Fprintf(out, "Could not fetch the cheat registry: %v\n", err) fmt.Fprintln(out, "Skipping official cheat packs — you can add packs later with `cheatmd packs install`.") @@ -113,9 +113,9 @@ func installPacks(ctx context.Context, out io.Writer, packs []registry.Pack) (de continue } manifest.Upsert(packmanifest.Entry{ - Name: pack.Name, - Repo: pack.Repo, - InstalledAt: time.Now(), + Name: pack.Name, + Repo: pack.Repo, + InstalledAt: time.Now(), }) installed++ } diff --git a/cmd/cheatmd/root.go b/cmd/cheatmd/root.go index c0c7b16..f28449e 100644 --- a/cmd/cheatmd/root.go +++ b/cmd/cheatmd/root.go @@ -16,15 +16,15 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "cheatmd [path]", - Short: "Executable Markdown Cheatsheets", - SilenceUsage: true, + Use: "cheatmd [path]", + Short: "Executable Markdown Cheatsheets", + SilenceUsage: true, Long: `Command cheatsheet tool that uses real Markdown files. Browse your cheatsheets interactively, select commands, fill in variables, and execute or copy the result.`, - Args: cobra.MaximumNArgs(1), - RunE: runCheats, + Args: cobra.MaximumNArgs(1), + RunE: runCheats, } func init() { @@ -59,65 +59,87 @@ func initConfig() { } func runCheats(cmd *cobra.Command, args []string) error { - // Determine path - path := "." - if len(args) > 0 { - path = args[0] - } else if config.GetPath() != "." { - path = config.GetPath() + absPath, err := resolveCheatPath(args) + if err != nil { + return err } - // Handle output mode flags - if p, _ := cmd.Flags().GetBool("print"); p { - config.SetOutput("print") - } else if c, _ := cmd.Flags().GetBool("copy"); c { - config.SetOutput("copy") - } else if e, _ := cmd.Flags().GetBool("exec"); e { - config.SetOutput("exec") - } + configureExecutionMode(cmd) - if auto, _ := cmd.Flags().GetBool("auto"); auto { - config.SetAutoSelect(true) + if lintFlag, _ := cmd.Flags().GetBool("lint"); lintFlag { + return runLint(cmd, absPath) } - query, _ := cmd.Flags().GetString("query") - match, _ := cmd.Flags().GetString("match") + benchmark, _ := cmd.Flags().GetBool("benchmark") + start := time.Now() - // Resolve path - absPath, err := filepath.Abs(path) + index, err := loadAndParseCheats(absPath) if err != nil { - return fmt.Errorf("error resolving path: %w", err) + return err } - info, err := os.Stat(absPath) - if err != nil { - return fmt.Errorf("path error: %w", err) + warnDuplicateExports(cmd, index) + + if len(index.Cheats) == 0 { + return fmt.Errorf("no cheats found in %s. Run `cheatmd init` to install starter packs", absPath) } - lintFlag, _ := cmd.Flags().GetBool("lint") - if lintFlag { - return runLint(cmd, absPath) + exec := executor.NewExecutor(index) + + if benchmark { + return runBenchmark(cmd, index, start) } - // Parse markdown files - benchmark, _ := cmd.Flags().GetBool("benchmark") + return executeHeadlessOrTUI(cmd, index, exec) +} - start := time.Now() +func resolveCheatPath(args []string) (string, error) { + path := "." + if len(args) > 0 { + path = args[0] + } else if config.Get().Path != "." { + path = config.Get().Path + } - p := parser.NewParser() - var index *parser.CheatIndex + absPath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("error resolving path: %w", err) + } - if info.IsDir() { - index, err = p.ParseDirectory(absPath) - } else { - index, err = p.ParseSingleFile(absPath) + if _, err := os.Stat(absPath); err != nil { + return "", fmt.Errorf("path error: %w", err) } + return absPath, nil +} +func configureExecutionMode(cmd *cobra.Command) { + if p, _ := cmd.Flags().GetBool("print"); p { + config.Get().Output = "print" + } else if c, _ := cmd.Flags().GetBool("copy"); c { + config.Get().Output = "copy" + } else if e, _ := cmd.Flags().GetBool("exec"); e { + config.Get().Output = "exec" + } + + if auto, _ := cmd.Flags().GetBool("auto"); auto { + config.Get().AutoSelect = true + } +} + +func loadAndParseCheats(absPath string) (*parser.CheatIndex, error) { + info, err := os.Stat(absPath) if err != nil { - return fmt.Errorf("parse error: %w", err) + return nil, fmt.Errorf("path error: %w", err) + } + + p := parser.NewParser() + if info.IsDir() { + return p.ParseDirectory(absPath) } + return p.ParseSingleFile(absPath) +} - // Check for duplicate exports +func warnDuplicateExports(cmd *cobra.Command, index *parser.CheatIndex) { if len(index.Duplicates) > 0 { fmt.Fprintln(cmd.ErrOrStderr(), "Warning: duplicate exports found:") for _, dup := range index.Duplicates { @@ -125,33 +147,29 @@ func runCheats(cmd *cobra.Command, args []string) error { } fmt.Fprintln(cmd.ErrOrStderr()) } +} - if len(index.Cheats) == 0 { - return fmt.Errorf("no cheats found in %s. Run `cheatmd init` to install starter packs", absPath) - } - - // Create executor - exec := executor.NewExecutor(index) +func runBenchmark(cmd *cobra.Command, index *parser.CheatIndex, start time.Time) error { + elapsed := time.Since(start) + runtime.GC() + var m runtime.MemStats + runtime.ReadMemStats(&m) + fmt.Fprintf(cmd.OutOrStdout(), "Loaded %d cheats in %v\n", len(index.Cheats), elapsed) + fmt.Fprintf(cmd.OutOrStdout(), "Memory: Alloc=%dMB, TotalAlloc=%dMB, Sys=%dMB, HeapObjects=%d\n", + m.Alloc/1024/1024, m.TotalAlloc/1024/1024, m.Sys/1024/1024, m.HeapObjects) + return nil +} - if benchmark { - elapsed := time.Since(start) - // Force GC and get memory stats - runtime.GC() - var m runtime.MemStats - runtime.ReadMemStats(&m) - fmt.Fprintf(cmd.OutOrStdout(), "Loaded %d cheats in %v\n", len(index.Cheats), elapsed) - fmt.Fprintf(cmd.OutOrStdout(), "Memory: Alloc=%dMB, TotalAlloc=%dMB, Sys=%dMB, HeapObjects=%d\n", - m.Alloc/1024/1024, m.TotalAlloc/1024/1024, m.Sys/1024/1024, m.HeapObjects) - return nil - } +func executeHeadlessOrTUI(cmd *cobra.Command, index *parser.CheatIndex, exec *executor.Executor) error { + query, _ := cmd.Flags().GetString("query") + match, _ := cmd.Flags().GetString("match") - headlessFlag, _ := cmd.Flags().GetBool("headless") - if headlessFlag { + if headlessFlag, _ := cmd.Flags().GetBool("headless"); headlessFlag { return headless.Run(index, exec, query, match) } - // Run the TUI (history view if --history was passed) var finalCmd string + var err error if historyFlag, _ := cmd.Flags().GetBool("history"); historyFlag { finalCmd, err = ui.RunHistory(index, exec) } else { @@ -165,24 +183,20 @@ func runCheats(cmd *cobra.Command, args []string) error { return nil } - // Apply hooks - if preHook := config.GetPreHook(); preHook != "" { + if preHook := config.Get().PreHook; preHook != "" { finalCmd = preHook + finalCmd } - if postHook := config.GetPostHook(); postHook != "" { + if postHook := config.Get().PostHook; postHook != "" { finalCmd = finalCmd + postHook } - switch config.GetOutput() { + switch config.Get().Output { case "exec": fmt.Fprint(cmd.ErrOrStderr(), finalCmd) return exec.OutputWithMode(finalCmd, executor.OutputExec) case "copy": - if err := exec.OutputWithMode(finalCmd, executor.OutputCopy); err != nil { - return err - } - return nil - default: // print + return exec.OutputWithMode(finalCmd, executor.OutputCopy) + default: // print fmt.Fprint(cmd.OutOrStdout(), finalCmd) return nil } diff --git a/internal/headless/headless.go b/internal/headless/headless.go index 04047f3..e1e60dc 100644 --- a/internal/headless/headless.go +++ b/internal/headless/headless.go @@ -25,30 +25,30 @@ type Executor interface { // promptVar defines the JSON-RPC structure for prompting variables. type promptVar struct { - Name string `json:"name"` - Header string `json:"header"` - Placeholder string `json:"placeholder"` - Options []string `json:"options"` - Multi bool `json:"multi"` - varIdx int `json:"-"` + Name string `json:"name"` + Header string `json:"header"` + Placeholder string `json:"placeholder"` + Options []string `json:"options"` + Multi bool `json:"multi"` + varIdx int `json:"-"` } // RunnerSession encapsulates the state and lifecycle of a command execution process. // It orchestrates variable resolution and final command execution. type RunnerSession struct { - Index *parser.CheatIndex - Exec Executor - Cheat *parser.Cheat - Vars []resolver.VarState - Scanner *bufio.Scanner + Index *parser.CheatIndex + Exec Executor + Cheat *parser.Cheat + Vars []resolver.VarState + Scanner *bufio.Scanner } // Run acts as the primary entry point, constructing a session and starting the execution. func Run(index *parser.CheatIndex, exec Executor, initialQuery, matchCmd string) error { session := &RunnerSession{ - Index: index, - Exec: exec, - Scanner: bufio.NewScanner(os.Stdin), + Index: index, + Exec: exec, + Scanner: bufio.NewScanner(os.Stdin), } return session.Execute(initialQuery, matchCmd) } @@ -68,7 +68,7 @@ func (s *RunnerSession) Execute(initialQuery, matchCmd string) error { // findTargetCheat scans the provided index for the optimal cheat block to run. func (s *RunnerSession) findTargetCheat(initialQuery, matchCmd string) error { - cheats := s.Index.FilterByConfig(config.GetRequireCheatBlock()) + cheats := s.Index.FilterByConfig(config.Get().RequireCheatBlock) if len(cheats) == 0 { return fmt.Errorf("no executable cheats found in index") } @@ -145,32 +145,32 @@ func (s *RunnerSession) findExactHeaderMatch(cheats []*parser.Cheat, query strin // executes the command on the target shell, and reports the output via JSON-RPC. func (s *RunnerSession) runCommand() error { finalCmd := s.buildAndRecordCommand() - runErr, stdout, stderr := s.executeWithConfiguredMode(finalCmd) + stdout, stderr, runErr := s.executeWithConfiguredMode(finalCmd) return s.reportCompletion(finalCmd, stdout, stderr, runErr) } func (s *RunnerSession) buildAndRecordCommand() string { finalCmd := s.Exec.BuildFinalCommand(s.Cheat) - if preHook := config.GetPreHook(); preHook != "" { + if preHook := config.Get().PreHook; preHook != "" { finalCmd = preHook + finalCmd } - if postHook := config.GetPostHook(); postHook != "" { + if postHook := config.Get().PostHook; postHook != "" { finalCmd = finalCmd + postHook } return finalCmd } -func (s *RunnerSession) executeWithConfiguredMode(finalCmd string) (error, string, string) { - switch config.GetOutput() { +func (s *RunnerSession) executeWithConfiguredMode(finalCmd string) (string, string, error) { + switch config.Get().Output { case "exec": - stdout, stderr, err := runCommandAndCapture(config.GetShell(), finalCmd) - return err, stdout, stderr + stdout, stderr, err := runCommandAndCapture(config.Get().Shell, finalCmd) + return stdout, stderr, err case "copy": err := s.Exec.OutputWithMode(finalCmd, executor.OutputCopy) - return err, "", "" - default: // print - return nil, finalCmd, "" + return "", "", err + default: // print + return finalCmd, "", nil } } @@ -178,15 +178,15 @@ func (s *RunnerSession) reportCompletion(finalCmd, stdout, stderr string, runErr status, errMsg := s.determineRunStatus(runErr) completedFrame := map[string]interface{}{ - "jsonrpc": "2.0", - "method": "completed", + "jsonrpc": "2.0", + "method": "completed", "params": map[string]interface{}{ - "status": status, - "command": finalCmd, - "stdout": stdout, - "stderr": stderr, - "error": errMsg, - "exit_code": getExitCode(runErr), + "status": status, + "command": finalCmd, + "stdout": stdout, + "stderr": stderr, + "error": errMsg, + "exit_code": getExitCode(runErr), }, } diff --git a/internal/headless/vars.go b/internal/headless/vars.go index f0c72c3..c21975e 100644 --- a/internal/headless/vars.go +++ b/internal/headless/vars.go @@ -220,7 +220,7 @@ func (s *RunnerSession) allVariantsConditional(vs *resolver.VarState) bool { // canAutoContinue enforces the configuration rules for automatically advancing // through prefilled fields without prompting. func (s *RunnerSession) canAutoContinue(vs *resolver.VarState) bool { - autoContinue := config.GetAutoContinue() + autoContinue := config.Get().AutoContinue return autoContinue && vs.Prefill != "" && !vs.SkipAutoCont } @@ -266,12 +266,12 @@ func (s *RunnerSession) buildPromptVarIfReady(vs *resolver.VarState, idx int, sc options := s.evaluateShellOptions(vs, scope, selectOpts) return &promptVar{ - Name: vs.Def.Name, - Header: resolver.ExtractCustomHeader(vs.Def.Args), - Placeholder: vs.Prefill, - Options: options, - Multi: selectOpts.Multi, - varIdx: idx, + Name: vs.Def.Name, + Header: resolver.ExtractCustomHeader(vs.Def.Args), + Placeholder: vs.Prefill, + Options: options, + Multi: selectOpts.Multi, + varIdx: idx, } } @@ -324,12 +324,12 @@ func (s *RunnerSession) promptClient(promptVars []promptVar) error { func (s *RunnerSession) marshalPromptRequest(promptVars []promptVar) ([]byte, error) { promptReq := map[string]interface{}{ - "jsonrpc": "2.0", - "method": "prompt", + "jsonrpc": "2.0", + "method": "prompt", "params": map[string]interface{}{ "variables": promptVars, }, - "id": 1, + "id": 1, } reqBytes, err := json.Marshal(promptReq) @@ -340,15 +340,15 @@ func (s *RunnerSession) marshalPromptRequest(promptVars []promptVar) ([]byte, er } type promptResponse struct { - Jsonrpc string `json:"jsonrpc"` - Result struct { + Jsonrpc string `json:"jsonrpc"` + Result struct { Values map[string]string `json:"values"` - } `json:"result"` - Error *struct { - Code int `json:"code"` - Message string `json:"message"` - } `json:"error"` - Id int `json:"id"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + Id int `json:"id"` } func (s *RunnerSession) parsePromptResponse(line string) (*promptResponse, error) { diff --git a/internal/resolver/helpers.go b/internal/resolver/helpers.go index c19add9..126d73d 100644 --- a/internal/resolver/helpers.go +++ b/internal/resolver/helpers.go @@ -74,11 +74,11 @@ func ApplyMapTransform(value string, opts SelectOptions) string { } // Run the map command with the value as stdin - cmd := exec.Command(config.GetShell(), "-c", opts.MapCmd) + cmd := exec.Command(config.Get().Shell, "-c", opts.MapCmd) cmd.Stdin = strings.NewReader(value) out, err := cmd.Output() if err != nil { - return value // fallback to original on error + return value // fallback to original on error } return strings.TrimSpace(string(out)) } diff --git a/internal/resolver/match.go b/internal/resolver/match.go index 4527d6a..d0da984 100644 --- a/internal/resolver/match.go +++ b/internal/resolver/match.go @@ -37,19 +37,7 @@ func buildMatchPattern(cmd string) (*regexp.Regexp, []string) { } func buildMatchPatternWithScore(cmd string) (*regexp.Regexp, []string, int) { - var parts []string - if config.VarSyntaxAllowsDollar() { - parts = append(parts, `\$(\w+)`) - } - if config.VarSyntaxAllowsAngle() { - parts = append(parts, `<(\w+)>`) - } - if len(parts) == 0 { - parts = append(parts, `\$(\w+)`) - } - varPattern := regexp.MustCompile(strings.Join(parts, "|")) - allMatches := varPattern.FindAllStringSubmatchIndex(cmd, -1) - + allMatches := extractVariableMatches(cmd) var varOrder []string literalScore := 0 @@ -58,16 +46,7 @@ func buildMatchPatternWithScore(cmd string) (*regexp.Regexp, []string, int) { lastEnd := 0 for i, match := range allMatches { - varStart := match[0] - varEnd := match[1] - - var varName string - for j := 2; j < len(match); j += 2 { - if match[j] != -1 { - varName = cmd[match[j]:match[j+1]] - break - } - } + varStart, varEnd, varName := extractMatchBounds(cmd, match) if varStart > lastEnd { literal := cmd[lastEnd:varStart] @@ -76,49 +55,7 @@ func buildMatchPatternWithScore(cmd string) (*regexp.Regexp, []string, int) { } varOrder = append(varOrder, varName) - - beforeVar := cmd[:varStart] - afterVar := cmd[varEnd:] - - if strings.HasSuffix(beforeVar, `"`) && strings.HasPrefix(afterVar, `"`) { - current := result.String() - if strings.HasSuffix(current, `"`) { - result.Reset() - result.WriteString(current[:len(current)-1]) - } - result.WriteString(`"([^"]*)"`) - lastEnd = varEnd + 1 - continue - } else if strings.HasSuffix(beforeVar, `'`) && strings.HasPrefix(afterVar, `'`) { - current := result.String() - if strings.HasSuffix(current, `'`) { - result.Reset() - result.WriteString(current[:len(current)-1]) - } - result.WriteString(`'([^']*)'`) - lastEnd = varEnd + 1 - continue - } - - isLastVar := i == len(allMatches)-1 - remainingText := strings.TrimSpace(cmd[varEnd:]) - if isLastVar && remainingText == "" { - result.WriteString(`(.+)`) - } else { - nextLiteralStart := varEnd - nextLiteralEnd := len(cmd) - if i+1 < len(allMatches) { - nextLiteralEnd = allMatches[i+1][0] - } - nextLiteral := strings.TrimSpace(cmd[nextLiteralStart:nextLiteralEnd]) - - if nextLiteral != "" { - result.WriteString(`(.+?)`) - } else { - result.WriteString(`(\S+)`) - } - } - lastEnd = varEnd + lastEnd = appendRegexForVariable(&result, cmd, varStart, varEnd, i, allMatches) } if lastEnd < len(cmd) { @@ -135,6 +72,77 @@ func buildMatchPatternWithScore(cmd string) (*regexp.Regexp, []string, int) { return re, varOrder, literalScore } +func extractVariableMatches(cmd string) [][]int { + var parts []string + if config.VarSyntaxAllowsDollar() { + parts = append(parts, `\$(\w+)`) + } + if config.VarSyntaxAllowsAngle() { + parts = append(parts, `<(\w+)>`) + } + if len(parts) == 0 { + parts = append(parts, `\$(\w+)`) + } + varPattern := regexp.MustCompile(strings.Join(parts, "|")) + return varPattern.FindAllStringSubmatchIndex(cmd, -1) +} + +func extractMatchBounds(cmd string, match []int) (int, int, string) { + varStart := match[0] + varEnd := match[1] + var varName string + for j := 2; j < len(match); j += 2 { + if match[j] != -1 { + varName = cmd[match[j]:match[j+1]] + break + } + } + return varStart, varEnd, varName +} + +func appendRegexForVariable(result *strings.Builder, cmd string, varStart, varEnd, matchIndex int, allMatches [][]int) int { + beforeVar := cmd[:varStart] + afterVar := cmd[varEnd:] + + if strings.HasSuffix(beforeVar, `"`) && strings.HasPrefix(afterVar, `"`) { + current := result.String() + if strings.HasSuffix(current, `"`) { + result.Reset() + result.WriteString(current[:len(current)-1]) + } + result.WriteString(`"([^"]*)"`) + return varEnd + 1 + } else if strings.HasSuffix(beforeVar, `'`) && strings.HasPrefix(afterVar, `'`) { + current := result.String() + if strings.HasSuffix(current, `'`) { + result.Reset() + result.WriteString(current[:len(current)-1]) + } + result.WriteString(`'([^']*)'`) + return varEnd + 1 + } + + isLastVar := matchIndex == len(allMatches)-1 + remainingText := strings.TrimSpace(cmd[varEnd:]) + if isLastVar && remainingText == "" { + result.WriteString(`(.+)`) + } else { + nextLiteralStart := varEnd + nextLiteralEnd := len(cmd) + if matchIndex+1 < len(allMatches) { + nextLiteralEnd = allMatches[matchIndex+1][0] + } + nextLiteral := strings.TrimSpace(cmd[nextLiteralStart:nextLiteralEnd]) + + if nextLiteral != "" { + result.WriteString(`(.+?)`) + } else { + result.WriteString(`(\S+)`) + } + } + return varEnd +} + // PrefillScopeFromMatch extracts variable values from the matched command and // writes them into cheat.Scope. func PrefillScopeFromMatch(cheat *parser.Cheat, input string) { @@ -154,10 +162,11 @@ func PrefillScopeFromMatch(cheat *parser.Cheat, input string) { } for i, name := range varNames { - if i+1 < len(matches) { - if _, exists := cheat.Scope[name]; !exists { - cheat.Scope[name] = matches[i+1] - } + if i+1 >= len(matches) { + continue + } + if _, exists := cheat.Scope[name]; !exists { + cheat.Scope[name] = matches[i+1] } } } @@ -179,40 +188,48 @@ func InferDependentVars(cheat *parser.Cheat, index *parser.CheatIndex) { continue } - for _, def := range defs { - if def.Literal == "" || def.Condition == "" { - continue - } + if inferFromDefinitions(cheat, defs, prefillValue) { + changed = true + } + } + } +} - condVar, condOp, condValue := parseCondition(def.Condition) - if condVar == "" { - continue - } +func inferFromDefinitions(cheat *parser.Cheat, defs []parser.VarDef, prefillValue string) bool { + changed := false + for _, def := range defs { + if def.Literal == "" || def.Condition == "" { + continue + } - if _, exists := cheat.Scope[condVar]; exists { - continue - } + condVar, condOp, condValue := parseCondition(def.Condition) + if condVar == "" { + continue + } - literalResult := executor.SubstituteVars(def.Literal, cheat.Scope, "dollar") - - if strings.Contains(literalResult, "$") { - extracted := extractEmbeddedVars(def.Literal, prefillValue, cheat.Scope) - for k, v := range extracted { - if _, exists := cheat.Scope[k]; !exists { - cheat.Scope[k] = v - changed = true - } - } - literalResult = executor.SubstituteVars(def.Literal, cheat.Scope, "dollar") - } + if _, exists := cheat.Scope[condVar]; exists { + continue + } + + literalResult := executor.SubstituteVars(def.Literal, cheat.Scope, "dollar") - if literalResult == prefillValue && condOp == "==" { - cheat.Scope[condVar] = condValue + if strings.Contains(literalResult, "$") { + extracted := extractEmbeddedVars(def.Literal, prefillValue, cheat.Scope) + for k, v := range extracted { + if _, exists := cheat.Scope[k]; !exists { + cheat.Scope[k] = v changed = true } } + literalResult = executor.SubstituteVars(def.Literal, cheat.Scope, "dollar") + } + + if literalResult == prefillValue && condOp == "==" { + cheat.Scope[condVar] = condValue + changed = true } } + return changed } func parseCondition(cond string) (varName, op, value string) { diff --git a/internal/shellgen/widgets.go b/internal/shellgen/widgets.go index e7e60ba..d201088 100644 --- a/internal/shellgen/widgets.go +++ b/internal/shellgen/widgets.go @@ -8,7 +8,7 @@ import ( ) func BashWidget() string { - keyWidget := config.GetKeyWidget() + keyWidget := config.Get().KeyWidget return fmt.Sprintf(`#!/usr/bin/env bash _cheatmd_widget() { @@ -36,7 +36,7 @@ fi } func ZshWidget() string { - keyWidget := config.GetKeyWidget() + keyWidget := config.Get().KeyWidget // Convert bash-style keybinding to zsh format (e.g., \C-g -> ^g) zshKey := convertToZshKey(keyWidget) return fmt.Sprintf(`#!/usr/bin/env zsh @@ -65,7 +65,7 @@ bindkey '%s' _cheatmd_widget } func FishWidget() string { - keyWidget := config.GetKeyWidget() + keyWidget := config.Get().KeyWidget // Convert bash-style keybinding to fish format (e.g., \C-g -> \cg) fishKey := convertToFishKey(keyWidget) return fmt.Sprintf(`function _cheatmd_widget diff --git a/internal/ui/cheat_select.go b/internal/ui/cheat_select.go index bb0f56c..9531419 100644 --- a/internal/ui/cheat_select.go +++ b/internal/ui/cheat_select.go @@ -14,6 +14,7 @@ import ( "github.com/cheatmd-dev/cheatmd/pkg/chainstate" "github.com/cheatmd-dev/cheatmd/pkg/config" "github.com/cheatmd-dev/cheatmd/pkg/parser" + "github.com/mattn/go-runewidth" ) // ============================================================================ @@ -22,12 +23,12 @@ import ( // cheatItem wraps a Cheat with display metadata. type cheatItem struct { - cheat *parser.Cheat - folder string - file string - chainName string - chainStep int - chainTotal int + cheat *parser.Cheat + folder string + file string + chainName string + chainStep int + chainTotal int } func newCheatItem(cheat *parser.Cheat) cheatItem { @@ -35,15 +36,15 @@ func newCheatItem(cheat *parser.Cheat) cheatItem { file := strings.TrimSuffix(filepath.Base(cheat.File), filepath.Ext(cheat.File)) return cheatItem{ - cheat: cheat, - folder: folder, - file: file, + cheat: cheat, + folder: folder, + file: file, } } type chainGroup struct { - Name string - Steps []*parser.Cheat + Name string + Steps []*parser.Cheat } func buildChains(cheats []*parser.Cheat) []chainGroup { @@ -113,8 +114,8 @@ func (item *cheatItem) containsWord(word string) bool { // buildPathDisplay builds the path display string based on config options. func buildPathDisplay(folder, file string) string { - showFolder := config.GetShowFolder() - showFile := config.GetShowFile() + showFolder := config.Get().ShowFolder + showFile := config.Get().ShowFile if showFolder && showFile { return folder + "/" + file @@ -132,19 +133,19 @@ func buildPathDisplay(folder, file string) string { // columnConfig holds display column widths and gaps. type columnConfig struct { - headerWidth int - descWidth int - cmdWidth int - gap int + headerWidth int + descWidth int + cmdWidth int + gap int } // loadColumnConfig loads column configuration from config. func loadColumnConfig() columnConfig { return columnConfig{ - headerWidth: config.GetColumnHeader(), - descWidth: config.GetColumnDesc(), - cmdWidth: config.GetColumnCommand(), - gap: config.GetColumnGap(), + headerWidth: config.Get().Columns.Header, + descWidth: config.Get().Columns.Desc, + cmdWidth: config.Get().Columns.Command, + gap: config.Get().Columns.Gap, } } @@ -211,19 +212,19 @@ func (m *mainModel) handleCheatSelectKey(msg tea.KeyMsg) tea.Cmd { if m.picker.HandleKey(msg) { return nil } - if msg.String() == config.GetKeyOpen() { + if msg.String() == config.Get().KeyOpen { if opt, ok := m.picker.Selected(); ok { openFileInViewer(opt.cheat.File) } } - if msg.String() == config.GetKeyPreview() { + if msg.String() == config.Get().KeyPreview { if opt, ok := m.picker.Selected(); ok { if m.enterPreview(opt.cheat) { return tea.ClearScreen } } } - if msg.String() == config.GetKeyHistory() { + if msg.String() == config.Get().KeyHistory { if m.enterHistory() { return tea.ClearScreen } @@ -312,9 +313,9 @@ func (m *mainModel) renderCheatSelect() string { height = 24 } - inputLines := 3 // divider + info + input + inputLines := 3 // divider + info + input - previewHeight := config.GetPreviewHeight() + previewHeight := config.Get().PreviewHeight if previewHeight < 1 { previewHeight = 6 } @@ -418,7 +419,7 @@ func (m *mainModel) renderListItem(item cheatItem, selected bool, gap string) st headerRendered := m.renderHeaderColumn(pathPart, headerPart, pStyle, hStyle, selected) desc := truncateString(firstLine(item.cheat.Description), m.columns.descWidth) - descPadded := fmt.Sprintf("%-*s", m.columns.descWidth, desc) + descPadded := runewidth.FillRight(desc, m.columns.descWidth) maxCmd := m.calculateCommandWidth() cmd := truncateString(firstLine(item.cheat.Command), maxCmd) @@ -449,23 +450,29 @@ func (m *mainModel) getItemStyles(selected bool) (path, header, desc, cmd lipglo // renderHeaderColumn renders the path+header column with proper truncation. func (m *mainModel) renderHeaderColumn(pathPart, headerPart string, pStyle, hStyle lipgloss.Style, selected bool) string { - var fullHeader string - if pathPart != "" { - fullHeader = pathPart + " " + headerPart - } else { - fullHeader = headerPart + fullWidth := runewidth.StringWidth(pathPart) + if pathPart != "" && headerPart != "" { + fullWidth += 1 // space } + fullWidth += runewidth.StringWidth(headerPart) - if m.columns.headerWidth > 1 && len(fullHeader) > m.columns.headerWidth { - fullHeader = fullHeader[:m.columns.headerWidth-1] + "…" - if pathPart != "" && len(pathPart) >= len(fullHeader) { - pathPart = fullHeader + if m.columns.headerWidth > 1 && fullWidth > m.columns.headerWidth { + if pathPart != "" && runewidth.StringWidth(pathPart) >= m.columns.headerWidth { + pathPart = runewidth.Truncate(pathPart, m.columns.headerWidth, "…") headerPart = "" } else if pathPart != "" { - headerPart = fullHeader[len(pathPart)+1:] + avail := m.columns.headerWidth - runewidth.StringWidth(pathPart) - 1 + headerPart = runewidth.Truncate(headerPart, avail, "…") } else { - headerPart = fullHeader + headerPart = runewidth.Truncate(headerPart, m.columns.headerWidth, "…") + } + + // Recalculate width + fullWidth = runewidth.StringWidth(pathPart) + if pathPart != "" && headerPart != "" { + fullWidth += 1 } + fullWidth += runewidth.StringWidth(headerPart) } var rendered string @@ -477,7 +484,7 @@ func (m *mainModel) renderHeaderColumn(pathPart, headerPart string, pStyle, hSty rendered = hStyle.Render(headerPart) } - if padding := m.columns.headerWidth - len(fullHeader); padding > 0 { + if padding := m.columns.headerWidth - fullWidth; padding > 0 { padStr := strings.Repeat(" ", padding) if selected { padStr = styles.Selected.Render(padStr) @@ -507,7 +514,7 @@ func (m *mainModel) renderInput(width int) string { b.WriteString("\n") b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d/%d", len(m.picker.Filtered), len(m.cheats)))) b.WriteString(" • ") - keyOpen := config.GetKeyOpen() + keyOpen := config.Get().KeyOpen if keyOpen == "" { keyOpen = "ctrl+o" } diff --git a/internal/ui/history_view.go b/internal/ui/history_view.go index 0d71e32..79678e8 100644 --- a/internal/ui/history_view.go +++ b/internal/ui/history_view.go @@ -13,23 +13,23 @@ import ( // historyState holds the overlay state for the execution-history picker. type historyState struct { - picker *Picker[history.Entry] + picker *Picker[history.Entry] // Saved cheat-select state to restore on cancel. - prevInput string - prevCursor int - prevOffset int + prevInput string + prevCursor int + prevOffset int } // enterHistory transitions from phaseCheatSelect into the history overlay. // Returns true on success, false if there are no entries or history is // otherwise unavailable. func (m *mainModel) enterHistory() bool { - path, err := history.DefaultPath(config.GetHistoryFile()) + path, err := history.DefaultPath(config.Get().HistoryFile) if err != nil { return false } - entries, err := history.Load(path, config.GetHistoryMax()) + entries, err := history.Load(path, config.Get().HistoryMax) if err != nil || len(entries) == 0 { return false } @@ -38,9 +38,9 @@ func (m *mainModel) enterHistory() bool { hay := strings.ToLower(e.Command + " " + e.Header + " " + e.File) return matchesAllWords(hay, words) }), - prevInput: m.textInput.Value(), - prevCursor: m.picker.Cursor, - prevOffset: m.picker.Offset, + prevInput: m.textInput.Value(), + prevCursor: m.picker.Cursor, + prevOffset: m.picker.Offset, } m.textInput.SetValue("") m.textInput.Placeholder = "Search history..." @@ -130,7 +130,7 @@ func (m *mainModel) updateHistory(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd := m.handleHistoryKey(msg); cmd != nil { return m, cmd } - if isHistoryNavKey(msg.String()) { + if isOverlayNavKey(msg.String()) { return m, nil } } @@ -144,16 +144,6 @@ func (m *mainModel) updateHistory(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tiCmd } -// isHistoryNavKey mirrors isSubstituteNavKey: navigation/accept/cancel keys -// the overlay swallows rather than passing to the text input. -func isHistoryNavKey(key string) bool { - switch key { - case "ctrl+c", "esc", "enter", "up", "down", "ctrl+p", "ctrl+n", "pgup", "pgdown": - return true - } - return false -} - // renderHistory renders the history overlay using the shared overlay layout. func (m *mainModel) renderHistory() string { extra := "" @@ -178,14 +168,14 @@ func (m *mainModel) renderHistory() string { } return m.renderOverlayWindow(OverlayConfig{ - Title: "History", - TitleExtra: extra, - MatchesCount: matchCount, - EnterHint: "Enter re-run cheat", - Items: items, - SelectedIndex: cursor, - Offset: offset, - Input: m.textInput, + Title: "History", + TitleExtra: extra, + MatchesCount: matchCount, + EnterHint: "Enter re-run cheat", + Items: items, + SelectedIndex: cursor, + Offset: offset, + Input: m.textInput, }) } diff --git a/internal/ui/main_model.go b/internal/ui/main_model.go index 6f5f335..5eebf17 100644 --- a/internal/ui/main_model.go +++ b/internal/ui/main_model.go @@ -225,3 +225,13 @@ func (m *mainModel) View() string { return m.renderCheatSelect() } } + +// isOverlayNavKey reports whether key is a navigation/accept/cancel key +// that an overlay handles directly (rather than passing to the text input). +func isOverlayNavKey(key string) bool { + switch key { + case "ctrl+c", "esc", "enter", "up", "down", "ctrl+p", "ctrl+n", "pgup", "pgdown": + return true + } + return false +} diff --git a/internal/ui/preview.go b/internal/ui/preview.go index eebf894..6bcd634 100644 --- a/internal/ui/preview.go +++ b/internal/ui/preview.go @@ -18,15 +18,15 @@ import ( // The user enters via the configured key during phaseCheatSelect or // phaseVarResolve and returns to the previous phase on Esc. type previewOverlayState struct { - viewport viewport.Model - cheat *parser.Cheat // cheat whose file is being shown - prevPhase uiPhase // phase to restore on exit - errorMsg string // non-empty if rendering or reading failed + viewport viewport.Model + cheat *parser.Cheat // cheat whose file is being shown + prevPhase uiPhase // phase to restore on exit + errorMsg string // non-empty if rendering or reading failed } type previewPendingCodeBlock struct { - command string - headerLine int + command string + headerLine int } // enterPreview transitions to phasePreview with the markdown rendering of the @@ -41,9 +41,9 @@ func (m *mainModel) enterPreview(c *parser.Cheat) bool { data, err := os.ReadFile(c.File) if err != nil { m.previewState = &previewOverlayState{ - cheat: c, - prevPhase: m.phase, - errorMsg: fmt.Sprintf("Could not read %s: %v", c.File, err), + cheat: c, + prevPhase: m.phase, + errorMsg: fmt.Sprintf("Could not read %s: %v", c.File, err), } m.previewState.viewport = newPreviewViewport(m.width, m.height) m.previewState.viewport.SetContent(m.previewState.errorMsg) @@ -67,9 +67,9 @@ func (m *mainModel) enterPreview(c *parser.Cheat) bool { } m.previewState = &previewOverlayState{ - viewport: vp, - cheat: c, - prevPhase: m.phase, + viewport: vp, + cheat: c, + prevPhase: m.phase, } m.phase = phasePreview return true @@ -91,7 +91,7 @@ func (m *mainModel) updatePreview(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: if m.previewState != nil { m.previewState.viewport.Width = max(msg.Width, 1) - m.previewState.viewport.Height = max(msg.Height-1, 1) // 1 row for hint + m.previewState.viewport.Height = max(msg.Height-1, 1) // 1 row for hint } case tea.KeyMsg: switch msg.String() { @@ -155,138 +155,84 @@ func renderMarkdown(raw string, width int) (string, error) { } // cheatmdGlamourStyle returns an ansi.StyleConfig that maps glamour's style -// slots to cheatmd's configured color palette (color_header, color_command, -// color_path, color_border, color_desc, color_dim). Called once per preview -// open so live config edits take effect on the next preview. +// slots to cheatmd's configured color palette. func cheatmdGlamourStyle() ansi.StyleConfig { - header := config.GetColorHeader() - command := config.GetColorCommand() - desc := config.GetColorDesc() - path := config.GetColorPath() - border := config.GetColorBorder() - dim := config.GetColorDim() - - str := func(s string) *string { return &s } - b := func(v bool) *bool { return &v } - u := func(v uint) *uint { return &v } - margin := uint(2) listIndent := uint(2) - return ansi.StyleConfig{ - Document: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - BlockPrefix: "\n", - BlockSuffix: "\n", - Color: str(desc), - }, - Margin: u(margin), - }, - BlockQuote: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{Color: str(dim)}, - Indent: u(1), - IndentToken: str("│ "), - }, - List: ansi.StyleList{ - LevelIndent: listIndent, - }, - Heading: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - BlockSuffix: "\n", - Color: str(header), - Bold: b(true), - }, - }, - H1: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Color: str(header), - Bold: b(true), - }, - }, - H2: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "## ", - Color: str(header), - Bold: b(true), - }, - }, - H3: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "### ", - Color: str(header), - Bold: b(true), - }, - }, - H4: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "#### ", - Color: str(header), - }, - }, - H5: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "##### ", - Color: str(header), - }, - }, - H6: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "###### ", - Color: str(dim), - }, - }, - Strikethrough: ansi.StylePrimitive{CrossedOut: b(true)}, - Emph: ansi.StylePrimitive{Italic: b(true), Color: str(desc)}, - Strong: ansi.StylePrimitive{Bold: b(true), Color: str(desc)}, - HorizontalRule: ansi.StylePrimitive{ - Color: str(border), - Format: "\n────────\n", - }, - Item: ansi.StylePrimitive{BlockPrefix: "• "}, - Enumeration: ansi.StylePrimitive{BlockPrefix: ". "}, - Task: ansi.StyleTask{ - StylePrimitive: ansi.StylePrimitive{}, - Ticked: "[✓] ", - Unticked: "[ ] ", - }, - Link: ansi.StylePrimitive{ - Color: str(path), - Underline: b(true), - }, - LinkText: ansi.StylePrimitive{ - Color: str(path), - Bold: b(true), - }, - Image: ansi.StylePrimitive{ - Color: str(path), - Underline: b(true), - }, - ImageText: ansi.StylePrimitive{ - Color: str(dim), - Format: "Image: {{.text}} →", - }, - Code: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Color: str(command), - }, - }, - CodeBlock: ansi.StyleCodeBlock{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{Color: str(command)}, - Margin: u(margin), - }, - // Letting Chroma stay nil makes glamour render code blocks as - // flat-colored text in our command color, which keeps the - // preview visually consistent with the rest of the TUI. - }, - Table: ansi.StyleTable{ - StyleBlock: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{}}, + cfg := ansi.StyleConfig{ + Document: cheatmdDocumentStyle(margin), + BlockQuote: cheatmdBlockQuoteStyle(), + List: ansi.StyleList{LevelIndent: listIndent}, + Heading: cheatmdHeadingStyle(true), + H1: cheatmdHeadingStyleLevel(" ", " "), + H2: cheatmdHeadingStyleLevel("## ", ""), + H3: cheatmdHeadingStyleLevel("### ", ""), + H4: cheatmdHeadingStyleLevel("#### ", ""), + H5: cheatmdHeadingStyleLevel("##### ", ""), + H6: cheatmdHeadingStyleLevelDim("###### "), + Strikethrough: ansi.StylePrimitive{CrossedOut: bPtr(true)}, + Emph: ansi.StylePrimitive{Italic: bPtr(true), Color: sPtr(config.Get().Colors.Desc)}, + Strong: ansi.StylePrimitive{Bold: bPtr(true), Color: sPtr(config.Get().Colors.Desc)}, + HorizontalRule: ansi.StylePrimitive{Color: sPtr(config.Get().Colors.Border), Format: "\n────────\n"}, + Item: ansi.StylePrimitive{BlockPrefix: "• "}, + Enumeration: ansi.StylePrimitive{BlockPrefix: ". "}, + Task: ansi.StyleTask{StylePrimitive: ansi.StylePrimitive{}, Ticked: "[✓] ", Unticked: "[ ] "}, + Link: ansi.StylePrimitive{Color: sPtr(config.Get().Colors.Path), Underline: bPtr(true)}, + LinkText: ansi.StylePrimitive{Color: sPtr(config.Get().Colors.Path), Bold: bPtr(true)}, + Image: ansi.StylePrimitive{Color: sPtr(config.Get().Colors.Path), Underline: bPtr(true)}, + ImageText: ansi.StylePrimitive{Color: sPtr(config.Get().Colors.Dim), Format: "Image: {{.text}} →"}, + Code: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Prefix: " ", Suffix: " ", Color: sPtr(config.Get().Colors.Command)}}, + CodeBlock: cheatmdCodeBlockStyle(margin), + Table: ansi.StyleTable{StyleBlock: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{}}}, + DefinitionDescription: ansi.StylePrimitive{BlockPrefix: "\n→ "}, + } + return cfg +} + +func sPtr(s string) *string { return &s } +func bPtr(v bool) *bool { return &v } +func uPtr(v uint) *uint { return &v } + +func cheatmdDocumentStyle(margin uint) ansi.StyleBlock { + return ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{BlockPrefix: "\n", BlockSuffix: "\n", Color: sPtr(config.Get().Colors.Desc)}, + Margin: uPtr(margin), + } +} + +func cheatmdBlockQuoteStyle() ansi.StyleBlock { + return ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{Color: sPtr(config.Get().Colors.Dim)}, + Indent: uPtr(1), + IndentToken: sPtr("│ "), + } +} + +func cheatmdHeadingStyle(bold bool) ansi.StyleBlock { + return ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{BlockSuffix: "\n", Color: sPtr(config.Get().Colors.Header), Bold: bPtr(bold)}, + } +} + +func cheatmdHeadingStyleLevel(prefix, suffix string) ansi.StyleBlock { + return ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{Prefix: prefix, Suffix: suffix, Color: sPtr(config.Get().Colors.Header), Bold: bPtr(true)}, + } +} + +func cheatmdHeadingStyleLevelDim(prefix string) ansi.StyleBlock { + return ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{Prefix: prefix, Color: sPtr(config.Get().Colors.Dim)}, + } +} + +func cheatmdCodeBlockStyle(margin uint) ansi.StyleCodeBlock { + return ansi.StyleCodeBlock{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{Color: sPtr(config.Get().Colors.Command)}, + Margin: uPtr(margin), }, - DefinitionDescription: ansi.StylePrimitive{BlockPrefix: "\n→ "}, } } @@ -299,11 +245,11 @@ func findCheatHeaderSourceLine(raw string, cheat *parser.Cheat) int { } var ( - currentHeaderLine = -1 - inCodeFence bool - inCheatBlock bool - codeLines []string - pending []previewPendingCodeBlock + currentHeaderLine = -1 + inCodeFence bool + inCheatBlock bool + codeLines []string + pending []previewPendingCodeBlock ) lines := strings.Split(raw, "\n") @@ -316,8 +262,8 @@ func findCheatHeaderSourceLine(raw string, cheat *parser.Cheat) int { command := strings.TrimSpace(strings.Join(codeLines, "\n")) if command != "" { pending = append(pending, previewPendingCodeBlock{ - command: command, - headerLine: currentHeaderLine, + command: command, + headerLine: currentHeaderLine, }) } codeLines = nil diff --git a/internal/ui/resolve.go b/internal/ui/resolve.go index 30f6d88..e76c821 100644 --- a/internal/ui/resolve.go +++ b/internal/ui/resolve.go @@ -70,10 +70,7 @@ func extractCustomHeader(selectorArgs string) string { return resolver.ExtractCustomHeader(selectorArgs) } -// parseShellArgs parses a string into arguments, respecting quotes -func parseShellArgs(s string) []string { - return resolver.ParseShellArgs(s) -} + // applyMapTransform transforms the selected value based on options func applyMapTransform(value string, opts SelectOptions) string { diff --git a/internal/ui/run.go b/internal/ui/run.go index 9d68fd7..3d314bb 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -23,7 +23,7 @@ func recordRun(cheat *parser.Cheat, finalCmd string) { if cheat == nil || finalCmd == "" { return } - path, err := history.DefaultPath(config.GetHistoryFile()) + path, err := history.DefaultPath(config.Get().HistoryFile) if err != nil { return } @@ -36,10 +36,10 @@ func recordRun(cheat *parser.Cheat, finalCmd string) { } } _ = history.Append(path, history.Entry{ - Command: finalCmd, - File: cheat.File, - Header: cheat.Header, - Scope: scopeCopy, + Command: finalCmd, + File: cheat.File, + Header: cheat.Header, + Scope: scopeCopy, }) } @@ -85,9 +85,7 @@ func RunTUI(index *parser.CheatIndex, exec Executor, initialQuery, matchCmd stri // non-default starting phase. startPhase == phaseCheatSelect behaves the // same as RunTUI; phaseHistory opens the history overlay on entry. func RunTUIWithStart(index *parser.CheatIndex, exec Executor, initialQuery, matchCmd string, startPhase uiPhase) (string, error) { - requireCheatBlock := config.GetRequireCheatBlock() - autoSelect := config.GetAutoSelect() - + requireCheatBlock := config.Get().RequireCheatBlock cheats := index.FilterByConfig(requireCheatBlock) if len(cheats) == 0 { return "", fmt.Errorf("no cheats found") @@ -101,96 +99,103 @@ func RunTUIWithStart(index *parser.CheatIndex, exec Executor, initialQuery, matc if m.chainPath != "" { m.chainState, _ = chainstate.Load(m.chainPath) } - resumeChain := false - if initialQuery == "" && matchCmd == "" && startPhase == phaseCheatSelect { - if active := chainstate.ActiveName(index.Root, m.chainState); active != "" { - initialQuery = "/chain " + active - resumeChain = true - } - } - if matchCmd != "" { - if matched := resolver.FindMatchingCheat(cheats, matchCmd); matched != nil { - m.selected = matched - resolver.PrefillScopeFromMatch(matched, matchCmd) - resolver.InferDependentVars(matched, index) - m.startVarResolutionInternal() - - if m.phase != phaseVarResolve { - finalCmd := exec.BuildFinalCommand(m.selected) - recordRun(m.selected, finalCmd) - advanceChain(index, m.selected, m.chainPath, m.chainState) - return finalCmd, nil - } + initialQuery, resumeChain := applyInitialChainState(&m, index, initialQuery, matchCmd, startPhase) - if m.varState != nil && len(m.varState.vars) > 0 { - vs := &m.varState.vars[0] - if vs.prefill != "" { - m.textInput.SetValue(vs.prefill) - m.textInput.CursorEnd() - } - } - } else { + if matchCmd != "" { + if finalCmd, ok := handleMatchCmd(&m, index, exec, matchCmd); ok { + return finalCmd, nil + } + if m.selected == nil { initialQuery = matchCmd } } if initialQuery != "" { - m.textInput.SetValue(initialQuery) - m.filterCheats() - - if (autoSelect || resumeChain) && len(m.picker.Filtered) == 1 { - m.selected = m.picker.Filtered[0].cheat - m.startVarResolutionInternal() - - if m.phase != phaseVarResolve { - finalCmd := exec.BuildFinalCommand(m.selected) - recordRun(m.selected, finalCmd) - advanceChain(index, m.selected, m.chainPath, m.chainState) - return finalCmd, nil - } + if finalCmd, ok := handleInitialQuery(&m, index, exec, initialQuery, resumeChain); ok { + return finalCmd, nil } } - // If a non-default start phase is requested, transition into it before - // starting the bubbletea program. Only phaseHistory is supported as a - // jump-start currently; unsupported values are ignored. - if startPhase == phaseHistory && m.phase == phaseCheatSelect { - if !m.enterHistory() { - return "", fmt.Errorf("no history yet (run some cheats first)") - } + if startPhase == phaseHistory && m.selected == nil && initialQuery == "" { + m.enterHistory() } - ttyIn, ttyOut, cleanup := getTTY() + in, out, cleanup := getTTY() + defer cleanup() RefreshStyles() - p := tea.NewProgram(&m, tea.WithAltScreen(), tea.WithOutput(ttyOut), tea.WithInput(ttyIn)) - finalModel, err := p.Run() - cleanup() - if err != nil { + p := tea.NewProgram(&m, tea.WithAltScreen(), tea.WithInput(in), tea.WithOutput(out)) + if _, err := p.Run(); err != nil { return "", err } - result := finalModel.(*mainModel) - if result.quitting && result.selected == nil { - return "", nil + if m.selected != nil { + return finishCheat(&m, index, exec), nil } - if result.selected == nil { - return "", nil + return "", nil +} + +func applyInitialChainState(m *mainModel, index *parser.CheatIndex, initialQuery, matchCmd string, startPhase uiPhase) (string, bool) { + if initialQuery == "" && matchCmd == "" && startPhase == phaseCheatSelect { + if active := chainstate.ActiveName(index.Root, m.chainState); active != "" { + return "/chain " + active, true + } } + return initialQuery, false +} + +func handleMatchCmd(m *mainModel, index *parser.CheatIndex, exec Executor, matchCmd string) (string, bool) { + matched := resolver.FindMatchingCheat(index.FilterByConfig(config.Get().RequireCheatBlock), matchCmd) + if matched == nil { + return "", false + } + m.selected = matched + resolver.PrefillScopeFromMatch(matched, matchCmd) + resolver.InferDependentVars(matched, index) + m.startVarResolutionInternal() + + if m.phase != phaseVarResolve { + return finishCheat(m, index, exec), true + } + + if m.varState != nil && len(m.varState.vars) > 0 { + if vs := &m.varState.vars[0]; vs.prefill != "" { + m.textInput.SetValue(vs.prefill) + m.textInput.CursorEnd() + } + } + return "", false +} - finalCmd := exec.BuildFinalCommand(result.selected) - recordRun(result.selected, finalCmd) - advanceChain(index, result.selected, result.chainPath, result.chainState) - return finalCmd, nil +func handleInitialQuery(m *mainModel, index *parser.CheatIndex, exec Executor, initialQuery string, resumeChain bool) (string, bool) { + m.textInput.SetValue(initialQuery) + m.filterCheats() + + if (config.Get().AutoSelect || resumeChain) && len(m.picker.Filtered) == 1 { + m.selected = m.picker.Filtered[0].cheat + m.startVarResolutionInternal() + + if m.phase != phaseVarResolve { + return finishCheat(m, index, exec), true + } + + if m.varState != nil && len(m.varState.vars) > 0 { + if vs := &m.varState.vars[0]; vs.prefill != "" { + m.textInput.SetValue(vs.prefill) + m.textInput.CursorEnd() + } + } + } + return "", false } func loadFrecencyScores() map[string]float64 { - path, err := history.DefaultPath(config.GetHistoryFile()) + path, err := history.DefaultPath(config.Get().HistoryFile) if err != nil { return nil } - entries, err := history.Load(path, config.GetHistoryMax()) + entries, err := history.Load(path, config.Get().HistoryMax) if err != nil { return nil } @@ -222,7 +227,7 @@ func advanceChain(index *parser.CheatIndex, cheat *parser.Cheat, path string, st func openFileInViewer(filePath string) { var cmd *exec.Cmd - if editor := config.GetEditor(); editor != "" { + if editor := config.Get().Editor; editor != "" { cmd = exec.Command(editor, filePath) } else { switch runtime.GOOS { @@ -236,3 +241,10 @@ func openFileInViewer(filePath string) { } _ = cmd.Start() } + +func finishCheat(m *mainModel, index *parser.CheatIndex, exec Executor) string { + finalCmd := exec.BuildFinalCommand(m.selected) + recordRun(m.selected, finalCmd) + advanceChain(index, m.selected, m.chainPath, m.chainState) + return finalCmd +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 23efa7a..dda7edc 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -8,59 +8,59 @@ import ( // StyleManager encapsulates all TUI styles and provides methods for style operations type StyleManager struct { // List view styles - Header lipgloss.Style - Desc lipgloss.Style - Command lipgloss.Style - Path lipgloss.Style - Selected lipgloss.Style - Cursor lipgloss.Style - Dim lipgloss.Style + Header lipgloss.Style + Desc lipgloss.Style + Command lipgloss.Style + Path lipgloss.Style + Selected lipgloss.Style + Cursor lipgloss.Style + Dim lipgloss.Style // Preview styles - PreviewHeader lipgloss.Style - PreviewDesc lipgloss.Style - PreviewCmd lipgloss.Style - PreviewPath lipgloss.Style + PreviewHeader lipgloss.Style + PreviewDesc lipgloss.Style + PreviewCmd lipgloss.Style + PreviewPath lipgloss.Style // Chrome styles - Border lipgloss.Style - Divider lipgloss.Style + Border lipgloss.Style + Divider lipgloss.Style // Colors for direct access - SelectedBg lipgloss.Color + SelectedBg lipgloss.Color } // DefaultStyles returns a StyleManager with default styles func DefaultStyles() *StyleManager { return &StyleManager{ - Header: lipgloss.NewStyle().Bold(true), - Desc: lipgloss.NewStyle(), - Command: lipgloss.NewStyle(), - Path: lipgloss.NewStyle(), - Selected: lipgloss.NewStyle().Background(lipgloss.Color("236")), - Cursor: lipgloss.NewStyle().Foreground(lipgloss.Color("212")), - Dim: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), - PreviewHeader: lipgloss.NewStyle().Bold(true), - PreviewDesc: lipgloss.NewStyle(), - PreviewCmd: lipgloss.NewStyle(), - PreviewPath: lipgloss.NewStyle(), - Border: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")), - Divider: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), - SelectedBg: lipgloss.Color("236"), + Header: lipgloss.NewStyle().Bold(true), + Desc: lipgloss.NewStyle(), + Command: lipgloss.NewStyle(), + Path: lipgloss.NewStyle(), + Selected: lipgloss.NewStyle().Background(lipgloss.Color("236")), + Cursor: lipgloss.NewStyle().Foreground(lipgloss.Color("212")), + Dim: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), + PreviewHeader: lipgloss.NewStyle().Bold(true), + PreviewDesc: lipgloss.NewStyle(), + PreviewCmd: lipgloss.NewStyle(), + PreviewPath: lipgloss.NewStyle(), + Border: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")), + Divider: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), + SelectedBg: lipgloss.Color("236"), } } // LoadFromConfig updates styles based on configuration func (s *StyleManager) LoadFromConfig() { // Get colors from config - headerColor := parseANSIColor(config.GetColorHeader()) - descColor := parseANSIColor(config.GetColorDesc()) - cmdColor := parseANSIColor(config.GetColorCommand()) - pathColor := parseANSIColor(config.GetColorPath()) - borderColor := lipgloss.Color(config.GetColorBorder()) - cursorColor := lipgloss.Color(config.GetColorCursor()) - selectedBg := lipgloss.Color(config.GetColorSelected()) - dimColor := lipgloss.Color(config.GetColorDim()) + headerColor := parseANSIColor(config.Get().Colors.Header) + descColor := parseANSIColor(config.Get().Colors.Desc) + cmdColor := parseANSIColor(config.Get().Colors.Command) + pathColor := parseANSIColor(config.Get().Colors.Path) + borderColor := lipgloss.Color(config.Get().Colors.Border) + cursorColor := lipgloss.Color(config.Get().Colors.Cursor) + selectedBg := lipgloss.Color(config.Get().Colors.Selected) + dimColor := lipgloss.Color(config.Get().Colors.Dim) // List view styles s.Header = lipgloss.NewStyle().Foreground(headerColor) @@ -91,10 +91,10 @@ func (s *StyleManager) WithSelection(style lipgloss.Style) lipgloss.Style { // parseANSIColor converts ANSI color codes to lipgloss colors func parseANSIColor(code string) lipgloss.Color { ansiToLipgloss := map[string]string{ - "30": "0", "31": "1", "32": "2", "33": "3", - "34": "4", "35": "5", "36": "6", "37": "7", - "90": "8", "91": "9", "92": "10", "93": "11", - "94": "12", "95": "13", "96": "14", "97": "15", + "30": "0", "31": "1", "32": "2", "33": "3", + "34": "4", "35": "5", "36": "6", "37": "7", + "90": "8", "91": "9", "92": "10", "93": "11", + "94": "12", "95": "13", "96": "14", "97": "15", } if mapped, ok := ansiToLipgloss[code]; ok { return lipgloss.Color(mapped) diff --git a/internal/ui/substitute_search.go b/internal/ui/substitute_search.go index d05dc63..ded8861 100644 --- a/internal/ui/substitute_search.go +++ b/internal/ui/substitute_search.go @@ -13,11 +13,11 @@ import ( // env/history value, and returns to phaseVarResolve with the chosen value // loaded into the var prompt. type substituteSearchState struct { - picker *Picker[substituteOption] + picker *Picker[substituteOption] - prevInput string // textInput value before entering the overlay - prevCursor int - prevOffset int + prevInput string // textInput value before entering the overlay + prevCursor int + prevOffset int } // updateSubstituteSearch handles updates while the substitute overlay is open. @@ -29,7 +29,7 @@ func (m *mainModel) updateSubstituteSearch(msg tea.Msg) (tea.Model, tea.Cmd) { } // If the key was a navigation/accept/cancel key we already handled it; // otherwise fall through and let the text input absorb it. - if isSubstituteNavKey(msg.String()) { + if isOverlayNavKey(msg.String()) { return m, nil } } @@ -43,21 +43,11 @@ func (m *mainModel) updateSubstituteSearch(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tiCmd } -// isSubstituteNavKey reports whether key is a navigation/accept/cancel key -// that the overlay handles directly (rather than passing to the text input). -func isSubstituteNavKey(key string) bool { - switch key { - case "ctrl+c", "esc", "enter", "up", "down", "ctrl+p", "ctrl+n", "pgup", "pgdown": - return true - } - return false -} - // enterSubstituteSearch transitions from phaseVarResolve into the substitute // search overlay. Returns true if the transition happened; false if disabled // or there are no sources to show. func (m *mainModel) enterSubstituteSearch() bool { - sources := config.GetSubstituteSources() + sources := config.Get().SubstituteSources if len(sources) == 0 { return false } @@ -70,9 +60,9 @@ func (m *mainModel) enterSubstituteSearch() bool { hay := strings.ToLower(opt.Display) return matchesAllWords(hay, words) }), - prevInput: m.textInput.Value(), - prevCursor: m.picker.Cursor, - prevOffset: m.picker.Offset, + prevInput: m.textInput.Value(), + prevCursor: m.picker.Cursor, + prevOffset: m.picker.Offset, } m.textInput.SetValue("") m.textInput.Placeholder = "Search env / history..." @@ -174,13 +164,13 @@ func (m *mainModel) renderSubstituteSearch() string { } return m.renderOverlayWindow(OverlayConfig{ - Title: "Substitute search", - TitleExtra: extra, - MatchesCount: matchCount, - EnterHint: "Enter use value", - Items: items, - SelectedIndex: cursor, - Offset: offset, - Input: m.textInput, + Title: "Substitute search", + TitleExtra: extra, + MatchesCount: matchCount, + EnterHint: "Enter use value", + Items: items, + SelectedIndex: cursor, + Offset: offset, + Input: m.textInput, }) } diff --git a/internal/ui/substitute_sources.go b/internal/ui/substitute_sources.go index 54d063e..3681438 100644 --- a/internal/ui/substitute_sources.go +++ b/internal/ui/substitute_sources.go @@ -111,18 +111,16 @@ func collectHistoryOptions() []substituteOption { scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - type entry struct { - name, value string - } - var entries []entry + var entries []assignment for scanner.Scan() { line := strings.TrimSpace(stripHistoryPrefix(scanner.Text())) if line == "" { continue } - for _, a := range extractAssignments(line) { - entries = append(entries, entry{a.name, a.value}) - } + entries = append(entries, extractAssignments(line)...) + } + if err := scanner.Err(); err != nil { + // Log or handle, but return whatever we parsed so far for resilience } // Newest first, dedupe by "name=value". @@ -167,10 +165,7 @@ func extractAssignments(line string) []assignment { case "export", "set", "typeset", "local", "readonly": continue case "declare": - // skip any flag-like tokens that follow (e.g. -x, -gx) - for i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "-") { - i++ - } + i = skipFlags(tokens, i) continue } if a, ok := parseAssignment(tok); ok { @@ -180,6 +175,13 @@ func extractAssignments(line string) []assignment { return out } +func skipFlags(tokens []string, i int) int { + for i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "-") { + i++ + } + return i +} + // parseAssignment splits "NAME=value" if NAME is a valid shell var name. // Returns false on anything else (commands, paths, flags, etc.). func parseAssignment(tok string) (assignment, bool) { diff --git a/internal/ui/var_resolve.go b/internal/ui/var_resolve.go index 32ba85e..63dd44a 100644 --- a/internal/ui/var_resolve.go +++ b/internal/ui/var_resolve.go @@ -1,12 +1,12 @@ package ui import ( - "fmt" "os" "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/cheatmd-dev/cheatmd/internal/resolver" "github.com/cheatmd-dev/cheatmd/pkg/config" "github.com/cheatmd-dev/cheatmd/pkg/executor" "github.com/cheatmd-dev/cheatmd/pkg/parser" @@ -18,8 +18,8 @@ import ( // shellResultMsg is sent when a shell command completes. type shellResultMsg struct { - options []string - err error + options []string + err error } // ============================================================================ @@ -66,9 +66,9 @@ func (m *mainModel) startVarResolutionInternal() { } m.varState = &varResolveState{ - cheat: cheat, - vars: vars, - currentIdx: 0, + cheat: cheat, + vars: vars, + currentIdx: 0, } m.phase = phaseVarResolve @@ -84,7 +84,6 @@ func (m *mainModel) startVarResolutionInternal() { // command to run a shell command to get options. func (m *mainModel) prepareCurrentVar() tea.Cmd { if m.varState == nil || m.varState.currentIdx >= len(m.varState.vars) { - // All variables resolved - copy to scope and quit. if m.varState != nil { for _, vs := range m.varState.vars { if vs.resolved { @@ -96,26 +95,11 @@ func (m *mainModel) prepareCurrentVar() tea.Cmd { } vs := &m.varState.vars[m.varState.currentIdx] + scope := m.currentVarScope() - scope := make(map[string]string) - for _, v := range m.varState.vars { - if v.resolved { - scope[v.def.Name] = v.value - } - } - - // Select the matching variant based on conditions. selectedDef := selectVariant(vs.variants, scope) if selectedDef == nil { - allConditional := true - for _, v := range vs.variants { - if v.Condition == "" { - allConditional = false - break - } - } - if allConditional && len(vs.variants) > 0 { - // All variants conditional and none matched - skip. + if allVariantsConditional(vs.variants) { vs.resolved = true vs.value = "" m.varState.currentIdx++ @@ -125,9 +109,7 @@ func (m *mainModel) prepareCurrentVar() tea.Cmd { } vs.def = *selectedDef - // Auto-continue if the prefill is good enough. - autoContinue := config.GetAutoContinue() - if autoContinue && vs.prefill != "" && !vs.skipAutoCont { + if config.Get().AutoContinue && vs.prefill != "" && !vs.skipAutoCont { vs.value = vs.prefill vs.resolved = true m.varState.currentIdx++ @@ -135,43 +117,74 @@ func (m *mainModel) prepareCurrentVar() tea.Cmd { } m.varState.customHeader = extractCustomHeader(vs.def.Args) - m.varState.selectOpts = parseSelectorOpts(vs.def.Args) + m.varState.selectOpts = resolver.ParseSelectorOpts(vs.def.Args) - // Literal value: substitute scope vars and either show or auto-resolve. if vs.def.Literal != "" { - result := executor.SubstituteVars(vs.def.Literal, scope, "dollar") - if vs.skipAutoCont { - m.varState.isPromptOnly = true - m.varState.options = nil - if m.varState.picker != nil { - m.varState.picker.SetItems(nil) - } - m.textInput.SetValue(result) - m.textInput.CursorEnd() - return nil - } - vs.value = result - vs.resolved = true - m.varState.currentIdx++ - return m.prepareCurrentVar() + return m.prepareLiteralVar(vs, scope) } - // Prompt only. if strings.TrimSpace(vs.def.Shell) == "" { + return m.preparePromptVar(vs) + } + + return m.prepareShellVar(vs, scope) +} + +func (m *mainModel) currentVarScope() map[string]string { + scope := make(map[string]string) + for _, v := range m.varState.vars { + if v.resolved { + scope[v.def.Name] = v.value + } + } + return scope +} + +func allVariantsConditional(variants []parser.VarDef) bool { + if len(variants) == 0 { + return false + } + for _, v := range variants { + if v.Condition == "" { + return false + } + } + return true +} + +func (m *mainModel) prepareLiteralVar(vs *varState, scope map[string]string) tea.Cmd { + result := executor.SubstituteVars(vs.def.Literal, scope, config.Get().VarSyntax) + if vs.skipAutoCont { m.varState.isPromptOnly = true m.varState.options = nil if m.varState.picker != nil { m.varState.picker.SetItems(nil) } - if vs.prefill != "" { - m.textInput.SetValue(vs.prefill) - m.textInput.CursorEnd() - } + m.textInput.SetValue(result) + m.textInput.CursorEnd() return nil } + vs.value = result + vs.resolved = true + m.varState.currentIdx++ + return m.prepareCurrentVar() +} - // Run shell command asynchronously to get options. - shellCmd := executor.SubstituteVars(vs.def.Shell, scope, "dollar") +func (m *mainModel) preparePromptVar(vs *varState) tea.Cmd { + m.varState.isPromptOnly = true + m.varState.options = nil + if m.varState.picker != nil { + m.varState.picker.SetItems(nil) + } + if vs.prefill != "" { + m.textInput.SetValue(vs.prefill) + m.textInput.CursorEnd() + } + return nil +} + +func (m *mainModel) prepareShellVar(vs *varState, scope map[string]string) tea.Cmd { + shellCmd := executor.SubstituteVars(vs.def.Shell, scope, config.Get().VarSyntax) return func() tea.Msg { output, err := m.executor.RunShell(shellCmd) if err != nil { @@ -182,43 +195,6 @@ func (m *mainModel) prepareCurrentVar() tea.Cmd { } } -// parseSelectorOpts parses selector options from args. -func parseSelectorOpts(selectorArgs string) SelectOptions { - opts := SelectOptions{} - if selectorArgs == "" { - return opts - } - - args := parseShellArgs(selectorArgs) - for i := 0; i < len(args); i++ { - switch args[i] { - case "--delimiter": - if i+1 < len(args) { - opts.Delimiter = args[i+1] - i++ - } - case "--column": - if i+1 < len(args) { - fmt.Sscanf(args[i+1], "%d", &opts.Column) - i++ - } - case "--select-column": - if i+1 < len(args) { - fmt.Sscanf(args[i+1], "%d", &opts.SelectColumn) - i++ - } - case "--map": - if i+1 < len(args) { - opts.MapCmd = args[i+1] - i++ - } - case "--multi": - opts.Multi = true - } - } - return opts -} - // ============================================================================ // Update // ============================================================================ @@ -298,9 +274,9 @@ func (m *mainModel) handleShellResult(msg shellResultMsg) (tea.Model, tea.Cmd) { for i, opt := range msg.options { display := getDisplayColumn(opt, opts.Delimiter, opts.Column) items[i] = FilteredOption{ - Display: display, - Original: opt, - SearchText: strings.ToLower(display), + Display: display, + Original: opt, + SearchText: strings.ToLower(display), } } diff --git a/internal/ui/var_resolve_keys.go b/internal/ui/var_resolve_keys.go index 470d34d..b84959e 100644 --- a/internal/ui/var_resolve_keys.go +++ b/internal/ui/var_resolve_keys.go @@ -10,13 +10,39 @@ import ( // handleVarResolveKey processes keyboard input during variable resolution. func (m *mainModel) handleVarResolveKey(msg tea.KeyMsg) tea.Cmd { + if cmd := m.handleVarResolveNavKey(msg); cmd != nil { + return cmd + } + + switch msg.String() { + case "enter": + return m.acceptVarValue(false) + case "alt+enter", "ctrl+j": + return m.acceptVarValue(true) + case "up", "ctrl+p", "down", "ctrl+n", "pgup", "pgdown": + if !m.varState.isPromptOnly && m.varState.picker != nil { + m.varState.picker.HandleKey(msg) + } + return nil + case "tab": + return m.handleVarResolveTab(msg) + case " ": + if cmd := m.handleVarResolveSpace(msg); cmd != nil { + return cmd + } + default: + return m.handleVarResolveDefaultKey(msg) + } + return nil +} + +func (m *mainModel) handleVarResolveNavKey(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "ctrl+c": m.quitting = true m.selected = nil return tea.Quit case "esc": - // Go back to previous var or cheat selection. if m.varState.currentIdx > 0 { m.varState.currentIdx-- vs := &m.varState.vars[m.varState.currentIdx] @@ -24,8 +50,10 @@ func (m *mainModel) handleVarResolveKey(msg tea.KeyMsg) tea.Cmd { vs.value = "" vs.skipAutoCont = true m.textInput.SetValue("") - m.picker.Cursor = 0 - m.picker.Offset = 0 + if m.varState.picker != nil { + m.picker.Cursor = 0 + m.picker.Offset = 0 + } return m.prepareCurrentVar() } m.phase = phaseCheatSelect @@ -37,63 +65,61 @@ func (m *mainModel) handleVarResolveKey(msg tea.KeyMsg) tea.Cmd { m.picker.Cursor = 0 m.picker.Offset = 0 return nil - case "enter": - return m.acceptVarValue(false) - case "alt+enter", "ctrl+j": - return m.acceptVarValue(true) - case "up", "ctrl+p", "down", "ctrl+n", "pgup", "pgdown": - if !m.varState.isPromptOnly && m.varState.picker != nil { - m.varState.picker.HandleKey(msg) - } + } + return nil +} + +func (m *mainModel) handleVarResolveTab(msg tea.KeyMsg) tea.Cmd { + if m.completePathFromInput() { return nil - case "tab": - if m.completePathFromInput() { - return nil - } - if !m.varState.isPromptOnly && m.varState.picker != nil { - if opt, ok := m.varState.picker.Selected(); ok { - m.textInput.SetValue(opt.Display) - m.textInput.CursorEnd() - } + } + if !m.varState.isPromptOnly && m.varState.picker != nil { + if opt, ok := m.varState.picker.Selected(); ok { + m.textInput.SetValue(opt.Display) + m.textInput.CursorEnd() } - case " ": - if m.varState.selectOpts.Multi && !m.varState.isPromptOnly && m.varState.picker != nil { - if opt, ok := m.varState.picker.Selected(); ok { - vs := &m.varState.vars[m.varState.currentIdx] - original := opt.Original - if vs.multiSelectedSet[original] { - vs.multiSelectedSet[original] = false - // Remove from multiSelected list - for i, val := range vs.multiSelected { - if val == original { - vs.multiSelected = append(vs.multiSelected[:i], vs.multiSelected[i+1:]...) - break - } + } + return nil +} + +func (m *mainModel) handleVarResolveSpace(msg tea.KeyMsg) tea.Cmd { + if m.varState.selectOpts.Multi && !m.varState.isPromptOnly && m.varState.picker != nil { + if opt, ok := m.varState.picker.Selected(); ok { + vs := &m.varState.vars[m.varState.currentIdx] + original := opt.Original + if vs.multiSelectedSet[original] { + vs.multiSelectedSet[original] = false + for i, val := range vs.multiSelected { + if val == original { + vs.multiSelected = append(vs.multiSelected[:i], vs.multiSelected[i+1:]...) + break } - } else { - vs.multiSelectedSet[original] = true - vs.multiSelected = append(vs.multiSelected, original) } - // Don't pass space to text input, just return nil to re-render - return nil + } else { + vs.multiSelectedSet[original] = true + vs.multiSelected = append(vs.multiSelected, original) } + return func() tea.Msg { return nil } // Return a non-nil dummy command to bypass text input } - default: - if msg.String() == config.GetKeyOpen() { - if m.varState != nil && m.varState.cheat != nil { - openFileInViewer(m.varState.cheat.File) - } + } + return nil +} + +func (m *mainModel) handleVarResolveDefaultKey(msg tea.KeyMsg) tea.Cmd { + if msg.String() == config.Get().KeyOpen { + if m.varState != nil && m.varState.cheat != nil { + openFileInViewer(m.varState.cheat.File) } - if msg.String() == config.GetKeySubstitute() { - if m.enterSubstituteSearch() { - return tea.Batch(tea.ClearScreen, textinput.Blink) - } + } + if msg.String() == config.Get().KeySubstitute { + if m.enterSubstituteSearch() { + return tea.Batch(tea.ClearScreen, textinput.Blink) } - if msg.String() == config.GetKeyPreview() { - if m.varState != nil && m.varState.cheat != nil { - if m.enterPreview(m.varState.cheat) { - return tea.ClearScreen - } + } + if msg.String() == config.Get().KeyPreview { + if m.varState != nil && m.varState.cheat != nil { + if m.enterPreview(m.varState.cheat) { + return tea.ClearScreen } } } diff --git a/internal/ui/var_resolve_render.go b/internal/ui/var_resolve_render.go index 9e8caa4..e634393 100644 --- a/internal/ui/var_resolve_render.go +++ b/internal/ui/var_resolve_render.go @@ -129,10 +129,10 @@ func (m *mainModel) renderVarHeader(width int) string { progressCmd := m.varState.cheat.Command for i, vs := range m.varState.vars { if vs.resolved { - progressCmd = executor.ReplaceVar(progressCmd, vs.def.Name, styles.Header.Render(vs.value), config.GetVarSyntax()) + progressCmd = executor.ReplaceVar(progressCmd, vs.def.Name, styles.Header.Render(vs.value), config.Get().VarSyntax) } else if i == m.varState.currentIdx { displayStr := formatVarName(m.varState.cheat.Command, vs.def.Name) - progressCmd = executor.ReplaceVar(progressCmd, vs.def.Name, styles.Cursor.Render(displayStr), config.GetVarSyntax()) + progressCmd = executor.ReplaceVar(progressCmd, vs.def.Name, styles.Cursor.Render(displayStr), config.Get().VarSyntax) } } b.WriteString(progressCmd) @@ -169,9 +169,9 @@ func (m *mainModel) renderVarHeader(width int) string { // formatVarName returns the variable name formatted according to how it appears in the command, // or defaults based on the syntax config. func formatVarName(cmd string, name string) string { - if config.GetVarSyntax() == "angle" { + if config.Get().VarSyntax == "angle" { return "<" + name + ">" - } else if config.GetVarSyntax() == "both" { + } else if config.Get().VarSyntax == "both" { if strings.Contains(cmd, "<"+name+">") { return "<" + name + ">" } diff --git a/pkg/config/config.go b/pkg/config/config.go index 26d891d..e96e7d3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -56,10 +56,10 @@ type Config struct { PreviewHeight int `mapstructure:"preview_height"` // Colors - Colors ColorConfig + Colors ColorConfig `mapstructure:",squash"` // Columns - Columns ColumnConfig + Columns ColumnConfig `mapstructure:",squash"` } // ColorConfig holds all color settings @@ -91,57 +91,31 @@ type ColumnConfig struct { // via the registry_url config key for private/self-hosted registries. const DefaultRegistryURL = "https://raw.githubusercontent.com/cheatmd-dev/registry/main/registry.yaml" -var defaults = struct { - path string - registryURL string - output string - shell string - editor string - preHook string - postHook string - requireCheatBlock bool - allowUndeclaredVars bool - varSyntax string - autoSelect bool - autoContinue bool - keyWidget string - keyOpen string - keySubstitute string - keyPreview string - keyHistory string - historyFile string - historyMax int - substituteSources []string - showFolder bool - showFile bool - previewHeight int - colors ColorConfig - columns ColumnConfig -}{ - path: ".", - registryURL: DefaultRegistryURL, - output: "print", - shell: "", // Set dynamically - editor: "", // Empty means use system default (xdg-open/open/start) - preHook: "", - postHook: "", - requireCheatBlock: false, - allowUndeclaredVars: false, - varSyntax: "dollar", - autoSelect: false, - autoContinue: false, - keyWidget: "\\C-g", // Ctrl+G for shell widgets - keyOpen: "ctrl+o", // Ctrl+O in TUI - keySubstitute: "ctrl+t", // Ctrl+T opens substitute search during var resolution - keyPreview: "ctrl+y", // Ctrl+Y opens markdown preview of current cheat's file - keyHistory: "ctrl+h", // Ctrl+H opens execution history - historyFile: "", // Empty -> $XDG_DATA_HOME/cheatmd/history.jsonl - historyMax: 1000, - substituteSources: []string{"env", "history"}, - showFolder: true, - showFile: true, - previewHeight: 6, - colors: ColorConfig{ +var DefaultConfig = Config{ + Path: ".", + RegistryURL: DefaultRegistryURL, + Output: "print", + Shell: "", // Set dynamically + Editor: "", // Empty means use system default (xdg-open/open/start) + PreHook: "", + PostHook: "", + RequireCheatBlock: false, + AllowUndeclaredVars: false, + VarSyntax: "dollar", + AutoSelect: false, + AutoContinue: false, + KeyWidget: "\\C-g", // Ctrl+G for shell widgets + KeyOpen: "ctrl+o", // Ctrl+O in TUI + KeySubstitute: "ctrl+t", // Ctrl+T opens substitute search during var resolution + KeyPreview: "ctrl+y", // Ctrl+Y opens markdown preview of current cheat's file + KeyHistory: "ctrl+h", // Ctrl+H opens execution history + HistoryFile: "", // Empty -> $XDG_DATA_HOME/cheatmd/history.jsonl + HistoryMax: 1000, + SubstituteSources: []string{"env", "history"}, + ShowFolder: true, + ShowFile: true, + PreviewHeight: 6, + Colors: ColorConfig{ Header: "36", // Cyan Command: "32", // Green Desc: "246", // Light gray @@ -151,7 +125,7 @@ var defaults = struct { Selected: "236", // Dark bg Dim: "245", // Medium gray }, - columns: ColumnConfig{ + Columns: ColumnConfig{ Gap: 4, Header: 40, Desc: 40, @@ -164,11 +138,17 @@ var defaults = struct { // ============================================================================ // cfg is the global config instance -var cfg Config +var cfg Config = DefaultConfig // Init initializes configuration with viper func Init() error { - setDefaults() + cfg = DefaultConfig // copy defaults + + shell := os.Getenv("SHELL") + if shell != "" { + cfg.Shell = shell + } + configureViper() var configErr error @@ -185,62 +165,12 @@ func Init() error { return configErr } -// setDefaults sets all default values in viper -func setDefaults() { - shell := os.Getenv("SHELL") - if shell == "" { - shell = "/bin/bash" - } - - viper.SetDefault("path", defaults.path) - viper.SetDefault("registry_url", defaults.registryURL) - viper.SetDefault("output", defaults.output) - viper.SetDefault("shell", shell) - viper.SetDefault("editor", defaults.editor) - viper.SetDefault("pre_hook", defaults.preHook) - viper.SetDefault("post_hook", defaults.postHook) - viper.SetDefault("require_cheat_block", defaults.requireCheatBlock) - viper.SetDefault("allow_undeclared_vars", defaults.allowUndeclaredVars) - viper.SetDefault("var_syntax", defaults.varSyntax) - viper.SetDefault("auto_select", defaults.autoSelect) - viper.SetDefault("auto_continue", defaults.autoContinue) - - // Keybindings - viper.SetDefault("key_widget", defaults.keyWidget) - viper.SetDefault("key_open", defaults.keyOpen) - viper.SetDefault("key_substitute", defaults.keySubstitute) - viper.SetDefault("key_preview", defaults.keyPreview) - viper.SetDefault("key_history", defaults.keyHistory) - - // Execution history - viper.SetDefault("history_file", defaults.historyFile) - viper.SetDefault("history_max", defaults.historyMax) - - // Substitute search - viper.SetDefault("substitute_sources", defaults.substituteSources) - - // Display options - viper.SetDefault("show_folder", defaults.showFolder) - viper.SetDefault("show_file", defaults.showFile) - viper.SetDefault("preview_height", defaults.previewHeight) - - // Colors - viper.SetDefault("color_header", defaults.colors.Header) - viper.SetDefault("color_command", defaults.colors.Command) - viper.SetDefault("color_desc", defaults.colors.Desc) - viper.SetDefault("color_path", defaults.colors.Path) - viper.SetDefault("color_border", defaults.colors.Border) - viper.SetDefault("color_cursor", defaults.colors.Cursor) - viper.SetDefault("color_selected", defaults.colors.Selected) - viper.SetDefault("color_dim", defaults.colors.Dim) - - // Columns - viper.SetDefault("column_gap", defaults.columns.Gap) - viper.SetDefault("column_header", defaults.columns.Header) - viper.SetDefault("column_desc", defaults.columns.Desc) - viper.SetDefault("column_command", defaults.columns.Command) +// Get returns a pointer to the global configuration +func Get() *Config { + return &cfg } + // configureViper sets up viper configuration sources func configureViper() { viper.SetConfigName("cheatmd") @@ -260,260 +190,6 @@ func configureViper() { // Getters - Core Settings // ============================================================================ -// GetPath returns the cheat path with tilde expansion -func GetPath() string { - return expandTilde(viper.GetString("path")) -} - -// GetRegistryURL returns the cheat-pack registry manifest URL. -func GetRegistryURL() string { - return viper.GetString("registry_url") -} - -// GetOutput returns the output mode -func GetOutput() string { - return viper.GetString("output") -} - -// GetShell returns the configured shell -func GetShell() string { - return viper.GetString("shell") -} - -// GetPreHook returns the pre-execution hook -func GetPreHook() string { - return viper.GetString("pre_hook") -} - -// GetPostHook returns the post-execution hook -func GetPostHook() string { - return viper.GetString("post_hook") -} - -// GetEditor returns the configured editor command (empty = system default) -func GetEditor() string { - return viper.GetString("editor") -} - -// GetAllowUndeclaredVars returns whether variables referenced in a cheat's -// command but not declared in any block should be prompted at -// runtime. When false (default), undeclared variables are silently skipped. -// -// Reads from the cached struct populated at Init() because this getter is -// called in hot paths (per-variable, per-render). -func GetAllowUndeclaredVars() bool { - return cfg.AllowUndeclaredVars -} - -// GetVarSyntax returns the configured variable syntax mode. -// Valid values: "dollar" (default), "angle", "both". -// -// Reads from the cached struct populated at Init() because this getter is -// called in hot paths (per-variable, per-render). -func GetVarSyntax() string { - v := cfg.VarSyntax - switch v { - case "dollar", "angle", "both": - return v - default: - return "dollar" - } -} - -// VarSyntaxAllowsDollar reports whether $name is recognized as a variable. -func VarSyntaxAllowsDollar() bool { - s := GetVarSyntax() - return s == "dollar" || s == "both" -} - -// VarSyntaxAllowsAngle reports whether is recognized as a variable. -func VarSyntaxAllowsAngle() bool { - s := GetVarSyntax() - return s == "angle" || s == "both" -} - -// GetRequireCheatBlock returns whether to require cheat blocks -func GetRequireCheatBlock() bool { - return viper.GetBool("require_cheat_block") -} - -// GetAutoSelect returns whether to auto-select single matches -func GetAutoSelect() bool { - return viper.GetBool("auto_select") -} - -// GetAutoContinue returns whether to auto-continue when vars are prefilled from environment -func GetAutoContinue() bool { - return viper.GetBool("auto_continue") -} - -// ============================================================================ -// Getters - Keybindings -// ============================================================================ - -// GetKeyWidget returns the keybinding for shell widget activation (e.g., "\C-g" for Ctrl+G) -func GetKeyWidget() string { - return viper.GetString("key_widget") -} - -// GetKeyOpen returns the keybinding for opening markdown in editor (e.g., "ctrl+o") -func GetKeyOpen() string { - return viper.GetString("key_open") -} - -// GetKeySubstitute returns the keybinding for opening the substitute search -// during variable resolution (e.g., "ctrl+t"). -func GetKeySubstitute() string { - return viper.GetString("key_substitute") -} - -// GetKeyPreview returns the keybinding for opening the markdown preview of -// the current cheat's source file (e.g., "ctrl+y"). -func GetKeyPreview() string { - return viper.GetString("key_preview") -} - -// GetKeyHistory returns the keybinding for opening the execution history -// overlay (e.g., "ctrl+h"). -func GetKeyHistory() string { - return viper.GetString("key_history") -} - -// GetHistoryFile returns the override path for the history file, or "" for -// the default ($XDG_DATA_HOME/cheatmd/history.jsonl). -func GetHistoryFile() string { - return viper.GetString("history_file") -} - -// GetHistoryMax returns the cap on history entries shown in the picker. -// Zero or negative means unlimited. -func GetHistoryMax() int { - return viper.GetInt("history_max") -} - -// GetSubstituteSources returns the enabled sources for substitute search. -// Valid entries: "env", "history". Empty disables the feature. -func GetSubstituteSources() []string { - return viper.GetStringSlice("substitute_sources") -} - -// ============================================================================ -// Getters - Display Options -// ============================================================================ - -// GetShowFolder returns whether to show folder in title/list -func GetShowFolder() bool { - return viper.GetBool("show_folder") -} - -// GetShowFile returns whether to show file in title/list -func GetShowFile() bool { - return viper.GetBool("show_file") -} - -// GetPreviewHeight returns the preview section height in lines -func GetPreviewHeight() int { - return viper.GetInt("preview_height") -} - -// ============================================================================ -// Getters - Colors -// ============================================================================ - -// GetColorHeader returns the header color code -func GetColorHeader() string { - return viper.GetString("color_header") -} - -// GetColorCommand returns the command color code -func GetColorCommand() string { - return viper.GetString("color_command") -} - -// GetColorDesc returns the description color code -func GetColorDesc() string { - return viper.GetString("color_desc") -} - -// GetColorPath returns the path color code -func GetColorPath() string { - return viper.GetString("color_path") -} - -// GetColorBorder returns the border color code -func GetColorBorder() string { - return viper.GetString("color_border") -} - -// GetColorCursor returns the cursor color code -func GetColorCursor() string { - return viper.GetString("color_cursor") -} - -// GetColorSelected returns the selected background color code -func GetColorSelected() string { - return viper.GetString("color_selected") -} - -// GetColorDim returns the dimmed text color code -func GetColorDim() string { - return viper.GetString("color_dim") -} - -// ============================================================================ -// Getters - Columns -// ============================================================================ - -// GetColumnGap returns column spacing -func GetColumnGap() int { - return viper.GetInt("column_gap") -} - -// GetColumnHeader returns header column width -func GetColumnHeader() int { - return viper.GetInt("column_header") -} - -// GetColumnDesc returns description column width -func GetColumnDesc() int { - return viper.GetInt("column_desc") -} - -// GetColumnCommand returns command column width -func GetColumnCommand() int { - return viper.GetInt("column_command") -} - -// ============================================================================ -// Setters -// ============================================================================ - -// SetOutput sets the output mode at runtime -func SetOutput(mode string) { - viper.Set("output", mode) - cfg.Output = mode -} - -// SetAutoSelect sets auto-select mode at runtime -func SetAutoSelect(enabled bool) { - viper.Set("auto_select", enabled) - cfg.AutoSelect = enabled -} - -// ============================================================================ -// First-run setup -// ============================================================================ - -// DefaultConfigPath returns the path where WriteDefaultConfig writes the -// starter config: ~/.config/cheatmd/cheatmd.yaml. -func DefaultConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - return "cheatmd.yaml" - } - return filepath.Join(home, ".config", "cheatmd", "cheatmd.yaml") -} - // CheatsInstallDir returns the directory cheat packs should be installed into, // and where installed-pack detection looks. It is the configured cheats path // when the user has set one to a real directory; otherwise it falls back to @@ -523,7 +199,7 @@ func DefaultConfigPath() string { // The default path is "." (browse the current directory); we treat that as // "unset" for install purposes so packs never land in an arbitrary cwd. func CheatsInstallDir() string { - if p := GetPath(); p != "" && p != "." { + if p := Get().Path; p != "" && p != "." { return p } return DefaultCheatsDir() @@ -593,3 +269,22 @@ func expandTilde(path string) string { } return path } + +func VarSyntaxAllowsDollar() bool { + syntax := Get().VarSyntax + return syntax == "dollar" || syntax == "both" || syntax == "" +} + +func VarSyntaxAllowsAngle() bool { + syntax := Get().VarSyntax + return syntax == "angle" || syntax == "both" +} + +// DefaultConfigPath is the path to the user's config file +func DefaultConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "cheatmd.yaml" + } + return filepath.Join(home, ".config", "cheatmd", "cheatmd.yaml") +} diff --git a/pkg/convert/common.go b/pkg/convert/common.go index 8d479af..c966076 100644 --- a/pkg/convert/common.go +++ b/pkg/convert/common.go @@ -3,7 +3,6 @@ package convert import ( "path/filepath" "regexp" - "strconv" "strings" ) @@ -73,37 +72,6 @@ func rewriteNavi(command string) (string, []string) { return command, phs } -// rewriteTldr replaces {{name}} placeholders with $sanitized. -func rewriteTldr(command string) (string, []string) { - phs := extractTldrPlaceholders(command) - for _, ph := range phs { - sanitized := sanitizeVarName(ph) - command = strings.ReplaceAll(command, "{{"+ph+"}}", "$"+sanitized) - command = strings.ReplaceAll(command, "{{ "+ph+" }}", "$"+sanitized) - command = strings.ReplaceAll(command, "{{"+ph+" }}", "$"+sanitized) - command = strings.ReplaceAll(command, "{{ "+ph+"}}", "$"+sanitized) - } - return command, phs -} - -// rewriteBoth applies navi then tldr rewriting and returns the union of -// placeholders in encounter order. Used by the cheat/cheat format, which mixes -// both placeholder styles. -func rewriteBoth(command string) (string, []string) { - command, navi := rewriteNavi(command) - command, tldr := rewriteTldr(command) - seen := make(map[string]struct{}, len(navi)+len(tldr)) - all := make([]string, 0, len(navi)+len(tldr)) - for _, ph := range append(navi, tldr...) { - if _, ok := seen[ph]; ok { - continue - } - seen[ph] = struct{}{} - all = append(all, ph) - } - return command, all -} - func extractPlaceholders(cmd string, re *regexp.Regexp) []string { matches := re.FindAllStringSubmatch(cmd, -1) var list []string @@ -143,22 +111,23 @@ func extractTldrPlaceholders(cmd string) []string { } func findTldrPlaceholderEnd(cmd string, start int) int { - for i := start; i < len(cmd); { + consecutiveBraces := 0 + for i := start; i < len(cmd); i++ { if cmd[i] == '\n' { return -1 } - if cmd[i] != '}' { - i++ - continue - } - runStart := i - for i < len(cmd) && cmd[i] == '}' { - i++ - } - if i-runStart >= 2 { - return i - 2 + if cmd[i] == '}' { + consecutiveBraces++ + } else { + if consecutiveBraces >= 2 { + return i - 2 + } + consecutiveBraces = 0 } } + if consecutiveBraces >= 2 { + return len(cmd) - 2 + } return -1 } @@ -196,26 +165,6 @@ func consumeCodeBlock(lines []string, index int) (string, int) { return strings.TrimSpace(cb.String()), index } -// formatHeaderVarsBlock emits a `` block declaring each -// placeholder as a bare selector with the original name as its header label. -// Used by tldr and cheat outputs, where there's no upstream var definition. -func formatHeaderVarsBlock(placeholders []string) string { - if len(placeholders) == 0 { - return "" - } - var sb strings.Builder - sb.WriteString("\n") - return sb.String() -} - // parseFrontMatterTags extracts `tags:` from a leading `---` YAML block and // returns the tags plus the index of the first line after the block. Returns // (nil, 0) when there is no front matter. diff --git a/pkg/convert/dir.go b/pkg/convert/dir.go index cc303ca..b3b7ecb 100644 --- a/pkg/convert/dir.go +++ b/pkg/convert/dir.go @@ -128,9 +128,7 @@ func ConvertFile(format, inputPath, outputPath string) error { return nil } -// ConvertDirectory walks the input directory and converts all matching files. func ConvertDirectory(format, inputDir, outputDir string) error { - // Create output dir if it doesn't exist if err := os.MkdirAll(outputDir, 0755); err != nil { return fmt.Errorf("failed to create output directory %s: %w", outputDir, err) } @@ -141,74 +139,76 @@ func ConvertDirectory(format, inputDir, outputDir string) error { } if d.IsDir() { - // Skip hidden dirs if strings.HasPrefix(d.Name(), ".") && d.Name() != "." { return filepath.SkipDir } return nil } - // Check if the file matches our format's expected extension/criteria - shouldConvert := false - switch format { - case "navi": - shouldConvert = strings.HasSuffix(strings.ToLower(d.Name()), ".cheat") - case "tldr": - shouldConvert = strings.HasSuffix(strings.ToLower(d.Name()), ".md") - case "cheat": - // cheat/cheat community files typically have no extension and aren't hidden - shouldConvert = !strings.Contains(d.Name(), ".") && !strings.HasPrefix(d.Name(), "_") - } - - if !shouldConvert { + if !shouldConvertFile(format, d.Name()) { return nil } - // Calculate relative path to maintain structure - rel, err := filepath.Rel(inputDir, path) - if err != nil { - return err - } + return convertAndWriteFile(format, inputDir, outputDir, path) + }) - relBase := strings.TrimSuffix(rel, filepath.Ext(rel)) - targetFile := filepath.Join(outputDir, relBase+".md") + if err != nil { + return fmt.Errorf("error walking directory: %w", err) + } - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read file %s: %w", path, err) - } + return nil +} - var converted string - switch format { - case "navi": - converted, err = ConvertNavi(string(data), path) - case "tldr": - converted, err = ConvertTldr(string(data), path) - case "cheat": - converted, err = ConvertCheat(string(data), path) - } +func shouldConvertFile(format, filename string) bool { + switch format { + case "navi": + return strings.HasSuffix(strings.ToLower(filename), ".cheat") + case "tldr": + return strings.HasSuffix(strings.ToLower(filename), ".md") + case "cheat": + return !strings.Contains(filename, ".") && !strings.HasPrefix(filename, "_") + default: + return false + } +} - if err != nil { - return fmt.Errorf("failed to convert file %s: %w", path, err) - } +func convertAndWriteFile(format, inputDir, outputDir, path string) error { + rel, err := filepath.Rel(inputDir, path) + if err != nil { + return err + } - // Ensure parent directory exists for the output file - parentDir := filepath.Dir(targetFile) - if err := os.MkdirAll(parentDir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", parentDir, err) - } + relBase := strings.TrimSuffix(rel, filepath.Ext(rel)) + targetFile := filepath.Join(outputDir, relBase+".md") - if err := os.WriteFile(targetFile, []byte(converted), 0644); err != nil { - return fmt.Errorf("failed to write converted file %s: %w", targetFile, err) - } + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", path, err) + } - fmt.Printf("✓ Converted %s (%s) -> %s\n", rel, format, targetFile) - return nil - }) + var converted string + switch format { + case "navi": + converted, err = ConvertNavi(string(data), path) + case "tldr": + converted, err = ConvertTldr(string(data), path) + case "cheat": + converted, err = ConvertCheat(string(data), path) + } if err != nil { - return fmt.Errorf("error walking directory: %w", err) + return fmt.Errorf("failed to convert file %s: %w", path, err) + } + + parentDir := filepath.Dir(targetFile) + if err := os.MkdirAll(parentDir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", parentDir, err) + } + + if err := os.WriteFile(targetFile, []byte(converted), 0644); err != nil { + return fmt.Errorf("failed to write converted file %s: %w", targetFile, err) } + fmt.Printf("✓ Converted %s (%s) -> %s\n", rel, format, targetFile) return nil } diff --git a/pkg/convert/navi.go b/pkg/convert/navi.go index 1eeeb7f..16a1c99 100644 --- a/pkg/convert/navi.go +++ b/pkg/convert/navi.go @@ -188,15 +188,23 @@ func BuildNaviIndex(files []*NaviFile) *NaviIndex { ByTag: make(map[string][]*NaviSection), } for _, f := range files { - for _, s := range f.Sections { - for _, tag := range s.Tags { - idx.ByTag[tag] = append(idx.ByTag[tag], s) - } - } + indexNaviFile(idx, f) } return idx } +func indexNaviFile(idx *NaviIndex, f *NaviFile) { + for _, s := range f.Sections { + indexNaviSection(idx, s) + } +} + +func indexNaviSection(idx *NaviIndex, s *NaviSection) { + for _, tag := range s.Tags { + idx.ByTag[tag] = append(idx.ByTag[tag], s) + } +} + // ---------------------------------------------------------------------------- // Entry points // ---------------------------------------------------------------------------- @@ -240,9 +248,7 @@ func SerializeNaviFile(file *NaviFile, idx *NaviIndex) string { var sb strings.Builder fmt.Fprintf(&sb, "# Converted %s Cheatsheet\n\n", capitalize(filenameBase(file.Filename))) for _, section := range file.Sections { - for _, c := range section.Cheats { - writeNaviCheat(&sb, c, section, idx) - } + writeNaviSectionCheats(&sb, section, idx) if len(section.VarOrder) > 0 { writeSectionModule(&sb, section) } @@ -250,6 +256,12 @@ func SerializeNaviFile(file *NaviFile, idx *NaviIndex) string { return sb.String() } +func writeNaviSectionCheats(sb *strings.Builder, section *NaviSection, idx *NaviIndex) { + for _, c := range section.Cheats { + writeNaviCheat(sb, c, section, idx) + } +} + // writeNaviCheat emits one cheat block. For each placeholder it resolves: // - to its own section's module (if defined here), // - to a sibling section's module reached through an `@extends` tag, @@ -332,13 +344,20 @@ func resolvePlaceholders( // itself. func resolveExtends(c NaviCheat, own *NaviSection, ph string, idx *NaviIndex) string { for _, ext := range c.Imports { - for _, s := range idx.ByTag[ext] { - if s == own { - continue - } - if _, ok := s.Vars[ph]; ok { - return s.Module - } + if mod := resolveExtendsFromTag(ext, own, ph, idx); mod != "" { + return mod + } + } + return "" +} + +func resolveExtendsFromTag(tag string, own *NaviSection, ph string, idx *NaviIndex) string { + for _, s := range idx.ByTag[tag] { + if s == own { + continue + } + if _, ok := s.Vars[ph]; ok { + return s.Module } } return "" diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index e29bcf6..16652bc 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -85,17 +85,17 @@ func commandExists(name string) bool { // Executor handles shell command execution and variable substitution type Executor struct { - index *parser.CheatIndex - shell string - clipboard Clipboard + index *parser.CheatIndex + shell string + clipboard Clipboard } // NewExecutor creates a new executor with the given cheat index func NewExecutor(index *parser.CheatIndex) *Executor { return &Executor{ - index: index, - shell: config.GetShell(), - clipboard: &systemClipboard{}, + index: index, + shell: config.Get().Shell, + clipboard: &systemClipboard{}, } } @@ -161,7 +161,7 @@ func (e *Executor) Execute(command string) error { // BuildFinalCommand substitutes all variables in a cheat's command func (e *Executor) BuildFinalCommand(cheat *parser.Cheat) string { - result := SubstituteVars(cheat.Command, cheat.Scope, config.GetVarSyntax()) + result := SubstituteVars(cheat.Command, cheat.Scope, config.Get().VarSyntax) // Handle escaped dollar signs result = strings.ReplaceAll(result, "\\$", "$") @@ -183,7 +183,7 @@ func SubstituteVars(s string, scope map[string]string, syntax string) string { case "both": s = strings.ReplaceAll(s, "$"+name, val) s = strings.ReplaceAll(s, "<"+name+">", val) - default: // "dollar" + default: // "dollar" s = strings.ReplaceAll(s, "$"+name, val) } } @@ -198,14 +198,14 @@ func SubstituteVars(s string, scope map[string]string, syntax string) string { type OutputMode string const ( - OutputPrint OutputMode = "print" - OutputCopy OutputMode = "copy" - OutputExec OutputMode = "exec" + OutputPrint OutputMode = "print" + OutputCopy OutputMode = "copy" + OutputExec OutputMode = "exec" ) // Output handles command output based on the configured mode func (e *Executor) Output(command string) error { - mode := OutputMode(config.GetOutput()) + mode := OutputMode(config.Get().Output) return e.OutputWithMode(command, mode) } @@ -216,7 +216,7 @@ func (e *Executor) OutputWithMode(command string, mode OutputMode) error { return e.Execute(command) case OutputCopy: return e.clipboard.Copy(command) - default: // print + default: // print return nil } } diff --git a/pkg/executor/resolve.go b/pkg/executor/resolve.go index 8a7cdad..e2d004f 100644 --- a/pkg/executor/resolve.go +++ b/pkg/executor/resolve.go @@ -11,9 +11,9 @@ import ( // CollectDependencies gathers all variable definitions and their topological ordering. func CollectDependencies(cheat *parser.Cheat, index *parser.CheatIndex) ([]string, map[string][]parser.VarDef) { varDefs := CollectVarDefinitions(cheat, index) - usedVars := FindAllVars(cheat.Command, config.GetVarSyntax()) + usedVars := FindAllVars(cheat.Command, config.Get().VarSyntax) - if config.GetAllowUndeclaredVars() { + if config.Get().AllowUndeclaredVars { for _, name := range usedVars { if _, ok := varDefs[name]; !ok { varDefs[name] = []parser.VarDef{{Name: name}} @@ -38,11 +38,13 @@ func CollectVarDefinitions(cheat *parser.Cheat, index *parser.CheatIndex) map[st continue } seen[importName] = true - if module, ok := index.Modules[importName]; ok { - collectFromImports(module.Imports, seen) - for _, v := range module.Vars { - varDefs[v.Name] = append(varDefs[v.Name], v) - } + module, ok := index.Modules[importName] + if !ok { + continue + } + collectFromImports(module.Imports, seen) + for _, v := range module.Vars { + varDefs[v.Name] = append(varDefs[v.Name], v) } } } @@ -77,15 +79,20 @@ func FindAllDependencies(usedVars []string, varDefs map[string][]parser.VarDef) } allNeeded[varName] = true - for _, def := range varDefs[varName] { - for _, dep := range varDefDependencies(def) { - if !allNeeded[dep] { - queue = append(queue, dep) - } + queue = enqueueDependencies(queue, varDefs[varName], allNeeded) + } + return allNeeded +} + +func enqueueDependencies(queue []string, defs []parser.VarDef, allNeeded map[string]bool) []string { + for _, def := range defs { + for _, dep := range varDefDependencies(def) { + if !allNeeded[dep] { + queue = append(queue, dep) } } } - return allNeeded + return queue } // TopologicalSort orders variables by their dependencies. @@ -100,11 +107,7 @@ func TopologicalSort(usedVars []string, varDefs map[string][]parser.VarDef, allN return } visiting[varName] = true - for _, def := range varDefs[varName] { - for _, dep := range varDefDependencies(def) { - addWithDeps(dep) - } - } + visitDependencies(varDefs[varName], addWithDeps) visiting[varName] = false added[varName] = true orderedVars = append(orderedVars, varName) @@ -116,6 +119,14 @@ func TopologicalSort(usedVars []string, varDefs map[string][]parser.VarDef, allN return orderedVars } +func visitDependencies(defs []parser.VarDef, addWithDeps func(string)) { + for _, def := range defs { + for _, dep := range varDefDependencies(def) { + addWithDeps(dep) + } + } +} + // EvaluateCondition evaluates a condition expression against the scope. func EvaluateCondition(condition string, scope map[string]string) bool { condition = strings.TrimSpace(condition) @@ -179,45 +190,44 @@ func FindAllVars(cmd string, syntax string) []string { for i := 0; i < len(cmd); i++ { switch cmd[i] { case '$': - if !allowDollar { - continue - } - if i+1 >= len(cmd) { - continue - } - if i > 0 && cmd[i-1] == '\\' { - continue - } - j := i + 1 - for j < len(cmd) && parser.IsVarChar(cmd[j], j == i+1) { - j++ - } - if j > i+1 { - add(cmd[i+1 : j]) - } - i = j - 1 + i = scanDollarVar(cmd, i, allowDollar, add) case '<': - if !allowAngle { - continue - } - j := i + 1 - if j >= len(cmd) { - continue - } - if !parser.IsVarChar(cmd[j], true) { - continue - } - j++ - for j < len(cmd) && parser.IsVarChar(cmd[j], false) { - j++ - } - if j >= len(cmd) || cmd[j] != '>' { - continue - } - add(cmd[i+1 : j]) - i = j + i = scanAngleVar(cmd, i, allowAngle, add) } } return vars } + +func scanDollarVar(cmd string, i int, allowDollar bool, add func(string)) int { + if !allowDollar || i+1 >= len(cmd) || (i > 0 && cmd[i-1] == '\\') { + return i + } + j := i + 1 + for j < len(cmd) && parser.IsVarChar(cmd[j], j == i+1) { + j++ + } + if j > i+1 { + add(cmd[i+1 : j]) + } + return j - 1 +} + +func scanAngleVar(cmd string, i int, allowAngle bool, add func(string)) int { + if !allowAngle { + return i + } + j := i + 1 + if j >= len(cmd) || !parser.IsVarChar(cmd[j], true) { + return i + } + j++ + for j < len(cmd) && parser.IsVarChar(cmd[j], false) { + j++ + } + if j >= len(cmd) || cmd[j] != '>' { + return i + } + add(cmd[i+1 : j]) + return j +} diff --git a/pkg/linter/lang_refs.go b/pkg/linter/lang_refs.go index 328fd57..36e2378 100644 --- a/pkg/linter/lang_refs.go +++ b/pkg/linter/lang_refs.go @@ -67,40 +67,44 @@ func referencedVars(c *parser.Cheat) []Ref { if c.CommandStart == 0 { lineNo = 0 } - for col := 0; col < len(line); col++ { - switch line[col] { - case '$': - if lang == "shell" && inSingleQuotedShellText(line, col) { - continue - } - ref, end, ok := scanDollarRef(line, col, kind, lineNo) - if !ok { - continue - } - key := fmt.Sprintf("%d:%s", ref.Kind, ref.Name) - if !seen[key] { - seen[key] = true - refs = append(refs, ref) - } - col = end - 1 - case '<': - if heredocBodyLines[i] { - continue - } - ref, end, ok := scanAngleRef(line, col, lineNo) - if !ok { - continue - } - key := fmt.Sprintf("%d:%s", ref.Kind, ref.Name) - if !seen[key] { - seen[key] = true - refs = append(refs, ref) - } - col = end + scanLineRefs(line, lineNo, kind, lang, heredocBodyLines[i], seen, &refs) + } + return refs +} + +func scanLineRefs(line string, lineNo int, kind RefKind, lang string, suppressAngle bool, seen map[string]bool, refs *[]Ref) { + for col := 0; col < len(line); col++ { + switch line[col] { + case '$': + if lang == "shell" && inSingleQuotedShellText(line, col) { + continue + } + ref, end, ok := scanDollarRef(line, col, kind, lineNo) + if !ok { + continue + } + key := fmt.Sprintf("%d:%s", ref.Kind, ref.Name) + if !seen[key] { + seen[key] = true + *refs = append(*refs, ref) } + col = end - 1 + case '<': + if suppressAngle { + continue + } + ref, end, ok := scanAngleRef(line, col, lineNo) + if !ok { + continue + } + key := fmt.Sprintf("%d:%s", ref.Kind, ref.Name) + if !seen[key] { + seen[key] = true + *refs = append(*refs, ref) + } + col = end } } - return refs } func scanDollarRef(line string, pos int, kind RefKind, lineNo int) (Ref, int, bool) { @@ -197,37 +201,45 @@ var ( func addSyntaxDeclarations(cmd string, declared map[string]bool) { for _, re := range localDeclRegexes { - for _, m := range re.FindAllStringSubmatch(cmd, -1) { - if len(m) < 2 { - continue - } - fullMatchLower := strings.ToLower(m[0]) - if strings.HasPrefix(fullMatchLower, "param") || strings.HasPrefix(fullMatchLower, "function") { - for _, pm := range psVarInParamRe.FindAllStringSubmatch(m[1], -1) { - declared[pm[1]] = true - declared[strings.ToLower(pm[1])] = true - } - continue - } + findRegexDeclarations(re, cmd, declared) + } +} - if strings.HasPrefix(fullMatchLower, "read") || strings.HasPrefix(fullMatchLower, "local") || strings.HasPrefix(fullMatchLower, "declare") || strings.HasPrefix(fullMatchLower, "typeset") || strings.HasPrefix(fullMatchLower, "export") || strings.HasPrefix(fullMatchLower, "readonly") { - for _, field := range strings.Fields(m[1]) { - field = strings.TrimLeft(field, "-") - if field == "" || strings.Contains(field, "=") { - field = strings.SplitN(field, "=", 2)[0] - } - if isIdentifier(field) { - declared[field] = true - declared[strings.ToLower(field)] = true - } - } - continue - } +func findRegexDeclarations(re *regexp.Regexp, cmd string, declared map[string]bool) { + for _, m := range re.FindAllStringSubmatch(cmd, -1) { + processDeclMatch(m, declared) + } +} - declared[m[1]] = true - declared[strings.ToLower(m[1])] = true +func processDeclMatch(m []string, declared map[string]bool) { + if len(m) < 2 { + return + } + fullMatchLower := strings.ToLower(m[0]) + if strings.HasPrefix(fullMatchLower, "param") || strings.HasPrefix(fullMatchLower, "function") { + for _, pm := range psVarInParamRe.FindAllStringSubmatch(m[1], -1) { + declared[pm[1]] = true + declared[strings.ToLower(pm[1])] = true } + return } + + if strings.HasPrefix(fullMatchLower, "read") || strings.HasPrefix(fullMatchLower, "local") || strings.HasPrefix(fullMatchLower, "declare") || strings.HasPrefix(fullMatchLower, "typeset") || strings.HasPrefix(fullMatchLower, "export") || strings.HasPrefix(fullMatchLower, "readonly") { + for _, field := range strings.Fields(m[1]) { + field = strings.TrimLeft(field, "-") + if field == "" || strings.Contains(field, "=") { + field = strings.SplitN(field, "=", 2)[0] + } + if isIdentifier(field) { + declared[field] = true + declared[strings.ToLower(field)] = true + } + } + return + } + + declared[m[1]] = true + declared[strings.ToLower(m[1])] = true } func isLikelyPowerShellCommand(cmd string) bool { diff --git a/pkg/linter/linter.go b/pkg/linter/linter.go index 838bd7f..24e971e 100644 --- a/pkg/linter/linter.go +++ b/pkg/linter/linter.go @@ -130,18 +130,45 @@ func Lint(path string) ([]Finding, error) { func lintIndex(index *parser.CheatIndex, isDir bool) []Finding { var findings []Finding - // Collect duplicate exports + findings = append(findings, lintDuplicateExports(index)...) + findings = append(findings, lintDuplicateHeaders(index)...) + findings = append(findings, lintChainSteps(index)...) + findings = append(findings, lintImports(index)...) + findings = append(findings, lintUndeclaredVars(index)...) + + return findings +} + +func errorf(file string, line, col int, format string, args ...any) Finding { + return Finding{ + File: file, + Line: line, + Column: col, + Severity: SeverityError, + Message: fmt.Sprintf(format, args...), + } +} + +func warningf(file string, line, col int, format string, args ...any) Finding { + return Finding{ + File: file, + Line: line, + Column: col, + Severity: SeverityWarning, + Message: fmt.Sprintf(format, args...), + } +} + +func lintDuplicateExports(index *parser.CheatIndex) []Finding { + var findings []Finding for _, dup := range index.Duplicates { - findings = append(findings, Finding{ - File: dup.File2, - Line: 1, - Column: 1, - Severity: SeverityError, - Message: fmt.Sprintf("duplicate export %q (also defined in %s)", dup.Name, filepath.Base(dup.File1)), - }) + findings = append(findings, errorf(dup.File2, 1, 1, "duplicate export %q (also defined in %s)", dup.Name, filepath.Base(dup.File1))) } + return findings +} - // Check duplicate cheat headers globally +func lintDuplicateHeaders(index *parser.CheatIndex) []Finding { + var findings []Finding type cheatLoc struct { file string line int @@ -152,113 +179,119 @@ func lintIndex(index *parser.CheatIndex, isDir bool) []Finding { if c.Header == "" { continue } - if firstLoc, exists := headerLocs[c.Header]; exists { - if !warnedHeaders[c.Header] { - msg := fmt.Sprintf("duplicate cheat name %q (also `# %s` at line %d)", c.Header, c.Header, firstLoc.line) - if firstLoc.file != c.File { - msg = fmt.Sprintf("duplicate cheat name %q (also `# %s` at %s:%d)", c.Header, c.Header, firstLoc.file, firstLoc.line) - } - findings = append(findings, Finding{ - File: c.File, - Line: c.HeaderLine, - Column: 1, - Severity: SeverityWarning, - Message: msg, - }) - warnedHeaders[c.Header] = true - } - } else { + + firstLoc, exists := headerLocs[c.Header] + if !exists { headerLocs[c.Header] = cheatLoc{file: c.File, line: c.HeaderLine} + continue } + + if warnedHeaders[c.Header] { + continue + } + + msg := fmt.Sprintf("duplicate cheat name %q (also `# %s` at line %d)", c.Header, c.Header, firstLoc.line) + if firstLoc.file != c.File { + msg = fmt.Sprintf("duplicate cheat name %q (also `# %s` at %s:%d)", c.Header, c.Header, firstLoc.file, firstLoc.line) + } + findings = append(findings, warningf(c.File, c.HeaderLine, 1, "%s", msg)) + warnedHeaders[c.Header] = true } + return findings +} + +func lintChainSteps(index *parser.CheatIndex) []Finding { + var findings []Finding seenChainSteps := make(map[string]*parser.Cheat) chainSteps := make(map[string]map[int]*parser.Cheat) for _, c := range index.Cheats { - if c.ChainName != "" { - if chainSteps[c.ChainName] == nil { - chainSteps[c.ChainName] = make(map[int]*parser.Cheat) - } - chainSteps[c.ChainName][c.ChainStep] = c - key := fmt.Sprintf("%s:%d", c.ChainName, c.ChainStep) - if existing := seenChainSteps[key]; existing != nil { - line, col := findDSLRef(c.File, "chain", c.ChainName) - findings = append(findings, Finding{ - File: c.File, - Line: line, - Column: col, - Severity: SeverityError, - Message: fmt.Sprintf("duplicate chain step %q %d (also in %s)", c.ChainName, c.ChainStep, existing.File), - }) - } else { - seenChainSteps[key] = c - } + if c.ChainName == "" { + continue } - - // Missing imports. - for _, imp := range c.Imports { - if _, ok := index.Modules[imp]; !ok { - line, col := findDSLRef(c.File, "import", imp) - findings = append(findings, Finding{ - File: c.File, - Line: line, - Column: col, - Severity: SeverityError, - Message: fmt.Sprintf("import %q does not resolve to any exported module", imp), - }) - } + if chainSteps[c.ChainName] == nil { + chainSteps[c.ChainName] = make(map[int]*parser.Cheat) + } + chainSteps[c.ChainName][c.ChainStep] = c + key := fmt.Sprintf("%s:%d", c.ChainName, c.ChainStep) + if existing := seenChainSteps[key]; existing != nil { + line, col := findDSLRef(c.File, "chain", c.ChainName) + findings = append(findings, errorf(c.File, line, col, "duplicate chain step %q %d (also in %s)", c.ChainName, c.ChainStep, existing.File)) + } else { + seenChainSteps[key] = c } + } - // Undeclared $var / references in the command. Plain code - // fences without a cheat block are allowed to contain ordinary shell - // variables; only lint cheats that opted into metadata. - if c.Export != "" || !c.HasCheatBlock { + for name, steps := range chainSteps { + findings = append(findings, checkMissingChainSteps(name, steps)...) + } + return findings +} + +func checkMissingChainSteps(name string, steps map[int]*parser.Cheat) []Finding { + var findings []Finding + maxStep := 0 + var first *parser.Cheat + for step, cheat := range steps { + if first == nil || step < first.ChainStep { + first = cheat + } + if step > maxStep { + maxStep = step + } + } + for step := 1; step <= maxStep; step++ { + if steps[step] != nil { continue } - if len(c.Command) > 0 { - declared := declaredVarNames(c, index) - addSyntaxDeclarations(c.Command, declared) - for _, ref := range referencedVars(c) { - if isMissing(ref, declared, c.Command) { - findings = append(findings, Finding{ - File: c.File, - Line: ref.Line, - Column: ref.Column, - Severity: SeverityWarning, - Message: fmt.Sprintf("undeclared variable %q referenced in command", ref.Name), - }) - } - } + line, col := 0, 0 + file := "" + if first != nil { + file = first.File + line, col = findDSLRef(first.File, "chain", name) } + findings = append(findings, warningf(file, line, col, "chain %q is missing step %d", name, step)) } - for name, steps := range chainSteps { - maxStep := 0 - var first *parser.Cheat - for step, cheat := range steps { - if first == nil || step < first.ChainStep { - first = cheat - } - if step > maxStep { - maxStep = step - } + return findings +} + +func lintImports(index *parser.CheatIndex) []Finding { + var findings []Finding + for _, c := range index.Cheats { + findings = append(findings, lintCheatImports(c, index)...) + } + return findings +} + +func lintCheatImports(c *parser.Cheat, index *parser.CheatIndex) []Finding { + var findings []Finding + for _, imp := range c.Imports { + if _, ok := index.Modules[imp]; !ok { + line, col := findDSLRef(c.File, "import", imp) + findings = append(findings, errorf(c.File, line, col, "import %q does not resolve to any exported module", imp)) + } + } + return findings +} + +func lintUndeclaredVars(index *parser.CheatIndex) []Finding { + var findings []Finding + for _, c := range index.Cheats { + if c.Export != "" || !c.HasCheatBlock || len(c.Command) == 0 { + continue } - for step := 1; step <= maxStep; step++ { - if steps[step] != nil { - continue - } - line, col := 0, 0 - file := "" - if first != nil { - file = first.File - line, col = findDSLRef(first.File, "chain", name) - } - findings = append(findings, Finding{ - File: file, - Line: line, - Column: col, - Severity: SeverityWarning, - Message: fmt.Sprintf("chain %q is missing step %d", name, step), - }) + findings = append(findings, lintCheatUndeclaredVars(c, index)...) + } + return findings +} + +func lintCheatUndeclaredVars(c *parser.Cheat, index *parser.CheatIndex) []Finding { + var findings []Finding + declared := declaredVarNames(c, index) + addSyntaxDeclarations(c.Command, declared) + for _, ref := range referencedVars(c) { + if isMissing(ref, declared, c.Command) { + findings = append(findings, warningf(c.File, ref.Line, ref.Column, "undeclared variable %q referenced in command", ref.Name)) } } return findings diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go index 6afda9e..c7e1166 100644 --- a/pkg/parser/parser.go +++ b/pkg/parser/parser.go @@ -103,16 +103,7 @@ func parseFilesParallel(files []string) []parseResult { localParser.index = NewCheatIndex() localParser.parseLines(path, data) localCheats = append(localCheats, localParser.index.Cheats...) - for name, mod := range localParser.index.Modules { - if existing, ok := localModules[name]; ok { - localDuplicates = append(localDuplicates, DuplicateExport{ - Name: name, - File1: existing.File, - File2: mod.File, - }) - } - localModules[name] = mod - } + localModules = mergeModules(localModules, &localDuplicates, localParser.index.Modules) } } resultChan <- parseResult{cheats: localCheats, modules: localModules, duplicates: localDuplicates, errors: localParser.index.Errors} @@ -139,19 +130,7 @@ func (p *Parser) mergeResults(results []parseResult) { // Carry forward any duplicates detected within a single worker p.index.Duplicates = append(p.index.Duplicates, r.duplicates...) p.index.Errors = append(p.index.Errors, r.errors...) - for name, mod := range r.modules { - if p.index.Modules == nil { - p.index.Modules = make(map[string]*Module) - } - if existing, ok := p.index.Modules[name]; ok { - p.index.Duplicates = append(p.index.Duplicates, DuplicateExport{ - Name: name, - File1: existing.File, - File2: mod.File, - }) - } - p.index.Modules[name] = mod - } + p.index.Modules = mergeModules(p.index.Modules, &p.index.Duplicates, r.modules) } p.index.Cheats = totalCheats for _, c := range totalCheats { @@ -164,6 +143,23 @@ func (p *Parser) mergeResults(results []parseResult) { } } +func mergeModules(target map[string]*Module, duplicates *[]DuplicateExport, source map[string]*Module) map[string]*Module { + if target == nil { + target = make(map[string]*Module) + } + for name, mod := range source { + if existing, ok := target[name]; ok { + *duplicates = append(*duplicates, DuplicateExport{ + Name: name, + File1: existing.File, + File2: mod.File, + }) + } + target[name] = mod + } + return target +} + // ParseSingleFile parses a single markdown file func (p *Parser) ParseSingleFile(path string) (*CheatIndex, error) { p.index.Root = path @@ -305,96 +301,114 @@ func countNewlines(b []byte) int { return bytes.Count(b, []byte{'\n'}) } -// parseLine processes a single line (as bytes, no allocation) func (p *Parser) parseLine(path string, line []byte, s *parseState) { - // Fast path: inside code block - just accumulate if s.inCodeBlock { - if len(line) == 3 && line[0] == '`' && line[1] == '`' && line[2] == '`' { - s.inCodeBlock = false - content := trimSpaceBytes(s.codeBlockBuf) - if len(content) > 0 { - s.pendingCodeBlocks = append(s.pendingCodeBlocks, codeBlock{ - lang: s.codeBlockLang, - content: string(content), - description: s.codeBlockDesc, - startLine: s.codeBlockStart, - endLine: s.lineNo - 1, - }) - } - return - } - s.codeBlockBuf = append(s.codeBlockBuf, line...) - s.codeBlockBuf = append(s.codeBlockBuf, '\n') + p.parseLineInCodeBlock(line, s) return } - // Fast path: inside cheat block - just accumulate if s.inCheatBlock { - // Fast check: cheat end is "-->" possibly with whitespace - if len(line) >= 2 && line[0] == '-' && line[1] == '-' { - if isCheatEnd(line) { - s.inCheatBlock = false - p.processCheatBlock(path, s) - return - } - } - s.cheatBlockBuf = append(s.cheatBlockBuf, line...) - s.cheatBlockBuf = append(s.cheatBlockBuf, '\n') + p.parseLineInCheatBlock(path, line, s) return } - // Quick character checks before expensive operations if len(line) == 0 { return } first := line[0] - // Header - starts with # - if first == '#' { - if header, ok := parseHeader(line); ok { - p.processPendingBlocks(path, s) - s.reset(header, s.lineNo) - if header == "" { - p.index.Errors = append(p.index.Errors, ParseError{ - File: path, - Line: s.lineNo, - Message: "empty markdown header", - }) - } + if first == '#' && p.tryParseHeader(path, line, s) { + return + } + + if first == '`' && p.tryParseCodeBlockStart(line, s) { + return + } + + if first == '<' && p.tryParseCheatComment(path, line, s) { + return + } + + p.parseProseLine(line, s) +} + +func (p *Parser) parseLineInCodeBlock(line []byte, s *parseState) { + if len(line) == 3 && line[0] == '`' && line[1] == '`' && line[2] == '`' { + s.inCodeBlock = false + content := trimSpaceBytes(s.codeBlockBuf) + if len(content) > 0 { + s.pendingCodeBlocks = append(s.pendingCodeBlocks, codeBlock{ + lang: s.codeBlockLang, + content: string(content), + description: s.codeBlockDesc, + startLine: s.codeBlockStart, + endLine: s.lineNo - 1, + }) + } + return + } + s.codeBlockBuf = append(s.codeBlockBuf, line...) + s.codeBlockBuf = append(s.codeBlockBuf, '\n') +} + +func (p *Parser) parseLineInCheatBlock(path string, line []byte, s *parseState) { + if len(line) >= 2 && line[0] == '-' && line[1] == '-' { + if isCheatEnd(line) { + s.inCheatBlock = false + p.processCheatBlock(path, s) return } } + s.cheatBlockBuf = append(s.cheatBlockBuf, line...) + s.cheatBlockBuf = append(s.cheatBlockBuf, '\n') +} + +func (p *Parser) tryParseHeader(path string, line []byte, s *parseState) bool { + if header, ok := parseHeader(line); ok { + p.processPendingBlocks(path, s) + s.reset(header, s.lineNo) + if header == "" { + p.index.Errors = append(p.index.Errors, ParseError{ + File: path, + Line: s.lineNo, + Message: "empty markdown header", + }) + } + return true + } + return false +} - // Code block start - starts with ``` - if first == '`' && len(line) >= 3 && line[1] == '`' && line[2] == '`' { +func (p *Parser) tryParseCodeBlockStart(line []byte, s *parseState) bool { + if len(line) >= 3 && line[1] == '`' && line[2] == '`' { if lang, desc, ok := parseCodeBlockStart(line); ok { s.inCodeBlock = true s.codeBlockLang = lang s.codeBlockStart = s.lineNo + 1 s.codeBlockDesc = desc s.codeBlockBuf = s.codeBlockBuf[:0] - return + return true } } + return false +} - // Cheat comments - starts with < - if first == '<' { - // Single-line cheat comment: - if content, ok := parseCheatSingleLine(line); ok { - p.processCheatComment(path, s, content) - return - } - // Multi-line cheat block start: `, - wantErrors: []string{"variable \"a\" referenced"}, + wantErrors: []string{"variable \"a\" referenced"}, avoidErrors: []string{"variable \"i\" referenced"}, }, { @@ -238,7 +238,7 @@ $obj = ConvertFrom-Json $input_data `, - wantErrors: []string{"variable \"input_data\" referenced"}, + wantErrors: []string{"variable \"input_data\" referenced"}, avoidErrors: []string{"variable \"obj\" referenced"}, }, {