From def6195bfc4085fe805e966d9e1c4d40592747a1 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 8 Jul 2025 00:19:16 +0300 Subject: feat: show language usage percentages in showcase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Languages are now counted by lines of code - Displayed with percentages ordered by usage (highest first) - Format: 'Go (88.8%), Markdown (5.5%), ...' - Only shows languages with at least 0.1% usage - More accurate representation of project composition 🤖 Generated with Claude Code Co-Authored-By: Claude --- internal/showcase/language_detector.go | 235 +++++++++++++++++++++++++++++++++ internal/showcase/metadata.go | 119 ++--------------- internal/showcase/showcase.go | 15 ++- 3 files changed, 256 insertions(+), 113 deletions(-) create mode 100644 internal/showcase/language_detector.go (limited to 'internal') diff --git a/internal/showcase/language_detector.go b/internal/showcase/language_detector.go new file mode 100644 index 0000000..ac7bc75 --- /dev/null +++ b/internal/showcase/language_detector.go @@ -0,0 +1,235 @@ +package showcase + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// detectLanguages detects programming languages used in the repository with line counts +func detectLanguages(repoPath string) ([]LanguageStats, error) { + languageLines := make(map[string]int) + + // Define common language extensions + langExtensions := map[string]string{ + ".go": "Go", + ".py": "Python", + ".js": "JavaScript", + ".ts": "TypeScript", + ".java": "Java", + ".c": "C", + ".cpp": "C++", + ".cc": "C++", + ".cxx": "C++", + ".h": "C/C++", + ".hpp": "C++", + ".hxx": "C++", + ".cs": "C#", + ".rb": "Ruby", + ".php": "PHP", + ".swift": "Swift", + ".kt": "Kotlin", + ".rs": "Rust", + ".scala": "Scala", + ".r": "R", + ".m": "Objective-C", + ".mm": "Objective-C++", + ".sh": "Shell", + ".bash": "Shell", + ".zsh": "Shell", + ".fish": "Shell", + ".pl": "Perl", + ".lua": "Lua", + ".vim": "Vim Script", + ".el": "Emacs Lisp", + ".clj": "Clojure", + ".hs": "Haskell", + ".ml": "OCaml", + ".ex": "Elixir", + ".exs": "Elixir", + ".dart": "Dart", + ".jl": "Julia", + ".nim": "Nim", + ".v": "V", + ".zig": "Zig", + ".html": "HTML", + ".htm": "HTML", + ".css": "CSS", + ".scss": "SCSS", + ".sass": "Sass", + ".less": "Less", + ".xml": "XML", + ".json": "JSON", + ".yaml": "YAML", + ".yml": "YAML", + ".toml": "TOML", + ".ini": "INI", + ".cfg": "Config", + ".conf": "Config", + ".sql": "SQL", + ".md": "Markdown", + ".rst": "reStructuredText", + ".tex": "LaTeX", + } + + // Special files that indicate specific languages + specialFiles := map[string]string{ + "makefile": "Make", + "gnumakefile": "Make", + "dockerfile": "Docker", + "dockerfile.*": "Docker", + "cmakelists.txt": "CMake", + "rakefile": "Ruby", + "gemfile": "Ruby", + "package.json": "JavaScript", + "cargo.toml": "Rust", + "go.mod": "Go", + "go.sum": "Go", + "pom.xml": "Java", + "build.gradle": "Gradle", + "build.gradle.kts": "Kotlin", + "requirements.txt": "Python", + "setup.py": "Python", + "pyproject.toml": "Python", + "composer.json": "PHP", + "*.dockerfile": "Docker", + "containerfile": "Docker", + "jenkinsfile": "Groovy", + "vagrantfile": "Ruby", + } + + // Count lines for each language + err := filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + + // Skip directories + if info.IsDir() { + name := info.Name() + // Skip hidden directories and common non-code directories + if strings.HasPrefix(name, ".") && name != "." || + name == "node_modules" || + name == "vendor" || + name == "target" || + name == "dist" || + name == "build" || + name == "out" || + name == "__pycache__" || + name == "coverage" { + return filepath.SkipDir + } + return nil + } + + // Skip binary and large files + if info.Size() > 10*1024*1024 { // Skip files larger than 10MB + return nil + } + + // Get the filename and extension + basename := strings.ToLower(filepath.Base(path)) + ext := strings.ToLower(filepath.Ext(path)) + + // Determine the language + var language string + + // Check special files first + if lang, ok := specialFiles[basename]; ok { + language = lang + } else { + // Check by extension + if lang, ok := langExtensions[ext]; ok { + language = lang + } + } + + // If we identified a language, count its lines + if language != "" { + lines, err := countFileLines(path) + if err == nil { + languageLines[language] += lines + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + // Calculate total lines + totalLines := 0 + for _, lines := range languageLines { + totalLines += lines + } + + // Convert to LanguageStats with percentages + var stats []LanguageStats + for lang, lines := range languageLines { + percentage := 0.0 + if totalLines > 0 { + percentage = float64(lines) * 100.0 / float64(totalLines) + } + stats = append(stats, LanguageStats{ + Name: lang, + Lines: lines, + Percentage: percentage, + }) + } + + // Sort by percentage (descending) + sort.Slice(stats, func(i, j int) bool { + return stats[i].Percentage > stats[j].Percentage + }) + + return stats, nil +} + +// countFileLines counts the number of lines in a file +func countFileLines(path string) (int, error) { + file, err := os.Open(path) + if err != nil { + return 0, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + lines := 0 + for scanner.Scan() { + lines++ + } + + if err := scanner.Err(); err != nil { + return 0, err + } + + return lines, nil +} + +// FormatLanguagesWithPercentages formats languages with their percentages +func FormatLanguagesWithPercentages(languages []LanguageStats) string { + if len(languages) == 0 { + return "" + } + + var parts []string + for _, lang := range languages { + if lang.Percentage >= 0.1 { // Only show languages with at least 0.1% + parts = append(parts, fmt.Sprintf("%s (%.1f%%)", lang.Name, lang.Percentage)) + } + } + + // If all languages are below 0.1%, just show the names + if len(parts) == 0 { + for _, lang := range languages { + parts = append(parts, lang.Name) + } + } + + return strings.Join(parts, ", ") +} \ No newline at end of file diff --git a/internal/showcase/metadata.go b/internal/showcase/metadata.go index a2cfa47..713af90 100644 --- a/internal/showcase/metadata.go +++ b/internal/showcase/metadata.go @@ -10,9 +10,16 @@ import ( "time" ) +// LanguageStats holds statistics for a programming language +type LanguageStats struct { + Name string + Lines int + Percentage float64 +} + // RepoMetadata holds metadata about a repository type RepoMetadata struct { - Languages []string + Languages []LanguageStats // Languages with usage statistics CommitCount int LinesOfCode int FirstCommitDate string @@ -73,116 +80,6 @@ func extractRepoMetadata(repoPath string) (*RepoMetadata, error) { return metadata, nil } -// detectLanguages detects programming languages used in the repository -func detectLanguages(repoPath string) ([]string, error) { - languageMap := make(map[string]bool) - - // Define common language extensions - langExtensions := map[string]string{ - ".go": "Go", - ".py": "Python", - ".js": "JavaScript", - ".ts": "TypeScript", - ".java": "Java", - ".c": "C", - ".cpp": "C++", - ".cc": "C++", - ".h": "C/C++", - ".hpp": "C++", - ".cs": "C#", - ".rb": "Ruby", - ".php": "PHP", - ".swift": "Swift", - ".kt": "Kotlin", - ".rs": "Rust", - ".scala": "Scala", - ".r": "R", - ".m": "Objective-C", - ".mm": "Objective-C++", - ".sh": "Shell", - ".bash": "Bash", - ".zsh": "Zsh", - ".pl": "Perl", - ".lua": "Lua", - ".vim": "Vim Script", - ".el": "Emacs Lisp", - ".clj": "Clojure", - ".hs": "Haskell", - ".ml": "OCaml", - ".ex": "Elixir", - ".exs": "Elixir", - ".dart": "Dart", - ".jl": "Julia", - ".nim": "Nim", - ".v": "V", - ".zig": "Zig", - } - - // Walk through the repository - err := filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil // Skip errors - } - - // Skip hidden directories and common non-code directories - if info.IsDir() { - name := info.Name() - if strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor" || name == "target" || name == "dist" || name == "build" { - return filepath.SkipDir - } - return nil - } - - // Check file extension - ext := strings.ToLower(filepath.Ext(path)) - if lang, ok := langExtensions[ext]; ok { - languageMap[lang] = true - } - - // Check for special files - basename := filepath.Base(path) - switch strings.ToLower(basename) { - case "makefile", "gnumakefile": - languageMap["Make"] = true - case "dockerfile": - languageMap["Docker"] = true - case "cmakelists.txt": - languageMap["CMake"] = true - case "rakefile": - languageMap["Ruby"] = true - case "gemfile": - languageMap["Ruby"] = true - case "package.json": - languageMap["JavaScript/Node.js"] = true - case "cargo.toml": - languageMap["Rust"] = true - case "go.mod": - languageMap["Go"] = true - case "pom.xml": - languageMap["Java/Maven"] = true - case "build.gradle", "build.gradle.kts": - languageMap["Java/Gradle"] = true - case "requirements.txt", "setup.py", "pyproject.toml": - languageMap["Python"] = true - case "composer.json": - languageMap["PHP"] = true - } - - return nil - }) - - if err != nil { - return nil, err - } - - // Convert map to slice - var languages []string - for lang := range languageMap { - languages = append(languages, lang) - } - - return languages, nil -} // getCommitCount returns the total number of commits func getCommitCount(repoPath string) (int, error) { diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go index 6a9cbae..386a674 100644 --- a/internal/showcase/showcase.go +++ b/internal/showcase/showcase.go @@ -29,6 +29,17 @@ type ProjectSummary struct { Images []string // Relative paths to images in showcase directory } +// LegacyRepoMetadata for backwards compatibility with old cache files +type LegacyRepoMetadata struct { + Languages []string + CommitCount int + LinesOfCode int + FirstCommitDate string + LastCommitDate string + License string + AvgCommitAge float64 +} + // New creates a new showcase generator func New(cfg *config.Config, workDir string) *Generator { return &Generator{ @@ -82,7 +93,7 @@ func (g *Generator) GenerateShowcase(repoFilter []string, forceRegenerate bool) fmt.Printf("\n--- Generated summary for %s ---\n", repo) fmt.Println(summary.Summary) if summary.Metadata != nil { - fmt.Printf("Languages: %s\n", strings.Join(summary.Metadata.Languages, ", ")) + fmt.Printf("Languages: %s\n", FormatLanguagesWithPercentages(summary.Metadata.Languages)) fmt.Printf("Commits: %d\n", summary.Metadata.CommitCount) fmt.Printf("Lines of Code: %d\n", summary.Metadata.LinesOfCode) fmt.Printf("First Commit: %s\n", summary.Metadata.FirstCommitDate) @@ -293,7 +304,7 @@ func (g *Generator) formatGemtext(summaries []ProjectSummary) string { // Add metadata if available if summary.Metadata != nil { if len(summary.Metadata.Languages) > 0 { - builder.WriteString(fmt.Sprintf("* Languages: %s\n", strings.Join(summary.Metadata.Languages, ", "))) + builder.WriteString(fmt.Sprintf("* Languages: %s\n", FormatLanguagesWithPercentages(summary.Metadata.Languages))) } builder.WriteString(fmt.Sprintf("* Commits: %d\n", summary.Metadata.CommitCount)) builder.WriteString(fmt.Sprintf("* Lines of Code: %d\n", summary.Metadata.LinesOfCode)) -- cgit v1.2.3