finished vertical-.slices

This commit is contained in:
Bartal Laearsson
2026-08-07 23:10:42 +01:00
parent 8c39068453
commit 6fbb9bf983
4 changed files with 504 additions and 74 deletions
+60 -26
View File
@@ -1,13 +1,14 @@
# dirmd
Generate markdown documentation from directory structure.
Generate markdown documentation from directory structure. Optimized for LLM ingestion.
## Features
- Recursive directory traversal
- Tree visualization (standard tree command format)
- Full file contents with syntax-highlighted code blocks
- ASCII tree visualization (LLM-friendly, minimal token overhead)
- Full file contents with explicit delimiters and metadata
- Filters dotfiles, binaries, and blacklisted extensions
- Vertical slices mode: root file + one markdown per top-level directory
- Append mode for single files or directories
- Force overwrite of existing output files
- Configurable depth, file size limits, ignore patterns
@@ -31,12 +32,42 @@ Generate markdown documentation from directory structure.
# Append another directory
dirmd -a ~/repos/anotherproject -o existing.md
# Generate with vertical slices
dirmd -o output.md ~/repos/myproject --vertical-slices
# Generate and upload to Proton Drive (default: /my-files/md/<filename>)
dirmd -o output.md ~/repos/myproject --proton-drive
# Generate and upload to a specific remote path
dirmd -o output.md ~/repos/myproject --proton-drive --drive-path /my-files/docs/api.md
## Vertical Slices Mode
When `--vertical-slices` is passed, dirmd splits output into multiple files instead of one monolithic document:
- **Root file** (`-o output.md`): Full directory tree with cross-references to slice files, a vertical slices index table (file counts, total bytes per directory), and full contents of all root-level files.
- **Slice files** (`cmd.md`, `internal.md`, etc.): One per top-level directory, placed alongside the root file. Each contains its own tree, a back-reference to the root document, and full recursive file contents for that directory.
This mode cannot be combined with `--append` or `--input-file`.
Example output with `dirmd -o docs.md ~/repos/myproject --vertical-slices`:
docs/
├── docs.md ← root: tree + slices index + root file contents
├── cmd.md ← slice: tree + contents of cmd/
└── internal.md ← slice: tree + contents of internal/
## LLM-Optimized Output
Output is designed for RAG ingestion and LLM context windows, not human presentation:
- **ASCII tree format** (`+-` instead of Unicode box-drawing) to reduce token overhead
- **Explicit file delimiters**: `--- FILE: ./path (bytes, lines) ---` and `--- END FILE ---`
- **Document type markers**: `[ROOT]` and `[SLICE]` in H1 headers
- **Cross-references**: Directory entries in root tree show `-> see cmd.md`; slice files reference their root document
- **Slices index table**: File counts and total bytes per slice for informed retrieval decisions
- **Tilde fences** (`~~~`) to avoid conflicts with backticks in source files
## Flags
| Flag | Description | Default |
@@ -45,6 +76,7 @@ Generate markdown documentation from directory structure.
| `-a`, `--append` | Append to existing output file | false |
| `-f`, `--force` | Overwrite existing output file (non-append mode) | false |
| `-i`, `--input-file` | Single file input (append mode only) | |
| `--vertical-slices` | Split output into root file + one markdown per top-level directory | false |
| `--max-size` | Max file size in bytes | 524288 |
| `--max-depth` | Max directory recursion depth | 20 |
| `--ignore` | Additional ignore patterns (glob) | |
@@ -54,7 +86,9 @@ Generate markdown documentation from directory structure.
## Proton Drive Integration
dirmd can optionally upload the generated markdown file to Proton Drive using the [Proton Drive CLI](https://proton.me/support/drive-cli). The upload is performed only when `--proton-drive` is passed. Without it, dirmd behaves exactly as before — no Proton Drive dependency is required.
dirmd can optionally upload the generated markdown file(s) to Proton Drive using the [Proton Drive CLI](https://proton.me/support/drive-cli). The upload is performed only when `--proton-drive` is passed. Without it, dirmd behaves exactly as before — no Proton Drive dependency is required.
In vertical slices mode, all generated files (root + slices) are uploaded to `/my-files/md/`. The `--drive-path` flag is ignored in slices mode; each file is uploaded by its basename.
### Prerequisites
@@ -62,14 +96,7 @@ dirmd can optionally upload the generated markdown file to Proton Drive using th
2. Authenticate by running `proton-drive auth login`.
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. The local markdown file is still written successfully before the upload is attempted.
### Upload Behavior
- Default remote path: `/my-files/md/<filename>` where `<filename>` is the basename of the local output file.
- Override with `--drive-path /my-files/custom/report.md` (full path including filename).
- Existing remote files are overwritten (`--conflict-strategy replace`).
- The local markdown file is always written 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.
For full documentation on the Proton Drive CLI, including installation, authentication, and troubleshooting, refer to the [official Proton Drive CLI support page](https://proton.me/support/drive-cli).
@@ -84,6 +111,9 @@ For full documentation on the Proton Drive CLI, including installation, authenti
# Skip additional file extensions
dirmd -o output.md ~/repos/project --extensions .log --extensions .tmp
# Vertical slices with overwrite
dirmd -o docs.md ~/repos/project -f --vertical-slices
# Generate, then upload to default remote location
dirmd -o docs.md ~/repos/project --proton-drive
@@ -97,31 +127,35 @@ For full documentation on the Proton Drive CLI, including installation, authenti
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
1. Absolute path as H1 header with `[ROOT]` or `[SLICE]` marker
2. ASCII directory tree (indented with `+-` and `|` characters)
3. Vertical slices index table (sliced mode only)
4. All text files with content in fenced code blocks
5. Explicit `--- FILE: ---` and `--- END FILE ---` delimiters with byte and line counts
6. Language detection from file extensions
Example output structure:
Example output structure (non-sliced mode):
# /home/user/repos/myproject
# /home/user/repos/myproject [ROOT]
## Directory Tree
~~~
myproject/
├── cmd/
└── main.go
└── internal/
└── config.go
+- cmd/
| +- main.go
+- internal/
| +- config.go
~~~
## Contents
### ./cmd/main.go
```go
--- FILE: ./cmd/main.go (45 bytes, 3 lines) ---
~~~go
package main
func main() {}
```
~~~
--- END FILE ---
Note: Uses tilde fences (~~~) internally to avoid conflicts with nested backticks in source files.
+51 -11
View File
@@ -36,7 +36,10 @@ Examples:
# Overwrite existing local file
dirmd -o output.md ~/repos/myproject -f
# Generate and upload to Proton Drive (default remote path /my-files/md/<filename>)
# Generate with vertical slices (root + one file per top-level dir)
dirmd -o output.md ~/repos/myproject --vertical-slices
# Generate and upload to Proton Drive
dirmd -o output.md ~/repos/myproject --proton-drive
# Generate and upload to a specific remote path
@@ -54,6 +57,7 @@ Examples:
extensions []string
protonDrive bool
drivePath string
verticalSlices bool
)
func Execute() {
@@ -73,6 +77,7 @@ func init() {
rootCmd.Flags().StringArrayVar(&extensions, "extensions", nil, "additional file extensions to skip (repeatable)")
rootCmd.Flags().BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally")
rootCmd.Flags().StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (e.g. /my-files/md/report.md). Requires --proton-drive")
rootCmd.Flags().BoolVar(&verticalSlices, "vertical-slices", false, "split output into root file + one markdown per top-level directory")
rootCmd.MarkPersistentFlagRequired("output")
}
@@ -82,16 +87,25 @@ func run(cmd *cobra.Command, args []string) error {
return fmt.Errorf("--drive-path requires --proton-drive")
}
if verticalSlices && appendMode {
return fmt.Errorf("--vertical-slices cannot be used with --append")
}
if verticalSlices && singleFile != "" {
return fmt.Errorf("--vertical-slices cannot be used with --input-file")
}
cfg := &config.Config{
OutputPath: outputPath,
AppendMode: appendMode,
Force: force,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(config.DefaultIgnores, ignorePatterns...),
Extensions: append(config.DefaultExts, extensions...),
ProtonDrive: protonDrive,
DrivePath: drivePath,
OutputPath: outputPath,
AppendMode: appendMode,
Force: force,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(config.DefaultIgnores, ignorePatterns...),
Extensions: append(config.DefaultExts, extensions...),
ProtonDrive: protonDrive,
DrivePath: drivePath,
VerticalSlices: verticalSlices,
}
if singleFile != "" {
@@ -113,7 +127,6 @@ func run(cmd *cobra.Command, args []string) error {
}
cfg.AbsRoot = abs
// Non-append mode: check for existing output file
if !appendMode {
if _, err := os.Stat(outputPath); err == nil && !force {
return fmt.Errorf("output file %s already exists (use -f to overwrite or -a to append)", outputPath)
@@ -129,6 +142,33 @@ func run(cmd *cobra.Command, args []string) error {
return fmt.Errorf("no readable text files found in %s", cfg.AbsRoot)
}
if verticalSlices {
generated, err := renderer.WriteSlices(result, outputPath)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "generated %d files\n", len(generated))
if protonDrive {
if err := drive.CheckAuth(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload was skipped")
return err
}
for _, path := range generated {
if err := drive.Upload(path, "/my-files/md/"+filepath.Base(path)); err != nil {
fmt.Fprintf(os.Stderr, "error uploading %s: %v\n", path, err)
} else {
fmt.Fprintf(os.Stderr, "uploaded %s to Proton Drive\n", path)
}
}
}
return nil
}
if err := renderer.Write(result, outputPath, appendMode, cfg.SingleFile); err != nil {
return err
}
+44 -32
View File
@@ -53,18 +53,19 @@ func (s *stringSlice) Set(v string) error {
}
type Config struct {
InputPath string
OutputPath string
AppendMode bool
Force bool
SingleFile bool
MaxSize int64
MaxDepth int
Ignores []string
Extensions []string
AbsRoot string
ProtonDrive bool
DrivePath string
InputPath string
OutputPath string
AppendMode bool
Force bool
SingleFile bool
MaxSize int64
MaxDepth int
Ignores []string
Extensions []string
AbsRoot string
ProtonDrive bool
DrivePath string
VerticalSlices bool
}
func PrintUsage() {
@@ -77,17 +78,18 @@ func PrintUsage() {
func Parse() (*Config, error) {
var (
input string
output string
appendMd bool
force bool
maxSize int64
maxDepth int
ignores stringSlice
exts stringSlice
showVer bool
protonDrive bool
drivePath string
input string
output string
appendMd bool
force bool
maxSize int64
maxDepth int
ignores stringSlice
exts stringSlice
showVer bool
protonDrive bool
drivePath string
verticalSlices bool
)
flag.StringVar(&input, "i", "", "single file input (append mode only)")
@@ -101,6 +103,7 @@ func Parse() (*Config, error) {
flag.BoolVar(&showVer, "version", false, "print version and exit")
flag.BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally")
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.Usage = PrintUsage
flag.Parse()
@@ -118,16 +121,25 @@ func Parse() (*Config, error) {
return nil, fmt.Errorf("--drive-path requires --proton-drive")
}
if verticalSlices && appendMd {
return nil, fmt.Errorf("--vertical-slices cannot be used with --append")
}
if verticalSlices && input != "" {
return nil, fmt.Errorf("--vertical-slices cannot be used with --input-file")
}
cfg := &Config{
OutputPath: output,
AppendMode: appendMd,
Force: force,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(DefaultIgnores, ignores...),
Extensions: append(DefaultExts, exts...),
ProtonDrive: protonDrive,
DrivePath: drivePath,
OutputPath: output,
AppendMode: appendMd,
Force: force,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(DefaultIgnores, ignores...),
Extensions: append(DefaultExts, exts...),
ProtonDrive: protonDrive,
DrivePath: drivePath,
VerticalSlices: verticalSlices,
}
if input != "" {
+349 -5
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
@@ -13,6 +14,7 @@ import (
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 {
flags := os.O_CREATE | os.O_WRONLY
if appendMode {
@@ -29,20 +31,19 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
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)
fmt.Fprintf(&sb, "# %s [ROOT]\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(renderTreeForLLM(root, rootName))
sb.WriteString(fenceChars + "\n\n")
}
@@ -61,8 +62,9 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
}
language := extToLang(filepath.Ext(entry.RelPath))
lines := countLines(content)
fmt.Fprintf(&sb, "### ./%s\n\n", entry.RelPath)
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
fmt.Fprintf(&sb, "%s", fenceChars)
if language != "" {
fmt.Fprintf(&sb, "%s", language)
@@ -72,7 +74,7 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
if !strings.HasSuffix(content, "\n") {
fmt.Fprintf(&sb, "\n")
}
fmt.Fprintf(&sb, "%s\n\n", fenceChars)
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
}
}
@@ -80,6 +82,337 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
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, outputPath string) ([]string, error) {
entries := result.Entries
if len(entries) == 0 {
return nil, fmt.Errorf("no entries to write")
}
absRoot := result.AbsRoot
rootName := filepath.Base(absRoot)
topLevelDirs := make(map[string][]walker.Entry)
rootFiles := make([]walker.Entry, 0)
for _, e := range entries {
rel := e.RelPath
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) == 1 {
rootFiles = append(rootFiles, e)
} else {
firstPart := parts[0]
topLevelDirs[firstPart] = append(topLevelDirs[firstPart], e)
}
}
outDir := filepath.Dir(outputPath)
var generated []string
// Write root file
rootPath := outputPath
generated = append(generated, rootPath)
if err := writeRootSlice(rootPath, absRoot, rootName, rootFiles, topLevelDirs, outDir); err != nil {
return nil, fmt.Errorf("failed to write root slice: %w", err)
}
// Write each top-level directory slice into a mirroring subdirectory
var dirNames []string
for k := range topLevelDirs {
dirNames = append(dirNames, k)
}
sort.Strings(dirNames)
for _, dirName := range dirNames {
sliceName := strings.TrimSuffix(dirName, "/") + ".md"
sliceDir := filepath.Join(outDir, dirName)
if err := os.MkdirAll(sliceDir, 0755); err != nil {
return nil, fmt.Errorf("cannot create slice directory %s: %w", sliceDir, err)
}
slicePath := filepath.Join(sliceDir, sliceName)
generated = append(generated, slicePath)
absDir := filepath.Join(absRoot, dirName)
dirEntries := topLevelDirs[dirName]
// Strip dirName prefix for tree building only
treeEntries := make([]walker.Entry, len(dirEntries))
for i, e := range dirEntries {
treeEntries[i] = walker.Entry{
RelPath: strings.TrimPrefix(e.RelPath, dirName+string(filepath.Separator)),
Size: e.Size,
}
}
if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, filepath.Base(outputPath)); err != nil {
return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err)
}
}
return generated, nil
}
func writeRootSlice(path, absRoot, rootName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, outDir string) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("cannot create root file: %w", err)
}
defer f.Close()
var sb strings.Builder
fmt.Fprintf(&sb, "# %s [ROOT]\n\n", absRoot)
// Build combined tree with references
treeLines := buildTreeWithRefs(rootName, topLevelDirs, rootFiles)
sb.WriteString("## Directory Tree\n\n")
sb.WriteString(fenceChars + "\n")
sb.WriteString(treeLines)
sb.WriteString(fenceChars + "\n\n")
// Vertical slices index for LLM navigation
sb.WriteString("## Vertical Slices Index\n\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("| Slice File | Directory | Files | Total Bytes |\n")
sb.WriteString("|------------|-----------|-------|-------------|\n")
var dirNames []string
for k := range topLevelDirs {
dirNames = append(dirNames, k)
}
sort.Strings(dirNames)
for _, dirName := range dirNames {
entries := topLevelDirs[dirName]
fileCount := len(entries)
totalBytes := int64(0)
for _, e := range entries {
totalBytes += e.Size
}
sliceFile := filepath.Join(dirName, strings.TrimSuffix(dirName, "/")+".md")
fmt.Fprintf(&sb, "| %s | %s/ | %d | %d |\n", sliceFile, dirName, fileCount, totalBytes)
}
sb.WriteString("\n")
if len(rootFiles) > 0 {
sb.WriteString("## Root Contents\n\n")
sort.Slice(rootFiles, func(i, j int) bool {
return rootFiles[i].RelPath < rootFiles[j].RelPath
})
for _, entry := range rootFiles {
fullPath := filepath.Join(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))
lines := countLines(content)
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
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--- END FILE ---\n\n", fenceChars)
}
}
_, err = f.WriteString(sb.String())
return err
}
func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("cannot create slice file: %w", err)
}
defer f.Close()
var sb strings.Builder
fmt.Fprintf(&sb, "# %s [SLICE]\n\n", absDir)
fmt.Fprintf(&sb, "Root document: %s\n\n", rootFileName)
sb.WriteString("## Directory Tree\n\n")
sb.WriteString(fenceChars + "\n")
sliceTree := buildTreeFromEntries(dirName, treeEntries)
sb.WriteString(sliceTree)
sb.WriteString(fenceChars + "\n\n")
if len(fileEntries) > 0 {
sb.WriteString("## Contents\n\n")
sort.Slice(fileEntries, func(i, j int) bool {
return fileEntries[i].RelPath < fileEntries[j].RelPath
})
for _, entry := range fileEntries {
fullPath := filepath.Join(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))
lines := countLines(content)
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
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--- END FILE ---\n\n", fenceChars)
}
}
_, err = f.WriteString(sb.String())
return err
}
func buildTreeWithRefs(rootName string, topLevelDirs map[string][]walker.Entry, rootFiles []walker.Entry) string {
var sb strings.Builder
sb.WriteString(rootName)
sb.WriteString("/\n")
// Collect and sort directory names
var dirNames []string
for k := range topLevelDirs {
dirNames = append(dirNames, k)
}
sort.Strings(dirNames)
// Sort root files
sort.Slice(rootFiles, func(i, j int) bool {
return rootFiles[i].RelPath < rootFiles[j].RelPath
})
// Merge directories and files into a single sorted list for display
type treeItem struct {
name string
isDir bool
sliceRef string
}
items := make([]treeItem, 0, len(dirNames)+len(rootFiles))
for _, d := range dirNames {
items = append(items, treeItem{
name: d,
isDir: true,
sliceRef: filepath.Join(d, strings.TrimSuffix(d, "/")+".md"),
})
}
for _, rf := range rootFiles {
items = append(items, treeItem{
name: rf.RelPath,
isDir: false,
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].name < items[j].name
})
for _, item := range items {
sb.WriteString("+- ")
sb.WriteString(item.name)
if item.isDir {
sb.WriteString("/")
sb.WriteString(" -> see ")
sb.WriteString(item.sliceRef)
}
sb.WriteString("\n")
}
return sb.String()
}
func buildTreeFromEntries(rootName string, entries []walker.Entry) string {
root := &tree.Node{IsDir: true, Children: make(map[string]*tree.Node)}
for _, e := range entries {
parts := strings.Split(e.RelPath, string(filepath.Separator))
cur := root
for i, part := range parts {
isLeaf := i == len(parts)-1
if _, ok := cur.Children[part]; !ok {
node := &tree.Node{Name: part, IsDir: !isLeaf}
if !isLeaf {
node.Children = make(map[string]*tree.Node)
}
cur.Children[part] = node
}
cur = cur.Children[part]
}
}
var sb strings.Builder
sb.WriteString(rootName)
sb.WriteString("/\n")
renderNodeLLM(&sb, root, "")
return sb.String()
}
func renderTreeForLLM(root *tree.Node, rootName string) string {
var sb strings.Builder
sb.WriteString(rootName)
sb.WriteString("/\n")
renderNodeLLM(&sb, root, "")
return sb.String()
}
func renderNodeLLM(sb *strings.Builder, node *tree.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
sb.WriteString(prefix)
sb.WriteString("+- ")
sb.WriteString(child.Name)
if child.IsDir {
sb.WriteString("/")
}
sb.WriteString("\n")
if child.IsDir && child.Children != nil {
newPrefix := prefix
if isLast {
newPrefix += " "
} else {
newPrefix += "| "
}
renderNodeLLM(sb, child, newPrefix)
}
}
}
func readFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -131,3 +464,14 @@ func extToLang(ext string) string {
}
return ""
}
func countLines(content string) int {
if content == "" {
return 0
}
count := strings.Count(content, "\n")
if len(content) > 0 && content[len(content)-1] != '\n' {
count++
}
return count
}