v0.1.4 #15

Merged
bartal-lsn merged 9 commits from v-1-4 into main 2026-09-15 13:12:50 +01:00
4 changed files with 252 additions and 175 deletions
Showing only changes of commit 4396a6f619 - Show all commits
+54 -108
View File
@@ -52,11 +52,15 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
} }
if !singleFile { if !singleFile {
root := tree.Build(result.Entries) treeRoot, err := tree.BuildFullTree(result.AbsRoot, result.Cfg)
if err != nil {
return fmt.Errorf("cannot build tree: %w", err)
}
rootName := filepath.Base(result.AbsRoot) rootName := filepath.Base(result.AbsRoot)
sb.WriteString("## Directory Tree\n\n") sb.WriteString("## Directory Tree\n\n")
sb.WriteString(fenceChars + "\n") sb.WriteString(fenceChars + "\n")
sb.WriteString(renderTreeForLLM(root, rootName)) sb.WriteString(renderTreeWithLegend(treeRoot, rootName))
sb.WriteString(fenceChars + "\n\n") sb.WriteString(fenceChars + "\n\n")
} }
@@ -117,13 +121,9 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
} }
} }
var generated []string treeRoot, err := tree.BuildFullTree(absRoot, result.Cfg)
indexBasename := "index_" + repoName + ".md" if err != nil {
rootPath := filepath.Join(outputDir, indexBasename) return nil, fmt.Errorf("cannot build full tree: %w", err)
generated = append(generated, rootPath)
if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs, result.OmitTest, result.Commit, result.Instructions); err != nil {
return nil, fmt.Errorf("failed to write root slice: %w", err)
} }
var dirNames []string var dirNames []string
@@ -132,6 +132,21 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e
} }
sort.Strings(dirNames) sort.Strings(dirNames)
for _, dirName := range dirNames {
if node, ok := treeRoot.Children[dirName]; ok {
node.Ref = filepath.Join(dirName, dirName+"_"+repoName+".md")
}
}
var generated []string
indexBasename := "index_" + repoName + ".md"
rootPath := filepath.Join(outputDir, indexBasename)
generated = append(generated, rootPath)
if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs, result.OmitTest, result.Commit, result.Instructions, treeRoot); err != nil {
return nil, fmt.Errorf("failed to write root slice: %w", err)
}
for _, dirName := range dirNames { for _, dirName := range dirNames {
sliceName := strings.TrimSuffix(dirName, "/") + "_" + repoName + ".md" sliceName := strings.TrimSuffix(dirName, "/") + "_" + repoName + ".md"
sliceDir := filepath.Join(outputDir, dirName) sliceDir := filepath.Join(outputDir, dirName)
@@ -144,17 +159,7 @@ 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]
treeEntries := make([]walker.Entry, len(dirEntries)) if err := writeDirSlice(slicePath, absDir, absRoot, dirName, dirEntries, indexBasename, result.OmitTest, result.Commit, treeRoot); err != nil {
for i, e := range dirEntries {
sepIdx := strings.Index(e.RelPath, string(filepath.Separator))
if sepIdx >= 0 {
treeEntries[i] = walker.Entry{RelPath: e.RelPath[sepIdx+1:], Size: e.Size}
} else {
treeEntries[i] = e
}
}
if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, indexBasename, result.OmitTest, result.Commit); 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)
} }
} }
@@ -162,7 +167,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, omitTest bool, commit *git.CommitInfo, instContent string) error { func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, omitTest bool, commit *git.CommitInfo, instContent string, treeRoot *tree.Node) 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)
@@ -179,10 +184,9 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker
sb.WriteString("\n") sb.WriteString("\n")
} }
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(renderTreeWithLegend(treeRoot, rootName))
sb.WriteString(fenceChars + "\n\n") sb.WriteString(fenceChars + "\n\n")
sb.WriteString("## Vertical Slices Index\n\n") sb.WriteString("## Vertical Slices Index\n\n")
@@ -238,7 +242,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, omitTest bool, commit *git.CommitInfo) error { func writeDirSlice(path, absDir, absRoot, dirName string, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo, treeRoot *tree.Node) 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)
@@ -254,8 +258,7 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri
sb.WriteString("## Directory Tree\n\n") sb.WriteString("## Directory Tree\n\n")
sb.WriteString(fenceChars + "\n") sb.WriteString(fenceChars + "\n")
sliceTree := buildTreeFromEntries(dirName, treeEntries) sb.WriteString(renderTreeWithLegend(treeRoot, filepath.Base(absRoot)))
sb.WriteString(sliceTree)
sb.WriteString(fenceChars + "\n\n") sb.WriteString(fenceChars + "\n\n")
if len(fileEntries) > 0 { if len(fileEntries) > 0 {
@@ -287,93 +290,16 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri
return err return err
} }
func buildTreeWithRefs(rootName, repoName string, topLevelDirs map[string][]walker.Entry, rootFiles []walker.Entry) string { func renderTreeWithLegend(root *tree.Node, rootName string) string {
var sb strings.Builder
sb.WriteString(rootName)
sb.WriteString("/\n")
var dirNames []string
for k := range topLevelDirs {
dirNames = append(dirNames, k)
}
sort.Strings(dirNames)
sort.Slice(rootFiles, func(i, j int) bool {
return rootFiles[i].RelPath < rootFiles[j].RelPath
})
type treeItem struct {
name string
isDir bool
sliceRef string
}
items := make([]treeItem, 0, len(dirNames)+len(rootFiles))
for _, d := range dirNames {
sliceRef := filepath.Join(d, strings.TrimSuffix(d, "/")+"_"+repoName+".md")
items = append(items, treeItem{
name: d,
isDir: true,
sliceRef: sliceRef,
})
}
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 var sb strings.Builder
sb.WriteString(rootName) sb.WriteString(rootName)
sb.WriteString("/\n") sb.WriteString("/\n")
renderNodeLLM(&sb, root, "") renderNodeLLM(&sb, root, "")
return sb.String()
}
func renderTreeForLLM(root *tree.Node, rootName string) string { if hasAnyAnnotation(root) {
var sb strings.Builder sb.WriteString("Legend: ~ ignored | ~ tests omitted | ~ omitted | ~ exceeded --max-depth\n")
sb.WriteString(rootName) }
sb.WriteString("/\n")
renderNodeLLM(&sb, root, "")
return sb.String() return sb.String()
} }
@@ -395,6 +321,14 @@ func renderNodeLLM(sb *strings.Builder, node *tree.Node, prefix string) {
if child.IsDir { if child.IsDir {
sb.WriteString("/") sb.WriteString("/")
} }
if child.Annotation != "" {
sb.WriteString(" ~ ")
sb.WriteString(child.Annotation)
}
if child.Ref != "" {
sb.WriteString(" -> see ")
sb.WriteString(child.Ref)
}
sb.WriteString("\n") sb.WriteString("\n")
if child.IsDir && child.Children != nil { if child.IsDir && child.Children != nil {
@@ -409,6 +343,18 @@ func renderNodeLLM(sb *strings.Builder, node *tree.Node, prefix string) {
} }
} }
func hasAnyAnnotation(node *tree.Node) bool {
if node.Annotation != "" {
return true
}
for _, child := range node.Children {
if hasAnyAnnotation(child) {
return true
}
}
return false
}
func readFile(path string) (string, error) { func readFile(path string) (string, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
+23 -63
View File
@@ -1,76 +1,36 @@
package tree package tree
import (
"sort"
"strings"
"git.flo.fo/FLO/dirmd/internal/walker"
)
type Node struct { type Node struct {
Name string Name string
IsDir bool IsDir bool
Children map[string]*Node Children map[string]*Node
Annotation string
Ref string
} }
func Build(entries []walker.Entry) *Node { func splitPath(relPath string) []string {
root := &Node{IsDir: true, Children: make(map[string]*Node)} var parts []string
start := 0
for _, e := range entries { for i := 0; i <= len(relPath); i++ {
parts := strings.Split(e.RelPath, "/") if i == len(relPath) || relPath[i] == '/' || relPath[i] == '\\' {
cur := root if i > start {
parts = append(parts, relPath[start:i])
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] start = i + 1
} }
} }
return parts
return root
} }
func Render(root *Node, rootName string) string { func addPath(root *Node, rel string, isDir bool) *Node {
var sb strings.Builder parts := splitPath(rel)
sb.WriteString(rootName) cur := root
sb.WriteString("/\n") for i, part := range parts {
renderNode(&sb, root, "") if _, ok := cur.Children[part]; !ok {
return sb.String() cur.Children[part] = &Node{Name: part, IsDir: i < len(parts)-1, Children: make(map[string]*Node)}
}
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 + "├── ")
} }
cur = cur.Children[part]
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)
}
} }
cur.IsDir = isDir
return cur
} }
+170
View File
@@ -0,0 +1,170 @@
package tree
import (
"fmt"
"io/fs"
"path/filepath"
"strings"
"git.flo.fo/FLO/dirmd/internal/config"
"git.flo.fo/FLO/dirmd/internal/filter"
)
func BuildFullTree(absRoot string, cfg *config.Config) (*Node, error) {
root := &Node{IsDir: true, Children: make(map[string]*Node)}
err := filepath.WalkDir(absRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
rel, rerr := filepath.Rel(absRoot, path)
if rerr != nil || rel == "." {
return nil
}
if filter.IsSymlink(path) {
return nil
}
depth := strings.Count(rel, string(filepath.Separator)) + 1
if depth > cfg.MaxDepth {
node := addPath(root, rel, d.IsDir())
node.Annotation = "exceeded --max-depth"
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
name := filepath.Base(path)
if d.IsDir() {
if isIgnoredName(name, cfg.Ignores) {
node := addPath(root, rel, true)
node.Annotation = "ignored (--ignore)"
return filepath.SkipDir
}
if cfg.OmitTest && isTestDir(rel) {
node := addPath(root, rel, true)
node.Annotation = "tests omitted (--omit-test)"
return filepath.SkipDir
}
addPath(root, rel, true)
return nil
}
if annotation, ok := fileAnnotation(rel, name, cfg); ok {
node := addPath(root, rel, false)
node.Annotation = annotation
return nil
}
addPath(root, rel, false)
return nil
})
if err != nil {
return nil, fmt.Errorf("cannot walk %s: %w", absRoot, err)
}
return root, nil
}
func isIgnoredName(name string, ignores []string) bool {
if strings.HasPrefix(name, ".") {
return true
}
for _, pattern := range ignores {
if matched, _ := filepath.Match(pattern, name); matched {
return true
}
}
return false
}
func isTestDir(rel string) bool {
slashPath := filepath.ToSlash(rel)
return strings.Contains(slashPath, "/tests/") || strings.HasPrefix(slashPath, "tests/")
}
func fileAnnotation(rel, name string, cfg *config.Config) (string, bool) {
if strings.HasPrefix(name, ".") {
return "", false
}
for _, pattern := range cfg.Ignores {
if matched, _ := filepath.Match(pattern, name); matched {
return "ignored (--ignore)", true
}
}
if cfg.OmitTest && isTestFileName(rel, name) {
return "tests omitted (--omit-test)", true
}
if cfg.OmitMd && isNonReadmeMarkdown(name) {
return "omitted (--omit-md)", true
}
if cfg.FrontendOnly && !isFrontendOrReadme(rel, name) {
return "omitted (--frontend-only)", true
}
if cfg.SkipFrontend && isFrontendExt(rel) {
return "omitted (--skip-frontend)", true
}
return "", false
}
func isTestFileName(rel, name string) bool {
ext := strings.ToLower(filepath.Ext(name))
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(rel)
return strings.Contains(slashPath, "/src/test/")
case ".py":
lower := strings.ToLower(name)
if strings.HasPrefix(lower, "test_") || strings.HasSuffix(lower, "_test.py") {
return true
}
if strings.EqualFold(name, "conftest.py") {
return true
}
return isTestDir(rel)
}
return false
}
func isNonReadmeMarkdown(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
if ext != ".md" && ext != ".markdown" {
return false
}
return !strings.EqualFold(name, "README.md")
}
func isFrontendOrReadme(rel, name string) bool {
if isFrontendExt(rel) {
return true
}
return strings.EqualFold(name, "README.md")
}
func isFrontendExt(rel string) bool {
ext := strings.ToLower(filepath.Ext(rel))
for _, fe := range frontendExts {
if strings.EqualFold(ext, fe) {
return true
}
}
return false
}
var frontendExts = []string{
".html", ".htm", ".gohtml", ".tmpl",
".css", ".scss", ".sass", ".less",
".js", ".jsx", ".mjs", ".cjs",
".ts", ".tsx",
".vue", ".svelte",
}
+5 -4
View File
@@ -23,6 +23,7 @@ type Result struct {
AbsRoot string AbsRoot string
SingleFile bool SingleFile bool
OmitTest bool OmitTest bool
Cfg *config.Config
Commit *git.CommitInfo Commit *git.CommitInfo
Instructions string Instructions string
} }
@@ -67,7 +68,7 @@ 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, Cfg: cfg,
Commit: commitInfo, Commit: commitInfo,
Instructions: instContent, Instructions: instContent,
}, nil }, nil
@@ -83,10 +84,10 @@ func walkDirectory(cfg *config.Config) (*Result, error) {
OmitMd: cfg.OmitMd, OmitMd: cfg.OmitMd,
} }
result := &Result{AbsRoot: cfg.AbsRoot, OmitTest: cfg.OmitTest} result := &Result{AbsRoot: cfg.AbsRoot, Cfg: cfg, OmitTest: cfg.OmitTest}
result.Commit = git.GetCommitInfo(cfg.AbsRoot) result.Commit = git.GetCommitInfo(cfg.AbsRoot)
if cfg.IncludeInstructions { if cfg.IncludeInstructions {
instContent, err := instructions.Read(cfg.AbsRoot) instContent, err := instructions.Read(cfg.AbsRoot)
if err != nil { if err != nil {