Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 2 additions & 62 deletions internal/headless/headless.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -118,8 +117,8 @@ func (s *RunnerSession) tryMatchByQuery(cheats []*parser.Cheat, query string) er
return fmt.Errorf("headless runner requires a precise query or match command to isolate a single cheat block")
}
words := strings.Fields(strings.ToLower(query))
matchedCheats := s.filterCheatsByWords(cheats, words)
s.Cheat = s.findExactHeaderMatch(matchedCheats, query)
matchedCheats := resolver.SearchCheats(cheats, words)
s.Cheat = resolver.FindExactHeaderMatch(matchedCheats, query)
if s.Cheat != nil {
return nil
}
Expand All @@ -133,25 +132,6 @@ func (s *RunnerSession) tryMatchByQuery(cheats []*parser.Cheat, query string) er
return fmt.Errorf("headless runner requires a precise query or match command to isolate a single cheat block")
}

func (s *RunnerSession) filterCheatsByWords(cheats []*parser.Cheat, words []string) []*parser.Cheat {
var matched []*parser.Cheat
for _, c := range cheats {
if cheatMatchesQuery(c, words) {
matched = append(matched, c)
}
}
return matched
}

func (s *RunnerSession) findExactHeaderMatch(cheats []*parser.Cheat, query string) *parser.Cheat {
for _, mc := range cheats {
if strings.EqualFold(mc.Header, query) {
return mc
}
}
return nil
}

// runCommand constructs the final command string, attaches any configured hooks,
// executes the command on the target shell, and reports the output via JSON-RPC.
func (s *RunnerSession) runCommand() error {
Expand Down Expand Up @@ -270,43 +250,3 @@ func getExitCode(err error) int {
}
return -1
}

// cheatMatchesQuery performs a heuristic search across the cheat's metadata for targeting.
func cheatMatchesQuery(cheat *parser.Cheat, words []string) bool {
for _, word := range words {
if !wordMatchesCheat(cheat, word) {
return false
}
}
return true
}

func wordMatchesCheat(cheat *parser.Cheat, word string) bool {
if matchesBasicMetadata(cheat, word) {
return true
}
return matchesAnyTag(cheat.Tags, word)
}

func matchesBasicMetadata(cheat *parser.Cheat, word string) bool {
folder := strings.ToLower(filepath.Base(cheat.File))
file := strings.ToLower(strings.TrimSuffix(filepath.Base(cheat.File), ".md"))
header := strings.ToLower(cheat.Header)
desc := strings.ToLower(cheat.Description)
command := strings.ToLower(cheat.Command)

return strings.Contains(folder, word) ||
strings.Contains(file, word) ||
strings.Contains(header, word) ||
strings.Contains(desc, word) ||
strings.Contains(command, word)
}

func matchesAnyTag(tags []string, word string) bool {
for _, tag := range tags {
if strings.Contains(strings.ToLower(tag), word) {
return true
}
}
return false
}
74 changes: 74 additions & 0 deletions internal/resolver/search.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package resolver

import (
"path/filepath"
"strings"

"github.com/cheatmd-dev/cheatmd/pkg/parser"
)

// SearchCheats filters a slice of cheats returning only those that match all query words.
// Query words must be pre-lowercased.
func SearchCheats(cheats []*parser.Cheat, words []string) []*parser.Cheat {
var matched []*parser.Cheat
for _, c := range cheats {
if MatchesQuery(c, words) {
matched = append(matched, c)
}
}
return matched
}

// MatchesQuery returns true if the cheat's metadata contains all query words.
// Query words must be pre-lowercased.
func MatchesQuery(cheat *parser.Cheat, words []string) bool {
for _, word := range words {
if !ContainsWord(cheat, word) {
return false
}
}
return true
}

// ContainsWord returns true if the cheat's metadata (folder, file, header, description, command, tags)
// contains the given lowercase word.
func ContainsWord(cheat *parser.Cheat, word string) bool {
folder := strings.ToLower(filepath.Base(filepath.Dir(cheat.File)))
file := strings.ToLower(strings.TrimSuffix(filepath.Base(cheat.File), filepath.Ext(cheat.File)))

if strings.Contains(folder, word) ||
strings.Contains(file, word) ||
strings.Contains(strings.ToLower(cheat.Header), word) ||
strings.Contains(strings.ToLower(cheat.Description), word) ||
strings.Contains(strings.ToLower(cheat.Command), word) {
return true
}

for _, tag := range cheat.Tags {
if strings.Contains(strings.ToLower(tag), word) {
return true
}
}
return false
}

// FindExactHeaderMatch returns the first cheat with an exact case-insensitive header match.
func FindExactHeaderMatch(cheats []*parser.Cheat, query string) *parser.Cheat {
for _, mc := range cheats {
if strings.EqualFold(mc.Header, query) {
return mc
}
}
return nil
}

// MatchesAllWords returns true if the given text contains all the given words.
// The text and words must be pre-lowercased.
func MatchesAllWords(text string, words []string) bool {
for _, w := range words {
if !strings.Contains(text, w) {
return false
}
}
return true
}
72 changes: 72 additions & 0 deletions internal/resolver/search_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package resolver

import (
"testing"

"github.com/cheatmd-dev/cheatmd/pkg/parser"
)

func TestSearchCheats(t *testing.T) {
c1 := &parser.Cheat{
File: "/path/to/project/docker.md",
Header: "Build image",
Description: "Builds a docker image",
Command: "docker build -t app .",
Tags: []string{"container"},
}
c2 := &parser.Cheat{
File: "/path/to/project/k8s.md",
Header: "Deploy to k8s",
Description: "Deploys to kubernetes",
Command: "kubectl apply -f .",
Tags: []string{"container", "deploy"},
}

cheats := []*parser.Cheat{c1, c2}

// Test folder match
if len(SearchCheats(cheats, []string{"project"})) != 2 {
t.Error("Expected both cheats to match folder 'project'")
}

// Test file match
if len(SearchCheats(cheats, []string{"docker"})) != 1 {
t.Error("Expected only c1 to match 'docker'")
}

// Test header match
if len(SearchCheats(cheats, []string{"deploy", "k8s"})) != 1 {
t.Error("Expected only c2 to match 'deploy k8s'")
}

// Test tag match
if len(SearchCheats(cheats, []string{"container"})) != 2 {
t.Error("Expected both cheats to match tag 'container'")
}
}

func TestFindExactHeaderMatch(t *testing.T) {
c1 := &parser.Cheat{Header: "Build Image"}
c2 := &parser.Cheat{Header: "Deploy App"}
cheats := []*parser.Cheat{c1, c2}

if match := FindExactHeaderMatch(cheats, "build image"); match != c1 {
t.Error("Expected case-insensitive exact match for 'build image'")
}
if match := FindExactHeaderMatch(cheats, "deploy app"); match != c2 {
t.Error("Expected case-insensitive exact match for 'deploy app'")
}
if match := FindExactHeaderMatch(cheats, "build"); match != nil {
t.Error("Expected nil for partial match")
}
}

func TestMatchesAllWords(t *testing.T) {
text := "the quick brown fox"
if !MatchesAllWords(text, []string{"quick", "fox"}) {
t.Error("Expected text to match all words")
}
if MatchesAllWords(text, []string{"quick", "lazy"}) {
t.Error("Expected text to not match missing words")
}
}
34 changes: 3 additions & 31 deletions internal/ui/cheat_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"

"github.com/cheatmd-dev/cheatmd/internal/resolver"
"github.com/cheatmd-dev/cheatmd/pkg/chainstate"
"github.com/cheatmd-dev/cheatmd/pkg/config"
"github.com/cheatmd-dev/cheatmd/pkg/parser"
Expand Down Expand Up @@ -80,36 +81,7 @@ func newChainItem(chain chainGroup, cheat *parser.Cheat) cheatItem {
// matchesQuery reports whether the cheat item matches all search words.
// Words must be pre-lowercased.
func (item *cheatItem) matchesQuery(words []string) bool {
for _, word := range words {
if !item.containsWord(word) {
return false
}
}
return true
}

func (item *cheatItem) containsWord(word string) bool {
if strings.Contains(strings.ToLower(item.folder), word) {
return true
}
if strings.Contains(strings.ToLower(item.file), word) {
return true
}
if strings.Contains(strings.ToLower(item.cheat.Header), word) {
return true
}
if strings.Contains(strings.ToLower(item.cheat.Description), word) {
return true
}
if strings.Contains(strings.ToLower(item.cheat.Command), word) {
return true
}
for _, tag := range item.cheat.Tags {
if strings.Contains(strings.ToLower(tag), word) {
return true
}
}
return false
return resolver.MatchesQuery(item.cheat, words)
}

// buildPathDisplay builds the path display string based on config options.
Expand Down Expand Up @@ -280,7 +252,7 @@ func chainMatchesQuery(chain chainGroup, words []string) bool {
hay += " " + strings.ToLower(step.Header)
hay += " " + strings.ToLower(step.Description)
}
return matchesAllWords(hay, words)
return resolver.MatchesAllWords(hay, words)
}

func (m *mainModel) nextChainStep(chain chainGroup) *parser.Cheat {
Expand Down
10 changes: 0 additions & 10 deletions internal/ui/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,6 @@ func truncateLines(text string, maxLines int, maxLen int) string {
return text
}

// matchesAllWords returns true if text contains all words.
func matchesAllWords(text string, words []string) bool {
for _, word := range words {
if !strings.Contains(text, word) {
return false
}
}
return true
}

// formatKeyDisplay turns "ctrl+x" into "Ctrl+X" for display purposes.
func formatKeyDisplay(key string) string {
if strings.HasPrefix(key, "ctrl+") {
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/history_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func (m *mainModel) enterHistory() bool {
m.histState = &historyState{
picker: NewPicker(entries, func(e history.Entry, words []string) bool {
hay := strings.ToLower(e.Command + " " + e.Header + " " + e.File)
return matchesAllWords(hay, words)
return resolver.MatchesAllWords(hay, words)
}),
prevInput: m.textInput.Value(),
prevCursor: m.picker.Cursor,
Expand Down
4 changes: 2 additions & 2 deletions internal/ui/substitute_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

tea "github.com/charmbracelet/bubbletea"

"github.com/cheatmd-dev/cheatmd/internal/resolver"
"github.com/cheatmd-dev/cheatmd/pkg/config"
)

Expand Down Expand Up @@ -57,8 +58,7 @@ func (m *mainModel) enterSubstituteSearch() bool {
}
m.subState = &substituteSearchState{
picker: NewPicker(opts, func(opt substituteOption, words []string) bool {
hay := strings.ToLower(opt.Display)
return matchesAllWords(hay, words)
return resolver.MatchesAllWords(strings.ToLower(opt.Display), words)
}),
prevInput: m.textInput.Value(),
prevCursor: m.picker.Cursor,
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/var_resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ func (m *mainModel) handleShellResult(msg shellResultMsg) (tea.Model, tea.Cmd) {

if m.varState.picker == nil {
m.varState.picker = NewPicker(items, func(opt FilteredOption, words []string) bool {
return matchesAllWords(opt.SearchText, words)
return resolver.MatchesAllWords(opt.SearchText, words)
})
} else {
m.varState.picker.SetItems(items)
Expand Down
3 changes: 2 additions & 1 deletion internal/ui/var_resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/cheatmd-dev/cheatmd/internal/resolver"
"github.com/cheatmd-dev/cheatmd/pkg/parser"
)

Expand All @@ -18,7 +19,7 @@ func TestVarResolveArrowKeysMoveSelection(t *testing.T) {
m.varState = &varResolveState{
isPromptOnly: false,
picker: NewPicker(items, func(opt FilteredOption, words []string) bool {
return matchesAllWords(opt.SearchText, words)
return resolver.MatchesAllWords(opt.SearchText, words)
}),
}

Expand Down
Loading