diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index d3be2be..970c2f6 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -52,11 +52,15 @@ func Write(result *walker.Result, outputPath string, appendMode bool, 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) sb.WriteString("## Directory Tree\n\n") sb.WriteString(fenceChars + "\n") - sb.WriteString(renderTreeForLLM(root, rootName)) + sb.WriteString(renderTreeWithLegend(treeRoot, rootName)) sb.WriteString(fenceChars + "\n\n") } @@ -117,13 +121,9 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e } } - 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); err != nil { - return nil, fmt.Errorf("failed to write root slice: %w", err) + treeRoot, err := tree.BuildFullTree(absRoot, result.Cfg) + if err != nil { + return nil, fmt.Errorf("cannot build full tree: %w", err) } var dirNames []string @@ -132,6 +132,21 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e } 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 { sliceName := strings.TrimSuffix(dirName, "/") + "_" + repoName + ".md" sliceDir := filepath.Join(outputDir, dirName) @@ -144,17 +159,7 @@ func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, e absDir := filepath.Join(absRoot, dirName) dirEntries := topLevelDirs[dirName] - treeEntries := make([]walker.Entry, len(dirEntries)) - 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 { + if err := writeDirSlice(slicePath, absDir, absRoot, dirName, dirEntries, indexBasename, result.OmitTest, result.Commit, treeRoot); err != nil { 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 } -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) if err != nil { 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") } - treeLines := buildTreeWithRefs(rootName, repoName, topLevelDirs, rootFiles) sb.WriteString("## Directory Tree\n\n") sb.WriteString(fenceChars + "\n") - sb.WriteString(treeLines) + sb.WriteString(renderTreeWithLegend(treeRoot, rootName)) sb.WriteString(fenceChars + "\n\n") sb.WriteString("## Vertical Slices Index\n\n") @@ -238,7 +242,7 @@ func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker 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) if err != nil { 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(fenceChars + "\n") - sliceTree := buildTreeFromEntries(dirName, treeEntries) - sb.WriteString(sliceTree) + sb.WriteString(renderTreeWithLegend(treeRoot, filepath.Base(absRoot))) sb.WriteString(fenceChars + "\n\n") if len(fileEntries) > 0 { @@ -287,93 +290,16 @@ func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntri return err } -func buildTreeWithRefs(rootName, repoName string, topLevelDirs map[string][]walker.Entry, rootFiles []walker.Entry) 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] - } - } - +func renderTreeWithLegend(root *tree.Node, rootName string) string { 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, "") + if hasAnyAnnotation(root) { + sb.WriteString("Legend: ~ ignored | ~ tests omitted | ~ omitted | ~ exceeded --max-depth\n") + } + return sb.String() } @@ -395,6 +321,14 @@ func renderNodeLLM(sb *strings.Builder, node *tree.Node, prefix string) { if child.IsDir { sb.WriteString("/") } + if child.Annotation != "" { + sb.WriteString(" ~ ") + sb.WriteString(child.Annotation) + } + if child.Ref != "" { + sb.WriteString(" -> see ") + sb.WriteString(child.Ref) + } sb.WriteString("\n") 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) { data, err := os.ReadFile(path) if err != nil { diff --git a/internal/tree/tree.go b/internal/tree/tree.go index df2b92a..3a8077d 100644 --- a/internal/tree/tree.go +++ b/internal/tree/tree.go @@ -1,76 +1,36 @@ package tree -import ( - "sort" - "strings" - - "git.flo.fo/FLO/dirmd/internal/walker" -) - type Node struct { - Name string - IsDir bool - Children map[string]*Node + Name string + IsDir bool + Children map[string]*Node + Annotation string + Ref string } -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)} +func splitPath(relPath string) []string { + var parts []string + start := 0 + for i := 0; i <= len(relPath); i++ { + if i == len(relPath) || relPath[i] == '/' || relPath[i] == '\\' { + if i > start { + parts = append(parts, relPath[start:i]) } - cur = cur.Children[part] + start = i + 1 } } - - return root + return parts } -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 + "├── ") +func addPath(root *Node, rel string, isDir bool) *Node { + parts := splitPath(rel) + cur := root + for i, part := range parts { + if _, ok := cur.Children[part]; !ok { + cur.Children[part] = &Node{Name: part, IsDir: i < len(parts)-1, Children: make(map[string]*Node)} } - - 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 = cur.Children[part] } + cur.IsDir = isDir + return cur } diff --git a/internal/tree/unfiltered.go b/internal/tree/unfiltered.go new file mode 100644 index 0000000..255f4ce --- /dev/null +++ b/internal/tree/unfiltered.go @@ -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", +} diff --git a/internal/walker/walker.go b/internal/walker/walker.go index c44e8f9..af4c0a9 100644 --- a/internal/walker/walker.go +++ b/internal/walker/walker.go @@ -23,6 +23,7 @@ type Result struct { AbsRoot string SingleFile bool OmitTest bool + Cfg *config.Config Commit *git.CommitInfo Instructions string } @@ -67,7 +68,7 @@ func processSingleFile(cfg *config.Config) (*Result, error) { Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}}, AbsRoot: filepath.Dir(cfg.AbsRoot), SingleFile: true, - OmitTest: cfg.OmitTest, + Cfg: cfg, Commit: commitInfo, Instructions: instContent, }, nil @@ -83,10 +84,10 @@ func walkDirectory(cfg *config.Config) (*Result, error) { 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) - + if cfg.IncludeInstructions { instContent, err := instructions.Read(cfg.AbsRoot) if err != nil {