├── .github ├── dependabot.yml └── workflows │ └── go.yml ├── .gitignore ├── .golangci.yml ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── _examples ├── README.md ├── build-examples.sh ├── clean.sh ├── cleanenv │ ├── config.go │ └── doc.txt ├── customization │ ├── config.go │ └── doc.md ├── embedded │ ├── config.go │ └── doc.md ├── envprefix │ ├── config.go │ └── envprefix.md ├── field-names │ ├── config.go │ └── doc.md ├── full │ ├── config.go │ └── doc.md ├── project │ ├── config │ │ ├── cfg.go │ │ └── logging.go │ ├── db │ │ └── cfg.go │ ├── go.mod │ └── server │ │ ├── cfg.go │ │ └── timeout_cfg.go └── simple │ ├── config.go │ ├── doc.env │ ├── doc.html │ ├── doc.json │ ├── doc.md │ └── doc.txt ├── ast ├── collectors.go ├── collectors_test.go ├── debug.go ├── debug_coverage.go ├── errors.go ├── field.go ├── fieldtyperefkind_scan.go ├── fieldtyperefkind_string.go ├── file.go ├── file_test.go ├── model.go ├── parser.go ├── parser_test.go ├── pkg.go ├── pkg_test.go ├── testdata │ ├── empty.go │ ├── fields.go │ ├── onetype.go │ ├── parser │ │ ├── anonymous.txtar │ │ ├── arrays.txtar │ │ ├── comments.txtar │ │ ├── embedded.txtar │ │ ├── envprefix.txtar │ │ ├── field_names.txtar │ │ ├── funcs.txtar │ │ ├── go_generate.txtar │ │ ├── multifile.txtar │ │ ├── nodocs.txtar │ │ ├── simple.txtar │ │ ├── tags.txtar │ │ ├── type.txtar │ │ ├── typedef.txtar │ │ └── unexported.txtar │ └── twotypes.go ├── testhelper.go ├── type.go ├── types_test.go ├── utils.go └── walker.go ├── config.go ├── config_test.go ├── converter.go ├── converter_test.go ├── debug.go ├── debug ├── config.go ├── logger.go ├── logger_cov.go └── logger_nocov.go ├── debug_coverage.go ├── debug_nocoverage.go ├── doc.go ├── docenv ├── .gitignore ├── README.md └── main.go ├── field_decoder.go ├── field_decoder_test.go ├── generator.go ├── generator_test.go ├── go.mod ├── go.sum ├── linter ├── analyzer.go ├── linter.go ├── linter_test.go └── testdata │ ├── custom.go │ └── simple.go ├── main.go ├── main_test.go ├── render ├── config.go ├── renderer.go ├── renderer_test.go ├── templ │ ├── dotenv.tmpl │ ├── helpers.tmpl │ ├── html.tmpl │ ├── json.tmpl │ ├── markdown.tmpl │ └── plaintext.tmpl ├── templates.go └── templates_test.go ├── resolver ├── resolver.go └── resolver_test.go ├── tags ├── parser.go └── tags_test.go ├── testdata ├── embedded.txtar ├── envprefix.txtar ├── field-names.txtar └── simple.txtar ├── testfile.go ├── testutils └── assert.go ├── types ├── model.go └── targettype_string.go └── utils ├── caseconv.go ├── glob.go └── utils_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "gomod" 7 | directory: "/" 8 | labels: 9 | - "deps" 10 | assignees: 11 | - "g4s8" 12 | schedule: 13 | interval: "daily" 14 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | "on": 3 | push: 4 | branches: ["master"] 5 | pull_request: 6 | branches: ["master"] 7 | jobs: 8 | check: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-go@v3 13 | with: 14 | go-version: '1.24' 15 | - uses: actions/cache@v3 16 | with: 17 | path: ~/go/pkg/mod 18 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 19 | restore-keys: | 20 | ${{ runner.os }}-go- 21 | - name: Build 22 | run: go build -v ./... 23 | - name: Test 24 | run: go test -v ./... 25 | - name: Check examples 26 | run: | 27 | ./_examples/clean.sh 28 | ./_examples/build-examples.sh 29 | if $(git diff --quiet); then 30 | echo "examples are clean" 31 | else 32 | echo "examples are dirty, rebuild it locally before commiting" 33 | git diff | cat 34 | exit 1 35 | fi 36 | - name: Coverage report 37 | run: | 38 | go test -v -covermode=count -coverprofile=coverage.out -tags coverage ./... 39 | go tool cover -func=coverage.out 40 | - name: Upload coverage reports to Codecov 41 | uses: codecov/codecov-action@v3 42 | env: 43 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 44 | - name: Vet 45 | run: go vet 46 | - name: golangci-lint 47 | uses: golangci/golangci-lint-action@v3 48 | with: 49 | version: v1.64.7 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.gitstrap.yml 2 | /envdoc 3 | coverage.out 4 | test.cover 5 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - revive 4 | - staticcheck 5 | - errcheck 6 | - unused 7 | - errorlint 8 | - gofumpt 9 | - gocyclo 10 | - cyclop 11 | 12 | exclude: > 13 | ^(?:.*\/usr\/lib\/go\/src\/.*|.*\/vendor\/.*|.*\/testdata\/.*|.*\/_examples\/.*)$ 14 | 15 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | This project adheres to No Code of Conduct. We are all adults. We accept anyone's contributions. Nothing else matters. 4 | 5 | For more information please visit the [No Code of Conduct](https://github.com/domgetter/NCoC) homepage. 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kirill Che. 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # envdoc 2 | 3 | envdoc is a tool for generating documentation for environment variables in Go structs. 4 | It takes comments associated with `env` tags in Go structs and creates a Markdown, plaintext or HTML 5 | file with detailed documentation. 6 | 7 | For `docenv` linter see [docenv/README.md](./docenv/README.md). 8 | 9 |
10 | 11 | [![CI](https://github.com/g4s8/envdoc/actions/workflows/go.yml/badge.svg)](https://github.com/g4s8/envdoc/actions/workflows/go.yml) 12 | [![Go Reference](https://pkg.go.dev/badge/github.com/g4s8/envdoc.svg)](https://pkg.go.dev/github.com/g4s8/envdoc) 13 | [![codecov](https://codecov.io/gh/g4s8/envdoc/graph/badge.svg?token=sqXWNR755O)](https://codecov.io/gh/g4s8/envdoc) 14 | [![Go Report Card](https://goreportcard.com/badge/github.com/g4s8/envdoc)](https://goreportcard.com/report/github.com/g4s8/envdoc) 15 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) 16 | 17 | ## Installation 18 | 19 | ### Go >= 1.24 20 | 21 | Add `envdoc` tool and install it: 22 | ```bash 23 | go get -tool github.com/g4s8/envdoc@latest 24 | go install tool 25 | ``` 26 | 27 | Add `go:generate`: 28 | ```go 29 | //go:generate envdoc -output config.md 30 | type Config struct { 31 | // ... 32 | } 33 | ``` 34 | 35 | Generate: 36 | ```bash 37 | go generate ./... 38 | ``` 39 | 40 | ### Before Go 1.24 41 | 42 | Run it with `go run` in source file: 43 | ```go 44 | //go:generate go run github.com/g4s8/envdoc@latest -output environments.md 45 | type Config struct { 46 | // ... 47 | } 48 | ``` 49 | 50 | Or download binary to run it: 51 | ```bash 52 | go install github.com/g4s8/envdoc@latest 53 | ``` 54 | 55 | And use it in code: 56 | 57 | ```go 58 | //go:generate envdoc -output environments.md 59 | type Config struct { 60 | // ... 61 | } 62 | ``` 63 | 64 | ## Usage 65 | 66 | ```go 67 | //go:generate envdoc -output 68 | ``` 69 | 70 | * `-dir` (path string, *optional*) - Specify the directory to search for files. Default is the file dir with `go:generate` command. 71 | * `-files` (glob string, *optional*) - File glob pattern to specify file names to process. Default is the single file with `go:generate`. 72 | * `-types` (glob string, *optional*) - Type glob pattern for type names to process. If not specified, the next type after `go:generate` is used. 73 | * `-target` (`enum(caarlos0, cleanenv)` string, optional, default `caarlos0`) - Set env library target. 74 | * `-output` (path string, **required**) - Output file name for generated documentation. 75 | * `-format` (`enum(markdown, plaintext, html, dotenv, json)` string, *optional*) - Output format for documentation. Default is `markdown`. 76 | * `-no-styles` (`bool`, *optional*) - If true, CSS styles will not be included for `html` format. 77 | * `-env-prefix` (`string`, *optional*) - Sets additional global prefix for all environment variables. 78 | * `-tag-name` (string, *optional*, default: `env`) - Use custom tag name instead of `env`. 79 | * `-tag-default` (string, *optional*, default: `envDefault`) - Use "default" tag name instead of `envDefault`. 80 | * `-required-if-no-def` (bool, *optional*, default: `false`) - Set attributes as required if no default value is set. 81 | * `-field-names` (`bool`, *optional*) - Use field names as env names if `env:` tag is not specified. 82 | * `-debug` (`bool`, *optional*) - Enable debug output. 83 | 84 | These params are deprecated and will be removed in the next major release: 85 | * `-type` - Specify one type to process. 86 | * `-all` - Process all types in a file. 87 | 88 | Both parameters could be replaced with `-types` param: 89 | - Use `-types=Foo` instead of `-type=Foo`. 90 | - Use `-types='*'` instead of `-all`. 91 | 92 | ## Example 93 | 94 | Suppose you have the following Go file `config.go`: 95 | 96 | ```go 97 | package foo 98 | 99 | //go:generate envdoc --output env-doc.md 100 | type Config struct { 101 | // Port to listen for incoming connections 102 | Port int `env:"PORT,required"` 103 | // Address to serve 104 | Address string `env:"ADDRESS" envDefault:"localhost"` 105 | } 106 | ``` 107 | 108 | And the `go:generate` line above creates documentation in `env-doc.md` file: 109 | 110 | ```md 111 | # Environment Variables 112 | 113 | - `PORT` (**required**) - Port to listen for incoming connections 114 | - `ADDRESS` (default: `localhost`) - Address to serve 115 | ``` 116 | 117 | See [_examples](./_examples/) dir for more details. 118 | 119 | ## Compatibility 120 | 121 | This tool is compatible with 122 | - full compatibility: [caarlos0/env](https://github.com/caarlos0/env) 123 | - full compatibility: [ilyakaznacheev/cleanenv](https://github.com/ilyakaznacheev/cleanenv) 124 | - partial compatibility: [sethvargo/go-envconfig](https://github.com/sethvargo/go-envconfig) 125 | - partial compatibility: [joeshaw/envdecode](https://github.com/joeshaw/envdecode) 126 | 127 | *Let me know about any new lib to check compatibility.* 128 | 129 | 130 | ## Contributing 131 | 132 | If you find any issues or have suggestions for improvement, feel free to open an issue or submit a pull request. 133 | 134 | ## License 135 | 136 | This project is licensed under the MIT License - see the [LICENSE.md](/LICENSE.md) file for details. 137 | -------------------------------------------------------------------------------- /_examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | There are multiple dirs with examples. Each dir contains go source file with `go generate` directive 4 | and generated doc files. 5 | -------------------------------------------------------------------------------- /_examples/build-examples.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | cd ${0%/*} 5 | 6 | find . -type f -name "*.go" -exec go generate -v {} \; 7 | -------------------------------------------------------------------------------- /_examples/clean.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | cd ${0%/*} 5 | 6 | find . -type f \( -name "*.md" -or -name '*.txt' -or -name '*.html' -or -name '*.env' \) ! -name "README.md" -exec rm -v {} \; 7 | -------------------------------------------------------------------------------- /_examples/cleanenv/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Config is an example configuration structure. 4 | // It is used to generate documentation for the configuration 5 | // using the commands below. 6 | // 7 | //go:generate go run ../../ -output doc.txt -target cleanenv -format plaintext 8 | type Config struct { 9 | // Hosts name of hosts to listen on. 10 | Hosts []string `env:"HOST" env-required:"true" env-separator:";"` 11 | // Port to listen on. 12 | Port int `env:"PORT"` 13 | 14 | // Debug mode enabled. 15 | Debug bool `env:"DEBUG" env-default:"false"` 16 | 17 | // Timeouts configuration. 18 | Timeouts struct { 19 | // Read timeout. 20 | Read int `env:"READ" env-default:"10"` 21 | // Write timeout. 22 | Write int `env:"WRITE" env-default:"10"` 23 | } `env-prefix:"TIMEOUT_"` 24 | } 25 | -------------------------------------------------------------------------------- /_examples/cleanenv/doc.txt: -------------------------------------------------------------------------------- 1 | Environment Variables 2 | 3 | ## Config 4 | 5 | Config is an example configuration structure. 6 | It is used to generate documentation for the configuration 7 | using the commands below. 8 | 9 | * `HOST` (separated by `;`, required) - Hosts name of hosts to listen on. 10 | * `PORT` - Port to listen on. 11 | * `DEBUG` (default: `false`) - Debug mode enabled. 12 | * Timeouts configuration. 13 | * `TIMEOUT_READ` (default: `10`) - Read timeout. 14 | * `TIMEOUT_WRITE` (default: `10`) - Write timeout. 15 | 16 | -------------------------------------------------------------------------------- /_examples/customization/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Struct for tag customization. 4 | // 5 | //go:generate go run ../../ -output ./doc.md -tag-name xenv -tag-default xdef -required-if-no-def 6 | type CustomTagsConfig struct { 7 | // Host is the host name. 8 | Host string `xenv:"host" xdef:"localhost"` 9 | // NoDef is the no default value. 10 | NoDef string `xenv:"no_def"` 11 | } 12 | -------------------------------------------------------------------------------- /_examples/customization/doc.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | ## CustomTagsConfig 4 | 5 | Struct for tag customization. 6 | 7 | - `host` (default: `localhost`) - Host is the host name. 8 | - `no_def` (**required**) - NoDef is the no default value. 9 | 10 | -------------------------------------------------------------------------------- /_examples/embedded/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | // Config is the configuration for the application. 6 | // 7 | //go:generate go run ../../ -output doc.md 8 | type Config struct { 9 | // Start date. 10 | Start Date `env:"START,notEmpty"` 11 | } 12 | 13 | type Time time.Time 14 | 15 | // Date is a time.Time wrapper that uses the time.DateOnly layout. 16 | type Date struct { 17 | Time 18 | } 19 | -------------------------------------------------------------------------------- /_examples/embedded/doc.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | ## Config 4 | 5 | Config is the configuration for the application. 6 | 7 | - `START` (**required**, non-empty) - Start date. 8 | 9 | -------------------------------------------------------------------------------- /_examples/envprefix/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Settings is the application settings. 4 | // 5 | //go:generate go run ../../ -output envprefix.md -types Settings -env-prefix X_ 6 | type Settings struct { 7 | // Database is the database settings 8 | Database Database `envPrefix:"DB_"` 9 | 10 | // Server is the server settings 11 | Server ServerConfig `envPrefix:"SERVER_"` 12 | 13 | // Debug is the debug flag 14 | Debug bool `env:"DEBUG"` 15 | } 16 | 17 | // Database is the database settings. 18 | type Database struct { 19 | // Port is the port to connect to 20 | Port Int `env:"PORT,required"` 21 | // Host is the host to connect to 22 | Host string `env:"HOST,notEmpty" envDefault:"localhost"` 23 | // User is the user to connect as 24 | User string `env:"USER"` 25 | // Password is the password to use 26 | Password string `env:"PASSWORD"` 27 | // DisableTLS is the flag to disable TLS 28 | DisableTLS bool `env:"DISABLE_TLS"` 29 | } 30 | 31 | // ServerConfig is the server settings. 32 | type ServerConfig struct { 33 | // Port is the port to listen on 34 | Port Int `env:"PORT,required"` 35 | 36 | // Host is the host to listen on 37 | Host string `env:"HOST,notEmpty" envDefault:"localhost"` 38 | 39 | // Timeout is the timeout settings 40 | Timeout TimeoutConfig `envPrefix:"TIMEOUT_"` 41 | } 42 | 43 | // TimeoutConfig is the timeout settings. 44 | type TimeoutConfig struct { 45 | // Read is the read timeout 46 | Read Int `env:"READ" envDefault:"30"` 47 | // Write is the write timeout 48 | Write Int `env:"WRITE" envDefault:"30"` 49 | } 50 | -------------------------------------------------------------------------------- /_examples/envprefix/envprefix.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | ## Settings 4 | 5 | Settings is the application settings. 6 | 7 | - `X_DB_PORT` (**required**) - Port is the port to connect to 8 | - `X_DB_HOST` (**required**, non-empty, default: `localhost`) - Host is the host to connect to 9 | - `X_DB_USER` - User is the user to connect as 10 | - `X_DB_PASSWORD` - Password is the password to use 11 | - `X_DB_DISABLE_TLS` - DisableTLS is the flag to disable TLS 12 | - `X_SERVER_PORT` (**required**) - Port is the port to listen on 13 | - `X_SERVER_HOST` (**required**, non-empty, default: `localhost`) - Host is the host to listen on 14 | - `X_SERVER_TIMEOUT_READ` (default: `30`) - Read is the read timeout 15 | - `X_SERVER_TIMEOUT_WRITE` (default: `30`) - Write is the write timeout 16 | - `X_DEBUG` - Debug is the debug flag 17 | 18 | -------------------------------------------------------------------------------- /_examples/field-names/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // FieldNames uses field names as env names. 4 | // 5 | //go:generate go run ../../ -output doc.md -field-names 6 | type FieldNames struct { 7 | // Foo is a single field. 8 | Foo string 9 | // Bar and Baz are two fields. 10 | Bar, Baz string 11 | // Quux is a field with a tag. 12 | Quux string `env:"QUUX"` 13 | // FooBar is a field with a default value. 14 | FooBar string `envDefault:"quuux"` 15 | // Required is a required field. 16 | Required string `env:",required"` 17 | } 18 | -------------------------------------------------------------------------------- /_examples/field-names/doc.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | ## FieldNames 4 | 5 | FieldNames uses field names as env names. 6 | 7 | - `FOO` - Foo is a single field. 8 | - `BAR` - Bar and Baz are two fields. 9 | - `BAZ` - Bar and Baz are two fields. 10 | - `QUUX` - Quux is a field with a tag. 11 | - `FOO_BAR` (default: `quuux`) - FooBar is a field with a default value. 12 | - Required is a required field. 13 | 14 | -------------------------------------------------------------------------------- /_examples/full/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // ComplexConfig is an example configuration structure. 4 | // It contains a few fields with different types of tags. 5 | // It is trying to cover all the possible cases. 6 | // 7 | //go:generate go run ../../ -output doc.md -types * 8 | type ComplexConfig struct { 9 | // Secret is a secret value that is read from a file. 10 | Secret string `env:"SECRET,file"` 11 | // Password is a password that is read from a file. 12 | Password string `env:"PASSWORD,file" envDefault:"/tmp/password" json:"password"` 13 | // Certificate is a certificate that is read from a file. 14 | Certificate string `env:"CERTIFICATE,file,expand" envDefault:"${CERTIFICATE_FILE}"` 15 | // Key is a secret key. 16 | SecretKey string `env:"SECRET_KEY,required" json:"secret_key"` 17 | // SecretVal is a secret value. 18 | SecretVal string `json:"secret_val" env:"SECRET_VAL,notEmpty"` 19 | 20 | // Hosts is a list of hosts. 21 | Hosts []string `env:"HOSTS,required" envSeparator:":"` 22 | // Words is just a list of words. 23 | Words []string `env:"WORDS,file" envDefault:"one,two,three"` 24 | 25 | Comment string `env:"COMMENT,required" envDefault:"This is a comment."` // Just a comment. 26 | 27 | // Anon is an anonymous structure. 28 | Anon struct { 29 | // User is a user name. 30 | User string `env:"USER,required"` 31 | // Pass is a password. 32 | Pass string `env:"PASS,required"` 33 | } `envPrefix:"ANON_"` 34 | } 35 | 36 | type NextConfig struct { // NextConfig is a configuration structure. 37 | // Mount is a mount point. 38 | Mount string `env:"MOUNT,required"` 39 | } 40 | -------------------------------------------------------------------------------- /_examples/full/doc.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | ## ComplexConfig 4 | 5 | ComplexConfig is an example configuration structure. 6 | It contains a few fields with different types of tags. 7 | It is trying to cover all the possible cases. 8 | 9 | - `SECRET` (from-file) - Secret is a secret value that is read from a file. 10 | - `PASSWORD` (from-file, default: `/tmp/password`) - Password is a password that is read from a file. 11 | - `CERTIFICATE` (expand, from-file, default: `${CERTIFICATE_FILE}`) - Certificate is a certificate that is read from a file. 12 | - `SECRET_KEY` (**required**) - Key is a secret key. 13 | - `SECRET_VAL` (**required**, non-empty) - SecretVal is a secret value. 14 | - `HOSTS` (separated by `:`, **required**) - Hosts is a list of hosts. 15 | - `WORDS` (comma-separated, from-file, default: `one`) - Words is just a list of words. 16 | - `COMMENT` (**required**, default: `This is a comment.`) - Just a comment. 17 | - `ANON_USER` (**required**) - User is a user name. 18 | - `ANON_PASS` (**required**) - Pass is a password. 19 | 20 | ## NextConfig 21 | 22 | - `MOUNT` (**required**) - Mount is a mount point. 23 | 24 | -------------------------------------------------------------------------------- /_examples/project/config/cfg.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "example.com/db" 5 | "example.com/server" 6 | ) 7 | 8 | //go:generate go run ../../../ -dir ../ -files ./config/cfg.go -types * -output ../config.md -format markdown 9 | type Config struct { 10 | // AppName is the name of the application. 11 | AppName string `env:"APP_NAME" envDefault:"myapp"` 12 | 13 | // Server config. 14 | Server server.Config `envPrefix:"SERVER_"` 15 | 16 | // Database config. 17 | Database db.Config `envPrefix:"DB_"` 18 | 19 | // Logging config. 20 | Logging Logging `envPrefix:"LOG_"` 21 | } 22 | -------------------------------------------------------------------------------- /_examples/project/config/logging.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Logging struct { 4 | // Level of the logging. 5 | Level string `env:"LEVEL" envDefault:"info"` 6 | // Format of the logging. 7 | Format string `env:"FORMAT" envDefault:"json"` 8 | } 9 | -------------------------------------------------------------------------------- /_examples/project/db/cfg.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | // Config holds the configuration for the database. 4 | type Config struct { 5 | // Host of the database. 6 | Host string `env:"HOST,required"` 7 | // Port of the database. 8 | Port string `env:"PORT,required"` 9 | // User of the database. 10 | User string `env:"USER" envDefault:"user"` 11 | // Password of the database. 12 | Password string `env:"PASSWORD,nonempty"` 13 | 14 | SslConfig `envPrefix:"SSL_"` 15 | } 16 | 17 | // SslConfig holds the configuration for the SSL of the database. 18 | type SslConfig struct { 19 | // SslMode of the database. 20 | SslMode string `env:"MODE" envDefault:"disable"` 21 | // SslCert of the database. 22 | SslCert string `env:"CERT"` 23 | // SslKey of the database. 24 | SslKey string `env:"KEY"` 25 | } 26 | -------------------------------------------------------------------------------- /_examples/project/go.mod: -------------------------------------------------------------------------------- 1 | module example.com 2 | 3 | go 1.22.5 4 | -------------------------------------------------------------------------------- /_examples/project/server/cfg.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | type Config struct { 4 | // Host of the server. 5 | Host string `env:"HOST,required"` 6 | // Port of the server. 7 | Port string `env:"PORT,required"` 8 | // Timeout of the server. 9 | Timeout TimeoutConfig `envPrefix:"TIMEOUT_"` 10 | } 11 | -------------------------------------------------------------------------------- /_examples/project/server/timeout_cfg.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | // TimeoutConfig holds the configuration for the timeouts of the server. 4 | type TimeoutConfig struct { 5 | // ReadTimeout of the server. 6 | ReadTimeout string `env:"READ,required"` 7 | // WriteTimeout of the server. 8 | WriteTimeout string `env:"WRITE,required"` 9 | } 10 | -------------------------------------------------------------------------------- /_examples/simple/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Config is an example configuration structure. 4 | // It is used to generate documentation for the configuration 5 | // using the commands below. 6 | // 7 | //go:generate go run ../../ -output doc.txt -format plaintext 8 | //go:generate go run ../../ -output doc.md -format markdown 9 | //go:generate go run ../../ -output doc.html -format html 10 | //go:generate go run ../../ -output doc.env -format dotenv 11 | //go:generate go run ../../ -output doc.json -format json 12 | type Config struct { 13 | // Hosts name of hosts to listen on. 14 | Hosts []string `env:"HOST,required", envSeparator:";"` 15 | // Port to listen on. 16 | Port int `env:"PORT,notEmpty"` 17 | 18 | // Debug mode enabled. 19 | Debug bool `env:"DEBUG" envDefault:"false"` 20 | 21 | // Prefix for something. 22 | Prefix string `env:"PREFIX"` 23 | } 24 | -------------------------------------------------------------------------------- /_examples/simple/doc.env: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | 4 | ## Config 5 | ## Config is an example configuration structure. 6 | ## It is used to generate documentation for the configuration 7 | ## using the commands below. 8 | # 9 | ## Hosts name of hosts to listen on. 10 | ## (separated by ';', required) 11 | # HOST="" 12 | ## Port to listen on. 13 | ## (required, non-empty) 14 | # PORT="" 15 | ## Debug mode enabled. 16 | ## (default: 'false') 17 | # DEBUG="false" 18 | ## Prefix for something. 19 | # PREFIX="" 20 | 21 | -------------------------------------------------------------------------------- /_examples/simple/doc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Environment Variables 6 | 73 | 74 | 75 |
76 |
77 |

Environment Variables

78 | 79 |

Config

80 |

Config is an example configuration structure. 81 | It is used to generate documentation for the configuration 82 | using the commands below.

83 |
    84 |
  • HOST (separated by ";", required) - Hosts name of hosts to listen on.
  • 85 |
  • PORT (required, non-empty) - Port to listen on.
  • 86 |
  • DEBUG (default: false) - Debug mode enabled.
  • 87 |
  • PREFIX - Prefix for something.
  • 88 |
89 | 90 |
91 |
92 | 93 | 94 | -------------------------------------------------------------------------------- /_examples/simple/doc.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "env_name": "HOST", 4 | "doc": "Hosts name of hosts to listen on.", 5 | "env_separator": ";", 6 | "required": true 7 | }, 8 | { 9 | "env_name": "PORT", 10 | "doc": "Port to listen on.", 11 | "required": true, 12 | "non_empty": true 13 | }, 14 | { 15 | "env_name": "DEBUG", 16 | "doc": "Debug mode enabled.", 17 | "env_default": "false" 18 | }, 19 | { 20 | "env_name": "PREFIX", 21 | "doc": "Prefix for something." 22 | } 23 | ] 24 | -------------------------------------------------------------------------------- /_examples/simple/doc.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | ## Config 4 | 5 | Config is an example configuration structure. 6 | It is used to generate documentation for the configuration 7 | using the commands below. 8 | 9 | - `HOST` (separated by `;`, **required**) - Hosts name of hosts to listen on. 10 | - `PORT` (**required**, non-empty) - Port to listen on. 11 | - `DEBUG` (default: `false`) - Debug mode enabled. 12 | - `PREFIX` - Prefix for something. 13 | 14 | -------------------------------------------------------------------------------- /_examples/simple/doc.txt: -------------------------------------------------------------------------------- 1 | Environment Variables 2 | 3 | ## Config 4 | 5 | Config is an example configuration structure. 6 | It is used to generate documentation for the configuration 7 | using the commands below. 8 | 9 | * `HOST` (separated by `;`, required) - Hosts name of hosts to listen on. 10 | * `PORT` (required, non-empty) - Port to listen on. 11 | * `DEBUG` (default: `false`) - Debug mode enabled. 12 | * `PREFIX` - Prefix for something. 13 | 14 | -------------------------------------------------------------------------------- /ast/collectors.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | 7 | "github.com/g4s8/envdoc/debug" 8 | ) 9 | 10 | type RootCollectorOption func(*RootCollector) 11 | 12 | func WithFileGlob(glob func(string) bool) RootCollectorOption { 13 | return func(c *RootCollector) { 14 | c.fileGlob = glob 15 | } 16 | } 17 | 18 | func WithTypeGlob(glob func(string) bool) RootCollectorOption { 19 | return func(c *RootCollector) { 20 | c.typeGlob = glob 21 | } 22 | } 23 | 24 | func WithGoGenDecl(line int, file string) RootCollectorOption { 25 | return func(c *RootCollector) { 26 | c.gogenDecl = &struct { 27 | line int 28 | file string 29 | }{ 30 | line: line, 31 | file: file, 32 | } 33 | } 34 | } 35 | 36 | var ( 37 | _ interface { 38 | FileHandler 39 | TypeHandler 40 | CommentHandler 41 | } = (*RootCollector)(nil) 42 | _ interface { 43 | DocHandler 44 | CommentHandler 45 | FieldHandler 46 | } = (*TypeCollector)(nil) 47 | _ FieldHandler = (*FieldCollector)(nil) 48 | ) 49 | 50 | type RootCollector struct { 51 | baseDir string 52 | fileGlob func(string) bool 53 | typeGlob func(string) bool 54 | gogenDecl *struct { 55 | line int 56 | file string 57 | } 58 | 59 | // pendingType is true if gogen declaration was specified 60 | // and the next type will be the expected one 61 | pendingType bool 62 | 63 | files []*FileSpec 64 | } 65 | 66 | var globAcceptAll = func(string) bool { return true } 67 | 68 | func NewRootCollector(baseDir string, opts ...RootCollectorOption) *RootCollector { 69 | c := &RootCollector{ 70 | baseDir: baseDir, 71 | fileGlob: globAcceptAll, 72 | typeGlob: globAcceptAll, 73 | } 74 | for _, opt := range opts { 75 | opt(c) 76 | } 77 | return c 78 | } 79 | 80 | func (c *RootCollector) Files() []*FileSpec { 81 | // order by file name 82 | res := make([]*FileSpec, len(c.files)) 83 | copy(res, c.files) 84 | sort.Slice(res, func(i, j int) bool { 85 | return res[i].Name < res[j].Name 86 | }) 87 | return res 88 | } 89 | 90 | func (c *RootCollector) onFile(f *FileSpec) interface { 91 | TypeHandler 92 | CommentHandler 93 | } { 94 | // convert file name to relative path using baseDir 95 | // if baseDir is empty or `.` then the file name is used as is. 96 | name := f.Name 97 | if c.baseDir != "" && c.baseDir != "." { 98 | name, _ = strings.CutPrefix(name, c.baseDir) 99 | name, _ = strings.CutPrefix(name, "/") 100 | name = "./" + name 101 | } 102 | f.Name = name 103 | 104 | if c.fileGlob(f.Name) { 105 | f.Export = true 106 | } 107 | debug.Logf("# COL: file %q, export=%t\n", f.Name, f.Export) 108 | c.files = append(c.files, f) 109 | return c 110 | } 111 | 112 | func (c *RootCollector) currentFile() *FileSpec { 113 | if len(c.files) == 0 { 114 | panic("emitted type without file") 115 | } 116 | return c.files[len(c.files)-1] 117 | } 118 | 119 | func (c *RootCollector) onType(tpe *TypeSpec) interface { 120 | FieldHandler 121 | CommentHandler 122 | } { 123 | currentFile := c.currentFile() 124 | 125 | var export bool 126 | if c.gogenDecl != nil { 127 | if c.pendingType { 128 | c.pendingType = false 129 | export = true 130 | } 131 | } else if c.typeGlob(tpe.Name) { 132 | export = true 133 | } 134 | 135 | tpe.Export = export 136 | currentFile.Types = append(currentFile.Types, tpe) 137 | return &TypeCollector{spec: tpe} 138 | } 139 | 140 | func (c *RootCollector) setComment(spec *CommentSpec) { 141 | currentFile := c.currentFile() 142 | 143 | if c.gogenDecl == nil { 144 | return 145 | } 146 | if c.gogenDecl.file == currentFile.Name && c.gogenDecl.line == spec.Line { 147 | c.pendingType = true 148 | } 149 | } 150 | 151 | type TypeCollector struct { 152 | spec *TypeSpec 153 | } 154 | 155 | func (c *TypeCollector) setDoc(spec *DocSpec) { 156 | c.spec.Doc = spec.Doc 157 | } 158 | 159 | func (c *TypeCollector) setComment(spec *CommentSpec) { 160 | if c.spec.Doc != "" { 161 | c.spec.Doc = spec.Text 162 | } 163 | } 164 | 165 | func (c *TypeCollector) onField(f *FieldSpec) FieldHandler { 166 | c.spec.Fields = append(c.spec.Fields, f) 167 | return &FieldCollector{spec: f} 168 | } 169 | 170 | type FieldCollector struct { 171 | spec *FieldSpec 172 | } 173 | 174 | func (c *FieldCollector) onField(f *FieldSpec) FieldHandler { 175 | c.spec.Fields = append(c.spec.Fields, f) 176 | return &FieldCollector{spec: f} 177 | } 178 | -------------------------------------------------------------------------------- /ast/collectors_test.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/g4s8/envdoc/testutils" 7 | ) 8 | 9 | func testGlob(t *testing.T, name string) func(string) bool { 10 | t.Helper() 11 | return func(s string) bool { 12 | ok := s == name 13 | t.Logf("test glob: %s == %s = %t", s, name, ok) 14 | return ok 15 | } 16 | } 17 | 18 | func TestRootCollector(t *testing.T) { 19 | t.Run("glob", func(t *testing.T) { 20 | col := NewRootCollector("./base", 21 | WithFileGlob(testGlob(t, "./first.go")), 22 | WithTypeGlob(testGlob(t, "TestType"))) 23 | col.onFile(&FileSpec{ 24 | Name: "./base/first.go", 25 | }) 26 | col.onFile(&FileSpec{ 27 | Name: "./base/second.go", 28 | }) 29 | files := col.Files() 30 | testutils.AssertFatal(t, len(files) == 2, "unexpected files count: %d", len(files)) 31 | testutils.AssertError(t, files[0].Name == "./first.go", "[0]unexpected file name: %s", files[0].Name) 32 | testutils.AssertError(t, files[1].Name == "./second.go", "[1]unexpected file name: %s", files[1].Name) 33 | testutils.AssertError(t, files[0].Export == true, "[0]unexpected export flag: %t", files[0].Export) 34 | testutils.AssertError(t, files[1].Export == false, "[1]unexpected export flag: %t", files[1].Export) 35 | current := col.currentFile() 36 | testutils.AssertError(t, current.Name == "./second.go", "unexpected current file: %s", current.Name) 37 | 38 | col.onType(&TypeSpec{ 39 | Name: "TestType", 40 | }) 41 | col.onType(&TypeSpec{ 42 | Name: "AnotherType", 43 | }) 44 | types := current.Types 45 | testutils.AssertFatal(t, len(types) == 2, "unexpected types count: %d", len(types)) 46 | testutils.AssertError(t, types[0].Name == "TestType", "[0]unexpected type name: %s", types[0].Name) 47 | testutils.AssertError(t, types[1].Name == "AnotherType", "[1]unexpected type name: %s", types[1].Name) 48 | testutils.AssertError(t, types[0].Export == true, "[0]unexpected export flag: %t", types[0].Export) 49 | testutils.AssertError(t, types[1].Export == false, "[1]unexpected export flag: %t", types[1].Export) 50 | }) 51 | t.Run("comment", func(t *testing.T) { 52 | col := NewRootCollector("./base", 53 | WithGoGenDecl(1, "./first.go")) 54 | col.onFile(&FileSpec{ 55 | Name: "./base/first.go", 56 | }) 57 | col.setComment(&CommentSpec{ 58 | Line: 1, 59 | }) 60 | current := col.currentFile() 61 | testutils.AssertFatal(t, current.Name == "./first.go", "unexpected current file: %s", current.Name) 62 | testutils.AssertError(t, col.pendingType == true, "unexpected pending type flag: %t", col.pendingType) 63 | 64 | col.onType(&TypeSpec{ 65 | Name: "TestType", 66 | }) 67 | col.onType(&TypeSpec{ 68 | Name: "AnotherType", 69 | }) 70 | types := current.Types 71 | testutils.AssertFatal(t, len(types) == 2, "unexpected types count: %d", len(types)) 72 | testutils.AssertError(t, types[0].Name == "TestType", "[0]unexpected type name: %s", types[0].Name) 73 | testutils.AssertError(t, types[1].Name == "AnotherType", "[1]unexpected type name: %s", types[1].Name) 74 | testutils.AssertError(t, types[0].Export == true, "[0]unexpected export flag: %t", types[0].Export) 75 | testutils.AssertError(t, types[1].Export == false, "[1]unexpected export flag: %t", types[1].Export) 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /ast/debug.go: -------------------------------------------------------------------------------- 1 | //go:build !coverage 2 | 3 | package ast 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | func printTraverse(files []*FileSpec, level int) { 11 | indent := strings.Repeat(" ", level) 12 | for _, file := range files { 13 | fmt.Printf("%sFILE:%q\n", indent, file.Name) 14 | printTraverseTypes(file.Types, level+1) 15 | } 16 | } 17 | 18 | func printTraverseTypes(types []*TypeSpec, level int) { 19 | indent := strings.Repeat(" ", level) 20 | for _, t := range types { 21 | fmt.Printf("%sTYPE:%q; doc: %q\n", indent, t.Name, t.Doc) 22 | printTraverseFields(t.Fields, level+1) 23 | } 24 | } 25 | 26 | func printTraverseFields(fields []*FieldSpec, level int) { 27 | indent := strings.Repeat(" ", level) 28 | for _, f := range fields { 29 | names := strings.Join(f.Names, ", ") 30 | fmt.Printf("%sFIELD:%s (%s); doc: %q\n", indent, names, f.TypeRef.String(), f.Doc) 31 | printTraverseFields(f.Fields, level+1) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ast/debug_coverage.go: -------------------------------------------------------------------------------- 1 | //go:build coverage 2 | 3 | package ast 4 | 5 | func printTraverse(files []*FileSpec, level int) {} 6 | -------------------------------------------------------------------------------- /ast/errors.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ( 9 | ErrAstParse = errors.New("ast parse error") 10 | ErrFieldParse = fmt.Errorf("parse field: %w", ErrAstParse) 11 | ) 12 | -------------------------------------------------------------------------------- /ast/field.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import "go/ast" 4 | 5 | type fieldVisitor struct { 6 | pkg string 7 | h FieldHandler 8 | 9 | nested bool 10 | } 11 | 12 | func newFieldVisitor(pkg string, h FieldHandler) *fieldVisitor { 13 | return &fieldVisitor{pkg: pkg, h: h} 14 | } 15 | 16 | func (v *fieldVisitor) Visit(n ast.Node) ast.Visitor { 17 | debugNode("field", n) 18 | switch t := n.(type) { 19 | case *ast.StructType: 20 | v.nested = true 21 | return v 22 | case *ast.Field: 23 | if !v.nested { 24 | return nil 25 | } 26 | fs := getFieldSpec(t, v.pkg) 27 | if fs == nil { 28 | return nil 29 | } 30 | if fa := v.h.onField(fs); fa != nil { 31 | return newFieldVisitor(v.pkg, fa) 32 | } 33 | } 34 | return v 35 | } 36 | -------------------------------------------------------------------------------- /ast/fieldtyperefkind_scan.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | func (r *FieldTypeRefKind) ScanStr(s string) bool { 4 | for i := 0; i < len(_FieldTypeRefKind_index)-1; i++ { 5 | from, to := _FieldTypeRefKind_index[i], _FieldTypeRefKind_index[i+1] 6 | if s == _FieldTypeRefKind_name[from:to] { 7 | *r = FieldTypeRefKind(i) 8 | return true 9 | } 10 | } 11 | return false 12 | } 13 | -------------------------------------------------------------------------------- /ast/fieldtyperefkind_string.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=FieldTypeRefKind -trimprefix=FieldType"; DO NOT EDIT. 2 | 3 | package ast 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[FieldTypeIdent-0] 12 | _ = x[FieldTypeSelector-1] 13 | _ = x[FieldTypePtr-2] 14 | _ = x[FieldTypeArray-3] 15 | _ = x[FieldTypeMap-4] 16 | _ = x[FieldTypeStruct-5] 17 | } 18 | 19 | const _FieldTypeRefKind_name = "IdentSelectorPtrArrayMapStruct" 20 | 21 | var _FieldTypeRefKind_index = [...]uint8{0, 5, 13, 16, 21, 24, 30} 22 | 23 | func (i FieldTypeRefKind) String() string { 24 | if i < 0 || i >= FieldTypeRefKind(len(_FieldTypeRefKind_index)-1) { 25 | return "FieldTypeRefKind(" + strconv.FormatInt(int64(i), 10) + ")" 26 | } 27 | return _FieldTypeRefKind_name[_FieldTypeRefKind_index[i]:_FieldTypeRefKind_index[i+1]] 28 | } 29 | -------------------------------------------------------------------------------- /ast/file.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "go/ast" 5 | "go/doc" 6 | "go/token" 7 | "strings" 8 | ) 9 | 10 | type fileVisitorHandler = interface { 11 | TypeHandler 12 | CommentHandler 13 | } 14 | 15 | type fileVisitor struct { 16 | fset *token.FileSet 17 | file *ast.File 18 | docs *doc.Package 19 | h fileVisitorHandler 20 | } 21 | 22 | func newFileVisitor(fset *token.FileSet, file *ast.File, docs *doc.Package, h fileVisitorHandler) *fileVisitor { 23 | return &fileVisitor{ 24 | fset: fset, 25 | file: file, 26 | docs: docs, 27 | h: h, 28 | } 29 | } 30 | 31 | func (v *fileVisitor) Visit(n ast.Node) ast.Visitor { 32 | debugNode("file", n) 33 | switch t := n.(type) { 34 | case *ast.Comment: 35 | line := findCommentLine(t, v.fset, v.file) 36 | text := strings.TrimPrefix(t.Text, "//") 37 | text = strings.TrimSpace(text) 38 | v.h.setComment(&CommentSpec{ 39 | Line: line, 40 | Text: text, 41 | }) 42 | return nil 43 | case *ast.TypeSpec: 44 | doc := resolveTypeDocs(v.docs, t) 45 | if ta := v.h.onType(&TypeSpec{ 46 | Name: t.Name.Name, 47 | Doc: doc, 48 | }); ta != nil { 49 | return newTypeVisitor(v.file.Name.String(), ta) 50 | } 51 | return nil 52 | } 53 | return v 54 | } 55 | -------------------------------------------------------------------------------- /ast/file_test.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "go/ast" 5 | "testing" 6 | ) 7 | 8 | func TestFileVisitor(t *testing.T) { 9 | fset, pkg, docs := loadTestFileSet(t) 10 | fh, fv, file := testFileVisitor(fset, pkg, "testdata/onetype.go", docs) 11 | ast.Walk(fv, file) 12 | 13 | types := make([]*TypeSpec, 0) 14 | for _, f := range fh.files { 15 | types = append(types, f.Types...) 16 | } 17 | if expect, actual := 1, len(types); expect != actual { 18 | t.Fatalf("expected %d types, got %d", expect, actual) 19 | } 20 | if expect, actual := "One", types[0].Name; expect != actual { 21 | t.Fatalf("expected type name %q, got %q", expect, actual) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ast/model.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import "strings" 4 | 5 | //go:generate stringer -type=FieldTypeRefKind -trimprefix=FieldType 6 | type FieldTypeRefKind int 7 | 8 | const ( 9 | FieldTypeIdent FieldTypeRefKind = iota 10 | FieldTypeSelector 11 | FieldTypePtr 12 | FieldTypeArray 13 | FieldTypeMap 14 | FieldTypeStruct 15 | ) 16 | 17 | type ( 18 | FileSpec struct { 19 | Name string 20 | Pkg string 21 | Types []*TypeSpec 22 | Export bool // tru if file should be exported 23 | } 24 | 25 | TypeSpec struct { 26 | Name string 27 | Doc string 28 | Fields []*FieldSpec 29 | Export bool // true if type should be exported 30 | } 31 | 32 | FieldSpec struct { 33 | Names []string 34 | Doc string 35 | Tag string 36 | TypeRef FieldTypeRef 37 | Fields []*FieldSpec 38 | } 39 | 40 | FieldTypeRef struct { 41 | Name string 42 | Pkg string 43 | Kind FieldTypeRefKind 44 | } 45 | 46 | DocSpec struct { 47 | Doc string 48 | } 49 | 50 | CommentSpec struct { 51 | Text string 52 | Line int 53 | } 54 | ) 55 | 56 | type ( 57 | CommentHandler interface { 58 | setComment(*CommentSpec) 59 | } 60 | 61 | DocHandler interface { 62 | setDoc(*DocSpec) 63 | } 64 | 65 | FieldHandler interface { 66 | onField(*FieldSpec) FieldHandler 67 | } 68 | 69 | TypeHandler interface { 70 | onType(*TypeSpec) interface { 71 | FieldHandler 72 | CommentHandler 73 | } 74 | } 75 | 76 | FileHandler interface { 77 | onFile(*FileSpec) interface { 78 | TypeHandler 79 | CommentHandler 80 | } 81 | } 82 | ) 83 | 84 | func (tr FieldTypeRef) String() string { 85 | switch tr.Kind { 86 | case FieldTypeIdent: 87 | return tr.Name 88 | case FieldTypeSelector: 89 | return tr.Pkg + "." + tr.Name 90 | case FieldTypePtr: 91 | return "*" + tr.Name 92 | case FieldTypeArray: 93 | return "[]" + tr.Name 94 | case FieldTypeMap: 95 | return "map[string]" + tr.Name 96 | case FieldTypeStruct: 97 | return "struct" 98 | } 99 | return "" 100 | } 101 | 102 | func (tr FieldTypeRef) IsBuiltIn() bool { 103 | switch tr.Name { 104 | case 105 | "string", 106 | "int", 107 | "int8", 108 | "int16", 109 | "int32", 110 | "int64", 111 | "uint", 112 | "uint8", 113 | "uint16", 114 | "uint32", 115 | "uint64", 116 | "float32", 117 | "float64", 118 | "bool", 119 | "byte", 120 | "rune", 121 | "complex64", 122 | "complex128": 123 | return true 124 | 125 | default: 126 | return false 127 | } 128 | } 129 | 130 | func (fs *FileSpec) String() string { 131 | return fs.Name 132 | } 133 | 134 | func (ts *TypeSpec) String() string { 135 | return ts.Name 136 | } 137 | 138 | func (fs *FieldSpec) String() string { 139 | return strings.Join(fs.Names, ", ") 140 | } 141 | -------------------------------------------------------------------------------- /ast/parser.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "fmt" 5 | "go/parser" 6 | "go/token" 7 | "io/fs" 8 | "path/filepath" 9 | 10 | "github.com/g4s8/envdoc/utils" 11 | ) 12 | 13 | type ParserConfigOption func(*Parser) 14 | 15 | func WithDebug(debug bool) ParserConfigOption { 16 | return func(p *Parser) { 17 | p.debug = debug 18 | } 19 | } 20 | 21 | func WithExecConfig(execFile string, execLine int) ParserConfigOption { 22 | return func(p *Parser) { 23 | p.gogenFile = execFile 24 | p.gogenLine = execLine 25 | } 26 | } 27 | 28 | type Parser struct { 29 | fileGlob string 30 | typeGlob string 31 | gogenLine int 32 | gogenFile string 33 | debug bool 34 | } 35 | 36 | func NewParser(fileGlob, typeGlob string, opts ...ParserConfigOption) *Parser { 37 | p := &Parser{ 38 | fileGlob: fileGlob, 39 | typeGlob: typeGlob, 40 | } 41 | 42 | for _, opt := range opts { 43 | opt(p) 44 | } 45 | 46 | return p 47 | } 48 | 49 | func (p *Parser) Parse(dir string) ([]*FileSpec, error) { 50 | fset := token.NewFileSet() 51 | 52 | var colOpts []RootCollectorOption 53 | if p.typeGlob == "" { 54 | colOpts = append(colOpts, WithGoGenDecl(p.gogenLine, p.gogenFile)) 55 | } else { 56 | m, err := utils.NewGlobMatcher(p.typeGlob) 57 | if err != nil { 58 | return nil, fmt.Errorf("create type glob matcher: %w", err) 59 | } 60 | colOpts = append(colOpts, WithTypeGlob(m)) 61 | } 62 | if p.fileGlob != "" { 63 | m, err := utils.NewGlobMatcher(p.fileGlob) 64 | if err != nil { 65 | return nil, fmt.Errorf("create file glob matcher: %w", err) 66 | } 67 | colOpts = append(colOpts, WithFileGlob(m)) 68 | } 69 | 70 | col := NewRootCollector(dir, colOpts...) 71 | 72 | if p.debug { 73 | fmt.Printf("Parsing dir %q (f=%q t=%q)\n", dir, p.fileGlob, p.typeGlob) 74 | } 75 | // walk through the directory and each subdirectory and call parseDir for each of them 76 | if err := filepath.Walk(dir, parseWalker(fset, col)); err != nil { 77 | return nil, fmt.Errorf("failed to walk through dir: %w", err) 78 | } 79 | 80 | if p.debug { 81 | fmt.Printf("Parsed types:\n") 82 | printTraverse(col.Files(), 0) 83 | } 84 | 85 | return col.Files(), nil 86 | } 87 | 88 | func parseWalker(fset *token.FileSet, col *RootCollector) filepath.WalkFunc { 89 | return func(path string, info fs.FileInfo, err error) error { 90 | if err != nil { 91 | return fmt.Errorf("failed to walk through dir: %w", err) 92 | } 93 | if !info.IsDir() { 94 | return nil 95 | } 96 | if err := parseDir(path, fset, col); err != nil { 97 | return fmt.Errorf("failed to parse dir %q: %w", path, err) 98 | } 99 | return nil 100 | } 101 | } 102 | 103 | func parseDir(dir string, fset *token.FileSet, col *RootCollector) error { 104 | pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments|parser.SkipObjectResolution) 105 | if err != nil { 106 | return fmt.Errorf("failed to parse dir: %w", err) 107 | } 108 | 109 | for _, pkg := range pkgs { 110 | Walk(pkg, fset, col) 111 | } 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /ast/parser_test.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/g4s8/envdoc/debug" 12 | "golang.org/x/tools/txtar" 13 | "gopkg.in/yaml.v2" 14 | ) 15 | 16 | func TestDataParser(t *testing.T) { 17 | files, err := filepath.Glob("testdata/parser/*.txtar") 18 | if err != nil { 19 | t.Fatalf("failed to list testdata files: %s", err) 20 | } 21 | t.Logf("Found %d testdata files", len(files)) 22 | if len(files) == 0 { 23 | t.Fatal("no testdata files found") 24 | } 25 | 26 | for _, file := range files { 27 | file := file 28 | 29 | t.Run(filepath.Base(file), func(t *testing.T) { 30 | t.Parallel() 31 | 32 | ar, err := txtar.ParseFile(file) 33 | if err != nil { 34 | t.Fatalf("failed to parse txtar file: %s", err) 35 | } 36 | 37 | dir := t.TempDir() 38 | if err := extractTxtar(ar, dir); err != nil { 39 | t.Fatalf("failed to extract txtar: %s", err) 40 | } 41 | 42 | tc := readTestCase(t, dir) 43 | testParser(t, dir, tc) 44 | }) 45 | } 46 | } 47 | 48 | type parserExpectedTypeRef struct { 49 | Name string `yaml:"name"` 50 | Kind string `yaml:"kind"` 51 | Package string `yaml:"pkg"` 52 | } 53 | 54 | func (ref *parserExpectedTypeRef) toAST(t *testing.T) FieldTypeRef { 55 | t.Helper() 56 | 57 | if ref == nil { 58 | return FieldTypeRef{} 59 | } 60 | 61 | var kind FieldTypeRefKind 62 | if !kind.ScanStr(ref.Kind) { 63 | t.Fatalf("invalid type kind: %s", ref.Kind) 64 | } 65 | return FieldTypeRef{ 66 | Name: ref.Name, 67 | Kind: kind, 68 | Pkg: ref.Package, 69 | } 70 | } 71 | 72 | type parserExpectedField struct { 73 | Names []string `yaml:"names"` 74 | Doc string `yaml:"doc"` 75 | Tag string `yaml:"tag"` 76 | TypeRef *parserExpectedTypeRef `yaml:"type_ref"` 77 | Fields []*parserExpectedField `yaml:"fields"` 78 | } 79 | 80 | func (field *parserExpectedField) toAST(t *testing.T) *FieldSpec { 81 | t.Helper() 82 | 83 | names := make([]string, len(field.Names)) 84 | copy(names, field.Names) 85 | fields := make([]*FieldSpec, len(field.Fields)) 86 | for i, f := range field.Fields { 87 | fields[i] = f.toAST(t) 88 | } 89 | return &FieldSpec{ 90 | Names: names, 91 | Doc: field.Doc, 92 | Tag: field.Tag, 93 | Fields: fields, 94 | TypeRef: field.TypeRef.toAST(t), 95 | } 96 | } 97 | 98 | type parserExpectedType struct { 99 | Name string `yaml:"name"` 100 | Exported bool `yaml:"export"` 101 | Doc string `yaml:"doc"` 102 | Fields []*parserExpectedField `yaml:"fields"` 103 | } 104 | 105 | func (typ *parserExpectedType) toAST(t *testing.T) *TypeSpec { 106 | t.Helper() 107 | 108 | fields := make([]*FieldSpec, len(typ.Fields)) 109 | for i, f := range typ.Fields { 110 | fields[i] = f.toAST(t) 111 | } 112 | return &TypeSpec{ 113 | Name: typ.Name, 114 | Export: typ.Exported, 115 | Doc: typ.Doc, 116 | Fields: fields, 117 | } 118 | } 119 | 120 | type parserExpectedFile struct { 121 | Name string `yaml:"name"` 122 | Package string `yaml:"pkg"` 123 | Exported bool `yaml:"export"` 124 | Types []*parserExpectedType `yaml:"types"` 125 | } 126 | 127 | func (file *parserExpectedFile) toAST(t *testing.T) *FileSpec { 128 | t.Helper() 129 | 130 | types := make([]*TypeSpec, len(file.Types)) 131 | for i, typ := range file.Types { 132 | types[i] = typ.toAST(t) 133 | } 134 | return &FileSpec{ 135 | Name: file.Name, 136 | Pkg: file.Package, 137 | Export: file.Exported, 138 | Types: types, 139 | } 140 | } 141 | 142 | func parserFilesToAST(t *testing.T, files []*parserExpectedFile) []*FileSpec { 143 | t.Helper() 144 | 145 | res := make([]*FileSpec, len(files)) 146 | for i, f := range files { 147 | res[i] = f.toAST(t) 148 | } 149 | return res 150 | } 151 | 152 | type parserTestCase struct { 153 | SrcFile string `yaml:"src_file"` 154 | FileGlob string `yaml:"file_glob"` 155 | TypeGlob string `yaml:"type_glob"` 156 | Debug bool `yaml:"debug"` 157 | 158 | Expect []*parserExpectedFile `yaml:"files"` 159 | } 160 | 161 | func testParser(t *testing.T, dir string, tc parserTestCase) { 162 | t.Helper() 163 | 164 | var opts []ParserConfigOption 165 | if tc.Debug || debug.Config.Enabled { 166 | debug.SetTestLogger(t) 167 | t.Log("Debug mode") 168 | t.Logf("using dir: %s", dir) 169 | opts = append(opts, WithDebug(true)) 170 | } 171 | p := NewParser(tc.FileGlob, tc.TypeGlob, opts...) 172 | files, err := p.Parse(dir) 173 | if err != nil { 174 | t.Fatalf("failed to parse files: %s", err) 175 | } 176 | astFiles := parserFilesToAST(t, tc.Expect) 177 | checkFiles(t, "/files", astFiles, files) 178 | } 179 | 180 | func checkFiles(t *testing.T, prefix string, expect, res []*FileSpec) { 181 | t.Helper() 182 | 183 | if len(expect) != len(res) { 184 | t.Errorf("%s: Expected %d files, got %d", prefix, len(expect), len(res)) 185 | for i, file := range expect { 186 | t.Logf("Expected[%d]: %v", i, file) 187 | } 188 | for i, file := range res { 189 | t.Logf("Got[%d]: %v", i, file) 190 | } 191 | return 192 | } 193 | for i, file := range expect { 194 | checkFile(t, fmt.Sprintf("%s/%s", prefix, file.Name), file, res[i]) 195 | } 196 | } 197 | 198 | func checkFile(t *testing.T, prefix string, expect, res *FileSpec) { 199 | t.Helper() 200 | 201 | if !strings.HasSuffix(res.Name, expect.Name) { 202 | t.Errorf("%s: Expected name %q, got %q", prefix, expect.Name, res.Name) 203 | } 204 | if expect.Pkg != res.Pkg { 205 | t.Errorf("%s: Expected package %q, got %q", prefix, expect.Pkg, res.Pkg) 206 | } 207 | if expect.Export != res.Export { 208 | t.Errorf("%s: Expected export %t, got %t", prefix, expect.Export, res.Export) 209 | } 210 | checkTypes(t, prefix+"/types", expect.Types, res.Types) 211 | } 212 | 213 | func checkTypes(t *testing.T, prefix string, expect, res []*TypeSpec) { 214 | t.Helper() 215 | 216 | if len(expect) != len(res) { 217 | t.Errorf("%s: Expected %d types, got %d", prefix, len(expect), len(res)) 218 | for i, typ := range expect { 219 | t.Logf("Expected[%d]: %v", i, typ) 220 | } 221 | for i, typ := range res { 222 | t.Logf("Got[%d]: %v", i, typ) 223 | } 224 | return 225 | } 226 | for i, typ := range expect { 227 | checkType(t, fmt.Sprintf("%s/%s", prefix, typ.Name), typ, res[i]) 228 | } 229 | } 230 | 231 | func checkType(t *testing.T, prefix string, expect, res *TypeSpec) { 232 | t.Helper() 233 | 234 | if expect.Name != res.Name { 235 | t.Errorf("%s: Expected name %s, got %s", prefix, expect.Name, res.Name) 236 | } 237 | if expect.Doc != res.Doc { 238 | t.Errorf("%s: Expected doc %s, got %s", prefix, expect.Doc, res.Doc) 239 | } 240 | if expect.Export != res.Export { 241 | t.Errorf("%s: Expected export %t, got %t", prefix, expect.Export, res.Export) 242 | } 243 | checkFields(t, prefix+"/fields", expect.Fields, res.Fields) 244 | } 245 | 246 | func checkFields(t *testing.T, prefix string, expect, res []*FieldSpec) { 247 | t.Helper() 248 | 249 | if len(expect) != len(res) { 250 | t.Errorf("%s: Expected %d fields, got %d", prefix, len(expect), len(res)) 251 | for i, field := range expect { 252 | t.Logf("Expected[%d]: %s", i, field) 253 | } 254 | for i, field := range res { 255 | t.Logf("Got[%d]: %s", i, field) 256 | } 257 | return 258 | } 259 | for i, field := range expect { 260 | str := field.String() 261 | if str == "" { 262 | str = fmt.Sprintf("%d", i) 263 | } 264 | checkField(t, fmt.Sprintf("%s/%s", prefix, str), field, res[i]) 265 | } 266 | } 267 | 268 | func checkField(t *testing.T, prefix string, expect, res *FieldSpec) { 269 | t.Helper() 270 | 271 | if len(expect.Names) != len(res.Names) { 272 | t.Errorf("%s: Expected %d names, got %d", prefix, len(expect.Names), len(res.Names)) 273 | for i, name := range expect.Names { 274 | t.Logf("Expected[%d]: %s", i, name) 275 | } 276 | for i, name := range res.Names { 277 | t.Logf("Got[%d]: %s", i, name) 278 | } 279 | return 280 | } 281 | for i, name := range expect.Names { 282 | if name != res.Names[i] { 283 | t.Errorf("%s: Expected name at %s, got %s", prefix, name, res.Names[i]) 284 | } 285 | } 286 | if expect.Doc != res.Doc { 287 | t.Errorf("%s: Expected doc %s, got %s", prefix, expect.Doc, res.Doc) 288 | } 289 | if expect.Tag != res.Tag { 290 | t.Errorf("%s: Expected tag %q, got %q", prefix, expect.Tag, res.Tag) 291 | } 292 | 293 | checkTypeRef(t, prefix+"/typeref", &expect.TypeRef, &res.TypeRef) 294 | checkFields(t, prefix+"/fields", expect.Fields, res.Fields) 295 | } 296 | 297 | func checkTypeRef(t *testing.T, prefix string, expect, res *FieldTypeRef) { 298 | t.Helper() 299 | 300 | if expect.Name != res.Name { 301 | t.Errorf("%s: Expected type name %s, got %s", prefix, expect.Name, res.Name) 302 | } 303 | if expect.Kind != res.Kind { 304 | t.Errorf("%s: Expected type kind %s, got %s", prefix, expect.Kind, res.Kind) 305 | } 306 | } 307 | 308 | //--- 309 | 310 | func extractTxtar(ar *txtar.Archive, dir string) error { 311 | for _, file := range ar.Files { 312 | name := filepath.Join(dir, file.Name) 313 | if err := os.MkdirAll(filepath.Dir(name), 0o777); err != nil { 314 | return err 315 | } 316 | if err := os.WriteFile(name, file.Data, 0o666); err != nil { 317 | return err 318 | } 319 | } 320 | return nil 321 | } 322 | 323 | func readTestCase(t *testing.T, dir string) parserTestCase { 324 | t.Helper() 325 | 326 | testCaseFile, err := os.Open(path.Join(dir, "testcase.yaml")) 327 | if err != nil { 328 | t.Fatalf("failed to open testcase file: %s", err) 329 | } 330 | defer testCaseFile.Close() 331 | 332 | var tmp struct { 333 | TestCase parserTestCase `yaml:"testcase"` 334 | } 335 | if err := yaml.NewDecoder(testCaseFile).Decode(&tmp); err != nil { 336 | t.Fatalf("failed to decode testcase: %s", err) 337 | } 338 | return tmp.TestCase 339 | } 340 | -------------------------------------------------------------------------------- /ast/pkg.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "go/ast" 5 | "go/doc" 6 | "go/token" 7 | ) 8 | 9 | type pkgVisitor struct { 10 | fset *token.FileSet 11 | h FileHandler 12 | 13 | pkg string 14 | docs *doc.Package 15 | } 16 | 17 | func newPkgVisitor(fset *token.FileSet, h FileHandler) *pkgVisitor { 18 | return &pkgVisitor{ 19 | fset: fset, 20 | h: h, 21 | } 22 | } 23 | 24 | func (p *pkgVisitor) Visit(n ast.Node) ast.Visitor { 25 | debugNode("pkg", n) 26 | switch t := n.(type) { 27 | //nolint:staticcheck 28 | case *ast.Package: 29 | p.pkg = t.Name 30 | p.docs = doc.New(t, "./", doc.PreserveAST|doc.AllDecls) 31 | return p 32 | case *ast.File: 33 | f := p.fset.File(t.Pos()) 34 | if fa := p.h.onFile(&FileSpec{ 35 | Name: f.Name(), 36 | Pkg: p.pkg, 37 | }); fa != nil { 38 | return newFileVisitor(p.fset, t, p.docs, fa) 39 | } 40 | } 41 | return p 42 | } 43 | -------------------------------------------------------------------------------- /ast/pkg_test.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "go/ast" 5 | "slices" 6 | "testing" 7 | ) 8 | 9 | func TestPkgVisitor(t *testing.T) { 10 | fset, pkg, _ := loadTestFileSet(t) 11 | h := &testFileHandler{} 12 | v := newPkgVisitor(fset, h) 13 | ast.Walk(v, pkg) 14 | if len(h.files) != 4 { 15 | t.Fatalf("expected 4 files, got %d", len(h.files)) 16 | } 17 | expectFiles := []string{"empty.go", "onetype.go", "twotypes.go", "fields.go"} 18 | expectPkg := "testdata" 19 | fileNames := make([]string, len(h.files)) 20 | for i, f := range h.files { 21 | fileNames[i] = f.Name 22 | t.Logf("file %q", f.Name) 23 | } 24 | for _, e := range expectFiles { 25 | e = "testdata/" + e 26 | if !slices.Contains(fileNames, e) { 27 | t.Fatalf("file %q not found", e) 28 | } 29 | } 30 | for _, f := range h.files { 31 | if f.Pkg != expectPkg { 32 | t.Fatalf("expected pkg %q, got %q", expectPkg, f.Pkg) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ast/testdata/empty.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | // empty 4 | -------------------------------------------------------------------------------- /ast/testdata/fields.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | // fields 4 | 5 | // Fields is a struct 6 | type Fields struct { 7 | // A field 8 | A int 9 | B string 10 | C bool // c-field 11 | } 12 | -------------------------------------------------------------------------------- /ast/testdata/onetype.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | // onetype 4 | type One struct{} 5 | -------------------------------------------------------------------------------- /ast/testdata/parser/anonymous.txtar: -------------------------------------------------------------------------------- 1 | Anonymous field struct 2 | 3 | -- src.go -- 4 | package main 5 | 6 | // Config is the configuration for the application. 7 | type Config struct { 8 | // Repo is the configuration for the repository. 9 | Repo struct { 10 | // Conn is the connection string for the repository. 11 | Conn string `env:"CONN,notEmpty"` 12 | } `envPrefix:"REPO_"` 13 | } 14 | 15 | -- testcase.yaml -- 16 | testcase: 17 | src_file: src.go 18 | file_glob: "*.go" 19 | type_glob: "*" 20 | files: 21 | - name: src.go 22 | pkg: main 23 | export: true 24 | types: 25 | - name: Config 26 | export: true 27 | doc: Config is the configuration for the application. 28 | fields: 29 | - names: [Repo] 30 | doc: Repo is the configuration for the repository. 31 | tag: envPrefix:"REPO_" 32 | type_ref: {kind: Struct} 33 | fields: 34 | - names: [Conn] 35 | doc: Conn is the connection string for the repository. 36 | tag: env:"CONN,notEmpty" 37 | type_ref: {name: string, kind: Ident} 38 | -------------------------------------------------------------------------------- /ast/testdata/parser/arrays.txtar: -------------------------------------------------------------------------------- 1 | Array fields 2 | 3 | -- src.go -- 4 | package testdata 5 | 6 | // Arrays stub 7 | type Arrays struct { 8 | // DotSeparated stub 9 | DotSeparated []string `env:"DOT_SEPARATED" envSeparator:"."` 10 | // CommaSeparated stub 11 | CommaSeparated []string `env:"COMMA_SEPARATED"` 12 | } 13 | 14 | -- testcase.yaml -- 15 | 16 | testcase: 17 | src_file: src.go 18 | file_glob: "*.go" 19 | type_glob: "*" 20 | files: 21 | - name: src.go 22 | pkg: testdata 23 | export: true 24 | types: 25 | - name: Arrays 26 | export: true 27 | doc: Arrays stub 28 | fields: 29 | - names: [DotSeparated] 30 | doc: DotSeparated stub 31 | tag: env:"DOT_SEPARATED" envSeparator:"." 32 | type_ref: {name: string, kind: Array} 33 | - names: [CommaSeparated] 34 | doc: CommaSeparated stub 35 | tag: env:"COMMA_SEPARATED" 36 | type_ref: {name: string, kind: Array} 37 | -------------------------------------------------------------------------------- /ast/testdata/parser/comments.txtar: -------------------------------------------------------------------------------- 1 | Go comments as documentation 2 | 3 | -- src.go -- 4 | package testdata 5 | 6 | //go:generate STUB 7 | type Comments struct { 8 | // Foo stub 9 | Foo int `env:"FOO"` 10 | Bar int `env:"BAR"` // Bar stub 11 | } 12 | 13 | -- testcase.yaml -- 14 | 15 | testcase: 16 | src_file: src.go 17 | file_glob: "*.go" 18 | type_glob: "*" 19 | files: 20 | - name: src.go 21 | pkg: testdata 22 | export: true 23 | types: 24 | - name: Comments 25 | export: true 26 | fields: 27 | - names: [Foo] 28 | doc: Foo stub 29 | tag: env:"FOO" 30 | type_ref: {name: int, kind: Ident} 31 | - names: [Bar] 32 | doc: Bar stub 33 | tag: env:"BAR" 34 | type_ref: {name: int, kind: Ident} 35 | -------------------------------------------------------------------------------- /ast/testdata/parser/embedded.txtar: -------------------------------------------------------------------------------- 1 | Field with embedded structs 2 | 3 | -- src.go -- 4 | package testdata 5 | 6 | import "time" 7 | 8 | type ServerConfig struct { 9 | // Host of the server. 10 | Host string `env:"HOST"` 11 | } 12 | 13 | type Config struct { 14 | ServerConfig 15 | 16 | // Start date. 17 | Start Date `env:"START,notEmpty"` 18 | } 19 | 20 | // Date is a time.Time wrapper that uses the time.DateOnly layout. 21 | type Date struct { 22 | time.Time 23 | } 24 | 25 | -- testcase.yaml -- 26 | 27 | testcase: 28 | src_file: src.go 29 | file_glob: "*.go" 30 | type_glob: Config 31 | files: 32 | - name: src.go 33 | pkg: testdata 34 | export: true 35 | types: 36 | - name: ServerConfig 37 | export: false 38 | fields: 39 | - names: [Host] 40 | doc: Host of the server. 41 | tag: env:"HOST" 42 | type_ref: {name: string, kind: Ident} 43 | - name: Config 44 | export: true 45 | fields: 46 | - names: [] 47 | type_ref: {name: ServerConfig, kind: Ident} 48 | - names: [Start] 49 | doc: Start date. 50 | tag: env:"START,notEmpty" 51 | type_ref: {name: Date, kind: Ident} 52 | - name: Date 53 | export: false 54 | doc: Date is a time.Time wrapper that uses the time.DateOnly layout. 55 | fields: 56 | - type_ref: {name: Time, package: time, kind: Selector} 57 | -------------------------------------------------------------------------------- /ast/testdata/parser/envprefix.txtar: -------------------------------------------------------------------------------- 1 | Struct fields with env-prefix 2 | 3 | -- src.go -- 4 | package main 5 | 6 | // Settings is the application settings. 7 | type Settings struct { 8 | // Database is the database settings 9 | Database Database `envPrefix:"DB_"` 10 | 11 | // Server is the server settings 12 | Server ServerConfig `envPrefix:"SERVER_"` 13 | 14 | // Debug is the debug flag 15 | Debug bool `env:"DEBUG"` 16 | } 17 | 18 | // Database is the database settings. 19 | type Database struct { 20 | // Port is the port to connect to 21 | Port Int `env:"PORT,required"` 22 | // Host is the host to connect to 23 | Host string `env:"HOST,notEmpty" envDefault:"localhost"` 24 | // User is the user to connect as 25 | User string `env:"USER"` 26 | // Password is the password to use 27 | Password string `env:"PASSWORD"` 28 | // DisableTLS is the flag to disable TLS 29 | DisableTLS bool `env:"DISABLE_TLS"` 30 | } 31 | 32 | // ServerConfig is the server settings. 33 | type ServerConfig struct { 34 | // Port is the port to listen on 35 | Port Int `env:"PORT,required"` 36 | 37 | // Host is the host to listen on 38 | Host string `env:"HOST,notEmpty" envDefault:"localhost"` 39 | 40 | // Timeout is the timeout settings 41 | Timeout TimeoutConfig `envPrefix:"TIMEOUT_"` 42 | } 43 | 44 | // TimeoutConfig is the timeout settings. 45 | type TimeoutConfig struct { 46 | // Read is the read timeout 47 | Read Int `env:"READ" envDefault:"30"` 48 | // Write is the write timeout 49 | Write Int `env:"WRITE" envDefault:"30"` 50 | } 51 | 52 | -- testcase.yaml -- 53 | 54 | testcase: 55 | src_file: src.go 56 | file_glob: "*.go" 57 | type_glob: Settings 58 | files: 59 | - name: src.go 60 | pkg: main 61 | export: true 62 | types: 63 | - name: Settings 64 | export: true 65 | doc: Settings is the application settings. 66 | fields: 67 | - names: [Database] 68 | doc: Database is the database settings 69 | tag: envPrefix:"DB_" 70 | type_ref: {name: Database, kind: Ident} 71 | - names: [Server] 72 | doc: Server is the server settings 73 | tag: envPrefix:"SERVER_" 74 | type_ref: {name: ServerConfig, kind: Ident} 75 | - names: [Debug] 76 | doc: Debug is the debug flag 77 | tag: env:"DEBUG" 78 | type_ref: {name: bool, kind: Ident} 79 | - name: Database 80 | export: false 81 | doc: Database is the database settings. 82 | fields: 83 | - names: [Port] 84 | doc: Port is the port to connect to 85 | tag: env:"PORT,required" 86 | type_ref: {name: Int, kind: Ident} 87 | - names: [Host] 88 | doc: Host is the host to connect to 89 | tag: env:"HOST,notEmpty" envDefault:"localhost" 90 | type_ref: {name: string, kind: Ident} 91 | - names: [User] 92 | doc: User is the user to connect as 93 | tag: env:"USER" 94 | type_ref: {name: string, kind: Ident} 95 | - names: [Password] 96 | doc: Password is the password to use 97 | tag: env:"PASSWORD" 98 | type_ref: {name: string, kind: Ident} 99 | - names: [DisableTLS] 100 | doc: DisableTLS is the flag to disable TLS 101 | tag: env:"DISABLE_TLS" 102 | type_ref: {name: bool, kind: Ident} 103 | - name: ServerConfig 104 | export: false 105 | doc: ServerConfig is the server settings. 106 | fields: 107 | - names: [Port] 108 | doc: Port is the port to listen on 109 | tag: env:"PORT,required" 110 | type_ref: {name: Int, kind: Ident} 111 | - names: [Host] 112 | doc: Host is the host to listen on 113 | tag: env:"HOST,notEmpty" envDefault:"localhost" 114 | type_ref: {name: string, kind: Ident} 115 | - names: [Timeout] 116 | doc: Timeout is the timeout settings 117 | tag: envPrefix:"TIMEOUT_" 118 | type_ref: {name: TimeoutConfig, kind: Ident} 119 | - name: TimeoutConfig 120 | export: false 121 | doc: TimeoutConfig is the timeout settings. 122 | fields: 123 | - names: [Read] 124 | doc: Read is the read timeout 125 | tag: env:"READ" envDefault:"30" 126 | type_ref: {name: Int, kind: Ident} 127 | - names: [Write] 128 | doc: Write is the write timeout 129 | tag: env:"WRITE" envDefault:"30" 130 | type_ref: {name: Int, kind: Ident} 131 | 132 | -------------------------------------------------------------------------------- /ast/testdata/parser/field_names.txtar: -------------------------------------------------------------------------------- 1 | Test field names as env names 2 | 3 | -- src.go -- 4 | package testdata 5 | 6 | // FieldNames uses field names as env names. 7 | type FieldNames struct { 8 | // Foo is a single field. 9 | Foo string 10 | // Bar and Baz are two fields. 11 | Bar, Baz string 12 | // Quux is a field with a tag. 13 | Quux string `env:"QUUX"` 14 | // FooBar is a field with a default value. 15 | FooBar string `envDefault:"quuux"` 16 | // Required is a required field. 17 | Required string `env:",required"` 18 | } 19 | 20 | -- testcase.yaml -- 21 | 22 | testcase: 23 | src_file: src.go 24 | file_glob: "*.go" 25 | type_glob: FieldNames 26 | files: 27 | - name: src.go 28 | pkg: testdata 29 | export: true 30 | types: 31 | - name: FieldNames 32 | export: true 33 | doc: FieldNames uses field names as env names. 34 | fields: 35 | - names: [Foo] 36 | doc: Foo is a single field. 37 | type_ref: {name: string, kind: Ident} 38 | - names: [Bar, Baz] 39 | doc: Bar and Baz are two fields. 40 | type_ref: {name: string, kind: Ident} 41 | - names: [Quux] 42 | doc: Quux is a field with a tag. 43 | tag: env:"QUUX" 44 | type_ref: {name: string, kind: Ident} 45 | - names: [FooBar] 46 | doc: FooBar is a field with a default value. 47 | tag: envDefault:"quuux" 48 | type_ref: {name: string, kind: Ident} 49 | - names: [Required] 50 | doc: Required is a required field. 51 | tag: env:",required" 52 | type_ref: {name: string, kind: Ident} 53 | -------------------------------------------------------------------------------- /ast/testdata/parser/funcs.txtar: -------------------------------------------------------------------------------- 1 | Parser should ignore funcs. 2 | 3 | -- src.go -- 4 | package testdata 5 | 6 | // Test case for #21 where the envdoc panics if target type function presents. 7 | 8 | //go:generate envdoc -output test.md --all 9 | type aconfig struct { 10 | // this is some value 11 | Somevalue string `env:"SOME_VALUE" envDefault:"somevalue"` 12 | } 13 | 14 | // when this function is present, go generate panics with "expected type node root child, got nodeField ()". 15 | func someFuncThatTakesInput(configs ...interface{}) { 16 | // this is some comment 17 | } 18 | 19 | func (a *aconfig) someFuncThatTakesInput(configs ...interface{}) { 20 | // this is some comment 21 | } 22 | 23 | -- testcase.yaml -- 24 | 25 | testcase: 26 | src_file: src.go 27 | file_glob: "*.go" 28 | type_glob: aconfig 29 | files: 30 | - name: src.go 31 | pkg: testdata 32 | export: true 33 | types: 34 | - name: aconfig 35 | export: true 36 | fields: 37 | - names: [Somevalue] 38 | doc: this is some value 39 | tag: env:"SOME_VALUE" envDefault:"somevalue" 40 | type_ref: {name: string, kind: Ident} 41 | -------------------------------------------------------------------------------- /ast/testdata/parser/go_generate.txtar: -------------------------------------------------------------------------------- 1 | Go generate directives should be ignored. 2 | 3 | -- src.go -- 4 | package testdata 5 | 6 | //go:generate STUB 7 | type Type1 struct { 8 | // Foo stub 9 | Foo int `env:"FOO"` 10 | } 11 | 12 | type Type2 struct { 13 | // Baz stub 14 | Baz int `env:"BAZ"` 15 | } 16 | 17 | -- testcase.yaml -- 18 | testcase: 19 | src_file: src.go 20 | file_glob: "*.go" 21 | type_glob: "*" 22 | files: 23 | - name: src.go 24 | pkg: testdata 25 | export: true 26 | types: 27 | - name: Type1 28 | export: true 29 | fields: 30 | - names: [Foo] 31 | doc: Foo stub 32 | tag: env:"FOO" 33 | type_ref: {name: int, kind: Ident} 34 | - name: Type2 35 | export: true 36 | fields: 37 | - names: [Baz] 38 | doc: Baz stub 39 | tag: env:"BAZ" 40 | type_ref: {name: int, kind: Ident} 41 | -------------------------------------------------------------------------------- /ast/testdata/parser/multifile.txtar: -------------------------------------------------------------------------------- 1 | Multiple files. 2 | 3 | -- cfg/config.go -- 4 | package cfg 5 | 6 | import "github.com/smallstep/certificates/db" 7 | 8 | // Config for the application. 9 | type Config struct { 10 | // Environment of the application. 11 | Environment string `env:"ENVIRONMENT,notEmpty" envDefault:"development"` 12 | 13 | ServerConfig `envPrefix:"SERVER_"` 14 | 15 | // Database config. 16 | Database db.Config `envPrefix:"DB_"` 17 | } 18 | 19 | -- cfg/server.go -- 20 | package cfg 21 | 22 | type Config struct { 23 | // Host of the server. 24 | Host string `env:"HOST,notEmpty"` 25 | // Port of the server. 26 | Port int `env:"PORT" envDefault:"8080"` 27 | } 28 | 29 | -- db/config.go -- 30 | package db 31 | 32 | // Config for the database. 33 | type Config struct { 34 | // Host of the database. 35 | Host string `env:"HOST,notEmpty"` 36 | // Port of the database. 37 | Port int `env:"PORT" envDefault:"5432"` 38 | // Username of the database. 39 | Username string `env:"USERNAME,notEmpty"` 40 | // Password of the database. 41 | Password string `env:"PASSWORD,notEmpty"` 42 | // Name of the database. 43 | Name string `env:"NAME,notEmpty"` 44 | } 45 | 46 | -- testcase.yaml -- 47 | testcase: 48 | file_glob: "./cfg/config.go" 49 | type_glob: 'Config' 50 | files: 51 | - name: ./cfg/config.go 52 | pkg: cfg 53 | export: true 54 | types: 55 | - name: 'Config' 56 | export: true 57 | fields: 58 | - names: [Environment] 59 | doc: Environment of the application. 60 | tag: env:"ENVIRONMENT,notEmpty" envDefault:"development" 61 | type_ref: {name: string, kind: Ident} 62 | - 63 | type_ref: {name: ServerConfig, kind: Ident} 64 | tag: envPrefix:"SERVER_" 65 | - names: [Database] 66 | doc: Database config. 67 | tag: envPrefix:"DB_" 68 | type_ref: {name: Config, kind: Selector} 69 | - name: ./cfg/server.go 70 | pkg: cfg 71 | export: false 72 | types: 73 | - name: Config 74 | export: true 75 | fields: 76 | - names: [Host] 77 | doc: Host of the server. 78 | tag: env:"HOST,notEmpty" 79 | type_ref: {name: string, kind: Ident} 80 | - names: [Port] 81 | doc: Port of the server. 82 | tag: env:"PORT" envDefault:"8080" 83 | type_ref: {name: int, kind: Ident} 84 | - name: ./db/config.go 85 | pkg: db 86 | export: false 87 | types: 88 | - name: Config 89 | doc: Config for the database. 90 | export: true 91 | fields: 92 | - names: [Host] 93 | doc: Host of the database. 94 | tag: env:"HOST,notEmpty" 95 | type_ref: {name: string, kind: Ident} 96 | - names: [Port] 97 | doc: Port of the database. 98 | tag: env:"PORT" envDefault:"5432" 99 | type_ref: {name: int, kind: Ident} 100 | - names: [Username] 101 | doc: Username of the database. 102 | tag: env:"USERNAME,notEmpty" 103 | type_ref: {name: string, kind: Ident} 104 | - names: [Password] 105 | doc: Password of the database. 106 | tag: env:"PASSWORD,notEmpty" 107 | type_ref: {name: string, kind: Ident} 108 | - names: [Name] 109 | doc: Name of the database. 110 | tag: env:"NAME,notEmpty" 111 | type_ref: {name: string, kind: Ident} 112 | -------------------------------------------------------------------------------- /ast/testdata/parser/nodocs.txtar: -------------------------------------------------------------------------------- 1 | 2 | -- src.go -- 3 | package main 4 | 5 | type Config struct { 6 | Repo struct { 7 | Conn string `env:"CONN,notEmpty"` 8 | } `envPrefix:"REPO_"` 9 | } 10 | 11 | -- testcase.yaml -- 12 | 13 | testcase: 14 | src_file: src.go 15 | file_glob: "*.go" 16 | type_glob: Config 17 | files: 18 | - name: src.go 19 | pkg: main 20 | export: true 21 | types: 22 | - name: Config 23 | export: true 24 | fields: 25 | - names: [Repo] 26 | type_ref: {kind: Struct} 27 | tag: envPrefix:"REPO_" 28 | fields: 29 | - names: [Conn] 30 | tag: env:"CONN,notEmpty" 31 | type_ref: {name: string, kind: Ident} 32 | -------------------------------------------------------------------------------- /ast/testdata/parser/simple.txtar: -------------------------------------------------------------------------------- 1 | Simple test case 2 | 3 | -- src.go -- 4 | package testdata 5 | 6 | type Foo struct { 7 | // One is a one. 8 | One string `env:"ONE"` 9 | // Two is a two. 10 | Two string `env:"TWO"` 11 | } 12 | 13 | // Bar is a bar. 14 | type Bar struct { 15 | // Three is a three. 16 | Three string `env:"THREE"` 17 | // Four is a four. 18 | Four string `env:"FOUR"` 19 | } 20 | 21 | -- testcase.yaml -- 22 | 23 | testcase: 24 | src_file: src.go 25 | file_glob: "*.go" 26 | type_glob: "*" 27 | files: 28 | - name: src.go 29 | pkg: testdata 30 | export: true 31 | types: 32 | - name: Foo 33 | export: true 34 | fields: 35 | - names: [One] 36 | doc: One is a one. 37 | tag: env:"ONE" 38 | type_ref: {name: string, kind: Ident} 39 | - names: [Two] 40 | doc: Two is a two. 41 | tag: env:"TWO" 42 | type_ref: {name: string, kind: Ident} 43 | - name: Bar 44 | export: true 45 | doc: Bar is a bar. 46 | fields: 47 | - names: [Three] 48 | doc: Three is a three. 49 | tag: env:"THREE" 50 | type_ref: {name: string, kind: Ident} 51 | - names: [Four] 52 | doc: Four is a four. 53 | tag: env:"FOUR" 54 | type_ref: {name: string, kind: Ident} 55 | -------------------------------------------------------------------------------- /ast/testdata/parser/tags.txtar: -------------------------------------------------------------------------------- 1 | Multiple tags for fields. 2 | 3 | -- src.go -- 4 | package testdata 5 | 6 | type Type1 struct { 7 | // Secret is a secret value that is read from a file. 8 | Secret string `env:"SECRET,file"` 9 | // Password is a password that is read from a file. 10 | Password string `env:"PASSWORD,file" envDefault:"/tmp/password" json:"password"` 11 | // Certificate is a certificate that is read from a file. 12 | Certificate string `env:"CERTIFICATE,file,expand" envDefault:"${CERTIFICATE_FILE}"` 13 | // Key is a secret key. 14 | SecretKey string `env:"SECRET_KEY,required" json:"secret_key"` 15 | // SecretVal is a secret value. 16 | SecretVal string `json:"secret_val" env:"SECRET_VAL,notEmpty"` 17 | // NotEnv is not an environment variable. 18 | NotEnv string `json:"not_env"` 19 | // NoTag is not tagged. 20 | NoTag string 21 | // BrokenTag is a tag that is broken. 22 | BrokenTag string `env:"BROKEN_TAG,required` 23 | } 24 | 25 | -- testcase.yaml -- 26 | 27 | testcase: 28 | src_file: src.go 29 | file_glob: "*.go" 30 | type_glob: Type1 31 | files: 32 | - name: src.go 33 | pkg: testdata 34 | export: true 35 | types: 36 | - name: Type1 37 | export: true 38 | fields: 39 | - names: [Secret] 40 | doc: Secret is a secret value that is read from a file. 41 | tag: env:"SECRET,file" 42 | type_ref: {name: string, kind: Ident} 43 | - names: [Password] 44 | doc: Password is a password that is read from a file. 45 | tag: env:"PASSWORD,file" envDefault:"/tmp/password" json:"password" 46 | type_ref: {name: string, kind: Ident} 47 | - names: [Certificate] 48 | doc: Certificate is a certificate that is read from a file. 49 | tag: env:"CERTIFICATE,file,expand" envDefault:"${CERTIFICATE_FILE}" 50 | type_ref: {name: string, kind: Ident} 51 | - names: [SecretKey] 52 | doc: Key is a secret key. 53 | tag: env:"SECRET_KEY,required" json:"secret_key" 54 | type_ref: {name: string, kind: Ident} 55 | - names: [SecretVal] 56 | doc: SecretVal is a secret value. 57 | tag: json:"secret_val" env:"SECRET_VAL,notEmpty" 58 | type_ref: {name: string, kind: Ident} 59 | - names: [NotEnv] 60 | doc: NotEnv is not an environment variable. 61 | tag: json:"not_env" 62 | type_ref: {name: string, kind: Ident} 63 | - names: [NoTag] 64 | doc: NoTag is not tagged. 65 | type_ref: {name: string, kind: Ident} 66 | - names: [BrokenTag] 67 | doc: BrokenTag is a tag that is broken. 68 | tag: 'env:"BROKEN_TAG,required' 69 | type_ref: {name: string, kind: Ident} 70 | 71 | -------------------------------------------------------------------------------- /ast/testdata/parser/type.txtar: -------------------------------------------------------------------------------- 1 | Multiple types. 2 | 3 | -- src.go -- 4 | package testdata 5 | 6 | type Type1 struct { 7 | // Foo stub 8 | Foo int `env:"FOO"` 9 | } 10 | 11 | type Type2 struct { 12 | // Baz stub 13 | Baz int `env:"BAZ"` 14 | } 15 | 16 | -- testcase.yaml -- 17 | 18 | testcase: 19 | src_file: src.go 20 | file_glob: "*.go" 21 | type_glob: Type* 22 | files: 23 | - name: src.go 24 | pkg: testdata 25 | export: true 26 | types: 27 | - name: Type1 28 | export: true 29 | fields: 30 | - names: [Foo] 31 | doc: Foo stub 32 | tag: env:"FOO" 33 | type_ref: {name: int, kind: Ident} 34 | - name: Type2 35 | export: true 36 | fields: 37 | - names: [Baz] 38 | doc: Baz stub 39 | tag: env:"BAZ" 40 | type_ref: {name: int, kind: Ident} 41 | -------------------------------------------------------------------------------- /ast/testdata/parser/typedef.txtar: -------------------------------------------------------------------------------- 1 | Custom type definition. 2 | 3 | -- src.go -- 4 | package testdata 5 | 6 | import "time" 7 | 8 | type Config struct { 9 | // Start date. 10 | Start Date `env:"START"` 11 | } 12 | 13 | // Date is a time.Time wrapper that uses the time.DateOnly layout. 14 | type Date time.Time 15 | 16 | -- testcase.yaml -- 17 | testcase: 18 | src_file: src.go 19 | file_glob: "*.go" 20 | type_glob: Config 21 | files: 22 | - name: src.go 23 | pkg: testdata 24 | export: true 25 | types: 26 | - name: Config 27 | export: true 28 | fields: 29 | - names: [Start] 30 | doc: Start date. 31 | tag: env:"START" 32 | type_ref: {name: Date, kind: Ident} 33 | - name: Date 34 | export: false 35 | doc: Date is a time.Time wrapper that uses the time.DateOnly layout. 36 | -------------------------------------------------------------------------------- /ast/testdata/parser/unexported.txtar: -------------------------------------------------------------------------------- 1 | Unexported type. 2 | 3 | -- src.go -- 4 | package testdata 5 | 6 | type appconfig struct { 7 | // Port the application will listen on inside the container 8 | Port int `env:"PORT" envDefault:"8080"` 9 | } 10 | 11 | -- testcase.yaml -- 12 | 13 | testcase: 14 | src_file: src.go 15 | file_glob: "*.go" 16 | type_glob: appconfig 17 | files: 18 | - name: src.go 19 | pkg: testdata 20 | export: true 21 | types: 22 | - name: appconfig 23 | export: true 24 | fields: 25 | - names: [Port] 26 | doc: Port the application will listen on inside the container 27 | tag: env:"PORT" envDefault:"8080" 28 | type_ref: {name: int, kind: Ident} 29 | -------------------------------------------------------------------------------- /ast/testdata/twotypes.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | // twotypes 4 | 5 | type ( 6 | Foo1 struct{} 7 | Foo2 struct{} 8 | ) 9 | -------------------------------------------------------------------------------- /ast/testhelper.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "go/ast" 5 | "go/doc" 6 | "go/parser" 7 | "go/token" 8 | ) 9 | 10 | type T interface { 11 | Helper() 12 | Fatal(args ...interface{}) 13 | Fatalf(format string, args ...interface{}) 14 | } 15 | 16 | //nolint:staticcheck 17 | func loadTestFileSet(t T) (*token.FileSet, *ast.Package, *doc.Package) { 18 | t.Helper() 19 | // load go files from ./testdata dir 20 | fset := token.NewFileSet() 21 | pkgs, err := parser.ParseDir(fset, "./testdata", nil, parser.ParseComments|parser.SkipObjectResolution) 22 | if err != nil { 23 | t.Fatalf("failed to parse testdata: %s", err) 24 | } 25 | pkg, ok := pkgs["testdata"] 26 | if !ok { 27 | t.Fatal("package 'testdata' not found") 28 | } 29 | docs := doc.New(pkg, "./", doc.PreserveAST|doc.AllDecls) 30 | return fset, pkg, docs 31 | } 32 | 33 | var ( 34 | _ TypeHandler = (*testTypeHandler)(nil) 35 | _ CommentHandler = (*testTypeHandler)(nil) 36 | _ FileHandler = (*testFileHandler)(nil) 37 | ) 38 | 39 | type TestTypeHandler interface { 40 | TypeHandler 41 | CommentHandler 42 | 43 | Types() []*TypeSpec 44 | } 45 | 46 | type testCommentHandler struct { 47 | comments []*CommentSpec 48 | } 49 | 50 | func (h *testCommentHandler) setComment(c *CommentSpec) { 51 | h.comments = append(h.comments, c) 52 | } 53 | 54 | type testSubfieldHandler struct { 55 | f *FieldSpec 56 | } 57 | 58 | func (h *testSubfieldHandler) onField(f *FieldSpec) FieldHandler { 59 | h.f.Fields = append(h.f.Fields, f) 60 | return &testSubfieldHandler{f: f} 61 | } 62 | 63 | type testFieldHandler struct { 64 | testCommentHandler 65 | t *TypeSpec 66 | } 67 | 68 | func (h *testFieldHandler) onField(f *FieldSpec) FieldHandler { 69 | h.t.Fields = append(h.t.Fields, f) 70 | return &testSubfieldHandler{f: f} 71 | } 72 | 73 | type testTypeHandler struct { 74 | testCommentHandler 75 | f *FileSpec 76 | } 77 | 78 | func (h *testTypeHandler) onType(t *TypeSpec) typeVisitorHandler { 79 | h.f.Types = append(h.f.Types, t) 80 | return &testFieldHandler{t: t} 81 | } 82 | 83 | func (h *testTypeHandler) Types() []*TypeSpec { 84 | return h.f.Types 85 | } 86 | 87 | type testFileHandler struct { 88 | testCommentHandler 89 | files []*FileSpec 90 | } 91 | 92 | func (h *testFileHandler) onFile(f *FileSpec) interface { 93 | TypeHandler 94 | CommentHandler 95 | } { 96 | h.files = append(h.files, f) 97 | return &testTypeHandler{f: f} 98 | } 99 | 100 | //nolint:staticcheck 101 | func testFileVisitor(fset *token.FileSet, pkg *ast.Package, fileName string, 102 | docs *doc.Package, 103 | ) (*testFileHandler, *fileVisitor, *ast.File) { 104 | fileAst := pkg.Files[fileName] 105 | fileTkn := fset.File(fileAst.Pos()) 106 | fileSpec := &FileSpec{ 107 | Name: fileTkn.Name(), 108 | Pkg: pkg.Name, 109 | } 110 | fh := &testFileHandler{} 111 | th := fh.onFile(fileSpec) 112 | fv := newFileVisitor(fset, fileAst, docs, th) 113 | return fh, fv, fileAst 114 | } 115 | -------------------------------------------------------------------------------- /ast/type.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "go/ast" 5 | ) 6 | 7 | type typeVisitorHandler = interface { 8 | CommentHandler 9 | FieldHandler 10 | } 11 | 12 | type typeVisitor struct { 13 | pkg string 14 | h typeVisitorHandler 15 | } 16 | 17 | func newTypeVisitor(pkg string, h typeVisitorHandler) *typeVisitor { 18 | return &typeVisitor{pkg: pkg, h: h} 19 | } 20 | 21 | func (v *typeVisitor) Visit(n ast.Node) ast.Visitor { 22 | debugNode("type", n) 23 | switch t := n.(type) { 24 | case *ast.Comment: 25 | v.h.setComment(&CommentSpec{ 26 | Text: t.Text, 27 | }) 28 | return nil 29 | case *ast.Field: 30 | fs := getFieldSpec(t, v.pkg) 31 | if fs == nil { 32 | return nil 33 | } 34 | if fa := v.h.onField(fs); fa != nil { 35 | return newFieldVisitor(v.pkg, fa) 36 | } 37 | return nil 38 | } 39 | return v 40 | } 41 | -------------------------------------------------------------------------------- /ast/types_test.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "go/ast" 5 | "testing" 6 | ) 7 | 8 | type fileHandler struct { 9 | types []*TypeSpec 10 | typeH *typeHandler 11 | } 12 | 13 | func (h *fileHandler) setComment(_ *CommentSpec) { 14 | } 15 | 16 | func (h *fileHandler) onType(t *TypeSpec) typeVisitorHandler { 17 | h.types = append(h.types, t) 18 | h.typeH = &typeHandler{} 19 | return h.typeH 20 | } 21 | 22 | type typeHandler struct { 23 | fields []*FieldSpec 24 | comments []*CommentSpec 25 | } 26 | 27 | func (h *typeHandler) setComment(c *CommentSpec) { 28 | h.comments = append(h.comments, c) 29 | } 30 | 31 | func (h *typeHandler) onField(f *FieldSpec) FieldHandler { 32 | h.fields = append(h.fields, f) 33 | return nil 34 | } 35 | 36 | func TestTypesVisitor(t *testing.T) { 37 | fset, pkg, docs := loadTestFileSet(t) 38 | file := pkg.Files["testdata/fields.go"] 39 | h := &fileHandler{} 40 | v := newFileVisitor(fset, file, docs, h) 41 | 42 | ast.Walk(v, file) 43 | 44 | fh := h.typeH 45 | if expect, actual := 3, len(fh.fields); expect != actual { 46 | t.Fatalf("expected %d fields, got %d", expect, actual) 47 | } 48 | if expect, actual := "A", fh.fields[0].Names[0]; expect != actual { 49 | t.Fatalf("expected field name %q, got %q", expect, actual) 50 | } 51 | if expect, actual := "B", fh.fields[1].Names[0]; expect != actual { 52 | t.Fatalf("expected field name %q, got %q", expect, actual) 53 | } 54 | if expect, actual := "C", fh.fields[2].Names[0]; expect != actual { 55 | t.Fatalf("expected field name %q, got %q", expect, actual) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /ast/utils.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "go/ast" 5 | "go/doc" 6 | "go/token" 7 | "strings" 8 | 9 | "github.com/g4s8/envdoc/debug" 10 | ) 11 | 12 | func getFieldTypeRef(f ast.Expr, ref *FieldTypeRef) bool { 13 | switch t := f.(type) { 14 | case *ast.Ident: 15 | ref.Name = t.Name 16 | ref.Kind = FieldTypeIdent 17 | case *ast.SelectorExpr: 18 | getFieldTypeRef(t.X, ref) 19 | ref.Pkg = ref.Name 20 | ref.Name = t.Sel.Name 21 | ref.Kind = FieldTypeSelector 22 | case *ast.StarExpr: 23 | getFieldTypeRef(t.X, ref) 24 | ref.Kind = FieldTypePtr 25 | case *ast.ArrayType: 26 | getFieldTypeRef(t.Elt, ref) 27 | ref.Kind = FieldTypeArray 28 | case *ast.MapType: 29 | getFieldTypeRef(t.Value, ref) 30 | ref.Kind = FieldTypeMap 31 | case *ast.StructType: 32 | ref.Kind = FieldTypeStruct 33 | default: 34 | return false 35 | } 36 | return true 37 | } 38 | 39 | func extractFieldNames(f *ast.Field) []string { 40 | names := make([]string, len(f.Names)) 41 | for i, n := range f.Names { 42 | names[i] = n.Name 43 | } 44 | return names 45 | } 46 | 47 | func extractFieldDoc(f *ast.Field) (doc string, ok bool) { 48 | doc = f.Doc.Text() 49 | if doc == "" { 50 | doc = f.Comment.Text() 51 | } 52 | doc = strings.TrimSpace(doc) 53 | return doc, doc != "" 54 | } 55 | 56 | func findCommentLine(c *ast.Comment, fset *token.FileSet, file *ast.File) int { 57 | lines := fset.File(file.Pos()).Lines() 58 | for l, pos := range lines { 59 | if token.Pos(pos) > c.Pos() { 60 | return l 61 | } 62 | } 63 | return 0 64 | } 65 | 66 | func getFieldSpec(n *ast.Field, pkg string) *FieldSpec { 67 | names := extractFieldNames(n) 68 | allPrivate := true 69 | for _, name := range names { 70 | if strings.ToLower(name[:1]) != name[:1] { 71 | allPrivate = false 72 | break 73 | } 74 | } 75 | if len(names) > 0 && allPrivate { 76 | // skip private fields 77 | return nil 78 | } 79 | 80 | var fs FieldSpec 81 | fs.Names = names 82 | if !getFieldTypeRef(n.Type, &fs.TypeRef) { 83 | // unsupported field type 84 | return nil 85 | } 86 | if fs.TypeRef.Pkg == "" { 87 | fs.TypeRef.Pkg = pkg 88 | } 89 | if doc, ok := extractFieldDoc(n); ok { 90 | fs.Doc = doc 91 | } 92 | if tag := n.Tag; tag != nil { 93 | fs.Tag = strings.Trim(tag.Value, "`") 94 | } 95 | 96 | return &fs 97 | } 98 | 99 | //nolint:cyclop 100 | func debugNode(src string, n ast.Node) { 101 | if !debug.Config.Enabled { 102 | return 103 | } 104 | if n == nil { 105 | return 106 | } 107 | 108 | switch t := n.(type) { 109 | case *ast.File: 110 | debug.Logf("# AST(%s): File pkg=%q\n", src, t.Name.Name) 111 | //nolint:staticcheck 112 | case *ast.Package: 113 | debug.Logf("# AST(%s): Package %s\n", src, t.Name) 114 | case *ast.TypeSpec: 115 | debug.Logf("# AST(%s): Type %s\n", src, t.Name.Name) 116 | case *ast.Field: 117 | names := extractFieldNames(t) 118 | debug.Logf("# AST(%s): Field %s\n", src, strings.Join(names, ", ")) 119 | case *ast.Comment: 120 | debug.Logf("# AST(%s): Comment %s\n", src, t.Text) 121 | case *ast.StructType: 122 | debug.Logf("# AST(%s): Struct\n", src) 123 | case *ast.GenDecl, *ast.Ident, *ast.FuncDecl: 124 | // ignore 125 | default: 126 | debug.Logf("# AST(%s): %T\n", src, t) 127 | } 128 | } 129 | 130 | func resolveTypeDocs(docs *doc.Package, t *ast.TypeSpec) string { 131 | typeName := t.Name.String() 132 | docStr := strings.TrimSpace(t.Doc.Text()) 133 | if docStr == "" { 134 | for _, t := range docs.Types { 135 | if t.Name == typeName { 136 | docStr = strings.TrimSpace(t.Doc) 137 | break 138 | } 139 | } 140 | } 141 | return docStr 142 | } 143 | -------------------------------------------------------------------------------- /ast/walker.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "go/ast" 5 | "go/token" 6 | ) 7 | 8 | func Walk(n ast.Node, fset *token.FileSet, h FileHandler) { 9 | v := newPkgVisitor(fset, h) 10 | ast.Walk(v, n) 11 | } 12 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/g4s8/envdoc/types" 13 | "github.com/g4s8/envdoc/utils" 14 | ) 15 | 16 | type Config struct { 17 | // Dir to search for files 18 | Dir string 19 | // FileGlob to filter by file name 20 | FileGlob string 21 | // TypeGlob to filter by type name 22 | TypeGlob string 23 | // OutFile to write the output to 24 | OutFile string 25 | // OutFormat specify the output format 26 | OutFormat types.OutFormat 27 | // EnvPrefix to prefix the env vars with 28 | EnvPrefix string 29 | // NoStyles to disable styles for HTML format 30 | NoStyles bool 31 | // FieldNames flag enables field names usage intead of `env` tag. 32 | FieldNames bool 33 | // Target is the target type 34 | Target types.TargetType 35 | 36 | // TagName sets custom tag name, `env` by default. 37 | TagName string 38 | // TagDefault sets default env tag name, `envDefault` by default. 39 | TagDefault string 40 | // TagRequiredIfNoDef sets attributes as required if no default value is set. 41 | RequiredIfNoDef bool 42 | 43 | // ExecLine is the line of go:generate command 44 | ExecLine int 45 | // ExecFile is the file of go:generate command 46 | ExecFile string 47 | 48 | // Debug output enabled 49 | Debug bool 50 | } 51 | 52 | //nolint:cyclop 53 | func (c *Config) parseFlags(f *flag.FlagSet) error { 54 | // input flags 55 | f.StringVar(&c.Dir, "dir", "", "Dir to search for files, default is the file dir with go:generate command") 56 | f.StringVar(&c.FileGlob, "files", "", "FileGlob to filter by file name") 57 | f.StringVar(&c.TypeGlob, "types", "", "Type glob to filter by type name") 58 | var target string 59 | f.StringVar(&target, "target", "caarlos0", "Target type, default `caarlos0`") 60 | // output flags 61 | f.StringVar(&c.OutFile, "output", "", "Output file path") 62 | f.StringVar((*string)(&c.OutFormat), "format", "markdown", "Output format, default `markdown`") 63 | f.BoolVar(&c.NoStyles, "no-styles", false, "Disable styles for HTML output") 64 | // app config flags 65 | f.StringVar(&c.EnvPrefix, "env-prefix", "", "Environment variable prefix") 66 | f.BoolVar(&c.FieldNames, "field-names", false, "Use field names if tag is not specified") 67 | f.BoolVar(&c.Debug, "debug", false, "Enable debug output") 68 | // customization 69 | f.StringVar(&c.TagName, "tag-name", "env", "Custom tag name") 70 | f.StringVar(&c.TagDefault, "tag-default", "envDefault", "Default tag name") 71 | f.BoolVar(&c.RequiredIfNoDef, "required-if-no-def", false, "Set attributes as required if no default value is set") 72 | // deprecated flags 73 | var ( 74 | typeName string 75 | all bool 76 | ) 77 | f.StringVar(&typeName, "type", "", "Type name to filter by type name (deprecated: use -types instead)") 78 | f.BoolVar(&all, "all", false, "Generate documentation for all types in the file (deprecated: use -types='*' instead)") 79 | 80 | // parse 81 | if err := f.Parse(os.Args[1:]); err != nil { 82 | return fmt.Errorf("parse flags: %w", err) 83 | } 84 | 85 | // deprecated flags `all`, `type` and new flag `types` can't be used together 86 | if all && typeName != "" { 87 | return errors.New("flags -all and -type can't be used together") 88 | } 89 | if all && c.TypeGlob != "" { 90 | return errors.New("flags -all and -types can't be used together") 91 | } 92 | if typeName != "" && c.TypeGlob != "" { 93 | return errors.New("flags -type and -types can't be used together") 94 | } 95 | 96 | targetType, err := types.ParseTargetType(target) 97 | if err != nil { 98 | return fmt.Errorf("parse target type: %w", err) 99 | } 100 | c.Target = targetType 101 | 102 | // check for deprecated flags 103 | var deprecatedWarning strings.Builder 104 | if typeName != "" { 105 | deprecatedWarning.WriteString("\t-type flag is deprecated, use -types instead\n") 106 | c.TypeGlob = typeName 107 | } 108 | if all { 109 | deprecatedWarning.WriteString("\t-all flag is deprecated, use -types='*' instead\n") 110 | c.TypeGlob = "*" 111 | } 112 | if deprecatedWarning.Len() > 0 { 113 | fmt.Fprintln(os.Stderr, "WARNING! Deprecated flags are used. It will be removed in the next major release.") 114 | fmt.Fprintln(os.Stderr, deprecatedWarning.String()) 115 | } 116 | 117 | return nil 118 | } 119 | 120 | var ErrNotCalledByGoGenerate = errors.New("not called by go generate") 121 | 122 | func (c *Config) parseEnv() error { 123 | inputFileName := os.Getenv("GOFILE") 124 | if inputFileName == "" { 125 | return fmt.Errorf("no exec input file specified: %w", ErrNotCalledByGoGenerate) 126 | } 127 | c.ExecFile = inputFileName 128 | 129 | if e := os.Getenv("GOLINE"); e != "" { 130 | i, err := strconv.Atoi(e) 131 | if err != nil { 132 | return fmt.Errorf("invalid exec line number specified: %w", err) 133 | } 134 | c.ExecLine = i 135 | } else { 136 | return fmt.Errorf("no exec line number specified: %w", ErrNotCalledByGoGenerate) 137 | } 138 | 139 | if e := os.Getenv("DEBUG"); e != "" { 140 | c.Debug = true 141 | } 142 | 143 | return nil 144 | } 145 | 146 | func (c *Config) normalize() { 147 | c.TypeGlob = utils.UnescapeGlob(c.TypeGlob) 148 | c.FileGlob = utils.UnescapeGlob(c.FileGlob) 149 | } 150 | 151 | func (c *Config) setDefaults() { 152 | if c.FileGlob == "" { 153 | c.FileGlob = c.ExecFile 154 | } 155 | if c.Dir == "" { 156 | c.Dir = "." 157 | } 158 | } 159 | 160 | func (c *Config) fprint(out io.Writer) { 161 | fmt.Fprintln(out, "Config:") 162 | fmt.Fprintf(out, " Dir: %q\n", c.Dir) 163 | if c.FileGlob != "" { 164 | fmt.Fprintf(out, " FileGlob: %q\n", c.FileGlob) 165 | } 166 | if c.TypeGlob != "" { 167 | fmt.Fprintf(out, " TypeGlob: %q\n", c.TypeGlob) 168 | } 169 | fmt.Fprintf(out, " OutFile: %q\n", c.OutFile) 170 | fmt.Fprintf(out, " OutFormat: %q\n", c.OutFormat) 171 | if c.EnvPrefix != "" { 172 | fmt.Fprintf(out, " EnvPrefix: %q\n", c.EnvPrefix) 173 | } 174 | if c.NoStyles { 175 | fmt.Fprintln(out, " NoStyles: true") 176 | } 177 | fmt.Printf(" ExecFile: %q\n", c.ExecFile) 178 | fmt.Printf(" ExecLine: %d\n", c.ExecLine) 179 | if c.FieldNames { 180 | fmt.Fprintln(out, " FieldNames: true") 181 | } 182 | if c.Debug { 183 | fmt.Fprintln(out, " Debug: true") 184 | } 185 | } 186 | 187 | func (c *Config) Load() error { 188 | fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) 189 | if err := c.parseFlags(fs); err != nil { 190 | return fmt.Errorf("parse flags: %w", err) 191 | } 192 | if err := c.parseEnv(); err != nil { 193 | return fmt.Errorf("parse env: %w", err) 194 | } 195 | c.setDefaults() 196 | c.normalize() 197 | return nil 198 | } 199 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "testing" 7 | 8 | "github.com/g4s8/envdoc/testutils" 9 | ) 10 | 11 | func TestConfig(t *testing.T) { 12 | t.Run("parse flags", func(t *testing.T) { 13 | var c Config 14 | fs := flag.NewFlagSet("test", flag.ContinueOnError) 15 | os.Args = []string{ 16 | "test", 17 | "-types", "foo,bar", 18 | "-files", "*", 19 | "-output", "out.txt", 20 | "-format", "plaintext", 21 | "-env-prefix", "FOO", 22 | "-no-styles", 23 | "-field-names", 24 | "-debug", 25 | "-tag-name", "xenv", 26 | "-tag-default", "default", 27 | "-required-if-no-def", 28 | } 29 | if err := c.parseFlags(fs); err != nil { 30 | t.Fatalf("unexpected error: %v", err) 31 | } 32 | testutils.AssertError(t, c.TypeGlob == "foo,bar", "unexpected TypeGlob: %q", c.TypeGlob) 33 | testutils.AssertError(t, c.FileGlob == "*", "unexpected FileGlob: %q", c.FileGlob) 34 | testutils.AssertError(t, c.OutFile == "out.txt", "unexpected OutFile: %q", c.OutFile) 35 | testutils.AssertError(t, c.OutFormat == "plaintext", "unexpected OutFormat: %q", c.OutFormat) 36 | testutils.AssertError(t, c.EnvPrefix == "FOO", "unexpected EnvPrefix: %q", c.EnvPrefix) 37 | testutils.AssertError(t, c.NoStyles, "unexpected NoStyles: false") 38 | testutils.AssertError(t, c.FieldNames, "unexpected FieldNames: false") 39 | testutils.AssertError(t, c.Debug, "unexpected Debug: false") 40 | testutils.AssertError(t, c.TagName == "xenv", "unexpected TagName: %q", c.TagName) 41 | testutils.AssertError(t, c.TagDefault == "default", "unexpected TagDefault: %q", c.TagDefault) 42 | testutils.AssertError(t, c.RequiredIfNoDef, "unexpected RequiredIfNoDef: false") 43 | }) 44 | t.Run("normalize", func(t *testing.T) { 45 | var c Config 46 | c.TypeGlob = `"foo"` 47 | c.FileGlob = `"*"` 48 | c.normalize() 49 | if c.TypeGlob != "foo" { 50 | t.Errorf("unexpected TypeGlob: %q", c.TypeGlob) 51 | } 52 | if c.FileGlob != "*" { 53 | t.Errorf("unexpected FileGlob: %q", c.FileGlob) 54 | } 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /converter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/g4s8/envdoc/ast" 9 | "github.com/g4s8/envdoc/debug" 10 | "github.com/g4s8/envdoc/types" 11 | ) 12 | 13 | type Resolver interface { 14 | Resolve(ref *ast.FieldTypeRef) *ast.TypeSpec 15 | } 16 | 17 | type ConverterOpts struct { 18 | EnvPrefix string 19 | TagName string 20 | TagDefault string 21 | RequiredIfNoDef bool 22 | UseFieldNames bool 23 | } 24 | 25 | type Converter struct { 26 | target types.TargetType 27 | opts ConverterOpts 28 | } 29 | 30 | func NewConverter(target types.TargetType, opts ConverterOpts) *Converter { 31 | return &Converter{ 32 | target: target, 33 | opts: opts, 34 | } 35 | } 36 | 37 | func (c *Converter) ScopesFromFiles(res Resolver, files []*ast.FileSpec) []*types.EnvScope { 38 | var scopes []*types.EnvScope 39 | for _, f := range files { 40 | if !f.Export { 41 | debug.Logf("# CONV: skip file %q\n", f.Name) 42 | continue 43 | } 44 | for _, t := range f.Types { 45 | if !t.Export { 46 | debug.Logf("# CONV: skip type %q\n", t.Name) 47 | continue 48 | } 49 | scopes = append(scopes, c.ScopeFromType(res, t)) 50 | } 51 | } 52 | return scopes 53 | } 54 | 55 | func (c *Converter) ScopeFromType(res Resolver, t *ast.TypeSpec) *types.EnvScope { 56 | scope := &types.EnvScope{ 57 | Name: t.Name, 58 | Doc: t.Doc, 59 | } 60 | scope.Vars = c.DocItemsFromFields(res, c.opts.EnvPrefix, t.Fields) 61 | debug.Logf("# CONV: found scope %q\n", scope.Name) 62 | return scope 63 | } 64 | 65 | func (c *Converter) DocItemsFromFields(res Resolver, prefix string, fields []*ast.FieldSpec) []*types.EnvDocItem { 66 | var items []*types.EnvDocItem 67 | for _, f := range fields { 68 | debug.Logf("\t# CONV: field [%s] type=%s flen=%d\n", 69 | strings.Join(f.Names, ","), f.TypeRef, len(f.Fields)) 70 | if len(f.Names) == 0 { 71 | // embedded field 72 | if len(f.Fields) == 0 { 73 | // resolve embedded types 74 | tpe := res.Resolve(&f.TypeRef) 75 | if tpe != nil { 76 | f.Fields = tpe.Fields 77 | } 78 | } 79 | items = append(items, c.DocItemsFromFields(res, prefix, f.Fields)...) 80 | continue 81 | } 82 | items = append(items, c.DocItemsFromField(res, prefix, f)...) 83 | } 84 | return items 85 | } 86 | 87 | func (c *Converter) DocItemsFromField(resolver Resolver, prefix string, f *ast.FieldSpec) []*types.EnvDocItem { 88 | dec := NewFieldDecoder(c.target, FieldDecoderOpts{ 89 | EnvPrefix: prefix, 90 | TagName: c.opts.TagName, 91 | TagDefault: c.opts.TagDefault, 92 | RequiredIfNoDef: c.opts.RequiredIfNoDef, 93 | UseFieldNames: c.opts.UseFieldNames, 94 | }) 95 | info, newPrefix := dec.Decode(f) 96 | if newPrefix != "" { 97 | prefix = newPrefix 98 | } 99 | 100 | var children []*types.EnvDocItem 101 | switch f.TypeRef.Kind { 102 | case ast.FieldTypeStruct: 103 | children = c.DocItemsFromFields(resolver, prefix, f.Fields) 104 | debug.Logf("\t# CONV: struct %q (%d childrens)\n", f.TypeRef.String(), len(children)) 105 | case ast.FieldTypeSelector, ast.FieldTypeIdent, ast.FieldTypeArray, ast.FieldTypePtr: 106 | if f.TypeRef.IsBuiltIn() { 107 | break 108 | } 109 | tpe := resolver.Resolve(&f.TypeRef) 110 | debug.Logf("\t# CONV: resolve %q -> %v\n", f.TypeRef.String(), tpe) 111 | if tpe == nil { 112 | if newPrefix != "" { 113 | // Target type is env-prefixed, it means it's a reference 114 | // to another struct type. We can't process it here, because 115 | // we can't resolve the target type and its fields. 116 | fmt.Fprintf(os.Stderr, "WARNING: failed to resolve type %q\n", f.TypeRef.String()) 117 | } 118 | break 119 | } 120 | children = c.DocItemsFromFields(resolver, prefix, tpe.Fields) 121 | debug.Logf("\t# CONV: selector %q (%d childrens)\n", f.TypeRef.String(), len(children)) 122 | } 123 | 124 | res := make([]*types.EnvDocItem, len(info.Names), len(info.Names)+1) 125 | opts := types.EnvVarOptions{ 126 | Required: info.Required, 127 | Expand: info.Expand, 128 | NonEmpty: info.NonEmpty, 129 | FromFile: info.FromFile, 130 | Default: info.Default, 131 | Separator: info.Separator, 132 | } 133 | for i, name := range info.Names { 134 | res[i] = &types.EnvDocItem{ 135 | Name: name, 136 | Doc: f.Doc, 137 | Opts: opts, 138 | Children: children, 139 | } 140 | debug.Logf("\t# CONV: docItem %q (%d childrens)\n", name, len(children)) 141 | } 142 | 143 | if len(info.Names) == 0 && len(children) > 0 { 144 | return children 145 | } 146 | return res 147 | } 148 | -------------------------------------------------------------------------------- /converter_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/g4s8/envdoc/ast" 8 | "github.com/g4s8/envdoc/resolver" 9 | "github.com/g4s8/envdoc/types" 10 | ) 11 | 12 | var opts = ConverterOpts{ 13 | TagName: "env", 14 | TagDefault: "envDefault", 15 | } 16 | 17 | func TestConvertDocItems(t *testing.T) { 18 | opts := opts 19 | opts.UseFieldNames = true 20 | 21 | c := NewConverter(types.TargetTypeCaarlos0, opts) 22 | fieldValues := []*ast.FieldSpec{ 23 | { 24 | Names: []string{"Field1"}, 25 | TypeRef: ast.FieldTypeRef{ 26 | Name: "string", 27 | Kind: ast.FieldTypeIdent, 28 | }, 29 | Tag: `env:"FIELD1,required,file"`, 30 | Doc: "Field1 doc", 31 | }, 32 | { 33 | Names: []string{"Field2", "Field3"}, 34 | TypeRef: ast.FieldTypeRef{ 35 | Name: "int", 36 | Kind: ast.FieldTypeIdent, 37 | }, 38 | Doc: "Field2 and Field3 doc", 39 | }, 40 | { 41 | Names: []string{"FieldDef"}, 42 | TypeRef: ast.FieldTypeRef{ 43 | Name: "string", 44 | Kind: ast.FieldTypeIdent, 45 | }, 46 | Doc: "Field with default", 47 | Tag: `env:"FIELD_DEF" envDefault:"envdef"`, 48 | }, 49 | { 50 | Names: []string{"FieldArr"}, 51 | TypeRef: ast.FieldTypeRef{ 52 | Name: "[]string", 53 | Kind: ast.FieldTypeArray, 54 | }, 55 | Doc: "Field array", 56 | Tag: `env:"FIELD_ARR"`, 57 | }, 58 | { 59 | Names: []string{"FieldArrSep"}, 60 | TypeRef: ast.FieldTypeRef{ 61 | Name: "[]string", 62 | Kind: ast.FieldTypeArray, 63 | }, 64 | Doc: "Field array with separator", 65 | Tag: `env:"FIELD_ARR_SEP" envSeparator:":"`, 66 | }, 67 | { 68 | Names: []string{"FooField"}, 69 | TypeRef: ast.FieldTypeRef{ 70 | Name: "Foo", 71 | Kind: ast.FieldTypePtr, 72 | }, 73 | Tag: `envPrefix:"FOO_"`, 74 | }, 75 | { 76 | Names: []string{"BarField"}, 77 | TypeRef: ast.FieldTypeRef{ 78 | Pkg: "config", 79 | Name: "Bar", 80 | Kind: ast.FieldTypeIdent, 81 | }, 82 | Tag: `envPrefix:"BAR_"`, 83 | }, 84 | { 85 | Names: []string{"StructField"}, 86 | TypeRef: ast.FieldTypeRef{ 87 | Kind: ast.FieldTypeStruct, 88 | }, 89 | Fields: []*ast.FieldSpec{ 90 | { 91 | Names: []string{"Field1"}, 92 | TypeRef: ast.FieldTypeRef{ 93 | Name: "string", 94 | Kind: ast.FieldTypeIdent, 95 | }, 96 | Doc: "Field1 doc", 97 | Tag: `env:"FIELD1"`, 98 | }, 99 | }, 100 | Tag: `envPrefix:"STRUCT_"`, 101 | }, 102 | { 103 | Names: []string{}, 104 | Doc: "Embedded field", 105 | Fields: []*ast.FieldSpec{ 106 | { 107 | Names: []string{"Field4"}, 108 | TypeRef: ast.FieldTypeRef{ 109 | Name: "string", 110 | Kind: ast.FieldTypeIdent, 111 | }, 112 | Doc: "Field4 doc", 113 | Tag: `env:"FIELD4,notEmpty,expand"`, 114 | }, 115 | }, 116 | TypeRef: ast.FieldTypeRef{ 117 | Kind: ast.FieldTypeStruct, 118 | }, 119 | }, 120 | } 121 | resolver := resolver.NewTypeResolver() 122 | resolver.AddTypes("", []*ast.TypeSpec{ 123 | { 124 | Name: "Foo", 125 | Doc: "Foo doc", 126 | Fields: []*ast.FieldSpec{ 127 | { 128 | Names: []string{"FOne"}, 129 | Doc: "Foo one field", 130 | Tag: `env:"F1"`, 131 | }, 132 | }, 133 | }, 134 | }) 135 | resolver.AddTypes("config", []*ast.TypeSpec{ 136 | { 137 | Name: "Bar", 138 | Doc: "Bar doc", 139 | Fields: []*ast.FieldSpec{ 140 | { 141 | Names: []string{"BOne"}, 142 | Doc: "Bar one field", 143 | Tag: `env:"B1"`, 144 | }, 145 | }, 146 | }, 147 | }) 148 | 149 | res := c.DocItemsFromFields(resolver, "", fieldValues) 150 | expect := []*types.EnvDocItem{ 151 | { 152 | Name: "FIELD1", 153 | Doc: "Field1 doc", 154 | Opts: types.EnvVarOptions{ 155 | Required: true, 156 | FromFile: true, 157 | }, 158 | }, 159 | { 160 | Name: "FIELD2", 161 | Doc: "Field2 and Field3 doc", 162 | }, 163 | { 164 | Name: "FIELD3", 165 | Doc: "Field2 and Field3 doc", 166 | }, 167 | { 168 | Name: "FIELD_DEF", 169 | Doc: "Field with default", 170 | Opts: types.EnvVarOptions{ 171 | Default: "envdef", 172 | }, 173 | }, 174 | { 175 | Name: "FIELD_ARR", 176 | Doc: "Field array", 177 | Opts: types.EnvVarOptions{ 178 | Separator: ",", 179 | }, 180 | }, 181 | { 182 | Name: "FIELD_ARR_SEP", 183 | Doc: "Field array with separator", 184 | Opts: types.EnvVarOptions{ 185 | Separator: ":", 186 | }, 187 | }, 188 | { 189 | Name: "FOO_FIELD", 190 | Children: []*types.EnvDocItem{ 191 | { 192 | Name: "FOO_F1", 193 | Doc: "Foo one field", 194 | }, 195 | }, 196 | }, 197 | { 198 | Name: "BAR_FIELD", 199 | Children: []*types.EnvDocItem{ 200 | { 201 | Name: "BAR_B1", 202 | Doc: "Bar one field", 203 | }, 204 | }, 205 | }, 206 | { 207 | Name: "STRUCT_FIELD", 208 | Children: []*types.EnvDocItem{ 209 | { 210 | Name: "STRUCT_FIELD1", 211 | Doc: "Field1 doc", 212 | }, 213 | }, 214 | }, 215 | { 216 | Name: "FIELD4", 217 | Doc: "Field4 doc", 218 | Opts: types.EnvVarOptions{ 219 | Required: true, 220 | NonEmpty: true, 221 | Expand: true, 222 | }, 223 | }, 224 | } 225 | if len(expect) != len(res) { 226 | t.Errorf("Expected %d items, got %d", len(expect), len(res)) 227 | for i, item := range expect { 228 | t.Logf("Expect[%d] %q", i, item.Name) 229 | } 230 | for i, item := range res { 231 | t.Logf("Actual[%d] %q", i, item.Name) 232 | } 233 | t.FailNow() 234 | } 235 | for i, item := range expect { 236 | checkDocItem(t, fmt.Sprintf("%d", i), item, res[i]) 237 | } 238 | } 239 | 240 | func TestConverterScopes(t *testing.T) { 241 | files := []*ast.FileSpec{ 242 | { 243 | Name: "main.go", 244 | Pkg: "main", 245 | Export: true, 246 | Types: []*ast.TypeSpec{ 247 | { 248 | Name: "Config", 249 | Doc: "Config doc", 250 | Export: true, 251 | Fields: []*ast.FieldSpec{ 252 | { 253 | Names: []string{"Field1"}, 254 | TypeRef: ast.FieldTypeRef{ 255 | Name: "string", 256 | Kind: ast.FieldTypeIdent, 257 | }, 258 | Doc: "Field1 doc", 259 | Tag: `env:"FIELD1,required,file"`, 260 | }, 261 | }, 262 | }, 263 | { 264 | Name: "Foo", 265 | Doc: "Foo doc", 266 | Export: false, 267 | Fields: []*ast.FieldSpec{ 268 | { 269 | Names: []string{"FOne"}, 270 | Doc: "Foo one field", 271 | Tag: `env:"F1"`, 272 | }, 273 | }, 274 | }, 275 | }, 276 | }, 277 | { 278 | Name: "config.go", 279 | Pkg: "config", 280 | Export: false, 281 | Types: []*ast.TypeSpec{ 282 | { 283 | Name: "Bar", 284 | Doc: "Bar doc", 285 | Export: true, 286 | Fields: []*ast.FieldSpec{ 287 | { 288 | Names: []string{"BOne"}, 289 | Doc: "Bar one field", 290 | Tag: `env:"B1"`, 291 | }, 292 | }, 293 | }, 294 | }, 295 | }, 296 | } 297 | c := NewConverter(types.TargetTypeCaarlos0, opts) 298 | resolver := resolver.NewTypeResolver() 299 | scopes := c.ScopesFromFiles(resolver, files) 300 | expect := []*types.EnvScope{ 301 | { 302 | Name: "Config", 303 | Doc: "Config doc", 304 | Vars: []*types.EnvDocItem{ 305 | { 306 | Name: "FIELD1", 307 | Doc: "Field1 doc", 308 | Opts: types.EnvVarOptions{ 309 | Required: true, 310 | FromFile: true, 311 | }, 312 | }, 313 | }, 314 | }, 315 | } 316 | if len(expect) != len(scopes) { 317 | t.Fatalf("Expected %d scopes, got %d", len(expect), len(scopes)) 318 | } 319 | for i, scope := range expect { 320 | checkScope(t, fmt.Sprintf("%d", i), scope, scopes[i]) 321 | } 322 | } 323 | 324 | func TestConverterFailedToResolve(t *testing.T) { 325 | field := &ast.FieldSpec{ 326 | Names: []string{"BarField"}, 327 | TypeRef: ast.FieldTypeRef{ 328 | Pkg: "config", 329 | Name: "Bar", 330 | Kind: ast.FieldTypeIdent, 331 | }, 332 | Tag: `envPrefix:"BAR_"`, 333 | } 334 | c := NewConverter(types.TargetTypeCaarlos0, opts) 335 | resolver := resolver.NewTypeResolver() 336 | item := c.DocItemsFromField(resolver, "", field) 337 | if len(item) != 0 { 338 | t.Fatalf("Expected 0 items, got %d", len(item)) 339 | } 340 | } 341 | 342 | func checkScope(t *testing.T, scope string, expect, actual *types.EnvScope) { 343 | t.Helper() 344 | 345 | if expect.Name != actual.Name { 346 | t.Errorf("Expected name %s, got %s", expect.Name, actual.Name) 347 | } 348 | if expect.Doc != actual.Doc { 349 | t.Errorf("Expected doc %s, got %s", expect.Doc, actual.Doc) 350 | } 351 | if len(expect.Vars) != len(actual.Vars) { 352 | t.Fatalf("Expected %d vars, got %d", len(expect.Vars), len(actual.Vars)) 353 | } 354 | for i, item := range expect.Vars { 355 | checkDocItem(t, fmt.Sprintf("%s/%d", scope, i), item, actual.Vars[i]) 356 | } 357 | } 358 | 359 | func checkDocItem(t *testing.T, scope string, expect, actual *types.EnvDocItem) { 360 | t.Helper() 361 | if expect.Name != actual.Name { 362 | t.Errorf("Expected name %s, got %s", expect.Name, actual.Name) 363 | } 364 | if expect.Doc != actual.Doc { 365 | t.Errorf("Expected doc %s, got %s", expect.Doc, actual.Doc) 366 | } 367 | if expect.Opts != actual.Opts { 368 | t.Errorf("Expected opts %v, got %v", expect.Opts, actual.Opts) 369 | } 370 | if len(expect.Children) != len(actual.Children) { 371 | t.Errorf("Expected %d children, got %d", len(expect.Children), len(actual.Children)) 372 | } 373 | for i, child := range expect.Children { 374 | checkDocItem(t, fmt.Sprintf("%s/%d", scope, i), child, actual.Children[i]) 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var DebugConfig struct { 4 | Enabled bool 5 | } 6 | -------------------------------------------------------------------------------- /debug/config.go: -------------------------------------------------------------------------------- 1 | package debug 2 | 3 | // Config is a global debug configuration. 4 | var Config struct { 5 | Enabled bool 6 | } 7 | -------------------------------------------------------------------------------- /debug/logger.go: -------------------------------------------------------------------------------- 1 | package debug 2 | 3 | import "io" 4 | 5 | type Logger interface { 6 | Logf(format string, args ...interface{}) 7 | Log(args ...interface{}) 8 | } 9 | 10 | type nopLogger struct{} 11 | 12 | func (l *nopLogger) Logf(_ string, _ ...interface{}) {} 13 | 14 | func (l *nopLogger) Log(_ ...interface{}) {} 15 | 16 | var logger Logger 17 | 18 | type Printer interface { 19 | Debug(io.Writer) 20 | } 21 | -------------------------------------------------------------------------------- /debug/logger_cov.go: -------------------------------------------------------------------------------- 1 | //go:build coverage 2 | 3 | package debug 4 | 5 | import ( 6 | "io" 7 | "testing" 8 | ) 9 | 10 | func NewLogger(out io.Writer) Logger { 11 | return &nopLogger{} 12 | } 13 | 14 | func SetLogger() { 15 | } 16 | 17 | func SetTestLogger(t *testing.T) { 18 | } 19 | 20 | func Logf(format string, args ...interface{}) { 21 | } 22 | 23 | func Log(args ...interface{}) { 24 | } 25 | 26 | func PrintDebug(p Printer) { 27 | } 28 | -------------------------------------------------------------------------------- /debug/logger_nocov.go: -------------------------------------------------------------------------------- 1 | //go:build !coverage 2 | 3 | package debug 4 | 5 | import ( 6 | "fmt" 7 | "io" 8 | "os" 9 | "testing" 10 | ) 11 | 12 | type ioLogger struct { 13 | out io.Writer 14 | } 15 | 16 | func NewLogger(out io.Writer) Logger { 17 | return &ioLogger{out: out} 18 | } 19 | 20 | func (l *ioLogger) Logf(format string, args ...interface{}) { 21 | fmt.Fprintf(l.out, format, args...) 22 | } 23 | 24 | func (l *ioLogger) Log(args ...interface{}) { 25 | fmt.Fprint(l.out, args...) 26 | } 27 | 28 | type testLogger struct { 29 | t *testing.T 30 | } 31 | 32 | func NewTestLogger(t *testing.T) Logger { 33 | return &testLogger{t: t} 34 | } 35 | 36 | func (l *testLogger) Logf(format string, args ...interface{}) { 37 | l.t.Helper() 38 | l.t.Logf(format, args...) 39 | } 40 | 41 | func (l *testLogger) Log(args ...interface{}) { 42 | l.t.Helper() 43 | l.t.Log(args...) 44 | } 45 | 46 | func SetLogger() { 47 | if !Config.Enabled { 48 | logger = &nopLogger{} 49 | return 50 | } 51 | logger = NewLogger(os.Stdout) 52 | } 53 | 54 | func SetTestLogger(t *testing.T) { 55 | if logger == nil { 56 | SetLogger() 57 | } 58 | currentLogger := logger 59 | t.Cleanup(func() { 60 | logger = currentLogger 61 | }) 62 | logger = NewTestLogger(t) 63 | } 64 | 65 | func Logf(format string, args ...interface{}) { 66 | if logger == nil { 67 | SetLogger() 68 | } 69 | logger.Logf(format, args...) 70 | } 71 | 72 | func Log(args ...interface{}) { 73 | if logger == nil { 74 | SetLogger() 75 | } 76 | logger.Log(args...) 77 | } 78 | 79 | func PrintDebug(p Printer) { 80 | if Config.Enabled { 81 | p.Debug(os.Stdout) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /debug_coverage.go: -------------------------------------------------------------------------------- 1 | //go:build coverage 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/g4s8/envdoc/types" 7 | ) 8 | 9 | func printScopesTree(s []*types.EnvScope) { 10 | } 11 | 12 | func printDocItem(prefix string, item *types.EnvDocItem) { 13 | } 14 | -------------------------------------------------------------------------------- /debug_nocoverage.go: -------------------------------------------------------------------------------- 1 | //go:build !coverage 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/g4s8/envdoc/debug" 7 | "github.com/g4s8/envdoc/types" 8 | ) 9 | 10 | func printScopesTree(s []*types.EnvScope) { 11 | if !debug.Config.Enabled { 12 | return 13 | } 14 | debug.Log("Scopes tree:\n") 15 | for _, scope := range s { 16 | debug.Logf(" - %q\n", scope.Name) 17 | for _, item := range scope.Vars { 18 | printDocItem(" ", item) 19 | } 20 | } 21 | } 22 | 23 | func printDocItem(prefix string, item *types.EnvDocItem) { 24 | debug.Logf("%s- %q\n", prefix, item.Name) 25 | for _, child := range item.Children { 26 | printDocItem(prefix+" ", child) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | envdoc is a tool to generate documentation for environment variables 3 | from a Go source file. It is intended to be used as a go generate 4 | directive. 5 | 6 | For example, given the following Go type with struct tags and 7 | a `go:generate` directive: 8 | 9 | //go:generate go run github.com/g4s8/envdoc@latest -output config.md 10 | type Config struct { 11 | // Host name to listen on. 12 | Host string `env:"HOST,required"` 13 | // Port to listen on. 14 | Port int `env:"PORT,notEmpty"` 15 | 16 | // Debug mode enabled. 17 | Debug bool `env:"DEBUG" envDefault:"false"` 18 | } 19 | 20 | Running go generate will generate the following Markdown file: 21 | 22 | # Environment variables 23 | 24 | - `HOST` (**required**) - Host name to listen on. 25 | - `PORT` (**required**, not-empty) - Port to listen on. 26 | - `DEBUG` (default: `false`) - Debug mode enabled. 27 | 28 | By default envdoc generates documentation in Markdown format, but it 29 | can also generate plaintext or HTML. 30 | 31 | Options: 32 | - `-output` - Output file name. 33 | - `-type` - Type name to generate documentation for. Defaults for 34 | the next type after `go:generate` directive. 35 | - `-format` (default: `markdown`) - Set output format type, either `markdown`, 36 | `plaintext` or `html`. 37 | - `-all` - Generate documentation for all types in the file. 38 | - `-env-prefix` - Environment variable prefix. 39 | - `-no-styles` - Disable built-int CSS styles for HTML format. 40 | - `-field-names` - Use field names instead of struct tags for variable names 41 | if tags are not set. 42 | */ 43 | package main 44 | -------------------------------------------------------------------------------- /docenv/.gitignore: -------------------------------------------------------------------------------- 1 | /docenv 2 | -------------------------------------------------------------------------------- /docenv/README.md: -------------------------------------------------------------------------------- 1 | # docenv - linter for environment documentation 2 | 3 | The linter check that all environment variable fields with `env` tag are documented. 4 | 5 | ## Install linter 6 | 7 | ``` 8 | go install github.com/g4s8/envdoc/docenv@latest 9 | ``` 10 | 11 | ## Example 12 | 13 | The struct with undocumented fields: 14 | ```go 15 | type Config struct { 16 | Hosts []string `env:"HOST,required", envSeparator:";"` 17 | Port int `env:"PORT,notEmpty"` 18 | } 19 | ``` 20 | 21 | Run the linter: 22 | ```bash 23 | $ go vet -vettool=$(which docenv) ./config.go 24 | config.go:12:2: field `Hosts` with `env` tag should have a documentation comment 25 | config.go:13:2: field `Port` with `env` tag should have a documentation comment 26 | ``` 27 | 28 | ## Usage 29 | 30 | Flags: 31 | - `env-name` sets custom env tag name (default `env`) 32 | 33 | ``` 34 | go vet go vet -vettool=$(which docenv) -docenv.env-name=foo ./config.go 35 | ``` 36 | -------------------------------------------------------------------------------- /docenv/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/g4s8/envdoc/linter" 5 | "golang.org/x/tools/go/analysis/unitchecker" 6 | ) 7 | 8 | func main() { 9 | analyzer := linter.NewAnlyzer(true) 10 | unitchecker.Main(analyzer) 11 | } 12 | -------------------------------------------------------------------------------- /field_decoder.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/g4s8/envdoc/ast" 5 | "github.com/g4s8/envdoc/tags" 6 | "github.com/g4s8/envdoc/types" 7 | "github.com/g4s8/envdoc/utils" 8 | ) 9 | 10 | type FieldInfo struct { 11 | Names []string 12 | Required bool 13 | Expand bool 14 | NonEmpty bool 15 | FromFile bool 16 | Default string 17 | Separator string 18 | } 19 | 20 | type FieldDecoder interface { 21 | Decode(f *ast.FieldSpec) (FieldInfo, string) 22 | } 23 | 24 | type FieldDecoderOpts struct { 25 | EnvPrefix string 26 | TagName string 27 | TagDefault string 28 | RequiredIfNoDef bool 29 | UseFieldNames bool 30 | } 31 | 32 | func NewFieldDecoder(target types.TargetType, opts FieldDecoderOpts) FieldDecoder { 33 | switch target { 34 | case types.TargetTypeCaarlos0: 35 | return &caarlos0fieldDecoder{opts: opts} 36 | case types.TargetTypeCleanenv: 37 | return &cleanenvFieldDecoder{opts: opts} 38 | default: 39 | panic("unknown target type") 40 | } 41 | } 42 | 43 | type caarlos0fieldDecoder struct { 44 | opts FieldDecoderOpts 45 | } 46 | 47 | func (d *caarlos0fieldDecoder) decodeFieldNames(f *ast.FieldSpec, tag *tags.FieldTag, out *FieldInfo) { 48 | var names []string 49 | if envName, ok := tag.GetFirst(d.opts.TagName); ok { 50 | names = []string{envName} 51 | } else if d.opts.UseFieldNames && len(f.Names) > 0 { 52 | names = make([]string, len(f.Names)) 53 | for i, name := range f.Names { 54 | names[i] = utils.CamelToSnake(name) 55 | } 56 | } 57 | for i, name := range names { 58 | names[i] = d.opts.EnvPrefix + name 59 | } 60 | out.Names = names 61 | } 62 | 63 | func (d *caarlos0fieldDecoder) decodeTagValues(_ *ast.FieldSpec, tag *tags.FieldTag, out *FieldInfo) { 64 | if tagValues := tag.GetAll(d.opts.TagName); len(tagValues) > 1 { 65 | for _, tagValue := range tagValues[1:] { 66 | switch tagValue { 67 | case "required": 68 | out.Required = true 69 | case "expand": 70 | out.Expand = true 71 | case "notEmpty": 72 | out.Required = true 73 | out.NonEmpty = true 74 | case "file": 75 | out.FromFile = true 76 | } 77 | } 78 | } 79 | } 80 | 81 | func (d *caarlos0fieldDecoder) decodeEnvDefault(_ *ast.FieldSpec, tag *tags.FieldTag, out *FieldInfo) { 82 | if envDefault, ok := tag.GetFirst(d.opts.TagDefault); ok { 83 | out.Default = envDefault 84 | } else if !ok && d.opts.RequiredIfNoDef { 85 | out.Required = true 86 | } 87 | } 88 | 89 | func (d *caarlos0fieldDecoder) decodeEnvSeparator(f *ast.FieldSpec, tag *tags.FieldTag, out *FieldInfo) { 90 | if envSeparator, ok := tag.GetFirst("envSeparator"); ok { 91 | out.Separator = envSeparator 92 | } else if f.TypeRef.Kind == ast.FieldTypeArray { 93 | out.Separator = "," 94 | } 95 | } 96 | 97 | func (d *caarlos0fieldDecoder) Decode(f *ast.FieldSpec) (res FieldInfo, prefix string) { 98 | tag := tags.ParseFieldTag(f.Tag) 99 | 100 | d.decodeFieldNames(f, &tag, &res) 101 | d.decodeTagValues(f, &tag, &res) 102 | d.decodeEnvDefault(f, &tag, &res) 103 | d.decodeEnvSeparator(f, &tag, &res) 104 | 105 | if envPrefix, ok := tag.GetFirst("envPrefix"); ok { 106 | prefix = d.opts.EnvPrefix + envPrefix 107 | } 108 | 109 | return 110 | } 111 | 112 | type cleanenvFieldDecoder struct { 113 | opts FieldDecoderOpts 114 | } 115 | 116 | func (d *cleanenvFieldDecoder) Decode(f *ast.FieldSpec) (res FieldInfo, prefix string) { 117 | tag := tags.ParseFieldTag(f.Tag) 118 | 119 | name, _ := tag.GetFirst("env") 120 | 121 | var required bool 122 | if envRequired, ok := tag.GetFirst("env-required"); ok { 123 | required = envRequired == "true" 124 | } 125 | 126 | var defaultVal string 127 | if envDefault, ok := tag.GetFirst("env-default"); ok { 128 | defaultVal = envDefault 129 | } 130 | 131 | var separator string 132 | if envSeparator, ok := tag.GetFirst("env-separator"); ok { 133 | separator = envSeparator 134 | } 135 | 136 | if envPrefix, ok := tag.GetFirst("env-prefix"); ok { 137 | prefix = d.opts.EnvPrefix + envPrefix 138 | } 139 | 140 | res.Names = []string{d.opts.EnvPrefix + name} 141 | res.Required = required 142 | res.Default = defaultVal 143 | res.Separator = separator 144 | 145 | return 146 | } 147 | -------------------------------------------------------------------------------- /field_decoder_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/g4s8/envdoc/ast" 8 | "github.com/g4s8/envdoc/testutils" 9 | "github.com/g4s8/envdoc/types" 10 | ) 11 | 12 | func TestFieldDecoder(t *testing.T) { 13 | type testCase struct { 14 | target types.TargetType 15 | name string 16 | opts FieldDecoderOpts 17 | spec *ast.FieldSpec 18 | expectField FieldInfo 19 | expectPrefix string 20 | } 21 | for _, test := range []testCase{ 22 | { 23 | target: types.TargetTypeCaarlos0, 24 | name: "single name", 25 | opts: FieldDecoderOpts{ 26 | TagName: "env", 27 | }, 28 | spec: &ast.FieldSpec{ 29 | Names: []string{"Fooooooo"}, 30 | Doc: "foo doc", 31 | Tag: `env:"FOO,required"`, 32 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 33 | }, 34 | expectField: FieldInfo{ 35 | Names: []string{"FOO"}, 36 | Required: true, 37 | }, 38 | }, 39 | { 40 | target: types.TargetTypeCaarlos0, 41 | name: "multiple names", 42 | opts: FieldDecoderOpts{ 43 | TagName: "env", 44 | UseFieldNames: true, 45 | }, 46 | spec: &ast.FieldSpec{ 47 | Names: []string{"Foo", "Bar"}, 48 | Doc: "foo doc", 49 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 50 | }, 51 | expectField: FieldInfo{ 52 | Names: []string{"FOO", "BAR"}, 53 | }, 54 | }, 55 | { 56 | target: types.TargetTypeCaarlos0, 57 | name: "default value", 58 | opts: FieldDecoderOpts{ 59 | TagName: "env", 60 | TagDefault: "default", 61 | }, 62 | spec: &ast.FieldSpec{ 63 | Names: []string{"Foo"}, 64 | Doc: "foo doc", 65 | Tag: `env:"FOO" default:"bar"`, 66 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 67 | }, 68 | expectField: FieldInfo{ 69 | Names: []string{"FOO"}, 70 | Default: "bar", 71 | }, 72 | }, 73 | { 74 | target: types.TargetTypeCaarlos0, 75 | name: "prefix", 76 | opts: FieldDecoderOpts{ 77 | TagName: "env", 78 | EnvPrefix: "PREFIX_", 79 | }, 80 | spec: &ast.FieldSpec{ 81 | Names: []string{"Foo"}, 82 | Doc: "foo doc", 83 | Tag: `env:"FOO"`, 84 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 85 | }, 86 | expectField: FieldInfo{ 87 | Names: []string{"PREFIX_FOO"}, 88 | }, 89 | }, 90 | { 91 | target: types.TargetTypeCaarlos0, 92 | name: "separator", 93 | opts: FieldDecoderOpts{ 94 | TagName: "env", 95 | }, 96 | spec: &ast.FieldSpec{ 97 | Names: []string{"Foo"}, 98 | Doc: "foo doc", 99 | Tag: `env:"FOO"`, 100 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeArray}, 101 | }, 102 | expectField: FieldInfo{ 103 | Names: []string{"FOO"}, 104 | Separator: ",", 105 | }, 106 | }, 107 | { 108 | target: types.TargetTypeCaarlos0, 109 | name: "separator from tag", 110 | opts: FieldDecoderOpts{ 111 | TagName: "env", 112 | }, 113 | spec: &ast.FieldSpec{ 114 | Names: []string{"Foo"}, 115 | Doc: "foo doc", 116 | Tag: `env:"FOO" envSeparator:":"`, 117 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeArray}, 118 | }, 119 | expectField: FieldInfo{ 120 | Names: []string{"FOO"}, 121 | Separator: ":", 122 | }, 123 | }, 124 | { 125 | target: types.TargetTypeCaarlos0, 126 | name: "required if no default", 127 | opts: FieldDecoderOpts{ 128 | TagName: "env", 129 | RequiredIfNoDef: true, 130 | }, 131 | spec: &ast.FieldSpec{ 132 | Names: []string{"Foo"}, 133 | Doc: "foo doc", 134 | Tag: `env:"FOO"`, 135 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 136 | }, 137 | expectField: FieldInfo{ 138 | Names: []string{"FOO"}, 139 | Required: true, 140 | }, 141 | }, 142 | { 143 | target: types.TargetTypeCaarlos0, 144 | name: "expand", 145 | opts: FieldDecoderOpts{ 146 | TagName: "env", 147 | }, 148 | spec: &ast.FieldSpec{ 149 | Names: []string{"Foo"}, 150 | Doc: "foo doc", 151 | Tag: `env:"FOO,expand"`, 152 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 153 | }, 154 | expectField: FieldInfo{ 155 | Names: []string{"FOO"}, 156 | Expand: true, 157 | }, 158 | }, 159 | { 160 | target: types.TargetTypeCaarlos0, 161 | name: "non-empty", 162 | opts: FieldDecoderOpts{ 163 | TagName: "env", 164 | }, 165 | spec: &ast.FieldSpec{ 166 | Names: []string{"Foo"}, 167 | Doc: "foo doc", 168 | Tag: `env:"FOO,notEmpty"`, 169 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 170 | }, 171 | expectField: FieldInfo{ 172 | Names: []string{"FOO"}, 173 | Required: true, 174 | NonEmpty: true, 175 | }, 176 | }, 177 | { 178 | target: types.TargetTypeCaarlos0, 179 | name: "from file", 180 | opts: FieldDecoderOpts{ 181 | TagName: "env", 182 | }, 183 | spec: &ast.FieldSpec{ 184 | Names: []string{"Foo"}, 185 | Doc: "foo doc", 186 | Tag: `env:"FOO,file"`, 187 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 188 | }, 189 | expectField: FieldInfo{ 190 | Names: []string{"FOO"}, 191 | FromFile: true, 192 | }, 193 | }, 194 | { 195 | target: types.TargetTypeCaarlos0, 196 | name: "field prefix", 197 | opts: FieldDecoderOpts{ 198 | TagName: "env", 199 | TagDefault: "default", 200 | EnvPrefix: "X_", 201 | }, 202 | spec: &ast.FieldSpec{ 203 | Names: []string{"Foo"}, 204 | Doc: "foo doc", 205 | Tag: `env:"FOO" envPrefix:"BAR_"`, 206 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 207 | }, 208 | expectField: FieldInfo{ 209 | Names: []string{"X_FOO"}, 210 | }, 211 | expectPrefix: "X_BAR_", 212 | }, 213 | 214 | { 215 | target: types.TargetTypeCleanenv, 216 | name: "name", 217 | spec: &ast.FieldSpec{ 218 | Names: []string{"Foo"}, 219 | Doc: "foo doc", 220 | Tag: `env:"FOO"`, 221 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 222 | }, 223 | expectField: FieldInfo{ 224 | Names: []string{"FOO"}, 225 | }, 226 | }, 227 | { 228 | target: types.TargetTypeCleanenv, 229 | name: "required", 230 | spec: &ast.FieldSpec{ 231 | Names: []string{"Foo"}, 232 | Doc: "foo doc", 233 | Tag: `env:"FOO" env-required:"true"`, 234 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 235 | }, 236 | expectField: FieldInfo{ 237 | Names: []string{"FOO"}, 238 | Required: true, 239 | }, 240 | }, 241 | { 242 | target: types.TargetTypeCleanenv, 243 | name: "default", 244 | spec: &ast.FieldSpec{ 245 | Names: []string{"Foo"}, 246 | Doc: "foo doc", 247 | Tag: `env:"FOO" env-default:"bar"`, 248 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 249 | }, 250 | expectField: FieldInfo{ 251 | Names: []string{"FOO"}, 252 | Default: "bar", 253 | }, 254 | }, 255 | { 256 | target: types.TargetTypeCleanenv, 257 | name: "separator", 258 | spec: &ast.FieldSpec{ 259 | Names: []string{"Foo"}, 260 | Doc: "foo doc", 261 | Tag: `env:"FOO" env-separator:":"`, 262 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 263 | }, 264 | expectField: FieldInfo{ 265 | Names: []string{"FOO"}, 266 | Separator: ":", 267 | }, 268 | }, 269 | { 270 | target: types.TargetTypeCleanenv, 271 | name: "prefix", 272 | spec: &ast.FieldSpec{ 273 | Names: []string{"Foo"}, 274 | Doc: "foo doc", 275 | Tag: `env:"FOO" env-prefix:"BAR_"`, 276 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 277 | }, 278 | expectField: FieldInfo{ 279 | Names: []string{"FOO"}, 280 | }, 281 | expectPrefix: "BAR_", 282 | }, 283 | { 284 | target: types.TargetTypeCleanenv, 285 | name: "field prefix", 286 | opts: FieldDecoderOpts{ 287 | EnvPrefix: "X_", 288 | }, 289 | spec: &ast.FieldSpec{ 290 | Names: []string{"Foo"}, 291 | Doc: "foo doc", 292 | Tag: `env:"FOO" env-prefix:"BAR_"`, 293 | TypeRef: ast.FieldTypeRef{Name: "string", Kind: ast.FieldTypeIdent}, 294 | }, 295 | expectField: FieldInfo{ 296 | Names: []string{"X_FOO"}, 297 | }, 298 | expectPrefix: "X_BAR_", 299 | }, 300 | } { 301 | t.Run(fmt.Sprintf("%s_%s", test.target, test.name), func(t *testing.T) { 302 | d := NewFieldDecoder(test.target, test.opts) 303 | res, prefix := d.Decode(test.spec) 304 | assertEqFieldInfo(t, test.expectField, res) 305 | testutils.AssertError(t, prefix == test.expectPrefix, "expected prefix: %s, got: %s", test.expectPrefix, prefix) 306 | }) 307 | } 308 | } 309 | 310 | func assertEqFieldInfo(t *testing.T, expect, actual FieldInfo) { 311 | t.Helper() 312 | 313 | testutils.AssertFatal(t, len(expect.Names) == len(actual.Names), "unexpected names: %v", actual.Names) 314 | for i, name := range expect.Names { 315 | testutils.AssertError(t, name == actual.Names[i], "[%d] expected name %q got %q", i, name, actual.Names[i]) 316 | } 317 | testutils.AssertError(t, expect.Required == actual.Required, "required flag mismatch") 318 | testutils.AssertError(t, expect.Expand == actual.Expand, "expand flag mismatch") 319 | testutils.AssertError(t, expect.NonEmpty == actual.NonEmpty, "non-empty flag mismatch") 320 | testutils.AssertError(t, expect.FromFile == actual.FromFile, "from-file flag mismatch") 321 | testutils.AssertError(t, expect.Default == actual.Default, "default value mismatch") 322 | testutils.AssertError(t, expect.Separator == actual.Separator, "separator mismatch") 323 | } 324 | -------------------------------------------------------------------------------- /generator.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/g4s8/envdoc/ast" 8 | "github.com/g4s8/envdoc/debug" 9 | "github.com/g4s8/envdoc/resolver" 10 | "github.com/g4s8/envdoc/types" 11 | ) 12 | 13 | type Renderer interface { 14 | Render(scopes []*types.EnvScope, out io.Writer) error 15 | } 16 | 17 | type Generator struct { 18 | parser *ast.Parser 19 | converter *Converter 20 | renderer Renderer 21 | } 22 | 23 | func NewGenerator(parser *ast.Parser, converter *Converter, renderer Renderer) *Generator { 24 | return &Generator{ 25 | parser: parser, 26 | converter: converter, 27 | renderer: renderer, 28 | } 29 | } 30 | 31 | func (g *Generator) Generate(dir string, out io.Writer) error { 32 | files, err := g.parser.Parse(dir) 33 | if err != nil { 34 | return fmt.Errorf("parse dir: %w", err) 35 | } 36 | 37 | res := resolver.ResolveAllTypes(files) 38 | debug.PrintDebug(res) 39 | 40 | scopes := g.converter.ScopesFromFiles(res, files) 41 | printScopesTree(scopes) 42 | 43 | if err := g.renderer.Render(scopes, out); err != nil { 44 | return fmt.Errorf("render: %w", err) 45 | } 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /generator_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/g4s8/envdoc/ast" 14 | "github.com/g4s8/envdoc/render" 15 | "github.com/g4s8/envdoc/types" 16 | "golang.org/x/tools/txtar" 17 | ) 18 | 19 | func TestGenerator(t *testing.T) { 20 | files, err := filepath.Glob("testdata/*.txtar") 21 | if err != nil { 22 | t.Fatalf("failed to list testdata files: %s", err) 23 | } 24 | t.Logf("Found %d testdata files", len(files)) 25 | if len(files) == 0 { 26 | t.Fatal("no testdata files found") 27 | } 28 | 29 | for _, file := range files { 30 | file := file 31 | 32 | t.Run(filepath.Base(file), func(t *testing.T) { 33 | t.Parallel() 34 | 35 | ar, err := txtar.ParseFile(file) 36 | if err != nil { 37 | t.Fatalf("failed to parse txtar file: %s", err) 38 | } 39 | spec := parseTestSpec(t, string(ar.Comment)) 40 | t.Logf("Test case: %s", spec.Comment) 41 | 42 | dir := extractTxtar(t, ar) 43 | 44 | p := ast.NewParser("*", spec.TypeName) 45 | conv := NewConverter(types.TargetTypeCaarlos0, ConverterOpts{ 46 | EnvPrefix: spec.EnvPrefix, 47 | TagName: "env", 48 | TagDefault: "envDefault", 49 | UseFieldNames: spec.FieldNames, 50 | }) 51 | rend := render.NewRenderer(types.OutFormatTxt, false) 52 | gen := NewGenerator(p, conv, rend) 53 | var out bytes.Buffer 54 | runGenerator(t, gen, spec, dir, &out) 55 | 56 | expectFile, err := os.Open(path.Join(dir, "expect.txt")) 57 | if err != nil { 58 | t.Fatalf("failed to open expect.txt: %s", err) 59 | } 60 | defer expectFile.Close() 61 | expect, err := io.ReadAll(expectFile) 62 | if err != nil { 63 | t.Fatalf("failed to read expect.txt: %s", err) 64 | } 65 | if !bytes.Equal(out.Bytes(), expect) { 66 | t.Logf("Expected:\n%s", expect) 67 | t.Logf("Got:\n%s", out.String()) 68 | t.Fatalf("Output mismatch") 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func extractTxtar(t *testing.T, ar *txtar.Archive) string { 75 | t.Helper() 76 | 77 | dir := t.TempDir() 78 | for _, file := range ar.Files { 79 | name := filepath.Join(dir, file.Name) 80 | if err := os.MkdirAll(filepath.Dir(name), 0o777); err != nil { 81 | t.Fatalf("failed to create dir: %s", err) 82 | } 83 | if err := os.WriteFile(name, file.Data, 0o666); err != nil { 84 | t.Fatalf("failed to write file: %s", err) 85 | } 86 | } 87 | return dir 88 | } 89 | 90 | func runGenerator(t *testing.T, gen interface{ Generate(string, io.Writer) error }, spec GenTestSpec, dir string, out *bytes.Buffer) { 91 | t.Helper() 92 | 93 | if err := gen.Generate(dir, out); err != nil { 94 | if spec.Success { 95 | t.Fatalf("failed to generate: %s", err) 96 | } 97 | t.Logf("Expected error: %s", err) 98 | return 99 | } 100 | if !spec.Success { 101 | t.Fatalf("expected error, but got success") 102 | } 103 | } 104 | 105 | type GenTestSpec struct { 106 | Success bool 107 | TypeName string 108 | EnvPrefix string 109 | FieldNames bool 110 | Comment string 111 | } 112 | 113 | func parseTestSpec(t *testing.T, data string) GenTestSpec { 114 | t.Helper() 115 | 116 | var res GenTestSpec 117 | // comment is a multiline test spec. 118 | // the first line starts with either `Success:` or `Error` 119 | // with following description of test case. 120 | // If the first line starts with `Error`, the test is expected to fail. 121 | // Next lines may contain: 122 | // - TypeName: type name to process 123 | scanner := bufio.NewScanner(strings.NewReader(data)) 124 | for scanner.Scan() { 125 | line := scanner.Text() 126 | if strings.HasPrefix(line, "Success:") { 127 | res.Success = true 128 | continue 129 | } 130 | if strings.HasPrefix(line, "Success:") || strings.HasPrefix(line, "Error:") { 131 | s := strings.SplitN(line, ":", 2) 132 | res.Comment = strings.TrimSpace(s[1]) 133 | continue 134 | } 135 | 136 | if strings.HasPrefix(line, "TypeName:") { 137 | res.TypeName = strings.TrimSpace(strings.TrimPrefix(line, "TypeName:")) 138 | continue 139 | } 140 | if strings.HasPrefix(line, "EnvPrefix:") { 141 | res.EnvPrefix = strings.TrimSpace(strings.TrimPrefix(line, "EnvPrefix:")) 142 | continue 143 | } 144 | if strings.HasPrefix(line, "FieldNames:") { 145 | res.FieldNames = strings.TrimSpace(strings.TrimPrefix(line, "FieldNames:")) == "true" 146 | continue 147 | } 148 | } 149 | if err := scanner.Err(); err != nil { 150 | t.Fatalf("failed to read comment: %s", err) 151 | } 152 | if res.TypeName == "" { 153 | res.TypeName = "*" 154 | } 155 | return res 156 | } 157 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/g4s8/envdoc 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/gobwas/glob v0.2.3 7 | golang.org/x/tools v0.33.0 8 | gopkg.in/yaml.v2 v2.4.0 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 2 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 3 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 4 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 5 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 6 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 7 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 8 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 9 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 10 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 14 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 15 | -------------------------------------------------------------------------------- /linter/analyzer.go: -------------------------------------------------------------------------------- 1 | package linter 2 | 3 | import ( 4 | "golang.org/x/tools/go/analysis" 5 | ) 6 | 7 | // Option is a linter configuration option. 8 | type Option func(*linter) 9 | 10 | // WithEnvName sets custom env tag name for linter. 11 | func WithEnvName(name string) Option { 12 | return func(l *linter) { 13 | l.envName = name 14 | } 15 | } 16 | 17 | // WithNoComments disables check for documentation comments. 18 | func WithNoComments() Option { 19 | return func(l *linter) { 20 | l.noComments = true 21 | } 22 | } 23 | 24 | // NewAnlyzer creates a new linter analyzer. 25 | func NewAnlyzer(parseFlags bool, opts ...Option) *analysis.Analyzer { 26 | l := &linter{ 27 | envName: "env", 28 | } 29 | for _, opt := range opts { 30 | opt(l) 31 | } 32 | a := &analysis.Analyzer{ 33 | Name: "docenv", 34 | Doc: "check that all environment variables are documented", 35 | Run: l.run, 36 | } 37 | if parseFlags { 38 | a.Flags.StringVar(&l.envName, "env-name", l.envName, "environment variable tag name") 39 | } 40 | return a 41 | } 42 | -------------------------------------------------------------------------------- /linter/linter.go: -------------------------------------------------------------------------------- 1 | package linter 2 | 3 | import ( 4 | "fmt" 5 | "go/ast" 6 | "strings" 7 | 8 | "golang.org/x/tools/go/analysis" 9 | ) 10 | 11 | type linter struct { 12 | envName string 13 | noComments bool 14 | } 15 | 16 | func (l *linter) run(pass *analysis.Pass) (interface{}, error) { 17 | tagStr := fmt.Sprintf("%s:", l.envName) 18 | for _, f := range pass.Files { 19 | ast.Inspect(f, func(n ast.Node) bool { 20 | field, ok := n.(*ast.Field) 21 | if !ok { 22 | return true 23 | } 24 | 25 | if field.Tag == nil { 26 | return true 27 | } 28 | 29 | tag := field.Tag.Value 30 | if !strings.Contains(tag, tagStr) { 31 | return true 32 | } 33 | 34 | if !checkFieldDoc(field, l.noComments) { 35 | names := fieldNames(field) 36 | pass.Reportf(field.Pos(), 37 | "field `%s` with `%s` tag should have a documentation comment", 38 | names, l.envName) 39 | } 40 | 41 | return true 42 | }) 43 | } 44 | return nil, nil 45 | } 46 | 47 | func fieldNames(f *ast.Field) string { 48 | var names []string 49 | for _, name := range f.Names { 50 | names = append(names, name.Name) 51 | } 52 | return strings.Join(names, ", ") 53 | } 54 | 55 | func checkFieldDoc(f *ast.Field, noComments bool) bool { 56 | if f.Doc != nil && strings.TrimSpace(f.Doc.Text()) != "" { 57 | return true 58 | } 59 | 60 | if noComments { 61 | return false 62 | } else if f.Comment != nil && strings.TrimSpace(f.Comment.Text()) != "" { 63 | return true 64 | } 65 | 66 | return false 67 | } 68 | -------------------------------------------------------------------------------- /linter/linter_test.go: -------------------------------------------------------------------------------- 1 | package linter 2 | 3 | import ( 4 | "bytes" 5 | "go/ast" 6 | "go/parser" 7 | "go/token" 8 | "log" 9 | "testing" 10 | 11 | "golang.org/x/tools/go/analysis" 12 | "golang.org/x/tools/go/analysis/passes/inspect" 13 | ) 14 | 15 | func TestLinter(t *testing.T) { 16 | for _, tc := range []struct { 17 | name string 18 | file string 19 | opts []Option 20 | expectOut []string 21 | }{ 22 | { 23 | name: "simple", 24 | file: "testdata/simple.go", 25 | expectOut: []string{ 26 | "testdata/simple.go:10: field `Undocumented` with `env` tag should have a documentation comment", 27 | "", 28 | }, 29 | }, 30 | { 31 | name: "custom", 32 | file: "testdata/custom.go", 33 | opts: []Option{WithEnvName("foo"), WithNoComments()}, 34 | expectOut: []string{ 35 | "testdata/custom.go:10: field `Undocumented` with `foo` tag should have a documentation comment", 36 | "testdata/custom.go:12: field `NoComments` with `foo` tag should have a documentation comment", 37 | "", 38 | }, 39 | }, 40 | } { 41 | t.Run(tc.name, func(t *testing.T) { 42 | var out bytes.Buffer 43 | log.SetOutput(&out) 44 | log.SetFlags(0) 45 | 46 | a := NewAnlyzer(false, tc.opts...) 47 | 48 | fset, file := prepareFile(t, tc.file) 49 | pass := &analysis.Pass{ 50 | Analyzer: a, 51 | Fset: fset, 52 | Files: []*ast.File{file}, 53 | Report: func(d analysis.Diagnostic) { 54 | log.Printf("%s:%d: %s", fset.Position(d.Pos).Filename, fset.Position(d.Pos).Line, d.Message) 55 | }, 56 | ResultOf: make(map[*analysis.Analyzer]interface{}), 57 | } 58 | 59 | res, err := inspect.Analyzer.Run(pass) 60 | if err != nil { 61 | t.Fatalf("could not run inspect analyzer: %v", err) 62 | } 63 | pass.ResultOf[inspect.Analyzer] = res 64 | 65 | if _, err := a.Run(pass); err != nil { 66 | t.Fatalf("could not run linter: %v", err) 67 | } 68 | 69 | lines := bytes.Split(out.Bytes(), []byte("\n")) 70 | if len(lines) != len(tc.expectOut) { 71 | t.Fatalf("unexpected number of lines: got %d, want %d", len(lines), len(tc.expectOut)) 72 | } 73 | for i, line := range lines { 74 | if string(line) != tc.expectOut[i] { 75 | t.Errorf("unexpected output: got %q, want %q", line, tc.expectOut[i]) 76 | } 77 | } 78 | }) 79 | } 80 | } 81 | 82 | func prepareFile(t *testing.T, name string) (*token.FileSet, *ast.File) { 83 | t.Helper() 84 | fset := token.NewFileSet() 85 | file, err := parser.ParseFile(fset, name, nil, parser.ParseComments) 86 | if err != nil { 87 | t.Fatalf("could not parse file: %v", err) 88 | } 89 | return fset, file 90 | } 91 | -------------------------------------------------------------------------------- /linter/testdata/custom.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | type Config struct { 4 | // Host is the host of the server. 5 | Host string `foo:"HOST"` 6 | 7 | // Port is the port of the server. 8 | Port int `foo:"PORT"` 9 | 10 | Undocumented string `foo:"UNDOCUMENTED"` 11 | 12 | NoComments string `foo:"NOCOMMENTS"` // comment is not a doc 13 | } 14 | -------------------------------------------------------------------------------- /linter/testdata/simple.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | type Config struct { 4 | // Host is the host of the server. 5 | Host string `env:"HOST"` 6 | 7 | // Port is the port of the server. 8 | Port int `env:"PORT"` 9 | 10 | Undocumented string `env:"UNDOCUMENTED"` 11 | } 12 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | //go:build !coverage 2 | 3 | package main 4 | 5 | import ( 6 | "bufio" 7 | "fmt" 8 | "os" 9 | 10 | "github.com/g4s8/envdoc/ast" 11 | "github.com/g4s8/envdoc/debug" 12 | "github.com/g4s8/envdoc/render" 13 | ) 14 | 15 | func main() { 16 | var cfg Config 17 | if err := cfg.Load(); err != nil { 18 | fatal("Failed to load config: %v", err) 19 | } 20 | if cfg.Debug { 21 | debug.Config.Enabled = true 22 | cfg.fprint(os.Stdout) 23 | } 24 | 25 | parser := ast.NewParser(cfg.FileGlob, cfg.TypeGlob, 26 | ast.WithDebug(cfg.Debug), 27 | ast.WithExecConfig(cfg.ExecFile, cfg.ExecLine)) 28 | converter := NewConverter(cfg.Target, ConverterOpts{ 29 | EnvPrefix: cfg.EnvPrefix, 30 | TagName: cfg.TagName, 31 | TagDefault: cfg.TagDefault, 32 | RequiredIfNoDef: cfg.RequiredIfNoDef, 33 | UseFieldNames: cfg.FieldNames, 34 | }) 35 | renderer := render.NewRenderer(cfg.OutFormat, cfg.NoStyles) 36 | gen := NewGenerator(parser, converter, renderer) 37 | 38 | out, err := os.Create(cfg.OutFile) 39 | if err != nil { 40 | fatal("Failed to open output file: %v", err) 41 | } 42 | buf := bufio.NewWriter(out) 43 | defer func() { 44 | if err := out.Close(); err != nil { 45 | fatal("Failed to close output file: %v", err) 46 | } 47 | }() 48 | 49 | if err := gen.Generate(cfg.Dir, buf); err != nil { 50 | fatal("Failed to generate: %v", err) 51 | } 52 | if err := buf.Flush(); err != nil { 53 | fatal("Failed to flush output: %v", err) 54 | } 55 | } 56 | 57 | func fatal(format string, args ...interface{}) { 58 | fmt.Fprintf(os.Stderr, format, args...) 59 | fmt.Fprintln(os.Stderr) 60 | os.Exit(1) 61 | } 62 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "testing" 7 | 8 | "github.com/g4s8/envdoc/debug" 9 | ) 10 | 11 | type testConfig struct { 12 | Debug bool 13 | } 14 | 15 | func TestMain(m *testing.M) { 16 | var cfg testConfig 17 | flag.BoolVar(&cfg.Debug, "debug", false, "Enable debug mode") 18 | flag.Parse() 19 | 20 | debug.Config.Enabled = cfg.Debug 21 | 22 | os.Exit(m.Run()) 23 | } 24 | -------------------------------------------------------------------------------- /render/config.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import "github.com/g4s8/envdoc/types" 4 | 5 | type renderItemConfig struct { 6 | SeparatorFormat string 7 | SeparatorDefault string 8 | OptRequired string 9 | OptExpand string 10 | OptNonEmpty string 11 | OptFromFile string 12 | EnvDefaultFormat string 13 | } 14 | type renderConfig struct { 15 | Item renderItemConfig 16 | tmpl template 17 | } 18 | 19 | var configs = map[types.OutFormat]renderConfig{ 20 | types.OutFormatMarkdown: { 21 | Item: renderItemConfig{ 22 | SeparatorFormat: "separated by `%s`", 23 | SeparatorDefault: "comma-separated", 24 | OptRequired: "**required**", 25 | OptExpand: "expand", 26 | OptFromFile: "from-file", 27 | OptNonEmpty: "non-empty", 28 | EnvDefaultFormat: "default: `%s`", 29 | }, 30 | tmpl: newTmplText("markdown.tmpl"), 31 | }, 32 | types.OutFormatHTML: { 33 | Item: renderItemConfig{ 34 | SeparatorFormat: `separated by "%s"`, 35 | SeparatorDefault: "comma-separated", 36 | OptRequired: "required", 37 | OptExpand: "expand", 38 | OptFromFile: "from-file", 39 | OptNonEmpty: "non-empty", 40 | EnvDefaultFormat: "default: %s", 41 | }, 42 | tmpl: newTmplText("html.tmpl"), 43 | }, 44 | types.OutFormatTxt: { 45 | Item: renderItemConfig{ 46 | SeparatorFormat: "separated by `%s`", 47 | SeparatorDefault: "comma-separated", 48 | OptRequired: "required", 49 | OptExpand: "expand", 50 | OptFromFile: "from-file", 51 | OptNonEmpty: "non-empty", 52 | EnvDefaultFormat: "default: `%s`", 53 | }, 54 | tmpl: newTmplText("plaintext.tmpl"), 55 | }, 56 | types.OutFormatEnv: { 57 | Item: renderItemConfig{ 58 | SeparatorFormat: "separated by '%s'", 59 | SeparatorDefault: "comma-separated", 60 | OptRequired: "required", 61 | OptExpand: "expand", 62 | OptFromFile: "from-file", 63 | OptNonEmpty: "non-empty", 64 | EnvDefaultFormat: "default: '%s'", 65 | }, 66 | tmpl: newTmplText("dotenv.tmpl"), 67 | }, 68 | types.OutFormatJSON: { 69 | Item: renderItemConfig{}, 70 | tmpl: newTmplText("json.tmpl"), 71 | }, 72 | } 73 | -------------------------------------------------------------------------------- /render/renderer.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/g4s8/envdoc/types" 8 | ) 9 | 10 | type Renderer struct { 11 | format types.OutFormat 12 | noStyles bool 13 | } 14 | 15 | func NewRenderer(format types.OutFormat, noStyles bool) *Renderer { 16 | return &Renderer{ 17 | format: format, 18 | noStyles: noStyles, 19 | } 20 | } 21 | 22 | func (r *Renderer) Render(scopes []*types.EnvScope, out io.Writer) error { 23 | cfg, ok := configs[r.format] 24 | if !ok { 25 | return fmt.Errorf("unknown format: %q", r.format) 26 | } 27 | 28 | c := newRenderContext(scopes, cfg, r.noStyles) 29 | f := templateRenderer(cfg.tmpl) 30 | 31 | if err := f(c, out); err != nil { 32 | return fmt.Errorf("render: %w", err) 33 | } 34 | return nil 35 | } 36 | 37 | type renderSection struct { 38 | Name string 39 | Doc string 40 | Items []renderItem 41 | } 42 | 43 | type renderItem struct { 44 | EnvName string `json:"env_name"` 45 | Doc string `json:"doc"` 46 | EnvDefault string `json:"env_default,omitempty"` 47 | EnvSeparator string `json:"env_separator,omitempty"` 48 | 49 | Required bool `json:"required,omitempty"` 50 | Expand bool `json:"expand,omitempty"` 51 | NonEmpty bool `json:"non_empty,omitempty"` 52 | FromFile bool `json:"from_file,omitempty"` 53 | 54 | children []renderItem 55 | Indent int `json:"-"` 56 | } 57 | 58 | func (i renderItem) Children(indentInc int) []renderItem { 59 | indent := i.Indent + indentInc 60 | res := make([]renderItem, len(i.children)) 61 | for j, child := range i.children { 62 | child.Indent = indent 63 | res[j] = child 64 | } 65 | return res 66 | } 67 | 68 | type renderContext struct { 69 | Title string 70 | Sections []renderSection 71 | Styles bool 72 | Config renderConfig 73 | } 74 | 75 | func newRenderContext(scopes []*types.EnvScope, cfg renderConfig, noStyles bool) renderContext { 76 | res := renderContext{ 77 | Sections: make([]renderSection, len(scopes)), 78 | Styles: !noStyles, 79 | Config: cfg, 80 | } 81 | res.Title = "Environment Variables" 82 | for i, scope := range scopes { 83 | section := renderSection{ 84 | Name: scope.Name, 85 | Doc: scope.Doc, 86 | Items: make([]renderItem, len(scope.Vars)), 87 | } 88 | for j, item := range scope.Vars { 89 | item := newRenderItem(item) 90 | item.Indent = 1 91 | section.Items[j] = item 92 | } 93 | res.Sections[i] = section 94 | } 95 | return res 96 | } 97 | 98 | func newRenderItem(item *types.EnvDocItem) renderItem { 99 | children := make([]renderItem, len(item.Children)) 100 | for i, child := range item.Children { 101 | children[i] = newRenderItem(child) 102 | } 103 | return renderItem{ 104 | EnvName: item.Name, 105 | Doc: item.Doc, 106 | EnvDefault: item.Opts.Default, 107 | EnvSeparator: item.Opts.Separator, 108 | Required: item.Opts.Required, 109 | Expand: item.Opts.Expand, 110 | NonEmpty: item.Opts.NonEmpty, 111 | FromFile: item.Opts.FromFile, 112 | children: children, 113 | } 114 | } 115 | 116 | type template interface { 117 | Execute(wr io.Writer, data any) error 118 | } 119 | 120 | func templateRenderer(t template) func(renderContext, io.Writer) error { 121 | return func(c renderContext, out io.Writer) error { 122 | if err := t.Execute(out, c); err != nil { 123 | return fmt.Errorf("render template: %w", err) 124 | } 125 | return nil 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /render/renderer_test.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/g4s8/envdoc/types" 8 | ) 9 | 10 | func TestRenderer(t *testing.T) { 11 | r := NewRenderer(types.OutFormatTxt, false) 12 | scopes := []*types.EnvScope{ 13 | { 14 | Name: "scope1", 15 | Doc: "scope1 doc", 16 | Vars: []*types.EnvDocItem{ 17 | { 18 | Name: "VAR1", 19 | Doc: "VAR1 doc", 20 | Opts: types.EnvVarOptions{ 21 | Required: true, 22 | }, 23 | }, 24 | }, 25 | }, 26 | } 27 | var sb strings.Builder 28 | if err := r.Render(scopes, &sb); err != nil { 29 | t.Fatalf("Failed to render: %s", err) 30 | } 31 | // Environment Variables 32 | 33 | // scope1 34 | 35 | // scope1 doc 36 | 37 | // * `VAR1` (required): VAR1 doc (required) 38 | var expectSb strings.Builder 39 | expectSb.WriteString("Environment Variables\n\n") 40 | expectSb.WriteString("## scope1\n\n") 41 | expectSb.WriteString("scope1 doc\n\n") 42 | expectSb.WriteString(" * `VAR1` (required) - VAR1 doc\n") 43 | expectSb.WriteString("\n") 44 | 45 | if expect, actual := expectSb.String(), sb.String(); actual != expect { 46 | t.Logf("Expected:\n%s", expect) 47 | t.Logf("Got:\n%s", actual) 48 | t.Fatalf("Unexpected output") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /render/templ/dotenv.tmpl: -------------------------------------------------------------------------------- 1 | {{- define "item" }} 2 | {{- $ := index . 0 }} 3 | {{- $cfg := index . 1 }} 4 | {{- if eq $.EnvName "" }} 5 | # 6 | {{- end }} 7 | {{- template "doc.lines" (list $.Doc "##") }} 8 | {{- if $.EnvName }} 9 | {{- if $.Doc }} 10 | {{- printf "\n" }} 11 | {{- end }} 12 | {{- template "item.options" (list $ $cfg "## (%s)\n") }} 13 | {{- if $.EnvDefault }} 14 | {{- printf `# %s="%s"` $.EnvName $.EnvDefault }} 15 | {{- else }} 16 | {{- printf `# %s=""` $.EnvName }} 17 | {{- end }} 18 | {{- end }} 19 | {{- $children := $.Children 0 }} 20 | {{- if $children }} 21 | # 22 | {{- range $child := $children }} 23 | {{- template "item" (list $child $cfg) }} 24 | {{- end }} 25 | {{- end }} 26 | {{- end -}} 27 | 28 | {{- $cfg := $.Config -}} 29 | # {{ .Title }} 30 | {{ range .Sections }} 31 | {{- print "\n" }} 32 | {{- if .Name }} 33 | ## {{ .Name }} 34 | {{- end }} 35 | {{- template "doc.lines" (list .Doc "##") }} 36 | # 37 | {{- range $item := .Items }} 38 | {{- template "item" (list $item $cfg.Item) }} 39 | {{- end }} 40 | {{- end }} 41 | 42 | -------------------------------------------------------------------------------- /render/templ/helpers.tmpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Render doc lines: split doc by newline and prefix with `##` each line. 3 | */}} 4 | {{- define "doc.lines" }} 5 | {{- $ := index . 0 }} 6 | {{- $prefix := index . 1 }} 7 | {{- if $ }} 8 | {{- $docLines := split $ "\n" }} 9 | {{- range $line := $docLines }} 10 | {{ $prefix }} {{ $line }} 11 | {{- end }} 12 | {{- end }} 13 | {{- end -}} 14 | 15 | {{/* 16 | Render item options using items config (inline rendering). 17 | */}} 18 | {{- define "item.options" -}} 19 | {{- $ := index . 0 -}} 20 | {{- $cfg := index . 1 -}} 21 | {{- $format := index . 2 -}} 22 | {{/* 23 | SeparatorFormat string 24 | SeparatorDefault string 25 | OptRequired string 26 | OptExpand string 27 | OptNonEmpty string 28 | OptFromFile string 29 | EnvDefaultFormat string 30 | */}} 31 | {{- $opts := strSlice -}} 32 | {{- if eq $.EnvSeparator "," -}} 33 | {{- $opts = (strAppend $opts $cfg.SeparatorDefault) -}} 34 | {{- else if $.EnvSeparator -}} 35 | {{- $opts = (printf $cfg.SeparatorFormat $.EnvSeparator | strAppend $opts) -}} 36 | {{- end }} 37 | {{- if $.Required -}} 38 | {{- $opts = (strAppend $opts $cfg.OptRequired) -}} 39 | {{- end -}} 40 | {{- if $.Expand -}} 41 | {{- $opts = (strAppend $opts $cfg.OptExpand) -}} 42 | {{- end -}} 43 | {{- if $.NonEmpty -}} 44 | {{- $opts = (strAppend $opts $cfg.OptNonEmpty) -}} 45 | {{- end -}} 46 | {{- if $.FromFile -}} 47 | {{- $opts = (strAppend $opts $cfg.OptFromFile) -}} 48 | {{- end -}} 49 | {{- if $.EnvDefault -}} 50 | {{- $opts = (printf $cfg.EnvDefaultFormat $.EnvDefault | strAppend $opts) -}} 51 | {{- end -}} 52 | {{- if $opts -}} 53 | {{- join $opts ", " | printf $format -}} 54 | {{- end -}} 55 | {{- end -}} 56 | -------------------------------------------------------------------------------- /render/templ/html.tmpl: -------------------------------------------------------------------------------- 1 | {{- define "item" }} 2 | {{- $ := index . 0 }} 3 | {{- $cfg := index . 1 }} 4 |
  • 5 | {{- $comma := false -}} 6 | {{- if $.EnvName -}} 7 | {{ $.EnvName }} 8 | {{- template "item.options" (list $ $cfg " (%s)") }} 9 | {{- $.Doc | printf " - %s" -}} 10 | {{- else -}} 11 | {{- $.Doc | printf "%s" -}} 12 | {{- end}} 13 | {{- $children := $.Children 0 -}} 14 | {{- if $children }} 15 | 20 | {{ end -}} 21 |
  • 22 | {{- end -}} 23 | 24 | {{- $cfg := $.Config -}} 25 | 26 | 27 | 28 | 29 | {{ .Title }} 30 | {{ if .Styles -}} 31 | 98 | {{- end }} 99 | 100 | 101 |
    102 |
    103 |

    {{ .Title }}

    104 | {{ range .Sections }} 105 |

    {{ .Name }}

    106 | {{ if ne .Doc "" -}} 107 |

    {{ .Doc }}

    108 | {{- end }} 109 |
      110 | {{- range $item := .Items }} 111 | {{- template "item" (list $item $cfg.Item) -}} 112 | {{ end }} 113 |
    114 | {{ end }} 115 |
    116 |
    117 | 118 | 119 | -------------------------------------------------------------------------------- /render/templ/json.tmpl: -------------------------------------------------------------------------------- 1 | {{- range .Sections }} 2 | {{- marshalIndent .Items }} 3 | {{- end }} 4 | -------------------------------------------------------------------------------- /render/templ/markdown.tmpl: -------------------------------------------------------------------------------- 1 | {{- define "item" }} 2 | {{- $ := index . 0 }} 3 | {{- $cfg := index . 1 }} 4 | {{- $indent := index . 2 }} 5 | {{- repeat " " $indent }} 6 | {{- if $.EnvName }} 7 | {{- $.EnvName | printf "- `%s`" }} 8 | {{- template "item.options" (list $ $cfg " (%s)") }} 9 | {{- $.Doc | printf " - %s" }} 10 | {{- else }} 11 | {{- $.Doc | printf "- %s" }} 12 | {{- end }} 13 | {{- $children := $.Children 0 }} 14 | {{- if $children }} 15 | {{- range $child := $children }} 16 | {{ template "item" (list $child $cfg (sum $indent 2)) }} 17 | {{- end }} 18 | {{- end -}} 19 | {{ end -}} 20 | 21 | {{- $cfg := $.Config -}} 22 | # {{ .Title }} 23 | {{ range .Sections -}} 24 | {{ if .Name }} 25 | ## {{ .Name }} 26 | {{ end }} 27 | {{- if .Doc }} 28 | {{ .Doc }} 29 | {{ end }} 30 | {{ range $item := .Items }} 31 | {{- template "item" (list $item $cfg.Item 1) }} 32 | {{ end -}} 33 | {{ end }} 34 | -------------------------------------------------------------------------------- /render/templ/plaintext.tmpl: -------------------------------------------------------------------------------- 1 | {{- define "item" }} 2 | {{- $ := index . 0 }} 3 | {{- $cfg := index . 1 }} 4 | {{- $indent := index . 2 }} 5 | {{- repeat " " $indent }} 6 | {{- if $.EnvName }} 7 | {{- $.EnvName | printf "* `%s`" -}} 8 | {{- template "item.options" (list $ $cfg " (%s)") }} 9 | {{- $.Doc | printf " - %s" }} 10 | {{- else }} 11 | {{- $.Doc | printf "* %s" }} 12 | {{- end }} 13 | {{- $children := $.Children 0 }} 14 | {{- if $children }} 15 | {{- range $child := $children }} 16 | {{ template "item" (list $child $cfg (sum $indent 2)) }} 17 | {{- end }} 18 | {{- end -}} 19 | {{ end -}} 20 | 21 | {{- $cfg := $.Config -}} 22 | {{ .Title }} 23 | {{ range .Sections }} 24 | ## {{ .Name }} 25 | {{ if .Doc }} 26 | {{ .Doc }} 27 | {{ end }} 28 | {{ range $item := .Items }} 29 | {{- template "item" (list $item $cfg.Item 1) }} 30 | {{ end -}} 31 | {{ end }} 32 | -------------------------------------------------------------------------------- /render/templates.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "path" 7 | "strings" 8 | 9 | texttmpl "text/template" 10 | ) 11 | 12 | //go:embed templ 13 | var templatesFS embed.FS 14 | 15 | var tplFuncs = map[string]any{ 16 | "repeat": strings.Repeat, 17 | "split": strings.Split, 18 | "strAppend": func(arr []string, item string) []string { 19 | return append(arr, item) 20 | }, 21 | "join": strings.Join, 22 | "strSlice": func() []string { 23 | return make([]string, 0) 24 | }, 25 | "list": func(args ...any) []any { 26 | return args 27 | }, 28 | "sum": func(args ...int) int { 29 | var sum int 30 | for _, v := range args { 31 | sum += v 32 | } 33 | return sum 34 | }, 35 | "marshalIndent": func(v any) (string, error) { 36 | a, err := json.MarshalIndent(v, "", " ") 37 | return string(a), err 38 | }, 39 | } 40 | 41 | const ( 42 | tmplDir = "templ" 43 | tmplHelpers = "helpers.tmpl" 44 | ) 45 | 46 | func newTmplText(name string) *texttmpl.Template { 47 | return texttmpl.Must(texttmpl.New(name). 48 | Funcs(tplFuncs). 49 | ParseFS(templatesFS, 50 | path.Join(tmplDir, name), 51 | path.Join(tmplDir, tmplHelpers))) 52 | } 53 | -------------------------------------------------------------------------------- /render/templates_test.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "slices" 5 | "testing" 6 | ) 7 | 8 | func TestTplFuncs(t *testing.T) { 9 | // "repeat": strings.Repeat, 10 | // "split": strings.Split, 11 | // "strAppend": func(arr []string, item string) []string { 12 | // return append(arr, item) 13 | // }, 14 | // "join": strings.Join, 15 | // "strSlice": func() []string { 16 | // return make([]string, 0) 17 | // }, 18 | // "list": func(args ...any) []any { 19 | // return args 20 | // }, 21 | // "sum": func(args ...int) int { 22 | // var sum int 23 | // for _, v := range args { 24 | // sum += v 25 | // } 26 | // return sum 27 | // }, 28 | t.Run("repeat", func(t *testing.T) { 29 | f := tplFuncs["repeat"].(func(string, int) string) 30 | if f("a", 3) != "aaa" { 31 | t.Error("repeat failed") 32 | } 33 | }) 34 | t.Run("split", func(t *testing.T) { 35 | f := tplFuncs["split"].(func(string, string) []string) 36 | if f("a,b,c", ",") == nil { 37 | t.Error("split failed") 38 | } 39 | }) 40 | t.Run("strAppend", func(t *testing.T) { 41 | f := tplFuncs["strAppend"].(func([]string, string) []string) 42 | if !slices.Equal(f([]string{"a"}, "b"), []string{"a", "b"}) { 43 | t.Error("strAppend failed") 44 | } 45 | }) 46 | t.Run("join", func(t *testing.T) { 47 | f := tplFuncs["join"].(func([]string, string) string) 48 | if f([]string{"a", "b"}, ",") != "a,b" { 49 | t.Error("join failed") 50 | } 51 | }) 52 | t.Run("strSlice", func(t *testing.T) { 53 | f := tplFuncs["strSlice"].(func() []string) 54 | if f() == nil { 55 | t.Error("strSlice failed") 56 | } 57 | }) 58 | t.Run("list", func(t *testing.T) { 59 | f := tplFuncs["list"].(func(...any) []any) 60 | lst := f(1, 2, 3) 61 | for i, v := range lst { 62 | if v != i+1 { 63 | t.Error("list failed") 64 | } 65 | } 66 | }) 67 | t.Run("sum", func(t *testing.T) { 68 | f := tplFuncs["sum"].(func(...int) int) 69 | if f(1, 2, 3) != 6 { 70 | t.Error("sum failed") 71 | } 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /resolver/resolver.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/g4s8/envdoc/ast" 8 | ) 9 | 10 | type typeQualifier struct { 11 | pkg string 12 | name string 13 | } 14 | 15 | type TypeResolver struct { 16 | types map[typeQualifier]*ast.TypeSpec 17 | } 18 | 19 | func NewTypeResolver() *TypeResolver { 20 | return &TypeResolver{ 21 | types: make(map[typeQualifier]*ast.TypeSpec), 22 | } 23 | } 24 | 25 | func (r *TypeResolver) AddTypes(pkg string, types []*ast.TypeSpec) { 26 | for _, t := range types { 27 | r.types[typeQualifier{pkg: pkg, name: t.Name}] = t 28 | } 29 | } 30 | 31 | func (r *TypeResolver) Resolve(ref *ast.FieldTypeRef) *ast.TypeSpec { 32 | return r.types[typeQualifier{pkg: ref.Pkg, name: ref.Name}] 33 | } 34 | 35 | func ResolveAllTypes(files []*ast.FileSpec) *TypeResolver { 36 | r := NewTypeResolver() 37 | for _, f := range files { 38 | pkg := f.Pkg 39 | r.AddTypes(pkg, f.Types) 40 | } 41 | return r 42 | } 43 | 44 | func (r *TypeResolver) Debug(out io.Writer) { 45 | fmt.Fprintln(out, "Resolved types:") 46 | for k, v := range r.types { 47 | fmt.Fprintf(out, " %s.%s: %q (export=%t)\n", 48 | k.pkg, k.name, v.Name, v.Export) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /resolver/resolver_test.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/g4s8/envdoc/ast" 7 | ) 8 | 9 | func TestResolver(t *testing.T) { 10 | res := ResolveAllTypes([]*ast.FileSpec{ 11 | { 12 | Pkg: "main", 13 | Types: []*ast.TypeSpec{ 14 | { 15 | Name: "Foo", 16 | Export: true, 17 | }, 18 | { 19 | Name: "Bar", 20 | Export: false, 21 | }, 22 | }, 23 | }, 24 | { 25 | Pkg: "test", 26 | Types: []*ast.TypeSpec{ 27 | { 28 | Name: "Baz", 29 | Export: true, 30 | }, 31 | }, 32 | }, 33 | }) 34 | foo := res.Resolve(&ast.FieldTypeRef{Pkg: "main", Name: "Foo"}) 35 | if foo == nil { 36 | t.Fatalf("Foo type not resolved") 37 | } 38 | if foo.Name != "Foo" { 39 | t.Errorf("Invalid Foo type: %s", foo.Name) 40 | } 41 | 42 | bar := res.Resolve(&ast.FieldTypeRef{Pkg: "main", Name: "Bar"}) 43 | if bar == nil { 44 | t.Fatalf("Bar type not resolved") 45 | } 46 | if bar != nil && bar.Name != "Bar" { 47 | t.Errorf("Invalid Bar type: %s", bar.Name) 48 | } 49 | 50 | baz := res.Resolve(&ast.FieldTypeRef{Pkg: "test", Name: "Baz"}) 51 | if baz == nil { 52 | t.Fatalf("Baz type not resolved") 53 | } 54 | if baz.Name != "Baz" { 55 | t.Errorf("Invalid Baz type: %s", baz.Name) 56 | } 57 | 58 | nope := res.Resolve(&ast.FieldTypeRef{Pkg: "test", Name: "Nope"}) 59 | if nope != nil { 60 | t.Errorf("Nope type resolved, but it should not") 61 | } 62 | 63 | wrongPgk := res.Resolve(&ast.FieldTypeRef{Pkg: "main", Name: "Baz"}) 64 | if wrongPgk != nil { 65 | t.Errorf("Baz type resolved, but it should not") 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tags/parser.go: -------------------------------------------------------------------------------- 1 | package tags 2 | 3 | import "strings" 4 | 5 | type FieldTag map[string][]string 6 | 7 | func ParseFieldTag(tag string) FieldTag { 8 | t := make(FieldTag) 9 | for _, fields := range strings.Fields(tag) { 10 | if !strings.Contains(fields, ":") { 11 | continue 12 | } 13 | parts := strings.Split(fields, ":") 14 | key := parts[0] 15 | if vals := fieldTagValues(tag, key); vals != nil { 16 | t[key] = vals 17 | } 18 | } 19 | return t 20 | } 21 | 22 | func (t FieldTag) GetAll(key string) []string { 23 | return t[key] 24 | } 25 | 26 | func (t FieldTag) GetFirst(key string) (string, bool) { 27 | if len(t[key]) == 0 { 28 | return "", false 29 | } 30 | return t[key][0], true 31 | } 32 | 33 | func fieldTagValues(tag, tagName string) []string { 34 | tagPrefix := tagName + ":" 35 | if !strings.Contains(tag, tagPrefix) { 36 | return nil 37 | } 38 | tagValue := strings.Split(tag, tagPrefix)[1] 39 | leftQ := strings.Index(tagValue, `"`) 40 | if leftQ == -1 || leftQ == len(tagValue)-1 { 41 | return nil 42 | } 43 | rightQ := strings.Index(tagValue[leftQ+1:], `"`) 44 | if rightQ == -1 { 45 | return nil 46 | } 47 | tagValue = tagValue[leftQ+1 : leftQ+rightQ+1] 48 | return strings.Split(tagValue, ",") 49 | } 50 | -------------------------------------------------------------------------------- /tags/tags_test.go: -------------------------------------------------------------------------------- 1 | package tags 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "testing" 7 | ) 8 | 9 | func tagShouldErr(t *testing.T, tag string) { 10 | t.Helper() 11 | if len(ParseFieldTag(tag)) != 0 { 12 | t.Errorf("expected empty, got %v", tag) 13 | } 14 | } 15 | 16 | func TestFieldTags(t *testing.T) { 17 | const src = `env:"PASSWORD,required,file" envDefault:"/tmp/password" json:"password"` 18 | tag := ParseFieldTag(src) 19 | expectAll := map[string][]string{ 20 | "env": {"PASSWORD", "required", "file"}, 21 | "envDefault": {"/tmp/password"}, 22 | "json": {"password"}, 23 | } 24 | for k, v := range expectAll { 25 | if got := tag.GetAll(k); !slices.Equal(got, v) { 26 | t.Errorf("%q: expected %q, got %q", k, v, got) 27 | } 28 | } 29 | 30 | expectOne := map[string]string{ 31 | "env": "PASSWORD", 32 | "envDefault": "/tmp/password", 33 | "json": "password", 34 | } 35 | for k, v := range expectOne { 36 | if got, ok := tag.GetFirst(k); !ok || got != v { 37 | t.Errorf("%q: expected %q, got %q", k, v, got) 38 | } 39 | } 40 | 41 | unexpectedKeys := []string{"yaml", "xml"} 42 | for _, k := range unexpectedKeys { 43 | if got := tag.GetAll(k); len(got) != 0 { 44 | t.Errorf("%q: expected empty, got %v", k, got) 45 | } 46 | if got, ok := tag.GetFirst(k); ok { 47 | t.Errorf("%q: expected empty, got %v", k, got) 48 | } 49 | } 50 | 51 | t.Run("error", func(t *testing.T) { 52 | tagShouldErr(t, `envPASSWORD`) 53 | tagShouldErr(t, `env:"PASSWORD`) 54 | tagShouldErr(t, `env:PASSWORD"`) 55 | }) 56 | } 57 | 58 | func TestFieldTagValues(t *testing.T) { 59 | tests := []struct { 60 | tag, key string 61 | expect []string 62 | err bool 63 | }{ 64 | { 65 | tag: `env:"PASSWORD,required,file"`, 66 | key: "env", 67 | expect: []string{"PASSWORD", "required", "file"}, 68 | }, 69 | { 70 | tag: `envDefault:"/tmp/password"`, 71 | key: "envDefault", 72 | expect: []string{"/tmp/password"}, 73 | }, 74 | { 75 | tag: `json:"password"`, 76 | key: "json", 77 | expect: []string{"password"}, 78 | }, 79 | { 80 | tag: `jsonpassword`, 81 | key: "json", 82 | err: true, 83 | }, 84 | { 85 | tag: `json:"password`, 86 | key: "env", 87 | err: true, 88 | }, 89 | { 90 | tag: `env:PASSWORD"`, 91 | key: "env", 92 | err: true, 93 | }, 94 | { 95 | tag: `env:"PASSWORD`, 96 | key: "env", 97 | err: true, 98 | }, 99 | } 100 | for i, test := range tests { 101 | t.Run(fmt.Sprintf("case%d", i), func(t *testing.T) { 102 | vals := fieldTagValues(test.tag, test.key) 103 | if test.err { 104 | if vals != nil { 105 | t.Errorf("expected nil, got %v", vals) 106 | } 107 | return 108 | } 109 | 110 | if !slices.Equal(vals, test.expect) { 111 | t.Errorf("expected %v, got %v", test.expect, vals) 112 | } 113 | }) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /testdata/embedded.txtar: -------------------------------------------------------------------------------- 1 | Success: embedded fields. 2 | TypeName: Config 3 | 4 | -- src.go -- 5 | package main 6 | 7 | import "time" 8 | 9 | type Embedded struct { 10 | // Foo field. 11 | Foo string `env:"FOO"` 12 | } 13 | 14 | type TypeDef string 15 | 16 | type Bar struct { 17 | TypeDef 18 | } 19 | 20 | // Config doc. 21 | type Config struct { 22 | Embedded 23 | // Bar field. 24 | Bar Bar `env:"BAR"` 25 | } 26 | 27 | -- expect.txt -- 28 | Environment Variables 29 | 30 | ## Config 31 | 32 | Config doc. 33 | 34 | * `FOO` - Foo field. 35 | * `BAR` - Bar field. 36 | 37 | -------------------------------------------------------------------------------- /testdata/envprefix.txtar: -------------------------------------------------------------------------------- 1 | Success: Structs with env-prefix 2 | TypeName: Settings 3 | EnvPrefix: X_ 4 | 5 | -- src.go -- 6 | package main 7 | 8 | // Settings is the application settings. 9 | type Settings struct { 10 | // Database is the database settings 11 | Database Database `envPrefix:"DB_"` 12 | 13 | // Server is the server settings 14 | Server ServerConfig `envPrefix:"SERVER_"` 15 | 16 | // Debug is the debug flag 17 | Debug bool `env:"DEBUG"` 18 | } 19 | 20 | // Database is the database settings. 21 | type Database struct { 22 | // Port is the port to connect to 23 | Port Int `env:"PORT,required"` 24 | // Host is the host to connect to 25 | Host string `env:"HOST,notEmpty" envDefault:"localhost"` 26 | // User is the user to connect as 27 | User string `env:"USER"` 28 | // Password is the password to use 29 | Password string `env:"PASSWORD"` 30 | // DisableTLS is the flag to disable TLS 31 | DisableTLS bool `env:"DISABLE_TLS"` 32 | } 33 | 34 | // ServerConfig is the server settings. 35 | type ServerConfig struct { 36 | // Port is the port to listen on 37 | Port Int `env:"PORT,required"` 38 | 39 | // Host is the host to listen on 40 | Host string `env:"HOST,notEmpty" envDefault:"localhost"` 41 | 42 | // Timeout is the timeout settings 43 | Timeout TimeoutConfig `envPrefix:"TIMEOUT_"` 44 | } 45 | 46 | // TimeoutConfig is the timeout settings. 47 | type TimeoutConfig struct { 48 | // Read is the read timeout 49 | Read Int `env:"READ" envDefault:"30"` 50 | // Write is the write timeout 51 | Write Int `env:"WRITE" envDefault:"30"` 52 | } 53 | 54 | -- expect.txt -- 55 | Environment Variables 56 | 57 | ## Settings 58 | 59 | Settings is the application settings. 60 | 61 | * `X_DB_PORT` (required) - Port is the port to connect to 62 | * `X_DB_HOST` (required, non-empty, default: `localhost`) - Host is the host to connect to 63 | * `X_DB_USER` - User is the user to connect as 64 | * `X_DB_PASSWORD` - Password is the password to use 65 | * `X_DB_DISABLE_TLS` - DisableTLS is the flag to disable TLS 66 | * `X_SERVER_PORT` (required) - Port is the port to listen on 67 | * `X_SERVER_HOST` (required, non-empty, default: `localhost`) - Host is the host to listen on 68 | * `X_SERVER_TIMEOUT_READ` (default: `30`) - Read is the read timeout 69 | * `X_SERVER_TIMEOUT_WRITE` (default: `30`) - Write is the write timeout 70 | * `X_DEBUG` - Debug is the debug flag 71 | 72 | -------------------------------------------------------------------------------- /testdata/field-names.txtar: -------------------------------------------------------------------------------- 1 | Success: using field names as env vars 2 | FieldNames: true 3 | 4 | -- src.go -- 5 | package main 6 | 7 | // FieldNames uses field names as env names. 8 | type FieldNames struct { 9 | // Foo is a single field. 10 | Foo string 11 | // Bar and Baz are two fields. 12 | Bar, Baz string 13 | // Quux is a field with a tag. 14 | Quux string `env:"QUUX"` 15 | // FooBar is a field with a default value. 16 | FooBar string `envDefault:"quuux"` 17 | } 18 | 19 | -- expect.txt -- 20 | Environment Variables 21 | 22 | ## FieldNames 23 | 24 | FieldNames uses field names as env names. 25 | 26 | * `FOO` - Foo is a single field. 27 | * `BAR` - Bar and Baz are two fields. 28 | * `BAZ` - Bar and Baz are two fields. 29 | * `QUUX` - Quux is a field with a tag. 30 | * `FOO_BAR` (default: `quuux`) - FooBar is a field with a default value. 31 | 32 | -------------------------------------------------------------------------------- /testdata/simple.txtar: -------------------------------------------------------------------------------- 1 | Success: simple test case 2 | 3 | -- src.go -- 4 | package main 5 | 6 | // Config is an example configuration structure. 7 | // It is used to generate documentation for the configuration 8 | // using the commands below. 9 | type Config struct { 10 | // Hosts name of hosts to listen on. 11 | Hosts []string `env:"HOST,required", envSeparator:";"` 12 | // Port to listen on. 13 | Port int `env:"PORT,notEmpty"` 14 | 15 | // Debug mode enabled. 16 | Debug bool `env:"DEBUG" envDefault:"false"` 17 | 18 | // Prefix for something. 19 | Prefix string `env:"PREFIX"` 20 | } 21 | 22 | -- expect.txt -- 23 | Environment Variables 24 | 25 | ## Config 26 | 27 | Config is an example configuration structure. 28 | It is used to generate documentation for the configuration 29 | using the commands below. 30 | 31 | * `HOST` (separated by `;`, required) - Hosts name of hosts to listen on. 32 | * `PORT` (required, non-empty) - Port to listen on. 33 | * `DEBUG` (default: `false`) - Debug mode enabled. 34 | * `PREFIX` - Prefix for something. 35 | 36 | -------------------------------------------------------------------------------- /testfile.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "sync/atomic" 4 | 5 | type Foo struct { 6 | X atomic.Bool 7 | T bool 8 | F func(int) string 9 | } 10 | -------------------------------------------------------------------------------- /testutils/assert.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import "testing" 4 | 5 | func AssertFatal(t *testing.T, ok bool, format string, args ...interface{}) { 6 | t.Helper() 7 | if !ok { 8 | t.Fatalf(format, args...) 9 | } 10 | } 11 | 12 | func AssertError(t *testing.T, ok bool, format string, args ...interface{}) { 13 | t.Helper() 14 | if !ok { 15 | t.Errorf(format, args...) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /types/model.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "fmt" 4 | 5 | // OutFormat is an output format for the documentation. 6 | type OutFormat string 7 | 8 | const ( 9 | OutFormatMarkdown OutFormat = "markdown" 10 | OutFormatHTML OutFormat = "html" 11 | OutFormatTxt OutFormat = "plaintext" 12 | OutFormatEnv OutFormat = "dotenv" 13 | OutFormatJSON OutFormat = "json" 14 | ) 15 | 16 | // EnvDocItem is a documentation item for one environment variable. 17 | type EnvDocItem struct { 18 | // Name of the environment variable. 19 | Name string 20 | // Doc is a documentation text for the environment variable. 21 | Doc string 22 | // Opts is a set of options for environment variable parsing. 23 | Opts EnvVarOptions 24 | // Children is a list of child environment variables. 25 | Children []*EnvDocItem 26 | } 27 | 28 | type EnvScope struct { 29 | // Name of the scope. 30 | Name string 31 | // Doc is a documentation text for the scope. 32 | Doc string 33 | // Vars is a list of environment variables. 34 | Vars []*EnvDocItem 35 | } 36 | 37 | // EnvVarOptions is a set of options for environment variable parsing. 38 | type EnvVarOptions struct { 39 | // Separator is a separator for array types. 40 | Separator string 41 | // Required is a flag that enables required check. 42 | Required bool 43 | // Expand is a flag that enables environment variable expansion. 44 | Expand bool 45 | // NonEmpty is a flag that enables non-empty check. 46 | NonEmpty bool 47 | // FromFile is a flag that enables reading environment variable from a file. 48 | FromFile bool 49 | // Default is a default value for the environment variable. 50 | Default string 51 | } 52 | 53 | // TargetType is an env library target. 54 | // 55 | //go:generate stringer -type=TargetType 56 | type TargetType int 57 | 58 | const ( 59 | TargetTypeCaarlos0 TargetType = iota 60 | TargetTypeCleanenv 61 | ) 62 | 63 | func ParseTargetType(s string) (TargetType, error) { 64 | switch s { 65 | case "caarlos0": 66 | return TargetTypeCaarlos0, nil 67 | case "cleanenv": 68 | return TargetTypeCleanenv, nil 69 | default: 70 | return 0, fmt.Errorf("unknown target type: %s", s) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /types/targettype_string.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=TargetType"; DO NOT EDIT. 2 | 3 | package types 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[TargetTypeCaarlos0-0] 12 | _ = x[TargetTypeCleanenv-1] 13 | } 14 | 15 | const _TargetType_name = "TargetTypeCaarlos0TargetTypeCleanenv" 16 | 17 | var _TargetType_index = [...]uint8{0, 18, 36} 18 | 19 | func (i TargetType) String() string { 20 | if i < 0 || i >= TargetType(len(_TargetType_index)-1) { 21 | return "TargetType(" + strconv.FormatInt(int64(i), 10) + ")" 22 | } 23 | return _TargetType_name[_TargetType_index[i]:_TargetType_index[i+1]] 24 | } 25 | -------------------------------------------------------------------------------- /utils/caseconv.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | "unicode" 6 | "unicode/utf8" 7 | ) 8 | 9 | func CamelToSnake(s string) string { 10 | const underscore = '_' 11 | var result strings.Builder 12 | result.Grow(len(s) + 5) 13 | 14 | var buf [utf8.UTFMax]byte 15 | var prev rune 16 | var pos int 17 | for i, r := range s { 18 | pos += utf8.EncodeRune(buf[:], r) 19 | // read next rune 20 | var next rune 21 | if pos < len(s) { 22 | next, _ = utf8.DecodeRuneInString(s[pos:]) 23 | } 24 | if i > 0 && prev != underscore && r != underscore && unicode.IsUpper(r) && (unicode.IsLower(next)) { 25 | result.WriteRune(underscore) 26 | } 27 | result.WriteRune(unicode.ToUpper(r)) 28 | prev = r 29 | } 30 | 31 | return result.String() 32 | } 33 | -------------------------------------------------------------------------------- /utils/glob.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | 7 | "github.com/gobwas/glob" 8 | ) 9 | 10 | // un-escape -types and -files globs: '*' -> *, "foo" -> foo 11 | // if first and last characters are quotes, remove them. 12 | func UnescapeGlob(s string) string { 13 | if len(s) >= 2 && ((s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'')) { 14 | return s[1 : len(s)-1] 15 | } 16 | return s 17 | } 18 | 19 | func NewGlobMatcher(ptn string) (func(string) bool, error) { 20 | g, err := glob.Compile(ptn) 21 | if err != nil { 22 | return nil, fmt.Errorf("inalid glob pattern: %w", err) 23 | } 24 | return g.Match, nil 25 | } 26 | 27 | func NewGlobFileMatcher(ptn string) (func(fs.FileInfo) bool, error) { 28 | m, err := NewGlobMatcher(ptn) 29 | if err != nil { 30 | return nil, err 31 | } 32 | return func(fi fs.FileInfo) bool { 33 | return m(fi.Name()) 34 | }, nil 35 | } 36 | -------------------------------------------------------------------------------- /utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io/fs" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | type fakeFileInfo struct { 10 | name string 11 | } 12 | 13 | func (fi fakeFileInfo) Name() string { 14 | return fi.name 15 | } 16 | 17 | func (fi fakeFileInfo) Size() int64 { 18 | panic("Size() not implemented") 19 | } 20 | 21 | func (fi fakeFileInfo) Mode() fs.FileMode { 22 | panic("Mode() not implemented") 23 | } 24 | 25 | func (fi fakeFileInfo) ModTime() time.Time { 26 | panic("ModTime() not implemented") 27 | } 28 | 29 | func (fi fakeFileInfo) IsDir() bool { 30 | panic("IsDir() not implemented") 31 | } 32 | 33 | func (fi fakeFileInfo) Sys() interface{} { 34 | panic("Sys() not implemented") 35 | } 36 | 37 | func globTesteer(matcher func(string) bool, targets map[string]bool) func(*testing.T) { 38 | return func(t *testing.T) { 39 | for target, expected := range targets { 40 | if matcher(target) != expected { 41 | t.Errorf("unexpected result for %q: got %v, want %v", target, !expected, expected) 42 | } 43 | } 44 | } 45 | } 46 | 47 | var globTestTargets = map[string]bool{ 48 | "main.go": true, 49 | "main_test.go": true, 50 | "utils.go": true, 51 | "utils_test.java": false, 52 | "file.txt": false, 53 | "test.go.txt": false, 54 | "cfg/Config.go": true, 55 | } 56 | 57 | func TestGlobMatcher(t *testing.T) { 58 | m, err := NewGlobMatcher("*.go") 59 | if err != nil { 60 | t.Fatalf("unexpected error: %v", err) 61 | } 62 | 63 | t.Run("match", globTesteer(m, globTestTargets)) 64 | 65 | t.Run("error", func(t *testing.T) { 66 | _, err := NewGlobMatcher("[") 67 | if err == nil { 68 | t.Fatalf("expected error but got nil") 69 | } 70 | }) 71 | } 72 | 73 | func TestGlobFileMatcher(t *testing.T) { 74 | m, err := NewGlobFileMatcher("*.go") 75 | if err != nil { 76 | t.Fatalf("unexpected error: %v", err) 77 | } 78 | 79 | fileWrapper := func(name string) bool { 80 | fi := fakeFileInfo{name} 81 | return m(fi) 82 | } 83 | 84 | t.Run("match", globTesteer(fileWrapper, globTestTargets)) 85 | t.Run("error", func(t *testing.T) { 86 | _, err := NewGlobFileMatcher("[") 87 | if err == nil { 88 | t.Fatalf("expected error but got nil") 89 | } 90 | }) 91 | } 92 | 93 | func TestCamelToSnake(t *testing.T) { 94 | tests := map[string]string{ 95 | "CamelCase": "CAMEL_CASE", 96 | "camelCase": "CAMEL_CASE", 97 | "camel": "CAMEL", 98 | "Camel": "CAMEL", 99 | "camel_case": "CAMEL_CASE", 100 | "camel_case_": "CAMEL_CASE_", 101 | "camel_case__": "CAMEL_CASE__", 102 | "camelCase_": "CAMEL_CASE_", 103 | "camelCase__": "CAMEL_CASE__", 104 | "camel_case__snake": "CAMEL_CASE__SNAKE", 105 | "": "", 106 | " ": " ", 107 | "_": "_", 108 | "_A_": "_A_", 109 | "ABBRFoo": "ABBR_FOO", 110 | "FOO_BAR": "FOO_BAR", 111 | "ЮниКод": "ЮНИ_КОД", 112 | "ՅունիԿոդ": "ՅՈՒՆԻ_ԿՈԴ", 113 | } 114 | 115 | for input, expected := range tests { 116 | if got := CamelToSnake(input); got != expected { 117 | t.Errorf("unexpected result for %q: got %q, want %q", input, got, expected) 118 | } 119 | } 120 | } 121 | 122 | func TestUnescapeGlob(t *testing.T) { 123 | tests := map[string]string{ 124 | `"foo"`: `foo`, 125 | `"foo`: `"foo`, 126 | `foo"`: `foo"`, 127 | `foo`: `foo`, 128 | `'foo'`: `foo`, 129 | `'foo`: `'foo`, 130 | `foo'`: `foo'`, 131 | `*`: `*`, 132 | `*foo*`: `*foo*`, 133 | `'*'`: `*`, 134 | ``: ``, 135 | } 136 | 137 | for input, expected := range tests { 138 | if got := UnescapeGlob(input); got != expected { 139 | t.Errorf("unexpected result for %q: got %q, want %q", input, got, expected) 140 | } 141 | } 142 | } 143 | --------------------------------------------------------------------------------