v0.1.4 #15
+54
-108
@@ -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 {
|
||||
|
||||
+23
-63
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user