├── .all-contributorsrc ├── .github └── dependabot.yml ├── .gitignore ├── .goreleaser.yml ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── deployments ├── aur │ └── PKGBUILD └── npm │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── index.js │ └── package.json ├── go.mod ├── go.sum ├── internal ├── ast │ └── ast.go ├── config │ └── config.go ├── exec │ └── exec.go ├── lexer │ ├── lexer.go │ ├── matcher.go │ ├── runereader.go │ ├── runes.go │ └── tokens.go ├── parser │ └── parser.go ├── runfile │ ├── command.go │ ├── runfile.go │ └── scope.go └── util │ └── util.go ├── main.go └── version.go /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "chabad360", 10 | "name": "chabad360", 11 | "avatar_url": "https://avatars2.githubusercontent.com/u/1668291?v=4", 12 | "profile": "http://chabad360.me", 13 | "contributions": [ 14 | "doc", 15 | "infra", 16 | "bug" 17 | ] 18 | }, 19 | { 20 | "login": "dawidd6", 21 | "name": "Dawid Dziurla", 22 | "avatar_url": "https://avatars0.githubusercontent.com/u/9713907?v=4", 23 | "profile": "https://github.com/dawidd6", 24 | "contributions": [ 25 | "infra" 26 | ] 27 | }, 28 | { 29 | "login": "rwhogg", 30 | "name": "Bob \"Wombat\" Hogg", 31 | "avatar_url": "https://avatars3.githubusercontent.com/u/2373856?v=4", 32 | "profile": "https://github.com/rwhogg", 33 | "contributions": [ 34 | "doc" 35 | ] 36 | }, 37 | { 38 | "login": "Gys", 39 | "name": "Gys", 40 | "avatar_url": "https://avatars0.githubusercontent.com/u/943251?v=4", 41 | "profile": "https://github.com/Gys", 42 | "contributions": [ 43 | "bug" 44 | ] 45 | }, 46 | { 47 | "login": "rburchell", 48 | "name": "Robin Burchell", 49 | "avatar_url": "https://avatars.githubusercontent.com/u/125863?v=4", 50 | "profile": "https://crimson.no", 51 | "contributions": [ 52 | "code" 53 | ] 54 | } 55 | ], 56 | "contributorsPerLine": 7, 57 | "projectName": "run", 58 | "projectOwner": "TekWizely", 59 | "repoType": "github", 60 | "repoHost": "https://github.com", 61 | "skipCi": true 62 | } 63 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | version: 2 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "sunday" 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ============================================================================== 2 | # Project-Specific 3 | # ============================================================================== 4 | 5 | # Output of `go build` 6 | run 7 | 8 | # Output of `GOBIN="$PWD/bin" go install` 9 | bin/ 10 | 11 | # Testing Runfile(s) 12 | Runfile 13 | runfile.sh 14 | *.run 15 | *.rf 16 | *.env 17 | 18 | # ============================================================================== 19 | # Build Assets 20 | # ============================================================================== 21 | 22 | # GoReleaser 23 | dist/ 24 | 25 | # ============================================================================== 26 | # GoLang 27 | # ============================================================================== 28 | 29 | # Binaries for programs and plugins 30 | *.exe 31 | *.exe~ 32 | *.dll 33 | *.so 34 | *.dylib 35 | 36 | # Test binary, built with `go test -c` 37 | *.test 38 | 39 | # Output of the go coverage tool 40 | *.out 41 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # See documentation 2 | # 3 | # http://goreleaser.com 4 | # 5 | # Usage: 6 | # 7 | # goreleaser release --clean --skip=publish [--snapshot] 8 | # 9 | version: 2 10 | project_name: run 11 | env: 12 | - GO111MODULE=on 13 | before: 14 | hooks: 15 | - go mod tidy 16 | # - go generate ./... 17 | builds: 18 | - env: 19 | - CGO_ENABLED=0 20 | binary: run 21 | ldflags: 22 | - -s -w -X "main.Version={{.Version}}" -X "main.BuildDate={{.Date}}" -X "main.GitSummary={{.Summary}}" -X "main.BuildTool=goreleaser" 23 | goos: 24 | - darwin 25 | - linux 26 | goarch: 27 | - amd64 28 | - arm64 29 | # ignore: 30 | # - goos: linux 31 | # goarch: arm64 32 | checksum: 33 | name_template: 'checksums.txt' 34 | snapshot: 35 | version_template: "{{ .Version }}" 36 | changelog: 37 | sort: desc 38 | filters: 39 | exclude: 40 | - '^docs:' 41 | - '^test:' 42 | nfpms: 43 | - 44 | maintainer: TekWizely 45 | homepage: https://github.com/TekWizely/run 46 | description: "Task runner that helps you easily manage and invoke small scripts and wrappers." 47 | license: MIT 48 | formats: 49 | - deb 50 | - rpm 51 | - apk 52 | aurs: 53 | - 54 | skip_upload: "true" 55 | maintainers: 56 | - TekWizely 57 | homepage: https://github.com/TekWizely/run 58 | description: "Task runner that helps you easily manage and invoke small scripts and wrappers." 59 | license: MIT 60 | brews: 61 | - 62 | skip_upload: "true" 63 | # tap: 64 | # owner: Tekwizely 65 | # name: homebrew-tap 66 | # folder: Formula 67 | url_template: "https://github.com/TekWizely/run/archive/{{ .Version }}.tar.gz" 68 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 69 | license: MIT 70 | homepage: https://github.com/TekWizely/run 71 | description: "Task runner that helps you easily manage and invoke small scripts and wrappers." 72 | dependencies: 73 | - name: go 74 | type: build 75 | # install: | 76 | # system "go", "build", "-trimpath", "-ldflags", "-w -s -X \"main.BuildTool=brew via tekwizely/tap/run\"", "-o", bin/name 77 | test: | 78 | text = "Hello Homebrew!" 79 | task = "hello" 80 | (testpath/"Runfile").write <<~EOS 81 | #{task}: 82 | echo #{text} 83 | EOS 84 | assert_equal text, shell_output("#{bin}/#{name} #{task}").chomp 85 | 86 | release: 87 | disable: true 88 | draft: true 89 | github: 90 | owner: TekWizely 91 | name: run 92 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # ============================================================================== 2 | # Pre-commit hook configuration 3 | # See https://pre-commit.com for more information 4 | # See https://pre-commit.com/hooks.html for more hooks 5 | # ============================================================================== 6 | repos: 7 | 8 | # ========================================================================== 9 | # Built-in hooks 10 | # ========================================================================== 11 | - repo: git://github.com/pre-commit/pre-commit-hooks 12 | rev: v4.4.0 13 | hooks: 14 | - id: check-merge-conflict 15 | - id: check-yaml 16 | - id: end-of-file-fixer 17 | exclude: 'PKGBUILD' 18 | - id: trailing-whitespace 19 | 20 | # ========================================================================== 21 | # Golang Pre-Commit Hooks | https://github.com/tekwizely/pre-commit-golang 22 | # 23 | # Visit the project home page to learn more about the available Hooks, 24 | # including useful arguments you might want to pass into them. 25 | # 26 | # Staged Files: 27 | # Unless configured to "always_run" (see below), hooks ONLY run when 28 | # matching file types (usually *.go) are staged. 29 | # 30 | # File-Based Hooks: 31 | # By default, hooks run against matching staged files individually. 32 | # Currently, file-based hooks DO NOT accept user-args. 33 | # 34 | # Repo-Based Hooks: 35 | # Hooks named '*-repo-*' only run once (if any matching files are staged). 36 | # They are NOT provided the list of staged files. 37 | # Generally, repo-based hooks DO accept user-args. 38 | # 39 | # Repo-Hook Suffixes: 40 | # *-repo : Hook runs with no target argument 41 | # (good for adding custom arguments / targets) 42 | # *-repo-dir : Hook runs using './...' as target. 43 | # *-repo-pkg : Hook runs using '$(go list)/...' as target. 44 | # 45 | # Fix Suffix: 46 | # Hooks named `*-fix` fix (modify) files directly, when possible. 47 | # 48 | # Aliases: 49 | # Consider adding aliases to longer-named hooks for easier CLI usage. 50 | # 51 | # Useful Hook Parameters: 52 | # - id: hook-id 53 | # alias: hook-alias # Create an alias 54 | # args: [arg1, arg2, ...] # Pass arguments 55 | # always_run: true # Run even if no matching files staged 56 | # ========================================================================== 57 | - repo: https://github.com/tekwizely/pre-commit-golang 58 | rev: v1.0.0-rc.1 59 | hooks: 60 | - id: go-build-mod 61 | alias: build 62 | - id: go-test-mod 63 | alias: test 64 | - id: go-vet-mod 65 | alias: vet 66 | - id: go-sec-mod 67 | alias: sec 68 | args: ['-exclude=G204'] 69 | # - id: go-fmt 70 | # - id: go-imports # replaces go-fmt 71 | - id: go-returns # replaces go-imports & go-fmt 72 | alias: fmt 73 | args: [-d=false, -w] 74 | 75 | - id: go-lint 76 | alias: lint 77 | - id: go-critic 78 | alias: critic 79 | # 80 | # GolangCI-Lint 81 | # - Fast Multi-Linter 82 | # - Can be configured to replace MOST other hooks 83 | # - Supports repo config file for configuration 84 | # - https://github.com/golangci/golangci-lint 85 | # 86 | - id: golangci-lint-mod 87 | alias: ci 88 | args: ['-D=deadcode,unused', --fix] 89 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 TekWizely 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 | -------------------------------------------------------------------------------- /deployments/aur/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Mendel Greenberg 2 | 3 | pkgname=run-git 4 | _pkgname=run 5 | pkgver=v0.7.0.r8.g0d33f74 6 | pkgrel=1 7 | pkgdesc="Easily manage and invoke small scripts and wrappers" 8 | arch=('i686' 'x86_64') 9 | url="https://github.com/TekWizely/run" 10 | license=('MIT') 11 | provides=('run') 12 | makedepends=( 13 | 'go' 14 | 'git' 15 | ) 16 | source=("git+https://github.com/TekWizely/run.git") 17 | sha256sums=('SKIP') 18 | 19 | pkgver() { 20 | cd "${srcdir}/${_pkgname}" 21 | git describe --tags --long 2>/dev/null | sed 's/\([^-]*-g\)/r\1/;s/-/./g' 22 | } 23 | 24 | build(){ 25 | cd "${srcdir}/${_pkgname}" 26 | export GOCACHE="${srcdir}/cache" 27 | export GOPATH="${srcdir}/gopath" 28 | go mod vendor 29 | go build \ 30 | -mod=vendor \ 31 | -trimpath \ 32 | -ldflags "-extldflags $LDFLAGS" . 33 | } 34 | 35 | package(){ 36 | cd "${srcdir}/${_pkgname}" 37 | install -Dm755 run "${pkgdir}/usr/bin/run" 38 | install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" 39 | } -------------------------------------------------------------------------------- /deployments/npm/.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | node_modules 3 | package-lock.json 4 | unpacked_bin 5 | -------------------------------------------------------------------------------- /deployments/npm/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 TekWizely 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 | -------------------------------------------------------------------------------- /deployments/npm/README.md: -------------------------------------------------------------------------------- 1 | # Run: Easily manage and invoke small scripts and wrappers 2 | 3 | Do you find yourself using tools like `make` to manage non-build-related scripts? 4 | 5 | Build tools are great, but they are not optimized for general script management. 6 | 7 | Run aims to be better at managing small scripts and wrappers, while incorporating a familiar make-like syntax. 8 | -------------------------------------------------------------------------------- /deployments/npm/index.js: -------------------------------------------------------------------------------- 1 | var binwrap = require("binwrap"); 2 | var path = require("path"); 3 | 4 | var packageInfo = require(path.join(__dirname, "package.json")); 5 | var version = packageInfo.version; 6 | var root = "https://github.com/TekWizely/run/releases/download/v" + version 7 | module.exports = binwrap({ 8 | dirname: __dirname, 9 | binaries: [ 10 | "run" 11 | ], 12 | urls: { 13 | "darwin-x64": root + "/run_" + version + "_darwin_amd64.tar.gz", 14 | "darwin-arm64": root + "/run_" + version + "_darwin_arm64.tar.gz", 15 | "linux-x64": root + "/run_" + version + "_linux_amd64.tar.gz" 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /deployments/npm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tekwizely/run", 3 | "version": "0.11.3", 4 | "description": "Easily manage and invoke small scripts and wrappers", 5 | "homepage": "https://github.com/TekWizely/run", 6 | "license": "MIT", 7 | "author": "TekWizely ", 8 | "main": "index.js", 9 | "preferGlobal": true, 10 | "keywords": [ 11 | "Run", 12 | "Runfile", 13 | "Golang" 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/TekWizely/run.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/TekWizely/run/issues" 21 | }, 22 | "scripts": { 23 | "install": "binwrap-install", 24 | "prepare": "binwrap-prepare", 25 | "test": "binwrap-test", 26 | "prepublish": "npm test" 27 | }, 28 | "files": [ 29 | "bin", 30 | "index.js", 31 | "README.md", 32 | "LICENSE" 33 | ], 34 | "bin": { 35 | "run": "bin/run" 36 | }, 37 | "dependencies": { 38 | "binwrap": "^0.2.2" 39 | }, 40 | "devDependencies": {} 41 | } 42 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tekwizely/run 2 | 3 | go 1.13 4 | 5 | // To update: 6 | // 7 | // $ go get github.com/tekwizely/go-parsing/lexer@master 8 | // $ go get github.com/tekwizely/go-parsing/lexer/token@master 9 | // $ go get github.com/tekwizely/go-parsing/parser@master 10 | // 11 | require ( 12 | github.com/goreleaser/fileglob v1.3.0 13 | github.com/subosito/gotenv v1.6.0 14 | github.com/tekwizely/go-parsing/lexer v0.0.0-20210910181107-ed69a13f4d15 15 | github.com/tekwizely/go-parsing/lexer/token v0.0.0-20210910181107-ed69a13f4d15 16 | github.com/tekwizely/go-parsing/parser v0.0.0-20210910181107-ed69a13f4d15 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8= 2 | github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 7 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 8 | github.com/goreleaser/fileglob v1.3.0 h1:/X6J7U8lbDpQtBvGcwwPS6OpzkNVlVEsFUVRx9+k+7I= 9 | github.com/goreleaser/fileglob v1.3.0/go.mod h1:Jx6BoXv3mbYkEzwm9THo7xbr5egkAraxkGorbJb4RxU= 10 | github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= 11 | github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 15 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 16 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 17 | github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= 18 | github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 19 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 20 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 21 | github.com/tekwizely/go-parsing/lexer v0.0.0-20190714043513-9514494dd58a/go.mod h1:M1+qLv1DBvWnJVqjBkbGN49/c78ZrS6Js2ZJ3J6gEnA= 22 | github.com/tekwizely/go-parsing/lexer v0.0.0-20210910181107-ed69a13f4d15 h1:All3gqlRswSCOaeaQb8p7wMxdoK8w+z5T7CjWyiKq7o= 23 | github.com/tekwizely/go-parsing/lexer v0.0.0-20210910181107-ed69a13f4d15/go.mod h1:M1+qLv1DBvWnJVqjBkbGN49/c78ZrS6Js2ZJ3J6gEnA= 24 | github.com/tekwizely/go-parsing/lexer/token v0.0.0-20190714025745-8a1a69651c50/go.mod h1:hrGEp224LWZwYH1FrdvwQC2uMjZdX5MDsydAc5FnrXM= 25 | github.com/tekwizely/go-parsing/lexer/token v0.0.0-20210910181107-ed69a13f4d15 h1:lf+8rI/3cB1BSIdGhycmKQgpTzQ6iywEtr2ofK3N2Ps= 26 | github.com/tekwizely/go-parsing/lexer/token v0.0.0-20210910181107-ed69a13f4d15/go.mod h1:hrGEp224LWZwYH1FrdvwQC2uMjZdX5MDsydAc5FnrXM= 27 | github.com/tekwizely/go-parsing/parser v0.0.0-20210910181107-ed69a13f4d15 h1:Eyefrt0lw1dgErIKTJgpHKwFpHNCe8u8LpSfni75wV4= 28 | github.com/tekwizely/go-parsing/parser v0.0.0-20210910181107-ed69a13f4d15/go.mod h1:8HolNblTMzOKF+p5Fno0hLiAu2ykgl9Utsq036uEi8s= 29 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 30 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 31 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 32 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 33 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 34 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 35 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 36 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 37 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 38 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 39 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 40 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 41 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 42 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 43 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 48 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 49 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 50 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 51 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 52 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 53 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 54 | golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= 55 | golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 56 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 57 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 58 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 59 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 60 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 61 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 62 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 63 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 64 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 65 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 66 | -------------------------------------------------------------------------------- /internal/ast/ast.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/goreleaser/fileglob" 12 | "github.com/subosito/gotenv" 13 | "github.com/tekwizely/run/internal/config" 14 | "github.com/tekwizely/run/internal/exec" 15 | "github.com/tekwizely/run/internal/runfile" 16 | "github.com/tekwizely/run/internal/util" 17 | ) 18 | 19 | // ParseBytes is a conceit as we need a way for AST to invoke 20 | // parsers for include functionality. 21 | // 22 | var ParseBytes func(runfile []byte) *Ast = nil 23 | 24 | // ProcessAST processes an AST into a Runfile. 25 | // 26 | func ProcessAST(ast *Ast) *runfile.Runfile { 27 | rf := runfile.NewRunfile() 28 | // Seed attributes 29 | // 30 | rf.Scope.PutAttr(".SHELL", config.DefaultShell) 31 | rf.Scope.PutAttr(".RUN", config.RunBin) 32 | rf.Scope.PutAttr(".RUNFILE", config.RunfileAbs) 33 | rf.Scope.PutAttr(".RUNFILE.DIR", config.RunfileAbsDir) 34 | rf.Scope.PutAttr(".SELF", config.CurrentRunfileAbs) 35 | rf.Scope.PutAttr(".SELF.DIR", config.CurrentRunfileAbsDir) 36 | for _, n := range ast.nodes { 37 | n.Apply(rf) 38 | } 39 | return rf 40 | } 41 | 42 | // ProcessAstRunfile process an AST into an existing Runfile 43 | // Assumes Runfile created via ProcessAST 44 | // 45 | func ProcessAstRunfile(ast *Ast, rf *runfile.Runfile) { 46 | // Seed attributes 47 | // Save current values, restore before leaving 48 | // 49 | selfRunfileBak, _ := rf.Scope.GetAttr(".SELF") 50 | selfRunfileDirBak, _ := rf.Scope.GetAttr(".SELF.DIR") 51 | defer func() { 52 | rf.Scope.PutAttr(".SELF", selfRunfileBak) 53 | rf.Scope.PutAttr(".SELF.DIR", selfRunfileDirBak) 54 | }() 55 | rf.Scope.PutAttr(".SELF", config.CurrentRunfileAbs) 56 | rf.Scope.PutAttr(".SELF.DIR", config.CurrentRunfileAbsDir) 57 | for _, n := range ast.nodes { 58 | n.Apply(rf) 59 | } 60 | } 61 | 62 | // Ast is the root ast container. 63 | // 64 | type Ast struct { 65 | nodes []node 66 | } 67 | 68 | // Add adds a root level node to the ast. 69 | // 70 | func (a *Ast) Add(n node) { 71 | a.nodes = append(a.nodes, n) 72 | } 73 | 74 | // AddScopeNode adds a Scope Node to the ast, wrapping it. 75 | // 76 | func (a *Ast) AddScopeNode(n scopeNode) { 77 | a.nodes = append(a.nodes, &nodeScopeNode{node: n}) 78 | } 79 | 80 | // NewAST is a convenience method. 81 | // 82 | func NewAST() *Ast { 83 | a := &Ast{} 84 | return a 85 | } 86 | 87 | // node 88 | // 89 | type node interface { 90 | Apply(r *runfile.Runfile) 91 | } 92 | 93 | // scopeNode 94 | // 95 | type scopeNode interface { 96 | Apply(s *runfile.Scope) 97 | } 98 | 99 | // ScopeValueNode is a scope node that results in a string value. 100 | // 101 | type ScopeValueNode interface { 102 | Apply(s *runfile.Scope) string 103 | } 104 | 105 | // nodeScopeNode 106 | // 107 | type nodeScopeNode struct { 108 | node scopeNode 109 | } 110 | 111 | // Apply applies the node to the runfile. 112 | // 113 | func (a *nodeScopeNode) Apply(r *runfile.Runfile) { 114 | a.node.Apply(r.Scope) 115 | } 116 | 117 | // ScopeValueNodeList builds a single string from a list of value nodes. 118 | // 119 | type ScopeValueNodeList struct { 120 | Values []ScopeValueNode 121 | } 122 | 123 | // Apply applies the node to the scope. 124 | // 125 | func (a *ScopeValueNodeList) Apply(s *runfile.Scope) string { 126 | b := &strings.Builder{} 127 | if a != nil { 128 | for _, v := range a.Values { 129 | b.WriteString(v.Apply(s)) 130 | } 131 | } 132 | return b.String() 133 | } 134 | 135 | // NewScopeValueNodeList is a convenience method. 136 | // 137 | func NewScopeValueNodeList(value []ScopeValueNode) *ScopeValueNodeList { 138 | return &ScopeValueNodeList{Values: value} 139 | } 140 | 141 | // NewScopeValueNodeList1 is a convenience method for wrapping a single value node. 142 | // 143 | func NewScopeValueNodeList1(value ScopeValueNode) *ScopeValueNodeList { 144 | return &ScopeValueNodeList{Values: []ScopeValueNode{value}} 145 | } 146 | 147 | // ScopeVarExport exports a variable. 148 | // 149 | type ScopeVarExport struct { 150 | VarName string 151 | } 152 | 153 | // NewVarExport is a convenience method for exporting variables. 154 | // 155 | func NewVarExport(varName string) *ScopeVarExport { 156 | return &ScopeVarExport{VarName: varName} 157 | } 158 | 159 | // Apply applies the node to the scope. 160 | // 161 | func (a *ScopeVarExport) Apply(s *runfile.Scope) { 162 | s.ExportVar(a.VarName) 163 | } 164 | 165 | // ScopeAttrExport exports an attribute. 166 | // 167 | type ScopeAttrExport struct { 168 | AttrName string 169 | VarName string 170 | } 171 | 172 | // NewAttrExport is a convenience method for exporting attributes. 173 | // 174 | func NewAttrExport(attrName string, varName string) *ScopeAttrExport { 175 | return &ScopeAttrExport{AttrName: attrName, VarName: varName} 176 | } 177 | 178 | // Apply applies the node to the scope. 179 | // 180 | func (a *ScopeAttrExport) Apply(s *runfile.Scope) { 181 | s.ExportAttr(a.AttrName, a.VarName) 182 | } 183 | 184 | // ScopeAssert asserts the test, exiting with message on failure. 185 | // 186 | type ScopeAssert struct { 187 | Runfile string 188 | Line int 189 | Test ScopeValueNode 190 | Message ScopeValueNode 191 | } 192 | 193 | // Apply applies the node to the scope. 194 | // 195 | func (a *ScopeAssert) Apply(s *runfile.Scope) { 196 | assert := &runfile.Assert{} 197 | assert.Runfile = a.Runfile 198 | assert.Line = a.Line 199 | assert.Test = a.Test.Apply(s) 200 | assert.Message = strings.TrimSpace(a.Message.Apply(s)) 201 | s.AddAssert(assert) 202 | } 203 | 204 | // ScopeInclude includes other runfiles. 205 | // 206 | type ScopeInclude struct { 207 | FilePattern ScopeValueNode 208 | MissingSingleOk bool 209 | MissingMatchersOk bool 210 | } 211 | 212 | // Apply applies the node to the scope. 213 | // 214 | func (a *ScopeInclude) Apply(r *runfile.Runfile) { 215 | filePattern := a.FilePattern.Apply(r.Scope) 216 | var ( 217 | files []string 218 | err error 219 | ) 220 | // We want the absolute file paths for include tracking 221 | // If pattern is not absolute, assume its relative to config.RunfileAbsDir 222 | // 223 | if !filepath.IsAbs(filePattern) { 224 | filePattern = filepath.Join(config.RunfileAbsDir, filePattern) 225 | } 226 | // Skip fileglob if pattern does not look like a glob. 227 | // By checking this ourselves, we hope to gain more control over error reporting, 228 | // as fileglob currently (as of v1.3.0) conceals the fs.ErrorNotExist condition. 229 | // 230 | if fileglob.ContainsMatchers(filePattern) { 231 | if files, err = fileglob.Glob(filePattern, fileglob.MaybeRootFS); err != nil { 232 | panic(fmt.Errorf("processing include pattern '%s': %s", filePattern, err)) 233 | } else if len(files) == 0 { 234 | if a.MissingMatchersOk { 235 | // OK for fileglob to result in 0 files, but notify user 236 | // 237 | if config.ShowNotices { 238 | log.Printf("NOTICE: include pattern resulted in no matches: %s", filePattern) 239 | } 240 | } else { 241 | panic(fmt.Errorf("include pattern resulted in no matches: %s", filePattern)) 242 | } 243 | } 244 | } else { 245 | // Specific (not-glob) filename expected to exist - Checked in loop below 246 | // 247 | files = []string{filePattern} 248 | } 249 | // Save log prefix and current runfile values, restore before leaving 250 | // 251 | logPrefixBak := log.Prefix() 252 | currentRunfileBak := config.CurrentRunfile 253 | currentRunfileAbsBak := config.CurrentRunfileAbs 254 | currentRunfileAbsDirBak := config.CurrentRunfileAbsDir 255 | defer func() { 256 | log.SetPrefix(logPrefixBak) 257 | config.CurrentRunfile = currentRunfileBak 258 | config.CurrentRunfileAbs = currentRunfileAbsBak 259 | config.CurrentRunfileAbsDir = currentRunfileAbsDirBak 260 | }() 261 | // NOTE: filenames assumed to be absolute 262 | // TODO Sort list (path aware) ? 263 | // 264 | for _, filename := range files { 265 | // Have we included this file already? 266 | // 267 | if _, exists := config.IncludeCycleMap[filename]; exists { 268 | // Treat as a notice since we safely avoided the (possibly) infinite loop 269 | // 270 | if config.ShowNotices { 271 | log.Printf("NOTICE: runfile already included: '%s' - Skipping", filename) 272 | } 273 | } else { 274 | fileBytes, exists, err := util.ReadFileIfExists(filename) 275 | if exists { 276 | // Mark file included 277 | // 278 | config.IncludeCycleMap[filename] = struct{}{} 279 | // Set new prefix so parse errors/line numbers will be relative to the correct file 280 | // For brevity, use path relative to config.RunfileAbsDir if possible 281 | // 282 | filenameMaybeRel := util.TryMakeRelative(config.RunfileAbsDir, filename) 283 | log.SetPrefix(filenameMaybeRel + ": ") 284 | config.CurrentRunfile = filenameMaybeRel 285 | config.CurrentRunfileAbs = filename 286 | config.CurrentRunfileAbsDir = filepath.Dir(config.CurrentRunfileAbs) 287 | // Parse the file 288 | // 289 | rfAst := ParseBytes(fileBytes) 290 | // Process the AST 291 | // 292 | ProcessAstRunfile(rfAst, r) 293 | // Restore values here too for consistency 294 | // 295 | log.SetPrefix(logPrefixBak) 296 | config.CurrentRunfile = currentRunfileBak 297 | config.CurrentRunfileAbs = currentRunfileAbsBak 298 | config.CurrentRunfileAbsDir = currentRunfileAbsDirBak 299 | } else { 300 | // 301 | // We're about to panic, assume prior defer will restore values before exiting 302 | // 303 | if err == nil { 304 | if a.MissingSingleOk { 305 | // OK if file missing, but notify user 306 | // 307 | if config.ShowNotices { 308 | log.Printf("NOTICE: include runfile not found: '%s'", filename) 309 | } 310 | } else { 311 | panic(fmt.Errorf("include runfile not found: '%s'", filename)) 312 | } 313 | } else { 314 | // If path error, just show the wrapped error 315 | // 316 | if pathErr, ok := err.(*os.PathError); ok { 317 | err = pathErr.Unwrap() 318 | } 319 | panic(fmt.Errorf("include runfile '%s': %s", filename, err.Error())) 320 | } 321 | } 322 | } 323 | } 324 | } 325 | 326 | // ScopeIncludeEnv includes .env files. 327 | // 328 | type ScopeIncludeEnv struct { 329 | FilePattern ScopeValueNode 330 | MissingSingleOk bool 331 | MissingMatchersOk bool 332 | } 333 | 334 | // Apply applies the node to the scope. 335 | // 336 | func (a *ScopeIncludeEnv) Apply(r *runfile.Runfile) { 337 | filePattern := a.FilePattern.Apply(r.Scope) 338 | // We want the absolute file paths for include tracking 339 | // If pattern is not absolute, assume its relative to config.RunfileAbsDir 340 | // 341 | if !filepath.IsAbs(filePattern) { 342 | filePattern = filepath.Join(config.RunfileAbsDir, filePattern) 343 | } 344 | // Skip fileglob if pattern does not look like a glob. 345 | // By checking this ourselves, we hope to gain more control over error reporting, 346 | // as fileglob currently (as of v1.3.0) conceals the fs.ErrorNotExist condition. 347 | // 348 | var files []string 349 | if fileglob.ContainsMatchers(filePattern) { 350 | var err error 351 | if files, err = fileglob.Glob(filePattern, fileglob.MaybeRootFS); err != nil { 352 | panic(fmt.Errorf("processing include.env pattern '%s': %s", filePattern, err)) 353 | } else if len(files) == 0 { 354 | if a.MissingMatchersOk { 355 | // OK for fileglob to result in 0 files, but notify user 356 | // 357 | if config.ShowNotices { 358 | log.Printf("NOTICE: include.env pattern resulted in no matches: %s", filePattern) 359 | } 360 | } else { 361 | panic(fmt.Errorf("include.env pattern resulted in no matches: %s", filePattern)) 362 | } 363 | } 364 | } else { 365 | // Specific (not-glob) filename expected to exist - Checked in loop below 366 | // 367 | files = []string{filePattern} 368 | } 369 | // NOTE: filenames assumed to be absolute 370 | // TODO Sort list (path aware) ? 371 | // 372 | for _, filename := range files { 373 | // Have we included this file already? 374 | // 375 | if _, exists := config.IncludeEnvCycleMap[filename]; exists { 376 | // Treat as a notice since we safely avoided the (possibly) infinite loop 377 | // 378 | if config.ShowNotices { 379 | log.Printf("NOTICE: env file already included: '%s' - Skipping", filename) 380 | } 381 | } else { 382 | fileBytes, exists, err := util.ReadFileIfExists(filename) 383 | if exists { 384 | // Mark file included 385 | // 386 | config.IncludeEnvCycleMap[filename] = struct{}{} 387 | // Parse the file 388 | // 389 | dotEnv, err := gotenv.StrictParse(bytes.NewReader(fileBytes)) 390 | if err != nil { 391 | panic(fmt.Errorf("include.env file '%s': %s", filename, err.Error())) 392 | } 393 | // No values exported by default 394 | // 395 | for k, v := range dotEnv { 396 | r.Scope.PutVar(k, v) 397 | } 398 | } else { 399 | if err == nil { 400 | if a.MissingSingleOk { 401 | // OK if file missing, but notify user 402 | // 403 | if config.ShowNotices { 404 | log.Printf("NOTICE: include.env file not found: '%s'", filename) 405 | } 406 | } else { 407 | panic(fmt.Errorf("include.env file not found: '%s'", filename)) 408 | } 409 | } else { 410 | // If path error, just show the wrapped error 411 | // 412 | if pathErr, ok := err.(*os.PathError); ok { 413 | err = pathErr.Unwrap() 414 | } 415 | panic(fmt.Errorf("include.env file '%s': %s", filename, err.Error())) 416 | } 417 | } 418 | } 419 | } 420 | } 421 | 422 | // ScopeBracketString wraps a bracketed string. 423 | // 424 | type ScopeBracketString struct { 425 | Value ScopeValueNode 426 | } 427 | 428 | // NewScopeBracketString is a convenience method. 429 | // 430 | func NewScopeBracketString(value ScopeValueNode) ScopeValueNode { 431 | return &ScopeBracketString{Value: value} 432 | } 433 | 434 | // Apply applies the node to the scope. 435 | // 436 | func (a *ScopeBracketString) Apply(s *runfile.Scope) string { 437 | return "[ " + a.Value.Apply(s) + " ]" 438 | } 439 | 440 | // ScopeDBracketString wraps a double-bracketed string. 441 | // 442 | type ScopeDBracketString struct { 443 | Value ScopeValueNode 444 | } 445 | 446 | // NewScopeDBracketString is a convenience method. 447 | // 448 | func NewScopeDBracketString(value ScopeValueNode) ScopeValueNode { 449 | return &ScopeDBracketString{Value: value} 450 | } 451 | 452 | // Apply applies the node to the scope. 453 | // 454 | func (a *ScopeDBracketString) Apply(s *runfile.Scope) string { 455 | return "[[ " + a.Value.Apply(s) + " ]]" 456 | } 457 | 458 | // ScopeParenString wraps a paren-string. 459 | // 460 | type ScopeParenString struct { 461 | Value ScopeValueNode 462 | } 463 | 464 | // NewScopeParenString is a convenience method. 465 | // 466 | func NewScopeParenString(value ScopeValueNode) ScopeValueNode { 467 | return &ScopeParenString{Value: value} 468 | } 469 | 470 | // Apply applies the node to the scope. 471 | // 472 | func (a *ScopeParenString) Apply(s *runfile.Scope) string { 473 | return "( " + a.Value.Apply(s) + " )" 474 | } 475 | 476 | // ScopeDParenString wraps a double-paren string. 477 | // 478 | type ScopeDParenString struct { 479 | Value ScopeValueNode 480 | } 481 | 482 | // NewScopeDParenString is a convenience method. 483 | // 484 | func NewScopeDParenString(value ScopeValueNode) ScopeValueNode { 485 | return &ScopeDParenString{Value: value} 486 | } 487 | 488 | // Apply applies the node to the scope. 489 | // 490 | func (a *ScopeDParenString) Apply(s *runfile.Scope) string { 491 | return "(( " + a.Value.Apply(s) + " ))" 492 | } 493 | 494 | // Cmd wraps a parsed command. 495 | // 496 | type Cmd struct { 497 | Flags config.CmdFlags 498 | Name string 499 | Config *CmdConfig 500 | Script []string 501 | Runfile string 502 | Line int 503 | } 504 | 505 | // Apply applies the node to the runfile. 506 | // 507 | func (a *Cmd) Apply(r *runfile.Runfile) { 508 | r.Cmds = append(r.Cmds, a) 509 | } 510 | 511 | // GetCmd generates a Runfile command from the node. 512 | // Fulfills runfile.CmdProvider#GetCmd 513 | // 514 | func (a *Cmd) GetCmd(r *runfile.Runfile) *runfile.RunCmd { 515 | return a.GetCmdEnv(r, map[string]string{}) 516 | } 517 | 518 | // GetCmdEnv generates a Runfile command from the node and 519 | // a supplied starting env. 520 | // Provided env overrides the Runfile global env but not the command's env. 521 | // Fulfills runfile.CmdProvider#GetCmdEnv 522 | // 523 | func (a *Cmd) GetCmdEnv(r *runfile.Runfile, env map[string]string) *runfile.RunCmd { 524 | cmd := &runfile.RunCmd{ 525 | Flags: a.Flags, 526 | Name: a.Name, 527 | Scope: runfile.NewScope(), 528 | Script: a.Script, 529 | Runfile: a.Runfile, 530 | Line: a.Line, 531 | } 532 | // Exports 533 | // 534 | for _, export := range r.Scope.GetVarExports() { 535 | cmd.Scope.ExportVar(export.VarName) 536 | } 537 | for _, export := range r.Scope.GetAttrExports() { 538 | cmd.Scope.ExportAttr(export.AttrName, export.VarName) 539 | } 540 | for _, export := range a.Config.VarExports { 541 | cmd.Scope.ExportVar(export.VarName) 542 | } 543 | for _, export := range a.Config.AttrExports { 544 | cmd.Scope.ExportAttr(export.AttrName, export.VarName) 545 | } 546 | // Vars 547 | // Start with copy of global vars 548 | // 549 | for key, value := range r.Scope.Vars { 550 | cmd.Scope.PutVar(key, value) 551 | } 552 | // Attrs 553 | // 554 | for key, value := range r.Scope.Attrs { 555 | cmd.Scope.PutAttr(key, value) 556 | } 557 | // Provided Environment 558 | // 559 | for key, value := range env { 560 | cmd.Scope.PutVar(key, value) 561 | } 562 | // Config Environment 563 | // 564 | for _, varAssignment := range a.Config.Vars { 565 | varAssignment.Apply(cmd.Scope) 566 | } 567 | // Config 568 | // 569 | cmd.Config = &runfile.RunCmdConfig{} 570 | // .SHELL 571 | // 572 | cmd.Config.Shell = a.Config.Shell 573 | cmd.Scope.PutAttr(".SHELL", cmd.Shell()) 574 | // Config Desc 575 | // 576 | for _, desc := range a.Config.Desc { 577 | cmd.Config.Desc = append(cmd.Config.Desc, desc.Apply(cmd.Scope)) 578 | } 579 | cmd.Config.Desc = runfile.NormalizeCmdDesc(cmd.Config.Desc) 580 | // Config Usages 581 | // 582 | for _, usage := range a.Config.Usages { 583 | cmd.Config.Usages = append(cmd.Config.Usages, usage.Apply(cmd.Scope)) 584 | } 585 | // Config Opts 586 | // 587 | for _, opt := range a.Config.Opts { 588 | cmd.Config.Opts = append(cmd.Config.Opts, opt.Apply(cmd)) 589 | } 590 | // Config 'Env' Runs 591 | // 592 | for _, cmdRun := range a.Config.EnvRuns { 593 | cmd.Config.EnvRuns = append(cmd.Config.EnvRuns, cmdRun.Apply(cmd.Scope)) 594 | } 595 | // Config 'Before' Runs 596 | // 597 | for _, cmdRun := range a.Config.BeforeRuns { 598 | cmd.Config.BeforeRuns = append(cmd.Config.BeforeRuns, cmdRun.Apply(cmd.Scope)) 599 | } 600 | // Config 'After' Runs 601 | // 602 | for _, cmdRun := range a.Config.AfterRuns { 603 | cmd.Config.AfterRuns = append(cmd.Config.AfterRuns, cmdRun.Apply(cmd.Scope)) 604 | } 605 | // Asserts - Global first, then Command 606 | // 607 | for _, assert := range r.Scope.Asserts { 608 | cmd.Scope.AddAssert(assert) 609 | } 610 | for _, assert := range a.Config.Asserts { 611 | cmd.Scope.AddAssert(assert.Apply(cmd.Scope)) 612 | } 613 | return cmd 614 | } 615 | 616 | // CmdConfig wraps a command config. 617 | // 618 | type CmdConfig struct { 619 | Shell string 620 | Desc []ScopeValueNode 621 | Usages []ScopeValueNode 622 | Opts []*CmdOpt 623 | Vars []scopeNode 624 | VarExports []*ScopeVarExport 625 | AttrExports []*ScopeAttrExport 626 | Asserts []*CmdAssert 627 | EnvRuns []*CmdRun 628 | BeforeRuns []*CmdRun 629 | AfterRuns []*CmdRun 630 | } 631 | 632 | // CmdOpt wraps a command option. 633 | // 634 | type CmdOpt struct { 635 | Name string 636 | Required bool 637 | Default ScopeValueNode 638 | Short rune 639 | Long string 640 | Example string 641 | Desc ScopeValueNode 642 | } 643 | 644 | // Apply applies the node to the command. 645 | // 646 | func (a *CmdOpt) Apply(c *runfile.RunCmd) *runfile.RunCmdOpt { 647 | opt := &runfile.RunCmdOpt{} 648 | opt.Name = a.Name 649 | opt.Required = a.Required 650 | opt.HasDefault = a.Default != nil 651 | if opt.HasDefault { 652 | opt.Default = a.Default.Apply(c.Scope) 653 | } 654 | opt.Short = a.Short 655 | opt.Long = a.Long 656 | opt.Example = a.Example 657 | opt.Desc = a.Desc.Apply(c.Scope) 658 | return opt 659 | } 660 | 661 | // CmdAssert wraps a command assertion. 662 | // 663 | type CmdAssert struct { 664 | Runfile string 665 | Line int 666 | Test ScopeValueNode 667 | Message ScopeValueNode 668 | } 669 | 670 | // Apply applies the node to the Scope. 671 | // 672 | func (a *CmdAssert) Apply(s *runfile.Scope) *runfile.Assert { 673 | assert := &runfile.Assert{} 674 | assert.Runfile = a.Runfile 675 | assert.Line = a.Line 676 | assert.Test = a.Test.Apply(s) 677 | assert.Message = strings.TrimSpace(a.Message.Apply(s)) 678 | return assert 679 | } 680 | 681 | // CmdRun wraps a command config RUN invocation. 682 | // 683 | type CmdRun struct { 684 | Command string 685 | Args []ScopeValueNode 686 | } 687 | 688 | // Apply applies the node to the Scope. 689 | // 690 | func (a *CmdRun) Apply(s *runfile.Scope) *runfile.RunCmdRun { 691 | cmdRun := &runfile.RunCmdRun{} 692 | cmdRun.Command = a.Command 693 | for _, arg := range a.Args { 694 | cmdRun.Args = append(cmdRun.Args, arg.Apply(s)) 695 | } 696 | return cmdRun 697 | } 698 | 699 | // ScopeAttrAssignment wraps an attribute assignment. 700 | // 701 | type ScopeAttrAssignment struct { 702 | Name string 703 | Value ScopeValueNode 704 | } 705 | 706 | // Apply applies the node to the scope. 707 | // 708 | func (a *ScopeAttrAssignment) Apply(s *runfile.Scope) { 709 | s.PutAttr(a.Name, a.Value.Apply(s)) 710 | } 711 | 712 | // ScopeVarAssignment wraps a variable assignment. 713 | // 714 | type ScopeVarAssignment struct { 715 | Name string 716 | Value ScopeValueNode 717 | } 718 | 719 | // Apply applies the node to the scope. 720 | // 721 | func (a *ScopeVarAssignment) Apply(s *runfile.Scope) { 722 | s.PutVar(a.Name, a.Value.Apply(s)) 723 | } 724 | 725 | // ScopeVarQAssignment wraps a variable Q-Assignment. 726 | // 727 | type ScopeVarQAssignment struct { 728 | Name string 729 | Value ScopeValueNode 730 | } 731 | 732 | // Apply applies the node to the scope. 733 | // 734 | func (a *ScopeVarQAssignment) Apply(s *runfile.Scope) { 735 | // Only assign if not already present+non-empty 736 | // 737 | if val, ok := s.GetVar(a.Name); !ok || len(val) == 0 { 738 | // Use the Env value, if present+non-empty, else the assignment value 739 | // 740 | if val, ok = s.GetEnv(a.Name); !ok || len(val) == 0 { 741 | val = a.Value.Apply(s) 742 | } 743 | s.PutVar(a.Name, val) 744 | } 745 | } 746 | 747 | // ScopeValueRunes wraps a simple string as a value. 748 | // 749 | type ScopeValueRunes struct { 750 | Value string 751 | } 752 | 753 | // Apply applies the node to the scope, returning the value. 754 | // 755 | func (a *ScopeValueRunes) Apply(_ *runfile.Scope) string { 756 | return a.Value 757 | } 758 | 759 | // ScopeValueEsc wraps an escape sequence. 760 | // 761 | type ScopeValueEsc struct { 762 | Seq string 763 | } 764 | 765 | // Apply applies the node to the scope, returning the value. 766 | // 767 | func (a *ScopeValueEsc) Apply(_ *runfile.Scope) string { 768 | return string([]rune(a.Seq)[1]) // TODO A bit of a hack 769 | } 770 | 771 | // ScopeValueVar wraps a variable reference. 772 | // 773 | type ScopeValueVar struct { 774 | Name string 775 | } 776 | 777 | // Apply applies the node to the scope, returning the value. 778 | // 779 | func (a *ScopeValueVar) Apply(s *runfile.Scope) string { 780 | if val, ok := s.GetVar(a.Name); ok { 781 | return val 782 | } 783 | if val, ok := s.GetEnv(a.Name); ok { 784 | return val 785 | } 786 | if val, ok := s.GetAttr(a.Name); ok { 787 | return val 788 | } 789 | return "" 790 | } 791 | 792 | // ScopeValueShell wraps a command substitution string. 793 | // 794 | type ScopeValueShell struct { 795 | Cmd ScopeValueNode 796 | } 797 | 798 | // Apply applies the node to the scope, returning the value. 799 | // 800 | func (a *ScopeValueShell) Apply(s *runfile.Scope) string { 801 | cmd := a.Cmd.Apply(s) 802 | env := make(map[string]string) 803 | for _, export := range s.GetVarExports() { 804 | if value, ok := s.GetVar(export.VarName); ok { 805 | env[export.VarName] = value 806 | } else { 807 | log.Printf("WARNING: exported variable not defined: '%s'", export.VarName) 808 | } 809 | } 810 | for _, export := range s.GetAttrExports() { 811 | if value, ok := s.GetAttr(export.AttrName); ok { 812 | env[export.VarName] = value 813 | } else { 814 | log.Printf("WARNING: exported attribute not defined: '%s'", export.AttrName) 815 | } 816 | } 817 | capturedOutput := &strings.Builder{} 818 | shell, ok := s.GetAttr(".SHELL") 819 | if !ok || len(shell) == 0 { 820 | shell = config.DefaultShell 821 | } 822 | exec.ExecuteSubCommand(shell, cmd, env, capturedOutput) 823 | result := capturedOutput.String() 824 | 825 | // Trim trailing newlines, per std command-substitution behavior 826 | // 827 | for result[len(result)-1] == '\n' { 828 | result = result[0 : len(result)-1] 829 | } 830 | return result 831 | } 832 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "log" 7 | "reflect" 8 | "runtime" 9 | ) 10 | 11 | // CmdFlags captures various options for a command 12 | type CmdFlags int 13 | 14 | const ( 15 | // FlagHidden marks a command as Hidden 16 | // 17 | FlagHidden CmdFlags = 1 << iota 18 | 19 | // FlagPrivate marks a command as Private 20 | // 21 | FlagPrivate 22 | ) 23 | 24 | // Hidden returns true if flag represents Hidden 25 | // 26 | func (c CmdFlags) Hidden() bool { 27 | return c&FlagHidden > 0 28 | } 29 | 30 | // Private returns true if flag represents Private 31 | // 32 | func (c CmdFlags) Private() bool { 33 | return c&FlagPrivate > 0 34 | } 35 | 36 | // Command is an abstraction for a command, allowing us to mix runfile commands and custom comments (help, list, etc). 37 | // 38 | type Command struct { 39 | Flags CmdFlags 40 | Name string 41 | Title string 42 | Help func() 43 | Run func([]string, map[string]string, io.Writer) int 44 | Rename func(string) // Rename Command to script Name in 'main' mode 45 | Builtin bool 46 | } 47 | 48 | // DefaultShell specifies which shell to use for command scripts and sub-shells if none explicitly defined. 49 | // 50 | const DefaultShell = "sh" 51 | 52 | // Me stores the script name we consider the runfile to be running as. 53 | // 54 | var Me string 55 | 56 | // ShebangMode treats the Runfile as the executable 57 | // 58 | var ShebangMode bool 59 | 60 | // MainMode extends ShebangMode by auto-invoking the main command 61 | // 62 | var MainMode bool 63 | 64 | // ErrOut is where logs and errors are sent to (generally stderr). 65 | // 66 | var ErrOut io.Writer 67 | 68 | // ErrShell is an Error message for missing '.SHELL' attribute 69 | // 70 | var ErrShell = errors.New(".SHELL not defined") 71 | 72 | // CommandList stores a list of commands. 73 | // 74 | var CommandList []*Command 75 | 76 | // CommandMap stores a map of commands, keyed by the command name (lower-cased) 77 | // 78 | var CommandMap = make(map[string]*Command) 79 | 80 | // RunBin holds the absolute path to the run command in use. 81 | // 82 | var RunBin string 83 | 84 | // Runfile holds the (possibly relative) path to the primary Runfile. 85 | // 86 | var Runfile string 87 | 88 | // RunfileAbs holds the absolute path to the primary Runfile. 89 | // 90 | var RunfileAbs string 91 | 92 | // RunfileAbsDir holds the absolute path to the containing folder of the primary Runfile. 93 | // 94 | var RunfileAbsDir string 95 | 96 | // RunfileIsLoaded is true if the runfile has been successfully loaded 97 | // 98 | var RunfileIsLoaded bool 99 | 100 | // RunfileIsDefault is true if the current Runfile is the default "Runfile" 101 | // 102 | var RunfileIsDefault bool 103 | 104 | // CurrentRunfile holds the (possibly relative) path to the current (primary or otherwise) Runfile. 105 | // 106 | var CurrentRunfile string 107 | 108 | // CurrentRunfileAbs holds the absolute path to the current (primary or otherwise) Runfile. 109 | // 110 | var CurrentRunfileAbs string 111 | 112 | // CurrentRunfileAbsDir holds the absolute path to the containing folder of the current (primary or otherwise) Runfile. 113 | // 114 | var CurrentRunfileAbsDir string 115 | 116 | // IncludeCycleMap tracks included Runfiles to avoid infinite loops. Key = abs file paths of included Runfile 117 | // 118 | var IncludeCycleMap = map[string]struct{}{} 119 | 120 | // IncludeEnvCycleMap tracks included .env files to avoid infinite loops. Key = abs file paths of included .env file 121 | // 122 | var IncludeEnvCycleMap = map[string]struct{}{} 123 | 124 | // RunCycleMap tracks inter-cmd RUNs to avoid infinite loops. Key = lowercase name of cmd 125 | // 126 | var RunCycleMap = map[string]struct{}{} 127 | 128 | // EnableFnTrace shows parser/lexer fn call/stack 129 | // 130 | var EnableFnTrace = false 131 | 132 | // ShowScriptTmpDir shows the directory where Command/sub-shell scripts are stored 133 | // 134 | var ShowScriptTmpDir = false 135 | 136 | // ShowCmdShells shows the command shell in the command's help screen 137 | // 138 | var ShowCmdShells = false 139 | 140 | // ShowNotices shows NOTICE level logging 141 | // TODO Support verbose mode, so we can display notices :) 142 | // 143 | var ShowNotices = false 144 | 145 | // EnableRunfileOverride indicates if $RUNFILE env var or '-r | --runfile' arguments are supported in the current mode. 146 | // 147 | var EnableRunfileOverride = true 148 | 149 | // TraceFn logs lexer transitions 150 | // 151 | func TraceFn(msg string, i interface{}) { 152 | //goland:noinspection GoBoolExpressions 153 | if EnableFnTrace { 154 | fnName := runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() 155 | log.Println(msg, ":", fnName) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /internal/exec/exec.go: -------------------------------------------------------------------------------- 1 | package exec 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "os/exec" 10 | 11 | "github.com/tekwizely/run/internal/config" 12 | ) 13 | 14 | var tmpDir string 15 | 16 | func executeScript(shell string, script []string, args []string, env map[string]string, prefix string, out io.Writer) int { 17 | if shell == "" { 18 | panic(config.ErrShell) 19 | } 20 | if len(script) == 0 { 21 | return 0 22 | } 23 | // Tmp file will be cleaned up via CleanupTemporaryDir 24 | // 25 | tmpFile, err := tmpFile(fmt.Sprintf("%s-%s-*.sh", prefix, shell)) 26 | if err != nil { 27 | // ~= log.Fatal 28 | log.Print(err) 29 | return 1 30 | } 31 | defer func() { _ = tmpFile.Close() }() 32 | 33 | for _, line := range script { 34 | if _, err = tmpFile.Write([]byte(line)); err != nil { 35 | // ~= log.Fatal 36 | log.Print(err) 37 | return 1 38 | } 39 | } 40 | var cmd *exec.Cmd 41 | 42 | // Shebang or env ? 43 | // 44 | if shell == "#!" { 45 | // Try to make the cmd executable 46 | // 47 | var stat os.FileInfo 48 | if stat, err = tmpFile.Stat(); err != nil { 49 | // ~= log.Fatal 50 | log.Print(err) 51 | return 1 52 | } 53 | // Add user-executable bit 54 | // 55 | if err = tmpFile.Chmod(stat.Mode() | 0100); err != nil { 56 | // ~= log.Fatal 57 | log.Print(err) 58 | return 1 59 | } 60 | if err = tmpFile.Close(); err != nil { 61 | // ~= log.Fatal 62 | log.Print(err) 63 | return 1 64 | } 65 | 66 | cmd = exec.Command(tmpFile.Name(), args...) 67 | } else { 68 | cmd = exec.Command("/usr/bin/env", append([]string{shell, tmpFile.Name()}, args...)...) 69 | } 70 | 71 | cmd.Stdin = os.Stdin 72 | cmd.Stdout = out 73 | cmd.Stderr = os.Stderr 74 | cmd.Env = os.Environ() 75 | // Merge passed-in env with os environment 76 | // 77 | for k, v := range env { 78 | cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) 79 | } 80 | err = cmd.Run() 81 | if err == nil { 82 | return 0 83 | } 84 | if exitError, ok := err.(*exec.ExitError); ok { 85 | return exitError.ExitCode() 86 | } 87 | panic(err) 88 | } 89 | 90 | // ExecuteCmdScript executes a command script. 91 | // 92 | func ExecuteCmdScript(shell string, script []string, args []string, env map[string]string, out io.Writer) int { 93 | return executeScript(shell, script, args, env, "cmd", out) 94 | } 95 | 96 | // ExecuteSubCommand executes a command substitution. 97 | // 98 | func ExecuteSubCommand(shell string, command string, env map[string]string, out io.Writer) int { 99 | return executeScript(shell, []string{command}, []string{}, env, "sub", out) 100 | } 101 | 102 | // ExecuteTest will execute the test command against the supplied test string 103 | // 104 | func ExecuteTest(shell string, test string, env map[string]string) int { 105 | return executeScript(shell, []string{test}, []string{}, env, "test", os.Stdout) 106 | } 107 | 108 | // tmpFile creates a temporary file relative to tmpDir 109 | // Created files will be cleaned up in CleanupTemporaryDir 110 | // 111 | func tmpFile(pattern string) (*os.File, error) { 112 | if tmpDir == "" { 113 | var err error 114 | tmpDir, err = ioutil.TempDir("", "runfile-") 115 | //goland:noinspection GoBoolExpressions 116 | if config.ShowScriptTmpDir { 117 | _, _ = fmt.Fprintln(config.ErrOut, "temp dir: ", tmpDir) 118 | } 119 | if err != nil { 120 | return nil, err 121 | } 122 | } 123 | return ioutil.TempFile(tmpDir, pattern) 124 | } 125 | 126 | // CleanupTemporaryDir attempts to remove the previously created tmpDir 127 | // and any files within it. 128 | // 129 | func CleanupTemporaryDir() error { 130 | //goland:noinspection GoBoolExpressions 131 | if tmpDir != "" && !config.ShowScriptTmpDir { 132 | return os.RemoveAll(tmpDir) 133 | } 134 | return nil 135 | } 136 | -------------------------------------------------------------------------------- /internal/lexer/lexer.go: -------------------------------------------------------------------------------- 1 | package lexer 2 | 3 | import ( 4 | "bytes" 5 | "container/list" 6 | "strings" 7 | 8 | "github.com/tekwizely/go-parsing/lexer" 9 | "github.com/tekwizely/go-parsing/lexer/token" 10 | 11 | "github.com/tekwizely/run/internal/config" 12 | ) 13 | 14 | // LexFn is a lexer fun that takes a context 15 | // 16 | type LexFn func(*LexContext, *lexer.Lexer) LexFn 17 | 18 | // LexContext allows us to track additional states of the lexer 19 | // 20 | type LexContext struct { 21 | Fn LexFn 22 | fnStack *list.List 23 | Tokens token.Nexter 24 | } 25 | 26 | // lex delegates incoming lexer calls to the configured fn 27 | // 28 | func (ctx *LexContext) lex(l *lexer.Lexer) lexer.Fn { 29 | fn := ctx.Fn 30 | // EOF ? 31 | // 32 | if fn == nil { 33 | if ctx.fnStack.Len() == 0 { 34 | return nil 35 | } 36 | fn = ctx.fnStack.Remove(ctx.fnStack.Back()).(LexFn) 37 | config.TraceFn("Popped lexer function", fn) 38 | } 39 | // assert(fn != nil) 40 | config.TraceFn("Calling lexer function", fn) 41 | ctx.Fn = fn(ctx, l) 42 | return ctx.lex 43 | } 44 | 45 | // PushFn stores the specified function on the fn stack. 46 | // 47 | func (ctx *LexContext) PushFn(fn LexFn) { 48 | ctx.fnStack.PushBack(fn) 49 | config.TraceFn("Pushed lexer function", fn) 50 | } 51 | 52 | // Lex initiates the lexer against a byte array 53 | // 54 | func Lex(fileBytes []byte) *LexContext { 55 | reader := newReaderIgnoreCR(bytes.NewReader(fileBytes)) 56 | ctx := &LexContext{ 57 | Fn: LexMain, 58 | fnStack: list.New(), 59 | } 60 | ctx.Tokens = lexer.LexRuneReader(reader, ctx.lex) 61 | return ctx 62 | } 63 | 64 | // LexMain is the primary lexer entry point 65 | // 66 | func LexMain(_ *LexContext, l *lexer.Lexer) LexFn { 67 | 68 | switch { 69 | // := 70 | // 71 | case l.CanPeek(2) && l.Peek(1) == runeColon && l.Peek(2) == runeEquals: 72 | l.Next() // : 73 | l.Next() // = 74 | l.EmitType(TokenEquals) 75 | // ?= 76 | // 77 | case l.CanPeek(2) && l.Peek(1) == runeQMark && l.Peek(2) == runeEquals: 78 | l.Next() // ? 79 | l.Next() // = 80 | l.EmitType(TokenQMarkEquals) 81 | // Single-Char Token - Check AFTER multi-char tokens 82 | // 83 | case bytes.ContainsRune(singleRunes, l.Peek(1)): 84 | i := bytes.IndexRune(singleRunes, l.Peek(1)) 85 | l.Next() // Match the rune 86 | l.EmitType(singleTokens[i]) // Emit just the type, discarding the matched rune 87 | // Comment 88 | // 89 | case matchRune(l, runeHash): 90 | // Might be a hash-line 91 | // 92 | if matchOneOrMore(l, isHash) { 93 | // Might be a single line doc comment 94 | // 95 | if matchOneOrMore(l, isSpaceOrTab) { 96 | l.Clear() 97 | if l.CanPeek(1) && isPrintNonReturn(l.Peek(1)) { 98 | l.EmitToken(TokenConfigDescLineStart) 99 | return LexMain 100 | } 101 | } 102 | if matchNewlineOrEOF(l) { 103 | l.EmitType(TokenHashLine) 104 | return LexMain 105 | } 106 | } 107 | // Consume rest of line as a standard comment 108 | // 109 | for !matchNewlineOrEOF(l) { 110 | l.Next() 111 | } 112 | l.Clear() // Discard the comment (for now) 113 | // Leading Whitespace 114 | // 115 | case matchOneOrMore(l, isSpaceOrTab): 116 | l.Clear() // Discard 117 | // Newline 118 | // 119 | case matchNewline(l): 120 | l.EmitType(TokenNewline) 121 | // DotID - Starts with . 122 | // 123 | case matchDotID(l): 124 | l.EmitToken(TokenDotID) 125 | // Keyword / ID / DashID 126 | // 127 | case matchAnyID(l): 128 | name := strings.ToUpper(l.PeekToken()) 129 | switch { 130 | case isMainToken(name): 131 | l.EmitType(mainTokens[name]) 132 | // Only main tokens can contain '.' 133 | // 134 | case strings.ContainsRune(name, runeDot): 135 | l.EmitToken(TokenUnknownRune) 136 | case strings.ContainsRune(name, runeDash): 137 | l.EmitToken(TokenDashID) 138 | default: 139 | l.EmitToken(TokenID) 140 | } 141 | // Keyword / ID / [.!]? DashID 142 | // 143 | case matchCommandDefID(l): 144 | name := strings.ToUpper(l.PeekToken()) 145 | switch { 146 | case isMainToken(name): 147 | l.EmitType(mainTokens[name]) 148 | case strings.HasPrefix(name, "."): // Can only match at front 149 | l.EmitToken(TokenCommandDefID) 150 | case strings.HasPrefix(name, "!"): // Can only match at front 151 | l.EmitToken(TokenCommandDefID) 152 | case strings.ContainsRune(name, runeDash): 153 | l.EmitToken(TokenDashID) 154 | default: 155 | l.EmitToken(TokenID) 156 | } 157 | // Unknown 158 | // 159 | default: 160 | l.Next() 161 | l.EmitToken(TokenUnknownRune) 162 | return nil 163 | } 164 | 165 | return LexMain 166 | } 167 | 168 | // LexAssignmentValue delegates to other rValue lexers 169 | // 170 | func LexAssignmentValue(_ *LexContext, l *lexer.Lexer) LexFn { 171 | ignoreSpace(l) 172 | switch l.Peek(1) { 173 | case runeSQuote: 174 | l.EmitType(TokenSQStringStart) 175 | return LexSQString 176 | case runeDQuote: 177 | l.EmitType(TokenDQStringStart) 178 | return LexDQString 179 | case runeDollar: 180 | return lexDollarString 181 | } 182 | return lexUQString 183 | } 184 | 185 | // lexDollarString 186 | // 187 | func lexDollarString(_ *LexContext, l *lexer.Lexer) LexFn { 188 | if l.CanPeek(1) { 189 | if l.Peek(1) == runeDollar { 190 | if l.CanPeek(2) { 191 | switch l.Peek(2) { 192 | case runeLBrace: 193 | l.EmitType(TokenVarRefStart) 194 | return nil 195 | case runeLParen: 196 | l.EmitType(TokenSubCmdStart) 197 | return nil 198 | } 199 | } 200 | } 201 | } 202 | expectRune(l, runeDollar, "expecting dollar ('$')") 203 | l.EmitType(TokenDollar) 204 | return nil 205 | } 206 | 207 | // LexVarRef matches: [ '$' '{' [A-Za-z0-9_.]* '}' ] 208 | // 209 | func LexVarRef(_ *LexContext, l *lexer.Lexer) LexFn { 210 | // Dollar 211 | // 212 | expectRune(l, runeDollar, "expecting dollar ('$')") 213 | l.EmitType(TokenDollar) 214 | // Open Brace 215 | // 216 | expectRune(l, runeLBrace, "expecting l-brace ('{')") 217 | l.EmitType(TokenLBrace) 218 | // Variable Name 219 | // 220 | matchZeroOrMore(l, isAlphaNumUnderDot) 221 | l.EmitToken(TokenRunes) // Could be empty 222 | // Close Brace 223 | // 224 | expectRune(l, runeRBrace, "expecting r-brace ('}')") 225 | l.EmitType(TokenRBrace) 226 | 227 | return nil 228 | } 229 | 230 | // LexSubCmd matches: [ '$' '(' [::print::] ')' ] 231 | // 232 | func LexSubCmd(_ *LexContext, l *lexer.Lexer) LexFn { 233 | // Dollar 234 | // 235 | expectRune(l, runeDollar, "expecting dollar ('$')") 236 | l.EmitType(TokenDollar) 237 | // Open Paren 238 | // 239 | expectRune(l, runeLParen, "expecting l-paren ('(')") 240 | l.EmitType(TokenLParen) 241 | // Keep going until we find close paren 242 | // 243 | for l.CanPeek(1) { 244 | switch { 245 | // Consume a run of printable, non-paren non-escape characters 246 | // 247 | case matchOneOrMore(l, isPrintNonParenNonBackslash): 248 | l.EmitToken(TokenRunes) 249 | // Back-slash '\' 250 | // 251 | case matchRune(l, runeBackSlash): 252 | // In Shell mode, only '\', '(' and ')' are escapable 253 | // Anything else is considered two separate characters 254 | // 255 | if matchRune(l, runeBackSlash, runeLParen, runeRParen) { 256 | l.EmitToken(TokenEscapeSequence) 257 | } else { 258 | l.EmitToken(TokenRunes) 259 | } 260 | // Better be Close Paren ')' 261 | // 262 | default: 263 | expectRune(l, runeRParen, "expecting r-paren (')')") 264 | l.EmitType(TokenRParen) 265 | return nil 266 | } 267 | } 268 | return nil 269 | } 270 | 271 | // LexAssert assumes 'ASSERT' has already been matched 272 | // 273 | func LexAssert(ctx *LexContext, l *lexer.Lexer) LexFn { 274 | ignoreSpace(l) 275 | ctx.PushFn(LexAssertMessage) 276 | return lexTestString 277 | } 278 | 279 | // LexAssertMessage parses an (optional) assertion error message 280 | // 281 | func LexAssertMessage(_ *LexContext, l *lexer.Lexer) LexFn { 282 | ignoreSpace(l) 283 | switch { 284 | // "'" 285 | // 286 | case peekRuneEquals(l, runeSQuote): 287 | l.EmitType(TokenSQStringStart) 288 | return LexSQString 289 | // '"' 290 | // 291 | case peekRuneEquals(l, runeDQuote): 292 | l.EmitType(TokenDQStringStart) 293 | return LexDQString 294 | default: 295 | l.EmitType(TokenEmptyAssertMessage) 296 | return nil 297 | } 298 | } 299 | 300 | // lexTestString 301 | // 302 | func lexTestString(ctx *LexContext, l *lexer.Lexer) LexFn { 303 | //goland:noinspection GoImportUsedAsName 304 | var ( 305 | token token.Type 306 | elementFn LexFn 307 | endFn LexFn 308 | ) 309 | switch { 310 | 311 | // '[' | '[[' 312 | // 313 | case matchRune(l, runeLBracket): 314 | elementFn = lexBracketStringElement 315 | if matchRune(l, runeLBracket) { 316 | endFn = lexEndDBracketString 317 | token = TokenDBracketStringStart 318 | } else { 319 | endFn = lexEndBracketString 320 | token = TokenBracketStringStart 321 | } 322 | // '(' | '((' 323 | // 324 | case matchRune(l, runeLParen): 325 | elementFn = lexParenStringElement 326 | if matchRune(l, runeLParen) { 327 | endFn = lexEndDParenString 328 | token = TokenDParenStringStart 329 | } else { 330 | endFn = lexEndParenString 331 | token = TokenParenStringStart 332 | } 333 | } 334 | expectRune(l, ' ', "expecting space (' ')") 335 | l.EmitType(token) 336 | ctx.PushFn(endFn) 337 | return elementFn 338 | } 339 | 340 | // lexEndBracketString 341 | // 342 | func lexEndBracketString(_ *LexContext, l *lexer.Lexer) LexFn { 343 | expectRune(l, ' ', "expecting space (' ')") 344 | expectRune(l, runeRBracket, "expecting right-bracket (']')") 345 | l.EmitType(TokenBracketStringEnd) 346 | return nil 347 | } 348 | 349 | // lexEndDBracketString 350 | // 351 | func lexEndDBracketString(_ *LexContext, l *lexer.Lexer) LexFn { 352 | expectRune(l, ' ', "expecting space (' ')") 353 | expectRune(l, runeRBracket, "expecting double-right-bracket (']]')") 354 | expectRune(l, runeRBracket, "expecting double-right-bracket (']]')") 355 | l.EmitType(TokenDBracketStringEnd) 356 | return nil 357 | } 358 | 359 | // lexBracketStringElement 360 | // 361 | func lexBracketStringElement(_ *LexContext, l *lexer.Lexer) LexFn { 362 | switch { 363 | // Space may be end of string 364 | // 365 | case l.CanPeek(1) && l.Peek(1) == ' ' && l.CanPeek(2) && l.Peek(2) == runeRBracket: 366 | // Leave text to be matched by endFn 367 | // 368 | return nil 369 | case matchRune(l, ' '): 370 | l.EmitToken(TokenRunes) 371 | // Consume a run of printable, non-bracket non-escape, non-space characters 372 | // 373 | case matchOneOrMore(l, isPrintNonBracketNonBackslashNonSpace): 374 | l.EmitToken(TokenRunes) 375 | // Back-slash '\' 376 | // 377 | case matchRune(l, runeBackSlash): 378 | // In Bracket String mode, currently only '\', '[' and ']' are escapable 379 | // Anything else is considered two separate characters 380 | // 381 | if matchRune(l, runeBackSlash, runeLBracket, runeRBracket) { 382 | l.EmitToken(TokenEscapeSequence) 383 | } else { 384 | l.EmitToken(TokenRunes) 385 | } 386 | default: 387 | return nil 388 | } 389 | return lexBracketStringElement 390 | } 391 | 392 | // lexEndParenString 393 | // 394 | func lexEndParenString(_ *LexContext, l *lexer.Lexer) LexFn { 395 | expectRune(l, ' ', "expecting space (' ')") 396 | expectRune(l, runeRParen, "expecting right-paren (')')") 397 | l.EmitType(TokenParenStringEnd) 398 | return nil 399 | } 400 | 401 | // lexEndDParenString 402 | // 403 | func lexEndDParenString(_ *LexContext, l *lexer.Lexer) LexFn { 404 | expectRune(l, ' ', "expecting space (' ')") 405 | expectRune(l, runeRParen, "expecting double-right-paren ('))')") 406 | expectRune(l, runeRParen, "expecting double-right-paren ('))')") 407 | l.EmitType(TokenDParenStringEnd) 408 | return nil 409 | } 410 | 411 | // lexParenStringElement 412 | // 413 | func lexParenStringElement(_ *LexContext, l *lexer.Lexer) LexFn { 414 | switch { 415 | // Space may be end of string 416 | // 417 | case l.CanPeek(1) && l.Peek(1) == ' ' && l.CanPeek(2) && l.Peek(2) == runeRParen: 418 | // Leave text to be matched by endFn 419 | // 420 | return nil 421 | case matchRune(l, ' '): 422 | l.EmitToken(TokenRunes) 423 | // Consume a run of printable, non-paren non-escape characters 424 | // 425 | case matchOneOrMore(l, isPrintNonParenNonBackslashNonSpace): 426 | l.EmitToken(TokenRunes) 427 | // Back-slash '\' 428 | // 429 | case matchRune(l, runeBackSlash): 430 | // In Paren String mode, currently only '\', '(' and ')' are escapable 431 | // Anything else is considered two separate characters 432 | // 433 | if matchRune(l, runeBackSlash, runeLParen, runeRParen) { 434 | l.EmitToken(TokenEscapeSequence) 435 | } else { 436 | l.EmitToken(TokenRunes) 437 | } 438 | default: 439 | return nil 440 | } 441 | return lexParenStringElement 442 | } 443 | 444 | // LexSQString lexes a Single-Quoted String 445 | // No escapable sequences in SQuotes, not even '\'' 446 | // 447 | func LexSQString(_ *LexContext, l *lexer.Lexer) LexFn { 448 | // Open quote 449 | // 450 | expectRune(l, runeSQuote, "expecting single-quote (\"'\")") 451 | l.EmitType(TokenSQuote) 452 | // Match quoted value as a one-shot 453 | // 454 | matchZeroOrMore(l, isPrintNonSQuote) 455 | l.EmitToken(TokenRunes) // Could be empty 456 | // Close quote 457 | // 458 | expectRune(l, runeSQuote, "expecting single-quote (\"'\")") 459 | l.EmitType(TokenSQuote) 460 | 461 | return nil 462 | } 463 | 464 | // LexDQString lexes a Double-Quoted String 465 | // 466 | func LexDQString(ctx *LexContext, l *lexer.Lexer) LexFn { 467 | // Open quote 468 | // 469 | expectRune(l, runeDQuote, "expecting double-quote ('\"')") 470 | l.EmitType(TokenDQuote) 471 | ctx.PushFn(lexEndDQString) 472 | return lexDQStringElement 473 | } 474 | 475 | // lexEndDQString 476 | // 477 | func lexEndDQString(_ *LexContext, l *lexer.Lexer) LexFn { 478 | expectRune(l, runeDQuote, "expecting double-quote ('\"')") 479 | l.EmitType(TokenDQuote) 480 | return nil 481 | } 482 | 483 | // lexDQStringElement 484 | // 485 | func lexDQStringElement(ctx *LexContext, l *lexer.Lexer) LexFn { 486 | switch { 487 | // Consume a run of printable, non-quote non-escape characters 488 | // 489 | case matchOneOrMore(l, isPrintNonDQuoteNonBackslashNonDollar): 490 | l.EmitToken(TokenRunes) 491 | // Back-slash '\' 492 | // 493 | case matchRune(l, runeBackSlash): 494 | // In DQuote mode, currently only '\', '"' and '$' are escapable 495 | // Anything else is considered two separate characters 496 | // 497 | if matchRune(l, runeBackSlash, runeDQuote, runeDollar) { 498 | l.EmitToken(TokenEscapeSequence) 499 | } else { 500 | l.EmitToken(TokenRunes) 501 | } 502 | case l.CanPeek(1) && l.Peek(1) == runeDollar: 503 | ctx.PushFn(lexDQStringElement) 504 | return lexDollarString 505 | 506 | default: 507 | return nil 508 | } 509 | return lexDQStringElement 510 | } 511 | 512 | // lexUQString lexes an Unquoted string (no quotes, no interpolation) 513 | // 514 | func lexUQString(_ *LexContext, l *lexer.Lexer) LexFn { 515 | matchZeroOrMore(l, isPrintNonSpace) 516 | l.EmitToken(TokenRunes) // Could be empty 517 | return nil 518 | } 519 | 520 | // LexDocBlockDesc lexes a single dock block description line. 521 | // 522 | func LexDocBlockDesc(ctx *LexContext, l *lexer.Lexer) LexFn { 523 | m := l.Marker() 524 | if matchOne(l, isHash) { 525 | matchZeroOrMore(l, isSpaceOrTab) 526 | // 2+ # = ignored as a comment 527 | // 528 | if matchOne(l, isHash) { 529 | // Consume rest of line, including newline 530 | // 531 | for !matchNewlineOrEOF(l) { 532 | l.Next() 533 | } 534 | l.Clear() // Discard 535 | } else { 536 | l.Clear() // Clear # and leading space 537 | m = l.Marker() 538 | // Possible attribute 539 | // 540 | if matchConfigAttrID(l) { 541 | id := strings.ToUpper(l.PeekToken()) 542 | if t, ok := cmdConfigTokens[id]; ok { 543 | // We've gone this far, let's go ahead and emit 544 | // the attribute (vs rewind and re-scan) 545 | // 546 | l.Clear() 547 | l.EmitToken(TokenNewline) 548 | l.EmitType(TokenConfigDescEnd) 549 | l.EmitType(t) 550 | return nil 551 | } 552 | } 553 | m.Apply() 554 | // Desc line 555 | // 556 | ctx.PushFn(LexDocBlockDesc) 557 | return LexDocBlockNQString 558 | } 559 | return LexDocBlockDesc 560 | } 561 | m.Apply() 562 | l.EmitType(TokenConfigDescEnd) 563 | return nil 564 | } 565 | 566 | // LexDocBlockNQString lexes a doc block comment line 567 | // 568 | func LexDocBlockNQString(ctx *LexContext, l *lexer.Lexer) LexFn { 569 | switch { 570 | // Consume a run of printable, non-escape characters 571 | // 572 | case matchOneOrMore(l, isPrintNonBackslashNonDollarNonReturn): 573 | l.EmitToken(TokenRunes) 574 | // Back-slash '\' 575 | // 576 | case matchRune(l, runeBackSlash): 577 | // Currently only '\' and '$' are escapable 578 | // Anything else is considered two separate characters 579 | // 580 | if matchRune(l, runeBackSlash, runeDollar) { 581 | l.EmitToken(TokenEscapeSequence) 582 | } else { 583 | l.EmitToken(TokenRunes) 584 | } 585 | // Variable reference 586 | // 587 | case l.CanPeek(1) && l.Peek(1) == runeDollar: 588 | if l.CanPeek(2) && l.Peek(2) == runeLBrace { 589 | ctx.PushFn(LexDocBlockNQString) 590 | l.EmitType(TokenVarRefStart) 591 | return LexVarRef 592 | } 593 | l.Next() // Consume $ 594 | l.EmitToken(TokenRunes) 595 | default: 596 | if matchNewline(l) { 597 | l.EmitType(TokenNewline) 598 | } 599 | return nil 600 | } 601 | return LexDocBlockNQString 602 | } 603 | 604 | // LexDocBlockAttr lexes a doc block attribute line 605 | // 606 | func LexDocBlockAttr(_ *LexContext, l *lexer.Lexer) LexFn { 607 | m := l.Marker() 608 | if matchOne(l, isHash) { 609 | matchZeroOrMore(l, isSpaceOrTab) 610 | // 2+ # = ignored as a comment 611 | // 612 | if matchOne(l, isHash) { 613 | // Consume rest of line, including newline 614 | // 615 | for !matchNewlineOrEOF(l) { 616 | l.Next() 617 | } 618 | l.Clear() // Discard 619 | return LexDocBlockAttr 620 | } 621 | l.Clear() // Clear # and leading space 622 | // Ignore whitespace-only comment 623 | // 624 | if matchNewlineOrEOF(l) { 625 | l.Clear() // Discard 626 | return LexDocBlockAttr 627 | } 628 | if matchConfigAttrID(l) { 629 | name := strings.ToUpper(l.PeekToken()) 630 | if t, ok := cmdConfigTokens[name]; ok { 631 | l.EmitType(t) 632 | return LexDocBlockAttr 633 | } 634 | l.EmitErrorf("Unrecognized command attribute: %s", name) 635 | return nil 636 | } 637 | l.EmitError("expecting command attribute") 638 | return nil 639 | } 640 | m.Apply() 641 | l.EmitType(TokenConfigEnd) 642 | return nil 643 | } 644 | 645 | // LexCmdConfigShell lexes a doc block SHELL line 646 | // 647 | func LexCmdConfigShell(ctx *LexContext, l *lexer.Lexer) LexFn { 648 | ignoreSpace(l) 649 | ctx.PushFn(LexIgnoreNewline) 650 | return LexCmdShellName 651 | } 652 | 653 | // LexCmdConfigUsage lexes a doc block USAGE line 654 | // 655 | func LexCmdConfigUsage(_ *LexContext, l *lexer.Lexer) LexFn { 656 | ignoreSpace(l) 657 | return LexDocBlockNQString 658 | } 659 | 660 | // LexCmdConfigOpt matches: name [ ! | ? | ?= VALUE ] 661 | // 662 | func LexCmdConfigOpt(_ *LexContext, l *lexer.Lexer) LexFn { 663 | // Whitespace 664 | // 665 | ignoreSpace(l) 666 | 667 | // ID 668 | // 669 | if !matchID(l) { 670 | l.EmitError("expecting option name") 671 | return nil 672 | } 673 | l.EmitToken(TokenConfigOptName) 674 | 675 | // Whitespace 676 | // 677 | ignoreSpace(l) 678 | 679 | // ? (mark opt as optional) | ?= (mark opt as optional with default) 680 | // The naked optional indicator is itself optional :) 681 | // 682 | if matchRune(l, runeQMark) { 683 | // ?= 684 | if matchRune(l, runeEquals) { 685 | l.EmitType(TokenQMarkEquals) 686 | } else { 687 | l.Clear() // Discard - Optional is already the default 688 | } 689 | } else 690 | // ! (mark opt as required) 691 | // 692 | if matchRune(l, runeBang) { 693 | l.EmitType(TokenBang) 694 | } 695 | 696 | return LexCmdConfigOptTail 697 | } 698 | 699 | // LexCmdConfigOptTail matches: [-l] [--long] [