├── .dockerignore ├── .github └── workflows │ ├── lint.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .go-version ├── .golangci.yml ├── .goreleaser.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── attribute.go ├── attribute_test.go ├── block.go ├── block_test.go ├── body.go ├── body_test.go ├── fmt.go ├── fmt_test.go ├── mock.go ├── root.go ├── version.go └── version_test.go ├── editor ├── address.go ├── address_test.go ├── client.go ├── client_test.go ├── filter_attribute_append.go ├── filter_attribute_append_test.go ├── filter_attribute_remove.go ├── filter_attribute_remove_test.go ├── filter_attribute_rename.go ├── filter_attribute_rename_test.go ├── filter_attribute_replace.go ├── filter_attribute_replace_test.go ├── filter_attribute_set.go ├── filter_attribute_set_test.go ├── filter_block_append.go ├── filter_block_append_test.go ├── filter_block_get.go ├── filter_block_get_test.go ├── filter_block_new.go ├── filter_block_new_test.go ├── filter_block_remove.go ├── filter_block_remove_test.go ├── filter_block_rename.go ├── filter_block_rename_test.go ├── filter_body_get.go ├── filter_body_get_test.go ├── filter_formatter.go ├── filter_formatter_test.go ├── filter_multi.go ├── filter_vertical_formatter.go ├── filter_vertical_formatter_test.go ├── formatter_default.go ├── operator.go ├── operator_derive.go ├── operator_derive_test.go ├── operator_edit.go ├── operator_edit_test.go ├── sink_attribute_get.go ├── sink_attribute_get_test.go ├── sink_block_list.go ├── sink_block_list_test.go ├── source_parser.go └── test_helper.go ├── go.mod ├── go.sum ├── main.go └── main_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | bin/ 3 | tmp/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: lint 2 | permissions: 3 | contents: read 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | golangci: 14 | name: lint 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 5 17 | steps: 18 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 19 | - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 20 | with: 21 | go-version-file: '.go-version' 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6.5.2 24 | with: 25 | version: v1.63.4 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | permissions: 3 | contents: write 4 | 5 | on: 6 | push: 7 | tags: 8 | - "v[0-9]+.*" 9 | 10 | jobs: 11 | goreleaser: 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 5 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Go 20 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 21 | with: 22 | go-version-file: '.go-version' 23 | - name: Generate github app token 24 | uses: actions/create-github-app-token@31c86eb3b33c9b601a1f60f98dcbfd1d70f379b4 # v1.10.3 25 | id: app-token 26 | with: 27 | app-id: ${{ secrets.APP_ID }} 28 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 29 | owner: ${{ github.repository_owner }} 30 | repositories: homebrew-hcledit 31 | - name: Run GoReleaser 32 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 33 | with: 34 | version: "~> v2" 35 | args: release --clean 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 39 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | push: 7 | branches: 8 | - master 9 | pull_request: 10 | branches: 11 | - master 12 | 13 | jobs: 14 | test: 15 | runs-on: ${{ matrix.os }} 16 | timeout-minutes: 5 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, macOS-latest, windows-latest] 20 | steps: 21 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 23 | with: 24 | go-version-file: '.go-version' 25 | - name: test 26 | run: make test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | bin/ 3 | tmp/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /.go-version: -------------------------------------------------------------------------------- 1 | 1.23 2 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # https://golangci-lint.run/usage/configuration/ 2 | linters: 3 | disable-all: true 4 | enable: 5 | - errcheck 6 | - goimports 7 | - gosec 8 | - gosimple 9 | - govet 10 | - ineffassign 11 | - revive 12 | - staticcheck 13 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - binary: hcledit 4 | goos: 5 | - darwin 6 | - linux 7 | - windows 8 | goarch: 9 | - amd64 10 | - arm64 11 | ldflags: 12 | - -s -w 13 | - -X github.com/minamijoyo/hcledit/cmd.Version={{.Version}} 14 | env: 15 | - CGO_ENABLED=0 16 | release: 17 | prerelease: auto 18 | changelog: 19 | filters: 20 | exclude: 21 | - Merge pull request 22 | - Merge branch 23 | - Update README 24 | - Update CHANGELOG 25 | brews: 26 | - repository: 27 | owner: minamijoyo 28 | name: homebrew-hcledit 29 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 30 | commit_author: 31 | name: "Masayuki Morita" 32 | email: minamijoyo@gmail.com 33 | homepage: https://github.com/minamijoyo/hcledit 34 | description: "A command line editor for HCL" 35 | skip_upload: auto 36 | test: | 37 | system "#{bin}/hcledit version" 38 | install: | 39 | bin.install "hcledit" 40 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## master (Unreleased) 2 | 3 | ## 0.2.17 (2025/02/12) 4 | 5 | NEW FEATURES: 6 | 7 | * Add attribute mv and replace commands ([#111](https://github.com/minamijoyo/hcledit/pull/111)) 8 | 9 | ## 0.2.16 (2025/02/05) 10 | 11 | NEW FEATURES: 12 | 13 | * Add block new cmd ([#106](https://github.com/minamijoyo/hcledit/pull/106)) ([#109](https://github.com/minamijoyo/hcledit/pull/109)) ([#110](https://github.com/minamijoyo/hcledit/pull/110)) 14 | 15 | ENHANCEMENTS: 16 | 17 | * Update Go to v1.23.0 ([#107](https://github.com/minamijoyo/hcledit/pull/107)) 18 | * Update hcl to v2.23.0 ([#108](https://github.com/minamijoyo/hcledit/pull/108)) 19 | 20 | ## 0.2.15 (2024/08/30) 21 | 22 | BUG FIXES: 23 | 24 | * Fix attribute get --with-comments for inline comments ([#104](https://github.com/minamijoyo/hcledit/pull/104)) 25 | 26 | ## 0.2.14 (2024/08/29) 27 | 28 | NEW FEATURES: 29 | 30 | * add --with-comments to preserve comments when returning an attribute ([#103](https://github.com/minamijoyo/hcledit/pull/103)) 31 | 32 | ## 0.2.13 (2024/08/02) 33 | 34 | BUG FIXES: 35 | 36 | * Fix syntax error in release workflow ([#101](https://github.com/minamijoyo/hcledit/pull/101)) 37 | 38 | ## 0.2.12 (2024/08/02) 39 | 40 | ENHANCEMENTS: 41 | 42 | * Update hcl to v2.21.0 ([#95](https://github.com/minamijoyo/hcledit/pull/95)) 43 | * Update alpine to v3.20 ([#96](https://github.com/minamijoyo/hcledit/pull/96)) 44 | * Update golangci lint to v1.59.1 ([#97](https://github.com/minamijoyo/hcledit/pull/97)) 45 | * Update setup-go to v5 ([#98](https://github.com/minamijoyo/hcledit/pull/98)) 46 | * Update goreleaser to v2 ([#99](https://github.com/minamijoyo/hcledit/pull/99)) 47 | * Switch to the official action for creating GitHub App token ([#100](https://github.com/minamijoyo/hcledit/pull/100)) 48 | 49 | ## 0.2.11 (2024/04/15) 50 | 51 | ENHANCEMENTS: 52 | 53 | * feat: update to use go 1.22 ([#91](https://github.com/minamijoyo/hcledit/pull/91)) 54 | * Add support for namespaced function ([#93](https://github.com/minamijoyo/hcledit/pull/93)) 55 | 56 | ## 0.2.10 (2023/09/20) 57 | 58 | NEW FEATURES: 59 | 60 | * feat: add support for escaping . in address ([#83](https://github.com/minamijoyo/hcledit/pull/83)) 61 | 62 | ENHANCEMENTS: 63 | 64 | * Update Go to v1.21 ([#86](https://github.com/minamijoyo/hcledit/pull/86)) 65 | * Update hcl to v2.18.0 ([#87](https://github.com/minamijoyo/hcledit/pull/87)) 66 | 67 | ## 0.2.9 (2023/06/12) 68 | 69 | ENHANCEMENTS: 70 | 71 | * Update hcl to v2.17.0 ([#81](https://github.com/minamijoyo/hcledit/pull/81)) 72 | 73 | BUG FIXES: 74 | 75 | * Fix unexpected format when files do not end with newline ([#79](https://github.com/minamijoyo/hcledit/pull/79)) 76 | 77 | ## 0.2.8 (2023/05/11) 78 | 79 | BUG FIXES: 80 | 81 | * Fix multiline comment parsing ([#78](https://github.com/minamijoyo/hcledit/pull/78)) 82 | 83 | ## 0.2.7 (2023/04/19) 84 | 85 | ENHANCEMENTS: 86 | 87 | * Update Go to v1.20 ([#73](https://github.com/minamijoyo/hcledit/pull/73)) 88 | * Update hcl to v2.16.2 ([#74](https://github.com/minamijoyo/hcledit/pull/74)) 89 | * Use a native cache feature in actions/setup-go ([#75](https://github.com/minamijoyo/hcledit/pull/75)) 90 | * Update actions/setup-go to v4 ([#76](https://github.com/minamijoyo/hcledit/pull/76)) 91 | * Add windows build ([#77](https://github.com/minamijoyo/hcledit/pull/77)) 92 | 93 | ## 0.2.6 (2022/08/12) 94 | 95 | ENHANCEMENTS: 96 | 97 | * Use GitHub App token for updating brew formula on release ([#59](https://github.com/minamijoyo/hcledit/pull/59)) 98 | 99 | ## 0.2.5 (2022/06/16) 100 | 101 | ENHANCEMENTS: 102 | 103 | * Update Go to v1.18.3 ([#57](https://github.com/minamijoyo/hcledit/pull/57)) 104 | 105 | ## 0.2.4 (2022/06/13) 106 | 107 | ENHANCEMENTS: 108 | 109 | * Expose VerticalFormat ([#43](https://github.com/minamijoyo/hcledit/pull/43)) 110 | * Expose GetAttributeValueAsString ([#47](https://github.com/minamijoyo/hcledit/pull/47)) 111 | * Update golangci-lint to v1.45.2 and actions to latest ([#49](https://github.com/minamijoyo/hcledit/pull/49)) 112 | * Read Go version from .go-version on GitHub Actions ([#53](https://github.com/minamijoyo/hcledit/pull/53)) 113 | * Update Go to v1.17.10 and Alpine to v3.16 ([#54](https://github.com/minamijoyo/hcledit/pull/54)) 114 | * Update hcl to v2.12.0 ([#55](https://github.com/minamijoyo/hcledit/pull/55)) 115 | 116 | BUG FIXES: 117 | 118 | * Trim trailing duplicated TokenNewline in VerticalFormat ([#48](https://github.com/minamijoyo/hcledit/pull/48)) 119 | 120 | ## 0.2.3 (2022/02/12) 121 | 122 | ENHANCEMENTS: 123 | 124 | * Use golangci-lint instead of golint ([#40](https://github.com/minamijoyo/hcledit/pull/40)) 125 | * Fix lint errors ([#41](https://github.com/minamijoyo/hcledit/pull/41)) 126 | * Update hcl to v2.11.1 ([#42](https://github.com/minamijoyo/hcledit/pull/42)) 127 | 128 | ## 0.2.2 (2021/11/28) 129 | 130 | ENHANCEMENTS: 131 | 132 | * Update Go to v1.17.3 and Alpine to 3.14 ([#38](https://github.com/minamijoyo/hcledit/pull/38)) 133 | * Update hcl to v2.10.1 ([#39](https://github.com/minamijoyo/hcledit/pull/39)) 134 | * Add Apple Silicon (ARM 64) build ([#36](https://github.com/minamijoyo/hcledit/pull/36)) 135 | 136 | ## 0.2.1 (2021/10/28) 137 | 138 | ENHANCEMENTS: 139 | 140 | * Restrict permissions for GitHub Actions ([#34](https://github.com/minamijoyo/hcledit/pull/34)) 141 | * Set timeout for GitHub Actions ([#35](https://github.com/minamijoyo/hcledit/pull/35)) 142 | 143 | ## 0.2.0 (2021/04/06) 144 | 145 | BREAKING CHANGES: 146 | 147 | * Skip formatter if filter didn't change contents ([#24](https://github.com/minamijoyo/hcledit/pull/24)) 148 | 149 | Previously outputs are always formatted, but the outputs are no longer formatted if a given address doesn't match to suppress meaningless diff. 150 | 151 | NEW FEATURES: 152 | 153 | * Add support for getting nested block ([#22](https://github.com/minamijoyo/hcledit/pull/22)) 154 | * Add body get command ([#23](https://github.com/minamijoyo/hcledit/pull/23)) 155 | * Add support for in-place update ([#25](https://github.com/minamijoyo/hcledit/pull/25)) 156 | 157 | ENHANCEMENTS: 158 | 159 | * Redesign interfaces in editor package ([#18](https://github.com/minamijoyo/hcledit/pull/18)) 160 | * Update Go to v1.16.0 ([#19](https://github.com/minamijoyo/hcledit/pull/19)) 161 | * Update hcl to v2.9.0 ([#20](https://github.com/minamijoyo/hcledit/pull/20)) 162 | 163 | ## 0.1.3 (2021/01/30) 164 | 165 | ENHANCEMENTS: 166 | 167 | * Update hcl to v2.8.2 ([#16](https://github.com/minamijoyo/hcledit/pull/16)) 168 | * Fix broken GitHub Actions ([#17](https://github.com/minamijoyo/hcledit/pull/17)) 169 | 170 | ## 0.1.2 (2020/10/28) 171 | 172 | NEW FEATURES: 173 | 174 | * Add attribute append command ([#14](https://github.com/minamijoyo/hcledit/pull/14)) 175 | * Add fmt command ([#15](https://github.com/minamijoyo/hcledit/pull/15)) 176 | 177 | ## 0.1.1 (2020/10/25) 178 | 179 | NEW FEATURES: 180 | 181 | * Add block append command ([#8](https://github.com/minamijoyo/hcledit/pull/8)) 182 | 183 | ENHANCEMENTS: 184 | 185 | * Add integration test ([#5](https://github.com/minamijoyo/hcledit/pull/5)) 186 | * Update hcl to v2.7.0 ([#6](https://github.com/minamijoyo/hcledit/pull/6)) 187 | * Update Go to v1.15.2 ([#7](https://github.com/minamijoyo/hcledit/pull/7)) 188 | * Refactor to test argument flags ([#9](https://github.com/minamijoyo/hcledit/pull/9)) 189 | * Prevent uploading pre-release to Homebrew ([#12](https://github.com/minamijoyo/hcledit/pull/12)) 190 | 191 | BUG FIXES: 192 | 193 | * Fix binary compatibility issue for alpine ([#11](https://github.com/minamijoyo/hcledit/pull/11)) 194 | 195 | ## 0.1.0 (2020/08/22) 196 | 197 | Initial release 198 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build 2 | FROM golang:1.23-alpine3.21 AS builder 3 | RUN apk --no-cache add make git 4 | WORKDIR /work 5 | 6 | COPY go.mod go.sum ./ 7 | RUN go mod download 8 | 9 | COPY . . 10 | RUN make build 11 | 12 | # hub 13 | # The linux binary for hub can not run on alpine. 14 | # So we need to build it from source. 15 | # https://github.com/github/hub/issues/1818 16 | FROM golang:1.23-alpine3.21 AS hub 17 | RUN apk add --no-cache bash git 18 | RUN git clone https://github.com/github/hub /work 19 | WORKDIR /work 20 | RUN ./script/build -o bin/hub 21 | 22 | # runtime 23 | # Note: Required Tools for Primary Containers on CircleCI 24 | # https://circleci.com/docs/2.0/custom-images/#required-tools-for-primary-containers 25 | FROM alpine:3.21 26 | RUN apk --no-cache add bash git openssh-client tar gzip ca-certificates 27 | COPY --from=builder /work/bin/hcledit /usr/local/bin/ 28 | COPY --from=hub /work/bin/hub /usr/local/bin/ 29 | ENTRYPOINT ["hcledit"] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Masayuki Morita 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME := hcledit 2 | 3 | .DEFAULT_GOAL := build 4 | 5 | .PHONY: deps 6 | deps: 7 | go mod download 8 | 9 | .PHONY: build 10 | build: deps 11 | go build -o bin/$(NAME) 12 | 13 | .PHONY: install 14 | install: deps 15 | go install 16 | 17 | .PHONY: lint 18 | lint: 19 | golangci-lint run ./... 20 | 21 | .PHONY: test 22 | test: build 23 | go test ./... 24 | 25 | .PHONY: check 26 | check: lint test 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hcledit 2 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) 3 | [![GitHub release](https://img.shields.io/github/release/minamijoyo/hcledit.svg)](https://github.com/minamijoyo/hcledit/releases/latest) 4 | [![GoDoc](https://godoc.org/github.com/minamijoyo/hcledit/hcledit?status.svg)](https://godoc.org/github.com/minamijoyo/hcledit) 5 | 6 | ## Features 7 | 8 | - CLI-friendly: Read HCL from stdin, edit and write to stdout, easily pipe and combine other commands 9 | - Token-based edit: You can update lots of existing HCL files with automation scripts without losing comments. 10 | - Schemaless: No dependency on specific HCL application binary or schema 11 | - Support HCL2 (not HCL1) 12 | - Available operations: 13 | - attribute append / get / mv / replace / rm / set 14 | - block append / get / list / mv / new / rm 15 | - body get 16 | - fmt 17 | 18 | The hcledit focuses on editing HCL with command line, doesn't aim for generic query tools. It was originally born for refactoring Terraform configurations, but it's not limited to specific applications. 19 | The HCL specification is somewhat generic, so usability takes precedence over strictness if there is room for interpreting meanings in the schemaless approach. 20 | 21 | ## Install 22 | 23 | ### Homebrew 24 | 25 | If you are macOS user: 26 | 27 | ``` 28 | $ brew install minamijoyo/hcledit/hcledit 29 | ``` 30 | 31 | ### Download 32 | 33 | Download the latest compiled binaries and put it anywhere in your executable path. 34 | 35 | https://github.com/minamijoyo/hcledit/releases 36 | 37 | ### Source 38 | 39 | If you have Go 1.23+ development environment: 40 | 41 | ``` 42 | $ git clone https://github.com/minamijoyo/hcledit 43 | $ cd hcledit/ 44 | $ make install 45 | $ hcledit version 46 | ``` 47 | 48 | ## Usage 49 | 50 | ``` 51 | $ hcledit --help 52 | A command line editor for HCL 53 | 54 | Usage: 55 | hcledit [command] 56 | 57 | Available Commands: 58 | attribute Edit attribute 59 | block Edit block 60 | body Edit body 61 | fmt Format file 62 | help Help about any command 63 | version Print version 64 | 65 | Flags: 66 | -f, --file string A path of input file (default "-") 67 | -h, --help help for hcledit 68 | -u, --update Update files in-place 69 | 70 | Use "hcledit [command] --help" for more information about a command. 71 | ``` 72 | 73 | ### attribute 74 | 75 | ``` 76 | $ hcledit attribute --help 77 | Edit attribute 78 | 79 | Usage: 80 | hcledit attribute [flags] 81 | hcledit attribute [command] 82 | 83 | Available Commands: 84 | append Append attribute 85 | get Get attribute 86 | mv Move attribute (Rename attribute key) 87 | replace Replace both the name and value of attribute 88 | rm Remove attribute 89 | set Set attribute 90 | 91 | Flags: 92 | -h, --help help for attribute 93 | 94 | Global Flags: 95 | -f, --file string A path of input file (default "-") 96 | -u, --update Update files in-place 97 | 98 | Use "hcledit attribute [command] --help" for more information about a command. 99 | ``` 100 | 101 | Given the following file: 102 | 103 | ```attr.hcl 104 | resource "foo" "bar" { 105 | attr1 = "val1" 106 | nested { 107 | attr2 = "val2" 108 | } 109 | } 110 | ``` 111 | 112 | ``` 113 | $ cat tmp/attr.hcl | hcledit attribute get resource.foo.bar.nested.attr2 114 | "val2" 115 | ``` 116 | 117 | ``` 118 | $ cat tmp/attr.hcl | hcledit attribute set resource.foo.bar.nested.attr2 '"val3"' 119 | resource "foo" "bar" { 120 | attr1 = "val1" 121 | nested { 122 | attr2 = "val3" 123 | } 124 | } 125 | ``` 126 | 127 | ``` 128 | $ cat tmp/attr.hcl | hcledit attribute mv resource.foo.bar.nested.attr2 resource.foo.bar.nested.attr3 129 | resource "foo" "bar" { 130 | attr1 = "val1" 131 | nested { 132 | attr3 = "val2" 133 | } 134 | } 135 | ``` 136 | 137 | ``` 138 | $ cat tmp/attr.hcl | hcledit attribute replace resource.foo.bar.nested.attr2 attr3 '"val3"' 139 | resource "foo" "bar" { 140 | attr1 = "val1" 141 | nested { 142 | attr3 = "val3" 143 | } 144 | } 145 | ``` 146 | 147 | ``` 148 | $ cat tmp/attr.hcl | hcledit attribute rm resource.foo.bar.attr1 149 | resource "foo" "bar" { 150 | nested { 151 | attr2 = "val2" 152 | } 153 | } 154 | ``` 155 | 156 | ``` 157 | $ cat tmp/attr.hcl | hcledit attribute append resource.foo.bar.nested.attr3 '"val3"' --newline 158 | resource "foo" "bar" { 159 | attr1 = "val1" 160 | nested { 161 | attr2 = "val2" 162 | 163 | attr3 = "val3" 164 | } 165 | } 166 | ``` 167 | 168 | ### block 169 | 170 | ``` 171 | $ hcledit block --help 172 | Edit block 173 | 174 | Usage: 175 | hcledit block [flags] 176 | hcledit block [command] 177 | 178 | Available Commands: 179 | append Append block 180 | get Get block 181 | list List block 182 | mv Move block (Rename block type and labels) 183 | new Create a new block 184 | rm Remove block 185 | 186 | Flags: 187 | -h, --help help for block 188 | 189 | Global Flags: 190 | -f, --file string A path of input file (default "-") 191 | -u, --update Update files in-place 192 | 193 | Use "hcledit block [command] --help" for more information about a command. 194 | ``` 195 | 196 | Given the following file: 197 | 198 | ```block.hcl 199 | resource "foo" "bar" { 200 | attr1 = "val1" 201 | } 202 | 203 | resource "foo" "baz" { 204 | attr1 = "val2" 205 | } 206 | ``` 207 | 208 | ``` 209 | $ cat tmp/block.hcl | hcledit block list 210 | resource.foo.bar 211 | resource.foo.baz 212 | ``` 213 | 214 | ``` 215 | $ cat tmp/block.hcl | hcledit block get resource.foo.bar 216 | resource "foo" "bar" { 217 | attr1 = "val1" 218 | } 219 | ``` 220 | 221 | ``` 222 | $ cat tmp/block.hcl | hcledit block mv resource.foo.bar resource.foo.qux 223 | resource "foo" "qux" { 224 | attr1 = "val1" 225 | } 226 | 227 | resource "foo" "baz" { 228 | attr1 = "val2" 229 | } 230 | ``` 231 | 232 | ``` 233 | $ cat tmp/block.hcl | hcledit block rm resource.foo.baz 234 | resource "foo" "bar" { 235 | attr1 = "val1" 236 | } 237 | ``` 238 | 239 | ``` 240 | $ cat tmp/block.hcl | hcledit block append resource.foo.bar block1.label1 --newline 241 | resource "foo" "bar" { 242 | attr1 = "val1" 243 | 244 | block1 "label1" { 245 | } 246 | } 247 | 248 | resource "foo" "baz" { 249 | attr1 = "val2" 250 | } 251 | ``` 252 | 253 | ``` 254 | $ cat tmp/block.hcl | hcledit block new resource.foo.qux --newline 255 | resource "foo" "bar" { 256 | attr1 = "val1" 257 | } 258 | 259 | resource "foo" "baz" { 260 | attr1 = "val2" 261 | } 262 | 263 | resource "foo" "qux" { 264 | } 265 | ``` 266 | 267 | ### body 268 | 269 | ``` 270 | $ hcledit body --help 271 | Edit body 272 | 273 | Usage: 274 | hcledit body [flags] 275 | hcledit body [command] 276 | 277 | Available Commands: 278 | get Get body 279 | 280 | Flags: 281 | -h, --help help for body 282 | 283 | Global Flags: 284 | -f, --file string A path of input file (default "-") 285 | -u, --update Update files in-place 286 | 287 | Use "hcledit body [command] --help" for more information about a command. 288 | ``` 289 | 290 | Given the following file: 291 | 292 | ```body.hcl 293 | resource "foo" "bar" { 294 | attr1 = "val1" 295 | nested { 296 | attr2 = "val2" 297 | } 298 | } 299 | ``` 300 | 301 | ``` 302 | $ cat tmp/body.hcl | hcledit body get resource.foo.bar 303 | attr1 = "val1" 304 | nested { 305 | attr2 = "val2" 306 | } 307 | ``` 308 | 309 | ### fmt 310 | 311 | ``` 312 | $ hcledit fmt --help 313 | Format a file to a caconical style 314 | 315 | Usage: 316 | hcledit fmt [flags] 317 | 318 | Flags: 319 | -h, --help help for fmt 320 | 321 | Global Flags: 322 | -f, --file string A path of input file (default "-") 323 | -u, --update Update files in-place 324 | ``` 325 | 326 | Given the following file: 327 | 328 | ``` 329 | $ cat tmp/fmt.hcl 330 | resource "foo" "bar" { 331 | attr1 = "val1" 332 | attr2="val2" 333 | } 334 | ``` 335 | 336 | ``` 337 | $ cat tmp/fmt.hcl | hcledit fmt 338 | resource "foo" "bar" { 339 | attr1 = "val1" 340 | attr2 = "val2" 341 | } 342 | ``` 343 | 344 | ### Address escaping 345 | 346 | Address escaping is supported for labels that contain `.`. 347 | 348 | Given the following file: 349 | 350 | ```body.hcl 351 | resource "foo.bar" { 352 | attr1 = "val1" 353 | nested { 354 | attr2 = "val2" 355 | } 356 | } 357 | ``` 358 | 359 | ``` 360 | $ cat tmp/attr.hcl | hcledit attribute get 'resource.foo\.bar.nested.attr2' 361 | "val2" 362 | ``` 363 | 364 | ## License 365 | 366 | MIT 367 | -------------------------------------------------------------------------------- /cmd/attribute.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/minamijoyo/hcledit/editor" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | func init() { 13 | RootCmd.AddCommand(newAttributeCmd()) 14 | } 15 | 16 | func newAttributeCmd() *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: "attribute", 19 | Short: "Edit attribute", 20 | RunE: func(cmd *cobra.Command, _ []string) error { 21 | return cmd.Help() 22 | }, 23 | } 24 | 25 | cmd.AddCommand( 26 | newAttributeGetCmd(), 27 | newAttributeSetCmd(), 28 | newAttributeMvCmd(), 29 | newAttributeReplaceCmd(), 30 | newAttributeRmCmd(), 31 | newAttributeAppendCmd(), 32 | ) 33 | 34 | return cmd 35 | } 36 | 37 | func newAttributeGetCmd() *cobra.Command { 38 | cmd := &cobra.Command{ 39 | Use: "get
", 40 | Short: "Get attribute", 41 | Long: `Get matched attribute at a given address 42 | 43 | Arguments: 44 | ADDRESS An address of attribute to get. 45 | `, 46 | RunE: runAttributeGetCmd, 47 | } 48 | 49 | flags := cmd.Flags() 50 | flags.Bool("with-comments", false, "return comments along with attribute value") 51 | _ = viper.BindPFlag("attribute.get.withComments", flags.Lookup("with-comments")) 52 | 53 | return cmd 54 | } 55 | 56 | func runAttributeGetCmd(cmd *cobra.Command, args []string) error { 57 | if len(args) != 1 { 58 | return fmt.Errorf("expected 1 argument, but got %d arguments", len(args)) 59 | } 60 | 61 | address := args[0] 62 | file := viper.GetString("file") 63 | update := viper.GetBool("update") 64 | withComments := viper.GetBool("attribute.get.withComments") 65 | if update { 66 | return errors.New("The update flag is not allowed") 67 | } 68 | 69 | sink := editor.NewAttributeGetSink(address, withComments) 70 | c := newDefaultClient(cmd) 71 | return c.Derive(file, sink) 72 | } 73 | 74 | func newAttributeSetCmd() *cobra.Command { 75 | cmd := &cobra.Command{ 76 | Use: "set
", 77 | Short: "Set attribute", 78 | Long: `Set a value of matched attribute at a given address 79 | 80 | Arguments: 81 | ADDRESS An address of attribute to set. 82 | VALUE A new value of attribute. 83 | The value is set literally, even if references or expressions. 84 | Thus, if you want to set a string literal "foo", be sure to 85 | escape double quotes so that they are not discarded by your shell. 86 | e.g.) hcledit attribute set aaa.bbb.ccc '"foo"' 87 | `, 88 | RunE: runAttributeSetCmd, 89 | } 90 | 91 | return cmd 92 | } 93 | 94 | func runAttributeSetCmd(cmd *cobra.Command, args []string) error { 95 | if len(args) != 2 { 96 | return fmt.Errorf("expected 2 argument, but got %d arguments", len(args)) 97 | } 98 | 99 | address := args[0] 100 | value := args[1] 101 | file := viper.GetString("file") 102 | update := viper.GetBool("update") 103 | 104 | filter := editor.NewAttributeSetFilter(address, value) 105 | c := newDefaultClient(cmd) 106 | return c.Edit(file, update, filter) 107 | } 108 | 109 | func newAttributeRmCmd() *cobra.Command { 110 | cmd := &cobra.Command{ 111 | Use: "rm
", 112 | Short: "Remove attribute", 113 | Long: `Remove a matched attribute at a given address 114 | 115 | Arguments: 116 | ADDRESS An address of attribute to remove. 117 | `, 118 | RunE: runAttributeRmCmd, 119 | } 120 | 121 | return cmd 122 | } 123 | 124 | func newAttributeMvCmd() *cobra.Command { 125 | cmd := &cobra.Command{ 126 | Use: "mv ", 127 | Short: "Move attribute (Rename attribute key)", 128 | Long: `Move attribute (Rename attribute key) 129 | 130 | Arguments: 131 | FROM_ADDRESS An old address of attribute. 132 | TO_ADDRESS A new address of attribute. 133 | `, 134 | RunE: runAttributeMvCmd, 135 | } 136 | 137 | return cmd 138 | } 139 | 140 | func runAttributeMvCmd(cmd *cobra.Command, args []string) error { 141 | if len(args) != 2 { 142 | return fmt.Errorf("expected 2 argument, but got %d arguments", len(args)) 143 | } 144 | 145 | from := args[0] 146 | to := args[1] 147 | file := viper.GetString("file") 148 | update := viper.GetBool("update") 149 | 150 | filter := editor.NewAttributeRenameFilter(from, to) 151 | c := newDefaultClient(cmd) 152 | return c.Edit(file, update, filter) 153 | } 154 | 155 | func newAttributeReplaceCmd() *cobra.Command { 156 | cmd := &cobra.Command{ 157 | Use: "replace
", 158 | Short: "Replace both the name and value of attribute", 159 | Long: `Replace both the name and value of matched attribute at a given address 160 | 161 | Arguments: 162 | ADDRESS An address of attribute to be replaced. 163 | NAME A new name (key) of attribute. 164 | VALUE A new value of attribute. 165 | The value is set literally, even if references or expressions. 166 | Thus, if you want to set a string literal "bar", be sure to 167 | escape double quotes so that they are not discarded by your shell. 168 | e.g.) hcledit attribute replace aaa.bbb.ccc foo '"bar"' 169 | `, 170 | RunE: runAttributeReplaceCmd, 171 | } 172 | 173 | return cmd 174 | } 175 | 176 | func runAttributeReplaceCmd(cmd *cobra.Command, args []string) error { 177 | if len(args) != 3 { 178 | return fmt.Errorf("expected 3 argument, but got %d arguments", len(args)) 179 | } 180 | 181 | address := args[0] 182 | name := args[1] 183 | value := args[2] 184 | file := viper.GetString("file") 185 | update := viper.GetBool("update") 186 | 187 | filter := editor.NewAttributeReplaceFilter(address, name, value) 188 | c := newDefaultClient(cmd) 189 | return c.Edit(file, update, filter) 190 | } 191 | 192 | func runAttributeRmCmd(cmd *cobra.Command, args []string) error { 193 | if len(args) != 1 { 194 | return fmt.Errorf("expected 1 argument, but got %d arguments", len(args)) 195 | } 196 | 197 | address := args[0] 198 | file := viper.GetString("file") 199 | update := viper.GetBool("update") 200 | 201 | filter := editor.NewAttributeRemoveFilter(address) 202 | c := newDefaultClient(cmd) 203 | return c.Edit(file, update, filter) 204 | } 205 | 206 | func newAttributeAppendCmd() *cobra.Command { 207 | cmd := &cobra.Command{ 208 | Use: "append
", 209 | Short: "Append attribute", 210 | Long: `Append a new attribute at a given address 211 | 212 | Arguments: 213 | ADDRESS An address of attribute to append. 214 | VALUE A new value of attribute. 215 | The value is set literally, even if references or expressions. 216 | Thus, if you want to set a string literal "hoge", be sure to 217 | escape double quotes so that they are not discarded by your shell. 218 | e.g.) hcledit attribute append aaa.bbb.ccc '"hoge"' 219 | `, 220 | RunE: runAttributeAppendCmd, 221 | } 222 | 223 | flags := cmd.Flags() 224 | flags.Bool("newline", false, "Append a new line before a new attribute") 225 | _ = viper.BindPFlag("attribute.append.newline", flags.Lookup("newline")) 226 | 227 | return cmd 228 | } 229 | 230 | func runAttributeAppendCmd(cmd *cobra.Command, args []string) error { 231 | if len(args) != 2 { 232 | return fmt.Errorf("expected 2 argument, but got %d arguments", len(args)) 233 | } 234 | 235 | address := args[0] 236 | value := args[1] 237 | newline := viper.GetBool("attribute.append.newline") 238 | file := viper.GetString("file") 239 | update := viper.GetBool("update") 240 | 241 | filter := editor.NewAttributeAppendFilter(address, value, newline) 242 | c := newDefaultClient(cmd) 243 | return c.Edit(file, update, filter) 244 | } 245 | -------------------------------------------------------------------------------- /cmd/attribute_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestAttributeGet(t *testing.T) { 8 | src := `terraform { 9 | backend "s3" { 10 | region = "ap-northeast-1" 11 | bucket = "minamijoyo-hcledit" 12 | key = "services/hoge/dev/terraform.tfstate" 13 | } 14 | } 15 | locals { 16 | map = { 17 | # comment 18 | attribute = "bar" 19 | } 20 | attribute = "foo" # comment 21 | } 22 | ` 23 | 24 | cases := []struct { 25 | name string 26 | args []string 27 | ok bool 28 | want string 29 | }{ 30 | { 31 | name: "simple", 32 | args: []string{"terraform.backend.s3.key"}, 33 | ok: true, 34 | want: "\"services/hoge/dev/terraform.tfstate\"\n", 35 | }, 36 | { 37 | name: "no match", 38 | args: []string{"hoge"}, 39 | ok: true, 40 | want: "", 41 | }, 42 | { 43 | name: "no args", 44 | args: []string{}, 45 | ok: false, 46 | want: "", 47 | }, 48 | { 49 | name: "too many args", 50 | args: []string{"hoge", "fuga"}, 51 | ok: false, 52 | want: "", 53 | }, 54 | { 55 | name: "with comments", 56 | args: []string{"--with-comments", "locals.map"}, 57 | ok: true, 58 | want: `{ 59 | # comment 60 | attribute = "bar" 61 | } 62 | `, 63 | }, 64 | { 65 | name: "without comments", 66 | args: []string{"locals.map"}, 67 | ok: true, 68 | want: `{ 69 | 70 | attribute = "bar" 71 | } 72 | `, 73 | }, 74 | { 75 | name: "single with comments", 76 | args: []string{"--with-comments", "locals.attribute"}, 77 | ok: true, 78 | want: `"foo" # comment 79 | `, 80 | }, 81 | { 82 | name: "single without comments", 83 | args: []string{"locals.attribute"}, 84 | ok: true, 85 | want: `"foo" 86 | `, 87 | }, 88 | } 89 | 90 | for _, tc := range cases { 91 | t.Run(tc.name, func(t *testing.T) { 92 | cmd := newMockCmd(newAttributeGetCmd(), src) 93 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 94 | }) 95 | } 96 | } 97 | 98 | func TestAttributeSet(t *testing.T) { 99 | src := `terraform { 100 | backend "s3" { 101 | region = "ap-northeast-1" 102 | bucket = "minamijoyo-hcledit" 103 | key = "services/hoge/dev/terraform.tfstate" 104 | } 105 | } 106 | module "hoge" { 107 | source = "./hoge" 108 | env = "dev" 109 | } 110 | ` 111 | 112 | cases := []struct { 113 | name string 114 | args []string 115 | ok bool 116 | want string 117 | }{ 118 | { 119 | name: "string literal", 120 | args: []string{"terraform.backend.s3.key", `"services/fuga/dev/terraform.tfstate"`}, 121 | ok: true, 122 | want: `terraform { 123 | backend "s3" { 124 | region = "ap-northeast-1" 125 | bucket = "minamijoyo-hcledit" 126 | key = "services/fuga/dev/terraform.tfstate" 127 | } 128 | } 129 | module "hoge" { 130 | source = "./hoge" 131 | env = "dev" 132 | } 133 | `, 134 | }, 135 | { 136 | name: "string literal to variable reference", 137 | args: []string{"module.hoge.env", "var.env"}, 138 | ok: true, 139 | want: `terraform { 140 | backend "s3" { 141 | region = "ap-northeast-1" 142 | bucket = "minamijoyo-hcledit" 143 | key = "services/hoge/dev/terraform.tfstate" 144 | } 145 | } 146 | module "hoge" { 147 | source = "./hoge" 148 | env = var.env 149 | } 150 | `, 151 | }, 152 | { 153 | name: "no match", 154 | args: []string{"hoge", "fuga"}, 155 | ok: true, 156 | want: src, 157 | }, 158 | { 159 | name: "no args", 160 | args: []string{}, 161 | ok: false, 162 | want: "", 163 | }, 164 | { 165 | name: "1 arg", 166 | args: []string{"hoge"}, 167 | ok: false, 168 | want: "", 169 | }, 170 | { 171 | name: "too many args", 172 | args: []string{"hoge", "fuga", "piyo"}, 173 | ok: false, 174 | want: "", 175 | }, 176 | } 177 | 178 | for _, tc := range cases { 179 | t.Run(tc.name, func(t *testing.T) { 180 | cmd := newMockCmd(newAttributeSetCmd(), src) 181 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 182 | }) 183 | } 184 | } 185 | 186 | func TestAttributeMv(t *testing.T) { 187 | src := `locals { 188 | foo1 = "bar1" 189 | foo2 = "bar2" 190 | } 191 | 192 | resource "foo" "bar" { 193 | foo3 = "bar3" 194 | } 195 | ` 196 | 197 | cases := []struct { 198 | name string 199 | args []string 200 | ok bool 201 | want string 202 | }{ 203 | { 204 | name: "simple", 205 | args: []string{"locals.foo1", "locals.foo3"}, 206 | ok: true, 207 | want: `locals { 208 | foo3 = "bar1" 209 | foo2 = "bar2" 210 | } 211 | 212 | resource "foo" "bar" { 213 | foo3 = "bar3" 214 | } 215 | `, 216 | }, 217 | { 218 | name: "no match", 219 | args: []string{"locals.foo3", "locals.foo4"}, 220 | ok: true, 221 | want: src, 222 | }, 223 | { 224 | name: "duplicated", 225 | args: []string{"locals.foo1", "locals.foo2"}, 226 | ok: false, 227 | want: "", 228 | }, 229 | { 230 | name: "move an attribute accross blocks", 231 | args: []string{"locals.foo1", "resource.foo.bar.foo1"}, 232 | ok: false, 233 | want: "", 234 | }, 235 | { 236 | name: "no args", 237 | args: []string{}, 238 | ok: false, 239 | want: "", 240 | }, 241 | { 242 | name: "1 arg", 243 | args: []string{"hoge"}, 244 | ok: false, 245 | want: "", 246 | }, 247 | { 248 | name: "too many args", 249 | args: []string{"hoge", "fuga", "piyo"}, 250 | ok: false, 251 | want: "", 252 | }, 253 | } 254 | 255 | for _, tc := range cases { 256 | t.Run(tc.name, func(t *testing.T) { 257 | cmd := newMockCmd(newAttributeMvCmd(), src) 258 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 259 | }) 260 | } 261 | } 262 | 263 | func TestAttributeReplace(t *testing.T) { 264 | src := `terraform { 265 | backend "s3" { 266 | region = "ap-northeast-1" 267 | bucket = "my-s3lock-test" 268 | key = "dir1/terraform.tfstate" 269 | dynamodb_table = "tflock" 270 | profile = "foo" 271 | } 272 | } 273 | ` 274 | 275 | cases := []struct { 276 | name string 277 | args []string 278 | ok bool 279 | want string 280 | }{ 281 | { 282 | name: "simple", 283 | args: []string{"terraform.backend.s3.dynamodb_table", "use_lockfile", "true"}, 284 | ok: true, 285 | want: `terraform { 286 | backend "s3" { 287 | region = "ap-northeast-1" 288 | bucket = "my-s3lock-test" 289 | key = "dir1/terraform.tfstate" 290 | use_lockfile = true 291 | profile = "foo" 292 | } 293 | } 294 | `, 295 | }, 296 | { 297 | name: "no match", 298 | args: []string{"terraform.backend.s3.foo_table", "use_lockfile", "true"}, 299 | ok: true, 300 | want: src, 301 | }, 302 | { 303 | name: "duplicated", 304 | args: []string{"terraform.backend.s3.dynamodb_table", "profile", "true"}, 305 | ok: false, 306 | want: "", 307 | }, 308 | { 309 | name: "no args", 310 | args: []string{}, 311 | ok: false, 312 | want: "", 313 | }, 314 | { 315 | name: "1 arg", 316 | args: []string{"foo"}, 317 | ok: false, 318 | want: "", 319 | }, 320 | { 321 | name: "2 args", 322 | args: []string{"foo", "bar"}, 323 | ok: false, 324 | want: "", 325 | }, 326 | { 327 | name: "too many args", 328 | args: []string{"foo", "bar", "baz", "qux"}, 329 | ok: false, 330 | want: "", 331 | }, 332 | } 333 | 334 | for _, tc := range cases { 335 | t.Run(tc.name, func(t *testing.T) { 336 | cmd := newMockCmd(newAttributeReplaceCmd(), src) 337 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 338 | }) 339 | } 340 | } 341 | 342 | func TestAttributeRm(t *testing.T) { 343 | src := `locals { 344 | service = "hoge" 345 | env = "dev" 346 | region = "ap-northeast-1" 347 | }` 348 | 349 | cases := []struct { 350 | name string 351 | args []string 352 | ok bool 353 | want string 354 | }{ 355 | { 356 | name: "remove an unused local variable", 357 | args: []string{"locals.region"}, 358 | ok: true, 359 | want: `locals { 360 | service = "hoge" 361 | env = "dev" 362 | }`, 363 | }, 364 | { 365 | name: "no match", 366 | args: []string{"hoge"}, 367 | ok: true, 368 | want: src, 369 | }, 370 | { 371 | name: "no args", 372 | args: []string{}, 373 | ok: false, 374 | want: "", 375 | }, 376 | { 377 | name: "too many args", 378 | args: []string{"hoge", "fuga"}, 379 | ok: false, 380 | want: "", 381 | }, 382 | } 383 | 384 | for _, tc := range cases { 385 | t.Run(tc.name, func(t *testing.T) { 386 | cmd := newMockCmd(newAttributeRmCmd(), src) 387 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 388 | }) 389 | } 390 | } 391 | 392 | func TestAttributeAppend(t *testing.T) { 393 | src := `terraform { 394 | required_version = "0.13.5" 395 | 396 | backend "s3" { 397 | region = "ap-northeast-1" 398 | bucket = "foo" 399 | key = "bar/terraform.tfstate" 400 | } 401 | 402 | required_providers { 403 | } 404 | } 405 | ` 406 | 407 | cases := []struct { 408 | name string 409 | args []string 410 | ok bool 411 | want string 412 | }{ 413 | { 414 | name: "map literal", 415 | args: []string{"terraform.required_providers.aws", `{ 416 | source = "hashicorp/aws" 417 | version = "3.11.0" 418 | }`}, 419 | ok: true, 420 | want: `terraform { 421 | required_version = "0.13.5" 422 | 423 | backend "s3" { 424 | region = "ap-northeast-1" 425 | bucket = "foo" 426 | key = "bar/terraform.tfstate" 427 | } 428 | 429 | required_providers { 430 | aws = { 431 | source = "hashicorp/aws" 432 | version = "3.11.0" 433 | } 434 | } 435 | } 436 | `, 437 | }, 438 | { 439 | name: "no match", 440 | args: []string{"foo.bar", "baz"}, 441 | ok: true, 442 | want: src, 443 | }, 444 | { 445 | name: "no args", 446 | args: []string{}, 447 | ok: false, 448 | want: "", 449 | }, 450 | { 451 | name: "1 arg", 452 | args: []string{"terraform.required_providers.aws"}, 453 | ok: false, 454 | want: "", 455 | }, 456 | { 457 | name: "too many args", 458 | args: []string{"terraform.required_providers.aws", "foo", "var"}, 459 | ok: false, 460 | want: "", 461 | }, 462 | { 463 | name: "map literal (newline)", 464 | args: []string{ 465 | "terraform.required_providers.aws", 466 | `{ 467 | source = "hashicorp/aws" 468 | version = "3.11.0" 469 | }`, 470 | "--newline"}, 471 | ok: true, 472 | want: `terraform { 473 | required_version = "0.13.5" 474 | 475 | backend "s3" { 476 | region = "ap-northeast-1" 477 | bucket = "foo" 478 | key = "bar/terraform.tfstate" 479 | } 480 | 481 | required_providers { 482 | 483 | aws = { 484 | source = "hashicorp/aws" 485 | version = "3.11.0" 486 | } 487 | } 488 | } 489 | `, 490 | }, 491 | } 492 | 493 | for _, tc := range cases { 494 | t.Run(tc.name, func(t *testing.T) { 495 | cmd := newMockCmd(newAttributeAppendCmd(), src) 496 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 497 | }) 498 | } 499 | } 500 | -------------------------------------------------------------------------------- /cmd/block.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/minamijoyo/hcledit/editor" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | func init() { 13 | RootCmd.AddCommand(newBlockCmd()) 14 | } 15 | 16 | func newBlockCmd() *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: "block", 19 | Short: "Edit block", 20 | RunE: func(cmd *cobra.Command, _ []string) error { 21 | return cmd.Help() 22 | }, 23 | } 24 | 25 | cmd.AddCommand( 26 | newBlockGetCmd(), 27 | newBlockMvCmd(), 28 | newBlockListCmd(), 29 | newBlockRmCmd(), 30 | newBlockAppendCmd(), 31 | newBlockNewCmd(), 32 | ) 33 | 34 | return cmd 35 | } 36 | 37 | func newBlockGetCmd() *cobra.Command { 38 | cmd := &cobra.Command{ 39 | Use: "get
", 40 | Short: "Get block", 41 | Long: `Get matched blocks at a given address 42 | 43 | Arguments: 44 | ADDRESS An address of block to get. 45 | `, 46 | RunE: runBlockGetCmd, 47 | } 48 | 49 | return cmd 50 | } 51 | 52 | func runBlockGetCmd(cmd *cobra.Command, args []string) error { 53 | if len(args) != 1 { 54 | return fmt.Errorf("expected 1 argument, but got %d arguments", len(args)) 55 | } 56 | 57 | address := args[0] 58 | file := viper.GetString("file") 59 | update := viper.GetBool("update") 60 | 61 | filter := editor.NewBlockGetFilter(address) 62 | c := newDefaultClient(cmd) 63 | return c.Edit(file, update, filter) 64 | } 65 | 66 | func newBlockMvCmd() *cobra.Command { 67 | cmd := &cobra.Command{ 68 | Use: "mv ", 69 | Short: "Move block (Rename block type and labels)", 70 | Long: `Move block (Rename block type and labels) 71 | 72 | Arguments: 73 | FROM_ADDRESS An old address of block. 74 | TO_ADDRESS A new address of block. 75 | `, 76 | RunE: runBlockMvCmd, 77 | } 78 | 79 | return cmd 80 | } 81 | 82 | func runBlockMvCmd(cmd *cobra.Command, args []string) error { 83 | if len(args) != 2 { 84 | return fmt.Errorf("expected 2 argument, but got %d arguments", len(args)) 85 | } 86 | 87 | from := args[0] 88 | to := args[1] 89 | file := viper.GetString("file") 90 | update := viper.GetBool("update") 91 | 92 | filter := editor.NewBlockRenameFilter(from, to) 93 | c := newDefaultClient(cmd) 94 | return c.Edit(file, update, filter) 95 | } 96 | 97 | func newBlockListCmd() *cobra.Command { 98 | cmd := &cobra.Command{ 99 | Use: "list", 100 | Short: "List block", 101 | RunE: runBlockListCmd, 102 | } 103 | 104 | return cmd 105 | } 106 | 107 | func runBlockListCmd(cmd *cobra.Command, args []string) error { 108 | if len(args) != 0 { 109 | return fmt.Errorf("expected 0 argument, but got %d arguments", len(args)) 110 | } 111 | 112 | file := viper.GetString("file") 113 | update := viper.GetBool("update") 114 | if update { 115 | return errors.New("The update flag is not allowed") 116 | } 117 | 118 | sink := editor.NewBlockListSink() 119 | c := newDefaultClient(cmd) 120 | return c.Derive(file, sink) 121 | } 122 | 123 | func newBlockRmCmd() *cobra.Command { 124 | cmd := &cobra.Command{ 125 | Use: "rm
", 126 | Short: "Remove block", 127 | Long: `Remove matched blocks at a given address 128 | 129 | Arguments: 130 | ADDRESS An address of block to remove. 131 | `, 132 | RunE: runBlockRmCmd, 133 | } 134 | 135 | return cmd 136 | } 137 | 138 | func runBlockRmCmd(cmd *cobra.Command, args []string) error { 139 | if len(args) != 1 { 140 | return fmt.Errorf("expected 1 argument, but got %d arguments", len(args)) 141 | } 142 | 143 | address := args[0] 144 | file := viper.GetString("file") 145 | update := viper.GetBool("update") 146 | 147 | filter := editor.NewBlockRemoveFilter(address) 148 | c := newDefaultClient(cmd) 149 | return c.Edit(file, update, filter) 150 | } 151 | 152 | func newBlockAppendCmd() *cobra.Command { 153 | cmd := &cobra.Command{ 154 | Use: "append ", 155 | Short: "Append block", 156 | Long: `Append a new child block to matched blocks at a given parent block address 157 | 158 | Arguments: 159 | PARENT_ADDRESS A parent block address to be appended. 160 | CHILD_ADDRESS A new child block relative address. 161 | `, 162 | RunE: runBlockAppendCmd, 163 | } 164 | 165 | flags := cmd.Flags() 166 | flags.Bool("newline", false, "Append a new line before a new child block") 167 | _ = viper.BindPFlag("block.append.newline", flags.Lookup("newline")) 168 | 169 | return cmd 170 | } 171 | 172 | func runBlockAppendCmd(cmd *cobra.Command, args []string) error { 173 | if len(args) != 2 { 174 | return fmt.Errorf("expected 2 argument, but got %d arguments", len(args)) 175 | } 176 | 177 | parent := args[0] 178 | child := args[1] 179 | newline := viper.GetBool("block.append.newline") 180 | file := viper.GetString("file") 181 | update := viper.GetBool("update") 182 | 183 | filter := editor.NewBlockAppendFilter(parent, child, newline) 184 | c := newDefaultClient(cmd) 185 | return c.Edit(file, update, filter) 186 | } 187 | 188 | func newBlockNewCmd() *cobra.Command { 189 | cmd := &cobra.Command{ 190 | Use: "new
", 191 | Short: "Create a new block", 192 | Long: `Create a new block 193 | 194 | Arguments: 195 | ADDRESS An address of block to be created. 196 | `, 197 | RunE: runBlockNewCmd, 198 | } 199 | 200 | flags := cmd.Flags() 201 | flags.Bool("newline", false, "Append a new line before a new block") 202 | _ = viper.BindPFlag("block.new.newline", flags.Lookup("newline")) 203 | 204 | return cmd 205 | } 206 | 207 | func runBlockNewCmd(cmd *cobra.Command, args []string) error { 208 | if len(args) != 1 { 209 | return fmt.Errorf("expected 1 argument, but got %d arguments", len(args)) 210 | } 211 | 212 | address := args[0] 213 | file := viper.GetString("file") 214 | update := viper.GetBool("update") 215 | newline := viper.GetBool("block.new.newline") 216 | 217 | filter := editor.NewBlockNewFilter(address, newline) 218 | c := newDefaultClient(cmd) 219 | return c.Edit(file, update, filter) 220 | } 221 | -------------------------------------------------------------------------------- /cmd/block_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBlockGet(t *testing.T) { 8 | src := `terraform { 9 | required_version = "0.12.18" 10 | } 11 | 12 | provider "aws" { 13 | version = "2.43.0" 14 | region = "ap-northeast-1" 15 | } 16 | ` 17 | 18 | cases := []struct { 19 | name string 20 | args []string 21 | ok bool 22 | want string 23 | }{ 24 | { 25 | name: "simple", 26 | args: []string{"terraform"}, 27 | ok: true, 28 | want: `terraform { 29 | required_version = "0.12.18" 30 | } 31 | `, 32 | }, 33 | { 34 | name: "no match", 35 | args: []string{"hoge"}, 36 | ok: true, 37 | want: "", 38 | }, 39 | { 40 | name: "no args", 41 | args: []string{}, 42 | ok: false, 43 | want: "", 44 | }, 45 | { 46 | name: "too many args", 47 | args: []string{"hoge", "fuga"}, 48 | ok: false, 49 | want: "", 50 | }, 51 | } 52 | 53 | for _, tc := range cases { 54 | t.Run(tc.name, func(t *testing.T) { 55 | cmd := newMockCmd(newBlockGetCmd(), src) 56 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 57 | }) 58 | } 59 | } 60 | 61 | func TestBlockMv(t *testing.T) { 62 | src := `resource "aws_security_group" "test1" { 63 | name = "tfedit-test1" 64 | } 65 | 66 | resource "aws_security_group" "test2" { 67 | name = "tfedit-test2" 68 | } 69 | ` 70 | 71 | cases := []struct { 72 | name string 73 | args []string 74 | ok bool 75 | want string 76 | }{ 77 | { 78 | name: "simple", 79 | args: []string{"resource.aws_security_group.test1", "resource.aws_security_group.test3"}, 80 | ok: true, 81 | want: `resource "aws_security_group" "test3" { 82 | name = "tfedit-test1" 83 | } 84 | 85 | resource "aws_security_group" "test2" { 86 | name = "tfedit-test2" 87 | } 88 | `, 89 | }, 90 | { 91 | name: "no match", 92 | args: []string{"resource.aws_security_group.test", "resource.aws_security_group.test3"}, 93 | ok: true, 94 | want: src, 95 | }, 96 | { 97 | name: "no args", 98 | args: []string{}, 99 | ok: false, 100 | want: "", 101 | }, 102 | { 103 | name: "1 arg", 104 | args: []string{"hoge"}, 105 | ok: false, 106 | want: "", 107 | }, 108 | { 109 | name: "too many args", 110 | args: []string{"hoge", "fuga", "piyo"}, 111 | ok: false, 112 | want: "", 113 | }, 114 | } 115 | 116 | for _, tc := range cases { 117 | t.Run(tc.name, func(t *testing.T) { 118 | cmd := newMockCmd(newBlockMvCmd(), src) 119 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 120 | }) 121 | } 122 | } 123 | 124 | func TestBlockList(t *testing.T) { 125 | src := `terraform { 126 | required_version = "0.12.18" 127 | } 128 | 129 | provider "aws" { 130 | version = "2.43.0" 131 | region = "ap-northeast-1" 132 | } 133 | 134 | resource "aws_security_group" "hoge" { 135 | name = "hoge" 136 | egress { 137 | from_port = 0 138 | to_port = 0 139 | protocol = -1 140 | } 141 | } 142 | 143 | resource "aws_security_group" "fuga" { 144 | name = "fuga" 145 | egress { 146 | from_port = 0 147 | to_port = 0 148 | protocol = -1 149 | } 150 | } 151 | ` 152 | 153 | cases := []struct { 154 | name string 155 | args []string 156 | ok bool 157 | want string 158 | }{ 159 | { 160 | name: "simple", 161 | args: []string{}, 162 | ok: true, 163 | want: `terraform 164 | provider.aws 165 | resource.aws_security_group.hoge 166 | resource.aws_security_group.fuga 167 | `, 168 | }, 169 | } 170 | 171 | for _, tc := range cases { 172 | t.Run(tc.name, func(t *testing.T) { 173 | cmd := newMockCmd(newBlockListCmd(), src) 174 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 175 | }) 176 | } 177 | } 178 | 179 | func TestBlockRm(t *testing.T) { 180 | src := `data "aws_security_group" "hoge" { 181 | name = "hoge" 182 | } 183 | 184 | data "aws_security_group" "fuga" { 185 | name = "fuga" 186 | } 187 | ` 188 | 189 | cases := []struct { 190 | name string 191 | args []string 192 | ok bool 193 | want string 194 | }{ 195 | { 196 | name: "simple", 197 | args: []string{"data.aws_security_group.hoge"}, 198 | ok: true, 199 | want: `data "aws_security_group" "fuga" { 200 | name = "fuga" 201 | } 202 | `, 203 | }, 204 | { 205 | name: "no match", 206 | args: []string{"hoge"}, 207 | ok: true, 208 | want: src, 209 | }, 210 | { 211 | name: "no args", 212 | args: []string{}, 213 | ok: false, 214 | want: "", 215 | }, 216 | { 217 | name: "too many args", 218 | args: []string{"hoge", "fuga"}, 219 | ok: false, 220 | want: "", 221 | }, 222 | } 223 | 224 | for _, tc := range cases { 225 | t.Run(tc.name, func(t *testing.T) { 226 | cmd := newMockCmd(newBlockRmCmd(), src) 227 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 228 | }) 229 | } 230 | } 231 | 232 | func TestBlockAppend(t *testing.T) { 233 | src := `terraform { 234 | required_version = "0.13.5" 235 | 236 | backend "s3" { 237 | region = "ap-northeast-1" 238 | bucket = "foo" 239 | key = "bar/terraform.tfstate" 240 | } 241 | } 242 | ` 243 | 244 | cases := []struct { 245 | name string 246 | args []string 247 | ok bool 248 | want string 249 | }{ 250 | { 251 | name: "simple", 252 | args: []string{"terraform", "required_providers"}, 253 | ok: true, 254 | want: `terraform { 255 | required_version = "0.13.5" 256 | 257 | backend "s3" { 258 | region = "ap-northeast-1" 259 | bucket = "foo" 260 | key = "bar/terraform.tfstate" 261 | } 262 | required_providers { 263 | } 264 | } 265 | `, 266 | }, 267 | { 268 | name: "no match", 269 | args: []string{"foo", "bar"}, 270 | ok: true, 271 | want: src, 272 | }, 273 | { 274 | name: "no args", 275 | args: []string{}, 276 | ok: false, 277 | want: "", 278 | }, 279 | { 280 | name: "1 arg", 281 | args: []string{"terraform"}, 282 | ok: false, 283 | want: "", 284 | }, 285 | { 286 | name: "too many args", 287 | args: []string{"terraform", "required_providers", "foo"}, 288 | ok: false, 289 | want: "", 290 | }, 291 | { 292 | name: "newline", 293 | args: []string{"terraform", "required_providers", "--newline"}, 294 | ok: true, 295 | want: `terraform { 296 | required_version = "0.13.5" 297 | 298 | backend "s3" { 299 | region = "ap-northeast-1" 300 | bucket = "foo" 301 | key = "bar/terraform.tfstate" 302 | } 303 | 304 | required_providers { 305 | } 306 | } 307 | `, 308 | }, 309 | } 310 | 311 | for _, tc := range cases { 312 | t.Run(tc.name, func(t *testing.T) { 313 | cmd := newMockCmd(newBlockAppendCmd(), src) 314 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 315 | }) 316 | } 317 | } 318 | 319 | func TestBlockNew(t *testing.T) { 320 | 321 | src := `variable "var1" { 322 | type = string 323 | default = "foo" 324 | description = "example variable" 325 | } 326 | ` 327 | 328 | cases := []struct { 329 | name string 330 | args []string 331 | ok bool 332 | want string 333 | }{ 334 | { 335 | name: "with labels", 336 | args: []string{"resource.aws_instance.example"}, 337 | ok: true, 338 | want: `variable "var1" { 339 | type = string 340 | default = "foo" 341 | description = "example variable" 342 | } 343 | resource "aws_instance" "example" { 344 | } 345 | `, 346 | }, 347 | { 348 | name: "no labels", 349 | args: []string{"locals"}, 350 | ok: true, 351 | want: `variable "var1" { 352 | type = string 353 | default = "foo" 354 | description = "example variable" 355 | } 356 | locals { 357 | } 358 | `, 359 | }, 360 | { 361 | name: "newline", 362 | args: []string{"resource.aws_instance.example", "--newline"}, 363 | ok: true, 364 | want: `variable "var1" { 365 | type = string 366 | default = "foo" 367 | description = "example variable" 368 | } 369 | 370 | resource "aws_instance" "example" { 371 | } 372 | `, 373 | }, 374 | { 375 | name: "no args", 376 | args: []string{}, 377 | ok: false, 378 | want: "", 379 | }, 380 | { 381 | name: "too many args", 382 | args: []string{"foo", "bar"}, 383 | ok: false, 384 | want: "", 385 | }, 386 | } 387 | 388 | for _, tc := range cases { 389 | t.Run(tc.name, func(t *testing.T) { 390 | cmd := newMockCmd(newBlockNewCmd(), src) 391 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 392 | }) 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /cmd/body.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/minamijoyo/hcledit/editor" 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | func init() { 12 | RootCmd.AddCommand(newBodyCmd()) 13 | } 14 | 15 | func newBodyCmd() *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "body", 18 | Short: "Edit body", 19 | RunE: func(cmd *cobra.Command, _ []string) error { 20 | return cmd.Help() 21 | }, 22 | } 23 | 24 | cmd.AddCommand( 25 | newBodyGetCmd(), 26 | ) 27 | 28 | return cmd 29 | } 30 | 31 | func newBodyGetCmd() *cobra.Command { 32 | cmd := &cobra.Command{ 33 | Use: "get
", 34 | Short: "Get body", 35 | Long: `Get body of first matched block at a given address 36 | 37 | Arguments: 38 | ADDRESS An address of block to get. 39 | `, 40 | RunE: runBodyGetCmd, 41 | } 42 | 43 | return cmd 44 | } 45 | 46 | func runBodyGetCmd(cmd *cobra.Command, args []string) error { 47 | if len(args) != 1 { 48 | return fmt.Errorf("expected 1 argument, but got %d arguments", len(args)) 49 | } 50 | 51 | address := args[0] 52 | file := viper.GetString("file") 53 | update := viper.GetBool("update") 54 | 55 | filter := editor.NewBodyGetFilter(address) 56 | c := newDefaultClient(cmd) 57 | return c.Edit(file, update, filter) 58 | } 59 | -------------------------------------------------------------------------------- /cmd/body_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBodyGet(t *testing.T) { 8 | src := `resource "foo" "bar" { 9 | attr1 = "val1" 10 | nested { 11 | attr2 = "val2" 12 | } 13 | } 14 | ` 15 | 16 | cases := []struct { 17 | name string 18 | args []string 19 | ok bool 20 | want string 21 | }{ 22 | { 23 | name: "simple", 24 | args: []string{"resource.foo.bar"}, 25 | ok: true, 26 | want: `attr1 = "val1" 27 | nested { 28 | attr2 = "val2" 29 | } 30 | `, 31 | }, 32 | { 33 | name: "no match", 34 | args: []string{"hoge"}, 35 | ok: true, 36 | want: "", 37 | }, 38 | { 39 | name: "no args", 40 | args: []string{}, 41 | ok: false, 42 | want: "", 43 | }, 44 | { 45 | name: "too many args", 46 | args: []string{"hoge", "fuga"}, 47 | ok: false, 48 | want: "", 49 | }, 50 | } 51 | 52 | for _, tc := range cases { 53 | t.Run(tc.name, func(t *testing.T) { 54 | cmd := newMockCmd(newBodyGetCmd(), src) 55 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /cmd/fmt.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/minamijoyo/hcledit/editor" 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | func init() { 12 | RootCmd.AddCommand(newFmtCmd()) 13 | } 14 | 15 | func newFmtCmd() *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "fmt", 18 | Short: "Format file", 19 | Long: "Format a file to a caconical style", 20 | RunE: runFmtCmd, 21 | } 22 | 23 | return cmd 24 | } 25 | 26 | func runFmtCmd(cmd *cobra.Command, args []string) error { 27 | if len(args) != 0 { 28 | return fmt.Errorf("expected no argument, but got %d arguments", len(args)) 29 | } 30 | 31 | file := viper.GetString("file") 32 | update := viper.GetBool("update") 33 | 34 | filter := editor.NewFormatterFilter() 35 | c := newDefaultClient(cmd) 36 | return c.Edit(file, update, filter) 37 | } 38 | -------------------------------------------------------------------------------- /cmd/fmt_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFmt(t *testing.T) { 8 | 9 | cases := []struct { 10 | name string 11 | src string 12 | args []string 13 | ok bool 14 | want string 15 | }{ 16 | { 17 | name: "unformatted", 18 | src: ` 19 | resource "foo" "bar" { 20 | attr1 = "val1" 21 | attr2="val2" 22 | } 23 | `, 24 | args: []string{}, 25 | ok: true, 26 | want: ` 27 | resource "foo" "bar" { 28 | attr1 = "val1" 29 | attr2 = "val2" 30 | } 31 | `, 32 | }, 33 | { 34 | name: "syntax error", 35 | src: ` 36 | resource "foo" "bar" { 37 | attr1 = "val1" 38 | `, 39 | args: []string{}, 40 | ok: false, 41 | want: "", 42 | }, 43 | { 44 | name: "too many args", 45 | src: "", 46 | args: []string{"foo"}, 47 | ok: false, 48 | want: "", 49 | }, 50 | } 51 | 52 | for _, tc := range cases { 53 | t.Run(tc.name, func(t *testing.T) { 54 | cmd := newMockCmd(newFmtCmd(), tc.src) 55 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /cmd/mock.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // newMockCmd is a helper function which returns a *cobra.Command 12 | // whose in/out/err streams are mocked for testing. 13 | func newMockCmd(cmd *cobra.Command, input string) *cobra.Command { 14 | inStream := bytes.NewBufferString(input) 15 | outStream := new(bytes.Buffer) 16 | errStream := new(bytes.Buffer) 17 | 18 | cmd.SetIn(inStream) 19 | cmd.SetOut(outStream) 20 | cmd.SetErr(errStream) 21 | 22 | return cmd 23 | } 24 | 25 | // assertMockCmd is a high-level test helper to run a given mock command with 26 | // arguments and check if an error and its stdout are expected. 27 | func assertMockCmd(t *testing.T, cmd *cobra.Command, args []string, ok bool, want string) { 28 | err := runMockCmd(cmd, args) 29 | 30 | stderr := mockErr(cmd) 31 | if ok && err != nil { 32 | t.Fatalf("unexpected err = %s, stderr: \n%s", err, stderr) 33 | } 34 | 35 | stdout := mockOut(cmd) 36 | if !ok && err == nil { 37 | t.Fatalf("expected to return an error, but no error, stdout: \n%s", stdout) 38 | } 39 | 40 | if stdout != want { 41 | t.Fatalf("got:\n%s\nwant:\n%s", stdout, want) 42 | } 43 | } 44 | 45 | // runMockCmd is a helper function which parses flags and invokes a given mock 46 | // command. 47 | func runMockCmd(cmd *cobra.Command, args []string) error { 48 | cmdFlags := cmd.Flags() 49 | if err := cmdFlags.Parse(args); err != nil { 50 | return fmt.Errorf("failed to parse arguments: %s", err) 51 | } 52 | 53 | return cmd.RunE(cmd, cmdFlags.Args()) 54 | } 55 | 56 | // mockErr is a helper function which returns a string written to mocked err stream. 57 | func mockErr(cmd *cobra.Command) string { 58 | return cmd.ErrOrStderr().(*bytes.Buffer).String() 59 | } 60 | 61 | // mockOut is a helper function which returns a string written to mocked out stream. 62 | func mockOut(cmd *cobra.Command) string { 63 | return cmd.OutOrStdout().(*bytes.Buffer).String() 64 | } 65 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/minamijoyo/hcledit/editor" 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | // RootCmd is a top level command instance 12 | var RootCmd = &cobra.Command{ 13 | Use: "hcledit", 14 | Short: "A command line editor for HCL", 15 | SilenceErrors: true, 16 | SilenceUsage: true, 17 | } 18 | 19 | func init() { 20 | // set global flags 21 | flags := RootCmd.PersistentFlags() 22 | flags.StringP("file", "f", "-", "A path of input file") 23 | flags.BoolP("update", "u", false, "Update files in-place") 24 | _ = viper.BindPFlag("file", flags.Lookup("file")) 25 | _ = viper.BindPFlag("update", flags.Lookup("update")) 26 | 27 | setDefaultStream(RootCmd) 28 | } 29 | 30 | func setDefaultStream(cmd *cobra.Command) { 31 | cmd.SetIn(os.Stdin) 32 | cmd.SetOut(os.Stdout) 33 | cmd.SetErr(os.Stderr) 34 | } 35 | 36 | func newDefaultClient(cmd *cobra.Command) editor.Client { 37 | o := &editor.Option{ 38 | InStream: cmd.InOrStdin(), 39 | OutStream: cmd.OutOrStdout(), 40 | ErrStream: cmd.ErrOrStderr(), 41 | } 42 | return editor.NewClient(o) 43 | } 44 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var ( 10 | // Version is version number which automatically set on build. 11 | Version = "0.2.17" 12 | ) 13 | 14 | func init() { 15 | RootCmd.AddCommand(newVersionCmd()) 16 | } 17 | 18 | func newVersionCmd() *cobra.Command { 19 | cmd := &cobra.Command{ 20 | Use: "version", 21 | Short: "Print version", 22 | RunE: runVersionCmd, 23 | } 24 | 25 | return cmd 26 | } 27 | 28 | func runVersionCmd(cmd *cobra.Command, _ []string) error { 29 | _, err := fmt.Fprintln(cmd.OutOrStdout(), Version) 30 | return err 31 | } 32 | -------------------------------------------------------------------------------- /cmd/version_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestVersion(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | args []string 11 | ok bool 12 | want string 13 | }{ 14 | { 15 | name: "simple", 16 | args: []string{}, 17 | ok: true, 18 | want: Version + "\n", 19 | }, 20 | } 21 | 22 | for _, tc := range cases { 23 | t.Run(tc.name, func(t *testing.T) { 24 | cmd := newMockCmd(newVersionCmd(), "") 25 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /editor/address.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func createAddressFromString(address string) []string { 8 | var separator byte = '.' 9 | var escapeString byte = '\\' 10 | 11 | var result []string 12 | var token []byte 13 | for i := 0; i < len(address); i++ { 14 | if address[i] == separator { 15 | result = append(result, string(token)) 16 | token = token[:0] 17 | } else if address[i] == escapeString && i+1 < len(address) { 18 | i++ 19 | token = append(token, address[i]) 20 | } else { 21 | token = append(token, address[i]) 22 | } 23 | } 24 | result = append(result, string(token)) 25 | return result 26 | } 27 | 28 | func createStringFromAddress(address []string) string { 29 | separator := "." 30 | escapeString := "\\" 31 | 32 | result := "" 33 | 34 | for i, s := range address { 35 | if i > 0 { 36 | result = result + separator 37 | } 38 | result = result + strings.ReplaceAll(s, separator, escapeString+separator) 39 | } 40 | 41 | return result 42 | } 43 | -------------------------------------------------------------------------------- /editor/address_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func TestCreateAddressFromString(t *testing.T) { 10 | cases := []struct { 11 | name string 12 | address string 13 | want []string 14 | }{ 15 | { 16 | name: "simple address", 17 | address: "b1", 18 | want: []string{"b1"}, 19 | }, 20 | { 21 | name: "attribute address", 22 | address: "b1.l1.a1", 23 | want: []string{"b1", "l1", "a1"}, 24 | }, 25 | { 26 | name: "escaped address", 27 | address: `b1.l\.1.a1`, 28 | want: []string{"b1", "l.1", "a1"}, 29 | }, 30 | } 31 | 32 | for _, tc := range cases { 33 | t.Run(tc.name, func(t *testing.T) { 34 | got := createAddressFromString(tc.address) 35 | 36 | if diff := cmp.Diff(tc.want, got); diff != "" { 37 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff(-want +got):\n%v", got, tc.want, diff) 38 | } 39 | }) 40 | } 41 | } 42 | 43 | func TestCreateStringFromAddress(t *testing.T) { 44 | cases := []struct { 45 | name string 46 | address []string 47 | want string 48 | }{ 49 | { 50 | name: "simple address", 51 | address: []string{"b1"}, 52 | want: "b1", 53 | }, 54 | { 55 | name: "simple address", 56 | address: []string{"b1", "l1", "a1"}, 57 | want: "b1.l1.a1", 58 | }, 59 | { 60 | name: "simple address", 61 | address: []string{"b1", `l.1`, "a1"}, 62 | want: `b1.l\.1.a1`, 63 | }, 64 | } 65 | 66 | for _, tc := range cases { 67 | t.Run(tc.name, func(t *testing.T) { 68 | got := createStringFromAddress(tc.address) 69 | 70 | if diff := cmp.Diff(tc.want, got); diff != "" { 71 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff(-want +got):\n%v", got, tc.want, diff) 72 | } 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /editor/client.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | // Client is an interface for the entrypoint of editor package 8 | type Client interface { 9 | // Edit reads a HCL file and appies a given filter. 10 | // If filename is `-`, reads the input from stdin. 11 | // If update is true, the outputs is written to the input file, else to stdout. 12 | Edit(filename string, update bool, filter Filter) error 13 | // Derive reads a HCL file and appies a given sink. 14 | // If filename is `-`, reads the input from stdin. 15 | // The outputs is always written to stdout. 16 | Derive(filename string, sink Sink) error 17 | } 18 | 19 | // Option is a set of options for Client. 20 | type Option struct { 21 | // InStream is the stdin stream. 22 | InStream io.Reader 23 | // OutStream is the stdout stream. 24 | OutStream io.Writer 25 | // ErrStream is the stderr stream. 26 | ErrStream io.Writer 27 | } 28 | 29 | // client implements the Client interface. 30 | type client struct { 31 | o *Option 32 | } 33 | 34 | var _ Client = (*client)(nil) 35 | 36 | // NewClient creates a new instance of Client. 37 | func NewClient(o *Option) Client { 38 | return &client{ 39 | o: o, 40 | } 41 | } 42 | 43 | // Edit reads a HCL file and appies a given filter. 44 | // If filename is `-`, reads the input from stdin. 45 | // If update is true, the outputs is written to the input file, else to stdout. 46 | func (c *client) Edit(filename string, update bool, filter Filter) error { 47 | if filename == "-" { 48 | return EditStream(c.o.InStream, c.o.OutStream, filename, filter) 49 | } 50 | 51 | if update { 52 | return UpdateFile(filename, filter) 53 | } 54 | 55 | return ReadFile(filename, c.o.OutStream, filter) 56 | } 57 | 58 | // Derive reads a HCL file and appies a given sink. 59 | // If filename is `-`, reads the input from stdin. 60 | // The outputs is always written to stdout. 61 | func (c *client) Derive(filename string, sink Sink) error { 62 | if filename == "-" { 63 | return DeriveStream(c.o.InStream, c.o.OutStream, filename, sink) 64 | } 65 | 66 | return DeriveFile(filename, c.o.OutStream, sink) 67 | } 68 | -------------------------------------------------------------------------------- /editor/client_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestClientEdit(t *testing.T) { 9 | stdin := ` 10 | a0 = v0 11 | a1 = v1 12 | ` 13 | inFile := ` 14 | a0 = v0 15 | a2 = v2 16 | ` 17 | filter := NewAttributeSetFilter("a0", "v3") 18 | 19 | cases := []struct { 20 | name string 21 | stdin string 22 | inFile string 23 | filename string 24 | update bool 25 | ok bool 26 | stdout string 27 | stderr string 28 | outFile string 29 | }{ 30 | { 31 | name: "stdin", 32 | stdin: stdin, 33 | inFile: inFile, 34 | filename: "-", 35 | update: false, 36 | ok: true, 37 | stdout: ` 38 | a0 = v3 39 | a1 = v1 40 | `, 41 | stderr: "", 42 | outFile: inFile, 43 | }, 44 | { 45 | name: "read file", 46 | stdin: "", 47 | inFile: inFile, 48 | filename: "test", 49 | update: false, 50 | ok: true, 51 | stdout: ` 52 | a0 = v3 53 | a2 = v2 54 | `, 55 | stderr: "", 56 | outFile: inFile, 57 | }, 58 | { 59 | name: "update file", 60 | stdin: "", 61 | inFile: inFile, 62 | filename: "test", 63 | update: true, 64 | ok: true, 65 | stdout: "", 66 | stderr: "", 67 | outFile: ` 68 | a0 = v3 69 | a2 = v2 70 | `, 71 | }, 72 | } 73 | 74 | for _, tc := range cases { 75 | t.Run(tc.name, func(t *testing.T) { 76 | path := setupTestFile(t, tc.inFile) 77 | inStream := bytes.NewBufferString(tc.stdin) 78 | outStream := new(bytes.Buffer) 79 | errStream := new(bytes.Buffer) 80 | o := &Option{ 81 | InStream: inStream, 82 | OutStream: outStream, 83 | ErrStream: errStream, 84 | } 85 | c := NewClient(o) 86 | filename := tc.filename 87 | if filename != "-" { 88 | // A test file is generated at runtime. We don't use tc.filename, and use the generated file. 89 | filename = path 90 | } 91 | err := c.Edit(filename, tc.update, filter) 92 | if tc.ok && err != nil { 93 | t.Errorf("unexpected err = %s", err) 94 | } 95 | 96 | gotStdout := outStream.String() 97 | if !tc.ok && err == nil { 98 | t.Errorf("expected to return an error, but no error, outStream: \n%s", gotStdout) 99 | } 100 | 101 | if gotStdout != tc.stdout { 102 | t.Errorf("unexpected stdout. got:\n%s\nwant:\n%s", gotStdout, tc.stdout) 103 | } 104 | 105 | gotStderr := errStream.String() 106 | if gotStderr != tc.stderr { 107 | t.Errorf("unexpected stderr. got:\n%s\nwant:\n%s", gotStderr, tc.stderr) 108 | } 109 | 110 | gotOutFile := readTestFile(t, path) 111 | if gotOutFile != tc.outFile { 112 | t.Errorf("unexpected outFile. got:\n%s\nwant:\n%s", gotOutFile, tc.outFile) 113 | } 114 | }) 115 | } 116 | } 117 | 118 | func TestClientDerive(t *testing.T) { 119 | stdin := ` 120 | a0 = v0 121 | a1 = v1 122 | ` 123 | inFile := ` 124 | a0 = v3 125 | a2 = v2 126 | ` 127 | sink := NewAttributeGetSink("a0", false) 128 | 129 | cases := []struct { 130 | name string 131 | stdin string 132 | inFile string 133 | filename string 134 | ok bool 135 | stdout string 136 | stderr string 137 | outFile string 138 | }{ 139 | { 140 | name: "stdin", 141 | stdin: stdin, 142 | inFile: inFile, 143 | filename: "-", 144 | ok: true, 145 | stdout: "v0\n", 146 | stderr: "", 147 | outFile: inFile, 148 | }, 149 | { 150 | name: "read file", 151 | stdin: "", 152 | inFile: inFile, 153 | filename: "test", 154 | ok: true, 155 | stdout: "v3\n", 156 | stderr: "", 157 | outFile: inFile, 158 | }, 159 | } 160 | 161 | for _, tc := range cases { 162 | t.Run(tc.name, func(t *testing.T) { 163 | path := setupTestFile(t, tc.inFile) 164 | inStream := bytes.NewBufferString(tc.stdin) 165 | outStream := new(bytes.Buffer) 166 | errStream := new(bytes.Buffer) 167 | o := &Option{ 168 | InStream: inStream, 169 | OutStream: outStream, 170 | ErrStream: errStream, 171 | } 172 | c := NewClient(o) 173 | filename := tc.filename 174 | if filename != "-" { 175 | // A test file is generated at runtime. We don't use tc.filename, and use the generated file. 176 | filename = path 177 | } 178 | err := c.Derive(filename, sink) 179 | if tc.ok && err != nil { 180 | t.Errorf("unexpected err = %s", err) 181 | } 182 | 183 | gotStdout := outStream.String() 184 | if !tc.ok && err == nil { 185 | t.Errorf("expected to return an error, but no error, outStream: \n%s", gotStdout) 186 | } 187 | 188 | if gotStdout != tc.stdout { 189 | t.Errorf("unexpected stdout. got:\n%s\nwant:\n%s", gotStdout, tc.stdout) 190 | } 191 | 192 | gotStderr := errStream.String() 193 | if gotStderr != tc.stderr { 194 | t.Errorf("unexpected stderr. got:\n%s\nwant:\n%s", gotStderr, tc.stderr) 195 | } 196 | 197 | gotOutFile := readTestFile(t, path) 198 | if gotOutFile != tc.outFile { 199 | t.Errorf("unexpected outFile. got:\n%s\nwant:\n%s", gotOutFile, tc.outFile) 200 | } 201 | }) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /editor/filter_attribute_append.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2/hclwrite" 7 | ) 8 | 9 | // AttributeAppendFilter is a filter implementation for appending attribute. 10 | type AttributeAppendFilter struct { 11 | address string 12 | value string 13 | newline bool 14 | } 15 | 16 | var _ Filter = (*AttributeAppendFilter)(nil) 17 | 18 | // NewAttributeAppendFilter creates a new instance of AttributeAppendFilter. 19 | func NewAttributeAppendFilter(address string, value string, newline bool) Filter { 20 | return &AttributeAppendFilter{ 21 | address: address, 22 | value: value, 23 | newline: newline, 24 | } 25 | } 26 | 27 | // Filter reads HCL and appends a new attribute to a given address. 28 | // If a matched block not found, nothing happens. 29 | // If the given attribute already exists, it returns an error. 30 | // If a newline flag is true, it also appends a newline before the new attribute. 31 | func (f *AttributeAppendFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 32 | attrName := f.address 33 | body := inFile.Body() 34 | 35 | a := createAddressFromString(f.address) 36 | if len(a) > 1 { 37 | // if address contains dots, the last element is an attribute name, 38 | // and the rest is the address of the block. 39 | attrName = a[len(a)-1] 40 | blockAddr := createStringFromAddress(a[:len(a)-1]) 41 | blocks, err := findLongestMatchingBlocks(body, blockAddr) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | if len(blocks) == 0 { 47 | // not found 48 | return inFile, nil 49 | } 50 | 51 | // Use first matching one. 52 | body = blocks[0].Body() 53 | if body.GetAttribute(attrName) != nil { 54 | return nil, fmt.Errorf("attribute already exists: %s", f.address) 55 | } 56 | } 57 | 58 | // To delegate expression parsing to the hclwrite parser, 59 | // We build a new expression and set back to the attribute by tokens. 60 | expr, err := buildExpression(attrName, f.value) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | if f.newline { 66 | body.AppendNewline() 67 | } 68 | body.SetAttributeRaw(attrName, expr.BuildTokens(nil)) 69 | 70 | return inFile, nil 71 | } 72 | -------------------------------------------------------------------------------- /editor/filter_attribute_append_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestAttributeAppendFilter(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | address string 12 | value string 13 | newline bool 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple top level attribute", 19 | src: ` 20 | a0 = v0 21 | `, 22 | address: "a1", 23 | value: "v1", 24 | newline: false, 25 | ok: true, 26 | want: ` 27 | a0 = v0 28 | a1 = v1 29 | `, 30 | }, 31 | { 32 | name: "attribute in block", 33 | src: ` 34 | a0 = v0 35 | b1 "l1" { 36 | a1 = v1 37 | } 38 | `, 39 | address: "b1.l1.a2", 40 | value: "v2", 41 | newline: false, 42 | ok: true, 43 | want: ` 44 | a0 = v0 45 | b1 "l1" { 46 | a1 = v1 47 | a2 = v2 48 | } 49 | `, 50 | }, 51 | { 52 | name: "attribute in block (with newline)", 53 | src: ` 54 | a0 = v0 55 | b1 "l1" { 56 | a1 = v1 57 | } 58 | `, 59 | address: "b1.l1.a2", 60 | value: "v2", 61 | newline: true, 62 | ok: true, 63 | want: ` 64 | a0 = v0 65 | b1 "l1" { 66 | a1 = v1 67 | 68 | a2 = v2 69 | } 70 | `, 71 | }, 72 | { 73 | name: "block not found (noop)", 74 | src: ` 75 | a0 = v0 76 | b1 "l1" { 77 | a1 = v1 78 | } 79 | `, 80 | address: "b2.l1.a1", 81 | value: "v2", 82 | newline: false, 83 | ok: true, 84 | want: ` 85 | a0 = v0 86 | b1 "l1" { 87 | a1 = v1 88 | } 89 | `, 90 | }, 91 | { 92 | name: "attribute already exists (error)", 93 | src: ` 94 | a0 = v0 95 | b1 "l1" { 96 | a1 = v1 97 | } 98 | `, 99 | address: "b1.l1.a1", 100 | value: "v2", 101 | newline: false, 102 | ok: false, 103 | want: ``, 104 | }, 105 | { 106 | name: "escaped address", 107 | src: ` 108 | a0 = v0 109 | b1 "l.1" { 110 | a1 = v1 111 | } 112 | `, 113 | address: `b1.l\.1.a2`, 114 | value: "v2", 115 | newline: false, 116 | ok: true, 117 | want: ` 118 | a0 = v0 119 | b1 "l.1" { 120 | a1 = v1 121 | a2 = v2 122 | } 123 | `, 124 | }, 125 | } 126 | 127 | for _, tc := range cases { 128 | t.Run(tc.name, func(t *testing.T) { 129 | o := NewEditOperator(NewAttributeAppendFilter(tc.address, tc.value, tc.newline)) 130 | output, err := o.Apply([]byte(tc.src), "test") 131 | if tc.ok && err != nil { 132 | t.Fatalf("unexpected err = %s", err) 133 | } 134 | 135 | got := string(output) 136 | if !tc.ok && err == nil { 137 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 138 | } 139 | 140 | if got != tc.want { 141 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 142 | } 143 | }) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /editor/filter_attribute_remove.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/hashicorp/hcl/v2/hclwrite" 7 | ) 8 | 9 | // AttributeRemoveFilter is a filter implementation for removing attribute. 10 | type AttributeRemoveFilter struct { 11 | address string 12 | } 13 | 14 | var _ Filter = (*AttributeRemoveFilter)(nil) 15 | 16 | // NewAttributeRemoveFilter creates a new instance of AttributeRemoveFilter. 17 | func NewAttributeRemoveFilter(address string) Filter { 18 | return &AttributeRemoveFilter{ 19 | address: address, 20 | } 21 | } 22 | 23 | // Filter reads HCL and remove a matched attribute at a given address. 24 | func (f *AttributeRemoveFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 25 | attr, body, err := findAttribute(inFile.Body(), f.address) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | if attr != nil { 31 | a := strings.Split(f.address, ".") 32 | attrName := a[len(a)-1] 33 | body.RemoveAttribute(attrName) 34 | } 35 | 36 | return inFile, nil 37 | } 38 | -------------------------------------------------------------------------------- /editor/filter_attribute_remove_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestAttributeRemoveFilter(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | address string 12 | ok bool 13 | want string 14 | }{ 15 | { 16 | name: "simple top level attribute", 17 | src: ` 18 | a0 = v0 19 | a1 = v1 20 | `, 21 | address: "a0", 22 | ok: true, 23 | want: ` 24 | a1 = v1 25 | `, 26 | }, 27 | { 28 | name: "simple top level attribute (with comments)", 29 | src: ` 30 | // before attr 31 | a0 = "v0" // inline 32 | a1 = "v1" 33 | `, 34 | address: "a0", 35 | ok: true, 36 | want: ` 37 | a1 = "v1" 38 | `, // Unfortunately we can't keep the before attr comment. 39 | }, 40 | { 41 | name: "attribute in block", 42 | src: ` 43 | a0 = v0 44 | b1 "l1" { 45 | a1 = v1 46 | a2 = v2 47 | } 48 | `, 49 | address: "b1.l1.a1", 50 | ok: true, 51 | want: ` 52 | a0 = v0 53 | b1 "l1" { 54 | a2 = v2 55 | } 56 | `, 57 | }, 58 | { 59 | name: "top level attribute not found", 60 | src: ` 61 | a0 = v0 62 | `, 63 | address: "a1", 64 | ok: true, 65 | want: ` 66 | a0 = v0 67 | `, 68 | }, 69 | { 70 | name: "attribute not found in block", 71 | src: ` 72 | a0 = v0 73 | b1 "l1" { 74 | a1 = v1 75 | } 76 | `, 77 | address: "b1.l1.a2", 78 | ok: true, 79 | want: ` 80 | a0 = v0 81 | b1 "l1" { 82 | a1 = v1 83 | } 84 | `, 85 | }, 86 | { 87 | name: "block not found", 88 | src: ` 89 | a0 = v0 90 | b1 "l1" { 91 | a1 = v1 92 | } 93 | `, 94 | address: "b2.l1.a1", 95 | ok: true, 96 | want: ` 97 | a0 = v0 98 | b1 "l1" { 99 | a1 = v1 100 | } 101 | `, 102 | }, 103 | { 104 | name: "escaped address", 105 | src: ` 106 | a0 = v0 107 | b1 "l.1" { 108 | a1 = v1 109 | a2 = v2 110 | } 111 | `, 112 | address: `b1.l\.1.a1`, 113 | ok: true, 114 | want: ` 115 | a0 = v0 116 | b1 "l.1" { 117 | a2 = v2 118 | } 119 | `, 120 | }, 121 | } 122 | 123 | for _, tc := range cases { 124 | t.Run(tc.name, func(t *testing.T) { 125 | o := NewEditOperator(NewAttributeRemoveFilter(tc.address)) 126 | output, err := o.Apply([]byte(tc.src), "test") 127 | if tc.ok && err != nil { 128 | t.Fatalf("unexpected err = %s", err) 129 | } 130 | 131 | got := string(output) 132 | if !tc.ok && err == nil { 133 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 134 | } 135 | 136 | if got != tc.want { 137 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 138 | } 139 | }) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /editor/filter_attribute_rename.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2/hclwrite" 7 | ) 8 | 9 | // AttributeRenameFilter is a filter implementation for renaming attribute. 10 | type AttributeRenameFilter struct { 11 | from string 12 | to string 13 | } 14 | 15 | var _ Filter = (*AttributeRenameFilter)(nil) 16 | 17 | // NewAttributeRenameFilter creates a new instance of AttributeRenameFilter. 18 | func NewAttributeRenameFilter(from string, to string) Filter { 19 | return &AttributeRenameFilter{ 20 | from: from, 21 | to: to, 22 | } 23 | } 24 | 25 | // Filter reads HCL and renames matched an attribute at a given address. 26 | // The current implementation does not allow moving an attribute across blocks, 27 | // but it accepts addresses as arguments, which allows for future extensions. 28 | func (f *AttributeRenameFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 29 | fromAttr, fromBody, err := findAttribute(inFile.Body(), f.from) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | if fromAttr != nil { 35 | fromBlockAddress, fromAttributeName, err := parseAttributeAddress(f.from) 36 | if err != nil { 37 | return nil, err 38 | } 39 | toBlockAddress, toAttributeName, err := parseAttributeAddress(f.to) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | if fromBlockAddress == toBlockAddress { 45 | // The Body.RenameAttribute() returns false if fromName does not exist or 46 | // toName already exists. However, here, we want to return an error only 47 | // if toName already exists, so we check it ourselves. 48 | toAttr := fromBody.GetAttribute(toAttributeName) 49 | if toAttr != nil { 50 | return nil, fmt.Errorf("attribute already exists: %s", f.to) 51 | } 52 | 53 | _ = fromBody.RenameAttribute(fromAttributeName, toAttributeName) 54 | } else { 55 | return nil, fmt.Errorf("moving an attribute across blocks has not been implemented yet: %s -> %s", f.from, f.to) 56 | } 57 | } 58 | 59 | return inFile, nil 60 | } 61 | -------------------------------------------------------------------------------- /editor/filter_attribute_rename_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestAttributeRenameFilter(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | from string 12 | to string 13 | ok bool 14 | want string 15 | }{ 16 | { 17 | name: "simple", 18 | src: ` 19 | a0 = v0 20 | a1 = v1 21 | `, 22 | from: "a0", 23 | to: "a2", 24 | ok: true, 25 | want: ` 26 | a2 = v0 27 | a1 = v1 28 | `, 29 | }, 30 | { 31 | name: "with comments", 32 | src: ` 33 | # before attr 34 | a0 = "v0" # inline 35 | a1 = "v1" 36 | `, 37 | from: "a0", 38 | to: "a2", 39 | ok: true, 40 | want: ` 41 | # before attr 42 | a2 = "v0" # inline 43 | a1 = "v1" 44 | `, 45 | }, 46 | { 47 | name: "attribute in block", 48 | src: ` 49 | a0 = v0 50 | b1 "l1" { 51 | a1 = v1 52 | } 53 | `, 54 | from: "b1.l1.a1", 55 | to: "b1.l1.a2", 56 | ok: true, 57 | want: ` 58 | a0 = v0 59 | b1 "l1" { 60 | a2 = v1 61 | } 62 | `, 63 | }, 64 | { 65 | name: "not found", 66 | src: ` 67 | a0 = v0 68 | `, 69 | from: "a1", 70 | to: "a2", 71 | ok: true, 72 | want: ` 73 | a0 = v0 74 | `, 75 | }, 76 | { 77 | name: "attribute not found in block", 78 | src: ` 79 | a0 = v0 80 | b1 "l1" { 81 | a1 = v1 82 | } 83 | `, 84 | from: "b1.l1.a2", 85 | to: "b1.l1.a3", 86 | ok: true, 87 | want: ` 88 | a0 = v0 89 | b1 "l1" { 90 | a1 = v1 91 | } 92 | `, 93 | }, 94 | { 95 | name: "block not found", 96 | src: ` 97 | a0 = v0 98 | b1 "l1" { 99 | a1 = v1 100 | } 101 | `, 102 | from: "b2.l1.a1", 103 | to: "b2.l1.a2", 104 | ok: true, 105 | want: ` 106 | a0 = v0 107 | b1 "l1" { 108 | a1 = v1 109 | } 110 | `, 111 | }, 112 | { 113 | name: "attribute already exists", 114 | src: ` 115 | a0 = v0 116 | a1 = v1 117 | `, 118 | from: "a0", 119 | to: "a1", 120 | ok: false, 121 | want: "", 122 | }, 123 | { 124 | name: "moving an attribute across blocks", 125 | src: ` 126 | a0 = v0 127 | b1 "l1" { 128 | a1 = v1 129 | } 130 | `, 131 | from: "a0", 132 | to: "b1.l1.a0", 133 | ok: false, 134 | want: "", 135 | }, 136 | } 137 | 138 | for _, tc := range cases { 139 | t.Run(tc.name, func(t *testing.T) { 140 | o := NewEditOperator(NewAttributeRenameFilter(tc.from, tc.to)) 141 | output, err := o.Apply([]byte(tc.src), "test") 142 | if tc.ok && err != nil { 143 | t.Fatalf("unexpected err = %s", err) 144 | } 145 | 146 | got := string(output) 147 | if !tc.ok && err == nil { 148 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 149 | } 150 | 151 | if got != tc.want { 152 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 153 | } 154 | }) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /editor/filter_attribute_replace.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2/hclwrite" 7 | ) 8 | 9 | // AttributeReplaceFilter is a filter implementation for replacing attribute. 10 | type AttributeReplaceFilter struct { 11 | address string 12 | name string 13 | value string 14 | } 15 | 16 | var _ Filter = (*AttributeReplaceFilter)(nil) 17 | 18 | // NewAttributeReplaceFilter creates a new instance of AttributeReplaceFilter. 19 | func NewAttributeReplaceFilter(address string, name string, value string) Filter { 20 | return &AttributeReplaceFilter{ 21 | address: address, 22 | name: name, 23 | value: value, 24 | } 25 | } 26 | 27 | // Filter reads HCL and replaces both the name and value of matched an 28 | // attribute at a given address. 29 | func (f *AttributeReplaceFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 30 | attr, body, err := findAttribute(inFile.Body(), f.address) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | if attr != nil { 36 | _, fromAttributeName, err := parseAttributeAddress(f.address) 37 | if err != nil { 38 | return nil, err 39 | } 40 | toAttributeName := f.name 41 | 42 | // The Body.RenameAttribute() returns false if fromName does not exist or 43 | // toName already exists. However, here, we want to return an error only 44 | // if toName already exists, so we check it ourselves. 45 | toAttr := body.GetAttribute(toAttributeName) 46 | if toAttr != nil { 47 | return nil, fmt.Errorf("attribute already exists: %s", toAttributeName) 48 | } 49 | 50 | _ = body.RenameAttribute(fromAttributeName, toAttributeName) 51 | 52 | // To delegate expression parsing to the hclwrite parser, 53 | // We build a new expression and set back to the attribute by tokens. 54 | expr, err := buildExpression(toAttributeName, f.value) 55 | if err != nil { 56 | return nil, err 57 | } 58 | body.SetAttributeRaw(toAttributeName, expr.BuildTokens(nil)) 59 | } 60 | 61 | return inFile, nil 62 | } 63 | -------------------------------------------------------------------------------- /editor/filter_attribute_replace_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestAttributeReplaceFilter(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | address string 12 | toName string 13 | toValue string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple", 19 | src: ` 20 | a0 = v0 21 | a1 = v1 22 | `, 23 | address: "a0", 24 | toName: "a2", 25 | toValue: "v2", 26 | ok: true, 27 | want: ` 28 | a2 = v2 29 | a1 = v1 30 | `, 31 | }, 32 | { 33 | name: "with comments", 34 | src: ` 35 | # before attr 36 | a0 = "v0" # inline 37 | a1 = "v1" 38 | `, 39 | address: "a0", 40 | toName: "a2", 41 | toValue: `"v2"`, 42 | ok: true, 43 | want: ` 44 | # before attr 45 | a2 = "v2" # inline 46 | a1 = "v1" 47 | `, 48 | }, 49 | { 50 | name: "attribute in block", 51 | src: ` 52 | a0 = v0 53 | b1 "l1" { 54 | a1 = v1 55 | } 56 | `, 57 | address: "b1.l1.a1", 58 | toName: "a2", 59 | toValue: "v2", 60 | ok: true, 61 | want: ` 62 | a0 = v0 63 | b1 "l1" { 64 | a2 = v2 65 | } 66 | `, 67 | }, 68 | { 69 | name: "not found", 70 | src: ` 71 | a0 = v0 72 | `, 73 | address: "a1", 74 | toName: "a2", 75 | toValue: "v2", 76 | ok: true, 77 | want: ` 78 | a0 = v0 79 | `, 80 | }, 81 | { 82 | name: "attribute not found in block", 83 | src: ` 84 | a0 = v0 85 | b1 "l1" { 86 | a1 = v1 87 | } 88 | `, 89 | address: "b1.l1.a2", 90 | toName: "a3", 91 | toValue: "v3", 92 | ok: true, 93 | want: ` 94 | a0 = v0 95 | b1 "l1" { 96 | a1 = v1 97 | } 98 | `, 99 | }, 100 | { 101 | name: "block not found", 102 | src: ` 103 | a0 = v0 104 | b1 "l1" { 105 | a1 = v1 106 | } 107 | `, 108 | address: "b2.l1.a1", 109 | toName: "a2", 110 | toValue: "v2", 111 | ok: true, 112 | want: ` 113 | a0 = v0 114 | b1 "l1" { 115 | a1 = v1 116 | } 117 | `, 118 | }, 119 | { 120 | name: "attribute already exists", 121 | src: ` 122 | a0 = v0 123 | a1 = v1 124 | `, 125 | address: "a0", 126 | toName: "a1", 127 | toValue: "v2", 128 | ok: false, 129 | want: "", 130 | }, 131 | } 132 | 133 | for _, tc := range cases { 134 | t.Run(tc.name, func(t *testing.T) { 135 | o := NewEditOperator(NewAttributeReplaceFilter(tc.address, tc.toName, tc.toValue)) 136 | output, err := o.Apply([]byte(tc.src), "test") 137 | if tc.ok && err != nil { 138 | t.Fatalf("unexpected err = %s", err) 139 | } 140 | 141 | got := string(output) 142 | if !tc.ok && err == nil { 143 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 144 | } 145 | 146 | if got != tc.want { 147 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 148 | } 149 | }) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /editor/filter_attribute_set.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/hashicorp/hcl/v2" 8 | "github.com/hashicorp/hcl/v2/hclwrite" 9 | ) 10 | 11 | // AttributeSetFilter is a filter implementation for setting attribute. 12 | type AttributeSetFilter struct { 13 | address string 14 | value string 15 | } 16 | 17 | var _ Filter = (*AttributeSetFilter)(nil) 18 | 19 | // NewAttributeSetFilter creates a new instance of AttributeSetFilter. 20 | func NewAttributeSetFilter(address string, value string) Filter { 21 | return &AttributeSetFilter{ 22 | address: address, 23 | value: value, 24 | } 25 | } 26 | 27 | // Filter reads HCL and updates a value of matched an attribute at a given address. 28 | func (f *AttributeSetFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 29 | attr, body, err := findAttribute(inFile.Body(), f.address) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | if attr != nil { 35 | a := strings.Split(f.address, ".") 36 | attrName := a[len(a)-1] 37 | 38 | // To delegate expression parsing to the hclwrite parser, 39 | // We build a new expression and set back to the attribute by tokens. 40 | expr, err := buildExpression(attrName, f.value) 41 | if err != nil { 42 | return nil, err 43 | } 44 | body.SetAttributeRaw(attrName, expr.BuildTokens(nil)) 45 | } 46 | 47 | return inFile, nil 48 | } 49 | 50 | // buildExpression returns a new expressions for a given name and value of attribute. 51 | // At the time of wrting this, there is no way to parse expression from string. 52 | // So we generate a temporarily config on memory and parse it, and extract a generated expression. 53 | func buildExpression(name string, value string) (*hclwrite.Expression, error) { 54 | src := name + " = " + value 55 | f, err := safeParseConfig([]byte(src), "generated_by_buildExpression", hcl.Pos{Line: 1, Column: 1}) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to build expression at the parse phase: %s", err) 58 | } 59 | 60 | attr := f.Body().GetAttribute(name) 61 | if attr == nil { 62 | return nil, fmt.Errorf("failed to build expression at the get phase. name = %s, value = %s", name, value) 63 | } 64 | 65 | return attr.Expr(), nil 66 | } 67 | -------------------------------------------------------------------------------- /editor/filter_attribute_set_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestAttributeSetFilter(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | address string 12 | value string 13 | ok bool 14 | want string 15 | }{ 16 | { 17 | name: "simple top level attribute (reference)", 18 | src: ` 19 | a0 = v0 20 | a1 = v1 21 | `, 22 | address: "a0", 23 | value: "v2", 24 | ok: true, 25 | want: ` 26 | a0 = v2 27 | a1 = v1 28 | `, 29 | }, 30 | { 31 | name: "simple top level attribute (string literal)", 32 | src: ` 33 | a0 = "v0" 34 | a1 = "v1" 35 | `, 36 | address: "a0", 37 | value: `"v2"`, 38 | ok: true, 39 | want: ` 40 | a0 = "v2" 41 | a1 = "v1" 42 | `, 43 | }, 44 | { 45 | name: "simple top level attribute (number literal)", 46 | src: ` 47 | a0 = 0 48 | a1 = 1 49 | `, 50 | address: "a0", 51 | value: "2", 52 | ok: true, 53 | want: ` 54 | a0 = 2 55 | a1 = 1 56 | `, 57 | }, 58 | { 59 | name: "simple top level attribute (bool literal)", 60 | src: ` 61 | a0 = true 62 | a1 = true 63 | `, 64 | address: "a0", 65 | value: "false", 66 | ok: true, 67 | want: ` 68 | a0 = false 69 | a1 = true 70 | `, 71 | }, 72 | { 73 | name: "simple top level attribute (with comments)", 74 | src: ` 75 | // before attr 76 | a0 = "v0" // inline 77 | a1 = "v1" 78 | `, 79 | address: "a0", 80 | value: `"v2"`, 81 | ok: true, 82 | want: ` 83 | // before attr 84 | a0 = "v2" // inline 85 | a1 = "v1" 86 | `, 87 | }, 88 | { 89 | name: "attribute in block", 90 | src: ` 91 | a0 = v0 92 | b1 "l1" { 93 | a1 = v1 94 | } 95 | `, 96 | address: "b1.l1.a1", 97 | value: "v2", 98 | ok: true, 99 | want: ` 100 | a0 = v0 101 | b1 "l1" { 102 | a1 = v2 103 | } 104 | `, 105 | }, 106 | { 107 | name: "top level attribute not found", 108 | src: ` 109 | a0 = v0 110 | `, 111 | address: "a1", 112 | value: "v2", 113 | ok: true, 114 | want: ` 115 | a0 = v0 116 | `, 117 | }, 118 | { 119 | name: "attribute not found in block", 120 | src: ` 121 | a0 = v0 122 | b1 "l1" { 123 | a1 = v1 124 | } 125 | `, 126 | address: "b1.l1.a2", 127 | value: "v2", 128 | ok: true, 129 | want: ` 130 | a0 = v0 131 | b1 "l1" { 132 | a1 = v1 133 | } 134 | `, 135 | }, 136 | { 137 | name: "block not found", 138 | src: ` 139 | a0 = v0 140 | b1 "l1" { 141 | a1 = v1 142 | } 143 | `, 144 | address: "b2.l1.a1", 145 | value: "v2", 146 | ok: true, 147 | want: ` 148 | a0 = v0 149 | b1 "l1" { 150 | a1 = v1 151 | } 152 | `, 153 | }, 154 | { 155 | name: "escaped address", 156 | src: ` 157 | a0 = v0 158 | b1 "l.1" { 159 | a1 = v1 160 | } 161 | `, 162 | address: `b1.l\.1.a1`, 163 | value: "v2", 164 | ok: true, 165 | want: ` 166 | a0 = v0 167 | b1 "l.1" { 168 | a1 = v2 169 | } 170 | `, 171 | }, 172 | } 173 | 174 | for _, tc := range cases { 175 | t.Run(tc.name, func(t *testing.T) { 176 | o := NewEditOperator(NewAttributeSetFilter(tc.address, tc.value)) 177 | output, err := o.Apply([]byte(tc.src), "test") 178 | if tc.ok && err != nil { 179 | t.Fatalf("unexpected err = %s", err) 180 | } 181 | 182 | got := string(output) 183 | if !tc.ok && err == nil { 184 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 185 | } 186 | 187 | if got != tc.want { 188 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 189 | } 190 | }) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /editor/filter_block_append.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2/hclwrite" 7 | ) 8 | 9 | // BlockAppendFilter is a filter implementation for appending block. 10 | type BlockAppendFilter struct { 11 | parent string 12 | child string 13 | newline bool 14 | } 15 | 16 | var _ Filter = (*BlockAppendFilter)(nil) 17 | 18 | // NewBlockAppendFilter creates a new instance of BlockAppendFilter. 19 | func NewBlockAppendFilter(parent string, child string, newline bool) Filter { 20 | return &BlockAppendFilter{ 21 | parent: parent, 22 | child: child, 23 | newline: newline, 24 | } 25 | } 26 | 27 | // Filter reads HCL and appends only matched blocks at a given address. 28 | // The child address is relative to parent one. 29 | // If a newline flag is true, it also appends a newline before the new block. 30 | func (f *BlockAppendFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 31 | pTypeName, pLabels, err := parseAddress(f.parent) 32 | if err != nil { 33 | return nil, fmt.Errorf("failed to parse parent address: %s", err) 34 | } 35 | 36 | cTypeName, cLabels, err := parseAddress(f.child) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to parse child address: %s", err) 39 | } 40 | 41 | matched := findBlocks(inFile.Body(), pTypeName, pLabels) 42 | 43 | for _, b := range matched { 44 | if f.newline { 45 | b.Body().AppendNewline() 46 | } 47 | b.Body().AppendNewBlock(cTypeName, cLabels) 48 | } 49 | 50 | return inFile, nil 51 | } 52 | -------------------------------------------------------------------------------- /editor/filter_block_append_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBlockAppendFilter(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | parent string 12 | child string 13 | newline bool 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple", 19 | src: ` 20 | a0 = v0 21 | b1 { 22 | a2 = v2 23 | } 24 | 25 | b2 l1 { 26 | } 27 | `, 28 | parent: "b1", 29 | child: "b11", 30 | newline: false, 31 | ok: true, 32 | want: ` 33 | a0 = v0 34 | b1 { 35 | a2 = v2 36 | b11 { 37 | } 38 | } 39 | 40 | b2 l1 { 41 | } 42 | `, 43 | }, 44 | { 45 | name: "no match", 46 | src: ` 47 | a0 = v0 48 | b1 { 49 | a2 = v2 50 | } 51 | 52 | b2 l1 { 53 | } 54 | `, 55 | parent: "not_found", 56 | child: "b11", 57 | newline: false, 58 | ok: true, 59 | want: ` 60 | a0 = v0 61 | b1 { 62 | a2 = v2 63 | } 64 | 65 | b2 l1 { 66 | } 67 | `, 68 | }, 69 | { 70 | name: "empty", 71 | parent: "", 72 | child: "b11", 73 | newline: false, 74 | ok: false, 75 | want: "", 76 | }, 77 | { 78 | name: "with label", 79 | src: ` 80 | a0 = v0 81 | b1 { 82 | a2 = v2 83 | } 84 | 85 | b1 l1 { 86 | } 87 | `, 88 | parent: "b1.l1", 89 | child: "b11.l11.l12", 90 | newline: false, 91 | ok: true, 92 | want: ` 93 | a0 = v0 94 | b1 { 95 | a2 = v2 96 | } 97 | 98 | b1 l1 { 99 | b11 "l11" "l12" { 100 | } 101 | } 102 | `, 103 | }, 104 | { 105 | name: "multi blocks", 106 | src: ` 107 | b1 { 108 | } 109 | 110 | b1 l1 { 111 | } 112 | 113 | b1 l1 { 114 | } 115 | `, 116 | parent: "b1.l1", 117 | child: "b11.l11.l12", 118 | newline: false, 119 | ok: true, 120 | want: ` 121 | b1 { 122 | } 123 | 124 | b1 l1 { 125 | b11 "l11" "l12" { 126 | } 127 | } 128 | 129 | b1 l1 { 130 | b11 "l11" "l12" { 131 | } 132 | } 133 | `, 134 | }, 135 | { 136 | name: "append newline", 137 | src: ` 138 | b1 { 139 | a1 = v1 140 | } 141 | `, 142 | parent: "b1", 143 | child: "b11", 144 | newline: true, 145 | ok: true, 146 | want: ` 147 | b1 { 148 | a1 = v1 149 | 150 | b11 { 151 | } 152 | } 153 | `, 154 | }, 155 | { 156 | name: "escaped address", 157 | src: ` 158 | a0 = v0 159 | b1 { 160 | a2 = v2 161 | } 162 | 163 | b1 "l.1" { 164 | } 165 | `, 166 | parent: `b1.l\.1`, 167 | child: `b11.l\.1.l12`, 168 | newline: false, 169 | ok: true, 170 | want: ` 171 | a0 = v0 172 | b1 { 173 | a2 = v2 174 | } 175 | 176 | b1 "l.1" { 177 | b11 "l.1" "l12" { 178 | } 179 | } 180 | `, 181 | }, 182 | } 183 | 184 | for _, tc := range cases { 185 | t.Run(tc.name, func(t *testing.T) { 186 | o := NewEditOperator(NewBlockAppendFilter(tc.parent, tc.child, tc.newline)) 187 | output, err := o.Apply([]byte(tc.src), "test") 188 | if tc.ok && err != nil { 189 | t.Fatalf("unexpected err = %s", err) 190 | } 191 | 192 | got := string(output) 193 | if !tc.ok && err == nil { 194 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 195 | } 196 | 197 | if got != tc.want { 198 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 199 | } 200 | }) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /editor/filter_block_get.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/hashicorp/hcl/v2/hclwrite" 8 | ) 9 | 10 | // BlockGetFilter is a filter implementation for getting block. 11 | type BlockGetFilter struct { 12 | address string 13 | } 14 | 15 | var _ Filter = (*BlockGetFilter)(nil) 16 | 17 | // NewBlockGetFilter creates a new instance of BlockGetFilter. 18 | func NewBlockGetFilter(address string) Filter { 19 | return &BlockGetFilter{ 20 | address: address, 21 | } 22 | } 23 | 24 | // Filter reads HCL and writes only matched blocks at a given address. 25 | func (f *BlockGetFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 26 | typeName, labels, err := parseAddress(f.address) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | // find the top-level blocks first. 32 | matched := findBlocks(inFile.Body(), typeName, labels) 33 | if len(matched) == 0 { 34 | // If not found, then find nested blocks. 35 | // I'll reuse the findLongestMatchingBlocks to implement it as a compromise for now, 36 | // but it doesn't support the wildcard match. There is a bit inconsistency here. 37 | // To fix it, we will need to merge implementations of findBlocks and findLongestMatchingBlocks. 38 | matched, err = findLongestMatchingBlocks(inFile.Body(), f.address) 39 | if err != nil { 40 | return nil, err 41 | } 42 | } 43 | 44 | outFile := hclwrite.NewEmptyFile() 45 | for i, b := range matched { 46 | if i != 0 { 47 | // when adding a new block, insert a new line before the block. 48 | outFile.Body().AppendNewline() 49 | } 50 | outFile.Body().AppendBlock(b) 51 | 52 | } 53 | 54 | return outFile, nil 55 | } 56 | 57 | func parseAddress(address string) (string, []string, error) { 58 | if len(address) == 0 { 59 | return "", []string{}, fmt.Errorf("failed to parse address: %s", address) 60 | } 61 | 62 | a := createAddressFromString(address) 63 | typeName := a[0] 64 | labels := []string{} 65 | if len(a) > 1 { 66 | labels = a[1:] 67 | } 68 | return typeName, labels, nil 69 | } 70 | 71 | // parseAttributeAddress parses the address of an attribute and splits it into 72 | // the block address it belongs to and the attribute name. 73 | func parseAttributeAddress(address string) (string, string, error) { 74 | if len(address) == 0 { 75 | return "", "", fmt.Errorf("failed to parse attribute address: %s", address) 76 | } 77 | 78 | parts := strings.Split(address, ".") 79 | blockAddress := strings.Join(parts[:len(parts)-1], ".") 80 | attributeName := parts[len(parts)-1] 81 | 82 | return blockAddress, attributeName, nil 83 | } 84 | 85 | // findBlocks returns matching blocks from the body that have the given name 86 | // and labels or returns an empty list if there is currently no matching block. 87 | // The labels can be wildcard (*), but numbers of label must be equal. 88 | func findBlocks(b *hclwrite.Body, typeName string, labels []string) []*hclwrite.Block { 89 | var matched []*hclwrite.Block 90 | for _, block := range b.Blocks() { 91 | if typeName == block.Type() { 92 | labelNames := block.Labels() 93 | if len(labels) == 0 && len(labelNames) == 0 { 94 | matched = append(matched, block) 95 | continue 96 | } 97 | if matchLabels(labels, labelNames) { 98 | matched = append(matched, block) 99 | } 100 | } 101 | } 102 | 103 | return matched 104 | } 105 | 106 | // matchLabels returns true only if the matched and false otherwise. 107 | // The labels can be wildcard (*), but numbers of label must be equal. 108 | func matchLabels(lhs []string, rhs []string) bool { 109 | if len(lhs) != len(rhs) { 110 | return false 111 | } 112 | 113 | for i := range lhs { 114 | if !(lhs[i] == rhs[i] || lhs[i] == "*" || rhs[i] == "*") { 115 | return false 116 | } 117 | } 118 | 119 | return true 120 | } 121 | -------------------------------------------------------------------------------- /editor/filter_block_get_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBlockGetFilter(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | address string 12 | ok bool 13 | want string 14 | }{ 15 | { 16 | name: "simple", 17 | src: ` 18 | a0 = v0 19 | b1 { 20 | a2 = v2 21 | } 22 | 23 | b2 l1 { 24 | } 25 | `, 26 | address: "b1", 27 | ok: true, 28 | want: `b1 { 29 | a2 = v2 30 | } 31 | `, 32 | }, 33 | { 34 | name: "no match", 35 | address: "hoge", 36 | ok: true, 37 | want: "", 38 | }, 39 | { 40 | name: "empty", 41 | address: "", 42 | ok: false, 43 | want: "", 44 | }, 45 | { 46 | name: "unformatted", 47 | src: ` 48 | b1 { 49 | } 50 | `, 51 | address: "b1", 52 | ok: true, 53 | want: `b1 { 54 | } 55 | `, 56 | }, 57 | { 58 | name: "no label", 59 | src: ` 60 | b1 { 61 | } 62 | 63 | b1 l1 { 64 | } 65 | `, 66 | address: "b1", 67 | ok: true, 68 | want: `b1 { 69 | } 70 | `, 71 | }, 72 | { 73 | name: "with label", 74 | src: ` 75 | b1 { 76 | } 77 | 78 | b1 l1 { 79 | } 80 | `, 81 | address: "b1.l1", 82 | ok: true, 83 | want: `b1 l1 { 84 | } 85 | `, 86 | }, 87 | { 88 | name: "multi blocks", 89 | src: ` 90 | b1 { 91 | } 92 | 93 | b1 l1 { 94 | } 95 | 96 | b1 l1 { 97 | } 98 | `, 99 | address: "b1.l1", 100 | ok: true, 101 | want: `b1 l1 { 102 | } 103 | 104 | b1 l1 { 105 | } 106 | `, 107 | }, 108 | { 109 | name: "get a given block type and any labels", 110 | src: ` 111 | b1 { 112 | } 113 | 114 | b1 l1 { 115 | } 116 | 117 | b1 l2 { 118 | } 119 | `, 120 | address: "b1.*", 121 | ok: true, 122 | want: `b1 l1 { 123 | } 124 | 125 | b1 l2 { 126 | } 127 | `, 128 | }, 129 | { 130 | name: "get a given block type and prefixed labels", 131 | src: ` 132 | b1 { 133 | } 134 | 135 | b1 l1 { 136 | } 137 | 138 | b1 l1 l2 { 139 | } 140 | 141 | b1 l1 l3 { 142 | } 143 | `, 144 | address: "b1.l1.*", 145 | ok: true, 146 | want: `b1 l1 l2 { 147 | } 148 | 149 | b1 l1 l3 { 150 | } 151 | `, 152 | }, 153 | { 154 | name: "preserve comments", 155 | src: `// before block 156 | b1 { 157 | // before attr 158 | attr = val // inline 159 | } 160 | // after block 161 | `, 162 | address: "b1", 163 | ok: true, 164 | want: `// before block 165 | b1 { 166 | // before attr 167 | attr = val // inline 168 | } 169 | `, 170 | }, 171 | { 172 | name: "nested block", 173 | src: ` 174 | b1 { 175 | a1 = v1 176 | b2 { 177 | a2 = v2 178 | } 179 | } 180 | `, 181 | address: "b1.b2", 182 | ok: true, 183 | want: `b2 { 184 | a2 = v2 185 | } 186 | `, 187 | }, 188 | { 189 | name: "nested block (extra labels)", 190 | src: ` 191 | b1 "l1" { 192 | a1 = v1 193 | b2 { 194 | a2 = v2 195 | } 196 | } 197 | `, 198 | address: "b1.b2", 199 | ok: true, 200 | want: "", 201 | }, 202 | { 203 | name: "labels take precedence over nested blocks", 204 | src: ` 205 | b1 "b2" { 206 | a1 = v1 207 | b2 { 208 | a1 = v2 209 | } 210 | } 211 | `, 212 | address: "b1.b2", 213 | ok: true, 214 | want: `b1 "b2" { 215 | a1 = v1 216 | b2 { 217 | a1 = v2 218 | } 219 | } 220 | `, 221 | }, 222 | { 223 | name: "multi level nested block", 224 | src: ` 225 | b1 { 226 | a1 = v1 227 | b2 { 228 | a2 = v2 229 | b3 { 230 | a3 = v3 231 | } 232 | } 233 | } 234 | `, 235 | address: "b1.b2.b3", 236 | ok: true, 237 | want: `b3 { 238 | a3 = v3 239 | } 240 | `, 241 | }, 242 | { 243 | name: "escaped address", 244 | src: ` 245 | b1 "b.2" { 246 | a1 = v1 247 | b2 { 248 | a1 = v2 249 | } 250 | } 251 | `, 252 | address: `b1.b\.2`, 253 | ok: true, 254 | want: `b1 "b.2" { 255 | a1 = v1 256 | b2 { 257 | a1 = v2 258 | } 259 | } 260 | `, 261 | }, 262 | } 263 | 264 | for _, tc := range cases { 265 | t.Run(tc.name, func(t *testing.T) { 266 | o := NewEditOperator(NewBlockGetFilter(tc.address)) 267 | output, err := o.Apply([]byte(tc.src), "test") 268 | if tc.ok && err != nil { 269 | t.Fatalf("unexpected err = %s", err) 270 | } 271 | 272 | got := string(output) 273 | if !tc.ok && err == nil { 274 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 275 | } 276 | 277 | if got != tc.want { 278 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 279 | } 280 | }) 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /editor/filter_block_new.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2/hclwrite" 7 | ) 8 | 9 | // BlockNewFilter is a filter implementation for creating a new block 10 | type BlockNewFilter struct { 11 | address string 12 | newline bool 13 | } 14 | 15 | // Filter reads HCL and creates a new block with the given type and labels. 16 | func (f *BlockNewFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 17 | typeName, labels, err := parseAddress(f.address) 18 | if err != nil { 19 | return nil, fmt.Errorf("failed to parse address: %s", err) 20 | } 21 | 22 | if f.newline { 23 | inFile.Body().AppendNewline() 24 | } 25 | inFile.Body().AppendNewBlock(typeName, labels) 26 | return inFile, nil 27 | } 28 | 29 | var _ Filter = (*BlockNewFilter)(nil) 30 | 31 | func NewBlockNewFilter(address string, newline bool) Filter { 32 | return &BlockNewFilter{ 33 | address: address, 34 | newline: newline, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /editor/filter_block_new_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBlockNewFilter(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | address string 12 | newline bool 13 | want string 14 | }{ 15 | { 16 | name: "block with blockType and 2 labels, resource with newline", 17 | src: ` 18 | variable "var1" { 19 | type = string 20 | default = "foo" 21 | description = "example variable" 22 | } 23 | `, 24 | address: "resource.aws_instance.example", 25 | newline: true, 26 | want: ` 27 | variable "var1" { 28 | type = string 29 | default = "foo" 30 | description = "example variable" 31 | } 32 | 33 | resource "aws_instance" "example" { 34 | } 35 | `, 36 | }, 37 | { 38 | name: "block with blockType and 1 label, module without newline", 39 | src: ` 40 | variable "var1" { 41 | type = string 42 | default = "foo" 43 | description = "example variable" 44 | } 45 | `, 46 | address: "module.example", 47 | newline: false, 48 | want: ` 49 | variable "var1" { 50 | type = string 51 | default = "foo" 52 | description = "example variable" 53 | } 54 | module "example" { 55 | } 56 | `, 57 | }, 58 | { 59 | name: "block with blockType and 0 labels, locals without newline", 60 | src: ` 61 | variable "var1" { 62 | type = string 63 | default = "foo" 64 | description = "example variable" 65 | } 66 | `, 67 | address: "locals", 68 | newline: false, 69 | want: ` 70 | variable "var1" { 71 | type = string 72 | default = "foo" 73 | description = "example variable" 74 | } 75 | locals { 76 | } 77 | `, 78 | }, 79 | } 80 | 81 | for _, tc := range cases { 82 | t.Run(tc.name, func(t *testing.T) { 83 | o := NewEditOperator(NewBlockNewFilter(tc.address, tc.newline)) 84 | output, err := o.Apply([]byte(tc.src), "test") 85 | if err != nil { 86 | t.Fatalf("unexpected err = %s", err) 87 | } 88 | 89 | got := string(output) 90 | if got != tc.want { 91 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 92 | } 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /editor/filter_block_remove.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | ) 6 | 7 | // BlockRemoveFilter is a filter implementation for removing block. 8 | type BlockRemoveFilter struct { 9 | address string 10 | } 11 | 12 | var _ Filter = (*BlockRemoveFilter)(nil) 13 | 14 | // NewBlockRemoveFilter creates a new instance of BlockRemoveFilter. 15 | func NewBlockRemoveFilter(address string) Filter { 16 | return &BlockRemoveFilter{ 17 | address: address, 18 | } 19 | } 20 | 21 | // Filter reads HCL and removes only matched blocks at a given address. 22 | func (f *BlockRemoveFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 23 | m := NewMultiFilter([]Filter{ 24 | &unformattedBlockRemoveFilter{address: f.address}, 25 | &verticalFormatterFilter{}, 26 | }) 27 | return m.Filter(inFile) 28 | } 29 | 30 | // unformattedBlockRemoveFilter is a filter implementation for removing block without formatting. 31 | type unformattedBlockRemoveFilter struct { 32 | address string 33 | } 34 | 35 | var _ Filter = (*unformattedBlockRemoveFilter)(nil) 36 | 37 | // Filter reads HCL and removes only matched blocks at a given address without formatting. 38 | func (f *unformattedBlockRemoveFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 39 | typeName, labels, err := parseAddress(f.address) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | matched := findBlocks(inFile.Body(), typeName, labels) 45 | 46 | for _, b := range matched { 47 | inFile.Body().RemoveBlock(b) 48 | } 49 | 50 | return inFile, nil 51 | } 52 | -------------------------------------------------------------------------------- /editor/filter_block_remove_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBlockRemoveFilter(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | address string 12 | ok bool 13 | want string 14 | }{ 15 | { 16 | name: "simple", 17 | src: ` 18 | a0 = v0 19 | b1 { 20 | a2 = v2 21 | } 22 | 23 | b2 l1 { 24 | } 25 | `, 26 | address: "b1", 27 | ok: true, 28 | want: `a0 = v0 29 | 30 | b2 l1 { 31 | } 32 | `, 33 | }, 34 | { 35 | name: "no match", 36 | src: ` 37 | a0 = v0 38 | b1 { 39 | a2 = v2 40 | } 41 | 42 | b2 l1 { 43 | } 44 | `, 45 | address: "hoge", 46 | ok: true, 47 | want: `a0 = v0 48 | b1 { 49 | a2 = v2 50 | } 51 | 52 | b2 l1 { 53 | } 54 | `, 55 | }, 56 | { 57 | name: "empty", 58 | address: "", 59 | ok: false, 60 | want: "", 61 | }, 62 | { 63 | name: "with label", 64 | src: ` 65 | b1 { 66 | } 67 | 68 | b1 l1 { 69 | } 70 | `, 71 | address: "b1.l1", 72 | ok: true, 73 | want: `b1 { 74 | } 75 | `, 76 | }, 77 | { 78 | name: "multi blocks", 79 | src: ` 80 | b1 { 81 | } 82 | 83 | b1 l1 { 84 | } 85 | 86 | b1 l1 { 87 | } 88 | `, 89 | address: "b1.l1", 90 | ok: true, 91 | want: `b1 { 92 | } 93 | `, 94 | }, 95 | { 96 | name: "get a given block type and prefixed labels", 97 | src: ` 98 | b1 { 99 | } 100 | 101 | b1 l1 { 102 | } 103 | 104 | b1 l1 l2 { 105 | } 106 | 107 | b1 l1 l3 { 108 | } 109 | `, 110 | address: "b1.l1.*", 111 | ok: true, 112 | want: `b1 { 113 | } 114 | 115 | b1 l1 { 116 | } 117 | `, 118 | }, 119 | { 120 | name: "escaped address", 121 | src: ` 122 | b1 { 123 | } 124 | 125 | b1 "l.1" { 126 | } 127 | `, 128 | address: `b1.l\.1`, 129 | ok: true, 130 | want: `b1 { 131 | } 132 | `, 133 | }, 134 | } 135 | 136 | for _, tc := range cases { 137 | t.Run(tc.name, func(t *testing.T) { 138 | o := NewEditOperator(NewBlockRemoveFilter(tc.address)) 139 | output, err := o.Apply([]byte(tc.src), "test") 140 | if tc.ok && err != nil { 141 | t.Fatalf("unexpected err = %s", err) 142 | } 143 | 144 | got := string(output) 145 | if !tc.ok && err == nil { 146 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 147 | } 148 | 149 | if got != tc.want { 150 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 151 | } 152 | }) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /editor/filter_block_rename.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | ) 6 | 7 | // BlockRenameFilter is a filter implementation for renaming block. 8 | type BlockRenameFilter struct { 9 | from string 10 | to string 11 | } 12 | 13 | var _ Filter = (*BlockRenameFilter)(nil) 14 | 15 | // NewBlockRenameFilter creates a new instance of BlockRenameFilter. 16 | func NewBlockRenameFilter(from string, to string) Filter { 17 | return &BlockRenameFilter{ 18 | from: from, 19 | to: to, 20 | } 21 | } 22 | 23 | // Filter reads HCL and renames matched blocks at a given address. 24 | // The blocks which do not match the from address are output as is. 25 | // Rename means setting the block type and labels corresponding to the new 26 | // address. changing the block type does not make sense on an application 27 | // context, but filters can chain to others and the later filter may edit its 28 | // attributes. So we allow this filter to any block type and labels. 29 | func (f *BlockRenameFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 30 | fromTypeName, fromLabels, err := parseAddress(f.from) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | toTypeName, toLabels, err := parseAddress(f.to) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | matched := findBlocks(inFile.Body(), fromTypeName, fromLabels) 41 | 42 | for _, b := range matched { 43 | b.SetType(toTypeName) 44 | b.SetLabels(toLabels) 45 | } 46 | 47 | return inFile, nil 48 | } 49 | -------------------------------------------------------------------------------- /editor/filter_block_rename_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBlockRenameFilter(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | from string 12 | to string 13 | ok bool 14 | want string 15 | }{ 16 | { 17 | name: "simple", 18 | src: `a0 = v0 19 | b1 "l1" { 20 | a2 = v2 21 | } 22 | 23 | b2 "l2" { 24 | } 25 | `, 26 | from: "b1.l1", 27 | to: "b1.l2", 28 | ok: true, 29 | want: `a0 = v0 30 | b1 "l2" { 31 | a2 = v2 32 | } 33 | 34 | b2 "l2" { 35 | } 36 | `, 37 | }, 38 | 39 | { 40 | name: "escaped address", 41 | src: `a0 = v0 42 | b1 "l.1" { 43 | a2 = v2 44 | } 45 | 46 | b2 "l2" { 47 | } 48 | `, 49 | from: `b1.l\.1`, 50 | to: `b1.l\.2`, 51 | ok: true, 52 | want: `a0 = v0 53 | b1 "l.2" { 54 | a2 = v2 55 | } 56 | 57 | b2 "l2" { 58 | } 59 | `, 60 | }, 61 | } 62 | 63 | for _, tc := range cases { 64 | t.Run(tc.name, func(t *testing.T) { 65 | o := NewEditOperator(NewBlockRenameFilter(tc.from, tc.to)) 66 | output, err := o.Apply([]byte(tc.src), "test") 67 | if tc.ok && err != nil { 68 | t.Fatalf("unexpected err = %s", err) 69 | } 70 | 71 | got := string(output) 72 | if !tc.ok && err == nil { 73 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 74 | } 75 | 76 | if got != tc.want { 77 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 78 | } 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /editor/filter_body_get.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | ) 6 | 7 | // BodyGetFilter is a filter implementation for getting body of first matched block. 8 | type BodyGetFilter struct { 9 | address string 10 | } 11 | 12 | var _ Filter = (*BodyGetFilter)(nil) 13 | 14 | // NewBodyGetFilter creates a new instance of BodyGetFilter. 15 | func NewBodyGetFilter(address string) Filter { 16 | return &BodyGetFilter{ 17 | address: address, 18 | } 19 | } 20 | 21 | // Filter reads HCL and writes body of first matched block at a given address. 22 | func (f *BodyGetFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 23 | m := NewMultiFilter([]Filter{ 24 | NewBlockGetFilter(f.address), 25 | &firstBodyFilter{}, 26 | // body contains a leading NewLine token, it's natural to trim it. 27 | &verticalFormatterFilter{}, 28 | }) 29 | return m.Filter(inFile) 30 | } 31 | 32 | // firstBodyFilter is a filter implementation for getting body of first block. 33 | type firstBodyFilter struct { 34 | } 35 | 36 | var _ Filter = (*firstBodyFilter)(nil) 37 | 38 | // Filter reads HCL and writes body of first block. 39 | func (f *firstBodyFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 40 | outFile := hclwrite.NewEmptyFile() 41 | 42 | matched := inFile.Body().Blocks() 43 | if len(matched) > 0 { 44 | // The current implementation doesn't support index in address format. 45 | // Merging body of contents for multiple blocks doesn't make sense, 46 | // so we take the first matched block. 47 | body := matched[0].Body() 48 | tokens := body.BuildTokens(nil) 49 | outFile.Body().AppendUnstructuredTokens(tokens) 50 | } 51 | 52 | return outFile, nil 53 | } 54 | -------------------------------------------------------------------------------- /editor/filter_body_get_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBodyGetFilter(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | address string 12 | ok bool 13 | want string 14 | }{ 15 | { 16 | name: "simple", 17 | src: ` 18 | a0 = v0 19 | b1 { 20 | a2 = v2 21 | } 22 | 23 | b2 l1 { 24 | } 25 | `, 26 | address: "b1", 27 | ok: true, 28 | want: `a2 = v2 29 | `, 30 | }, 31 | { 32 | name: "no match", 33 | src: ` 34 | a0 = v0 35 | b1 { 36 | a2 = v2 37 | } 38 | `, 39 | address: "foo", 40 | ok: true, 41 | want: "", 42 | }, 43 | { 44 | name: "empty", 45 | address: "", 46 | ok: false, 47 | want: "", 48 | }, 49 | { 50 | name: "with label", 51 | src: ` 52 | b1 { 53 | a1 = v1 54 | } 55 | 56 | b1 l1 { 57 | a2 = v2 58 | } 59 | `, 60 | address: "b1.l1", 61 | ok: true, 62 | want: `a2 = v2 63 | `, 64 | }, 65 | { 66 | name: "get first block", 67 | src: ` 68 | b1 { 69 | a1 = v1 70 | } 71 | 72 | b1 l1 { 73 | a2 = v2 74 | } 75 | 76 | b1 l1 { 77 | a3 = v3 78 | } 79 | `, 80 | address: "b1.l1", 81 | ok: true, 82 | want: `a2 = v2 83 | `, 84 | }, 85 | { 86 | name: "get inside block", 87 | src: ` 88 | b1 { 89 | a1 = v1 90 | b2 { 91 | a2 = v2 92 | } 93 | } 94 | `, 95 | address: "b1.b2", 96 | ok: true, 97 | want: `a2 = v2 98 | `, 99 | }, 100 | { 101 | name: "get outside block", 102 | src: ` 103 | b1 { 104 | a1 = v1 105 | b2 { 106 | a2 = v2 107 | } 108 | } 109 | `, 110 | address: "b1", 111 | ok: true, 112 | want: `a1 = v1 113 | b2 { 114 | a2 = v2 115 | } 116 | `, 117 | }, 118 | { 119 | name: "escaped address", 120 | src: ` 121 | b1 { 122 | a1 = v1 123 | } 124 | 125 | b1 "l.1" { 126 | a2 = v2 127 | } 128 | `, 129 | address: `b1.l\.1`, 130 | ok: true, 131 | want: `a2 = v2 132 | `, 133 | }, 134 | } 135 | 136 | for _, tc := range cases { 137 | t.Run(tc.name, func(t *testing.T) { 138 | o := NewEditOperator(NewBodyGetFilter(tc.address)) 139 | output, err := o.Apply([]byte(tc.src), "test") 140 | if tc.ok && err != nil { 141 | t.Fatalf("unexpected err = %s", err) 142 | } 143 | 144 | got := string(output) 145 | if !tc.ok && err == nil { 146 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 147 | } 148 | 149 | if got != tc.want { 150 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 151 | } 152 | }) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /editor/filter_formatter.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/hashicorp/hcl/v2/hclwrite" 8 | ) 9 | 10 | // FormatterFilter is a Filter implementation which applies the default formatter as a Filter. 11 | type FormatterFilter struct { 12 | } 13 | 14 | var _ Filter = (*FormatterFilter)(nil) 15 | 16 | // NewFormatterFilter creates a new instance of FormatterFilter. 17 | func NewFormatterFilter() Filter { 18 | return &FormatterFilter{} 19 | } 20 | 21 | // Filter applies the default formatter as a Filter. 22 | func (f *FormatterFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 23 | formatter := NewDefaultFormatter() 24 | tmp, err := formatter.Format(inFile) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | // The hclwrite package doesn't provide a token-base interface, so we need to 30 | // parse it again. It's obviously inefficient, but the only way to match the 31 | // type signature. 32 | outFile, err := safeParseConfig(tmp, "generated_by_FormatterFilter", hcl.Pos{Line: 1, Column: 1}) 33 | if err != nil { 34 | // should never happen. 35 | return nil, fmt.Errorf("failed to parse formatted bytes: %s", err) 36 | } 37 | 38 | return outFile, nil 39 | } 40 | -------------------------------------------------------------------------------- /editor/filter_formatter_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFormatterFilter(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | ok bool 12 | want string 13 | }{ 14 | { 15 | name: "unformatted", 16 | src: ` 17 | b1 { 18 | a1 = v1 19 | a2=v2 20 | } 21 | `, 22 | ok: true, 23 | want: ` 24 | b1 { 25 | a1 = v1 26 | a2 = v2 27 | } 28 | `, 29 | }, 30 | { 31 | name: "formatted", 32 | src: ` 33 | b1 { 34 | a1 = v1 35 | a2 = v2 36 | } 37 | `, 38 | ok: true, 39 | want: ` 40 | b1 { 41 | a1 = v1 42 | a2 = v2 43 | } 44 | `, 45 | }, 46 | { 47 | name: "namespaced function", 48 | src: ` 49 | attr = provider::framework::example( ) 50 | `, 51 | ok: true, 52 | want: ` 53 | attr = provider::framework::example() 54 | `, 55 | }, 56 | { 57 | name: "syntax error", 58 | src: ` 59 | b1 { 60 | a1 = v1 61 | `, 62 | ok: false, 63 | want: "", 64 | }, 65 | } 66 | 67 | for _, tc := range cases { 68 | t.Run(tc.name, func(t *testing.T) { 69 | o := NewEditOperator(NewFormatterFilter()) 70 | output, err := o.Apply([]byte(tc.src), "test") 71 | if tc.ok && err != nil { 72 | t.Fatalf("unexpected err = %s", err) 73 | } 74 | 75 | got := string(output) 76 | if !tc.ok && err == nil { 77 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 78 | } 79 | 80 | if got != tc.want { 81 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 82 | } 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /editor/filter_multi.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import "github.com/hashicorp/hcl/v2/hclwrite" 4 | 5 | // MultiFilter is a Filter implementation which applies multiple filters in sequence. 6 | type MultiFilter struct { 7 | filters []Filter 8 | } 9 | 10 | var _ Filter = (*MultiFilter)(nil) 11 | 12 | // NewMultiFilter creates a new instance of MultiFilter. 13 | func NewMultiFilter(filters []Filter) Filter { 14 | return &MultiFilter{ 15 | filters: filters, 16 | } 17 | } 18 | 19 | // Filter applies multiple filters in sequence. 20 | func (f *MultiFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 21 | current := inFile 22 | for _, f := range f.filters { 23 | next, err := f.Filter(current) 24 | if err != nil { 25 | return nil, err 26 | } 27 | current = next 28 | } 29 | return current, nil 30 | } 31 | -------------------------------------------------------------------------------- /editor/filter_vertical_formatter.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclsyntax" 5 | "github.com/hashicorp/hcl/v2/hclwrite" 6 | ) 7 | 8 | // verticalFormatterFilter is a Filter implementation to format HCL. 9 | // At time of writing, the default hcl formatter does not support vertical 10 | // formatting. However, it's useful in some cases such as removing a block 11 | // because leading and trailing newline tokens don't belong to a block, so 12 | // deleting a block leaves extra newline tokens. 13 | // This is not included in the original hcl implementation, so we should not be 14 | // the default behavior of the formatter not to break existing fomatted hcl configurations. 15 | // Opt-in only where you neeed this feature. 16 | // Note that verticalFormatter formats only in vertical, and not in horizontal. 17 | // This was originally implemented as a Sink, but I found it's better as a Filter, 18 | // because using only default formatter as a Sink is more simple and consistent. 19 | type verticalFormatterFilter struct { 20 | } 21 | 22 | var _ Filter = (*verticalFormatterFilter)(nil) 23 | 24 | // Filter reads HCL and writes formatted contents in vertical. 25 | func (f *verticalFormatterFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 26 | tokens := inFile.BuildTokens(nil) 27 | vertical := VerticalFormat(tokens) 28 | 29 | outFile := hclwrite.NewEmptyFile() 30 | outFile.Body().AppendUnstructuredTokens(vertical) 31 | 32 | return outFile, nil 33 | } 34 | 35 | // VerticalFormat formats token in vertical. 36 | func VerticalFormat(tokens hclwrite.Tokens) hclwrite.Tokens { 37 | trimmedLeading := trimLeadingNewLine(tokens) 38 | removed := removeRedundantNewLine(trimmedLeading) 39 | trimmedTrailing := trimTrailingDuplicatedNewLine(removed) 40 | return trimmedTrailing 41 | } 42 | 43 | // trimLeadingNewLine trims leading newlines from tokens. 44 | func trimLeadingNewLine(tokens hclwrite.Tokens) hclwrite.Tokens { 45 | begin := 0 46 | for ; begin < len(tokens); begin++ { 47 | if tokens[begin].Type != hclsyntax.TokenNewline { 48 | break 49 | } 50 | } 51 | 52 | return tokens[begin:] 53 | } 54 | 55 | // trimTrailingDuplicatedNewLine trims trailing newlines from tokens. 56 | // We should not trim the last newlines because the last one means the end of 57 | // line. 58 | func trimTrailingDuplicatedNewLine(tokens hclwrite.Tokens) hclwrite.Tokens { 59 | end := len(tokens) 60 | var eof *hclwrite.Token 61 | for ; end > 1; end-- { 62 | if tokens[end-1].Type == hclsyntax.TokenEOF { 63 | // skip EOF 64 | eof = tokens[end-1] 65 | if tokens[end-2].Type != hclsyntax.TokenNewline { 66 | break 67 | } 68 | continue 69 | } 70 | if tokens[end-1].Type == hclsyntax.TokenNewline && 71 | tokens[end-2].Type != hclsyntax.TokenNewline { 72 | break 73 | } 74 | } 75 | 76 | ret := tokens[:end] 77 | if eof != nil { 78 | // restore EOF 79 | ret = append(ret, eof) 80 | } 81 | return ret 82 | } 83 | 84 | // removeRedundantNewLine removes Redundant newlines. 85 | // Two consecutive blank lines should be removed. 86 | // In other words, if there are three consecutive TokenNewline tokens, 87 | // the third and subsequent TokenNewline tokens are removed. 88 | func removeRedundantNewLine(tokens hclwrite.Tokens) hclwrite.Tokens { 89 | var removed hclwrite.Tokens 90 | beforeBefore := false 91 | before := false 92 | 93 | for _, token := range tokens { 94 | if token.Type != hclsyntax.TokenNewline { 95 | removed = append(removed, token) 96 | // reset 97 | beforeBefore = false 98 | before = false 99 | continue 100 | } 101 | // TokenNewLine 102 | if before && beforeBefore { 103 | // skip duplicated newlines 104 | continue 105 | } 106 | removed = append(removed, token) 107 | beforeBefore = before 108 | before = true 109 | } 110 | 111 | return removed 112 | } 113 | -------------------------------------------------------------------------------- /editor/filter_vertical_formatter_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestVerticalFormatterFilter(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | ok bool 12 | want string 13 | }{ 14 | { 15 | name: "simple", 16 | src: ` 17 | 18 | a0 = v0 19 | 20 | 21 | b1 { 22 | 23 | 24 | a2 = v2 25 | 26 | 27 | } 28 | 29 | 30 | 31 | b2 l1 {} 32 | 33 | 34 | `, 35 | ok: true, 36 | want: `a0 = v0 37 | 38 | b1 { 39 | 40 | a2 = v2 41 | 42 | } 43 | 44 | b2 l1 {} 45 | `, 46 | }, 47 | { 48 | name: "non-POSIX style end of file", 49 | src: `a0 = v0`, 50 | ok: true, 51 | want: `a0 = v0`, 52 | }, 53 | } 54 | 55 | for _, tc := range cases { 56 | t.Run(tc.name, func(t *testing.T) { 57 | filter := &verticalFormatterFilter{} 58 | o := NewEditOperator(filter) 59 | output, err := o.Apply([]byte(tc.src), "test") 60 | if tc.ok && err != nil { 61 | t.Fatalf("unexpected err = %s", err) 62 | } 63 | 64 | got := string(output) 65 | if !tc.ok && err == nil { 66 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 67 | } 68 | 69 | if got != tc.want { 70 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 71 | } 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /editor/formatter_default.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | ) 6 | 7 | // DefaultFormatter is a default Formatter implementation for formatting HCL. 8 | type DefaultFormatter struct { 9 | } 10 | 11 | var _ Formatter = (*DefaultFormatter)(nil) 12 | 13 | // NewDefaultFormatter creates a new instance of DefaultFormatter. 14 | func NewDefaultFormatter() Formatter { 15 | return &DefaultFormatter{} 16 | } 17 | 18 | // Format reads HCL, formats tokens and writes formatted contents. 19 | func (f *DefaultFormatter) Format(inFile *hclwrite.File) ([]byte, error) { 20 | raw := inFile.BuildTokens(nil).Bytes() 21 | out := hclwrite.Format(raw) 22 | return out, nil 23 | } 24 | -------------------------------------------------------------------------------- /editor/operator.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | ) 6 | 7 | // Operator is an interface which abstracts stream operations. 8 | // The hcledit provides not only operations for editing HCL, 9 | // but also for deriving such as listing. 10 | // They need similar but different implementations. 11 | type Operator interface { 12 | // Apply reads input bytes, apply some operations, and writes outputs. 13 | // The input and output contain arbitrary bytes (maybe HCL or not). 14 | // Note that a filename is used only for an error message. 15 | Apply(input []byte, filename string) ([]byte, error) 16 | } 17 | 18 | // Source is an interface which reads string and writes HCL 19 | type Source interface { 20 | // Source parses HCL and returns *hclwrite.File 21 | // filename is a metadata of input stream and used only for an error message. 22 | Source(src []byte, filename string) (*hclwrite.File, error) 23 | } 24 | 25 | // Filter is an interface which reads HCL and writes HCL 26 | type Filter interface { 27 | // Filter reads HCL and writes HCL 28 | Filter(*hclwrite.File) (*hclwrite.File, error) 29 | } 30 | 31 | // Sink is an interface which reads HCL and writes bytes. 32 | type Sink interface { 33 | // Sink reads HCL and writes bytes. 34 | Sink(*hclwrite.File) ([]byte, error) 35 | } 36 | 37 | // Formatter is an interface which reads HCL, formats tokens and writes bytes. 38 | // Formatter has a signature similar to Sink, but they have different features, 39 | // so we distinguish them with types. 40 | type Formatter interface { 41 | // Format reads HCL, formats tokens and writes bytes. 42 | Format(*hclwrite.File) ([]byte, error) 43 | } 44 | -------------------------------------------------------------------------------- /editor/operator_derive.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | ) 8 | 9 | // DeriveOperator is an implementation of Operator for deriving any bytes from HCL. 10 | type DeriveOperator struct { 11 | source Source 12 | sink Sink 13 | } 14 | 15 | var _ Operator = (*DeriveOperator)(nil) 16 | 17 | // NewDeriveOperator creates a new instance of operator for deriving any bytes from HCL. 18 | func NewDeriveOperator(sink Sink) Operator { 19 | return &DeriveOperator{ 20 | source: NewParserSource(), 21 | sink: sink, 22 | } 23 | } 24 | 25 | // Apply reads an input bytes, applies a given sink for deriving, and writes output. 26 | // The input contains arbitrary bytes in HCL, 27 | // and the output contains arbitrary bytes in non-HCL. 28 | // Note that a filename is used only for an error message. 29 | func (o *DeriveOperator) Apply(input []byte, filename string) ([]byte, error) { 30 | inFile, err := o.source.Source(input, filename) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return o.sink.Sink(inFile) 36 | } 37 | 38 | // DeriveStream is a helper method which builds a DeriveOperator from a given 39 | // sink and applies it to stream. 40 | // Note that a filename is used only for an error message. 41 | func DeriveStream(r io.Reader, w io.Writer, filename string, sink Sink) error { 42 | input, err := io.ReadAll(r) 43 | if err != nil { 44 | return fmt.Errorf("failed to read input: %s", err) 45 | } 46 | 47 | o := NewDeriveOperator(sink) 48 | output, err := o.Apply(input, filename) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | if _, err := w.Write(output); err != nil { 54 | return fmt.Errorf("failed to write output: %s", err) 55 | } 56 | 57 | return nil 58 | } 59 | 60 | // DeriveFile is a helper method which builds an DeriveOperator from a given 61 | // sink and applies it to a single file. 62 | // The outputs are written to stream. 63 | func DeriveFile(filename string, w io.Writer, sink Sink) error { 64 | input, err := os.ReadFile(filename) 65 | if err != nil { 66 | return fmt.Errorf("failed to open file: %s", err) 67 | } 68 | 69 | o := NewDeriveOperator(sink) 70 | output, err := o.Apply(input, filename) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | if _, err := w.Write(output); err != nil { 76 | return fmt.Errorf("failed to write output: %s", err) 77 | } 78 | 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /editor/operator_derive_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestOperatorDeriveApply(t *testing.T) { 9 | cases := []struct { 10 | name string 11 | src string 12 | address string 13 | withComments bool 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "match", 19 | src: ` 20 | a0 = v0 21 | a1 = v1 22 | `, 23 | address: "a0", 24 | withComments: false, 25 | ok: true, 26 | want: "v0\n", 27 | }, 28 | { 29 | name: "not found", 30 | src: ` 31 | a0 = v0 32 | a1 = v1 33 | `, 34 | address: "a2", 35 | withComments: false, 36 | ok: true, 37 | want: "", 38 | }, 39 | { 40 | name: "syntax error", 41 | src: ` 42 | b1 { 43 | a1 = v1 44 | `, 45 | ok: false, 46 | want: "", 47 | }, 48 | } 49 | 50 | for _, tc := range cases { 51 | t.Run(tc.name, func(t *testing.T) { 52 | o := NewDeriveOperator(NewAttributeGetSink(tc.address, tc.withComments)) 53 | output, err := o.Apply([]byte(tc.src), "test") 54 | if tc.ok && err != nil { 55 | t.Fatalf("unexpected err = %s", err) 56 | } 57 | 58 | got := string(output) 59 | if !tc.ok && err == nil { 60 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 61 | } 62 | 63 | if got != tc.want { 64 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 65 | } 66 | }) 67 | } 68 | } 69 | 70 | func TestDeriveStream(t *testing.T) { 71 | cases := []struct { 72 | name string 73 | src string 74 | address string 75 | withComments bool 76 | ok bool 77 | want string 78 | }{ 79 | { 80 | name: "match", 81 | src: ` 82 | a0 = v0 83 | a1 = v1 84 | `, 85 | address: "a0", 86 | withComments: false, 87 | ok: true, 88 | want: "v0\n", 89 | }, 90 | { 91 | name: "not found", 92 | src: ` 93 | a0 = v0 94 | a1 = v1 95 | `, 96 | address: "a2", 97 | withComments: false, 98 | ok: true, 99 | want: "", 100 | }, 101 | } 102 | 103 | for _, tc := range cases { 104 | t.Run(tc.name, func(t *testing.T) { 105 | inStream := bytes.NewBufferString(tc.src) 106 | outStream := new(bytes.Buffer) 107 | sink := NewAttributeGetSink(tc.address, tc.withComments) 108 | err := DeriveStream(inStream, outStream, "test", sink) 109 | if tc.ok && err != nil { 110 | t.Fatalf("unexpected err = %s", err) 111 | } 112 | 113 | got := outStream.String() 114 | if !tc.ok && err == nil { 115 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 116 | } 117 | 118 | if got != tc.want { 119 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 120 | } 121 | }) 122 | } 123 | } 124 | 125 | func TestDeriveFile(t *testing.T) { 126 | cases := []struct { 127 | name string 128 | src string 129 | address string 130 | withComments bool 131 | value string 132 | ok bool 133 | want string 134 | }{ 135 | { 136 | name: "match", 137 | src: ` 138 | a0 = v0 139 | a1 = v1 140 | `, 141 | address: "a0", 142 | withComments: false, 143 | ok: true, 144 | want: "v0\n", 145 | }, 146 | { 147 | name: "not found", 148 | src: ` 149 | a0 = v0 150 | a1 = v1 151 | `, 152 | address: "a2", 153 | withComments: false, 154 | ok: true, 155 | want: "", 156 | }, 157 | } 158 | 159 | for _, tc := range cases { 160 | t.Run(tc.name, func(t *testing.T) { 161 | path := setupTestFile(t, tc.src) 162 | outStream := new(bytes.Buffer) 163 | sink := NewAttributeGetSink(tc.address, tc.withComments) 164 | err := DeriveFile(path, outStream, sink) 165 | if tc.ok && err != nil { 166 | t.Fatalf("unexpected err = %s", err) 167 | } 168 | 169 | got := outStream.String() 170 | if !tc.ok && err == nil { 171 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 172 | } 173 | 174 | if got != tc.want { 175 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 176 | } 177 | 178 | input := readTestFile(t, path) 179 | if input != tc.src { 180 | t.Fatalf("the input file should be read-only, but changed: \n%s", input) 181 | } 182 | }) 183 | } 184 | } 185 | 186 | func TestDeriveFileNotFound(t *testing.T) { 187 | sink := NewAttributeGetSink("foo", false) 188 | outStream := new(bytes.Buffer) 189 | err := DeriveFile("not_found", outStream, sink) 190 | if err == nil { 191 | t.Error("expected to return an error, but no error") 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /editor/operator_edit.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | ) 9 | 10 | // EditOperator is an implementation of Operator for editing HCL. 11 | type EditOperator struct { 12 | source Source 13 | filter Filter 14 | formatter Formatter 15 | } 16 | 17 | var _ Operator = (*EditOperator)(nil) 18 | 19 | // NewEditOperator creates a new instance of operator for editing HCL. 20 | // If you want to apply multiple filters, use the MultiFilter to compose them. 21 | func NewEditOperator(filter Filter) Operator { 22 | return &EditOperator{ 23 | source: NewParserSource(), 24 | filter: filter, 25 | formatter: NewDefaultFormatter(), 26 | } 27 | } 28 | 29 | // Apply reads input bytes, applies some filters and formatter, and writes output. 30 | // The input and output contain arbitrary bytes in HCL. 31 | // Note that a filename is used only for an error message. 32 | func (o *EditOperator) Apply(input []byte, filename string) ([]byte, error) { 33 | inFile, err := o.source.Source(input, filename) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | tmpFile, err := o.filter.Filter(inFile) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | output := tmpFile.BuildTokens(nil).Bytes() 44 | // Skip the formatter if the filter didn't change contents to suppress meaningless diff 45 | if bytes.Equal(input, output) { 46 | return output, nil 47 | } 48 | 49 | return o.formatter.Format(tmpFile) 50 | } 51 | 52 | // EditStream is a helper method which builds an EditorOperator from a given 53 | // filter and applies it to stream. 54 | // Note that a filename is used only for an error message. 55 | func EditStream(r io.Reader, w io.Writer, filename string, filter Filter) error { 56 | input, err := io.ReadAll(r) 57 | if err != nil { 58 | return fmt.Errorf("failed to read input: %s", err) 59 | } 60 | 61 | o := NewEditOperator(filter) 62 | output, err := o.Apply(input, filename) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | if _, err := w.Write(output); err != nil { 68 | return fmt.Errorf("failed to write output: %s", err) 69 | } 70 | 71 | return nil 72 | } 73 | 74 | // UpdateFile is a helper method which builds an EditorOperator from a given 75 | // filter and applies it to a single file. 76 | // The outputs are written to the input file in-place. 77 | func UpdateFile(filename string, filter Filter) error { 78 | input, err := os.ReadFile(filename) 79 | if err != nil { 80 | return fmt.Errorf("failed to open file: %s", err) 81 | } 82 | 83 | o := NewEditOperator(filter) 84 | output, err := o.Apply(input, filename) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | // skip updating the timestamp of file if its contents has no change. 90 | if bytes.Equal(input, output) { 91 | return nil 92 | } 93 | 94 | // Write contents back to source file if changed. 95 | // nolint gosec 96 | // G306: Expect WriteFile permissions to be 0600 or less 97 | // We ignore it because the update operation does not affect file 98 | // permissions. 99 | if err = os.WriteFile(filename, output, os.ModePerm); err != nil { 100 | return fmt.Errorf("failed to write file: %s", err) 101 | } 102 | 103 | return nil 104 | } 105 | 106 | // ReadFile is a helper method which builds an EditorOperator from a given 107 | // filter and applies it to a single file. 108 | // The outputs are written to stream. 109 | func ReadFile(filename string, w io.Writer, filter Filter) error { 110 | input, err := os.ReadFile(filename) 111 | if err != nil { 112 | return fmt.Errorf("failed to open file: %s", err) 113 | } 114 | 115 | o := NewEditOperator(filter) 116 | output, err := o.Apply(input, filename) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | if _, err := w.Write(output); err != nil { 122 | return fmt.Errorf("failed to write output: %s", err) 123 | } 124 | 125 | return nil 126 | } 127 | -------------------------------------------------------------------------------- /editor/operator_edit_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestOperatorEditApply(t *testing.T) { 9 | cases := []struct { 10 | name string 11 | src string 12 | address string 13 | value string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "match (formatted)", 19 | src: ` 20 | a0 = v0 21 | a1 = v1 22 | `, 23 | address: "a0", 24 | value: "v2", 25 | ok: true, 26 | want: ` 27 | a0 = v2 28 | a1 = v1 29 | `, 30 | }, 31 | { 32 | name: "match (unformatted)", 33 | src: ` 34 | a0 = v0 35 | a1= v1 36 | `, 37 | address: "a0", 38 | value: "v2", 39 | ok: true, 40 | want: ` 41 | a0 = v2 42 | a1 = v1 43 | `, 44 | }, 45 | { 46 | name: "not found (formatted)", 47 | src: ` 48 | a0 = v0 49 | a1 = v1 50 | `, 51 | address: "a3", 52 | value: "v3", 53 | ok: true, 54 | want: ` 55 | a0 = v0 56 | a1 = v1 57 | `, 58 | }, 59 | { 60 | name: "not found (unformatted)", // skip format 61 | src: ` 62 | a0 = v0 63 | a1= v1 64 | `, 65 | address: "a3", 66 | value: "v3", 67 | ok: true, 68 | want: ` 69 | a0 = v0 70 | a1= v1 71 | `, 72 | }, 73 | { 74 | name: "syntax error", 75 | src: ` 76 | b1 { 77 | a1 = v1 78 | `, 79 | ok: false, 80 | want: "", 81 | }, 82 | } 83 | 84 | for _, tc := range cases { 85 | t.Run(tc.name, func(t *testing.T) { 86 | o := NewEditOperator(NewAttributeSetFilter(tc.address, tc.value)) 87 | output, err := o.Apply([]byte(tc.src), "test") 88 | if tc.ok && err != nil { 89 | t.Fatalf("unexpected err = %s", err) 90 | } 91 | 92 | got := string(output) 93 | if !tc.ok && err == nil { 94 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 95 | } 96 | 97 | if got != tc.want { 98 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 99 | } 100 | }) 101 | } 102 | } 103 | 104 | func TestEditStream(t *testing.T) { 105 | cases := []struct { 106 | name string 107 | src string 108 | address string 109 | value string 110 | ok bool 111 | want string 112 | }{ 113 | { 114 | name: "match", 115 | src: ` 116 | a0 = v0 117 | a1 = v1 118 | `, 119 | address: "a0", 120 | value: "v2", 121 | ok: true, 122 | want: ` 123 | a0 = v2 124 | a1 = v1 125 | `, 126 | }, 127 | { 128 | name: "not found", 129 | src: ` 130 | a0 = v0 131 | a1 = v1 132 | `, 133 | address: "a3", 134 | value: "v3", 135 | ok: true, 136 | want: ` 137 | a0 = v0 138 | a1 = v1 139 | `, 140 | }, 141 | } 142 | 143 | for _, tc := range cases { 144 | t.Run(tc.name, func(t *testing.T) { 145 | inStream := bytes.NewBufferString(tc.src) 146 | outStream := new(bytes.Buffer) 147 | filter := NewAttributeSetFilter(tc.address, tc.value) 148 | err := EditStream(inStream, outStream, "test", filter) 149 | if tc.ok && err != nil { 150 | t.Fatalf("unexpected err = %s", err) 151 | } 152 | 153 | got := outStream.String() 154 | if !tc.ok && err == nil { 155 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 156 | } 157 | 158 | if got != tc.want { 159 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 160 | } 161 | }) 162 | } 163 | } 164 | 165 | func TestUpdateFile(t *testing.T) { 166 | cases := []struct { 167 | name string 168 | src string 169 | address string 170 | value string 171 | ok bool 172 | want string 173 | }{ 174 | { 175 | name: "match", 176 | src: ` 177 | a0 = v0 178 | a1 = v1 179 | `, 180 | address: "a0", 181 | value: "v2", 182 | ok: true, 183 | want: ` 184 | a0 = v2 185 | a1 = v1 186 | `, 187 | }, 188 | { 189 | name: "not found", 190 | src: ` 191 | a0 = v0 192 | a1 = v1 193 | `, 194 | address: "a3", 195 | value: "v3", 196 | ok: true, 197 | want: ` 198 | a0 = v0 199 | a1 = v1 200 | `, 201 | }, 202 | } 203 | 204 | for _, tc := range cases { 205 | t.Run(tc.name, func(t *testing.T) { 206 | path := setupTestFile(t, tc.src) 207 | filter := NewAttributeSetFilter(tc.address, tc.value) 208 | err := UpdateFile(path, filter) 209 | if tc.ok && err != nil { 210 | t.Fatalf("unexpected err = %s", err) 211 | } 212 | 213 | got := readTestFile(t, path) 214 | if !tc.ok && err == nil { 215 | t.Fatalf("expected to return an error, but no error, contents: \n%s", got) 216 | } 217 | 218 | if got != tc.want { 219 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 220 | } 221 | }) 222 | } 223 | } 224 | 225 | func TestUpdateFileNotFound(t *testing.T) { 226 | filter := NewAttributeSetFilter("foo", "bar") 227 | err := UpdateFile("not_found", filter) 228 | if err == nil { 229 | t.Error("expected to return an error, but no error") 230 | } 231 | } 232 | 233 | func TestReadFile(t *testing.T) { 234 | cases := []struct { 235 | name string 236 | src string 237 | address string 238 | value string 239 | ok bool 240 | want string 241 | }{ 242 | { 243 | name: "match", 244 | src: ` 245 | a0 = v0 246 | a1 = v1 247 | `, 248 | address: "a0", 249 | value: "v2", 250 | ok: true, 251 | want: ` 252 | a0 = v2 253 | a1 = v1 254 | `, 255 | }, 256 | { 257 | name: "not found", 258 | src: ` 259 | a0 = v0 260 | a1 = v1 261 | `, 262 | address: "a3", 263 | value: "v3", 264 | ok: true, 265 | want: ` 266 | a0 = v0 267 | a1 = v1 268 | `, 269 | }, 270 | } 271 | 272 | for _, tc := range cases { 273 | t.Run(tc.name, func(t *testing.T) { 274 | path := setupTestFile(t, tc.src) 275 | outStream := new(bytes.Buffer) 276 | filter := NewAttributeSetFilter(tc.address, tc.value) 277 | err := ReadFile(path, outStream, filter) 278 | if tc.ok && err != nil { 279 | t.Fatalf("unexpected err = %s", err) 280 | } 281 | 282 | got := outStream.String() 283 | if !tc.ok && err == nil { 284 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 285 | } 286 | 287 | if got != tc.want { 288 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 289 | } 290 | 291 | input := readTestFile(t, path) 292 | if input != tc.src { 293 | t.Fatalf("the input file should be read-only, but changed: \n%s", input) 294 | } 295 | }) 296 | } 297 | } 298 | 299 | func TestReadFileNotFound(t *testing.T) { 300 | filter := NewAttributeSetFilter("foo", "bar") 301 | outStream := new(bytes.Buffer) 302 | err := ReadFile("not_found", outStream, filter) 303 | if err == nil { 304 | t.Error("expected to return an error, but no error") 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /editor/sink_attribute_get.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/hashicorp/hcl/v2/hclsyntax" 8 | "github.com/hashicorp/hcl/v2/hclwrite" 9 | ) 10 | 11 | // AttributeGetSink is a sink implementation for getting a value of attribute. 12 | type AttributeGetSink struct { 13 | address string 14 | withComments bool 15 | } 16 | 17 | var _ Sink = (*AttributeGetSink)(nil) 18 | 19 | // NewAttributeGetSink creates a new instance of AttributeGetSink. 20 | func NewAttributeGetSink(address string, withComments bool) Sink { 21 | return &AttributeGetSink{ 22 | address: address, 23 | withComments: withComments, 24 | } 25 | } 26 | 27 | // Sink reads HCL and writes value of attribute. 28 | func (s *AttributeGetSink) Sink(inFile *hclwrite.File) ([]byte, error) { 29 | attr, _, err := findAttribute(inFile.Body(), s.address) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | // not found 35 | if attr == nil { 36 | return []byte{}, nil 37 | } 38 | 39 | // treat expr as a string without interpreting its meaning. 40 | out, err := GetAttributeValueAsString(attr, s.withComments) 41 | if err != nil { 42 | return []byte{}, err 43 | } 44 | 45 | return []byte(out + "\n"), nil 46 | } 47 | 48 | // findAttribute returns first matching attribute at a given address. 49 | // If the address does not contain any dots, find attribute in the body. 50 | // If the address contains dots, the last element is an attribute name, 51 | // and the rest is the address of the block. 52 | // The block is fetched by findLongestMatchingBlocks. 53 | // If the attribute is found, the body containing it is also returned for updating. 54 | func findAttribute(body *hclwrite.Body, address string) (*hclwrite.Attribute, *hclwrite.Body, error) { 55 | if len(address) == 0 { 56 | return nil, nil, errors.New("failed to parse address. address is empty") 57 | } 58 | 59 | a := createAddressFromString(address) 60 | if len(a) == 1 { 61 | // if the address does not contain any dots, find attribute in the body. 62 | attr := body.GetAttribute(a[0]) 63 | return attr, body, nil 64 | } 65 | 66 | // if address contains dots, the last element is an attribute name, 67 | // and the rest is the address of the block. 68 | attrName := a[len(a)-1] 69 | blockAddr := createStringFromAddress(a[:len(a)-1]) 70 | blocks, err := findLongestMatchingBlocks(body, blockAddr) 71 | if err != nil { 72 | return nil, nil, err 73 | } 74 | 75 | if len(blocks) == 0 { 76 | // not found 77 | return nil, nil, nil 78 | } 79 | 80 | // if blocks are matched, check if it has a given attribute name 81 | for _, b := range blocks { 82 | attr := b.Body().GetAttribute(attrName) 83 | if attr != nil { 84 | // return first matching one. 85 | return attr, b.Body(), nil 86 | } 87 | } 88 | 89 | // not found 90 | return nil, nil, nil 91 | } 92 | 93 | // findLongestMatchingBlocks returns the longest matching blocks at a given address. 94 | // if the address does not cantain any dots, return all matching blocks by type. 95 | // If the address contains dots, the first element is a block type, 96 | // and the rest is labels or nested block type or composite of them. 97 | // It is ambiguous to find blocks from the address without a schema. 98 | // To distinguish them in address notation requires introducing a strange new 99 | // syntax, which is not user friendly. The address notation is not specified 100 | // in the scope of the HCL specification, So the initial implementation has 101 | // Terraform in mind, but we want to solve it in a schemaless way. 102 | // We prioritize realistic usability over accuracy, we rely on some heuristics 103 | // here to compromise. 104 | // Given the address A.B.C, the user knows if B is a label or a nested block 105 | // type. So if the block matched in either, we should consider it is matched. 106 | // If you had both a label name and a nested block type, the address would be 107 | // A.B.B.C. 108 | // The labels take precedence over nested blocks. This is because if a block 109 | // type is specified, it is assumed that the number of labels in the same block 110 | // type does not really change and only the label name can be changed by the 111 | // user, and we want to give the user room to avoid unintended conflicts. 112 | func findLongestMatchingBlocks(body *hclwrite.Body, address string) ([]*hclwrite.Block, error) { 113 | if len(address) == 0 { 114 | return nil, errors.New("failed to parse address. address is empty") 115 | } 116 | 117 | a := createAddressFromString(address) 118 | typeName := a[0] 119 | blocks := allMatchingBlocksByType(body, typeName) 120 | 121 | if len(a) == 1 { 122 | // if the address does not cantain any dots, 123 | // return all matching blocks by type 124 | return blocks, nil 125 | } 126 | 127 | matched := []*hclwrite.Block{} 128 | // if address contains dots, the next element maybe label or nested block. 129 | for _, b := range blocks { 130 | labels := b.Labels() 131 | // consume labels from address 132 | matchedlabels := longestMatchingLabels(labels, a[1:]) 133 | if len(matchedlabels) < len(labels) { 134 | // The labels take precedence over nested blocks. 135 | // If extra labels remain, skip it. 136 | continue 137 | } 138 | if len(matchedlabels) < (len(a)-1) || len(labels) == 0 { 139 | // if the block has no labels or partially matched ones, find the nested block 140 | nestedAddr := createStringFromAddress(a[1+len(matchedlabels):]) 141 | nested, err := findLongestMatchingBlocks(b.Body(), nestedAddr) 142 | if err != nil { 143 | return nil, err 144 | } 145 | matched = append(matched, nested...) 146 | continue 147 | } 148 | // all labels are matched, just add it to matched list. 149 | matched = append(matched, b) 150 | } 151 | 152 | return matched, nil 153 | } 154 | 155 | // allMatchingBlocksByType returns all matching blocks from the body that have the 156 | // given name or returns an empty list if there is currently no matching block. 157 | // This method is useful when you want to ignore label differences. 158 | func allMatchingBlocksByType(b *hclwrite.Body, typeName string) []*hclwrite.Block { 159 | matched := []*hclwrite.Block{} 160 | for _, block := range b.Blocks() { 161 | if typeName == block.Type() { 162 | matched = append(matched, block) 163 | } 164 | } 165 | 166 | return matched 167 | } 168 | 169 | // longestMatchLabels returns a partial labels from the beginning to the 170 | // matching part and returns an empty array if nothing matches. 171 | func longestMatchingLabels(labels []string, prefix []string) []string { 172 | matched := []string{} 173 | for i := range prefix { 174 | if len(labels) <= i { 175 | return matched 176 | } 177 | if prefix[i] != labels[i] { 178 | return matched 179 | } 180 | matched = append(matched, labels[i]) 181 | } 182 | return matched 183 | } 184 | 185 | // GetAttributeValueAsString returns a value of Attribute as string. 186 | // There is no way to get value as string directly, 187 | // so we parses tokens of Attribute and build string representation. 188 | func GetAttributeValueAsString(attr *hclwrite.Attribute, withComments bool) (string, error) { 189 | var rhsTokens hclwrite.Tokens 190 | if withComments { 191 | // Inline comments belong to an attribute, not an expression. 192 | attrTokens := attr.BuildTokens(nil) 193 | for i, t := range attrTokens { 194 | // find TokenEqual 195 | if t.Type != hclsyntax.TokenEqual { 196 | continue 197 | } 198 | rhsTokens = attrTokens[i+1:] 199 | break 200 | } 201 | } else { 202 | expr := attr.Expr() 203 | rhsTokens = expr.BuildTokens(nil) 204 | } 205 | 206 | // append tokens until find TokenComment 207 | var valueTokens hclwrite.Tokens 208 | for _, t := range rhsTokens { 209 | if t.Type == hclsyntax.TokenComment && !withComments { 210 | t.Bytes = []byte("\n") 211 | t.SpacesBefore = 0 212 | } 213 | valueTokens = append(valueTokens, t) 214 | } 215 | 216 | // TokenIdent records SpaceBefore, but we should ignore it here. 217 | value := strings.TrimSpace(string(valueTokens.Bytes())) 218 | 219 | return value, nil 220 | } 221 | -------------------------------------------------------------------------------- /editor/sink_attribute_get_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func TestAttributeGetSink(t *testing.T) { 10 | cases := []struct { 11 | name string 12 | src string 13 | address string 14 | withComments bool 15 | ok bool 16 | want string 17 | }{ 18 | { 19 | name: "simple top level attribute", 20 | src: ` 21 | a0 = v0 22 | a1 = v1 23 | `, 24 | address: "a0", 25 | withComments: false, 26 | ok: true, 27 | want: "v0\n", 28 | }, 29 | { 30 | name: "quoted literal is as it is and should not be unquoted", 31 | src: ` 32 | a0 = "v0" 33 | `, 34 | address: "a0", 35 | withComments: false, 36 | ok: true, 37 | want: "\"v0\"\n", 38 | }, 39 | { 40 | name: "not found", 41 | src: ` 42 | a0 = v0 43 | a1 = v1 44 | `, 45 | address: "hoge", 46 | withComments: false, 47 | ok: true, 48 | want: "", 49 | }, 50 | { 51 | name: "attribute without comments", 52 | src: ` 53 | // attr comment 54 | a0 = v0 // inline comment 55 | a1 = v1 56 | `, 57 | address: "a0", 58 | withComments: false, 59 | ok: true, 60 | want: "v0\n", 61 | }, 62 | { 63 | name: "attribute with comments", 64 | src: ` 65 | // attr comment 66 | a0 = v0 // inline comment 67 | a1 = v1 68 | `, 69 | address: "a0", 70 | withComments: true, 71 | ok: true, 72 | want: "v0 // inline comment\n", 73 | }, 74 | { 75 | name: "multiline attribute without comments", 76 | src: ` 77 | // attr comment 78 | a0 = v0 79 | a1 = [ 80 | "val1", 81 | "val2", // inline comment 82 | "val3", # another comment 83 | "val4", 84 | # a ocmment line 85 | "val5", 86 | ] 87 | a2 = v2 88 | `, 89 | address: "a1", 90 | withComments: false, 91 | ok: true, 92 | want: `[ 93 | "val1", 94 | "val2", 95 | "val3", 96 | "val4", 97 | 98 | "val5", 99 | ] 100 | `, 101 | }, 102 | { 103 | name: "multiline attribute with comments", 104 | src: ` 105 | // attr comment 106 | a0 = v0 107 | a1 = [ 108 | "val1", 109 | "val2", // inline comment 110 | "val3", # another comment 111 | "val4", 112 | # a ocmment line 113 | "val5", 114 | ] 115 | a2 = v2 116 | `, 117 | address: "a1", 118 | withComments: true, 119 | ok: true, 120 | want: `[ 121 | "val1", 122 | "val2", // inline comment 123 | "val3", # another comment 124 | "val4", 125 | # a ocmment line 126 | "val5", 127 | ] 128 | `, 129 | }, 130 | { 131 | name: "duplicated attributes should be error", 132 | src: ` 133 | a0 = v0 134 | a0 = v1 135 | `, 136 | address: "a0", 137 | withComments: false, 138 | ok: false, 139 | want: "", 140 | }, 141 | { 142 | name: "attribute in block", 143 | src: ` 144 | b1 { 145 | a1 = v1 146 | } 147 | `, 148 | address: "b1.a1", 149 | withComments: false, 150 | ok: true, 151 | want: "v1\n", 152 | }, 153 | { 154 | name: "attribute in block with a label", 155 | src: ` 156 | b1 "l1" { 157 | a1 = v1 158 | } 159 | `, 160 | address: "b1.l1.a1", 161 | withComments: false, 162 | ok: true, 163 | want: "v1\n", 164 | }, 165 | { 166 | name: "attribute in block with multiple labels", 167 | src: ` 168 | b1 { 169 | a1 = v0 170 | } 171 | b1 "l1" { 172 | a1 = v1 173 | } 174 | b1 "l1" "l2" { 175 | a1 = v2 176 | } 177 | b1 "l1" "l2" "l3" { 178 | a1 = v3 179 | } 180 | `, 181 | address: "b1.l1.l2.a1", 182 | withComments: false, 183 | ok: true, 184 | want: "v2\n", 185 | }, 186 | { 187 | name: "attribute in nested block", 188 | src: ` 189 | b1 { 190 | a1 = v1 191 | b2 { 192 | a2 = v2 193 | } 194 | } 195 | `, 196 | address: "b1.b2.a2", 197 | withComments: false, 198 | ok: true, 199 | want: "v2\n", 200 | }, 201 | { 202 | name: "attribute in nested block (extra labels)", 203 | src: ` 204 | b1 "l1" { 205 | a1 = v1 206 | b2 { 207 | a2 = v2 208 | } 209 | } 210 | `, 211 | address: "b1.b2.a2", 212 | withComments: false, 213 | ok: true, 214 | want: "", 215 | }, 216 | { 217 | name: "labels take precedence over nested blocks", 218 | src: ` 219 | b1 "b2" { 220 | a1 = v1 221 | b2 { 222 | a1 = v2 223 | } 224 | } 225 | `, 226 | address: "b1.b2.a1", 227 | withComments: false, 228 | ok: true, 229 | want: "v1\n", 230 | }, 231 | { 232 | name: "attribute in multi level nested block", 233 | src: ` 234 | b1 { 235 | a1 = v1 236 | b2 { 237 | a2 = v2 238 | b3 { 239 | a3 = v3 240 | } 241 | } 242 | } 243 | `, 244 | address: "b1.b2.b3.a3", 245 | withComments: false, 246 | ok: true, 247 | want: "v3\n", 248 | }, 249 | { 250 | name: "attribute in nested block with labels", 251 | src: ` 252 | b1 { 253 | a1 = v1 254 | b2 "b3" { 255 | a2 = v2 256 | b3 { 257 | a2 = v3 258 | } 259 | } 260 | } 261 | `, 262 | address: "b1.b2.b3.a2", 263 | withComments: false, 264 | ok: true, 265 | want: "v2\n", 266 | }, 267 | { 268 | name: "attribute in duplicated blocks", 269 | src: ` 270 | b1 "l1" "l2" { 271 | a1 = v1 272 | } 273 | b1 "l1" "l2" { 274 | a1 = v2 275 | } 276 | `, 277 | address: "b1.l1.l2.a1", 278 | withComments: false, 279 | ok: true, 280 | want: "v1\n", 281 | }, 282 | { 283 | name: "attribute in block with a escaped address", 284 | src: ` 285 | b1 "l.1" { 286 | a1 = v1 287 | } 288 | `, 289 | address: `b1.l\.1.a1`, 290 | withComments: false, 291 | ok: true, 292 | want: "v1\n", 293 | }, 294 | } 295 | 296 | for _, tc := range cases { 297 | t.Run(tc.name, func(t *testing.T) { 298 | o := NewDeriveOperator(NewAttributeGetSink(tc.address, tc.withComments)) 299 | output, err := o.Apply([]byte(tc.src), "test") 300 | if tc.ok && err != nil { 301 | t.Fatalf("unexpected err = %s", err) 302 | } 303 | 304 | got := string(output) 305 | if !tc.ok && err == nil { 306 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 307 | } 308 | 309 | if diff := cmp.Diff(tc.want, got); diff != "" { 310 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff(-want +got):\n%v", got, tc.want, diff) 311 | } 312 | }) 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /editor/sink_block_list.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/hashicorp/hcl/v2/hclwrite" 7 | ) 8 | 9 | // BlockListSink is a sink implementation for getting a list of block addresses. 10 | type BlockListSink struct{} 11 | 12 | var _ Sink = (*BlockListSink)(nil) 13 | 14 | // NewBlockListSink creates a new instance of BlockListSink. 15 | func NewBlockListSink() Sink { 16 | return &BlockListSink{} 17 | } 18 | 19 | // Sink reads HCL and writes a list of block addresses. 20 | func (s *BlockListSink) Sink(inFile *hclwrite.File) ([]byte, error) { 21 | addrs := []string{} 22 | for _, b := range inFile.Body().Blocks() { 23 | addrs = append(addrs, toAddress(b)) 24 | } 25 | 26 | out := strings.Join(addrs, "\n") 27 | if len(out) != 0 { 28 | // append a new line if output is not empty. 29 | out += "\n" 30 | } 31 | return []byte(out), nil 32 | } 33 | 34 | func toAddress(b *hclwrite.Block) string { 35 | addr := []string{} 36 | addr = append(addr, b.Type()) 37 | addr = append(addr, (b.Labels())...) 38 | return createStringFromAddress(addr) 39 | } 40 | -------------------------------------------------------------------------------- /editor/sink_block_list_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBlockListSink(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | src string 11 | ok bool 12 | want string 13 | }{ 14 | { 15 | name: "simple", 16 | src: ` 17 | a0 = v0 18 | b1 { 19 | a2 = v2 20 | } 21 | 22 | b2 l1 { 23 | } 24 | 25 | b2 "l.1" { 26 | } 27 | `, 28 | ok: true, 29 | want: `b1 30 | b2.l1 31 | b2.l\.1 32 | `, 33 | }, 34 | { 35 | name: "empty", 36 | src: "", 37 | ok: true, 38 | want: "", 39 | }, 40 | } 41 | 42 | for _, tc := range cases { 43 | t.Run(tc.name, func(t *testing.T) { 44 | o := NewDeriveOperator(NewBlockListSink()) 45 | output, err := o.Apply([]byte(tc.src), "test") 46 | if tc.ok && err != nil { 47 | t.Fatalf("unexpected err = %s", err) 48 | } 49 | 50 | got := string(output) 51 | if !tc.ok && err == nil { 52 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 53 | } 54 | 55 | if got != tc.want { 56 | t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) 57 | } 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /editor/source_parser.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "runtime/debug" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | "github.com/hashicorp/hcl/v2/hclwrite" 10 | ) 11 | 12 | // ParserSource is a Source implementation for parsing HCL. 13 | type ParserSource struct { 14 | } 15 | 16 | var _ Source = (*ParserSource)(nil) 17 | 18 | // NewParserSource creates a new instance of ParserSource. 19 | func NewParserSource() Source { 20 | return &ParserSource{} 21 | } 22 | 23 | // Source parses HCL and returns *hclwrite.File 24 | // filename is a metadata of input stream and used only for an error message. 25 | func (s *ParserSource) Source(src []byte, filename string) (*hclwrite.File, error) { 26 | return safeParseConfig(src, filename, hcl.Pos{Line: 1, Column: 1}) 27 | } 28 | 29 | // safeParseConfig parses config and recovers if panic occurs. 30 | // The current hclwrite implementation is no perfect and will panic if 31 | // unparseable input is given. We just treat it as a parse error so as not to 32 | // surprise users. 33 | func safeParseConfig(src []byte, filename string, start hcl.Pos) (f *hclwrite.File, e error) { 34 | defer func() { 35 | if err := recover(); err != nil { 36 | log.Printf("[DEBUG] failed to parse input: %s\nstacktrace: %s", filename, string(debug.Stack())) 37 | // Set a return value from panic recover 38 | e = fmt.Errorf(`failed to parse input: %s 39 | panic: %s 40 | This may be caused by a bug in the hclwrite parser`, filename, err) 41 | } 42 | }() 43 | 44 | f, diags := hclwrite.ParseConfig(src, filename, start) 45 | 46 | if diags.HasErrors() { 47 | return nil, fmt.Errorf("failed to parse input: %s", diags) 48 | } 49 | 50 | return f, nil 51 | } 52 | -------------------------------------------------------------------------------- /editor/test_helper.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | // setupTestFile creates a temporary file with given contents for testing. 9 | // It returns a path to the file. 10 | func setupTestFile(t *testing.T, contents string) string { 11 | t.Helper() 12 | f, err := os.CreateTemp("", "test-*.hcl") 13 | if err != nil { 14 | t.Fatalf("failed to create test file: %s", err) 15 | } 16 | 17 | path := f.Name() 18 | t.Cleanup(func() { os.Remove(path) }) 19 | 20 | if err := os.WriteFile(path, []byte(contents), 0600); err != nil { 21 | t.Fatalf("failed to write test file: %s", err) 22 | } 23 | 24 | return path 25 | } 26 | 27 | // readTestFile is a test helper for reading file with error handling. 28 | func readTestFile(t *testing.T, path string) string { 29 | t.Helper() 30 | b, err := os.ReadFile(path) 31 | if err != nil { 32 | t.Fatalf("failed to read test file: %s", err) 33 | } 34 | 35 | return string(b) 36 | } 37 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/minamijoyo/hcledit 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/google/go-cmp v0.6.0 7 | github.com/hashicorp/hcl/v2 v2.23.1-0.20250211201033-5c140ce1cb20 8 | github.com/hashicorp/logutils v1.0.0 9 | github.com/spf13/cobra v0.0.5 10 | github.com/spf13/viper v1.3.2 11 | ) 12 | 13 | require ( 14 | github.com/agext/levenshtein v1.2.1 // indirect 15 | github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect 16 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 17 | github.com/fsnotify/fsnotify v1.4.7 // indirect 18 | github.com/hashicorp/hcl v1.0.0 // indirect 19 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 20 | github.com/kr/pretty v0.3.1 // indirect 21 | github.com/magiconair/properties v1.8.0 // indirect 22 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect 23 | github.com/mitchellh/mapstructure v1.1.2 // indirect 24 | github.com/pelletier/go-toml v1.2.0 // indirect 25 | github.com/spf13/afero v1.1.2 // indirect 26 | github.com/spf13/cast v1.3.0 // indirect 27 | github.com/spf13/jwalterweatherman v1.0.0 // indirect 28 | github.com/spf13/pflag v1.0.5 // indirect 29 | github.com/stretchr/testify v1.4.0 // indirect 30 | github.com/zclconf/go-cty v1.13.0 // indirect 31 | golang.org/x/mod v0.17.0 // indirect 32 | golang.org/x/sync v0.10.0 // indirect 33 | golang.org/x/sys v0.28.0 // indirect 34 | golang.org/x/text v0.21.0 // indirect 35 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 36 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 37 | gopkg.in/yaml.v2 v2.2.7 // indirect 38 | ) 39 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= 4 | github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= 5 | github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= 6 | github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= 7 | github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= 8 | github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= 9 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 10 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 11 | github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= 12 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 13 | github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= 14 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 19 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 20 | github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= 21 | github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 22 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 23 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 24 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 25 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 26 | github.com/hashicorp/hcl/v2 v2.23.1-0.20250211201033-5c140ce1cb20 h1:wPU89nH3VGqF53Ab59jgdCBNLdDwECqZFPXWkAF4cNI= 27 | github.com/hashicorp/hcl/v2 v2.23.1-0.20250211201033-5c140ce1cb20/go.mod h1:k+HgkLpoWu9OS81sy4j1XKDXaWm/rLysG33v5ibdDnc= 28 | github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= 29 | github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= 30 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 31 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 32 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 33 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 34 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 35 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 36 | github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= 37 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 38 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 39 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= 40 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= 41 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 42 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 43 | github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= 44 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 45 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 46 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 47 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 48 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 49 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 50 | github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= 51 | github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= 52 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 53 | github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= 54 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 55 | github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= 56 | github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= 57 | github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= 58 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 59 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 60 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 61 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 62 | github.com/spf13/viper v1.3.2 h1:VUFqw5KcqRf7i70GOzW7N+Q7+gxVBkSSqiXB12+JQ4M= 63 | github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= 64 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 65 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 66 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 67 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 68 | github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= 69 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 70 | github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0= 71 | github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= 72 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= 73 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= 74 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 75 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 76 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 77 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 78 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 79 | golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 80 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 81 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 82 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 83 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 84 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 85 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 86 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 87 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 88 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 89 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 90 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 91 | gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= 92 | gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 93 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | 9 | "github.com/hashicorp/logutils" 10 | "github.com/minamijoyo/hcledit/cmd" 11 | ) 12 | 13 | func main() { 14 | log.SetOutput(logOutput()) 15 | log.Printf("[INFO] CLI args: %#v", os.Args) 16 | if err := cmd.RootCmd.Execute(); err != nil { 17 | fmt.Fprintf(os.Stderr, "%v\n", err) 18 | os.Exit(1) 19 | } 20 | } 21 | 22 | func logOutput() io.Writer { 23 | levels := []logutils.LogLevel{"TRACE", "DEBUG", "INFO", "WARN", "ERROR"} 24 | minLevel := os.Getenv("HCLEDIT_LOG") 25 | 26 | // default log writer is null device. 27 | writer := io.Discard 28 | if minLevel != "" { 29 | writer = os.Stderr 30 | } 31 | 32 | filter := &logutils.LevelFilter{ 33 | Levels: levels, 34 | MinLevel: logutils.LogLevel(minLevel), 35 | Writer: writer, 36 | } 37 | 38 | return filter 39 | } 40 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "sort" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | const ( 14 | VarRunMainForTesting = "RUN_MAIN_FOR_TESTING" 15 | VarRunMainForTestingArgPrefix = "RUN_MAIN_FOR_TESTING_ARG_" 16 | ) 17 | 18 | func TestHCLEditMain(t *testing.T) { 19 | if os.Getenv(VarRunMainForTesting) == "1" { 20 | os.Args = append([]string{"hcledit"}, envToArgs(os.Environ())...) 21 | 22 | // We DO call hcledit's main() here. So this looks like a normal `hcledit` process. 23 | main() 24 | 25 | // If `main()` did not call os.Exit(0) explicitly, we assume there was no error hence it's safe to call os.Exit(0) 26 | // on behalf of go runtime. 27 | os.Exit(0) 28 | 29 | // As main() or this block calls os.Exit, we never reach this line. 30 | // But the test called this block of code catches and verifies the exit code. 31 | return 32 | } 33 | 34 | testcases := []struct { 35 | subject string 36 | input string 37 | args []string 38 | wantStdout string 39 | wantStderr string 40 | wantExitCode int 41 | }{ 42 | { 43 | subject: "set existing nested attribute", 44 | input: `resource "foo" "bar" { 45 | attr1 = "val1" 46 | nested { 47 | attr2 = "val2" 48 | } 49 | } 50 | `, 51 | args: []string{ 52 | "attribute", 53 | "set", 54 | "resource.foo.bar.nested.attr2", 55 | "\"val3\"", 56 | }, 57 | wantStdout: `resource "foo" "bar" { 58 | attr1 = "val1" 59 | nested { 60 | attr2 = "val3" 61 | } 62 | } 63 | `, 64 | }, 65 | { 66 | subject: "set with insufficient args", 67 | input: `resource "foo" "bar" { 68 | attr1 = "val1" 69 | nested { 70 | attr2 = "val2" 71 | } 72 | } 73 | `, 74 | args: []string{ 75 | "attribute", 76 | "set", 77 | "resource.foo.bar.nested.attr2", 78 | }, 79 | wantStderr: `expected 2 argument, but got 1 arguments 80 | `, 81 | wantExitCode: 1, 82 | }, 83 | } 84 | 85 | for i := range testcases { 86 | tc := testcases[i] 87 | 88 | t.Run(tc.subject, func(t *testing.T) { 89 | // Do a second run of this specific test(TestHCLEditMain) with RUN_MAIN_FOR_TESTING=1 set, 90 | // So that the second run is able to run main() and this first run can verify the exit status returned by that. 91 | // 92 | // This technique originates from https://talks.golang.org/2014/testing.slide#23. 93 | self, err := os.Executable() 94 | if err != nil { 95 | t.Fatal(err) 96 | } 97 | cmd := exec.Command(self, "-test.run=TestHCLEditMain") 98 | cmd.Env = append( 99 | cmd.Env, 100 | os.Environ()..., 101 | ) 102 | cmd.Env = append( 103 | cmd.Env, 104 | VarRunMainForTesting+"=1", 105 | ) 106 | cmd.Env = append( 107 | cmd.Env, 108 | argsToEnv(tc.args)..., 109 | ) 110 | 111 | stdin := strings.NewReader(tc.input) 112 | stdout := &bytes.Buffer{} 113 | stderr := &bytes.Buffer{} 114 | 115 | cmd.Stdin = stdin 116 | cmd.Stdout = stdout 117 | cmd.Stderr = stderr 118 | 119 | err = cmd.Run() 120 | 121 | if got := stdout.String(); got != tc.wantStdout { 122 | t.Errorf("Unexpected stdout: want %q, got %q", tc.wantStdout, got) 123 | } 124 | 125 | if got := stderr.String(); got != tc.wantStderr { 126 | t.Errorf("Unexpected stderr: want %q, got %q", tc.wantStderr, got) 127 | } 128 | 129 | if tc.wantExitCode != 0 { 130 | exiterr, ok := err.(*exec.ExitError) 131 | 132 | if !ok { 133 | t.Fatalf("Unexpected error returned by os.Exit: %T", err) 134 | } 135 | 136 | if got := exiterr.ExitCode(); got != tc.wantExitCode { 137 | t.Errorf("Unexpected exit code: want %d, got %d", tc.wantExitCode, got) 138 | } 139 | } 140 | }) 141 | } 142 | } 143 | 144 | func argsToEnv(args []string) []string { 145 | var env []string 146 | 147 | for i, arg := range args { 148 | env = append(env, fmt.Sprintf("%s%d=%s", VarRunMainForTestingArgPrefix, i, arg)) 149 | } 150 | 151 | return env 152 | } 153 | 154 | func envToArgs(env []string) []string { 155 | var envvars []string 156 | 157 | for _, kv := range env { 158 | if strings.HasPrefix(kv, VarRunMainForTestingArgPrefix) { 159 | envvars = append(envvars, kv) 160 | } 161 | } 162 | 163 | sort.Strings(envvars) 164 | 165 | var args []string 166 | 167 | for _, kv := range envvars { 168 | args = append(args, strings.Split(kv, "=")[1]) 169 | } 170 | 171 | return args 172 | } 173 | --------------------------------------------------------------------------------