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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:

- uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26.3'

- name: Build
env:
Expand Down
3 changes: 2 additions & 1 deletion cmd/cheatmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
"github.com/spf13/viper"
)

var version = "0.1.7"
var version = "0.1.8"

var widgetCmd = &cobra.Command{
Use: "widget [shell]",
Expand Down Expand Up @@ -232,6 +232,7 @@ func runCheats(cmd *cobra.Command, args []string) error {

// Parse markdown files
benchmark, _ := cmd.Flags().GetBool("benchmark")

start := time.Now()

p := parser.NewParser()
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/gubarz/cheatmd

go 1.25.1
go 1.26.3

require (
github.com/charmbracelet/bubbles v0.21.0
Expand Down
30 changes: 3 additions & 27 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,19 +372,7 @@ func GetColorDim() string {
return viper.GetString("color_dim")
}

// GetColors returns all color settings as a ColorConfig
func GetColors() ColorConfig {
return ColorConfig{
Header: GetColorHeader(),
Command: GetColorCommand(),
Desc: GetColorDesc(),
Path: GetColorPath(),
Border: GetColorBorder(),
Cursor: GetColorCursor(),
Selected: GetColorSelected(),
Dim: GetColorDim(),
}
}


// ============================================================================
// Getters - Columns
Expand All @@ -410,15 +398,7 @@ func GetColumnCommand() int {
return viper.GetInt("column_command")
}

// GetColumns returns all column settings as a ColumnConfig
func GetColumns() ColumnConfig {
return ColumnConfig{
Gap: GetColumnGap(),
Header: GetColumnHeader(),
Desc: GetColumnDesc(),
Command: GetColumnCommand(),
}
}


// ============================================================================
// Setters
Expand All @@ -430,11 +410,7 @@ func SetOutput(mode string) {
cfg.Output = mode
}

// SetPath sets the cheat path at runtime
func SetPath(path string) {
viper.Set("path", path)
cfg.Path = path
}


// SetAutoSelect sets auto-select mode at runtime
func SetAutoSelect(enabled bool) {
Expand Down
92 changes: 92 additions & 0 deletions internal/executor/executor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package executor

import (
"testing"

"github.com/gubarz/cheatmd/internal/parser"
)

// mockClipboard implements Clipboard interface for testing
type mockClipboard struct {
lastCopied string
}

func (m *mockClipboard) Copy(text string) error {
m.lastCopied = text
return nil
}

func TestOutputWithMode_Copy(t *testing.T) {
mockClip := &mockClipboard{}
exec := NewExecutor(parser.NewCheatIndex()).WithClipboard(mockClip)

testText := "echo hello"
err := exec.OutputWithMode(testText, OutputCopy)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}

if mockClip.lastCopied != testText {
t.Errorf("expected clipboard to have %q, got %q", testText, mockClip.lastCopied)
}
}

func TestSubstituteVars(t *testing.T) {
tests := []struct {
name string
input string
scope map[string]string
expected string
}{
{
name: "simple substitution",
input: "echo $var",
scope: map[string]string{"var": "hello"},
expected: "echo hello",
},
{
name: "multiple substitutions",
input: "curl -u $user:$pass $url",
scope: map[string]string{"user": "admin", "pass": "secret", "url": "http://localhost"},
expected: "curl -u admin:secret http://localhost",
},
{
name: "prefix collision prevention",
input: "echo $username and $user",
scope: map[string]string{"user": "bob", "username": "alice"},
expected: "echo alice and bob", // longest match first prevents $user replacing start of $username
},
{
name: "missing var is left as is",
input: "echo $missing",
scope: map[string]string{"other": "val"},
expected: "echo $missing",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SubstituteVars(tt.input, tt.scope)
if got != tt.expected {
t.Errorf("SubstituteVars() = %q, want %q", got, tt.expected)
}
})
}
}

func TestBuildFinalCommand(t *testing.T) {
cheat := &parser.Cheat{
Command: "echo $greeting \\$HOME",
Scope: map[string]string{
"greeting": "hello",
},
}

exec := NewExecutor(parser.NewCheatIndex())
got := exec.BuildFinalCommand(cheat)
want := "echo hello $HOME"

if got != want {
t.Errorf("BuildFinalCommand() = %q, want %q", got, want)
}
}
146 changes: 146 additions & 0 deletions internal/parser/dsl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package parser

import "strings"

// parseCheatDSL parses the DSL content within a cheat block.
//
// Hand-rolled dispatch on the first keyword (var / if / fi / export / import)
// avoids per-line regex matching. Each non-comment, non-blank line is matched
// against at most one branch.
func parseCheatDSL(cheat *Cheat, content string) {
lines := joinContinuationLines(strings.Split(content, "\n"))

var currentCondition string

for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || line[0] == '#' {
continue
}

keyword, rest := splitFirstWord(line)
switch keyword {
case "fi":
if rest == "" {
currentCondition = ""
}
case "if":
if rest != "" {
currentCondition = rest
}
case "export":
if rest != "" && !containsWhitespace(rest) {
cheat.Export = rest
}
case "import":
if rest != "" && !containsWhitespace(rest) {
cheat.Imports = append(cheat.Imports, rest)
}
case "var":
parseVarLine(cheat, rest, currentCondition)
}
}
}

// parseVarLine handles the three var declaration forms:
//
// var NAME -> prompt-only
// var NAME := value -> literal
// var NAME = value -> shell
func parseVarLine(cheat *Cheat, rest, condition string) {
name, after := splitFirstWord(rest)
if name == "" || !isValidDSLVarName(name) {
return
}

if after == "" {
cheat.Vars = append(cheat.Vars, VarDef{
Name: name,
Condition: condition,
})
return
}

switch {
case strings.HasPrefix(after, ":="):
value := strings.TrimSpace(after[2:])
if value == "" {
return
}
cheat.Vars = append(cheat.Vars, ParseVarDefWithCondition(name, value, condition, true))
case after[0] == '=':
value := strings.TrimSpace(after[1:])
if value == "" {
return
}
cheat.Vars = append(cheat.Vars, ParseVarDefWithCondition(name, value, condition, false))
}
}

// splitFirstWord returns the leading whitespace-delimited token and the
// remainder with leading whitespace trimmed. If the input has no token, the
// keyword is "".
func splitFirstWord(s string) (keyword, rest string) {
i := 0
for i < len(s) && s[i] != ' ' && s[i] != '\t' {
i++
}
if i == 0 {
return "", ""
}
keyword = s[:i]
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
i++
}
rest = s[i:]
return
}

// isValidDSLVarName reports whether s is a valid var name in the DSL:
// letters, digits, and underscores (matching the `\w+` regex that the
// previous regex-based implementation used).
func isValidDSLVarName(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
return false
}
}
return true
}

// containsWhitespace reports whether s has any space or tab.
func containsWhitespace(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] == ' ' || s[i] == '\t' {
return true
}
}
return false
}

// joinContinuationLines joins lines that end with backslash.
func joinContinuationLines(lines []string) []string {
var result []string
var current strings.Builder

for _, line := range lines {
trimmed := strings.TrimRight(line, " \t")
if strings.HasSuffix(trimmed, "\\") {
current.WriteString(strings.TrimSuffix(trimmed, "\\"))
} else {
current.WriteString(line)
result = append(result, current.String())
current.Reset()
}
}

if current.Len() > 0 {
result = append(result, current.String())
}

return result
}
Loading
Loading