diff --git a/cmd/.root_test/check.txt b/cmd/.root_test/check.txt old mode 100755 new mode 100644 index b221b58..f13aed6 --- a/cmd/.root_test/check.txt +++ b/cmd/.root_test/check.txt @@ -1 +1 @@ -open .dep-tree.yml: no such file or directory \ No newline at end of file +when using the `check` subcommand, a .dep-tree.yml file must be provided, you can create one sample .dep-tree.yml file executing `dep-tree config` in your terminal \ No newline at end of file diff --git a/cmd/check.go b/cmd/check.go index ecefb4f..e0cc133 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "github.com/gabotechs/dep-tree/internal/config" @@ -11,20 +12,21 @@ import ( "github.com/gabotechs/dep-tree/internal/check" ) -func CheckCmd() *cobra.Command { +func CheckCmd(cfgF func() (*config.Config, error)) *cobra.Command { return &cobra.Command{ Use: "check", Short: "Checks that the dependency rules defined in the configuration file are not broken", GroupID: checkGroupId, Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { - if configPath == "" { - configPath = config.DefaultConfigPath - } - cfg, err := loadConfig() + cfg, err := cfgF() if err != nil { return err } + if cfg.Source == "default" { + return errors.New("when using the `check` subcommand, a .dep-tree.yml file must be provided, you can create one sample .dep-tree.yml file executing `dep-tree config` in your terminal") + } + if len(cfg.Check.Entrypoints) == 0 { return fmt.Errorf(`config file "%s" has no entrypoints`, cfg.Path) } @@ -33,11 +35,7 @@ func CheckCmd() *cobra.Command { return err } parser := language.NewParser(lang) - parser.UnwrapProxyExports = cfg.UnwrapExports - parser.Exclude = cfg.Exclude - if err != nil { - return err - } + applyConfigToParser(parser, cfg) return check.Check[*language.FileInfo]( parser, diff --git a/cmd/config.go b/cmd/config.go index 0d5d290..4e4107e 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -2,6 +2,7 @@ package cmd import ( "errors" + "fmt" "os" "github.com/spf13/cobra" @@ -9,20 +10,18 @@ import ( "github.com/gabotechs/dep-tree/internal/config" ) -func ConfigCmd() *cobra.Command { +func ConfigCmd(_ func() (*config.Config, error)) *cobra.Command { return &cobra.Command{ Use: "config", Short: "Generates a sample config in case that there's not already one present", Args: cobra.ExactArgs(0), Aliases: []string{"init"}, RunE: func(cmd *cobra.Command, args []string) error { - if configPath == "" { - configPath = config.DefaultConfigPath - } - if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { - return os.WriteFile(configPath, []byte(config.SampleConfig), 0o600) + path := config.DefaultConfigPath + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return os.WriteFile(path, []byte(config.SampleConfig), 0o600) } else { - return errors.New("Cannot generate config file, as one already exists in " + configPath) + return fmt.Errorf("cannot generate config file, as one already exists in %s", path) } }, } diff --git a/cmd/entropy.go b/cmd/entropy.go index 3b40938..81dd9c9 100644 --- a/cmd/entropy.go +++ b/cmd/entropy.go @@ -1,18 +1,19 @@ 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/config" "github.com/gabotechs/dep-tree/internal/entropy" + "github.com/gabotechs/dep-tree/internal/graph" + "github.com/gabotechs/dep-tree/internal/language" ) -var noBrowserOpen bool -var enableGui bool -var renderPath string +func EntropyCmd(cfgF func() (*config.Config, error)) *cobra.Command { + var noBrowserOpen bool + var enableGui bool + var renderPath string -func EntropyCmd() *cobra.Command { cmd := &cobra.Command{ Use: "entropy", Short: "(default) Renders a 3d force-directed graph in the browser", @@ -23,7 +24,7 @@ func EntropyCmd() *cobra.Command { if err != nil { return err } - cfg, err := loadConfig() + cfg, err := cfgF() if err != nil { return err } @@ -32,8 +33,8 @@ func EntropyCmd() *cobra.Command { return err } parser := language.NewParser(lang) - parser.UnwrapProxyExports = cfg.UnwrapExports - parser.Exclude = cfg.Exclude + applyConfigToParser(parser, cfg) + err = entropy.Render(files, parser, entropy.RenderConfig{ NoOpen: noBrowserOpen, EnableGui: enableGui, diff --git a/cmd/explain.go b/cmd/explain.go index 8c3e7e8..2748b73 100644 --- a/cmd/explain.go +++ b/cmd/explain.go @@ -2,17 +2,17 @@ package cmd import ( "os" - "path/filepath" "slices" "strings" + "github.com/gabotechs/dep-tree/internal/config" "github.com/gabotechs/dep-tree/internal/explain" "github.com/gabotechs/dep-tree/internal/graph" "github.com/gabotechs/dep-tree/internal/language" "github.com/spf13/cobra" ) -func ExplainCmd() *cobra.Command { +func ExplainCmd(cfgF func() (*config.Config, error)) *cobra.Command { cmd := &cobra.Command{ Use: "explain", Short: "Shows all the dependencies between two parts of the code", @@ -29,26 +29,23 @@ func ExplainCmd() *cobra.Command { return err } - cfg, err := loadConfig() + cfg, err := cfgF() if err != nil { return err } + lang, err := inferLang(fromFiles, cfg) if err != nil { return err } parser := language.NewParser(lang) - parser.UnwrapProxyExports = cfg.UnwrapExports - parser.Exclude = cfg.Exclude + applyConfigToParser(parser, cfg) + cwd, _ := os.Getwd() - for _, arg := range args { - if filepath.IsAbs(arg) { - parser.Include = append(parser.Include, arg) - } else { - parser.Include = append(parser.Include, filepath.Join(cwd, arg)) - } - } + tempCfg := config.Config{Path: cwd, Only: args} + tempCfg.EnsureAbsPaths() + parser.Include = tempCfg.Only deps, err := explain.Explain[*language.FileInfo]( parser, diff --git a/cmd/root.go b/cmd/root.go index 59d183d..2157f86 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,21 +25,12 @@ const renderGroupId = "render" const checkGroupId = "check" const defaultCommand = "entropy" -var configPath string -var unwrapExports bool -var jsTsConfigPaths bool -var jsWorkspaces bool -var pythonExcludeConditionalImports bool -var exclude []string - -var root *cobra.Command - func NewRoot(args []string) *cobra.Command { if args == nil { args = os.Args[1:] } - root = &cobra.Command{ + root := &cobra.Command{ Use: "dep-tree", Version: "v0.22.2", Short: "Visualize and check your project's dependency graph", @@ -63,26 +54,65 @@ $ dep-tree check`, root.SetErr(os.Stderr) root.SetArgs(args) - root.AddCommand( - EntropyCmd(), - TreeCmd(), - CheckCmd(), - ConfigCmd(), - ExplainCmd(), - ) - root.AddGroup(&cobra.Group{ID: renderGroupId, Title: "Visualize your dependencies graphically"}) root.AddGroup(&cobra.Group{ID: checkGroupId, Title: "Check your dependencies against your own rules"}) root.AddGroup(&cobra.Group{ID: explainGroupId, Title: "Display what are the dependencies between two portions of code"}) + cliCfg := config.NewConfigCwd() + + var fileConfigPath string + root.Flags().SortFlags = false root.PersistentFlags().SortFlags = false - root.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to dep-tree's config file. (default .dep-tree.yml)") - root.PersistentFlags().BoolVar(&unwrapExports, "unwrap-exports", false, "trace re-exported symbols to the file where they are declared. (default false)") - root.PersistentFlags().StringArrayVar(&exclude, "exclude", nil, "Files that match this glob pattern will be ignored. You can provide an arbitrary number of --exclude flags.") - root.PersistentFlags().BoolVar(&jsTsConfigPaths, "js-tsconfig-paths", true, "follow the tsconfig.json paths while resolving imports.") - root.PersistentFlags().BoolVar(&jsWorkspaces, "js-workspaces", true, "take the workspaces attribute in the root package.json into account for resolving paths.") - root.PersistentFlags().BoolVar(&pythonExcludeConditionalImports, "python-exclude-conditional-imports", false, "exclude imports wrapped inside if or try statements. (default false)") + root.PersistentFlags().StringVarP(&fileConfigPath, "config", "c", "", "path to dep-tree's config file. (default .dep-tree.yml)") + root.PersistentFlags().BoolVar(&cliCfg.UnwrapExports, "unwrap-exports", false, "trace re-exported symbols to the file where they are declared. (default false)") + root.PersistentFlags().BoolVar(&cliCfg.Js.TsConfigPaths, "js-tsconfig-paths", true, "follow the tsconfig.json paths while resolving imports.") + root.PersistentFlags().BoolVar(&cliCfg.Js.Workspaces, "js-workspaces", true, "take the workspaces attribute in the root package.json into account for resolving paths.") + root.PersistentFlags().BoolVar(&cliCfg.Python.ExcludeConditionalImports, "python-exclude-conditional-imports", false, "exclude imports wrapped inside if or try statements. (default false)") + root.PersistentFlags().StringArrayVar(&cliCfg.Only, "only", nil, "Files that do not match this glob pattern will be ignored. You can provide an arbitrary number of --only flags.") + root.PersistentFlags().StringArrayVar(&cliCfg.Exclude, "exclude", nil, "Files that match this glob pattern will be ignored. You can provide an arbitrary number of --exclude flags.") + + cfgF := func() (*config.Config, error) { + fileCfg, err := config.ParseConfigFromFile(fileConfigPath) + if err != nil { + return nil, err + } + fileCfg.EnsureAbsPaths() + cliCfg.EnsureAbsPaths() + + // These settings prefer the CLI over the file config + for _, a := range []struct { + name string + source *bool + dest *bool + }{ + {"unwrap-exports", &cliCfg.UnwrapExports, &fileCfg.UnwrapExports}, + {"js-tsconfig-paths", &cliCfg.Js.TsConfigPaths, &fileCfg.Js.TsConfigPaths}, + {"js-workspaces", &cliCfg.Js.Workspaces, &fileCfg.Js.Workspaces}, + {"python-exclude-conditional-imports", &cliCfg.Python.ExcludeConditionalImports, &fileCfg.Python.ExcludeConditionalImports}, + } { + if !root.PersistentFlags().Changed(a.name) { + *a.dest = *a.source + } + } + // NOTE: hard-enable this for now, as they don't produce a very good output. + fileCfg.Python.IgnoreFromImportsAsExports = true + fileCfg.Python.IgnoreDirectoryImports = true + + // merge the exclusions and inclusions from the CLI and from the config. + fileCfg.Exclude = append(fileCfg.Exclude, cliCfg.Exclude...) + fileCfg.Only = append(fileCfg.Only, cliCfg.Only...) + + return fileCfg, fileCfg.ValidatePatterns() + } + + root.AddCommand( + EntropyCmd(cfgF), + TreeCmd(cfgF), + CheckCmd(cfgF), + ConfigCmd(cfgF), + ExplainCmd(cfgF), + ) switch { case len(args) > 0 && utils.InArray(args[0], []string{"help", "completion", "-v", "--version", "-h", "--help"}): @@ -202,44 +232,10 @@ func filesFromArgs(args []string) ([]string, error) { return result, nil } -func loadConfig() (*config.Config, error) { - cfg, err := config.ParseConfig(configPath) - if err != nil { - return nil, err - } - if root.PersistentFlags().Changed("unwrap-exports") { - cfg.UnwrapExports = unwrapExports - } - if root.PersistentFlags().Changed("js-tsconfig-paths") { - cfg.Js.TsConfigPaths = jsTsConfigPaths - } - if root.PersistentFlags().Changed("js-workspaces") { - cfg.Js.Workspaces = jsWorkspaces - } - if root.PersistentFlags().Changed("python-exclude-conditional-imports") { - cfg.Python.ExcludeConditionalImports = pythonExcludeConditionalImports - } - // NOTE: hard-enable this for now, as they don't produce a very good output. - cfg.Python.IgnoreFromImportsAsExports = true - cfg.Python.IgnoreDirectoryImports = true - - absExclude := make([]string, len(exclude)) - cwd, _ := os.Getwd() - for i, file := range exclude { - if !filepath.IsAbs(file) { - absExclude[i] = filepath.Join(cwd, file) - } else { - absExclude[i] = file - } - } - cfg.Exclude = append(cfg.Exclude, absExclude...) - // validate exclusion patterns. - for _, exclusion := range cfg.Exclude { - if _, err := utils.GlobstarMatch(exclusion, ""); err != nil { - return nil, fmt.Errorf("exclude pattern '%s' is not correctly formatted", exclusion) - } - } - return cfg, nil +func applyConfigToParser(parser *language.Parser, cfg *config.Config) { + parser.UnwrapProxyExports = cfg.UnwrapExports + parser.Exclude = cfg.Exclude + parser.Include = cfg.Only } func relPathDisplay(node *graph.Node[*language.FileInfo]) string { diff --git a/cmd/tree.go b/cmd/tree.go index 4445cd3..a7db3ff 100644 --- a/cmd/tree.go +++ b/cmd/tree.go @@ -1,6 +1,7 @@ package cmd import ( + "github.com/gabotechs/dep-tree/internal/config" "github.com/gabotechs/dep-tree/internal/graph" "github.com/gabotechs/dep-tree/internal/language" "github.com/gabotechs/dep-tree/internal/tree" @@ -9,9 +10,9 @@ import ( "github.com/gabotechs/dep-tree/internal/tui" ) -var jsonFormat bool +func TreeCmd(cfgF func() (*config.Config, error)) *cobra.Command { + var jsonFormat bool -func TreeCmd() *cobra.Command { cmd := &cobra.Command{ Use: "tree", Short: "Render the dependency tree starting from the provided entrypoint", @@ -22,7 +23,8 @@ func TreeCmd() *cobra.Command { if err != nil { return err } - cfg, err := loadConfig() + + cfg, err := cfgF() if err != nil { return err } @@ -33,8 +35,7 @@ func TreeCmd() *cobra.Command { } parser := language.NewParser(lang) - parser.UnwrapProxyExports = cfg.UnwrapExports - parser.Exclude = cfg.Exclude + applyConfigToParser(parser, cfg) if jsonFormat { t, err := tree.NewTree[*language.FileInfo]( diff --git a/internal/config/config.go b/internal/config/config.go index d9d83a9..78f0de4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" + "github.com/gabotechs/dep-tree/internal/utils" "gopkg.in/yaml.v3" "github.com/gabotechs/dep-tree/internal/check" @@ -23,7 +24,9 @@ var SampleConfig string type Config struct { Path string + Source string Exclude []string `yaml:"exclude"` + Only []string `yaml:"only"` UnwrapExports bool `yaml:"unwrapExports"` Check check.Config `yaml:"check"` Js js.Config `yaml:"js"` @@ -32,17 +35,46 @@ type Config struct { Golang golang.Config `yaml:"golang"` } -func (c *Config) UnwrapProxyExports() bool { - return c.UnwrapExports +func NewConfigCwd() Config { + var cwd, _ = os.Getwd() + return Config{Path: cwd} } -func (c *Config) IgnoreFiles() []string { - return c.Exclude +func (c *Config) EnsureAbsPaths() { + for i, file := range c.Exclude { + if !filepath.IsAbs(file) { + c.Exclude[i] = filepath.Join(c.Path, file) + } + } + + for i, file := range c.Only { + if !filepath.IsAbs(file) { + c.Only[i] = filepath.Join(c.Path, file) + } + } +} + +func (c *Config) ValidatePatterns() error { + for _, pattern := range c.Exclude { + if _, err := utils.GlobstarMatch(pattern, ""); err != nil { + return fmt.Errorf("exclude pattern '%s' is not correctly formatted", pattern) + } + } + + for _, pattern := range c.Only { + if _, err := utils.GlobstarMatch(pattern, ""); err != nil { + return fmt.Errorf("only pattern '%s' is not correctly formatted", pattern) + } + } + + return nil } -func ParseConfig(cfgPath string) (*Config, error) { +func ParseConfigFromFile(cfgPath string) (*Config, error) { // Default values. cfg := Config{ + Path: cfgPath, + Source: "default", UnwrapExports: false, Js: js.Config{ Workspaces: true, @@ -54,45 +86,36 @@ func ParseConfig(cfgPath string) (*Config, error) { Rust: rust.Config{}, } - isDefault := cfgPath == "" + var isDefault bool if cfgPath == "" { + isDefault = true cfgPath = DefaultConfigPath } - content, err := os.ReadFile(cfgPath) + absCfgPath, err := filepath.Abs(cfgPath) + if err != nil { + return nil, err + } + cfg.Path = filepath.Dir(absCfgPath) + cfg.Check.Path = cfg.Path + // 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 + content, err := os.ReadFile(cfgPath) if os.IsNotExist(err) { - if !isDefault { - return &cfg, err - } else { + if isDefault { return &cfg, nil } + return nil, err } else if err != nil { - return &cfg, err - } - absCfgPath, err := filepath.Abs(cfgPath) - if err != nil { - return &cfg, err + return nil, err } - cfg.Path = filepath.Dir(absCfgPath) + cfg.Source = "file" decoder := yaml.NewDecoder(bytes.NewReader(content)) decoder.KnownFields(true) err = decoder.Decode(&cfg) 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 + return nil, fmt.Errorf(`config file "%s" is not a valid yml file: %w`, cfgPath, err) } cfg.Check.Init(filepath.Dir(absCfgPath)) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f16bed5..9adf520 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -74,8 +74,9 @@ func TestParseConfig(t *testing.T) { if tt.File != "" { tt.File = filepath.Join(testFolder, tt.File) } - cfg, err := ParseConfig(tt.File) + cfg, err := ParseConfigFromFile(tt.File) a.NoError(err) + cfg.EnsureAbsPaths() a.Equal(tt.ExpectedWhiteList, cfg.Check.WhiteList) a.Equal(tt.ExpectedBlackList, cfg.Check.BlackList) @@ -110,7 +111,7 @@ func TestConfig_ErrorHandling(t *testing.T) { for _, tt := range tests { t.Run(tt.Name, func(t *testing.T) { a := require.New(t) - _, err := ParseConfig(tt.File) + _, err := ParseConfigFromFile(tt.File) a.ErrorContains(err, tt.Expected) }) } @@ -118,6 +119,6 @@ func TestConfig_ErrorHandling(t *testing.T) { func TestSampleConfig(t *testing.T) { a := require.New(t) - _, err := ParseConfig("sample-config.yml") + _, err := ParseConfigFromFile("sample-config.yml") a.NoError(err) }