From 7984304ce6394cdc6b0eee61448f7099c761c92b Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Mon, 18 May 2026 23:14:38 -0600 Subject: [PATCH] refactor: consolidate UI logic into generics and dedupe logic --- cmd/cheatmd/dump.go | 174 ++++++++ cmd/cheatmd/main.go | 159 +------ internal/ui/chain_test.go | 10 +- internal/ui/cheat_select.go | 122 ++---- internal/ui/frecency_test.go | 4 +- internal/ui/history_view.go | 189 +++----- internal/ui/main_model.go | 14 +- internal/ui/match.go | 2 +- internal/ui/match_test.go | 26 -- internal/ui/overlay_view.go | 107 +++++ internal/ui/picker.go | 94 ++++ internal/ui/preview.go | 48 +-- internal/ui/resolve.go | 281 +----------- internal/ui/run.go | 4 +- internal/ui/selector_test.go | 6 +- internal/ui/substitute_search.go | 208 +++------ internal/ui/var_resolve.go | 184 +++----- pkg/executor/resolve.go | 223 ++++++++++ pkg/linter/linter.go | 717 ++++--------------------------- pkg/parser/dsl.go | 80 +++- pkg/parser/lex.go | 9 +- pkg/parser/parser.go | 66 ++- pkg/parser/parser_test.go | 14 +- pkg/parser/types.go | 8 + pkg/parser/utils.go | 114 +++++ 25 files changed, 1166 insertions(+), 1697 deletions(-) create mode 100644 cmd/cheatmd/dump.go create mode 100644 internal/ui/overlay_view.go create mode 100644 internal/ui/picker.go create mode 100644 pkg/executor/resolve.go create mode 100644 pkg/parser/utils.go diff --git a/cmd/cheatmd/dump.go b/cmd/cheatmd/dump.go new file mode 100644 index 0000000..7280bff --- /dev/null +++ b/cmd/cheatmd/dump.go @@ -0,0 +1,174 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gubarz/cheatmd/pkg/config" + "github.com/gubarz/cheatmd/pkg/parser" + "github.com/spf13/cobra" +) + +var dumpCmd = &cobra.Command{ + Use: "dump [path]", + Short: "Dump parsed cheat metadata", + Args: cobra.MaximumNArgs(1), + RunE: runDump, +} + +func init() { + dumpCmd.Flags().Bool("csv", false, "Dump cheats as CSV") + dumpCmd.Flags().Bool("json", false, "Dump cheats as JSON") +} + +type dumpEntry struct { + Filename string `json:"filename"` + Tags []string `json:"tags"` + Title string `json:"title"` + Description string `json:"description"` + Command string `json:"command"` + ChainName string `json:"chain_name,omitempty"` + ChainStep int `json:"chain_step,omitempty"` + Variables []dumpVar `json:"variables"` +} + +type dumpVar struct { + Name string `json:"name"` + Kind string `json:"kind"` + Value string `json:"value,omitempty"` + Args string `json:"args,omitempty"` + Condition string `json:"condition,omitempty"` +} + +func runDump(cmd *cobra.Command, args []string) error { + useCSV, _ := cmd.Flags().GetBool("csv") + useJSON, _ := cmd.Flags().GetBool("json") + if useCSV && useJSON { + return fmt.Errorf("choose only one dump format: --csv or --json") + } + if !useCSV && !useJSON { + useJSON = true + } + + index, err := parseDumpIndex(args) + if err != nil { + return err + } + + entries := make([]dumpEntry, 0, len(index.Cheats)) + for _, cheat := range index.Cheats { + entries = append(entries, dumpEntry{ + Filename: cheat.File, + Tags: cheat.Tags, + Title: cheat.Header, + Description: cheat.Description, + Command: cheat.Command, + ChainName: cheat.ChainName, + ChainStep: cheat.ChainStep, + Variables: dumpVars(cheat.Vars), + }) + } + + if useCSV { + return writeDumpCSV(cmd, entries) + } + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return enc.Encode(entries) +} + +func dumpVars(vars []parser.VarDef) []dumpVar { + out := make([]dumpVar, 0, len(vars)) + for _, v := range vars { + dv := dumpVar{ + Name: v.Name, + Args: v.Args, + Condition: v.Condition, + } + switch { + case v.Literal != "": + dv.Kind = "literal" + dv.Value = v.Literal + case v.Shell != "": + dv.Kind = "shell" + dv.Value = v.Shell + default: + dv.Kind = "prompt" + } + out = append(out, dv) + } + return out +} + +func parseDumpIndex(args []string) (*parser.CheatIndex, error) { + path := "." + if len(args) > 0 { + path = args[0] + } else if config.GetPath() != "." { + path = config.GetPath() + } + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("error resolving path: %w", err) + } + info, err := os.Stat(absPath) + if err != nil { + return nil, fmt.Errorf("path error: %w", err) + } + p := parser.NewParser() + if info.IsDir() { + return p.ParseDirectory(absPath) + } + return p.ParseSingleFile(absPath) +} + +func writeDumpCSV(cmd *cobra.Command, entries []dumpEntry) error { + w := csv.NewWriter(cmd.OutOrStdout()) + if err := w.Write([]string{"filename", "tags", "title", "description", "command", "chain_name", "chain_step", "variables"}); err != nil { + return err + } + for _, entry := range entries { + stepStr := "" + if entry.ChainStep > 0 { + stepStr = strconv.Itoa(entry.ChainStep) + } + if err := w.Write([]string{ + entry.Filename, + strings.Join(entry.Tags, "|"), + entry.Title, + entry.Description, + entry.Command, + entry.ChainName, + stepStr, + formatDumpVars(entry.Variables), + }); err != nil { + return err + } + } + w.Flush() + return w.Error() +} + +func formatDumpVars(vars []dumpVar) string { + parts := make([]string, 0, len(vars)) + for _, v := range vars { + part := v.Name + ":" + v.Kind + if v.Value != "" { + part += "=" + v.Value + } + if v.Args != "" { + part += " --- " + v.Args + } + if v.Condition != "" { + part += " if " + v.Condition + } + parts = append(parts, part) + } + return strings.Join(parts, "|") +} diff --git a/cmd/cheatmd/main.go b/cmd/cheatmd/main.go index f4193ef..914dd44 100644 --- a/cmd/cheatmd/main.go +++ b/cmd/cheatmd/main.go @@ -1,14 +1,11 @@ package main import ( - "encoding/csv" - "encoding/json" + "strings" "fmt" "os" "path/filepath" "runtime" - "strconv" - "strings" "time" "github.com/charmbracelet/lipgloss" @@ -72,8 +69,6 @@ func init() { viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output")) - dumpCmd.Flags().Bool("csv", false, "Dump cheats as CSV") - dumpCmd.Flags().Bool("json", false, "Dump cheats as JSON") } var chainCmd = &cobra.Command{ @@ -88,159 +83,7 @@ var chainResetCmd = &cobra.Command{ RunE: runChainReset, } -var dumpCmd = &cobra.Command{ - Use: "dump [path]", - Short: "Dump parsed cheat metadata", - Args: cobra.MaximumNArgs(1), - RunE: runDump, -} - -type dumpEntry struct { - Filename string `json:"filename"` - Tags []string `json:"tags"` - Title string `json:"title"` - Description string `json:"description"` - Command string `json:"command"` - ChainName string `json:"chain_name,omitempty"` - ChainStep int `json:"chain_step,omitempty"` - Variables []dumpVar `json:"variables"` -} - -type dumpVar struct { - Name string `json:"name"` - Kind string `json:"kind"` - Value string `json:"value,omitempty"` - Args string `json:"args,omitempty"` - Condition string `json:"condition,omitempty"` -} - -func runDump(cmd *cobra.Command, args []string) error { - useCSV, _ := cmd.Flags().GetBool("csv") - useJSON, _ := cmd.Flags().GetBool("json") - if useCSV && useJSON { - return fmt.Errorf("choose only one dump format: --csv or --json") - } - if !useCSV && !useJSON { - useJSON = true - } - - index, err := parseDumpIndex(args) - if err != nil { - return err - } - - entries := make([]dumpEntry, 0, len(index.Cheats)) - for _, cheat := range index.Cheats { - entries = append(entries, dumpEntry{ - Filename: cheat.File, - Tags: cheat.Tags, - Title: cheat.Header, - Description: cheat.Description, - Command: cheat.Command, - ChainName: cheat.ChainName, - ChainStep: cheat.ChainStep, - Variables: dumpVars(cheat.Vars), - }) - } - - if useCSV { - return writeDumpCSV(cmd, entries) - } - enc := json.NewEncoder(cmd.OutOrStdout()) - enc.SetIndent("", " ") - enc.SetEscapeHTML(false) - return enc.Encode(entries) -} - -func dumpVars(vars []parser.VarDef) []dumpVar { - out := make([]dumpVar, 0, len(vars)) - for _, v := range vars { - dv := dumpVar{ - Name: v.Name, - Args: v.Args, - Condition: v.Condition, - } - switch { - case v.Literal != "": - dv.Kind = "literal" - dv.Value = v.Literal - case v.Shell != "": - dv.Kind = "shell" - dv.Value = v.Shell - default: - dv.Kind = "prompt" - } - out = append(out, dv) - } - return out -} - -func parseDumpIndex(args []string) (*parser.CheatIndex, error) { - path := "." - if len(args) > 0 { - path = args[0] - } else if config.GetPath() != "." { - path = config.GetPath() - } - absPath, err := filepath.Abs(path) - if err != nil { - return nil, fmt.Errorf("error resolving path: %w", err) - } - info, err := os.Stat(absPath) - if err != nil { - return nil, fmt.Errorf("path error: %w", err) - } - p := parser.NewParser() - if info.IsDir() { - return p.ParseDirectory(absPath) - } - return p.ParseSingleFile(absPath) -} - -func writeDumpCSV(cmd *cobra.Command, entries []dumpEntry) error { - w := csv.NewWriter(cmd.OutOrStdout()) - if err := w.Write([]string{"filename", "tags", "title", "description", "command", "chain_name", "chain_step", "variables"}); err != nil { - return err - } - for _, entry := range entries { - stepStr := "" - if entry.ChainStep > 0 { - stepStr = strconv.Itoa(entry.ChainStep) - } - if err := w.Write([]string{ - entry.Filename, - strings.Join(entry.Tags, "|"), - entry.Title, - entry.Description, - entry.Command, - entry.ChainName, - stepStr, - formatDumpVars(entry.Variables), - }); err != nil { - return err - } - } - w.Flush() - return w.Error() -} -func formatDumpVars(vars []dumpVar) string { - parts := make([]string, 0, len(vars)) - for _, v := range vars { - part := v.Name + ":" + v.Kind - if v.Value != "" { - part += "=" + v.Value - } - if v.Args != "" { - part += " --- " + v.Args - } - if v.Condition != "" { - part += " if " + v.Condition - } - parts = append(parts, part) - } - return strings.Join(parts, "|") -} func runChainReset(cmd *cobra.Command, args []string) error { root := "." diff --git a/internal/ui/chain_test.go b/internal/ui/chain_test.go index 41091d7..a789660 100644 --- a/internal/ui/chain_test.go +++ b/internal/ui/chain_test.go @@ -27,14 +27,14 @@ func TestChainQuerySelectsNextStoredStep(t *testing.T) { m.textInput.SetValue("/chain demo") m.filterCheats() - if len(m.filtered) != 1 { - t.Fatalf("filtered chains = %d, want 1", len(m.filtered)) + if len(m.picker.Filtered) != 1 { + t.Fatalf("filtered chains = %d, want 1", len(m.picker.Filtered)) } - if got := m.filtered[0].cheat.Header; got != "Second" { + if got := m.picker.Filtered[0].cheat.Header; got != "Second" { t.Fatalf("selected chain step = %q, want Second", got) } - if m.filtered[0].chainStep != 2 || m.filtered[0].chainTotal != 2 { - t.Fatalf("chain display step = %d/%d, want 2/2", m.filtered[0].chainStep, m.filtered[0].chainTotal) + if m.picker.Filtered[0].chainStep != 2 || m.picker.Filtered[0].chainTotal != 2 { + t.Fatalf("chain display step = %d/%d, want 2/2", m.picker.Filtered[0].chainStep, m.picker.Filtered[0].chainTotal) } } diff --git a/internal/ui/cheat_select.go b/internal/ui/cheat_select.go index 378eb72..15ce381 100644 --- a/internal/ui/cheat_select.go +++ b/internal/ui/cheat_select.go @@ -87,56 +87,24 @@ func (item *cheatItem) matchesQuery(words []string) bool { 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) { + if strings.Contains(strings.ToLower(item.folder), word) { return true } - if containsIgnoreCaseFast(item.file, word) { + if strings.Contains(strings.ToLower(item.file), word) { return true } - if containsIgnoreCaseFast(item.cheat.Header, word) { + if strings.Contains(strings.ToLower(item.cheat.Header), word) { return true } - if containsIgnoreCaseFast(item.cheat.Description, word) { + if strings.Contains(strings.ToLower(item.cheat.Description), word) { return true } - if containsIgnoreCaseFast(item.cheat.Command, word) { + if strings.Contains(strings.ToLower(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 { + if strings.Contains(strings.ToLower(tag), word) { return true } } @@ -231,32 +199,26 @@ func (m *mainModel) handleCheatSelectKey(msg tea.KeyMsg) tea.Cmd { m.quitting = true return tea.Quit case "enter": - if m.cursor < len(m.filtered) { - item := m.filtered[m.cursor] - m.selected = item.cheat + if opt, ok := m.picker.Selected(); ok { + m.selected = opt.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 + m.picker.Cursor = 0 case "end", "ctrl+e": - m.cursor = max(0, len(m.filtered)-1) + m.picker.Cursor = max(0, len(m.picker.Filtered)-1) default: + if m.picker.HandleKey(msg) { + return nil + } if msg.String() == config.GetKeyOpen() { - if m.cursor < len(m.filtered) { - openFileInViewer(m.filtered[m.cursor].cheat.File) + if opt, ok := m.picker.Selected(); ok { + openFileInViewer(opt.cheat.File) } } if msg.String() == config.GetKeyPreview() { - if m.cursor < len(m.filtered) { - if m.enterPreview(m.filtered[m.cursor].cheat) { + if opt, ok := m.picker.Selected(); ok { + if m.enterPreview(opt.cheat) { return tea.ClearScreen } } @@ -270,45 +232,32 @@ func (m *mainModel) handleCheatSelectKey(msg tea.KeyMsg) tea.Cmd { 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 if chainQuery, ok := parseChainQuery(query); ok { + if chainQuery, ok := parseChainQuery(query); ok { words := strings.Fields(strings.ToLower(chainQuery)) - m.filtered = make([]cheatItem, 0, min(len(m.chains), 1000)) + filteredChains := make([]cheatItem, 0, min(len(m.chains), 1000)) for _, chain := range m.chains { if !chainMatchesQuery(chain, words) { continue } if cheat := m.nextChainStep(chain); cheat != nil { - m.filtered = append(m.filtered, newChainItem(chain, cheat)) - if len(m.filtered) >= 1000 { + filteredChains = append(filteredChains, newChainItem(chain, cheat)) + if len(filteredChains) >= 1000 { break } } } + m.picker.SetItems(filteredChains) } 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 - } - } + // Just standard filter + if len(m.picker.Items) != len(m.cheats) { + m.picker.SetItems(m.cheats) } + m.picker.Filter(query) } - - m.cursor = clamp(m.cursor, 0, max(0, len(m.filtered)-1)) } func parseChainQuery(query string) (string, bool) { @@ -390,8 +339,8 @@ func (m *mainModel) renderPreviewWithHeight(width int, maxLines int) string { defer putBuilder(b) lines := 0 - if m.cursor < len(m.filtered) { - item := m.filtered[m.cursor] + if opt, ok := m.picker.Selected(); ok { + item := opt pathDisplay := buildPathDisplay(item.folder, item.file) if pathDisplay != "" && lines < maxLines { b.WriteString(styles.PreviewPath.Render(pathDisplay)) @@ -439,18 +388,17 @@ func (m *mainModel) renderPreviewWithHeight(width int, maxLines int) string { // renderList renders the scrollable list of cheats. func (m *mainModel) renderList(maxHeight int) string { - if len(m.filtered) == 0 { - return "" + if len(m.picker.Filtered) == 0 { + return styles.Dim.Render("No cheats found. Press ESC to clear search.") } - start, end := scrollWindow(m.cursor, len(m.filtered), maxHeight, &m.offset) + start, end := scrollWindow(m.picker.Cursor, len(m.picker.Filtered), maxHeight, &m.picker.Offset) gap := strings.Repeat(" ", m.columns.gap) - b := getBuilder() - defer putBuilder(b) + var b strings.Builder for i := start; i < end; i++ { - item := m.filtered[i] - isSelected := i == m.cursor + item := m.picker.Filtered[i] + isSelected := i == m.picker.Cursor b.WriteString(m.renderListItem(item, isSelected, gap)) b.WriteString("\n") } @@ -557,7 +505,7 @@ func (m *mainModel) renderInput(width int) string { 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(styles.Dim.Render(fmt.Sprintf(" %d/%d", len(m.picker.Filtered), len(m.cheats)))) b.WriteString(" • ") keyOpen := config.GetKeyOpen() if keyOpen == "" { diff --git a/internal/ui/frecency_test.go b/internal/ui/frecency_test.go index 882ee20..e521f1a 100644 --- a/internal/ui/frecency_test.go +++ b/internal/ui/frecency_test.go @@ -16,7 +16,7 @@ func TestApplyFrecencyRanksCheatPicker(t *testing.T) { history.CheatKey(hot.File, hot.Header): 2, }) - if got := m.filtered[0].cheat.Header; got != "Hot" { + if got := m.picker.Filtered[0].cheat.Header; got != "Hot" { t.Fatalf("top ranked cheat = %q, want Hot", got) } } @@ -32,7 +32,7 @@ func TestApplyFrecencyRanksFilteredResults(t *testing.T) { m.textInput.SetValue("target") m.filterCheats() - if got := m.filtered[0].cheat.Header; got != "Second Target" { + if got := m.picker.Filtered[0].cheat.Header; got != "Second Target" { t.Fatalf("top filtered cheat = %q, want Second Target", got) } } diff --git a/internal/ui/history_view.go b/internal/ui/history_view.go index dbea048..5146f5b 100644 --- a/internal/ui/history_view.go +++ b/internal/ui/history_view.go @@ -13,10 +13,8 @@ import ( // historyState holds the overlay state for the execution-history picker. type historyState struct { - entries []history.Entry // all loaded entries, newest first - filtered []history.Entry - cursor int - offset int + picker *Picker[history.Entry] + // Saved cheat-select state to restore on cancel. prevInput string prevCursor int @@ -36,16 +34,16 @@ func (m *mainModel) enterHistory() bool { return false } m.histState = &historyState{ - entries: entries, - filtered: entries, + picker: NewPicker(entries, func(e history.Entry, words []string) bool { + hay := strings.ToLower(e.Command + " " + e.Header + " " + e.File) + return matchesAllWords(hay, words) + }), prevInput: m.textInput.Value(), - prevCursor: m.cursor, - prevOffset: m.offset, + prevCursor: m.picker.Cursor, + prevOffset: m.picker.Offset, } m.textInput.SetValue("") m.textInput.Placeholder = "Search history..." - m.cursor = 0 - m.offset = 0 m.phase = phaseHistory return true } @@ -54,8 +52,8 @@ func (m *mainModel) enterHistory() bool { func (m *mainModel) exitHistory() { if m.histState != nil { m.textInput.SetValue(m.histState.prevInput) - m.cursor = m.histState.prevCursor - m.offset = m.histState.prevOffset + m.picker.Cursor = m.histState.prevCursor + m.picker.Offset = m.histState.prevOffset } m.histState = nil m.textInput.Placeholder = "Type to search..." @@ -67,11 +65,15 @@ func (m *mainModel) exitHistory() { // resolution. If the cheat is no longer in the index (file renamed, header // changed) it falls back to inserting the raw command into the prompt. func (m *mainModel) acceptHistory() tea.Cmd { - if m.histState == nil || m.histState.cursor >= len(m.histState.filtered) { + if m.histState == nil { + m.exitHistory() + return nil + } + entry, ok := m.histState.picker.Selected() + if !ok { m.exitHistory() return nil } - entry := m.histState.filtered[m.histState.cursor] cheat := findCheatByRef(m.cheatIndex, entry.File, entry.Header) if cheat == nil { // Cheat no longer exists. Bail back to cheat select with the command @@ -97,40 +99,9 @@ func (m *mainModel) acceptHistory() tea.Cmd { // filterHistoryEntries applies the current input as a case-insensitive AND // fuzzy filter over entries (command + header + file). func (m *mainModel) filterHistoryEntries() { - if m.histState == nil { - return - } - query := strings.ToLower(strings.TrimSpace(m.textInput.Value())) - if query == "" { - m.histState.filtered = m.histState.entries - m.histState.cursor = 0 - m.histState.offset = 0 - return - } - words := strings.Fields(query) - result := make([]history.Entry, 0, len(m.histState.entries)) - for _, e := range m.histState.entries { - hay := strings.ToLower(e.Command + " " + e.Header + " " + e.File) - if matchesAllWords(hay, words) { - result = append(result, e) - } - } - m.histState.filtered = result - if m.histState.cursor >= len(result) { - m.histState.cursor = max(0, len(result)-1) - } - if m.histState.offset > m.histState.cursor { - m.histState.offset = m.histState.cursor - } -} - -// moveHistoryCursor clamps the cursor; offset is reconciled at render time. -func (m *mainModel) moveHistoryCursor(delta int) { - if m.histState == nil { - return + if m.histState != nil { + m.histState.picker.Filter(m.textInput.Value()) } - m.histState.cursor += delta - m.histState.cursor = clamp(m.histState.cursor, 0, max(0, len(m.histState.filtered)-1)) } // handleHistoryKey processes keys while in phaseHistory. @@ -145,17 +116,8 @@ func (m *mainModel) handleHistoryKey(msg tea.KeyMsg) tea.Cmd { return tea.ClearScreen case "enter": return m.acceptHistory() - case "up", "ctrl+p": - m.moveHistoryCursor(-1) - return nil - case "down", "ctrl+n": - m.moveHistoryCursor(1) - return nil - case "pgup": - m.moveHistoryCursor(-10) - return nil - case "pgdown": - m.moveHistoryCursor(10) + } + if m.histState != nil && m.histState.picker.HandleKey(msg) { return nil } return nil @@ -192,94 +154,39 @@ func isHistoryNavKey(key string) bool { return false } -// renderHistory renders the history overlay using the same layout shape as -// renderSubstituteSearch. +// renderHistory renders the history overlay using the shared overlay layout. func (m *mainModel) renderHistory() string { - width := max(m.width, 80) - height := m.height - if height < 1 { - height = 24 - } - - inputLines := 3 - previewHeight := 2 - preview := m.renderHistoryPreview(width, previewHeight) - - previewLines := countLines(preview) - listHeight := max(height-previewLines-inputLines, 1) - list := m.renderHistoryList(listHeight, width) - - return renderWindowLayout(height, preview, list, m.renderHistoryInput(width)) -} - -// renderHistoryPreview is the top header: title + divider, padded to fit. -func (m *mainModel) renderHistoryPreview(width, maxLines int) string { - b := getBuilder() - defer putBuilder(b) - lines := 0 - - if lines < maxLines { - b.WriteString(styles.Header.Render("History")) - if m.histState != nil { - b.WriteString(" ") - b.WriteString(styles.Dim.Render(fmt.Sprintf("(%d entries)", len(m.histState.entries)))) - } - 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() -} - -// renderHistoryList renders the scrolling list of history entries. -func (m *mainModel) renderHistoryList(maxHeight, width int) string { - if m.histState == nil || len(m.histState.filtered) == 0 { - return "" - } - - start, end := scrollWindow(m.histState.cursor, len(m.histState.filtered), maxHeight, &m.histState.offset) - maxLen := max(width-2, 10) - - b := getBuilder() - defer putBuilder(b) - for i := start; i < end; i++ { - entry := m.histState.filtered[i] - display := entry.Display(maxLen) - if i == m.histState.cursor { - b.WriteString(styles.Cursor.Render("▶ ")) - b.WriteString(styles.Selected.Render(styles.Command.Render(display))) - } else { - b.WriteString(" ") - b.WriteString(styles.Command.Render(display)) + extra := "" + matchCount := 0 + var items []string + if m.histState != nil { + extra = styles.Dim.Render(fmt.Sprintf("(%d entries)", len(m.histState.picker.Items))) + matchCount = len(m.histState.picker.Filtered) + for _, e := range m.histState.picker.Filtered { + items = append(items, e.Display(max(m.width-2, 10))) } - b.WriteString("\n") } - return b.String() -} -// renderHistoryInput renders the bottom divider, hint, and search input. -func (m *mainModel) renderHistoryInput(width int) string { - b := getBuilder() - defer putBuilder(b) - b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) - b.WriteString("\n") - matchCount := 0 + var offset *int + var cursor int if m.histState != nil { - matchCount = len(m.histState.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 re-run cheat")) - b.WriteString("\n") - b.WriteString(m.textInput.View()) - return b.String() + offset = &m.histState.picker.Offset + cursor = m.histState.picker.Cursor + } else { + zero := 0 + offset = &zero + } + + return m.renderOverlayWindow(OverlayConfig{ + Title: "History", + TitleExtra: extra, + MatchesCount: matchCount, + EnterHint: "Enter re-run cheat", + Items: items, + SelectedIndex: cursor, + Offset: offset, + Input: m.textInput, + }) } // findCheatByRef locates a cheat in the index matching the given file path diff --git a/internal/ui/main_model.go b/internal/ui/main_model.go index a1ddefe..9faee3c 100644 --- a/internal/ui/main_model.go +++ b/internal/ui/main_model.go @@ -73,10 +73,8 @@ type mainModel struct { // Cheat selection state cheats []cheatItem - filtered []cheatItem chains []chainGroup - cursor int - offset int // viewport scroll offset + picker *Picker[cheatItem] selected *parser.Cheat columns columnConfig lastQuery string @@ -107,7 +105,7 @@ type varResolveState struct { vars []varState currentIdx int options []string // options for current variable (from shell command) - filtered []FilteredOption + picker *Picker[FilteredOption] selectOpts SelectOptions customHeader string shellErr error // error from running shell command (if any) @@ -129,7 +127,9 @@ func (m *mainModel) applyFrecency(scores map[string]float64) { } return left > right }) - m.filtered = m.cheats + if m.picker != nil { + m.picker.SetItems(m.cheats) + } } // FilteredOption pairs display text with original value for variable selection @@ -155,8 +155,10 @@ func newMainModel(cheats []*parser.Cheat, index *parser.CheatIndex, exec Executo return mainModel{ cheats: items, - filtered: items, chains: buildChains(cheats), + picker: NewPicker(items, func(item cheatItem, words []string) bool { + return item.matchesQuery(words) + }), textInput: ti, columns: loadColumnConfig(), phase: phaseCheatSelect, diff --git a/internal/ui/match.go b/internal/ui/match.go index 3ce4d18..d0d3844 100644 --- a/internal/ui/match.go +++ b/internal/ui/match.go @@ -166,7 +166,7 @@ func inferDependentVars(cheat *parser.Cheat, index *parser.CheatIndex) { return } - varDefs := collectVarDefinitions(cheat, index) + varDefs := executor.CollectVarDefinitions(cheat, index) changed := true for changed { diff --git a/internal/ui/match_test.go b/internal/ui/match_test.go index def98da..10a0ffe 100644 --- a/internal/ui/match_test.go +++ b/internal/ui/match_test.go @@ -60,32 +60,6 @@ func TestBuildMatchPattern(t *testing.T) { } } -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{ diff --git a/internal/ui/overlay_view.go b/internal/ui/overlay_view.go new file mode 100644 index 0000000..b3cc4f2 --- /dev/null +++ b/internal/ui/overlay_view.go @@ -0,0 +1,107 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" +) + +// OverlayConfig provides the dynamic parts for renderOverlayWindow. +type OverlayConfig struct { + Title string + TitleExtra string // E.g. "(12 entries)" or "-> $var" + MatchesCount int + EnterHint string // E.g. "Enter use value" or "Enter re-run cheat" + Items []string + SelectedIndex int + Offset *int // Pointer to the state's offset for scrolling + Input textinput.Model +} + +// renderOverlayWindow provides a generic layout for the history and substitute search overlays. +// It handles the fixed-height header, the scrolling list, and the fixed-height input footer. +func (m *mainModel) renderOverlayWindow(cfg OverlayConfig) string { + width := max(m.width, 80) + height := m.height + if height < 1 { + height = 24 + } + + inputLines := 3 // divider + info + input + previewHeight := 2 + + preview := renderOverlayPreview(width, previewHeight, cfg.Title, cfg.TitleExtra) + previewLines := countLines(preview) + + listHeight := max(height-previewLines-inputLines, 1) + list := renderOverlayList(listHeight, width, cfg.Items, cfg.SelectedIndex, cfg.Offset) + + input := renderOverlayInput(width, cfg.MatchesCount, cfg.EnterHint, cfg.Input) + + return renderWindowLayout(height, preview, list, input) +} + +func renderOverlayPreview(width, maxLines int, title, extra string) string { + b := getBuilder() + defer putBuilder(b) + lines := 0 + + if lines < maxLines { + b.WriteString(styles.Header.Render(title)) + if extra != "" { + b.WriteString(" ") + b.WriteString(extra) + } + 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() +} + +func renderOverlayList(maxHeight, width int, items []string, selectedIdx int, offset *int) string { + if len(items) == 0 { + return "" + } + + start, end := scrollWindow(selectedIdx, len(items), maxHeight, offset) + maxLen := max(width-2, 10) + + b := getBuilder() + defer putBuilder(b) + for i := start; i < end; i++ { + display := truncateString(items[i], maxLen) + if i == selectedIdx { + 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() +} + +func renderOverlayInput(width, matchCount int, enterHint string, input textinput.Model) 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 matches", matchCount))) + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("ESC cancel")) + b.WriteString(" • ") + b.WriteString(styles.Dim.Render(enterHint)) + b.WriteString("\n") + b.WriteString(input.View()) + return b.String() +} diff --git a/internal/ui/picker.go b/internal/ui/picker.go new file mode 100644 index 0000000..22ecd50 --- /dev/null +++ b/internal/ui/picker.go @@ -0,0 +1,94 @@ +package ui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// Picker is a generic state manager for dropdown lists in the UI. +// It manages cursor bounds, scrolling offsets, and filtering of items. +type Picker[T any] struct { + Items []T + Filtered []T + Cursor int + Offset int + + FilterFn func(item T, queryWords []string) bool +} + +// NewPicker creates a new Picker with the given items and filter function. +func NewPicker[T any](items []T, filterFn func(T, []string) bool) *Picker[T] { + return &Picker[T]{ + Items: items, + Filtered: items, + FilterFn: filterFn, + } +} + +// SetItems replaces the picker's items and resets the filter to show all items. +func (p *Picker[T]) SetItems(items []T) { + p.Items = items + p.Filtered = items + p.Cursor = 0 + p.Offset = 0 +} + +// Filter applies the filter function against the current query string. +func (p *Picker[T]) Filter(query string) { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + p.Filtered = p.Items + p.Cursor = 0 + p.Offset = 0 + return + } + words := strings.Fields(query) + var result []T + for _, item := range p.Items { + if p.FilterFn(item, words) { + result = append(result, item) + } + } + p.Filtered = result + if p.Cursor >= len(p.Filtered) { + p.Cursor = max(0, len(p.Filtered)-1) + } + if p.Offset > p.Cursor { + p.Offset = p.Cursor + } +} + +// MoveCursor adjusts the cursor position by delta, clamping it to the filtered list bounds. +func (p *Picker[T]) MoveCursor(delta int) { + p.Cursor += delta + p.Cursor = clamp(p.Cursor, 0, max(0, len(p.Filtered)-1)) +} + +// HandleKey processes standard navigation keys. Returns true if the key was handled. +func (p *Picker[T]) HandleKey(msg tea.KeyMsg) bool { + switch msg.String() { + case "up", "ctrl+p": + p.MoveCursor(-1) + return true + case "down", "ctrl+n": + p.MoveCursor(1) + return true + case "pgup": + p.MoveCursor(-10) + return true + case "pgdown": + p.MoveCursor(10) + return true + } + return false +} + +// Selected returns the currently selected item, and a boolean indicating if one exists. +func (p *Picker[T]) Selected() (T, bool) { + var zero T + if p.Cursor < 0 || p.Cursor >= len(p.Filtered) { + return zero, false + } + return p.Filtered[p.Cursor], true +} diff --git a/internal/ui/preview.go b/internal/ui/preview.go index 7e2a232..df68e0c 100644 --- a/internal/ui/preview.go +++ b/internal/ui/preview.go @@ -341,7 +341,7 @@ func findCheatHeaderSourceLine(raw string, cheat *parser.Cheat) int { continue } - if header, ok := parsePreviewHeader(trimmed); ok { + if header, _, ok := parser.ParseMarkdownHeader(trimmed); ok { if currentHeaderLine >= 0 { if line := findPendingCommandHeaderLine(pending, cheat); line >= 0 { return line @@ -362,7 +362,7 @@ func findCheatHeaderSourceLine(raw string, cheat *parser.Cheat) int { continue } - if content, ok := parsePreviewCheatSingleLine(trimmed); ok { + if content, ok := parser.ParseCheatSingleLine(trimmed); ok { _ = content if len(pending) > 0 { last := pending[len(pending)-1] @@ -374,7 +374,7 @@ func findCheatHeaderSourceLine(raw string, cheat *parser.Cheat) int { continue } - if isPreviewCheatStart(trimmed) { + if parser.IsCheatStart(trimmed) { inCheatBlock = true continue } @@ -415,45 +415,3 @@ func renderedOffsetForSourceLine(raw string, sourceLine, width int) (int, bool) } return offset, true } - -func parsePreviewHeader(trimmed string) (string, bool) { - level := 0 - for level < len(trimmed) && trimmed[level] == '#' { - level++ - } - if level == 0 || level > 6 { - return "", false - } - if level == len(trimmed) { - return "", true - } - if trimmed[level] != ' ' && trimmed[level] != '\t' { - return "", false - } - return strings.TrimSpace(trimmed[level:]), true -} - -func parsePreviewCheatSingleLine(trimmed string) (string, bool) { - if !strings.HasPrefix(trimmed, "") { - return "", false - } - inner := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(trimmed, "")) - if len(inner) < len("cheat") || !strings.EqualFold(inner[:len("cheat")], "cheat") { - return "", false - } - if len(inner) > len("cheat") { - next := inner[len("cheat")] - if next != ' ' && next != '\t' { - return "", false - } - } - return strings.TrimSpace(inner[len("cheat"):]), true -} - -func isPreviewCheatStart(trimmed string) bool { - if !strings.HasPrefix(trimmed, "` block to -// declare defaults). -func findAllVars(cmd string, syntax string) []string { - allowDollar := syntax == "dollar" || syntax == "both" - allowAngle := syntax == "angle" || syntax == "both" - - var vars []string - seen := make(map[string]bool) - add := func(name string) { - if seen[name] { - return - } - seen[name] = true - vars = append(vars, name) - } - for i := 0; i < len(cmd); i++ { - switch cmd[i] { - case '$': - if !allowDollar { - continue - } - if i+1 >= len(cmd) { - continue - } - // Skip escaped $ - if i > 0 && cmd[i-1] == '\\' { - continue - } - j := i + 1 - for j < len(cmd) && isVarChar(cmd[j], j == i+1) { - j++ - } - if j > i+1 { - add(cmd[i+1 : j]) - } - i = j - 1 - case '<': - if !allowAngle { - continue - } - j := i + 1 - if j >= len(cmd) { - continue - } - if !isVarChar(cmd[j], true) { - continue - } - j++ - for j < len(cmd) && isVarChar(cmd[j], false) { - j++ - } - // Must close with '>' to be a variable reference; skip - // `` (default-bearing form is not auto-resolved). - if j >= len(cmd) || cmd[j] != '>' { - continue - } - add(cmd[i+1 : j]) - i = j - } - } - - return vars -} - -// splitLines splits text into non-empty trimmed lines -// Optimized for large inputs - uses strings.Index instead of Split -func splitLines(s string) []string { - if s == "" { - return nil - } - - // Count lines first to pre-allocate (rough estimate) - lineCount := strings.Count(s, "\n") + 1 - lines := make([]string, 0, lineCount) - - for len(s) > 0 { - idx := strings.IndexByte(s, '\n') - var line string - if idx == -1 { - line = s - s = "" - } else { - line = s[:idx] - s = s[idx+1:] - } - - // Trim inline (avoid TrimSpace allocation if not needed) - start, end := 0, len(line) - for start < end && (line[start] == ' ' || line[start] == '\t' || line[start] == '\r') { - start++ - } - for end > start && (line[end-1] == ' ' || line[end-1] == '\t' || line[end-1] == '\r') { - end-- - } - if start < end { - lines = append(lines, line[start:end]) - } - } - - return lines -} - -// isVarChar returns true if c is valid in a variable name -func isVarChar(c byte, first bool) bool { - if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' { - return true - } - return !first && c >= '0' && c <= '9' -} // parseShellArgs parses a string into arguments, respecting quotes func parseShellArgs(s string) []string { diff --git a/internal/ui/run.go b/internal/ui/run.go index 6cc7083..95e8cf8 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -138,8 +138,8 @@ func RunTUIWithStart(index *parser.CheatIndex, exec Executor, initialQuery, matc m.textInput.SetValue(initialQuery) m.filterCheats() - if (autoSelect || resumeChain) && len(m.filtered) == 1 { - m.selected = m.filtered[0].cheat + if (autoSelect || resumeChain) && len(m.picker.Filtered) == 1 { + m.selected = m.picker.Filtered[0].cheat m.startVarResolutionInternal() if m.phase != phaseVarResolve { diff --git a/internal/ui/selector_test.go b/internal/ui/selector_test.go index da35040..0dc276e 100644 --- a/internal/ui/selector_test.go +++ b/internal/ui/selector_test.go @@ -3,6 +3,8 @@ package ui import ( "reflect" "testing" + + "github.com/gubarz/cheatmd/pkg/parser" ) func TestParseShellArgs(t *testing.T) { @@ -243,7 +245,7 @@ func TestSplitLines(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := splitLines(tt.input) + got := parser.SplitLines(tt.input) if !reflect.DeepEqual(got, tt.want) { t.Errorf("splitLines(%q) = %v, want %v", tt.input, got, tt.want) } @@ -259,7 +261,7 @@ func TestEndToEnd_DelimiterColumnPipeline(t *testing.T) { selectorArgs := `--delimiter "," --column 2 --select-column 1` // 1. Split shell output into lines - lines := splitLines(shellOutput) + lines := parser.SplitLines(shellOutput) if len(lines) != 2 { t.Fatalf("splitLines() = %d lines, want 2", len(lines)) } diff --git a/internal/ui/substitute_search.go b/internal/ui/substitute_search.go index 075f868..ec4e61f 100644 --- a/internal/ui/substitute_search.go +++ b/internal/ui/substitute_search.go @@ -1,7 +1,6 @@ package ui import ( - "fmt" "strings" tea "github.com/charmbracelet/bubbletea" @@ -14,10 +13,8 @@ import ( // 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 + picker *Picker[substituteOption] + prevInput string // textInput value before entering the overlay prevCursor int prevOffset int @@ -69,16 +66,16 @@ func (m *mainModel) enterSubstituteSearch() bool { return false } m.subState = &substituteSearchState{ - options: opts, - filtered: opts, + picker: NewPicker(opts, func(opt substituteOption, words []string) bool { + hay := strings.ToLower(opt.Display) + return matchesAllWords(hay, words) + }), prevInput: m.textInput.Value(), - prevCursor: m.cursor, - prevOffset: m.offset, + prevCursor: m.picker.Cursor, + prevOffset: m.picker.Offset, } m.textInput.SetValue("") m.textInput.Placeholder = "Search env / history..." - m.cursor = 0 - m.offset = 0 m.phase = phaseSubstituteSearch return true } @@ -92,63 +89,36 @@ func (m *mainModel) exitSubstituteSearch(accept bool) { return } - if accept && m.subState.cursor < len(m.subState.filtered) { - m.textInput.SetValue(m.subState.filtered[m.subState.cursor].Value) - m.textInput.CursorEnd() + if accept { + if opt, ok := m.subState.picker.Selected(); ok { + m.textInput.SetValue(opt.Value) + m.textInput.CursorEnd() + } else { + m.textInput.SetValue(m.subState.prevInput) + m.textInput.CursorEnd() + } } else { m.textInput.SetValue(m.subState.prevInput) m.textInput.CursorEnd() } - m.cursor = m.subState.prevCursor - m.offset = m.subState.prevOffset + m.picker.Cursor = m.subState.prevCursor + m.picker.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() + if m.varState != nil && !m.varState.isPromptOnly && m.varState.picker != nil { + m.varState.picker.Filter(m.textInput.Value()) } } // 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 + if m.subState != nil { + m.subState.picker.Filter(m.textInput.Value()) } - 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. @@ -164,125 +134,53 @@ func (m *mainModel) handleSubstituteSearchKey(msg tea.KeyMsg) tea.Cmd { 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) + } + if m.subState != nil && m.subState.picker.HandleKey(msg) { 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. +// shared overlay layout. 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 + extra := "" + matchCount := 0 + var items []string - if lines < maxLines { + if m.subState != nil { 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)) + extra = styles.Dim.Render("→ ") + 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)) + + matchCount = len(m.subState.picker.Filtered) + for _, opt := range m.subState.picker.Filtered { + items = append(items, opt.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 + var offset *int + var cursor int 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() + offset = &m.subState.picker.Offset + cursor = m.subState.picker.Cursor + } else { + zero := 0 + offset = &zero + } + + return m.renderOverlayWindow(OverlayConfig{ + Title: "Substitute search", + TitleExtra: extra, + MatchesCount: matchCount, + EnterHint: "Enter use value", + Items: items, + SelectedIndex: cursor, + Offset: offset, + Input: m.textInput, + }) } diff --git a/internal/ui/var_resolve.go b/internal/ui/var_resolve.go index 4650cf8..960581b 100644 --- a/internal/ui/var_resolve.go +++ b/internal/ui/var_resolve.go @@ -10,6 +10,7 @@ import ( "github.com/gubarz/cheatmd/pkg/config" "github.com/gubarz/cheatmd/pkg/executor" + "github.com/gubarz/cheatmd/pkg/parser" ) // ============================================================================ @@ -75,8 +76,8 @@ func (m *mainModel) startVarResolutionInternal() { m.lastQuery = m.textInput.Value() m.textInput.SetValue("") m.textInput.Placeholder = "Type to filter or enter value..." - m.cursor = 0 - m.offset = 0 + m.picker.Cursor = 0 + m.picker.Offset = 0 } // prepareCurrentVar prepares the current variable for display. May return a @@ -142,7 +143,9 @@ func (m *mainModel) prepareCurrentVar() tea.Cmd { if vs.skipAutoCont { m.varState.isPromptOnly = true m.varState.options = nil - m.varState.filtered = nil + if m.varState.picker != nil { + m.varState.picker.SetItems(nil) + } m.textInput.SetValue(result) m.textInput.CursorEnd() return nil @@ -157,7 +160,9 @@ func (m *mainModel) prepareCurrentVar() tea.Cmd { if strings.TrimSpace(vs.def.Shell) == "" { m.varState.isPromptOnly = true m.varState.options = nil - m.varState.filtered = nil + if m.varState.picker != nil { + m.varState.picker.SetItems(nil) + } if vs.prefill != "" { m.textInput.SetValue(vs.prefill) m.textInput.CursorEnd() @@ -172,7 +177,7 @@ func (m *mainModel) prepareCurrentVar() tea.Cmd { if err != nil { return shellResultMsg{nil, err} } - lines := splitLines(output) + lines := parser.SplitLines(output) return shellResultMsg{lines, nil} } } @@ -237,8 +242,8 @@ func (m *mainModel) updateVarResolve(msg tea.Msg) (tea.Model, tea.Cmd) { if m.textInput.Value() != prevQuery { m.clearPathCompletions() - if !m.varState.isPromptOnly { - m.filterVarOptions() + if !m.varState.isPromptOnly && m.varState.picker != nil { + m.varState.picker.Filter(m.textInput.Value()) } } @@ -257,7 +262,9 @@ func (m *mainModel) handleShellResult(msg shellResultMsg) (tea.Model, tea.Cmd) { m.varState.shellErr = msg.err m.varState.isPromptOnly = true m.varState.options = nil - m.varState.filtered = nil + if m.varState.picker != nil { + m.varState.picker.SetItems(nil) + } m.textInput.SetValue(vs.prefill) return m, nil } @@ -282,67 +289,35 @@ func (m *mainModel) handleShellResult(msg shellResultMsg) (tea.Model, tea.Cmd) { m.textInput.CursorEnd() default: m.varState.isPromptOnly = false - m.buildVarFilteredList() - if vs.prefill != "" { - m.textInput.SetValue(vs.prefill) - m.textInput.CursorEnd() + + // Build options list + opts := m.varState.selectOpts + items := make([]FilteredOption, len(msg.options)) + for i, opt := range msg.options { + display := getDisplayColumn(opt, opts.Delimiter, opts.Column) + items[i] = FilteredOption{ + Display: display, + Original: opt, + SearchText: strings.ToLower(display), + } } - 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), + if m.varState.picker == nil { + m.varState.picker = NewPicker(items, func(opt FilteredOption, words []string) bool { + return matchesAllWords(opt.SearchText, words) + }) + } else { + m.varState.picker.SetItems(items) } - } -} - -// filterVarOptions filters the variable options based on search query. -func (m *mainModel) filterVarOptions() { - if m.varState == nil || m.varState.isPromptOnly { - return - } + m.varState.picker.Filter(m.textInput.Value()) - 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, - }) - } + if vs.prefill != "" { + m.textInput.SetValue(vs.prefill) + m.textInput.CursorEnd() } - m.varState.filtered = result } - m.cursor = clamp(m.cursor, 0, max(0, len(m.varState.filtered)-1)) + return m, nil } // handleVarResolveKey processes keyboard input during variable resolution. @@ -361,8 +336,8 @@ func (m *mainModel) handleVarResolveKey(msg tea.KeyMsg) tea.Cmd { vs.value = "" vs.skipAutoCont = true m.textInput.SetValue("") - m.cursor = 0 - m.offset = 0 + m.picker.Cursor = 0 + m.picker.Offset = 0 return m.prepareCurrentVar() } m.phase = phaseCheatSelect @@ -371,33 +346,20 @@ func (m *mainModel) handleVarResolveKey(msg tea.KeyMsg) tea.Cmd { m.textInput.SetValue(m.lastQuery) m.textInput.Placeholder = "Type to search..." m.filterCheats() - m.cursor = 0 - m.offset = 0 + m.picker.Cursor = 0 + m.picker.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.completePathFromInput() { return nil } - if !m.varState.isPromptOnly && m.cursor < len(m.varState.filtered) { - m.textInput.SetValue(m.varState.filtered[m.cursor].Display) + if !m.varState.isPromptOnly && m.varState.picker != nil { + if opt, ok := m.varState.picker.Selected(); ok { + m.textInput.SetValue(opt.Display) + m.textInput.CursorEnd() + } } default: if msg.String() == config.GetKeyOpen() { @@ -442,10 +404,8 @@ func (m *mainModel) completePathFromInput() bool { show := len(result.Candidates) > 1 m.varState.pathCompletions = result.Candidates m.varState.showPathCompletions = show - m.cursor = 0 - m.offset = 0 - if !m.varState.isPromptOnly { - m.filterVarOptions() + if !m.varState.isPromptOnly && m.varState.picker != nil { + m.varState.picker.Filter(m.textInput.Value()) } return true } @@ -473,15 +433,6 @@ func (m *mainModel) clearPathCompletions() { m.varState.showPathCompletions = false } -// 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 { @@ -493,9 +444,16 @@ func (m *mainModel) acceptVarValue() tea.Cmd { 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 if m.varState.picker != nil { + if opt, ok := m.varState.picker.Selected(); ok { + selected := opt.Original + if m.varState.selectOpts.MapCmd != "" { + selected = applyMapTransform(selected, m.varState.selectOpts) + } + value = selected + } else { + value = m.textInput.Value() + } } else { value = m.textInput.Value() } @@ -506,8 +464,8 @@ func (m *mainModel) acceptVarValue() tea.Cmd { m.textInput.SetValue("") m.clearPathCompletions() - m.cursor = 0 - m.offset = 0 + m.picker.Cursor = 0 + m.picker.Offset = 0 return m.prepareCurrentVar() } @@ -558,8 +516,9 @@ func (m *mainModel) renderVarBottomWithHeight(width int, maxHeight int) string { // Fixed lines: top divider(1) + bottom divider(1) + info line(1) + input(1) = 4 fixedLines := 4 + availableForList := max(maxHeight-fixedLines, 1) + if m.varState.showPathCompletions && len(m.varState.pathCompletions) > 0 { - availableForList := max(maxHeight-fixedLines, 1) listHeight := min(availableForList, min(10, len(m.varState.pathCompletions))) for i := 0; i < listHeight; i++ { candidate := m.varState.pathCompletions[i] @@ -567,14 +526,13 @@ func (m *mainModel) renderVarBottomWithHeight(width int, maxHeight int) string { b.WriteString(styles.Command.Render(candidate.Display)) b.WriteString("\n") } - } else 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) + } else if !m.varState.isPromptOnly && m.varState.picker != nil && len(m.varState.picker.Filtered) > 0 { + listHeight := min(availableForList, min(10, len(m.varState.picker.Filtered))) + start, end := scrollWindow(m.varState.picker.Cursor, len(m.varState.picker.Filtered), listHeight, &m.varState.picker.Offset) for i := start; i < end; i++ { - opt := m.varState.filtered[i] - if i == m.cursor { + opt := m.varState.picker.Filtered[i] + if i == m.varState.picker.Cursor { b.WriteString(styles.Cursor.Render("▶ ")) b.WriteString(styles.Selected.Render(styles.Command.Render(opt.Display))) } else { @@ -591,8 +549,8 @@ func (m *mainModel) renderVarBottomWithHeight(width int, maxHeight int) string { if m.varState.showPathCompletions && len(m.varState.pathCompletions) > 0 { b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d path matches", len(m.varState.pathCompletions)))) b.WriteString(" • ") - } else if !m.varState.isPromptOnly && len(m.varState.filtered) > 0 { - b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d options", len(m.varState.filtered)))) + } else if !m.varState.isPromptOnly && m.varState.picker != nil && len(m.varState.picker.Filtered) > 0 { + b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d options", len(m.varState.picker.Filtered)))) b.WriteString(" • ") } b.WriteString(styles.Dim.Render("ESC back")) @@ -616,10 +574,10 @@ func (m *mainModel) renderVarHeader(width int) string { 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), config.GetVarSyntax()) + progressCmd = executor.ReplaceVar(progressCmd, vs.def.Name, styles.Header.Render(vs.value), config.GetVarSyntax()) } else if i == m.varState.currentIdx { displayStr := formatVarName(m.varState.cheat.Command, vs.def.Name) - progressCmd = replaceVar(progressCmd, vs.def.Name, styles.Cursor.Render(displayStr), config.GetVarSyntax()) + progressCmd = executor.ReplaceVar(progressCmd, vs.def.Name, styles.Cursor.Render(displayStr), config.GetVarSyntax()) } } b.WriteString(progressCmd) diff --git a/pkg/executor/resolve.go b/pkg/executor/resolve.go new file mode 100644 index 0000000..f52dc15 --- /dev/null +++ b/pkg/executor/resolve.go @@ -0,0 +1,223 @@ +package executor + +import ( + "strings" + "regexp" + + "github.com/gubarz/cheatmd/pkg/config" + "github.com/gubarz/cheatmd/pkg/parser" +) + +// CollectDependencies gathers all variable definitions and their topological ordering. +func CollectDependencies(cheat *parser.Cheat, index *parser.CheatIndex) ([]string, map[string][]parser.VarDef) { + varDefs := CollectVarDefinitions(cheat, index) + usedVars := FindAllVars(cheat.Command, config.GetVarSyntax()) + + if config.GetAllowUndeclaredVars() { + for _, name := range usedVars { + if _, ok := varDefs[name]; !ok { + varDefs[name] = []parser.VarDef{{Name: name}} + } + } + } + + allNeeded := FindAllDependencies(usedVars, varDefs) + orderedVars := TopologicalSort(usedVars, varDefs, allNeeded) + + return orderedVars, varDefs +} + +// CollectVarDefinitions gathers all var definitions from imports and local cheat. +func CollectVarDefinitions(cheat *parser.Cheat, index *parser.CheatIndex) map[string][]parser.VarDef { + varDefs := make(map[string][]parser.VarDef) + + var collectFromImports func(imports []string, seen map[string]bool) + collectFromImports = func(imports []string, seen map[string]bool) { + for _, importName := range imports { + if seen[importName] { + continue + } + seen[importName] = true + if module, ok := index.Modules[importName]; ok { + collectFromImports(module.Imports, seen) + for _, v := range module.Vars { + varDefs[v.Name] = append(varDefs[v.Name], v) + } + } + } + } + collectFromImports(cheat.Imports, make(map[string]bool)) + + for _, v := range cheat.Vars { + varDefs[v.Name] = append(varDefs[v.Name], v) + } + return varDefs +} + +func varDefDependencies(def parser.VarDef) []string { + var deps []string + deps = append(deps, FindAllVars(def.Shell, "dollar")...) + deps = append(deps, FindAllVars(def.Literal, "dollar")...) + deps = append(deps, FindAllVars(def.Condition, "dollar")...) + return deps +} + +// FindAllDependencies finds transitive closure of all needed variables. +func FindAllDependencies(usedVars []string, varDefs map[string][]parser.VarDef) map[string]bool { + allNeeded := make(map[string]bool) + queue := make([]string, len(usedVars)) + copy(queue, usedVars) + + for len(queue) > 0 { + varName := queue[0] + queue = queue[1:] + + if allNeeded[varName] { + continue + } + allNeeded[varName] = true + + for _, def := range varDefs[varName] { + for _, dep := range varDefDependencies(def) { + if !allNeeded[dep] { + queue = append(queue, dep) + } + } + } + } + return allNeeded +} + +// TopologicalSort orders variables by their dependencies. +func TopologicalSort(usedVars []string, varDefs map[string][]parser.VarDef, allNeeded map[string]bool) []string { + var orderedVars []string + added := make(map[string]bool) + visiting := make(map[string]bool) + + var addWithDeps func(varName string) + addWithDeps = func(varName string) { + if added[varName] || !allNeeded[varName] || visiting[varName] { + return + } + visiting[varName] = true + for _, def := range varDefs[varName] { + for _, dep := range varDefDependencies(def) { + addWithDeps(dep) + } + } + visiting[varName] = false + added[varName] = true + orderedVars = append(orderedVars, varName) + } + + for _, v := range usedVars { + addWithDeps(v) + } + return orderedVars +} + +// EvaluateCondition evaluates a condition expression against the scope. +func EvaluateCondition(condition string, scope map[string]string) bool { + condition = strings.TrimSpace(condition) + + condition = SubstituteVars(condition, scope, "dollar") + + if strings.Contains(condition, "==") { + parts := strings.SplitN(condition, "==", 2) + if len(parts) == 2 { + left := strings.TrimSpace(parts[0]) + right := strings.TrimSpace(parts[1]) + return left == right + } + } + + if strings.Contains(condition, "!=") { + parts := strings.SplitN(condition, "!=", 2) + if len(parts) == 2 { + left := strings.TrimSpace(parts[0]) + right := strings.TrimSpace(parts[1]) + return left != right + } + } + + return condition != "" +} + +// ReplaceVar replaces variable references in cmd with replacement. +func ReplaceVar(cmd, varName, replacement string, syntax string) string { + q := regexp.QuoteMeta(varName) + var parts []string + if syntax == "dollar" || syntax == "both" { + parts = append(parts, `\$`+q+`\b`) + } + if syntax == "angle" || syntax == "both" { + parts = append(parts, `<`+q+`>`) + } + if len(parts) == 0 { + return cmd + } + pattern := strings.Join(parts, "|") + re := regexp.MustCompile(pattern) + return re.ReplaceAllLiteralString(cmd, replacement) +} + +// FindAllVars finds ALL variable references in a command, ignoring quoting. +func FindAllVars(cmd string, syntax string) []string { + allowDollar := syntax == "dollar" || syntax == "both" + allowAngle := syntax == "angle" || syntax == "both" + + var vars []string + seen := make(map[string]bool) + add := func(name string) { + if seen[name] { + return + } + seen[name] = true + vars = append(vars, name) + } + + for i := 0; i < len(cmd); i++ { + switch cmd[i] { + case '$': + if !allowDollar { + continue + } + if i+1 >= len(cmd) { + continue + } + if i > 0 && cmd[i-1] == '\\' { + continue + } + j := i + 1 + for j < len(cmd) && parser.IsVarChar(cmd[j], j == i+1) { + j++ + } + if j > i+1 { + add(cmd[i+1 : j]) + } + i = j - 1 + case '<': + if !allowAngle { + continue + } + j := i + 1 + if j >= len(cmd) { + continue + } + if !parser.IsVarChar(cmd[j], true) { + continue + } + j++ + for j < len(cmd) && parser.IsVarChar(cmd[j], false) { + j++ + } + if j >= len(cmd) || cmd[j] != '>' { + continue + } + add(cmd[i+1 : j]) + i = j + } + } + + return vars +} diff --git a/pkg/linter/linter.go b/pkg/linter/linter.go index 4df31cd..3ce5ed6 100644 --- a/pkg/linter/linter.go +++ b/pkg/linter/linter.go @@ -15,7 +15,6 @@ package linter import ( "bufio" - "errors" "fmt" "os" "path/filepath" @@ -81,32 +80,36 @@ func Lint(path string) ([]Finding, error) { return nil, err } - files, err := collectFiles(path, info.IsDir()) + p := parser.NewParser() + var index *parser.CheatIndex + if info.IsDir() { + index, err = p.ParseDirectory(path) + } else { + index, err = p.ParseSingleFile(path) + } if err != nil { return nil, err } var findings []Finding - // Per-file syntax + structural checks. - for _, f := range files { - fs, err := lintFile(f) - if err != nil { - findings = append(findings, Finding{ - File: f, - Severity: SeverityError, - Message: fmt.Sprintf("read error: %v", err), - }) - continue + for _, e := range index.Errors { + severity := SeverityError + if strings.Contains(e.Message, "cheat has no markdown header") || + strings.Contains(e.Message, "block has no preceding code block") { + severity = SeverityWarning } - findings = append(findings, fs...) + findings = append(findings, Finding{ + File: e.File, + Line: e.Line, + Column: 1, + Severity: severity, + Message: e.Message, + }) } - // Whole-index checks (imports, undeclared refs, duplicate exports). - indexFindings, err := lintIndex(path, info.IsDir()) - if err == nil { - findings = append(findings, indexFindings...) - } + indexFindings := lintIndex(index, info.IsDir()) + findings = append(findings, indexFindings...) sort.SliceStable(findings, func(i, j int) bool { if findings[i].File != findings[j].File { @@ -120,610 +123,56 @@ func Lint(path string) ([]Finding, error) { return findings, nil } -// collectFiles walks path and returns every markdown file. Single-file paths -// are returned as a one-element slice. -func collectFiles(path string, isDir bool) ([]string, error) { - if !isDir { - if !isMarkdown(path) { - return nil, fmt.Errorf("%s is not a markdown file", path) - } - return []string{path}, nil - } - var files []string - err := filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if !d.IsDir() && isMarkdown(p) { - files = append(files, p) - } - return nil - }) - return files, err -} - -func isMarkdown(p string) bool { - ext := strings.ToLower(filepath.Ext(p)) - return ext == ".md" -} - // ============================================================================ -// Per-file checks +// Whole-index checks // ============================================================================ -// lintFile reads the file once and produces findings for DSL syntax, -// structural, and intra-file issues. -func lintFile(path string) ([]Finding, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - +// lintIndex runs the parser and checks cross-cheat references: missing +// imports, duplicate exports, and undeclared variable references in commands. +func lintIndex(index *parser.CheatIndex, isDir bool) []Finding { var findings []Finding - type cheatBlock struct { - startLine int - lines []string // lines inside the block, in order - lineNos []int // 1-indexed line numbers for each entry above - } - var ( - inCheat bool - inCodeFence bool - curBlock cheatBlock - cheatNameCounts = map[string]int{} - cheatNameLineNos = map[string]int{} - cheatNameDisplays = map[string]string{} - currentHeader string - currentHeaderDisplay string - currentHeaderLine int - // Track whether the previous non-blank line was a fence close, so we - // can flag `` blocks not preceded by a fence. - pendingFence bool - ) - - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - - lineNo := 0 - for scanner.Scan() { - lineNo++ - raw := scanner.Text() - line := strings.TrimSpace(raw) - - // Code fence boundaries. - if strings.HasPrefix(line, "```") { - if inCodeFence { - inCodeFence = false - pendingFence = true - } else if !inCheat { - inCodeFence = true - } - continue - } - if inCodeFence { - continue - } - - // Cheat block boundaries. - if !inCheat { - if content, ok := parseCheatSingleLine(line); ok { - isExport := cheatBlockExports(content) - if !pendingFence && !isExport { - findings = append(findings, Finding{ - File: path, - Line: lineNo, - Column: 1, - Severity: SeverityWarning, - Message: " block has no preceding code block", - }) - } - if pendingFence && !isExport { - if currentHeader == "" { - findings = append(findings, Finding{ - File: path, - Line: lineNo, - Column: 1, - Severity: SeverityWarning, - Message: "cheat has no markdown header", - }) - } else { - findings = append(findings, lintDuplicateCheatName( - path, currentHeader, currentHeaderDisplay, currentHeaderLine, - cheatNameCounts, cheatNameLineNos, cheatNameDisplays, - )...) - } - } - findings = append(findings, lintCheatBlock(path, cheatBlock{ - startLine: lineNo, - lines: []string{content}, - lineNos: []int{lineNo}, - })...) - pendingFence = false - continue - } - } - if !inCheat && isCheatStart(line) { - inCheat = true - curBlock = cheatBlock{startLine: lineNo} - // Flag if not preceded by a fence (no command to attach to). - continue - } - if inCheat { - if isCheatEnd(line) { - findings = append(findings, lintCheatBlock(path, curBlock)...) - isExport := cheatBlockExports(strings.Join(curBlock.lines, "\n")) - if !pendingFence && !isExport { - findings = append(findings, Finding{ - File: path, - Line: curBlock.startLine, - Column: 1, - Severity: SeverityWarning, - Message: " block has no preceding code block", - }) - } - if pendingFence && !isExport { - if currentHeader == "" { - findings = append(findings, Finding{ - File: path, - Line: curBlock.startLine, - Column: 1, - Severity: SeverityWarning, - Message: "cheat has no markdown header", - }) - } else { - findings = append(findings, lintDuplicateCheatName( - path, currentHeader, currentHeaderDisplay, currentHeaderLine, - cheatNameCounts, cheatNameLineNos, cheatNameDisplays, - )...) - } - } - inCheat = false - curBlock = cheatBlock{} - pendingFence = false - continue - } - curBlock.lines = append(curBlock.lines, line) - curBlock.lineNos = append(curBlock.lineNos, lineNo) - continue - } - - // Markdown header tracking for empty-header and code-block/comment-block - // naming checks. Duplicate checks happen only for actual cheats. - if header, level, ok := parseMarkdownHeader(line); ok { - if header == "" { - currentHeader = "" - currentHeaderDisplay = "" - currentHeaderLine = 0 - findings = append(findings, Finding{ - File: path, - Line: lineNo, - Column: 1, - Severity: SeverityWarning, - Message: "empty markdown header", - }) - } else { - currentHeader = header - currentHeaderDisplay = fmt.Sprintf("%s %s", strings.Repeat("#", level), header) - currentHeaderLine = lineNo - } - } - - // Only fences reset the pending-fence flag toward the next cheat block. - if line != "" { - pendingFence = false - } - } - if err := scanner.Err(); err != nil { - return findings, err - } - - // Unterminated cheat block. - if inCheat { + // Collect duplicate exports + for _, dup := range index.Duplicates { findings = append(findings, Finding{ - File: path, - Line: curBlock.startLine, + File: dup.File2, + Line: 1, Column: 1, Severity: SeverityError, - Message: "unterminated `` block (missing `-->`)", + Message: fmt.Sprintf("duplicate export %q (also defined in %s)", dup.Name, filepath.Base(dup.File1)), }) } - return findings, nil -} - -func lintDuplicateCheatName(file, header, display string, lineNo int, counts map[string]int, lineNos map[string]int, displays map[string]string) []Finding { - counts[header]++ - if counts[header] == 1 { - lineNos[header] = lineNo - displays[header] = display - return nil - } - if counts[header] > 2 { - return nil - } - return []Finding{{ - File: file, - Line: lineNo, - Column: 1, - Severity: SeverityWarning, - Message: fmt.Sprintf( - "duplicate cheat name %q (also `%s` at line %d)", - header, displays[header], lineNos[header], - ), - }} -} - -func isCheatStart(trimmed string) bool { - // "") { - return "", false - } - inner := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(trimmed, "")) - if len(inner) < len("cheat") || !strings.EqualFold(inner[:len("cheat")], "cheat") { - return "", false - } - if len(inner) > len("cheat") { - next := inner[len("cheat")] - if next != ' ' && next != '\t' { - return "", false - } - } - return strings.TrimSpace(inner[len("cheat"):]), true -} - -func isCheatEnd(trimmed string) bool { - return trimmed == "-->" -} - -func cheatBlockExports(content string) bool { - lines := joinContinuationLines(strings.Split(content, "\n")) - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - keyword, rest := splitFirstWord(line) - if keyword == "export" && rest != "" && !containsWS(rest) { - return true - } - } - return false -} - -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 -} - -func joinContinuationLinesWithLineNos(lines []string, lineNos []int) ([]string, []int) { - var ( - result []string - resultNos []int - current strings.Builder - startNo int - ) - - for i, line := range lines { - if current.Len() == 0 { - startNo = lineNos[i] - } - - trimmed := strings.TrimRight(line, " \t") - if strings.HasSuffix(trimmed, "\\") { - current.WriteString(strings.TrimSuffix(trimmed, "\\")) - continue - } - - current.WriteString(line) - result = append(result, current.String()) - resultNos = append(resultNos, startNo) - current.Reset() - } - - if current.Len() > 0 { - result = append(result, current.String()) - resultNos = append(resultNos, startNo) + // Check duplicate cheat headers globally + type cheatLoc struct { + file string + line int } - - return result, resultNos -} - -// ============================================================================ -// DSL syntax check (inside a single block) -// ============================================================================ - -// lintCheatBlock validates the DSL inside one cheat block. It expects all -// lines to be one of: `var`/`if`/`fi`/`export`/`import`/`chain`, blank, or `#`-comment. -func lintCheatBlock(file string, b struct { - startLine int - lines []string - lineNos []int -}) []Finding { - var findings []Finding - ifDepth := 0 - ifLines := []int{} // stack of `if` line numbers awaiting matching `fi` - lines, lineNos := joinContinuationLinesWithLineNos(b.lines, b.lineNos) - - for i, line := range lines { - lineNo := lineNos[i] - if line == "" || strings.HasPrefix(line, "#") { + headerLocs := make(map[string]cheatLoc) + warnedHeaders := make(map[string]bool) + for _, c := range index.Cheats { + if c.Header == "" { continue } - - keyword, rest := splitFirstWord(line) - switch keyword { - case "fi": - if rest != "" { - findings = append(findings, Finding{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: fmt.Sprintf("`fi` takes no arguments, got %q", rest), - }) - continue - } - if ifDepth == 0 { - findings = append(findings, Finding{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: "`fi` without a matching `if`", - }) - } else { - ifDepth-- - ifLines = ifLines[:len(ifLines)-1] - } - case "if": - if rest == "" { - findings = append(findings, Finding{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: "`if` requires a condition", - }) - continue - } - ifDepth++ - ifLines = append(ifLines, lineNo) - case "export", "import": - if rest == "" { - findings = append(findings, Finding{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: fmt.Sprintf("`%s` requires a name", keyword), - }) - } else if containsWS(rest) { + if firstLoc, exists := headerLocs[c.Header]; exists { + if !warnedHeaders[c.Header] { + msg := fmt.Sprintf("duplicate cheat name %q (also `# %s` at line %d)", c.Header, c.Header, firstLoc.line) + if firstLoc.file != c.File { + msg = fmt.Sprintf("duplicate cheat name %q (also `# %s` at %s:%d)", c.Header, c.Header, firstLoc.file, firstLoc.line) + } findings = append(findings, Finding{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: fmt.Sprintf("`%s` name must be a single token", keyword), + File: c.File, + Line: c.CommandStart, + Column: 1, + Severity: SeverityWarning, + Message: msg, }) + warnedHeaders[c.Header] = true } - case "chain": - findings = append(findings, lintChainLine(file, lineNo, rest)...) - case "var": - findings = append(findings, lintVarLine(file, lineNo, rest)...) - default: - findings = append(findings, Finding{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: fmt.Sprintf( - "unknown DSL keyword %q (expected one of: var, if, fi, export, import, chain)", - keyword, - ), - }) - } - } - - for _, ln := range ifLines { - findings = append(findings, Finding{ - File: file, Line: ln, Column: 1, - Severity: SeverityError, - Message: "`if` without a matching `fi`", - }) - } - return findings -} - -func lintChainLine(file string, lineNo int, rest string) []Finding { - name, after := splitFirstWord(rest) - step, extra := splitFirstWord(after) - if name == "" || step == "" || extra != "" { - return []Finding{{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: "`chain` requires exactly a name and positive step number", - }} - } - if containsWS(name) { - return []Finding{{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: "`chain` name must be a single token", - }} - } - for i := 0; i < len(step); i++ { - if step[i] < '0' || step[i] > '9' { - return []Finding{{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: "`chain` step must be a positive number", - }} - } - } - if step == "0" { - return []Finding{{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: "`chain` step must be a positive number", - }} - } - return nil -} - -func lintVarLine(file string, lineNo int, rest string) []Finding { - name, after := splitFirstWord(rest) - if name == "" { - return []Finding{{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: "`var` requires a name", - }} - } - if !isValidVarName(name) { - return []Finding{{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: fmt.Sprintf("invalid var name %q", name), - }} - } - if after == "" { - return nil // prompt-only var, valid - } - switch { - case strings.HasPrefix(after, "---"): - return nil - case strings.HasPrefix(after, ":="): - if strings.TrimSpace(after[2:]) == "" { - return []Finding{{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: fmt.Sprintf("`var %s :=` has no value", name), - }} - } - case after[0] == '=': - if strings.TrimSpace(after[1:]) == "" { - return []Finding{{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: fmt.Sprintf("`var %s =` has no shell command", name), - }} - } - default: - return []Finding{{ - File: file, Line: lineNo, Column: 1, - Severity: SeverityError, - Message: fmt.Sprintf( - "`var %s ...` is missing an assignment operator (use `=` for shell or `:=` for literal)", - name, - ), - }} - } - return nil -} - -func splitFirstWord(s string) (head, rest string) { - i := 0 - for i < len(s) && s[i] != ' ' && s[i] != '\t' { - i++ - } - head = s[:i] - for i < len(s) && (s[i] == ' ' || s[i] == '\t') { - i++ - } - rest = s[i:] - return -} - -func isValidVarName(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 -} - -func containsWS(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] == ' ' || s[i] == '\t' { - return true + } else { + headerLocs[c.Header] = cheatLoc{file: c.File, line: c.CommandStart} } } - return false -} - -// ============================================================================ -// Whole-index checks -// ============================================================================ - -// lintIndex runs the parser and checks cross-cheat references: missing -// imports, duplicate exports, and undeclared variable references in commands. -func lintIndex(path string, isDir bool) ([]Finding, error) { - p := parser.NewParser() - var ( - index *parser.CheatIndex - err error - ) - if isDir { - index, err = p.ParseDirectory(path) - } else { - index, err = p.ParseSingleFile(path) - } - if err != nil { - return nil, err - } - if index == nil { - return nil, errors.New("parser returned no index") - } - - var findings []Finding - - // Duplicate exports. - for _, dup := range index.Duplicates { - line, col := findDSLRef(dup.File2, "export", dup.Name) - findings = append(findings, Finding{ - File: dup.File2, - Line: line, - Column: col, - Severity: SeverityError, - Message: fmt.Sprintf("duplicate export %q (also exported in %s)", dup.Name, dup.File1), - }) - } seenChainSteps := make(map[string]*parser.Cheat) chainSteps := make(map[string]map[int]*parser.Cheat) @@ -768,20 +217,19 @@ func lintIndex(path string, isDir bool) ([]Finding, error) { if c.Export != "" || !c.HasCheatBlock { continue } - declared := declaredVarNames(c, index) - addSyntaxDeclarations(c.Command, declared) - for _, ref := range referencedVars(c) { - if isMissing(ref, declared, c.Command) { - findings = append(findings, Finding{ - File: c.File, - Line: ref.Line, - Column: ref.Column, - Severity: SeverityWarning, - Message: fmt.Sprintf( - "variable %q referenced in header %q but not declared.", - ref.Name, c.Header, - ), - }) + if len(c.Command) > 0 { + declared := declaredVarNames(c, index) + addSyntaxDeclarations(c.Command, declared) + for _, ref := range referencedVars(c) { + if isMissing(ref, declared, c.Command) { + findings = append(findings, Finding{ + File: c.File, + Line: ref.Line, + Column: ref.Column, + Severity: SeverityWarning, + Message: fmt.Sprintf("undeclared variable %q referenced in command", ref.Name), + }) + } } } } @@ -815,7 +263,7 @@ func lintIndex(path string, isDir bool) ([]Finding, error) { }) } } - return findings, nil + return findings } func findDSLRef(file, keyword, name string) (int, int) { @@ -836,19 +284,19 @@ func findDSLRef(file, keyword, name string) (int, int) { line := strings.TrimSpace(raw) if !inCheat { - if content, ok := parseCheatSingleLine(line); ok { + if content, ok := parser.ParseCheatSingleLine(line); ok { if dslLineMatches(content, keyword, name) { return lineNo, stringColumn(raw, name) } continue } - if isCheatStart(line) { + if parser.IsCheatStart(line) { inCheat = true } continue } - if isCheatEnd(line) { + if parser.IsCheatEnd(line) { inCheat = false continue } @@ -864,8 +312,8 @@ func dslLineMatches(line, keyword, name string) bool { if line == "" || strings.HasPrefix(line, "#") { return false } - gotKeyword, rest := splitFirstWord(line) - gotName, _ := splitFirstWord(rest) + gotKeyword, rest := parser.SplitFirstWord(line) + gotName, _ := parser.SplitFirstWord(rest) return gotKeyword == keyword && gotName == name } @@ -953,13 +401,6 @@ func isMissing(ref Ref, declared map[string]bool, cmd string) bool { return true } -func isVarChar(c byte, first bool) bool { - if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' { - return true - } - return !first && c >= '0' && c <= '9' -} - func referencedVars(c *parser.Cheat) []Ref { var refs []Ref seen := make(map[string]bool) @@ -1036,11 +477,11 @@ func scanDollarRef(line string, pos int, kind RefKind, lineNo int) (Ref, int, bo if kind == RefShellParam && isShellSingleSpecial(next) { return Ref{Name: string(next), Kind: kind, Line: lineNo, Column: pos + 1}, pos + 2, true } - if !isVarChar(next, true) { + if !parser.IsVarChar(next, true) { return Ref{}, pos + 1, false } j := pos + 2 - for j < len(line) && isVarChar(line[j], false) { + for j < len(line) && parser.IsVarChar(line[j], false) { j++ } if kind == RefPowerShellParam && j < len(line) && line[j] == ':' { @@ -1051,11 +492,11 @@ func scanDollarRef(line string, pos int, kind RefKind, lineNo int) (Ref, int, bo func scanAngleRef(line string, pos int, lineNo int) (Ref, int, bool) { j := pos + 1 - if j >= len(line) || !isVarChar(line[j], true) { + if j >= len(line) || !parser.IsVarChar(line[j], true) { return Ref{}, pos + 1, false } j++ - for j < len(line) && isVarChar(line[j], false) { + for j < len(line) && parser.IsVarChar(line[j], false) { j++ } if j >= len(line) || line[j] != '>' { @@ -1143,7 +584,7 @@ func isLikelyPowerShellCommand(cmd string) bool { if trimmed == "" { return false } - first, _ := splitFirstWord(trimmed) + first, _ := parser.SplitFirstWord(trimmed) firstLower := strings.ToLower(first) if strings.Contains(first, "-") { verb := strings.SplitN(firstLower, "-", 2)[0] @@ -1218,11 +659,11 @@ func heredocMarker(line string) (string, bool) { } func isIdentifier(s string) bool { - if s == "" || !isVarChar(s[0], true) { + if s == "" || !parser.IsVarChar(s[0], true) { return false } for i := 1; i < len(s); i++ { - if !isVarChar(s[i], false) { + if !parser.IsVarChar(s[i], false) { return false } } @@ -1264,7 +705,7 @@ func isShellBracedVarChar(c byte, first bool) bool { if c >= '0' && c <= '9' { return true } - return isVarChar(c, first) + return parser.IsVarChar(c, first) } func isShellSingleSpecial(c byte) bool { diff --git a/pkg/parser/dsl.go b/pkg/parser/dsl.go index 7553bee..5769737 100644 --- a/pkg/parser/dsl.go +++ b/pkg/parser/dsl.go @@ -1,6 +1,7 @@ package parser import ( + "fmt" "strconv" "strings" ) @@ -10,12 +11,16 @@ import ( // Hand-rolled dispatch on the first keyword (var / if / fi / export / import / chain) // avoids per-line regex matching. Each non-comment, non-blank line is matched // against at most one branch. -func parseCheatDSL(cheat *Cheat, content string) { +func parseCheatDSL(cheat *Cheat, content string, path string, startLine int) []ParseError { lines := joinContinuationLines(strings.Split(content, "\n")) var currentCondition string + var errs []ParseError + ifDepth := 0 + ifLines := []int{} - for _, line := range lines { + for i, line := range lines { + lineNo := startLine + i line = strings.TrimSpace(line) if line == "" || line[0] == '#' { continue @@ -24,41 +29,74 @@ func parseCheatDSL(cheat *Cheat, content string) { keyword, rest := splitFirstWord(line) switch keyword { case "fi": - if rest == "" { - currentCondition = "" + if rest != "" { + errs = append(errs, ParseError{File: path, Line: lineNo, Message: fmt.Sprintf("`fi` takes no arguments, got %q", rest)}) + } + if ifDepth == 0 { + errs = append(errs, ParseError{File: path, Line: lineNo, Message: "`fi` without a matching `if`"}) + } else { + ifDepth-- + ifLines = ifLines[:len(ifLines)-1] } + currentCondition = "" case "if": - if rest != "" { + if rest == "" { + errs = append(errs, ParseError{File: path, Line: lineNo, Message: "`if` requires a condition"}) + } else { currentCondition = rest } + ifDepth++ + ifLines = append(ifLines, lineNo) case "export": - if rest != "" && !containsWhitespace(rest) { + if rest == "" { + errs = append(errs, ParseError{File: path, Line: lineNo, Message: "`export` requires a name"}) + } else if containsWhitespace(rest) { + errs = append(errs, ParseError{File: path, Line: lineNo, Message: "`export` name must be a single token"}) + } else { cheat.Export = rest } case "import": - if rest != "" && !containsWhitespace(rest) { + if rest == "" { + errs = append(errs, ParseError{File: path, Line: lineNo, Message: "`import` requires a name"}) + } else if containsWhitespace(rest) { + errs = append(errs, ParseError{File: path, Line: lineNo, Message: "`import` name must be a single token"}) + } else { cheat.Imports = append(cheat.Imports, rest) } case "chain": - parseChainLine(cheat, rest) + if err := parseChainLine(cheat, rest); err != "" { + errs = append(errs, ParseError{File: path, Line: lineNo, Message: err}) + } case "var": - parseVarLine(cheat, rest, currentCondition) + if err := parseVarLine(cheat, rest, currentCondition); err != "" { + errs = append(errs, ParseError{File: path, Line: lineNo, Message: err}) + } + default: + errs = append(errs, ParseError{File: path, Line: lineNo, Message: fmt.Sprintf("unknown DSL keyword %q (expected one of: var, if, fi, export, import, chain)", keyword)}) } } + for _, ln := range ifLines { + errs = append(errs, ParseError{File: path, Line: ln, Message: "`if` without a matching `fi`"}) + } + return errs } -func parseChainLine(cheat *Cheat, rest string) { +func parseChainLine(cheat *Cheat, rest string) string { name, after := splitFirstWord(rest) stepText, extra := splitFirstWord(after) if name == "" || stepText == "" || extra != "" { - return + return "`chain` requires exactly a name and positive step number" + } + if containsWhitespace(name) { + return "`chain` name must be a single token" } step, err := strconv.Atoi(stepText) if err != nil || step < 1 { - return + return "`chain` step must be a positive number" } cheat.ChainName = name cheat.ChainStep = step + return "" } // parseVarLine handles the three var declaration forms: @@ -67,10 +105,13 @@ func parseChainLine(cheat *Cheat, rest string) { // var NAME --- args -> prompt-only with selector/prompt args // var NAME := value -> literal // var NAME = value -> shell -func parseVarLine(cheat *Cheat, rest, condition string) { +func parseVarLine(cheat *Cheat, rest, condition string) string { name, after := splitFirstWord(rest) - if name == "" || !isValidDSLVarName(name) { - return + if name == "" { + return "`var` requires a name" + } + if !isValidDSLVarName(name) { + return fmt.Sprintf("invalid var name %q", name) } if after == "" { @@ -78,7 +119,7 @@ func parseVarLine(cheat *Cheat, rest, condition string) { Name: name, Condition: condition, }) - return + return "" } switch { @@ -91,16 +132,19 @@ func parseVarLine(cheat *Cheat, rest, condition string) { case strings.HasPrefix(after, ":="): value := strings.TrimSpace(after[2:]) if value == "" { - return + return fmt.Sprintf("`var %s :=` has no value", name) } cheat.Vars = append(cheat.Vars, ParseVarDefWithCondition(name, value, condition, true)) case after[0] == '=': value := strings.TrimSpace(after[1:]) if value == "" { - return + return fmt.Sprintf("`var %s =` has no shell command", name) } cheat.Vars = append(cheat.Vars, ParseVarDefWithCondition(name, value, condition, false)) + default: + return fmt.Sprintf("`var %s ...` is missing an assignment operator (use `=` for shell or `:=` for literal)", name) } + return "" } // splitFirstWord returns the leading whitespace-delimited token and the diff --git a/pkg/parser/lex.go b/pkg/parser/lex.go index aeae0d3..c25816a 100644 --- a/pkg/parser/lex.go +++ b/pkg/parser/lex.go @@ -99,14 +99,17 @@ func parseHeader(line []byte) (string, bool) { if i == 0 || i > 6 { return "", false } - if i >= len(line) || line[i] != ' ' { + if i >= len(line) { + return "", true + } + if line[i] != ' ' { return "", false } i++ if i >= len(line) { - return "", false + return "", true } - return string(line[i:]), true + return strings.TrimSpace(string(line[i:])), true } // parseCodeBlockStart parses ```lang title:"desc" without regex. diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go index e63af62..1163485 100644 --- a/pkg/parser/parser.go +++ b/pkg/parser/parser.go @@ -61,6 +61,7 @@ type parseResult struct { cheats []*Cheat modules map[string]*Module duplicates []DuplicateExport + errors []ParseError } // parseFilesParallel reads and parses files using a two-stage pipeline @@ -114,7 +115,7 @@ func parseFilesParallel(files []string) []parseResult { } } } - resultChan <- parseResult{cheats: localCheats, modules: localModules, duplicates: localDuplicates} + resultChan <- parseResult{cheats: localCheats, modules: localModules, duplicates: localDuplicates, errors: localParser.index.Errors} }(chunk) } @@ -137,6 +138,7 @@ func (p *Parser) mergeResults(results []parseResult) { totalCheats = append(totalCheats, r.cheats...) // Carry forward any duplicates detected within a single worker p.index.Duplicates = append(p.index.Duplicates, r.duplicates...) + p.index.Errors = append(p.index.Errors, r.errors...) for name, mod := range r.modules { if p.index.Modules == nil { p.index.Modules = make(map[string]*Module) @@ -187,6 +189,7 @@ type parseState struct { codeBlockDesc string codeBlockBuf []byte // direct byte buffer, no Builder overhead inCheatBlock bool + cheatBlockStart int cheatBlockBuf []byte pendingCodeBlocks []codeBlock fileTags []string // tags from front matter + footer @@ -232,6 +235,7 @@ func getParseState() *parseState { s.codeBlockDesc = "" s.codeBlockBuf = s.codeBlockBuf[:0] s.inCheatBlock = false + s.cheatBlockStart = 0 s.cheatBlockBuf = s.cheatBlockBuf[:0] s.pendingCodeBlocks = s.pendingCodeBlocks[:0] s.fileTags = s.fileTags[:0] @@ -282,6 +286,14 @@ func (p *Parser) parseLines(path string, data []byte) { } } + if state.inCheatBlock { + p.index.Errors = append(p.index.Errors, ParseError{ + File: path, + Line: state.cheatBlockStart, + Message: "unterminated `` block (missing `-->`)", + }) + } + // Process remaining pending blocks p.processPendingBlocks(path, state) } @@ -340,6 +352,13 @@ func (p *Parser) parseLine(path string, line []byte, s *parseState) { if header, ok := parseHeader(line); ok { p.processPendingBlocks(path, s) s.reset(header) + if header == "" { + p.index.Errors = append(p.index.Errors, ParseError{ + File: path, + Line: s.lineNo, + Message: "empty markdown header", + }) + } return } } @@ -366,6 +385,7 @@ func (p *Parser) parseLine(path string, line []byte, s *parseState) { // Multi-line cheat block start: comments func (p *Parser) processCheatComment(path string, s *parseState, content string) { if len(s.pendingCodeBlocks) == 0 { + // Standalone single-line comment without a code block + cheat := p.createCheat(path, s, codeBlock{}, content, true, s.lineNo) + if cheat.Export == "" { + p.index.Errors = append(p.index.Errors, ParseError{ + File: path, + Line: s.lineNo, + Message: " block has no preceding code block", + }) + } else { + p.index.RegisterModule(cheat) + } return } - p.flushLastPendingCheat(path, s, content) + p.flushLastPendingCheat(path, s, content, s.lineNo) } // processCheatBlock handles multi-line cheat blocks @@ -397,21 +428,27 @@ func (p *Parser) processCheatBlock(path string, s *parseState) { content := string(s.cheatBlockBuf) if len(s.pendingCodeBlocks) > 0 { - p.flushLastPendingCheat(path, s, content) + p.flushLastPendingCheat(path, s, content, s.cheatBlockStart) } else { // Standalone cheat block (module definition) - cheat := p.createCheat(path, s, codeBlock{}, content, true) - if cheat.Export != "" { + cheat := p.createCheat(path, s, codeBlock{}, content, true, s.cheatBlockStart) + if cheat.Export == "" { + p.index.Errors = append(p.index.Errors, ParseError{ + File: path, + Line: s.cheatBlockStart, + Message: " block has no preceding code block", + }) + } else { p.index.RegisterModule(cheat) } } } // flushLastPendingCheat pops the last pending code block and creates a cheat from it -func (p *Parser) flushLastPendingCheat(path string, s *parseState, cheatBlock string) { +func (p *Parser) flushLastPendingCheat(path string, s *parseState, cheatBlock string, cheatLine int) { lastIdx := len(s.pendingCodeBlocks) - 1 block := s.pendingCodeBlocks[lastIdx] - cheat := p.createCheat(path, s, block, cheatBlock, true) + cheat := p.createCheat(path, s, block, cheatBlock, true, cheatLine) p.index.AddCheat(cheat) p.index.RegisterModule(cheat) s.pendingCodeBlocks = s.pendingCodeBlocks[:lastIdx] @@ -421,7 +458,7 @@ func (p *Parser) flushLastPendingCheat(path string, s *parseState, cheatBlock st func (p *Parser) processPendingBlocks(path string, s *parseState) { for _, block := range s.pendingCodeBlocks { if IsShellLanguage(block.lang) && block.content != "" { - cheat := p.createCheat(path, s, block, "", false) + cheat := p.createCheat(path, s, block, "", false, block.startLine) p.index.AddCheat(cheat) } } @@ -432,7 +469,7 @@ func (p *Parser) processPendingBlocks(path string, s *parseState) { // ============================================================================ // createCheat creates a new cheat from parsed data -func (p *Parser) createCheat(path string, s *parseState, block codeBlock, cheatBlock string, hasCheatBlock bool) *Cheat { +func (p *Parser) createCheat(path string, s *parseState, block codeBlock, cheatBlock string, hasCheatBlock bool, cheatLine int) *Cheat { cheat := NewCheat(path, s.currentHeader) cheat.Description = strings.TrimSpace(block.description) cheat.Command = block.content @@ -442,8 +479,17 @@ func (p *Parser) createCheat(path string, s *parseState, block codeBlock, cheatB cheat.HasCheatBlock = hasCheatBlock cheat.Tags = p.buildCheatTags(path, s) + if cheat.Header == "" && hasCheatBlock { + p.index.Errors = append(p.index.Errors, ParseError{ + File: path, + Line: cheatLine, + Message: "cheat has no markdown header", + }) + } + if cheatBlock != "" { - parseCheatDSL(cheat, cheatBlock) + errors := parseCheatDSL(cheat, cheatBlock, path, cheatLine) + p.index.Errors = append(p.index.Errors, errors...) } s.headerCheats = append(s.headerCheats, cheat) diff --git a/pkg/parser/parser_test.go b/pkg/parser/parser_test.go index 9d392d7..ac9659e 100644 --- a/pkg/parser/parser_test.go +++ b/pkg/parser/parser_test.go @@ -82,7 +82,7 @@ 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) + parseCheatDSL(cheat, dslBlock, "test.md", 1) if len(cheat.Vars) != 3 { t.Fatalf("parseCheatDSL() parsed %d vars, want 3", len(cheat.Vars)) @@ -101,7 +101,7 @@ func TestParseCheatDSL_Literal(t *testing.T) { dslBlock := "var greeting := hello world" cheat := &Cheat{} - parseCheatDSL(cheat, dslBlock) + parseCheatDSL(cheat, dslBlock, "test.md", 1) if len(cheat.Vars) != 1 { t.Fatalf("parseCheatDSL() parsed %d vars, want 1", len(cheat.Vars)) @@ -116,7 +116,7 @@ func TestParseCheatDSL_Conditional(t *testing.T) { dslBlock := "if $method == password\nvar cred = echo enter-password\nfi" cheat := &Cheat{} - parseCheatDSL(cheat, dslBlock) + parseCheatDSL(cheat, dslBlock, "test.md", 1) if len(cheat.Vars) != 1 { t.Fatalf("parseCheatDSL() parsed %d vars, want 1", len(cheat.Vars)) @@ -131,7 +131,7 @@ func TestParseCheatDSL_ExportImport(t *testing.T) { dslBlock := "export mymodule\nimport othermodule" cheat := &Cheat{} - parseCheatDSL(cheat, dslBlock) + parseCheatDSL(cheat, dslBlock, "test.md", 1) if cheat.Export != "mymodule" { t.Errorf("parseCheatDSL() Export = %q, want %q", cheat.Export, "mymodule") @@ -146,7 +146,7 @@ func TestParseCheatDSL_Chain(t *testing.T) { dslBlock := "chain privesc 2" cheat := &Cheat{} - parseCheatDSL(cheat, dslBlock) + parseCheatDSL(cheat, dslBlock, "test.md", 1) if cheat.ChainName != "privesc" || cheat.ChainStep != 2 { t.Fatalf("chain = %q %d, want privesc 2", cheat.ChainName, cheat.ChainStep) @@ -157,7 +157,7 @@ func TestParseCheatDSL_Comments(t *testing.T) { dslBlock := "# this is a comment\nvar host = echo localhost" cheat := &Cheat{} - parseCheatDSL(cheat, dslBlock) + parseCheatDSL(cheat, dslBlock, "test.md", 1) if len(cheat.Vars) != 1 { t.Fatalf("parseCheatDSL() parsed %d vars, want 1", len(cheat.Vars)) @@ -172,7 +172,7 @@ func TestParseCheatDSL_PromptOnlyWithArgs(t *testing.T) { dslBlock := "if $auth_method != kerberos\nvar credential --- --header \"Credential\"\nfi" cheat := &Cheat{} - parseCheatDSL(cheat, dslBlock) + parseCheatDSL(cheat, dslBlock, "test.md", 1) if len(cheat.Vars) != 1 { t.Fatalf("parseCheatDSL() parsed %d vars, want 1", len(cheat.Vars)) diff --git a/pkg/parser/types.go b/pkg/parser/types.go index fd76d7b..dfeda59 100644 --- a/pkg/parser/types.go +++ b/pkg/parser/types.go @@ -112,11 +112,19 @@ type DuplicateExport struct { File2 string } +// ParseError records a syntax error encountered during parsing. +type ParseError struct { + File string + Line int + Message string +} + // CheatIndex holds all parsed cheats and modules. type CheatIndex struct { Cheats []*Cheat Modules map[string]*Module Duplicates []DuplicateExport + Errors []ParseError Root string ChainMaxSteps map[string]int } diff --git a/pkg/parser/utils.go b/pkg/parser/utils.go new file mode 100644 index 0000000..b5f4b88 --- /dev/null +++ b/pkg/parser/utils.go @@ -0,0 +1,114 @@ +package parser + +import ( + "strings" +) + +// ParseMarkdownHeader parses a line to see if it is a markdown header. +// It returns the header text, the level (number of #s), and a boolean +// indicating if it is a valid header. +func ParseMarkdownHeader(trimmed string) (header string, level int, ok bool) { + level = 0 + for level < len(trimmed) && trimmed[level] == '#' { + level++ + } + if level == 0 || level > 6 { + return "", 0, false + } + if level == len(trimmed) { + return "", level, true + } + if trimmed[level] != ' ' && trimmed[level] != '\t' { + return "", 0, false + } + return strings.TrimSpace(trimmed[level:]), level, true +} + +// ParseCheatSingleLine extracts the content of a single-line HTML comment if it starts with "cheat". +func ParseCheatSingleLine(trimmed string) (string, bool) { + if !strings.HasPrefix(trimmed, "") { + return "", false + } + inner := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(trimmed, "")) + if len(inner) < len("cheat") || !strings.EqualFold(inner[:len("cheat")], "cheat") { + return "", false + } + if len(inner) > len("cheat") { + next := inner[len("cheat")] + if next != ' ' && next != '\t' { + return "", false + } + } + return strings.TrimSpace(inner[len("cheat"):]), true +} + +// IsCheatStart checks if the line is the start of a multi-line cheat comment block. +func IsCheatStart(trimmed string) bool { + if !strings.HasPrefix(trimmed, "" +} + +// SplitFirstWord splits a string into the first word and the rest of the string. +func SplitFirstWord(s string) (head, rest string) { + i := 0 + for i < len(s) && s[i] != ' ' && s[i] != '\t' { + i++ + } + head = s[:i] + for i < len(s) && (s[i] == ' ' || s[i] == '\t') { + i++ + } + rest = s[i:] + return +} + +// IsVarChar returns true if c is valid in a variable name. +func IsVarChar(c byte, first bool) bool { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' { + return true + } + return !first && c >= '0' && c <= '9' +} + +// SplitLines splits text into non-empty trimmed lines. +func SplitLines(s string) []string { + if s == "" { + return nil + } + + lineCount := strings.Count(s, "\n") + 1 + lines := make([]string, 0, lineCount) + + for len(s) > 0 { + idx := strings.IndexByte(s, '\n') + var line string + if idx == -1 { + line = s + s = "" + } else { + line = s[:idx] + s = s[idx+1:] + } + + start, end := 0, len(line) + for start < end && (line[start] == ' ' || line[start] == '\t' || line[start] == '\r') { + start++ + } + for end > start && (line[end-1] == ' ' || line[end-1] == '\t' || line[end-1] == '\r') { + end-- + } + if start < end { + lines = append(lines, line[start:end]) + } + } + + return lines +}