From 0c0370166170d7f9bdf8da946338b6f7f4c9d0c5 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Thu, 28 May 2026 12:03:13 -0600 Subject: [PATCH 1/5] feat: converter for other formats --- cmd/cheatmd/convert.go | 196 +++++++++++++++++++++++++++ cmd/cheatmd/root.go | 1 + pkg/convert/cheat.go | 82 ++++++++++++ pkg/convert/common.go | 221 +++++++++++++++++++++++++++++++ pkg/convert/convert_test.go | 141 ++++++++++++++++++++ pkg/convert/navi.go | 257 ++++++++++++++++++++++++++++++++++++ pkg/convert/tldr.go | 80 +++++++++++ 7 files changed, 978 insertions(+) create mode 100644 cmd/cheatmd/convert.go create mode 100644 pkg/convert/cheat.go create mode 100644 pkg/convert/common.go create mode 100644 pkg/convert/convert_test.go create mode 100644 pkg/convert/navi.go create mode 100644 pkg/convert/tldr.go diff --git a/cmd/cheatmd/convert.go b/cmd/cheatmd/convert.go new file mode 100644 index 0000000..f86d798 --- /dev/null +++ b/cmd/cheatmd/convert.go @@ -0,0 +1,196 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/gubarz/cheatmd/pkg/convert" +) + +var convertCmd = &cobra.Command{ + Use: "convert [format] [input]", + Short: "Convert cheatsheets from other formats (navi, tldr, cheat) to CheatMD", + Long: `Convert cheatsheets from other popular formats into CheatMD executable markdown format. + +Supported formats: + - navi: Converts .cheat files (replaces with $var, parses tags, variables, and extends/imports) + - tldr: Converts TLDR markdown pages (replaces {{var}} with $var, creates interactive prompts) + - cheat: Converts cheat/cheat plain-text cheatsheets (parses frontmatter and comments) + +The input can be a single file or a directory. If a directory is provided, it will recursively find and convert all matching files. + +Examples: + cheatmd convert navi git.cheat -o git.md + cheatmd convert tldr ~/tldr/pages/common/tar.md -o tar.md + cheatmd convert cheat ~/cheats/personal/ -o ~/my-cheats/`, + Args: cobra.ExactArgs(2), + RunE: runConvert, +} + +func init() { + convertCmd.Flags().StringP("output", "o", ".", "Output file or directory path (defaults to current directory)") +} + +func runConvert(cmd *cobra.Command, args []string) error { + format := strings.ToLower(args[0]) + inputPath := args[1] + outputPath, _ := cmd.Flags().GetString("output") + + if format != "navi" && format != "tldr" && format != "cheat" { + return fmt.Errorf("invalid format %q: must be one of: navi, tldr, cheat", format) + } + + inputAbs, err := filepath.Abs(inputPath) + if err != nil { + return fmt.Errorf("failed to resolve input path: %w", err) + } + + info, err := os.Stat(inputAbs) + if err != nil { + return fmt.Errorf("input path error: %w", err) + } + + if info.IsDir() { + return convertDirectory(format, inputAbs, outputPath) + } + return convertFile(format, inputAbs, outputPath) +} + +func convertFile(format, inputPath, outputPath string) error { + data, err := os.ReadFile(inputPath) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", inputPath, err) + } + + var converted string + switch format { + case "navi": + converted, err = convert.ConvertNavi(string(data), inputPath) + case "tldr": + converted, err = convert.ConvertTldr(string(data), inputPath) + case "cheat": + converted, err = convert.ConvertCheat(string(data), inputPath) + } + + if err != nil { + return fmt.Errorf("conversion failed: %w", err) + } + + targetFile := outputPath + outInfo, err := os.Stat(outputPath) + if err == nil && outInfo.IsDir() { + // Output is an existing directory, save as .md + base := filepath.Base(inputPath) + base = strings.TrimSuffix(base, filepath.Ext(base)) + targetFile = filepath.Join(outputPath, base+".md") + } else if err != nil && (strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\")) { + // Output doesn't exist but looks like a directory path + if err := os.MkdirAll(outputPath, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", outputPath, err) + } + base := filepath.Base(inputPath) + base = strings.TrimSuffix(base, filepath.Ext(base)) + targetFile = filepath.Join(outputPath, base+".md") + } else { + // Output is a file path, ensure directory exists + dir := filepath.Dir(outputPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + } + + if err := os.WriteFile(targetFile, []byte(converted), 0644); err != nil { + return fmt.Errorf("failed to write output to %s: %w", targetFile, err) + } + + fmt.Printf("✓ Converted %s (%s) -> %s\n", filepath.Base(inputPath), format, targetFile) + return nil +} + +func convertDirectory(format, inputDir, outputDir string) error { + // Create output dir if it doesn't exist + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory %s: %w", outputDir, err) + } + + err := filepath.WalkDir(inputDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + // Skip hidden dirs + if strings.HasPrefix(d.Name(), ".") && d.Name() != "." { + return filepath.SkipDir + } + return nil + } + + // Check if the file matches our format's expected extension/criteria + shouldConvert := false + switch format { + case "navi": + shouldConvert = strings.HasSuffix(strings.ToLower(d.Name()), ".cheat") + case "tldr": + shouldConvert = strings.HasSuffix(strings.ToLower(d.Name()), ".md") + case "cheat": + // cheat/cheat community files typically have no extension and aren't hidden + shouldConvert = !strings.Contains(d.Name(), ".") && !strings.HasPrefix(d.Name(), "_") + } + + if !shouldConvert { + return nil + } + + // Calculate relative path to maintain structure + rel, err := filepath.Rel(inputDir, path) + if err != nil { + return err + } + + relBase := strings.TrimSuffix(rel, filepath.Ext(rel)) + targetFile := filepath.Join(outputDir, relBase+".md") + + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", path, err) + } + + var converted string + switch format { + case "navi": + converted, err = convert.ConvertNavi(string(data), path) + case "tldr": + converted, err = convert.ConvertTldr(string(data), path) + case "cheat": + converted, err = convert.ConvertCheat(string(data), path) + } + + if err != nil { + return fmt.Errorf("failed to convert file %s: %w", path, err) + } + + // Ensure parent directory exists for the output file + parentDir := filepath.Dir(targetFile) + if err := os.MkdirAll(parentDir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", parentDir, err) + } + + if err := os.WriteFile(targetFile, []byte(converted), 0644); err != nil { + return fmt.Errorf("failed to write converted file %s: %w", targetFile, err) + } + + fmt.Printf("✓ Converted %s (%s) -> %s\n", rel, format, targetFile) + return nil + }) + + if err != nil { + return fmt.Errorf("error walking directory: %w", err) + } + + return nil +} diff --git a/cmd/cheatmd/root.go b/cmd/cheatmd/root.go index f91c6a2..32eacb5 100644 --- a/cmd/cheatmd/root.go +++ b/cmd/cheatmd/root.go @@ -33,6 +33,7 @@ func init() { rootCmd.AddCommand(chainCmd) rootCmd.AddCommand(dumpCmd) rootCmd.AddCommand(composeCmd) + rootCmd.AddCommand(convertCmd) chainCmd.AddCommand(chainResetCmd) rootCmd.PersistentFlags().StringP("query", "q", "", "Initial search query") diff --git a/pkg/convert/cheat.go b/pkg/convert/cheat.go new file mode 100644 index 0000000..398df64 --- /dev/null +++ b/pkg/convert/cheat.go @@ -0,0 +1,82 @@ +package convert + +import ( + "fmt" + "strings" +) + +// CheatEntry is a single parsed entry from a cheat/cheat plain-text file. +type CheatEntry struct { + Desc string + Command string +} + +type cheatParser struct { + currentDesc strings.Builder + currentCmd strings.Builder + entries []CheatEntry +} + +func (p *cheatParser) parseLine(line string) { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return + } + + if !strings.HasPrefix(trimmed, "#") { + if p.currentCmd.Len() > 0 { + p.currentCmd.WriteByte('\n') + } + p.currentCmd.WriteString(line) + return + } + + p.flush() + descLine := strings.TrimSpace(trimmed[1:]) + if p.currentDesc.Len() > 0 { + p.currentDesc.WriteByte(' ') + } + p.currentDesc.WriteString(descLine) +} + +func (p *cheatParser) flush() { + if p.currentCmd.Len() == 0 { + return + } + p.entries = append(p.entries, CheatEntry{ + Desc: strings.TrimSpace(p.currentDesc.String()), + Command: strings.TrimSpace(p.currentCmd.String()), + }) + p.currentDesc.Reset() + p.currentCmd.Reset() +} + +// ConvertCheat converts a cheat/cheat plain-text cheatsheet to CheatMD format. +func ConvertCheat(content string, filename string) (string, error) { + lines := strings.Split(content, "\n") + tags, startLine := parseFrontMatterTags(lines) + + p := &cheatParser{} + for i := startLine; i < len(lines); i++ { + p.parseLine(lines[i]) + } + p.flush() + + var sb strings.Builder + fmt.Fprintf(&sb, "# Converted Cheats for %s\n\n", capitalize(filenameBase(filename))) + for _, e := range p.entries { + writeCheatEntry(&sb, e, tags) + } + return sb.String(), nil +} + +func writeCheatEntry(sb *strings.Builder, e CheatEntry, tags []string) { + command, placeholders := rewriteBoth(e.Command) + + fmt.Fprintf(sb, "## %s\n", e.Desc) + writeHashtags(sb, tags) + sb.WriteByte('\n') + fmt.Fprintf(sb, "```sh\n%s\n```\n", command) + sb.WriteString(formatHeaderVarsBlock(placeholders)) + sb.WriteByte('\n') +} diff --git a/pkg/convert/common.go b/pkg/convert/common.go new file mode 100644 index 0000000..bf5ac99 --- /dev/null +++ b/pkg/convert/common.go @@ -0,0 +1,221 @@ +package convert + +import ( + "path/filepath" + "regexp" + "strconv" + "strings" +) + +var ( + naviPlaceholderRe = regexp.MustCompile(`<([a-zA-Z0-9_\-\.\/]+)>`) + tldrPlaceholderRe = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_\-\.\/]+)\s*\}\}`) +) + +// varDef holds a navi-style variable definition: an optional shell command and +// optional selector args (the part after `---`). +type varDef struct { + Shell string + Args string +} + +func capitalize(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func filenameBase(filename string) string { + base := filepath.Base(filename) + return strings.TrimSuffix(base, filepath.Ext(base)) +} + +func parseCommaList(s string) []string { + var list []string + for _, part := range strings.Split(s, ",") { + if part = strings.TrimSpace(part); part != "" { + list = append(list, part) + } + } + return list +} + +// parseNaviVarLine parses `name: shell --- args` and returns (name, def, ok). +func parseNaviVarLine(line string) (string, varDef, bool) { + name, val, ok := strings.Cut(line, ":") + if !ok { + return "", varDef{}, false + } + name = strings.TrimSpace(name) + val = strings.TrimSpace(val) + + if shell, args, ok := strings.Cut(val, "---"); ok { + return name, varDef{Shell: strings.TrimSpace(shell), Args: strings.TrimSpace(args)}, true + } + return name, varDef{Shell: val}, true +} + +// rewriteNavi replaces placeholders with $sanitized and returns the +// rewritten command plus the deduped list of original placeholder names. +func rewriteNavi(command string) (string, []string) { + phs := extractPlaceholders(command, naviPlaceholderRe) + for _, ph := range phs { + command = strings.ReplaceAll(command, "<"+ph+">", "$"+sanitizeVarName(ph)) + } + return command, phs +} + +// rewriteTldr replaces {{name}} placeholders with $sanitized. +func rewriteTldr(command string) (string, []string) { + phs := extractPlaceholders(command, tldrPlaceholderRe) + for _, ph := range phs { + sanitized := sanitizeVarName(ph) + command = strings.ReplaceAll(command, "{{"+ph+"}}", "$"+sanitized) + command = strings.ReplaceAll(command, "{{ "+ph+" }}", "$"+sanitized) + command = strings.ReplaceAll(command, "{{"+ph+" }}", "$"+sanitized) + command = strings.ReplaceAll(command, "{{ "+ph+"}}", "$"+sanitized) + } + return command, phs +} + +// rewriteBoth applies navi then tldr rewriting and returns the union of +// placeholders in encounter order. Used by the cheat/cheat format, which mixes +// both placeholder styles. +func rewriteBoth(command string) (string, []string) { + command, navi := rewriteNavi(command) + command, tldr := rewriteTldr(command) + seen := make(map[string]struct{}, len(navi)+len(tldr)) + all := make([]string, 0, len(navi)+len(tldr)) + for _, ph := range append(navi, tldr...) { + if _, ok := seen[ph]; ok { + continue + } + seen[ph] = struct{}{} + all = append(all, ph) + } + return command, all +} + +func extractPlaceholders(cmd string, re *regexp.Regexp) []string { + matches := re.FindAllStringSubmatch(cmd, -1) + var list []string + seen := make(map[string]struct{}) + for _, m := range matches { + ph := strings.TrimSpace(m[1]) + if _, ok := seen[ph]; !ok { + seen[ph] = struct{}{} + list = append(list, ph) + } + } + return list +} + +func sanitizeVarName(s string) string { + var sb strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' { + sb.WriteByte(c) + } else { + sb.WriteByte('_') + } + } + res := sb.String() + for strings.Contains(res, "__") { + res = strings.ReplaceAll(res, "__", "_") + } + res = strings.Trim(res, "_") + if res == "" { + return "v" + } + if res[0] >= '0' && res[0] <= '9' { + res = "v_" + res + } + return res +} + +func consumeCodeBlock(lines []string, index int) (string, int) { + var cb strings.Builder + for index < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[index]), "```") { + cb.WriteString(lines[index]) + cb.WriteByte('\n') + index++ + } + return strings.TrimSpace(cb.String()), index +} + +// formatHeaderVarsBlock emits a `` block declaring each +// placeholder as a bare selector with the original name as its header label. +// Used by tldr and cheat outputs, where there's no upstream var definition. +func formatHeaderVarsBlock(placeholders []string) string { + if len(placeholders) == 0 { + return "" + } + var sb strings.Builder + sb.WriteString("\n") + return sb.String() +} + +// parseFrontMatterTags extracts `tags:` from a leading `---` YAML block and +// returns the tags plus the index of the first line after the block. Returns +// (nil, 0) when there is no front matter. +func parseFrontMatterTags(lines []string) ([]string, int) { + if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + return nil, 0 + } + var fm []string + i := 1 + for i < len(lines) && strings.TrimSpace(lines[i]) != "---" { + fm = append(fm, lines[i]) + i++ + } + if i >= len(lines) { + return nil, 0 + } + return parseCheatYAMLTags(fm), i + 1 +} + +func parseCheatYAMLTags(fm []string) []string { + for _, line := range fm { + trimmed := strings.TrimSpace(line) + if len(trimmed) >= 5 && strings.EqualFold(trimmed[:5], "tags:") { + return parseTagsLine(strings.TrimSpace(trimmed[5:])) + } + } + return nil +} + +func parseTagsLine(line string) []string { + line = strings.TrimPrefix(line, "[") + line = strings.TrimSuffix(line, "]") + var tags []string + for _, t := range strings.Split(line, ",") { + if t = strings.Trim(strings.TrimSpace(t), "\"'"); t != "" { + tags = append(tags, t) + } + } + return tags +} + +// writeHashtags writes each tag as `#tag ` followed by a newline. No-op for an +// empty list. +func writeHashtags(sb *strings.Builder, tags []string) { + if len(tags) == 0 { + return + } + for _, t := range tags { + sb.WriteByte('#') + sb.WriteString(strings.ToLower(t)) + sb.WriteByte(' ') + } + sb.WriteByte('\n') +} diff --git a/pkg/convert/convert_test.go b/pkg/convert/convert_test.go new file mode 100644 index 0000000..090ee89 --- /dev/null +++ b/pkg/convert/convert_test.go @@ -0,0 +1,141 @@ +package convert + +import ( + "strings" + "testing" +) + +func TestConvertNavi(t *testing.T) { + input := `% git, checkout +# Switch to a branch +@ git_utils +git checkout + +$ branch: git branch --format="%(refname:short)" --- --header "Select branch" +` + + converted, err := ConvertNavi(input, "git.cheat") + if err != nil { + t.Fatalf("ConvertNavi failed: %v", err) + } + + if !strings.Contains(converted, "## Switch to a branch") { + t.Errorf("Expected heading '## Switch to a branch', got: %s", converted) + } + + if !strings.Contains(converted, "#git #checkout") { + t.Errorf("Expected localized hashtags, got: %s", converted) + } + + if !strings.Contains(converted, "git checkout $branch") { + t.Errorf("Expected placeholder replacement in command, got: %s", converted) + } + + if !strings.Contains(converted, "import git") { + t.Errorf("Expected import git in cheat block, got: %s", converted) + } + + if !strings.Contains(converted, "import git_utils") { + t.Errorf("Expected import git_utils in cheat block, got: %s", converted) + } + + if !strings.Contains(converted, "export git") { + t.Errorf("Expected export git in global block, got: %s", converted) + } + + if !strings.Contains(converted, "var branch = git branch --format=\"%(refname:short)\" --- --header \"Select branch\"") { + t.Errorf("Expected var definition in global block, got: %s", converted) + } +} + +func TestConvertNaviSelectorAdaptation(t *testing.T) { + input := `% test, selector +# Retrieve items with various options +git log + +$ item: git log --oneline --- --headers 2 --column 3 --header "Select item" +` + + converted, err := ConvertNavi(input, "test.cheat") + if err != nil { + t.Fatalf("ConvertNavi failed: %v", err) + } + + expectedVar := `var item = git log --oneline | tail -n +3 --- --select-column 3 --header "Select item" --delimiter "\t"` + if !strings.Contains(converted, expectedVar) { + t.Errorf("Expected var definition:\n%s\ngot:\n%s", expectedVar, converted) + } +} + +func TestConvertTldr(t *testing.T) { + input := `# tar + +> Archiving utility. +> More information: . + +- Create a gzipped archive: +` + "```" + `sh +tar -czvf {{path/to/archive.tar.gz}} {{path/to/directory}} +` + "```" + ` +` + + converted, err := ConvertTldr(input, "tar.md") + if err != nil { + t.Fatalf("ConvertTldr failed: %v", err) + } + + if !strings.Contains(converted, "## Create a gzipped archive") { + t.Errorf("Expected heading '## Create a gzipped archive', got: %s", converted) + } + + if !strings.Contains(converted, "tar -czvf $path_to_archive_tar_gz $path_to_directory") { + t.Errorf("Expected command placeholders to be replaced, got: %s", converted) + } + + if !strings.Contains(converted, "var path_to_archive_tar_gz = --- --header \"path/to/archive.tar.gz\"") { + t.Errorf("Expected var definition for first placeholder, got: %s", converted) + } + + if !strings.Contains(converted, "var path_to_directory = --- --header \"path/to/directory\"") { + t.Errorf("Expected var definition for second placeholder, got: %s", converted) + } +} + +func TestConvertCheat(t *testing.T) { + input := `--- +syntax: sh +tags: [ tar, archive ] +--- +# To create a gzipped archive: +tar -czvf {{path/to/directory}} +` + + converted, err := ConvertCheat(input, "tar") + if err != nil { + t.Fatalf("ConvertCheat failed: %v", err) + } + + if !strings.Contains(converted, "## To create a gzipped archive") { + t.Errorf("Expected heading, got: %s", converted) + } + + if !strings.Contains(converted, "#tar #archive") { + t.Errorf("Expected localized hashtags, got: %s", converted) + } + + if strings.Contains(converted, "Tags: #tar #archive") { + t.Errorf("Did not expect redundant repeating tags line in body, got: %s", converted) + } + + if !strings.Contains(converted, "tar -czvf $archive_tar_gz $path_to_directory") { + t.Errorf("Expected placeholder replacement, got: %s", converted) + } + + if !strings.Contains(converted, "var archive_tar_gz = --- --header \"archive.tar.gz\"") { + t.Errorf("Expected var definition for navi placeholder, got: %s", converted) + } + + if !strings.Contains(converted, "var path_to_directory = --- --header \"path/to/directory\"") { + t.Errorf("Expected var definition for tldr placeholder, got: %s", converted) + } +} diff --git a/pkg/convert/navi.go b/pkg/convert/navi.go new file mode 100644 index 0000000..4905669 --- /dev/null +++ b/pkg/convert/navi.go @@ -0,0 +1,257 @@ +package convert + +import ( + "fmt" + "strings" +) + +// NaviCheat is a single parsed entry from a navi `.cheat` file. +type NaviCheat struct { + Description string + Command string + Imports []string + Tags []string +} + +type naviParser struct { + tags []string + imports []string + vars map[string]varDef + cheats []NaviCheat + active *NaviCheat +} + +func (p *naviParser) parseLine(line string) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, ";") { + return + } + + switch trimmed[0] { + case '%': + p.flush() + p.tags = parseCommaList(trimmed[1:]) + p.imports = nil + p.vars = make(map[string]varDef) + case '@': + exts := parseCommaList(trimmed[1:]) + p.imports = append(p.imports, exts...) + if p.active != nil { + p.active.Imports = append(p.active.Imports, exts...) + } + case '$': + if name, def, ok := parseNaviVarLine(trimmed[1:]); ok { + p.vars[name] = def + } + case '#': + p.flush() + p.active = &NaviCheat{ + Description: strings.TrimSpace(trimmed[1:]), + Tags: p.tags, + Imports: p.imports, + } + default: + if p.active == nil { + return + } + if p.active.Command != "" { + p.active.Command += "\n" + line + } else { + p.active.Command = line + } + } +} + +func (p *naviParser) flush() { + if p.active != nil { + p.cheats = append(p.cheats, *p.active) + p.active = nil + } +} + +// ConvertNavi converts a navi .cheat file to CheatMD format. +func ConvertNavi(content string, filename string) (string, error) { + p := &naviParser{vars: make(map[string]varDef)} + for _, line := range strings.Split(content, "\n") { + p.parseLine(line) + } + p.flush() + + return serializeNavi(p.cheats, p.vars, filename), nil +} + +func serializeNavi(cheats []NaviCheat, vars map[string]varDef, filename string) string { + moduleName := strings.ToLower(filenameBase(filename)) + + var sb strings.Builder + fmt.Fprintf(&sb, "# Converted %s Cheatsheet\n\n", capitalize(filenameBase(filename))) + for _, c := range cheats { + writeNaviCheat(&sb, c, moduleName) + } + writeNaviGlobalVars(&sb, moduleName, vars, cheats) + return sb.String() +} + +func writeNaviCheat(sb *strings.Builder, c NaviCheat, moduleName string) { + if c.Command == "" { + return + } + + command, placeholders := rewriteNavi(c.Command) + + fmt.Fprintf(sb, "## %s\n", c.Description) + writeHashtags(sb, c.Tags) + sb.WriteByte('\n') + + fmt.Fprintf(sb, "```sh\n%s\n```\n", command) + + sb.WriteString("\n\n") +} + +func writeNaviGlobalVars(sb *strings.Builder, moduleName string, vars map[string]varDef, cheats []NaviCheat) { + var usedVars []string + seen := make(map[string]bool) + for _, c := range cheats { + for _, ph := range extractPlaceholders(c.Command, naviPlaceholderRe) { + if sanitized := sanitizeVarName(ph); !seen[sanitized] { + seen[sanitized] = true + usedVars = append(usedVars, ph) + } + } + } + + if len(usedVars) == 0 { + return + } + + sb.WriteString("\n") +} + +func writeNaviVarDef(sb *strings.Builder, name string, def varDef) { + shell, args := adaptNaviVar(def.Shell, def.Args) + switch { + case args != "" && shell != "": + fmt.Fprintf(sb, "var %s = %s --- %s\n", name, shell, args) + case args != "": + fmt.Fprintf(sb, "var %s --- %s\n", name, args) + case shell != "": + fmt.Fprintf(sb, "var %s = %s\n", name, shell) + default: + fmt.Fprintf(sb, "var %s\n", name) + } +} + +// adaptNaviVar translates navi-flavored selector args to cheatmd-flavored ones: +// +// - `--headers N` is consumed and re-encoded as `| tail -n +N+1` on the shell. +// - `--column N` is renamed to `--select-column N`. +// - When a column is selected without an explicit `--delimiter`, `\t` is added. +// +// All other tokens pass through in their original order. +func adaptNaviVar(shell, args string) (string, string) { + if args == "" { + return shell, args + } + + tokens := parseSelectorArgs(args) + var out []string + var headers int + var hasColumn, hasDelimiter bool + + for i := 0; i < len(tokens); i++ { + tok := tokens[i] + next := "" + if i+1 < len(tokens) { + next = tokens[i+1] + } + switch tok { + case "--headers": + fmt.Sscanf(next, "%d", &headers) + i++ + case "--column", "--select-column": + hasColumn = true + out = append(out, "--select-column", next) + i++ + case "--delimiter": + hasDelimiter = true + out = append(out, "--delimiter", next) + i++ + default: + out = append(out, tok) + } + } + if hasColumn && !hasDelimiter { + out = append(out, "--delimiter", "\\t") + } + + newShell := strings.TrimSpace(shell) + if headers > 0 && newShell != "" { + newShell = fmt.Sprintf("%s | tail -n +%d", newShell, headers+1) + } + return newShell, serializeSelectorArgs(out) +} + +func parseSelectorArgs(s string) []string { + var args []string + var current strings.Builder + var inQuote bool + var quoteChar byte + + for i := 0; i < len(s); i++ { + c := s[i] + if inQuote { + if c == quoteChar { + inQuote = false + } else { + current.WriteByte(c) + } + continue + } + switch c { + case '"', '\'': + inQuote = true + quoteChar = c + case ' ', '\t': + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + default: + current.WriteByte(c) + } + } + if current.Len() > 0 { + args = append(args, current.String()) + } + return args +} + +func serializeSelectorArgs(args []string) string { + parts := make([]string, len(args)) + for i, arg := range args { + parts[i] = quoteArg(arg) + } + return strings.Join(parts, " ") +} + +func quoteArg(s string) string { + if s == "" { + return `""` + } + if strings.ContainsAny(s, " \t\"'\\") { + return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` + } + return s +} diff --git a/pkg/convert/tldr.go b/pkg/convert/tldr.go new file mode 100644 index 0000000..2cfccec --- /dev/null +++ b/pkg/convert/tldr.go @@ -0,0 +1,80 @@ +package convert + +import ( + "fmt" + "strings" +) + +type tldrExample struct { + Desc string + Command string +} + +type tldrParser struct { + commandName string + currentDesc string + examples []tldrExample +} + +func (p *tldrParser) parseLine(lines []string, index int) int { + line := strings.TrimSpace(lines[index]) + if line == "" { + return index + } + + if strings.HasPrefix(line, "# ") { + p.commandName = strings.TrimSpace(line[2:]) + return index + } + + if strings.HasPrefix(line, "- ") { + p.currentDesc = strings.TrimSuffix(strings.TrimSpace(line[2:]), ":") + return index + } + + if p.currentDesc == "" { + return index + } + + var command string + if strings.HasPrefix(line, "`") && strings.HasSuffix(line, "`") && len(line) >= 2 { + command = line[1 : len(line)-1] + } else if strings.HasPrefix(line, "```") { + command, index = consumeCodeBlock(lines, index+1) + } + + if command != "" { + p.examples = append(p.examples, tldrExample{Desc: p.currentDesc, Command: command}) + p.currentDesc = "" + } + return index +} + +// ConvertTldr converts a TLDR markdown cheatsheet to CheatMD format. +func ConvertTldr(content string, filename string) (string, error) { + lines := strings.Split(content, "\n") + p := &tldrParser{} + for i := 0; i < len(lines); i++ { + i = p.parseLine(lines, i) + } + + category := capitalize(p.commandName) + if category == "" { + category = capitalize(filenameBase(filename)) + } + + var sb strings.Builder + fmt.Fprintf(&sb, "# Converted TLDR for %s\n\n", category) + for _, ex := range p.examples { + writeTldrExample(&sb, ex) + } + return sb.String(), nil +} + +func writeTldrExample(sb *strings.Builder, ex tldrExample) { + command, placeholders := rewriteTldr(ex.Command) + fmt.Fprintf(sb, "## %s\n\n", ex.Desc) + fmt.Fprintf(sb, "```sh\n%s\n```\n", command) + sb.WriteString(formatHeaderVarsBlock(placeholders)) + sb.WriteByte('\n') +} From dd580d69d5ffa2acf6b223ca9973c6a196427b52 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Fri, 29 May 2026 00:24:40 -0600 Subject: [PATCH 2/5] fix: convert for navi --- cmd/cheatmd/convert.go | 75 ++++++ pkg/convert/common.go | 8 + pkg/convert/convert_test.go | 497 ++++++++++++++++++++++++++++++++++-- pkg/convert/navi.go | 449 +++++++++++++++++++++++++++----- 4 files changed, 950 insertions(+), 79 deletions(-) diff --git a/cmd/cheatmd/convert.go b/cmd/cheatmd/convert.go index f86d798..b64b398 100644 --- a/cmd/cheatmd/convert.go +++ b/cmd/cheatmd/convert.go @@ -55,11 +55,86 @@ func runConvert(cmd *cobra.Command, args []string) error { } if info.IsDir() { + // navi conversion is special-cased: @extends crosses file boundaries + // so we need to parse every .cheat file into a shared index before + // emitting any of them. The other formats are still per-file. + if format == "navi" { + return convertNaviDirectory(inputAbs, outputPath) + } return convertDirectory(format, inputAbs, outputPath) } return convertFile(format, inputAbs, outputPath) } +// convertNaviDirectory walks every .cheat file in inputDir, parses them all +// into a shared NaviIndex (so @extends references can resolve across files), +// and writes one converted markdown file per source under outputDir. +func convertNaviDirectory(inputDir, outputDir string) error { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory %s: %w", outputDir, err) + } + + sources, rels, err := collectNaviSources(inputDir) + if err != nil { + return err + } + if len(sources) == 0 { + return nil + } + + results := convert.ConvertNaviTree(sources) + for i, res := range results { + rel := rels[i] + relBase := strings.TrimSuffix(rel, filepath.Ext(rel)) + targetFile := filepath.Join(outputDir, relBase+".md") + if err := os.MkdirAll(filepath.Dir(targetFile), 0755); err != nil { + return fmt.Errorf("failed to create directory for %s: %w", targetFile, err) + } + if err := os.WriteFile(targetFile, []byte(res.Content), 0644); err != nil { + return fmt.Errorf("failed to write converted file %s: %w", targetFile, err) + } + fmt.Printf("✓ Converted %s (navi) -> %s\n", rel, targetFile) + } + return nil +} + +// collectNaviSources walks inputDir, returning every .cheat file's content +// alongside its relative path so the writer can preserve directory structure. +func collectNaviSources(inputDir string) ([]convert.NaviSource, []string, error) { + var sources []convert.NaviSource + var rels []string + + err := filepath.WalkDir(inputDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if strings.HasPrefix(d.Name(), ".") && d.Name() != "." { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(strings.ToLower(d.Name()), ".cheat") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", path, err) + } + rel, err := filepath.Rel(inputDir, path) + if err != nil { + return err + } + sources = append(sources, convert.NaviSource{Path: path, Content: string(data)}) + rels = append(rels, rel) + return nil + }) + if err != nil { + return nil, nil, fmt.Errorf("error walking directory: %w", err) + } + return sources, rels, nil +} + func convertFile(format, inputPath, outputPath string) error { data, err := os.ReadFile(inputPath) if err != nil { diff --git a/pkg/convert/common.go b/pkg/convert/common.go index bf5ac99..40dcffa 100644 --- a/pkg/convert/common.go +++ b/pkg/convert/common.go @@ -10,6 +10,14 @@ import ( var ( naviPlaceholderRe = regexp.MustCompile(`<([a-zA-Z0-9_\-\.\/]+)>`) tldrPlaceholderRe = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_\-\.\/]+)\s*\}\}`) + // shellEnvVarRe matches a shell-style environment variable reference in + // a navi command body. Navi uses `` for its own placeholders, so any + // `$NAME` is conventionally a shell env var the user expects to inherit + // from the calling environment. Captures: `${NAME}` (group 1) and `$NAME` + // (group 2). cheatmd's linter warns on unknown `$` references, so the + // converter declares each one as `var NAME := $NAME` to bind the cheatmd + // var to the env value and silence the warning. + shellEnvVarRe = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)`) ) // varDef holds a navi-style variable definition: an optional shell command and diff --git a/pkg/convert/convert_test.go b/pkg/convert/convert_test.go index 090ee89..5f8ed97 100644 --- a/pkg/convert/convert_test.go +++ b/pkg/convert/convert_test.go @@ -5,10 +5,12 @@ import ( "testing" ) -func TestConvertNavi(t *testing.T) { +func TestConvertNaviDefinedVarsGoInModule(t *testing.T) { + // Any var with a `$ def` line belongs in its section's module. Module + // name is derived from the section's tag list (joined with underscores) + // so multi-tag sections produce qualified names. input := `% git, checkout # Switch to a branch -@ git_utils git checkout $ branch: git branch --format="%(refname:short)" --- --header "Select branch" @@ -20,40 +22,397 @@ $ branch: git branch --format="%(refname:short)" --- --header "Select branch" } if !strings.Contains(converted, "## Switch to a branch") { - t.Errorf("Expected heading '## Switch to a branch', got: %s", converted) + t.Errorf("Expected heading, got:\n%s", converted) + } + if !strings.Contains(converted, "git checkout $branch") { + t.Errorf("Expected placeholder replacement, got:\n%s", converted) } - if !strings.Contains(converted, "#git #checkout") { - t.Errorf("Expected localized hashtags, got: %s", converted) + if !strings.Contains(converted, "export git_checkout\n") { + t.Errorf("Expected `export git_checkout` from tags [git, checkout], got:\n%s", converted) + } + if !strings.Contains(converted, "var branch = git branch --format=\"%(refname:short)\" --- --header \"Select branch\"") { + t.Errorf("Expected var def in module, got:\n%s", converted) } - if !strings.Contains(converted, "git checkout $branch") { - t.Errorf("Expected placeholder replacement in command, got: %s", converted) + block := extractCheatBlock(t, converted, "## Switch to a branch") + if !strings.Contains(block, "import git_checkout\n") { + t.Errorf("Cheat should import git_checkout, got:\n%s", block) + } + if strings.Contains(block, "var branch =") { + t.Errorf("Cheat should NOT inline branch def (it's in the module), got:\n%s", block) } +} + +func TestConvertNaviSectionsAreIsolated(t *testing.T) { + // android.cheat-style file with multiple `% tags` blocks: each section's + // vars must live in its own module, and cheats from one section must not + // see another section's vars (unless they `@extends` it). + input := `% android, emulator +# Start emulator +"$ANDROID_HOME/tools/emulator" -avd + +$ emulator: emulator -list-avds + +% android, Firebase Crashlytics Test +# Enable debug logging +adb -s shell setprop log.tag.CrashlyticsCore DEBUG - if !strings.Contains(converted, "import git") { - t.Errorf("Expected import git in cheat block, got: %s", converted) +$ device: adb devices | grep device | cut -f 1 +` + + converted, err := ConvertNavi(input, "android.cheat") + if err != nil { + t.Fatalf("ConvertNavi failed: %v", err) } - if !strings.Contains(converted, "import git_utils") { - t.Errorf("Expected import git_utils in cheat block, got: %s", converted) + // Each section gets its own export module. + if !strings.Contains(converted, "export android_emulator\n") { + t.Errorf("Expected `export android_emulator` module, got:\n%s", converted) + } + if !strings.Contains(converted, "export android_firebase_crashlytics_test\n") { + t.Errorf("Expected `export android_firebase_crashlytics_test` module, got:\n%s", converted) } - if !strings.Contains(converted, "export git") { - t.Errorf("Expected export git in global block, got: %s", converted) + // And the modules are NOT merged into a single `export android`. + if strings.Contains(converted, "export android\n") { + t.Errorf("Sections must not be merged into a single `export android`, got:\n%s", converted) } - if !strings.Contains(converted, "var branch = git branch --format=\"%(refname:short)\" --- --header \"Select branch\"") { - t.Errorf("Expected var definition in global block, got: %s", converted) + // Emulator cheat imports its own section's module and not the other. + emulator := extractCheatBlock(t, converted, "## Start emulator") + if !strings.Contains(emulator, "import android_emulator\n") { + t.Errorf("Emulator cheat should import android_emulator, got:\n%s", emulator) + } + if strings.Contains(emulator, "import android_firebase_crashlytics_test") { + t.Errorf("Emulator cheat must NOT see firebase section, got:\n%s", emulator) + } + + // Firebase cheat imports its own section's module and not the other. + fb := extractCheatBlock(t, converted, "## Enable debug logging") + if !strings.Contains(fb, "import android_firebase_crashlytics_test\n") { + t.Errorf("Firebase cheat should import android_firebase_crashlytics_test, got:\n%s", fb) + } + if strings.Contains(fb, "import android_emulator") { + t.Errorf("Firebase cheat must NOT see emulator section, got:\n%s", fb) + } + + // And each module contains only its section's vars. + emulatorMod := extractModuleBlock(t, converted, "android_emulator") + if !strings.Contains(emulatorMod, "var emulator =") { + t.Errorf("emulator module should contain `var emulator =`, got:\n%s", emulatorMod) + } + if strings.Contains(emulatorMod, "var device") { + t.Errorf("emulator module must NOT contain device, got:\n%s", emulatorMod) + } + + fbMod := extractModuleBlock(t, converted, "android_firebase_crashlytics_test") + if !strings.Contains(fbMod, "var device =") { + t.Errorf("firebase module should contain `var device =`, got:\n%s", fbMod) + } + if strings.Contains(fbMod, "var emulator") { + t.Errorf("firebase module must NOT contain emulator, got:\n%s", fbMod) + } +} + +func TestConvertNaviExtendsReachesAllMatchingSections(t *testing.T) { + // A cheat with `@android` reaches every section (across any file) that + // declares the `android` tag, including sibling sections in the same file. + input := `% android, emulator +$ emulator: emulator -list-avds + +% android, Firebase Crashlytics Test +$ device: adb devices | grep device | cut -f 1 + +% other +# uses both via @android +@ android +echo +` + + converted, err := ConvertNavi(input, "android.cheat") + if err != nil { + t.Fatalf("ConvertNavi failed: %v", err) + } + + block := extractCheatBlock(t, converted, "## uses both via @android") + if !strings.Contains(block, "import android_emulator\n") { + t.Errorf("Cross-section cheat should import android_emulator, got:\n%s", block) + } + if !strings.Contains(block, "import android_firebase_crashlytics_test\n") { + t.Errorf("Cross-section cheat should import android_firebase_crashlytics_test, got:\n%s", block) + } +} + +func TestConvertNaviBareVarsStayInline(t *testing.T) { + // A var referenced via `` with no `$ x: ...` definition must be + // declared inline under the cheat as `var x`, never exported. + input := `% solo +# uses url and bar; url has a def, bar does not +echo + +$ url: cat urls.txt +` + + converted, err := ConvertNavi(input, "solo.cheat") + if err != nil { + t.Fatalf("ConvertNavi failed: %v", err) + } + + // url has a def → goes in module + if !strings.Contains(converted, "export solo") { + t.Errorf("Expected export module (url has a def), got:\n%s", converted) + } + if !strings.Contains(converted, "var url = cat urls.txt") { + t.Errorf("Expected `var url = cat urls.txt` in module, got:\n%s", converted) + } + + block := extractCheatBlock(t, converted, "## uses url and bar") + if !strings.Contains(block, "import solo") { + t.Errorf("Cheat should import solo for url, got:\n%s", block) + } + // bar has no def → inline bare under the cheat + if !strings.Contains(block, "var bar\n") { + t.Errorf("Expected bare `var bar` inline (no def), got:\n%s", block) + } + // bar must NOT be in the module + if strings.Contains(extractModuleBlock(t, converted, "solo"), "var bar") { + t.Errorf("Bare var bar must not appear in export module, got:\n%s", converted) + } +} + +func TestConvertNaviCheatOnlyImportsModulesItUses(t *testing.T) { + // A cheat with no defined-var references must NOT import the file's + // module, even if other cheats in the file do. + input := `% mixed +# uses defined var +echo + +# uses only a bare var +echo + +$ x: ls +` + + converted, err := ConvertNavi(input, "mixed.cheat") + if err != nil { + t.Fatalf("ConvertNavi failed: %v", err) + } + + using := extractCheatBlock(t, converted, "## uses defined var") + if !strings.Contains(using, "import mixed") { + t.Errorf("Defined-var cheat should import mixed, got:\n%s", using) + } + + notUsing := extractCheatBlock(t, converted, "## uses only a bare var") + if strings.Contains(notUsing, "import mixed") { + t.Errorf("Bare-only cheat must NOT import mixed, got:\n%s", notUsing) + } + if !strings.Contains(notUsing, "var y\n") { + t.Errorf("Bare-only cheat should inline var y, got:\n%s", notUsing) + } +} + +func TestConvertNaviBindsShellEnvVars(t *testing.T) { + // A `$NAME` reference in a navi command body is a shell env var (navi + // itself uses `` for its placeholders). The converter must declare + // each such reference as `var NAME := $NAME` so cheatmd's linter doesn't + // flag it and so the value comes from the inherited environment. + input := `% deploy +# deploy to env +deploy --target=$DEPLOY_ENV --token=${API_TOKEN} + +# uses curly form only +echo ${HOME}/bin +` + + converted, err := ConvertNavi(input, "deploy.cheat") + if err != nil { + t.Fatalf("ConvertNavi failed: %v", err) + } + + first := extractCheatBlock(t, converted, "## deploy to env") + for _, want := range []string{ + "var DEPLOY_ENV := $DEPLOY_ENV", + "var API_TOKEN := $API_TOKEN", + "var region\n", + } { + if !strings.Contains(first, want) { + t.Errorf("Expected %q in first cheat block, got:\n%s", want, first) + } + } + + second := extractCheatBlock(t, converted, "## uses curly form only") + if !strings.Contains(second, "var HOME := $HOME") { + t.Errorf("Expected `var HOME := $HOME` from ${HOME}, got:\n%s", second) + } +} + +func TestConvertNaviShellVarNameCollisionWithPlaceholder(t *testing.T) { + // If the navi command has both `` and `$NAME` (same name), the + // placeholder wins and the shell-var declaration is skipped to avoid + // duplicate `var NAME` lines in the same block. + input := `% t +# collide +echo $NAME +` + + converted, err := ConvertNavi(input, "t.cheat") + if err != nil { + t.Fatalf("ConvertNavi failed: %v", err) + } + block := extractCheatBlock(t, converted, "## collide") + if !strings.Contains(block, "var NAME\n") { + t.Errorf("Expected bare `var NAME` from placeholder, got:\n%s", block) + } + if strings.Contains(block, "var NAME := $NAME") { + t.Errorf("Did not expect `var NAME := $NAME` when placeholder occupies the name, got:\n%s", block) } } +func TestConvertNaviExtendsAcrossFiles(t *testing.T) { + // A cheat with @extends should reach into sibling files for var defs. + // cheats only import sibling modules when the cheat actually uses one + // of that sibling's defined vars. + utils := NaviSource{ + Path: "git_utils.cheat", + Content: `% git_utils +# helper +echo helper + +$ branch: git branch --format="%(refname:short)" +$ remote: git remote +`, + } + main := NaviSource{ + Path: "git.cheat", + Content: `% git +# checkout +@ git_utils +git checkout + +# fetch +@ git_utils +git fetch + +# unrelated, no extends +echo +`, + } + + results := ConvertNaviTree([]NaviSource{utils, main}) + if len(results) != 2 { + t.Fatalf("Expected 2 results, got %d", len(results)) + } + + mainOut := findResult(t, results, "git.cheat") + co := extractCheatBlock(t, mainOut, "## checkout") + if !strings.Contains(co, "import git_utils") { + t.Errorf("checkout cheat should import git_utils for branch, got:\n%s", co) + } + if strings.Contains(co, "var branch") { + t.Errorf("checkout cheat should NOT inline branch (resolved via @extends), got:\n%s", co) + } + + fetch := extractCheatBlock(t, mainOut, "## fetch") + if !strings.Contains(fetch, "import git_utils") { + t.Errorf("fetch should import git_utils for remote, got:\n%s", fetch) + } + + unrelated := extractCheatBlock(t, mainOut, "## unrelated, no extends") + if strings.Contains(unrelated, "import git_utils") { + t.Errorf("Cheat without @extends must NOT import git_utils, got:\n%s", unrelated) + } + if !strings.Contains(unrelated, "var something\n") { + t.Errorf("Cheat with no def for something should inline it, got:\n%s", unrelated) + } + + // git.cheat has no `$` defs of its own → no export module + if strings.Contains(mainOut, "export git\n") { + t.Errorf("git.cheat has no own defs, should not emit export git, got:\n%s", mainOut) + } + + // git_utils.cheat exports both branch and remote + utilsOut := findResult(t, results, "git_utils.cheat") + if !strings.Contains(utilsOut, "export git_utils") { + t.Errorf("git_utils.cheat should emit export git_utils, got:\n%s", utilsOut) + } + if !strings.Contains(utilsOut, "var branch = git branch") { + t.Errorf("Expected branch def in git_utils module, got:\n%s", utilsOut) + } + if !strings.Contains(utilsOut, "var remote = git remote") { + t.Errorf("Expected remote def in git_utils module, got:\n%s", utilsOut) + } +} + +func findResult(t *testing.T, results []NaviResult, path string) string { + t.Helper() + for _, r := range results { + if r.Path == path { + return r.Content + } + } + t.Fatalf("result for %q not found", path) + return "" +} + +// extractModuleBlock returns the content of the per-file export module for +// the given module name. Used to assert what does and doesn't end up exported. +func extractModuleBlock(t *testing.T, converted, moduleName string) string { + t.Helper() + marker := "") + if end == -1 { + return rest + } + return rest[:end] +} + +// extractCheatBlock returns the substring from `heading` up to the next +// section boundary: either the next `## ` heading or the start of a shared +// `\n\n") } -func writeNaviGlobalVars(sb *strings.Builder, moduleName string, vars map[string]varDef, cheats []NaviCheat) { - var usedVars []string - seen := make(map[string]bool) - for _, c := range cheats { - for _, ph := range extractPlaceholders(c.Command, naviPlaceholderRe) { - if sanitized := sanitizeVarName(ph); !seen[sanitized] { - seen[sanitized] = true - usedVars = append(usedVars, ph) - } +// resolvePlaceholders bucket-sorts a cheat's placeholders into the set of +// modules to import and the list of names that need bare inline declarations. +// Imports are alphabetized so the diff is stable across runs. +func resolvePlaceholders( + c NaviCheat, + section *NaviSection, + idx *NaviIndex, + placeholders []string, +) (imports []string, inline []string) { + importSet := make(map[string]bool) + + for _, ph := range placeholders { + if _, ok := section.Vars[ph]; ok { + importSet[section.Module] = true + continue + } + if mod := resolveExtends(c, section, ph, idx); mod != "" { + importSet[mod] = true + continue } + inline = append(inline, ph) } - if len(usedVars) == 0 { - return + for m := range importSet { + imports = append(imports, m) + } + sort.Strings(imports) + return imports, inline +} + +// resolveExtends walks the cheat's `@extends` tag list and returns the module +// name of the first foreign section whose Vars map contains placeholder ph. +// The cheat's own section is skipped so the lookup doesn't trivially match +// itself. +func resolveExtends(c NaviCheat, own *NaviSection, ph string, idx *NaviIndex) string { + for _, ext := range c.Imports { + for _, s := range idx.ByTag[ext] { + if s == own { + continue + } + if _, ok := s.Vars[ph]; ok { + return s.Module + } + } } + return "" +} +// writeSectionModule emits the section's `export ` block listing +// every `$ def` declared under it, in first-seen order. +func writeSectionModule(sb *strings.Builder, section *NaviSection) { sb.WriteString("\n") + sb.WriteString("-->\n\n") } +// placeholderSet returns the sanitized names of every navi `` in cmd, +// so extractShellEnvVars can avoid duplicating a declaration for any shell-var +// reference that happens to share its name with a navi placeholder. +func placeholderSet(cmd string) map[string]bool { + out := make(map[string]bool) + for _, ph := range extractPlaceholders(cmd, naviPlaceholderRe) { + out[sanitizeVarName(ph)] = true + } + return out +} + +// extractShellEnvVars walks cmd for `$NAME` and `${NAME}` references in +// first-seen order, deduped, skipping any name that collides with a navi +// placeholder (which would produce duplicate `var` lines). +func extractShellEnvVars(cmd string, placeholders map[string]bool) []string { + matches := shellEnvVarRe.FindAllStringSubmatch(cmd, -1) + seen := make(map[string]bool) + var out []string + for _, m := range matches { + name := m[1] + if name == "" { + name = m[2] + } + if seen[name] || placeholders[name] { + continue + } + seen[name] = true + out = append(out, name) + } + return out +} + +// ---------------------------------------------------------------------------- +// Var def emission +// ---------------------------------------------------------------------------- + func writeNaviVarDef(sb *strings.Builder, name string, def varDef) { shell, args := adaptNaviVar(def.Shell, def.Args) switch { @@ -153,13 +405,26 @@ func writeNaviVarDef(sb *strings.Builder, name string, def varDef) { } } -// adaptNaviVar translates navi-flavored selector args to cheatmd-flavored ones: +// adaptNaviVar translates navi-flavored selector args to cheatmd-flavored ones. +// Reference: https://github.com/denisidoro/navi/blob/master/docs/cheatsheet/syntax/README.md // -// - `--headers N` is consumed and re-encoded as `| tail -n +N+1` on the shell. -// - `--column N` is renamed to `--select-column N`. -// - When a column is selected without an explicit `--delimiter`, `\t` is added. +// Mappings: +// - `--header-lines N` (and the older `--headers` alias) is consumed and +// re-encoded as `| tail -n +N+1` on the shell. cheatmd doesn't render an +// fzf header band, so dropping the rows from input matches user intent. +// - `--column N` (and `--select-column N`) becomes `--map "cut -fN"`. Going +// through cut bypasses cheatmd's fragile string-split delimiter handling +// and matches the proven idiomatic cheatmd pattern. +// - `--delimiter ` is consumed (with common escapes decoded) and folded +// into the cut invocation when non-tab. +// - `--map "X"` is preserved and chained after cut when both are present. +// - `--multi`, `--header `, and any other unrecognized tokens are passed +// through. // -// All other tokens pass through in their original order. +// Dropped (fzf-only flags that cheatmd has no equivalent for): +// +// `--prevent-extra`, `--expand`, `--fzf-overrides`, `--query`, `--filter`, +// `--preview`, `--preview-window`. func adaptNaviVar(shell, args string) (string, string) { if args == "" { return shell, args @@ -168,7 +433,7 @@ func adaptNaviVar(shell, args string) (string, string) { tokens := parseSelectorArgs(args) var out []string var headers int - var hasColumn, hasDelimiter bool + var columnN, delimiterVal, userMap string for i := 0; i < len(tokens); i++ { tok := tokens[i] @@ -177,23 +442,30 @@ func adaptNaviVar(shell, args string) (string, string) { next = tokens[i+1] } switch tok { - case "--headers": + case "--header-lines", "--headers": fmt.Sscanf(next, "%d", &headers) i++ case "--column", "--select-column": - hasColumn = true - out = append(out, "--select-column", next) + columnN = next i++ case "--delimiter": - hasDelimiter = true - out = append(out, "--delimiter", next) + delimiterVal = decodeShellEscapes(next) + i++ + case "--map": + userMap = next + i++ + case "--prevent-extra", "--expand": + // Valueless fzf-only flags with no cheatmd equivalent. Drop. + case "--fzf-overrides", "--query", "--filter", "--preview", "--preview-window": + // fzf-only flags with no cheatmd equivalent. Consume the arg. i++ default: out = append(out, tok) } } - if hasColumn && !hasDelimiter { - out = append(out, "--delimiter", "\\t") + + if mapCmd := buildMapCmd(columnN, delimiterVal, userMap); mapCmd != "" { + out = append(out, "--map", mapCmd) } newShell := strings.TrimSpace(shell) @@ -203,6 +475,59 @@ func adaptNaviVar(shell, args string) (string, string) { return newShell, serializeSelectorArgs(out) } +func buildMapCmd(columnN, delimiterVal, userMap string) string { + if columnN == "" { + return userMap + } + cutPipe := "cut -f" + columnN + if delimiterVal != "" && delimiterVal != "\t" { + cutPipe += " -d " + singleQuoteForShell(delimiterVal) + } + if userMap != "" { + return cutPipe + " | " + userMap + } + return cutPipe +} + +func singleQuoteForShell(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func decodeShellEscapes(s string) string { + if !strings.Contains(s, `\`) { + return s + } + var sb strings.Builder + sb.Grow(len(s)) + for i := 0; i < len(s); i++ { + if s[i] != '\\' || i+1 >= len(s) { + sb.WriteByte(s[i]) + continue + } + switch s[i+1] { + case 't': + sb.WriteByte('\t') + case 'n': + sb.WriteByte('\n') + case 'r': + sb.WriteByte('\r') + case '\\': + sb.WriteByte('\\') + case '"': + sb.WriteByte('"') + default: + sb.WriteByte(s[i]) + sb.WriteByte(s[i+1]) + } + i++ + } + return sb.String() +} + +// ---------------------------------------------------------------------------- +// Selector arg parsing/serialization +// ---------------------------------------------------------------------------- + func parseSelectorArgs(s string) []string { var args []string var current strings.Builder From 200cbcb14484eed296775eef8b7d66e78a53f8f6 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Fri, 29 May 2026 01:14:01 -0600 Subject: [PATCH 3/5] fix: convert for tldr --- pkg/convert/common.go | 49 +++++++- pkg/convert/convert_test.go | 225 +++++++++++++++++++++++++++++++++- pkg/convert/tldr.go | 218 ++++++++++++++++++++++++++++++-- pkg/executor/executor_test.go | 6 + 4 files changed, 478 insertions(+), 20 deletions(-) diff --git a/pkg/convert/common.go b/pkg/convert/common.go index 40dcffa..8d479af 100644 --- a/pkg/convert/common.go +++ b/pkg/convert/common.go @@ -9,7 +9,6 @@ import ( var ( naviPlaceholderRe = regexp.MustCompile(`<([a-zA-Z0-9_\-\.\/]+)>`) - tldrPlaceholderRe = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_\-\.\/]+)\s*\}\}`) // shellEnvVarRe matches a shell-style environment variable reference in // a navi command body. Navi uses `` for its own placeholders, so any // `$NAME` is conventionally a shell env var the user expects to inherit @@ -76,7 +75,7 @@ func rewriteNavi(command string) (string, []string) { // rewriteTldr replaces {{name}} placeholders with $sanitized. func rewriteTldr(command string) (string, []string) { - phs := extractPlaceholders(command, tldrPlaceholderRe) + phs := extractTldrPlaceholders(command) for _, ph := range phs { sanitized := sanitizeVarName(ph) command = strings.ReplaceAll(command, "{{"+ph+"}}", "$"+sanitized) @@ -119,6 +118,50 @@ func extractPlaceholders(cmd string, re *regexp.Regexp) []string { return list } +func extractTldrPlaceholders(cmd string) []string { + var list []string + seen := make(map[string]struct{}) + for i := 0; i < len(cmd)-1; i++ { + if cmd[i] != '{' || cmd[i+1] != '{' { + continue + } + start := i + 2 + end := findTldrPlaceholderEnd(cmd, start) + if end == -1 { + continue + } + ph := strings.TrimSpace(cmd[start:end]) + if ph != "" { + if _, ok := seen[ph]; !ok { + seen[ph] = struct{}{} + list = append(list, ph) + } + } + i = end + 1 + } + return list +} + +func findTldrPlaceholderEnd(cmd string, start int) int { + for i := start; i < len(cmd); { + if cmd[i] == '\n' { + return -1 + } + if cmd[i] != '}' { + i++ + continue + } + runStart := i + for i < len(cmd) && cmd[i] == '}' { + i++ + } + if i-runStart >= 2 { + return i - 2 + } + } + return -1 +} + func sanitizeVarName(s string) string { var sb strings.Builder for i := 0; i < len(s); i++ { @@ -165,7 +208,7 @@ func formatHeaderVarsBlock(placeholders []string) string { for _, ph := range placeholders { sb.WriteString("var ") sb.WriteString(sanitizeVarName(ph)) - sb.WriteString(" = --- --header ") + sb.WriteString(" --- --header ") sb.WriteString(strconv.Quote(ph)) sb.WriteByte('\n') } diff --git a/pkg/convert/convert_test.go b/pkg/convert/convert_test.go index 5f8ed97..a360c31 100644 --- a/pkg/convert/convert_test.go +++ b/pkg/convert/convert_test.go @@ -1,6 +1,8 @@ package convert import ( + "os" + "path/filepath" "strings" "testing" ) @@ -555,15 +557,230 @@ tar -czvf {{path/to/archive.tar.gz}} {{path/to/directory}} t.Errorf("Expected command placeholders to be replaced, got: %s", converted) } - if !strings.Contains(converted, "var path_to_archive_tar_gz = --- --header \"path/to/archive.tar.gz\"") { + if !strings.Contains(converted, "var path_to_archive_tar_gz = printf '%s\\n' 'path/to/archive.tar.gz' --- --header \"path/to/archive.tar.gz\"") { t.Errorf("Expected var definition for first placeholder, got: %s", converted) } - if !strings.Contains(converted, "var path_to_directory = --- --header \"path/to/directory\"") { + if !strings.Contains(converted, "var path_to_directory = printf '%s\\n' 'path/to/directory' --- --header \"path/to/directory\"") { t.Errorf("Expected var definition for second placeholder, got: %s", converted) } } +// ============================================================================ +// TLDR placeholder-shape tests +// ============================================================================ +// +// The tldr-pages style guide allows a long tail of placeholder shapes the +// initial converter regex didn't recognize. These tests pin down the shape +// classification, var-name derivation, and command rewriting for each form +// we expect to see in real pages. + +func TestConvertTldrOptionPairBecomesPickerWithFlagSuffix(t *testing.T) { + // `{{[-m|--message]}}` is the tldr "alternate short/long flag" form. + // It must become a picker fed by `printf '%s\n' '-m' '--message'`, and + // the var name carries `_flag` to dodge a collision with a sibling + // `{{message}}` placeholder in the same example (git-commit style). + input := "# git commit\n\n- Commit with message:\n\n`git commit {{[-m|--message]}} \"{{message}}\"`\n" + + converted, err := ConvertTldr(input, "git-commit.md") + if err != nil { + t.Fatalf("ConvertTldr failed: %v", err) + } + + if !strings.Contains(converted, "git commit $message_flag \"$message\"") { + t.Errorf("Expected `$message_flag` + `$message`, got:\n%s", converted) + } + if !strings.Contains(converted, `var message_flag = printf '%s\n' '-m' '--message' --- --header "[-m|--message]"`) { + t.Errorf("Expected message_flag picker var, got:\n%s", converted) + } + if !strings.Contains(converted, `var message = printf '%s\n' 'message' --- --header "message"`) { + t.Errorf("Expected free-text message var, got:\n%s", converted) + } +} + +func TestConvertTldrBareAlternationBecomesPicker(t *testing.T) { + // `{{f|d}}` is a bare alternation (no brackets) — picker over the + // listed options, named after the first option. + input := "# find\n\n- Find files or dirs:\n\n`find {{path/to/dir}} -type {{f|d}}`\n" + + converted, err := ConvertTldr(input, "find.md") + if err != nil { + t.Fatalf("ConvertTldr failed: %v", err) + } + + if !strings.Contains(converted, "-type $f") { + t.Errorf("Expected alternation to substitute as $f, got:\n%s", converted) + } + if !strings.Contains(converted, `var f = printf '%s\n' 'f' 'd' --- --header "f|d"`) { + t.Errorf("Expected alternation picker var, got:\n%s", converted) + } +} + +func TestConvertTldrBracketedAlternationInsidePathIsLiteral(t *testing.T) { + // `{{path/to/source.tar[.gz|.bz2|.xz]}}` has brackets INSIDE a larger + // payload. It must NOT be treated as an option pair; the user should + // type a literal filename whose extension matches the suggested set. + input := "# tar\n\n- Extract an archive:\n\n`tar xvf {{path/to/source.tar[.gz|.bz2|.xz]}}`\n" + + converted, err := ConvertTldr(input, "tar.md") + if err != nil { + t.Fatalf("ConvertTldr failed: %v", err) + } + + if strings.Contains(converted, "'path/to/source.tar[.gz'") { + t.Errorf("Bracketed alt inside a path must not be split into picker choices, got:\n%s", converted) + } + if !strings.Contains(converted, `var path_to_source_tar_gz_bz2_xz = printf '%s\n' 'path/to/source.tar[.gz|.bz2|.xz]' --- --header "path/to/source.tar[.gz|.bz2|.xz]"`) { + t.Errorf("Expected the bracketed path to appear as a free-text header label, got:\n%s", converted) + } +} + +func TestConvertTldrGlobAndCommandPlaceholdersSurvive(t *testing.T) { + // Real-world tldr pages stash globs (`{{*.html}}`), wildcards + // (`{{*lib*}}`), embedded commands (`{{wc -l}}`), and multi-arg + // placeholders (`{{path/to/file1 path/to/file2 ...}}`). All must round- + // trip through the converter and surface as bare prompts whose --header + // preserves the original payload as a hint. + input := "# samples\n\n" + + "- Glob:\n\n`find . -name '{{*.html}}'`\n\n" + + "- Wildcard:\n\n`find . -iname '{{*lib*}}'`\n\n" + + "- Embedded command:\n\n`find . -name '{{*.ext}}' -exec {{wc -l}} {} \\;`\n\n" + + "- Multi-arg:\n\n`tar cf {{path/to/target.tar}} {{path/to/file1 path/to/file2 ...}}`\n" + + converted, err := ConvertTldr(input, "samples.md") + if err != nil { + t.Fatalf("ConvertTldr failed: %v", err) + } + + // Original `{{...}}` payloads must not survive in the output — every one + // of them must have been substituted with `$`. + if strings.Contains(converted, "{{") || strings.Contains(converted, "}}") { + t.Errorf("All `{{...}}` must be rewritten, got:\n%s", converted) + } + for _, header := range []string{ + `"*.html"`, + `"*lib*"`, + `"wc -l"`, + `"path/to/file1 path/to/file2 ..."`, + } { + if !strings.Contains(converted, header) { + t.Errorf("Expected header label %s preserved, got:\n%s", header, converted) + } + } +} + +func TestConvertTldrJsonPlaceholderWithBracesSurvives(t *testing.T) { + input := "# curl\n\n- Send JSON:\n\n`curl {{[-d|--data]}} '{{{\"name\":\"bob\"}}}' {{http://example.com/users/1234}}`\n" + + converted, err := ConvertTldr(input, "curl.md") + if err != nil { + t.Fatalf("ConvertTldr failed: %v", err) + } + + if !strings.Contains(converted, "curl $data_flag '$name_bob' $http_example_com_users_1234") { + t.Errorf("Expected JSON payload to become a single quoted placeholder var, got:\n%s", converted) + } + if !strings.Contains(converted, `var name_bob = printf '%s\n' '{"name":"bob"}' --- --header "{\"name\":\"bob\"}"`) { + t.Errorf("Expected full JSON object preserved as header, got:\n%s", converted) + } + if strings.Contains(converted, "{$name_bob}") { + t.Errorf("JSON braces must not be left around a partial placeholder, got:\n%s", converted) + } +} + +func TestConvertTldrSameSanitizedNameGetsSuffixed(t *testing.T) { + // Two distinct raw payloads sanitizing to the same identifier must NOT + // produce duplicate `var X` lines; the second gets `_2`, etc. + input := "# t\n\n- Two paths:\n\n`cp {{path/to/file}} {{path/to/file.bak}}`\n" + + converted, err := ConvertTldr(input, "t.md") + if err != nil { + t.Fatalf("ConvertTldr failed: %v", err) + } + + if !strings.Contains(converted, "$path_to_file ") { + t.Errorf("Expected first placeholder as $path_to_file, got:\n%s", converted) + } + // Either suffix scheme is OK as long as the second is distinct. Assert + // non-collision by checking BOTH names are declared. + if !strings.Contains(converted, "var path_to_file ") { + t.Errorf("Expected first var declared, got:\n%s", converted) + } + if strings.Count(converted, "var path_to_file ") != 1 { + t.Errorf("First name must appear exactly once (no duplicates), got:\n%s", converted) + } +} + +func TestConvertTldrKeypressSyntaxPassesThrough(t *testing.T) { + // tldr's `<~><.>` keypress notation is NOT a placeholder; the + // converter must let it through verbatim. + input := "# ssh\n\n- Close session:\n\n`<~><.>`\n" + + converted, err := ConvertTldr(input, "ssh.md") + if err != nil { + t.Fatalf("ConvertTldr failed: %v", err) + } + if !strings.Contains(converted, "<~><.>") { + t.Errorf("Keypress syntax must survive, got:\n%s", converted) + } +} + +func TestConvertTldrSkipsHeaderInfoLines(t *testing.T) { + // `>` lines (description, "More information", "See also") are header + // metadata and must not show up as commands or descriptions. + input := "# tool\n\n> A description.\n> More information: .\n> See also: `other`.\n\n- Run it:\n\n`tool {{arg}}`\n" + + converted, err := ConvertTldr(input, "tool.md") + if err != nil { + t.Fatalf("ConvertTldr failed: %v", err) + } + for _, leak := range []string{"More information", "See also", "https://example.com"} { + if strings.Contains(converted, leak) { + t.Errorf("Header line %q leaked into output:\n%s", leak, converted) + } + } +} + +func TestConvertTldrRealFixturesProduceNoStrayPlaceholders(t *testing.T) { + // Sweep test against committed real-world tldr pages. The contract: + // every `{{...}}` in the source must be substituted with a `$NAME` in + // the converted output, and every emitted var line must reference a + // var that's actually used in the example's command. + for _, name := range []string{"tar", "curl", "grep", "find", "ssh", "git-commit"} { + t.Run(name, func(t *testing.T) { + src := readTestdata(t, "tldr/"+name+".md") + converted, err := ConvertTldr(src, name+".md") + if err != nil { + t.Fatalf("ConvertTldr(%s) failed: %v", name, err) + } + if strings.Contains(converted, "{{") { + t.Errorf("Stray `{{` in converted %s, indicating an unrewritten placeholder:\n%s", + name, converted) + } + if strings.Contains(converted, "}}") { + t.Errorf("Stray `}}` in converted %s:\n%s", name, converted) + } + // Round-trip sanity: the number of `## ` headings should match + // the number of `- ` example bullets in the source. + wantExamples := strings.Count(src, "\n- ") + gotExamples := strings.Count(converted, "\n## ") + if wantExamples != gotExamples { + t.Errorf("%s: example count drifted: source has %d, converted has %d", + name, wantExamples, gotExamples) + } + }) + } +} + +func readTestdata(t *testing.T, relPath string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", relPath)) + if err != nil { + t.Fatalf("read testdata %s: %v", relPath, err) + } + return string(data) +} + func TestConvertCheat(t *testing.T) { input := `--- syntax: sh @@ -594,11 +811,11 @@ tar -czvf {{path/to/directory}} t.Errorf("Expected placeholder replacement, got: %s", converted) } - if !strings.Contains(converted, "var archive_tar_gz = --- --header \"archive.tar.gz\"") { + if !strings.Contains(converted, "var archive_tar_gz --- --header \"archive.tar.gz\"") { t.Errorf("Expected var definition for navi placeholder, got: %s", converted) } - if !strings.Contains(converted, "var path_to_directory = --- --header \"path/to/directory\"") { + if !strings.Contains(converted, "var path_to_directory --- --header \"path/to/directory\"") { t.Errorf("Expected var definition for tldr placeholder, got: %s", converted) } } diff --git a/pkg/convert/tldr.go b/pkg/convert/tldr.go index 2cfccec..97a638f 100644 --- a/pkg/convert/tldr.go +++ b/pkg/convert/tldr.go @@ -2,14 +2,26 @@ package convert import ( "fmt" + "strconv" "strings" ) +// tldrExample is one parsed `- description: \`command\`` pair from a page. type tldrExample struct { Desc string Command string } +// tldrPlaceholder captures both the raw `{{...}}` payload and the cheatmd var +// shape it should become. Choices is non-nil when the placeholder represents a +// fixed set (option pair, alternation) so the emitter can produce a picker +// rather than a free-text prompt. +type tldrPlaceholder struct { + Raw string + Name string + Choices []string +} + type tldrParser struct { commandName string currentDesc string @@ -26,28 +38,42 @@ func (p *tldrParser) parseLine(lines []string, index int) int { p.commandName = strings.TrimSpace(line[2:]) return index } - + // `>` lines are header metadata (description, "More information", "See also") + // per the tldr style guide. They appear above any examples and carry no + // command content; skip them outright so they don't get mis-parsed. + if strings.HasPrefix(line, ">") { + return index + } if strings.HasPrefix(line, "- ") { p.currentDesc = strings.TrimSuffix(strings.TrimSpace(line[2:]), ":") return index } - if p.currentDesc == "" { return index } - var command string - if strings.HasPrefix(line, "`") && strings.HasSuffix(line, "`") && len(line) >= 2 { - command = line[1 : len(line)-1] - } else if strings.HasPrefix(line, "```") { - command, index = consumeCodeBlock(lines, index+1) + command, consumed := readCommand(lines, index) + if command == "" { + return consumed } + p.examples = append(p.examples, tldrExample{Desc: p.currentDesc, Command: command}) + p.currentDesc = "" + return consumed +} - if command != "" { - p.examples = append(p.examples, tldrExample{Desc: p.currentDesc, Command: command}) - p.currentDesc = "" +// readCommand returns the command body at lines[index], handling both the +// single-line backtick form `\`cmd\`` (the spec's default) and the fenced +// `\`\`\`sh ... \`\`\`` form (used by some real-world pages). +func readCommand(lines []string, index int) (cmd string, consumed int) { + line := strings.TrimSpace(lines[index]) + if strings.HasPrefix(line, "`") && strings.HasSuffix(line, "`") && len(line) >= 2 { + return line[1 : len(line)-1], index + } + if strings.HasPrefix(line, "```") { + body, end := consumeCodeBlock(lines, index+1) + return body, end } - return index + return "", index } // ConvertTldr converts a TLDR markdown cheatsheet to CheatMD format. @@ -72,9 +98,175 @@ func ConvertTldr(content string, filename string) (string, error) { } func writeTldrExample(sb *strings.Builder, ex tldrExample) { - command, placeholders := rewriteTldr(ex.Command) + command, placeholders := processTldrCommand(ex.Command) fmt.Fprintf(sb, "## %s\n\n", ex.Desc) fmt.Fprintf(sb, "```sh\n%s\n```\n", command) - sb.WriteString(formatHeaderVarsBlock(placeholders)) + if block := emitTldrVarBlock(placeholders); block != "" { + sb.WriteString(block) + } sb.WriteByte('\n') } + +// processTldrCommand walks the command body, classifies each `{{...}}` +// placeholder, assigns unique cheatmd var names (with collision suffixes for +// distinct raw payloads that happen to sanitize to the same identifier), and +// substitutes each occurrence with `$name`. Returns the rewritten command and +// the ordered, deduped placeholder list for var-block emission. +func processTldrCommand(command string) (string, []tldrPlaceholder) { + raws := extractTldrPlaceholders(command) + infos := classifyTldrPlaceholders(raws) + for _, info := range infos { + command = substituteTldrPlaceholder(command, info.Raw, info.Name) + } + return command, infos +} + +// substituteTldrPlaceholder replaces every `{{}}` occurrence (tolerating +// internal whitespace variants from hand-formatted pages) with `$name`. +func substituteTldrPlaceholder(cmd, raw, name string) string { + dollar := "$" + name + for _, form := range []string{"{{" + raw + "}}", "{{ " + raw + " }}", "{{ " + raw + "}}", "{{" + raw + " }}"} { + cmd = strings.ReplaceAll(cmd, form, dollar) + } + return cmd +} + +// classifyTldrPlaceholders bucket-sorts each raw payload into Simple, Option +// Pair, or Alternation, names it, and resolves any name collisions by +// suffixing with _2, _3, …. Order matches first-appearance so the emitted +// var block reads top-to-bottom in source order. +func classifyTldrPlaceholders(raws []string) []tldrPlaceholder { + out := make([]tldrPlaceholder, 0, len(raws)) + nameCount := make(map[string]int) + for _, raw := range raws { + ph := classifyTldrPlaceholder(raw) + uniq := nameCount[ph.Name] + nameCount[ph.Name] = uniq + 1 + if uniq > 0 { + ph.Name = fmt.Sprintf("%s_%d", ph.Name, uniq+1) + } + out = append(out, ph) + } + return out +} + +func classifyTldrPlaceholder(raw string) tldrPlaceholder { + if choices, ok := parseOptionPair(raw); ok { + return tldrPlaceholder{Raw: raw, Choices: choices, Name: nameForOptionPair(choices)} + } + if choices, ok := parseAlternation(raw); ok { + return tldrPlaceholder{Raw: raw, Choices: choices, Name: nameForAlternation(choices)} + } + return tldrPlaceholder{Raw: raw, Name: sanitizeVarName(raw)} +} + +// parseOptionPair detects `[opt1|opt2]`-wrapped payloads (tldr's "alternate +// short/long flag" form). The full payload must be enclosed in matching +// brackets; bracketed alternation embedded inside a larger payload (e.g. +// `path/to/source.tar[.gz|.bz2|.xz]`) is NOT an option pair and falls through +// to Simple so the user types a literal filename. +func parseOptionPair(raw string) ([]string, bool) { + if !strings.HasPrefix(raw, "[") || !strings.HasSuffix(raw, "]") { + return nil, false + } + inner := raw[1 : len(raw)-1] + if !strings.Contains(inner, "|") { + return nil, false + } + parts := splitTrim(inner, "|") + if len(parts) < 2 { + return nil, false + } + return parts, true +} + +// parseAlternation detects bare `a|b|c` payloads. Skips anything with brackets +// or internal whitespace — those shapes are ambiguous (could be globs, paths, +// or commands with flags) and are safer as free-text prompts. +func parseAlternation(raw string) ([]string, bool) { + if !strings.Contains(raw, "|") { + return nil, false + } + if strings.ContainsAny(raw, "[] ") { + return nil, false + } + parts := splitTrim(raw, "|") + if len(parts) < 2 { + return nil, false + } + return parts, true +} + +// nameForOptionPair derives a cheatmd var name from the longest option, +// stripping leading dashes and suffixing `_flag`. The `_flag` suffix avoids +// the common collision where the same page has both `{{[-m|--message]}}` and +// `{{message}}` placeholders. +func nameForOptionPair(choices []string) string { + longest := "" + for _, c := range choices { + trimmed := strings.TrimLeft(c, "-") + if len(trimmed) > len(longest) { + longest = trimmed + } + } + name := sanitizeVarName(longest) + if name == "" { + return "flag" + } + return name + "_flag" +} + +// nameForAlternation uses the first option as the var name basis. Subsequent +// collisions (e.g. two `f|d` placeholders in the same example) are resolved +// by the caller's suffix logic. +func nameForAlternation(choices []string) string { + if len(choices) == 0 { + return "v" + } + return sanitizeVarName(choices[0]) +} + +func splitTrim(s, sep string) []string { + parts := strings.Split(s, sep) + for i, p := range parts { + parts[i] = strings.TrimSpace(p) + } + return parts +} + +// emitTldrVarBlock renders the `` block for an example. Simple +// placeholders become bare prompts; option-pair and alternation placeholders +// become pickers fed by `printf '%s\n' …`, which is portable across echo +// flavors (POSIX echo doesn't always interpret `\n`). +func emitTldrVarBlock(placeholders []tldrPlaceholder) string { + if len(placeholders) == 0 { + return "" + } + var sb strings.Builder + sb.WriteString("\n") + return sb.String() +} + +func emitTldrVarLine(ph tldrPlaceholder) string { + header := strconv.Quote(ph.Raw) + if len(ph.Choices) == 0 { + return fmt.Sprintf("var %s = printf '%%s\\n' %s --- --header %s\n", + ph.Name, + singleQuoteForShell(ph.Raw), + header, + ) + } + args := make([]string, len(ph.Choices)) + for i, c := range ph.Choices { + args[i] = singleQuoteForShell(c) + } + return fmt.Sprintf("var %s = printf '%%s\\n' %s --- --header %s\n", + ph.Name, + strings.Join(args, " "), + header, + ) +} diff --git a/pkg/executor/executor_test.go b/pkg/executor/executor_test.go index c489336..9d87c38 100644 --- a/pkg/executor/executor_test.go +++ b/pkg/executor/executor_test.go @@ -56,6 +56,12 @@ func TestSubstituteVars_Dollar(t *testing.T) { scope: map[string]string{"user": "bob", "username": "alice"}, expected: "echo alice and bob", }, + { + name: "substitution happens inside shell quotes", + input: "curl -H '$Authorization_Bearer_token'", + scope: map[string]string{"Authorization_Bearer_token": "Authorization: Bearer token"}, + expected: "curl -H 'Authorization: Bearer token'", + }, { name: "missing var is left as is", input: "echo $missing", From 4e2a3f5db237c0aca554796f90a9582b41d607a1 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Fri, 29 May 2026 01:33:31 -0600 Subject: [PATCH 4/5] fix: convert for cheat --- pkg/convert/cheat.go | 97 ++++++++++++++++++++++++++++++++- pkg/convert/convert_test.go | 103 +++++++++++++++++++++++++++++++++++- pkg/convert/tldr.go | 14 +++-- 3 files changed, 206 insertions(+), 8 deletions(-) diff --git a/pkg/convert/cheat.go b/pkg/convert/cheat.go index 398df64..c8e41e6 100644 --- a/pkg/convert/cheat.go +++ b/pkg/convert/cheat.go @@ -71,12 +71,105 @@ func ConvertCheat(content string, filename string) (string, error) { } func writeCheatEntry(sb *strings.Builder, e CheatEntry, tags []string) { - command, placeholders := rewriteBoth(e.Command) + command, placeholders := processCheatCommand(e.Command) fmt.Fprintf(sb, "## %s\n", e.Desc) writeHashtags(sb, tags) sb.WriteByte('\n') fmt.Fprintf(sb, "```sh\n%s\n```\n", command) - sb.WriteString(formatHeaderVarsBlock(placeholders)) + if block := emitTldrVarBlock(placeholders); block != "" { + sb.WriteString(block) + } sb.WriteByte('\n') } + +func processCheatCommand(command string) (string, []tldrPlaceholder) { + naviRaws := extractPlaceholders(command, naviPlaceholderRe) + tldrRaws := extractTldrPlaceholders(command) + bracedRaws := extractCheatBracedPlaceholders(command) + + placeholders := make([]tldrPlaceholder, 0, len(naviRaws)+len(tldrRaws)+len(bracedRaws)) + for _, raw := range naviRaws { + placeholders = append(placeholders, tldrPlaceholder{ + Raw: raw, + Name: sanitizeVarName(raw), + }) + } + placeholders = append(placeholders, classifyTldrPlaceholders(tldrRaws)...) + for _, raw := range bracedRaws { + placeholders = append(placeholders, tldrPlaceholder{ + Raw: raw, + Name: sanitizeVarName(raw), + }) + } + placeholders = uniqueCheatPlaceholders(placeholders) + + for _, ph := range placeholders { + command = strings.ReplaceAll(command, "<"+ph.Raw+">", "$"+ph.Name) + command = substituteTldrPlaceholder(command, ph.Raw, ph.Name) + command = strings.ReplaceAll(command, "${"+ph.Raw+"}", "$"+ph.Name) + } + return command, placeholders +} + +func extractCheatBracedPlaceholders(command string) []string { + var list []string + seen := make(map[string]struct{}) + for i := 0; i < len(command)-2; i++ { + if command[i] == '\\' { + i++ + continue + } + if command[i] != '$' || command[i+1] != '{' { + continue + } + end := strings.IndexByte(command[i+2:], '}') + if end == -1 { + continue + } + raw := command[i+2 : i+2+end] + if !isCheatBracedPlaceholderName(raw) { + continue + } + if _, ok := seen[raw]; !ok { + seen[raw] = struct{}{} + list = append(list, raw) + } + i += end + 2 + } + return list +} + +func isCheatBracedPlaceholderName(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 == '_' || c == '-' { + continue + } + return false + } + return true +} + +func uniqueCheatPlaceholders(placeholders []tldrPlaceholder) []tldrPlaceholder { + out := make([]tldrPlaceholder, 0, len(placeholders)) + byRaw := make(map[string]string) + nameCount := make(map[string]int) + for _, ph := range placeholders { + if name, ok := byRaw[ph.Raw]; ok { + ph.Name = name + continue + } + uniq := nameCount[ph.Name] + nameCount[ph.Name] = uniq + 1 + if uniq > 0 { + ph.Name = fmt.Sprintf("%s_%d", ph.Name, uniq+1) + } + byRaw[ph.Raw] = ph.Name + out = append(out, ph) + } + return out +} diff --git a/pkg/convert/convert_test.go b/pkg/convert/convert_test.go index a360c31..4996c76 100644 --- a/pkg/convert/convert_test.go +++ b/pkg/convert/convert_test.go @@ -742,12 +742,27 @@ func TestConvertTldrSkipsHeaderInfoLines(t *testing.T) { } func TestConvertTldrRealFixturesProduceNoStrayPlaceholders(t *testing.T) { + fixtureDir := filepath.Join("testdata", "tldr") + if _, err := os.Stat(fixtureDir); err != nil { + if os.IsNotExist(err) { + t.Skipf("real tldr fixture directory %s is not present", fixtureDir) + } + t.Fatalf("stat fixture dir %s: %v", fixtureDir, err) + } + // Sweep test against committed real-world tldr pages. The contract: // every `{{...}}` in the source must be substituted with a `$NAME` in // the converted output, and every emitted var line must reference a // var that's actually used in the example's command. for _, name := range []string{"tar", "curl", "grep", "find", "ssh", "git-commit"} { t.Run(name, func(t *testing.T) { + fixturePath := filepath.Join("testdata", "tldr", name+".md") + if _, err := os.Stat(fixturePath); err != nil { + if os.IsNotExist(err) { + t.Skipf("real tldr fixture %s is not present", fixturePath) + } + t.Fatalf("stat fixture %s: %v", fixturePath, err) + } src := readTestdata(t, "tldr/"+name+".md") converted, err := ConvertTldr(src, name+".md") if err != nil { @@ -815,7 +830,93 @@ tar -czvf {{path/to/directory}} t.Errorf("Expected var definition for navi placeholder, got: %s", converted) } - if !strings.Contains(converted, "var path_to_directory --- --header \"path/to/directory\"") { + if !strings.Contains(converted, "var path_to_directory = printf '%s\\n' 'path/to/directory' --- --header \"path/to/directory\"") { t.Errorf("Expected var definition for tldr placeholder, got: %s", converted) } } + +func TestConvertCheatPreservesExampleValuesAsEditableDefaults(t *testing.T) { + input := `# Curl with header: +curl {{[-H|--header]}} '{{Authorization: Bearer token}}' {{[-X|--request]}} {{GET|POST}} {{https://example.com}} + +# JSON body: +curl {{[-d|--data]}} '{{{"name":"bob"}}}' {{http://example.com/users/1234}} +` + + converted, err := ConvertCheat(input, "curl") + if err != nil { + t.Fatalf("ConvertCheat failed: %v", err) + } + + if !strings.Contains(converted, "curl $header_flag '$Authorization_Bearer_token' $request_flag $GET $https_example_com") { + t.Errorf("Expected curl header command to be rewritten, got:\n%s", converted) + } + for _, want := range []string{ + `var header_flag = printf '%s\n' '-H' '--header' --- --header "[-H|--header]"`, + `var Authorization_Bearer_token = printf '%s\n' 'Authorization: Bearer token' --- --header "Authorization: Bearer token"`, + `var GET = printf '%s\n' 'GET' 'POST' --- --header "GET|POST"`, + `var https_example_com = printf '%s\n' 'https://example.com' --- --header "https://example.com"`, + `var name_bob = printf '%s\n' '{"name":"bob"}' --- --header "{\"name\":\"bob\"}"`, + } { + if !strings.Contains(converted, want) { + t.Errorf("Expected %q in converted cheat, got:\n%s", want, converted) + } + } + if strings.Contains(converted, "{$name_bob}") { + t.Errorf("JSON placeholder must not be partially rewritten, got:\n%s", converted) + } +} + +func TestConvertCheatMixedPlaceholderNameCollisionsAreUnique(t *testing.T) { + input := `# Copy: +cp {{path/to/file.bak}} +` + + converted, err := ConvertCheat(input, "copy") + if err != nil { + t.Fatalf("ConvertCheat failed: %v", err) + } + + if !strings.Contains(converted, "cp $path_to_file $path_to_file_bak") { + t.Errorf("Expected mixed placeholders to rewrite distinctly, got:\n%s", converted) + } + if strings.Count(converted, "var path_to_file ") != 1 { + t.Errorf("Expected one first var definition, got:\n%s", converted) + } + if !strings.Contains(converted, "var path_to_file_bak ") { + t.Errorf("Expected second var definition, got:\n%s", converted) + } +} + +func TestConvertCheatBracedPlaceholdersBecomePrompts(t *testing.T) { + input := `# Create a pool: +zpool create ${pool} raidz1 ${device} ${failed-device} + +# Preserve escaped shell template: +npm config set //npm.intra/:_authToken=\${NPM_TOKEN} +` + + converted, err := ConvertCheat(input, "zfs") + if err != nil { + t.Fatalf("ConvertCheat failed: %v", err) + } + + if !strings.Contains(converted, "zpool create $pool raidz1 $device $failed_device") { + t.Errorf("Expected braced placeholders to rewrite, got:\n%s", converted) + } + for _, want := range []string{ + `var pool --- --header "pool"`, + `var device --- --header "device"`, + `var failed_device --- --header "failed-device"`, + } { + if !strings.Contains(converted, want) { + t.Errorf("Expected %q in converted cheat, got:\n%s", want, converted) + } + } + if !strings.Contains(converted, `\${NPM_TOKEN}`) { + t.Errorf("Escaped braced shell value should be preserved, got:\n%s", converted) + } + if strings.Contains(converted, `var NPM_TOKEN`) { + t.Errorf("Escaped braced shell value should not become a prompt, got:\n%s", converted) + } +} diff --git a/pkg/convert/tldr.go b/pkg/convert/tldr.go index 97a638f..45a0cb5 100644 --- a/pkg/convert/tldr.go +++ b/pkg/convert/tldr.go @@ -17,9 +17,10 @@ type tldrExample struct { // fixed set (option pair, alternation) so the emitter can produce a picker // rather than a free-text prompt. type tldrPlaceholder struct { - Raw string - Name string - Choices []string + Raw string + Name string + Choices []string + UseDefault bool } type tldrParser struct { @@ -157,7 +158,7 @@ func classifyTldrPlaceholder(raw string) tldrPlaceholder { if choices, ok := parseAlternation(raw); ok { return tldrPlaceholder{Raw: raw, Choices: choices, Name: nameForAlternation(choices)} } - return tldrPlaceholder{Raw: raw, Name: sanitizeVarName(raw)} + return tldrPlaceholder{Raw: raw, Name: sanitizeVarName(raw), UseDefault: true} } // parseOptionPair detects `[opt1|opt2]`-wrapped payloads (tldr's "alternate @@ -253,13 +254,16 @@ func emitTldrVarBlock(placeholders []tldrPlaceholder) string { func emitTldrVarLine(ph tldrPlaceholder) string { header := strconv.Quote(ph.Raw) - if len(ph.Choices) == 0 { + if len(ph.Choices) == 0 && ph.UseDefault { return fmt.Sprintf("var %s = printf '%%s\\n' %s --- --header %s\n", ph.Name, singleQuoteForShell(ph.Raw), header, ) } + if len(ph.Choices) == 0 { + return fmt.Sprintf("var %s --- --header %s\n", ph.Name, header) + } args := make([]string, len(ph.Choices)) for i, c := range ph.Choices { args[i] = singleQuoteForShell(c) From 01b1a0d54bdffccbfedb068f30983ad165ef7597 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Fri, 29 May 2026 01:37:42 -0600 Subject: [PATCH 5/5] fix: convert fmt --- pkg/convert/tldr.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/convert/tldr.go b/pkg/convert/tldr.go index 45a0cb5..54dcde8 100644 --- a/pkg/convert/tldr.go +++ b/pkg/convert/tldr.go @@ -6,7 +6,7 @@ import ( "strings" ) -// tldrExample is one parsed `- description: \`command\`` pair from a page. +// tldrExample is one parsed `- description: \`command\“ pair from a page. type tldrExample struct { Desc string Command string @@ -63,8 +63,8 @@ func (p *tldrParser) parseLine(lines []string, index int) int { } // readCommand returns the command body at lines[index], handling both the -// single-line backtick form `\`cmd\`` (the spec's default) and the fenced -// `\`\`\`sh ... \`\`\`` form (used by some real-world pages). +// single-line backtick form `\`cmd\“ (the spec's default) and the fenced +// `\`\`\`sh ... \`\`\“ form (used by some real-world pages). func readCommand(lines []string, index int) (cmd string, consumed int) { line := strings.TrimSpace(lines[index]) if strings.HasPrefix(line, "`") && strings.HasSuffix(line, "`") && len(line) >= 2 {