add omit test files and only frontend

This commit is contained in:
2026-08-27 14:00:24 +01:00
parent 95b5a7b70c
commit 1f5243d953
6 changed files with 158 additions and 24 deletions
+46 -4
View File
@@ -13,8 +13,8 @@ Generate markdown documentation from directory structure. Optimized for LLM inge
- Force overwrite of existing output files - Force overwrite of existing output files
- Configurable depth, file size limits, ignore patterns - Configurable depth, file size limits, ignore patterns
- Optional upload to Proton Drive after generating output - Optional upload to Proton Drive after generating output
- Skip frontend files (`.html`, `.css`, `.js`, etc.)
![dirmd](./docs/dirmd.gif) - Omit test files and inline test blocks (Go, Java, Rust)
## Build ## Build
@@ -37,12 +37,49 @@ Generate markdown documentation from directory structure. Optimized for LLM inge
# Generate with vertical slices # Generate with vertical slices
dirmd -o output.md ~/repos/myproject --vertical-slices dirmd -o output.md ~/repos/myproject --vertical-slices
# Skip frontend files (.html, .css, .js, etc.)
dirmd -o output.md ~/repos/myproject --skip-frontend
# Omit test files and inline test blocks
# Go: excludes *_test.go
# Java: excludes *Test.java, *Tests.java, src/test/java/*
# Rust: strips #[cfg(test)] mod tests { ... } blocks
dirmd -o output.md ~/repos/myproject --omit-test
# Combine filters
dirmd -o output.md ~/repos/myproject --skip-frontend --omit-test
# Generate and upload to Proton Drive (default: /my-files/md/<filename>) # Generate and upload to Proton Drive (default: /my-files/md/<filename>)
dirmd -o output.md ~/repos/myproject --proton-drive dirmd -o output.md ~/repos/myproject --proton-drive
# Generate and upload to a specific remote path # Generate and upload to a specific remote path
dirmd -o output.md ~/repos/myproject --proton-drive --drive-path /my-files/docs/api.md dirmd -o output.md ~/repos/myproject --proton-drive --drive-path /my-files/docs/api.md
## Test File Omission
The `--omit-test` flag removes test files and inline test blocks to reduce token overhead in LLM context windows. Behavior varies by language:
| Language | Exclusion Strategy | What Gets Removed |
|----------|-------------------|-------------------|
| **Go** | File exclusion | All `*_test.go` files |
| **Java** | File exclusion | `*Test.java`, `*Tests.java`, files under `src/test/java/` |
| **Rust** | Content stripping | Entire `#[cfg(test)] mod tests { ... }` blocks removed from `.rs` files |
The Rust stripping works by detecting the `#[cfg(test)]` attribute, then tracking brace depth until the module closes. Nested braces in string literals may occasionally throw off the count, but this is acceptable for RAG use cases.
Frontend files can be excluded separately with `--skip-frontend`:
| Extension Type | Excluded With `--skip-frontend` |
|---------------|-------------------------------|
| Templates | `.html`, `.htm`, `.gohtml`, `.tmpl` |
| Stylesheets | `.css`, `.scss`, `.sass`, `.less` |
| Scripts | `.js`, `.jsx`, `.mjs`, `.cjs`, `.ts`, `.tsx` |
| Frameworks | `.vue`, `.svelte` |
These flags can be combined freely:
dirmd -o docs.md ~/repos/myproject --skip-frontend --omit-test
## Vertical Slices Mode ## Vertical Slices Mode
When `--vertical-slices` is passed, dirmd splits output into multiple files instead of one monolithic document: When `--vertical-slices` is passed, dirmd splits output into multiple files instead of one monolithic document:
@@ -84,7 +121,9 @@ Output is designed for RAG ingestion and LLM context windows, not human presenta
| `--ignore` | Additional ignore patterns (glob) | | | `--ignore` | Additional ignore patterns (glob) | |
| `--extensions` | Additional file extensions to skip | | | `--extensions` | Additional file extensions to skip | |
| `--proton-drive` | Upload output to Proton Drive after writing locally | false | | `--proton-drive` | Upload output to Proton Drive after writing locally | false |
| `--drive-path` | Full remote path on Proton Drive (e.g. /my-files/md/report.md) | /my-files/md/&lt;filename&gt; | | `--drive-path` | Full remote path on Proton Drive (e.g. /my-files/md/report.md) | /my-files/md/<filename> |
| `--skip-frontend` | Omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.) | false |
| `--omit-test` | Exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust | false |
## Proton Drive Integration ## Proton Drive Integration
@@ -96,7 +135,7 @@ In vertical slices mode, all generated files (root + slices) are uploaded to `/m
1. Install the Proton Drive CLI — see the [official guide](https://proton.me/support/drive-cli) for download and installation instructions. 1. Install the Proton Drive CLI — see the [official guide](https://proton.me/support/drive-cli) for download and installation instructions.
2. Authenticate by running `proton-drive auth login`. 2. Authenticate by running `proton-drive auth login`.
3. Ensure `proton-drive` is in your `PATH`. 3. Ensure `proton-drive` is in your PATH.
If the CLI is not installed or you are not logged in, dirmd will report the error to stderr and exit with a non-zero code. Local files are always written successfully before the upload is attempted. If the CLI is not installed or you are not logged in, dirmd will report the error to stderr and exit with a non-zero code. Local files are always written successfully before the upload is attempted.
@@ -116,6 +155,9 @@ For full documentation on the Proton Drive CLI, including installation, authenti
# Vertical slices with overwrite # Vertical slices with overwrite
dirmd -o docs.md ~/repos/project -f --vertical-slices dirmd -o docs.md ~/repos/project -f --vertical-slices
# Skip frontend and omit tests
dirmd -o docs.md ~/repos/project -f --skip-frontend --omit-test
# Generate, then upload to default remote location # Generate, then upload to default remote location
dirmd -o docs.md ~/repos/project --proton-drive dirmd -o docs.md ~/repos/project --proton-drive
+7
View File
@@ -1,3 +1,4 @@
// cmd/root.go
package cmd package cmd
import ( import (
@@ -41,6 +42,9 @@ Examples:
# Skip frontend files # Skip frontend files
dirmd -o ./docs ~/repos/myproject --skip-frontend dirmd -o ./docs ~/repos/myproject --skip-frontend
# Omit test files and test blocks
dirmd -o ./docs ~/repos/myproject --omit-test
# Generate and upload to Proton Drive # Generate and upload to Proton Drive
dirmd -o ./docs ~/repos/myproject --proton-drive dirmd -o ./docs ~/repos/myproject --proton-drive
@@ -61,6 +65,7 @@ Examples:
drivePath string drivePath string
verticalSlices bool verticalSlices bool
skipFrontend bool skipFrontend bool
omitTest bool
) )
func Execute() { func Execute() {
@@ -82,6 +87,7 @@ func init() {
rootCmd.Flags().StringVar(&drivePath, "drive-path", "", "full remote directory on Proton Drive (e.g. /my-files/docs/api). Requires --proton-drive") rootCmd.Flags().StringVar(&drivePath, "drive-path", "", "full remote directory on Proton Drive (e.g. /my-files/docs/api). Requires --proton-drive")
rootCmd.Flags().BoolVar(&verticalSlices, "vertical-slices", false, "split output into root index.md + one markdown per top-level directory") rootCmd.Flags().BoolVar(&verticalSlices, "vertical-slices", false, "split output into root index.md + one markdown per top-level directory")
rootCmd.Flags().BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.)") rootCmd.Flags().BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.)")
rootCmd.Flags().BoolVar(&omitTest, "omit-test", false, "exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust")
rootCmd.MarkPersistentFlagRequired("output") rootCmd.MarkPersistentFlagRequired("output")
} }
@@ -116,6 +122,7 @@ func run(cmd *cobra.Command, args []string) error {
DrivePath: drivePath, DrivePath: drivePath,
VerticalSlices: verticalSlices, VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend, SkipFrontend: skipFrontend,
OmitTest: omitTest,
} }
if singleFile != "" { if singleFile != "" {
+5
View File
@@ -1,3 +1,4 @@
// internal/config/config.go
package config package config
import ( import (
@@ -79,6 +80,7 @@ type Config struct {
DrivePath string DrivePath string
VerticalSlices bool VerticalSlices bool
SkipFrontend bool SkipFrontend bool
OmitTest bool
} }
func PrintUsage() { func PrintUsage() {
@@ -104,6 +106,7 @@ func Parse() (*Config, error) {
drivePath string drivePath string
verticalSlices bool verticalSlices bool
skipFrontend bool skipFrontend bool
omitTest bool
) )
flag.StringVar(&input, "i", "", "single file input (append mode only)") flag.StringVar(&input, "i", "", "single file input (append mode only)")
@@ -119,6 +122,7 @@ func Parse() (*Config, error) {
flag.StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (requires --proton-drive)") flag.StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (requires --proton-drive)")
flag.BoolVar(&verticalSlices, "vertical-slices", false, "split output into root file + one markdown per top-level directory") flag.BoolVar(&verticalSlices, "vertical-slices", false, "split output into root file + one markdown per top-level directory")
flag.BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.)") flag.BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.)")
flag.BoolVar(&omitTest, "omit-test", false, "exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust")
flag.Usage = PrintUsage flag.Usage = PrintUsage
flag.Parse() flag.Parse()
@@ -161,6 +165,7 @@ func Parse() (*Config, error) {
DrivePath: drivePath, DrivePath: drivePath,
VerticalSlices: verticalSlices, VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend, SkipFrontend: skipFrontend,
OmitTest: omitTest,
} }
if input != "" { if input != "" {
+24
View File
@@ -1,3 +1,4 @@
// internal/filter/filter.go
package filter package filter
import ( import (
@@ -10,6 +11,7 @@ type Filter struct {
MaxSize int64 MaxSize int64
Extensions []string Extensions []string
Ignores []string Ignores []string
OmitTest bool
} }
func (f *Filter) ShouldSkipDir(name string) bool { func (f *Filter) ShouldSkipDir(name string) bool {
@@ -48,6 +50,28 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool {
return true return true
} }
if f.OmitTest && isTestFile(path, name) {
return true
}
return false
}
func isTestFile(path, name string) bool {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".go":
return strings.HasSuffix(strings.ToLower(name), "_test.go")
case ".java":
base := strings.TrimSuffix(name, filepath.Ext(name))
if strings.HasSuffix(base, "Test") || strings.HasSuffix(base, "Tests") {
return true
}
slashPath := filepath.ToSlash(path)
return strings.Contains(slashPath, "/src/test/")
}
return false return false
} }
+70 -18
View File
@@ -1,3 +1,4 @@
// internal/renderer/renderer.go
package renderer package renderer
import ( import (
@@ -14,7 +15,6 @@ import (
const fenceChars = "~~~" const fenceChars = "~~~"
// Write writes a single markdown file with tree and file contents.
func Write(result *walker.Result, outputPath string, appendMode bool, singleFile bool) error { func Write(result *walker.Result, outputPath string, appendMode bool, singleFile bool) error {
flags := os.O_CREATE | os.O_WRONLY flags := os.O_CREATE | os.O_WRONLY
if appendMode { if appendMode {
@@ -55,7 +55,7 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
for _, entry := range result.Entries { for _, entry := range result.Entries {
fullPath := filepath.Join(result.AbsRoot, entry.RelPath) fullPath := filepath.Join(result.AbsRoot, entry.RelPath)
content, err := readFile(fullPath) content, err := readFileForOutput(fullPath, result.OmitTest)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err) fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
continue continue
@@ -82,8 +82,6 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
return err return err
} }
// WriteSlices generates a root markdown file plus one markdown per top-level directory.
// Returns the list of generated file paths.
func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, error) { func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, error) {
entries := result.Entries entries := result.Entries
if len(entries) == 0 { if len(entries) == 0 {
@@ -109,16 +107,14 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
var generated []string var generated []string
// Write root file as index_<reponame>.md
indexBasename := "index_" + repoName + ".md" indexBasename := "index_" + repoName + ".md"
rootPath := filepath.Join(outputDir, indexBasename) rootPath := filepath.Join(outputDir, indexBasename)
generated = append(generated, rootPath) generated = append(generated, rootPath)
if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs); err != nil { if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs, result.OmitTest); err != nil {
return nil, fmt.Errorf("failed to write root slice: %w", err) return nil, fmt.Errorf("failed to write root slice: %w", err)
} }
// Write each top-level directory slice into a mirroring subdirectory
var dirNames []string var dirNames []string
for k := range topLevelDirs { for k := range topLevelDirs {
dirNames = append(dirNames, k) dirNames = append(dirNames, k)
@@ -137,7 +133,6 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
absDir := filepath.Join(absRoot, dirName) absDir := filepath.Join(absRoot, dirName)
dirEntries := topLevelDirs[dirName] dirEntries := topLevelDirs[dirName]
// Strip dirName prefix for tree building only
treeEntries := make([]walker.Entry, len(dirEntries)) treeEntries := make([]walker.Entry, len(dirEntries))
for i, e := range dirEntries { for i, e := range dirEntries {
sepIdx := strings.Index(e.RelPath, string(filepath.Separator)) sepIdx := strings.Index(e.RelPath, string(filepath.Separator))
@@ -151,7 +146,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
} }
} }
if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, indexBasename); err != nil { if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, indexBasename, result.OmitTest); err != nil {
return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err) return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err)
} }
} }
@@ -159,7 +154,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
return generated, nil return generated, nil
} }
func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry) error { func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, omitTest bool) error {
f, err := os.Create(path) f, err := os.Create(path)
if err != nil { if err != nil {
return fmt.Errorf("cannot create root file: %w", err) return fmt.Errorf("cannot create root file: %w", err)
@@ -170,14 +165,12 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker
fmt.Fprintf(&sb, "# %s [ROOT]\n\n", absRoot) fmt.Fprintf(&sb, "# %s [ROOT]\n\n", absRoot)
// Build combined tree with references
treeLines := buildTreeWithRefs(rootName, repoName, topLevelDirs, rootFiles) treeLines := buildTreeWithRefs(rootName, repoName, topLevelDirs, rootFiles)
sb.WriteString("## Directory Tree\n\n") sb.WriteString("## Directory Tree\n\n")
sb.WriteString(fenceChars + "\n") sb.WriteString(fenceChars + "\n")
sb.WriteString(treeLines) sb.WriteString(treeLines)
sb.WriteString(fenceChars + "\n\n") sb.WriteString(fenceChars + "\n\n")
// Vertical slices index for LLM navigation
sb.WriteString("## Vertical Slices Index\n\n") sb.WriteString("## Vertical Slices Index\n\n")
sb.WriteString("These files contain the full contents of each top-level directory.\n") sb.WriteString("These files contain the full contents of each top-level directory.\n")
sb.WriteString("Read the relevant slice file for detailed source code.\n\n") sb.WriteString("Read the relevant slice file for detailed source code.\n\n")
@@ -211,7 +204,7 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker
for _, entry := range rootFiles { for _, entry := range rootFiles {
fullPath := filepath.Join(absRoot, entry.RelPath) fullPath := filepath.Join(absRoot, entry.RelPath)
content, err := readFile(fullPath) content, err := readFileForOutput(fullPath, omitTest)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err) fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
continue continue
@@ -238,7 +231,7 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker
return err return err
} }
func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string) error { func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string, omitTest bool) error {
f, err := os.Create(path) f, err := os.Create(path)
if err != nil { if err != nil {
return fmt.Errorf("cannot create slice file: %w", err) return fmt.Errorf("cannot create slice file: %w", err)
@@ -266,7 +259,7 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri
for _, entry := range fileEntries { for _, entry := range fileEntries {
fullPath := filepath.Join(absRoot, entry.RelPath) fullPath := filepath.Join(absRoot, entry.RelPath)
content, err := readFile(fullPath) content, err := readFileForOutput(fullPath, omitTest)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err) fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
continue continue
@@ -298,19 +291,16 @@ func buildTreeWithRefs(rootName, repoName string, topLevelDirs map[string][]walk
sb.WriteString(rootName) sb.WriteString(rootName)
sb.WriteString("/\n") sb.WriteString("/\n")
// Collect and sort directory names
var dirNames []string var dirNames []string
for k := range topLevelDirs { for k := range topLevelDirs {
dirNames = append(dirNames, k) dirNames = append(dirNames, k)
} }
sort.Strings(dirNames) sort.Strings(dirNames)
// Sort root files
sort.Slice(rootFiles, func(i, j int) bool { sort.Slice(rootFiles, func(i, j int) bool {
return rootFiles[i].RelPath < rootFiles[j].RelPath return rootFiles[i].RelPath < rootFiles[j].RelPath
}) })
// Merge directories and files into a single sorted list for display
type treeItem struct { type treeItem struct {
name string name string
isDir bool isDir bool
@@ -441,6 +431,68 @@ func readFile(path string) (string, error) {
return content, nil return content, nil
} }
func readFileForOutput(fullPath string, omitTest bool) (string, error) {
content, err := readFile(fullPath)
if err != nil {
return "", err
}
if omitTest && strings.EqualFold(filepath.Ext(fullPath), ".rs") {
content = stripTestBlocks(content)
}
return content, nil
}
func stripTestBlocks(content string) string {
lines := strings.Split(content, "\n")
var result []string
i := 0
for i < len(lines) {
line := lines[i]
trimmed := strings.TrimSpace(line)
if isCfgTestAttr(trimmed) {
braceDepth := 0
foundBrace := false
for i < len(lines) {
line := lines[i]
if !foundBrace {
if strings.Contains(line, "{") {
braceDepth += strings.Count(line, "{") - strings.Count(line, "}")
foundBrace = true
}
i++
continue
}
braceDepth += strings.Count(line, "{") - strings.Count(line, "}")
i++
if braceDepth <= 0 {
break
}
}
continue
}
result = append(result, line)
i++
}
if len(result) == 0 {
return ""
}
return strings.Join(result, "\n")
}
func isCfgTestAttr(line string) bool {
return strings.Contains(line, "#[cfg(test)]") ||
strings.Contains(line, "#[cfg(all(test") ||
strings.Contains(line, "#[cfg(any(test")
}
func extToLang(ext string) string { func extToLang(ext string) string {
mapping := map[string]string{ mapping := map[string]string{
".go": "go", ".go": "go",
+6 -2
View File
@@ -1,3 +1,4 @@
// internal/walker/walker.go
package walker package walker
import ( import (
@@ -20,6 +21,7 @@ type Result struct {
Entries []Entry Entries []Entry
AbsRoot string AbsRoot string
SingleFile bool SingleFile bool
OmitTest bool
} }
func Run(cfg *config.Config) (*Result, error) { func Run(cfg *config.Config) (*Result, error) {
@@ -43,6 +45,7 @@ func processSingleFile(cfg *config.Config) (*Result, error) {
MaxSize: cfg.MaxSize, MaxSize: cfg.MaxSize,
Extensions: cfg.Extensions, Extensions: cfg.Extensions,
Ignores: cfg.Ignores, Ignores: cfg.Ignores,
OmitTest: cfg.OmitTest,
} }
if flt.ShouldSkipFile(cfg.AbsRoot, filepath.Base(cfg.AbsRoot), info) { if flt.ShouldSkipFile(cfg.AbsRoot, filepath.Base(cfg.AbsRoot), info) {
@@ -53,8 +56,8 @@ func processSingleFile(cfg *config.Config) (*Result, error) {
Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}}, Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}},
AbsRoot: filepath.Dir(cfg.AbsRoot), AbsRoot: filepath.Dir(cfg.AbsRoot),
SingleFile: true, SingleFile: true,
OmitTest: cfg.OmitTest,
}, nil }, nil
} }
func walkDirectory(cfg *config.Config) (*Result, error) { func walkDirectory(cfg *config.Config) (*Result, error) {
@@ -62,9 +65,10 @@ func walkDirectory(cfg *config.Config) (*Result, error) {
MaxSize: cfg.MaxSize, MaxSize: cfg.MaxSize,
Extensions: cfg.Extensions, Extensions: cfg.Extensions,
Ignores: cfg.Ignores, Ignores: cfg.Ignores,
OmitTest: cfg.OmitTest,
} }
result := &Result{AbsRoot: cfg.AbsRoot} result := &Result{AbsRoot: cfg.AbsRoot, OmitTest: cfg.OmitTest}
err := filepath.WalkDir(cfg.AbsRoot, func(path string, d fs.DirEntry, err error) error { err := filepath.WalkDir(cfg.AbsRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {