finished, also added cobra cli
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
bin/
|
||||||
|
.env
|
||||||
|
vendor/
|
||||||
@@ -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.
|
||||||
-289
@@ -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] <directory>\n")
|
|
||||||
fmt.Fprintf(os.Stderr, " dirmd -a -i <file> -o <output>\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")
|
|
||||||
}
|
|
||||||
+116
@@ -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] <directory>",
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
module dirmd
|
module dirmd
|
||||||
|
|
||||||
go 1.26.4
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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] <directory>\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " dirmd -a -i <file> -o <output>\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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user