├── .codeclimate.yml ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yml ├── .mdl_style.rb ├── .mdlrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── cmd └── root.go ├── files ├── icon.png └── logo.png ├── go.mod ├── go.sum ├── internal └── hclsort │ ├── file_handler.go │ ├── hcl_processor.go │ ├── ingestor.go │ ├── testdata │ ├── expected.tf │ ├── expected.tofu │ ├── invalid.tf │ ├── valid.tf │ └── valid.tofu │ ├── tfsort_test.go │ └── types.go └── main.go /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "2" 3 | 4 | checks: 5 | argument-count: 6 | config: 7 | threshold: 4 8 | complex-logic: 9 | config: 10 | threshold: 4 11 | file-lines: 12 | config: 13 | threshold: 500 14 | method-complexity: 15 | enabled: false 16 | config: 17 | threshold: 20 18 | method-count: 19 | enabled: false 20 | config: 21 | threshold: 20 22 | method-lines: 23 | enabled: false 24 | config: 25 | threshold: 50 26 | nested-control-flow: 27 | config: 28 | threshold: 4 29 | return-statements: 30 | config: 31 | threshold: 10 32 | similar-code: 33 | config: 34 | threshold: # language-specific defaults. an override will affect all languages. 35 | identical-code: 36 | config: 37 | threshold: # language-specific defaults. an override will affect all languages. 38 | 39 | plugins: 40 | gofmt: 41 | enabled: true 42 | govet: 43 | enabled: true 44 | markdownlint: 45 | enabled: true 46 | shellcheck: 47 | enabled: true 48 | fixme: 49 | enabled: true 50 | config: 51 | strings: 52 | - FIXME 53 | - BUG 54 | - HACK 55 | 56 | exclude_patterns: 57 | - "config/" 58 | - "db/" 59 | - "dist/" 60 | - "features/" 61 | - "**/node_modules/" 62 | - "script/" 63 | - "**/spec/" 64 | - "**/test/" 65 | - "**/tests/" 66 | - "Tests/" 67 | - "**/vendor/" 68 | - "**/*_test.go" 69 | - "**/*.d.ts" 70 | - "examples" 71 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 5 8 | allow: 9 | - dependency-type: "direct" 10 | - dependency-type: "indirect" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | 8 | jobs: 9 | golangci-lint: 10 | name: golangci-lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | - name: Set up Go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: "stable" 19 | - name: Lint 20 | uses: golangci/golangci-lint-action@v7 21 | with: 22 | version: latest 23 | 24 | tests: 25 | name: Tests 26 | needs: golangci-lint 27 | strategy: 28 | matrix: 29 | os: 30 | - ubuntu-latest 31 | - macos-latest 32 | # - windows-latest - temporarily disabled due to github actions bug 33 | runs-on: ${{ matrix.os }} 34 | steps: 35 | - name: Checkout code 36 | uses: actions/checkout@v4 37 | - name: Set up Go 38 | uses: actions/setup-go@v5 39 | with: 40 | go-version: "stable" 41 | - name: Install dependencies 42 | run: go mod download 43 | - name: Go Test 44 | run: make test 45 | 46 | coverage: 47 | name: Coverage 48 | needs: tests 49 | runs-on: ubuntu-latest 50 | if: github.ref == 'refs/heads/master' 51 | steps: 52 | - name: Checkout code 53 | uses: actions/checkout@v4 54 | - name: Set up Go 55 | uses: actions/setup-go@v5 56 | with: 57 | go-version: "stable" 58 | - name: Code Climate Coverage Action 59 | uses: paambaati/codeclimate-action@v3.2.0 60 | env: 61 | CC_TEST_REPORTER_ID: ${{secrets.CC_TEST_REPORTER_ID}} 62 | with: 63 | coverageCommand: make coverage 64 | prefix: ${{ github.event.repository.name }} 65 | coverageLocations: "${{github.workspace}}/c.out:gocov" 66 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - run: git fetch --force --tags 19 | - uses: actions/setup-go@v5 20 | with: 21 | go-version: stable 22 | cache: true 23 | - name: Install mono 24 | run: sudo apt-get update && sudo apt-get install -y mono-devel 25 | - name: install chocolatey 26 | run: | 27 | mkdir -p /opt/chocolatey 28 | wget -q -O - "https://github.com/chocolatey/choco/releases/download/1.3.0/chocolatey.v1.3.0.tar.gz " | tar -xz -C "/opt/chocolatey" 29 | echo '#!/bin/bash' >> /usr/local/bin/choco 30 | echo 'mono /opt/chocolatey/choco.exe $@' >> /usr/local/bin/choco 31 | chmod +x /usr/local/bin/choco 32 | - uses: goreleaser/goreleaser-action@v6 33 | with: 34 | distribution: goreleaser 35 | version: latest 36 | args: release --clean 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.PUBLISHER_TOKEN }} 39 | CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Go workspace file 15 | go.work 16 | 17 | # DS Store 18 | .DS_Store 19 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | formatters: 4 | enable: 5 | - gofmt # checks if the code is formatted according to 'gofmt' command 6 | - goimports # checks if the code and import statements are formatted according to the 'goimports' command 7 | - golines # checks if code is formatted, and fixes long lines 8 | 9 | # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml 10 | settings: 11 | golines: 12 | # Target maximum line length. 13 | # Default: 100 14 | max-len: 120 15 | 16 | linters: 17 | enable: 18 | - asasalint # checks for pass []any as any in variadic func(...any) 19 | - asciicheck # checks that your code does not contain non-ASCII identifiers 20 | - bidichk # checks for dangerous unicode character sequences 21 | - bodyclose # checks whether HTTP response body is closed successfully 22 | - canonicalheader # checks whether net/http.Header uses canonical header 23 | - copyloopvar # detects places where loop variables are copied (Go 1.22+) 24 | - cyclop # checks function and package cyclomatic complexity 25 | - dupl # tool for code clone detection 26 | - durationcheck # checks for two durations multiplied together 27 | - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases 28 | - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error 29 | - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 30 | - exhaustive # checks exhaustiveness of enum switch statements 31 | - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions 32 | - fatcontext # detects nested contexts in loops 33 | - funcorder # checks the order of functions, methods, and constructors 34 | - funlen # tool for detection of long functions 35 | - gocheckcompilerdirectives # validates go compiler directive comments (//go:) 36 | - gochecknoglobals # checks that no global variables exist 37 | - gochecknoinits # checks that no init functions are present in Go code 38 | - gochecksumtype # checks exhaustiveness on Go "sum types" 39 | - gocognit # computes and checks the cognitive complexity of functions 40 | - goconst # finds repeated strings that could be replaced by a constant 41 | - gocritic # provides diagnostics that check for bugs, performance and style issues 42 | - gocyclo # computes and checks the cyclomatic complexity of functions 43 | - godot # checks if comments end in a period 44 | - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod 45 | - goprintffuncname # checks that printf-like functions are named with f at the end 46 | - gosec # inspects source code for security problems 47 | - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string 48 | - iface # checks the incorrect use of interfaces, helping developers avoid interface pollution 49 | - ineffassign # detects when assignments to existing variables are not used 50 | - intrange # finds places where for loops could make use of an integer range 51 | - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) 52 | - makezero # finds slice declarations with non-zero initial length 53 | - mirror # reports wrong mirror patterns of bytes/strings usage 54 | - mnd # detects magic numbers 55 | - musttag # enforces field tags in (un)marshaled structs 56 | - nakedret # finds naked returns in functions greater than a specified function length 57 | - nestif # reports deeply nested if statements 58 | - nilerr # finds the code that returns nil even if it checks that the error is not nil 59 | - nilnesserr # reports that it checks for err != nil, but it returns a different nil value error (powered by nilness and nilerr) 60 | - nilnil # checks that there is no simultaneous return of nil error and an invalid value 61 | - noctx # finds sending http request without context.Context 62 | - nolintlint # reports ill-formed or insufficient nolint directives 63 | - nonamedreturns # reports all named returns 64 | - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL 65 | - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative 66 | - predeclared # finds code that shadows one of Go's predeclared identifiers 67 | - promlinter # checks Prometheus metrics naming via promlint 68 | - protogetter # reports direct reads from proto message fields when getters should be used 69 | - reassign # checks that package variables are not reassigned 70 | - recvcheck # checks for receiver type consistency 71 | - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint 72 | - rowserrcheck # checks whether Err of rows is checked successfully 73 | - sloglint # ensure consistent code style when using log/slog 74 | - spancheck # checks for mistakes with OpenTelemetry/Census spans 75 | - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed 76 | - staticcheck # is a go vet on steroids, applying a ton of static analysis checks 77 | - testableexamples # checks if examples are testable (have an expected output) 78 | - testifylint # checks usage of github.com/stretchr/testify 79 | - testpackage # makes you use a separate _test package 80 | - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes 81 | - unconvert # removes unnecessary type conversions 82 | - unparam # reports unused function parameters 83 | - unused # checks for unused constants, variables, functions and types 84 | - usestdlibvars # detects the possibility to use variables/constants from the Go standard library 85 | - usetesting # reports uses of functions with replacement inside the testing package 86 | - wastedassign # finds wasted assignment statements 87 | - whitespace # detects leading and trailing whitespace 88 | 89 | # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml 90 | settings: 91 | cyclop: 92 | # The maximal code complexity to report. 93 | # Default: 10 94 | max-complexity: 30 95 | # The maximal average package complexity. 96 | # If it's higher than 0.0 (float) the check is enabled. 97 | # Default: 0.0 98 | package-average: 10.0 99 | 100 | errcheck: 101 | # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. 102 | # Such cases aren't reported by default. 103 | # Default: false 104 | check-type-assertions: true 105 | 106 | exhaustive: 107 | # Program elements to check for exhaustiveness. 108 | # Default: [ switch ] 109 | check: 110 | - switch 111 | - map 112 | 113 | funcorder: 114 | # Checks if the exported methods of a structure are placed before the non-exported ones. 115 | # Default: true 116 | struct-method: false 117 | 118 | funlen: 119 | # Checks the number of lines in a function. 120 | # If lower than 0, disable the check. 121 | # Default: 60 122 | lines: 100 123 | # Checks the number of statements in a function. 124 | # If lower than 0, disable the check. 125 | # Default: 40 126 | statements: 50 127 | 128 | gochecksumtype: 129 | # Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed. 130 | # Default: true 131 | default-signifies-exhaustive: false 132 | 133 | gocognit: 134 | # Minimal code complexity to report. 135 | # Default: 30 (but we recommend 10-20) 136 | min-complexity: 30 137 | 138 | gocritic: 139 | # Settings passed to gocritic. 140 | # The settings key is the name of a supported gocritic checker. 141 | # The list of supported checkers can be found at https://go-critic.com/overview. 142 | settings: 143 | captLocal: 144 | # Whether to restrict checker to params only. 145 | # Default: true 146 | paramsOnly: false 147 | underef: 148 | # Whether to skip (*x).method() calls where x is a pointer receiver. 149 | # Default: true 150 | skipRecvDeref: false 151 | 152 | gosec: 153 | excludes: 154 | - G306 # too strict 155 | 156 | govet: 157 | # Enable all analyzers. 158 | # Default: false 159 | enable-all: true 160 | # Disable analyzers by name. 161 | # Run `GL_DEBUG=govet golangci-lint run --enable=govet` to see default, all available analyzers, and enabled analyzers. 162 | # Default: [] 163 | disable: 164 | - fieldalignment # too strict 165 | # Settings per analyzer. 166 | settings: 167 | shadow: 168 | # Whether to be strict about shadowing; can be noisy. 169 | # Default: false 170 | strict: true 171 | 172 | mnd: 173 | # List of function patterns to exclude from analysis. 174 | # Values always ignored: `time.Date`, 175 | # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, 176 | # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. 177 | # Default: [] 178 | ignored-functions: 179 | - args.Error 180 | - flag.Arg 181 | - flag.Duration.* 182 | - flag.Float.* 183 | - flag.Int.* 184 | - flag.Uint.* 185 | - os.Chmod 186 | - os.Mkdir.* 187 | - os.OpenFile 188 | - os.WriteFile 189 | - prometheus.ExponentialBuckets.* 190 | - prometheus.LinearBuckets 191 | 192 | nakedret: 193 | # Make an issue if func has more lines of code than this setting, and it has naked returns. 194 | # Default: 30 195 | max-func-lines: 0 196 | 197 | nolintlint: 198 | # Exclude following linters from requiring an explanation. 199 | # Default: [] 200 | allow-no-explanation: [funlen, gocognit, golines] 201 | # Enable to require an explanation of nonzero length after each nolint directive. 202 | # Default: false 203 | require-explanation: true 204 | # Enable to require nolint directives to mention the specific linter being suppressed. 205 | # Default: false 206 | require-specific: true 207 | 208 | perfsprint: 209 | # Optimizes into strings concatenation. 210 | # Default: true 211 | strconcat: false 212 | 213 | reassign: 214 | # Patterns for global variable names that are checked for reassignment. 215 | # See https://github.com/curioswitch/go-reassign#usage 216 | # Default: ["EOF", "Err.*"] 217 | patterns: 218 | - ".*" 219 | 220 | rowserrcheck: 221 | # database/sql is always checked. 222 | # Default: [] 223 | packages: 224 | - github.com/jmoiron/sqlx 225 | 226 | sloglint: 227 | # Enforce not using global loggers. 228 | # Values: 229 | # - "": disabled 230 | # - "all": report all global loggers 231 | # - "default": report only the default slog logger 232 | # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global 233 | # Default: "" 234 | no-global: all 235 | # Enforce using methods that accept a context. 236 | # Values: 237 | # - "": disabled 238 | # - "all": report all contextless calls 239 | # - "scope": report only if a context exists in the scope of the outermost function 240 | # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only 241 | # Default: "" 242 | context: scope 243 | 244 | staticcheck: 245 | # SAxxxx checks in https://staticcheck.dev/docs/configuration/options/#checks 246 | # Example (to disable some checks): [ "all", "-SA1000", "-SA1001"] 247 | # Default: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"] 248 | checks: 249 | - all 250 | # Incorrect or missing package comment. 251 | # https://staticcheck.dev/docs/checks/#ST1000 252 | - -ST1000 253 | # Use consistent method receiver names. 254 | # https://staticcheck.dev/docs/checks/#ST1016 255 | - -ST1016 256 | # Omit embedded fields from selector expression. 257 | # https://staticcheck.dev/docs/checks/#QF1008 258 | - -QF1008 259 | 260 | usetesting: 261 | # Enable/disable `os.TempDir()` detections. 262 | # Default: false 263 | os-temp-dir: true 264 | 265 | exclusions: 266 | # Log a warning if an exclusion rule is unused. 267 | # Default: false 268 | warn-unused: true 269 | # Predefined exclusion rules. 270 | # Default: [] 271 | presets: 272 | - std-error-handling 273 | - common-false-positives 274 | # Excluding configuration per-path, per-linter, per-text and per-source. 275 | rules: 276 | - source: "TODO" 277 | linters: [godot] 278 | - text: "should have a package comment" 279 | linters: [revive] 280 | - text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported' 281 | linters: [revive] 282 | - text: 'package comment should be of the form ".+"' 283 | source: "// ?(nolint|TODO)" 284 | linters: [revive] 285 | - text: 'comment on exported \S+ \S+ should be of the form ".+"' 286 | source: "// ?(nolint|TODO)" 287 | linters: [revive, staticcheck] 288 | - path: '_test\.go' 289 | linters: 290 | - bodyclose 291 | - cyclop 292 | - dupl 293 | - errcheck 294 | - funlen 295 | - gocognit 296 | - goconst 297 | - gosec 298 | - noctx 299 | - wrapcheck 300 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - id: tfsort 4 | main: ./main.go 5 | binary: tfsort 6 | goos: 7 | - linux 8 | - darwin 9 | - windows 10 | goarch: 11 | - amd64 12 | - arm 13 | - arm64 14 | ldflags: 15 | - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} 16 | env: 17 | - CGO_ENABLED=0 18 | 19 | release: 20 | prerelease: auto 21 | 22 | universal_binaries: 23 | - replace: true 24 | 25 | brews: 26 | - name: tfsort 27 | description: "Sort Terraform files" 28 | homepage: "https://github.com/AlexNabokikh/tfsort" 29 | repository: 30 | owner: alexnabokikh 31 | name: homebrew-tfsort 32 | commit_author: 33 | name: Alex Nabokikh 34 | email: nabokikh@duck.com 35 | 36 | chocolateys: 37 | - name: tfsort 38 | title: tfsort a CLI utility to sort Terraform variables and outputs 39 | authors: Alexander Nabokikh 40 | project_url: https://github.com/AlexNabokikh/tfsort 41 | url_template: "https://github.com/AlexNabokikh/tfsort/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 42 | icon_url: "https://rawcdn.githack.com/AlexNabokikh/tfsort/751bf0ae4423f88e33309ccaf9ea2187d04245c3/files/icon.png" 43 | copyright: 2025 Alexander Nabokikh 44 | license_url: https://github.com/AlexNabokikh/tfsort/blob/master/LICENSE 45 | require_license_acceptance: false 46 | project_source_url: https://github.com/AlexNabokikh/tfsort 47 | docs_url: https://github.com/AlexNabokikh/tfsort/blob/master/README.md 48 | bug_tracker_url: https://github.com/AlexNabokikh/tfsort/issues 49 | tags: "terraform tf tfsort cli cross-platform" 50 | summary: A CLI utility to sort Terraform variables and outputs. 51 | description: | 52 | {{ .ProjectName }} installer package. 53 | A CLI utility to sort Terraform variables and outputs. 54 | release_notes: "https://github.com/AlexNabokikh/tfsort/releases/tag/v{{ .Version }}" 55 | api_key: "{{ .Env.CHOCOLATEY_API_KEY }}" 56 | source_repo: "https://push.chocolatey.org/" 57 | skip_publish: false 58 | goamd64: v1 59 | 60 | checksum: 61 | name_template: "checksums.txt" 62 | 63 | changelog: 64 | sort: asc 65 | filters: 66 | exclude: 67 | - "^docs:" 68 | - "^test:" 69 | -------------------------------------------------------------------------------- /.mdl_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | 3 | # Line length 4 | exclude_rule 'MD013' 5 | -------------------------------------------------------------------------------- /.mdlrc: -------------------------------------------------------------------------------- 1 | style '.mdl_style.rb' 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | nabokikh@duck.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/faq). 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are available at 128 | [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/faq). 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the tfsort project 2 | 3 | Want to contribute? Great! First, read this page. 4 | 5 | ## How to become a contributor 6 | 7 | - Direct contributions 8 | - **Create pull requests directly.** 9 | - Please send e-mails to nabokikh@duck.com if you have any questions. 10 | - Feedback suggestions and bugs. 11 | - We use GitHub issues to track bugs and features. 12 | - For bugs and general issues please [file a new issue](https://github.com/AlexNabokikh/tfsort/issues/new). 13 | 14 | ## Code contribution guidelines 15 | 16 | ### Conventional commits 17 | 18 | - Use [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/#summary) message syntax for repo auto-tagging and releasing via pipeline. 19 | 20 | ### Code style and conventions 21 | 22 | - Use [golangci](https://github.com/golangci/golangci-lint) to check code style. 23 | - Respect the rules in `.golangci.yaml` specified in the source tree. 24 | 25 | ### Fix wrong commit 26 | 27 | In case you have an issue with your commit it is possible to fix it with `git commit --amend`. 28 | 29 | In case the errored commit is not the last one it is possible to fix it with the following procedure: 30 | 31 | ```shell 32 | git rebase --interactive 'bbc643cd^' 33 | ``` 34 | 35 | Please note the caret `^` at the end of the command, because you need actually to rebase back to the commit before the one you wish to modify. 36 | 37 | In the default editor, modify `pick` to `edit` in the line mentioning `'bbc643cd'`. 38 | 39 | Save the file and exit: git will interpret and automatically execute the commands in the file. You will find yourself in the previous situation in which you just had created commit `bbc643cd`. 40 | 41 | At this point, `bbc643cd` is your last commit and you can easily amend it to change the text with the command: 42 | 43 | ```shell 44 | git commit --all --amend 45 | git rebase --continue 46 | ``` 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2025] [Alexander Nabokikh] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Go parameters 2 | GOCMD=go 3 | GOBUILD=$(GOCMD) build 4 | GOTEST=$(GOCMD) test 5 | GOCLEAN=$(GOCMD) clean 6 | GOINSTALL=$(GOCMD) install 7 | BINARY_NAME=tfsort 8 | COVERAGE_FILE=c.out 9 | 10 | LINTCMD=golangci-lint run 11 | 12 | # Git versioning 13 | GIT_TAG := $(shell git describe --tags --abbrev=0 2>/dev/null) 14 | GIT_COMMIT_HASH := $(shell git rev-parse HEAD 2>/dev/null) 15 | BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null) 16 | 17 | # Provide defaults if git commands fail or not in a git repo 18 | VERSION ?= $(if $(GIT_TAG),$(GIT_TAG),dev) 19 | COMMIT ?= $(if $(GIT_COMMIT_HASH),$(GIT_COMMIT_HASH),unknown) 20 | DATE ?= $(if $(BUILD_DATE),$(BUILD_DATE),unknown) 21 | 22 | # Build flags for injecting version information into main.go 23 | LDFLAGS = -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) 24 | 25 | .DEFAULT_GOAL := help 26 | 27 | .PHONY: all build test coverage lint clean install run help setup-lint 28 | 29 | all: build 30 | 31 | build: 32 | @echo "Building $(BINARY_NAME) version $(VERSION) (commit: $(COMMIT), built: $(DATE))..." 33 | $(GOBUILD) -ldflags="$(LDFLAGS)" -o $(BINARY_NAME) ./main.go 34 | 35 | test: 36 | @echo "Running tests..." 37 | $(GOTEST) -v ./... 38 | 39 | coverage: 40 | @echo "Running tests and generating coverage report ($(COVERAGE_FILE))..." 41 | $(GOTEST) -coverprofile=$(COVERAGE_FILE) ./... 42 | @sed -i "s%github.com/AlexNabokikh/%%" $(COVERAGE_FILE) 43 | 44 | lint: 45 | @echo "Linting code using golangci-lint..." 46 | @command -v golangci-lint >/dev/null 2>&1 || { \ 47 | echo >&2 "golangci-lint not found. Please install it first."; \ 48 | echo >&2 "See: https://golangci-lint.run/usage/install/"; \ 49 | echo >&2 "Alternatively, run 'make setup-lint' to try to install it."; \ 50 | exit 1; \ 51 | } 52 | $(LINTCMD) 53 | 54 | setup-lint: 55 | @echo "Attempting to install golangci-lint..." 56 | $(GOCMD) install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 57 | 58 | clean: 59 | @echo "Cleaning up build artifacts and coverage files..." 60 | $(GOCLEAN) -cache 61 | rm -f $(BINARY_NAME) 62 | rm -f $(COVERAGE_FILE) 63 | @echo "Cleanup complete." 64 | 65 | install: 66 | @echo "Installing $(BINARY_NAME) with version information..." 67 | $(GOINSTALL) -ldflags="$(LDFLAGS)" ./... 68 | @echo "$(BINARY_NAME) installed successfully." 69 | 70 | run: build 71 | @echo "Running $(BINARY_NAME) $(ARGS)..." 72 | ./$(BINARY_NAME) $(ARGS) 73 | 74 | help: 75 | @echo "Makefile for the $(BINARY_NAME) project" 76 | @echo "" 77 | @echo "Usage: make [target]" 78 | @echo "" 79 | @echo "Common targets:" 80 | @echo " all Build the application binary (same as 'build')." 81 | @echo " build Build the application binary with embedded version information." 82 | @echo " Override version details: make build VERSION=1.0.1 COMMIT=mycommit DATE=mydate" 83 | @echo " test Run all Go tests." 84 | @echo " coverage Run tests and generate a code coverage report (outputs to $(COVERAGE_FILE))." 85 | @echo " lint Lint the Go source code using golangci-lint." 86 | @echo " clean Remove build artifacts (the binary, coverage files) and clear Go build cache." 87 | @echo " install Install the application binary to your Go bin path (e.g., $$GOPATH/bin)." 88 | @echo " run Build and then run the application. Pass arguments via ARGS variable." 89 | @echo " Example: make run ARGS=\"input.tf -o output.tf\"" 90 | @echo " help Show this help message (default target)." 91 | @echo "" 92 | @echo "Other targets:" 93 | @echo " setup-lint Attempt to install golangci-lint using 'go install'." 94 | @echo "" 95 | @echo "Build Variables:" 96 | @echo " VERSION Set the version string (default: latest git tag or 'dev')." 97 | @echo " COMMIT Set the commit hash (default: current git commit hash or 'unknown')." 98 | @echo " DATE Set the build date (default: current UTC date/time or 'unknown')." 99 | 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tfsort 2 | 3 | [![badge-gh-ci](https://github.com/AlexNabokikh/tfsort/actions/workflows/ci.yml/badge.svg)](https://github.com/AlexNabokikh/tfsort/actions/workflows/ci.yml/badge.svg) 4 | [![badge-gh-release](https://github.com/AlexNabokikh/tfsort/actions/workflows/release.yml/badge.svg)](https://github.com/AlexNabokikh/tfsort/actions/workflows/release.yml/badge.svg) 5 | [![go-report-card](https://goreportcard.com/badge/github.com/AlexNabokikh/tfsort)](https://goreportcard.com/report/github.com/AlexNabokikh/tfsort) 6 | [![maintainability](https://api.codeclimate.com/v1/badges/7d6a9fee7a8775dea0d8/maintainability)](https://codeclimate.com/github/AlexNabokikh/tfsort/maintainability) 7 | [![test-coverage](https://api.codeclimate.com/v1/badges/7d6a9fee7a8775dea0d8/test_coverage)](https://codeclimate.com/github/AlexNabokikh/tfsort/test_coverage) 8 | 9 | ![Logo](files/logo.png) 10 | 11 | `tfsort` is a command-line utility designed for meticulous engineers who prefer to keep their Terraform `variable`, `output`, `locals` and `terraform` blocks sorted alphabetically. It also corrects spacing issues between these blocks and removes leading or trailing newlines in the processed files. `tfsort` can read from files or stdin, write to files or stdout, and process directories recursively. 12 | 13 | ## Contents 14 | 15 | - [Demo](#demo) 16 | - [Key Features](#key-features) 17 | - [Supported File Types](#supported-file-types) 18 | - [Installation](#installation) 19 | - [Homebrew](#homebrew) 20 | - [Chocolatey (Windows)](#chocolatey-windows) 21 | - [Using `go install`](#using-go-install) 22 | - [Binary Release](#binary-release) 23 | - [From Source](#from-source) 24 | - [Usage](#usage) 25 | - [Command Synopsis](#command-synopsis) 26 | - [Arguments](#arguments) 27 | - [Flags](#flags) 28 | - [Examples](#examples) 29 | - [Contributing](#contributing) 30 | - [Code of Conduct](#code-of-conduct) 31 | - [Author](#author) 32 | - [License](#license) 33 | 34 | ## Demo 35 | 36 | [![asciicast](https://asciinema.org/a/5EpZOwIhHnMgCZT9NUx5h8vfN.svg)](https://asciinema.org/a/5EpZOwIhHnMgCZT9NUx5h8vfN) 37 | 38 | ## Key Features 39 | 40 | - **Alphabetical Sorting**: Sorts `variable`, `output`, `locals` and `terraform` blocks within your HCL files. 41 | - **Flexible Input/Output**: 42 | - Read from a specific file or standard input (stdin). 43 | - Overwrite the input file, write to a new file, or print to standard output (stdout). 44 | - **Recursive Processing**: Sort files in an entire directory and its subdirectories. 45 | - Intelligently skips common version control (`.git`) and Terraform utility directories (`.terraform`, `.terragrunt-cache`). 46 | - **Dry Run Mode**: Preview changes without modifying any files. 47 | - **Code Formatting**: 48 | - Corrects spacing between sorted blocks. 49 | - Removes unnecessary leading or trailing newlines from the file. 50 | 51 | ## Supported File Types 52 | 53 | `tfsort` processes files with the following extensions: 54 | 55 | - `.tf` 56 | - `.hcl` 57 | - `.tofu` 58 | 59 | ## Installation 60 | 61 | ### Homebrew 62 | 63 | To install `tfsort` using Homebrew: 64 | 65 | 1. Add the tap: 66 | 67 | ```bash 68 | brew tap alexnabokikh/tfsort 69 | ``` 70 | 71 | 2. Install `tfsort`: 72 | 73 | ```bash 74 | brew install tfsort 75 | ``` 76 | 77 | ### Chocolatey (Windows) 78 | 79 | To install `tfsort` using Chocolatey: 80 | 81 | ```bash 82 | choco install tfsort 83 | ``` 84 | 85 | ### Using `go install` 86 | 87 | If you have Go installed and configured, you can install `tfsort` directly using `go install`: 88 | 89 | ```bash 90 | go install github.com/AlexNabokikh/tfsort@latest 91 | ``` 92 | 93 | This command will download the source code, compile it, and install the `tfsort` binary into your `$GOPATH/bin` or `$GOBIN` directory (make sure this directory is in your system's `PATH`). 94 | 95 | ### Binary Release 96 | 97 | Download the latest binary release for your operating system from the [Releases Page](https://github.com/AlexNabokikh/tfsort/releases). 98 | 99 | ### From Source 100 | 101 | Alternatively, build `tfsort` from source: 102 | 103 | 1. Clone the repository: 104 | 105 | ```bash 106 | git clone https://github.com/AlexNabokikh/tfsort.git 107 | cd tfsort 108 | ``` 109 | 110 | 2. Build the binary: 111 | 112 | ```bash 113 | go build . 114 | ``` 115 | 116 | This will create a `tfsort` executable in the current directory. 117 | 118 | ## Usage 119 | 120 | ### Command Synopsis 121 | 122 | ```bash 123 | tfsort [file_or_directory|-] [flags] 124 | ``` 125 | 126 | ### Arguments 127 | 128 | - `file_or_directory` (optional): 129 | - Path to a single Terraform/HCL file (e.g., `variables.tf`). 130 | - Path to a directory to process recursively (requires the `-r` flag). 131 | - `-` (optional): 132 | - Instructs `tfsort` to read input from stdin. 133 | - If no file/directory argument is provided and stdin is detected as a pipe (e.g., `cat file.tf | tfsort`), `tfsort` will read from stdin. 134 | - If no arguments are provided and stdin is not a pipe, `tfsort` will show the help message. 135 | 136 | ### Flags 137 | 138 | - `-o, --out `: 139 | - Specifies the path to the output file. 140 | - If the input is a file and `-o` is not provided, the input file is overwritten. 141 | - If the input is stdin and `-o` is not provided, the output is sent to stdout. 142 | - This flag **cannot** be used with `-r, --recursive`. 143 | - `-d, --dry-run`: 144 | - Previews the changes by printing the sorted content to stdout. 145 | - No files will be modified when this flag is used. 146 | - `-r, --recursive`: 147 | - Recursively sorts supported files in the specified directory. 148 | - Files are modified in-place unless `-d, --dry-run` is also specified. 149 | - If `-r` is used, a directory path must be provided as an argument. 150 | - Cannot be used with stdin input (`-`) or the `-o, --out` flag. 151 | - `-h, --help`: 152 | - Displays a comprehensive help message, listing available commands, arguments, and flags with their descriptions. 153 | - `-v, --version`: 154 | - Displays the installed version of the `tfsort` application, typically including the version number, commit hash, and build date if available. 155 | 156 | ## Examples 157 | 158 | 1. **Sort a single file in-place:** 159 | (Sorts `variable`, `output`, `locals` and `terraform` blocks in `my_variables.tf` and overwrites the file) 160 | 161 | ```bash 162 | tfsort my_variables.tf 163 | ``` 164 | 165 | 2. **Sort a single file and write to a new file:** 166 | 167 | ```bash 168 | tfsort my_variables.tf -o sorted_variables.tf 169 | ``` 170 | 171 | 3. **Preview changes for a single file (dry run):** 172 | (Prints the sorted content to the console without modifying `my_variables.tf`) 173 | 174 | ```bash 175 | tfsort my_variables.tf -d 176 | ``` 177 | 178 | 4. **Sort content from stdin and print to stdout:** 179 | 180 | ```bash 181 | cat my_config.tf | tfsort - 182 | ``` 183 | 184 | Or, if `tfsort` is part of a pipeline and no file argument is given: 185 | 186 | ```bash 187 | cat my_config.tf | tfsort 188 | ``` 189 | 190 | 5. **Sort content from stdin and write to a file:** 191 | 192 | ```bash 193 | cat my_config.tf | tfsort - -o sorted_from_stdin.tf 194 | ``` 195 | 196 | 6. **Recursively sort files in a directory (in-place):** 197 | (Sorts all `.tf`, `.hcl`, `.tofu` files in `my_terraform_project/` and its subdirectories, modifying them in-place. Skips `.git`, `.terraform`, `.terragrunt-cache`.) 198 | 199 | ```bash 200 | tfsort -r ./my_terraform_project/ 201 | ``` 202 | 203 | 7. **Recursively sort files in a directory (dry run):** 204 | (Prints what would be changed for each file to the console without modifying them.) 205 | 206 | ```bash 207 | tfsort -r ./my_terraform_project/ -d 208 | ``` 209 | 210 | ## Contributing 211 | 212 | Contributions are welcome! Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) file for guidelines on how to contribute to this project, including code contributions, bug reports, and feature suggestions. 213 | 214 | ## Code of Conduct 215 | 216 | This project adheres to the Contributor Covenant Code of Conduct. By participating, you are expected to uphold this code. Please read the [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) file for details. 217 | 218 | ## Author 219 | 220 | This project was created by [Alexander Nabokikh](https://www.linkedin.com/in/nabokih/). 221 | 222 | ## License 223 | 224 | This software is available under the following licenses: 225 | 226 | - **[Apache 2.0](./LICENSE)** 227 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/AlexNabokikh/tfsort/internal/hclsort" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | // errShowHelp is a sentinel error used to indicate that help should be shown. 17 | var errShowHelp = errors.New("show help requested") 18 | 19 | // Execute is the entry point for the CLI. 20 | func Execute(version, commit, date string) { 21 | var ( 22 | outputPath string 23 | dryRun bool 24 | recursive bool 25 | ) 26 | 27 | rootCmd := &cobra.Command{ 28 | Use: "tfsort [file_or_directory|-]", 29 | Short: "A utility to sort Terraform variables and outputs. " + 30 | "If no file is specified or '-' is used as the filename, input is read from stdin. " + 31 | "If a directory is provided with -r, files are processed recursively.", 32 | Args: cobra.MaximumNArgs(1), 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | ingestor := hclsort.NewIngestor() 35 | 36 | if recursive { 37 | return runRecursiveMode(ingestor, args, dryRun, outputPath) 38 | } 39 | return runSingleMode(cmd, ingestor, args, outputPath, dryRun) 40 | }, 41 | } 42 | 43 | // SilenceUsage ensures that the usage message is not printed on every error. 44 | rootCmd.SilenceUsage = true 45 | 46 | // Set the version string for Cobra to use 47 | if version != "" && version != "dev" { 48 | rootCmd.Version = fmt.Sprintf( 49 | "%s (commit: %s, built: %s)", 50 | version, 51 | commit, 52 | date, 53 | ) 54 | } else { 55 | rootCmd.Version = "dev (build details not available)" 56 | if version == "dev" && commit != "none" && date != "unknown" { 57 | rootCmd.Version = fmt.Sprintf( 58 | "dev (commit: %s, built: %s)", 59 | commit, 60 | date, 61 | ) 62 | } 63 | } 64 | 65 | rootCmd.PersistentFlags().StringVarP( 66 | &outputPath, 67 | "out", 68 | "o", 69 | "", 70 | "path to the output file (cannot be used with --recursive)", 71 | ) 72 | rootCmd.PersistentFlags().BoolVarP( 73 | &dryRun, 74 | "dry-run", 75 | "d", false, 76 | "preview the changes without altering the original file(s).", 77 | ) 78 | rootCmd.PersistentFlags().BoolVarP( 79 | &recursive, 80 | "recursive", 81 | "r", 82 | false, 83 | "recursively sort files in a directory (in-place unless --dry-run is specified)", 84 | ) 85 | 86 | if err := rootCmd.Execute(); err != nil { 87 | os.Exit(1) 88 | } 89 | } 90 | 91 | // runRecursiveMode handles the logic when the --recursive flag is used. 92 | func runRecursiveMode( 93 | ingestor *hclsort.Ingestor, 94 | args []string, 95 | isDryRun bool, 96 | outPath string, 97 | ) error { 98 | if outPath != "" { 99 | return errors.New( 100 | "the -o/--out flag cannot be used with -r/--recursive", 101 | ) 102 | } 103 | if len(args) == 0 { 104 | return errors.New( 105 | "a directory path must be specified when using --recursive", 106 | ) 107 | } 108 | inputPath := args[0] 109 | if inputPath == "-" { 110 | return errors.New("stdin input ('-') cannot be used with --recursive") 111 | } 112 | 113 | info, err := os.Stat(inputPath) 114 | if err != nil { 115 | return fmt.Errorf( 116 | "failed to stat input path '%s': %w", 117 | inputPath, 118 | err, 119 | ) 120 | } 121 | if !info.IsDir() { 122 | return fmt.Errorf( 123 | "inputPath '%s' is not a directory; --recursive requires a directory", 124 | inputPath, 125 | ) 126 | } 127 | 128 | if !isDryRun { 129 | fmt.Printf("Recursively processing directory: %s\n", inputPath) 130 | } 131 | 132 | walkErr := filepath.WalkDir( 133 | inputPath, 134 | newWalkDirCallback(ingestor, isDryRun), 135 | ) 136 | if walkErr != nil { 137 | return fmt.Errorf( 138 | "error walking directory '%s': %w", 139 | inputPath, 140 | walkErr, 141 | ) 142 | } 143 | return nil 144 | } 145 | 146 | // newWalkDirCallback creates a callback function for filepath.WalkDir. 147 | func newWalkDirCallback( 148 | ingestor *hclsort.Ingestor, 149 | isDryRun bool, 150 | ) fs.WalkDirFunc { 151 | return func(currentPath string, d fs.DirEntry, errInWalk error) error { 152 | if errInWalk != nil { 153 | fmt.Fprintf( 154 | os.Stderr, 155 | "Warning: error accessing path %s: %v\n", 156 | currentPath, 157 | errInWalk, 158 | ) 159 | if errors.Is(errInWalk, fs.ErrPermission) { 160 | return nil 161 | } 162 | return errInWalk 163 | } 164 | 165 | if d.IsDir() { 166 | dirName := d.Name() 167 | if dirName == ".git" || 168 | dirName == ".terraform" || 169 | dirName == ".terragrunt-cache" { 170 | if !isDryRun { 171 | fmt.Printf("Skipping directory: %s\n", currentPath) 172 | } 173 | return filepath.SkipDir 174 | } 175 | return nil 176 | } 177 | 178 | fileExtension := strings.TrimPrefix(filepath.Ext(currentPath), ".") 179 | if !ingestor.AllowedTypes[fileExtension] { 180 | return nil 181 | } 182 | 183 | if !isDryRun { 184 | fmt.Printf("Processing %s...\n", currentPath) 185 | } 186 | errParse := ingestor.Parse(currentPath, "", isDryRun, false) 187 | if errParse != nil { 188 | fmt.Fprintf( 189 | os.Stderr, 190 | "Error sorting file %s: %v\n", 191 | currentPath, 192 | errParse, 193 | ) 194 | } 195 | return nil 196 | } 197 | } 198 | 199 | // runSingleMode handles the logic for a single file input or stdin. 200 | func runSingleMode( 201 | cmd *cobra.Command, 202 | ingestor *hclsort.Ingestor, 203 | args []string, 204 | outPath string, 205 | isDryRun bool, 206 | ) error { 207 | currentInputPath, isStdin, err := determineInputSource(args) 208 | if err != nil { 209 | if errors.Is(err, errShowHelp) { 210 | _ = cmd.Help() 211 | return nil 212 | } 213 | return err 214 | } 215 | 216 | if !isStdin { 217 | if validationErr := hclsort.ValidateFilePath(currentInputPath); validationErr != nil { 218 | return validationErr 219 | } 220 | } 221 | 222 | return ingestor.Parse(currentInputPath, outPath, isDryRun, isStdin) 223 | } 224 | 225 | // determineInputSource determines the input path and whether it's from stdin. 226 | func determineInputSource( 227 | args []string, 228 | ) (string, bool, error) { 229 | if len(args) == 0 { 230 | stat, statErr := os.Stdin.Stat() 231 | if statErr != nil { 232 | return "", false, fmt.Errorf("failed to stat stdin: %w", statErr) 233 | } 234 | if (stat.Mode() & os.ModeCharDevice) == 0 { 235 | return hclsort.StdInPathIdentifier, true, nil 236 | } 237 | return "", false, errShowHelp 238 | } 239 | 240 | pathArg := args[0] 241 | if pathArg == "-" { 242 | return hclsort.StdInPathIdentifier, true, nil 243 | } 244 | return pathArg, false, nil 245 | } 246 | -------------------------------------------------------------------------------- /files/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexNabokikh/tfsort/95c2c451ccefc3fcc5c55a7462b746e7330e1ab8/files/icon.png -------------------------------------------------------------------------------- /files/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexNabokikh/tfsort/95c2c451ccefc3fcc5c55a7462b746e7330e1ab8/files/logo.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/AlexNabokikh/tfsort 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/hashicorp/hcl/v2 v2.23.0 7 | github.com/spf13/cobra v1.9.1 8 | ) 9 | 10 | require ( 11 | github.com/agext/levenshtein v1.2.3 // indirect 12 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 13 | github.com/go-test/deep v1.1.1 // indirect 14 | github.com/google/go-cmp v0.7.0 // indirect 15 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 16 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 17 | github.com/spf13/pflag v1.0.6 // indirect 18 | github.com/zclconf/go-cty v1.16.3 // indirect 19 | golang.org/x/mod v0.24.0 // indirect 20 | golang.org/x/sync v0.14.0 // indirect 21 | golang.org/x/text v0.25.0 // indirect 22 | golang.org/x/tools v0.33.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= 2 | github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= 3 | github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= 4 | github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 9 | github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 10 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 11 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 12 | github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= 13 | github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= 14 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 15 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 16 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= 17 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 18 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 19 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 20 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 21 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 22 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 23 | github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= 24 | github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= 25 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= 26 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= 27 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 28 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 29 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 30 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 31 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 32 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 33 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 34 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 35 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 36 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 37 | -------------------------------------------------------------------------------- /internal/hclsort/file_handler.go: -------------------------------------------------------------------------------- 1 | package hclsort 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | // ValidateFilePath checks if the path is valid for processing. 12 | func ValidateFilePath(path string) error { 13 | if path == "" { 14 | return errors.New("file path is required") 15 | } 16 | 17 | info, err := os.Stat(path) 18 | switch { 19 | case os.IsNotExist(err): 20 | return errors.New("file does not exist") 21 | case err != nil: 22 | return fmt.Errorf("error accessing file '%s': %w", path, err) 23 | case info.IsDir(): 24 | return errors.New("path is a directory, not a file") 25 | default: 26 | return nil 27 | } 28 | } 29 | 30 | // CheckFileExtension verifies the file extension against a list of allowed types. 31 | func CheckFileExtension(path string, allowedTypes map[string]bool) error { 32 | fileExtension := "" 33 | ext := filepath.Ext(path) 34 | if len(ext) > 0 { 35 | fileExtension = ext[1:] 36 | } 37 | 38 | if !allowedTypes[fileExtension] { 39 | if fileExtension != "" { 40 | return fmt.Errorf( 41 | "file extension '%s' is not a supported Terraform/HCL type", 42 | fileExtension, 43 | ) 44 | } 45 | } 46 | return nil 47 | } 48 | 49 | // ReadFileBytes reads the content of the file at the given path. 50 | func ReadFileBytes(path string) ([]byte, error) { 51 | src, err := os.ReadFile(path) 52 | if err != nil { 53 | return nil, fmt.Errorf("error reading file '%s': %w", path, err) 54 | } 55 | return src, nil 56 | } 57 | 58 | // WriteSortedContent handles writing the outputBytes to the specified destination. 59 | func WriteSortedContent( 60 | originalPathOrMarker string, 61 | outputPath string, 62 | dryRun bool, 63 | outputBytes []byte, 64 | isInputFromStdin bool, 65 | ) error { 66 | finalBytes := append(bytes.TrimSpace(outputBytes), '\n') 67 | 68 | switch { 69 | case outputPath != "": 70 | err := os.WriteFile(outputPath, finalBytes, 0644) 71 | if err != nil { 72 | return fmt.Errorf( 73 | "error writing output to file '%s': %w", 74 | outputPath, 75 | err, 76 | ) 77 | } 78 | case dryRun: 79 | fmt.Print(string(finalBytes)) 80 | case isInputFromStdin: 81 | _, err := fmt.Print(string(finalBytes)) 82 | if err != nil { 83 | return fmt.Errorf("error writing to stdout: %w", err) 84 | } 85 | default: 86 | err := os.WriteFile(originalPathOrMarker, finalBytes, 0644) 87 | if err != nil { 88 | return fmt.Errorf( 89 | "error writing output to file '%s': %w", 90 | originalPathOrMarker, 91 | err, 92 | ) 93 | } 94 | } 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /internal/hclsort/hcl_processor.go: -------------------------------------------------------------------------------- 1 | package hclsort 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | "github.com/hashicorp/hcl/v2" 8 | "github.com/hashicorp/hcl/v2/hclsyntax" 9 | "github.com/hashicorp/hcl/v2/hclwrite" 10 | ) 11 | 12 | // ParseHCLContent parses the HCL source byte slice using hclwrite. 13 | func ParseHCLContent( 14 | src []byte, 15 | filename string, 16 | ) (*hclwrite.File, error) { 17 | file, diags := hclwrite.ParseConfig( 18 | src, 19 | filename, 20 | hcl.Pos{Line: 1, Column: 1}, 21 | ) 22 | if diags.HasErrors() { 23 | return nil, fmt.Errorf( 24 | "error parsing HCL content from '%s': %w", 25 | filename, 26 | diags, 27 | ) 28 | } 29 | return file, nil 30 | } 31 | 32 | // sortRequiredProvidersInBlock sorts the entries in any required_providers block. 33 | func sortRequiredProvidersInBlock(block *hclwrite.Block) { 34 | for _, b := range block.Body().Blocks() { 35 | if b.Type() != "required_providers" { 36 | continue 37 | } 38 | body := b.Body() 39 | attrs := body.Attributes() 40 | 41 | providerNames := make([]string, 0, len(attrs)) 42 | for name := range attrs { 43 | providerNames = append(providerNames, name) 44 | } 45 | sort.Strings(providerNames) 46 | 47 | body.Clear() 48 | body.AppendNewline() 49 | 50 | for i, name := range providerNames { 51 | attr := attrs[name] 52 | tokens := attr.BuildTokens(nil) 53 | 54 | start, end := 0, len(tokens) 55 | for start < end && tokens[start].Type == hclsyntax.TokenNewline { 56 | start++ 57 | } 58 | for end > start && tokens[end-1].Type == hclsyntax.TokenNewline { 59 | end-- 60 | } 61 | body.AppendUnstructuredTokens(tokens[start:end]) 62 | if i+1 < len(providerNames) { 63 | body.AppendNewline() 64 | } 65 | } 66 | body.AppendNewline() 67 | } 68 | } 69 | 70 | // sortLocalsBlock sorts the top‐level assignments in a locals block. 71 | func sortLocalsBlock(block *hclwrite.Block) { 72 | body := block.Body() 73 | attrs := body.Attributes() 74 | 75 | names := make([]string, 0, len(attrs)) 76 | for name := range attrs { 77 | names = append(names, name) 78 | } 79 | sort.Strings(names) 80 | 81 | body.Clear() 82 | body.AppendNewline() 83 | for i, name := range names { 84 | attr := attrs[name] 85 | tokens := attr.BuildTokens(nil) 86 | 87 | start, end := 0, len(tokens) 88 | for start < end && tokens[start].Type == hclsyntax.TokenNewline { 89 | start++ 90 | } 91 | for end > start && tokens[end-1].Type == hclsyntax.TokenNewline { 92 | end-- 93 | } 94 | 95 | body.AppendUnstructuredTokens(tokens[start:end]) 96 | if i+1 < len(names) { 97 | body.AppendNewline() 98 | } 99 | } 100 | body.AppendNewline() 101 | } 102 | 103 | // ProcessAndSortBlocks extracts sortable blocks (variables, outputs, locals, terraform) and sorts them. 104 | func ProcessAndSortBlocks( 105 | file *hclwrite.File, 106 | allowedBlocks map[string]bool, 107 | ) *hclwrite.File { 108 | for _, block := range file.Body().Blocks() { 109 | switch block.Type() { 110 | case "terraform": 111 | sortRequiredProvidersInBlock(block) 112 | case "locals": 113 | sortLocalsBlock(block) 114 | } 115 | } 116 | 117 | body := file.Body() 118 | originalBlocks := body.Blocks() 119 | 120 | sortableItems := make([]*SortableBlock, 0) 121 | otherBlocks := make([]*hclwrite.Block, 0) 122 | 123 | for _, block := range originalBlocks { 124 | blockType := block.Type() 125 | if allowedBlocks[blockType] && len(block.Labels()) > 0 { 126 | sortableItems = append(sortableItems, &SortableBlock{ 127 | Name: block.Labels()[0], 128 | Block: block, 129 | }) 130 | } else { 131 | otherBlocks = append(otherBlocks, block) 132 | } 133 | } 134 | 135 | sort.Slice(sortableItems, func(i, j int) bool { 136 | return sortableItems[i].Name < sortableItems[j].Name 137 | }) 138 | 139 | body.Clear() 140 | 141 | for i, block := range otherBlocks { 142 | body.AppendBlock(block) 143 | if i < len(otherBlocks)-1 || len(sortableItems) > 0 { 144 | body.AppendNewline() 145 | } 146 | } 147 | 148 | for i, sb := range sortableItems { 149 | body.AppendBlock(sb.Block) 150 | if i < len(sortableItems)-1 { 151 | body.AppendNewline() 152 | } 153 | } 154 | 155 | return file 156 | } 157 | 158 | // FormatHCLBytes formats the HCL file's content into a byte slice. 159 | func FormatHCLBytes(file *hclwrite.File) []byte { 160 | return hclwrite.Format(file.Bytes()) 161 | } 162 | -------------------------------------------------------------------------------- /internal/hclsort/ingestor.go: -------------------------------------------------------------------------------- 1 | package hclsort 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | ) 8 | 9 | // NewIngestor returns a new Ingestor instance with default allowed types and blocks. 10 | func NewIngestor() *Ingestor { 11 | return &Ingestor{ 12 | AllowedTypes: map[string]bool{ 13 | "tf": true, 14 | "hcl": true, 15 | "tofu": true, 16 | }, 17 | AllowedBlocks: map[string]bool{ 18 | "variable": true, 19 | "output": true, 20 | }, 21 | } 22 | } 23 | 24 | // Parse orchestrates the reading, parsing, sorting, and writing of a Terraform/HCL file. 25 | func (i *Ingestor) Parse( 26 | inputPath string, 27 | outputPath string, 28 | dryRun bool, 29 | isStdin bool, 30 | ) error { 31 | var src []byte 32 | var err error 33 | filenameForParser := inputPath 34 | 35 | if isStdin { 36 | src, err = io.ReadAll(os.Stdin) 37 | if err != nil { 38 | return fmt.Errorf("error reading from stdin: %w", err) 39 | } 40 | } else { 41 | if extErr := CheckFileExtension(inputPath, i.AllowedTypes); extErr != nil { 42 | fmt.Fprintf(os.Stderr, "Warning: %v\n", extErr) 43 | } 44 | src, err = ReadFileBytes(inputPath) 45 | if err != nil { 46 | return err 47 | } 48 | } 49 | 50 | hclFile, err := ParseHCLContent(src, filenameForParser) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | processedFile := ProcessAndSortBlocks(hclFile, i.AllowedBlocks) 56 | 57 | formattedBytes := FormatHCLBytes(processedFile) 58 | 59 | return WriteSortedContent(inputPath, outputPath, dryRun, formattedBytes, isStdin) 60 | } 61 | -------------------------------------------------------------------------------- /internal/hclsort/testdata/expected.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 0.12" 3 | } 4 | 5 | locals { 6 | kubernetes_pipeline_roles = [ 7 | for role in var.kubernetes_pipeline_roles : { 8 | rolearn = role.rolearn 9 | namespaces = role.namespaces 10 | } 11 | ] 12 | } 13 | 14 | variable "additional_userdata" { 15 | default = "" 16 | } 17 | 18 | variable "aws-profile" { 19 | # inside block comment 20 | description = "The aws profile name, used when creating the kubeconfig file." 21 | } 22 | 23 | variable "eks_shared_namespaces" { 24 | description = "Namespaces to be shared between teams." 25 | type = map(list(string)) 26 | default = { 27 | dns = ["external-dns"] 28 | infra = ["infra-shared"] 29 | logging = ["logging"] 30 | monitoring = ["infra-monitoring"] 31 | ingress = ["infra-ingress"] 32 | argo = ["argo"] 33 | newrelic = ["newrelic"] 34 | } 35 | } 36 | 37 | variable "external_dns_additional_managed_zones" { 38 | description = "Additional managed zones for external-dns." 39 | type = list(string) 40 | default = [] 41 | } 42 | 43 | output "hardened-image-id" { 44 | description = "The AMI ID of the hardened image." 45 | value = data.aws_ami.hardened.id 46 | } 47 | 48 | variable "kubernetes_pipeline_roles" { 49 | description = "IAM roles for pipelines required access to EKS." 50 | type = list(object({ 51 | rolearn = string 52 | namespaces = list(string) 53 | })) 54 | default = [] 55 | } 56 | 57 | # comment for kubernetes_pipeline_users valiable 58 | variable "kubernetes_pipeline_users" { 59 | description = "IAM users for pipelines required access to EKS." 60 | type = list(object({ 61 | userarn = string 62 | namespaces = list(string) 63 | })) 64 | default = [] 65 | } 66 | 67 | output "private_key_name" { 68 | description = "The name of the private key made to share." 69 | value = module.account.private_key_name 70 | } 71 | -------------------------------------------------------------------------------- /internal/hclsort/testdata/expected.tofu: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 0.12" 3 | } 4 | 5 | locals { 6 | kubernetes_pipeline_roles = [ 7 | for role in var.kubernetes_pipeline_roles : { 8 | rolearn = role.rolearn 9 | namespaces = role.namespaces 10 | } 11 | ] 12 | } 13 | 14 | variable "additional_userdata" { 15 | default = "" 16 | } 17 | 18 | variable "aws-profile" { 19 | # inside block comment 20 | description = "The aws profile name, used when creating the kubeconfig file." 21 | } 22 | 23 | variable "eks_shared_namespaces" { 24 | description = "Namespaces to be shared between teams." 25 | type = map(list(string)) 26 | default = { 27 | dns = ["external-dns"] 28 | infra = ["infra-shared"] 29 | logging = ["logging"] 30 | monitoring = ["infra-monitoring"] 31 | ingress = ["infra-ingress"] 32 | argo = ["argo"] 33 | newrelic = ["newrelic"] 34 | } 35 | } 36 | 37 | variable "external_dns_additional_managed_zones" { 38 | description = "Additional managed zones for external-dns." 39 | type = list(string) 40 | default = [] 41 | } 42 | 43 | output "hardened-image-id" { 44 | description = "The AMI ID of the hardened image." 45 | value = data.aws_ami.hardened.id 46 | } 47 | 48 | variable "kubernetes_pipeline_roles" { 49 | description = "IAM roles for pipelines required access to EKS." 50 | type = list(object({ 51 | rolearn = string 52 | namespaces = list(string) 53 | })) 54 | default = [] 55 | } 56 | 57 | # comment for kubernetes_pipeline_users valiable 58 | variable "kubernetes_pipeline_users" { 59 | description = "IAM users for pipelines required access to EKS." 60 | type = list(object({ 61 | userarn = string 62 | namespaces = list(string) 63 | })) 64 | default = [] 65 | } 66 | 67 | output "private_key_name" { 68 | description = "The name of the private key made to share." 69 | value = module.account.private_key_name 70 | } 71 | -------------------------------------------------------------------------------- /internal/hclsort/testdata/invalid.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "b" { 2 | bucket = "my-tf-test-bucket" 3 | 4 | tags = { 5 | Name = "My bucket" 6 | Environment = "Dev" 7 | } 8 | } 9 | 10 | resource "aws_s3_bucket_acl" "example" { 11 | bucket = aws_s3_bucket.b.id 12 | acl = "private" 13 | } 14 | -------------------------------------------------------------------------------- /internal/hclsort/testdata/valid.tf: -------------------------------------------------------------------------------- 1 | variable "kubernetes_pipeline_roles" { 2 | description = "IAM roles for pipelines required access to EKS." 3 | type = list(object({ 4 | rolearn = string 5 | namespaces = list(string) 6 | })) 7 | default = [] 8 | } 9 | 10 | 11 | 12 | # comment for kubernetes_pipeline_users valiable 13 | variable "kubernetes_pipeline_users" { 14 | description = "IAM users for pipelines required access to EKS." 15 | type = list(object({ 16 | userarn = string 17 | namespaces = list(string) 18 | })) 19 | default = [] 20 | } 21 | 22 | 23 | variable "external_dns_additional_managed_zones" { 24 | description = "Additional managed zones for external-dns." 25 | type = list(string) 26 | default = [] 27 | } 28 | 29 | variable "aws-profile" { 30 | # inside block comment 31 | description = "The aws profile name, used when creating the kubeconfig file." 32 | } 33 | 34 | variable "additional_userdata" { 35 | default = "" 36 | } 37 | 38 | 39 | 40 | 41 | terraform { 42 | required_version = ">= 0.12" 43 | } 44 | 45 | 46 | 47 | 48 | variable "eks_shared_namespaces" { 49 | description = "Namespaces to be shared between teams." 50 | type = map(list(string)) 51 | default = { 52 | dns = ["external-dns"] 53 | infra = ["infra-shared"] 54 | logging = ["logging"] 55 | monitoring = ["infra-monitoring"] 56 | ingress = ["infra-ingress"] 57 | argo = ["argo"] 58 | newrelic = ["newrelic"] 59 | } 60 | } 61 | 62 | output "hardened-image-id" { 63 | description = "The AMI ID of the hardened image." 64 | value = data.aws_ami.hardened.id 65 | } 66 | 67 | 68 | 69 | locals { 70 | kubernetes_pipeline_roles = [ 71 | for role in var.kubernetes_pipeline_roles : { 72 | rolearn = role.rolearn 73 | namespaces = role.namespaces 74 | } 75 | ] 76 | } 77 | 78 | 79 | 80 | output "private_key_name" { 81 | description = "The name of the private key made to share." 82 | value = module.account.private_key_name 83 | } 84 | -------------------------------------------------------------------------------- /internal/hclsort/testdata/valid.tofu: -------------------------------------------------------------------------------- 1 | variable "kubernetes_pipeline_roles" { 2 | description = "IAM roles for pipelines required access to EKS." 3 | type = list(object({ 4 | rolearn = string 5 | namespaces = list(string) 6 | })) 7 | default = [] 8 | } 9 | 10 | 11 | 12 | # comment for kubernetes_pipeline_users valiable 13 | variable "kubernetes_pipeline_users" { 14 | description = "IAM users for pipelines required access to EKS." 15 | type = list(object({ 16 | userarn = string 17 | namespaces = list(string) 18 | })) 19 | default = [] 20 | } 21 | 22 | 23 | variable "external_dns_additional_managed_zones" { 24 | description = "Additional managed zones for external-dns." 25 | type = list(string) 26 | default = [] 27 | } 28 | 29 | variable "aws-profile" { 30 | # inside block comment 31 | description = "The aws profile name, used when creating the kubeconfig file." 32 | } 33 | 34 | variable "additional_userdata" { 35 | default = "" 36 | } 37 | 38 | 39 | 40 | 41 | terraform { 42 | required_version = ">= 0.12" 43 | } 44 | 45 | 46 | 47 | 48 | variable "eks_shared_namespaces" { 49 | description = "Namespaces to be shared between teams." 50 | type = map(list(string)) 51 | default = { 52 | dns = ["external-dns"] 53 | infra = ["infra-shared"] 54 | logging = ["logging"] 55 | monitoring = ["infra-monitoring"] 56 | ingress = ["infra-ingress"] 57 | argo = ["argo"] 58 | newrelic = ["newrelic"] 59 | } 60 | } 61 | 62 | output "hardened-image-id" { 63 | description = "The AMI ID of the hardened image." 64 | value = data.aws_ami.hardened.id 65 | } 66 | 67 | 68 | 69 | locals { 70 | kubernetes_pipeline_roles = [ 71 | for role in var.kubernetes_pipeline_roles : { 72 | rolearn = role.rolearn 73 | namespaces = role.namespaces 74 | } 75 | ] 76 | } 77 | 78 | 79 | 80 | output "private_key_name" { 81 | description = "The name of the private key made to share." 82 | value = module.account.private_key_name 83 | } 84 | -------------------------------------------------------------------------------- /internal/hclsort/tfsort_test.go: -------------------------------------------------------------------------------- 1 | package hclsort_test 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/AlexNabokikh/tfsort/internal/hclsort" 11 | ) 12 | 13 | const ( 14 | testDataBaseDir = "testdata" 15 | validFilePath = "testdata/valid.tf" 16 | validTofuPath = "testdata/valid.tofu" 17 | expectedTfPath = "testdata/expected.tf" 18 | expectedTofuPath = "testdata/expected.tofu" 19 | outputFile = "output.tf" 20 | ) 21 | 22 | func setupTestDir(t *testing.T) { 23 | t.Helper() 24 | if _, err := os.Stat(testDataBaseDir); os.IsNotExist(err) { 25 | err = os.Mkdir(testDataBaseDir, 0755) 26 | if err != nil { 27 | t.Fatalf("Failed to create testdata directory: %v", err) 28 | } 29 | } 30 | } 31 | 32 | func cleanupTestFiles(t *testing.T, files ...string) { 33 | t.Helper() 34 | for _, file := range files { 35 | _ = os.Chmod(file, 0600) 36 | _ = os.Remove(file) 37 | } 38 | } 39 | 40 | func mockStdin(t *testing.T, content string) func() { 41 | t.Helper() 42 | originalStdin := os.Stdin 43 | r, w, errPipe := os.Pipe() 44 | if errPipe != nil { 45 | t.Fatalf("Failed to create pipe for stdin mock: %v", errPipe) 46 | } 47 | 48 | go func() { 49 | defer w.Close() 50 | _, errWrite := w.WriteString(content) 51 | if errWrite != nil { 52 | t.Logf("Error writing to stdin mock: %v", errWrite) 53 | } 54 | }() 55 | 56 | os.Stdin = r //nolint:reassign //common pattern for mocking standard I/O in tests 57 | 58 | return func() { 59 | os.Stdin = originalStdin //nolint:reassign //common pattern for mocking standard I/O in tests 60 | if errClose := r.Close(); errClose != nil { 61 | t.Logf("Error closing mocked stdin reader: %v", errClose) 62 | } 63 | } 64 | } 65 | 66 | func captureOutput(t *testing.T, action func()) string { 67 | t.Helper() 68 | oldStdout := os.Stdout 69 | r, w, errPipe := os.Pipe() 70 | if errPipe != nil { 71 | t.Fatalf("Failed to create pipe for stdout capture: %v", errPipe) 72 | } 73 | os.Stdout = w //nolint:reassign //common pattern for mocking standard I/O in tests 74 | 75 | action() 76 | 77 | if errClose := w.Close(); errClose != nil { 78 | t.Logf("Warning: failed to close writer for stdout capture: %v", errClose) 79 | } 80 | os.Stdout = oldStdout //nolint:reassign //common pattern for mocking standard I/O in tests 81 | 82 | outBytes, errRead := io.ReadAll(r) 83 | if errRead != nil { 84 | t.Fatalf("Failed to read from stdout capture pipe: %v", errRead) 85 | } 86 | if errClose := r.Close(); errClose != nil { 87 | t.Logf("Warning: failed to close reader for stdout capture: %v", errClose) 88 | } 89 | return string(outBytes) 90 | } 91 | 92 | func TestCheckFileExtension(t *testing.T) { 93 | setupTestDir(t) 94 | 95 | ingestor := hclsort.NewIngestor() 96 | allowedTypes := ingestor.AllowedTypes 97 | 98 | invalidExtFile := filepath.Join(testDataBaseDir, "invalid_file.txt") 99 | if err := os.WriteFile(invalidExtFile, []byte("data"), 0600); err != nil { 100 | t.Fatalf("Failed to create invalid extension file: %v", err) 101 | } 102 | defer cleanupTestFiles(t, invalidExtFile) 103 | 104 | t.Run("Valid Terraform File Path", func(t *testing.T) { 105 | if err := hclsort.CheckFileExtension(validFilePath, allowedTypes); err != nil { 106 | t.Errorf("Unexpected error for valid .tf file path: %v", err) 107 | } 108 | }) 109 | 110 | t.Run("Valid OpenTofu File Path", func(t *testing.T) { 111 | if err := hclsort.CheckFileExtension(validTofuPath, allowedTypes); err != nil { 112 | t.Errorf("Unexpected error for valid .tofu file path: %v", err) 113 | } 114 | }) 115 | 116 | t.Run("Path with valid extension (file existence not checked)", func(t *testing.T) { 117 | err := hclsort.CheckFileExtension("nonExistentFile.tf", allowedTypes) 118 | if err != nil { 119 | t.Errorf( 120 | "Expected no error for a path with a valid extension (.tf), but got: %v", 121 | err, 122 | ) 123 | } 124 | }) 125 | 126 | t.Run("Path with unsupported extension", func(t *testing.T) { 127 | err := hclsort.CheckFileExtension(invalidExtFile, allowedTypes) 128 | if err == nil { 129 | t.Error( 130 | "Expected error for unsupported file type (.txt) but got nil", 131 | ) 132 | } else if !strings.Contains( 133 | err.Error(), 134 | "not a supported Terraform/HCL type", 135 | ) { 136 | t.Errorf( 137 | "Expected 'not a supported Terraform/HCL type' error, but got: %v", 138 | err, 139 | ) 140 | } 141 | }) 142 | 143 | t.Run("Path with empty extension", func(t *testing.T) { 144 | if err := hclsort.CheckFileExtension("fileWithNoExtension", allowedTypes); err != nil { 145 | t.Errorf( 146 | "Expected no error for a path with no extension, but got: %v", 147 | err, 148 | ) 149 | } 150 | }) 151 | 152 | t.Run("Path with only a dot (treated as no extension)", func(t *testing.T) { 153 | if err := hclsort.CheckFileExtension(".", allowedTypes); err != nil { 154 | t.Errorf( 155 | "Expected no error for a path that is just a dot ('.'), but got: %v", 156 | err, 157 | ) 158 | } 159 | }) 160 | } 161 | 162 | //gocyclo:ignore 163 | func TestParse(t *testing.T) { 164 | setupTestDir(t) 165 | 166 | validTestContentBytes, errReadFile := os.ReadFile(validFilePath) 167 | if errReadFile != nil { 168 | t.Fatalf("Failed to read %s: %v. Ensure it exists and contains unsorted HCL.", validFilePath, errReadFile) 169 | } 170 | validTestContentForStdin := string(validTestContentBytes) 171 | 172 | expectedSortedTestContentBytes, errReadFile := os.ReadFile(expectedTfPath) 173 | if errReadFile != nil { 174 | t.Fatalf("Failed to read %s: %v. Ensure it exists and contains sorted HCL.", expectedTfPath, errReadFile) 175 | } 176 | expectedSortedTestContent := string(expectedSortedTestContentBytes) 177 | normalizedExpectedSortedContent := strings.TrimSpace(expectedSortedTestContent) + "\n" 178 | 179 | expectedSortedTofuContentBytes, errReadFile := os.ReadFile(expectedTofuPath) 180 | if errReadFile != nil { 181 | t.Fatalf("Failed to read %s: %v. Ensure it exists and contains sorted HCL.", expectedTofuPath, errReadFile) 182 | } 183 | normalizedExpectedSortedTofuContent := strings.TrimSpace(string(expectedSortedTofuContentBytes)) + "\n" 184 | 185 | ingestor := hclsort.NewIngestor() 186 | invalidHclFile := filepath.Join(testDataBaseDir, "invalid_syntax.tf") 187 | unwritableOutputFile := filepath.Join(testDataBaseDir, "unwritable_output.tf") 188 | unreadableInputFile := filepath.Join(testDataBaseDir, "unreadable_input.tf") 189 | 190 | defer cleanupTestFiles( 191 | t, 192 | outputFile, 193 | invalidHclFile, 194 | unwritableOutputFile, 195 | unreadableInputFile, 196 | ) 197 | 198 | t.Run("File does not exist", func(t *testing.T) { 199 | err := ingestor.Parse("nonExistentFile.tf", outputFile, false, false) 200 | if err == nil { 201 | t.Error("Expected error for non-existent file but got nil") 202 | } else if !strings.Contains(err.Error(), "no such file or directory") && 203 | !strings.Contains(err.Error(), "error reading file") { 204 | t.Errorf( 205 | "Expected 'no such file' or 'error reading file' error, but got: %v", 206 | err, 207 | ) 208 | } 209 | }) 210 | 211 | t.Run("Input file read error", func(t *testing.T) { 212 | if err := os.WriteFile(unreadableInputFile, []byte(`variable "a" {}`), 0600); err != nil { 213 | t.Fatalf("Failed to create file for read error test: %v", err) 214 | } 215 | 216 | errChmod := os.Chmod(unreadableInputFile, 0000) 217 | if errChmod != nil { 218 | t.Logf("Warning: Could not set input file permissions to 0000: %v", errChmod) 219 | _, readErrAttempt := os.ReadFile(unreadableInputFile) 220 | if readErrAttempt == nil { 221 | _ = os.Chmod(unreadableInputFile, 0600) 222 | t.Skipf("Skipping read error test: unable to make file %s unreadable by owner", unreadableInputFile) 223 | } 224 | } 225 | defer func() { _ = os.Chmod(unreadableInputFile, 0600) }() 226 | 227 | errParse := ingestor.Parse(unreadableInputFile, outputFile, false, false) 228 | switch { 229 | case errParse == nil: 230 | t.Errorf("Expected error when reading input file with permissions 0000 but got nil") 231 | case !strings.Contains(errParse.Error(), "error reading file"): 232 | t.Errorf("Expected 'error reading file' error, but got: %v", errParse) 233 | } 234 | }) 235 | 236 | t.Run("Invalid HCL Syntax from file", func(t *testing.T) { 237 | invalidContent := []byte(`variable "a" { type = string`) 238 | if err := os.WriteFile(invalidHclFile, invalidContent, 0600); err != nil { 239 | t.Fatalf("Failed to create invalid HCL file: %v", err) 240 | } 241 | 242 | errParse := ingestor.Parse(invalidHclFile, outputFile, false, false) 243 | if errParse == nil { 244 | t.Error("Expected error for invalid HCL syntax but got nil") 245 | } else if !strings.Contains(errParse.Error(), "error parsing HCL content") { 246 | t.Errorf("Expected HCL parsing error, but got: %v", errParse) 247 | } 248 | }) 249 | 250 | t.Run("Write to output file (.tf)", func(t *testing.T) { 251 | cleanupTestFiles(t, outputFile) 252 | 253 | if err := ingestor.Parse(validFilePath, outputFile, false, false); err != nil { 254 | t.Fatalf("Parse failed unexpectedly: %v", err) 255 | } 256 | 257 | if _, errStat := os.Stat(outputFile); os.IsNotExist(errStat) { 258 | t.Fatal("Output file was not created") 259 | } 260 | 261 | outFileBytes, errRead := os.ReadFile(outputFile) 262 | if errRead != nil { 263 | t.Fatalf("Failed to read output file %s: %v", outputFile, errRead) 264 | } 265 | 266 | if string(outFileBytes) != normalizedExpectedSortedContent { 267 | t.Errorf("Output file content mismatch.\nExpected:\n%s\nGot:\n%s", 268 | normalizedExpectedSortedContent, string(outFileBytes)) 269 | } 270 | }) 271 | 272 | t.Run("Write to output file (.tofu)", func(t *testing.T) { 273 | cleanupTestFiles(t, outputFile) 274 | 275 | if err := ingestor.Parse(validTofuPath, outputFile, false, false); err != nil { 276 | t.Fatalf("Parse failed unexpectedly for .tofu file: %v", err) 277 | } 278 | 279 | if _, errStat := os.Stat(outputFile); os.IsNotExist(errStat) { 280 | t.Fatal("Output file was not created for .tofu input") 281 | } 282 | 283 | outFileBytes, errRead := os.ReadFile(outputFile) 284 | if errRead != nil { 285 | t.Fatalf("Failed to read output file %s: %v", outputFile, errRead) 286 | } 287 | 288 | if string(outFileBytes) != normalizedExpectedSortedTofuContent { 289 | t.Errorf("Output file content mismatch for .tofu.\nExpected:\n%s\nGot:\n%s", 290 | normalizedExpectedSortedTofuContent, string(outFileBytes)) 291 | } 292 | }) 293 | 294 | t.Run("Write to stdout (dry run from file)", func(t *testing.T) { 295 | cleanupTestFiles(t, outputFile) 296 | 297 | var parseErr error 298 | capturedStdout := captureOutput(t, func() { 299 | parseErr = ingestor.Parse(validFilePath, "", true, false) 300 | }) 301 | 302 | if parseErr != nil { 303 | t.Fatalf("Parse failed unexpectedly during dry run from file: %v", parseErr) 304 | } 305 | 306 | if capturedStdout != normalizedExpectedSortedContent { 307 | t.Errorf("Dry run output mismatch for file input.\nExpected:\n%s\nGot:\n%s", 308 | normalizedExpectedSortedContent, capturedStdout) 309 | } 310 | 311 | if _, errStat := os.Stat(outputFile); !os.IsNotExist(errStat) { 312 | t.Error("Output file was created during dry run from file, but should not have been") 313 | } 314 | }) 315 | 316 | t.Run("Overwrite input file", func(t *testing.T) { 317 | tempInputFile := filepath.Join(testDataBaseDir, "temp_overwrite.tf") 318 | if err := os.WriteFile(tempInputFile, validTestContentBytes, 0600); err != nil { 319 | t.Fatalf("Failed to create temp input file: %v", err) 320 | } 321 | defer cleanupTestFiles(t, tempInputFile) 322 | 323 | if err := ingestor.Parse(tempInputFile, "", false, false); err != nil { 324 | t.Fatalf("Parse failed unexpectedly during overwrite: %v", err) 325 | } 326 | 327 | modifiedBytes, errRead := os.ReadFile(tempInputFile) 328 | if errRead != nil { 329 | t.Fatalf("Failed to read modified input file %s: %v", tempInputFile, errRead) 330 | } 331 | 332 | if string(modifiedBytes) != normalizedExpectedSortedContent { 333 | t.Errorf("Overwritten input file content mismatch.\nExpected:\n%s\nGot:\n%s", 334 | normalizedExpectedSortedContent, string(modifiedBytes)) 335 | } 336 | }) 337 | 338 | t.Run("Error writing to output file (permissions)", func(t *testing.T) { 339 | if f, errCreate := os.Create(unwritableOutputFile); errCreate == nil { 340 | _ = f.Close() 341 | if errChmod := os.Chmod(unwritableOutputFile, 0444); errChmod != nil { 342 | t.Logf("Warning: Could not set output file to read-only, test might not be effective: %v", errChmod) 343 | } 344 | } else { 345 | t.Fatalf("Failed to create dummy output file for permissions test: %v", errCreate) 346 | } 347 | defer func() { _ = os.Chmod(unwritableOutputFile, 0600) }() 348 | 349 | errParse := ingestor.Parse(validFilePath, unwritableOutputFile, false, false) 350 | switch { 351 | case errParse == nil: 352 | t.Error("Expected error when writing to read-only output file but got nil") 353 | case !strings.Contains(errParse.Error(), "error writing output"): 354 | t.Errorf("Expected 'error writing output' error, but got: %v", errParse) 355 | } 356 | }) 357 | 358 | t.Run("Read from stdin, write to stdout", func(t *testing.T) { 359 | cleanupTestFiles(t, outputFile) 360 | 361 | restoreStdin := mockStdin(t, validTestContentForStdin) 362 | defer restoreStdin() 363 | 364 | var parseErr error 365 | capturedStdout := captureOutput(t, func() { 366 | parseErr = ingestor.Parse(hclsort.StdInPathIdentifier, "", false, true) 367 | }) 368 | 369 | if parseErr != nil { 370 | t.Fatalf("Parse from stdin to stdout failed unexpectedly: %v", parseErr) 371 | } 372 | 373 | if capturedStdout != normalizedExpectedSortedContent { 374 | t.Errorf("Output to stdout from stdin mismatch.\nExpected:\n%s\nGot:\n%s", 375 | normalizedExpectedSortedContent, capturedStdout) 376 | } 377 | 378 | if _, errStat := os.Stat(outputFile); !os.IsNotExist(errStat) { 379 | t.Error("Output file was created during stdin to stdout test, but should not have been") 380 | } 381 | }) 382 | 383 | t.Run("Read from stdin, write to output file", func(t *testing.T) { 384 | cleanupTestFiles(t, outputFile) 385 | 386 | restoreStdin := mockStdin(t, validTestContentForStdin) 387 | defer restoreStdin() 388 | 389 | err := ingestor.Parse(hclsort.StdInPathIdentifier, outputFile, false, true) 390 | if err != nil { 391 | t.Fatalf("Parse from stdin to output file failed unexpectedly: %v", err) 392 | } 393 | 394 | if _, errStat := os.Stat(outputFile); os.IsNotExist(errStat) { 395 | t.Fatal("Output file was not created when parsing from stdin with -o") 396 | } 397 | 398 | outFileBytes, errRead := os.ReadFile(outputFile) 399 | if errRead != nil { 400 | t.Fatalf("Failed to read output file %s: %v", outputFile, errRead) 401 | } 402 | 403 | if string(outFileBytes) != normalizedExpectedSortedContent { 404 | t.Errorf("Output file content mismatch from stdin.\nExpected:\n%s\nGot:\n%s", 405 | normalizedExpectedSortedContent, string(outFileBytes)) 406 | } 407 | }) 408 | 409 | t.Run("Read from stdin, dry run to stdout", func(t *testing.T) { 410 | cleanupTestFiles(t, outputFile) 411 | 412 | restoreStdin := mockStdin(t, validTestContentForStdin) 413 | defer restoreStdin() 414 | 415 | var parseErr error 416 | capturedStdout := captureOutput(t, func() { 417 | parseErr = ingestor.Parse(hclsort.StdInPathIdentifier, "", true, true) 418 | }) 419 | 420 | if parseErr != nil { 421 | t.Fatalf("Parse from stdin with dry-run failed unexpectedly: %v", parseErr) 422 | } 423 | 424 | if capturedStdout != normalizedExpectedSortedContent { 425 | t.Errorf("Dry run output to stdout from stdin mismatch.\nExpected:\n%s\nGot:\n%s", 426 | normalizedExpectedSortedContent, capturedStdout) 427 | } 428 | if _, errStat := os.Stat(outputFile); !os.IsNotExist(errStat) { 429 | t.Error("Output file was created during stdin dry run, but should not have been") 430 | } 431 | }) 432 | 433 | t.Run("Invalid HCL from stdin", func(t *testing.T) { 434 | invalidContent := `variable "a" { type = string` 435 | restoreStdin := mockStdin(t, invalidContent) 436 | defer restoreStdin() 437 | 438 | err := ingestor.Parse(hclsort.StdInPathIdentifier, outputFile, false, true) 439 | if err == nil { 440 | t.Error("Expected error for invalid HCL from stdin but got nil") 441 | } else { 442 | if !strings.Contains(err.Error(), "error parsing HCL content") { 443 | t.Errorf("Expected HCL parsing error from stdin, but got: %v", err) 444 | } 445 | if !strings.Contains(err.Error(), hclsort.StdInPathIdentifier) { 446 | t.Errorf("Expected error message for stdin to contain '%s', but got: %v", hclsort.StdInPathIdentifier, err.Error()) 447 | } 448 | } 449 | }) 450 | } 451 | 452 | func TestValidateFilePath(t *testing.T) { 453 | setupTestDir(t) 454 | 455 | if _, statErr := os.Stat(validFilePath); os.IsNotExist(statErr) { 456 | if writeErr := os.WriteFile(validFilePath, []byte(`variable "a" {}`), 0600); writeErr != nil { 457 | t.Fatalf("Failed to create %s for TestValidateFilePath: %v", validFilePath, writeErr) 458 | } 459 | } 460 | 461 | t.Run("File path is empty", func(t *testing.T) { 462 | if err := hclsort.ValidateFilePath(""); err == nil { 463 | t.Error("Expected error for empty file path but got nil") 464 | } else if err.Error() != "file path is required" { 465 | t.Errorf("Expected 'file path is required' error, but got: %v", err) 466 | } 467 | }) 468 | 469 | t.Run("File not exists", func(t *testing.T) { 470 | if err := hclsort.ValidateFilePath("nonExistentFile.tf"); err == nil { 471 | t.Error("Expected error for non-existent file but got nil") 472 | } else if err.Error() != "file does not exist" { 473 | t.Errorf("Expected 'file does not exist' error, but got: %v", err) 474 | } 475 | }) 476 | 477 | t.Run("Path is directory", func(t *testing.T) { 478 | if err := hclsort.ValidateFilePath(testDataBaseDir); err == nil { 479 | t.Errorf("Expected error when path is a directory but got nil") 480 | } else if err.Error() != "path is a directory, not a file" { 481 | t.Errorf( 482 | "Expected 'path is a directory, not a file' error, but got: %v", 483 | err, 484 | ) 485 | } 486 | }) 487 | 488 | t.Run("Valid File Path", func(t *testing.T) { 489 | if err := hclsort.ValidateFilePath(validFilePath); err != nil { 490 | t.Errorf("Unexpected error for valid file path: %v", err) 491 | } 492 | }) 493 | } 494 | 495 | func TestSortRequiredProvidersInBlock(t *testing.T) { 496 | const hclInput = ` 497 | terraform { 498 | required_providers { 499 | z = { source = "provider/z" } 500 | a = { source = "provider/a" } 501 | } 502 | required_versions = ">= 1.0" 503 | } 504 | ` 505 | 506 | file, err := hclsort.ParseHCLContent([]byte(hclInput), "testfile.tf") 507 | if err != nil { 508 | t.Fatalf("ParseHCLContent failed: %v", err) 509 | } 510 | 511 | sortedFile := hclsort.ProcessAndSortBlocks(file, map[string]bool{}) 512 | 513 | output := string(hclsort.FormatHCLBytes(sortedFile)) 514 | 515 | idxA := strings.Index(output, "a =") 516 | idxZ := strings.Index(output, "z =") 517 | 518 | if idxA < 0 || idxZ < 0 { 519 | t.Fatalf("did not find both providers in output:\n%s", output) 520 | } 521 | if idxA > idxZ { 522 | t.Errorf("expected provider “a” to appear before “z”,\noutput was:\n%s", output) 523 | } 524 | } 525 | 526 | func TestSortLocalsBlock(t *testing.T) { 527 | const hclInput = ` 528 | locals { 529 | other = 42 530 | 531 | blocks = { 532 | a = { 533 | list = [1, 23, 6] 534 | string = "yes" 535 | } 536 | b = {} 537 | c = {} 538 | } 539 | } 540 | ` 541 | 542 | file, err := hclsort.ParseHCLContent([]byte(hclInput), "test.tf") 543 | if err != nil { 544 | t.Fatalf("ParseHCLContent failed: %v", err) 545 | } 546 | 547 | sortedFile := hclsort.ProcessAndSortBlocks(file, map[string]bool{}) 548 | 549 | output := string(hclsort.FormatHCLBytes(sortedFile)) 550 | 551 | idxBlocks := strings.Index(output, "blocks") 552 | idxOther := strings.Index(output, "other =") 553 | if idxBlocks < 0 || idxOther < 0 { 554 | t.Fatalf("did not find both locals entries in output:\n%s", output) 555 | } 556 | if idxBlocks > idxOther { 557 | t.Errorf("expected blocks before “other”, but got:\n%s", output) 558 | } 559 | 560 | idxA := strings.Index(output, "a =") 561 | idxB := strings.Index(output, "b =") 562 | idxC := strings.Index(output, "c =") 563 | if idxA >= idxB || idxB >= idxC { 564 | t.Errorf("expected a < b < c inside blocks, but got:\n%s", output) 565 | } 566 | 567 | idxList := strings.Index(output, "list") 568 | idxString := strings.Index(output, "string") 569 | if idxList < 0 || idxString < 0 { 570 | t.Fatalf("did not find nested attributes in output:\n%s", output) 571 | } 572 | if idxList > idxString { 573 | t.Errorf("expected list before string, but got:\n%s", output) 574 | } 575 | } 576 | -------------------------------------------------------------------------------- /internal/hclsort/types.go: -------------------------------------------------------------------------------- 1 | package hclsort 2 | 3 | import "github.com/hashicorp/hcl/v2/hclwrite" 4 | 5 | // StdInPathIdentifier is a marker for when input is read from stdin. 6 | const StdInPathIdentifier = "" 7 | 8 | // Ingestor is a struct that contains the logic for parsing Terraform files. 9 | type Ingestor struct { 10 | AllowedTypes map[string]bool 11 | AllowedBlocks map[string]bool 12 | } 13 | 14 | // SortableBlock holds information needed for sorting. 15 | type SortableBlock struct { 16 | Name string 17 | Block *hclwrite.Block 18 | } 19 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/AlexNabokikh/tfsort/cmd" 5 | ) 6 | 7 | // Build time variables are set using -ldflags. 8 | var ( 9 | version = "dev" 10 | commit = "none" //nolint:gochecknoglobals // Set by ldflags at build time 11 | date = "unknown" //nolint:gochecknoglobals // Set by ldflags at build time 12 | ) 13 | 14 | func main() { 15 | cmd.Execute(version, commit, date) 16 | } 17 | --------------------------------------------------------------------------------