diff --git a/cmd/root.go b/cmd/root.go index 200da8b..93b499c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,7 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/gabotechs/dep-tree/internal/config" + "github.com/gabotechs/dep-tree/internal/cpp" "github.com/gabotechs/dep-tree/internal/dummy" golang "github.com/gabotechs/dep-tree/internal/go" "github.com/gabotechs/dep-tree/internal/graph" @@ -141,6 +142,7 @@ func inferLang(files []string, cfg *config.Config) (language.Language, error) { python int rust int golang int + cpp int dummy int }{} top := struct { @@ -173,6 +175,12 @@ func inferLang(files []string, cfg *config.Config) (language.Language, error) { top.v = score.golang top.lang = "golang" } + case utils.EndsWith(file, cpp.Extensions): + score.cpp += 1 + if score.cpp > top.v { + top.v = score.cpp + top.lang = "cpp" + } case utils.EndsWith(file, dummy.Extensions): score.dummy += 1 if score.dummy > top.v { @@ -193,6 +201,8 @@ func inferLang(files []string, cfg *config.Config) (language.Language, error) { return python.MakePythonLanguage(&cfg.Python) case "golang": return golang.NewLanguage(files[0], &cfg.Golang) + case "cpp": + return cpp.NewLanguage(&cfg.Cpp), nil case "dummy": return &dummy.Language{}, nil default: diff --git a/internal/config/config.go b/internal/config/config.go index 78f0de4..2b616ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "gopkg.in/yaml.v3" "github.com/gabotechs/dep-tree/internal/check" + "github.com/gabotechs/dep-tree/internal/cpp" golang "github.com/gabotechs/dep-tree/internal/go" "github.com/gabotechs/dep-tree/internal/js" "github.com/gabotechs/dep-tree/internal/python" @@ -33,6 +34,7 @@ type Config struct { Rust rust.Config `yaml:"rust"` Python python.Config `yaml:"python"` Golang golang.Config `yaml:"golang"` + Cpp cpp.Config `yaml:"cpp"` } func NewConfigCwd() Config { diff --git a/internal/cpp/config.go b/internal/cpp/config.go new file mode 100644 index 0000000..86a11c5 --- /dev/null +++ b/internal/cpp/config.go @@ -0,0 +1,20 @@ +package cpp + +type Config struct { + IncludeDirs []string `yaml:"include_dirs"` + + ExcludeSystemHeaders bool `yaml:"exclude_system_headers"` + + HeaderExtensions []string `yaml:"header_extensions"` + + SourceExtensions []string `yaml:"source_extensions"` +} + +func DefaultConfig() *Config { + return &Config{ + IncludeDirs: []string{}, + ExcludeSystemHeaders: true, + HeaderExtensions: []string{".h", ".hpp", ".hh", ".hxx", ".h++"}, + SourceExtensions: []string{".cpp", ".cc", ".cxx", ".c++"}, + } +} diff --git a/internal/cpp/language.go b/internal/cpp/language.go new file mode 100644 index 0000000..b6dfb60 --- /dev/null +++ b/internal/cpp/language.go @@ -0,0 +1,220 @@ +package cpp + +import ( + "bytes" + "os" + "path/filepath" + "strings" + + "github.com/gabotechs/dep-tree/internal/language" +) + +var Extensions = []string{ + "cpp", "cc", "cxx", "c++", + "hpp", "hh", "hxx", "h++", "h", +} + +type Language struct { + Cfg *Config +} + +func NewLanguage(cfg *Config) *Language { + if cfg == nil { + cfg = &Config{} + } + return &Language{Cfg: cfg} +} + +func (l *Language) ParseFile(path string) (*language.FileInfo, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + file, err := ParseCppFile(string(content)) + if err != nil { + return nil, err + } + + currentDir, _ := os.Getwd() + relPath, _ := filepath.Rel(currentDir, path) + + return &language.FileInfo{ + Content: file, + Loc: bytes.Count(content, []byte("\n")), + Size: len(content), + AbsPath: path, + RelPath: relPath, + }, nil +} + +func (l *Language) ParseImports(file *language.FileInfo) (*language.ImportsResult, error) { + var result language.ImportsResult + + cppFile, ok := file.Content.(*File) + if !ok { + return &result, nil + } + + for _, include := range cppFile.Includes { + if include.IsSystem { + continue + } + + absPath := l.resolveIncludePath(file.AbsPath, include.Header) + if absPath != "" { + result.Imports = append(result.Imports, language.ImportEntry{ + All: true, + AbsPath: absPath, + }) + } + } + + return &result, nil +} + +func (l *Language) ParseExports(file *language.FileInfo) (*language.ExportsResult, error) { + var result language.ExportsResult + + // For C++, determining exports is complex as it depends on: + // - Public class/struct members + // - Free functions + // - Global variables + // - Template definitions + // For now, we'll treat header files as exporting everything + // and source files as exporting nothing by default + + if l.isHeaderFile(file.AbsPath) { + // Header files export all their content + result.Exports = append(result.Exports, language.ExportEntry{ + All: true, + AbsPath: file.AbsPath, + }) + } + + return &result, nil +} + +func (l *Language) resolveIncludePath(sourceFile, includePath string) string { + // If the include path is absolute, return it as-is + if filepath.IsAbs(includePath) { + return includePath + } + + // try relative to the source file directory + sourceDir := filepath.Dir(sourceFile) + resolvedPath := filepath.Join(sourceDir, includePath) + + if _, err := os.Stat(resolvedPath); err == nil { + abs, _ := filepath.Abs(resolvedPath) + return abs + } + + if !hasExtension(includePath) { + for _, ext := range []string{".h", ".hpp", ".hxx", ".h++"} { + testPath := resolvedPath + ext + if _, err := os.Stat(testPath); err == nil { + abs, _ := filepath.Abs(testPath) + return abs + } + } + } + + // try relative to project root + projectRoots := l.findProjectRoots(sourceDir) + for _, root := range projectRoots { + testPath := filepath.Join(root, includePath) + if _, err := os.Stat(testPath); err == nil { + abs, _ := filepath.Abs(testPath) + return abs + } + + // Try with extensions + if !hasExtension(includePath) { + for _, ext := range []string{".h", ".hpp", ".hxx", ".h++"} { + testPathWithExt := testPath + ext + if _, err := os.Stat(testPathWithExt); err == nil { + abs, _ := filepath.Abs(testPathWithExt) + return abs + } + } + } + } + + // try supporting configured include directories + for _, includeDir := range l.Cfg.IncludeDirs { + var testPath string + if filepath.IsAbs(includeDir) { + testPath = filepath.Join(includeDir, includePath) + } else { + testPath = filepath.Join(sourceDir, includeDir, includePath) + } + + if _, err := os.Stat(testPath); err == nil { + abs, _ := filepath.Abs(testPath) + return abs + } + + // Try with extensions + if !hasExtension(includePath) { + for _, ext := range []string{".h", ".hpp", ".hxx", ".h++"} { + testPathWithExt := testPath + ext + if _, err := os.Stat(testPathWithExt); err == nil { + abs, _ := filepath.Abs(testPathWithExt) + return abs + } + } + } + } + + return "" +} + +func (l *Language) findProjectRoots(startDir string) []string { + var roots []string + currentDir := startDir + + // Common project root indicators + rootIndicators := []string{ + "CMakeLists.txt", "Makefile", "SConstruct", // Build files + ".git", ".hg", ".svn", // Version control + "package.json", "Cargo.toml", "go.mod", // Language-specific + "README.md", "README.txt", // Documentation + } + + for { + for _, indicator := range rootIndicators { + if _, err := os.Stat(filepath.Join(currentDir, indicator)); err == nil { + roots = append(roots, currentDir) + break + } + } + + parentDir := filepath.Dir(currentDir) + if parentDir == currentDir { + break + } + currentDir = parentDir + + if len(roots) >= 3 { + break + } + } + + return roots +} + +func (l *Language) isHeaderFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + headerExts := []string{".h", ".hpp", ".hh", ".hxx", ".h++"} + for _, headerExt := range headerExts { + if ext == headerExt { + return true + } + } + return false +} + +func hasExtension(path string) bool { + return filepath.Ext(path) != "" +} diff --git a/internal/cpp/language_test.go b/internal/cpp/language_test.go new file mode 100644 index 0000000..d884f95 --- /dev/null +++ b/internal/cpp/language_test.go @@ -0,0 +1,168 @@ +package cpp + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLanguageParseFile(t *testing.T) { + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.cpp") + + content := `#include +#include "header.h" + +int main() { + return 0; +}` + + err := os.WriteFile(testFile, []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + lang := NewLanguage(nil) + fileInfo, err := lang.ParseFile(testFile) + if err != nil { + t.Fatalf("ParseFile() error = %v", err) + } + + if fileInfo.AbsPath != testFile { + t.Errorf("Expected AbsPath %s, got %s", testFile, fileInfo.AbsPath) + } + + if fileInfo.Size == 0 { + t.Error("Expected file size > 0") + } + + if fileInfo.Loc == 0 { + t.Error("Expected lines of code > 0") + } +} + +func TestLanguageParseImports(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "main.cpp") + headerFile := filepath.Join(tmpDir, "myheader.h") + + // Create the header file + err := os.WriteFile(headerFile, []byte("// Header content"), 0644) + if err != nil { + t.Fatalf("Failed to create header file: %v", err) + } + + // Create main file that includes the header + content := `#include +#include "myheader.h" + +int main() { return 0; }` + + err = os.WriteFile(testFile, []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + lang := NewLanguage(nil) + fileInfo, err := lang.ParseFile(testFile) + if err != nil { + t.Fatalf("ParseFile() error = %v", err) + } + + imports, err := lang.ParseImports(fileInfo) + if err != nil { + t.Fatalf("ParseImports() error = %v", err) + } + + // Should only have 1 import (system includes are excluded) + if len(imports.Imports) != 1 { + t.Errorf("Expected 1 import, got %d", len(imports.Imports)) + } + + if len(imports.Imports) > 0 { + expectedPath, _ := filepath.Abs(headerFile) + if imports.Imports[0].AbsPath != expectedPath { + t.Errorf("Expected import path %s, got %s", expectedPath, imports.Imports[0].AbsPath) + } + + if !imports.Imports[0].All { + t.Error("Expected All to be true for C++ includes") + } + } +} + +func TestLanguageParseExports(t *testing.T) { + tmpDir := t.TempDir() + + // Test header file (should export everything) + headerFile := filepath.Join(tmpDir, "test.h") + err := os.WriteFile(headerFile, []byte("class MyClass {};"), 0644) + if err != nil { + t.Fatalf("Failed to create header file: %v", err) + } + + lang := NewLanguage(nil) + fileInfo, err := lang.ParseFile(headerFile) + if err != nil { + t.Fatalf("ParseFile() error = %v", err) + } + + exports, err := lang.ParseExports(fileInfo) + if err != nil { + t.Fatalf("ParseExports() error = %v", err) + } + + // Header files should export everything + if len(exports.Exports) != 1 { + t.Errorf("Expected 1 export entry for header file, got %d", len(exports.Exports)) + } + + if len(exports.Exports) > 0 && !exports.Exports[0].All { + t.Error("Expected header file to export all") + } + + // Test source file (should export nothing by default) + sourceFile := filepath.Join(tmpDir, "test.cpp") + err = os.WriteFile(sourceFile, []byte("int main() { return 0; }"), 0644) + if err != nil { + t.Fatalf("Failed to create source file: %v", err) + } + + fileInfo, err = lang.ParseFile(sourceFile) + if err != nil { + t.Fatalf("ParseFile() error = %v", err) + } + + exports, err = lang.ParseExports(fileInfo) + if err != nil { + t.Fatalf("ParseExports() error = %v", err) + } + + // Source files should not export by default + if len(exports.Exports) != 0 { + t.Errorf("Expected 0 export entries for source file, got %d", len(exports.Exports)) + } +} + +func TestExtensions(t *testing.T) { + expectedExtensions := []string{"cpp", "cc", "cxx", "c++", "hpp", "hh", "hxx", "h++", "h"} + + if len(Extensions) != len(expectedExtensions) { + t.Errorf("Expected %d extensions, got %d", len(expectedExtensions), len(Extensions)) + } + + for _, ext := range expectedExtensions { + found := false + for _, actualExt := range Extensions { + if ext == actualExt { + found = true + break + } + } + if !found { + t.Errorf("Expected extension %s not found", ext) + } + } +} diff --git a/internal/cpp/parser.go b/internal/cpp/parser.go new file mode 100644 index 0000000..9ad1f48 --- /dev/null +++ b/internal/cpp/parser.go @@ -0,0 +1,61 @@ +package cpp + +import ( + "bufio" + "regexp" + "strings" +) + +// IncludeStatement represents a C++ #include directive +type IncludeStatement struct { + // IsSystem is true for #include
(system headers) + IsSystem bool + // Header is the included header name + Header string + // OriginalLine is the full original include line + OriginalLine string +} + +// File represents a parsed C++ file with include statements +type File struct { + Includes []IncludeStatement +} + +var ( + // Regex patterns for matching C++ include statements + systemIncludePattern = regexp.MustCompile(`^\s*#\s*include\s*<([^>]+)>\s*`) + localIncludePattern = regexp.MustCompile(`^\s*#\s*include\s*"([^"]+)"\s*`) +) + +// ParseCppFile parses a C++ file and extracts include statements +func ParseCppFile(content string) (*File, error) { + file := &File{ + Includes: make([]IncludeStatement, 0), + } + + scanner := bufio.NewScanner(strings.NewReader(content)) + for scanner.Scan() { + line := scanner.Text() + + // Check for system includes (#include
) + if matches := systemIncludePattern.FindStringSubmatch(line); matches != nil { + file.Includes = append(file.Includes, IncludeStatement{ + IsSystem: true, + Header: matches[1], + OriginalLine: line, + }) + continue + } + + // Check for local includes (#include "header") + if matches := localIncludePattern.FindStringSubmatch(line); matches != nil { + file.Includes = append(file.Includes, IncludeStatement{ + IsSystem: false, + Header: matches[1], + OriginalLine: line, + }) + } + } + + return file, scanner.Err() +} diff --git a/internal/cpp/parser_test.go b/internal/cpp/parser_test.go new file mode 100644 index 0000000..319989a --- /dev/null +++ b/internal/cpp/parser_test.go @@ -0,0 +1,93 @@ +package cpp + +import ( + "testing" +) + +func TestParseCppFile(t *testing.T) { + tests := []struct { + name string + content string + expected int // expected number of includes + }{ + { + name: "simple local include", + content: `#include "header.h" +int main() { return 0; }`, + expected: 1, + }, + { + name: "system include", + content: `#include +#include +int main() { return 0; }`, + expected: 2, + }, + { + name: "mixed includes", + content: `#include +#include "myheader.hpp" +#include +#include "another.h"`, + expected: 4, + }, + { + name: "no includes", + content: `int main() { + return 0; +}`, + expected: 0, + }, + { + name: "includes with comments", + content: `// This is a comment +#include "test.h" // inline comment +/* block comment */ +#include `, + expected: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file, err := ParseCppFile(tt.content) + if err != nil { + t.Fatalf("ParseCppFile() error = %v", err) + } + + if len(file.Includes) != tt.expected { + t.Errorf("ParseCppFile() got %d includes, want %d", len(file.Includes), tt.expected) + } + }) + } +} + +func TestIncludeTypeDetection(t *testing.T) { + content := `#include +#include "local.h"` + + file, err := ParseCppFile(content) + if err != nil { + t.Fatalf("ParseCppFile() error = %v", err) + } + + if len(file.Includes) != 2 { + t.Fatalf("Expected 2 includes, got %d", len(file.Includes)) + } + + // First include should be system + if !file.Includes[0].IsSystem { + t.Error("Expected first include to be system include") + } + if file.Includes[0].Header != "iostream" { + t.Errorf("Expected header 'iostream', got '%s'", file.Includes[0].Header) + } + + // Second include should be local + if file.Includes[1].IsSystem { + t.Error("Expected second include to be local include") + } + if file.Includes[1].Header != "local.h" { + t.Errorf("Expected header 'local.h', got '%s'", file.Includes[1].Header) + } +}