From 7a05ac0a60338a382c428fb8658176bd23e500b0 Mon Sep 17 00:00:00 2001 From: Digital Studium Date: Tue, 23 Sep 2025 06:38:03 +0300 Subject: [PATCH 1/7] Add goreleaser --- .github/workflows/release.yml | 27 ++++++++++++++ .goreleaser.yaml | 26 +++++++++++++ README.md | 2 +- main.go | 70 +++++++++++++++++++++-------------- 4 files changed, 96 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e254e00 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,27 @@ +# .github/workflows/release.yml +name: Release + +on: + push: + tags: + - "v*" + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v4 + with: + go-version: "1.25" + + - uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..de42088 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,26 @@ +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.Version={{.Version}} + +archives: + - format: tar.gz + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + +release: + github: + owner: digitalstudium + name: helmfmt diff --git a/README.md b/README.md index 3c6f040..d773183 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ These Go-template tags are indented: These are not indented: -- `tpl` and `toYaml` because they can break YAML indentation +- `tpl`, `template` and `toYaml` because they can break YAML indentation --- diff --git a/main.go b/main.go index 4e234b7..29cd606 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "flag" "fmt" "io/fs" "os" @@ -8,45 +9,63 @@ import ( "strings" ) +// Version can be set at build time with -ldflags "-X main.Version=v1.2.3" +var Version = "dev" + func main() { os.Exit(run(os.Args[1:])) } func run(args []string) int { - if len(args) == 0 { - usage() - return 2 + // Handle version subcommand early + if len(args) > 0 && args[0] == "version" { + fmt.Println(Version) + return 0 } - stdout := false - var files []string + fs := flag.NewFlagSet(filepath.Base(os.Args[0]), flag.ContinueOnError) + fs.Usage = func() { + prog := fs.Name() + fmt.Fprintf(fs.Output(), "Usage: %s [--stdout] --files ...\n", prog) + fmt.Fprintf(fs.Output(), " OR: %s \n", prog) + fmt.Fprintf(fs.Output(), " OR: %s version\n", prog) + fmt.Fprintf(fs.Output(), "\nFlags:\n") + fs.PrintDefaults() + } - // Pre-commit/IDE mode: --files ... [--stdout] - if args[0] == "--files" { - if len(args) < 2 { - fmt.Fprintln(os.Stderr, "Error: --files requires at least one file argument") - return 2 - } - for _, a := range args[1:] { - if a == "--stdout" { - stdout = true - continue - } - files = append(files, a) + filesMode := fs.Bool("files", false, "Process specific files (remaining args are file paths)") + stdout := fs.Bool("stdout", false, "Output to stdout instead of modifying files") + version := fs.Bool("version", false, "Show version and exit") + + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return 0 } - if len(files) == 0 { + return 2 + } + + if *version { + fmt.Println(Version) + return 0 + } + + remaining := fs.Args() + + if *filesMode { + if len(remaining) == 0 { fmt.Fprintln(os.Stderr, "Error: --files requires at least one file argument") return 2 } - return process(files, stdout) + return process(remaining, *stdout) } - // Chart directory mode: - if len(args) != 1 { - usage() + // Chart directory mode + if len(remaining) != 1 { + fs.Usage() return 2 } - root := filepath.Join(args[0], "templates") + + root := filepath.Join(remaining[0], "templates") if _, err := os.Stat(root); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) return 1 @@ -60,11 +79,6 @@ func run(args []string) int { return process(files, false) } -func usage() { - prog := filepath.Base(os.Args[0]) - fmt.Fprintf(os.Stderr, "Usage: %s OR %s --files ... [--stdout]\n", prog, prog) -} - func collectFiles(root string) ([]string, error) { var out []string err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { From e5888612ff36893f4dba92dfb8e82b93817f082b Mon Sep 17 00:00:00 2001 From: Digital Studium Date: Tue, 23 Sep 2025 06:44:00 +0300 Subject: [PATCH 2/7] Biuld on PR --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e254e00..47930fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,11 @@ on: push: tags: - "v*" + branches: + - main + pull_request: + branches: + - main jobs: goreleaser: From 81d593c81781c7759e384d9378c2c51e17ecc776 Mon Sep 17 00:00:00 2001 From: Digital Studium Date: Tue, 23 Sep 2025 06:46:45 +0300 Subject: [PATCH 3/7] Biuld snapshot --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47930fc..fb9c9d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,6 @@ jobs: with: distribution: goreleaser version: latest - args: release --clean + args: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || 'build --snapshot' }} --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From e1689fe7c96c5ff6f8b5044032214e0286379a47 Mon Sep 17 00:00:00 2001 From: Digital Studium Date: Tue, 23 Sep 2025 06:51:05 +0300 Subject: [PATCH 4/7] Add VERSION --- .github/workflows/release.yml | 13 ++++++++++++- VERSION | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 VERSION diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb9c9d0..11f1a9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,10 +23,21 @@ jobs: with: go-version: "1.25" + - name: Set version + id: version + run: | + if [[ "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]]; then + echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT + else + VERSION=$(cat VERSION) + echo "version=v${VERSION}-dev-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + fi + - uses: goreleaser/goreleaser-action@v5 with: distribution: goreleaser - version: latest + version: v1.26.2 args: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || 'build --snapshot' }} --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }} diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.2.0 From 222921ec3a992409202bbe40b6699f61cefb6e64 Mon Sep 17 00:00:00 2001 From: Digital Studium Date: Tue, 23 Sep 2025 06:54:55 +0300 Subject: [PATCH 5/7] Fix version format --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11f1a9b..c785e8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: with: distribution: goreleaser version: v1.26.2 - args: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || 'build --snapshot' }} --clean + args: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || 'build --snapshot --skip-validate' }} --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }} From 8fa219532fdff30c2c7bbc4563b8550e7f68e4bb Mon Sep 17 00:00:00 2001 From: Digital Studium Date: Tue, 23 Sep 2025 06:57:20 +0300 Subject: [PATCH 6/7] Fix version --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c785e8f..b1d7127 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,14 +30,14 @@ jobs: echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT else VERSION=$(cat VERSION) - echo "version=v${VERSION}-dev-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + echo "version=v${VERSION}" >> $GITHUB_OUTPUT fi - uses: goreleaser/goreleaser-action@v5 with: distribution: goreleaser version: v1.26.2 - args: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || 'build --snapshot --skip-validate' }} --clean + args: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || 'build --snapshot' }} --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }} From 3715a5bbae169b57103a55762e74042c68de63ce Mon Sep 17 00:00:00 2001 From: Digital Studium Date: Tue, 23 Sep 2025 09:24:15 +0300 Subject: [PATCH 7/7] Update readme --- README.md | 111 ++++++++++- format.go | 74 +++++-- go.mod | 3 + go.sum | 6 + main.go | 185 ++++++++++++------ templates_test.go | 81 ++++---- templates_test/control_with_comment.yaml | 12 +- templates_test/include_disabled.yaml | 8 + templates_test/include_excluded.yaml | 8 + .../mixed_with_yaml_and_toYaml.yaml | 30 +-- ...nested_control_with_multiline_comment.yaml | 22 +-- templates_test/plain_yaml.yaml | 12 +- .../templates/control_with_comment.yaml | 4 + templates_test/templates/include_normal.yaml | 6 + .../templates/mixed_with_yaml_and_toYaml.yaml | 13 ++ ...nested_control_with_multiline_comment.yaml | 9 + templates_test/templates/plain_yaml.yaml | 4 + .../templates/tests/include_excluded.yaml | 6 + .../control_with_comment.yaml | 4 + .../templates_expected/include_normal.yaml | 6 + .../mixed_with_yaml_and_toYaml.yaml | 13 ++ ...nested_control_with_multiline_comment.yaml | 9 + .../templates_expected/plain_yaml.yaml | 4 + .../tests/include_excluded.yaml | 6 + 24 files changed, 447 insertions(+), 189 deletions(-) create mode 100644 templates_test/include_disabled.yaml create mode 100644 templates_test/include_excluded.yaml create mode 100644 templates_test/templates/control_with_comment.yaml create mode 100644 templates_test/templates/include_normal.yaml create mode 100644 templates_test/templates/mixed_with_yaml_and_toYaml.yaml create mode 100644 templates_test/templates/nested_control_with_multiline_comment.yaml create mode 100644 templates_test/templates/plain_yaml.yaml create mode 100644 templates_test/templates/tests/include_excluded.yaml create mode 100644 templates_test/templates_expected/control_with_comment.yaml create mode 100644 templates_test/templates_expected/include_normal.yaml create mode 100644 templates_test/templates_expected/mixed_with_yaml_and_toYaml.yaml create mode 100644 templates_test/templates_expected/nested_control_with_multiline_comment.yaml create mode 100644 templates_test/templates_expected/plain_yaml.yaml create mode 100644 templates_test/templates_expected/tests/include_excluded.yaml diff --git a/README.md b/README.md index d773183..54f0644 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ Download it from [releases](https://github.com/digitalstudium/helmfmt/releases) ```bash helmfmt helmfmt --files ... +helmfmt --files ... --stdout +helmfmt --disable-indent=template,include +helmfmt --enable-indent=toYaml --files ... ``` Example run: @@ -127,6 +130,112 @@ Processed: 2, Updated: 1, Errors: 0 --- +## Configuration + +`helmfmt` can be configured using a `.helmfmt` file in JSON format. The tool looks for configuration files in this order: + +1. `./.helmfmt` (project directory) +2. `~/.helmfmt` (home directory) + +### Default Configuration + +```json +{ + "indent_size": 2, + "extensions": [".yaml", ".yml", ".tpl"], + "rules": { + "indent": { + "tpl": { + "disabled": true, + "exclude": [] + }, + "toYaml": { + "disabled": true, + "exclude": [] + }, + "template": { + "disabled": false, + "exclude": [] + }, + "printf": { + "disabled": false, + "exclude": [] + }, + "include": { + "disabled": false, + "exclude": [] + }, + "fail": { + "disabled": false, + "exclude": [] + } + } + } +} +``` + +### Rule Configuration + +Each rule can be configured with: + +- **`disabled`**: Set to `true` to disable the rule entirely +- **`exclude`**: Array of file patterns to exclude from this rule + +### Example Configurations + +**Enable `tpl` and `toYaml` indentation:** + +```json +{ + "rules": { + "indent": { + "tpl": { + "disabled": false + }, + "toYaml": { + "disabled": false + } + } + } +} +``` + +**Exclude test files from `include` indentation:** + +```json +{ + "rules": { + "indent": { + "include": { + "exclude": ["tests/*", "**/test-*.yaml"] + } + } + } +} +``` + +**Use 4 spaces for indentation:** + +```json +{ + "indent_size": 4 +} +``` + +### Command-line Rule Overrides + +You can override configuration rules using command-line flags: + +```bash +# Disable specific rules +helmfmt --disable-indent=template,include + +# Enable specific rules (overrides config file) +helmfmt --enable-indent=tpl,toYaml +``` + +--- + ## pre-commit hook configuration To use `helmfmt` as a pre-commit hook, add the following to your `.pre-commit-config.yaml`: @@ -134,7 +243,7 @@ To use `helmfmt` as a pre-commit hook, add the following to your `.pre-commit-co ```yaml repos: - repo: https://github.com/digitalstudium/helmfmt - rev: v0.1.1 + rev: v0.2.0 hooks: - id: helmfmt ``` diff --git a/format.go b/format.go index a22abec..be2ccda 100644 --- a/format.go +++ b/format.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "path/filepath" "regexp" "strings" "text/template" @@ -11,8 +12,6 @@ import ( _ "helm.sh/helm/v3/pkg/engine" // Import to work with Helm's private functions (via go linkname) ) -const indentStep = 2 // Number of spaces per indentation level - // Типы токенов для ясности, что мы нашли type tokenKind int @@ -37,7 +36,6 @@ var ( controlRe = regexp.MustCompile(`^\s*(if|range|with|define|block)\b`) elseRe = regexp.MustCompile(`^\s*else\b`) endRe = regexp.MustCompile(`^\s*end\b`) - simpleRe = regexp.MustCompile(`^\s*(include|fail|printf)\b`) endInLineRe = regexp.MustCompile(`\{\{(-?)(\s*)end\b[^}]*(-?)\}\}`) // Для извлечения первого слова @@ -64,7 +62,7 @@ func validateTemplateSyntax(src string) error { } // Главная функция выравнивания -func formatIndentation(src string) string { +func formatIndentation(src string, config *Config, filePath string) string { lines := strings.Split(src, "\n") depth := 0 @@ -81,11 +79,23 @@ func formatIndentation(src string) string { _, _, _ = skipLeadingBlockComment(lines, i) } - _, _, endLine, kind, found := getTokenAtLineStartSkippingLeadingComments(lines, i) + keyword, _, endLine, kind, found := getTokenAtLineStartSkippingLeadingComments(lines, i, config) if !found { continue } + // Check if we should skip indenting this simple function + if kind == tokSimple { + ruleName := getRuleName(keyword, kind) + if ruleName != "" { + rule := config.Rules.Indent[ruleName] // Updated access pattern + if rule.Disabled || matchesExcludePattern(filePath, rule.Exclude) { + i = endLine + continue // Skip indenting this token + } + } + } + // Вычисляем уровень отступа level := depth if kind == tokElse || kind == tokEnd { @@ -94,15 +104,13 @@ func formatIndentation(src string) string { } } - indent := strings.Repeat(" ", level*indentStep) - - // Применяем отступ ко всем строкам, начиная с комментария (если есть) - actualStartLine := commentStart - for j := actualStartLine; j <= endLine && j < len(lines); j++ { + // Apply indentation + indent := strings.Repeat(" ", level*config.IndentSize) + for j := commentStart; j <= endLine && j < len(lines); j++ { lines[j] = indent + strings.TrimLeft(lines[j], " \t") } - // Меняем глубину после обработки текущего тега + // Always update depth for control structures switch kind { case tokControlOpen: depth++ @@ -119,7 +127,7 @@ func formatIndentation(src string) string { } // Ищем токен в начале строки, пропуская ведущий комментарий -func getTokenAtLineStartSkippingLeadingComments(lines []string, start int) (keyword string, startLine int, endLine int, kind tokenKind, found bool) { +func getTokenAtLineStartSkippingLeadingComments(lines []string, start int, config *Config) (keyword string, startLine int, endLine int, kind tokenKind, found bool) { i := start for { @@ -153,16 +161,16 @@ func getTokenAtLineStartSkippingLeadingComments(lines []string, start int) (keyw } // Парсим токен из остатка строки - return parseTokenFromLine(lines, ci, remainder) + return parseTokenFromLine(lines, ci, remainder, config) } // Парсим токен напрямую - return parseTokenFromLine(lines, i, line) + return parseTokenFromLine(lines, i, line, config) } } // Парсинг токена из строки -func parseTokenFromLine(lines []string, lineIdx int, line string) (keyword string, startLine int, endLine int, kind tokenKind, found bool) { +func parseTokenFromLine(lines []string, lineIdx int, line string, config *Config) (keyword string, startLine int, endLine int, kind tokenKind, found bool) { // Извлекаем содержимое после {{ или {{- match := tagOpenRe.FindStringSubmatch(line) if match == nil { @@ -198,12 +206,16 @@ func parseTokenFromLine(lines []string, lineIdx int, line string) (keyword strin end := findTagEndMultiline(lines, lineIdx, line) return "end", lineIdx, end, tokEnd, true - case simpleRe.MatchString(content): - matches := simpleRe.FindStringSubmatch(content) - keyword := matches[1] + default: + // Check if first word has a rule defined - if so, treat as simple + if matches := firstWordRe.FindStringSubmatch(content); matches != nil { + keyword := matches[1] - end := findTagEndMultiline(lines, lineIdx, line) - return keyword, lineIdx, end, tokSimple, true + if _, hasRule := config.Rules.Indent[keyword]; hasRule { // Updated access pattern + end := findTagEndMultiline(lines, lineIdx, line) + return keyword, lineIdx, end, tokSimple, true + } + } } return "", lineIdx, lineIdx, tokNone, false @@ -288,3 +300,25 @@ func hasEndInRange(lines []string, start, end int) bool { } return false } + +func matchesExcludePattern(filePath string, patterns []string) bool { + for _, pattern := range patterns { + // Convert glob pattern to regex + if matched, _ := filepath.Match(pattern, filePath); matched { + return true + } + + // Also support regex patterns + if regexp.MustCompile(pattern).MatchString(filePath) { + return true + } + } + return false +} + +func getRuleName(keyword string, kind tokenKind) string { + if kind == tokSimple { + return keyword // Just return the keyword (printf, include, fail) + } + return "" +} diff --git a/go.mod b/go.mod index 4796d01..d17cebb 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/digitalstudium/helmfmt go 1.25.1 require ( + github.com/spf13/cobra v1.10.1 gopkg.in/yaml.v3 v3.0.1 helm.sh/helm/v3 v3.19.0 ) @@ -26,6 +27,7 @@ require ( github.com/google/gnostic-models v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -38,6 +40,7 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.7.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index 89d2c1a..2ea1919 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,7 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= @@ -52,6 +53,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -90,12 +93,15 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/main.go b/main.go index 29cd606..ecaad79 100644 --- a/main.go +++ b/main.go @@ -1,92 +1,163 @@ package main import ( - "flag" + "encoding/json" "fmt" "io/fs" "os" "path/filepath" "strings" + + "github.com/spf13/cobra" ) // Version can be set at build time with -ldflags "-X main.Version=v1.2.3" var Version = "dev" -func main() { - os.Exit(run(os.Args[1:])) +type Config struct { + IndentSize int `json:"indent_size"` + Extensions []string `json:"extensions"` + Rules RulesConfig `json:"rules"` +} + +type RulesConfig struct { + Indent map[string]RuleConfig `json:"indent"` +} + +type RuleConfig struct { + Disabled bool `json:"disabled"` + Exclude []string `json:"exclude"` } -func run(args []string) int { - // Handle version subcommand early - if len(args) > 0 && args[0] == "version" { - fmt.Println(Version) - return 0 +func loadConfig() *Config { + // Default config + config := &Config{ + IndentSize: 2, + Extensions: []string{".yaml", ".yml", ".tpl"}, + Rules: RulesConfig{ + Indent: map[string]RuleConfig{ + "tpl": {Disabled: true, Exclude: []string{}}, + "toYaml": {Disabled: true, Exclude: []string{}}, + "template": {Disabled: false, Exclude: []string{}}, + "printf": {Disabled: false, Exclude: []string{}}, + "include": {Disabled: false, Exclude: []string{}}, + "fail": {Disabled: false, Exclude: []string{}}, + }, + }, } - fs := flag.NewFlagSet(filepath.Base(os.Args[0]), flag.ContinueOnError) - fs.Usage = func() { - prog := fs.Name() - fmt.Fprintf(fs.Output(), "Usage: %s [--stdout] --files ...\n", prog) - fmt.Fprintf(fs.Output(), " OR: %s \n", prog) - fmt.Fprintf(fs.Output(), " OR: %s version\n", prog) - fmt.Fprintf(fs.Output(), "\nFlags:\n") - fs.PrintDefaults() + // Try to load from home directory first + if homeDir, err := os.UserHomeDir(); err == nil { + homeConfigPath := filepath.Join(homeDir, ".helmfmt") + loadConfigFile(homeConfigPath, config) } - filesMode := fs.Bool("files", false, "Process specific files (remaining args are file paths)") - stdout := fs.Bool("stdout", false, "Output to stdout instead of modifying files") - version := fs.Bool("version", false, "Show version and exit") + // Try to load from current directory (overrides home config) + loadConfigFile(".helmfmt", config) - if err := fs.Parse(args); err != nil { - if err == flag.ErrHelp { - return 0 - } - return 2 + return config +} + +func loadConfigFile(path string, config *Config) { + data, err := os.ReadFile(path) + if err != nil { + return // File doesn't exist, skip silently } - if *version { - fmt.Println(Version) - return 0 + if err := json.Unmarshal(data, config); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Error parsing config from %s: %v\n", path, err) } +} - remaining := fs.Args() +func main() { + os.Exit(run()) +} - if *filesMode { - if len(remaining) == 0 { - fmt.Fprintln(os.Stderr, "Error: --files requires at least one file argument") - return 2 - } - return process(remaining, *stdout) +func run() int { + config := loadConfig() + var stdout, files bool + var disableRules, enableRules []string + + var rootCmd = &cobra.Command{ + Use: "helmfmt [flags] [chart-path | file1 file2 ...]", + Short: "Format Helm templates", + Version: Version, + RunE: func(cmd *cobra.Command, args []string) error { + // Apply rule overrides from flags + for _, rule := range disableRules { + if _, exists := config.Rules.Indent[rule]; exists { // Updated access pattern + ruleConfig := config.Rules.Indent[rule] + ruleConfig.Disabled = true + config.Rules.Indent[rule] = ruleConfig + } else { + return fmt.Errorf("unknown rule: %s", rule) + } + } + + for _, rule := range enableRules { + if _, exists := config.Rules.Indent[rule]; exists { // Updated access pattern + ruleConfig := config.Rules.Indent[rule] + ruleConfig.Disabled = false + config.Rules.Indent[rule] = ruleConfig + } else { + return fmt.Errorf("unknown rule: %s", rule) + } + } + + if files { + // Files mode + if len(args) == 0 { + return fmt.Errorf("--files requires at least one file argument") + } + exitCode := process(args, stdout, config) + if exitCode != 0 { + os.Exit(exitCode) + } + return nil + } + + // Chart mode + if len(args) != 1 { + return fmt.Errorf("chart mode requires exactly one chart path") + } + + root := filepath.Join(args[0], "templates") + if _, err := os.Stat(root); err != nil { + return err + } + + chartFiles, err := collectFiles(root, config) + if err != nil { + return err + } + exitCode := process(chartFiles, false, config) + if exitCode != 0 { + os.Exit(exitCode) + } + return nil + }, } - // Chart directory mode - if len(remaining) != 1 { - fs.Usage() - return 2 - } + rootCmd.Flags().BoolVar(&files, "files", false, "Process specific files") + rootCmd.Flags().BoolVar(&stdout, "stdout", false, "Output to stdout") + rootCmd.Flags().StringSliceVar(&disableRules, "disable-indent", []string{}, "Disable specific indent rules (e.g., --disable-indent=printf,include)") + rootCmd.Flags().StringSliceVar(&enableRules, "enable-indent", []string{}, "Enable specific indent rules (e.g., --enable-indent=printf,include)") - root := filepath.Join(remaining[0], "templates") - if _, err := os.Stat(root); err != nil { + if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) return 1 } - - files, err := collectFiles(root) - if err != nil { - fmt.Fprintf(os.Stderr, "WalkDir error: %v\n", err) - return 1 - } - return process(files, false) + return 0 } -func collectFiles(root string) ([]string, error) { +func collectFiles(root string, config *Config) ([]string, error) { var out []string err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { fmt.Fprintf(os.Stderr, "Walk error at %s: %v\n", path, err) return nil } - if d.IsDir() || !wanted(path) { + if d.IsDir() || !wanted(path, config) { return nil } out = append(out, path) @@ -95,7 +166,7 @@ func collectFiles(root string) ([]string, error) { return out, err } -func process(files []string, stdout bool) int { +func process(files []string, stdout bool, config *Config) int { var total, updated, failed int for _, file := range files { @@ -115,7 +186,7 @@ func process(files []string, stdout bool) int { continue } - formatted := ensureTrailingNewline(formatIndentation(orig)) + formatted := ensureTrailingNewline(formatIndentation(orig, config, file)) if stdout { fmt.Print(formatted) @@ -152,10 +223,12 @@ func process(files []string, stdout bool) int { return 0 } -func wanted(path string) bool { - switch strings.ToLower(filepath.Ext(path)) { - case ".yaml", ".yml", ".tpl": - return true +func wanted(path string, config *Config) bool { + ext := strings.ToLower(filepath.Ext(path)) + for _, validExt := range config.Extensions { + if ext == validExt { + return true + } } return false } diff --git a/templates_test.go b/templates_test.go index 626a498..60ea37a 100644 --- a/templates_test.go +++ b/templates_test.go @@ -1,8 +1,6 @@ package main import ( - "bytes" - "io" "os" "path/filepath" "testing" @@ -11,10 +9,10 @@ import ( ) type TestCase struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Input string `yaml:"input"` - Expected string `yaml:"expected"` + Name string `yaml:"name"` + Config *Config `yaml:"config,omitempty"` // Full config structure + InputFile string `yaml:"input_file"` + ExpectedFile string `yaml:"expected_file"` } func TestFormatIndentationFromTemplates(t *testing.T) { @@ -42,53 +40,48 @@ func TestFormatIndentationFromTemplates(t *testing.T) { } t.Logf("Running test: %s", testCase.Name) - if testCase.Description != "" { - t.Logf("Description: %s", testCase.Description) - } - // Create temp file with test input - tmpfile, err := os.CreateTemp("", "helmfmt-test-*.yaml") - if err != nil { - t.Fatal(err) + // Load default config and apply test-specific overrides + config := loadConfig() + if testCase.Config != nil { + // Merge test config with default config + if testCase.Config.IndentSize != 0 { + config.IndentSize = testCase.Config.IndentSize + } + if testCase.Config.Extensions != nil { + config.Extensions = testCase.Config.Extensions + } + if testCase.Config.Rules.Indent != nil { + // Merge indent rules + for ruleName, ruleConfig := range testCase.Config.Rules.Indent { + config.Rules.Indent[ruleName] = ruleConfig + } + } } - defer os.Remove(tmpfile.Name()) // cleanup after test - defer tmpfile.Close() - if _, err := tmpfile.Write([]byte(testCase.Input)); err != nil { - t.Fatal(err) - } - if err := tmpfile.Close(); err != nil { - t.Fatal(err) + // Read input file + inputPath := filepath.Join(testDir, testCase.InputFile) + inputContent, err := os.ReadFile(inputPath) + if err != nil { + t.Fatalf("Failed to read input file %s: %v", inputPath, err) } - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - // Call the REAL process function from main.go with stdout=true - exitCode := process([]string{tmpfile.Name()}, true) - - // Close writer, restore stdout - w.Close() - os.Stdout = oldStdout - - // Read captured output - var buf bytes.Buffer - if _, err := io.Copy(&buf, r); err != nil { - t.Fatal(err) + // Read expected file + expectedPath := filepath.Join(testDir, testCase.ExpectedFile) + expectedContent, err := os.ReadFile(expectedPath) + if err != nil { + t.Fatalf("Failed to read expected file %s: %v", expectedPath, err) } - result := buf.String() - // Check for processing errors - if exitCode != 0 { - t.Fatalf("process() failed with exit code %d for test '%s'", exitCode, testCase.Name) - } + // Format using the input file path for exclusion matching + result := formatIndentation(string(inputContent), config, testCase.InputFile) + result = ensureTrailingNewline(result) + expected := string(expectedContent) // Compare result - if result != testCase.Expected { - t.Errorf("Test '%s' failed\nInput:\n%s\n\nExpected:\n%s\n\nGot:\n%s", - testCase.Name, testCase.Input, testCase.Expected, result) + if result != expected { + t.Errorf("Test '%s' failed\nInput file: %s\nExpected file: %s\nExpected:\n%s\n\nGot:\n%s", + testCase.Name, testCase.InputFile, testCase.ExpectedFile, expected, result) } }) } diff --git a/templates_test/control_with_comment.yaml b/templates_test/control_with_comment.yaml index 549f6f2..717d025 100644 --- a/templates_test/control_with_comment.yaml +++ b/templates_test/control_with_comment.yaml @@ -1,11 +1,3 @@ name: "Control structure with comment" -input: | - {{- if .Values.enabled }} - {{/* This is a comment */}} - {{- $var := .Values.someValue }} - {{- end }} -expected: | - {{- if .Values.enabled }} - {{/* This is a comment */}} - {{- $var := .Values.someValue }} - {{- end }} +input_file: "templates/control_with_comment.yaml" +expected_file: "templates_expected/control_with_comment.yaml" diff --git a/templates_test/include_disabled.yaml b/templates_test/include_disabled.yaml new file mode 100644 index 0000000..1873c82 --- /dev/null +++ b/templates_test/include_disabled.yaml @@ -0,0 +1,8 @@ +name: "Include directive with disabled indentation" +config: + rules: + indent: + include: + disabled: true +input_file: "templates/include_normal.yaml" +expected_file: "templates_expected/tests/include_excluded.yaml" diff --git a/templates_test/include_excluded.yaml b/templates_test/include_excluded.yaml new file mode 100644 index 0000000..ae21bae --- /dev/null +++ b/templates_test/include_excluded.yaml @@ -0,0 +1,8 @@ +name: "Include directive with file exclusion" +config: + rules: + indent: + include: + exclude: ["tests/*"] +input_file: "templates/tests/include_excluded.yaml" +expected_file: "templates_expected/tests/include_excluded.yaml" diff --git a/templates_test/mixed_with_yaml_and_toYaml.yaml b/templates_test/mixed_with_yaml_and_toYaml.yaml index 572734c..20d6553 100644 --- a/templates_test/mixed_with_yaml_and_toYaml.yaml +++ b/templates_test/mixed_with_yaml_and_toYaml.yaml @@ -1,29 +1,3 @@ name: "Mixed with yaml and toYaml" -input: | - {{- if .Values.createNamespace }} - {{- range .Values.namespaces }} - apiVersion: v1 - kind: Namespace - metadata: - name: {{ . }} - {{- with $.Values.namespaceLabels }} - labels: - {{ toYaml . | indent 4 }} - {{- end }} - --- - {{- end }} - {{- end }} -expected: | - {{- if .Values.createNamespace }} - {{- range .Values.namespaces }} - apiVersion: v1 - kind: Namespace - metadata: - name: {{ . }} - {{- with $.Values.namespaceLabels }} - labels: - {{ toYaml . | indent 4 }} - {{- end }} - --- - {{- end }} - {{- end }} +input_file: "templates/mixed_with_yaml_and_toYaml.yaml" +expected_file: "templates_expected/mixed_with_yaml_and_toYaml.yaml" diff --git a/templates_test/nested_control_with_multiline_comment.yaml b/templates_test/nested_control_with_multiline_comment.yaml index 9e743b2..4d8bcc7 100644 --- a/templates_test/nested_control_with_multiline_comment.yaml +++ b/templates_test/nested_control_with_multiline_comment.yaml @@ -1,21 +1,3 @@ name: "Nested control structure with multiline comment" -input: | - {{- if .Values.enabled }} - {{ range $foobar := .Values.list }} - {{/* - This is - a multiline comment - */}} - {{- $var := .Values.someValue }} - {{- end }} - {{- end }} -expected: | - {{- if .Values.enabled }} - {{ range $foobar := .Values.list }} - {{/* - This is - a multiline comment - */}} - {{- $var := .Values.someValue }} - {{- end }} - {{- end }} +input_file: "templates/nested_control_with_multiline_comment.yaml" +expected_file: "templates_expected/nested_control_with_multiline_comment.yaml" diff --git a/templates_test/plain_yaml.yaml b/templates_test/plain_yaml.yaml index 33a161c..f39d4f0 100644 --- a/templates_test/plain_yaml.yaml +++ b/templates_test/plain_yaml.yaml @@ -1,11 +1,3 @@ name: "Plain yaml" -input: | - apiVersion: v1 - kind: Namespace - metadata: - name: my-namespace -expected: | - apiVersion: v1 - kind: Namespace - metadata: - name: my-namespace +input_file: "templates/plain_yaml.yaml" +expected_file: "templates_expected/plain_yaml.yaml" diff --git a/templates_test/templates/control_with_comment.yaml b/templates_test/templates/control_with_comment.yaml new file mode 100644 index 0000000..f5bf8ea --- /dev/null +++ b/templates_test/templates/control_with_comment.yaml @@ -0,0 +1,4 @@ +{{- if .Values.enabled }} +{{/* This is a comment */}} +{{- $var := .Values.someValue }} +{{- end }} diff --git a/templates_test/templates/include_normal.yaml b/templates_test/templates/include_normal.yaml new file mode 100644 index 0000000..a643494 --- /dev/null +++ b/templates_test/templates/include_normal.yaml @@ -0,0 +1,6 @@ +{{- if .Values.enabled }} +{{ include "mytemplate" . }} +{{- range .Values.items }} +{{- include "itemtemplate" . }} +{{- end }} +{{- end }} diff --git a/templates_test/templates/mixed_with_yaml_and_toYaml.yaml b/templates_test/templates/mixed_with_yaml_and_toYaml.yaml new file mode 100644 index 0000000..f56741d --- /dev/null +++ b/templates_test/templates/mixed_with_yaml_and_toYaml.yaml @@ -0,0 +1,13 @@ +{{- if .Values.createNamespace }} +{{- range .Values.namespaces }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ . }} +{{- with $.Values.namespaceLabels }} + labels: +{{ toYaml . | indent 4 }} +{{- end }} +--- +{{- end }} +{{- end }} diff --git a/templates_test/templates/nested_control_with_multiline_comment.yaml b/templates_test/templates/nested_control_with_multiline_comment.yaml new file mode 100644 index 0000000..bd9b15d --- /dev/null +++ b/templates_test/templates/nested_control_with_multiline_comment.yaml @@ -0,0 +1,9 @@ +{{- if .Values.enabled }} +{{ range $foobar := .Values.list }} +{{/* +This is +a multiline comment +*/}} +{{- $var := .Values.someValue }} +{{- end }} +{{- end }} diff --git a/templates_test/templates/plain_yaml.yaml b/templates_test/templates/plain_yaml.yaml new file mode 100644 index 0000000..4cb279b --- /dev/null +++ b/templates_test/templates/plain_yaml.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: my-namespace diff --git a/templates_test/templates/tests/include_excluded.yaml b/templates_test/templates/tests/include_excluded.yaml new file mode 100644 index 0000000..a643494 --- /dev/null +++ b/templates_test/templates/tests/include_excluded.yaml @@ -0,0 +1,6 @@ +{{- if .Values.enabled }} +{{ include "mytemplate" . }} +{{- range .Values.items }} +{{- include "itemtemplate" . }} +{{- end }} +{{- end }} diff --git a/templates_test/templates_expected/control_with_comment.yaml b/templates_test/templates_expected/control_with_comment.yaml new file mode 100644 index 0000000..8f1e900 --- /dev/null +++ b/templates_test/templates_expected/control_with_comment.yaml @@ -0,0 +1,4 @@ +{{- if .Values.enabled }} + {{/* This is a comment */}} + {{- $var := .Values.someValue }} +{{- end }} diff --git a/templates_test/templates_expected/include_normal.yaml b/templates_test/templates_expected/include_normal.yaml new file mode 100644 index 0000000..9b78388 --- /dev/null +++ b/templates_test/templates_expected/include_normal.yaml @@ -0,0 +1,6 @@ +{{- if .Values.enabled }} + {{ include "mytemplate" . }} + {{- range .Values.items }} + {{- include "itemtemplate" . }} + {{- end }} +{{- end }} diff --git a/templates_test/templates_expected/mixed_with_yaml_and_toYaml.yaml b/templates_test/templates_expected/mixed_with_yaml_and_toYaml.yaml new file mode 100644 index 0000000..e4b5d52 --- /dev/null +++ b/templates_test/templates_expected/mixed_with_yaml_and_toYaml.yaml @@ -0,0 +1,13 @@ +{{- if .Values.createNamespace }} + {{- range .Values.namespaces }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ . }} + {{- with $.Values.namespaceLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} +--- + {{- end }} +{{- end }} diff --git a/templates_test/templates_expected/nested_control_with_multiline_comment.yaml b/templates_test/templates_expected/nested_control_with_multiline_comment.yaml new file mode 100644 index 0000000..5aeb829 --- /dev/null +++ b/templates_test/templates_expected/nested_control_with_multiline_comment.yaml @@ -0,0 +1,9 @@ +{{- if .Values.enabled }} + {{ range $foobar := .Values.list }} + {{/* + This is + a multiline comment + */}} + {{- $var := .Values.someValue }} + {{- end }} +{{- end }} diff --git a/templates_test/templates_expected/plain_yaml.yaml b/templates_test/templates_expected/plain_yaml.yaml new file mode 100644 index 0000000..4cb279b --- /dev/null +++ b/templates_test/templates_expected/plain_yaml.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: my-namespace diff --git a/templates_test/templates_expected/tests/include_excluded.yaml b/templates_test/templates_expected/tests/include_excluded.yaml new file mode 100644 index 0000000..af50524 --- /dev/null +++ b/templates_test/templates_expected/tests/include_excluded.yaml @@ -0,0 +1,6 @@ +{{- if .Values.enabled }} +{{ include "mytemplate" . }} + {{- range .Values.items }} +{{- include "itemtemplate" . }} + {{- end }} +{{- end }}