commit f040f28325716c030399c2d9f36c89ffedc98f98 Author: Andy Dixon Date: Sat Jan 24 14:08:43 2026 +0000 Add source indexer Parallel file parsing, import linking, language/layer classification, and .gitignore/.ignore handling, with tests. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f649195 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/codenavigator diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..71f76d4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module codenavigator + +go 1.26.1 diff --git a/indexer.go b/indexer.go new file mode 100644 index 0000000..b93fa8e --- /dev/null +++ b/indexer.go @@ -0,0 +1,550 @@ +package main + +import ( + "bytes" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + "sync/atomic" + "unicode/utf8" +) + +type Symbol struct { + Name string `json:"name"` + Kind string `json:"kind"` + Line int `json:"line"` + Signature string `json:"signature"` +} + +type FileRecord struct { + ID uint32 `json:"id"` + Path string `json:"path"` + Name string `json:"name"` + Directory string `json:"directory"` + Extension string `json:"extension"` + Language string `json:"language"` + Layer string `json:"layer"` + Lines int `json:"lines"` + Bytes int `json:"bytes"` + Complexity int `json:"complexity"` + Preview string `json:"preview"` + Symbols []Symbol `json:"symbols"` + Imports []string `json:"imports"` +} + +type Edge struct { + From uint32 `json:"from"` + To uint32 `json:"to"` + Kind string `json:"kind"` + Confidence string `json:"confidence"` +} + +type Snapshot struct { + ID, Name, Source string + Files []FileRecord + Edges []Edge + TotalLines int + Definitions int + References int + Warnings []string + Root string + delivered map[uint32][][2]int + released map[uint32]bool +} + +type definitionPattern struct { + kind string + re *regexp.Regexp +} + +var definitionPatterns = []definitionPattern{ + {"class", regexp.MustCompile(`(?:class|struct|enum|trait|interface|record)\s+([A-Za-z_][A-Za-z0-9_]*)`)}, + {"function", regexp.MustCompile(`(?:fn|func|def|function)\s+([A-Za-z_][A-Za-z0-9_]*)`)}, + {"function", regexp.MustCompile(`(?:public|private|protected|static|async|export|const|let|var|unsafe|pub|virtual|override|inline|final|synchronized|abstract|extern|\s)+\s*([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\)\s*(?:\{|=>|throws)`)}, +} + +var importPatterns = []*regexp.Regexp{ + regexp.MustCompile(`from\s+["']?([^"';\s]+)`), + regexp.MustCompile(`import\s*["']([^"']+)`), + regexp.MustCompile(`^\s*import\s+([A-Za-z_][A-Za-z0-9_.]*)\s*(?:;|$|\bas\b)`), + regexp.MustCompile(`import\s*\(\s*["']([^"']+)`), + regexp.MustCompile(`require\s*\(\s*["']([^"']+)`), + regexp.MustCompile(`#include\s*[<"]([^>"]+)`), + regexp.MustCompile(`(?:use|mod)\s+([A-Za-z_][A-Za-z0-9_:]*)`), + regexp.MustCompile(`using\s+([A-Za-z_][A-Za-z0-9_.]*)`), +} + +var complexityTokens = []string{" if ", " for ", " while ", " match ", " switch ", "&&", "||"} + +const previewBudget = 48 * 1024 * 1024 + +// indexDirectory walks root and parses files in parallel; IDs follow walk order. +func indexDirectory(root string, progress func(phase string, completed, total int, message string)) (*Snapshot, error) { + paths, err := walkSource(root) + if err != nil { + return nil, err + } + if len(paths) == 0 { + return nil, fmt.Errorf("No readable files were found") + } + + total := len(paths) + records := make([]*FileRecord, total) + warnings := make([]string, total) + var next, done atomic.Int64 + var wg sync.WaitGroup + for range runtime.GOMAXPROCS(0) { + wg.Go(func() { + for { + i := int(next.Add(1) - 1) + if i >= total { + return + } + records[i], warnings[i] = parseFile(root, paths[i]) + if n := done.Add(1); n%20 == 1 { + progress("parsing", int(n-1), total, fmt.Sprintf("Parsing %d of %d", n, total)) + } + } + }) + } + wg.Wait() + + files := make([]FileRecord, 0, total) + snapshot := &Snapshot{Root: root, Warnings: []string{}, delivered: map[uint32][][2]int{}, released: map[uint32]bool{}} + for i, record := range records { + if warnings[i] != "" { + snapshot.Warnings = append(snapshot.Warnings, warnings[i]) + } + if record != nil { + record.ID = uint32(len(files) + 1) + files = append(files, *record) + } + } + progress("linking", len(files), len(files), "Linking files and symbols…") + + previewBytes := 0 + for i := range files { + previewBytes += len(files[i].Preview) + } + if previewBytes > previewBudget { + perFile := max(previewBudget/max(len(files), 1), 32) + for i := range files { + if p := files[i].Preview; len(p) > perFile { + end := perFile + for end > 0 && !utf8.RuneStart(p[end]) { + end-- + } + files[i].Preview = p[:end] + } + } + } + + snapshot.Edges = linkFiles(files) + // Imports are only an indexing intermediate; edges carry what the frontend needs. + for i := range files { + files[i].Imports = []string{} + snapshot.TotalLines += files[i].Lines + snapshot.Definitions += len(files[i].Symbols) + } + snapshot.Files = files + snapshot.References = len(snapshot.Edges) + return snapshot, nil +} + +// parseFile returns nil for skipped files (binary or >8MB) and a warning for unreadable ones. +func parseFile(root, path string) (*FileRecord, string) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Sprintf("%s: %v", path, err) + } + if len(data) > 8*1024*1024 || bytes.IndexByte(data[:min(len(data), 4096)], 0) >= 0 { + return nil, "" + } + text := string(data) + if !utf8.ValidString(text) { + text = strings.ToValidUTF8(text, "�") + } + relative, err := filepath.Rel(root, path) + if err != nil { + relative = path + } + relative = filepath.ToSlash(relative) + name := filepath.Base(path) + extension := strings.ToLower(extensionOf(name)) + + record := &FileRecord{ + Path: relative, + Name: name, + Extension: extension, + Language: languageFor(extension), + Layer: layerFor(relative), + Bytes: len(data), + Complexity: 1, + Symbols: []Symbol{}, + } + if i := strings.LastIndexByte(relative, '/'); i >= 0 { + record.Directory = relative[:i] + } + + preview := make([]string, 0, 60) + for len(text) > 0 { + line, rest, found := strings.Cut(text, "\n") + if found { + line = strings.TrimSuffix(line, "\r") + } + text = rest + record.Lines++ + if len(preview) < 60 { + preview = append(preview, line) + } + for _, token := range complexityTokens { + if strings.Contains(line, token) { + record.Complexity++ + } + } + if mayDefine(line) { + for _, pattern := range definitionPatterns { + if m := pattern.re.FindStringSubmatchIndex(line); m != nil && m[2] >= 0 { + record.Symbols = append(record.Symbols, Symbol{strings.Clone(line[m[2]:m[3]]), pattern.kind, record.Lines, strings.Clone(truncateRunes(strings.TrimSpace(line), 180))}) + break + } + } + } + if mayImport(line) { + for _, re := range importPatterns { + if m := re.FindStringSubmatchIndex(line); m != nil && m[2] >= 0 { + record.Imports = append(record.Imports, strings.Clone(line[m[2]:m[3]])) + } + } + } + } + record.Lines = max(record.Lines, 1) + // Enough source for zoomed-in tiles without copying whole repositories into the scene. + record.Preview = strings.Clone(strings.Join(preview, "\n")) // Join aliases a lone line + return record, "" +} + +func mayDefine(line string) bool { + return strings.Contains(line, "(") || strings.Contains(line, "class ") || strings.Contains(line, "struct ") || + strings.Contains(line, "enum ") || strings.Contains(line, "interface ") || strings.Contains(line, "fn ") || + strings.Contains(line, "func ") || strings.Contains(line, "def ") || strings.Contains(line, "function ") +} + +func mayImport(line string) bool { + return strings.Contains(line, "import") || strings.Contains(line, "from ") || strings.Contains(line, "require") || + strings.Contains(line, "#include") || strings.Contains(line, "use ") || strings.Contains(line, "mod ") || + strings.Contains(line, "using ") +} + +func truncateRunes(s string, n int) string { + for i := range s { + if n == 0 { + return s[:i] + } + n-- + } + return s +} + +// extensionOf mirrors Rust's Path::extension: dotfiles like ".gitignore" have none. +func extensionOf(name string) string { + if i := strings.LastIndexByte(name, '.'); i > 0 { + return name[i+1:] + } + return "" +} + +var skippedNames = map[string]bool{".git": true, "target": true, "node_modules": true, ".next": true, "dist": true, "build": true} + +// walkSource lists regular files in lexical order, honouring .ignore files everywhere +// and .gitignore files inside git checkouts. +func walkSource(root string) ([]string, error) { + var paths []string + var rules []ignoreRule + inGit := false + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // unreadable entries are skipped, like the ignore crate's walker + } + rel, _ := filepath.Rel(root, path) + rel = filepath.ToSlash(rel) + if path != root { + if skippedNames[d.Name()] || ignored(rules, rel, d.IsDir()) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + } + if !d.IsDir() { + if d.Type().IsRegular() { + paths = append(paths, path) + } + return nil + } + if _, err := os.Stat(filepath.Join(path, ".git")); err == nil { + inGit = true + } + base := "" + if path != root { + base = rel + "/" + } + if inGit { + rules = append(rules, loadIgnoreFile(filepath.Join(path, ".gitignore"), base)...) + } + rules = append(rules, loadIgnoreFile(filepath.Join(path, ".ignore"), base)...) + return nil + }) + return paths, err +} + +type ignoreRule struct { + re *regexp.Regexp + negate bool + dirOnly bool + base string // slash-terminated directory the rule file lives in, "" for root + anyDir bool // pattern without a slash matches the basename at any depth +} + +// ponytail: covers standard gitignore globs (*, ?, **, [], !, trailing /); no global or .git/info/exclude files. +func loadIgnoreFile(path, base string) []ignoreRule { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var rules []ignoreRule + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimRight(strings.TrimSuffix(line, "\r"), " ") + if line == "" || line[0] == '#' { + continue + } + rule := ignoreRule{base: base} + if line[0] == '!' { + rule.negate, line = true, line[1:] + } else if line[0] == '\\' { + line = line[1:] + } + if strings.HasSuffix(line, "/") { + rule.dirOnly, line = true, strings.TrimSuffix(line, "/") + } + rule.anyDir = !strings.Contains(line, "/") + line = strings.TrimPrefix(line, "/") + if line == "" { + continue + } + if re, err := regexp.Compile("^" + globToRegexp(line) + "$"); err == nil { + rule.re = re + rules = append(rules, rule) + } + } + return rules +} + +func globToRegexp(glob string) string { + var b strings.Builder + for i := 0; i < len(glob); i++ { + switch c := glob[i]; { + case strings.HasPrefix(glob[i:], "**/"): + b.WriteString("(?:.*/)?") + i += 2 + case strings.HasPrefix(glob[i:], "**"): + b.WriteString(".*") + i++ + case c == '*': + b.WriteString("[^/]*") + case c == '?': + b.WriteString("[^/]") + case c == '[': + if end := strings.IndexByte(glob[i+1:], ']'); end > 0 { + class := glob[i+1 : i+1+end] + if class[0] == '!' { + class = "^" + class[1:] + } + b.WriteString("[" + strings.ReplaceAll(class, `\`, `\\`) + "]") + i += end + 1 + } else { + b.WriteString(`\[`) + } + case c == '\\' && i+1 < len(glob): + i++ + b.WriteString(regexp.QuoteMeta(glob[i : i+1])) + default: + b.WriteString(regexp.QuoteMeta(glob[i : i+1])) + } + } + return b.String() +} + +// ignored applies rules in order; later (deeper) rules override earlier ones. +func ignored(rules []ignoreRule, rel string, isDir bool) bool { + result := false + for _, rule := range rules { + if (rule.dirOnly && !isDir) || !strings.HasPrefix(rel, rule.base) { + continue + } + target := rel[len(rule.base):] + if rule.anyDir { + target = target[strings.LastIndexByte(target, '/')+1:] + } + if rule.re.MatchString(target) { + result = !rule.negate + } + } + return result +} + +func linkFiles(files []FileRecord) []Edge { + exact := make(map[string]uint32, len(files)) + lookup := make(map[string]uint32, len(files)*4) // 0 marks an ambiguous key + add := func(key string, id uint32) { + if old, ok := lookup[key]; ok && old != id { + lookup[key] = 0 + } else if !ok { + lookup[key] = id + } + } + for i := range files { + file := &files[i] + path := strings.ReplaceAll(file.Path, `\`, "/") + ext := extensionOf(file.Name) + extensionless := path + stem := file.Name + if ext != "" { + extensionless = path[:len(path)-len(ext)-1] + stem = file.Name[:len(file.Name)-len(ext)-1] + } + exact[path] = file.ID + add(path, file.ID) + add(file.Name, file.ID) + add(extensionless, file.ID) + if dir, ok := strings.CutSuffix(extensionless, "/index"); ok { + add(dir, file.ID) + } + for j := range len(path) { + if path[j] == '/' { + add(path[j+1:], file.ID) + } + } + for j := range len(extensionless) { + if extensionless[j] == '/' { + add(extensionless[j+1:], file.ID) + } + } + if stem != "" { + add(stem, file.ID) + } + } + resolve := func(key string) uint32 { + if id, ok := exact[key]; ok { + return id + } + return lookup[key] + } + + type pair struct{ from, to uint32 } + seen := map[pair]bool{} + edges := []Edge{} + for i := range files { + file := &files[i] + for _, imp := range file.Imports { + imp = strings.ReplaceAll(imp, `\`, "/") + relativeImport := strings.HasPrefix(imp, ".") + direct := resolve(normalizePath(file.Directory + "/" + imp)) + if direct == 0 && !relativeImport { + direct = resolve(normalizePath(imp)) + } + target := direct + if target == 0 && !relativeImport { + module := strings.ReplaceAll(imp, "::", "/") + if file.Language == "Python" || file.Language == "Java" || file.Language == "C#" { + module = strings.ReplaceAll(module, ".", "/") + } + if target = resolve(module); target == 0 { + target = lookup[module[strings.LastIndexByte(module, '/')+1:]] + } + } + if target != 0 && target != file.ID && !seen[pair{file.ID, target}] { + seen[pair{file.ID, target}] = true + confidence := "inferred" + if direct != 0 { + confidence = "known" + } + edges = append(edges, Edge{file.ID, target, "import", confidence}) + } + } + } + return edges +} + +func normalizePath(path string) string { + parts := make([]string, 0, 8) + for part := range strings.SplitSeq(path, "/") { + switch part { + case "", ".": + case "..": + if len(parts) > 0 { + parts = parts[:len(parts)-1] + } + default: + parts = append(parts, part) + } + } + return strings.Join(parts, "/") +} + +var languages = map[string]string{} + +func init() { + for language, extensions := range map[string]string{ + "JavaScript": "js jsx mjs cjs", "TypeScript": "ts tsx", "Python": "py pyi", "Rust": "rs", "Go": "go", + "C": "c h", "C++": "cc cpp cxx hpp hh", "Java": "java", "C#": "cs", "F#": "fs fsx", "Kotlin": "kt kts", + "Swift": "swift", "Scala": "scala sc", "Dart": "dart", "PHP": "php", "Ruby": "rb", "R": "r", "Perl": "pl pm", + "Lua": "lua", "SQL": "sql", "Elixir": "ex exs", "Erlang": "erl hrl", "Haskell": "hs lhs", "OCaml": "ml mli", + "Clojure": "clj cljs cljc edn", "Zig": "zig", "Nim": "nim", "Solidity": "sol", "Groovy": "groovy gradle", + "HTML": "html htm", "Markup": "xml svg vue svelte", "CSS": "css scss sass less", + "Data": "json jsonc toml yaml yml ini properties", "Docs": "md mdx txt rst", "Shell": "sh zsh bash fish", + } { + for ext := range strings.FieldsSeq(extensions) { + languages[ext] = language + } + } +} + +func languageFor(extension string) string { + if language, ok := languages[extension]; ok { + return language + } + return "Other" +} + +func layerFor(path string) string { + lower := strings.ToLower(path) + has := func(subs ...string) bool { + for _, s := range subs { + if strings.Contains(lower, s) { + return true + } + } + return false + } + switch { + case has("test", "spec", "fixture"): + return "test" + case has("vendor", "third_party", "external"): + return "vendor" + case has("generated", "gen/") || strings.HasSuffix(lower, ".min.js"): + return "generated" + case has("platform", "windows", "linux", "darwin"): + return "platform" + case has("lib/", "libs/", "packages/"): + return "library" + case has("docs/") || strings.HasSuffix(lower, ".md"): + return "docs" + } + return "application" +} diff --git a/indexer_test.go b/indexer_test.go new file mode 100644 index 0000000..cfd5d2e --- /dev/null +++ b/indexer_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func record(id uint32, path string, imports ...string) FileRecord { + dir, name := "", path + if i := strings.LastIndexByte(path, '/'); i >= 0 { + dir, name = path[:i], path[i+1:] + } + return FileRecord{ID: id, Path: path, Name: name, Directory: dir, Language: "JavaScript", Imports: imports} +} + +func edgePairs(edges []Edge) [][2]uint32 { + pairs := [][2]uint32{} + for _, e := range edges { + pairs = append(pairs, [2]uint32{e.From, e.To}) + } + return pairs +} + +func TestResolvesRelativeImportsIncludesAndIndexFiles(t *testing.T) { + edges := linkFiles([]FileRecord{ + record(1, "src/main.js", "./utils.js", "../include/config.h", "./widgets", "./utils.js"), + record(2, "src/utils.js"), + record(3, "include/config.h"), + record(4, "src/widgets/index.ts"), + record(5, "elsewhere/utils.js"), + }) + if got, want := edgePairs(edges), [][2]uint32{{1, 2}, {1, 3}, {1, 4}}; !reflect.DeepEqual(got, want) { + t.Fatalf("edges = %v, want %v", got, want) + } + if edges[0].Confidence != "known" { + t.Fatalf("relative import confidence = %q", edges[0].Confidence) + } +} + +func TestResolvesHeaderSuffixWithoutGuessingAmbiguousNames(t *testing.T) { + edges := linkFiles([]FileRecord{ + record(1, "main.c", "config.h", "utils.js", "missing.js"), + record(2, "include/config.h"), + record(3, "a/utils.js"), + record(4, "b/utils.js"), + }) + if got, want := edgePairs(edges), [][2]uint32{{1, 2}}; !reflect.DeepEqual(got, want) { + t.Fatalf("edges = %v, want %v", got, want) + } +} + +func TestResolvesDottedModulesAsInferred(t *testing.T) { + a := record(1, "app/main.py", "app.models.user") + a.Language = "Python" + edges := linkFiles([]FileRecord{a, record(2, "app/models/user.py")}) + if len(edges) != 1 || edges[0].To != 2 || edges[0].Confidence != "inferred" { + t.Fatalf("edges = %+v", edges) + } +} + +func TestExtractsNamedJavaScriptImportTarget(t *testing.T) { + var imports []string + for _, re := range importPatterns { + if m := re.FindStringSubmatch("import { render } from './renderer.js';"); m != nil { + imports = append(imports, m[1]) + } + } + if !reflect.DeepEqual(imports, []string{"./renderer.js"}) { + t.Fatalf("imports = %v", imports) + } +} + +func TestClassifiesLanguagesAndLayers(t *testing.T) { + for in, want := range map[string]string{"rs": "Rust", "tsx": "TypeScript", "cpp": "C++", "zzz": "Other"} { + if got := languageFor(in); got != want { + t.Errorf("languageFor(%q) = %q, want %q", in, got, want) + } + } + for in, want := range map[string]string{"src/widget.test.ts": "test", "vendor/lib.c": "vendor", "a/gen/x.go": "generated", "README.md": "docs", "src/main.go": "application"} { + if got := layerFor(in); got != want { + t.Errorf("layerFor(%q) = %q, want %q", in, got, want) + } + } +} + +func TestExtractsCommonSymbols(t *testing.T) { + for _, line := range []string{"pub fn render_scene() {", "class WindowManager {", "public void run() throws IOException"} { + matched := false + for _, p := range definitionPatterns { + matched = matched || p.re.MatchString(line) + } + if !matched { + t.Errorf("no definition pattern matched %q", line) + } + } +} + +func TestExtensionOf(t *testing.T) { + for in, want := range map[string]string{"a.rs": "rs", ".gitignore": "", "a.tar.gz": "gz", "Makefile": ""} { + if got := extensionOf(in); got != want { + t.Errorf("extensionOf(%q) = %q, want %q", in, got, want) + } + } +} + +func TestIgnoreRules(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("# c\n*.log\n/out/\n!keep.log\ndocs/**/*.tmp\n"), 0o644) + rules := loadIgnoreFile(filepath.Join(dir, ".gitignore"), "") + cases := []struct { + path string + isDir bool + want bool + }{ + {"a.log", false, true}, {"deep/b.log", false, true}, {"keep.log", false, false}, + {"out", true, true}, {"src/out", true, false}, {"out", false, false}, + {"docs/x/y/z.tmp", false, true}, {"docs/z.tmp", false, true}, {"z.tmp", false, false}, + } + for _, c := range cases { + if got := ignored(rules, c.path, c.isDir); got != c.want { + t.Errorf("ignored(%q, %v) = %v, want %v", c.path, c.isDir, got, c.want) + } + } +} + +func writeTree(t *testing.T, files map[string]string) string { + t.Helper() + root := t.TempDir() + for path, content := range files { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +func TestIndexDirectory(t *testing.T) { + root := writeTree(t, map[string]string{ + "src/main.js": "import { util } from './util.js';\r\nfunction main() {\n if (a && b) { util(); }\n}\n", + "src/util.js": "export function util() {\n}\n", + "node_modules/x.js": "skipped", + "build": "skipped file named build", + "image.bin": "\x00\x01binary", + "ignored.log": "ignored by .ignore", + ".ignore": "*.log\n", + "sub/.gitignore": "*.js\n", // not a git checkout, so this is not honoured + "sub/kept.js": "x\xffy", + ".hidden/visible.txt": "hidden dirs are walked", + }) + var phases []string + snapshot, err := indexDirectory(root, func(phase string, _, _ int, _ string) { phases = append(phases, phase) }) + if err != nil { + t.Fatal(err) + } + var paths []string + for i, f := range snapshot.Files { + if f.ID != uint32(i+1) { + t.Errorf("file %s has id %d, want %d", f.Path, f.ID, i+1) + } + paths = append(paths, f.Path) + } + want := []string{".hidden/visible.txt", ".ignore", "src/main.js", "src/util.js", "sub/.gitignore", "sub/kept.js"} + if !reflect.DeepEqual(paths, want) { + t.Fatalf("paths = %v, want %v", paths, want) + } + main := snapshot.Files[2] + if main.Lines != 4 || main.Complexity != 3 || main.Language != "JavaScript" || main.Directory != "src" || main.Name != "main.js" { + t.Errorf("main.js record = %+v", main) + } + if !strings.HasPrefix(main.Preview, "import { util } from './util.js';\nfunction main() {") { + t.Errorf("preview = %q", main.Preview) + } + if len(main.Symbols) != 2 || main.Symbols[0].Name != "main" || main.Symbols[0].Line != 2 { + t.Errorf("symbols = %+v", main.Symbols) + } + if got := edgePairs(snapshot.Edges); !reflect.DeepEqual(got, [][2]uint32{{3, 4}}) { + t.Errorf("edges = %v", got) + } + if snapshot.Files[5].Preview != "x�y" { + t.Errorf("invalid UTF-8 preview = %q", snapshot.Files[5].Preview) + } + if snapshot.References != 1 || snapshot.Definitions != 3 || phases[len(phases)-1] != "linking" { + t.Errorf("snapshot totals refs=%d defs=%d phases=%v", snapshot.References, snapshot.Definitions, phases) + } +} + +func TestGitignoreHonouredInsideCheckout(t *testing.T) { + root := writeTree(t, map[string]string{".git/HEAD": "ref", ".gitignore": "gen/\n", "gen/a.go": "x", "main.go": "package main"}) + paths, err := walkSource(root) + if err != nil { + t.Fatal(err) + } + if len(paths) != 2 || !strings.HasSuffix(paths[0], ".gitignore") || !strings.HasSuffix(paths[1], "main.go") { + t.Fatalf("paths = %v", paths) + } +} + +func TestEmptyDirectoryFails(t *testing.T) { + if _, err := indexDirectory(t.TempDir(), func(string, int, int, string) {}); err == nil { + t.Fatal("expected error for empty directory") + } +}