From a9ebf41bf90881e51745776b79b352f460fac0b4 Mon Sep 17 00:00:00 2001 From: Bartal Laearsson Date: Sun, 2 Aug 2026 22:14:57 +0100 Subject: [PATCH] finished, also added cobra cli --- .gitignore | 3 + README.md | 83 ++++++++++ cmd/cli/main.go | 289 ---------------------------------- cmd/root.go | 116 ++++++++++++++ go.mod | 7 + go.sum | 10 ++ internal/config/config.go | 135 ++++++++++++++++ internal/filter/filter.go | 60 +++++++ internal/renderer/renderer.go | 133 ++++++++++++++++ internal/tree/tree.go | 76 +++++++++ internal/walker/walker.go | 123 +++++++++++++++ main.go | 8 + 12 files changed, 754 insertions(+), 289 deletions(-) create mode 100644 .gitignore create mode 100644 README.md delete mode 100644 cmd/cli/main.go create mode 100644 cmd/root.go create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/filter/filter.go create mode 100644 internal/renderer/renderer.go create mode 100644 internal/tree/tree.go create mode 100644 internal/walker/walker.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53770bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +.env +vendor/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7712e03 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# dirmd + +Generate markdown documentation from directory structure. + +## Features + +- Recursive directory traversal +- Tree visualization (standard tree command format) +- Full file contents with syntax-highlighted code blocks +- Filters dotfiles, binaries, and blacklisted extensions +- Append mode for single files or directories +- Configurable depth, file size limits, ignore patterns + +## Build + + go build -o dirmd . + +## Usage + + # Create new documentation + dirmd -o output.md ~/repos/myproject + + # Append a single file + dirmd -a -i README.md -o existing.md + + # Append another directory + dirmd -a ~/repos/anotherproject -o existing.md + +## Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `-o`, `--output` | Output markdown file path (required) | | +| `-a`, `--append` | Append to existing output file | false | +| `-i`, `--input-file` | Single file input (append mode only) | | +| `--max-size` | Max file size in bytes | 524288 | +| `--max-depth` | Max directory recursion depth | 20 | +| `--ignore` | Additional ignore patterns (glob) | | +| `--extensions` | Additional file extensions to skip | | + +## Examples + + # Custom depth and size limit + dirmd -o output.md ~/repos/project --max-depth 5 --max-size 262144 + + # Ignore specific directories + dirmd -o output.md ~/repos/project --ignore __pycache__ --ignore .venv + + # Skip additional file extensions + dirmd -o output.md ~/repos/project --extensions .log --extensions .tmp + +## Output Format + +Produces markdown with: + +1. Absolute path as H1 header +2. Directory tree (indented with 4 spaces per level) +3. All text files with content in code blocks +4. Language detection from file extensions + +Example output structure: + + # /home/user/repos/myproject + + ## Directory Tree + + myproject/ + ├── cmd/ + │ └── main.go + └── internal/ + └── config.go + + ## Contents + + ### ./cmd/main.go + + ```go + package main + + func main() {} + ``` + +Note: Uses tilde fences (~~~) internally to avoid conflicts with nested backticks in source files. diff --git a/cmd/cli/main.go b/cmd/cli/main.go deleted file mode 100644 index 287e8fb..0000000 --- a/cmd/cli/main.go +++ /dev/null @@ -1,289 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" -) - -const ( - defaultMaxSize = 512 * 1024 // 512KB - defaultMaxDepth = 20 - version = "0.1.0" -) - -var defaultIgnores = []string{ - ".git", - "node_modules", - "vendor", - "bin", -} - -var defaultExts = []string{ - ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".ico", - ".mp4", ".avi", ".mov", ".mkv", ".webm", - ".mp3", ".wav", ".flac", ".ogg", - ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", - ".exe", ".dll", ".so", ".dylib", ".wasm", - ".pdf", ".doc", ".docx", ".xls", ".xlsx", - ".pem", ".key", ".cert", - ".db", ".sqlite", ".sqlite3", -} - -type stringSlice []string - -func (s *stringSlice) String() string { - return strings.Join(*s, ", ") -} - -func (s *stringSlice) Set(v string) error { - *s = append(*s, v) - return nil -} - -type config struct { - inputPath string - outputPath string - appendMode bool - maxSize int64 - maxDepth int - ignores []string - exts []string - absRoot string -} - -func parseFlags() (*config, error) { - var ( - input string - output string - appendMd bool - maxSize int64 - maxDepth int - ignores stringSlice - exts stringSlice - showVer bool - ) - - flag.StringVar(&input, "i", "", "single file input (append mode only)") - flag.StringVar(&output, "o", "", "output markdown file path (required)") - flag.BoolVar(&appendMd, "a", false, "append to existing output file") - flag.Int64Var(&maxSize, "max-size", defaultMaxSize, "max file size in bytes") - flag.IntVar(&maxDepth, "max-depth", defaultMaxDepth, "max directory recursion depth") - flag.Var(&ignores, "ignore", "additional ignore patterns (glob, repeatable)") - flag.Var(&exts, "extensions", "additional file extensions to skip (repeatable, e.g. .log)") - flag.BoolVar(&showVer, "version", false, "print version and exit") - - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "dirmd v%s\n\n", version) - fmt.Fprintf(os.Stderr, "Usage: dirmd [flags] \n") - fmt.Fprintf(os.Stderr, " dirmd -a -i -o \n\n") - fmt.Fprintf(os.Stderr, "Flags:\n") - flag.PrintDefaults() - } - - flag.Parse() - - if showVer { - fmt.Printf("dirmd v%s\n", version) - os.Exit(0) - } - - // Validation - if output == "" { - return nil, fmt.Errorf("-o is required") - } - - cfg := &config{ - outputPath: output, - appendMode: appendMd, - maxSize: maxSize, - maxDepth: maxDepth, - ignores: append(defaultIgnores, ignores...), - exts: append(defaultExts, exts...), - } - - if input != "" { - // Single file mode - cfg.inputPath = input - if !appendMd { - return nil, fmt.Errorf("single file input (-i) requires append mode (-a)") - } - } else { - // Directory mode - args := flag.Args() - if len(args) < 1 { - return nil, fmt.Errorf("directory argument is required when -i is not used") - } - cfg.inputPath = args[0] - } - - // Resolve absolute path - abs, err := filepath.Abs(cfg.inputPath) - if err != nil { - return nil, fmt.Errorf("failed to resolve absolute path: %w", err) - } - cfg.absRoot = abs - - // Non-append mode: output must not exist - if !appendMd { - if _, err := os.Stat(output); err == nil { - return nil, fmt.Errorf("output file %s already exists (use -a to append)", output) - } - } - - return cfg, nil -} - -func isSymlink(path string) bool { - info, err := os.Lstat(path) - if err != nil { - return false - } - return info.Mode()&os.ModeSymlink != 0 -} - -func walkDirectory(cfg *config) error { - return filepath.WalkDir(cfg.absRoot, func(path string, d fs.DirEntry, err error) error { - if err != nil { - fmt.Fprintf(os.Stderr, "warning: %v\n", err) - return nil - } - - rel, err := filepath.Rel(cfg.absRoot, path) - if err != nil { - return nil - } - - // Calculate depth - depth := strings.Count(rel, string(filepath.Separator)) - if rel == "." { - depth = 0 - } - - // Check max depth - if depth > cfg.maxDepth { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - - // Skip symlinks - if isSymlink(path) { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - - // Skip dotfiles - base := filepath.Base(path) - if strings.HasPrefix(base, ".") && base != "." { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - - // Check ignore patterns against full relative path - for _, pattern := range cfg.ignores { - matched, _ := filepath.Match(pattern, base) - if matched { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - } - - if d.IsDir() { - fmt.Fprintf(os.Stderr, "[dir] %s (depth %d)\n", rel, depth) - return nil - } - - // Check extension blacklist - ext := filepath.Ext(path) - for _, badExt := range cfg.exts { - if strings.EqualFold(ext, badExt) { - fmt.Fprintf(os.Stderr, "[skip] %s (blacklisted extension)\n", rel) - return nil - } - } - - // Check file size - info, err := d.Info() - if err != nil { - fmt.Fprintf(os.Stderr, "warning: cannot stat %s: %v\n", rel, err) - return nil - } - if info.Size() > cfg.maxSize { - fmt.Fprintf(os.Stderr, "[skip] %s (size %d > %d)\n", rel, info.Size(), cfg.maxSize) - return nil - } - - fmt.Fprintf(os.Stderr, "[file] %s (size %d, depth %d)\n", rel, info.Size(), depth) - return nil - }) -} - -func processSingleFile(cfg *config) error { - info, err := os.Stat(cfg.absRoot) - if err != nil { - return fmt.Errorf("cannot access %s: %w", cfg.absRoot, err) - } - - if info.IsDir() { - // User passed a directory with -i, treat as directory walk - return walkDirectory(cfg) - } - - if info.Size() > cfg.maxSize { - return fmt.Errorf("file %s exceeds max size (%d > %d)", cfg.absRoot, info.Size(), cfg.maxSize) - } - - fmt.Fprintf(os.Stderr, "[file] %s (size %d)\n", cfg.absRoot, info.Size()) - return nil -} - -func main() { - cfg, err := parseFlags() - if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n\n", err) - flag.Usage() - os.Exit(1) - } - - if cfg.inputPath == "" { - fmt.Fprintln(os.Stderr, "error: no input specified") - flag.Usage() - os.Exit(1) - } - - if cfg.appendMode && cfg.inputPath != "" { - // Could be single file or directory with -i in append mode - if err := processSingleFile(cfg); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } - } else { - // Directory mode - info, err := os.Stat(cfg.absRoot) - if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } - if !info.IsDir() { - fmt.Fprintf(os.Stderr, "error: %s is not a directory\n", cfg.absRoot) - os.Exit(1) - } - if err := walkDirectory(cfg); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } - } - - fmt.Fprintf(os.Stderr, "done\n") -} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..9c11343 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "dirmd/internal/config" + "dirmd/internal/renderer" + "dirmd/internal/walker" +) + +var ( + rootCmd = &cobra.Command{ + Use: "dirmd [flags] ", + Short: "Generate markdown documentation from directory structure", + Long: `dirmd walks a directory and generates a markdown file with: + +- Directory tree structure +- All readable text files with their contents + +Examples: + + # Create new documentation + dirmd -o output.md ~/repos/myproject + + # Append a single file + dirmd -a -i README.md -o existing.md + + # Append another directory + dirmd -a -o existing.md ~/repos/anotherproject`, + RunE: run, + } + + outputPath string + appendMode bool + singleFile string + maxSize int64 + maxDepth int + ignorePatterns []string + extensions []string +) + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output markdown file path (required)") + rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing output file") + rootCmd.Flags().StringVarP(&singleFile, "input-file", "i", "", "single file input (append mode only)") + rootCmd.Flags().Int64Var(&maxSize, "max-size", config.DefaultMaxSize, "max file size in bytes") + rootCmd.Flags().IntVar(&maxDepth, "max-depth", config.DefaultMaxDepth, "max directory recursion depth") + rootCmd.Flags().StringArrayVar(&ignorePatterns, "ignore", nil, "additional ignore patterns (glob)") + rootCmd.Flags().StringArrayVar(&extensions, "extensions", nil, "additional file extensions to skip (repeatable)") + + rootCmd.MarkPersistentFlagRequired("output") +} + +func run(cmd *cobra.Command, args []string) error { + cfg := &config.Config{ + OutputPath: outputPath, + AppendMode: appendMode, + MaxSize: maxSize, + MaxDepth: maxDepth, + Ignores: append(config.DefaultIgnores, ignorePatterns...), + Extensions: append(config.DefaultExts, extensions...), + } + + if singleFile != "" { + cfg.InputPath = singleFile + cfg.SingleFile = true + if !appendMode { + return fmt.Errorf("single file input (-i) requires append mode (-a)") + } + } else { + if len(args) < 1 { + return fmt.Errorf("directory argument is required when -i is not used") + } + cfg.InputPath = args[0] + } + + abs, err := filepath.Abs(cfg.InputPath) + if err != nil { + return fmt.Errorf("failed to resolve absolute path: %w", err) + } + cfg.AbsRoot = abs + + // Non-append mode: output must not exist + if !appendMode { + if _, err := os.Stat(outputPath); err == nil { + return fmt.Errorf("output file %s already exists (use -a to append)", outputPath) + } + } + + result, err := walker.Run(cfg) + if err != nil { + return err + } + + if len(result.Entries) == 0 { + return fmt.Errorf("no readable text files found in %s", cfg.AbsRoot) + } + + if err := renderer.Write(result, outputPath, appendMode, cfg.SingleFile); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "wrote %d entries to %s\n", len(result.Entries), outputPath) + return nil +} + diff --git a/go.mod b/go.mod index 66737b3..135878a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module dirmd go 1.26.4 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7747293 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,135 @@ +package config + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + DefaultMaxSize = 512 * 1024 + DefaultMaxDepth = 20 + Version = "0.1.0" +) + +var DefaultIgnores = []string{ + ".git", + "node_modules", + "vendor", + "bin", +} + +var DefaultExts = []string{ + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".ico", + ".mp4", ".avi", ".mov", ".mkv", ".webm", + ".mp3", ".wav", ".flac", ".ogg", + ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", + ".exe", ".dll", ".so", ".dylib", ".wasm", + ".pdf", ".doc", ".docx", ".xls", ".xlsx", + ".pem", ".key", ".cert", + ".db", ".sqlite", ".sqlite3", +} + +type stringSlice []string + +func (s *stringSlice) String() string { + return strings.Join(*s, ", ") +} + +func (s *stringSlice) Set(v string) error { + *s = append(*s, v) + return nil +} + +type Config struct { + InputPath string + OutputPath string + AppendMode bool + SingleFile bool + MaxSize int64 + MaxDepth int + Ignores []string + Extensions []string + AbsRoot string +} + +func PrintUsage() { + fmt.Fprintf(os.Stderr, "dirmd v%s\n\n", Version) + fmt.Fprintf(os.Stderr, "Usage: dirmd [flags] \n") + fmt.Fprintf(os.Stderr, " dirmd -a -i -o \n\n") + fmt.Fprintf(os.Stderr, "Flags:\n") + flag.PrintDefaults() +} + +func Parse() (*Config, error) { + var ( + input string + output string + appendMd bool + maxSize int64 + maxDepth int + ignores stringSlice + exts stringSlice + showVer bool + ) + + flag.StringVar(&input, "i", "", "single file input (append mode only)") + flag.StringVar(&output, "o", "", "output markdown file path (required)") + flag.BoolVar(&appendMd, "a", false, "append to existing output file") + flag.Int64Var(&maxSize, "max-size", DefaultMaxSize, "max file size in bytes") + flag.IntVar(&maxDepth, "max-depth", DefaultMaxDepth, "max directory recursion depth") + flag.Var(&ignores, "ignore", "additional ignore patterns (glob, repeatable)") + flag.Var(&exts, "extensions", "additional file extensions to skip (repeatable)") + flag.BoolVar(&showVer, "version", false, "print version and exit") + + flag.Usage = PrintUsage + flag.Parse() + + if showVer { + fmt.Printf("dirmd v%s\n", Version) + os.Exit(0) + } + + if output == "" { + return nil, fmt.Errorf("-o is required") + } + + cfg := &Config{ + OutputPath: output, + AppendMode: appendMd, + MaxSize: maxSize, + MaxDepth: maxDepth, + Ignores: append(DefaultIgnores, ignores...), + Extensions: append(DefaultExts, exts...), + } + + if input != "" { + cfg.SingleFile = true + cfg.InputPath = input + if !appendMd { + return nil, fmt.Errorf("single file input (-i) requires append mode (-a)") + } + } else { + args := flag.Args() + if len(args) < 1 { + return nil, fmt.Errorf("directory argument is required when -i is not used") + } + cfg.InputPath = args[0] + } + + abs, err := filepath.Abs(cfg.InputPath) + if err != nil { + return nil, fmt.Errorf("failed to resolve absolute path: %w", err) + } + cfg.AbsRoot = abs + + if !appendMd { + if _, err := os.Stat(output); err == nil { + return nil, fmt.Errorf("output file %s already exists (use -a to append)", output) + } + } + + return cfg, nil +} diff --git a/internal/filter/filter.go b/internal/filter/filter.go new file mode 100644 index 0000000..c12cd4f --- /dev/null +++ b/internal/filter/filter.go @@ -0,0 +1,60 @@ +package filter + +import ( + "os" + "path/filepath" + "strings" +) + +type Filter struct { + MaxSize int64 + Extensions []string + Ignores []string +} + +func (f *Filter) ShouldSkipDir(name string) bool { + if strings.HasPrefix(name, ".") { + return true + } + for _, pattern := range f.Ignores { + matched, _ := filepath.Match(pattern, name) + if matched { + return true + } + } + return false +} + +func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool { + if strings.HasPrefix(name, ".") { + return true + } + + for _, pattern := range f.Ignores { + matched, _ := filepath.Match(pattern, name) + if matched { + return true + } + } + + ext := filepath.Ext(path) + for _, bad := range f.Extensions { + if strings.EqualFold(ext, bad) { + return true + } + } + + if info.Size() > f.MaxSize { + return true + } + + return false +} + +func IsSymlink(path string) bool { + info, err := os.Lstat(path) + if err != nil { + return false + } + return info.Mode()&os.ModeSymlink != 0 +} diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go new file mode 100644 index 0000000..398c298 --- /dev/null +++ b/internal/renderer/renderer.go @@ -0,0 +1,133 @@ +package renderer + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "unicode/utf8" + + "dirmd/internal/tree" + "dirmd/internal/walker" +) + +const fenceChars = "~~~" + +func Write(result *walker.Result, outputPath string, appendMode bool, singleFile bool) error { + flags := os.O_CREATE | os.O_WRONLY + if appendMode { + flags |= os.O_APPEND + } else { + flags |= os.O_TRUNC + } + + f, err := os.OpenFile(outputPath, flags, 0644) + if err != nil { + return fmt.Errorf("cannot open output file: %w", err) + } + defer f.Close() + + var sb strings.Builder + + // Determine the correct H1 path + h1Path := result.AbsRoot + if singleFile && len(result.Entries) > 0 { + h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath) + } + + fmt.Fprintf(&sb, "# %s\n\n", h1Path) + + if !singleFile { + root := tree.Build(result.Entries) + rootName := filepath.Base(result.AbsRoot) + sb.WriteString("## Directory Tree\n\n") + sb.WriteString(fenceChars + "\n") + sb.WriteString(tree.Render(root, rootName)) + sb.WriteString(fenceChars + "\n\n") + } + + if len(result.Entries) > 0 { + if !singleFile { + sb.WriteString("## Contents\n\n") + } + + for _, entry := range result.Entries { + fullPath := filepath.Join(result.AbsRoot, entry.RelPath) + + content, err := readFile(fullPath) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err) + continue + } + + language := extToLang(filepath.Ext(entry.RelPath)) + + fmt.Fprintf(&sb, "### ./%s\n\n", entry.RelPath) + fmt.Fprintf(&sb, "%s", fenceChars) + if language != "" { + fmt.Fprintf(&sb, "%s", language) + } + fmt.Fprintf(&sb, "\n") + fmt.Fprintf(&sb, "%s", content) + if !strings.HasSuffix(content, "\n") { + fmt.Fprintf(&sb, "\n") + } + fmt.Fprintf(&sb, "%s\n\n", fenceChars) + } + } + + _, err = f.WriteString(sb.String()) + return err +} + +func readFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + + for i, b := range data { + if b == 0 { + return "", fmt.Errorf("contains null byte at position %d", i) + } + } + + if !utf8.Valid(data) { + return "", fmt.Errorf("invalid UTF-8 encoding") + } + + content := string(data) + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + + return content, nil +} + +func extToLang(ext string) string { + mapping := map[string]string{ + ".go": "go", + ".md": "markdown", + ".yml": "yaml", + ".yaml": "yaml", + ".js": "javascript", + ".ts": "typescript", + ".css": "css", + ".html": "html", + ".json": "json", + ".xml": "xml", + ".sql": "sql", + ".sh": "bash", + ".py": "python", + ".rs": "rust", + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".hpp": "cpp", + ".txt": "text", + } + + if lang, ok := mapping[strings.ToLower(ext)]; ok { + return lang + } + return "" +} diff --git a/internal/tree/tree.go b/internal/tree/tree.go new file mode 100644 index 0000000..a06726a --- /dev/null +++ b/internal/tree/tree.go @@ -0,0 +1,76 @@ +package tree + +import ( + "sort" + "strings" + + "dirmd/internal/walker" +) + +type Node struct { + Name string + IsDir bool + Children map[string]*Node +} + +func Build(entries []walker.Entry) *Node { + root := &Node{IsDir: true, Children: make(map[string]*Node)} + + for _, e := range entries { + parts := strings.Split(e.RelPath, "/") + cur := root + + for i, part := range parts { + isLeaf := i == len(parts)-1 + if _, ok := cur.Children[part]; !ok { + cur.Children[part] = &Node{Name: part, IsDir: !isLeaf, Children: make(map[string]*Node)} + } + cur = cur.Children[part] + } + } + + return root +} + +func Render(root *Node, rootName string) string { + var sb strings.Builder + sb.WriteString(rootName) + sb.WriteString("/\n") + renderNode(&sb, root, "") + return sb.String() +} + +func renderNode(sb *strings.Builder, node *Node, prefix string) { + keys := make([]string, 0, len(node.Children)) + for k := range node.Children { + keys = append(keys, k) + } + sort.Strings(keys) + + for i, key := range keys { + child := node.Children[key] + isLast := i == len(keys)-1 + + if isLast { + sb.WriteString(prefix + "└── ") + } else { + sb.WriteString(prefix + "├── ") + } + + sb.WriteString(child.Name) + if child.IsDir { + sb.WriteString("/") + } + sb.WriteString("\n") + + if child.IsDir { + newPrefix := prefix + if isLast { + newPrefix += " " + } else { + newPrefix += "│ " + } + renderNode(sb, child, newPrefix) + } + } +} diff --git a/internal/walker/walker.go b/internal/walker/walker.go new file mode 100644 index 0000000..f4bd3be --- /dev/null +++ b/internal/walker/walker.go @@ -0,0 +1,123 @@ +package walker + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "dirmd/internal/config" + "dirmd/internal/filter" +) + +type Entry struct { + RelPath string + Size int64 +} + +type Result struct { + Entries []Entry + AbsRoot string + SingleFile bool +} + +func Run(cfg *config.Config) (*Result, error) { + if cfg.SingleFile { + return processSingleFile(cfg) + } + return walkDirectory(cfg) +} + +func processSingleFile(cfg *config.Config) (*Result, error) { + info, err := os.Stat(cfg.AbsRoot) + if err != nil { + return nil, fmt.Errorf("cannot access %s: %w", cfg.AbsRoot, err) + } + + if info.IsDir() { + return walkDirectory(cfg) + } + + flt := &filter.Filter{ + MaxSize: cfg.MaxSize, + Extensions: cfg.Extensions, + Ignores: cfg.Ignores, + } + + if flt.ShouldSkipFile(cfg.AbsRoot, filepath.Base(cfg.AbsRoot), info) { + return nil, fmt.Errorf("file %s filtered out", cfg.AbsRoot) + } + + return &Result{ + Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}}, + AbsRoot: filepath.Dir(cfg.AbsRoot), + SingleFile: true, + }, nil + +} + +func walkDirectory(cfg *config.Config) (*Result, error) { + flt := &filter.Filter{ + MaxSize: cfg.MaxSize, + Extensions: cfg.Extensions, + Ignores: cfg.Ignores, + } + + result := &Result{AbsRoot: cfg.AbsRoot} + + err := filepath.WalkDir(cfg.AbsRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + fmt.Fprintf(os.Stderr, "warning: %v\n", err) + return nil + } + + rel, err := filepath.Rel(cfg.AbsRoot, path) + if err != nil { + return nil + } + + depth := strings.Count(rel, string(filepath.Separator)) + if rel != "." { + depth++ + } + + if depth > cfg.MaxDepth { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + if filter.IsSymlink(path) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + base := filepath.Base(path) + + if d.IsDir() { + if rel != "." && flt.ShouldSkipDir(base) { + return filepath.SkipDir + } + return nil + } + + info, err := d.Info() + if err != nil { + fmt.Fprintf(os.Stderr, "warning: cannot stat %s: %v\n", rel, err) + return nil + } + + if flt.ShouldSkipFile(path, base, info) { + return nil + } + + result.Entries = append(result.Entries, Entry{RelPath: rel, Size: info.Size()}) + return nil + }) + + return result, err +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..afef884 --- /dev/null +++ b/main.go @@ -0,0 +1,8 @@ +package main + +import "dirmd/cmd" + +func main() { + cmd.Execute() +} +