| 1 | package bakeoff |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "io/fs" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "regexp" |
| 9 | "sort" |
| 10 | "strings" |
| 11 | ) |
| 12 | |
| 13 | // CodeStats counts non-blank lines by role, plus direct dependencies. |
| 14 | type CodeStats struct{ Server, Templates, JS, Tests, Deps int } |
| 15 | |
| 16 | var ( |
| 17 | skipDirs = map[string]bool{"dist": true, "vendor": true, ".cache": true, "node_modules": true, ".git": true, "_build": true, "dist-newstyle": true, "target": true, "zig-out": true, ".zig-cache": true, ".build": true} |
| 18 | skipExts = map[string]bool{".css": true, ".md": true, ".sum": true, ".lock": true, ".json": true, ".db": true, ".png": true, ".ico": true, ".svg": true} |
| 19 | sourceExts = map[string]bool{".go": true, ".rs": true, ".zig": true, ".ml": true, ".mli": true, ".hs": true, ".swift": true, ".mojo": true, ".đŸ”¥": true, ".rb": true, ".nim": true, ".nims": true, ".nimf": true, ".c": true, ".h": true, ".js": true, ".mjs": true, ".html": true, ".tmpl": true, ".erb": true, ".mustache": true, ".eex": true, ".sql": true} |
| 20 | templateExt = map[string]bool{".nimf": true, ".html": true, ".tmpl": true, ".erb": true, ".mustache": true, ".eex": true} |
| 21 | testName = regexp.MustCompile(`(_test\.|\.test\.|_spec\.|Spec\.hs$|Test\.hs$|Tests\.swift$)`) |
| 22 | vanName = regexp.MustCompile(`^van[-.]`) |
| 23 | ) |
| 24 | |
| 25 | // Roles a file can play in CountCode and SourceFiles. |
| 26 | const ( |
| 27 | roleServer = "server" |
| 28 | roleTemplate = "templates" |
| 29 | roleJS = "js" |
| 30 | roleTest = "tests" |
| 31 | roleDeps = "deps" |
| 32 | ) |
| 33 | |
| 34 | // walkCode calls f for every counted file under dir with its slash path |
| 35 | // relative to dir and its role. Build output, vendored code, lockfiles, |
| 36 | // CSS and docs are skipped. |
| 37 | func walkCode(dir string, f func(rel, role string) error) error { |
| 38 | return filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { |
| 39 | if err != nil { |
| 40 | return err |
| 41 | } |
| 42 | rel, _ := filepath.Rel(dir, p) |
| 43 | rel = filepath.ToSlash(rel) |
| 44 | if d.IsDir() { |
| 45 | if p != dir && skipDirs[d.Name()] { |
| 46 | return filepath.SkipDir |
| 47 | } |
| 48 | return nil |
| 49 | } |
| 50 | if rel == "DEPS" { |
| 51 | return f(rel, roleDeps) |
| 52 | } |
| 53 | ext := strings.ToLower(filepath.Ext(p)) |
| 54 | if skipExts[ext] || !sourceExts[ext] || vanName.MatchString(d.Name()) || generated(p) { |
| 55 | return nil |
| 56 | } |
| 57 | parts := strings.Split(rel, "/") |
| 58 | inDir := func(names ...string) bool { |
| 59 | for _, seg := range parts[:len(parts)-1] { |
| 60 | for _, n := range names { |
| 61 | if seg == n { |
| 62 | return true |
| 63 | } |
| 64 | } |
| 65 | } |
| 66 | return false |
| 67 | } |
| 68 | switch { |
| 69 | case inDir("test", "tests") || testName.MatchString(d.Name()): |
| 70 | return f(rel, roleTest) |
| 71 | case ext == ".js" || ext == ".mjs": |
| 72 | return f(rel, roleJS) |
| 73 | case templateExt[ext] || inDir("templates", "views"): |
| 74 | return f(rel, roleTemplate) |
| 75 | } |
| 76 | return f(rel, roleServer) |
| 77 | }) |
| 78 | } |
| 79 | |
| 80 | // CountCode counts non-blank lines under dir by role; DEPS counts as Deps. |
| 81 | func CountCode(dir string) (CodeStats, error) { |
| 82 | var c CodeStats |
| 83 | err := walkCode(dir, func(rel, role string) error { |
| 84 | n, err := lines(filepath.Join(dir, rel)) |
| 85 | if err != nil { |
| 86 | return err |
| 87 | } |
| 88 | switch role { |
| 89 | case roleServer: |
| 90 | c.Server += n |
| 91 | case roleTemplate: |
| 92 | c.Templates += n |
| 93 | case roleJS: |
| 94 | c.JS += n |
| 95 | case roleTest: |
| 96 | c.Tests += n |
| 97 | case roleDeps: |
| 98 | c.Deps = n |
| 99 | } |
| 100 | return nil |
| 101 | }) |
| 102 | return c, err |
| 103 | } |
| 104 | |
| 105 | // SourceFiles lists the app's own server, template and JS files (no tests), |
| 106 | // sorted: what a reader of the app has to read. |
| 107 | func SourceFiles(dir string) ([]string, error) { |
| 108 | var out []string |
| 109 | err := walkCode(dir, func(rel, role string) error { |
| 110 | if role == roleServer || role == roleTemplate || role == roleJS { |
| 111 | out = append(out, rel) |
| 112 | } |
| 113 | return nil |
| 114 | }) |
| 115 | sort.Strings(out) |
| 116 | return out, err |
| 117 | } |
| 118 | |
| 119 | func lines(path string) (int, error) { |
| 120 | f, err := os.Open(path) |
| 121 | if err != nil { |
| 122 | return 0, err |
| 123 | } |
| 124 | defer f.Close() |
| 125 | n := 0 |
| 126 | sc := bufio.NewScanner(f) |
| 127 | sc.Buffer(make([]byte, 1<<20), 1<<24) |
| 128 | for sc.Scan() { |
| 129 | if strings.TrimSpace(sc.Text()) != "" { |
| 130 | n++ |
| 131 | } |
| 132 | } |
| 133 | return n, sc.Err() |
| 134 | } |
| 135 | |
| 136 | var generatedMark = regexp.MustCompile(`(?i)(\bgenerated\b|do not edit)`) |
| 137 | |
| 138 | // generated reports whether a file says, in its first three lines, that a |
| 139 | // tool wrote it. Such files are copies (embedded assets, say), not code |
| 140 | // anyone wrote, so they're neither counted nor shown to the scorer. |
| 141 | func generated(path string) bool { |
| 142 | f, err := os.Open(path) |
| 143 | if err != nil { |
| 144 | return false |
| 145 | } |
| 146 | defer f.Close() |
| 147 | sc := bufio.NewScanner(f) |
| 148 | sc.Buffer(make([]byte, 1<<20), 1<<24) |
| 149 | for i := 0; i < 3 && sc.Scan(); i++ { |
| 150 | if generatedMark.MatchString(sc.Text()) { |
| 151 | return true |
| 152 | } |
| 153 | } |
| 154 | return false |
| 155 | } |
| 156 | |