From ba4163968a2815a4c58c1001b2515014320c6c30 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Mon, 11 May 2026 22:04:26 -0600 Subject: [PATCH 1/2] refactor converted monolith files (parser, main_model) into smaller files. changed parser to byte level scanning. lazy init map to reduce memory pressure. added tests. upgraded go to 1.26.3. --- .github/workflows/release.yml | 2 +- cmd/cheatmd/main.go | 1 + go.mod | 2 +- internal/config/config.go | 30 +- internal/executor/executor_test.go | 92 + internal/parser/dsl.go | 146 ++ internal/parser/lex.go | 212 ++ internal/parser/parser.go | 938 +------- internal/parser/parser_test.go | 265 +++ internal/parser/tags.go | 413 ++++ internal/parser/types.go | 144 ++ internal/ui/cheat_select.go | 463 ++++ internal/ui/helpers.go | 107 + internal/ui/infer_test.go | 35 +- internal/ui/main_model.go | 1967 +---------------- internal/ui/match.go | 282 +++ internal/ui/match_test.go | 124 ++ internal/ui/resolve.go | 24 +- internal/ui/run.go | 156 ++ internal/ui/selector_test.go | 301 +++ internal/ui/substitute_search.go | 288 +++ .../{substitute.go => substitute_sources.go} | 0 internal/ui/var_models.go | 496 ----- internal/ui/var_resolve.go | 569 +++++ 24 files changed, 3639 insertions(+), 3418 deletions(-) create mode 100644 internal/executor/executor_test.go create mode 100644 internal/parser/dsl.go create mode 100644 internal/parser/lex.go create mode 100644 internal/parser/parser_test.go create mode 100644 internal/parser/tags.go create mode 100644 internal/parser/types.go create mode 100644 internal/ui/cheat_select.go create mode 100644 internal/ui/helpers.go create mode 100644 internal/ui/match.go create mode 100644 internal/ui/match_test.go create mode 100644 internal/ui/run.go create mode 100644 internal/ui/selector_test.go create mode 100644 internal/ui/substitute_search.go rename internal/ui/{substitute.go => substitute_sources.go} (100%) delete mode 100644 internal/ui/var_models.go create mode 100644 internal/ui/var_resolve.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56dcdea..eb319ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.3' - name: Build env: diff --git a/cmd/cheatmd/main.go b/cmd/cheatmd/main.go index dbe8aa9..2884850 100644 --- a/cmd/cheatmd/main.go +++ b/cmd/cheatmd/main.go @@ -232,6 +232,7 @@ func runCheats(cmd *cobra.Command, args []string) error { // Parse markdown files benchmark, _ := cmd.Flags().GetBool("benchmark") + start := time.Now() p := parser.NewParser() diff --git a/go.mod b/go.mod index d1a9e51..5c345ff 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/gubarz/cheatmd -go 1.25.1 +go 1.26.3 require ( github.com/charmbracelet/bubbles v0.21.0 diff --git a/internal/config/config.go b/internal/config/config.go index 2296d8d..aea88aa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -372,19 +372,7 @@ func GetColorDim() string { return viper.GetString("color_dim") } -// GetColors returns all color settings as a ColorConfig -func GetColors() ColorConfig { - return ColorConfig{ - Header: GetColorHeader(), - Command: GetColorCommand(), - Desc: GetColorDesc(), - Path: GetColorPath(), - Border: GetColorBorder(), - Cursor: GetColorCursor(), - Selected: GetColorSelected(), - Dim: GetColorDim(), - } -} + // ============================================================================ // Getters - Columns @@ -410,15 +398,7 @@ func GetColumnCommand() int { return viper.GetInt("column_command") } -// GetColumns returns all column settings as a ColumnConfig -func GetColumns() ColumnConfig { - return ColumnConfig{ - Gap: GetColumnGap(), - Header: GetColumnHeader(), - Desc: GetColumnDesc(), - Command: GetColumnCommand(), - } -} + // ============================================================================ // Setters @@ -430,11 +410,7 @@ func SetOutput(mode string) { cfg.Output = mode } -// SetPath sets the cheat path at runtime -func SetPath(path string) { - viper.Set("path", path) - cfg.Path = path -} + // SetAutoSelect sets auto-select mode at runtime func SetAutoSelect(enabled bool) { diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go new file mode 100644 index 0000000..aa5a48e --- /dev/null +++ b/internal/executor/executor_test.go @@ -0,0 +1,92 @@ +package executor + +import ( + "testing" + + "github.com/gubarz/cheatmd/internal/parser" +) + +// mockClipboard implements Clipboard interface for testing +type mockClipboard struct { + lastCopied string +} + +func (m *mockClipboard) Copy(text string) error { + m.lastCopied = text + return nil +} + +func TestOutputWithMode_Copy(t *testing.T) { + mockClip := &mockClipboard{} + exec := NewExecutor(parser.NewCheatIndex()).WithClipboard(mockClip) + + testText := "echo hello" + err := exec.OutputWithMode(testText, OutputCopy) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if mockClip.lastCopied != testText { + t.Errorf("expected clipboard to have %q, got %q", testText, mockClip.lastCopied) + } +} + +func TestSubstituteVars(t *testing.T) { + tests := []struct { + name string + input string + scope map[string]string + expected string + }{ + { + name: "simple substitution", + input: "echo $var", + scope: map[string]string{"var": "hello"}, + expected: "echo hello", + }, + { + name: "multiple substitutions", + input: "curl -u $user:$pass $url", + scope: map[string]string{"user": "admin", "pass": "secret", "url": "http://localhost"}, + expected: "curl -u admin:secret http://localhost", + }, + { + name: "prefix collision prevention", + input: "echo $username and $user", + scope: map[string]string{"user": "bob", "username": "alice"}, + expected: "echo alice and bob", // longest match first prevents $user replacing start of $username + }, + { + name: "missing var is left as is", + input: "echo $missing", + scope: map[string]string{"other": "val"}, + expected: "echo $missing", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SubstituteVars(tt.input, tt.scope) + if got != tt.expected { + t.Errorf("SubstituteVars() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestBuildFinalCommand(t *testing.T) { + cheat := &parser.Cheat{ + Command: "echo $greeting \\$HOME", + Scope: map[string]string{ + "greeting": "hello", + }, + } + + exec := NewExecutor(parser.NewCheatIndex()) + got := exec.BuildFinalCommand(cheat) + want := "echo hello $HOME" + + if got != want { + t.Errorf("BuildFinalCommand() = %q, want %q", got, want) + } +} diff --git a/internal/parser/dsl.go b/internal/parser/dsl.go new file mode 100644 index 0000000..be4adb8 --- /dev/null +++ b/internal/parser/dsl.go @@ -0,0 +1,146 @@ +package parser + +import "strings" + +// parseCheatDSL parses the DSL content within a cheat block. +// +// Hand-rolled dispatch on the first keyword (var / if / fi / export / import) +// avoids per-line regex matching. Each non-comment, non-blank line is matched +// against at most one branch. +func parseCheatDSL(cheat *Cheat, content string) { + lines := joinContinuationLines(strings.Split(content, "\n")) + + var currentCondition string + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || line[0] == '#' { + continue + } + + keyword, rest := splitFirstWord(line) + switch keyword { + case "fi": + if rest == "" { + currentCondition = "" + } + case "if": + if rest != "" { + currentCondition = rest + } + case "export": + if rest != "" && !containsWhitespace(rest) { + cheat.Export = rest + } + case "import": + if rest != "" && !containsWhitespace(rest) { + cheat.Imports = append(cheat.Imports, rest) + } + case "var": + parseVarLine(cheat, rest, currentCondition) + } + } +} + +// parseVarLine handles the three var declaration forms: +// +// var NAME -> prompt-only +// var NAME := value -> literal +// var NAME = value -> shell +func parseVarLine(cheat *Cheat, rest, condition string) { + name, after := splitFirstWord(rest) + if name == "" || !isValidDSLVarName(name) { + return + } + + if after == "" { + cheat.Vars = append(cheat.Vars, VarDef{ + Name: name, + Condition: condition, + }) + return + } + + switch { + case strings.HasPrefix(after, ":="): + value := strings.TrimSpace(after[2:]) + if value == "" { + return + } + cheat.Vars = append(cheat.Vars, ParseVarDefWithCondition(name, value, condition, true)) + case after[0] == '=': + value := strings.TrimSpace(after[1:]) + if value == "" { + return + } + cheat.Vars = append(cheat.Vars, ParseVarDefWithCondition(name, value, condition, false)) + } +} + +// splitFirstWord returns the leading whitespace-delimited token and the +// remainder with leading whitespace trimmed. If the input has no token, the +// keyword is "". +func splitFirstWord(s string) (keyword, rest string) { + i := 0 + for i < len(s) && s[i] != ' ' && s[i] != '\t' { + i++ + } + if i == 0 { + return "", "" + } + keyword = s[:i] + for i < len(s) && (s[i] == ' ' || s[i] == '\t') { + i++ + } + rest = s[i:] + return +} + +// isValidDSLVarName reports whether s is a valid var name in the DSL: +// letters, digits, and underscores (matching the `\w+` regex that the +// previous regex-based implementation used). +func isValidDSLVarName(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { + return false + } + } + return true +} + +// containsWhitespace reports whether s has any space or tab. +func containsWhitespace(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] == ' ' || s[i] == '\t' { + return true + } + } + return false +} + +// joinContinuationLines joins lines that end with backslash. +func joinContinuationLines(lines []string) []string { + var result []string + var current strings.Builder + + for _, line := range lines { + trimmed := strings.TrimRight(line, " \t") + if strings.HasSuffix(trimmed, "\\") { + current.WriteString(strings.TrimSuffix(trimmed, "\\")) + } else { + current.WriteString(line) + result = append(result, current.String()) + current.Reset() + } + } + + if current.Len() > 0 { + result = append(result, current.String()) + } + + return result +} diff --git a/internal/parser/lex.go b/internal/parser/lex.go new file mode 100644 index 0000000..6a74012 --- /dev/null +++ b/internal/parser/lex.go @@ -0,0 +1,212 @@ +package parser + +import ( + "bytes" + "strings" + "sync" +) + +// ============================================================================ +// String Interner +// ============================================================================ + +// stringInterner provides string deduplication (interning). +type stringInterner struct { + mu sync.RWMutex + strings map[string]string +} + +func newStringInterner() *stringInterner { + return &stringInterner{ + strings: make(map[string]string, 1024), + } +} + +// Intern returns a canonical version of the string. +// If the string was seen before, returns the previously stored instance. +func (si *stringInterner) Intern(s string) string { + if s == "" { + return "" + } + si.mu.RLock() + if interned, ok := si.strings[s]; ok { + si.mu.RUnlock() + return interned + } + si.mu.RUnlock() + + si.mu.Lock() + if interned, ok := si.strings[s]; ok { + si.mu.Unlock() + return interned + } + si.strings[s] = s + si.mu.Unlock() + return s +} + +// InternBytes returns a canonical version of the string from a byte slice. +// Uses unsafe string conversion for map lookup to avoid allocation in Go 1.22+. +func (si *stringInterner) InternBytes(b []byte) string { + if len(b) == 0 { + return "" + } + si.mu.RLock() + if interned, ok := si.strings[string(b)]; ok { + si.mu.RUnlock() + return interned + } + si.mu.RUnlock() + + si.mu.Lock() + s := string(b) + if interned, ok := si.strings[s]; ok { + si.mu.Unlock() + return interned + } + si.strings[s] = s + si.mu.Unlock() + return s +} + +// pathInterner is the global interner for file paths and common strings. +var pathInterner = newStringInterner() + +// ============================================================================ +// Language Classification +// ============================================================================ + +// IsShellLanguage reports whether the code-fence language is a shell language +// (one where $var injection is safe). +func IsShellLanguage(lang string) bool { + lang = strings.ToLower(lang) + if lang == "mermaid" || lang == "dot" || lang == "chart" { + return false + } + return true +} + +// ============================================================================ +// Byte-Level Line Lexing +// ============================================================================ + +// parseHeader extracts header text without regex: "## Header" -> "Header". +func parseHeader(line []byte) (string, bool) { + i := 0 + for i < len(line) && line[i] == '#' { + i++ + } + if i == 0 || i > 6 { + return "", false + } + if i >= len(line) || line[i] != ' ' { + return "", false + } + i++ + if i >= len(line) { + return "", false + } + return string(line[i:]), true +} + +// parseCodeBlockStart parses ```lang title:"desc" without regex. +func parseCodeBlockStart(line []byte) (lang, desc string, ok bool) { + if len(line) < 3 || line[0] != '`' || line[1] != '`' || line[2] != '`' { + return "", "", false + } + rest := line[3:] + + // Extract language + langEnd := 0 + for langEnd < len(rest) && isWordChar(rest[langEnd]) { + langEnd++ + } + lang = string(rest[:langEnd]) + + // Check for title + titleIdx := bytes.Index(rest[langEnd:], []byte("title:\"")) + if titleIdx == -1 { + return lang, "", true + } + + // Extract title + start := langEnd + titleIdx + 7 // length of `title:"` + end := bytes.IndexByte(rest[start:], '"') + if end == -1 { + return lang, "", true + } + + return lang, string(rest[start : start+end]), true +} + +// parseCheatSingleLine parses and returns the content. +func parseCheatSingleLine(line []byte) (string, bool) { + if len(line) < 15 { + return "", false + } + if !bytes.HasPrefix(line, []byte("")) { + return "", false + } + + inner := bytes.TrimSpace(line[4 : len(line)-3]) + if len(inner) < 5 { + return "", false + } + if !bytes.EqualFold(inner[:5], []byte("cheat")) { + return "", false + } + + return string(bytes.TrimSpace(inner[5:])), true +} + +// isCheatStart checks for "" (multiline cheat block end). +func isCheatEnd(line []byte) bool { + trimmed := bytes.TrimSpace(line) + return bytes.Equal(trimmed, []byte("-->")) +} + +// isWordChar returns true for [a-zA-Z0-9_]. +func isWordChar(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' +} + +// trimSpaceBytes trims leading/trailing whitespace from bytes without allocating. +func trimSpaceBytes(b []byte) []byte { + start := 0 + for start < len(b) && (b[start] == ' ' || b[start] == '\t' || b[start] == '\n' || b[start] == '\r') { + start++ + } + end := len(b) + for end > start && (b[end-1] == ' ' || b[end-1] == '\t' || b[end-1] == '\n' || b[end-1] == '\r') { + end-- + } + return b[start:end] +} + +// ============================================================================ +// File-System Helpers +// ============================================================================ + +// isMarkdownFile reports whether path has a markdown extension. +func isMarkdownFile(path string) bool { + if len(path) < 3 { + return false + } + ext := path[len(path)-3:] + return ext == ".md" || ext == ".MD" || strings.EqualFold(path[len(path)-3:], ".md") +} diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 297c51c..8815894 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -4,222 +4,12 @@ import ( "bytes" "os" "path/filepath" - "regexp" "runtime" "strings" "sync" ) -// ============================================================================ -// String Interner - reduces memory by deduplicating strings -// ============================================================================ - -// stringInterner provides string deduplication (interning) -type stringInterner struct { - mu sync.RWMutex - strings map[string]string -} - -func newStringInterner() *stringInterner { - return &stringInterner{ - strings: make(map[string]string, 1024), - } -} - -// Intern returns a canonical version of the string -// If the string was seen before, returns the previously stored instance -func (si *stringInterner) Intern(s string) string { - if s == "" { - return "" - } - si.mu.RLock() - if interned, ok := si.strings[s]; ok { - si.mu.RUnlock() - return interned - } - si.mu.RUnlock() - - si.mu.Lock() - // Double-check after acquiring write lock - if interned, ok := si.strings[s]; ok { - si.mu.Unlock() - return interned - } - si.strings[s] = s - si.mu.Unlock() - return s -} - -// Global interner for file paths and common strings -var pathInterner = newStringInterner() - -// ============================================================================ -// Domain Types -// ============================================================================ - -// Cheat represents a single executable cheat entry -type Cheat struct { - File string // Source file path - Header string // Section header - Description string // Description text - Command string // Shell command template - Tags []string // Tags from path/header - Export string // Module name if exported - Imports []string // Imported modules - Vars []VarDef // Variable definitions - Scope map[string]string // Resolved values at runtime - HasCheatBlock bool // Whether this cheat has a block -} - -// NewCheat creates a new Cheat -func NewCheat(file, header string) *Cheat { - return &Cheat{ - File: pathInterner.Intern(file), - Header: header, - // Scope allocated lazily at runtime when needed - } -} - -// VarDef represents a variable definition -type VarDef struct { - Name string // Variable name - Shell string // Shell command to generate values (for = syntax) - Literal string // Literal value with var substitution (for := syntax) - Args string // Selector arguments after --- - Condition string // Conditional expression: "$var == value" or "$var != value" -} - -// ParseVarDef parses a variable definition from name and value (shell command) -func ParseVarDef(name, value string) VarDef { - v := VarDef{Name: name} - if idx := strings.Index(value, "---"); idx != -1 { - v.Shell = strings.TrimSpace(value[:idx]) - v.Args = strings.TrimSpace(value[idx+3:]) - } else { - v.Shell = strings.TrimSpace(value) - } - return v -} - -// ParseVarDefLiteral parses a literal variable definition (no shell, just substitution) -func ParseVarDefLiteral(name, value string) VarDef { - v := VarDef{Name: name} - if idx := strings.Index(value, "---"); idx != -1 { - v.Literal = strings.TrimSpace(value[:idx]) - v.Args = strings.TrimSpace(value[idx+3:]) - } else { - v.Literal = strings.TrimSpace(value) - } - return v -} - -// ParseVarDefWithCondition parses a variable definition with an optional condition -func ParseVarDefWithCondition(name, value, condition string, isLiteral bool) VarDef { - var v VarDef - if isLiteral { - v = ParseVarDefLiteral(name, value) - } else { - v = ParseVarDef(name, value) - } - v.Condition = condition - return v -} -// Module represents an exportable module with variables -type Module struct { - Name string - Vars []VarDef - Imports []string - File string - Cheats []*Cheat -} - -// NewModule creates a Module from a Cheat -func NewModule(cheat *Cheat) *Module { - return &Module{ - Name: cheat.Export, - Vars: cheat.Vars, - Imports: cheat.Imports, - File: cheat.File, - Cheats: []*Cheat{cheat}, - } -} - -// ============================================================================ -// Cheat Index -// ============================================================================ - -// DuplicateExport records a duplicate export definition -type DuplicateExport struct { - Name string - File1 string - File2 string -} - -// CheatIndex holds all parsed cheats and modules -type CheatIndex struct { - Cheats []*Cheat - Modules map[string]*Module - Duplicates []DuplicateExport -} - -// NewCheatIndex creates an empty cheat index -func NewCheatIndex() *CheatIndex { - return &CheatIndex{ - Cheats: make([]*Cheat, 0), - Modules: make(map[string]*Module), - } -} - -// AddCheat adds a cheat to the index -func (idx *CheatIndex) AddCheat(cheat *Cheat) { - idx.Cheats = append(idx.Cheats, cheat) -} - -// RegisterModule registers a module from a cheat with an export -// Tracks duplicates if the same export name is used multiple times -func (idx *CheatIndex) RegisterModule(cheat *Cheat) { - if cheat.Export == "" { - return - } - if existing, ok := idx.Modules[cheat.Export]; ok { - idx.Duplicates = append(idx.Duplicates, DuplicateExport{ - Name: cheat.Export, - File1: existing.File, - File2: cheat.File, - }) - } - idx.Modules[cheat.Export] = NewModule(cheat) -} - -// ============================================================================ -// Regex Patterns -// ============================================================================ - -var patterns = struct { - header *regexp.Regexp - codeBlockStart *regexp.Regexp - cheatStart *regexp.Regexp - cheatEnd *regexp.Regexp - cheatSingleLine *regexp.Regexp -}{ - header: regexp.MustCompile(`^(#{1,6})\s+(.+)$`), - codeBlockStart: regexp.MustCompile("^```(\\w*)(?:\\s+title:\"([^\"]*)\")?\\s*$"), - cheatStart: regexp.MustCompile(`(?i)^\s*$`), - cheatSingleLine: regexp.MustCompile(`(?i)^$`), -} - -// IsShellLanguage returns true if the language is a shell language -func IsShellLanguage(lang string) bool { - lang = strings.ToLower(lang) - // Exclude diagrams or data formats that would choke on $variable injection - if lang == "mermaid" || lang == "dot" || lang == "chart" { - return false - } - // Everything else is likely a script or a command snippet - return true -} // ============================================================================ // Parser @@ -352,6 +142,9 @@ func (p *Parser) mergeResults(results []parseResult) { for _, r := range results { totalCheats = append(totalCheats, r.cheats...) 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, @@ -569,156 +362,12 @@ func (p *Parser) parseLine(path string, line []byte, s *parseState) { } } } - -// mergeTags appends newTags to existing, lowercasing and deduping in place. -func mergeTags(existing []string, newTags []string) []string { - seen := make(map[string]struct{}, len(existing)+len(newTags)) - for _, t := range existing { - seen[t] = struct{}{} - } - for _, t := range newTags { - t = strings.ToLower(strings.TrimSpace(t)) - if t == "" { - continue - } - if _, ok := seen[t]; ok { - continue - } - seen[t] = struct{}{} - existing = append(existing, t) - } - return existing -} - -// parseHeader extracts header text without regex: "## Header" -> "Header" -func parseHeader(line []byte) (string, bool) { - i := 0 - // Count leading # - for i < len(line) && line[i] == '#' { - i++ - } - if i == 0 || i > 6 { - return "", false - } - // Must have space after # - if i >= len(line) || line[i] != ' ' { - return "", false - } - i++ // skip space - // Rest is header text - if i >= len(line) { - return "", false - } - return string(line[i:]), true -} - -// parseCodeBlockStart parses ```lang title:"desc" without regex for simple cases -func parseCodeBlockStart(line []byte) (lang, desc string, ok bool) { - if len(line) < 3 || line[0] != '`' || line[1] != '`' || line[2] != '`' { - return "", "", false - } - rest := line[3:] - - // Fast path: just ``` or ```lang - titleIdx := bytes.Index(rest, []byte("title:")) - if titleIdx == -1 { - // No title - just extract lang (word characters until space or end) - end := 0 - for end < len(rest) && isWordChar(rest[end]) { - end++ - } - return string(rest[:end]), "", true - } - - // Has title - use regex for complex case - if matches := patterns.codeBlockStart.FindSubmatch(line); matches != nil { - lang := "" - desc := "" - if len(matches) > 1 { - lang = string(matches[1]) - } - if len(matches) > 2 { - desc = string(matches[2]) - } - return lang, desc, true - } - return "", "", false -} - -// parseCheatSingleLine parses and returns the content -func parseCheatSingleLine(line []byte) (string, bool) { - // Quick rejection - if len(line) < 15 { // minimum: - return "", false - } - if !bytes.HasPrefix(line, []byte("")) { - return "", false - } - - // Check for "cheat" after (multiline end) -func isCheatEnd(line []byte) bool { - trimmed := bytes.TrimSpace(line) - return bytes.Equal(trimmed, []byte("-->")) -} - -// isWordChar returns true for [a-zA-Z0-9_] -func isWordChar(b byte) bool { - return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' -} - -// trimSpaceBytes trims leading/trailing whitespace from bytes without allocating -func trimSpaceBytes(b []byte) []byte { - start := 0 - for start < len(b) && (b[start] == ' ' || b[start] == '\t' || b[start] == '\n' || b[start] == '\r') { - start++ - } - end := len(b) - for end > start && (b[end-1] == ' ' || b[end-1] == '\t' || b[end-1] == '\n' || b[end-1] == '\r') { - end-- - } - return b[start:end] -} - // processCheatComment handles single-line comments func (p *Parser) processCheatComment(path string, s *parseState, content string) { if len(s.pendingCodeBlocks) == 0 { return } - lastIdx := len(s.pendingCodeBlocks) - 1 - block := s.pendingCodeBlocks[lastIdx] - cheat := p.createCheat(path, s, block.description, block.content, content, true) - p.index.AddCheat(cheat) - p.index.RegisterModule(cheat) - s.pendingCodeBlocks = s.pendingCodeBlocks[:lastIdx] + p.flushLastPendingCheat(path, s, content) } // processCheatBlock handles multi-line cheat blocks @@ -726,12 +375,7 @@ func (p *Parser) processCheatBlock(path string, s *parseState) { content := string(s.cheatBlockBuf) if len(s.pendingCodeBlocks) > 0 { - lastIdx := len(s.pendingCodeBlocks) - 1 - block := s.pendingCodeBlocks[lastIdx] - cheat := p.createCheat(path, s, block.description, block.content, content, true) - p.index.AddCheat(cheat) - p.index.RegisterModule(cheat) - s.pendingCodeBlocks = s.pendingCodeBlocks[:lastIdx] + p.flushLastPendingCheat(path, s, content) } else { // Standalone cheat block (module definition) cheat := p.createCheat(path, s, "", "", content, true) @@ -741,6 +385,16 @@ func (p *Parser) processCheatBlock(path string, s *parseState) { } } +// flushLastPendingCheat pops the last pending code block and creates a cheat from it +func (p *Parser) flushLastPendingCheat(path string, s *parseState, cheatBlock string) { + lastIdx := len(s.pendingCodeBlocks) - 1 + block := s.pendingCodeBlocks[lastIdx] + cheat := p.createCheat(path, s, block.description, block.content, cheatBlock, true) + p.index.AddCheat(cheat) + p.index.RegisterModule(cheat) + s.pendingCodeBlocks = s.pendingCodeBlocks[:lastIdx] +} + // processPendingBlocks processes remaining code blocks without cheat metadata func (p *Parser) processPendingBlocks(path string, s *parseState) { for _, block := range s.pendingCodeBlocks { @@ -771,566 +425,4 @@ func (p *Parser) createCheat(path string, s *parseState, description, command, c return cheat } -// buildCheatTags merges all tag sources for one cheat: path tags, heading-suffix -// tag, file-level tags (front matter + footer), and per-cheat inline #tags. -// Result is lowercased and deduplicated. -// -// Fast path: when there are no extra tag sources we return the cached path -// tags directly (zero allocation). Otherwise we dedupe with a linear scan -// over the accumulator; tag counts per cheat are tiny, so linear beats a -// map and avoids closure allocation. -func (p *Parser) buildCheatTags(path string, s *parseState) []string { - pathTags := p.getTagsForPath(path, s.currentHeader) - - if len(s.fileTags) == 0 && len(s.currentHeaderTags) == 0 { - return pathTags - } - - extra := len(s.fileTags) + len(s.currentHeaderTags) - out := make([]string, len(pathTags), len(pathTags)+extra) - copy(out, pathTags) - - out = appendUniqueTags(out, s.fileTags) - out = appendUniqueTags(out, s.currentHeaderTags) - return out -} - -// appendUniqueTags appends every entry in src to dst, lowercased and trimmed, -// skipping duplicates already in dst. Linear scan; suitable for small tag sets. -func appendUniqueTags(dst []string, src []string) []string { -outer: - for _, t := range src { - t = strings.ToLower(strings.TrimSpace(t)) - if t == "" { - continue - } - for _, existing := range dst { - if existing == t { - continue outer - } - } - dst = append(dst, t) - } - return dst -} - -// getTagsForPath returns cached path tags plus header tag -func (p *Parser) getTagsForPath(path, header string) []string { - dir := filepath.Dir(path) - pathTags, ok := p.pathTagsCache[dir] - if !ok { - // Build path tags once per directory - for _, part := range strings.Split(dir, string(filepath.Separator)) { - if part != "" && part != "." { - pathTags = append(pathTags, strings.ToLower(part)) - } - } - p.pathTagsCache[dir] = pathTags - } - - // Add header tag if present - if idx := strings.IndexByte(header, ':'); idx != -1 { - // Copy and append to avoid modifying cached slice - tags := make([]string, len(pathTags), len(pathTags)+1) - copy(tags, pathTags) - return append(tags, strings.ToLower(strings.TrimSpace(header[:idx]))) - } - - return pathTags -} - -// parseCheatDSL parses the DSL content within a cheat block. -// -// Hand-rolled dispatch on the first keyword (var / if / fi / export / import) -// avoids the per-line regex matching that dominated CPU previously. Each -// non-comment, non-blank line is matched against at most one branch. -func parseCheatDSL(cheat *Cheat, content string) { - lines := joinContinuationLines(strings.Split(content, "\n")) - - var currentCondition string - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || line[0] == '#' { - continue - } - - keyword, rest := splitFirstWord(line) - switch keyword { - case "fi": - if rest == "" { - currentCondition = "" - } - case "if": - if rest != "" { - currentCondition = rest - } - case "export": - if rest != "" && !containsWhitespace(rest) { - cheat.Export = rest - } - case "import": - if rest != "" && !containsWhitespace(rest) { - cheat.Imports = append(cheat.Imports, rest) - } - case "var": - parseVarLine(cheat, rest, currentCondition) - } - } -} - -// parseVarLine handles the three var declaration forms: -// -// var NAME -> prompt-only -// var NAME := value -> literal -// var NAME = value -> shell -func parseVarLine(cheat *Cheat, rest, condition string) { - name, after := splitFirstWord(rest) - if name == "" || !isValidDSLVarName(name) { - return - } - - if after == "" { - cheat.Vars = append(cheat.Vars, VarDef{ - Name: name, - Condition: condition, - }) - return - } - - // Detect ":=" vs "=" assignment operator. - switch { - case strings.HasPrefix(after, ":="): - value := strings.TrimSpace(after[2:]) - if value == "" { - return - } - cheat.Vars = append(cheat.Vars, ParseVarDefWithCondition(name, value, condition, true)) - case after[0] == '=': - value := strings.TrimSpace(after[1:]) - if value == "" { - return - } - cheat.Vars = append(cheat.Vars, ParseVarDefWithCondition(name, value, condition, false)) - } -} - -// splitFirstWord returns the leading whitespace-delimited token and the -// remainder with leading whitespace trimmed. If the input has no token, the -// keyword is "". -func splitFirstWord(s string) (keyword, rest string) { - i := 0 - for i < len(s) && s[i] != ' ' && s[i] != '\t' { - i++ - } - if i == 0 { - return "", "" - } - keyword = s[:i] - for i < len(s) && (s[i] == ' ' || s[i] == '\t') { - i++ - } - rest = s[i:] - return -} - -// isValidDSLVarName reports whether s matches `\w+` (the regex previously -// used for var names): letters, digits, underscores; first char unrestricted -// to mirror the prior behavior (regexp `\w` allows leading digit). -func isValidDSLVarName(s string) bool { - if s == "" { - return false - } - for i := 0; i < len(s); i++ { - c := s[i] - if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { - return false - } - } - return true -} - -// containsWhitespace reports whether s has any space or tab. -func containsWhitespace(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] == ' ' || s[i] == '\t' { - return true - } - } - return false -} - -// joinContinuationLines joins lines that end with backslash -func joinContinuationLines(lines []string) []string { - var result []string - var current strings.Builder - - for _, line := range lines { - trimmed := strings.TrimRight(line, " \t") - if strings.HasSuffix(trimmed, "\\") { - // Line continues - remove backslash and append - current.WriteString(strings.TrimSuffix(trimmed, "\\")) - } else { - // Line ends - append and flush - current.WriteString(line) - result = append(result, current.String()) - current.Reset() - } - } - - // Don't forget any remaining content - if current.Len() > 0 { - result = append(result, current.String()) - } - - return result -} - -// ============================================================================ -// Tag Extraction -// ============================================================================ - -// extractFrontMatterTags strips a leading YAML front-matter block (--- ... ---) -// from data and returns the remainder plus any tags found. -func extractFrontMatterTags(data []byte) ([]byte, []string) { - // Must start with "---" followed by newline (allow optional BOM/whitespace). - i := 0 - for i < len(data) && (data[i] == ' ' || data[i] == '\t' || data[i] == '\r') { - i++ - } - if i+3 > len(data) || data[i] != '-' || data[i+1] != '-' || data[i+2] != '-' { - return data, nil - } - // After "---" we require newline (allow trailing spaces). - j := i + 3 - for j < len(data) && (data[j] == ' ' || data[j] == '\t' || data[j] == '\r') { - j++ - } - if j >= len(data) || data[j] != '\n' { - return data, nil - } - bodyStart := j + 1 - - // Find closing "---" at the start of a line. - closeStart, closeEnd, ok := findYAMLClose(data, bodyStart) - if !ok { - return data, nil - } - - tags := parseYAMLTags(data[bodyStart:closeStart]) - return data[closeEnd:], tags -} - -// extractFooterTags strips a trailing tag block from data and returns the -// remainder plus any tags found. Two shapes are recognized: -// -// 1. YAML footer: --- \n tags: [...] \n --- -// 2. Hashtag footer: optional --- rule, then one or more lines of -// whitespace-separated #tag tokens -// -// Walks backward from end-of-file; stops at the first non-tag, non-rule, -// non-blank line. -func extractFooterTags(data []byte) ([]byte, []string) { - // Trim trailing whitespace. - end := len(data) - for end > 0 && (data[end-1] == '\n' || data[end-1] == '\r' || data[end-1] == ' ' || data[end-1] == '\t') { - end-- - } - if end == 0 { - return data, nil - } - - // Try YAML form first. - if body, tags, ok := extractYAMLFooter(data, end); ok { - return body, tags - } - - // Try hashtag form. - if body, tags, ok := extractHashtagFooter(data, end); ok { - return body, tags - } - - return data, nil -} - -// extractYAMLFooter recognizes a trailing --- ... --- YAML block. -func extractYAMLFooter(data []byte, end int) ([]byte, []string, bool) { - if end < 3 { - return nil, nil, false - } - if data[end-1] != '-' || data[end-2] != '-' || data[end-3] != '-' { - return nil, nil, false - } - if end-3 > 0 && data[end-4] != '\n' { - return nil, nil, false - } - - openEnd := end - 3 - openStart := openEnd - for openStart > 0 { - lineEnd := openStart - lineStart := lineEnd - for lineStart > 0 && data[lineStart-1] != '\n' { - lineStart-- - } - line := bytes.TrimRight(data[lineStart:lineEnd], " \t\r") - if bytes.Equal(line, []byte("---")) && lineStart != openEnd-3 { - tags := parseYAMLTags(data[lineEnd+1 : openEnd]) - return data[:lineStart], tags, true - } - if lineStart == 0 { - break - } - openStart = lineStart - 1 - } - return nil, nil, false -} - -// extractHashtagFooter recognizes a trailing block of #tag lines, optionally -// preceded by a "---" horizontal rule. -func extractHashtagFooter(data []byte, end int) ([]byte, []string, bool) { - var tags []string - cut := end - sawTagLine := false - - for cut > 0 { - // Find start of the line ending at cut. - lineEnd := cut - lineStart := lineEnd - for lineStart > 0 && data[lineStart-1] != '\n' { - lineStart-- - } - line := bytes.TrimSpace(data[lineStart:lineEnd]) - - if len(line) == 0 { - // Blank line; allow it between tag lines. - if lineStart == 0 { - break - } - cut = lineStart - 1 - continue - } - - if lineTags, ok := parseHashtagLine(line); ok { - tags = append(lineTags, tags...) - sawTagLine = true - if lineStart == 0 { - cut = 0 - break - } - cut = lineStart - 1 - continue - } - - // Optional --- rule above the tag block; consume it then stop. - if bytes.Equal(line, []byte("---")) && sawTagLine { - cut = lineStart - break - } - break - } - - if !sawTagLine { - return nil, nil, false - } - - // Trim trailing whitespace from the kept body. - body := data[:cut] - bodyEnd := len(body) - for bodyEnd > 0 && (body[bodyEnd-1] == '\n' || body[bodyEnd-1] == '\r' || body[bodyEnd-1] == ' ' || body[bodyEnd-1] == '\t') { - bodyEnd-- - } - return body[:bodyEnd], tags, true -} - -// parseHashtagLine returns the tags on a line if the line consists *only* of -// whitespace and #tag tokens. Returns false otherwise. -func parseHashtagLine(line []byte) ([]string, bool) { - var tags []string - i := 0 - for i < len(line) { - // Skip whitespace. - for i < len(line) && (line[i] == ' ' || line[i] == '\t') { - i++ - } - if i >= len(line) { - break - } - if line[i] != '#' { - return nil, false - } - j := i + 1 - if j >= len(line) { - return nil, false - } - c := line[j] - if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { - return nil, false - } - k := j + 1 - for k < len(line) { - b := line[k] - if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || - b == '_' || b == '-' || b == '.' || b == '/' { - k++ - continue - } - break - } - tags = append(tags, string(line[j:k])) - i = k - } - if len(tags) == 0 { - return nil, false - } - return tags, true -} - -// findYAMLClose locates a "---" line at start-of-line at or after pos. -// Returns the byte offset where "---" begins and the offset just past its newline. -func findYAMLClose(data []byte, pos int) (closeStart, closeEnd int, ok bool) { - lineStart := pos - for lineStart < len(data) { - // Find end of current line - lineEnd := lineStart - for lineEnd < len(data) && data[lineEnd] != '\n' { - lineEnd++ - } - line := bytes.TrimRight(data[lineStart:lineEnd], " \t\r") - if bytes.Equal(line, []byte("---")) { - next := lineEnd - if next < len(data) && data[next] == '\n' { - next++ - } - return lineStart, next, true - } - if lineEnd >= len(data) { - return 0, 0, false - } - lineStart = lineEnd + 1 - } - return 0, 0, false -} - -// parseYAMLTags reads tags from a YAML block. Supports: -// -// tags: [a, b, c] -// tags: a, b, c -// tags: -// - a -// - b -func parseYAMLTags(data []byte) []string { - var tags []string - lines := bytes.Split(data, []byte("\n")) - inList := false - for _, raw := range lines { - line := string(bytes.TrimRight(raw, " \t\r")) - trimmed := strings.TrimSpace(line) - if trimmed == "" { - continue - } - - if inList { - if strings.HasPrefix(trimmed, "- ") || trimmed == "-" { - item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")) - item = strings.Trim(item, "\"'") - if item != "" { - tags = append(tags, item) - } - continue - } - // Non list-item line ends the list mode unless it's another key - inList = false - } - - lower := strings.ToLower(trimmed) - if !strings.HasPrefix(lower, "tags:") { - continue - } - rest := strings.TrimSpace(trimmed[len("tags:"):]) - - if rest == "" { - inList = true - continue - } - - // Inline forms: "[a, b]" or "a, b" - rest = strings.TrimPrefix(rest, "[") - rest = strings.TrimSuffix(rest, "]") - for _, part := range strings.Split(rest, ",") { - item := strings.Trim(strings.TrimSpace(part), "\"'") - if item != "" { - tags = append(tags, item) - } - } - } - return tags -} - -// scanInlineTags appends inline #tag tokens found in a prose line to dst. -// A tag is "#" followed by an ASCII letter, then word chars and -./. -// Excludes hex colors and numeric-only tokens. Stops at heading lines. -func scanInlineTags(line []byte, dst *[]string) { - if len(line) == 0 { - return - } - // Skip heading lines (start with # space) - if line[0] == '#' { - // Real markdown heading: "# foo" with whitespace after some #s. - i := 0 - for i < len(line) && line[i] == '#' { - i++ - } - if i < len(line) && (line[i] == ' ' || line[i] == '\t') { - return - } - } - - for i := 0; i < len(line); i++ { - if line[i] != '#' { - continue - } - // Must be at start of token (preceded by space/start/punct) - if i > 0 { - prev := line[i-1] - if prev != ' ' && prev != '\t' && prev != '(' && prev != '[' && prev != ',' { - continue - } - } - // Next char must be ASCII letter (rules out hex colors, #1, #@, etc.) - j := i + 1 - if j >= len(line) { - return - } - c := line[j] - if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { - continue - } - // Consume tag body: word chars plus - . / - k := j + 1 - for k < len(line) { - b := line[k] - if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || - b == '_' || b == '-' || b == '.' || b == '/' { - k++ - continue - } - break - } - *dst = append(*dst, string(line[j:k])) - i = k - 1 - } -} -// ============================================================================ -// Helpers -// ============================================================================ - -// isMarkdownFile checks if a path is a markdown file -func isMarkdownFile(path string) bool { - if len(path) < 3 { - return false - } - ext := path[len(path)-3:] - return ext == ".md" || ext == ".MD" || strings.EqualFold(path[len(path)-3:], ".md") -} diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go new file mode 100644 index 0000000..bf2117e --- /dev/null +++ b/internal/parser/parser_test.go @@ -0,0 +1,265 @@ +package parser + +import ( + "reflect" + "testing" +) + +func TestExtractFrontMatterTags(t *testing.T) { + content := []byte("---\ntags:\n - bash\n - networking\n - security\nauthor: goober\n---\n\n# My Cheatsheet\n") + _, tags := extractFrontMatterTags(content) + want := []string{"bash", "networking", "security"} + + if !reflect.DeepEqual(tags, want) { + t.Errorf("extractFrontMatterTags() = %v, want %v", tags, want) + } +} + +func TestExtractFrontMatterTags_NoFrontmatter(t *testing.T) { + content := []byte("# Just a header\nSome content\n") + _, tags := extractFrontMatterTags(content) + + if len(tags) != 0 { + t.Errorf("extractFrontMatterTags() expected no tags, got %v", tags) + } +} + +func TestParseHashtagLine(t *testing.T) { + tests := []struct { + name string + line string + wantOk bool + want []string + }{ + { + name: "all hashtags", + line: "#bash #linux #networking", + wantOk: true, + want: []string{"bash", "linux", "networking"}, + }, + { + name: "single hashtag", + line: "#pentest", + wantOk: true, + want: []string{"pentest"}, + }, + { + name: "mixed text rejects", + line: "# Just a header", + wantOk: false, + want: nil, + }, + { + name: "header with inline tags rejects", + line: "# My Header #bash #linux", + wantOk: false, + want: nil, + }, + { + name: "empty line", + line: "", + wantOk: false, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tags, ok := parseHashtagLine([]byte(tt.line)) + if ok != tt.wantOk { + t.Errorf("parseHashtagLine(%q) ok = %v, want %v", tt.line, ok, tt.wantOk) + } + if tt.wantOk && !reflect.DeepEqual(tags, tt.want) { + t.Errorf("parseHashtagLine(%q) = %v, want %v", tt.line, tags, tt.want) + } + }) + } +} + +func TestParseCheatDSL(t *testing.T) { + dslBlock := "var host = 192.168.1.1\nvar port = 80,443 --- Target ports\nvar timeout = 10" + + cheat := &Cheat{} + parseCheatDSL(cheat, dslBlock) + + if len(cheat.Vars) != 3 { + t.Fatalf("parseCheatDSL() parsed %d vars, want 3", len(cheat.Vars)) + } + + if cheat.Vars[0].Name != "host" || cheat.Vars[0].Shell != "192.168.1.1" { + t.Errorf("parseCheatDSL() first var = {Name:%q Shell:%q}, want {Name:\"host\" Shell:\"192.168.1.1\"}", cheat.Vars[0].Name, cheat.Vars[0].Shell) + } + + if cheat.Vars[1].Name != "port" || cheat.Vars[1].Shell != "80,443" || cheat.Vars[1].Args != "Target ports" { + t.Errorf("parseCheatDSL() second var = {Name:%q Shell:%q Args:%q}", cheat.Vars[1].Name, cheat.Vars[1].Shell, cheat.Vars[1].Args) + } +} + +func TestParseCheatDSL_Literal(t *testing.T) { + dslBlock := "var greeting := hello world" + + cheat := &Cheat{} + parseCheatDSL(cheat, dslBlock) + + if len(cheat.Vars) != 1 { + t.Fatalf("parseCheatDSL() parsed %d vars, want 1", len(cheat.Vars)) + } + + if cheat.Vars[0].Name != "greeting" || cheat.Vars[0].Literal != "hello world" { + t.Errorf("parseCheatDSL() literal var = {Name:%q Literal:%q}", cheat.Vars[0].Name, cheat.Vars[0].Literal) + } +} + +func TestParseCheatDSL_Conditional(t *testing.T) { + dslBlock := "if $method == password\nvar cred = echo enter-password\nfi" + + cheat := &Cheat{} + parseCheatDSL(cheat, dslBlock) + + if len(cheat.Vars) != 1 { + t.Fatalf("parseCheatDSL() parsed %d vars, want 1", len(cheat.Vars)) + } + + if cheat.Vars[0].Condition != "$method == password" { + t.Errorf("parseCheatDSL() condition = %q, want %q", cheat.Vars[0].Condition, "$method == password") + } +} + +func TestParseCheatDSL_ExportImport(t *testing.T) { + dslBlock := "export mymodule\nimport othermodule" + + cheat := &Cheat{} + parseCheatDSL(cheat, dslBlock) + + if cheat.Export != "mymodule" { + t.Errorf("parseCheatDSL() Export = %q, want %q", cheat.Export, "mymodule") + } + + if len(cheat.Imports) != 1 || cheat.Imports[0] != "othermodule" { + t.Errorf("parseCheatDSL() Imports = %v, want [othermodule]", cheat.Imports) + } +} + +func TestParseCheatDSL_Comments(t *testing.T) { + dslBlock := "# this is a comment\nvar host = echo localhost" + + cheat := &Cheat{} + parseCheatDSL(cheat, dslBlock) + + if len(cheat.Vars) != 1 { + t.Fatalf("parseCheatDSL() parsed %d vars, want 1", len(cheat.Vars)) + } + + if cheat.Vars[0].Name != "host" { + t.Errorf("parseCheatDSL() var name = %q, want %q", cheat.Vars[0].Name, "host") + } +} + +func TestNewCheatIndex(t *testing.T) { + idx := NewCheatIndex() + if idx == nil { + t.Fatal("NewCheatIndex() returned nil") + } + if idx.Cheats != nil { + t.Errorf("NewCheatIndex() Cheats should be nil, got %v", idx.Cheats) + } +} + +func TestCheatIndexAddCheat(t *testing.T) { + idx := NewCheatIndex() + cheat := &Cheat{Header: "test"} + idx.AddCheat(cheat) + + if len(idx.Cheats) != 1 { + t.Fatalf("AddCheat() len = %d, want 1", len(idx.Cheats)) + } + if idx.Cheats[0].Header != "test" { + t.Errorf("AddCheat() header = %q, want \"test\"", idx.Cheats[0].Header) + } +} + +func TestRegisterModule(t *testing.T) { + idx := NewCheatIndex() + cheat := &Cheat{ + Export: "mymod", + File: "test.md", + Vars: []VarDef{{Name: "host", Shell: "echo localhost"}}, + } + idx.RegisterModule(cheat) + + if idx.Modules == nil { + t.Fatal("RegisterModule() did not initialize Modules map") + } + + mod, ok := idx.Modules["mymod"] + if !ok { + t.Fatal("RegisterModule() module not found") + } + + if mod.Name != "mymod" { + t.Errorf("RegisterModule() module name = %q, want \"mymod\"", mod.Name) + } +} + +func TestRegisterModule_Duplicate(t *testing.T) { + idx := NewCheatIndex() + cheat1 := &Cheat{Export: "dup", File: "a.md"} + cheat2 := &Cheat{Export: "dup", File: "b.md"} + + idx.RegisterModule(cheat1) + idx.RegisterModule(cheat2) + + if len(idx.Duplicates) != 1 { + t.Fatalf("RegisterModule() duplicates = %d, want 1", len(idx.Duplicates)) + } + if idx.Duplicates[0].File1 != "a.md" || idx.Duplicates[0].File2 != "b.md" { + t.Errorf("RegisterModule() duplicate = %v", idx.Duplicates[0]) + } +} + +func TestParseVarDef(t *testing.T) { + tests := []struct { + name string + varName string + value string + wantShell string + wantArgs string + }{ + { + name: "simple shell command", + varName: "host", + value: "echo 127.0.0.1", + wantShell: "echo 127.0.0.1", + wantArgs: "", + }, + { + name: "with description", + varName: "port", + value: "echo 80 --- Target port", + wantShell: "echo 80", + wantArgs: "Target port", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := ParseVarDef(tt.varName, tt.value) + if v.Name != tt.varName { + t.Errorf("ParseVarDef() Name = %q, want %q", v.Name, tt.varName) + } + if v.Shell != tt.wantShell { + t.Errorf("ParseVarDef() Shell = %q, want %q", v.Shell, tt.wantShell) + } + if v.Args != tt.wantArgs { + t.Errorf("ParseVarDef() Args = %q, want %q", v.Args, tt.wantArgs) + } + }) + } +} + +func TestParseVarDefLiteral(t *testing.T) { + v := ParseVarDefLiteral("greeting", "hello world --- A greeting") + if v.Name != "greeting" || v.Literal != "hello world" || v.Args != "A greeting" { + t.Errorf("ParseVarDefLiteral() = {Name:%q Literal:%q Args:%q}", v.Name, v.Literal, v.Args) + } +} diff --git a/internal/parser/tags.go b/internal/parser/tags.go new file mode 100644 index 0000000..be754b4 --- /dev/null +++ b/internal/parser/tags.go @@ -0,0 +1,413 @@ +package parser + +import ( + "bytes" + "path/filepath" + "strings" +) + +// ============================================================================ +// Tag Assembly +// ============================================================================ + +// buildCheatTags merges all tag sources for one cheat: path tags, heading-suffix +// tag, file-level tags (front matter + footer), and per-cheat inline #tags. +// Result is lowercased and deduplicated. +// +// Fast path: when there are no extra tag sources we return the cached path +// tags directly (zero allocation). Otherwise we dedupe with a linear scan +// over the accumulator; tag counts per cheat are tiny, so linear beats a +// map and avoids closure allocation. +func (p *Parser) buildCheatTags(path string, s *parseState) []string { + pathTags := p.getTagsForPath(path, s.currentHeader) + + if len(s.fileTags) == 0 && len(s.currentHeaderTags) == 0 { + return pathTags + } + + extra := len(s.fileTags) + len(s.currentHeaderTags) + out := make([]string, len(pathTags), len(pathTags)+extra) + copy(out, pathTags) + + out = appendUniqueTags(out, s.fileTags) + out = appendUniqueTags(out, s.currentHeaderTags) + return out +} + +// appendUniqueTags appends every entry in src to dst, lowercased and trimmed, +// skipping duplicates already in dst. Linear scan; suitable for small tag sets. +func appendUniqueTags(dst []string, src []string) []string { +outer: + for _, t := range src { + t = strings.ToLower(strings.TrimSpace(t)) + if t == "" { + continue + } + for _, existing := range dst { + if existing == t { + continue outer + } + } + dst = append(dst, t) + } + return dst +} + +// getTagsForPath returns cached path tags plus header tag. +func (p *Parser) getTagsForPath(path, header string) []string { + dir := filepath.Dir(path) + pathTags, ok := p.pathTagsCache[dir] + if !ok { + for _, part := range strings.Split(dir, string(filepath.Separator)) { + if part != "" && part != "." { + pathTags = append(pathTags, strings.ToLower(part)) + } + } + p.pathTagsCache[dir] = pathTags + } + + // Add header tag if present (header text before a ":" prefix). + if idx := strings.IndexByte(header, ':'); idx != -1 { + tags := make([]string, len(pathTags), len(pathTags)+1) + copy(tags, pathTags) + return append(tags, strings.ToLower(strings.TrimSpace(header[:idx]))) + } + + return pathTags +} + +// mergeTags appends newTags to existing, lowercasing and deduping in place. +func mergeTags(existing []string, newTags []string) []string { + seen := make(map[string]struct{}, len(existing)+len(newTags)) + for _, t := range existing { + seen[t] = struct{}{} + } + for _, t := range newTags { + t = strings.ToLower(strings.TrimSpace(t)) + if t == "" { + continue + } + if _, ok := seen[t]; ok { + continue + } + seen[t] = struct{}{} + existing = append(existing, t) + } + return existing +} + +// ============================================================================ +// Tag Extraction +// ============================================================================ + +// extractFrontMatterTags strips a leading YAML front-matter block (--- ... ---) +// from data and returns the remainder plus any tags found. +func extractFrontMatterTags(data []byte) ([]byte, []string) { + i := 0 + for i < len(data) && (data[i] == ' ' || data[i] == '\t' || data[i] == '\r') { + i++ + } + if i+3 > len(data) || data[i] != '-' || data[i+1] != '-' || data[i+2] != '-' { + return data, nil + } + j := i + 3 + for j < len(data) && (data[j] == ' ' || data[j] == '\t' || data[j] == '\r') { + j++ + } + if j >= len(data) || data[j] != '\n' { + return data, nil + } + bodyStart := j + 1 + + closeStart, closeEnd, ok := findYAMLClose(data, bodyStart) + if !ok { + return data, nil + } + + tags := parseYAMLTags(data[bodyStart:closeStart]) + return data[closeEnd:], tags +} + +// extractFooterTags strips a trailing tag block from data and returns the +// remainder plus any tags found. Two shapes are recognized: +// +// 1. YAML footer: --- \n tags: [...] \n --- +// 2. Hashtag footer: optional --- rule, then one or more lines of +// whitespace-separated #tag tokens +// +// Walks backward from end-of-file; stops at the first non-tag, non-rule, +// non-blank line. +func extractFooterTags(data []byte) ([]byte, []string) { + end := len(data) + for end > 0 && (data[end-1] == '\n' || data[end-1] == '\r' || data[end-1] == ' ' || data[end-1] == '\t') { + end-- + } + if end == 0 { + return data, nil + } + + if body, tags, ok := extractYAMLFooter(data, end); ok { + return body, tags + } + if body, tags, ok := extractHashtagFooter(data, end); ok { + return body, tags + } + return data, nil +} + +// extractYAMLFooter recognizes a trailing --- ... --- YAML block. +func extractYAMLFooter(data []byte, end int) ([]byte, []string, bool) { + if end < 3 { + return nil, nil, false + } + if data[end-1] != '-' || data[end-2] != '-' || data[end-3] != '-' { + return nil, nil, false + } + if end-3 > 0 && data[end-4] != '\n' { + return nil, nil, false + } + + openEnd := end - 3 + openStart := openEnd + for openStart > 0 { + lineEnd := openStart + lineStart := lineEnd + for lineStart > 0 && data[lineStart-1] != '\n' { + lineStart-- + } + line := bytes.TrimRight(data[lineStart:lineEnd], " \t\r") + if bytes.Equal(line, []byte("---")) && lineStart != openEnd-3 { + tags := parseYAMLTags(data[lineEnd+1 : openEnd]) + return data[:lineStart], tags, true + } + if lineStart == 0 { + break + } + openStart = lineStart - 1 + } + return nil, nil, false +} + +// extractHashtagFooter recognizes a trailing block of #tag lines, optionally +// preceded by a "---" horizontal rule. +func extractHashtagFooter(data []byte, end int) ([]byte, []string, bool) { + var tags []string + cut := end + sawTagLine := false + + for cut > 0 { + lineEnd := cut + lineStart := lineEnd + for lineStart > 0 && data[lineStart-1] != '\n' { + lineStart-- + } + line := bytes.TrimSpace(data[lineStart:lineEnd]) + + if len(line) == 0 { + if lineStart == 0 { + break + } + cut = lineStart - 1 + continue + } + + if lineTags, ok := parseHashtagLine(line); ok { + tags = append(lineTags, tags...) + sawTagLine = true + if lineStart == 0 { + cut = 0 + break + } + cut = lineStart - 1 + continue + } + + if bytes.Equal(line, []byte("---")) && sawTagLine { + cut = lineStart + break + } + break + } + + if !sawTagLine { + return nil, nil, false + } + + body := data[:cut] + bodyEnd := len(body) + for bodyEnd > 0 && (body[bodyEnd-1] == '\n' || body[bodyEnd-1] == '\r' || body[bodyEnd-1] == ' ' || body[bodyEnd-1] == '\t') { + bodyEnd-- + } + return body[:bodyEnd], tags, true +} + +// parseHashtagLine returns the tags on a line if the line consists *only* of +// whitespace and #tag tokens. Returns false otherwise. +func parseHashtagLine(line []byte) ([]string, bool) { + var tags []string + i := 0 + for i < len(line) { + for i < len(line) && (line[i] == ' ' || line[i] == '\t') { + i++ + } + if i >= len(line) { + break + } + if line[i] != '#' { + return nil, false + } + j := i + 1 + if j >= len(line) { + return nil, false + } + c := line[j] + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { + return nil, false + } + k := consumeTagBody(line, j+1) + tags = append(tags, pathInterner.InternBytes(line[j:k])) + i = k + } + if len(tags) == 0 { + return nil, false + } + return tags, true +} + +// findYAMLClose locates a "---" line at start-of-line at or after pos. +// Returns the byte offset where "---" begins and the offset just past its newline. +func findYAMLClose(data []byte, pos int) (closeStart, closeEnd int, ok bool) { + lineStart := pos + for lineStart < len(data) { + lineEnd := lineStart + for lineEnd < len(data) && data[lineEnd] != '\n' { + lineEnd++ + } + line := bytes.TrimRight(data[lineStart:lineEnd], " \t\r") + if bytes.Equal(line, []byte("---")) { + next := lineEnd + if next < len(data) && data[next] == '\n' { + next++ + } + return lineStart, next, true + } + if lineEnd >= len(data) { + return 0, 0, false + } + lineStart = lineEnd + 1 + } + return 0, 0, false +} + +// parseYAMLTags reads tags from a YAML block. Supports: +// +// tags: [a, b, c] +// tags: a, b, c +// tags: +// - a +// - b +func parseYAMLTags(data []byte) []string { + var tags []string + lines := bytes.Split(data, []byte("\n")) + inList := false + for _, raw := range lines { + line := string(bytes.TrimRight(raw, " \t\r")) + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + if inList { + if strings.HasPrefix(trimmed, "- ") || trimmed == "-" { + item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")) + item = strings.Trim(item, "\"'") + if item != "" { + tags = append(tags, item) + } + continue + } + inList = false + } + + lower := strings.ToLower(trimmed) + if !strings.HasPrefix(lower, "tags:") { + continue + } + rest := strings.TrimSpace(trimmed[len("tags:"):]) + + if rest == "" { + inList = true + continue + } + + rest = strings.TrimPrefix(rest, "[") + rest = strings.TrimSuffix(rest, "]") + for _, part := range strings.Split(rest, ",") { + item := strings.Trim(strings.TrimSpace(part), "\"'") + if item != "" { + tags = append(tags, item) + } + } + } + return tags +} + +// scanInlineTags appends inline #tag tokens found in a prose line to dst. +// A tag is "#" followed by an ASCII letter, then word chars and -./. +// Excludes hex colors and numeric-only tokens. Stops at heading lines. +func scanInlineTags(line []byte, dst *[]string) { + if len(line) == 0 { + return + } + // Skip heading lines (start with # space). + if line[0] == '#' { + i := 0 + for i < len(line) && line[i] == '#' { + i++ + } + if i < len(line) && (line[i] == ' ' || line[i] == '\t') { + return + } + } + + for i := 0; i < len(line); i++ { + if line[i] != '#' { + continue + } + // Must be at start of token (preceded by space/start/punct). + if i > 0 { + prev := line[i-1] + if prev != ' ' && prev != '\t' && prev != '(' && prev != '[' && prev != ',' { + continue + } + } + // Next char must be ASCII letter (rules out hex colors, #1, #@, etc.). + j := i + 1 + if j >= len(line) { + return + } + c := line[j] + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { + continue + } + // Consume tag body: word chars plus - . / + k := consumeTagBody(line, j+1) + *dst = append(*dst, pathInterner.InternBytes(line[j:k])) + i = k - 1 + } +} + +// consumeTagBody advances the index past any valid hashtag body characters +func consumeTagBody(line []byte, start int) int { + k := start + for k < len(line) { + b := line[k] + if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || + b == '_' || b == '-' || b == '.' || b == '/' { + k++ + continue + } + break + } + return k +} diff --git a/internal/parser/types.go b/internal/parser/types.go new file mode 100644 index 0000000..1246aa1 --- /dev/null +++ b/internal/parser/types.go @@ -0,0 +1,144 @@ +package parser + +import "strings" + +// ============================================================================ +// Domain Types +// ============================================================================ + +// Cheat represents a single executable cheat entry. +type Cheat struct { + File string // Source file path + Header string // Section header + Description string // Description text + Command string // Shell command template + Tags []string // Tags from path/header + Export string // Module name if exported + Imports []string // Imported modules + Vars []VarDef // Variable definitions + Scope map[string]string // Resolved values at runtime + HasCheatBlock bool // Whether this cheat has a block +} + +// NewCheat creates a new Cheat. +func NewCheat(file, header string) *Cheat { + return &Cheat{ + File: pathInterner.Intern(file), + Header: header, + // Scope allocated lazily at runtime when needed. + } +} + +// VarDef represents a variable definition. +type VarDef struct { + Name string // Variable name + Shell string // Shell command to generate values (for = syntax) + Literal string // Literal value with var substitution (for := syntax) + Args string // Selector arguments after --- + Condition string // Conditional expression: "$var == value" or "$var != value" +} + +// ParseVarDef parses a variable definition from name and value (shell command). +func ParseVarDef(name, value string) VarDef { + v := VarDef{Name: name} + if idx := strings.Index(value, "---"); idx != -1 { + v.Shell = strings.TrimSpace(value[:idx]) + v.Args = strings.TrimSpace(value[idx+3:]) + } else { + v.Shell = strings.TrimSpace(value) + } + return v +} + +// ParseVarDefLiteral parses a literal variable definition (no shell, just +// substitution). +func ParseVarDefLiteral(name, value string) VarDef { + v := VarDef{Name: name} + if idx := strings.Index(value, "---"); idx != -1 { + v.Literal = strings.TrimSpace(value[:idx]) + v.Args = strings.TrimSpace(value[idx+3:]) + } else { + v.Literal = strings.TrimSpace(value) + } + return v +} + +// ParseVarDefWithCondition parses a variable definition with an optional +// condition. +func ParseVarDefWithCondition(name, value, condition string, isLiteral bool) VarDef { + var v VarDef + if isLiteral { + v = ParseVarDefLiteral(name, value) + } else { + v = ParseVarDef(name, value) + } + v.Condition = condition + return v +} + +// Module represents an exportable module with variables. +type Module struct { + Name string + Vars []VarDef + Imports []string + File string + Cheats []*Cheat +} + +// NewModule creates a Module from a Cheat. +func NewModule(cheat *Cheat) *Module { + return &Module{ + Name: cheat.Export, + Vars: cheat.Vars, + Imports: cheat.Imports, + File: cheat.File, + Cheats: []*Cheat{cheat}, + } +} + +// ============================================================================ +// Cheat Index +// ============================================================================ + +// DuplicateExport records a duplicate export definition. +type DuplicateExport struct { + Name string + File1 string + File2 string +} + +// CheatIndex holds all parsed cheats and modules. +type CheatIndex struct { + Cheats []*Cheat + Modules map[string]*Module + Duplicates []DuplicateExport +} + +// NewCheatIndex creates an empty cheat index. +func NewCheatIndex() *CheatIndex { + return &CheatIndex{} +} + +// AddCheat adds a cheat to the index. +func (idx *CheatIndex) AddCheat(cheat *Cheat) { + idx.Cheats = append(idx.Cheats, cheat) +} + +// RegisterModule registers a module from a cheat with an export. Duplicate +// export names are tracked for later reporting. +func (idx *CheatIndex) RegisterModule(cheat *Cheat) { + if cheat.Export == "" { + return + } + if idx.Modules == nil { + idx.Modules = make(map[string]*Module) + } + if existing, ok := idx.Modules[cheat.Export]; ok { + idx.Duplicates = append(idx.Duplicates, DuplicateExport{ + Name: cheat.Export, + File1: existing.File, + File2: cheat.File, + }) + } + idx.Modules[cheat.Export] = NewModule(cheat) +} diff --git a/internal/ui/cheat_select.go b/internal/ui/cheat_select.go new file mode 100644 index 0000000..94dbc21 --- /dev/null +++ b/internal/ui/cheat_select.go @@ -0,0 +1,463 @@ +package ui + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/gubarz/cheatmd/internal/config" + "github.com/gubarz/cheatmd/internal/parser" +) + +// ============================================================================ +// Cheat Item +// ============================================================================ + +// cheatItem wraps a Cheat with display metadata. +type cheatItem struct { + cheat *parser.Cheat + folder string + file string +} + +func newCheatItem(cheat *parser.Cheat) cheatItem { + folder := filepath.Base(filepath.Dir(cheat.File)) + file := strings.TrimSuffix(filepath.Base(cheat.File), filepath.Ext(cheat.File)) + + return cheatItem{ + cheat: cheat, + folder: folder, + file: file, + } +} + +// matchesQuery reports whether the cheat item matches all search words. +// Words must be pre-lowercased. +func (item *cheatItem) matchesQuery(words []string) bool { + for _, word := range words { + if !item.containsWord(word) { + return false + } + } + return true +} + +// containsWord reports whether any of the item's searchable fields contains +// the given lowercased word. +func (item *cheatItem) containsWord(word string) bool { + if containsIgnoreCaseFast(item.folder, word) { + return true + } + if containsIgnoreCaseFast(item.file, word) { + return true + } + if containsIgnoreCaseFast(item.cheat.Header, word) { + return true + } + if containsIgnoreCaseFast(item.cheat.Description, word) { + return true + } + if containsIgnoreCaseFast(item.cheat.Command, word) { + return true + } + for _, tag := range item.cheat.Tags { + // tags are already lowercased by the parser, but containsIgnoreCaseFast is safe + if containsIgnoreCaseFast(tag, word) { + return true + } + } + return false +} + +// containsIgnoreCaseFast is a fast, zero-allocation case-insensitive substring check. +// It assumes lowerSubstr is already lowercased. +func containsIgnoreCaseFast(s, lowerSubstr string) bool { + if len(lowerSubstr) == 0 { + return true + } + if len(lowerSubstr) > len(s) { + return false + } + n := len(lowerSubstr) + for i := 0; i <= len(s)-n; i++ { + match := true + for j := 0; j < n; j++ { + c := s[i+j] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + if c != lowerSubstr[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} + +// buildPathDisplay builds the path display string based on config options. +func buildPathDisplay(folder, file string) string { + showFolder := config.GetShowFolder() + showFile := config.GetShowFile() + + if showFolder && showFile { + return folder + "/" + file + } else if showFolder { + return folder + } else if showFile { + return file + } + return "" +} + +// ============================================================================ +// Column Config +// ============================================================================ + +// columnConfig holds display column widths and gaps. +type columnConfig struct { + 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(), + } +} + +// ============================================================================ +// Debounce +// ============================================================================ + +// filterMsg triggers filtering after debounce. +type filterMsg struct{} + +// debounceFilter returns a command that triggers filtering after a delay. +func debounceFilter() tea.Cmd { + return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg { + return filterMsg{} + }) +} + +// ============================================================================ +// Update +// ============================================================================ + +// updateCheatSelect handles updates during cheat selection phase. +func (m *mainModel) updateCheatSelect(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + if cmd := m.handleCheatSelectKey(msg); cmd != nil { + return m, cmd + } + case filterMsg: + m.filterCheats() + return m, nil + } + + prevQuery := m.textInput.Value() + var tiCmd tea.Cmd + m.textInput, tiCmd = m.textInput.Update(msg) + cmds = append(cmds, tiCmd) + + if m.textInput.Value() != prevQuery { + cmds = append(cmds, debounceFilter()) + } + + return m, tea.Batch(cmds...) +} + +// handleCheatSelectKey processes keyboard input during cheat selection. +func (m *mainModel) handleCheatSelectKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "ctrl+c", "esc": + m.quitting = true + return tea.Quit + case "enter": + if m.cursor < len(m.filtered) { + m.selected = m.filtered[m.cursor].cheat + return m.startVarResolution() + } + case "up", "ctrl+p": + m.moveCursor(-1) + case "down", "ctrl+n": + m.moveCursor(1) + case "pgup": + m.moveCursor(-10) + case "pgdown": + m.moveCursor(10) + case "home", "ctrl+a": + m.cursor = 0 + case "end", "ctrl+e": + m.cursor = max(0, len(m.filtered)-1) + default: + if msg.String() == config.GetKeyOpen() { + if m.cursor < len(m.filtered) { + openFileInViewer(m.filtered[m.cursor].cheat.File) + } + } + } + return nil +} + +func (m *mainModel) moveCursor(delta int) { + m.cursor += delta + m.cursor = clamp(m.cursor, 0, max(0, len(m.filtered)-1)) +} + + + +// filterCheats filters the cheat list based on the search query. +func (m *mainModel) filterCheats() { + query := strings.TrimSpace(m.textInput.Value()) + + if query == "" { + m.filtered = m.cheats + } else { + words := strings.Fields(strings.ToLower(query)) + m.filtered = make([]cheatItem, 0, min(len(m.cheats), 1000)) + for i := range m.cheats { + if m.cheats[i].matchesQuery(words) { + m.filtered = append(m.filtered, m.cheats[i]) + if len(m.filtered) >= 1000 { + break + } + } + } + } + + m.cursor = clamp(m.cursor, 0, max(0, len(m.filtered)-1)) +} + +// ============================================================================ +// Render +// ============================================================================ + +// renderCheatSelect builds the cheat selection view. +func (m *mainModel) renderCheatSelect() string { + width := max(m.width, 80) + height := m.height + if height < 1 { + height = 24 + } + + inputLines := 3 // divider + info + input + + previewHeight := config.GetPreviewHeight() + if previewHeight < 1 { + previewHeight = 6 + } + + minListHeight := 3 + + availableForPreviewAndList := height - inputLines + if availableForPreviewAndList < previewHeight+minListHeight { + previewHeight = max(availableForPreviewAndList-minListHeight, 2) + } + + preview := m.renderPreviewWithHeight(width, previewHeight) + listHeight := max(height-countLines(preview)-inputLines, 1) + list := m.renderList(listHeight) + + return renderWindowLayout(height, preview, list, m.renderInput(width)) +} + +// renderPreviewWithHeight renders the preview section with a fixed height. +func (m *mainModel) renderPreviewWithHeight(width int, maxLines int) string { + b := getBuilder() + defer putBuilder(b) + lines := 0 + + if m.cursor < len(m.filtered) { + item := m.filtered[m.cursor] + pathDisplay := buildPathDisplay(item.folder, item.file) + if pathDisplay != "" && lines < maxLines { + b.WriteString(styles.PreviewPath.Render(pathDisplay)) + b.WriteString("\n") + lines++ + } + + if lines < maxLines { + b.WriteString(styles.PreviewHeader.Render(item.cheat.Header)) + b.WriteString("\n") + lines++ + } + + if item.cheat.Description != "" && lines < maxLines { + desc := truncateLines(item.cheat.Description, 1, 200) + b.WriteString(styles.PreviewDesc.Render(desc)) + b.WriteString("\n") + lines++ + } + + if lines < maxLines { + b.WriteString("\n") + lines++ + } + + if lines < maxLines { + cmd := truncateLines(item.cheat.Command, maxLines-lines, 0) + cmdLines := strings.Count(cmd, "\n") + 1 + b.WriteString(styles.PreviewCmd.Render(cmd)) + b.WriteString("\n") + lines += cmdLines + } + } + + for lines < maxLines { + b.WriteString("\n") + lines++ + } + + b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) + b.WriteString("\n") + + return b.String() +} + +// renderList renders the scrollable list of cheats. +func (m *mainModel) renderList(maxHeight int) string { + if len(m.filtered) == 0 { + return "" + } + + start, end := scrollWindow(m.cursor, len(m.filtered), maxHeight, &m.offset) + gap := strings.Repeat(" ", m.columns.gap) + + b := getBuilder() + defer putBuilder(b) + for i := start; i < end; i++ { + item := m.filtered[i] + isSelected := i == m.cursor + b.WriteString(m.renderListItem(item, isSelected, gap)) + b.WriteString("\n") + } + + return b.String() +} + +// renderListItem renders a single list item. +func (m *mainModel) renderListItem(item cheatItem, selected bool, gap string) string { + pStyle, hStyle, dStyle, cStyle := m.getItemStyles(selected) + + pathPart := buildPathDisplay(item.folder, item.file) + headerPart := item.cheat.Header + 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) + + maxCmd := m.calculateCommandWidth() + cmd := truncateString(firstLine(item.cheat.Command), maxCmd) + + gapStr := gap + if selected { + gapStr = styles.Selected.Render(gap) + } + + line := headerRendered + gapStr + dStyle.Render(descPadded) + gapStr + cStyle.Render(cmd) + if selected { + return styles.Cursor.Render("▶ ") + line + } + return " " + line +} + +// getItemStyles returns the appropriate styles based on selection state. +func (m *mainModel) getItemStyles(selected bool) (path, header, desc, cmd lipgloss.Style) { + path, header, desc, cmd = styles.Path, styles.Header, styles.Desc, styles.Command + if selected { + path = styles.WithSelection(path) + header = styles.WithSelection(header) + desc = styles.WithSelection(desc) + cmd = styles.WithSelection(cmd) + } + return +} + +// 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 + } + + if m.columns.headerWidth > 1 && len(fullHeader) > m.columns.headerWidth { + fullHeader = fullHeader[:m.columns.headerWidth-1] + "…" + if pathPart != "" && len(pathPart) >= len(fullHeader) { + pathPart = fullHeader + headerPart = "" + } else if pathPart != "" { + headerPart = fullHeader[len(pathPart)+1:] + } else { + headerPart = fullHeader + } + } + + var rendered string + if pathPart != "" && headerPart != "" { + rendered = pStyle.Render(pathPart) + " " + hStyle.Render(headerPart) + } else if pathPart != "" { + rendered = pStyle.Render(pathPart) + } else { + rendered = hStyle.Render(headerPart) + } + + if padding := m.columns.headerWidth - len(fullHeader); padding > 0 { + padStr := strings.Repeat(" ", padding) + if selected { + padStr = styles.Selected.Render(padStr) + } + rendered += padStr + } + return rendered +} + +// calculateCommandWidth returns the available width for command column. +func (m *mainModel) calculateCommandWidth() int { + maxCmd := m.columns.cmdWidth + if m.width > 0 { + usedWidth := m.columns.headerWidth + m.columns.gap*2 + m.columns.descWidth + 4 + if available := m.width - usedWidth; available > 0 && available < maxCmd { + maxCmd = available + } + } + return maxCmd +} + +// renderInput renders the input section at the bottom. +func (m *mainModel) renderInput(width int) string { + b := getBuilder() + defer putBuilder(b) + b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) + b.WriteString("\n") + b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d/%d", len(m.filtered), len(m.cheats)))) + b.WriteString(" • ") + keyOpen := config.GetKeyOpen() + if keyOpen == "" { + keyOpen = "ctrl+o" + } + b.WriteString(styles.Dim.Render(formatKeyDisplay(keyOpen) + " open")) + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("ESC exit")) + b.WriteString("\n") + b.WriteString(m.textInput.View()) + return b.String() +} diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go new file mode 100644 index 0000000..db93279 --- /dev/null +++ b/internal/ui/helpers.go @@ -0,0 +1,107 @@ +package ui + +import "strings" + +// clamp restricts v to the range [minV, maxV]. +func clamp(v, minV, maxV int) int { + if v < minV { + return minV + } + if v > maxV { + return maxV + } + return v +} + + +// safeTextInputWidth clamps text input width to a positive value. +// Terminal APIs can briefly report very small/zero sizes in edge cases. +func safeTextInputWidth(totalWidth int) int { + return max(totalWidth-4, 1) +} + +// countLines counts the number of lines in a string. +func countLines(s string) int { + if s == "" { + return 0 + } + return strings.Count(s, "\n") + 1 +} + +// scrollWindow calculates the visible range for a scrollable list, adjusting +// offset so the cursor stays visible. +func scrollWindow(cursor, total, height int, offset *int) (start, end int) { + if cursor < *offset { + *offset = cursor + } + if cursor >= *offset+height { + *offset = cursor - height + 1 + } + maxOffset := max(0, total-height) + *offset = clamp(*offset, 0, maxOffset) + + start = *offset + end = min(start+height, total) + return +} + +// truncateString truncates a string to maxLen with ellipsis. +func truncateString(s string, maxLen int) string { + if maxLen <= 3 || len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +// 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") + "..." + } + if maxLen > 0 && len(text) > maxLen { + text = text[:maxLen-3] + "..." + } + return text +} + +// matchesAllWords returns true if text contains all words. +func matchesAllWords(text string, words []string) bool { + for _, word := range words { + if !strings.Contains(text, word) { + return false + } + } + return true +} + +// formatKeyDisplay turns "ctrl+x" into "Ctrl+X" for display purposes. +func formatKeyDisplay(key string) string { + if strings.HasPrefix(key, "ctrl+") { + return "Ctrl+" + strings.ToUpper(key[5:]) + } + return key +} + +// firstLine returns the substring up to the first newline, or s if none. +func firstLine(s string) string { + if idx := strings.IndexByte(s, '\n'); idx >= 0 { + return s[:idx] + } + return s +} + +// renderWindowLayout composes a vertical layout with padding inserted before the bottom section. +func renderWindowLayout(height int, top, middle, bottom string) string { + topLines := countLines(top) + middleLines := countLines(middle) + bottomLines := countLines(bottom) + padding := max(height-topLines-middleLines-bottomLines, 0) + + var b strings.Builder + b.WriteString(top) + b.WriteString(middle) + b.WriteString(strings.Repeat("\n", padding)) + b.WriteString(bottom) + return b.String() +} diff --git a/internal/ui/infer_test.go b/internal/ui/infer_test.go index 3b67b41..977d483 100644 --- a/internal/ui/infer_test.go +++ b/internal/ui/infer_test.go @@ -41,17 +41,22 @@ func TestExtractEmbeddedVars(t *testing.T) { t.Run(tt.name, func(t *testing.T) { result := extractEmbeddedVars(tt.template, tt.actual, tt.scope) - for key, expected := range tt.expected { - if actual, ok := result[key]; !ok { - t.Errorf("expected result[%q] = %q, but key not found", key, expected) - } else if actual != expected { - t.Errorf("expected result[%q] = %q, got %q", key, expected, actual) - } - } + assertMapEq(t, tt.expected, result) }) } } +func assertMapEq(t *testing.T, expected, actual map[string]string) { + t.Helper() + for key, exp := range expected { + if act, ok := actual[key]; !ok { + t.Errorf("expected map[%q] = %q, but key not found. map=%v", key, exp, actual) + } else if act != exp { + t.Errorf("expected map[%q] = %q, got %q", key, exp, act) + } + } +} + func TestPrefillScopeFromMatch(t *testing.T) { tests := []struct { name string @@ -110,13 +115,7 @@ func TestPrefillScopeFromMatch(t *testing.T) { prefillScopeFromMatch(cheat, tt.input) - for key, expected := range tt.expectedScope { - if actual, ok := cheat.Scope[key]; !ok { - t.Errorf("expected scope[%q] = %q, but key not found. scope=%v", key, expected, cheat.Scope) - } else if actual != expected { - t.Errorf("expected scope[%q] = %q, got %q", key, expected, actual) - } - } + assertMapEq(t, tt.expectedScope, cheat.Scope) }) } } @@ -176,13 +175,7 @@ func TestInferDependentVars(t *testing.T) { inferDependentVars(cheat, index) - for key, expected := range tt.expectedScope { - if actual, ok := cheat.Scope[key]; !ok { - t.Errorf("expected scope[%q] = %q, but key not found. scope=%v", key, expected, cheat.Scope) - } else if actual != expected { - t.Errorf("expected scope[%q] = %q, got %q", key, expected, actual) - } - } + assertMapEq(t, tt.expectedScope, cheat.Scope) }) } } diff --git a/internal/ui/main_model.go b/internal/ui/main_model.go index f3b8859..61dc0a9 100644 --- a/internal/ui/main_model.go +++ b/internal/ui/main_model.go @@ -1,25 +1,23 @@ package ui import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" "strings" "sync" - "time" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/gubarz/cheatmd/internal/config" "github.com/gubarz/cheatmd/internal/executor" "github.com/gubarz/cheatmd/internal/parser" ) +// Executor defines the interface required by the UI for command execution and resolution. +type Executor interface { + RunShell(command string) (string, error) + BuildFinalCommand(cheat *parser.Cheat) string + OutputWithMode(command string, mode executor.OutputMode) error +} + // ============================================================================ // String Builder Pool - reduces GC pressure from rendering // ============================================================================ @@ -42,128 +40,6 @@ func putBuilder(b *strings.Builder) { } } -// ============================================================================ -// Cheat Item -// ============================================================================ - -// cheatItem wraps a Cheat with display metadata -type cheatItem struct { - cheat *parser.Cheat - folder string - file string -} - -// newCheatItem creates a cheatItem from a Cheat -func newCheatItem(cheat *parser.Cheat) cheatItem { - folder := filepath.Base(filepath.Dir(cheat.File)) - file := strings.TrimSuffix(filepath.Base(cheat.File), filepath.Ext(cheat.File)) - - return cheatItem{ - cheat: cheat, - folder: folder, - file: file, - } -} - -// matchesQuery checks if the cheat item matches all search words -// Uses case-insensitive substring matching on original strings -func (item *cheatItem) matchesQuery(words []string) bool { - for _, word := range words { - if !item.containsWord(word) { - return false - } - } - return true -} - -// containsWord checks if any field contains the word (case-insensitive) -func (item *cheatItem) containsWord(word string) bool { - // Check smaller fields first for fast rejection - if containsIgnoreCase(item.folder, word) { - return true - } - if containsIgnoreCase(item.file, word) { - return true - } - if containsIgnoreCase(item.cheat.Header, word) { - return true - } - // Check larger fields only if needed - if containsIgnoreCase(item.cheat.Description, word) { - return true - } - if containsIgnoreCase(item.cheat.Command, word) { - return true - } - for _, tag := range item.cheat.Tags { - if containsIgnoreCase(tag, word) { - return true - } - } - return false -} - -// containsIgnoreCase is a fast case-insensitive substring check -func containsIgnoreCase(s, substr string) bool { - if len(substr) > len(s) { - return false - } - // Use strings.Contains with pre-lowercased substr (caller should cache this) - // For ASCII, we can do a fast manual check - return strings.Contains(strings.ToLower(s), substr) -} - -// buildPathDisplay builds the path display string based on config options -func buildPathDisplay(folder, file string) string { - showFolder := config.GetShowFolder() - showFile := config.GetShowFile() - - if showFolder && showFile { - return folder + "/" + file - } else if showFolder { - return folder - } else if showFile { - return file - } - return "" -} - -// ============================================================================ -// Column Config -// ============================================================================ - -// columnConfig holds display column widths and gaps -type columnConfig struct { - 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(), - } -} - -// ============================================================================ -// Debounce -// ============================================================================ - -// filterMsg triggers filtering after debounce -type filterMsg struct{} - -// debounceFilter returns a command that triggers filtering after a delay -func debounceFilter() tea.Cmd { - return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg { - return filterMsg{} - }) -} - // ============================================================================ // Main Model - Unified TUI (Cheat Selection + Variable Resolution) // ============================================================================ @@ -206,45 +82,30 @@ type mainModel struct { // Dependencies for variable resolution cheatIndex *parser.CheatIndex - executor *executor.Executor -} - -// substituteSearchState holds the overlay state for substitute search. -// The user enters via the configured key during phaseVarResolve, picks an -// env/history value, and returns to phaseVarResolve with the chosen value -// loaded into the var prompt. -type substituteSearchState struct { - options []substituteOption - filtered []substituteOption - cursor int - offset int - prevInput string // textInput value before entering the overlay - prevCursor int - prevOffset int + executor Executor } - // varResolveState holds state for resolving variables within the unified TUI type varResolveState struct { cheat *parser.Cheat vars []varState currentIdx int options []string // options for current variable (from shell command) - filtered []filteredVarOption + filtered []FilteredOption selectOpts SelectOptions customHeader string shellErr error // error from running shell command (if any) isPromptOnly bool // true if no options, just text input } -// filteredVarOption pairs display text with original value for variable selection -type filteredVarOption struct { - display string - original string - searchText string +// FilteredOption pairs display text with original value for variable selection +type FilteredOption struct { + Display string + Original string + SearchText string } // newMainModel creates a new mainModel with the given cheats -func newMainModel(cheats []*parser.Cheat, index *parser.CheatIndex, exec *executor.Executor) mainModel { +func newMainModel(cheats []*parser.Cheat, index *parser.CheatIndex, exec Executor) mainModel { ti := textinput.New() ti.Placeholder = "Type to search..." ti.Focus() @@ -268,7 +129,7 @@ func newMainModel(cheats []*parser.Cheat, index *parser.CheatIndex, exec *execut } // Init implements tea.Model -func (m mainModel) Init() tea.Cmd { +func (m *mainModel) Init() tea.Cmd { // If we're already in variable resolution phase (from --match), prepare the first variable if m.phase == phaseVarResolve && m.varState != nil { return tea.Batch(textinput.Blink, m.prepareCurrentVar()) @@ -277,11 +138,11 @@ func (m mainModel) Init() tea.Cmd { } // Update implements tea.Model -func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Handle window size for both phases if wsMsg, ok := msg.(tea.WindowSizeMsg); ok { - m.width = maxInt(wsMsg.Width, 1) - m.height = maxInt(wsMsg.Height, 1) + m.width = max(wsMsg.Width, 1) + m.height = max(wsMsg.Height, 1) m.textInput.Width = safeTextInputWidth(m.width) } @@ -296,885 +157,9 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } -// updateSubstituteSearch handles updates while the substitute overlay is open. -func (m mainModel) updateSubstituteSearch(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - if cmd := m.handleSubstituteSearchKey(msg); cmd != nil { - return m, 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()) { - return m, nil - } - } - - prev := m.textInput.Value() - var tiCmd tea.Cmd - m.textInput, tiCmd = m.textInput.Update(msg) - if m.textInput.Value() != prev { - m.filterSubstituteOptions() - } - return m, tiCmd -} - -func isSubstituteNavKey(key string) bool { - switch key { - case "ctrl+c", "esc", "enter", "up", "down", "ctrl+p", "ctrl+n", "pgup", "pgdown": - return true - } - return false -} - -// updateCheatSelect handles updates during cheat selection phase -func (m mainModel) updateCheatSelect(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - if cmd := m.handleCheatSelectKey(msg); cmd != nil { - return m, cmd - } - case filterMsg: - m.filterCheats() - return m, nil - } - - prevQuery := m.textInput.Value() - var tiCmd tea.Cmd - m.textInput, tiCmd = m.textInput.Update(msg) - cmds = append(cmds, tiCmd) - - // Only trigger debounced filter if query changed - if m.textInput.Value() != prevQuery { - cmds = append(cmds, debounceFilter()) - } - - return m, tea.Batch(cmds...) -} - -// handleCheatSelectKey processes keyboard input during cheat selection -func (m *mainModel) handleCheatSelectKey(msg tea.KeyMsg) tea.Cmd { - switch msg.String() { - case "ctrl+c": - m.quitting = true - return tea.Quit - case "esc": - m.quitting = true - return tea.Quit - case "enter": - if m.cursor < len(m.filtered) { - m.selected = m.filtered[m.cursor].cheat - // Transition to variable resolution phase - return m.startVarResolution() - } - case "up", "ctrl+p": - m.moveCursor(-1) - case "down", "ctrl+n": - m.moveCursor(1) - case "pgup": - m.moveCursor(-10) - case "pgdown": - m.moveCursor(10) - case "home", "ctrl+a": - m.cursor = 0 - case "end", "ctrl+e": - m.cursor = max(0, len(m.filtered)-1) - default: - // Check for configurable open key - if msg.String() == config.GetKeyOpen() { - if m.cursor < len(m.filtered) { - openFileInViewer(m.filtered[m.cursor].cheat.File) - } - } - } - return nil -} - -// moveCursor moves the cursor by delta, clamping to valid range -func (m *mainModel) moveCursor(delta int) { - m.cursor += delta - m.cursor = clamp(m.cursor, 0, max(0, len(m.filtered)-1)) - m.adjustOffset() -} - -// adjustOffset ensures cursor is visible within viewport -func (m *mainModel) adjustOffset() { - // Estimate visible height (will be adjusted in render, but this keeps offset roughly correct) - viewHeight := maxInt(m.height-10, 3) // approximate list height - if viewHeight <= 0 { - return - } - // Scroll up: cursor went above viewport - if m.cursor < m.offset { - m.offset = m.cursor - } - // Scroll down: cursor went below viewport - if m.cursor >= m.offset+viewHeight { - m.offset = m.cursor - viewHeight + 1 - } - // Clamp offset - maxOffset := max(0, len(m.filtered)-viewHeight) - m.offset = clamp(m.offset, 0, maxOffset) -} - -// filterCheats filters the cheat list based on the search query -func (m *mainModel) filterCheats() { - query := strings.TrimSpace(m.textInput.Value()) - - if query == "" { - m.filtered = m.cheats - } else { - words := strings.Fields(strings.ToLower(query)) - m.filtered = make([]cheatItem, 0, min(len(m.cheats), 1000)) - for i := range m.cheats { - if m.cheats[i].matchesQuery(words) { - m.filtered = append(m.filtered, m.cheats[i]) - // Limit results to prevent UI lag - if len(m.filtered) >= 1000 { - break - } - } - } - } - - m.cursor = clamp(m.cursor, 0, max(0, len(m.filtered)-1)) - m.adjustOffset() -} - -// formatKeyDisplay converts internal key format to display format -// e.g., "ctrl+o" -> "Ctrl+O", "ctrl+x" -> "Ctrl+X" -func formatKeyDisplay(key string) string { - if strings.HasPrefix(key, "ctrl+") { - return "Ctrl+" + strings.ToUpper(key[5:]) - } - return key -} - -// ============================================================================ -// Variable Resolution Phase (unified TUI - no flicker) -// ============================================================================ - -// shellResultMsg is sent when a shell command completes -type shellResultMsg struct { - options []string - err error -} - -// startVarResolution initiates variable resolution and returns a command -func (m *mainModel) startVarResolution() tea.Cmd { - m.startVarResolutionInternal() - if m.phase != phaseVarResolve { - // No variables to resolve - finish immediately - return tea.Quit - } - return m.prepareCurrentVar() -} - -// startVarResolutionInternal sets up variable resolution state -func (m *mainModel) startVarResolutionInternal() { - cheat := m.selected - if cheat == nil { - return - } - - // Initialize scope if nil - if cheat.Scope == nil { - cheat.Scope = make(map[string]string) - } - - vars := collectVariables(cheat, m.cheatIndex) - if len(vars) == 0 { - // No variables - stay in cheat select phase (will quit immediately) - return - } - - // Pre-fill from cheat.Scope (populated by --match) or environment - for i := range vars { - varName := vars[i].def.Name - if scopeVal, ok := cheat.Scope[varName]; ok && scopeVal != "" { - vars[i].prefill = scopeVal - vars[i].skipAutoCont = true - } else if envVal := os.Getenv(varName); envVal != "" { - vars[i].prefill = envVal - } - } - - m.varState = &varResolveState{ - cheat: cheat, - vars: vars, - currentIdx: 0, - } - m.phase = phaseVarResolve - - // Save query and reset text input for variable resolution - m.lastQuery = m.textInput.Value() - m.textInput.SetValue("") - m.textInput.Placeholder = "Type to filter or enter value..." - m.cursor = 0 - m.offset = 0 -} - -// prepareCurrentVar prepares the current variable for display -// May 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 { - m.selected.Scope[vs.def.Name] = vs.value - } - } - } - return tea.Quit - } - - vs := &m.varState.vars[m.varState.currentIdx] - - // Build current scope from already-resolved variables - 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 { - // Check if all variants are conditional - 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 - vs.resolved = true - vs.value = "" - m.varState.currentIdx++ - return m.prepareCurrentVar() - } - selectedDef = &vs.def - } - vs.def = *selectedDef - - // Check auto-continue - autoContinue := config.GetAutoContinue() - if autoContinue && vs.prefill != "" && !vs.skipAutoCont { - vs.value = vs.prefill - vs.resolved = true - m.varState.currentIdx++ - return m.prepareCurrentVar() - } - - // Extract custom header from args - m.varState.customHeader = extractCustomHeader(vs.def.Args) - m.varState.selectOpts = parseSelectorOpts(vs.def.Args) - - // Handle literal values (no shell execution) - if vs.def.Literal != "" { - result := executor.SubstituteVars(vs.def.Literal, scope) - // If user went back to this var, show it instead of auto-resolving - if vs.skipAutoCont { - m.varState.isPromptOnly = true - m.varState.options = nil - m.varState.filtered = nil - m.textInput.SetValue(result) - m.textInput.CursorEnd() - return nil - } - vs.value = result - vs.resolved = true - m.varState.currentIdx++ - return m.prepareCurrentVar() - } - - // Check if shell command is empty (prompt only) - if strings.TrimSpace(vs.def.Shell) == "" { - m.varState.isPromptOnly = true - m.varState.options = nil - m.varState.filtered = nil - if vs.prefill != "" { - m.textInput.SetValue(vs.prefill) - m.textInput.CursorEnd() - } - return nil - } - - // Run shell command asynchronously to get options - shellCmd := executor.SubstituteVars(vs.def.Shell, scope) - - return func() tea.Msg { - output, err := m.executor.RunShell(shellCmd) - if err != nil { - return shellResultMsg{nil, err} - } - lines := splitLines(output) - return shellResultMsg{lines, nil} - } -} - -// 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++ - } - } - } - return opts -} - -// updateVarResolve handles updates during variable resolution phase -func (m mainModel) updateVarResolve(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - if cmd := m.handleVarResolveKey(msg); cmd != nil { - return m, cmd - } - case shellResultMsg: - return m.handleShellResult(msg) - } - - // Update text input - prevQuery := m.textInput.Value() - var tiCmd tea.Cmd - m.textInput, tiCmd = m.textInput.Update(msg) - - // Filter options if query changed - if m.textInput.Value() != prevQuery && !m.varState.isPromptOnly { - m.filterVarOptions() - } - - return m, tiCmd -} - -// handleShellResult processes the result of a shell command -func (m mainModel) handleShellResult(msg shellResultMsg) (tea.Model, tea.Cmd) { - if m.varState == nil { - return m, nil - } - - vs := &m.varState.vars[m.varState.currentIdx] - - if msg.err != nil { - // Shell command failed - fall back to prompt only - m.varState.shellErr = msg.err - m.varState.isPromptOnly = true - m.varState.options = nil - m.varState.filtered = nil - m.textInput.SetValue(vs.prefill) - return m, nil - } - - m.varState.options = msg.options - m.varState.shellErr = nil - - switch len(msg.options) { - case 0: - // No output - prompt only - m.varState.isPromptOnly = true - if vs.prefill != "" { - m.textInput.SetValue(vs.prefill) - m.textInput.CursorEnd() - } - case 1: - // Single result - prefill - m.varState.isPromptOnly = true - prefill := vs.prefill - if prefill == "" { - prefill = applyMapTransform(msg.options[0], m.varState.selectOpts) - } - m.textInput.SetValue(prefill) - m.textInput.CursorEnd() - default: - // Multiple options - show selection - m.varState.isPromptOnly = false - m.buildVarFilteredList() - if vs.prefill != "" { - m.textInput.SetValue(vs.prefill) - m.textInput.CursorEnd() - } - m.filterVarOptions() - m.cursor = 0 - m.offset = 0 - } - - return m, nil -} - -// buildVarFilteredList builds the filtered list from options -func (m *mainModel) buildVarFilteredList() { - if m.varState == nil { - return - } - - opts := m.varState.selectOpts - m.varState.filtered = make([]filteredVarOption, len(m.varState.options)) - - for i, opt := range m.varState.options { - display := getDisplayColumn(opt, opts.Delimiter, opts.Column) - m.varState.filtered[i] = filteredVarOption{ - display: display, - original: opt, - searchText: strings.ToLower(display), - } - } -} - -// filterVarOptions filters the variable options based on search query -func (m *mainModel) filterVarOptions() { - if m.varState == nil || m.varState.isPromptOnly { - return - } - - query := strings.ToLower(strings.TrimSpace(m.textInput.Value())) - if query == "" { - // Rebuild full list - m.buildVarFilteredList() - } else { - words := strings.Fields(query) - opts := m.varState.selectOpts - result := make([]filteredVarOption, 0, len(m.varState.options)) - - for _, opt := range m.varState.options { - display := getDisplayColumn(opt, opts.Delimiter, opts.Column) - searchText := strings.ToLower(display) - - matches := true - for _, word := range words { - if !strings.Contains(searchText, word) { - matches = false - break - } - } - if matches { - result = append(result, filteredVarOption{ - display: display, - original: opt, - searchText: searchText, - }) - } - } - m.varState.filtered = result - } - - m.cursor = clamp(m.cursor, 0, max(0, len(m.varState.filtered)-1)) -} - -// 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 // Signal to not execute - 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.cursor = 0 - m.offset = 0 - return m.prepareCurrentVar() - } - // Go back to cheat selection - m.phase = phaseCheatSelect - m.varState = nil - m.selected = nil - m.textInput.SetValue(m.lastQuery) - m.textInput.Placeholder = "Type to search..." - m.cursor = 0 - m.offset = 0 - return nil - case "enter": - return m.acceptVarValue() - case "up", "ctrl+p": - if !m.varState.isPromptOnly { - m.moveVarCursor(-1) - } - case "down", "ctrl+n": - if !m.varState.isPromptOnly { - m.moveVarCursor(1) - } - case "pgup": - if !m.varState.isPromptOnly { - m.moveVarCursor(-10) - } - case "pgdown": - if !m.varState.isPromptOnly { - m.moveVarCursor(10) - } - case "tab": - if !m.varState.isPromptOnly && m.cursor < len(m.varState.filtered) { - m.textInput.SetValue(m.varState.filtered[m.cursor].display) - } - default: - // Check for configurable open key - if msg.String() == config.GetKeyOpen() { - if m.varState != nil && m.varState.cheat != nil { - openFileInViewer(m.varState.cheat.File) - } - } - // Check for substitute-search key - if msg.String() == config.GetKeySubstitute() { - if m.enterSubstituteSearch() { - // Force a clean repaint and start the textinput blink so - // the overlay renders correctly on the very first frame. - return tea.Batch(tea.ClearScreen, textinput.Blink) - } - } - } - return nil -} - -// 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() - if len(sources) == 0 { - return false - } - opts := collectSubstituteOptions(sources) - if len(opts) == 0 { - return false - } - m.subState = &substituteSearchState{ - options: opts, - filtered: opts, - prevInput: m.textInput.Value(), - prevCursor: m.cursor, - prevOffset: m.offset, - } - m.textInput.SetValue("") - m.textInput.Placeholder = "Search env / history..." - m.cursor = 0 - m.offset = 0 - m.phase = phaseSubstituteSearch - return true -} - -// exitSubstituteSearch returns to phaseVarResolve. If accept is true the -// currently highlighted option's Value is loaded into the var prompt; -// otherwise the previous input is restored. Returns a Cmd that the caller -// should propagate to bubbletea to clear any overlay artifacts. -func (m *mainModel) exitSubstituteSearch(accept bool) { - if m.subState == nil { - m.phase = phaseVarResolve - return - } - - if accept && m.subState.cursor < len(m.subState.filtered) { - m.textInput.SetValue(m.subState.filtered[m.subState.cursor].Value) - m.textInput.CursorEnd() - } else { - m.textInput.SetValue(m.subState.prevInput) - m.textInput.CursorEnd() - } - m.cursor = m.subState.prevCursor - m.offset = m.subState.prevOffset - m.subState = nil - m.textInput.Placeholder = "Type to filter or enter value..." - m.phase = phaseVarResolve - - // Refilter var options if we're in select mode (the input may have changed). - if m.varState != nil && !m.varState.isPromptOnly { - m.filterVarOptions() - } -} - -// filterSubstituteOptions applies the textInput's current value as a -// space-separated AND fuzzy filter over the substitute option list. -func (m *mainModel) filterSubstituteOptions() { - if m.subState == nil { - return - } - query := strings.ToLower(strings.TrimSpace(m.textInput.Value())) - if query == "" { - m.subState.filtered = m.subState.options - m.subState.cursor = 0 - m.subState.offset = 0 - return - } - words := strings.Fields(query) - result := make([]substituteOption, 0, len(m.subState.options)) - for _, opt := range m.subState.options { - hay := strings.ToLower(opt.Display) - if matchesAllWords(hay, words) { - result = append(result, opt) - } - } - m.subState.filtered = result - if m.subState.cursor >= len(result) { - m.subState.cursor = max(0, len(result)-1) - } - if m.subState.offset > m.subState.cursor { - m.subState.offset = m.subState.cursor - } -} - -// moveSubstituteCursor clamps the cursor; offset is reconciled by scrollWindow -// at render time using the actual list height. -func (m *mainModel) moveSubstituteCursor(delta int) { - if m.subState == nil { - return - } - m.subState.cursor += delta - m.subState.cursor = clamp(m.subState.cursor, 0, max(0, len(m.subState.filtered)-1)) -} - -// handleSubstituteSearchKey processes keys while in phaseSubstituteSearch. -func (m *mainModel) handleSubstituteSearchKey(msg tea.KeyMsg) tea.Cmd { - switch msg.String() { - case "ctrl+c": - m.quitting = true - m.selected = nil - return tea.Quit - case "esc": - m.exitSubstituteSearch(false) - return tea.ClearScreen - case "enter": - m.exitSubstituteSearch(true) - return tea.ClearScreen - case "up", "ctrl+p": - m.moveSubstituteCursor(-1) - return nil - case "down", "ctrl+n": - m.moveSubstituteCursor(1) - return nil - case "pgup": - m.moveSubstituteCursor(-10) - return nil - case "pgdown": - m.moveSubstituteCursor(10) - return nil - } - return nil -} - -// moveVarCursor moves the cursor during variable selection -func (m *mainModel) moveVarCursor(delta int) { - if m.varState == nil { - return - } - m.cursor += delta - m.cursor = clamp(m.cursor, 0, max(0, len(m.varState.filtered)-1)) - - // Adjust offset for viewport - viewHeight := maxInt(m.height-10, 3) - if m.cursor < m.offset { - m.offset = m.cursor - } - if m.cursor >= m.offset+viewHeight { - m.offset = m.cursor - viewHeight + 1 - } - maxOffset := max(0, len(m.varState.filtered)-viewHeight) - m.offset = clamp(m.offset, 0, maxOffset) -} - -// acceptVarValue accepts the current value and moves to next variable -func (m *mainModel) acceptVarValue() tea.Cmd { - if m.varState == nil { - return tea.Quit - } - - vs := &m.varState.vars[m.varState.currentIdx] - var value string - - if m.varState.isPromptOnly { - value = m.textInput.Value() - } else if m.cursor < len(m.varState.filtered) { - // Selected from list - get original value - selected := m.varState.filtered[m.cursor].original - - value = applyMapTransform(selected, m.varState.selectOpts) - } else { - // Use typed input - value = m.textInput.Value() - } - - vs.value = value - vs.resolved = true - m.varState.currentIdx++ - - // Reset for next variable - m.textInput.SetValue("") - m.cursor = 0 - m.offset = 0 - - return m.prepareCurrentVar() -} - -// renderVarResolve renders the variable resolution view -func (m mainModel) renderVarResolve() string { - if m.varState == nil { - return "" - } - - width := maxInt(m.width, 80) - height := m.height - if height < 1 { - height = 24 // fallback for uninitialized - } - - b := getBuilder() - defer putBuilder(b) - - // Header showing progress (at top) - header := m.renderVarHeader(width) - headerLines := countLines(header) - - // Bottom section: list + input - // Calculate available space and pass it to renderVarBottom - availableForBottom := maxInt(height-headerLines, 5) - bottom := m.renderVarBottomWithHeight(width, availableForBottom) - bottomLines := countLines(bottom) - - // Layout: header at top, padding in middle, bottom at bottom - padding := maxInt(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 - - // Options list (if not prompt-only) - if !m.varState.isPromptOnly && len(m.varState.filtered) > 0 { - // Calculate available space for list - availableForList := maxInt(maxHeight-fixedLines, 1) - listHeight := minInt(availableForList, minInt(10, len(m.varState.filtered))) - start, end := scrollWindow(m.cursor, len(m.varState.filtered), listHeight, &m.offset) - - for i := start; i < end; i++ { - opt := m.varState.filtered[i] - if i == m.cursor { - b.WriteString(styles.Cursor.Render("▶ ")) - b.WriteString(styles.Selected.Render(styles.Command.Render(opt.display))) - } else { - b.WriteString(" ") - b.WriteString(styles.Command.Render(opt.display)) - } - b.WriteString("\n") - } - } - - // Footer with input - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - - if !m.varState.isPromptOnly && len(m.varState.filtered) > 0 { - b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d options", len(m.varState.filtered)))) - b.WriteString(" • ") - } - b.WriteString(styles.Dim.Render("ESC back")) - b.WriteString(" • ") - b.WriteString(styles.Dim.Render("Enter accept")) - 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) - - // Command with progress highlighting - progressCmd := m.varState.cheat.Command - for i, vs := range m.varState.vars { - if vs.resolved { - progressCmd = replaceVar(progressCmd, vs.def.Name, styles.Header.Render(vs.value)) - } else if i == m.varState.currentIdx { - progressCmd = replaceVar(progressCmd, vs.def.Name, styles.Cursor.Render("$"+vs.def.Name)) - } - } - b.WriteString(progressCmd) - b.WriteString("\n") - - // Variable list - for i, vs := range m.varState.vars { - if vs.resolved { - b.WriteString(styles.Command.Render("✓")) - b.WriteString(" ") - b.WriteString(styles.Dim.Render("$" + vs.def.Name)) - b.WriteString(" = ") - b.WriteString(styles.Header.Render(vs.value)) - } else if i == m.varState.currentIdx { - b.WriteString(styles.Cursor.Render("▶ $" + vs.def.Name)) - } else { - b.WriteString(styles.Dim.Render("○ $" + vs.def.Name)) - } - b.WriteString("\n") - } - - // Custom header if present - if m.varState.customHeader != "" { - b.WriteString("\n") - b.WriteString(styles.Header.Render(m.varState.customHeader)) - b.WriteString("\n") - } - - // Divider - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - - return b.String() -} // View implements tea.Model -func (m mainModel) View() string { +func (m *mainModel) View() string { if m.quitting && m.selected == nil { return "" } @@ -1190,915 +175,3 @@ func (m mainModel) View() string { } } -// renderSubstituteSearch renders the env/history picker overlay using the -// same layout shape as renderCheatSelect: fixed-height preview at top, -// scrolling list in the middle, padding, divider + info + input at bottom. -func (m mainModel) renderSubstituteSearch() string { - width := maxInt(m.width, 80) - height := m.height - if height < 1 { - height = 24 - } - - inputLines := 3 // divider + info + input - - // Preview block is just two lines: title + divider. Match the cheat - // select pattern that uses a padded fixed-height block. - previewHeight := 2 - - preview := m.renderSubstitutePreview(width, previewHeight) - previewLines := countLines(preview) - - listHeight := maxInt(height-previewLines-inputLines, 1) - list := m.renderSubstituteList(listHeight) - listLines := countLines(list) - - padding := maxInt(height-previewLines-listLines-inputLines, 0) - - b := getBuilder() - defer putBuilder(b) - b.WriteString(preview) - b.WriteString(list) - b.WriteString(strings.Repeat("\n", padding)) - b.WriteString(m.renderSubstituteInput(width)) - - return b.String() -} - -// renderSubstitutePreview renders the title header at fixed height (padded), -// followed by a divider. Mirrors renderPreviewWithHeight's shape. -func (m mainModel) renderSubstitutePreview(width, maxLines int) string { - b := getBuilder() - defer putBuilder(b) - lines := 0 - - if lines < maxLines { - var varName string - if m.varState != nil && m.varState.currentIdx < len(m.varState.vars) { - varName = m.varState.vars[m.varState.currentIdx].def.Name - } - b.WriteString(styles.Header.Render("Substitute search")) - if varName != "" { - b.WriteString(" ") - b.WriteString(styles.Dim.Render("→ ")) - b.WriteString(styles.Cursor.Render("$" + varName)) - } - b.WriteString("\n") - lines++ - } - - for lines < maxLines { - b.WriteString("\n") - lines++ - } - - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - return b.String() -} - -// renderSubstituteList renders the scrolling list, mirrors renderList shape. -// Each row is hard-truncated to terminal width so long env values (e.g. PATH) -// can't wrap and push other rows off-screen. -func (m *mainModel) renderSubstituteList(maxHeight int) string { - if m.subState == nil || len(m.subState.filtered) == 0 { - return "" - } - - start, end := scrollWindow(m.subState.cursor, len(m.subState.filtered), maxHeight, &m.subState.offset) - width := maxInt(m.width, 80) - // 2 chars for the "▶ " or " " prefix. - maxLen := maxInt(width-2, 10) - - b := getBuilder() - defer putBuilder(b) - for i := start; i < end; i++ { - opt := m.subState.filtered[i] - display := truncateString(opt.Display, maxLen) - if i == m.subState.cursor { - b.WriteString(styles.Cursor.Render("▶ ")) - b.WriteString(styles.Selected.Render(styles.Command.Render(display))) - } else { - b.WriteString(" ") - b.WriteString(styles.Command.Render(display)) - } - b.WriteString("\n") - } - return b.String() -} - -// renderSubstituteInput renders the bottom divider + hint + input, mirrors -// renderInput's shape. -func (m mainModel) renderSubstituteInput(width int) string { - b := getBuilder() - defer putBuilder(b) - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - matchCount := 0 - if m.subState != nil { - matchCount = len(m.subState.filtered) - } - b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d matches", matchCount))) - b.WriteString(" • ") - b.WriteString(styles.Dim.Render("ESC cancel")) - b.WriteString(" • ") - b.WriteString(styles.Dim.Render("Enter use value")) - b.WriteString("\n") - b.WriteString(m.textInput.View()) - return b.String() -} - - -// renderCheatSelect builds the cheat selection view -func (m mainModel) renderCheatSelect() string { - width := maxInt(m.width, 80) - height := m.height - if height < 1 { - height = 24 // fallback for uninitialized - } - - inputLines := 3 // divider + info + input - - // Calculate available space for preview and list - // Preview can shrink if terminal is very small - previewHeight := config.GetPreviewHeight() - if previewHeight < 1 { - previewHeight = 6 - } - - // Minimum list height we want to show - minListHeight := 3 - - // If terminal is too small, shrink preview first - availableForPreviewAndList := height - inputLines - if availableForPreviewAndList < previewHeight+minListHeight { - // Shrink preview to fit, keeping at least 2 lines for preview - previewHeight = maxInt(availableForPreviewAndList-minListHeight, 2) - } - - preview := m.renderPreviewWithHeight(width, previewHeight) - previewLines := countLines(preview) - - listHeight := maxInt(height-previewLines-inputLines, 1) - list := m.renderList(listHeight) - listLines := countLines(list) - - padding := maxInt(height-previewLines-listLines-inputLines, 0) - - b := getBuilder() - defer putBuilder(b) - b.WriteString(preview) - b.WriteString(list) - b.WriteString(strings.Repeat("\n", padding)) - b.WriteString(m.renderInput(width)) - - return b.String() -} - -// renderPreviewWithHeight renders the preview section with a specific height -func (m mainModel) renderPreviewWithHeight(width int, maxLines int) string { - b := getBuilder() - defer putBuilder(b) - lines := 0 - - if m.cursor < len(m.filtered) { - item := m.filtered[m.cursor] - pathDisplay := buildPathDisplay(item.folder, item.file) - if pathDisplay != "" && lines < maxLines { - b.WriteString(styles.PreviewPath.Render(pathDisplay)) - b.WriteString("\n") - lines++ - } - - if lines < maxLines { - b.WriteString(styles.PreviewHeader.Render(item.cheat.Header)) - b.WriteString("\n") - lines++ - } - - if item.cheat.Description != "" && lines < maxLines { - desc := truncateLines(item.cheat.Description, 1, 200) - b.WriteString(styles.PreviewDesc.Render(desc)) - b.WriteString("\n") - lines++ - } - - if lines < maxLines { - b.WriteString("\n") - lines++ - } - - if lines < maxLines { - cmd := truncateLines(item.cheat.Command, maxLines-lines, 0) - cmdLines := strings.Count(cmd, "\n") + 1 - b.WriteString(styles.PreviewCmd.Render(cmd)) - b.WriteString("\n") - lines += cmdLines - } - } - - // Pad to fixed height - for lines < maxLines { - b.WriteString("\n") - lines++ - } - - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - - return b.String() -} - -// renderList renders the scrollable list of cheats -func (m *mainModel) renderList(maxHeight int) string { - if len(m.filtered) == 0 { - return "" - } - - start, end := scrollWindow(m.cursor, len(m.filtered), maxHeight, &m.offset) - gap := strings.Repeat(" ", m.columns.gap) - - b := getBuilder() - defer putBuilder(b) - for i := start; i < end; i++ { - item := m.filtered[i] - isSelected := i == m.cursor - b.WriteString(m.renderListItem(item, isSelected, gap)) - b.WriteString("\n") - } - - return b.String() -} - -// renderListItem renders a single list item -func (m mainModel) renderListItem(item cheatItem, selected bool, gap string) string { - pStyle, hStyle, dStyle, cStyle := m.getItemStyles(selected) - - // Build header column - pathPart := buildPathDisplay(item.folder, item.file) - headerPart := item.cheat.Header - headerRendered := m.renderHeaderColumn(pathPart, headerPart, pStyle, hStyle, selected) - - // Description and command columns - desc := truncateString(firstLine(item.cheat.Description), m.columns.descWidth) - descPadded := fmt.Sprintf("%-*s", m.columns.descWidth, desc) - - maxCmd := m.calculateCommandWidth() - cmd := truncateString(firstLine(item.cheat.Command), maxCmd) - - // Build line - gapStr := gap - if selected { - gapStr = styles.Selected.Render(gap) - } - - line := headerRendered + gapStr + dStyle.Render(descPadded) + gapStr + cStyle.Render(cmd) - if selected { - return styles.Cursor.Render("▶ ") + line - } - return " " + line -} - -// getItemStyles returns the appropriate styles based on selection state -func (m mainModel) getItemStyles(selected bool) (path, header, desc, cmd lipgloss.Style) { - path, header, desc, cmd = styles.Path, styles.Header, styles.Desc, styles.Command - if selected { - path = styles.WithSelection(path) - header = styles.WithSelection(header) - desc = styles.WithSelection(desc) - cmd = styles.WithSelection(cmd) - } - return -} - -// 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 - } - - if m.columns.headerWidth > 1 && len(fullHeader) > m.columns.headerWidth { - fullHeader = fullHeader[:m.columns.headerWidth-1] + "…" - if pathPart != "" && len(pathPart) >= len(fullHeader) { - pathPart = fullHeader - headerPart = "" - } else if pathPart != "" { - headerPart = fullHeader[len(pathPart)+1:] - } else { - headerPart = fullHeader - } - } - - var rendered string - if pathPart != "" && headerPart != "" { - rendered = pStyle.Render(pathPart) + " " + hStyle.Render(headerPart) - } else if pathPart != "" { - rendered = pStyle.Render(pathPart) - } else { - rendered = hStyle.Render(headerPart) - } - - // Pad to column width - if padding := m.columns.headerWidth - len(fullHeader); padding > 0 { - padStr := strings.Repeat(" ", padding) - if selected { - padStr = styles.Selected.Render(padStr) - } - rendered += padStr - } - return rendered -} - -// calculateCommandWidth returns the available width for command column -func (m mainModel) calculateCommandWidth() int { - maxCmd := m.columns.cmdWidth - if m.width > 0 { - usedWidth := m.columns.headerWidth + m.columns.gap*2 + m.columns.descWidth + 4 - if available := m.width - usedWidth; available > 0 && available < maxCmd { - maxCmd = available - } - } - return maxCmd -} - -// firstLine returns the first line of a string -func firstLine(s string) string { - if idx := strings.IndexByte(s, '\n'); idx >= 0 { - return s[:idx] - } - return s -} - -// renderInput renders the input section at the bottom -func (m mainModel) renderInput(width int) string { - b := getBuilder() - defer putBuilder(b) - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d/%d", len(m.filtered), len(m.cheats)))) - b.WriteString(" • ") - keyOpen := config.GetKeyOpen() - if keyOpen == "" { - keyOpen = "ctrl+o" - } - b.WriteString(styles.Dim.Render(formatKeyDisplay(keyOpen) + " open")) - b.WriteString(" • ") - b.WriteString(styles.Dim.Render("ESC exit")) - b.WriteString("\n") - b.WriteString(m.textInput.View()) - return b.String() -} - -// ============================================================================ -// Run TUI -// ============================================================================ - -// getTTY returns file handles for TUI input/output -// Uses /dev/tty to bypass shell pipes and command substitution -func getTTY() (in *os.File, out *os.File, cleanup func()) { - var closers []func() - - // Check if stdout is a terminal - // If not (e.g., piped or captured by $()), use /dev/tty - if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) == 0 { - // stdout is NOT a terminal - we're being captured - out, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0) - if err != nil { - out = os.Stderr // Last resort fallback - } else { - closers = append(closers, func() { out.Close() }) - } - - in, err := os.OpenFile("/dev/tty", os.O_RDONLY, 0) - if err != nil { - in = os.Stdin - } else { - closers = append(closers, func() { in.Close() }) - } - - // Tell lipgloss to use the TTY for color detection - lipgloss.SetDefaultRenderer(lipgloss.NewRenderer(out)) - - return in, out, func() { - for _, c := range closers { - c() - } - } - } - - // stdout IS a terminal - use normal stdin/stdout - return os.Stdin, os.Stdout, func() {} -} - -// RunTUI launches the Bubble Tea interface (unified - no flicker) -func RunTUI(index *parser.CheatIndex, exec *executor.Executor, initialQuery, matchCmd string) error { - requireCheatBlock := config.GetRequireCheatBlock() - autoSelect := config.GetAutoSelect() - - cheats := filterCheatsByConfig(index.Cheats, requireCheatBlock) - if len(cheats) == 0 { - return fmt.Errorf("no cheats found") - } - - m := newMainModel(cheats, index, exec) - - // If matchCmd is provided, try to find a cheat whose command matches - if matchCmd != "" { - if matched := findMatchingCheat(cheats, matchCmd); matched != nil { - m.selected = matched - // Pre-fill scope from the matched command - prefillScopeFromMatch(matched, matchCmd) - // Try to infer dependent variables from literals - inferDependentVars(matched, index) - // Start variable resolution immediately - m.startVarResolutionInternal() - - // If no variables to resolve, skip TUI entirely - if m.phase != phaseVarResolve { - finalCmd := exec.BuildFinalCommand(m.selected) - return executeOutput(finalCmd, exec) - } - - // Pre-set text input for first variable if it has a prefill - 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 { - // No exact match - use as initial query - initialQuery = matchCmd - } - } - - if initialQuery != "" { - m.textInput.SetValue(initialQuery) - m.filterCheats() - - // Auto-select if exactly one match and --auto flag is set - if autoSelect && len(m.filtered) == 1 { - m.selected = m.filtered[0].cheat - m.startVarResolutionInternal() - - // If no variables to resolve, skip TUI entirely - if m.phase != phaseVarResolve { - finalCmd := exec.BuildFinalCommand(m.selected) - return executeOutput(finalCmd, exec) - } - } - } - - // Always run the TUI (unified flow handles everything) - ttyIn, ttyOut, cleanup := getTTY() - RefreshStyles() // Refresh after getTTY sets up the renderer - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithOutput(ttyOut), tea.WithInput(ttyIn)) - finalModel, err := p.Run() - cleanup() - - if err != nil { - return err - } - - result := finalModel.(mainModel) - if result.quitting && result.selected == nil { - return nil - } - - // The unified TUI completes with the final command built - if result.selected == nil { - return nil - } - - finalCmd := exec.BuildFinalCommand(result.selected) - return executeOutput(finalCmd, exec) -} - -// filterCheatsByConfig returns cheats matching configuration -func filterCheatsByConfig(cheats []*parser.Cheat, requireCheatBlock bool) []*parser.Cheat { - if !requireCheatBlock { - return cheats - } - - result := make([]*parser.Cheat, 0, len(cheats)) - for _, cheat := range cheats { - if cheat.HasCheatBlock { - result = append(result, cheat) - } - } - return result -} - -// ============================================================================ -// Helpers -// ============================================================================ - -// clamp restricts v to the range [minV, maxV] -func clamp(v, minV, maxV int) int { - if v < minV { - return minV - } - if v > maxV { - return maxV - } - return v -} - -// maxInt returns the larger of a and b -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} - -// safeTextInputWidth clamps text input width to a positive value. -// Terminal APIs can briefly report very small/zero sizes in edge cases. -func safeTextInputWidth(totalWidth int) int { - return maxInt(totalWidth-4, 1) -} - -// countLines counts the number of lines in a string -func countLines(s string) int { - if s == "" { - return 0 - } - return strings.Count(s, "\n") + 1 -} - -// scrollWindow calculates the visible range for a scrollable list -func scrollWindow(cursor, total, height int, offset *int) (start, end int) { - // Ensure offset keeps cursor visible (final adjustment) - if cursor < *offset { - *offset = cursor - } - if cursor >= *offset+height { - *offset = cursor - height + 1 - } - maxOffset := max(0, total-height) - *offset = clamp(*offset, 0, maxOffset) - - start = *offset - end = min(start+height, total) - return -} - -// truncateString truncates a string to maxLen with ellipsis -func truncateString(s string, maxLen int) string { - if maxLen <= 3 || len(s) <= maxLen { - return s - } - return s[:maxLen-3] + "..." -} - -// 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") + "..." - } - if maxLen > 0 && len(text) > maxLen { - text = text[:maxLen-3] + "..." - } - return text -} - -// matchesAllWords returns true if text contains all words -func matchesAllWords(text string, words []string) bool { - for _, word := range words { - if !strings.Contains(text, word) { - return false - } - } - return true -} - -// findMatchingCheat finds a cheat whose command pattern matches the input -// It builds a regex from the cheat command (replacing $var with capture groups) -// and returns the first match -func findMatchingCheat(cheats []*parser.Cheat, input string) *parser.Cheat { - input = strings.TrimSpace(input) - if input == "" { - return nil - } - - for _, cheat := range cheats { - pattern, _ := buildMatchPattern(cheat.Command) - if pattern.MatchString(input) { - return cheat - } - } - return nil -} - -// buildMatchPattern converts a command template to a regex pattern for matching -// e.g. "echo $name" -> "^echo (\S+)$" -// e.g. 'echo "$name"' -> '^echo "([^"]*)"$' -// Returns the pattern and a list of variable names for each capture group (may have duplicates) -func buildMatchPattern(cmd string) (*regexp.Regexp, []string) { - // First, find all variables in order - varPattern := regexp.MustCompile(`\$(\w+)`) - allMatches := varPattern.FindAllStringSubmatchIndex(cmd, -1) - - var varOrder []string // var name for each capture group (may have duplicates) - - // Build the pattern by processing the command - var result strings.Builder - result.WriteString(`^\s*`) - lastEnd := 0 - - for i, match := range allMatches { - // match[0:1] is full match start:end, match[2:3] is capture group start:end - varStart := match[0] - varEnd := match[1] - varName := cmd[match[2]:match[3]] - - // Add escaped literal text before this variable - if varStart > lastEnd { - result.WriteString(regexp.QuoteMeta(cmd[lastEnd:varStart])) - } - - // Every occurrence gets a capture group (Go regex doesn't support backreferences) - varOrder = append(varOrder, varName) - - // Check if variable is inside quotes - beforeVar := cmd[:varStart] - afterVar := cmd[varEnd:] - - if strings.HasSuffix(beforeVar, `"`) && strings.HasPrefix(afterVar, `"`) { - // Inside double quotes - don't include the quotes in capture - // Remove the trailing quote we already added - current := result.String() - if strings.HasSuffix(current, `"`) { - result.Reset() - result.WriteString(current[:len(current)-1]) - } - result.WriteString(`"([^"]*)"`) - lastEnd = varEnd + 1 // Skip the closing quote - continue - } else if strings.HasSuffix(beforeVar, `'`) && strings.HasPrefix(afterVar, `'`) { - // Inside single quotes - current := result.String() - if strings.HasSuffix(current, `'`) { - result.Reset() - result.WriteString(current[:len(current)-1]) - } - result.WriteString(`'([^']*)'`) - lastEnd = varEnd + 1 // Skip the closing quote - continue - } else { - // Unquoted variable - // Check if this is the last variable AND there's no more text after it - isLastVar := i == len(allMatches)-1 - remainingText := strings.TrimSpace(cmd[varEnd:]) - if isLastVar && remainingText == "" { - // Last variable at end of command - use greedy pattern to capture multi-word values - result.WriteString(`(.+)`) - } else { - // Mid-command variable: find the next literal text to anchor on - // Look for next literal text (between this var and next var, or end of command) - nextLiteralStart := varEnd - nextLiteralEnd := len(cmd) - if i+1 < len(allMatches) { - nextLiteralEnd = allMatches[i+1][0] // start of next variable - } - nextLiteral := strings.TrimSpace(cmd[nextLiteralStart:nextLiteralEnd]) - - if nextLiteral != "" { - // Use non-greedy pattern that stops at the next literal - // e.g., for "...$auth_flags add..." we want (.+?) followed by " add" - result.WriteString(`(.+?)`) - } else { - // No literal after - just use \S+ (next thing is another variable) - result.WriteString(`(\S+)`) - } - } - } - lastEnd = varEnd - } - - // Add remaining literal text - if lastEnd < len(cmd) { - result.WriteString(regexp.QuoteMeta(cmd[lastEnd:])) - } - result.WriteString(`\s*$`) - - re, err := regexp.Compile(result.String()) - if err != nil { - return regexp.MustCompile(`^$`), nil - } - return re, varOrder -} - -// prefillScopeFromMatch extracts variable values from the matched command -func prefillScopeFromMatch(cheat *parser.Cheat, input string) { - input = strings.TrimSpace(input) - pattern, varNames := buildMatchPattern(cheat.Command) - if pattern == nil || len(varNames) == 0 { - return - } - - matches := pattern.FindStringSubmatch(input) - if matches == nil { - return - } - - if cheat.Scope == nil { - cheat.Scope = make(map[string]string) - } - - // varNames may have duplicates - use first occurrence of each variable - for i, name := range varNames { - if i+1 < len(matches) { - // Only set if not already set (use first occurrence) - if _, exists := cheat.Scope[name]; !exists { - cheat.Scope[name] = matches[i+1] - } - } - } -} - -// inferDependentVars tries to reverse-engineer dependent variables from literal values -// For example, if auth_flags=-k and we have "if $auth_method == kerberos then auth_flags := -k" -// we can infer auth_method=kerberos -func inferDependentVars(cheat *parser.Cheat, index *parser.CheatIndex) { - if len(cheat.Scope) == 0 { - return - } - - // Get all variable definitions - varDefs := collectVarDefinitions(cheat, index) - - // Keep inferring until no new values are found - changed := true - for changed { - changed = false - for varName, prefillValue := range cheat.Scope { - defs, ok := varDefs[varName] - if !ok { - continue - } - - // Look for conditional literal definitions that could produce this value - for _, def := range defs { - if def.Literal == "" || def.Condition == "" { - continue - } - - // Parse condition: "$var == value" or "$var != value" - condVar, condOp, condValue := parseCondition(def.Condition) - if condVar == "" { - continue - } - - // Skip if condition var is already set - if _, exists := cheat.Scope[condVar]; exists { - continue - } - - // Check if this literal (with current scope) would produce the prefilled value - literalResult := executor.SubstituteVars(def.Literal, cheat.Scope) - - // If literal still has unresolved vars, try to extract them - if strings.Contains(literalResult, "$") { - // Try to extract embedded variable values - extracted := extractEmbeddedVars(def.Literal, prefillValue, cheat.Scope) - for k, v := range extracted { - if _, exists := cheat.Scope[k]; !exists { - cheat.Scope[k] = v - changed = true - } - } - // Recompute literal with new extractions - literalResult = executor.SubstituteVars(def.Literal, cheat.Scope) - } - - // Check if the resolved literal matches the prefilled value - if literalResult == prefillValue { - // Infer the condition variable - if condOp == "==" { - cheat.Scope[condVar] = condValue - changed = true - } - // For != we can't directly infer the value - } - } - } - } -} - -// parseCondition parses "$var == value" or "$var != value" -func parseCondition(cond string) (varName, op, value string) { - cond = strings.TrimSpace(cond) - - // Try == first - if idx := strings.Index(cond, "=="); idx != -1 { - left := strings.TrimSpace(cond[:idx]) - right := strings.TrimSpace(cond[idx+2:]) - if strings.HasPrefix(left, "$") { - return left[1:], "==", right - } - } - - // Try != - if idx := strings.Index(cond, "!="); idx != -1 { - left := strings.TrimSpace(cond[:idx]) - right := strings.TrimSpace(cond[idx+2:]) - if strings.HasPrefix(left, "$") { - return left[1:], "!=", right - } - } - - return "", "", "" -} - -// extractEmbeddedVars tries to extract variable values embedded in a literal template -// For example: template="-p $credential", actual="-p mypass" -> {credential: mypass} -func extractEmbeddedVars(template, actual string, existingScope map[string]string) map[string]string { - result := make(map[string]string) - - // Substitute already-known variables - pattern := template - for k, v := range existingScope { - pattern = strings.ReplaceAll(pattern, "$"+k, regexp.QuoteMeta(v)) - } - - // Find remaining variables and build a regex - varPattern := regexp.MustCompile(`\$(\w+)`) - varMatches := varPattern.FindAllStringSubmatchIndex(pattern, -1) - if len(varMatches) == 0 { - return result - } - - // Build regex pattern, replacing $var with capture groups - var regexParts strings.Builder - regexParts.WriteString("^") - lastEnd := 0 - var varNames []string - - for i, match := range varMatches { - varStart := match[0] - varEnd := match[1] - varName := pattern[match[2]:match[3]] - - if varStart > lastEnd { - regexParts.WriteString(regexp.QuoteMeta(pattern[lastEnd:varStart])) - } - - // Use greedy capture for the last variable, non-greedy otherwise - if i == len(varMatches)-1 && varEnd == len(pattern) { - regexParts.WriteString(`(.+)`) // Greedy for final variable at end - } else { - regexParts.WriteString(`(.+?)`) // Non-greedy otherwise - } - varNames = append(varNames, varName) - lastEnd = varEnd - } - if lastEnd < len(pattern) { - regexParts.WriteString(regexp.QuoteMeta(pattern[lastEnd:])) - } - regexParts.WriteString("$") - - re, err := regexp.Compile(regexParts.String()) - if err != nil { - return result - } - - matches := re.FindStringSubmatch(actual) - if matches == nil { - return result - } - - for i, name := range varNames { - if i+1 < len(matches) { - result[name] = matches[i+1] - } - } - - return result -} - -// openFileInViewer opens the file in the configured editor or system default -func openFileInViewer(filePath string) { - var cmd *exec.Cmd - - // Check for configured editor first - if editor := config.GetEditor(); editor != "" { - cmd = exec.Command(editor, filePath) - } else { - // Fall back to system default - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", filePath) - case "windows": - cmd = exec.Command("cmd", "/c", "start", "", filePath) - default: // linux, freebsd, etc. - cmd = exec.Command("xdg-open", filePath) - } - } - _ = cmd.Start() -} diff --git a/internal/ui/match.go b/internal/ui/match.go new file mode 100644 index 0000000..6561937 --- /dev/null +++ b/internal/ui/match.go @@ -0,0 +1,282 @@ +package ui + +import ( + "regexp" + "strings" + + "github.com/gubarz/cheatmd/internal/executor" + "github.com/gubarz/cheatmd/internal/parser" +) + +// findMatchingCheat finds a cheat whose command pattern matches the input. +// It builds a regex from the cheat command (replacing $var with capture groups) +// and returns the first match. +func findMatchingCheat(cheats []*parser.Cheat, input string) *parser.Cheat { + input = strings.TrimSpace(input) + if input == "" { + return nil + } + + for _, cheat := range cheats { + pattern, _ := buildMatchPattern(cheat.Command) + if pattern.MatchString(input) { + return cheat + } + } + return nil +} + +// buildMatchPattern converts a command template to a regex pattern for matching. +// +// "echo $name" -> "^echo (\S+)$" +// 'echo "$name"' -> '^echo "([^"]*)"$' +// +// Returns the pattern and a list of variable names for each capture group. +// The slice may have duplicates (one entry per capture group) because Go +// regex doesn't support backreferences. +func buildMatchPattern(cmd string) (*regexp.Regexp, []string) { + varPattern := regexp.MustCompile(`\$(\w+)`) + allMatches := varPattern.FindAllStringSubmatchIndex(cmd, -1) + + var varOrder []string + + var result strings.Builder + result.WriteString(`^\s*`) + lastEnd := 0 + + for i, match := range allMatches { + varStart := match[0] + varEnd := match[1] + varName := cmd[match[2]:match[3]] + + if varStart > lastEnd { + result.WriteString(regexp.QuoteMeta(cmd[lastEnd:varStart])) + } + + varOrder = append(varOrder, varName) + + beforeVar := cmd[:varStart] + afterVar := cmd[varEnd:] + + if strings.HasSuffix(beforeVar, `"`) && strings.HasPrefix(afterVar, `"`) { + // Inside double quotes - don't include the quotes in capture. + 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 == "" { + // Last variable at end of command - greedy to capture multi-word values. + 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 + } + + if lastEnd < len(cmd) { + result.WriteString(regexp.QuoteMeta(cmd[lastEnd:])) + } + result.WriteString(`\s*$`) + + re, err := regexp.Compile(result.String()) + if err != nil { + return regexp.MustCompile(`^$`), nil + } + return re, varOrder +} + +// prefillScopeFromMatch extracts variable values from the matched command and +// writes them into cheat.Scope. +func prefillScopeFromMatch(cheat *parser.Cheat, input string) { + input = strings.TrimSpace(input) + pattern, varNames := buildMatchPattern(cheat.Command) + if pattern == nil || len(varNames) == 0 { + return + } + + matches := pattern.FindStringSubmatch(input) + if matches == nil { + return + } + + if cheat.Scope == nil { + cheat.Scope = make(map[string]string) + } + + for i, name := range varNames { + if i+1 < len(matches) { + if _, exists := cheat.Scope[name]; !exists { + cheat.Scope[name] = matches[i+1] + } + } + } +} + +// inferDependentVars reverse-engineers dependent variables from literal values. +// Example: if auth_flags=-k and we have "if $auth_method == kerberos then +// auth_flags := -k", we can infer auth_method=kerberos. +func inferDependentVars(cheat *parser.Cheat, index *parser.CheatIndex) { + if len(cheat.Scope) == 0 { + return + } + + varDefs := collectVarDefinitions(cheat, index) + + changed := true + for changed { + changed = false + for varName, prefillValue := range cheat.Scope { + defs, ok := varDefs[varName] + if !ok { + continue + } + + for _, def := range defs { + if def.Literal == "" || def.Condition == "" { + continue + } + + condVar, condOp, condValue := parseCondition(def.Condition) + if condVar == "" { + continue + } + + if _, exists := cheat.Scope[condVar]; exists { + continue + } + + literalResult := executor.SubstituteVars(def.Literal, cheat.Scope) + + 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) + } + + if literalResult == prefillValue && condOp == "==" { + cheat.Scope[condVar] = condValue + changed = true + } + } + } + } +} + +// parseCondition parses "$var == value" or "$var != value". +func parseCondition(cond string) (varName, op, value string) { + cond = strings.TrimSpace(cond) + + if idx := strings.Index(cond, "=="); idx != -1 { + left := strings.TrimSpace(cond[:idx]) + right := strings.TrimSpace(cond[idx+2:]) + if strings.HasPrefix(left, "$") { + return left[1:], "==", right + } + } + + if idx := strings.Index(cond, "!="); idx != -1 { + left := strings.TrimSpace(cond[:idx]) + right := strings.TrimSpace(cond[idx+2:]) + if strings.HasPrefix(left, "$") { + return left[1:], "!=", right + } + } + + return "", "", "" +} + +// extractEmbeddedVars extracts variable values embedded in a literal template. +// Example: template="-p $credential", actual="-p mypass" -> {credential: mypass}. +func extractEmbeddedVars(template, actual string, existingScope map[string]string) map[string]string { + result := make(map[string]string) + + pattern := template + for k, v := range existingScope { + pattern = strings.ReplaceAll(pattern, "$"+k, regexp.QuoteMeta(v)) + } + + varPattern := regexp.MustCompile(`\$(\w+)`) + varMatches := varPattern.FindAllStringSubmatchIndex(pattern, -1) + if len(varMatches) == 0 { + return result + } + + var regexParts strings.Builder + regexParts.WriteString("^") + lastEnd := 0 + var varNames []string + + for i, match := range varMatches { + varStart := match[0] + varEnd := match[1] + varName := pattern[match[2]:match[3]] + + if varStart > lastEnd { + regexParts.WriteString(regexp.QuoteMeta(pattern[lastEnd:varStart])) + } + + // Greedy for the last variable at end of string, non-greedy otherwise. + if i == len(varMatches)-1 && varEnd == len(pattern) { + regexParts.WriteString(`(.+)`) + } else { + regexParts.WriteString(`(.+?)`) + } + varNames = append(varNames, varName) + lastEnd = varEnd + } + if lastEnd < len(pattern) { + regexParts.WriteString(regexp.QuoteMeta(pattern[lastEnd:])) + } + regexParts.WriteString("$") + + re, err := regexp.Compile(regexParts.String()) + if err != nil { + return result + } + + matches := re.FindStringSubmatch(actual) + if matches == nil { + return result + } + + for i, name := range varNames { + if i+1 < len(matches) { + result[name] = matches[i+1] + } + } + + return result +} diff --git a/internal/ui/match_test.go b/internal/ui/match_test.go new file mode 100644 index 0000000..183e95a --- /dev/null +++ b/internal/ui/match_test.go @@ -0,0 +1,124 @@ +package ui + +import ( + "testing" + + "github.com/gubarz/cheatmd/internal/parser" +) + +func TestBuildMatchPattern(t *testing.T) { + tests := []struct { + name string + cmd string + wantVarNames []string + }{ + { + name: "simple var", + cmd: "echo $name", + wantVarNames: []string{"name"}, + }, + { + name: "var with double quotes", + cmd: `curl "$url"`, + wantVarNames: []string{"url"}, + }, + { + name: "var with single quotes", + cmd: `ssh '$user'@host`, + wantVarNames: []string{"user"}, + }, + { + name: "multiple vars", + cmd: "nmap -p $port $host", + wantVarNames: []string{"port", "host"}, + }, + { + name: "no vars", + cmd: "echo hello world", + wantVarNames: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pattern, varNames := buildMatchPattern(tt.cmd) + + if pattern == nil && len(tt.wantVarNames) > 0 { + t.Fatalf("buildMatchPattern() returned nil pattern, expected vars %v", tt.wantVarNames) + } + + if len(varNames) != len(tt.wantVarNames) { + t.Fatalf("buildMatchPattern() varNames = %v, want %v", varNames, tt.wantVarNames) + } + + for i := range varNames { + if varNames[i] != tt.wantVarNames[i] { + t.Errorf("buildMatchPattern() varNames[%d] = %q, want %q", i, varNames[i], tt.wantVarNames[i]) + } + } + }) + } +} + +func TestContainsIgnoreCaseFast(t *testing.T) { + tests := []struct { + name string + s string + substr string + want bool + }{ + {"lowercase match", "Hello World", "hello", true}, + {"substr must be lowered", "Hello World", "WORLD", false}, + {"end of string", "Hello World", "world", true}, + {"all caps source", "NMAP", "nmap", true}, + {"substr longer than s", "short", "longer string", false}, + {"empty source", "", "x", false}, + {"empty substr", "anything", "", true}, + {"mixed case", "CasE MiXeD", "case mixed", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := containsIgnoreCaseFast(tt.s, tt.substr) + if got != tt.want { + t.Errorf("containsIgnoreCaseFast(%q, %q) = %v, want %v", tt.s, tt.substr, got, tt.want) + } + }) + } +} + +func TestCheatItemMatchesQuery(t *testing.T) { + cheat := &parser.Cheat{ + File: "/cheats/networking/nmap.md", + Header: "Port Scan", + Description: "Scan common ports", + Command: "nmap -sV $target", + Tags: []string{"recon", "pentest"}, + } + item := newCheatItem(cheat) + + tests := []struct { + name string + words []string + want bool + }{ + {"matches folder", []string{"network"}, true}, + {"matches file", []string{"nmap"}, true}, + {"matches header", []string{"port"}, true}, + {"matches description", []string{"common"}, true}, + {"matches command", []string{"nmap"}, true}, + {"matches tag", []string{"pentest"}, true}, + {"matches multiple words", []string{"nmap", "port"}, true}, + {"no match", []string{"gobuster"}, false}, + {"partial multi match fails", []string{"nmap", "gobuster"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := item.matchesQuery(tt.words) + if got != tt.want { + t.Errorf("matchesQuery(%v) = %v, want %v", tt.words, got, tt.want) + } + }) + } +} diff --git a/internal/ui/resolve.go b/internal/ui/resolve.go index 5d3a8d0..e117cdd 100644 --- a/internal/ui/resolve.go +++ b/internal/ui/resolve.go @@ -17,7 +17,7 @@ import ( // ============================================================================ // Run launches the Bubble Tea TUI interface -func Run(index *parser.CheatIndex, exec *executor.Executor, initialQuery, matchCmd string) error { +func Run(index *parser.CheatIndex, exec Executor, initialQuery, matchCmd string) error { return RunTUI(index, exec, initialQuery, matchCmd) } @@ -25,6 +25,26 @@ func Run(index *parser.CheatIndex, exec *executor.Executor, initialQuery, matchC // Variable Resolution // ============================================================================ +// SelectOptions holds display options for selection +type SelectOptions struct { + Delimiter string + Column int // 1-indexed, 0 = all (display column) + SelectColumn int // 1-indexed, 0 = no extraction (return full/original line) + MapCmd string // command to transform selected value +} + +// getDisplayColumn extracts the display column from a line +func getDisplayColumn(line, delimiter string, column int) string { + if delimiter == "" || column == 0 { + return line + } + parts := strings.Split(line, delimiter) + if column > 0 && column <= len(parts) { + return strings.TrimSpace(parts[column-1]) + } + return line +} + // varState tracks a variable and its resolved value type varState struct { def parser.VarDef // The selected/active definition @@ -247,7 +267,7 @@ func extractCustomHeader(selectorArgs string) string { // ============================================================================ // executeOutput handles the final command based on output mode -func executeOutput(command string, exec *executor.Executor) error { +func executeOutput(command string, exec Executor) error { // Apply hooks finalCmd := command if preHook := config.GetPreHook(); preHook != "" { diff --git a/internal/ui/run.go b/internal/ui/run.go new file mode 100644 index 0000000..24b0740 --- /dev/null +++ b/internal/ui/run.go @@ -0,0 +1,156 @@ +package ui + +import ( + "fmt" + "os" + "os/exec" + "runtime" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/gubarz/cheatmd/internal/config" + "github.com/gubarz/cheatmd/internal/parser" +) + +// getTTY returns file handles for TUI input/output. Uses /dev/tty to bypass +// shell pipes and command substitution when stdout is not a terminal. +func getTTY() (in *os.File, out *os.File, cleanup func()) { + var closers []func() + + if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) == 0 { + // stdout is NOT a terminal - we're being captured. + out, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0) + if err != nil { + out = os.Stderr + } else { + closers = append(closers, func() { out.Close() }) + } + + in, err := os.OpenFile("/dev/tty", os.O_RDONLY, 0) + if err != nil { + in = os.Stdin + } else { + closers = append(closers, func() { in.Close() }) + } + + lipgloss.SetDefaultRenderer(lipgloss.NewRenderer(out)) + + return in, out, func() { + for _, c := range closers { + c() + } + } + } + + return os.Stdin, os.Stdout, func() {} +} + +// RunTUI launches the Bubble Tea interface (unified, no flicker). +func RunTUI(index *parser.CheatIndex, exec Executor, initialQuery, matchCmd string) error { + requireCheatBlock := config.GetRequireCheatBlock() + autoSelect := config.GetAutoSelect() + + cheats := filterCheatsByConfig(index.Cheats, requireCheatBlock) + if len(cheats) == 0 { + return fmt.Errorf("no cheats found") + } + + m := newMainModel(cheats, index, exec) + + if matchCmd != "" { + if matched := findMatchingCheat(cheats, matchCmd); matched != nil { + m.selected = matched + prefillScopeFromMatch(matched, matchCmd) + inferDependentVars(matched, index) + m.startVarResolutionInternal() + + if m.phase != phaseVarResolve { + finalCmd := exec.BuildFinalCommand(m.selected) + return executeOutput(finalCmd, exec) + } + + 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 { + initialQuery = matchCmd + } + } + + if initialQuery != "" { + m.textInput.SetValue(initialQuery) + m.filterCheats() + + if autoSelect && len(m.filtered) == 1 { + m.selected = m.filtered[0].cheat + m.startVarResolutionInternal() + + if m.phase != phaseVarResolve { + finalCmd := exec.BuildFinalCommand(m.selected) + return executeOutput(finalCmd, exec) + } + } + } + + ttyIn, ttyOut, cleanup := getTTY() + RefreshStyles() + p := tea.NewProgram(&m, tea.WithAltScreen(), tea.WithOutput(ttyOut), tea.WithInput(ttyIn)) + finalModel, err := p.Run() + cleanup() + + if err != nil { + return err + } + + result := finalModel.(*mainModel) + if result.quitting && result.selected == nil { + return nil + } + if result.selected == nil { + return nil + } + + finalCmd := exec.BuildFinalCommand(result.selected) + return executeOutput(finalCmd, exec) +} + +// filterCheatsByConfig returns cheats matching configuration. When +// requireCheatBlock is true, cheats without a block are +// excluded. +func filterCheatsByConfig(cheats []*parser.Cheat, requireCheatBlock bool) []*parser.Cheat { + if !requireCheatBlock { + return cheats + } + + result := make([]*parser.Cheat, 0, len(cheats)) + for _, cheat := range cheats { + if cheat.HasCheatBlock { + result = append(result, cheat) + } + } + return result +} + +// openFileInViewer opens filePath in the configured editor or system default. +func openFileInViewer(filePath string) { + var cmd *exec.Cmd + + if editor := config.GetEditor(); editor != "" { + cmd = exec.Command(editor, filePath) + } else { + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", filePath) + case "windows": + cmd = exec.Command("cmd", "/c", "start", "", filePath) + default: + cmd = exec.Command("xdg-open", filePath) + } + } + _ = cmd.Start() +} diff --git a/internal/ui/selector_test.go b/internal/ui/selector_test.go new file mode 100644 index 0000000..da35040 --- /dev/null +++ b/internal/ui/selector_test.go @@ -0,0 +1,301 @@ +package ui + +import ( + "reflect" + "testing" +) + +func TestParseShellArgs(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + { + name: "double quoted delimiter", + input: `--delimiter "\t" --column 2`, + want: []string{"--delimiter", `\t`, "--column", "2"}, + }, + { + name: "single quoted", + input: `--delimiter '\t' --column 1`, + want: []string{"--delimiter", `\t`, "--column", "1"}, + }, + { + name: "double quoted with space", + input: `--header "Pick a host" --column 1`, + want: []string{"--header", "Pick a host", "--column", "1"}, + }, + { + name: "no args", + input: "", + want: nil, + }, + { + name: "extra whitespace", + input: ` --delimiter "," `, + want: []string{"--delimiter", ","}, + }, + { + name: "map command", + input: `--map "awk '{print $1}'"`, + want: []string{"--map", "awk '{print $1}'"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseShellArgs(tt.input) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseShellArgs(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseSelectorOpts(t *testing.T) { + tests := []struct { + name string + args string + want SelectOptions + }{ + { + name: "delimiter and column", + args: `--delimiter "\t" --column 2`, + want: SelectOptions{Delimiter: `\t`, Column: 2}, + }, + { + name: "all options", + args: `--delimiter "," --column 2 --select-column 1 --map "cut -d: -f1"`, + want: SelectOptions{Delimiter: ",", Column: 2, SelectColumn: 1, MapCmd: "cut -d: -f1"}, + }, + { + name: "header is ignored in SelectOptions", + args: `--header "Pick one" --delimiter ":"`, + want: SelectOptions{Delimiter: ":"}, + }, + { + name: "empty args", + args: "", + want: SelectOptions{}, + }, + { + name: "select-column only", + args: `--select-column 3`, + want: SelectOptions{SelectColumn: 3}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseSelectorOpts(tt.args) + if got != tt.want { + t.Errorf("parseSelectorOpts(%q) = %+v, want %+v", tt.args, got, tt.want) + } + }) + } +} + +func TestGetDisplayColumn(t *testing.T) { + tests := []struct { + name string + line string + delimiter string + column int + want string + }{ + { + name: "tab delimited column 2", + line: "192.168.1.1\twebserver\tlinux", + delimiter: "\t", + column: 2, + want: "webserver", + }, + { + name: "comma delimited column 1", + line: "admin,password123,active", + delimiter: ",", + column: 1, + want: "admin", + }, + { + name: "column out of range returns full line", + line: "one\ttwo", + delimiter: "\t", + column: 5, + want: "one\ttwo", + }, + { + name: "column 0 returns full line", + line: "one\ttwo\tthree", + delimiter: "\t", + column: 0, + want: "one\ttwo\tthree", + }, + { + name: "empty delimiter returns full line", + line: "one\ttwo", + delimiter: "", + column: 2, + want: "one\ttwo", + }, + { + name: "trims whitespace", + line: "one\t two \tthree", + delimiter: "\t", + column: 2, + want: "two", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getDisplayColumn(tt.line, tt.delimiter, tt.column) + if got != tt.want { + t.Errorf("getDisplayColumn(%q, %q, %d) = %q, want %q", tt.line, tt.delimiter, tt.column, got, tt.want) + } + }) + } +} + +func TestApplyMapTransform_SelectColumn(t *testing.T) { + // Test select-column extraction (without --map, which requires shell) + tests := []struct { + name string + value string + opts SelectOptions + want string + }{ + { + name: "extract column 1 from tab-delimited", + value: "192.168.1.1\twebserver\tlinux", + opts: SelectOptions{Delimiter: "\t", SelectColumn: 1}, + want: "192.168.1.1", + }, + { + name: "extract column 2 from comma-delimited", + value: "admin,secret,active", + opts: SelectOptions{Delimiter: ",", SelectColumn: 2}, + want: "secret", + }, + { + name: "column out of range returns original", + value: "one\ttwo", + opts: SelectOptions{Delimiter: "\t", SelectColumn: 10}, + want: "one\ttwo", + }, + { + name: "no select-column returns original", + value: "one\ttwo\tthree", + opts: SelectOptions{Delimiter: "\t", SelectColumn: 0}, + want: "one\ttwo\tthree", + }, + { + name: "no delimiter returns original", + value: "one\ttwo", + opts: SelectOptions{Delimiter: "", SelectColumn: 1}, + want: "one\ttwo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := applyMapTransform(tt.value, tt.opts) + if got != tt.want { + t.Errorf("applyMapTransform(%q, %+v) = %q, want %q", tt.value, tt.opts, got, tt.want) + } + }) + } +} + +func TestSplitLines(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + { + name: "simple lines", + input: "one\ntwo\nthree", + want: []string{"one", "two", "three"}, + }, + { + name: "empty lines filtered", + input: "one\n\ntwo\n\n\nthree\n", + want: []string{"one", "two", "three"}, + }, + { + name: "whitespace trimmed", + input: " one \n\ttwo\t\n three ", + want: []string{"one", "two", "three"}, + }, + { + name: "carriage returns handled", + input: "one\r\ntwo\r\nthree\r\n", + want: []string{"one", "two", "three"}, + }, + { + name: "empty input", + input: "", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitLines(tt.input) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("splitLines(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// TestEndToEnd_DelimiterColumnPipeline tests the full pipeline: +// shell output -> splitLines -> parseSelectorOpts -> getDisplayColumn -> applyMapTransform +func TestEndToEnd_DelimiterColumnPipeline(t *testing.T) { + // Simulate: var host = echo -e "192.168.1.1,webserver\n10.0.0.1,db" --- --delimiter "," --column 2 --select-column 1 + shellOutput := "192.168.1.1,webserver\n10.0.0.1,db" + selectorArgs := `--delimiter "," --column 2 --select-column 1` + + // 1. Split shell output into lines + lines := splitLines(shellOutput) + if len(lines) != 2 { + t.Fatalf("splitLines() = %d lines, want 2", len(lines)) + } + + // 2. Parse selector options + opts := parseSelectorOpts(selectorArgs) + if opts.Delimiter != "," { + t.Errorf("opts.Delimiter = %q, want %q", opts.Delimiter, ",") + } + if opts.Column != 2 { + t.Errorf("opts.Column = %d, want 2", opts.Column) + } + if opts.SelectColumn != 1 { + t.Errorf("opts.SelectColumn = %d, want 1", opts.SelectColumn) + } + + // 3. Display column (what the user sees in the picker) + display0 := getDisplayColumn(lines[0], opts.Delimiter, opts.Column) + display1 := getDisplayColumn(lines[1], opts.Delimiter, opts.Column) + if display0 != "webserver" { + t.Errorf("display[0] = %q, want %q", display0, "webserver") + } + if display1 != "db" { + t.Errorf("display[1] = %q, want %q", display1, "db") + } + + // 4. Select column (the value that gets substituted into the command) + // User picks line 0 -> applyMapTransform extracts select-column 1 + selected := applyMapTransform(lines[0], opts) + if selected != "192.168.1.1" { + t.Errorf("applyMapTransform() = %q, want %q", selected, "192.168.1.1") + } + + // User picks line 1 + selected2 := applyMapTransform(lines[1], opts) + if selected2 != "10.0.0.1" { + t.Errorf("applyMapTransform() = %q, want %q", selected2, "10.0.0.1") + } +} diff --git a/internal/ui/substitute_search.go b/internal/ui/substitute_search.go new file mode 100644 index 0000000..2defa30 --- /dev/null +++ b/internal/ui/substitute_search.go @@ -0,0 +1,288 @@ +package ui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/gubarz/cheatmd/internal/config" +) + +// substituteSearchState holds the overlay state for substitute search. +// The user enters via the configured key during phaseVarResolve, picks an +// env/history value, and returns to phaseVarResolve with the chosen value +// loaded into the var prompt. +type substituteSearchState struct { + options []substituteOption + filtered []substituteOption + cursor int + offset int + prevInput string // textInput value before entering the overlay + prevCursor int + prevOffset int +} + +// updateSubstituteSearch handles updates while the substitute overlay is open. +func (m *mainModel) updateSubstituteSearch(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if cmd := m.handleSubstituteSearchKey(msg); cmd != nil { + return m, 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()) { + return m, nil + } + } + + prev := m.textInput.Value() + var tiCmd tea.Cmd + m.textInput, tiCmd = m.textInput.Update(msg) + if m.textInput.Value() != prev { + m.filterSubstituteOptions() + } + 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() + if len(sources) == 0 { + return false + } + opts := collectSubstituteOptions(sources) + if len(opts) == 0 { + return false + } + m.subState = &substituteSearchState{ + options: opts, + filtered: opts, + prevInput: m.textInput.Value(), + prevCursor: m.cursor, + prevOffset: m.offset, + } + m.textInput.SetValue("") + m.textInput.Placeholder = "Search env / history..." + m.cursor = 0 + m.offset = 0 + m.phase = phaseSubstituteSearch + return true +} + +// exitSubstituteSearch returns to phaseVarResolve. If accept is true the +// currently highlighted option's Value is loaded into the var prompt; +// otherwise the previous input is restored. +func (m *mainModel) exitSubstituteSearch(accept bool) { + if m.subState == nil { + m.phase = phaseVarResolve + return + } + + if accept && m.subState.cursor < len(m.subState.filtered) { + m.textInput.SetValue(m.subState.filtered[m.subState.cursor].Value) + m.textInput.CursorEnd() + } else { + m.textInput.SetValue(m.subState.prevInput) + m.textInput.CursorEnd() + } + m.cursor = m.subState.prevCursor + m.offset = m.subState.prevOffset + m.subState = nil + m.textInput.Placeholder = "Type to filter or enter value..." + m.phase = phaseVarResolve + + // Refilter var options if we're in select mode (the input may have changed). + if m.varState != nil && !m.varState.isPromptOnly { + m.filterVarOptions() + } +} + +// filterSubstituteOptions applies the textInput's current value as a +// space-separated AND fuzzy filter over the substitute option list. +func (m *mainModel) filterSubstituteOptions() { + if m.subState == nil { + return + } + query := strings.ToLower(strings.TrimSpace(m.textInput.Value())) + if query == "" { + m.subState.filtered = m.subState.options + m.subState.cursor = 0 + m.subState.offset = 0 + return + } + words := strings.Fields(query) + result := make([]substituteOption, 0, len(m.subState.options)) + for _, opt := range m.subState.options { + hay := strings.ToLower(opt.Display) + if matchesAllWords(hay, words) { + result = append(result, opt) + } + } + m.subState.filtered = result + if m.subState.cursor >= len(result) { + m.subState.cursor = max(0, len(result)-1) + } + if m.subState.offset > m.subState.cursor { + m.subState.offset = m.subState.cursor + } +} + +// moveSubstituteCursor clamps the cursor; offset is reconciled by scrollWindow +// at render time using the actual list height. +func (m *mainModel) moveSubstituteCursor(delta int) { + if m.subState == nil { + return + } + m.subState.cursor += delta + m.subState.cursor = clamp(m.subState.cursor, 0, max(0, len(m.subState.filtered)-1)) +} + +// handleSubstituteSearchKey processes keys while in phaseSubstituteSearch. +func (m *mainModel) handleSubstituteSearchKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "ctrl+c": + m.quitting = true + m.selected = nil + return tea.Quit + case "esc": + m.exitSubstituteSearch(false) + return tea.ClearScreen + case "enter": + m.exitSubstituteSearch(true) + return tea.ClearScreen + case "up", "ctrl+p": + m.moveSubstituteCursor(-1) + return nil + case "down", "ctrl+n": + m.moveSubstituteCursor(1) + return nil + case "pgup": + m.moveSubstituteCursor(-10) + return nil + case "pgdown": + m.moveSubstituteCursor(10) + return nil + } + return nil +} + +// renderSubstituteSearch renders the env/history picker overlay using the +// same layout shape as renderCheatSelect: fixed-height preview at top, +// scrolling list in the middle, padding, divider + info + input at bottom. +func (m *mainModel) renderSubstituteSearch() string { + width := max(m.width, 80) + height := m.height + if height < 1 { + height = 24 + } + + inputLines := 3 // divider + info + input + + // Preview block is just two lines: title + divider. Match the cheat + // select pattern that uses a padded fixed-height block. + previewHeight := 2 + preview := m.renderSubstitutePreview(width, previewHeight) + + previewLines := countLines(preview) + listHeight := max(height-previewLines-inputLines, 1) + list := m.renderSubstituteList(listHeight) + + return renderWindowLayout(height, preview, list, m.renderSubstituteInput(width)) +} + +// renderSubstitutePreview renders the title header at fixed height (padded), +// followed by a divider. Mirrors renderPreviewWithHeight's shape. +func (m *mainModel) renderSubstitutePreview(width, maxLines int) string { + b := getBuilder() + defer putBuilder(b) + lines := 0 + + if lines < maxLines { + var varName string + if m.varState != nil && m.varState.currentIdx < len(m.varState.vars) { + varName = m.varState.vars[m.varState.currentIdx].def.Name + } + b.WriteString(styles.Header.Render("Substitute search")) + if varName != "" { + b.WriteString(" ") + b.WriteString(styles.Dim.Render("→ ")) + b.WriteString(styles.Cursor.Render("$" + varName)) + } + b.WriteString("\n") + lines++ + } + + for lines < maxLines { + b.WriteString("\n") + lines++ + } + + b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) + b.WriteString("\n") + return b.String() +} + +// renderSubstituteList renders the scrolling list, mirrors renderList shape. +// Each row is hard-truncated to terminal width so long env values (e.g. PATH) +// can't wrap and push other rows off-screen. +func (m *mainModel) renderSubstituteList(maxHeight int) string { + if m.subState == nil || len(m.subState.filtered) == 0 { + return "" + } + + start, end := scrollWindow(m.subState.cursor, len(m.subState.filtered), maxHeight, &m.subState.offset) + width := max(m.width, 80) + // 2 chars for the "▶ " or " " prefix. + maxLen := max(width-2, 10) + + b := getBuilder() + defer putBuilder(b) + for i := start; i < end; i++ { + opt := m.subState.filtered[i] + display := truncateString(opt.Display, maxLen) + if i == m.subState.cursor { + b.WriteString(styles.Cursor.Render("▶ ")) + b.WriteString(styles.Selected.Render(styles.Command.Render(display))) + } else { + b.WriteString(" ") + b.WriteString(styles.Command.Render(display)) + } + b.WriteString("\n") + } + return b.String() +} + +// renderSubstituteInput renders the bottom divider + hint + input, mirrors +// renderInput's shape. +func (m *mainModel) renderSubstituteInput(width int) string { + b := getBuilder() + defer putBuilder(b) + b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) + b.WriteString("\n") + matchCount := 0 + if m.subState != nil { + matchCount = len(m.subState.filtered) + } + b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d matches", matchCount))) + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("ESC cancel")) + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("Enter use value")) + b.WriteString("\n") + b.WriteString(m.textInput.View()) + return b.String() +} diff --git a/internal/ui/substitute.go b/internal/ui/substitute_sources.go similarity index 100% rename from internal/ui/substitute.go rename to internal/ui/substitute_sources.go diff --git a/internal/ui/var_models.go b/internal/ui/var_models.go deleted file mode 100644 index 9addc52..0000000 --- a/internal/ui/var_models.go +++ /dev/null @@ -1,496 +0,0 @@ -package ui - -import ( - "os" - "strings" - "time" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" -) - -// ============================================================================ -// Variable Select Model - For selecting from a list of options -// ============================================================================ - -// SelectOptions holds display options for selection -type SelectOptions struct { - Delimiter string - Column int // 1-indexed, 0 = all (display column) - SelectColumn int // 1-indexed, 0 = no extraction (return full/original line) - MapCmd string // command to transform selected value -} - -// varSelectModel is for selecting from a list of options -type varSelectModel struct { - varName string - header string - customHeader string - options []string // original values - displayOpts []string // what to display (may be transformed by delimiter/column) - filtered []filteredOption - cursor int - offset int // viewport scroll offset - textInput textinput.Model - width int - height int - selected string - cancelled bool - selectOpts SelectOptions - filePath string // source file for opening with ctrl+o -} - -// filteredOption pairs display text with original value -type filteredOption struct { - display string - original string - searchText string // pre-lowercased for fast filtering -} - -// newVarSelectModelWithOpts creates a variable selection model with display options -func newVarSelectModelWithOpts(varName string, options []string, header, customHeader, prefill, filePath string, opts SelectOptions) varSelectModel { - ti := textinput.New() - ti.Placeholder = "Type to filter or enter custom value..." - ti.Focus() - ti.CharLimit = 512 - ti.Width = 60 - - if prefill != "" { - ti.SetValue(prefill) - } - - m := varSelectModel{ - varName: varName, - header: header, - customHeader: customHeader, - options: options, - filtered: nil, // Will be populated lazily - textInput: ti, - selectOpts: opts, - filePath: filePath, - } - - // Initial filter (builds filtered list) - m.filterOptions() - - return m -} - -// getDisplayColumn extracts the display column from a line -func getDisplayColumn(line, delimiter string, column int) string { - if delimiter == "" || column == 0 { - return line - } - parts := strings.Split(line, delimiter) - if column > 0 && column <= len(parts) { - return strings.TrimSpace(parts[column-1]) - } - return line -} - -// Init implements tea.Model -func (m varSelectModel) Init() tea.Cmd { - return textinput.Blink -} - -// Update implements tea.Model -func (m varSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - if cmd := m.handleKeyPress(msg); cmd != nil { - return m, cmd - } - case tea.WindowSizeMsg: - m.width = maxInt(msg.Width, 1) - m.height = maxInt(msg.Height, 1) - m.textInput.Width = safeTextInputWidth(m.width) - } - - var cmd tea.Cmd - m.textInput, cmd = m.textInput.Update(msg) - m.filterOptions() - - return m, cmd -} - -// handleKeyPress processes keyboard input -func (m *varSelectModel) handleKeyPress(msg tea.KeyMsg) tea.Cmd { - switch msg.String() { - case "ctrl+c": - m.cancelled = true - m.selected = "__EXIT__" - return tea.Quit - case "esc": - m.cancelled = true - return tea.Quit - case "enter": - if m.cursor < len(m.filtered) { - m.selected = m.filtered[m.cursor].original // Return original value, not display - } else { - m.selected = m.textInput.Value() - } - return tea.Quit - case "up", "ctrl+p": - m.moveCursor(-1) - case "down", "ctrl+n": - m.moveCursor(1) - case "tab": - if m.cursor < len(m.filtered) { - m.textInput.SetValue(m.filtered[m.cursor].display) - } - case "ctrl+o": - if m.filePath != "" { - openFileInViewer(m.filePath) - } - } - return nil -} - -// moveCursor moves the cursor by delta -func (m *varSelectModel) moveCursor(delta int) { - m.cursor += delta - m.cursor = clamp(m.cursor, 0, maxInt(0, len(m.filtered)-1)) -} - -// filterOptions filters options based on the input query -func (m *varSelectModel) filterOptions() { - query := strings.TrimSpace(strings.ToLower(m.textInput.Value())) - const maxResults = 1000 // Limit results for performance - - if query == "" { - // No filter - show first maxResults options (lazy) - limit := len(m.options) - if limit > maxResults { - limit = maxResults - } - m.filtered = make([]filteredOption, limit) - for i := 0; i < limit; i++ { - opt := m.options[i] - m.filtered[i] = filteredOption{ - display: getDisplayColumn(opt, m.selectOpts.Delimiter, m.selectOpts.Column), - original: opt, - } - } - } else { - words := strings.Fields(query) - m.filtered = make([]filteredOption, 0, maxResults) - for _, opt := range m.options { - optLower := strings.ToLower(opt) - if matchesAllWords(optLower, words) { - m.filtered = append(m.filtered, filteredOption{ - display: getDisplayColumn(opt, m.selectOpts.Delimiter, m.selectOpts.Column), - original: opt, - }) - if len(m.filtered) >= maxResults { - break - } - } - } - } - - m.cursor = clamp(m.cursor, 0, maxInt(0, len(m.filtered)-1)) -} - -// View implements tea.Model -func (m varSelectModel) View() string { - width := maxInt(m.width, 80) - height := maxInt(m.height, 24) - - header := m.renderHeader() - bottom := m.renderBottom(width) - - headerLines := countLines(header) - bottomLines := countLines(bottom) - spacing := maxInt(height-headerLines-bottomLines, 0) - - var b strings.Builder - b.WriteString(header) - b.WriteString(strings.Repeat("\n", spacing)) - b.WriteString(bottom) - - return b.String() -} - -// renderHeader renders the header section -func (m varSelectModel) renderHeader() string { - width := maxInt(m.width, 80) - var b strings.Builder - b.WriteString(m.header) - b.WriteString("\n") - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - - if m.customHeader != "" { - b.WriteString(styles.Cursor.Render(m.customHeader)) - b.WriteString(styles.Dim.Render(" • Ctrl+O open • ESC back • Enter select")) - } else { - b.WriteString(styles.Dim.Render("Select value for ")) - b.WriteString(styles.Cursor.Render("$" + m.varName)) - b.WriteString(styles.Dim.Render(" • Ctrl+O open • ESC back • Enter select")) - } - - return b.String() -} - -// renderBottom renders the options list and input -func (m *varSelectModel) renderBottom(width int) string { - var b strings.Builder - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - - // Options list - listHeight := minInt(10, len(m.filtered)) - start, end := scrollWindow(m.cursor, len(m.filtered), listHeight, &m.offset) - - for i := start; i < end; i++ { - opt := m.filtered[i] - if i == m.cursor { - b.WriteString(styles.Cursor.Render("▶ ")) - b.WriteString(styles.Selected.Render(styles.Command.Render(opt.display))) - } else { - b.WriteString(" ") - b.WriteString(styles.Command.Render(opt.display)) - } - b.WriteString("\n") - } - - // Footer - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - b.WriteString(m.textInput.View()) - - return b.String() -} - -// ============================================================================ -// Variable Input Model - For entering a custom value -// ============================================================================ - -// varInputModel is for entering a custom value (no options list) -type varInputModel struct { - varName string - header string - customHeader string - textInput textinput.Model - width int - height int - value string - cancelled bool - filePath string // source file for opening with ctrl+o -} - -// newVarInputModel creates a new variable input model -func newVarInputModel(varName, header, customHeader, prefill, filePath string) varInputModel { - ti := textinput.New() - ti.Placeholder = "Enter value..." - ti.Focus() - ti.CharLimit = 512 - ti.Width = 60 - - if prefill != "" { - ti.SetValue(prefill) - } - - return varInputModel{ - varName: varName, - header: header, - customHeader: customHeader, - textInput: ti, - filePath: filePath, - } -} - -// Init implements tea.Model -func (m varInputModel) Init() tea.Cmd { - return textinput.Blink -} - -// Update implements tea.Model -func (m varInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - if cmd := m.handleKeyPress(msg); cmd != nil { - return m, cmd - } - case tea.WindowSizeMsg: - m.width = maxInt(msg.Width, 1) - m.height = maxInt(msg.Height, 1) - m.textInput.Width = safeTextInputWidth(m.width) - } - - var cmd tea.Cmd - m.textInput, cmd = m.textInput.Update(msg) - return m, cmd -} - -// handleKeyPress processes keyboard input -func (m *varInputModel) handleKeyPress(msg tea.KeyMsg) tea.Cmd { - switch msg.String() { - case "ctrl+c": - m.cancelled = true - m.value = "__EXIT__" - return tea.Quit - case "esc": - m.cancelled = true - return tea.Quit - case "enter": - m.value = m.textInput.Value() - return tea.Quit - case "ctrl+o": - if m.filePath != "" { - openFileInViewer(m.filePath) - } - } - return nil -} - -// View implements tea.Model -func (m varInputModel) View() string { - width := maxInt(m.width, 80) - height := maxInt(m.height, 24) - - header := m.renderHeader() - bottom := m.renderBottom(width) - - headerLines := countLines(header) - bottomLines := countLines(bottom) - spacing := maxInt(height-headerLines-bottomLines, 0) - - var b strings.Builder - b.WriteString(header) - b.WriteString(strings.Repeat("\n", spacing)) - b.WriteString(bottom) - - return b.String() -} - -// renderHeader renders the header section -func (m varInputModel) renderHeader() string { - width := maxInt(m.width, 80) - var b strings.Builder - b.WriteString(m.header) - b.WriteString("\n") - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - - if m.customHeader != "" { - b.WriteString(styles.Cursor.Render(m.customHeader)) - b.WriteString(styles.Dim.Render(" • Ctrl+O open • ESC back • Enter confirm")) - } else { - b.WriteString(styles.Dim.Render("Enter value for ")) - b.WriteString(styles.Cursor.Render("$" + m.varName)) - b.WriteString(styles.Dim.Render(" • Ctrl+O open • ESC back • Enter confirm")) - } - - return b.String() -} - -// renderBottom renders the input section -func (m varInputModel) renderBottom(width int) string { - var b strings.Builder - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - b.WriteString(m.textInput.View()) - return b.String() -} - -// ============================================================================ -// Public API for Variable Resolution -// ============================================================================ - -// SelectWithTUI displays options for variable selection -// Returns (value, goBack, error) - if value is "__EXIT__" caller should exit completely -func SelectWithTUI(varName string, options []string, header, customHeader, prefill, filePath string) (string, bool, error) { - return SelectWithTUIOptions(varName, options, header, customHeader, prefill, filePath, SelectOptions{}) -} - -// SelectWithTUIOptions displays options for variable selection with display options -// Returns (value, goBack, error) - if value is "__EXIT__" caller should exit completely -func SelectWithTUIOptions(varName string, options []string, header, customHeader, prefill, filePath string, opts SelectOptions) (string, bool, error) { - debug := os.Getenv("CHEATMD_DEBUG") != "" - var start time.Time - - if debug { - start = time.Now() - } - ttyIn, ttyOut, cleanup := getTTY() - if debug { - os.Stderr.WriteString("[DEBUG] getTTY: " + time.Since(start).String() + "\n") - start = time.Now() - } - - RefreshStyles() // Refresh after getTTY sets up the renderer - if debug { - os.Stderr.WriteString("[DEBUG] RefreshStyles: " + time.Since(start).String() + "\n") - start = time.Now() - } - defer cleanup() - - m := newVarSelectModelWithOpts(varName, options, header, customHeader, prefill, filePath, opts) - if debug { - os.Stderr.WriteString("[DEBUG] newVarSelectModel: " + time.Since(start).String() + "\n") - start = time.Now() - } - - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithOutput(ttyOut), tea.WithInput(ttyIn)) - if debug { - os.Stderr.WriteString("[DEBUG] NewProgram: " + time.Since(start).String() + "\n") - start = time.Now() - } - - finalModel, err := p.Run() - if debug { - os.Stderr.WriteString("[DEBUG] Run: " + time.Since(start).String() + "\n") - } - if err != nil { - return "", false, err - } - - result := finalModel.(varSelectModel) - if result.selected == "__EXIT__" { - return "__EXIT__", false, nil - } - if result.cancelled { - return "", true, nil - } - - selected := applyMapTransform(result.selected, opts) - return selected, false, nil -} - -// PromptWithTUI displays an input prompt for variable entry -// Returns (value, goBack, error) - if value is "__EXIT__" caller should exit completely -func PromptWithTUI(varName, header, customHeader, prefill, filePath string) (string, bool, error) { - ttyIn, ttyOut, cleanup := getTTY() - RefreshStyles() // Refresh after getTTY sets up the renderer - defer cleanup() - - m := newVarInputModel(varName, header, customHeader, prefill, filePath) - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithOutput(ttyOut), tea.WithInput(ttyIn)) - - finalModel, err := p.Run() - if err != nil { - return "", false, err - } - - result := finalModel.(varInputModel) - if result.value == "__EXIT__" { - return "__EXIT__", false, nil - } - if result.cancelled { - return "", true, nil - } - return result.value, false, nil -} - -// ============================================================================ -// Additional Helpers -// ============================================================================ - -// minInt returns the smaller of a and b -func minInt(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/internal/ui/var_resolve.go b/internal/ui/var_resolve.go new file mode 100644 index 0000000..57e8d84 --- /dev/null +++ b/internal/ui/var_resolve.go @@ -0,0 +1,569 @@ +package ui + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/gubarz/cheatmd/internal/config" + "github.com/gubarz/cheatmd/internal/executor" +) + +// ============================================================================ +// Types +// ============================================================================ + +// shellResultMsg is sent when a shell command completes. +type shellResultMsg struct { + options []string + err error +} + +// ============================================================================ +// Lifecycle +// ============================================================================ + +// startVarResolution initiates variable resolution and returns a command. +func (m *mainModel) startVarResolution() tea.Cmd { + m.startVarResolutionInternal() + if m.phase != phaseVarResolve { + // No variables to resolve - finish immediately. + return tea.Quit + } + return m.prepareCurrentVar() +} + +// startVarResolutionInternal sets up variable resolution state. +func (m *mainModel) startVarResolutionInternal() { + cheat := m.selected + if cheat == nil { + return + } + + if cheat.Scope == nil { + cheat.Scope = make(map[string]string) + } + + vars := collectVariables(cheat, m.cheatIndex) + if len(vars) == 0 { + // No variables - stay in cheat select phase (will quit immediately). + return + } + + // Pre-fill from cheat.Scope (populated by --match) or environment. + for i := range vars { + varName := vars[i].def.Name + if scopeVal, ok := cheat.Scope[varName]; ok && scopeVal != "" { + vars[i].prefill = scopeVal + vars[i].skipAutoCont = true + } else if envVal := os.Getenv(varName); envVal != "" { + vars[i].prefill = envVal + } + } + + m.varState = &varResolveState{ + cheat: cheat, + vars: vars, + currentIdx: 0, + } + m.phase = phaseVarResolve + + // Save query and reset text input for variable resolution. + m.lastQuery = m.textInput.Value() + m.textInput.SetValue("") + m.textInput.Placeholder = "Type to filter or enter value..." + m.cursor = 0 + m.offset = 0 +} + +// prepareCurrentVar prepares the current variable for display. May return a +// 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 { + m.selected.Scope[vs.def.Name] = vs.value + } + } + } + return tea.Quit + } + + vs := &m.varState.vars[m.varState.currentIdx] + + 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. + vs.resolved = true + vs.value = "" + m.varState.currentIdx++ + return m.prepareCurrentVar() + } + selectedDef = &vs.def + } + vs.def = *selectedDef + + // Auto-continue if the prefill is good enough. + autoContinue := config.GetAutoContinue() + if autoContinue && vs.prefill != "" && !vs.skipAutoCont { + vs.value = vs.prefill + vs.resolved = true + m.varState.currentIdx++ + return m.prepareCurrentVar() + } + + m.varState.customHeader = extractCustomHeader(vs.def.Args) + m.varState.selectOpts = 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) + if vs.skipAutoCont { + m.varState.isPromptOnly = true + m.varState.options = nil + m.varState.filtered = nil + m.textInput.SetValue(result) + m.textInput.CursorEnd() + return nil + } + vs.value = result + vs.resolved = true + m.varState.currentIdx++ + return m.prepareCurrentVar() + } + + // Prompt only. + if strings.TrimSpace(vs.def.Shell) == "" { + m.varState.isPromptOnly = true + m.varState.options = nil + m.varState.filtered = nil + if vs.prefill != "" { + m.textInput.SetValue(vs.prefill) + m.textInput.CursorEnd() + } + return nil + } + + // Run shell command asynchronously to get options. + shellCmd := executor.SubstituteVars(vs.def.Shell, scope) + return func() tea.Msg { + output, err := m.executor.RunShell(shellCmd) + if err != nil { + return shellResultMsg{nil, err} + } + lines := splitLines(output) + return shellResultMsg{lines, nil} + } +} + +// 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++ + } + } + } + return opts +} + +// ============================================================================ +// Update +// ============================================================================ + +// updateVarResolve handles updates during variable resolution phase. +func (m *mainModel) updateVarResolve(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if cmd := m.handleVarResolveKey(msg); cmd != nil { + return m, cmd + } + case shellResultMsg: + return m.handleShellResult(msg) + } + + prevQuery := m.textInput.Value() + var tiCmd tea.Cmd + m.textInput, tiCmd = m.textInput.Update(msg) + + if m.textInput.Value() != prevQuery && !m.varState.isPromptOnly { + m.filterVarOptions() + } + + return m, tiCmd +} + +// handleShellResult processes the result of a shell command. +func (m *mainModel) handleShellResult(msg shellResultMsg) (tea.Model, tea.Cmd) { + if m.varState == nil { + return m, nil + } + + vs := &m.varState.vars[m.varState.currentIdx] + + if msg.err != nil { + m.varState.shellErr = msg.err + m.varState.isPromptOnly = true + m.varState.options = nil + m.varState.filtered = nil + m.textInput.SetValue(vs.prefill) + return m, nil + } + + m.varState.options = msg.options + m.varState.shellErr = nil + + switch len(msg.options) { + case 0: + m.varState.isPromptOnly = true + if vs.prefill != "" { + m.textInput.SetValue(vs.prefill) + m.textInput.CursorEnd() + } + case 1: + m.varState.isPromptOnly = true + prefill := vs.prefill + if prefill == "" { + prefill = applyMapTransform(msg.options[0], m.varState.selectOpts) + } + m.textInput.SetValue(prefill) + m.textInput.CursorEnd() + default: + m.varState.isPromptOnly = false + m.buildVarFilteredList() + if vs.prefill != "" { + m.textInput.SetValue(vs.prefill) + m.textInput.CursorEnd() + } + m.filterVarOptions() + m.cursor = 0 + m.offset = 0 + } + + return m, nil +} + +// buildVarFilteredList builds the filtered list from options. +func (m *mainModel) buildVarFilteredList() { + if m.varState == nil { + return + } + + opts := m.varState.selectOpts + m.varState.filtered = make([]FilteredOption, len(m.varState.options)) + + for i, opt := range m.varState.options { + display := getDisplayColumn(opt, opts.Delimiter, opts.Column) + m.varState.filtered[i] = FilteredOption{ + Display: display, + Original: opt, + SearchText: strings.ToLower(display), + } + } +} + +// filterVarOptions filters the variable options based on search query. +func (m *mainModel) filterVarOptions() { + if m.varState == nil || m.varState.isPromptOnly { + return + } + + query := strings.ToLower(strings.TrimSpace(m.textInput.Value())) + if query == "" { + m.buildVarFilteredList() + } else { + words := strings.Fields(query) + opts := m.varState.selectOpts + result := make([]FilteredOption, 0, len(m.varState.options)) + + for _, opt := range m.varState.options { + display := getDisplayColumn(opt, opts.Delimiter, opts.Column) + searchText := strings.ToLower(display) + if matchesAllWords(searchText, words) { + result = append(result, FilteredOption{ + Display: display, + Original: opt, + SearchText: searchText, + }) + } + } + m.varState.filtered = result + } + + m.cursor = clamp(m.cursor, 0, max(0, len(m.varState.filtered)-1)) +} + +// 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.cursor = 0 + m.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.cursor = 0 + m.offset = 0 + return nil + case "enter": + return m.acceptVarValue() + case "up", "ctrl+p": + if !m.varState.isPromptOnly { + m.moveVarCursor(-1) + } + case "down", "ctrl+n": + if !m.varState.isPromptOnly { + m.moveVarCursor(1) + } + case "pgup": + if !m.varState.isPromptOnly { + m.moveVarCursor(-10) + } + case "pgdown": + if !m.varState.isPromptOnly { + m.moveVarCursor(10) + } + case "tab": + if !m.varState.isPromptOnly && m.cursor < len(m.varState.filtered) { + m.textInput.SetValue(m.varState.filtered[m.cursor].Display) + } + 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) + } + } + } + return nil +} + +// moveVarCursor moves the cursor during variable selection. +func (m *mainModel) moveVarCursor(delta int) { + if m.varState == nil { + return + } + m.cursor += delta + m.cursor = clamp(m.cursor, 0, max(0, len(m.varState.filtered)-1)) +} + +// acceptVarValue accepts the current value and moves to next variable. +func (m *mainModel) acceptVarValue() tea.Cmd { + if m.varState == nil { + return tea.Quit + } + + vs := &m.varState.vars[m.varState.currentIdx] + var value string + + if m.varState.isPromptOnly { + value = m.textInput.Value() + } else if m.cursor < len(m.varState.filtered) { + selected := m.varState.filtered[m.cursor].Original + value = applyMapTransform(selected, m.varState.selectOpts) + } else { + value = m.textInput.Value() + } + + vs.value = value + vs.resolved = true + m.varState.currentIdx++ + + m.textInput.SetValue("") + m.cursor = 0 + m.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 + + if !m.varState.isPromptOnly && len(m.varState.filtered) > 0 { + availableForList := max(maxHeight-fixedLines, 1) + listHeight := min(availableForList, min(10, len(m.varState.filtered))) + start, end := scrollWindow(m.cursor, len(m.varState.filtered), listHeight, &m.offset) + + for i := start; i < end; i++ { + opt := m.varState.filtered[i] + if i == m.cursor { + b.WriteString(styles.Cursor.Render("▶ ")) + b.WriteString(styles.Selected.Render(styles.Command.Render(opt.Display))) + } else { + b.WriteString(" ") + b.WriteString(styles.Command.Render(opt.Display)) + } + b.WriteString("\n") + } + } + + b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) + b.WriteString("\n") + + if !m.varState.isPromptOnly && len(m.varState.filtered) > 0 { + b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d options", len(m.varState.filtered)))) + b.WriteString(" • ") + } + b.WriteString(styles.Dim.Render("ESC back")) + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("Enter accept")) + 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 = replaceVar(progressCmd, vs.def.Name, styles.Header.Render(vs.value)) + } else if i == m.varState.currentIdx { + progressCmd = replaceVar(progressCmd, vs.def.Name, styles.Cursor.Render("$"+vs.def.Name)) + } + } + b.WriteString(progressCmd) + b.WriteString("\n") + + for i, vs := range m.varState.vars { + if vs.resolved { + b.WriteString(styles.Command.Render("✓")) + b.WriteString(" ") + b.WriteString(styles.Dim.Render("$" + vs.def.Name)) + b.WriteString(" = ") + b.WriteString(styles.Header.Render(vs.value)) + } else if i == m.varState.currentIdx { + b.WriteString(styles.Cursor.Render("▶ $" + vs.def.Name)) + } else { + b.WriteString(styles.Dim.Render("○ $" + vs.def.Name)) + } + 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() +} From bde8270fc41cdb883ea2d59edf4ff5f341439621 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Mon, 11 May 2026 22:05:58 -0600 Subject: [PATCH 2/2] Bump version from 0.1.7 to 0.1.8 --- cmd/cheatmd/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/cheatmd/main.go b/cmd/cheatmd/main.go index 2884850..a75c7cc 100644 --- a/cmd/cheatmd/main.go +++ b/cmd/cheatmd/main.go @@ -16,7 +16,7 @@ import ( "github.com/spf13/viper" ) -var version = "0.1.7" +var version = "0.1.8" var widgetCmd = &cobra.Command{ Use: "widget [shell]",