Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,29 @@ buildtree "docker-project/
└── settings.yaml"
```

## Exporting Project Structure for LLM Context

Buildtree now includes a powerful feature that allows you to export your entire project structure with file contents to a single file, perfect for providing context to Large Language Models (LLMs).

### Key Features

- **Complete project context**: Export all files with their paths and contents in a structured format
- **Smart filtering**: Include only relevant files by extension with `--filter`
- **Size control**: Limit file size with `--max-size` (default: 100kb)
- **Ignore patterns**: Skip directories like `.git` and `node_modules` by default
- **Hidden files**: Control inclusion of hidden files with `--include-hidden`

### Usage

```bash
buildtree -s OUTPUT_FILE [DIRECTORY] [OPTIONS]
```

This example will collect the contents of all files with the *.go, *.txt, *.md extension and combine them into a single file with relative paths.
```bash
buildtree -s export-structure.txt ./project-dir -f go,txt,md -I vendor
```

## Use Cases
- Quickly test LLM-generated file structures
- Create educational examples for documentation
Expand Down
128 changes: 119 additions & 9 deletions cmd/buildtree/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import (
"fmt"
"io"
"os"
"strings"

"github.com/neomen/buildtree/internal/builder"
"github.com/neomen/buildtree/internal/exporter"
"github.com/neomen/buildtree/internal/parser"
"github.com/neomen/buildtree/internal/utils"
)

var (
Expand All @@ -16,7 +19,7 @@ var (
date = "unknown"
)

// Добавим интерфейсы для зависимостей, чтобы можно было мокировать их в тестах
// Add interfaces for dependencies so that you can mock them in tests
type parserInterface interface {
ParseInput(input string) (*parser.Node, error)
}
Expand All @@ -25,7 +28,7 @@ type builderInterface interface {
BuildTree(root *parser.Node, maxDepth int) error
}

// Реальные реализации
// Real implementations
type realParser struct{}
type realBuilder struct{}

Expand All @@ -37,26 +40,79 @@ func (r *realBuilder) BuildTree(root *parser.Node, maxDepth int) error {
return builder.BuildTree(root, maxDepth)
}

// Вынесем основную логику в отдельную функцию для тестирования
// Let's put the main logic in a separate function for testing
func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, p parserInterface, b builderInterface) int {
// Создаем новый набор флагов для каждого вызова
// First, we look for the -s or --structure flag in the arguments
var saveStructureValue string
var newArgs []string
skipNext := false

// We go through all the arguments to find the -s flag
for i, arg := range args {
if skipNext {
skipNext = false
continue
}

// Checking the long shape of the flag
if arg == "-s" || arg == "--structure" {
if i+1 < len(args) {
saveStructureValue = args[i+1]
skipNext = true
continue
}
// Checking the form with equality
} else if strings.HasPrefix(arg, "-s=") || strings.HasPrefix(arg, "--structure=") {
parts := strings.SplitN(arg, "=", 2)
if len(parts) == 2 {
saveStructureValue = parts[1]
continue
}
}

// Saving arguments, except for the found flag
newArgs = append(newArgs, arg)
}

// Creating a new set of flags
flags := flag.NewFlagSet("buildtree", flag.ContinueOnError)
flags.SetOutput(stderr) // Устанавливаем вывод ошибок флагов в stderr
flags.SetOutput(stderr)

// Defining all flags
filePath := flags.String("input-file", "", "Path to file containing directory structure")
helpFlag := flags.Bool("help", false, "Show help")
maxDepth := flags.Int("max-depth", 20, "Maximum nesting depth allowed (0 = no limit)")
versionFlag := flags.Bool("version", false, "Show version information")

// A flag for exporting the structure (needed for parsing other flags)
saveStructure := flags.String("s", "", "Save current directory structure with file contents to specified file")

// Additional flags for export
structureFilter := flags.String("filter", "", "Comma-separated list of file extensions to include (e.g., go,js,txt)")
ignoreDirs := flags.String("ignore-dir", "", "Comma-separated list of directories to ignore (in addition to .git and node_modules)")
maxSize := flags.String("max-size", "100kb", "Maximum file size to include (e.g., 100kb, 1mb)")
includeHidden := flags.Bool("include-hidden", false, "Include hidden files and directories (starting with .)")

// Adding Aliases
flags.StringVar(filePath, "i", "", "Alias for --input-file")
flags.IntVar(maxDepth, "d", 20, "Alias for --max-depth")
flags.BoolVar(versionFlag, "v", false, "Alias for --version")
flags.BoolVar(helpFlag, "h", false, "Alias for --help")
flags.StringVar(saveStructure, "structure", "", "Alias for --save-structure")
flags.StringVar(structureFilter, "f", "", "Alias for --filter")
flags.StringVar(ignoreDirs, "I", "", "Alias for --ignore-dir (uppercase i)")

// Парсим аргументы
if err := flags.Parse(args); err != nil {
// Parse the remaining arguments
if err := flags.Parse(newArgs); err != nil {
return 1
}

// If we found the -s flag manually, we use its value
if saveStructureValue != "" {
*saveStructure = saveStructureValue
}

// Processing of standard flags
if *helpFlag {
printHelp(stdout)
return 0
Expand All @@ -67,19 +123,68 @@ func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, p p
return 0
}

// FLAG HANDLING -s
if *saveStructure != "" {
// Checking for conflicts
if *filePath != "" {
fmt.Fprintln(stderr, "Error: -s/--structure cannot be used with --input-file")
return 1
}

// Defining the root directory
rootDir := "."
args := flags.Args()
if len(args) > 0 {
rootDir = args[0]
}

// Processing filters
var filters []string
if *structureFilter != "" {
filters = strings.Split(*structureFilter, ",")
for i := range filters {
filters[i] = strings.TrimSpace(filters[i])
}
}

// Processing ignored directories
var ignoreList []string
if *ignoreDirs != "" {
ignoreList = strings.Split(*ignoreDirs, ",")
for i := range ignoreList {
ignoreList[i] = strings.TrimSpace(ignoreList[i])
}
}

// Parse the maximum size
maxSizeBytes, err := utils.ParseSize(*maxSize)
if err != nil {
fmt.Fprintf(stderr, "Error parsing max-size: %v\n", err)
return 1
}

// Export the structure
if err := exporter.ExportStructure(*saveStructure, rootDir, filters, ignoreList, maxSizeBytes, *includeHidden); err != nil {
fmt.Fprintf(stderr, "Error exporting structure: %v\n", err)
return 1
}
return 0
}

// Normal processing (creating a structure)
input := getInput(*filePath, stdin, flags, stderr)
if input == "" {
return 1
}

// Parse the input structure
// Parsing the structure
root, err := p.ParseInput(input)
if err != nil {
fmt.Fprintf(stderr, "Error parsing input: %v\n", err)
return 1
}

// Build the file structure
// Building a tree
if err := b.BuildTree(root, *maxDepth); err != nil {
fmt.Fprintf(stderr, "Error building tree: %v\n", err)
return 1
Expand Down Expand Up @@ -115,6 +220,11 @@ func printHelp(w io.Writer) {
fmt.Fprintln(w, " -d, --max-depth N Maximum nesting depth allowed (0=unlimited, default:20)")
fmt.Fprintln(w, " -h, --help Show this help message")
fmt.Fprintln(w, " -v, --version Show version information")
fmt.Fprintln(w, " -s, --structure FILE Save current directory structure with file contents to FILE")
fmt.Fprintln(w, " -f, --filter EXTS Comma-separated list of file extensions to include (e.g., go,js,txt)")
fmt.Fprintln(w, " -I, --ignore-dir DIRS Comma-separated list of directories to ignore (in addition to .git and node_modules)")
fmt.Fprintln(w, " --max-size SIZE Maximum file size to include (default: 100kb, e.g., 100kb, 1mb)")
fmt.Fprintln(w, " -H, --include-hidden Include hidden files and directories (starting with .)")
fmt.Fprintln(w, "\nExamples:")
fmt.Fprintln(w, " buildtree \"project/\n├── src/\n│ └── main.go\"")
fmt.Fprintln(w, " buildtree --input-file structure.txt")
Expand Down
147 changes: 147 additions & 0 deletions internal/exporter/exporter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package exporter

import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)

// ExportStructure saves the current directory structure with file contents to the specified file
// with filtering options
func ExportStructure(outputFile, rootDir string, filters []string, ignoreDirs []string, maxSize int64, includeHidden bool) error {
// Convert relative path to absolute
rootDirAbs, err := filepath.Abs(rootDir)
if err != nil {
return fmt.Errorf("failed to get absolute path for root directory: %w", err)
}

// Check the existence of the root directory BEFORE creating the output file
info, err := os.Stat(rootDirAbs)
if os.IsNotExist(err) {
return fmt.Errorf("root directory does not exist: %s", rootDirAbs)
}
if err != nil {
return fmt.Errorf("error checking root directory: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("root path is not a directory: %s", rootDirAbs)
}

// Get absolute path of output file
outputFileAbs, err := filepath.Abs(outputFile)
if err != nil {
return fmt.Errorf("failed to get absolute path for output file: %w", err)
}

// Create or truncate the output file
f, err := os.Create(outputFile)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer f.Close()

// Default ignore directories
defaultIgnore := []string{".git", "node_modules"}
if len(ignoreDirs) == 0 {
ignoreDirs = defaultIgnore
} else {
// Add default ignore directories to user-provided ones
ignoreDirs = append(ignoreDirs, defaultIgnore...)
}

// Walk through the directory
return filepath.WalkDir(rootDirAbs, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}

// Skip the output file itself to avoid recursion
pathAbs, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to get absolute path for %s: %w", path, err)
}
if filepath.Clean(pathAbs) == filepath.Clean(outputFileAbs) {
return nil
}

// Check if directory should be ignored
if d.IsDir() {
dirName := d.Name()

// Check for hidden directories (unless includeHidden is true)
if !includeHidden && strings.HasPrefix(dirName, ".") {
return fs.SkipDir
}

for _, ignore := range ignoreDirs {
if dirName == ignore {
return fs.SkipDir
}
}
}

// Process only files
if !d.IsDir() {
fileName := d.Name()

// Check for hidden files (unless includeHidden is true)
if !includeHidden && strings.HasPrefix(fileName, ".") {
return nil
}

// Check file size
info, err := d.Info()
if err != nil {
return fmt.Errorf("failed to get file info for %s: %w", path, err)
}

if maxSize > 0 && info.Size() > maxSize {
return nil
}

// Apply filters if specified
if len(filters) > 0 {
ext := strings.TrimPrefix(filepath.Ext(d.Name()), ".")
matched := false
for _, filter := range filters {
if strings.EqualFold(ext, filter) {
matched = true
break
}
}
if !matched {
return nil
}
}

// Get relative path
relPath, err := filepath.Rel(rootDirAbs, path)
if err != nil {
return fmt.Errorf("failed to get relative path for %s: %w", path, err)
}

// Replacing backslashes with straight ones
relPath = filepath.ToSlash(relPath)

// Read file content
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read file %s: %w", path, err)
}

// Write header and content
if _, err := fmt.Fprintf(f, "# %s\n", relPath); err != nil {
return err
}
if _, err := f.Write(content); err != nil {
return err
}
if _, err := f.WriteString("\n\n"); err != nil {
return err
}
}
return nil
})
}
Loading
Loading