Add source indexer
Parallel file parsing, import linking, language/layer classification, and .gitignore/.ignore handling, with tests.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/codenavigator
|
||||||
+550
@@ -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"
|
||||||
|
}
|
||||||
+209
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user