diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 04bad3a7d..ca48554b2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -86,6 +86,14 @@ jobs: timeout-minutes: 15 runs-on: ${{ matrix.flavor.os }} steps: + # Configures Git to ensure LF line endings on Windows to pass the `atmos validate editorconfig` check. + # The check expects LF endings, but on Windows, files might default to CRLF due to core.autocrlf. + # Since the validation runs in CI, we enforce consistent settings across all OS environments. + - name: Set Git Preferences for windows + if: matrix.flavor.target == 'windows' + run: | + git config --global core.autocrlf false + git config --global core.eol lf - name: Check out code into the Go module directory uses: actions/checkout@v4 diff --git a/atmos.yaml b/atmos.yaml index e9793b62f..16ebeb3a9 100644 --- a/atmos.yaml +++ b/atmos.yaml @@ -91,6 +91,44 @@ logs: # Can also be set using 'ATMOS_LOGS_LEVEL' ENV var, or '--logs-level' command-line argument level: Info +validate: + # The configuration settings for the editorconfig-checker. + + editorconfig: + # A comma-separated list of file paths or patterns to exclude from checks. + exclude: "" + + # If set to true, the default ignore patterns (like .git/*) will not be applied. + ignore_defaults: false + + # Runs the checker without making any changes or producing output, useful for testing configuration. + dry_run: false + + # Specifies the output format. Options: "default", "json". + format: "default" + + # Disables colored output in the terminal. + no_color: false + + disable: + # Disables checking for trailing whitespace at the end of lines. + trim_trailing_whitespace: false + + # Disables checking for consistent line endings (e.g., LF vs. CRLF). + end_of_line: false + + # Disables checking for the presence of a newline at the end of files. + insert_final_newline: false + + # Disables checking for consistent indentation style (e.g., tabs or spaces). + indentation: false + + # Disables checking for consistent indentation size (e.g., 2 spaces or 4 spaces). + indent_size: false + + # Disables checking for lines exceeding a maximum length. + max_line_length: false + # Custom CLI commands commands: - name: tf diff --git a/cmd/editor_config.go b/cmd/editor_config.go new file mode 100644 index 000000000..aa07bb185 --- /dev/null +++ b/cmd/editor_config.go @@ -0,0 +1,189 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/cloudposse/atmos/pkg/schema" + u "github.com/cloudposse/atmos/pkg/utils" + "github.com/cloudposse/atmos/pkg/version" + + "github.com/editorconfig-checker/editorconfig-checker/v3/pkg/config" + er "github.com/editorconfig-checker/editorconfig-checker/v3/pkg/error" + "github.com/editorconfig-checker/editorconfig-checker/v3/pkg/files" + "github.com/editorconfig-checker/editorconfig-checker/v3/pkg/utils" + "github.com/editorconfig-checker/editorconfig-checker/v3/pkg/validation" + "github.com/spf13/cobra" +) + +var ( + defaultConfigFilePath = ".editorconfig" + initEditorConfig bool + currentConfig *config.Config + cliConfig config.Config + configFilePath string + tmpExclude string +) + +var editorConfigCmd *cobra.Command = &cobra.Command{ + Use: "editorconfig", + Short: "Validate all files against the EditorConfig", + Long: "Validate all files against the project's EditorConfig rules", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + initializeConfig() + }, + Run: func(cmd *cobra.Command, args []string) { + if cliConfig.Help { + cmd.Help() + os.Exit(0) + } + runMainLogic() + }, +} + +// initializeConfig breaks the initialization cycle by separating the config setup +func initializeConfig() { + replaceAtmosConfigInConfig(atmosConfig) + + if configFilePath == "" { + configFilePath = defaultConfigFilePath + } + + var err error + currentConfig, err = config.NewConfig(configFilePath) + if err != nil { + u.LogErrorAndExit(atmosConfig, err) + } + + if initEditorConfig { + err := currentConfig.Save(version.Version) + if err != nil { + u.LogErrorAndExit(atmosConfig, err) + } + } + + _ = currentConfig.Parse() + + if tmpExclude != "" { + currentConfig.Exclude = append(currentConfig.Exclude, tmpExclude) + } + + currentConfig.Merge(cliConfig) +} + +func replaceAtmosConfigInConfig(atmosConfig schema.AtmosConfiguration) { + if atmosConfig.Validate.EditorConfig.ConfigFilePath != "" { + configFilePath = atmosConfig.Validate.EditorConfig.ConfigFilePath + } + if atmosConfig.Validate.EditorConfig.Exclude != "" { + tmpExclude = atmosConfig.Validate.EditorConfig.Exclude + } + if atmosConfig.Validate.EditorConfig.Init { + initEditorConfig = atmosConfig.Validate.EditorConfig.Init + } + if atmosConfig.Validate.EditorConfig.IgnoreDefaults { + cliConfig.IgnoreDefaults = atmosConfig.Validate.EditorConfig.IgnoreDefaults + } + if atmosConfig.Validate.EditorConfig.DryRun { + cliConfig.DryRun = atmosConfig.Validate.EditorConfig.DryRun + } + if atmosConfig.Validate.EditorConfig.Format != "" { + cliConfig.Format = atmosConfig.Validate.EditorConfig.Format + } + if atmosConfig.Logs.Level == "trace" { + cliConfig.Verbose = true + } + if atmosConfig.Validate.EditorConfig.NoColor { + cliConfig.NoColor = atmosConfig.Validate.EditorConfig.NoColor + } + if atmosConfig.Validate.EditorConfig.Disable.TrimTrailingWhitespace { + cliConfig.Disable.TrimTrailingWhitespace = atmosConfig.Validate.EditorConfig.Disable.TrimTrailingWhitespace + } + if atmosConfig.Validate.EditorConfig.Disable.EndOfLine { + cliConfig.Disable.EndOfLine = atmosConfig.Validate.EditorConfig.Disable.EndOfLine + } + if atmosConfig.Validate.EditorConfig.Disable.InsertFinalNewline { + cliConfig.Disable.InsertFinalNewline = atmosConfig.Validate.EditorConfig.Disable.InsertFinalNewline + } + if atmosConfig.Validate.EditorConfig.Disable.Indentation { + cliConfig.Disable.Indentation = atmosConfig.Validate.EditorConfig.Disable.Indentation + } + if atmosConfig.Validate.EditorConfig.Disable.IndentSize { + cliConfig.Disable.IndentSize = atmosConfig.Validate.EditorConfig.Disable.IndentSize + } + if atmosConfig.Validate.EditorConfig.Disable.MaxLineLength { + cliConfig.Disable.MaxLineLength = atmosConfig.Validate.EditorConfig.Disable.MaxLineLength + } +} + +// runMainLogic contains the main logic +func runMainLogic() { + config := *currentConfig + u.LogDebug(atmosConfig, config.GetAsString()) + u.LogTrace(atmosConfig, fmt.Sprintf("Exclude Regexp: %s", config.GetExcludesAsRegularExpression())) + + if err := checkVersion(config); err != nil { + u.LogErrorAndExit(atmosConfig, err) + } + + filePaths, err := files.GetFiles(config) + if err != nil { + u.LogErrorAndExit(atmosConfig, err) + } + + if config.DryRun { + for _, file := range filePaths { + u.LogInfo(atmosConfig, file) + } + os.Exit(0) + } + + errors := validation.ProcessValidation(filePaths, config) + errorCount := er.GetErrorCount(errors) + + if errorCount != 0 { + er.PrintErrors(errors, config) + u.LogErrorAndExit(atmosConfig, fmt.Errorf("\n%d errors found", errorCount)) + } + + u.LogDebug(atmosConfig, fmt.Sprintf("%d files checked", len(filePaths))) + u.LogInfo(atmosConfig, "No errors found") +} + +func checkVersion(config config.Config) error { + if !utils.FileExists(config.Path) || config.Version == "" { + return nil + } + if config.Version != version.Version { + return fmt.Errorf("version mismatch: binary=%s, config=%s", + version.Version, config.Version) + } + + return nil +} + +// addPersistentFlags adds flags to the root command +func addPersistentFlags(cmd *cobra.Command) { + cmd.PersistentFlags().StringVar(&configFilePath, "config", "", "Path to the configuration file") + cmd.PersistentFlags().StringVar(&tmpExclude, "exclude", "", "Regex to exclude files from checking") + cmd.PersistentFlags().BoolVar(&initEditorConfig, "init", false, "creates an initial configuration") + + cmd.PersistentFlags().BoolVar(&cliConfig.IgnoreDefaults, "ignore-defaults", false, "Ignore default excludes") + cmd.PersistentFlags().BoolVar(&cliConfig.DryRun, "dry-run", false, "Show which files would be checked") + cmd.PersistentFlags().BoolVar(&cliConfig.ShowVersion, "version", false, "Print the version number") + cmd.PersistentFlags().StringVar(&cliConfig.Format, "format", "default", "Specify the output format: default, gcc") + cmd.PersistentFlags().BoolVar(&cliConfig.NoColor, "no-color", false, "Don't print colors") + cmd.PersistentFlags().BoolVar(&cliConfig.Disable.TrimTrailingWhitespace, "disable-trim-trailing-whitespace", false, "Disable trailing whitespace check") + cmd.PersistentFlags().BoolVar(&cliConfig.Disable.EndOfLine, "disable-end-of-line", false, "Disable end-of-line check") + cmd.PersistentFlags().BoolVar(&cliConfig.Disable.InsertFinalNewline, "disable-insert-final-newline", false, "Disable final newline check") + cmd.PersistentFlags().BoolVar(&cliConfig.Disable.Indentation, "disable-indentation", false, "Disable indentation check") + cmd.PersistentFlags().BoolVar(&cliConfig.Disable.IndentSize, "disable-indent-size", false, "Disable indent size check") + cmd.PersistentFlags().BoolVar(&cliConfig.Disable.MaxLineLength, "disable-max-line-length", false, "Disable max line length check") +} + +func init() { + // Add flags + addPersistentFlags(editorConfigCmd) + // Add command + validateCmd.AddCommand(editorConfigCmd) +} diff --git a/examples/quick-start-simple/.editorconfig b/examples/quick-start-simple/.editorconfig new file mode 100644 index 000000000..836760da3 --- /dev/null +++ b/examples/quick-start-simple/.editorconfig @@ -0,0 +1,38 @@ +# EditorConfig for Terraform repository + +root = true + +# Terraform files +[*.tf] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +# HCL files (for Terraform configurations) +[*.hcl] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +# Atmos configurations (if they exist as YAML or TOML) +[*.yml] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.toml] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/examples/quick-start-simple/atmos.yaml b/examples/quick-start-simple/atmos.yaml index 555d07515..37ec5031e 100644 --- a/examples/quick-start-simple/atmos.yaml +++ b/examples/quick-start-simple/atmos.yaml @@ -17,5 +17,4 @@ stacks: name_pattern: "{stage}" logs: - file: "/dev/stderr" level: Info diff --git a/go.mod b/go.mod index c7cc392e5..12498f206 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/lipgloss v1.0.0 github.com/charmbracelet/log v0.4.0 + github.com/editorconfig-checker/editorconfig-checker/v3 v3.0.3 github.com/elewis787/boa v0.1.2 github.com/fatih/color v1.18.0 github.com/go-git/go-git/v5 v5.13.0 @@ -95,6 +96,7 @@ require ( github.com/aws/smithy-go v1.22.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/baulk/chardet v0.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 // indirect @@ -122,10 +124,12 @@ require ( github.com/docker/libkv v0.2.2-0.20180912205406-458977154600 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad // indirect + github.com/editorconfig/editorconfig-core-go/v2 v2.6.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.4 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.0 // indirect github.com/go-ini/ini v1.67.0 // indirect diff --git a/go.sum b/go.sum index 2e9c307f2..2beabd21d 100644 --- a/go.sum +++ b/go.sum @@ -385,6 +385,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/baulk/chardet v0.1.0 h1:6/r5nPMikB9OG1Njs10VfVHZTDMFH6BdybHPISpfUVA= +github.com/baulk/chardet v0.1.0/go.mod h1:0ibN6068qswel5Hv54U7GNJUU57njfzPJrLIq7Y8xas= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= @@ -513,6 +515,10 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad h1:Qk76DOWdOp+GlyDKBAG3Klr9cn7N+LcYc82AZ2S7+cA= github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad/go.mod h1:mPKfmRa823oBIgl2r20LeMSpTAteW5j7FLkc0vjmzyQ= github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/editorconfig-checker/editorconfig-checker/v3 v3.0.3 h1:WO9Yd7/KjfGDYUeSBsGKh+Uj+K+/oTnJ3elDQ7Oq7yU= +github.com/editorconfig-checker/editorconfig-checker/v3 v3.0.3/go.mod h1:9mvpU+I3xMoU+l0vNtR98SUX/AsoAhBCntntRtNIu3Y= +github.com/editorconfig/editorconfig-core-go/v2 v2.6.2 h1:dKG8sc7n321deIVRcQtwlMNoBEra7j0qQ8RwxO8RN0w= +github.com/editorconfig/editorconfig-core-go/v2 v2.6.2/go.mod h1:7dvD3GCm7eBw53xZ/lsiq72LqobdMg3ITbMBxnmJmqY= github.com/elazarl/goproxy v1.2.1 h1:njjgvO6cRG9rIqN2ebkqy6cQz2Njkx7Fsfv/zIZqgug= github.com/elazarl/goproxy v1.2.1/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64= github.com/elewis787/boa v0.1.2 h1:xNKWJ9X2MWbLSLLOA31N4l1Jdec9FZSkbTvXy3C8rw4= @@ -553,6 +559,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= +github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index 8108be1bd..55c8215d4 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -1,6 +1,8 @@ package schema -import "github.com/cloudposse/atmos/pkg/store" +import ( + "github.com/cloudposse/atmos/pkg/store" +) type AtmosSectionMapType = map[string]any @@ -30,13 +32,39 @@ type AtmosConfiguration struct { StackType string `yaml:"stackType,omitempty" json:"StackType,omitempty" mapstructure:"stackType"` Default bool `yaml:"default" json:"default" mapstructure:"default"` Version Version `yaml:"version,omitempty" json:"version,omitempty" mapstructure:"version"` - + Validate Validate `yaml:"validate,omitempty" json:"validate,omitempty" mapstructure:"validate"` // Stores is never read from yaml, it is populated in processStoreConfig and it's used to pass to the populated store // registry through to the yaml parsing functions when !store is run and to pass the registry to the hooks // functions to be able to call stores from within hooks. Stores store.StoreRegistry `yaml:"stores_registry,omitempty" json:"stores_registry,omitempty" mapstructure:"stores_registry"` } +type Validate struct { + EditorConfig EditorConfig `yaml:"editorconfig,omitempty" json:"editorconfig,omitempty" mapstructure:"editorconfig"` +} + +type EditorConfig struct { + IgnoreDefaults bool `yaml:"ignore_defaults,omitempty" json:"ignore_defaults,omitempty" mapstructure:"ignore_defaults"` + DryRun bool `yaml:"dry_run,omitempty" json:"dry_run,omitempty" mapstructure:"dry_run"` + Format string `yaml:"format,omitempty" json:"format,omitempty" mapstructure:"format"` + NoColor bool `yaml:"no_color,omitempty" json:"no_color,omitempty" mapstructure:"no_color"` + Disable DisabledChecks `yaml:"disable,omitempty" json:"disable,omitempty" mapstructure:"disable"` + + ConfigFilePath string `yaml:"config_file_path,omitempty" json:"config_file_path,omitempty" mapstructure:"config_file_path"` + Exclude string `yaml:"exclude,omitempty" json:"exclude,omitempty" mapstructure:"exclude"` + Init bool `yaml:"init,omitempty" json:"init,omitempty" mapstructure:"init"` +} + +// DisabledChecks is a Struct which represents disabled checks +type DisabledChecks struct { + EndOfLine bool `yaml:"end_of_line,omitempty" json:"end_of_line,omitempty" mapstructure:"end_of_line"` + InsertFinalNewline bool `yaml:"insert_final_newline,omitempty" json:"insert_final_newline,omitempty" mapstructure:"insert_final_newline"` + Indentation bool `yaml:"indentation,omitempty" json:"indentation,omitempty" mapstructure:"indentation"` + IndentSize bool `yaml:"indent_size,omitempty" json:"indent_size,omitempty" mapstructure:"indent_size"` + MaxLineLength bool `yaml:"max_line_length,omitempty" json:"max_line_length,omitempty" mapstructure:"max_line_length"` + TrimTrailingWhitespace bool `yaml:"trim_trailing_whitespace,omitempty" json:"trim_trailing_whitespace,omitempty" mapstructure:"trim_trailing_whitespace"` +} + type AtmosSettings struct { ListMergeStrategy string `yaml:"list_merge_strategy" json:"list_merge_strategy" mapstructure:"list_merge_strategy"` Docs Docs `yaml:"docs,omitempty" json:"docs,omitempty" mapstructure:"docs"` diff --git a/tests/test_cases.yaml b/tests/test_cases.yaml index 9ebb6641f..aa09bb99e 100644 --- a/tests/test_cases.yaml +++ b/tests/test_cases.yaml @@ -136,7 +136,20 @@ tests: stderr: - 'unknown command "non-existent" for "atmos"' exit_code: 1 - + - name: atmos validate editorconfig + enabled: true + description: "Ensure atmos CLI validates based on the .ecrc file." + workdir: "../examples/quick-start-simple/" + command: "atmos" + args: + - "validate" + - "editorconfig" + expect: + stdout: + - "No errors found" + stderr: + - "^$" + exit_code: 0 - name: atmos describe config -f yaml enabled: true description: "Ensure atmos CLI outputs the Atmos configuration in YAML."