├── .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)
3 | [](https://github.com/minamijoyo/hcledit/releases/latest)
4 | [](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 |
--------------------------------------------------------------------------------