diff --git a/cmd/cheatmd/convert.go b/cmd/cheatmd/convert.go new file mode 100644 index 0000000..b64b398 --- /dev/null +++ b/cmd/cheatmd/convert.go @@ -0,0 +1,271 @@ +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() { + // 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 { + 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..c8e41e6 --- /dev/null +++ b/pkg/convert/cheat.go @@ -0,0 +1,175 @@ +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 := 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) + 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/common.go b/pkg/convert/common.go new file mode 100644 index 0000000..8d479af --- /dev/null +++ b/pkg/convert/common.go @@ -0,0 +1,272 @@ +package convert + +import ( + "path/filepath" + "regexp" + "strconv" + "strings" +) + +var ( + naviPlaceholderRe = regexp.MustCompile(`<([a-zA-Z0-9_\-\.\/]+)>`) + // 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 +// 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 := extractTldrPlaceholders(command) + 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 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++ { + 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..4996c76 --- /dev/null +++ b/pkg/convert/convert_test.go @@ -0,0 +1,922 @@ +package convert + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +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 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, got:\n%s", converted) + } + if !strings.Contains(converted, "git checkout $branch") { + t.Errorf("Expected placeholder replacement, got:\n%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) + } + + 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 + +$ device: adb devices | grep device | cut -f 1 +` + + converted, err := ConvertNavi(input, "android.cheat") + if err != nil { + t.Fatalf("ConvertNavi failed: %v", err) + } + + // 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) + } + + // 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) + } + + // 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") +} + +// 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) + } + + 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\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 { + 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. +// Reference: https://github.com/denisidoro/navi/blob/master/docs/cheatsheet/syntax/README.md +// +// 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. +// +// 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 + } + + tokens := parseSelectorArgs(args) + var out []string + var headers int + var columnN, delimiterVal, userMap string + + for i := 0; i < len(tokens); i++ { + tok := tokens[i] + next := "" + if i+1 < len(tokens) { + next = tokens[i+1] + } + switch tok { + case "--header-lines", "--headers": + fmt.Sscanf(next, "%d", &headers) + i++ + case "--column", "--select-column": + columnN = next + i++ + case "--delimiter": + 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 mapCmd := buildMapCmd(columnN, delimiterVal, userMap); mapCmd != "" { + out = append(out, "--map", mapCmd) + } + + newShell := strings.TrimSpace(shell) + if headers > 0 && newShell != "" { + newShell = fmt.Sprintf("%s | tail -n +%d", newShell, headers+1) + } + 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 + 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..54dcde8 --- /dev/null +++ b/pkg/convert/tldr.go @@ -0,0 +1,276 @@ +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 + UseDefault bool +} + +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 + } + // `>` 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 + } + + command, consumed := readCommand(lines, index) + if command == "" { + return consumed + } + p.examples = append(p.examples, tldrExample{Desc: p.currentDesc, Command: command}) + p.currentDesc = "" + return consumed +} + +// 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 +} + +// 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 := processTldrCommand(ex.Command) + fmt.Fprintf(sb, "## %s\n\n", ex.Desc) + fmt.Fprintf(sb, "```sh\n%s\n```\n", command) + 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), UseDefault: true} +} + +// 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 && 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) + } + 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",