Skip to content

Commit e9ed7c1

Browse files
JordanCoinclaude
andauthored
Enhance language detection: more manifest signals, recursive scan, Makefile heuristics, and tests (#57)
* Improve detectLanguagesFromFiles fallback coverage * fix: address review comments on language detection - Remove 'make' sentinel from manifests, check Makefile directly - Tighten C heuristic: exclude clang++ false positive - Cache ScanFiles result to avoid double directory walk - Fix build error in countSourceFiles Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 756d904 commit e9ed7c1

2 files changed

Lines changed: 137 additions & 34 deletions

File tree

cmd/context.go

Lines changed: 85 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import (
44
"encoding/json"
55
"flag"
66
"fmt"
7+
"io"
78
"os"
89
"path/filepath"
910
"sort"
11+
"strings"
1012
"time"
1113

1214
"codemap/config"
@@ -248,63 +250,112 @@ func buildProjectContext(root string, info *hubInfo) ProjectContext {
248250
return ctx
249251
}
250252

251-
// detectLanguagesFromFiles does a quick scan of the project root for language signals.
252-
// Checks manifest files first (fast), then scans top-level source files.
253+
// detectLanguagesFromFiles does a quick scan for language signals.
254+
// Checks manifest files first (fast), then scans source files recursively.
253255
func detectLanguagesFromFiles(root string) map[string]bool {
254256
langs := make(map[string]bool)
257+
addLang := func(lang string) {
258+
if lang != "" {
259+
langs[lang] = true
260+
}
261+
}
255262

256263
// Manifest files → definitive language signal
257-
manifests := map[string]string{
258-
"go.mod": "go",
259-
"package.json": "javascript",
260-
"Cargo.toml": "rust",
261-
"pyproject.toml": "python",
262-
"setup.py": "python",
263-
"requirements.txt": "python",
264-
"Gemfile": "ruby",
265-
"build.gradle": "java",
266-
"pom.xml": "java",
267-
"Package.swift": "swift",
268-
"mix.exs": "elixir",
269-
"composer.json": "php",
270-
"build.sbt": "scala",
264+
manifests := map[string][]string{
265+
"go.mod": {"go"},
266+
"package.json": {"javascript"},
267+
"Cargo.toml": {"rust"},
268+
"pyproject.toml": {"python"},
269+
"setup.py": {"python"},
270+
"requirements.txt": {"python"},
271+
"Gemfile": {"ruby"},
272+
"build.gradle": {"java"},
273+
"build.gradle.kts": {"kotlin", "java"},
274+
"pom.xml": {"java"},
275+
"Package.swift": {"swift"},
276+
"Podfile": {"swift"},
277+
"mix.exs": {"elixir"},
278+
"composer.json": {"php"},
279+
"build.sbt": {"scala"},
280+
"tsconfig.json": {"typescript"},
271281
}
272-
for file, lang := range manifests {
282+
for file, signalLangs := range manifests {
273283
if _, err := os.Stat(filepath.Join(root, file)); err == nil {
274-
langs[lang] = true
284+
for _, lang := range signalLangs {
285+
addLang(lang)
286+
}
275287
}
276288
}
277289

278-
// If we found manifests, that's usually enough
279-
if len(langs) > 0 {
280-
return langs
290+
// C# project files can have arbitrary names; detect by glob at repo root.
291+
for _, pattern := range []string{"*.csproj", "*.sln"} {
292+
matches, _ := filepath.Glob(filepath.Join(root, pattern))
293+
if len(matches) > 0 {
294+
addLang("csharp")
295+
}
281296
}
282297

283-
// Fall back to scanning top-level files by extension
284-
entries, err := os.ReadDir(root)
285-
if err != nil {
286-
return langs
298+
// JS/TS monorepo signal: packages/*/package.json.
299+
if matches, _ := filepath.Glob(filepath.Join(root, "packages", "*", "package.json")); len(matches) > 0 {
300+
addLang("javascript")
287301
}
288-
for _, entry := range entries {
289-
if entry.IsDir() {
290-
continue
291-
}
292-
if lang := scanner.DetectLanguage(entry.Name()); lang != "" {
293-
langs[lang] = true
302+
303+
// Makefile heuristics for C/C++ projects — check directly, no sentinel.
304+
if _, err := os.Stat(filepath.Join(root, "Makefile")); err == nil {
305+
applyMakefileHeuristics(filepath.Join(root, "Makefile"), addLang)
306+
}
307+
308+
// Include subdirectory source files. Reuse the scan result for countSourceFiles too.
309+
gitCache := scanner.NewGitIgnoreCache(root)
310+
if files, err := scanner.ScanFiles(root, gitCache, nil, nil); err == nil {
311+
for _, f := range files {
312+
addLang(scanner.DetectLanguage(f.Path))
294313
}
314+
// Cache file count to avoid a second scan in countSourceFiles
315+
cachedFileCount = len(files)
295316
}
296317

297318
return langs
298319
}
299320

321+
// cachedFileCount avoids a second ScanFiles walk in countSourceFiles.
322+
var cachedFileCount = -1
323+
324+
func applyMakefileHeuristics(path string, addLang func(string)) {
325+
f, err := os.Open(path)
326+
if err != nil {
327+
return
328+
}
329+
defer f.Close()
330+
331+
buf, err := io.ReadAll(io.LimitReader(f, 128*1024))
332+
if err != nil {
333+
return
334+
}
335+
content := strings.ToLower(string(buf))
336+
337+
if strings.Contains(content, "g++") || strings.Contains(content, "clang++") || strings.Contains(content, ".cpp") || strings.Contains(content, ".cc") {
338+
addLang("cpp")
339+
}
340+
// Tighten C detection: exclude clang++ and .cpp/.cc false positives
341+
if strings.Contains(content, "gcc") ||
342+
(strings.Contains(content, "clang") && !strings.Contains(content, "clang++")) {
343+
addLang("c")
344+
}
345+
}
346+
300347
// countSourceFiles does a quick count of source files in the project.
348+
// Uses cached result from detectLanguagesFromFiles if available.
301349
func countSourceFiles(root string) int {
302-
count := 0
350+
if cachedFileCount >= 0 {
351+
count := cachedFileCount
352+
cachedFileCount = -1 // reset for next call
353+
return count
354+
}
303355
gitCache := scanner.NewGitIgnoreCache(root)
304356
files, err := scanner.ScanFiles(root, gitCache, nil, nil)
305357
if err != nil {
306358
return 0
307359
}
308-
count = len(files)
309-
return count
360+
return len(files)
310361
}

cmd/context_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestDetectLanguagesFromFiles_ManifestSignals(t *testing.T) {
10+
root := t.TempDir()
11+
12+
mustWriteFile(t, filepath.Join(root, "app.csproj"), "<Project />")
13+
mustWriteFile(t, filepath.Join(root, "build.gradle.kts"), "plugins { kotlin(\"jvm\") }")
14+
mustWriteFile(t, filepath.Join(root, "Podfile"), "platform :ios, '13.0'")
15+
mustWriteFile(t, filepath.Join(root, "tsconfig.json"), "{}")
16+
mustWriteFile(t, filepath.Join(root, "Makefile"), "CC=gcc\nCXX=g++\n")
17+
mustWriteFile(t, filepath.Join(root, "packages", "ui", "package.json"), "{}")
18+
19+
langs := detectLanguagesFromFiles(root)
20+
21+
for _, want := range []string{"csharp", "kotlin", "java", "swift", "typescript", "javascript", "c", "cpp"} {
22+
if !langs[want] {
23+
t.Fatalf("expected %q to be detected, got %#v", want, langs)
24+
}
25+
}
26+
}
27+
28+
func TestDetectLanguagesFromFiles_SubdirectorySources(t *testing.T) {
29+
root := t.TempDir()
30+
31+
mustWriteFile(t, filepath.Join(root, "src", "main.ts"), "export const n = 1")
32+
mustWriteFile(t, filepath.Join(root, "internal", "core", "worker.go"), "package core")
33+
34+
langs := detectLanguagesFromFiles(root)
35+
36+
if !langs["typescript"] {
37+
t.Fatalf("expected typescript from subdirectory source, got %#v", langs)
38+
}
39+
if !langs["go"] {
40+
t.Fatalf("expected go from subdirectory source, got %#v", langs)
41+
}
42+
}
43+
44+
func mustWriteFile(t *testing.T, path, content string) {
45+
t.Helper()
46+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
47+
t.Fatalf("mkdir %s: %v", path, err)
48+
}
49+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
50+
t.Fatalf("write %s: %v", path, err)
51+
}
52+
}

0 commit comments

Comments
 (0)