diff --git a/.golangci.yml b/.golangci.yml index 9154ed1..cb446af 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,7 +6,7 @@ linters-settings: min-len: 2 min-occurrences: 2 gocyclo: - min-complexity: 15 + min-complexity: 16 godot: check-all: true goimports: diff --git a/cmd/check.go b/cmd/check.go index 0a42d5d..90e22b7 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -3,6 +3,8 @@ package cmd import ( "fmt" + "github.com/gabotechs/dep-tree/internal/graph" + "github.com/gabotechs/dep-tree/internal/language" "github.com/spf13/cobra" "github.com/gabotechs/dep-tree/internal/check" @@ -26,15 +28,22 @@ func CheckCmd() *cobra.Command { if len(cfg.Check.Entrypoints) == 0 { return fmt.Errorf(`config file "%s" has no entrypoints`, configPath) } - parserBuilder, err := makeParserBuilder(cfg.Check.Entrypoints, cfg) + lang, err := inferLang(cfg.Check.Entrypoints, cfg) if err != nil { return err } - parser, err := parserBuilder(args) + parser := language.NewParser(lang) + parser.UnwrapProxyExports = cfg.UnwrapExports + parser.Exclude = cfg.Exclude if err != nil { return err } - return check.Check(parser, &cfg.Check) + return check.Check[*language.FileInfo]( + parser, + func(node *graph.Node[*language.FileInfo]) string { return node.Data.RelPath }, + &cfg.Check, + graph.NewStdErrCallbacks[*language.FileInfo](), + ) }, } } diff --git a/cmd/entropy.go b/cmd/entropy.go index 9c13db9..6b84d4e 100644 --- a/cmd/entropy.go +++ b/cmd/entropy.go @@ -1,6 +1,8 @@ package cmd import ( + "github.com/gabotechs/dep-tree/internal/graph" + "github.com/gabotechs/dep-tree/internal/language" "github.com/spf13/cobra" "github.com/gabotechs/dep-tree/internal/entropy" @@ -24,17 +26,17 @@ func EntropyCmd() *cobra.Command { if err != nil { return err } - parserBuilder, err := makeParserBuilder(files, cfg) + lang, err := inferLang(files, cfg) if err != nil { return err } - parser, err := parserBuilder(files) - if err != nil { - return err - } - err = entropy.Render(parser, files, entropy.RenderConfig{ - NoOpen: noBrowserOpen, - EnableGui: enableGui, + parser := language.NewParser(lang) + parser.UnwrapProxyExports = cfg.UnwrapExports + parser.Exclude = cfg.Exclude + err = entropy.Render(files, parser, entropy.RenderConfig{ + NoOpen: noBrowserOpen, + EnableGui: enableGui, + LoadCallbacks: graph.NewStdErrCallbacks[*language.FileInfo](), }) return err }, diff --git a/cmd/root.go b/cmd/root.go index 7a39f2c..0d6b180 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,15 +6,14 @@ import ( "os" "path/filepath" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/gabotechs/dep-tree/internal/config" "github.com/gabotechs/dep-tree/internal/js" "github.com/gabotechs/dep-tree/internal/language" "github.com/gabotechs/dep-tree/internal/python" "github.com/gabotechs/dep-tree/internal/rust" "github.com/gabotechs/dep-tree/internal/utils" + "github.com/spf13/cobra" + "github.com/spf13/pflag" ) const renderGroupId = "render" @@ -93,7 +92,7 @@ $ dep-tree check`, return root } -func inferLang(files []string) string { +func inferLang(files []string, cfg *config.Config) (language.Language, error) { score := struct { js int python int @@ -125,20 +124,16 @@ func inferLang(files []string) string { } } } - return top.lang -} - -func makeParserBuilder(files []string, cfg *config.Config) (language.NodeParserBuilder, error) { - if len(files) == 0 { + if top.lang == "" { return nil, errors.New("at least one file must be provided") } - switch inferLang(files) { + switch top.lang { case "js": - return language.ParserBuilder(js.MakeJsLanguage, &cfg.Js, cfg), nil + return js.MakeJsLanguage(&cfg.Js) case "rust": - return language.ParserBuilder(rust.MakeRustLanguage, &cfg.Rust, cfg), nil + return rust.MakeRustLanguage(&cfg.Rust) case "python": - return language.ParserBuilder(python.MakePythonLanguage, &cfg.Python, cfg), nil + return python.MakePythonLanguage(&cfg.Python) default: return nil, fmt.Errorf("file \"%s\" not supported", files[0]) } @@ -188,6 +183,15 @@ func loadConfig() (*config.Config, error) { cfg.Python.IgnoreFromImportsAsExports = true cfg.Python.IgnoreDirectoryImports = true + absExclude := make([]string, len(exclude)) + for i, file := range exclude { + if !filepath.IsAbs(file) { + cwd, _ := os.Getwd() + absExclude[i] = filepath.Join(cwd, file) + } else { + absExclude[i] = file + } + } cfg.Exclude = append(cfg.Exclude, exclude...) // validate exclusion patterns. for _, exclusion := range cfg.Exclude { diff --git a/cmd/root_test.go b/cmd/root_test.go index e0eb786..51f0272 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -6,6 +6,11 @@ import ( "strings" "testing" + "github.com/gabotechs/dep-tree/internal/config" + "github.com/gabotechs/dep-tree/internal/js" + "github.com/gabotechs/dep-tree/internal/language" + "github.com/gabotechs/dep-tree/internal/python" + "github.com/gabotechs/dep-tree/internal/rust" "github.com/stretchr/testify/require" "github.com/gabotechs/dep-tree/internal/utils" @@ -107,34 +112,41 @@ func TestInferLang(t *testing.T) { tests := []struct { Name string Files []string - Expected string + Expected language.Language + Error string }{ { Name: "only 1 file", Files: []string{"foo.js"}, - Expected: "js", + Expected: &js.Language{}, }, { Name: "majority of files", Files: []string{"foo.js", "bar.rs", "foo.rs", "foo.py"}, - Expected: "rust", + Expected: &rust.Language{}, }, { Name: "unrelated files", Files: []string{"foo.py", "foo.pdf"}, - Expected: "python", + Expected: &python.Language{}, }, { - Name: "no match", - Files: []string{"foo.pdf", "bar.docx"}, - Expected: "", + Name: "no match", + Files: []string{"foo.pdf", "bar.docx"}, + Error: "at least one file must be provided", }, } for _, tt := range tests { t.Run(tt.Name, func(t *testing.T) { a := require.New(t) - a.Equal(tt.Expected, inferLang(tt.Files)) + lang, err := inferLang(tt.Files, &config.Config{}) + if tt.Error != "" { + a.ErrorContains(err, tt.Error) + } else { + a.NoError(err) + a.IsType(tt.Expected, lang) + } }) } } diff --git a/cmd/tree.go b/cmd/tree.go index 6e25453..af4aaa2 100644 --- a/cmd/tree.go +++ b/cmd/tree.go @@ -3,6 +3,8 @@ package cmd import ( "fmt" + "github.com/gabotechs/dep-tree/internal/graph" + "github.com/gabotechs/dep-tree/internal/language" "github.com/gabotechs/dep-tree/internal/tree" "github.com/spf13/cobra" @@ -27,22 +29,38 @@ func TreeCmd() *cobra.Command { return err } - parserBuilder, err := makeParserBuilder(files, cfg) + lang, err := inferLang(files, cfg) if err != nil { return err } + parser := language.NewParser(lang) + parser.UnwrapProxyExports = cfg.UnwrapExports + parser.Exclude = cfg.Exclude + if jsonFormat { - parser, err := parserBuilder(files) + t, err := tree.NewTree[*language.FileInfo]( + files, + parser, + func(node *graph.Node[*language.FileInfo]) string { return node.Data.RelPath }, + graph.NewStdErrCallbacks[*language.FileInfo](), + ) if err != nil { return err } - rendered, err := tree.PrintStructured(files, parser) + rendered, err := t.RenderStructured() fmt.Println(rendered) return err } else { - return tui.Loop(files, parserBuilder, nil, true, nil) + return tui.Loop[*language.FileInfo]( + files, + parser, + func(node *graph.Node[*language.FileInfo]) string { return node.Data.RelPath }, + nil, + true, + nil, + graph.NewStdErrCallbacks[*language.FileInfo]()) } }, } diff --git a/internal/check/check.go b/internal/check/check.go index a005f14..e493297 100644 --- a/internal/check/check.go +++ b/internal/check/check.go @@ -5,20 +5,31 @@ import ( "path/filepath" "strings" - "github.com/gabotechs/dep-tree/internal/dep_tree" + "github.com/gabotechs/dep-tree/internal/graph" "github.com/gabotechs/dep-tree/internal/utils" ) -func Check[T any](parser dep_tree.NodeParser[T], cfg *Config) error { - dt := dep_tree.NewDepTree(parser, cfg.Entrypoints).WithStdErrLoader() - err := dt.LoadGraph() +func Check[T any]( + parser graph.NodeParser[T], + display func(node *graph.Node[T]) string, + cfg *Config, + callbacks graph.LoadCallbacks[T], +) error { + // 1. build the graph. + files := make([]string, len(cfg.Entrypoints)) + for i, file := range cfg.Entrypoints { + files[i] = filepath.Join(cfg.Path, file) + } + + g := graph.NewGraph[T]() + err := g.Load(files, parser, callbacks) if err != nil { return err } - // 1. Check for rule violations in the graph. + // 2. Check for rule violations in the graph. failures := make([]string, 0) - for _, node := range dt.Graph.AllNodes() { - for _, dep := range dt.Graph.FromId(node.Id) { + for _, node := range g.AllNodes() { + for _, dep := range g.FromId(node.Id) { from, to := cfg.rel(node.Id), cfg.rel(dep.Id) pass, err := cfg.Check(from, to) if err != nil { @@ -28,14 +39,14 @@ func Check[T any](parser dep_tree.NodeParser[T], cfg *Config) error { } } } - // 2. Check for cycles. - dt.LoadCycles() + // 3. Check for cycles. + cycles := g.RemoveElementaryCycles() if !cfg.AllowCircularDependencies { - for el := dt.Cycles.Front(); el != nil; el = el.Next() { - formattedCycleStack := make([]string, len(el.Value.Stack)) - for i, el := range el.Value.Stack { - if node := dt.Graph.Get(el); node != nil { - formattedCycleStack[i] = parser.Display(node).Name + for _, cycle := range cycles { + formattedCycleStack := make([]string, len(cycle.Stack)) + for i, el := range cycle.Stack { + if node := g.Get(el); node != nil { + formattedCycleStack[i] = display(node) } else { formattedCycleStack[i] = el } diff --git a/internal/check/check_test.go b/internal/check/check_test.go index abaa665..9dfee7a 100644 --- a/internal/check/check_test.go +++ b/internal/check/check_test.go @@ -4,9 +4,8 @@ import ( "strings" "testing" + "github.com/gabotechs/dep-tree/internal/graph" "github.com/stretchr/testify/require" - - "github.com/gabotechs/dep-tree/internal/dep_tree" ) func TestCheck(t *testing.T) { @@ -37,7 +36,7 @@ func TestCheck(t *testing.T) { Failures: []string{ "0 -> 3", "4 -> 3", - "detected circular dependency: 3 -> 4 -> 3", + "detected circular dependency: 4 -> 3 -> 4", }, }, } @@ -46,7 +45,12 @@ func TestCheck(t *testing.T) { t.Run(tt.Name, func(t *testing.T) { a := require.New(t) - err := Check[[]int](&dep_tree.TestParser{Spec: tt.Spec}, tt.Config) + err := Check[[]int]( + &graph.TestParser{Spec: tt.Spec}, + func(node *graph.Node[[]int]) string { return node.Id }, + tt.Config, + nil, + ) if tt.Failures != nil { msg := err.Error() failures := strings.Split(msg, "\n") diff --git a/internal/config/.config_test/.excludes.yml b/internal/config/.config_test/.excludes.yml new file mode 100644 index 0000000..55da765 --- /dev/null +++ b/internal/config/.config_test/.excludes.yml @@ -0,0 +1,5 @@ +exclude: + - '**/foo.js' + - '*/foo.js' + - 'foo/**/foo.js' + - '/**/*.js' diff --git a/internal/config/config.go b/internal/config/config.go index f27743c..eb66aff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,6 +57,8 @@ func ParseConfig(cfgPath string) (*Config, error) { cfgPath = DefaultConfigPath } content, err := os.ReadFile(cfgPath) + // If a specific path was requested, and it does not exist, fail + // If no specific path was requested, and the default config path does not exist, succeed if os.IsNotExist(err) { if !isDefault { return &cfg, err @@ -78,6 +80,19 @@ func ParseConfig(cfgPath string) (*Config, error) { if err != nil { return &cfg, fmt.Errorf(`config file "%s" is not a valid yml file: %w`, cfgPath, err) } + + exclude := make([]string, len(cfg.Exclude)) + for i, pattern := range cfg.Exclude { + if !filepath.IsAbs(pattern) { + exclude[i] = filepath.Join(cfg.Path, pattern) + } else { + exclude[i] = pattern + } + } + if len(exclude) > 0 { + cfg.Exclude = exclude + } + cfg.Check.Init(filepath.Dir(absCfgPath)) return &cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c69089d..95e577d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,11 +10,14 @@ import ( const testFolder = ".config_test" func TestParseConfig(t *testing.T) { + absTestFolder, _ := filepath.Abs(testFolder) + tests := []struct { Name string File string ExpectedWhiteList map[string][]string ExpectedBlackList map[string][]string + ExpectedExclude []string }{ { Name: "default file", @@ -52,6 +55,16 @@ func TestParseConfig(t *testing.T) { }, }, }, + { + Name: "Exclusion", + File: ".excludes.yml", + ExpectedExclude: []string{ + filepath.Join(absTestFolder, "**/foo.js"), + filepath.Join(absTestFolder, "*/foo.js"), + filepath.Join(absTestFolder, "foo/**/foo.js"), + "/**/*.js", + }, + }, } for _, tt := range tests { @@ -66,6 +79,7 @@ func TestParseConfig(t *testing.T) { a.Equal(tt.ExpectedWhiteList, cfg.Check.WhiteList) a.Equal(tt.ExpectedBlackList, cfg.Check.BlackList) + a.Equal(tt.ExpectedExclude, cfg.Exclude) }) } } diff --git a/internal/dep_tree/dep_tree.go b/internal/dep_tree/dep_tree.go deleted file mode 100644 index 6451846..0000000 --- a/internal/dep_tree/dep_tree.go +++ /dev/null @@ -1,89 +0,0 @@ -package dep_tree - -import ( - "fmt" - "os" - "time" - - "github.com/elliotchance/orderedmap/v2" - "github.com/schollz/progressbar/v3" - - "github.com/gabotechs/dep-tree/internal/graph" -) - -type NodeParserBuilder[T any] func([]string) (NodeParser[T], error) - -type DisplayResult struct { - Name string - Group string -} - -type NodeParser[T any] interface { - Display(node *graph.Node[T]) DisplayResult - Node(id string) (*graph.Node[T], error) - Deps(node *graph.Node[T]) ([]*graph.Node[T], error) -} - -type DepTree[T any] struct { - // Info present on DepTree construction. - Ids []string - NodeParser[T] - // Info present just after node processing. - Graph *graph.Graph[T] - Entrypoints []*graph.Node[T] - Cycles *orderedmap.OrderedMap[[2]string, graph.Cycle] - // callbacks - onStartLoading func() - onNodeStartLoad func(*graph.Node[T]) - onNodeFinishLoad func(*graph.Node[T], []*graph.Node[T]) - onFinishLoad func() -} - -func NewDepTree[T any](parser NodeParser[T], ids []string) *DepTree[T] { - return &DepTree[T]{ - Ids: ids, - NodeParser: parser, - Graph: graph.NewGraph[T](), - Cycles: orderedmap.NewOrderedMap[[2]string, graph.Cycle](), - onStartLoading: func() {}, - onNodeStartLoad: func(_ *graph.Node[T]) {}, - onNodeFinishLoad: func(_ *graph.Node[T], _ []*graph.Node[T]) {}, - onFinishLoad: func() {}, - } -} - -func (dt *DepTree[T]) WithStdErrLoader() *DepTree[T] { - bar := progressbar.NewOptions64( - -1, - progressbar.OptionSetDescription("Loading graph..."), - progressbar.OptionSetWriter(os.Stderr), - progressbar.OptionSetWidth(10), - progressbar.OptionThrottle(65*time.Millisecond), - progressbar.OptionShowIts(), - progressbar.OptionSpinnerType(14), - progressbar.OptionFullWidth(), - progressbar.OptionSetRenderBlankState(true), - ) - diff := make(map[string]bool) - done := 0 - dt.onStartLoading = func() { - bar.Reset() - } - dt.onNodeStartLoad = func(n *graph.Node[T]) { - done += 1 - _ = bar.Set(done) - bar.Describe(fmt.Sprintf("(%d/%d) Loading %s...", done, len(diff), dt.NodeParser.Display(n).Name)) - } - dt.onNodeFinishLoad = func(n *graph.Node[T], ns []*graph.Node[T]) { - for _, n := range ns { - diff[n.Id] = true - } - bar.Describe(fmt.Sprintf("(%d/%d) Loading %s...", done, len(diff), dt.NodeParser.Display(n).Name)) - } - dt.onFinishLoad = func() { - bar.Describe("Finished loading") - _ = bar.Finish() - _ = bar.Clear() - } - return dt -} diff --git a/internal/dep_tree/load.go b/internal/dep_tree/load.go deleted file mode 100644 index a2f6562..0000000 --- a/internal/dep_tree/load.go +++ /dev/null @@ -1,85 +0,0 @@ -package dep_tree - -import ( - "github.com/elliotchance/orderedmap/v2" - "github.com/gammazero/deque" - - "github.com/gabotechs/dep-tree/internal/graph" -) - -func (dt *DepTree[T]) LoadGraph() error { - dt.Graph = graph.NewGraph[T]() - - visited := make(map[string]bool) - dt.onStartLoading() - - for _, id := range dt.Ids { - node, err := dt.NodeParser.Node(id) - if err != nil { - return err - } - var queue deque.Deque[*graph.Node[T]] - queue.PushBack(node) - if !dt.Graph.Has(node.Id) { - dt.Graph.AddNode(node) - } - for queue.Len() > 0 { - node := queue.PopFront() - if _, ok := visited[node.Id]; ok { - continue - } - dt.onNodeStartLoad(node) - visited[node.Id] = true - - deps, err := dt.NodeParser.Deps(node) - if err != nil { - node.AddErrors(err) - continue - } - dt.onNodeFinishLoad(node, deps) - - for _, dep := range deps { - // No own child. - if dep.Id == node.Id { - continue - } - if !dt.Graph.Has(dep.Id) { - dt.Graph.AddNode(dep) - } - err = dt.Graph.AddFromToEdge(node.Id, dep.Id) - queue.PushBack(dep) - if err != nil { - return err - } - } - } - } - if len(dt.Ids) == 1 { - // If exactly one file was provided, take that as the entrypoint. - dt.Entrypoints = []*graph.Node[T]{dt.Graph.Get(dt.Ids[0])} - } else { - // If multiple files were provided, use the nodes without parents as entrypoints. - // Note that due to cyclic dependencies, there might be no parents without dependencies. - dt.Entrypoints = dt.Graph.GetNodesWithoutParents() - } - dt.onFinishLoad() - - return nil -} - -func (dt *DepTree[T]) LoadCycles() { - dt.Cycles = orderedmap.NewOrderedMap[[2]string, graph.Cycle]() - - // First, remove the cycles computed from each entrypoint. This allows - // us trim the cycles in a more "controlled way" - for _, entrypoint := range dt.Entrypoints { - for _, cycle := range dt.Graph.RemoveCycles(entrypoint) { - dt.Cycles.Set(cycle.Cause, cycle) - } - } - // Then, remove the cycles computed without taking entrypoints into account. - // These are not as nice, as the rule for determining which cycles are trimmed is more arbitrary. - for _, cycle := range dt.Graph.RemoveJohnsonCycles() { - dt.Cycles.Set(cycle.Cause, cycle) - } -} diff --git a/internal/dep_tree/test_parser.go b/internal/dep_tree/test_parser.go deleted file mode 100644 index ef57311..0000000 --- a/internal/dep_tree/test_parser.go +++ /dev/null @@ -1,47 +0,0 @@ -package dep_tree - -import ( - "errors" - "fmt" - "strconv" - - "github.com/gabotechs/dep-tree/internal/graph" -) - -type TestParser struct { - Spec [][]int -} - -var _ NodeParser[[]int] = &TestParser{} - -func (t *TestParser) Node(id string) (*graph.Node[[]int], error) { - idInt, err := strconv.Atoi(id) - if err != nil { - return nil, err - } - if idInt >= len(t.Spec) { - return nil, fmt.Errorf("%d not present in spec", idInt) - } else { - return graph.MakeNode(id, t.Spec[idInt]), nil - } -} - -func (t *TestParser) Deps(n *graph.Node[[]int]) ([]*graph.Node[[]int], error) { - result := make([]*graph.Node[[]int], 0) - for _, child := range n.Data { - if child < 0 { - return nil, errors.New("no negative children") - } - c, err := t.Node(strconv.Itoa(child)) - if err != nil { - n.Errors = append(n.Errors, err) - } else { - result = append(result, c) - } - } - return result, nil -} - -func (t *TestParser) Display(n *graph.Node[[]int]) DisplayResult { - return DisplayResult{Name: n.Id} -} diff --git a/internal/entropy/dirs.go b/internal/entropy/dirs.go index 5155cd0..fc04e07 100644 --- a/internal/entropy/dirs.go +++ b/internal/entropy/dirs.go @@ -8,7 +8,6 @@ import ( "github.com/elliotchance/orderedmap/v2" "github.com/gabotechs/dep-tree/internal/language" - "github.com/gabotechs/dep-tree/internal/utils" ) @@ -66,10 +65,10 @@ func (d *DirTree) AddDirs(dirs []string) { } } -func (d *DirTree) AddDirsFromDisplay(display language.DisplayResult) { - dirs := splitBaseNames(filepath.Dir(display.Name)) - if display.Group != "" { - d.AddDirs(utils.AppendFront(display.Group, dirs)) +func (d *DirTree) AddDirsFromFileInfo(info *language.FileInfo) { + dirs := splitBaseNames(filepath.Dir(info.RelPath)) + if info.Package != "" { + d.AddDirs(utils.AppendFront(info.Package, dirs)) } else { d.AddDirs(dirs) } @@ -120,10 +119,10 @@ func (d *DirTree) ColorForDir(dirs []string, format colorFormat) []float64 { } } -func (d *DirTree) ColorForDisplay(display language.DisplayResult, format colorFormat) []float64 { - dirs := splitBaseNames(filepath.Dir(display.Name)) - if display.Group != "" { - return d.ColorForDir(utils.AppendFront(display.Group, dirs), format) +func (d *DirTree) ColorForFileInfo(info *language.FileInfo, format colorFormat) []float64 { + dirs := splitBaseNames(filepath.Dir(info.RelPath)) + if info.Package != "" { + return d.ColorForDir(utils.AppendFront(info.Package, dirs), format) } else { return d.ColorForDir(dirs, format) } @@ -151,10 +150,10 @@ func (d *DirTree) GroupingsForDir(dirs []string) []string { return result } -func (d *DirTree) GroupingsForDisplay(display language.DisplayResult) []string { - dirs := splitBaseNames(filepath.Dir(display.Name)) - if display.Group != "" { - return d.GroupingsForDir(utils.AppendFront(display.Group, dirs)) +func (d *DirTree) GroupingsForFileInfo(info *language.FileInfo) []string { + dirs := splitBaseNames(filepath.Dir(info.RelPath)) + if info.Package != "" { + return d.GroupingsForDir(utils.AppendFront(info.Package, dirs)) } else { return d.GroupingsForDir(dirs) } diff --git a/internal/entropy/graph.go b/internal/entropy/graph.go index bb10f2c..be4f712 100644 --- a/internal/entropy/graph.go +++ b/internal/entropy/graph.go @@ -1,9 +1,9 @@ package entropy import ( + "fmt" "path/filepath" - "github.com/gabotechs/dep-tree/internal/dep_tree" "github.com/gabotechs/dep-tree/internal/graph" "github.com/gabotechs/dep-tree/internal/language" "github.com/gabotechs/dep-tree/internal/utils" @@ -45,14 +45,30 @@ func toInt(arr []float64) []int { return result } -func makeGraph(dt *dep_tree.DepTree[language.FileInfo], parser language.NodeParser) Graph { +func makeGraph(files []string, parser graph.NodeParser[*language.FileInfo], loadCallbacks graph.LoadCallbacks[*language.FileInfo]) (Graph, error) { + g := graph.NewGraph[*language.FileInfo]() + err := g.Load(files, parser, loadCallbacks) + if err != nil { + return Graph{}, err + } + var entrypoints []*graph.Node[*language.FileInfo] + if len(files) == 1 { + entrypoint := g.Get(files[0]) + if entrypoint == nil { + return Graph{}, fmt.Errorf("could not find entrypoint %s", files[0]) + } + entrypoints = append(entrypoints, entrypoint) + } else { + entrypoints = g.GetNodesWithoutParents() + } + cycles := g.RemoveCycles(entrypoints) out := Graph{ Nodes: make([]Node, 0), Links: make([]Link, 0), } - allNodes := dt.Graph.AllNodes() - maxLoc := max(utils.Max(allNodes, func(n *language.Node) int { + allNodes := g.AllNodes() + maxLoc := max(utils.Max(allNodes, func(n *graph.Node[*language.FileInfo]) int { return n.Data.Loc }), 1) @@ -61,30 +77,29 @@ func makeGraph(dt *dep_tree.DepTree[language.FileInfo], parser language.NodePars dirTree := NewDirTree() for _, node := range allNodes { - dirTree.AddDirsFromDisplay(parser.Display(node)) + dirTree.AddDirsFromFileInfo(node.Data) } for _, node := range allNodes { - display := parser.Display(node) - dirName := filepath.Dir(display.Name) + dirName := filepath.Dir(node.Data.RelPath) out.Nodes = append(out.Nodes, Node{ Id: node.ID(), - FileName: filepath.Base(display.Name), - Group: display.Group, + FileName: filepath.Base(node.Data.RelPath), + Group: node.Data.Package, DirName: dirName + "/", Loc: node.Data.Loc, Size: maxNodeSize * node.Data.Loc / maxLoc, - Color: toInt(dirTree.ColorForDisplay(display, RGB)), + Color: toInt(dirTree.ColorForFileInfo(node.Data, RGB)), }) - for _, to := range dt.Graph.FromId(node.Id) { + for _, to := range g.FromId(node.Id) { out.Links = append(out.Links, Link{ From: node.ID(), To: to.ID(), }) } - for _, parentFolder := range dirTree.GroupingsForDisplay(display) { + for _, parentFolder := range dirTree.GroupingsForFileInfo(node.Data) { folderNode := graph.MakeNode(parentFolder, 0) out.Links = append(out.Links, Link{ From: node.ID(), @@ -103,13 +118,13 @@ func makeGraph(dt *dep_tree.DepTree[language.FileInfo], parser language.NodePars } } - for el := dt.Cycles.Front(); el != nil; el = el.Next() { + for _, cycle := range cycles { out.Links = append(out.Links, Link{ - From: graph.MakeNode(el.Key[0], 0).ID(), - To: graph.MakeNode(el.Key[1], 0).ID(), + From: graph.MakeNode(cycle.Cause[0], 0).ID(), + To: graph.MakeNode(cycle.Cause[1], 0).ID(), IsCyclic: true, }) } - return out + return out, nil } diff --git a/internal/entropy/render.go b/internal/entropy/render.go index ebf953c..7aba886 100644 --- a/internal/entropy/render.go +++ b/internal/entropy/render.go @@ -8,7 +8,7 @@ import ( "os" "path/filepath" - "github.com/gabotechs/dep-tree/internal/dep_tree" + "github.com/gabotechs/dep-tree/internal/graph" "github.com/gabotechs/dep-tree/internal/language" ) @@ -19,21 +19,18 @@ const ToReplace = "const DATA = {}" const ReplacePrefix = "const DATA = " type RenderConfig struct { - NoOpen bool - EnableGui bool + NoOpen bool + EnableGui bool + LoadCallbacks graph.LoadCallbacks[*language.FileInfo] } -func Render(parser language.NodeParser, files []string, cfg RenderConfig) error { - dt := dep_tree.NewDepTree(parser, files).WithStdErrLoader() - err := dt.LoadGraph() +func Render(files []string, parser graph.NodeParser[*language.FileInfo], cfg RenderConfig) error { + graph3d, err := makeGraph(files, parser, cfg.LoadCallbacks) if err != nil { return err } - - dt.LoadCycles() - graph := makeGraph(dt, parser) - graph.EnableGui = cfg.EnableGui - marshaled, err := json.Marshal(graph) + graph3d.EnableGui = cfg.EnableGui + marshaled, err := json.Marshal(graph3d) if err != nil { return err } diff --git a/internal/graph/cycles.go b/internal/graph/cycles.go index 5e649ef..474d1d0 100644 --- a/internal/graph/cycles.go +++ b/internal/graph/cycles.go @@ -11,7 +11,7 @@ type Cycle struct { Stack []string } -func (g *Graph[T]) removeCycles(node *Node[T], callstack *utils.CallStack, done map[string]bool) []Cycle { +func (g *Graph[T]) removeCyclesStartingFromNode(node *Node[T], callstack *utils.CallStack, done map[string]bool) []Cycle { if done[node.Id] { return nil } @@ -37,18 +37,22 @@ func (g *Graph[T]) removeCycles(node *Node[T], callstack *utils.CallStack, done } var cycles []Cycle for _, toNode := range g.FromId(node.Id) { - cycles = append(cycles, g.removeCycles(toNode, callstack, done)...) + cycles = append(cycles, g.removeCyclesStartingFromNode(toNode, callstack, done)...) } done[node.Id] = true callstack.Pop() return cycles } -func (g *Graph[T]) RemoveCycles(node *Node[T]) []Cycle { - return g.removeCycles(node, utils.NewCallStack(), map[string]bool{}) +// RemoveCyclesStartingFromNode removes cycles that appear in order while performing a depth +// first search from a certain node. Note that this may not remove all cycles in the graph. +func (g *Graph[T]) RemoveCyclesStartingFromNode(node *Node[T]) []Cycle { + return g.removeCyclesStartingFromNode(node, utils.NewCallStack(), map[string]bool{}) } -func (g *Graph[T]) RemoveJohnsonCycles() []Cycle { +// RemoveElementaryCycles removes all the elementary cycles in the graph. The result +// of this can be non-deterministic. +func (g *Graph[T]) RemoveElementaryCycles() []Cycle { johnsonCycles := topo.DirectedCyclesIn(g) cycles := make([]Cycle, len(johnsonCycles)) for i, c := range johnsonCycles { @@ -65,3 +69,23 @@ func (g *Graph[T]) RemoveJohnsonCycles() []Cycle { return cycles } + +// RemoveCycles removes all cycles in the graph, giving preference to cycles that start +// from the provided nodes. +func (g *Graph[T]) RemoveCycles(nodes []*Node[T]) []Cycle { + cycles := make([]Cycle, 0) + + // First, remove the cycles computed from each entrypoint. This allows + // us trim the cycles in a more "controlled way" + for _, node := range nodes { + for _, cycle := range g.RemoveCyclesStartingFromNode(node) { + cycles = append(cycles, cycle) + } + } + // Then, remove the cycles computed without taking entrypoints into account. + // These are not as nice, as the rule for determining which cycles are trimmed is more arbitrary. + for _, cycle := range g.RemoveElementaryCycles() { + cycles = append(cycles, cycle) + } + return cycles +} diff --git a/internal/graph/cycles_test.go b/internal/graph/cycles_test.go index b49e4c4..a89cf25 100644 --- a/internal/graph/cycles_test.go +++ b/internal/graph/cycles_test.go @@ -122,7 +122,7 @@ func TestGraph_RemoveCycles(t *testing.T) { a := require.New(t) g := MakeTestGraph(tt.Children) - cycles := g.RemoveCycles(g.Get("0")) + cycles := g.RemoveCyclesStartingFromNode(g.Get("0")) var actualCycles [][]int var actualCauses [][2]int diff --git a/internal/graph/load.go b/internal/graph/load.go new file mode 100644 index 0000000..45ed8aa --- /dev/null +++ b/internal/graph/load.go @@ -0,0 +1,147 @@ +package graph + +import ( + "fmt" + "os" + "time" + + "github.com/gammazero/deque" + "github.com/schollz/progressbar/v3" +) + +type NodeParser[T any] interface { + Node(id string) (*Node[T], error) + Deps(node *Node[T]) ([]*Node[T], error) +} + +type NodeParserBuilder[T any] func([]string) (NodeParser[T], error) + +func (g *Graph[T]) Load(ids []string, parser NodeParser[T], callbacks LoadCallbacks[T]) error { + if callbacks == nil { + callbacks = &EmptyCallbacks[T]{} + } + visited := make(map[string]bool) + callbacks.onStartLoading() + + for _, id := range ids { + node, err := parser.Node(id) + if err != nil { + return err + } + var queue deque.Deque[*Node[T]] + queue.PushBack(node) + if !g.Has(node.Id) { + g.AddNode(node) + } + for queue.Len() > 0 { + node := queue.PopFront() + if _, ok := visited[node.Id]; ok { + continue + } + callbacks.onNodeStartLoad(node) + visited[node.Id] = true + + deps, err := parser.Deps(node) + if err != nil { + node.AddErrors(err) + continue + } + callbacks.onNodeFinishLoad(node, deps) + + for _, dep := range deps { + // No own child. + if dep.Id == node.Id { + continue + } + if !g.Has(dep.Id) { + g.AddNode(dep) + } + err = g.AddFromToEdge(node.Id, dep.Id) + queue.PushBack(dep) + if err != nil { + return err + } + } + } + } + callbacks.onFinishLoad() + + return nil +} + +type LoadCallbacks[T any] interface { + onStartLoading() + onNodeStartLoad(node *Node[T]) + onNodeFinishLoad(node *Node[T], deps []*Node[T]) + onFinishLoad() +} + +type EmptyCallbacks[T any] struct{} + +func (e EmptyCallbacks[T]) onStartLoading() {} +func (e EmptyCallbacks[T]) onNodeStartLoad(_ *Node[T]) {} +func (e EmptyCallbacks[T]) onNodeFinishLoad(_ *Node[T], _ []*Node[T]) {} +func (e EmptyCallbacks[T]) onFinishLoad() {} + +type TestCallbacks[T any] struct { + startLoad int + startNode int + finishLoad int + finishNode int +} + +func (t *TestCallbacks[T]) onStartLoading() { + t.startLoad++ +} +func (t *TestCallbacks[T]) onNodeStartLoad(_ *Node[T]) { + t.startNode++ +} +func (t *TestCallbacks[T]) onNodeFinishLoad(_ *Node[T], _ []*Node[T]) { + t.finishNode++ +} +func (t *TestCallbacks[T]) onFinishLoad() { + t.finishLoad++ +} + +type StdErrCallbacks[T any] struct { + bar *progressbar.ProgressBar + nodes map[string]struct{} + done int +} + +func NewStdErrCallbacks[T any]() *StdErrCallbacks[T] { + bar := progressbar.NewOptions64( + -1, + progressbar.OptionSetDescription("Loading graph..."), + progressbar.OptionSetWriter(os.Stderr), + progressbar.OptionSetWidth(10), + progressbar.OptionThrottle(65*time.Millisecond), + progressbar.OptionShowIts(), + progressbar.OptionSpinnerType(14), + progressbar.OptionFullWidth(), + progressbar.OptionSetRenderBlankState(true), + ) + return &StdErrCallbacks[T]{bar, map[string]struct{}{}, 0} +} + +func (s *StdErrCallbacks[T]) onStartLoading() { + s.bar.Reset() + s.nodes = map[string]struct{}{} + s.done = 0 +} +func (s *StdErrCallbacks[T]) onNodeStartLoad(node *Node[T]) { + s.done += 1 + _ = s.bar.Set(s.done) + s.bar.Describe(fmt.Sprintf("(%d/%d) Loading %s...", s.done, len(s.nodes), node.Id)) +} +func (s *StdErrCallbacks[T]) onNodeFinishLoad(node *Node[T], deps []*Node[T]) { + for _, n := range deps { + s.nodes[n.Id] = struct{}{} + } + s.bar.Describe(fmt.Sprintf("(%d/%d) Loading %s...", s.done, len(s.nodes), node.Id)) +} +func (s *StdErrCallbacks[T]) onFinishLoad() { + s.bar.Describe("Finished loading") + _ = s.bar.Finish() + _ = s.bar.Clear() +} diff --git a/internal/dep_tree/load_test.go b/internal/graph/load_test.go similarity index 68% rename from internal/dep_tree/load_test.go rename to internal/graph/load_test.go index 511fc81..b9cf592 100644 --- a/internal/dep_tree/load_test.go +++ b/internal/graph/load_test.go @@ -1,48 +1,46 @@ -package dep_tree +package graph import ( "strconv" "testing" - "github.com/gabotechs/dep-tree/internal/graph" "github.com/stretchr/testify/require" ) func TestLoadDeps_noOwnChild(t *testing.T) { a := require.New(t) - testGraph := &TestParser{ + testParser := TestParser{ Spec: [][]int{{0}}, } - dt := NewDepTree[[]int](testGraph, []string{"0"}) - err := dt.LoadGraph() + g := NewGraph[[]int]() + err := g.Load([]string{"0"}, &testParser, nil) a.NoError(err) - dt.LoadCycles() - a.Equal(0, len(dt.Graph.ToId("0"))) + a.Equal(0, len(g.ToId("0"))) } func TestLoadDeps_ErrorHandle(t *testing.T) { a := require.New(t) - testGraph := &TestParser{ + testParser := TestParser{ Spec: [][]int{ {1}, {2}, {-3}, }, } + g := NewGraph[[]int]() + err := g.Load([]string{"0"}, &testParser, nil) + a.NoError(err) - dt := NewDepTree[[]int](testGraph, []string{"0"}) + g.RemoveCycles([]*Node[[]int]{MakeNode("0", testParser.Spec[0])}) - err := dt.LoadGraph() - a.NoError(err) - dt.LoadCycles() - node0 := dt.Graph.Get("0") + node0 := g.Get("0") a.NotNil(node0) a.Equal(len(node0.Errors), 0) - node1 := dt.Graph.Get("1") + node1 := g.Get("1") a.NotNil(node1) a.Equal(len(node1.Errors), 0) - node2 := dt.Graph.Get("2") + node2 := g.Get("2") a.NotNil(node2) a.ErrorContains(node2.Errors[0], "no negative children") } @@ -50,7 +48,7 @@ func TestLoadDeps_ErrorHandle(t *testing.T) { func TestLoadDeps_Callbacks(t *testing.T) { a := require.New(t) - testGraph := &TestParser{ + testParser := TestParser{ Spec: [][]int{ 0: {1}, 1: {2}, @@ -61,30 +59,16 @@ func TestLoadDeps_Callbacks(t *testing.T) { }, } - dt := NewDepTree[[]int](testGraph, []string{"5", "0"}) - startLoad := 0 - startNode := 0 - finishLoad := 0 - finishNode := 0 - dt.onStartLoading = func() { - startLoad++ - } - dt.onFinishLoad = func() { - finishLoad++ - } - dt.onNodeStartLoad = func(_ *graph.Node[[]int]) { - startNode++ - } - dt.onNodeFinishLoad = func(_ *graph.Node[[]int], _ []*graph.Node[[]int]) { - finishNode++ - } - err := dt.LoadGraph() + testCallbacks := TestCallbacks[[]int]{} + + g := NewGraph[[]int]() + err := g.Load([]string{"0"}, &testParser, &testCallbacks) a.NoError(err) - a.Equal(1, startLoad) - a.Equal(6, startNode) - a.Equal(1, finishLoad) - a.Equal(6, finishNode) + a.Equal(1, testCallbacks.startLoad) + a.Equal(5, testCallbacks.startNode) + a.Equal(1, testCallbacks.finishLoad) + a.Equal(5, testCallbacks.finishNode) } func TestLoadDeps_loadGraph(t *testing.T) { @@ -191,25 +175,28 @@ func TestLoadDeps_loadGraph(t *testing.T) { for _, tt := range tests { t.Run(tt.Name, func(t *testing.T) { a := require.New(t) - testGraph := &TestParser{ + testParser := TestParser{ Spec: tt.Spec, } - var files []string + var ids []string for _, id := range tt.Ids { - files = append(files, string(rune(id))) + ids = append(ids, strconv.Itoa(id)) } - dt := NewDepTree[[]int](testGraph, []string{"0", "1", "2", "3", "4"}) - err := dt.LoadGraph() + g := NewGraph[[]int]() + err := g.Load(ids, &testParser, nil) a.NoError(err) - dt.LoadCycles() + + nodesWithoutParents := g.GetNodesWithoutParents() + cycles := g.RemoveCycles(nodesWithoutParents) + entrypoints := make([]int, 0) - for _, entrypoint := range dt.Entrypoints { + for _, entrypoint := range nodesWithoutParents { id, _ := strconv.Atoi(entrypoint.Id) entrypoints = append(entrypoints, id) } a.Equal(tt.Entrypoints, entrypoints) - a.Equal(tt.NCycles, dt.Cycles.Len()) + a.Equal(tt.NCycles, len(cycles)) }) } } diff --git a/internal/graph/testgraph.go b/internal/graph/test_utils.go similarity index 50% rename from internal/graph/testgraph.go rename to internal/graph/test_utils.go index 2ee44c3..4043308 100644 --- a/internal/graph/testgraph.go +++ b/internal/graph/test_utils.go @@ -1,6 +1,8 @@ package graph import ( + "errors" + "fmt" "strconv" "github.com/gammazero/deque" @@ -41,3 +43,37 @@ func MakeTestGraph(spec [][]int) *Graph[int] { } return g } + +type TestParser struct { + Spec [][]int +} + +var _ NodeParser[[]int] = &TestParser{} + +func (t *TestParser) Node(id string) (*Node[[]int], error) { + idInt, err := strconv.Atoi(id) + if err != nil { + return nil, err + } + if idInt >= len(t.Spec) { + return nil, fmt.Errorf("%d not present in spec", idInt) + } else { + return MakeNode(id, t.Spec[idInt]), nil + } +} + +func (t *TestParser) Deps(n *Node[[]int]) ([]*Node[[]int], error) { + result := make([]*Node[[]int], 0) + for _, child := range n.Data { + if child < 0 { + return nil, errors.New("no negative children") + } + c, err := t.Node(strconv.Itoa(child)) + if err != nil { + n.Errors = append(n.Errors, err) + } else { + result = append(result, c) + } + } + return result, nil +} diff --git a/internal/js/exports.go b/internal/js/exports.go index ce9cce7..91e1ea2 100644 --- a/internal/js/exports.go +++ b/internal/js/exports.go @@ -9,12 +9,12 @@ import ( type ExportsCacheKey string -//nolint:gocyclo -func (l *Language) ParseExports(file *js_grammar.File) (*language.ExportsEntries, error) { +func (l *Language) ParseExports(file *language.FileInfo) (*language.ExportsEntries, error) { exports := make([]language.ExportEntry, 0) var errors []error - for _, stmt := range file.Statements { + content := file.Content.(*js_grammar.File) + for _, stmt := range content.Statements { switch { case stmt == nil: // Is this even possible? @@ -25,7 +25,7 @@ func (l *Language) ParseExports(file *js_grammar.File) (*language.ExportsEntries Original: stmt.DeclarationExport.Name, }, }, - Path: file.Path, + Path: file.AbsPath, }) case stmt.ListExport != nil: if stmt.ListExport.ExportDeconstruction != nil { @@ -37,7 +37,7 @@ func (l *Language) ParseExports(file *js_grammar.File) (*language.ExportsEntries Alias: name.Alias, }, }, - Path: file.Path, + Path: file.AbsPath, }) } } @@ -49,11 +49,11 @@ func (l *Language) ParseExports(file *js_grammar.File) (*language.ExportsEntries Original: "default", }, }, - Path: file.Path, + Path: file.AbsPath, }) } case stmt.ProxyExport != nil: - exportFrom, err := l.ResolvePath(stmt.ProxyExport.From, filepath.Dir(file.Path)) + exportFrom, err := l.ResolvePath(stmt.ProxyExport.From, filepath.Dir(file.AbsPath)) if err != nil { errors = append(errors, err) continue diff --git a/internal/js/imports.go b/internal/js/imports.go index 228544a..8fe1448 100644 --- a/internal/js/imports.go +++ b/internal/js/imports.go @@ -7,11 +7,12 @@ import ( "github.com/gabotechs/dep-tree/internal/language" ) -func (l *Language) ParseImports(file *js_grammar.File) (*language.ImportsResult, error) { +func (l *Language) ParseImports(file *language.FileInfo) (*language.ImportsResult, error) { imports := make([]language.ImportEntry, 0) var errors []error - for _, stmt := range file.Statements { + content := file.Content.(*js_grammar.File) + for _, stmt := range content.Statements { importPath := "" entry := language.ImportEntry{} @@ -22,14 +23,14 @@ func (l *Language) ParseImports(file *js_grammar.File) (*language.ImportsResult, importPath = stmt.StaticImport.Path if imported := stmt.StaticImport.Imported; imported != nil { if imported.Default { - entry.Names = append(entry.Names, "default") + entry.Symbols = append(entry.Symbols, "default") } if selection := imported.SelectionImport; selection != nil { if selection.AllImport != nil { entry.All = true } if selection.Deconstruction != nil { - entry.Names = append(entry.Names, selection.Deconstruction.Names...) + entry.Symbols = append(entry.Symbols, selection.Deconstruction.Names...) } } } else { @@ -41,15 +42,15 @@ func (l *Language) ParseImports(file *js_grammar.File) (*language.ImportsResult, case stmt.Require != nil: importPath = stmt.Require.Path entry.All = stmt.Require.Alias != "" - entry.Names = stmt.Require.Names + entry.Symbols = stmt.Require.Names default: continue } var err error - entry.Path, err = l.ResolvePath(importPath, filepath.Dir(file.Path)) + entry.AbsPath, err = l.ResolvePath(importPath, filepath.Dir(file.AbsPath)) if err != nil { errors = append(errors, err) - } else if entry.Path != "" { + } else if entry.AbsPath != "" { imports = append(imports, entry) } } diff --git a/internal/js/imports_test.go b/internal/js/imports_test.go index 8506ae2..41ce811 100644 --- a/internal/js/imports_test.go +++ b/internal/js/imports_test.go @@ -25,14 +25,14 @@ func TestParser_parseImports(t *testing.T) { Name: "test 1", File: filepath.Join(importsTestFolder, "index.ts"), Expected: []language.ImportEntry{ - {Names: []string{"a", "b"}, Path: filepath.Join(wd, importsTestFolder, "2", "2.ts")}, - {All: true, Path: filepath.Join(wd, importsTestFolder, "2", "index.ts")}, - {All: true, Path: filepath.Join(wd, importsTestFolder, "1", "a", "a.ts")}, - {All: true, Path: filepath.Join(wd, importsTestFolder, "1", "a", "index.ts")}, - {Names: []string{"Unexisting"}, Path: filepath.Join(wd, importsTestFolder, "1", "a", "index.ts")}, - {All: true, Path: filepath.Join(wd, importsTestFolder, "2", "2.ts")}, - {Names: []string{"a", "b"}, Path: filepath.Join(wd, importsTestFolder, "2", "2.ts")}, - {Path: filepath.Join(wd, importsTestFolder, "1", "a", "index.ts")}, + {Symbols: []string{"a", "b"}, AbsPath: filepath.Join(wd, importsTestFolder, "2", "2.ts")}, + {All: true, AbsPath: filepath.Join(wd, importsTestFolder, "2", "index.ts")}, + {All: true, AbsPath: filepath.Join(wd, importsTestFolder, "1", "a", "a.ts")}, + {All: true, AbsPath: filepath.Join(wd, importsTestFolder, "1", "a", "index.ts")}, + {Symbols: []string{"Unexisting"}, AbsPath: filepath.Join(wd, importsTestFolder, "1", "a", "index.ts")}, + {All: true, AbsPath: filepath.Join(wd, importsTestFolder, "2", "2.ts")}, + {Symbols: []string{"a", "b"}, AbsPath: filepath.Join(wd, importsTestFolder, "2", "2.ts")}, + {AbsPath: filepath.Join(wd, importsTestFolder, "1", "a", "index.ts")}, }, ExpectedErrors: []string{ "could not perform relative import for './unexisting'", diff --git a/internal/js/js_grammar/grammar.go b/internal/js/js_grammar/grammar.go index 319b979..9f13cab 100644 --- a/internal/js/js_grammar/grammar.go +++ b/internal/js/js_grammar/grammar.go @@ -7,6 +7,7 @@ import ( "github.com/alecthomas/participle/v2" "github.com/alecthomas/participle/v2/lexer" + "github.com/gabotechs/dep-tree/internal/language" "github.com/gabotechs/dep-tree/internal/utils" ) @@ -25,17 +26,6 @@ type Statement struct { type File struct { Statements []*Statement `(@@ | ANY | ALL | Punct | Ident | String | BacktickString)*` - Path string - loc int - size int -} - -func (f File) Loc() int { - return f.loc -} - -func (f File) Size() int { - return f.size } var ( @@ -59,16 +49,19 @@ var ( ) ) -func Parse(filePath string) (*File, error) { +func Parse(filePath string) (*language.FileInfo, error) { content, err := os.ReadFile(filePath) if err != nil { return nil, err } - file, err := parser.ParseBytes(filePath, content) - if file != nil { - file.Path = filePath - file.loc = bytes.Count(content, []byte("\n")) - file.size = len(content) + statements, err := parser.ParseBytes(filePath, content) + if err != nil { + return nil, err } - return file, err + return &language.FileInfo{ + Content: statements, + Loc: bytes.Count(content, []byte("\n")), + Size: len(content), + AbsPath: filePath, + }, nil } diff --git a/internal/js/language.go b/internal/js/language.go index ff186e8..ab42b0b 100644 --- a/internal/js/language.go +++ b/internal/js/language.go @@ -16,7 +16,7 @@ type Language struct { Cfg *Config } -var _ language.Language[js_grammar.File] = &Language{} +var _ language.Language = &Language{} var findFirstPackageJsonWithNameCache = map[string]*packageJson{} @@ -32,27 +32,26 @@ func findFirstPackageJsonWithName(searchPath string) *packageJson { } } nextSearchPath := filepath.Dir(searchPath) - findFirstPackageJsonWithNameCache[searchPath] = findFirstPackageJsonWithName(nextSearchPath) - return findFirstPackageJsonWithNameCache[searchPath] -} - -func (l *Language) Display(id string) language.DisplayResult { - pkgJson := findFirstPackageJsonWithName(filepath.Dir(id)) - if pkgJson == nil { - return language.DisplayResult{Name: id} - } - - result, err := filepath.Rel(pkgJson.absPath, id) - if err != nil { - return language.DisplayResult{Name: id, Group: pkgJson.Name} + if nextSearchPath != searchPath { + findFirstPackageJsonWithNameCache[searchPath] = findFirstPackageJsonWithName(nextSearchPath) } - return language.DisplayResult{Name: result, Group: pkgJson.Name} + return findFirstPackageJsonWithNameCache[searchPath] } -func MakeJsLanguage(cfg *Config) (language.Language[js_grammar.File], error) { +func MakeJsLanguage(cfg *Config) (language.Language, error) { return &Language{Cfg: cfg}, nil } -func (l *Language) ParseFile(id string) (*js_grammar.File, error) { - return js_grammar.Parse(id) +func (l *Language) ParseFile(id string) (*language.FileInfo, error) { + fileInfo, err := js_grammar.Parse(id) + if err != nil { + return nil, err + } + pkgJson := findFirstPackageJsonWithName(filepath.Dir(id)) + if pkgJson == nil { + return fileInfo, nil + } + fileInfo.Package = pkgJson.Name + fileInfo.RelPath, _ = filepath.Rel(pkgJson.absPath, id) + return fileInfo, nil } diff --git a/internal/js/language_test.go b/internal/js/language_test.go index 2658f4d..65262dc 100644 --- a/internal/js/language_test.go +++ b/internal/js/language_test.go @@ -4,39 +4,33 @@ import ( "path/filepath" "testing" - "github.com/gabotechs/dep-tree/internal/language" "github.com/stretchr/testify/require" ) func TestLanguage_Display(t *testing.T) { tests := []struct { - Name string - Path string - Expected language.DisplayResult + Name string + Path string + ExpectedRelPath string + ExpectedPackage string }{ { - Name: "with a parent package.json", - Path: filepath.Join(resolverTestFolder, "src", "utils", "sum.ts"), - Expected: language.DisplayResult{ - Name: "src/utils/sum.ts", - Group: "test-project", - }, + Name: "with a parent package.json", + Path: filepath.Join(resolverTestFolder, "src", "utils", "sum.ts"), + ExpectedPackage: "test-project", + ExpectedRelPath: "src/utils/sum.ts", }, { - Name: "with a parent package.json (same as above for checking cache)", - Path: filepath.Join(resolverTestFolder, "src", "utils", "sum.ts"), - Expected: language.DisplayResult{ - Name: "src/utils/sum.ts", - Group: "test-project", - }, + Name: "with a parent package.json (same as above for checking cache)", + Path: filepath.Join(resolverTestFolder, "src", "utils", "sum.ts"), + ExpectedPackage: "test-project", + ExpectedRelPath: "src/utils/sum.ts", }, { - Name: "with two parent package.json, one without name", - Path: filepath.Join(resolverTestFolder, "src", "module", "main.ts"), - Expected: language.DisplayResult{ - Name: "src/module/main.ts", - Group: "test-project", - }, + Name: "with two parent package.json, one without name", + Path: filepath.Join(resolverTestFolder, "src", "module", "main.ts"), + ExpectedPackage: "test-project", + ExpectedRelPath: "src/module/main.ts", }, } @@ -46,8 +40,11 @@ func TestLanguage_Display(t *testing.T) { _lang, err := MakeJsLanguage(nil) a.NoError(err) lang := _lang.(*Language) - abs, _ := filepath.Abs(tt.Path) - a.Equal(tt.Expected, lang.Display(abs)) + absPath, _ := filepath.Abs(tt.Path) + file, err := lang.ParseFile(absPath) + a.NoError(err) + a.Equal(tt.ExpectedPackage, file.Package) + a.Equal(tt.ExpectedRelPath, file.RelPath) }) } } diff --git a/internal/language/exports.go b/internal/language/exports.go index 599c556..013e187 100644 --- a/internal/language/exports.go +++ b/internal/language/exports.go @@ -39,15 +39,7 @@ type ExportsEntries struct { Errors []error } -type ExportsResult struct { - // Exports: map from exported name to exported path. - Exports *orderedmap.OrderedMap[string, string] - // Errors: errors gathered while resolving exports. - Errors []error -} - -//nolint:gocyclo -func (p *Parser[F]) parseExports( +func (p *Parser) parseExports( id string, unwrappedExports bool, stack *utils.CallStack, @@ -60,7 +52,7 @@ func (p *Parser[F]) parseExports( } defer stack.Pop() cacheKey := fmt.Sprintf("%s-%t", id, unwrappedExports) - if cached, ok := p.exportsCache[cacheKey]; ok { + if cached, ok := p.ExportsCache[cacheKey]; ok { return cached, nil } @@ -69,7 +61,7 @@ func (p *Parser[F]) parseExports( return nil, err } - wrapped, err := p.lang.ParseExports(file) + wrapped, err := p.Lang.ParseExports(file) if err != nil { return nil, err } @@ -93,7 +85,7 @@ func (p *Parser[F]) parseExports( } if export.All { - for el := unwrapped.Exports.Front(); el != nil; el = el.Next() { + for el := unwrapped.Symbols.Front(); el != nil; el = el.Next() { if unwrappedExports { exports.Set(el.Key, el.Value) } else { @@ -105,7 +97,7 @@ func (p *Parser[F]) parseExports( exportErrors = append(exportErrors, unwrapped.Errors...) for _, name := range export.Names { - if exportPath, ok := unwrapped.Exports.Get(name.Original); ok { + if exportPath, ok := unwrapped.Symbols.Get(name.Original); ok { if unwrappedExports { exports.Set(name.name(), exportPath) } else { @@ -118,7 +110,7 @@ func (p *Parser[F]) parseExports( } } - result := ExportsResult{Exports: exports, Errors: exportErrors} - p.exportsCache[cacheKey] = &result + result := ExportsResult{Symbols: exports, Errors: exportErrors} + p.ExportsCache[cacheKey] = &result return &result, nil } diff --git a/internal/language/exports_test.go b/internal/language/exports_test.go index 7e8cdbc..d1b3365 100644 --- a/internal/language/exports_test.go +++ b/internal/language/exports_test.go @@ -275,12 +275,12 @@ func TestParser_CachedUnwrappedParseExports(t *testing.T) { exports, err := parser.parseExports("1", true, nil) a.NoError(err) - a.Equal(tt.ExpectedUnwrapped, exports.Exports) + a.Equal(tt.ExpectedUnwrapped, exports.Symbols) exports, err = parser.parseExports("1", false, nil) a.NoError(err) - a.Equal(tt.ExpectedWrapped, exports.Exports) + a.Equal(tt.ExpectedWrapped, exports.Symbols) var expectedErrors []error for _, expectedError := range tt.ExpectedErrors { expectedErrors = append(expectedErrors, errors.New(expectedError)) diff --git a/internal/language/file.go b/internal/language/file.go index f4faf13..8b9439c 100644 --- a/internal/language/file.go +++ b/internal/language/file.go @@ -1,15 +1,13 @@ package language -type FileCacheKey string - -func (p *Parser[F]) parseFile(id string) (*F, error) { - if cached, ok := p.fileCache[id]; ok { +func (p *Parser) parseFile(absPath string) (*FileInfo, error) { + if cached, ok := p.FileCache[absPath]; ok { return cached, nil } - result, err := p.lang.ParseFile(id) + result, err := p.Lang.ParseFile(absPath) if err != nil { return nil, err } - p.fileCache[id] = result + p.FileCache[absPath] = result return result, err } diff --git a/internal/language/imports.go b/internal/language/imports.go index f1e60ea..272226b 100644 --- a/internal/language/imports.go +++ b/internal/language/imports.go @@ -1,48 +1,17 @@ package language -type ImportEntry struct { - // All: if all the names from Path are imported. - All bool - // Names: what specific names form Path are imported. - Names []string - // Path: from where are the names imported. - Path string -} - -func AllImport(path string) ImportEntry { - return ImportEntry{All: true, Path: path} -} - -func EmptyImport(path string) ImportEntry { - return ImportEntry{Path: path} -} - -func NamesImport(names []string, path string) ImportEntry { - return ImportEntry{Names: names, Path: path} -} - -type ImportsResult struct { - // Imports: ordered map from absolute imported path to the array of names that where imported. - // if one of the names is *, then all the names are imported - Imports []ImportEntry - // Errors: errors while parsing imports. - Errors []error -} - -type ImportsCacheKey string - -func (p *Parser[F]) gatherImportsFromFile(id string) (*ImportsResult, error) { - if cached, ok := p.importsCache[id]; ok { +func (p *Parser) gatherImportsFromFile(id string) (*ImportsResult, error) { + if cached, ok := p.ImportsCache[id]; ok { return cached, nil } file, err := p.parseFile(id) if err != nil { return nil, err } - result, err := p.lang.ParseImports(file) + result, err := p.Lang.ParseImports(file) if err != nil { return nil, err } - p.importsCache[id] = result + p.ImportsCache[id] = result return result, err } diff --git a/internal/language/imports_test.go b/internal/language/imports_test.go index 25e9349..40d74a7 100644 --- a/internal/language/imports_test.go +++ b/internal/language/imports_test.go @@ -13,7 +13,7 @@ func TestParser_parseImports_IsCached(t *testing.T) { imports: map[string]*ImportsResult{ "1": { Imports: []ImportEntry{ - {All: true, Path: "2"}, + {All: true, AbsPath: "2"}, }, }, }, diff --git a/internal/language/language.go b/internal/language/language.go new file mode 100644 index 0000000..63ddcb1 --- /dev/null +++ b/internal/language/language.go @@ -0,0 +1,100 @@ +package language + +import "github.com/elliotchance/orderedmap/v2" + +// FileInfo gathers all the information related to a source file. +type FileInfo struct { + // Content is a bucket for language implementations to inject some language-specific data in it, + // like parsed statements. + Content any + // AbsPath is the absolute path of the source file. + AbsPath string + // RelPath is the path relative to the root of the project. Different programming languages might + // choose to decide what is the "root of the project". For example, JS might be where the nearest + // package.json is located. + RelPath string + // Package is the name of the package/module/workspace where the source file is located. Each + // language implementation is in charge of deciding what is a package. For example, for JS/TS + // Package might be the "name" field of the closest package.json file, for rust the name of the + // cargo workspace where the file belongs to. + Package string + // Loc is the amount of lines of code a file has. + Loc int + // Size is the size in bytes of the file. + Size int +} + +// ImportEntry represent an import statement in a programming language. +type ImportEntry struct { + // All is true if all the symbols from another source file are imported. Some programming languages + // allow importing all the symbols: + // JS -> import * from './foo' + // Python -> from .foo import * + // Rust -> use crate::foo::*; + All bool + // Symbols are the specific symbols that are imported from another source file. Some programming languages + // allow to import only specific symbols: + // JS -> import { bar } from './foo' + // Python -> from .foo import bar + // Rust -> use crate::foo::bar; + Symbols []string + // AbsPath is the absolute path of the source file from where are the symbols are import imported. + // For example, having file /foo/bar.ts with the following content + // import { baz } from './baz' + // will result in an ImportEntry with AbsPath = /foo/baz.ts + AbsPath string +} + +// AllImport builds an ImportEntry where all the symbols are imported. +func AllImport(absPath string) ImportEntry { + return ImportEntry{All: true, AbsPath: absPath} +} + +// EmptyImport builds an ImportEntry where nothing specific is imported, like a side effect import. +func EmptyImport(absPath string) ImportEntry { + return ImportEntry{AbsPath: absPath} +} + +// SymbolsImport builds an ImportEntry where only specific symbols are imported. +func SymbolsImport(symbols []string, absPath string) ImportEntry { + return ImportEntry{Symbols: symbols, AbsPath: absPath} +} + +// ImportsResult is the result of gathering all the import statements from +// a source file. +type ImportsResult struct { + // Imports is the list of ImportEntry for the source file. + Imports []ImportEntry + // Errors are the non-fatal errors that occurred while parsing imports. These + // might be rendered nicely in a UI. + Errors []error +} + +// ExportsResult is the result of gathering all the export statements from +// a source file, in case the language implementation explicitly exports certain files. +type ExportsResult struct { + // Symbols is an ordered map data structure where the keys are the symbols exported from + // the source file and the values are path from where they are declared. Symbols might + // be declared in a different path from where they are exported, for example: + // + // export { foo } from './bar' + // + // the `foo` symbol is being exported from the current file, but it's declared on the + // `bar.ts` file. + Symbols *orderedmap.OrderedMap[string, string] + // Errors are the non-fatal errors that occurred while parsing exports. These + // might be rendered nicely in a UI. + Errors []error +} + +type Language interface { + // ParseFile receives an absolute file path and returns F, where F is the specific file implementation + // defined by the language. This file object F will be used as input for parsing imports and exports. + ParseFile(path string) (*FileInfo, error) + // ParseImports receives the file F parsed by the ParseFile method and gathers the imports that the file + // F contains. + ParseImports(file *FileInfo) (*ImportsResult, error) + // ParseExports receives the file F parsed by the ParseFile method and gathers the exports that the file + // F contains. + ParseExports(file *FileInfo) (*ExportsEntries, error) +} diff --git a/internal/language/parser.go b/internal/language/parser.go index 4ea4dc9..0afe1b9 100644 --- a/internal/language/parser.go +++ b/internal/language/parser.go @@ -2,87 +2,35 @@ package language import ( "github.com/elliotchance/orderedmap/v2" - "github.com/gabotechs/dep-tree/internal/dep_tree" "github.com/gabotechs/dep-tree/internal/graph" "github.com/gabotechs/dep-tree/internal/utils" ) -type Node = graph.Node[FileInfo] -type Graph = graph.Graph[FileInfo] -type NodeParser = dep_tree.NodeParser[FileInfo] -type NodeParserBuilder = dep_tree.NodeParserBuilder[FileInfo] -type DisplayResult = dep_tree.DisplayResult - -type FileInfo struct { - Loc int - Size int -} - -type CodeFile interface { - Loc() int - Size() int -} - -type Language[F CodeFile] interface { - // ParseFile receives an absolute file path and returns F, where F is the specific file implementation - // defined by the language. This file object F will be used as input for parsing imports and exports. - ParseFile(path string) (*F, error) - // ParseImports receives the file F parsed by the ParseFile method and gathers the imports that the file - // F contains. - ParseImports(file *F) (*ImportsResult, error) - // ParseExports receives the file F parsed by the ParseFile method and gathers the exports that the file - // F contains. - ParseExports(file *F) (*ExportsEntries, error) - // Display takes an absolute path to a file and displays it nicely. - Display(path string) DisplayResult -} - -type Parser[F CodeFile] struct { - lang Language[F] - unwrapProxyExports bool - exclude []string +type Parser struct { + Lang Language + UnwrapProxyExports bool + Exclude []string // cache - fileCache map[string]*F - importsCache map[string]*ImportsResult - exportsCache map[string]*ExportsResult + FileCache map[string]*FileInfo + ImportsCache map[string]*ImportsResult + ExportsCache map[string]*ExportsResult } -var _ NodeParser = &Parser[CodeFile]{} - -type Config interface { - UnwrapProxyExports() bool - IgnoreFiles() []string -} - -type Builder[F CodeFile, C any] func(C) (Language[F], error) - -func ParserBuilder[F CodeFile, C any](languageBuilder Builder[F, C], langCfg C, generalCfg Config) NodeParserBuilder { - fileCache := map[string]*F{} - importsCache := map[string]*ImportsResult{} - exportsCache := map[string]*ExportsResult{} - return func(files []string) (NodeParser, error) { - lang, err := languageBuilder(langCfg) - if err != nil { - return nil, err - } - - parser := &Parser[F]{ - lang: lang, - unwrapProxyExports: true, - fileCache: fileCache, - importsCache: importsCache, - exportsCache: exportsCache, - } - if generalCfg != nil { - parser.unwrapProxyExports = generalCfg.UnwrapProxyExports() - parser.exclude = generalCfg.IgnoreFiles() - } - return parser, err +func NewParser(lang Language) *Parser { + return &Parser{ + Lang: lang, + UnwrapProxyExports: false, + Exclude: nil, + FileCache: make(map[string]*FileInfo), + ImportsCache: make(map[string]*ImportsResult), + ExportsCache: make(map[string]*ExportsResult), } } -func (p *Parser[F]) shouldExclude(path string) bool { - for _, exclusion := range p.exclude { +var _ graph.NodeParser[*FileInfo] = &Parser{} + +func (p *Parser) shouldExclude(path string) bool { + for _, exclusion := range p.Exclude { if ok, _ := utils.GlobstarMatch(exclusion, path); ok { return true } @@ -90,22 +38,15 @@ func (p *Parser[F]) shouldExclude(path string) bool { return false } -func (p *Parser[F]) Node(id string) (*Node, error) { - return graph.MakeNode(id, FileInfo{}), nil -} - -func (p *Parser[F]) updateNodeInfo(n *Node) error { - file, err := p.parseFile(n.Id) +func (p *Parser) Node(id string) (*graph.Node[*FileInfo], error) { + file, err := p.parseFile(id) if err != nil { - return err + return nil, err } - n.Data.Size = (*file).Size() - n.Data.Loc = (*file).Loc() - return nil + return graph.MakeNode(id, file), nil } -func (p *Parser[F]) Deps(n *Node) ([]*Node, error) { - _ = p.updateNodeInfo(n) +func (p *Parser) Deps(n *graph.Node[*FileInfo]) ([]*graph.Node[*FileInfo], error) { imports, err := p.gatherImportsFromFile(n.Id) if err != nil { return nil, err @@ -113,9 +54,9 @@ func (p *Parser[F]) Deps(n *Node) ([]*Node, error) { n.AddErrors(imports.Errors...) // Some exports might be re-exporting symbols from other files, we consider - // those as if they where normal imports. + // those as if they were normal imports. // - // TODO: if exports are parsed as imports, they might say that that a name is being + // NOTE: if exports are parsed as imports, they might say that a name is being // imported from a path when it's actually not available. // ex: // index.ts -> import { foo } from 'foo.ts' @@ -124,17 +65,17 @@ func (p *Parser[F]) Deps(n *Node) ([]*Node, error) { // If unwrappedExports is true, this will say that `foo` is exported from `bar.ts`, which // technically is true, but it's not true to say that `foo` is imported from `bar.ts`. // It's more accurate to say that `bar` is imported from `bar.ts`, even if the alias is `foo`. - // Instead we never unwrap export to avoid this. + // Instead, we never unwrap export to avoid this. exports, err := p.parseExports(n.Id, false, nil) if err != nil { return nil, err } n.AddErrors(exports.Errors...) - for el := exports.Exports.Front(); el != nil; el = el.Next() { + for el := exports.Symbols.Front(); el != nil; el = el.Next() { if el.Value != n.Id { imports.Imports = append(imports.Imports, ImportEntry{ - Names: []string{el.Key}, - Path: el.Value, + Symbols: []string{el.Key}, + AbsPath: el.Value, }) } } @@ -145,46 +86,47 @@ func (p *Parser[F]) Deps(n *Node) ([]*Node, error) { // a different file, we want that file. Ex: foo.ts -> utils/index.ts -> utils/sum.ts. If unwrapProxyExports is // set to true, we must trace those exports back. for _, importEntry := range imports.Imports { - if !p.unwrapProxyExports { - resolvedImports.Set(importEntry.Path, true) + if !p.UnwrapProxyExports { + resolvedImports.Set(importEntry.AbsPath, true) continue } // NOTE: at this point p.unwrapProxyExports is always true. - exports, err = p.parseExports(importEntry.Path, p.unwrapProxyExports, nil) + exports, err = p.parseExports(importEntry.AbsPath, p.UnwrapProxyExports, nil) if err != nil { return nil, err } n.AddErrors(exports.Errors...) if importEntry.All { // If all imported, then dump every path in the resolved imports. - for el := exports.Exports.Front(); el != nil; el = el.Next() { + for el := exports.Symbols.Front(); el != nil; el = el.Next() { resolvedImports.Set(el.Value, true) } - continue - } else if len(importEntry.Names) == 0 { - resolvedImports.Set(importEntry.Path, true) - } - for _, name := range importEntry.Names { - if exportPath, ok := exports.Exports.Get(name); ok { - resolvedImports.Set(exportPath, true) - } else { - // TODO: this is not retro-compatible, do it in a different PR. - // n.AddErrors(fmt.Errorf("name %s is imported by %s but not exported by %s", name, n.Id, importEntry.Id)). + } else if len(importEntry.Symbols) == 0 { + resolvedImports.Set(importEntry.AbsPath, true) + } else { + for _, name := range importEntry.Symbols { + if exportPath, ok := exports.Symbols.Get(name); ok { + resolvedImports.Set(exportPath, true) + } else { + // TODO: this is not retro-compatible, do it in a different PR. + // n.AddErrors(fmt.Errorf("name %s is imported by %s but not exported by %s", name, n.Id, importEntry.Id)). + } } } } - deps := make([]*Node, 0) + deps := make([]*graph.Node[*FileInfo], 0) for _, imported := range resolvedImports.Keys() { - node := graph.MakeNode(imported, FileInfo{}) - if !p.shouldExclude(p.Display(node).Name) { - deps = append(deps, node) + if p.shouldExclude(imported) { + continue } + node, err := p.Node(imported) + if err != nil { + n.AddErrors(err) + continue + } + deps = append(deps, node) } return deps, nil } - -func (p *Parser[F]) Display(n *Node) dep_tree.DisplayResult { - return p.lang.Display(n.Id) -} diff --git a/internal/language/parser_test.go b/internal/language/parser_test.go index 7314360..997025c 100644 --- a/internal/language/parser_test.go +++ b/internal/language/parser_test.go @@ -6,6 +6,60 @@ import ( "github.com/stretchr/testify/require" ) +func TestParser_shouldExclude(t *testing.T) { + tests := []struct { + Name string + Paths []string + Exclude []string + Expected []string + }{ + { + Name: "simple", + Paths: []string{"/foo/bar.ts", "/foo/baz.ts"}, + Exclude: []string{"/foo/bar.ts"}, + Expected: []string{"/foo/baz.ts"}, + }, + { + Name: "globstar", + Paths: []string{"/foo/bar.ts", "/foo/baz.ts"}, + Exclude: []string{"/foo/*.ts"}, + Expected: nil, + }, + { + Name: "globstar 2", + Paths: []string{"/foo/1/2/3/4/bar.ts", "/foo/baz.ts"}, + Exclude: []string{"/foo/**/*.ts"}, + Expected: nil, + }, + { + Name: "globstar 3", + Paths: []string{"/foo/1/2/3/4/bar.ts", "/foo/baz.ts"}, + Exclude: []string{"2/**/*.ts"}, + Expected: []string{"/foo/1/2/3/4/bar.ts", "/foo/baz.ts"}, + }, + { + Name: "globstar 4", + Paths: []string{"/foo/1/2/3/4/bar.ts", "/foo/baz.ts"}, + Exclude: []string{"*/2/**/*.ts"}, + Expected: []string{"/foo/1/2/3/4/bar.ts", "/foo/baz.ts"}, + }, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + a := require.New(t) + parser := Parser{Exclude: tt.Exclude} + var result []string + for _, path := range tt.Paths { + if !parser.shouldExclude(path) { + result = append(result, path) + } + } + a.Equal(tt.Expected, result) + }) + } +} + func TestParser_Deps(t *testing.T) { tests := []struct { Name string @@ -21,7 +75,7 @@ func TestParser_Deps(t *testing.T) { Imports: map[string]*ImportsResult{ "1": { Imports: []ImportEntry{ - {All: true, Path: "2"}, + {All: true, AbsPath: "2"}, }, }, }, @@ -74,7 +128,7 @@ func TestParser_Deps(t *testing.T) { Imports: map[string]*ImportsResult{ "1": { Imports: []ImportEntry{ - {All: true, Path: "2"}, + {All: true, AbsPath: "2"}, }, }, }, @@ -202,7 +256,7 @@ func TestParser_Deps(t *testing.T) { exports: tt.Exports, } parser := lang.testParser() - parser.unwrapProxyExports = true + parser.UnwrapProxyExports = true node, err := parser.Node(tt.Path) a.NoError(err) deps, err := parser.Deps(node) @@ -214,7 +268,7 @@ func TestParser_Deps(t *testing.T) { } a.Equal(tt.ExpectedUnwrapped, result) - parser.unwrapProxyExports = false + parser.UnwrapProxyExports = false deps, err = parser.Deps(node) a.NoError(err) @@ -242,8 +296,8 @@ func TestParser_DepsErrors(t *testing.T) { Imports: map[string]*ImportsResult{ "1": {Imports: []ImportEntry{ { - Names: []string{"foo"}, - Path: "2", + Symbols: []string{"foo"}, + AbsPath: "2", }, }}, }, diff --git a/internal/language/test_language.go b/internal/language/test_language.go index fb7263a..5dc4aad 100644 --- a/internal/language/test_language.go +++ b/internal/language/test_language.go @@ -3,63 +3,51 @@ package language import ( "errors" "time" - - "github.com/gabotechs/dep-tree/internal/dep_tree" ) -type TestFile struct { +type TestFileContent struct { Name string } -func (t TestFile) Loc() int { - return 0 -} - -func (t TestFile) Size() int { - return 0 -} - type TestLanguage struct { imports map[string]*ImportsResult exports map[string]*ExportsEntries } -func (t *TestLanguage) testParser() *Parser[TestFile] { - return &Parser[TestFile]{ - lang: t, - fileCache: map[string]*TestFile{}, - importsCache: map[string]*ImportsResult{}, - exportsCache: map[string]*ExportsResult{}, +func (t *TestLanguage) testParser() *Parser { + return &Parser{ + Lang: t, + FileCache: map[string]*FileInfo{}, + ImportsCache: map[string]*ImportsResult{}, + ExportsCache: map[string]*ExportsResult{}, } } -var _ Language[TestFile] = &TestLanguage{} +var _ Language = &TestLanguage{} -func (t *TestLanguage) ParseFile(id string) (*TestFile, error) { +func (t *TestLanguage) ParseFile(id string) (*FileInfo, error) { time.Sleep(time.Millisecond) - return &TestFile{ - Name: id, + return &FileInfo{ + Content: TestFileContent{id}, }, nil } -func (t *TestLanguage) ParseImports(file *TestFile) (*ImportsResult, error) { +func (t *TestLanguage) ParseImports(file *FileInfo) (*ImportsResult, error) { time.Sleep(time.Millisecond) - if imports, ok := t.imports[file.Name]; ok { + content := file.Content.(TestFileContent) + if imports, ok := t.imports[content.Name]; ok { return imports, nil } else { - return imports, errors.New(file.Name + " not found") + return imports, errors.New(content.Name + " not found") } } -func (t *TestLanguage) ParseExports(file *TestFile) (*ExportsEntries, error) { +func (t *TestLanguage) ParseExports(file *FileInfo) (*ExportsEntries, error) { time.Sleep(time.Millisecond) - if exports, ok := t.exports[file.Name]; ok { + content := file.Content.(TestFileContent) + if exports, ok := t.exports[content.Name]; ok { return exports, nil } else { - return exports, errors.New(file.Name + " not found") + return exports, errors.New(content.Name + " not found") } } - -func (t *TestLanguage) Display(id string) dep_tree.DisplayResult { - return dep_tree.DisplayResult{Name: id} -} diff --git a/internal/python/exports.go b/internal/python/exports.go index 460e829..d984705 100644 --- a/internal/python/exports.go +++ b/internal/python/exports.go @@ -36,10 +36,12 @@ func (l *Language) handleFromImportForExport(imp *python_grammar.FromImport, fil } //nolint:gocyclo -func (l *Language) ParseExports(file *python_grammar.File) (*language.ExportsEntries, error) { +func (l *Language) ParseExports(file *language.FileInfo) (*language.ExportsEntries, error) { var exports []language.ExportEntry var errors []error - for _, stmt := range file.Statements { + + content := file.Content.(*python_grammar.File) + for _, stmt := range content.Statements { switch { case stmt == nil: continue @@ -51,10 +53,10 @@ func (l *Language) ParseExports(file *python_grammar.File) (*language.ExportsEnt Alias: stmt.Import.Alias, }, }, - Path: file.Path, + Path: file.AbsPath, }) case stmt.FromImport != nil && !stmt.FromImport.Indented && !l.cfg.IgnoreFromImportsAsExports: - newExports, err := l.handleFromImportForExport(stmt.FromImport, file.Path) + newExports, err := l.handleFromImportForExport(stmt.FromImport, file.AbsPath) if err != nil { errors = append(errors, err) } else { @@ -64,7 +66,7 @@ func (l *Language) ParseExports(file *python_grammar.File) (*language.ExportsEnt case stmt.VariableUnpack != nil: entry := language.ExportEntry{ Names: make([]language.ExportName, len(stmt.VariableUnpack.Names)), - Path: file.Path, + Path: file.AbsPath, } for i, name := range stmt.VariableUnpack.Names { entry.Names[i] = language.ExportName{Original: name} @@ -73,7 +75,7 @@ func (l *Language) ParseExports(file *python_grammar.File) (*language.ExportsEnt case stmt.VariableAssign != nil: entry := language.ExportEntry{ Names: make([]language.ExportName, len(stmt.VariableAssign.Names)), - Path: file.Path, + Path: file.AbsPath, } for i, name := range stmt.VariableAssign.Names { entry.Names[i] = language.ExportName{Original: name} @@ -82,17 +84,17 @@ func (l *Language) ParseExports(file *python_grammar.File) (*language.ExportsEnt case stmt.VariableTyping != nil: exports = append(exports, language.ExportEntry{ Names: []language.ExportName{{Original: stmt.VariableTyping.Name}}, - Path: file.Path, + Path: file.AbsPath, }) case stmt.Function != nil: exports = append(exports, language.ExportEntry{ Names: []language.ExportName{{Original: stmt.Function.Name}}, - Path: file.Path, + Path: file.AbsPath, }) case stmt.Class != nil: exports = append(exports, language.ExportEntry{ Names: []language.ExportName{{Original: stmt.Class.Name}}, - Path: file.Path, + Path: file.AbsPath, }) } } diff --git a/internal/python/imports.go b/internal/python/imports.go index bdbd93a..01b9442 100644 --- a/internal/python/imports.go +++ b/internal/python/imports.go @@ -61,7 +61,7 @@ func handleFromImportNames(resolved *ResolveResult, names []string) ([]language. switch { // `from my_file import foo, bar` -> names foo and bar from my_file.py are imported. case resolved.File != nil: - return []language.ImportEntry{language.NamesImport(names, resolved.File.Path)}, nil + return []language.ImportEntry{language.SymbolsImport(names, resolved.File.Path)}, nil // `from my_module import foo, bar` -> names foo and bar from the my_module/__init__.py file are imported. // It might happen that some of those names are actually Python files (e.g. my_module/foo.py). @@ -84,7 +84,7 @@ func handleFromImportNames(resolved *ResolveResult, names []string) ([]language. } } if namesFromInit != nil { - imports = append(imports, language.NamesImport(namesFromInit, resolved.InitModule.Path)) + imports = append(imports, language.SymbolsImport(namesFromInit, resolved.InitModule.Path)) } return imports, nil @@ -128,18 +128,19 @@ func (l *Language) handleFromImport(imp *python_grammar.FromImport, currDir stri } } -func (l *Language) ParseImports(file *python_grammar.File) (*language.ImportsResult, error) { +func (l *Language) ParseImports(file *language.FileInfo) (*language.ImportsResult, error) { imports := make([]language.ImportEntry, 0) var errors []error - for _, stmt := range file.Statements { + content := file.Content.(*python_grammar.File) + for _, stmt := range content.Statements { switch { case stmt == nil: // Is this even possible? case stmt.Import != nil: - imports = append(imports, l.handleImport(stmt.Import, filepath.Dir(file.Path))...) + imports = append(imports, l.handleImport(stmt.Import, filepath.Dir(file.AbsPath))...) case stmt.FromImport != nil: - newImports, err := l.handleFromImport(stmt.FromImport, filepath.Dir(file.Path)) + newImports, err := l.handleFromImport(stmt.FromImport, filepath.Dir(file.AbsPath)) imports = append(imports, newImports...) if err != nil { errors = append(errors, err) diff --git a/internal/python/imports_test.go b/internal/python/imports_test.go index d581c05..0e3dfcd 100644 --- a/internal/python/imports_test.go +++ b/internal/python/imports_test.go @@ -32,12 +32,12 @@ func TestLanguage_ParseImports(t *testing.T) { language.EmptyImport(filepath.Join(importsTestFolder, "src", "main.py")), language.EmptyImport(filepath.Join(importsTestFolder, "src", "main.py")), language.EmptyImport(filepath.Join(importsTestFolder, "src", "module", "__init__.py")), - language.NamesImport([]string{"main"}, filepath.Join(importsTestFolder, "src", "main.py")), + language.SymbolsImport([]string{"main"}, filepath.Join(importsTestFolder, "src", "main.py")), language.EmptyImport(filepath.Join(importsTestFolder, "src", "main.py")), - language.NamesImport([]string{"main"}, filepath.Join(importsTestFolder, "src", "main.py")), + language.SymbolsImport([]string{"main"}, filepath.Join(importsTestFolder, "src", "main.py")), language.AllImport(filepath.Join(importsTestFolder, "src", "module", "__init__.py")), language.EmptyImport(filepath.Join(importsTestFolder, "src", "module", "module.py")), - language.NamesImport([]string{"bar"}, filepath.Join(importsTestFolder, "src", "module", "__init__.py")), + language.SymbolsImport([]string{"bar"}, filepath.Join(importsTestFolder, "src", "module", "__init__.py")), }, ExpectedErrors: []string{ "cannot import file src.py from directory", @@ -54,12 +54,12 @@ func TestLanguage_ParseImports(t *testing.T) { language.EmptyImport(filepath.Join(importsTestFolder, "src", "main.py")), language.EmptyImport(filepath.Join(importsTestFolder, "src", "main.py")), language.EmptyImport(filepath.Join(importsTestFolder, "src", "module", "__init__.py")), - // language.NamesImport([]string{"main"}, filepath.Join(importsTestFolder, "src", "main.py")), + // language.SymbolsImport([]string{"main"}, filepath.Join(importsTestFolder, "src", "main.py")), // language.EmptyImport(filepath.Join(importsTestFolder, "src", "main.py")), - language.NamesImport([]string{"main"}, filepath.Join(importsTestFolder, "src", "main.py")), + language.SymbolsImport([]string{"main"}, filepath.Join(importsTestFolder, "src", "main.py")), language.AllImport(filepath.Join(importsTestFolder, "src", "module", "__init__.py")), language.EmptyImport(filepath.Join(importsTestFolder, "src", "module", "module.py")), - language.NamesImport([]string{"bar"}, filepath.Join(importsTestFolder, "src", "module", "__init__.py")), + language.SymbolsImport([]string{"bar"}, filepath.Join(importsTestFolder, "src", "module", "__init__.py")), }, ExpectedErrors: []string{ "cannot import file src.py from directory", @@ -124,7 +124,9 @@ func TestLanguage_ParseImports_Errors(t *testing.T) { lang, err := MakePythonLanguage(nil) a.NoError(err) - result, err := lang.ParseImports(&tt.File) //nolint:gosec + file := language.FileInfo{Content: &tt.File} //nolint:gosec + + result, err := lang.ParseImports(&file) a.NoError(err) a.Equal(len(tt.ExpectedErrors), len(result.Errors)) diff --git a/internal/python/language.go b/internal/python/language.go index e633b6f..dee8a77 100644 --- a/internal/python/language.go +++ b/internal/python/language.go @@ -19,18 +19,9 @@ type Language struct { cfg *Config } -var _ language.Language[python_grammar.File] = &Language{} +var _ language.Language = &Language{} -func (l *Language) Display(id string) language.DisplayResult { - basePath := findClosestDirWithRootFile(filepath.Dir(id)) - result, err := filepath.Rel(basePath, id) - if err != nil { - return language.DisplayResult{Name: id} - } - return language.DisplayResult{Name: result} -} - -func MakePythonLanguage(cfg *Config) (language.Language[python_grammar.File], error) { +func MakePythonLanguage(cfg *Config) (language.Language, error) { lang := Language{ cfg: cfg, } @@ -46,6 +37,13 @@ func MakePythonLanguage(cfg *Config) (language.Language[python_grammar.File], er return &lang, nil } -func (l *Language) ParseFile(id string) (*python_grammar.File, error) { - return python_grammar.Parse(id) +func (l *Language) ParseFile(id string) (*language.FileInfo, error) { + file, err := python_grammar.Parse(id) + if err != nil { + return nil, err + } + basePath := findClosestDirWithRootFile(filepath.Dir(id)) + // NOTE: Python has no sense of packages + file.RelPath, _ = filepath.Rel(basePath, id) + return file, nil } diff --git a/internal/python/python_grammar/grammar.go b/internal/python/python_grammar/grammar.go index e46dcb8..d39f29c 100644 --- a/internal/python/python_grammar/grammar.go +++ b/internal/python/python_grammar/grammar.go @@ -7,6 +7,7 @@ import ( "github.com/alecthomas/participle/v2" "github.com/alecthomas/participle/v2/lexer" + "github.com/gabotechs/dep-tree/internal/language" ) type Statement struct { @@ -23,17 +24,6 @@ type Statement struct { type File struct { Statements []*Statement `(@@ | SOF | ANY | ALL | Ident | Space | NewLine | String | MultilineString)*` - Path string - loc int - size int -} - -func (f File) Loc() int { - return f.loc -} - -func (f File) Size() int { - return f.size } var ( @@ -57,16 +47,19 @@ var ( ) ) -func Parse(filePath string) (*File, error) { +func Parse(filePath string) (*language.FileInfo, error) { content, err := os.ReadFile(filePath) if err != nil { return nil, err } - file, err := parser.ParseBytes(filePath, content) - if file != nil { - file.Path = filePath - file.loc = bytes.Count(content, []byte("\n")) - file.size = len(content) + statements, err := parser.ParseBytes(filePath, content) + if err != nil { + return nil, err } - return file, err + return &language.FileInfo{ + Content: statements, + Loc: bytes.Count(content, []byte("\n")), + Size: len(content), + AbsPath: filePath, + }, nil } diff --git a/internal/rust/exports.go b/internal/rust/exports.go index 1bad472..c3dd705 100644 --- a/internal/rust/exports.go +++ b/internal/rust/exports.go @@ -7,15 +7,16 @@ import ( "github.com/gabotechs/dep-tree/internal/rust/rust_grammar" ) -func (l *Language) ParseExports(file *rust_grammar.File) (*language.ExportsEntries, error) { +func (l *Language) ParseExports(file *language.FileInfo) (*language.ExportsEntries, error) { exports := make([]language.ExportEntry, 0) var errors []error - for _, stmt := range file.Statements { + content := file.Content.(*rust_grammar.File) + for _, stmt := range content.Statements { switch { case stmt.Use != nil && stmt.Use.Pub: for _, use := range stmt.Use.Flatten() { - path, err := resolve(use.PathSlices, file.Path) + path, err := resolve(use.PathSlices, file.AbsPath) if err != nil { errors = append(errors, fmt.Errorf("error resolving use statement for name %s: %w", use.Name.Original, err)) continue @@ -38,12 +39,12 @@ func (l *Language) ParseExports(file *rust_grammar.File) (*language.ExportsEntri case stmt.Pub != nil: exports = append(exports, language.ExportEntry{ Names: []language.ExportName{{Original: string(stmt.Pub.Name)}}, - Path: file.Path, + Path: file.AbsPath, }) case stmt.Mod != nil && stmt.Mod.Pub: exports = append(exports, language.ExportEntry{ Names: []language.ExportName{{Original: string(stmt.Mod.Name)}}, - Path: file.Path, + Path: file.AbsPath, }) } } diff --git a/internal/rust/imports.go b/internal/rust/imports.go index 8b3e093..3f8ea9e 100644 --- a/internal/rust/imports.go +++ b/internal/rust/imports.go @@ -9,14 +9,15 @@ import ( "github.com/gabotechs/dep-tree/internal/utils" ) -func (l *Language) ParseImports(file *rust_grammar.File) (*language.ImportsResult, error) { +func (l *Language) ParseImports(file *language.FileInfo) (*language.ImportsResult, error) { imports := make([]language.ImportEntry, 0) var errors []error - for _, stmt := range file.Statements { + content := file.Content.(*rust_grammar.File) + for _, stmt := range content.Statements { if stmt.Use != nil { for _, use := range stmt.Use.Flatten() { - id, err := resolve(use.PathSlices, file.Path) + id, err := resolve(use.PathSlices, file.AbsPath) if err != nil { errors = append(errors, fmt.Errorf("error resolving use statement for name %s: %w", use.Name.Original, err)) continue @@ -26,20 +27,20 @@ func (l *Language) ParseImports(file *rust_grammar.File) (*language.ImportsResul if use.All { imports = append(imports, language.ImportEntry{ - All: use.All, - Path: id, + All: use.All, + AbsPath: id, }) } else { imports = append(imports, language.ImportEntry{ - Names: []string{string(use.Name.Original)}, - Path: id, + Symbols: []string{string(use.Name.Original)}, + AbsPath: id, }) } } } else if stmt.Mod != nil && !stmt.Mod.Local { names := []string{string(stmt.Mod.Name)} - thisDir := filepath.Dir(file.Path) + thisDir := filepath.Dir(file.AbsPath) var modPath string if p := filepath.Join(thisDir, string(stmt.Mod.Name)+".rs"); utils.FileExists(p) { @@ -52,9 +53,9 @@ func (l *Language) ParseImports(file *rust_grammar.File) (*language.ImportsResul } imports = append(imports, language.ImportEntry{ - All: true, - Names: names, - Path: modPath, + All: true, + Symbols: names, + AbsPath: modPath, }) } } diff --git a/internal/rust/imports_test.go b/internal/rust/imports_test.go index 71d628d..bc339c5 100644 --- a/internal/rust/imports_test.go +++ b/internal/rust/imports_test.go @@ -21,53 +21,53 @@ func TestLanguage_ParseImports(t *testing.T) { Name: "lib.rs", Expected: []language.ImportEntry{ { - All: true, - Names: []string{"sum"}, - Path: filepath.Join(absTestFolder, "src", "sum.rs"), + All: true, + Symbols: []string{"sum"}, + AbsPath: filepath.Join(absTestFolder, "src", "sum.rs"), }, { - All: true, - Names: []string{"div"}, - Path: filepath.Join(absTestFolder, "src", "div", "mod.rs"), + All: true, + Symbols: []string{"div"}, + AbsPath: filepath.Join(absTestFolder, "src", "div", "mod.rs"), }, { - All: true, - Names: []string{"avg"}, - Path: filepath.Join(absTestFolder, "src", "avg.rs"), + All: true, + Symbols: []string{"avg"}, + AbsPath: filepath.Join(absTestFolder, "src", "avg.rs"), }, { - All: true, - Names: []string{"abs"}, - Path: filepath.Join(absTestFolder, "src", "abs.rs"), + All: true, + Symbols: []string{"abs"}, + AbsPath: filepath.Join(absTestFolder, "src", "abs.rs"), }, { - All: true, - Names: []string{"avg_2"}, - Path: filepath.Join(absTestFolder, "src", "avg_2.rs"), + All: true, + Symbols: []string{"avg_2"}, + AbsPath: filepath.Join(absTestFolder, "src", "avg_2.rs"), }, { - Names: []string{"abs"}, - Path: filepath.Join(absTestFolder, "src", "abs", "abs.rs"), + Symbols: []string{"abs"}, + AbsPath: filepath.Join(absTestFolder, "src", "abs", "abs.rs"), }, { - Names: []string{"div"}, - Path: filepath.Join(absTestFolder, "src", "div", "mod.rs"), + Symbols: []string{"div"}, + AbsPath: filepath.Join(absTestFolder, "src", "div", "mod.rs"), }, { - Names: []string{"avg"}, - Path: filepath.Join(absTestFolder, "src", "avg_2.rs"), + Symbols: []string{"avg"}, + AbsPath: filepath.Join(absTestFolder, "src", "avg_2.rs"), }, { - Names: []string{"sum"}, - Path: filepath.Join(absTestFolder, "src", "lib.rs"), + Symbols: []string{"sum"}, + AbsPath: filepath.Join(absTestFolder, "src", "lib.rs"), }, { - All: true, - Path: filepath.Join(absTestFolder, "src", "sum.rs"), + All: true, + AbsPath: filepath.Join(absTestFolder, "src", "sum.rs"), }, { - Names: []string{"run"}, - Path: filepath.Join(absTestFolder, "src", "lib.rs"), + Symbols: []string{"run"}, + AbsPath: filepath.Join(absTestFolder, "src", "lib.rs"), }, }, }, diff --git a/internal/rust/language.go b/internal/rust/language.go index bb8d779..a61e8f3 100644 --- a/internal/rust/language.go +++ b/internal/rust/language.go @@ -4,7 +4,6 @@ import ( "path/filepath" "github.com/gabotechs/dep-tree/internal/language" - "github.com/gabotechs/dep-tree/internal/rust/rust_grammar" ) var Extensions = []string{ @@ -13,26 +12,22 @@ var Extensions = []string{ type Language struct{} -func (l *Language) ParseFile(id string) (*rust_grammar.File, error) { - return CachedRustFile(id) +var _ language.Language = &Language{} + +func MakeRustLanguage(_ *Config) (language.Language, error) { + return &Language{}, nil } -func (l *Language) Display(id string) language.DisplayResult { - cargoToml, err := findClosestCargoToml(filepath.Dir(id)) +func (l *Language) ParseFile(id string) (*language.FileInfo, error) { + file, err := CachedRustFile(id) if err != nil { - return language.DisplayResult{ - Name: id, - } + return nil, err } - result, err := filepath.Rel(cargoToml.path, id) + cargoToml, err := findClosestCargoToml(filepath.Dir(id)) if err != nil { - return language.DisplayResult{Name: id, Group: cargoToml.PackageDefinition.Name} + return file, nil } - return language.DisplayResult{Name: result, Group: cargoToml.PackageDefinition.Name} -} - -var _ language.Language[rust_grammar.File] = &Language{} - -func MakeRustLanguage(_ *Config) (language.Language[rust_grammar.File], error) { - return &Language{}, nil + file.Package = cargoToml.PackageDefinition.Name + file.RelPath, _ = filepath.Rel(cargoToml.path, id) + return file, nil } diff --git a/internal/rust/mod_tree.go b/internal/rust/mod_tree.go index 0c63a1b..55edd5f 100644 --- a/internal/rust/mod_tree.go +++ b/internal/rust/mod_tree.go @@ -47,7 +47,8 @@ func makeModTree(mainPath string, name string, parent *ModTree) (*ModTree, error Children: make(map[string]*ModTree), } - for _, stmt := range file.Statements { + content := file.Content.(*rust_grammar.File) + for _, stmt := range content.Statements { if stmt.Mod != nil { if stmt.Mod.Local { modTree.Children[string(stmt.Mod.Name)] = &ModTree{ diff --git a/internal/rust/rust_grammar/grammar.go b/internal/rust/rust_grammar/grammar.go index 8d0fd64..57186cc 100644 --- a/internal/rust/rust_grammar/grammar.go +++ b/internal/rust/rust_grammar/grammar.go @@ -7,6 +7,7 @@ import ( "github.com/alecthomas/participle/v2" "github.com/alecthomas/participle/v2/lexer" + "github.com/gabotechs/dep-tree/internal/language" ) type Statement struct { @@ -50,16 +51,19 @@ var ( ) ) -func Parse(filePath string) (*File, error) { +func Parse(filePath string) (*language.FileInfo, error) { content, err := os.ReadFile(filePath) if err != nil { return nil, err } file, err := parser.ParseBytes(filePath, content) - if file != nil { - file.Path = filePath - file.loc = bytes.Count(content, []byte("\n")) - file.size = len(content) + if err != nil { + return nil, err } - return file, err + return &language.FileInfo{ + Content: file, + Loc: bytes.Count(content, []byte("\n")), + Size: len(content), + AbsPath: filePath, + }, nil } diff --git a/internal/dep_tree/.render_test/Children and Parents should be consistent.txt b/internal/tree/.render_test/Children and Parents should be consistent.txt similarity index 100% rename from internal/dep_tree/.render_test/Children and Parents should be consistent.txt rename to internal/tree/.render_test/Children and Parents should be consistent.txt diff --git a/internal/dep_tree/.render_test/Cyclic deps.txt b/internal/tree/.render_test/Cyclic deps.txt similarity index 100% rename from internal/dep_tree/.render_test/Cyclic deps.txt rename to internal/tree/.render_test/Cyclic deps.txt diff --git a/internal/dep_tree/.render_test/Simple.txt b/internal/tree/.render_test/Simple.txt similarity index 100% rename from internal/dep_tree/.render_test/Simple.txt rename to internal/tree/.render_test/Simple.txt diff --git a/internal/dep_tree/.render_test/Some nodes have errors.txt b/internal/tree/.render_test/Some nodes have errors.txt similarity index 100% rename from internal/dep_tree/.render_test/Some nodes have errors.txt rename to internal/tree/.render_test/Some nodes have errors.txt diff --git a/internal/dep_tree/.render_test/Two in the same level.txt b/internal/tree/.render_test/Two in the same level.txt similarity index 100% rename from internal/dep_tree/.render_test/Two in the same level.txt rename to internal/tree/.render_test/Two in the same level.txt diff --git a/internal/dep_tree/.render_test/Weird cycle combination 2.txt b/internal/tree/.render_test/Weird cycle combination 2.txt similarity index 100% rename from internal/dep_tree/.render_test/Weird cycle combination 2.txt rename to internal/tree/.render_test/Weird cycle combination 2.txt diff --git a/internal/dep_tree/.render_test/Weird cycle combination.txt b/internal/tree/.render_test/Weird cycle combination.txt similarity index 100% rename from internal/dep_tree/.render_test/Weird cycle combination.txt rename to internal/tree/.render_test/Weird cycle combination.txt diff --git a/internal/dep_tree/.structured_test/Children and Parents should be consistent.json b/internal/tree/.structured_test/Children and Parents should be consistent.json similarity index 100% rename from internal/dep_tree/.structured_test/Children and Parents should be consistent.json rename to internal/tree/.structured_test/Children and Parents should be consistent.json diff --git a/internal/dep_tree/.structured_test/Cyclic deps.json b/internal/tree/.structured_test/Cyclic deps.json similarity index 100% rename from internal/dep_tree/.structured_test/Cyclic deps.json rename to internal/tree/.structured_test/Cyclic deps.json diff --git a/internal/dep_tree/.structured_test/Simple.json b/internal/tree/.structured_test/Simple.json similarity index 100% rename from internal/dep_tree/.structured_test/Simple.json rename to internal/tree/.structured_test/Simple.json diff --git a/internal/dep_tree/.structured_test/Some nodes have errors.json b/internal/tree/.structured_test/Some nodes have errors.json similarity index 100% rename from internal/dep_tree/.structured_test/Some nodes have errors.json rename to internal/tree/.structured_test/Some nodes have errors.json diff --git a/internal/dep_tree/.structured_test/Two in the same level.json b/internal/tree/.structured_test/Two in the same level.json similarity index 100% rename from internal/dep_tree/.structured_test/Two in the same level.json rename to internal/tree/.structured_test/Two in the same level.json diff --git a/internal/dep_tree/.structured_test/Weird cycle combination.json b/internal/tree/.structured_test/Weird cycle combination.json similarity index 100% rename from internal/dep_tree/.structured_test/Weird cycle combination.json rename to internal/tree/.structured_test/Weird cycle combination.json diff --git a/internal/tree/longestpath_test.go b/internal/tree/longestpath_test.go index 95f33a2..2c403dc 100644 --- a/internal/tree/longestpath_test.go +++ b/internal/tree/longestpath_test.go @@ -3,21 +3,20 @@ package tree import ( "testing" - "github.com/gabotechs/dep-tree/internal/dep_tree" + "github.com/gabotechs/dep-tree/internal/graph" "github.com/stretchr/testify/require" ) func Test_longestPath(t *testing.T) { var tests = []struct { Name string - Children [][]int + Spec [][]int ExpectedLevels []int - NoTrimCycles bool ExpectedError string }{ { Name: "Simple", - Children: [][]int{ + Spec: [][]int{ 0: {1, 2}, 1: {3}, 2: {3}, @@ -27,7 +26,7 @@ func Test_longestPath(t *testing.T) { }, { Name: "Cycle", - Children: [][]int{ + Spec: [][]int{ 0: {1, 2, 3}, 1: {2, 4}, 2: {3, 4}, @@ -38,7 +37,7 @@ func Test_longestPath(t *testing.T) { }, { Name: "Cycle 2", - Children: [][]int{ + Spec: [][]int{ 0: {1, 2}, 1: {2, 0}, 2: {0, 1}, @@ -47,7 +46,7 @@ func Test_longestPath(t *testing.T) { }, { Name: "Cycle 3", - Children: [][]int{ + Spec: [][]int{ 0: {1}, 1: {2}, 2: {1}, @@ -56,7 +55,7 @@ func Test_longestPath(t *testing.T) { }, { Name: "Cycle 4", - Children: [][]int{ + Spec: [][]int{ 0: {1}, 1: {2}, 2: {0}, @@ -65,7 +64,7 @@ func Test_longestPath(t *testing.T) { }, { Name: "Cycle 5", - Children: [][]int{ + Spec: [][]int{ 0: {1}, 1: {2}, 2: {3}, @@ -76,7 +75,7 @@ func Test_longestPath(t *testing.T) { }, { Name: "Cycle 6", - Children: [][]int{ + Spec: [][]int{ 0: {1}, 1: {2}, 2: {3}, @@ -87,46 +86,28 @@ func Test_longestPath(t *testing.T) { }, { Name: "Avoid same level", - Children: [][]int{ + Spec: [][]int{ 0: {1, 2}, 1: {}, 2: {1}, }, ExpectedLevels: []int{0, 1, 2}, }, - { - Name: "Cycle (without trimming cycles first)", - Children: [][]int{ - 0: {1}, - 1: {2}, - 2: {1}, - }, - NoTrimCycles: true, - ExpectedError: "cannot calculate longest path between nodes because there is at least one cycle in the graph: cycle detected:\n1\n2\n1", - }, } for _, tt := range tests { t.Run(tt.Name, func(t *testing.T) { a := require.New(t) - testParser := dep_tree.TestParser{ - Spec: tt.Children, - } - - dt := dep_tree.NewDepTree[[]int](&testParser, []string{"0"}) - err := dt.LoadGraph() - a.NoError(err) - - if !tt.NoTrimCycles { - dt.LoadCycles() - } - - numNodes := len(tt.Children) + tree, err := NewTree[[]int]( + []string{"0"}, + &graph.TestParser{Spec: tt.Spec}, + func(node *graph.Node[[]int]) string { return node.Id }, + nil, + ) + numNodes := len(tt.Spec) if tt.ExpectedError != "" { - _, err = NewTree(dt) a.EqualError(err, tt.ExpectedError) } else { - tree, err := NewTree(dt) a.NoError(err) var lvls []int for i := 0; i < numNodes; i++ { diff --git a/internal/tree/render.go b/internal/tree/render.go index e173e10..4a3f3d3 100644 --- a/internal/tree/render.go +++ b/internal/tree/render.go @@ -53,7 +53,7 @@ func (t *Tree[T]) Render() (*board.Board, error) { err := b.AddBlock( &board.Block{ Id: n.Node.Id, - Label: prefix + t.NodeParser.Display(n.Node).Name, + Label: prefix + t.display(n.Node), Position: utils.Vec(indent*n.Lvl+xOffset, i+yOffset), Tags: tags, }, @@ -76,13 +76,13 @@ func (t *Tree[T]) Render() (*board.Board, error) { } } } - for _, cycle := range t.Cycles.Keys() { + for _, cycle := range t.Cycles { tags := map[string]string{ - ConnectorOriginNodeIdTag: cycle[0], - ConnectorDestinationNodeIdTag: cycle[1], + ConnectorOriginNodeIdTag: cycle.Cause[0], + ConnectorDestinationNodeIdTag: cycle.Cause[1], } - err := b.AddConnector(cycle[0], cycle[1], tags) + err := b.AddConnector(cycle.Cause[0], cycle.Cause[1], tags) if err != nil { return nil, err } diff --git a/internal/tree/render_test.go b/internal/tree/render_test.go index 3ffd9fa..f26651a 100644 --- a/internal/tree/render_test.go +++ b/internal/tree/render_test.go @@ -4,7 +4,7 @@ import ( "path/filepath" "testing" - "github.com/gabotechs/dep-tree/internal/dep_tree" + "github.com/gabotechs/dep-tree/internal/graph" "github.com/stretchr/testify/require" "github.com/gabotechs/dep-tree/internal/utils" @@ -89,17 +89,13 @@ func TestRenderGraph(t *testing.T) { for _, tt := range tests { t.Run(tt.Name, func(t *testing.T) { a := require.New(t) - testParser := dep_tree.TestParser{ - Spec: tt.Spec, - } - dt := dep_tree.NewDepTree[[]int](&testParser, []string{"0"}) - - err := dt.LoadGraph() - a.NoError(err) - dt.LoadCycles() - - tree, err := NewTree(dt) + tree, err := NewTree[[]int]( + []string{"0"}, + &graph.TestParser{Spec: tt.Spec}, + func(node *graph.Node[[]int]) string { return node.Id }, + nil, + ) a.NoError(err) board, err := tree.Render() diff --git a/internal/tree/structured.go b/internal/tree/structured.go index 60e661a..6a852ef 100644 --- a/internal/tree/structured.go +++ b/internal/tree/structured.go @@ -3,9 +3,7 @@ package tree import ( "encoding/json" "errors" - "fmt" - "github.com/gabotechs/dep-tree/internal/dep_tree" "github.com/gabotechs/dep-tree/internal/utils" ) @@ -40,7 +38,7 @@ func (t *Tree[T]) makeStructuredTree( result = make(map[string]interface{}) } var err error - result[t.NodeParser.Display(to).Name], err = t.makeStructuredTree(to.Id, stack, cache) + result[t.display(to)], err = t.makeStructuredTree(to.Id, stack, cache) if err != nil { return nil, err } @@ -51,37 +49,30 @@ func (t *Tree[T]) makeStructuredTree( } func (t *Tree[T]) RenderStructured() ([]byte, error) { - if len(t.Entrypoints) > 1 { - return nil, fmt.Errorf("this functionality requires that only 1 entrypoint is provided, but %d where detected. Consider providing a single entrypoint to your program", len(t.Entrypoints)) - } - - println("building structured tree") - tree, err := t.makeStructuredTree(t.Entrypoints[0].Id, nil, nil) + tree, err := t.makeStructuredTree(t.entrypoint.Id, nil, nil) if err != nil { return nil, err } - println("structured tree built") structuredTree := StructuredTree{ Tree: map[string]interface{}{ - t.NodeParser.Display(t.Entrypoints[0]).Name: tree, + t.display(t.entrypoint): tree, }, CircularDependencies: make([][]string, 0), Errors: make(map[string][]string), } - for _, cycle := range t.Cycles.Keys() { - cycleDep, _ := t.Cycles.Get(cycle) - renderedCycle := make([]string, len(cycleDep.Stack)) - for i, cycleDepEntry := range cycleDep.Stack { - renderedCycle[i] = t.NodeParser.Display(t.Graph.Get(cycleDepEntry)).Name + for _, cycle := range t.Cycles { + renderedCycle := make([]string, len(cycle.Stack)) + for i, cycleDepEntry := range cycle.Stack { + renderedCycle[i] = t.display(t.Graph.Get(cycleDepEntry)) } structuredTree.CircularDependencies = append(structuredTree.CircularDependencies, renderedCycle) } for _, node := range t.Nodes { if node.Node.Errors != nil && len(node.Node.Errors) > 0 { - erroredNode := t.NodeParser.Display(t.Graph.Get(node.Node.Id)).Name + erroredNode := t.display(t.Graph.Get(node.Node.Id)) nodeErrors := make([]string, len(node.Node.Errors)) for i, err := range node.Node.Errors { nodeErrors[i] = err.Error() @@ -92,26 +83,3 @@ func (t *Tree[T]) RenderStructured() ([]byte, error) { return json.MarshalIndent(structuredTree, "", " ") } - -func PrintStructured[T any]( - files []string, - parser dep_tree.NodeParser[T], -) (string, error) { - dt := dep_tree.NewDepTree(parser, files) - err := dt.LoadGraph() - if err != nil { - return "", err - } - dt.LoadCycles() - - tree, err := NewTree(dt) - if err != nil { - return "", err - } - - output, err := tree.RenderStructured() - if err != nil { - return "", err - } - return string(output), nil -} diff --git a/internal/tree/structured_test.go b/internal/tree/structured_test.go index 29db59b..f3d9526 100644 --- a/internal/tree/structured_test.go +++ b/internal/tree/structured_test.go @@ -4,7 +4,7 @@ import ( "path/filepath" "testing" - "github.com/gabotechs/dep-tree/internal/dep_tree" + "github.com/gabotechs/dep-tree/internal/graph" "github.com/stretchr/testify/require" "github.com/gabotechs/dep-tree/internal/utils" @@ -22,36 +22,36 @@ func TestDepTree_RenderStructuredGraph(t *testing.T) { { Name: "Simple", Spec: [][]int{ - {1, 2, 3}, - {2, 4}, - {3, 4}, - {4}, - {3}, + 0: {1, 2, 3}, + 1: {2, 4}, + 2: {3, 4}, + 3: {4}, + 4: {3}, }, }, { Name: "Two in the same level", Spec: [][]int{ - {1, 2, 3}, - {3}, - {3}, - {}, + 0: {1, 2, 3}, + 1: {3}, + 2: {3}, + 3: {}, }, }, { Name: "Cyclic deps", Spec: [][]int{ - {1}, - {2}, - {1}, + 0: {1}, + 1: {2}, + 2: {1}, }, }, { Name: "Children and Parents should be consistent", Spec: [][]int{ - {1, 2}, - {}, - {1}, + 0: {1, 2}, + 1: {}, + 2: {1}, }, }, { @@ -67,11 +67,11 @@ func TestDepTree_RenderStructuredGraph(t *testing.T) { { Name: "Some nodes have errors", Spec: [][]int{ - {1, 2, 3}, - {2, 4, 4275}, - {3, 4}, - {1423}, - {}, + 0: {1, 2, 3}, + 1: {2, 4, 4275}, + 2: {3, 4}, + 3: {1423}, + 4: {}, }, }, } @@ -80,14 +80,19 @@ func TestDepTree_RenderStructuredGraph(t *testing.T) { t.Run(tt.Name, func(t *testing.T) { a := require.New(t) - rendered, err := PrintStructured[[]int]( + tree, err := NewTree[[]int]( []string{"0"}, - &dep_tree.TestParser{Spec: tt.Spec}, + &graph.TestParser{Spec: tt.Spec}, + func(node *graph.Node[[]int]) string { return node.Id }, + nil, ) a.NoError(err) + rendered, err := tree.RenderStructured() + a.NoError(err) + renderOutFile := filepath.Join(structuredDir, filepath.Base(tt.Name+".json")) - utils.GoldenTest(t, renderOutFile, rendered) + utils.GoldenTest(t, renderOutFile, string(rendered)) }) } } diff --git a/internal/tree/tree.go b/internal/tree/tree.go index 7cb823c..3a3b900 100644 --- a/internal/tree/tree.go +++ b/internal/tree/tree.go @@ -5,7 +5,6 @@ import ( "fmt" "sort" - "github.com/gabotechs/dep-tree/internal/dep_tree" "github.com/gabotechs/dep-tree/internal/graph" ) @@ -15,27 +14,52 @@ type NodeWithLevel[T any] struct { } type Tree[T any] struct { - *dep_tree.DepTree[T] - Nodes []*NodeWithLevel[T] - // cache + Graph *graph.Graph[T] + NodeParser graph.NodeParser[T] + + display func(node *graph.Node[T]) string + entrypoint *graph.Node[T] + Nodes []*NodeWithLevel[T] + Cycles []graph.Cycle longestPathCache map[string]int } -func NewTree[T any](dt *dep_tree.DepTree[T]) (*Tree[T], error) { - if len(dt.Entrypoints) == 0 { +func NewTree[T any]( + files []string, + parser graph.NodeParser[T], + display func(node *graph.Node[T]) string, + callbacks graph.LoadCallbacks[T], +) (*Tree[T], error) { + if len(files) == 0 { return nil, errors.New("this functionality requires that at least 1 entrypoint is provided") } - if len(dt.Entrypoints) > 1 { - return nil, fmt.Errorf("this functionality requires that only 1 entrypoint is provided, but %d where detected. Consider providing a single entrypoint to your program", len(dt.Entrypoints)) + if len(files) > 1 { + return nil, fmt.Errorf("this functionality requires that only 1 entrypoint is provided, but %d where passed. Consider providing a single entrypoint to your program", len(files)) + } + g := graph.NewGraph[T]() + err := g.Load(files, parser, callbacks) + if err != nil { + return nil, err } - allNodes := dt.Graph.AllNodes() + entrypoint, err := parser.Node(files[0]) + if err != nil { + return nil, err + } + + cycles := g.RemoveCyclesStartingFromNode(entrypoint) + + allNodes := g.AllNodes() tree := Tree[T]{ - dt, - make([]*NodeWithLevel[T], len(allNodes)), - make(map[string]int), + Graph: g, + NodeParser: parser, + display: display, + entrypoint: entrypoint, + Nodes: make([]*NodeWithLevel[T], len(allNodes)), + Cycles: cycles, + longestPathCache: make(map[string]int), } for i, n := range allNodes { - lvl, err := tree.longestPath(dt.Entrypoints[0].Id, n.Id, nil) + lvl, err := tree.longestPath(entrypoint.Id, n.Id, nil) if err != nil { return nil, err } @@ -51,7 +75,3 @@ func NewTree[T any](dt *dep_tree.DepTree[T]) (*Tree[T], error) { }) return &tree, nil } - -func (t *Tree[T]) LoadNodes() error { - return nil -} diff --git a/internal/tui/systems/render.go b/internal/tui/systems/render.go index ac648ee..b0e0f16 100644 --- a/internal/tui/systems/render.go +++ b/internal/tui/systems/render.go @@ -3,10 +3,10 @@ package systems import ( "strings" - "github.com/gabotechs/dep-tree/internal/tree" "github.com/gdamore/tcell/v2" "github.com/gabotechs/dep-tree/internal/board/graphics" + "github.com/gabotechs/dep-tree/internal/tree" "github.com/gabotechs/dep-tree/internal/utils" ) diff --git a/internal/tui/systems/spatial.go b/internal/tui/systems/spatial.go index 7988a0a..d13b753 100644 --- a/internal/tui/systems/spatial.go +++ b/internal/tui/systems/spatial.go @@ -66,6 +66,7 @@ func SpatialSystem(s *State, ss *SpatialState) { up(ss.ScreenSize.Y/2, s, ss) case tcell.KeyCtrlD: down(ss.ScreenSize.Y/2, s, ss) + default: } } computeScreenOffset(s, ss) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 94e3bef..ee4d148 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,11 +1,11 @@ package tui import ( - "github.com/gabotechs/dep-tree/internal/tree" "github.com/gdamore/tcell/v2" - "github.com/gabotechs/dep-tree/internal/dep_tree" "github.com/gabotechs/dep-tree/internal/ecs" + "github.com/gabotechs/dep-tree/internal/graph" + "github.com/gabotechs/dep-tree/internal/tree" "github.com/gabotechs/dep-tree/internal/tui/systems" "github.com/gabotechs/dep-tree/internal/utils" ) @@ -27,22 +27,14 @@ func initScreen() (tcell.Screen, error) { func Loop[T any]( files []string, - parserBuilder dep_tree.NodeParserBuilder[T], + parser graph.NodeParser[T], + display func(node *graph.Node[T]) string, screen tcell.Screen, isRootNavigation bool, tickChan chan bool, + callbacks graph.LoadCallbacks[T], ) error { - parser, err := parserBuilder(files) - if err != nil { - return err - } - dt := dep_tree.NewDepTree(parser, files).WithStdErrLoader() - err = dt.LoadGraph() - if err != nil { - return err - } - dt.LoadCycles() - t, err := tree.NewTree(dt) + t, err := tree.NewTree(files, parser, display, callbacks) if err != nil { return err } @@ -79,7 +71,7 @@ func Loop[T any]( Event: nil, IsRootNavigation: isRootNavigation, OnNavigate: func(s *systems.State) error { - return Loop[T]([]string{s.SelectedId}, parserBuilder, screen, false, tickChan) + return Loop[T]([]string{s.SelectedId}, parser, display, screen, false, tickChan, nil) }, } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index dfef302..7694234 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -7,12 +7,12 @@ import ( "strings" "testing" + "github.com/gabotechs/dep-tree/internal/graph" "github.com/gdamore/tcell/v2" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/stretchr/testify/require" - "github.com/gabotechs/dep-tree/internal/dep_tree" "github.com/gabotechs/dep-tree/internal/js" "github.com/gabotechs/dep-tree/internal/language" "github.com/gabotechs/dep-tree/internal/python" @@ -135,22 +135,29 @@ func TestTui(t *testing.T) { finish := make(chan error) go func() { - var parserBuilder dep_tree.NodeParserBuilder[language.FileInfo] + var lang language.Language + var err error switch { case utils.EndsWith(entrypointPath, js.Extensions): - parserBuilder = language.ParserBuilder(js.MakeJsLanguage, nil, nil) + lang, err = js.MakeJsLanguage(nil) case utils.EndsWith(entrypointPath, rust.Extensions): - parserBuilder = language.ParserBuilder(rust.MakeRustLanguage, nil, nil) + lang, err = rust.MakeRustLanguage(nil) case utils.EndsWith(entrypointPath, python.Extensions): - parserBuilder = language.ParserBuilder(python.MakePythonLanguage, nil, nil) + lang, err = python.MakePythonLanguage(nil) } + a.NoError(err) + + parser := language.NewParser(lang) + parser.UnwrapProxyExports = true - finish <- Loop[language.FileInfo]( + finish <- Loop[*language.FileInfo]( []string{entrypointPath}, - parserBuilder, + parser, + func(node *graph.Node[*language.FileInfo]) string { return node.Data.RelPath }, screen, true, update, + nil, ) }()