├── .codeclimate.yml ├── .github └── workflows │ ├── build.yml │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cmd └── gaze │ ├── main.go │ └── version ├── codecov.yml ├── doc ├── img │ ├── p01.png │ ├── p02.png │ ├── p03.png │ ├── p04.png │ └── p05.png └── parallel.md ├── go.mod ├── go.sum ├── pkg ├── app │ ├── app.go │ ├── app_test.go │ ├── args.go │ └── option.go ├── config │ ├── config.go │ ├── config_test.go │ ├── default.go │ └── default.yml ├── gazer │ ├── commands.go │ ├── commands_test.go │ ├── gazer.go │ ├── gazer_test.go │ ├── proc.go │ ├── proc_test.go │ ├── template.go │ └── template_test.go ├── gutil │ ├── fs.go │ ├── fs_test.go │ ├── time.go │ └── time_test.go ├── logger │ ├── logger.go │ └── logger_test.go ├── notify │ ├── notify.go │ └── notify_test.go └── uniq │ ├── uniq.go │ └── uniq_test.go └── test └── e2e ├── files ├── append.py ├── append.rb ├── hello.go ├── hello.py ├── hello.rb ├── hello.rs ├── repeat.py └── repeat.rb ├── run_forever.sh ├── test01.sh ├── test02.sh ├── test03.sh ├── test04.sh └── test_all.sh /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | checks: 3 | return-statements: 4 | enabled: false 5 | exclude_patterns: 6 | - "pkg/**/*_test.go" 7 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | permissions: 4 | contents: read 5 | pull-requests: write 6 | 7 | on: [push, pull_request, workflow_dispatch] 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | go-version: ["1.24"] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | - name: Install Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: ${{ matrix.go-version }} 23 | cache: true 24 | - name: Make 25 | run: make build-all 26 | - name: Check Cross-Compilation 27 | run: make check-cross-compile 28 | - name: Check if manual build 29 | id: manual_build 30 | if: github.event_name == 'workflow_dispatch' 31 | run: echo "is_manual_build=true" >> $GITHUB_OUTPUT 32 | - name: Upload Build as Artifact (macOS) 33 | if: steps.manual_build.outputs.is_manual_build == 'true' 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: macos_amd-${{ matrix.go-version }} 37 | path: dist/macos_amd 38 | - name: Upload Build as Artifact (Linux) 39 | if: steps.manual_build.outputs.is_manual_build == 'true' 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: linux-${{ matrix.go-version }} 43 | path: dist/linux 44 | - name: Upload Build as Artifact (Windows) 45 | if: steps.manual_build.outputs.is_manual_build == 'true' 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: windows-${{ matrix.go-version }} 49 | path: dist/windows 50 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | permissions: 4 | contents: read 5 | pull-requests: write 6 | 7 | on: [push, pull_request] 8 | 9 | jobs: 10 | lint: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | go-version: ["1.24"] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | - name: Install Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: ${{ matrix.go-version }} 23 | cache: true 24 | - name: go vet 25 | run: go vet ./pkg/... ./cmd/... 26 | - name: staticcheck 27 | run: | 28 | go install honnef.co/go/tools/cmd/staticcheck@latest 29 | staticcheck ./pkg/... ./cmd/... 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | pull-requests: write 6 | 7 | on: 8 | push: 9 | tags: 10 | - "v*.*.*" 11 | 12 | jobs: 13 | release: 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest] 17 | go-version: ["1.24"] 18 | runs-on: ${{ matrix.os }} 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | - name: Install Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: ${{ matrix.go-version }} 27 | cache: true 28 | - name: Verify version 29 | run: | 30 | VERSION_FILE=$(cat cmd/gaze/version) 31 | TAG_VERSION=${GITHUB_REF##*/} 32 | if [ "$VERSION_FILE" != "$TAG_VERSION" ]; then 33 | echo "Error: Version in version file ($VERSION_FILE) does not match tag version ($TAG_VERSION)" 34 | exit 1 35 | fi 36 | - name: Package 37 | run: make package 38 | - name: Upload 39 | uses: softprops/action-gh-release@v2 40 | with: 41 | files: dist/*.zip 42 | 43 | license: 44 | strategy: 45 | matrix: 46 | os: [ubuntu-latest] 47 | go-version: ["1.24"] 48 | runs-on: ${{ matrix.os }} 49 | steps: 50 | - name: Checkout code 51 | uses: actions/checkout@v4 52 | - name: Install Go 53 | uses: actions/setup-go@v5 54 | with: 55 | go-version: ${{ matrix.go-version }} 56 | cache: true 57 | - name: Install Licenses tool 58 | run: go install github.com/google/go-licenses@latest 59 | - name: License 60 | run: | 61 | go-licenses save ./... --save_path=license 62 | go-licenses csv ./... | tee license/license.csv 63 | zip -r ./license.zip ./license 64 | - name: Upload 65 | uses: softprops/action-gh-release@v2 66 | with: 67 | files: license.zip 68 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | permissions: 4 | contents: read 5 | pull-requests: write 6 | 7 | on: [push, pull_request] 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest, macos-latest, windows-latest] 14 | go-version: ["1.24"] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - name: Install tools 18 | run: curl --version 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | - name: Install Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ matrix.go-version }} 25 | cache: true 26 | - name: Test 27 | if: matrix.os != 'ubuntu-latest' 28 | uses: nick-fields/retry@v2 29 | with: 30 | timeout_minutes: 10 31 | max_attempts: 30 32 | command: go test -v ./pkg/... 33 | - name: Test with coverage 34 | if: matrix.os == 'ubuntu-latest' 35 | run: go test -v -coverprofile=coverage.txt -covermode=atomic ./pkg/... 36 | - name: Codecov 37 | if: matrix.os == 'ubuntu-latest' 38 | uses: codecov/codecov-action@v5 39 | with: 40 | fail_ci_if_error: true 41 | flags: unittests 42 | token: ${{ secrets.CODECOV_TOKEN }} 43 | - name: End to end test 44 | if: matrix.os == 'ubuntu-latest' 45 | uses: nick-fields/retry@v2 46 | with: 47 | timeout_minutes: 10 48 | max_attempts: 30 49 | command: make e2e 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | *.pdb 8 | 9 | # Test binary, build with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | /dist/ 16 | .vscode/ 17 | test/**/*.log 18 | coverage.txt 19 | 20 | ?.py 21 | ?.rb 22 | _*.* 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-present wtetsu 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOCMD=go 2 | BINARY_NAME=gaze 3 | OUT=dist 4 | CMD=cmd/gaze/main.go 5 | VERSION := $(shell cat cmd/gaze/version) 6 | PLATFORMS = macos_amd macos_arm windows linux 7 | 8 | build: 9 | ${GOCMD} build -ldflags "-s -w" -v ${CMD} 10 | build-all: build-macos-amd build-macos-arm build-windows build-linux 11 | build-all-amd: build-macos-amd build-windows build-linux 12 | build-macos-amd: 13 | GOOS=darwin GOARCH=amd64 ${GOCMD} build -ldflags "-s -w" -o ${OUT}/gaze_macos_amd_${VERSION}/${BINARY_NAME} -v ${CMD} 14 | build-macos-arm: 15 | GOOS=darwin GOARCH=arm64 ${GOCMD} build -ldflags "-s -w" -o ${OUT}/gaze_macos_arm_${VERSION}/${BINARY_NAME} -v ${CMD} 16 | build-windows: 17 | GOOS=windows GOARCH=amd64 ${GOCMD} build -ldflags "-s -w" -o ${OUT}/gaze_windows_${VERSION}/${BINARY_NAME}.exe -v ${CMD} 18 | build-linux: 19 | GOOS=linux GOARCH=amd64 ${GOCMD} build -ldflags "-s -w" -o ${OUT}/gaze_linux_${VERSION}/${BINARY_NAME} -v ${CMD} 20 | ut: 21 | ${GOCMD} test github.com/wtetsu/gaze/pkg/... 22 | e2e: 23 | ${GOCMD} build -ldflags "-s -w" -o test/e2e/ -v ${CMD} 24 | cd test/e2e && sh test_all.sh 25 | cov: 26 | ${GOCMD} test -coverprofile=coverage.txt -covermode=atomic github.com/wtetsu/gaze/pkg/... 27 | clean: 28 | ${GOCMD} clean ${CMD} 29 | @for plat in $(PLATFORMS); do \ 30 | rm -rf ${OUT}/gaze_$${plat}_${VERSION}; \ 31 | rm -f ${OUT}/gaze_$${plat}_${VERSION}.zip; \ 32 | done 33 | check-cross-compile: 34 | @echo "Checking cross-compiled binaries..." 35 | @file ${OUT}/gaze_macos_amd_${VERSION}/${BINARY_NAME} | grep "Mach-O 64-bit" | grep -c x86_64 | grep -q "1" || (echo "Error: macOS amd binary is not correctly built" && exit 1) 36 | @file ${OUT}/gaze_macos_arm_${VERSION}/${BINARY_NAME} | grep "Mach-O 64-bit" | grep -c arm64 | grep -q "1" || (echo "Error: macOS arm binary is not correctly built" && exit 1) 37 | @file ${OUT}/gaze_windows_${VERSION}/${BINARY_NAME}.exe | grep "MS Windows" | grep -c x86-64 | grep -q "1" || (echo "Error: Windows binary is not correctly built" && exit 1) 38 | @file ${OUT}/gaze_linux_${VERSION}/${BINARY_NAME} | grep "ELF 64-bit LSB" | grep -c x86-64 | grep -q "1" || (echo "Error: Linux binary is not correctly built" && exit 1) 39 | @echo "Cross-compilation check passed!" 40 | package: clean build-all check-cross-compile 41 | @for plat in $(PLATFORMS); do \ 42 | cp LICENSE README.md ${OUT}/gaze_$${plat}_${VERSION}; \ 43 | (cd ${OUT} && zip -r gaze_$${plat}_${VERSION}.zip ./gaze_$${plat}_${VERSION}); \ 44 | done 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | gaze logo 4 |

5 | 6 |

7 | Test 8 | Go Report Card 9 | Maintainability 10 | codecov 11 | Go Reference 12 |

13 | 14 | 15 | # 👁️Gaze: Save & Run 16 | 17 | 18 | Focus on your code, not commands! 19 | 20 | 21 | 22 | ---- 23 | 24 | 25 | 😵‍💫 Rerunning commands after each edit disrupts your flow. 26 | 27 | Let Gaze handle it! 28 | 29 | - Save a.py -> 👁️Runs `python a.py` 30 | - Save a.rb -> 👁️Runs `rubocop` 31 | - Save a.go -> 👁️Runs `make build` 32 | - Save Dockerfile -> 👁️Runs `docker build` 33 | - And so forth... 34 | 35 | ## Installation 36 | 37 | ### Homebrew (macOS) 38 | 39 | ``` 40 | brew install gaze 41 | ``` 42 | 43 | Or, [download binary](https://github.com/wtetsu/gaze/releases) (macOS, Windows, Linux) 44 | 45 | ## Quick start 46 | 47 | 48 | Setting up Gaze is easy. 49 | 50 | ``` 51 | # Gaze current directory 52 | gaze . 53 | ``` 54 | 55 | Then, open your favorite editor in another terminal and start editing! 56 | 57 | 58 | ``` 59 | vi a.py 60 | ``` 61 | 62 | 63 | 64 | ## Why Gaze? (Features) 65 | 66 | Plenty of 'update-and-run' tools exist, but if you're coding, Gaze is the ideal choice — it's designed for coding flow. 67 | 68 | - 📦 Easy to use, out-of-the-box 69 | - ⚡ Lightning-fast response 70 | - 🌎 Language-agnostic, editor-agnostic 71 | - 🔧 Flexible configuration 72 | - 📝 Create-and-rename file actions handling 73 | - 🚀 Optimal parallel handling 74 | - See also: [Parallel handling](/doc/parallel.md) 75 | - 76 | 77 | 78 | # How to use 79 | 80 | Gaze prioritizes ease of use with its simple invocation. 81 | 82 | ``` 83 | gaze . 84 | ``` 85 | 86 | Then, switch to another terminal and run `vi a.py`. Gaze executes a.py in response to your file modifications. 87 | 88 | --- 89 | 90 | Gaze at one file. 91 | 92 | ``` 93 | gaze a.py 94 | ``` 95 | 96 | --- 97 | 98 | Specify files using pattern matching (\*, \*\*, ?, {, }) 99 | 100 | ``` 101 | gaze "*.py" 102 | ``` 103 | 104 | ``` 105 | gaze "src/**/*.rb" 106 | ``` 107 | 108 | ``` 109 | gaze "{aaa,bbb}/*.{rb,py}" 110 | ``` 111 | 112 | --- 113 | 114 | Specify a custom command by `-c` option. 115 | 116 | ``` 117 | gaze "src/**/*.js" -c "eslint {{file}}" 118 | ``` 119 | 120 | --- 121 | 122 | Kill the previous process before launching a new process. This is useful if you are writing a server. 123 | 124 | ``` 125 | gaze -r server.py 126 | ``` 127 | 128 | --- 129 | 130 | Kill a running process after 1000(ms). This is useful if you love infinite loops. 131 | 132 | ``` 133 | gaze -t 1000 complicated.py 134 | ``` 135 | 136 | --- 137 | 138 | Specify multiple commands within quotes, separated by newlines. 139 | 140 | ``` 141 | gaze "*.cpp" -c "gcc {{file}} -o a.out 142 | ls -l a.out 143 | ./a.out" 144 | ``` 145 | 146 | Output when a.cpp was updated. 147 | 148 | ``` 149 | [gcc a.cpp -o a.out](1/3) 150 | 151 | [ls -l a.out](2/3) 152 | -rwxr-xr-x 1 user group 42155 Mar 3 00:31 a.out 153 | 154 | [./a.out](3/3) 155 | hello, world! 156 | ``` 157 | 158 | Gaze will not execute subsequent commands if a command exits with a non-zero status. 159 | 160 | 161 | ``` 162 | [gcc a.cpp -o a.out](1/3) 163 | a.cpp: In function 'int main()': 164 | a.cpp:5:28: error: expected ';' before '}' token 165 | printf("hello, world!\n") 166 | ^ 167 | ; 168 | } 169 | ~ 170 | exit status 1 171 | ``` 172 | 173 | ### Configuration 174 | 175 | Gaze is language-agnostic. 176 | 177 | For convenience, it provides helpful default configurations for a variety of popular languages (e.g., Go, Python, Ruby, JavaScript, Rust, etc.). 178 | 179 | 180 | ``` 181 | gaze a.py 182 | ``` 183 | 184 | By default, this command is equivalent to `gaze a.py -c 'python "{{file}}"'` because the default configuration includes: 185 | 186 | ```yaml 187 | commands: 188 | - ext: .py 189 | cmd: python "{{file}}" 190 | ``` 191 | 192 | 193 | 194 | You can view the default YAML configuration using `gaze -y`. 195 | 196 | 197 |
198 | ⚙️The default configuration 199 | 200 | ```yaml 201 | commands: 202 | - ext: .go 203 | cmd: go run "{{file}}" 204 | - ext: .py 205 | cmd: python "{{file}}" 206 | - ext: .rb 207 | cmd: ruby "{{file}}" 208 | - ext: .js 209 | cmd: node "{{file}}" 210 | - ext: .d 211 | cmd: dmd -run "{{file}}" 212 | - ext: .groovy 213 | cmd: groovy "{{file}}" 214 | - ext: .php 215 | cmd: php "{{file}}" 216 | - ext: .java 217 | cmd: java "{{file}}" 218 | - ext: .kts 219 | cmd: kotlinc -script "{{file}}" 220 | - ext: .rs 221 | cmd: | 222 | rustc "{{file}}" -o"{{base0}}.out" 223 | ./"{{base0}}.out" 224 | - ext: .cpp 225 | cmd: | 226 | gcc "{{file}}" -o"{{base0}}.out" 227 | ./"{{base0}}.out" 228 | - ext: .ts 229 | cmd: | 230 | tsc "{{file}}" --outFile "{{base0}}.out" 231 | node ./"{{base0}}.out" 232 | - ext: .zig 233 | cmd: zig run "{{file}}" 234 | - re: ^Dockerfile$ 235 | cmd: docker build -f "{{file}}" . 236 | 237 | log: 238 | start: "[{{command}}]{{step}}" 239 | end: "({{elapsed_ms}}ms)" 240 | ``` 241 | 242 |
243 | 244 | --- 245 | 246 | To customize your configuration, create your own configuration file: 247 | 248 | 249 | ``` 250 | gaze -y > ~/.gaze.yml 251 | vi ~/.gaze.yml 252 | ``` 253 | 254 | Gaze searches for a configuration file in the following order: 255 | 256 | 1. A file specified by -f option 257 | 1. ~/.config/gaze/gaze.yml 258 | 1. ~/.gaze.yml 259 | 1. (Default) 260 | 261 | 262 | 263 | 264 | 265 | ### Options: 266 | 267 | ``` 268 | Usage: gaze [options] file(s) 269 | 270 | Options: 271 | -c Command(s) to run when files change. 272 | -r Restart mode: send SIGTERM to the running process before starting the next command. 273 | -t Timeout (ms): send SIGTERM to the running process after the specified time. 274 | -f Path to a YAML configuration file. 275 | -v Verbose mode: show additional information. 276 | -q Quiet mode: suppress normal output. 277 | -y Show the default YAML configuration. 278 | -h Show help. 279 | --color Set color mode (0: plain, 1: colorful). 280 | --version Show version information. 281 | 282 | Examples: 283 | gaze . 284 | gaze main.go 285 | gaze a.rb b.rb 286 | gaze -c make "**/*.c" 287 | gaze -c "eslint {{file}}" "src/**/*.js" 288 | gaze -r server.py 289 | gaze -t 1000 complicated.py 290 | 291 | For more information: https://github.com/wtetsu/gaze 292 | ``` 293 | 294 | ### Command format 295 | 296 | You can use [Mustache]() templates in your commands. 297 | 298 | ``` 299 | gaze -c "echo {{file}} {{ext}} {{abs}}" . 300 | ``` 301 | 302 | | Parameter | Example | 303 | | --------- | ------------------------- | 304 | | {{file}} | src/mod1/main.py | 305 | | {{ext}} | .py | 306 | | {{base}} | main.py | 307 | | {{base0}} | main | 308 | | {{dir}} | src/mod1 | 309 | | {{abs}} | /my/proj/src/mod1/main.py | 310 | 311 | 312 | # Third-party data 313 | 314 | - Great Go libraries 315 | - See [go.mod](https://github.com/wtetsu/gaze/blob/master/go.mod) and [license.zip](https://github.com/wtetsu/gaze/releases) 316 | -------------------------------------------------------------------------------- /cmd/gaze/main.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package main 8 | 9 | import ( 10 | _ "embed" 11 | "errors" 12 | "fmt" 13 | "os" 14 | "strings" 15 | 16 | "github.com/wtetsu/gaze/pkg/app" 17 | "github.com/wtetsu/gaze/pkg/config" 18 | "github.com/wtetsu/gaze/pkg/logger" 19 | ) 20 | 21 | //go:embed version 22 | var version string 23 | 24 | const ( 25 | errTimeout = "timeout must be more than 0" 26 | errColor = "color must be 0 or 1" 27 | errMaxWatchDirs = "maxWatchDirs must be more than 0" 28 | ) 29 | 30 | func main() { 31 | args := app.ParseArgs(os.Args, func() { 32 | fmt.Println(usage2()) 33 | }) 34 | 35 | if !args.Debug() { 36 | // panic handler 37 | defer func() { 38 | if err := recover(); err != nil { 39 | logger.ErrorObject(err) 40 | os.Exit(1) 41 | } 42 | }() 43 | } 44 | 45 | done, exitCode := earlyExit(args) 46 | if done { 47 | os.Exit(exitCode) 48 | return 49 | } 50 | 51 | initLogger(args) 52 | 53 | err := validate(args) 54 | if err != nil { 55 | logger.Error(err.Error()) 56 | return 57 | } 58 | 59 | appOptions := app.NewAppOptions(args.Timeout(), args.Restart(), args.MaxWatchDirs()) 60 | 61 | err = app.Start(args.Targets(), args.UserCommand(), args.File(), appOptions) 62 | if err != nil { 63 | logger.ErrorObject(err) 64 | os.Exit(1) 65 | } 66 | } 67 | 68 | func earlyExit(args *app.Args) (bool, int) { 69 | if args.Help() { 70 | fmt.Println(usage2()) 71 | return true, 0 72 | } 73 | 74 | if args.Version() { 75 | fmt.Println("gaze " + version) 76 | return true, 0 77 | } 78 | 79 | if args.Yaml() { 80 | fmt.Println(config.Default()) 81 | return true, 0 82 | } 83 | 84 | if len(args.Targets()) == 0 { 85 | fmt.Println(usage1()) 86 | return true, 1 87 | } 88 | return false, 0 89 | } 90 | 91 | func initLogger(args *app.Args) { 92 | if args.Color() == 0 { 93 | logger.Plain() 94 | } else { 95 | logger.Colorful() 96 | } 97 | if args.Quiet() { 98 | logger.Level(logger.QUIET) 99 | } 100 | if args.Verbose() { 101 | logger.Level(logger.VERBOSE) 102 | } 103 | if args.Debug() { 104 | logger.Level(logger.DEBUG) 105 | } 106 | } 107 | 108 | func validate(args *app.Args) error { 109 | var errorList []string 110 | if args.Timeout() <= 0 { 111 | errorList = append(errorList, errTimeout) 112 | } 113 | if args.Color() != 0 && args.Color() != 1 { 114 | errorList = append(errorList, errColor) 115 | } 116 | if args.MaxWatchDirs() <= 0 { 117 | errorList = append(errorList, errMaxWatchDirs) 118 | } 119 | if len(errorList) >= 1 { 120 | return errors.New(strings.Join(errorList, "\n")) 121 | } 122 | return nil 123 | } 124 | 125 | func usage1() string { 126 | return `Usage: gaze [options] file(s) 127 | 128 | Options(excerpt): 129 | -c Command(s) to run when files change. 130 | -r Restart mode: send SIGTERM to the running process before starting the next command. 131 | -t Timeout (ms): send SIGTERM to the running process after the specified time. 132 | -h Show help. 133 | 134 | Examples: 135 | gaze . 136 | gaze main.go 137 | gaze a.rb b.rb 138 | gaze -c make "**/*.c" 139 | gaze -c "eslint {{file}}" "src/**/*.js" 140 | gaze -r server.py 141 | gaze -t 1000 complicated.py 142 | 143 | For more information: https://github.com/wtetsu/gaze` 144 | } 145 | 146 | func usage2() string { 147 | return `Usage: gaze [options] file(s) 148 | 149 | Options: 150 | -c Command(s) to run when files change. 151 | -r Restart mode: send SIGTERM to the running process before starting the next command. 152 | -t Timeout (ms): send SIGTERM to the running process after the specified time. 153 | -f Path to a YAML configuration file. 154 | -v Verbose mode: show additional information. 155 | -q Quiet mode: suppress normal output. 156 | -y Show the default YAML configuration. 157 | -h Show help. 158 | --color Set color mode (0: plain, 1: colorful). 159 | --version Show version information. 160 | 161 | Examples: 162 | gaze . 163 | gaze main.go 164 | gaze a.rb b.rb 165 | gaze -c make "**/*.c" 166 | gaze -c "eslint {{file}}" "src/**/*.js" 167 | gaze -r server.py 168 | gaze -t 1000 complicated.py 169 | 170 | For more information: https://github.com/wtetsu/gaze` 171 | } 172 | -------------------------------------------------------------------------------- /cmd/gaze/version: -------------------------------------------------------------------------------- 1 | v1.2.2 -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | threshold: 85% 6 | patch: 7 | default: 8 | enabled: false 9 | -------------------------------------------------------------------------------- /doc/img/p01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtetsu/gaze/c8894363d760e4ca7c6e76eb6e3746fed9b38de4/doc/img/p01.png -------------------------------------------------------------------------------- /doc/img/p02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtetsu/gaze/c8894363d760e4ca7c6e76eb6e3746fed9b38de4/doc/img/p02.png -------------------------------------------------------------------------------- /doc/img/p03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtetsu/gaze/c8894363d760e4ca7c6e76eb6e3746fed9b38de4/doc/img/p03.png -------------------------------------------------------------------------------- /doc/img/p04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtetsu/gaze/c8894363d760e4ca7c6e76eb6e3746fed9b38de4/doc/img/p04.png -------------------------------------------------------------------------------- /doc/img/p05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtetsu/gaze/c8894363d760e4ca7c6e76eb6e3746fed9b38de4/doc/img/p05.png -------------------------------------------------------------------------------- /doc/parallel.md: -------------------------------------------------------------------------------- 1 | # Parallel handling 2 | 3 | Gaze deals with multiple processes nicely. 4 | 5 | ## The simplest case 6 | 7 | 1. You update a.py 8 | 2. Gaze invokes `python a.py` 9 | 3. `python a.py` finishes 10 | 11 | ![p01](img/p01.png) 12 | 13 | No controversial point. 14 | 15 | ## Update twice in a row 16 | 17 | In case you update a.py again during the first process is still running, 18 | 19 | - Gaze waits until the first process finishes. 20 | - Right after the first process finished, a second process launches. 21 | 22 | ![p02](img/p02.png) 23 | 24 | Since a.py was modified after the first `python a.py` launched, running `python a.py` again after the first process finished is a natural behavior. 25 | 26 | ## Update more than twice in a row 27 | 28 | In case you update a.py **multiple times** during the first process is still running, 29 | 30 | - Gaze waits until the first process finishes. 31 | - Right after the first process finished, a second process launches. 32 | - (Gaze does NOT launch third process) 33 | 34 | ![p03](img/p03.png) 35 | 36 | Note that **Gaze doesn't invoke the third process** in this case. Since there is no update after the second launch, not invoking a third process is natural behavior as a development purpose tool. 37 | 38 | ## Update multiple files 39 | 40 | Gaze deals with multiple processes nicely. 41 | 42 | In case you update another file during the first process is still running, what should occur? **Gaze runs a second process in parallel**. 43 | 44 | ![p04](img/p04.png) 45 | 46 | Since you may develop multiple files and one might keep running for a long time, this is also natural behavior as a development purpose command. 47 | 48 | ## Update multiple files but run one command 49 | 50 | There is a case Gaze runs the same command even when different files are updated. In this case, Gaze invokes the same command `make` when any .go file was updated. 51 | 52 | ``` 53 | gaze -c make '*.go' 54 | ``` 55 | 56 | What should happen when you update multiple \*.go files during the first `make` is still running? The Gaze's answer is below. 57 | 58 | ![p05](img/p05.png) 59 | 60 | **Gaze manages processes by commands, NOT files**. Since the same command is specified for any files, Gaze waits until the first `make` finished in this case. Right after the first `make` finished, the next `make` launches. 61 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wtetsu/gaze 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/bmatcuk/doublestar v1.3.4 7 | github.com/cbroglie/mustache v1.4.0 8 | github.com/fatih/color v1.18.0 9 | github.com/fsnotify/fsnotify v1.7.0 10 | github.com/mattn/go-shellwords v1.0.12 11 | gopkg.in/yaml.v3 v3.0.1 12 | ) 13 | 14 | require ( 15 | github.com/kr/text v0.2.0 // indirect 16 | github.com/mattn/go-colorable v0.1.13 // indirect 17 | github.com/mattn/go-isatty v0.0.20 // indirect 18 | golang.org/x/sys v0.25.0 // indirect 19 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= 2 | github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= 3 | github.com/cbroglie/mustache v1.4.0 h1:Azg0dVhxTml5me+7PsZ7WPrQq1Gkf3WApcHMjMprYoU= 4 | github.com/cbroglie/mustache v1.4.0/go.mod h1:SS1FTIghy0sjse4DUVGV1k/40B1qE1XkD9DtDsHo9iM= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 7 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 8 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 9 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 10 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 11 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 12 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 13 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 14 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 15 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 16 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 17 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 18 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 19 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 20 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 21 | github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= 22 | github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= 23 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 24 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 25 | golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= 26 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 28 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 29 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 30 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 31 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 32 | -------------------------------------------------------------------------------- /pkg/app/app.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package app 8 | 9 | import ( 10 | "flag" 11 | "runtime" 12 | "strings" 13 | 14 | "github.com/wtetsu/gaze/pkg/config" 15 | "github.com/wtetsu/gaze/pkg/gazer" 16 | "github.com/wtetsu/gaze/pkg/logger" 17 | "github.com/wtetsu/gaze/pkg/uniq" 18 | ) 19 | 20 | // Start starts a gaze process 21 | func Start(watchFiles []string, userCommand string, file string, appOptions AppOptions) error { 22 | theGazer, err := gazer.New(watchFiles, appOptions.MaxWatchDirs()) 23 | if err != nil { 24 | return err 25 | } 26 | defer theGazer.Close() 27 | 28 | commandConfigs, err := createCommandConfig(userCommand, file) 29 | if err != nil { 30 | return err 31 | } 32 | err = theGazer.Run(commandConfigs, appOptions.Timeout(), appOptions.Restart()) 33 | return err 34 | } 35 | 36 | func createCommandConfig(userCommand string, file string) (*config.Config, error) { 37 | if userCommand != "" { 38 | logger.Debug("userCommand: %s", userCommand) 39 | commandConfigs, err := config.NewWithFixedCommand(userCommand) 40 | if err != nil { 41 | return nil, err 42 | } 43 | return commandConfigs, nil 44 | } 45 | 46 | if file != "" { 47 | return config.LoadConfigFromFile(file) 48 | } 49 | 50 | return config.LoadPreferredConfig() 51 | } 52 | 53 | // ParseArgs parses command arguments. 54 | func ParseArgs(osArgs []string, usage func()) *Args { 55 | flagSet := flag.NewFlagSet(osArgs[0], flag.ExitOnError) 56 | 57 | flagSet.Usage = func() { 58 | if usage != nil { 59 | usage() 60 | } 61 | } 62 | 63 | var defaultMaxWatchDirs int 64 | if runtime.GOOS == "darwin" { 65 | defaultMaxWatchDirs = 100 66 | } else { 67 | defaultMaxWatchDirs = 10000 68 | } 69 | 70 | help := flagSet.Bool("h", false, "") 71 | restart := flagSet.Bool("r", false, "") 72 | userCommand := flagSet.String("c", "", "") 73 | timeout := flagSet.Int64("t", 1<<50, "") 74 | yaml := flagSet.Bool("y", false, "") 75 | quiet := flagSet.Bool("q", false, "") 76 | verbose := flagSet.Bool("v", false, "") 77 | file := flagSet.String("f", "", "") 78 | color := flagSet.Int("color", 1, "") 79 | debug := flagSet.Bool("debug", false, "") 80 | version := flagSet.Bool("version", false, "") 81 | maxWatchDirs := flagSet.Int("w", defaultMaxWatchDirs, "") 82 | 83 | files := []string{} 84 | optionStartIndex := len(osArgs) 85 | for i, a := range osArgs[1:] { 86 | if strings.HasPrefix(a, "-") { 87 | optionStartIndex = i + 1 88 | break 89 | } 90 | files = append(files, a) 91 | } 92 | err := flagSet.Parse(osArgs[optionStartIndex:]) 93 | if err != nil { 94 | return nil 95 | } 96 | 97 | u := uniq.New() 98 | u.AddAll(files) 99 | u.AddAll(flagSet.Args()) 100 | 101 | args := Args{ 102 | help: *help, 103 | restart: *restart, 104 | userCommand: *userCommand, 105 | timeout: *timeout, 106 | yaml: *yaml, 107 | quiet: *quiet, 108 | verbose: *verbose, 109 | debug: *debug, 110 | file: *file, 111 | color: *color, 112 | version: *version, 113 | targets: u.List(), 114 | maxWatchDirs: *maxWatchDirs, 115 | } 116 | 117 | return &args 118 | } 119 | -------------------------------------------------------------------------------- /pkg/app/app_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package app 8 | 9 | import ( 10 | "os" 11 | "reflect" 12 | "testing" 13 | "time" 14 | ) 15 | 16 | func TestCreateCommandConfig(t *testing.T) { 17 | commandConfigs, err := createCommandConfig("", "") 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | if commandConfigs == nil || len(commandConfigs.Commands) == 0 { 22 | t.Fatal() 23 | } 24 | } 25 | 26 | func TestCreateCommandConfigWithUserCommand(t *testing.T) { 27 | commandConfigs, err := createCommandConfig("ls", "") 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | if commandConfigs == nil || len(commandConfigs.Commands) != 1 { 32 | t.Fatal() 33 | } 34 | } 35 | 36 | func TestCreateCommandConfigWithFile(t *testing.T) { 37 | 38 | commandConfigs, err := createCommandConfig("", "no.yml") 39 | if commandConfigs != nil || err == nil { 40 | t.Fatal(err) 41 | } 42 | 43 | ymlFile := createTempFile("*.yml", yaml()) 44 | 45 | commandConfigs, err = createCommandConfig("", ymlFile) 46 | if commandConfigs == nil || err != nil { 47 | t.Fatal(err) 48 | } 49 | if len(commandConfigs.Commands) != 0 { 50 | t.Fatal() 51 | } 52 | } 53 | 54 | func TestEndTopEnd(t *testing.T) { 55 | rb := createTempFile("*.rb", `puts "Hello from Ruby`) 56 | py := createTempFile("*.py", `print("Hello from Python")`) 57 | 58 | watchFiles := []string{rb, py} 59 | userCommand := "" 60 | file := "" 61 | appOptions := NewAppOptions(0, false, 100) 62 | 63 | go Start(watchFiles, userCommand, file, appOptions) 64 | 65 | time.Sleep(100 * time.Millisecond) 66 | touch(rb) 67 | time.Sleep(100 * time.Millisecond) 68 | touch(py) 69 | time.Sleep(300 * time.Millisecond) 70 | } 71 | 72 | func TestEndTopEndError(t *testing.T) { 73 | rb := createTempFile("*.rb", `puts "Hello from Ruby`) 74 | py := createTempFile("*.py", `print("Hello from Python")`) 75 | 76 | watchFiles := []string{rb, py} 77 | userCommand := "" 78 | file := "--invalid--" 79 | appOptions := NewAppOptions(0, false, 100) 80 | 81 | err := Start(watchFiles, userCommand, file, appOptions) 82 | if err == nil { 83 | t.Fatal() 84 | } 85 | } 86 | 87 | func TestParseArgs(t *testing.T) { 88 | usage := func() {} 89 | if !ParseArgs([]string{"", "-h"}, usage).Help() { 90 | t.Fatal() 91 | } 92 | if !ParseArgs([]string{"", "-r"}, usage).Restart() { 93 | t.Fatal() 94 | } 95 | if ParseArgs([]string{"", "-c", "echo"}, usage).UserCommand() != "echo" { 96 | t.Fatal() 97 | } 98 | if ParseArgs([]string{"", "-t", "999"}, usage).Timeout() != 999 { 99 | t.Fatal() 100 | } 101 | if !ParseArgs([]string{"", "-y"}, usage).Yaml() { 102 | t.Fatal() 103 | } 104 | if !ParseArgs([]string{"", "-q"}, usage).Quiet() { 105 | t.Fatal() 106 | } 107 | if !ParseArgs([]string{"", "-v"}, usage).Verbose() { 108 | t.Fatal() 109 | } 110 | if ParseArgs([]string{"", "-f", "abc.yml"}, usage).File() != "abc.yml" { 111 | t.Fatal() 112 | } 113 | if ParseArgs([]string{"", "-c", "1"}, usage).Color() != 1 { 114 | t.Fatal() 115 | } 116 | if ParseArgs([]string{"", "-w", "9999"}, usage).MaxWatchDirs() != 9999 { 117 | t.Fatal() 118 | } 119 | if !ParseArgs([]string{"", "--debug"}, usage).Debug() { 120 | t.Fatal() 121 | } 122 | if !ParseArgs([]string{"", "--version"}, usage).Version() { 123 | t.Fatal() 124 | } 125 | if !reflect.DeepEqual(ParseArgs([]string{"", "a.txt", "b.txt", "c.txt"}, usage).Targets(), []string{"a.txt", "b.txt", "c.txt"}) { 126 | t.Fatal() 127 | } 128 | if !reflect.DeepEqual(ParseArgs([]string{"", "-v", "a.txt", "b.txt", "c.txt"}, usage).Targets(), []string{"a.txt", "b.txt", "c.txt"}) { 129 | t.Fatal() 130 | } 131 | if !reflect.DeepEqual(ParseArgs([]string{"", "a.txt", "b.txt", "c.txt", "-v"}, usage).Targets(), []string{"a.txt", "b.txt", "c.txt"}) { 132 | t.Fatal() 133 | } 134 | } 135 | 136 | func createTempFile(pattern string, content string) string { 137 | file, err := os.CreateTemp("", pattern) 138 | if err != nil { 139 | return "" 140 | } 141 | file.WriteString(content) 142 | file.Close() 143 | 144 | return file.Name() 145 | } 146 | 147 | func touch(fileName string) { 148 | file, err := os.OpenFile(fileName, os.O_RDWR|os.O_APPEND, 0666) 149 | if err != nil { 150 | return 151 | } 152 | file.WriteString("") 153 | file.Close() 154 | } 155 | 156 | func yaml() string { 157 | return `# 158 | commands: 159 | - ext: .py 160 | run: python "{{file}}" 161 | - ext: .rb 162 | run: ruby "{{file}}" 163 | - ext: .js 164 | run: node "{{file}}" 165 | ` 166 | } 167 | -------------------------------------------------------------------------------- /pkg/app/args.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package app 8 | 9 | // Args has application arguments 10 | type Args struct { 11 | help bool 12 | restart bool 13 | userCommand string 14 | timeout int64 15 | yaml bool 16 | quiet bool 17 | verbose bool 18 | file string 19 | color int 20 | debug bool 21 | version bool 22 | targets []string 23 | maxWatchDirs int 24 | } 25 | 26 | // Help returns a.help 27 | func (a *Args) Help() bool { 28 | return a.help 29 | } 30 | 31 | // Restart returns a.restart 32 | func (a *Args) Restart() bool { 33 | return a.restart 34 | } 35 | 36 | // UserCommand returns a.userCommand 37 | func (a *Args) UserCommand() string { 38 | return a.userCommand 39 | } 40 | 41 | // Timeout returns a.timeout 42 | func (a *Args) Timeout() int64 { 43 | return a.timeout 44 | } 45 | 46 | // Yaml returns a.yaml 47 | func (a *Args) Yaml() bool { 48 | return a.yaml 49 | } 50 | 51 | // Quiet returns a.quiet 52 | func (a *Args) Quiet() bool { 53 | return a.quiet 54 | } 55 | 56 | // Verbose returns a.verbose 57 | func (a *Args) Verbose() bool { 58 | return a.verbose 59 | } 60 | 61 | // File returns a.file 62 | func (a *Args) File() string { 63 | return a.file 64 | } 65 | 66 | // Color returns a.color 67 | func (a *Args) Color() int { 68 | return a.color 69 | } 70 | 71 | // Debug returns a.debug 72 | func (a *Args) Debug() bool { 73 | return a.debug 74 | } 75 | 76 | // Version returns a.version 77 | func (a *Args) Version() bool { 78 | return a.version 79 | } 80 | 81 | // Targets returns a.targets 82 | func (a *Args) Targets() []string { 83 | return a.targets 84 | } 85 | 86 | // MaxWatchDirs returns a.maxWatchDirs 87 | func (a *Args) MaxWatchDirs() int { 88 | return a.maxWatchDirs 89 | } 90 | -------------------------------------------------------------------------------- /pkg/app/option.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package app 8 | 9 | type AppOptions struct { 10 | timeout int64 11 | restart bool 12 | maxWatchDirs int 13 | } 14 | 15 | func NewAppOptions(timeout int64, restart bool, maxWatchDirs int) AppOptions { 16 | return AppOptions{ 17 | timeout: timeout, 18 | restart: restart, 19 | maxWatchDirs: maxWatchDirs, 20 | } 21 | } 22 | 23 | func (a AppOptions) Timeout() int64 { 24 | return a.timeout 25 | } 26 | 27 | func (a AppOptions) Restart() bool { 28 | return a.restart 29 | } 30 | 31 | func (a AppOptions) MaxWatchDirs() int { 32 | return a.maxWatchDirs 33 | } 34 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package config 8 | 9 | import ( 10 | "errors" 11 | "os" 12 | "os/user" 13 | "path" 14 | "path/filepath" 15 | "regexp" 16 | 17 | "github.com/cbroglie/mustache" 18 | "github.com/wtetsu/gaze/pkg/gutil" 19 | "github.com/wtetsu/gaze/pkg/logger" 20 | "gopkg.in/yaml.v3" 21 | ) 22 | 23 | // For deserialize 24 | type rawConfig struct { 25 | Commands []rawCommand 26 | Log *rawLog 27 | } 28 | 29 | // For deserialize 30 | type rawCommand struct { 31 | Ext string 32 | Cmd string 33 | Re string 34 | } 35 | 36 | // For deserialize 37 | type rawLog struct { 38 | Start string 39 | End string 40 | } 41 | 42 | // Config represents Gaze configuration 43 | type Config struct { 44 | Commands []Command 45 | Log *Log 46 | } 47 | 48 | // Command represents Gaze configuration 49 | type Command struct { 50 | Ext string 51 | Cmd string 52 | re *regexp.Regexp 53 | } 54 | 55 | type Log struct { 56 | start *mustache.Template 57 | end *mustache.Template 58 | } 59 | 60 | // New returns a new Config. 61 | func NewWithFixedCommand(command string) (*Config, error) { 62 | if command == "" { 63 | return nil, errors.New("empty command") 64 | } 65 | fixedCommand := rawCommand{Cmd: command, Re: "."} 66 | loadedRawConfig, err := loadPreferredRawConfig(homeDirPath()) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | config := rawConfig{Commands: []rawCommand{fixedCommand}, Log: loadedRawConfig.Log} 72 | return toConfig(&config), nil 73 | } 74 | 75 | // LoadPreferredConfig loads a configuration file. 76 | // Priority: default < ~/.gaze.yml < ~/.config/gaze/gaze.yml < -f option) 77 | func LoadPreferredConfig() (*Config, error) { 78 | rawConfig, err := loadPreferredRawConfig(homeDirPath()) 79 | if err != nil { 80 | return nil, err 81 | } 82 | return toConfig(rawConfig), nil 83 | } 84 | 85 | func loadPreferredRawConfig(home string) (*rawConfig, error) { 86 | configPath := searchConfigPath(home) 87 | 88 | if configPath != "" { 89 | logger.Info("config: " + configPath) 90 | parsedRawConfig, err := parseRawConfigFromFile(configPath) 91 | if err != nil { 92 | return nil, err 93 | } 94 | return parsedRawConfig, nil 95 | } 96 | 97 | logger.Info("config: (default)") 98 | return defaultRawConfig(), nil 99 | } 100 | 101 | func defaultRawConfig() *rawConfig { 102 | bytes := []byte(Default()) 103 | rawConfig, _ := parseRawConfigFromBytes(bytes) // Parse error never occurs 104 | return rawConfig 105 | } 106 | 107 | // LoadConfigFromFile loads a configuration file. 108 | func LoadConfigFromFile(configPath string) (*Config, error) { 109 | bytes, err := os.ReadFile(configPath) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | rawConfig, err := parseRawConfigFromBytes(bytes) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | return toConfig(rawConfig), nil 120 | } 121 | 122 | func toConfig(rawConfig *rawConfig) *Config { 123 | resultConfig := &Config{} 124 | if len(rawConfig.Commands) == 0 { 125 | logger.Notice("No commands defined in the configuration file. Gaze will not function properly.") 126 | } 127 | 128 | for i := 0; i < len(rawConfig.Commands); i++ { 129 | rawCmd := &rawConfig.Commands[i] 130 | 131 | if rawCmd.Cmd == "" { 132 | logger.Error("Empty cmd (%d)", i) 133 | continue 134 | } 135 | if rawCmd.Ext == "" && rawCmd.Re == "" { 136 | logger.Debug("Both ext and re are empty (%d)", i) 137 | continue 138 | } 139 | 140 | if rawCmd.Re != "" { 141 | re, err := regexp.Compile(rawCmd.Re) 142 | if err == nil { 143 | resultConfig.Commands = append(resultConfig.Commands, Command{Cmd: rawCmd.Cmd, Ext: rawCmd.Ext, re: re}) 144 | } else { 145 | logger.Error("Failed to compile regexp: " + err.Error()) 146 | } 147 | continue 148 | } 149 | 150 | if rawCmd.Ext != "" { 151 | resultConfig.Commands = append(resultConfig.Commands, Command{Cmd: rawCmd.Cmd, Ext: rawCmd.Ext}) 152 | continue 153 | } 154 | } 155 | 156 | sourceLog := rawConfig.Log 157 | if sourceLog == nil { 158 | defaultRawConfig, _ := parseRawConfigFromBytes([]byte(Default())) // Parse error never occurs 159 | sourceLog = defaultRawConfig.Log 160 | } 161 | 162 | start := parseMustacheTemplate(sourceLog.Start) 163 | end := parseMustacheTemplate(sourceLog.End) 164 | 165 | resultConfig.Log = &Log{start: start, end: end} 166 | 167 | return resultConfig 168 | } 169 | 170 | // parseMustacheTemplate parses a mustache template that tolerates errors 171 | func parseMustacheTemplate(source string) *mustache.Template { 172 | template, err := mustache.ParseStringRaw(source, true) 173 | if err != nil { 174 | logger.Error("Failed to parse template: %s: %s", err.Error(), source) 175 | return nil 176 | } 177 | return template 178 | } 179 | 180 | func searchConfigPath(home string) string { 181 | if !gutil.IsDir(home) { 182 | return "" 183 | } 184 | configDir := path.Join(home, ".config", "gaze") 185 | for _, n := range []string{"gaze.yml", "gaze.yaml"} { 186 | candidate := path.Join(configDir, n) 187 | if gutil.IsFile(candidate) { 188 | return candidate 189 | } 190 | } 191 | for _, n := range []string{".gaze.yml", ".gaze.yaml"} { 192 | candidate := path.Join(home, n) 193 | if gutil.IsFile(candidate) { 194 | return candidate 195 | } 196 | } 197 | return "" 198 | } 199 | 200 | func homeDirPath() string { 201 | currentUser, err := user.Current() 202 | if err != nil { 203 | return "" 204 | } 205 | return filepath.ToSlash(currentUser.HomeDir) 206 | } 207 | 208 | func parseRawConfigFromFile(path string) (*rawConfig, error) { 209 | bytes, err := os.ReadFile(path) 210 | if err != nil { 211 | return nil, err 212 | } 213 | return parseRawConfigFromBytes(bytes) 214 | } 215 | 216 | func parseRawConfigFromBytes(fileBuffer []byte) (*rawConfig, error) { 217 | rawConfig := rawConfig{} 218 | err := yaml.Unmarshal(fileBuffer, &rawConfig) 219 | if err != nil { 220 | return nil, err 221 | } 222 | 223 | return &rawConfig, nil 224 | } 225 | 226 | // Match return true is filePath meets the condition 227 | func (c *Command) Match(filePath string) bool { 228 | if filePath == "" { 229 | return false 230 | } 231 | 232 | if c.Ext != "" && c.re == nil { 233 | return c.Ext == filepath.Ext(filePath) 234 | } 235 | if c.Ext == "" && c.re != nil { 236 | return c.re.MatchString(filePath) 237 | } 238 | 239 | // Both are set 240 | return c.Ext == filepath.Ext(filePath) && c.re.MatchString(filePath) 241 | } 242 | 243 | func (l *Log) RenderStart(params map[string]string) string { 244 | return renderLog(l.start, params) 245 | } 246 | 247 | func (l *Log) RenderEnd(params map[string]string) string { 248 | return renderLog(l.end, params) 249 | } 250 | 251 | func renderLog(tmpl *mustache.Template, params map[string]string) string { 252 | if tmpl == nil { 253 | return "" 254 | } 255 | log, err := tmpl.Render(params) 256 | if err != nil { 257 | logger.Error("Failed to render log: %s", err) 258 | return "" 259 | } 260 | return log 261 | } 262 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package config 8 | 9 | import ( 10 | "os" 11 | "path" 12 | "testing" 13 | 14 | "github.com/cbroglie/mustache" 15 | ) 16 | 17 | func TestNewWithFixedCommand(t *testing.T) { 18 | config, _ := NewWithFixedCommand("ls") 19 | if !config.Commands[0].Match("abcdefg") { 20 | t.Fatal() 21 | } 22 | 23 | config, err := NewWithFixedCommand("") 24 | if err == nil || config != nil { 25 | t.Fatal() 26 | } 27 | } 28 | 29 | func TestInit(t *testing.T) { 30 | LoadPreferredConfig() 31 | } 32 | 33 | func TestMatch(t *testing.T) { 34 | yaml := createTempFile("*.yml", testConfig()) 35 | c, err := LoadConfigFromFile(yaml) 36 | 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | if len(c.Commands) != 3 { 41 | t.Fatal() 42 | } 43 | if getFirstMatch(c, "") != nil { 44 | t.Fatal() 45 | } 46 | if getFirstMatch(c, "a.rb").Cmd != "run01" { 47 | t.Fatal() 48 | } 49 | if getFirstMatch(c, "Dockerfile").Cmd != "run02" { 50 | t.Fatal() 51 | } 52 | if getFirstMatch(c, ".Dockerfile") != nil { 53 | t.Fatal() 54 | } 55 | if getFirstMatch(c, "Dockerfile.") != nil { 56 | t.Fatal() 57 | } 58 | if getFirstMatch(c, "abc.txt").Cmd != "run03" { 59 | t.Fatal() 60 | } 61 | if getFirstMatch(c, "abcdef.txt").Cmd != "run03" { 62 | t.Fatal() 63 | } 64 | if getFirstMatch(c, "ab.txt") != nil { 65 | t.Fatal() 66 | } 67 | if getFirstMatch(c, "abc") != nil { 68 | t.Fatal() 69 | } 70 | if getFirstMatch(c, "zzz.txt") != nil { 71 | t.Fatal() 72 | } 73 | } 74 | 75 | func TestInvalidYaml(t *testing.T) { 76 | rawConfig, err := parseRawConfigFromBytes([]byte("aaa_bbb_ccc")) 77 | if err == nil { 78 | t.Fatal() 79 | } 80 | if rawConfig != nil { 81 | t.Fatal() 82 | } 83 | 84 | config, err := LoadConfigFromFile("___.yml") 85 | t.Log(config) 86 | t.Log(err) 87 | if err == nil { 88 | t.Fatal() 89 | } 90 | if config != nil { 91 | t.Fatal() 92 | } 93 | } 94 | 95 | func TestLoadConfigFromFileInvalidYaml(t *testing.T) { 96 | invalidYAML := "invalid: : yaml: ::::" 97 | tmpFile, err := os.CreateTemp("", "invalid-config-*.yml") 98 | if err != nil { 99 | t.Fatal(err) 100 | } 101 | defer os.Remove(tmpFile.Name()) 102 | 103 | _, err = tmpFile.WriteString(invalidYAML) 104 | if err != nil { 105 | t.Fatal(err) 106 | } 107 | tmpFile.Close() 108 | 109 | cfg, err := LoadConfigFromFile(tmpFile.Name()) 110 | if err == nil { 111 | t.Fatal("expected error when loading a file with invalid YAML") 112 | } 113 | if cfg != nil { 114 | t.Fatal("expected nil configuration when YAML is invalid") 115 | } 116 | } 117 | func TestSearchConfigPath(t *testing.T) { 118 | tempDir, err := os.MkdirTemp("", "__gaze_test") 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | 123 | if searchConfigPath("") != "" { 124 | t.Fatal() 125 | } 126 | 127 | // Should be not found 128 | if searchConfigPath(tempDir) != "" { 129 | t.Fatal() 130 | } 131 | 132 | os.Create(path.Join(tempDir, ".gaze.yaml")) 133 | if searchConfigPath(tempDir) != path.Join(tempDir, ".gaze.yaml") { 134 | t.Fatal() 135 | } 136 | 137 | os.Create(path.Join(tempDir, ".gaze.yml")) 138 | if searchConfigPath(tempDir) != path.Join(tempDir, ".gaze.yml") { 139 | t.Fatal() 140 | } 141 | 142 | os.MkdirAll(path.Join(tempDir, ".config", "gaze"), os.ModePerm) 143 | os.Create(path.Join(tempDir, ".config", "gaze", "gaze.yaml")) 144 | if searchConfigPath(tempDir) != path.Join(tempDir, ".config", "gaze", "gaze.yaml") { 145 | t.Fatal() 146 | } 147 | 148 | os.Create(path.Join(tempDir, ".config", "gaze", "gaze.yml")) 149 | if searchConfigPath(tempDir) != path.Join(tempDir, ".config", "gaze", "gaze.yml") { 150 | t.Fatal() 151 | } 152 | } 153 | 154 | func getFirstMatch(config *Config, fileName string) *Command { 155 | var result *Command 156 | for _, command := range config.Commands { 157 | if command.Match(fileName) { 158 | result = &command 159 | break 160 | } 161 | } 162 | return result 163 | } 164 | 165 | func testConfig() string { 166 | return `# 167 | commands: 168 | - ext: 169 | cmd: run00 170 | - ext: .rb 171 | cmd: run01 172 | - re: ^Dockerfile$ 173 | cmd: run02 174 | - re: ^abc 175 | ext: .txt 176 | cmd: run03 177 | ` 178 | } 179 | 180 | func createTempFile(pattern string, content string) string { 181 | file, err := os.CreateTemp("", pattern) 182 | if err != nil { 183 | return "" 184 | } 185 | file.WriteString(content) 186 | file.Close() 187 | 188 | return file.Name() 189 | } 190 | 191 | func TestRenderStartEnd(t *testing.T) { 192 | logConf := &Log{} 193 | 194 | startTmpl, err := mustache.ParseStringRaw("Start: {{key}}", true) 195 | if err != nil { 196 | t.Fatalf("failed to parse start template: %s", err) 197 | } 198 | logConf.start = startTmpl 199 | 200 | endTmpl, err := mustache.ParseStringRaw("End: {{key}}", true) 201 | if err != nil { 202 | t.Fatalf("failed to parse end template: %s", err) 203 | } 204 | logConf.end = endTmpl 205 | 206 | params := map[string]string{"key": "value1"} 207 | startResult := logConf.RenderStart(params) 208 | expectedStart := "Start: value1" 209 | if startResult != expectedStart { 210 | t.Fatalf("expected %q but got %q", expectedStart, startResult) 211 | } 212 | 213 | params = map[string]string{"key": "value2"} 214 | endResult := logConf.RenderEnd(params) 215 | expectedEnd := "End: value2" 216 | if endResult != expectedEnd { 217 | t.Fatalf("expected %q but got %q", expectedEnd, endResult) 218 | } 219 | 220 | startResult = logConf.RenderStart(nil) 221 | expectedStart = "Start: " 222 | if startResult != expectedStart { 223 | t.Fatalf("expected %q but got %q", expectedStart, startResult) 224 | } 225 | } 226 | 227 | func TestRenderLog(t *testing.T) { 228 | templateStr := "Hello, {{name}}!" 229 | tmpl, err := mustache.ParseStringRaw(templateStr, true) 230 | if err != nil { 231 | t.Fatalf("failed to parse template: %s", err) 232 | } 233 | params := map[string]string{ 234 | "name": "World", 235 | } 236 | result := renderLog(tmpl, params) 237 | expected := "Hello, World!" 238 | if result != expected { 239 | t.Fatalf("expected %q but got %q", expected, result) 240 | } 241 | 242 | result = renderLog(tmpl, nil) 243 | expected = "Hello, !" 244 | if result != expected { 245 | t.Fatalf("expected %q but got %q", expected, result) 246 | } 247 | 248 | result = renderLog(nil, nil) 249 | if result != "" { 250 | t.Fatalf("expected empty string but got %q", result) 251 | } 252 | } 253 | 254 | func TestToConfig(t *testing.T) { 255 | rawCfg := &rawConfig{ 256 | Commands: []rawCommand{ 257 | // 1. Valid command with ext only. 258 | {Ext: ".go", Cmd: "runGo"}, 259 | // 2. Valid command with ext and valid regexp. 260 | {Ext: ".rb", Re: "^test", Cmd: "runRb"}, 261 | // 3. Both ext and re empty; should be skipped. 262 | {Cmd: "badCmd"}, 263 | // 4. Invalid regexp; should be skipped. 264 | {Re: "(", Cmd: "invalidRegex"}, 265 | // 5. Valid command with regexp only. 266 | {Re: "^match", Cmd: "matchCmd"}, 267 | // 6. ext provided as empty while re is empty; should be skipped. 268 | {Ext: "", Cmd: "extOnly"}, 269 | // 7. Valid command with regexp only. 270 | {Re: "^noExt", Cmd: "regexOnly"}, 271 | // 8. Empty command; should be skipped. 272 | {Cmd: ""}, 273 | }, 274 | Log: &rawLog{ 275 | Start: "start: {{var}}", 276 | End: "end: {{var}}", 277 | }, 278 | } 279 | 280 | cfg := toConfig(rawCfg) 281 | // Expected commands: #1, #2, #5, and #7. 282 | expectedCmdCount := 4 283 | if len(cfg.Commands) != expectedCmdCount { 284 | t.Fatalf("expected %d commands but got %d", expectedCmdCount, len(cfg.Commands)) 285 | } 286 | 287 | // Test first command. 288 | cmd := cfg.Commands[0] 289 | if cmd.Cmd != "runGo" || cmd.Ext != ".go" || cmd.re != nil { 290 | t.Errorf("unexpected command 0: %+v", cmd) 291 | } 292 | 293 | // Test second command. 294 | cmd = cfg.Commands[1] 295 | if cmd.Cmd != "runRb" || cmd.Ext != ".rb" || cmd.re == nil { 296 | t.Errorf("unexpected command 1: %+v", cmd) 297 | } else { 298 | // Verify the regexp compiles and matches an example string. 299 | if !cmd.re.MatchString("test_file.rb") { 300 | t.Errorf("regex did not match expected string for command 1") 301 | } 302 | } 303 | 304 | // Test third command. 305 | cmd = cfg.Commands[2] 306 | if cmd.Cmd != "matchCmd" || cmd.re == nil { 307 | t.Errorf("unexpected command 2: %+v", cmd) 308 | } 309 | 310 | // Test fourth command. 311 | cmd = cfg.Commands[3] 312 | if cmd.Cmd != "regexOnly" || cmd.re == nil { 313 | t.Errorf("unexpected command 3: %+v", cmd) 314 | } 315 | 316 | // Test log rendering. 317 | if cfg.Log == nil { 318 | t.Fatal("expected Log not to be nil") 319 | } 320 | startOut := cfg.Log.RenderStart(map[string]string{"var": "X"}) 321 | if startOut != "start: X" { 322 | t.Errorf("expected start log 'start: X', got '%s'", startOut) 323 | } 324 | endOut := cfg.Log.RenderEnd(map[string]string{"var": "Y"}) 325 | if endOut != "end: Y" { 326 | t.Errorf("expected end log 'end: Y', got '%s'", endOut) 327 | } 328 | } 329 | 330 | func TestToConfigNoCommands(t *testing.T) { 331 | // rawConfig with no commands; only log section provided. 332 | rawCfg := &rawConfig{ 333 | Commands: []rawCommand{}, 334 | Log: &rawLog{ 335 | Start: "start: {{var}}", 336 | End: "end: {{var}}", 337 | }, 338 | } 339 | cfg := toConfig(rawCfg) 340 | 341 | // Expect no commands to be added. 342 | if len(cfg.Commands) != 0 { 343 | t.Fatalf("expected 0 commands but got %d", len(cfg.Commands)) 344 | } 345 | 346 | // Validate that log templates are parsed and rendered correctly. 347 | startOut := cfg.Log.RenderStart(map[string]string{"var": "test"}) 348 | if startOut != "start: test" { 349 | t.Errorf("expected 'start: test', got %q", startOut) 350 | } 351 | endOut := cfg.Log.RenderEnd(map[string]string{"var": "test"}) 352 | if endOut != "end: test" { 353 | t.Errorf("expected 'end: test', got %q", endOut) 354 | } 355 | } 356 | 357 | func TestParseRawConfigFromFile(t *testing.T) { 358 | // Test with valid YAML content 359 | validYAML := ` 360 | commands: 361 | - ext: .go 362 | cmd: runGo 363 | log: 364 | start: "start: {{var}}" 365 | end: "end: {{var}}" 366 | ` 367 | tmpFile, err := os.CreateTemp("", "valid-config-*.yml") 368 | if err != nil { 369 | t.Fatal(err) 370 | } 371 | defer os.Remove(tmpFile.Name()) 372 | if _, err := tmpFile.WriteString(validYAML); err != nil { 373 | t.Fatal(err) 374 | } 375 | tmpFile.Close() 376 | 377 | rawCfg, err := parseRawConfigFromFile(tmpFile.Name()) 378 | if err != nil { 379 | t.Fatalf("unexpected error parsing valid YAML: %v", err) 380 | } 381 | if rawCfg == nil { 382 | t.Fatal("expected non-nil rawConfig") 383 | } 384 | if len(rawCfg.Commands) != 1 { 385 | t.Fatalf("expected 1 command, got %d", len(rawCfg.Commands)) 386 | } 387 | if rawCfg.Log == nil { 388 | t.Fatal("expected non-nil Log in rawConfig") 389 | } 390 | 391 | // Test with non-existent file path 392 | _, err = parseRawConfigFromFile("nonexistentfile.yml") 393 | if err == nil { 394 | t.Fatal("expected error when file does not exist") 395 | } 396 | 397 | // Test with invalid YAML content 398 | invalidYAML := "invalid: : yaml: ::::" 399 | tmpFile2, err := os.CreateTemp("", "invalid-config-*.yml") 400 | if err != nil { 401 | t.Fatal(err) 402 | } 403 | defer os.Remove(tmpFile2.Name()) 404 | if _, err := tmpFile2.WriteString(invalidYAML); err != nil { 405 | t.Fatal(err) 406 | } 407 | tmpFile2.Close() 408 | 409 | rawCfg, err = parseRawConfigFromFile(tmpFile2.Name()) 410 | if err == nil { 411 | t.Fatal("expected error when YAML is invalid") 412 | } 413 | if rawCfg != nil { 414 | t.Fatal("expected nil rawConfig when YAML is invalid") 415 | } 416 | } 417 | 418 | func TestLoadPreferredRawConfigFound(t *testing.T) { 419 | // Create a temporary directory to act as the home directory. 420 | tempHome, err := os.MkdirTemp("", "gaze-test-found") 421 | if err != nil { 422 | t.Fatal(err) 423 | } 424 | defer os.RemoveAll(tempHome) 425 | 426 | // Prepare a configuration file in the prioritized directory: 427 | // .config/gaze/gaze.yml 428 | configDir := path.Join(tempHome, ".config", "gaze") 429 | if err := os.MkdirAll(configDir, os.ModePerm); err != nil { 430 | t.Fatal(err) 431 | } 432 | configFilePath := path.Join(configDir, "gaze.yml") 433 | configContent := `commands: 434 | - ext: .test 435 | cmd: testCmd 436 | log: 437 | start: "start: {{var}}" 438 | end: "end: {{var}}" 439 | ` 440 | if err := os.WriteFile(configFilePath, []byte(configContent), 0644); err != nil { 441 | t.Fatal(err) 442 | } 443 | 444 | rawCfg, err := loadPreferredRawConfig(tempHome) 445 | if err != nil { 446 | t.Fatalf("loadPreferredRawConfig returned error: %s", err) 447 | } 448 | if rawCfg == nil { 449 | t.Fatal("expected non-nil rawConfig") 450 | } 451 | if len(rawCfg.Commands) == 0 { 452 | t.Fatal("expected at least one command from the configuration file") 453 | } 454 | if rawCfg.Commands[0].Cmd != "testCmd" { 455 | t.Fatalf("expected command 'testCmd', got %q", rawCfg.Commands[0].Cmd) 456 | } 457 | } 458 | 459 | func TestLoadPreferredRawConfigDefault(t *testing.T) { 460 | // Create a temporary directory with no config file. 461 | tempHome, err := os.MkdirTemp("", "gaze-test-default") 462 | if err != nil { 463 | t.Fatal(err) 464 | } 465 | defer os.RemoveAll(tempHome) 466 | 467 | // Ensure there is no config file anywhere under tempHome. 468 | // loadPreferredRawConfig will fall back to using the default configuration. 469 | rawCfg, err := loadPreferredRawConfig(tempHome) 470 | if err != nil { 471 | t.Fatalf("loadPreferredRawConfig returned error: %s", err) 472 | } 473 | if rawCfg == nil { 474 | t.Fatal("expected non-nil rawConfig") 475 | } 476 | // Since default configuration is used, we expect Log to be non-nil. 477 | if rawCfg.Log == nil { 478 | t.Fatal("expected non-nil Log in the default configuration") 479 | } 480 | } 481 | func TestParseMustacheTemplate(t *testing.T) { 482 | t.Run("valid template", func(t *testing.T) { 483 | templateStr := "Hello, {{name}}!" 484 | tmpl := parseMustacheTemplate(templateStr) 485 | if tmpl == nil { 486 | t.Fatal("expected non-nil template for valid mustache string") 487 | } 488 | result, err := tmpl.Render(map[string]string{"name": "World"}) 489 | if err != nil { 490 | t.Fatalf("error rendering template: %s", err) 491 | } 492 | expected := "Hello, World!" 493 | if result != expected { 494 | t.Fatalf("expected %q but got %q", expected, result) 495 | } 496 | }) 497 | 498 | t.Run("invalid template", func(t *testing.T) { 499 | // An unclosed tag should result in an error during parsing. 500 | invalidTemplateStr := "Hello, {{name" 501 | tmpl := parseMustacheTemplate(invalidTemplateStr) 502 | if tmpl != nil { 503 | t.Fatal("expected nil template for invalid mustache string") 504 | } 505 | }) 506 | } 507 | func TestRenderLogSimple(t *testing.T) { 508 | templateStr := "Test: {{value}}" 509 | tmpl, err := mustache.ParseStringRaw(templateStr, true) 510 | if err != nil { 511 | t.Fatalf("failed to parse template: %s", err) 512 | } 513 | result := renderLog(tmpl, map[string]string{"value": "OK"}) 514 | expected := "Test: OK" 515 | if result != expected { 516 | t.Fatalf("expected %q but got %q", expected, result) 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /pkg/config/default.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package config 8 | 9 | import ( 10 | _ "embed" 11 | ) 12 | 13 | //go:embed default.yml 14 | var defaultYml string 15 | 16 | // Default returns the default configuration 17 | func Default() string { 18 | return defaultYml 19 | } 20 | -------------------------------------------------------------------------------- /pkg/config/default.yml: -------------------------------------------------------------------------------- 1 | # Gaze configuration(priority: default < ~/.gaze.yml < ~/.config/gaze/gaze.yml < -f option) 2 | commands: 3 | - ext: .go 4 | cmd: go run "{{file}}" 5 | - ext: .py 6 | cmd: python "{{file}}" 7 | - ext: .rb 8 | cmd: ruby "{{file}}" 9 | - ext: .js 10 | cmd: node "{{file}}" 11 | - ext: .d 12 | cmd: dmd -run "{{file}}" 13 | - ext: .groovy 14 | cmd: groovy "{{file}}" 15 | - ext: .php 16 | cmd: php "{{file}}" 17 | - ext: .java 18 | cmd: java "{{file}}" 19 | - ext: .kts 20 | cmd: kotlinc -script "{{file}}" 21 | - ext: .rs 22 | cmd: | 23 | rustc "{{file}}" -o"{{base0}}.out" 24 | ./"{{base0}}.out" 25 | - ext: .cpp 26 | cmd: | 27 | gcc "{{file}}" -o"{{base0}}.out" 28 | ./"{{base0}}.out" 29 | - ext: .ts 30 | cmd: | 31 | tsc "{{file}}" --outFile "{{base0}}.out" 32 | node ./"{{base0}}.out" 33 | - ext: .zig 34 | cmd: zig run "{{file}}" 35 | - re: ^Dockerfile$ 36 | cmd: docker build -f "{{file}}" . 37 | 38 | log: 39 | start: "[{{command}}]{{step}}" 40 | end: "({{elapsed_ms}}ms)" 41 | -------------------------------------------------------------------------------- /pkg/gazer/commands.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package gazer 8 | 9 | import ( 10 | "os/exec" 11 | "sync" 12 | "time" 13 | 14 | "github.com/wtetsu/gaze/pkg/notify" 15 | ) 16 | 17 | type commands struct { 18 | commands map[string]command 19 | events map[string]notify.Event 20 | mutex sync.Mutex 21 | } 22 | 23 | type command struct { 24 | cmd *exec.Cmd 25 | lastLaunched int64 26 | } 27 | 28 | func newCommands() commands { 29 | return commands{ 30 | commands: make(map[string]command), 31 | events: make(map[string]notify.Event), 32 | } 33 | } 34 | 35 | func (c *commands) update(key string, cmd *exec.Cmd) { 36 | c.mutex.Lock() 37 | defer c.mutex.Unlock() 38 | 39 | if cmd == nil { 40 | delete(c.commands, key) 41 | return 42 | } 43 | c.commands[key] = command{cmd: cmd, lastLaunched: time.Now().UnixNano()} 44 | } 45 | 46 | func (c *commands) get(key string) *command { 47 | c.mutex.Lock() 48 | defer c.mutex.Unlock() 49 | 50 | cmd, ok := c.commands[key] 51 | if !ok { 52 | return nil 53 | } 54 | return &cmd 55 | } 56 | 57 | func (c *commands) enqueue(commandString string, event notify.Event) { 58 | c.mutex.Lock() 59 | defer c.mutex.Unlock() 60 | 61 | c.events[commandString] = event 62 | } 63 | 64 | func (c *commands) dequeue(commandString string) *notify.Event { 65 | c.mutex.Lock() 66 | defer c.mutex.Unlock() 67 | 68 | event, ok := c.events[commandString] 69 | 70 | if !ok { 71 | return nil 72 | } 73 | 74 | // delete both event and command 75 | delete(c.events, commandString) 76 | delete(c.commands, commandString) 77 | return &event 78 | } 79 | -------------------------------------------------------------------------------- /pkg/gazer/commands_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package gazer 8 | 9 | import ( 10 | "os/exec" 11 | "testing" 12 | "time" 13 | 14 | "github.com/wtetsu/gaze/pkg/notify" 15 | ) 16 | 17 | func TestCommandsBasic1(t *testing.T) { 18 | commands := newCommands() 19 | 20 | key := "key01" 21 | 22 | if commands.get(key) != nil { 23 | t.Fatal() 24 | } 25 | var cmd exec.Cmd 26 | commands.update(key, &cmd) 27 | if commands.get(key) == nil { 28 | t.Fatal() 29 | } 30 | commands.update(key, nil) 31 | if commands.get(key) != nil { 32 | t.Fatal() 33 | } 34 | } 35 | 36 | func TestCommandsBasic2(t *testing.T) { 37 | rbCommand := `ruby a.rb` 38 | pyCommand := `python a.py` 39 | 40 | commands := newCommands() 41 | 42 | if commands.dequeue(rbCommand) != nil { 43 | t.Fatal() 44 | } 45 | if commands.dequeue(pyCommand) != nil { 46 | t.Fatal() 47 | } 48 | commands.enqueue(rbCommand, notify.Event{Name: rbCommand, Time: 1}) 49 | commands.enqueue(pyCommand, notify.Event{Name: pyCommand, Time: 2}) 50 | 51 | if commands.dequeue(rbCommand) == nil { 52 | t.Fatal() 53 | } 54 | if commands.dequeue(pyCommand) == nil { 55 | t.Fatal() 56 | } 57 | if commands.dequeue(rbCommand) != nil { 58 | t.Fatal() 59 | } 60 | if commands.dequeue(pyCommand) != nil { 61 | t.Fatal() 62 | } 63 | } 64 | 65 | func TestCommandsParallel(t *testing.T) { 66 | commands := newCommands() 67 | 68 | key := "key01" 69 | 70 | go func() { 71 | for i := 0; i < 100; i++ { 72 | commands.get(key) 73 | time.Sleep(1 * time.Millisecond) 74 | } 75 | }() 76 | go func() { 77 | for i := 0; i < 100; i++ { 78 | var cmd exec.Cmd 79 | commands.update(key, &cmd) 80 | time.Sleep(1 * time.Millisecond) 81 | } 82 | }() 83 | go func() { 84 | for i := 0; i < 100; i++ { 85 | commands.update(key, nil) 86 | time.Sleep(1 * time.Millisecond) 87 | } 88 | }() 89 | 90 | } 91 | -------------------------------------------------------------------------------- /pkg/gazer/gazer.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package gazer 8 | 9 | import ( 10 | "errors" 11 | "path/filepath" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | "time" 17 | 18 | "github.com/wtetsu/gaze/pkg/config" 19 | "github.com/wtetsu/gaze/pkg/gutil" 20 | "github.com/wtetsu/gaze/pkg/logger" 21 | "github.com/wtetsu/gaze/pkg/notify" 22 | ) 23 | 24 | // Gazer gazes filesystem. 25 | type Gazer struct { 26 | patterns []string 27 | notify *notify.Notify 28 | isClosed bool 29 | invokeCount uint64 30 | commands commands 31 | mutexes sync.Map 32 | } 33 | 34 | // New returns a new Gazer. 35 | func New(patterns []string, maxWatchDirs int) (*Gazer, error) { 36 | cleanPatterns := make([]string, len(patterns)) 37 | for i, p := range patterns { 38 | cleanPatterns[i] = filepath.Clean(p) 39 | } 40 | 41 | notify, err := notify.New(cleanPatterns, maxWatchDirs) 42 | if err != nil { 43 | return nil, err 44 | } 45 | return &Gazer{ 46 | patterns: cleanPatterns, 47 | notify: notify, 48 | isClosed: false, 49 | invokeCount: 0, 50 | commands: newCommands(), 51 | mutexes: sync.Map{}, 52 | }, nil 53 | } 54 | 55 | // Close disposes internal resources. 56 | func (g *Gazer) Close() { 57 | if g.isClosed { 58 | return 59 | } 60 | g.notify.Close() 61 | g.isClosed = true 62 | } 63 | 64 | // Run starts to gaze. 65 | func (g *Gazer) Run(configs *config.Config, timeoutMills int64, restart bool) error { 66 | if timeoutMills <= 0 { 67 | return errors.New("timeout must be more than 0") 68 | } 69 | err := g.repeatRunAndWait(configs, timeoutMills, restart) 70 | return err 71 | } 72 | 73 | // repeatRunAndWait continuously monitors file system events. 74 | // - Executes corresponding commands based on provided configuration 75 | // - Handles process restarts and timeouts if needed 76 | // - Gracefully shuts down upon receiving a SIGINT signal 77 | func (g *Gazer) repeatRunAndWait(commandConfigs *config.Config, timeoutMills int64, restart bool) error { 78 | sigInt := sigIntChannel() 79 | 80 | isTerminated := false 81 | for { 82 | select { 83 | case event := <-g.notify.Events: 84 | if isTerminated { 85 | break 86 | } 87 | logger.Debug("Receive: %s", event.Name) 88 | 89 | // This line is expected to not be executed concurrently by multiple threads. 90 | g.handleEvent(commandConfigs, timeoutMills, restart, event) 91 | 92 | case <-sigInt: 93 | isTerminated = true 94 | return nil 95 | } 96 | } 97 | } 98 | 99 | // handleEvent processes the received file system event. 100 | func (g *Gazer) handleEvent(config *config.Config, timeoutMills int64, restart bool, event notify.Event) { 101 | commandStringList := g.tryToFindCommand(event.Name, config.Commands) 102 | if commandStringList == nil { 103 | return 104 | } 105 | 106 | queueManageKey := strings.Join(commandStringList, "\n") 107 | 108 | ongoingCommand := g.commands.get(queueManageKey) 109 | 110 | if ongoingCommand != nil && restart { 111 | kill(ongoingCommand.cmd, "Restart") 112 | g.commands.update(queueManageKey, nil) 113 | } 114 | 115 | if ongoingCommand != nil && !restart { 116 | g.commands.enqueue(queueManageKey, event) 117 | return 118 | } 119 | 120 | mutex := g.lock(queueManageKey) 121 | 122 | g.invokeCount++ 123 | 124 | go func() { 125 | g.invoke(commandStringList, queueManageKey, timeoutMills, config.Log) 126 | logger.Debug("Unlock: %s", queueManageKey) 127 | mutex.Unlock() 128 | }() 129 | } 130 | 131 | func (g *Gazer) tryToFindCommand(filePath string, commandConfigs []config.Command) []string { 132 | if !matchAny(g.patterns, filePath) { 133 | return nil 134 | } 135 | 136 | rawCommandString, err := getMatchedCommand(filePath, commandConfigs) 137 | if err != nil { 138 | logger.NoticeObject(err) 139 | return nil 140 | } 141 | 142 | commandStringList := splitCommand(rawCommandString) 143 | if len(commandStringList) == 0 { 144 | logger.Debug("Command not found: %s", filePath) 145 | return nil 146 | } 147 | 148 | return commandStringList 149 | } 150 | 151 | func (g *Gazer) lock(queueManageKey string) *sync.Mutex { 152 | logger.Debug("Lock: %s", queueManageKey) 153 | mutex, ok := g.mutexes.Load(queueManageKey) 154 | if !ok { 155 | mutex = &sync.Mutex{} 156 | g.mutexes.Store(queueManageKey, mutex) 157 | } 158 | m := mutex.(*sync.Mutex) 159 | m.Lock() 160 | return m 161 | } 162 | 163 | // invoke executes commands, handles timeouts, and processes queued events. 164 | func (g *Gazer) invoke(commandStringList []string, queueManageKey string, timeoutMills int64, logConfig *config.Log) { 165 | lastLaunched := time.Now().UnixNano() 166 | 167 | commandSize := len(commandStringList) 168 | 169 | for i, commandString := range commandStringList { 170 | logCommandStart(logConfig, commandString, commandSize, i) 171 | 172 | cmdResult := g.invokeOneCommand(commandString, queueManageKey, timeoutMills) 173 | elapsed := cmdResult.EndTime.UnixNano() - cmdResult.StartTime.UnixNano() 174 | logCommandEnd(logConfig, commandString, elapsed/1_000_000) 175 | if cmdResult.Err != nil { 176 | if len(cmdResult.Err.Error()) > 0 { 177 | logger.NoticeObject(cmdResult.Err) 178 | } 179 | break 180 | } 181 | } 182 | // Handle waiting events 183 | queuedEvent := g.commands.dequeue(queueManageKey) 184 | if queuedEvent == nil { 185 | g.commands.update(queueManageKey, nil) 186 | } else { 187 | canAbolish := lastLaunched > queuedEvent.Time 188 | if canAbolish { 189 | logger.Debug("Abolish:%d, %d", lastLaunched, queuedEvent.Time) 190 | } else { 191 | // Requeue 192 | g.commands.update(queueManageKey, nil) 193 | g.notify.Requeue(*queuedEvent) 194 | } 195 | } 196 | } 197 | 198 | func logCommandStart(logConfig *config.Log, commandString string, commandSize int, i int) { 199 | params := makeCommonLogParams(commandString) 200 | 201 | if commandSize >= 2 { 202 | params["step"] = "(" + strconv.Itoa(i+1) + "/" + strconv.Itoa(commandSize) + ")" 203 | } 204 | 205 | log := logConfig.RenderStart(params) 206 | if log != "" { 207 | logger.NoticeWithBlank(log) 208 | } 209 | } 210 | 211 | func logCommandEnd(logConfig *config.Log, commandString string, elapsedMs int64) { 212 | params := makeCommonLogParams(commandString) 213 | params["elapsed_ms"] = strconv.FormatInt(elapsedMs, 10) 214 | log := logConfig.RenderEnd(params) 215 | if log != "" { 216 | logger.Notice(log) 217 | } 218 | } 219 | 220 | func makeCommonLogParams(makeCommonLogParams string) map[string]string { 221 | now := time.Now() 222 | return map[string]string{ 223 | "command": makeCommonLogParams, 224 | "YYYY": now.Format("2006"), 225 | "MM": now.Format("01"), 226 | "DD": now.Format("02"), 227 | "HH": now.Format("15"), 228 | "mm": now.Format("04"), 229 | "ss": now.Format("05"), 230 | "SSS": now.Format(".000")[1:], // Remove the leading dot 231 | } 232 | } 233 | 234 | func (g *Gazer) invokeOneCommand(commandString string, queueManageKey string, timeoutMills int64) CmdResult { 235 | cmd := createCommand(commandString) 236 | g.commands.update(queueManageKey, cmd) 237 | return executeCommandOrTimeout(cmd, timeoutMills) 238 | } 239 | 240 | func matchAny(watchFiles []string, s string) bool { 241 | for _, f := range watchFiles { 242 | if gutil.GlobMatch(f, s) { 243 | return true 244 | } 245 | } 246 | return false 247 | } 248 | 249 | func getMatchedCommand(filePath string, commandConfigs []config.Command) (string, error) { 250 | var result string 251 | var resultError error 252 | for _, c := range commandConfigs { 253 | if c.Match(filePath) { 254 | command, err := render(c.Cmd, filePath) 255 | result = command 256 | resultError = err 257 | break 258 | } 259 | } 260 | return result, resultError 261 | } 262 | 263 | var newLines = regexp.MustCompile("\r\n|\n\r|\n|\r") 264 | 265 | func splitCommand(commandString string) []string { 266 | var commandList []string 267 | for _, rawCmd := range newLines.Split(commandString, -1) { 268 | cmd := strings.TrimSpace(rawCmd) 269 | if len(cmd) > 0 { 270 | commandList = append(commandList, cmd) 271 | } 272 | } 273 | return commandList 274 | } 275 | 276 | // InvokeCount returns the current execution counter 277 | func (g *Gazer) InvokeCount() uint64 { 278 | return g.invokeCount 279 | } 280 | -------------------------------------------------------------------------------- /pkg/gazer/gazer_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package gazer 8 | 9 | import ( 10 | "fmt" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "testing" 15 | "time" 16 | 17 | "github.com/wtetsu/gaze/pkg/config" 18 | ) 19 | 20 | func TestBasic(t *testing.T) { 21 | py1 := createTempFile("*.py", `import datetime; print(datetime.datetime.now())`) 22 | rb1 := createTempFile("*.rb", `print(Time.new)`) 23 | 24 | if py1 == "" || rb1 == "" { 25 | t.Fatal("Temp files error") 26 | } 27 | 28 | gazer, _ := New([]string{py1, rb1}, 100) 29 | if gazer == nil { 30 | t.Fatal() 31 | } 32 | defer gazer.Close() 33 | 34 | c, err := config.LoadPreferredConfig() 35 | if err != nil { 36 | t.Fatal() 37 | } 38 | go gazer.Run(c, 10*1000, false) 39 | if gazer.InvokeCount() != 0 { 40 | t.Fatal() 41 | } 42 | 43 | for i := 0; i < 100; i++ { 44 | touch(py1) 45 | touch(rb1) 46 | if gazer.InvokeCount() >= 4 { 47 | break 48 | } 49 | time.Sleep(50 * time.Millisecond) 50 | } 51 | 52 | if gazer.InvokeCount() < 4 { 53 | t.Fatal() 54 | } 55 | } 56 | 57 | func TestDoNothing(t *testing.T) { 58 | py1 := createTempFile("a'aa*.py", `import datetime; print(datetime.datetime.now())`) 59 | rb1 := createTempFile("b'bb.*.rb", `print(Time.new)`) 60 | 61 | if py1 == "" || rb1 == "" { 62 | t.Fatal("Temp files error") 63 | } 64 | 65 | gazer, _ := New([]string{py1, rb1}, 100) 66 | if gazer == nil { 67 | t.Fatal() 68 | } 69 | defer gazer.Close() 70 | 71 | c, err := config.LoadPreferredConfig() 72 | if err != nil { 73 | t.Fatal() 74 | } 75 | go gazer.Run(c, 10*1000, false) 76 | if gazer.InvokeCount() != 0 { 77 | t.Fatal() 78 | } 79 | 80 | for i := 0; i < 100; i++ { 81 | touch(py1) 82 | touch(rb1) 83 | if gazer.InvokeCount() >= 4 { 84 | break 85 | } 86 | time.Sleep(5 * time.Millisecond) 87 | } 88 | 89 | if gazer.InvokeCount() > 0 { 90 | t.Fatal() 91 | } 92 | } 93 | 94 | func TestRename(t *testing.T) { 95 | py1 := createTempFile("*.py", `import datetime; print(datetime.datetime.now())`) 96 | rb1 := createTempFile("*.rb", `print(Time.new)`) 97 | 98 | py2 := py1 + ".tmp" 99 | rb2 := rb1 + ".tmp" 100 | 101 | if py1 == "" || rb1 == "" { 102 | t.Fatal("Temp files error") 103 | } 104 | 105 | gazer, _ := New([]string{py1, rb1}, 100) 106 | if gazer == nil { 107 | t.Fatal() 108 | } 109 | defer gazer.Close() 110 | 111 | c, err := config.LoadPreferredConfig() 112 | if err != nil { 113 | t.Fatal() 114 | } 115 | go gazer.Run(c, 10*1000, false) 116 | if gazer.InvokeCount() != 0 { 117 | t.Fatal() 118 | } 119 | 120 | for i := 0; i < 20; i++ { 121 | if gazer.InvokeCount() >= 10 { 122 | break 123 | } 124 | 125 | os.Rename(py1, py2) 126 | os.Rename(rb1, rb2) 127 | 128 | time.Sleep(50 * time.Millisecond) 129 | 130 | touch(py1) 131 | os.Rename(py2, py1) 132 | touch(rb2) 133 | os.Rename(rb2, rb1) 134 | 135 | time.Sleep(50 * time.Millisecond) 136 | } 137 | 138 | if gazer.InvokeCount() < 10 { 139 | t.Fatal() 140 | } 141 | } 142 | 143 | func TestRestart(t *testing.T) { 144 | content := ` 145 | import time 146 | 147 | print("start") 148 | # time.sleep(1) 149 | print("end") 150 | ` 151 | 152 | py1 := createTempFile("*.py", content) 153 | if py1 == "" { 154 | t.Fatal("Temp files error") 155 | } 156 | 157 | gazer, _ := New([]string{py1}, 100) 158 | if gazer == nil { 159 | t.Fatal() 160 | } 161 | defer gazer.Close() 162 | 163 | c, err := config.LoadPreferredConfig() 164 | if err != nil { 165 | t.Fatal() 166 | } 167 | go gazer.Run(c, 10*1000, true) 168 | 169 | if gazer.InvokeCount() != 0 { 170 | t.Fatal() 171 | } 172 | 173 | for i := 0; i < 100; i++ { 174 | touch(py1) 175 | touch(py1) 176 | touch(py1) 177 | if gazer.InvokeCount() >= 2 { 178 | break 179 | } 180 | time.Sleep(10 * time.Millisecond) 181 | } 182 | 183 | if gazer.InvokeCount() < 2 { 184 | t.Fatalf("count:%d", gazer.InvokeCount()) 185 | } 186 | 187 | gazer.Close() 188 | gazer.Close() 189 | } 190 | 191 | func TestKill(t *testing.T) { 192 | py1 := createTempFile("*.py", `import time; time.sleep(5)`) 193 | rb1 := createTempFile("*.rb", `sleep(5)`) 194 | 195 | if py1 == "" || rb1 == "" { 196 | t.Fatal("Temp files error") 197 | } 198 | 199 | gazer, _ := New([]string{py1, rb1}, 100) 200 | if gazer == nil { 201 | t.Fatal() 202 | } 203 | defer gazer.Close() 204 | 205 | c, err := config.LoadPreferredConfig() 206 | if err != nil { 207 | t.Fatal() 208 | } 209 | go gazer.Run(c, 10*1000, false) 210 | if gazer.InvokeCount() != 0 { 211 | t.Fatal() 212 | } 213 | 214 | touch(py1) 215 | touch(rb1) 216 | 217 | py1Command := fmt.Sprintf(`python "%s"`, py1) 218 | rb1Command := fmt.Sprintf(`ruby "%s"`, rb1) 219 | 220 | pyKilled := false 221 | rbKilled := false 222 | for i := 0; i < 100; i++ { 223 | if !pyKilled && kill(getCmd(&gazer.commands, py1Command), "test") { 224 | pyKilled = true 225 | } 226 | if !rbKilled && kill(getCmd(&gazer.commands, rb1Command), "test") { 227 | rbKilled = true 228 | } 229 | if pyKilled && rbKilled { 230 | break 231 | } 232 | time.Sleep(10 * time.Millisecond) 233 | } 234 | 235 | if !pyKilled || !rbKilled { 236 | t.Fatal() 237 | } 238 | } 239 | 240 | func getCmd(commands *commands, command string) *exec.Cmd { 241 | c := commands.get(command) 242 | if c == nil { 243 | return nil 244 | } 245 | 246 | return c.cmd 247 | } 248 | 249 | func TestInvalidCommand(t *testing.T) { 250 | py1 := createTempFile("*.py", `import datetime; print(datetime.datetime.now())`) 251 | rb1 := createTempFile("*.rb", `print(Time.new)`) 252 | 253 | if py1 == "" || rb1 == "" { 254 | t.Fatal("Temp files error") 255 | } 256 | 257 | gazer, _ := New([]string{py1, rb1}, 100) 258 | if gazer == nil { 259 | t.Fatal() 260 | } 261 | defer gazer.Close() 262 | 263 | var commandConfigs config.Config 264 | 265 | commandConfigs.Commands = append(commandConfigs.Commands, config.Command{Ext: ".rb", Cmd: "ruby {{file]]"}) 266 | commandConfigs.Commands = append(commandConfigs.Commands, config.Command{Ext: ".py", Cmd: ""}) 267 | 268 | go gazer.Run(&commandConfigs, 10*1000, false) 269 | if gazer.InvokeCount() != 0 { 270 | t.Fatal() 271 | } 272 | 273 | for i := 0; i < 100; i++ { 274 | touch(py1) 275 | touch(rb1) 276 | if gazer.InvokeCount() >= 1 { 277 | break 278 | } 279 | time.Sleep(5 * time.Millisecond) 280 | } 281 | 282 | if gazer.InvokeCount() > 0 { 283 | t.Fatal() 284 | } 285 | } 286 | 287 | func TestGetAppropriateCommandOk(t *testing.T) { 288 | var commandConfigs config.Config 289 | 290 | var command string 291 | var err error 292 | 293 | commandConfigs.Commands = append(commandConfigs.Commands, config.Command{Ext: "", Cmd: "echo"}) 294 | commandConfigs.Commands = append(commandConfigs.Commands, config.Command{Ext: ".txt", Cmd: ""}) 295 | 296 | command, err = getMatchedCommand("a.txt", commandConfigs.Commands) 297 | if command != "" || err != nil { 298 | t.Fatal() 299 | } 300 | 301 | commandConfigs.Commands = append(commandConfigs.Commands, config.Command{Ext: ".txt", Cmd: "echo"}) 302 | 303 | command, err = getMatchedCommand("", commandConfigs.Commands) 304 | if command == "a.txt" || err != nil { 305 | t.Fatal() 306 | } 307 | } 308 | 309 | func TestGetAppropriateCommandError(t *testing.T) { 310 | var commandConfigs config.Config 311 | 312 | var command string 313 | var err error 314 | 315 | commandConfigs.Commands = append(commandConfigs.Commands, config.Command{Ext: ".rb", Cmd: "ruby {{file]]"}) 316 | commandConfigs.Commands = append(commandConfigs.Commands, config.Command{Ext: ".py", Cmd: "python {{file]]"}) 317 | 318 | command, err = getMatchedCommand("a.txt", commandConfigs.Commands) 319 | if command != "" || err != nil { 320 | t.Fatal() 321 | } 322 | 323 | command, err = getMatchedCommand("a.rb", commandConfigs.Commands) 324 | if command != "" || err == nil { 325 | t.Fatal() 326 | } 327 | command, err = getMatchedCommand("a.py", commandConfigs.Commands) 328 | if command != "" || err == nil { 329 | t.Fatal() 330 | } 331 | } 332 | 333 | func TestInvalidTimeout(t *testing.T) { 334 | gazer, _ := New([]string{}, 100) 335 | 336 | var err error 337 | 338 | err = gazer.Run(nil, 0, false) 339 | if err == nil { 340 | t.Fatal() 341 | } 342 | 343 | err = gazer.Run(nil, -1, false) 344 | if err == nil { 345 | t.Fatal() 346 | } 347 | } 348 | 349 | func createTempFile(pattern string, content string) string { 350 | dirpath, err := os.MkdirTemp("", "_gaze") 351 | 352 | if err != nil { 353 | return "" 354 | } 355 | 356 | file, err := os.CreateTemp(dirpath, pattern) 357 | if err != nil { 358 | return "" 359 | } 360 | file.WriteString(content) 361 | file.Close() 362 | 363 | return filepath.ToSlash(file.Name()) 364 | } 365 | 366 | func touch(fileName string) { 367 | file, err := os.OpenFile(fileName, os.O_RDWR|os.O_APPEND, 0666) 368 | if err != nil { 369 | return 370 | } 371 | file.WriteString("#\n") 372 | file.Close() 373 | } 374 | -------------------------------------------------------------------------------- /pkg/gazer/proc.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package gazer 8 | 9 | import ( 10 | "errors" 11 | "fmt" 12 | "os" 13 | "os/exec" 14 | "os/signal" 15 | "runtime" 16 | "syscall" 17 | "time" 18 | 19 | "github.com/mattn/go-shellwords" 20 | "github.com/wtetsu/gaze/pkg/gutil" 21 | "github.com/wtetsu/gaze/pkg/logger" 22 | ) 23 | 24 | type CmdResult struct { 25 | StartTime time.Time 26 | EndTime time.Time 27 | Err error 28 | } 29 | 30 | func executeCommandOrTimeout(cmd *exec.Cmd, timeoutMills int64) CmdResult { 31 | exec := executeCommandAsync(cmd) 32 | 33 | var cmdResult CmdResult 34 | var launchedTime = time.Now() 35 | finished := false 36 | timeout := gutil.After(timeoutMills) 37 | for { 38 | if finished { 39 | break 40 | } 41 | select { 42 | case <-timeout: 43 | if cmd.Process == nil { 44 | timeout = gutil.After(5) 45 | continue 46 | } 47 | kill(cmd, "Timeout") 48 | finished = true 49 | cmdResult = CmdResult{StartTime: launchedTime, EndTime: time.Now(), Err: errors.New("")} 50 | case cmdResult = <-exec: 51 | finished = true 52 | } 53 | } 54 | if cmdResult.Err != nil { 55 | return cmdResult 56 | } 57 | 58 | if cmd.ProcessState != nil { 59 | exitCode := cmd.ProcessState.ExitCode() 60 | if exitCode != 0 { 61 | cmdResult.Err = fmt.Errorf("exitCode:%d", exitCode) 62 | return cmdResult 63 | } 64 | } 65 | 66 | return cmdResult 67 | } 68 | 69 | func executeCommandAsync(cmd *exec.Cmd) <-chan CmdResult { 70 | ch := make(chan CmdResult) 71 | 72 | go func() { 73 | if cmd == nil { 74 | ch <- CmdResult{Err: errors.New("failed: cmd is nil")} 75 | return 76 | } 77 | cmdResult := executeCommand(cmd) 78 | ch <- cmdResult 79 | }() 80 | return ch 81 | } 82 | 83 | func executeCommand(cmd *exec.Cmd) CmdResult { 84 | cmd.Stdout = os.Stdout 85 | cmd.Stderr = os.Stderr 86 | 87 | start := time.Now() 88 | err := cmd.Start() 89 | if err != nil { 90 | return CmdResult{StartTime: start, EndTime: time.Now(), Err: err} 91 | } 92 | 93 | if cmd.Process != nil { 94 | logger.Info("Pid: %d", cmd.Process.Pid) 95 | } else { 96 | logger.Info("Pid: ????") 97 | } 98 | err = cmd.Wait() 99 | 100 | return CmdResult{StartTime: start, EndTime: time.Now(), Err: err} 101 | } 102 | 103 | func kill(cmd *exec.Cmd, reason string) bool { 104 | if cmd == nil || cmd.Process == nil { 105 | return false 106 | } 107 | if cmd.ProcessState != nil && cmd.ProcessState.Exited() { 108 | return false 109 | } 110 | 111 | var signal os.Signal 112 | if runtime.GOOS == "windows" { 113 | signal = os.Kill 114 | } else { 115 | signal = syscall.SIGTERM 116 | } 117 | err := cmd.Process.Signal(signal) 118 | if err != nil { 119 | logger.Notice("kill failed: %v", err) 120 | return false 121 | } 122 | logger.Notice("%s: %d has been killed", reason, cmd.Process.Pid) 123 | return true 124 | } 125 | 126 | func createCommand(commandString string) *exec.Cmd { 127 | parser := shellwords.NewParser() 128 | // parser.ParseBacktick = true 129 | // parser.ParseEnv = true 130 | args, err := parser.Parse(commandString) 131 | if err != nil { 132 | return nil 133 | } 134 | if len(args) == 1 { 135 | return exec.Command(args[0]) 136 | } 137 | return exec.Command(args[0], args[1:]...) 138 | } 139 | 140 | func sigIntChannel() chan struct{} { 141 | ch := make(chan struct{}) 142 | go func() { 143 | c := make(chan os.Signal, 1) 144 | signal.Notify(c, os.Interrupt) 145 | <-c 146 | close(ch) 147 | }() 148 | return ch 149 | } 150 | -------------------------------------------------------------------------------- /pkg/gazer/proc_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package gazer 8 | 9 | import ( 10 | "math" 11 | "os/exec" 12 | "testing" 13 | "time" 14 | ) 15 | 16 | func TestProc1(t *testing.T) { 17 | // Very normal 18 | cmd := createCommand("echo hello") 19 | executeCommandOrTimeout(cmd, math.MaxInt64) 20 | } 21 | 22 | func TestProc2(t *testing.T) { 23 | // Kill using timeout 24 | cmd := createCommand("sleep 60") 25 | executeCommandOrTimeout(cmd, 100) 26 | } 27 | 28 | func TestProc3(t *testing.T) { 29 | // Kill using a signal 30 | cmd := createCommand("sleep 60") 31 | go executeCommandOrTimeout(cmd, 60*10000) 32 | 33 | for { 34 | time.Sleep(50 * time.Millisecond) 35 | if cmd.Process != nil { 36 | cmd.Process.Kill() 37 | break 38 | } 39 | } 40 | } 41 | 42 | func TestProc4(t *testing.T) { 43 | cmd1 := createCommand("ls") 44 | if len(cmd1.Args) != 1 { 45 | t.Fatal() 46 | } 47 | if kill(cmd1, "test") { 48 | t.Fatal() 49 | } 50 | 51 | cmd2 := createCommand("ls aaa.txt") 52 | if len(cmd2.Args) != 2 { 53 | t.Fatal() 54 | } 55 | if kill(cmd2, "test") { 56 | t.Fatal() 57 | } 58 | 59 | cmd3 := createCommand(`ls aaa.txt "Program Files"`) 60 | if len(cmd3.Args) != 3 { 61 | t.Fatal() 62 | } 63 | if kill(cmd3, "test") { 64 | t.Fatal() 65 | } 66 | } 67 | 68 | func TestProc5(t *testing.T) { 69 | var cmd *exec.Cmd = nil 70 | cmdResult := executeCommandOrTimeout(cmd, 100) 71 | if cmdResult.Err == nil { 72 | t.Fatal() 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/gazer/template.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package gazer 8 | 9 | import ( 10 | "fmt" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/cbroglie/mustache" 15 | ) 16 | 17 | var templateCache = make(map[string]*mustache.Template) 18 | 19 | func render(sourceString string, rawfilePath string) (string, error) { 20 | template, err := getOrCreateTemplate(sourceString) 21 | if err != nil { 22 | return "", err 23 | } 24 | 25 | filePath := filepath.ToSlash(rawfilePath) 26 | ext := filepath.Ext(filePath) 27 | base := filepath.Base(filePath) 28 | rawAbs, _ := filepath.Abs(filePath) 29 | abs := filepath.ToSlash(rawAbs) 30 | dir := filepath.ToSlash(filepath.Dir(filePath)) 31 | 32 | arr := strings.Split(base, ".") 33 | base0 := baseN(arr, 0) 34 | base1 := baseN(arr, 1) 35 | base2 := baseN(arr, 2) 36 | 37 | params := map[string]string{ 38 | "file": filePath, 39 | "ext": ext, 40 | "base": base, 41 | "abs": abs, 42 | "dir": dir, 43 | "base0": base0, 44 | "base1": base1, 45 | "base2": base2, 46 | } 47 | 48 | result, err := template.Render(params) 49 | 50 | return result, err 51 | } 52 | 53 | func getOrCreateTemplate(sourceString string) (*mustache.Template, error) { 54 | cachedTemplate, ok := templateCache[sourceString] 55 | if ok { 56 | return cachedTemplate, nil 57 | } 58 | 59 | template, err := mustache.ParseStringRaw(sourceString, true) 60 | if err != nil { 61 | return nil, fmt.Errorf("%v(%s)", err, sourceString) 62 | } 63 | templateCache[sourceString] = template 64 | return template, nil 65 | } 66 | 67 | func baseN(arr []string, lastIndex int) string { 68 | var list []string 69 | for i := 0; ; i++ { 70 | if i > lastIndex || i >= len(arr) { 71 | break 72 | } 73 | list = append(list, arr[i]) 74 | } 75 | return strings.Join(list, ".") 76 | } 77 | -------------------------------------------------------------------------------- /pkg/gazer/template_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package gazer 8 | 9 | import ( 10 | "testing" 11 | ) 12 | 13 | func TestTemplate1(t *testing.T) { 14 | r, err := render("{{file}} {{ext}} {{base}} {{dir}} {{base0}} {{base1}} {{base2}}", "/full/path/test.txt.bak") 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | 19 | if r != "/full/path/test.txt.bak .bak test.txt.bak /full/path test test.txt test.txt.bak" { 20 | t.Fatal(r) 21 | } 22 | } 23 | 24 | func TestTemplateError(t *testing.T) { 25 | r, err := render("{{file}", "/full/path/test.txt.bak") 26 | if err == nil || r != "" { 27 | t.Fatal(err) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pkg/gutil/fs.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package gutil 8 | 9 | import ( 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/bmatcuk/doublestar" 15 | "github.com/wtetsu/gaze/pkg/logger" 16 | "github.com/wtetsu/gaze/pkg/uniq" 17 | ) 18 | 19 | // Find returns a list of files and directories that match the pattern. 20 | func Find(pattern string) ([]string, []string) { 21 | return find(pattern, doublestar.Glob) 22 | } 23 | 24 | func find(pattern string, globFunc func(string) ([]string, error)) ([]string, []string) { 25 | foundFiles, err := globFunc(pattern) 26 | if err != nil { 27 | return []string{}, []string{} 28 | } 29 | entryList := append([]string{pattern}, foundFiles...) 30 | 31 | fileList, dirList := doFileDir(entryList) 32 | return fileList, dirList 33 | } 34 | 35 | func doFileDir(entries []string) ([]string, []string) { 36 | fileUniq := uniq.New() 37 | dirUniq := uniq.New() 38 | 39 | for _, entry := range entries { 40 | stat := Stat(entry) 41 | if stat == nil { 42 | continue 43 | } 44 | if IsDir(entry) { 45 | dirUniq.Add(filepath.Clean(entry)) 46 | } else { 47 | fileUniq.Add(entry) 48 | dirPath := filepath.Dir(entry) 49 | dirUniq.Add(dirPath) 50 | } 51 | } 52 | return fileUniq.List(), dirUniq.List() 53 | } 54 | 55 | // GlobMatch returns true if a pattern matches a path string 56 | func GlobMatch(rawPattern string, rawFilePath string) bool { 57 | pattern := filepath.ToSlash(rawPattern) 58 | filePath := strings.TrimSuffix(filepath.ToSlash(rawFilePath), "/") 59 | 60 | ok, _ := doublestar.Match(pattern, filePath) 61 | if ok { 62 | logger.Debug("rawPattern:%s, rawFilePath:%s, true(file)", rawPattern, rawFilePath) 63 | return true 64 | } 65 | 66 | dirPath := filepath.ToSlash(filepath.Dir(filePath)) 67 | 68 | ok, _ = doublestar.Match(pattern, dirPath) 69 | if ok { 70 | logger.Debug("rawPattern:%s, rawFilePath:%s, true(dir)", rawPattern, rawFilePath) 71 | return true 72 | } 73 | 74 | logger.Debug("rawPattern:%s, rawFilePath:%s, false", rawPattern, rawFilePath) 75 | return false 76 | } 77 | 78 | // IsDir returns true if path is a directory. 79 | func IsDir(path string) bool { 80 | info := Stat(path) 81 | return info != nil && info.IsDir() 82 | } 83 | 84 | // IsFile returns true if path is a file. 85 | func IsFile(path string) bool { 86 | info := Stat(path) 87 | return info != nil && !info.IsDir() 88 | } 89 | 90 | // Stat returns a FileInfo. 91 | func Stat(path string) os.FileInfo { 92 | info, err := os.Stat(path) 93 | if err != nil { 94 | return nil 95 | } 96 | return info 97 | } 98 | -------------------------------------------------------------------------------- /pkg/gutil/fs_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package gutil 8 | 9 | import ( 10 | "fmt" 11 | "os" 12 | "path/filepath" 13 | "testing" 14 | ) 15 | 16 | func TestFind(t *testing.T) { 17 | files, _ := Find("./*.go") 18 | 19 | if len(files) == 0 { 20 | t.Fatal() 21 | } 22 | } 23 | 24 | func TestGlob(t *testing.T) { 25 | 26 | if GlobMatch("*.py", "a.rb") { 27 | t.Fatal() 28 | } 29 | if !GlobMatch("*.rb", "a.rb") { 30 | t.Fatal() 31 | } 32 | if !GlobMatch(".", "a.rb") { 33 | t.Fatal() 34 | } 35 | if !GlobMatch("/**/*.rb", "/full/path/a.rb") { 36 | t.Fatal() 37 | } 38 | 39 | if !GlobMatch("xx9", "xx9") { 40 | t.Fatal() 41 | } 42 | if !GlobMatch("xx9", "xx9/a.rb") { 43 | t.Fatal() 44 | } 45 | 46 | if !GlobMatch("xx?yy", "xx9yy") { 47 | t.Fatal() 48 | } 49 | if !GlobMatch("xx?yy", "xx9yy/a.rb") { 50 | t.Fatal() 51 | } 52 | if GlobMatch("xx?yy", "xx99yy") { 53 | t.Fatal() 54 | } 55 | if GlobMatch("xx?yy", "xx99yy/a.rb") { 56 | t.Fatal() 57 | } 58 | 59 | if !GlobMatch("xx*yy", "xx9yy") { 60 | t.Fatal() 61 | } 62 | if !GlobMatch("xx*yy", "xx9yy/a.rb") { 63 | t.Fatal() 64 | } 65 | if !GlobMatch("xx*yy", "xx99yy") { 66 | t.Fatal() 67 | } 68 | if !GlobMatch("xx*yy", "xx99yy/a.rb") { 69 | t.Fatal() 70 | } 71 | } 72 | 73 | func TestIs(t *testing.T) { 74 | if !IsFile("fs.go") { 75 | t.Fatal() 76 | } 77 | if IsFile("__fs.go") { 78 | t.Fatal() 79 | } 80 | if !IsDir(".") || !IsDir("..") { 81 | t.Fatal() 82 | } 83 | if IsDir("fs.go") { 84 | t.Fatal() 85 | } 86 | if IsDir("__fs.go") { 87 | t.Fatal() 88 | } 89 | } 90 | 91 | func TestGlobFuncWithError(t *testing.T) { 92 | // Define a custom glob function that returns an error. 93 | errorGlob := func(pattern string) ([]string, error) { 94 | return nil, fmt.Errorf("dummy error") 95 | } 96 | files, dirs := find("dummy", errorGlob) 97 | if len(files) != 0 || len(dirs) != 0 { 98 | t.Fatal("expected empty lists when glob function returns an error") 99 | } 100 | } 101 | 102 | func TestGlobFuncWithCustomSuccess(t *testing.T) { 103 | // Create a temporary file so that os.Stat can return valid info. 104 | tmpDir := t.TempDir() 105 | filePath := filepath.Join(tmpDir, "test.txt") 106 | f, err := os.Create(filePath) 107 | if err != nil { 108 | t.Fatalf("failed to create test file: %v", err) 109 | } 110 | f.Close() 111 | 112 | // Define a custom glob function that returns our test file. 113 | customGlob := func(pattern string) ([]string, error) { 114 | return []string{filePath}, nil 115 | } 116 | 117 | files, dirs := find(filePath, customGlob) 118 | 119 | // Check that the file is listed. 120 | if len(files) == 0 { 121 | t.Fatal("expected test file in files list") 122 | } 123 | found := false 124 | for _, f := range files { 125 | if f == filePath { 126 | found = true 127 | break 128 | } 129 | } 130 | if !found { 131 | t.Fatal("test file not found in files list") 132 | } 133 | 134 | // Check that the file's directory is included in the directory list. 135 | dirFound := false 136 | fileDir := filepath.Dir(filePath) 137 | for _, d := range dirs { 138 | if filepath.Clean(d) == filepath.Clean(fileDir) { 139 | dirFound = true 140 | break 141 | } 142 | } 143 | if !dirFound { 144 | t.Fatal("directory of test file not found in dirs list") 145 | } 146 | } 147 | func TestDoFileDir(t *testing.T) { 148 | // Create a temporary directory for testing. 149 | tmpDir := t.TempDir() 150 | 151 | // Create a temporary file inside tmpDir. 152 | filePath := filepath.Join(tmpDir, "test.txt") 153 | f, err := os.Create(filePath) 154 | if err != nil { 155 | t.Fatalf("failed to create test file: %v", err) 156 | } 157 | f.Close() 158 | 159 | // Create a temporary subdirectory inside tmpDir. 160 | subDir := filepath.Join(tmpDir, "subdir") 161 | if err := os.Mkdir(subDir, 0755); err != nil { 162 | t.Fatalf("failed to create test directory: %v", err) 163 | } 164 | 165 | // Build the entries list including: 166 | // - A file entry (twice to test uniqueness) 167 | // - A directory entry 168 | // - A non-existent file entry (which should be ignored) 169 | entries := []string{ 170 | filePath, 171 | subDir, 172 | filePath, 173 | filepath.Join(tmpDir, "nonexistent"), 174 | } 175 | 176 | files, dirs := doFileDir(entries) 177 | 178 | // Verify that the file list contains exactly one element: filePath. 179 | if len(files) != 1 { 180 | t.Fatalf("expected 1 file, got %d", len(files)) 181 | } 182 | if files[0] != filePath { 183 | t.Fatalf("expected file path %s, got %s", filePath, files[0]) 184 | } 185 | 186 | // The expected directories are: 187 | // - tmpDir (the parent of filePath) 188 | // - subDir (from the directory entry) 189 | expectedDirs := map[string]struct{}{ 190 | filepath.Clean(tmpDir): {}, 191 | filepath.Clean(subDir): {}, 192 | } 193 | 194 | // Check that each expected directory is present in the results. 195 | for _, d := range dirs { 196 | cleaned := filepath.Clean(d) 197 | delete(expectedDirs, cleaned) 198 | } 199 | 200 | if len(expectedDirs) != 0 { 201 | t.Fatal("not all expected directories were found in the results") 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /pkg/gutil/time.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package gutil 8 | 9 | import ( 10 | "os" 11 | "time" 12 | ) 13 | 14 | // GetFileModifiedTime returns the last modified time of a file. 15 | // If there is an error, it will return 0 16 | func GetFileModifiedTime(filePath string) int64 { 17 | fileInfo, err := os.Stat(filePath) 18 | if err != nil { 19 | return 0 20 | } 21 | return fileInfo.ModTime().UnixNano() 22 | } 23 | 24 | // After waits for the duration. 25 | func After(d int64) <-chan struct{} { 26 | ch := make(chan struct{}) 27 | 28 | go func() { 29 | time.Sleep(time.Duration(d) * time.Millisecond) 30 | close(ch) 31 | }() 32 | 33 | return ch 34 | } 35 | -------------------------------------------------------------------------------- /pkg/gutil/time_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package gutil 8 | 9 | import ( 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func Test(t *testing.T) { 15 | zero := GetFileModifiedTime("___invalid__") 16 | if zero != 0 { 17 | t.Fatal() 18 | } 19 | 20 | fileTime := GetFileModifiedTime("time.go") 21 | if fileTime == 0 { 22 | t.Fatal() 23 | } 24 | 25 | time.Sleep(1 * time.Millisecond) 26 | now1 := time.Now().UnixNano() 27 | 28 | if now1 < fileTime { 29 | t.Fatal() 30 | } 31 | 32 | ch := After(5) 33 | now2 := time.Now().UnixNano() 34 | if now2 < now1 { 35 | t.Fatal() 36 | } 37 | <-ch 38 | now3 := time.Now().UnixNano() 39 | if now3 < now2 { 40 | t.Fatal() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package logger 8 | 9 | import ( 10 | "fmt" 11 | "os" 12 | "sync" 13 | 14 | "github.com/fatih/color" 15 | ) 16 | 17 | // Log level. 18 | const ( 19 | SILENT = 0 20 | QUIET = 1 21 | NORMAL = 2 22 | VERBOSE = 3 23 | DEBUG = 4 24 | ) 25 | 26 | var logLevel = NORMAL 27 | var count = 0 28 | 29 | var printInfo func(format string, a ...interface{}) 30 | var printNotice func(format string, a ...interface{}) 31 | var printDebug func(format string, a ...interface{}) 32 | var printError func(format string, a ...interface{}) 33 | 34 | var initialized = false 35 | 36 | var mutex = &sync.Mutex{} 37 | 38 | func initialize() { 39 | if initialized { 40 | return 41 | } 42 | Plain() 43 | } 44 | 45 | // Level sets a new log level. 46 | func Level(newLogLevel int) { 47 | logLevel = newLogLevel 48 | } 49 | 50 | // Colorful enables colorful output 51 | func Colorful() { 52 | printInfo = color.New(color.FgHiCyan).PrintfFunc() 53 | printNotice = color.New(color.FgCyan).PrintfFunc() 54 | printDebug = color.New(color.FgHiMagenta).PrintfFunc() 55 | 56 | f := color.New(color.FgRed).FprintfFunc() 57 | printError = func(format string, a ...interface{}) { 58 | f(color.Error, format, a...) 59 | } 60 | initialized = true 61 | } 62 | 63 | // Plain disables colorful output 64 | func Plain() { 65 | naivePrint := func(format string, a ...interface{}) { 66 | fmt.Printf(format, a...) 67 | } 68 | printInfo = naivePrint 69 | printNotice = naivePrint 70 | printDebug = naivePrint 71 | printError = func(format string, a ...interface{}) { 72 | fmt.Fprintf(os.Stderr, format, a...) 73 | } 74 | initialized = true 75 | } 76 | 77 | // Error writes an error log 78 | func Error(format string, a ...interface{}) { 79 | if logLevel < QUIET { 80 | return 81 | } 82 | mutex.Lock() 83 | defer mutex.Unlock() 84 | initialize() 85 | newLine() 86 | printError(format, a...) 87 | fmt.Println() 88 | count++ 89 | } 90 | 91 | // ErrorObject writes an error log 92 | func ErrorObject(a ...interface{}) { 93 | Error("%v", a...) 94 | } 95 | 96 | // Notice writes a notice log 97 | func Notice(format string, a ...interface{}) { 98 | notice(false, format, a...) 99 | } 100 | 101 | // NoticeWithBlank writes a notice log 102 | func NoticeWithBlank(format string, a ...interface{}) { 103 | notice(true, format, a...) 104 | } 105 | 106 | // NoticeObject writes a notice log 107 | func NoticeObject(a ...interface{}) { 108 | notice(false, "%v", a...) 109 | } 110 | 111 | func notice(enableSpace bool, format string, a ...interface{}) { 112 | if logLevel < NORMAL { 113 | return 114 | } 115 | mutex.Lock() 116 | defer mutex.Unlock() 117 | initialize() 118 | if enableSpace { 119 | newLine() 120 | printNotice(format, a...) 121 | } else { 122 | printNotice(format, a...) 123 | } 124 | fmt.Println() 125 | count++ 126 | } 127 | 128 | // Info writes a info log 129 | func Info(format string, a ...interface{}) { 130 | if logLevel < VERBOSE { 131 | return 132 | } 133 | mutex.Lock() 134 | defer mutex.Unlock() 135 | initialize() 136 | printInfo(format, a...) 137 | fmt.Println() 138 | count++ 139 | } 140 | 141 | // Debug writes a debug log 142 | func Debug(format string, a ...interface{}) { 143 | if logLevel < DEBUG { 144 | return 145 | } 146 | mutex.Lock() 147 | defer mutex.Unlock() 148 | initialize() 149 | printDebug(format, a...) 150 | fmt.Println() 151 | count++ 152 | } 153 | 154 | // DebugObject writes a debug log 155 | func DebugObject(a ...interface{}) { 156 | Debug("%v", a...) 157 | } 158 | 159 | func newLine() { 160 | count++ 161 | if count <= 1 { 162 | return 163 | } 164 | fmt.Println() 165 | } 166 | -------------------------------------------------------------------------------- /pkg/logger/logger_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package logger 8 | 9 | import ( 10 | "testing" 11 | ) 12 | 13 | func Test(t *testing.T) { 14 | for level := 0; level <= 4; level++ { 15 | Level(level) 16 | writeAll() 17 | } 18 | 19 | Colorful() 20 | 21 | for level := 0; level <= 4; level++ { 22 | Level(level) 23 | writeAll() 24 | } 25 | 26 | Plain() 27 | for level := 0; level <= 4; level++ { 28 | Level(level) 29 | Colorful() 30 | } 31 | } 32 | 33 | func writeAll() { 34 | Error("log(Error)") 35 | ErrorObject("log(ErrorObject)") 36 | 37 | Notice("log(Notice)") 38 | NoticeObject("log(NoticeObject)") 39 | NoticeWithBlank("log(NoticeWithBlank)") 40 | 41 | Info("log(Info)") 42 | //InfoObject("log") 43 | 44 | Debug("log(Debug)") 45 | DebugObject("log(DebugObject)") 46 | } 47 | -------------------------------------------------------------------------------- /pkg/notify/notify.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package notify 8 | 9 | import ( 10 | "errors" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | "time" 15 | 16 | "github.com/bmatcuk/doublestar" 17 | "github.com/fsnotify/fsnotify" 18 | "github.com/wtetsu/gaze/pkg/gutil" 19 | "github.com/wtetsu/gaze/pkg/logger" 20 | "github.com/wtetsu/gaze/pkg/uniq" 21 | ) 22 | 23 | // Notify delivers events to a channel when files are virtually updated. 24 | // "create+rename" is regarded as "update". 25 | type Notify struct { 26 | Events chan Event 27 | Errors chan error 28 | watcher *fsnotify.Watcher 29 | isClosed bool 30 | times map[string]int64 31 | pendingPeriod int64 32 | regardRenameAsModPeriod int64 33 | detectCreate bool 34 | candidates []string 35 | } 36 | 37 | // Event represents a single file system notification. 38 | type Event struct { 39 | Name string 40 | Time int64 41 | } 42 | 43 | // Op describes a set of file operations. 44 | type Op = fsnotify.Op 45 | 46 | // Close disposes internal resources. 47 | func (n *Notify) Close() { 48 | if n.isClosed { 49 | return 50 | } 51 | n.watcher.Close() 52 | n.isClosed = true 53 | } 54 | 55 | // New creates a Notify 56 | func New(patterns []string, maxWatchDirs int) (*Notify, error) { 57 | watcher, err := fsnotify.NewWatcher() 58 | if err != nil { 59 | logger.ErrorObject(err) 60 | return nil, err 61 | } 62 | 63 | candidates := findCandidatesDirectories(patterns) 64 | watchDirs := findActualDirs(candidates, maxWatchDirs) 65 | 66 | if len(watchDirs) > maxWatchDirs { 67 | logger.Error(strings.Join(watchDirs[:maxWatchDirs], "\n") + "\n...") 68 | return nil, errors.New("too many watchDirs") 69 | } 70 | 71 | for _, t := range watchDirs { 72 | err = watcher.Add(t) 73 | if err != nil { 74 | if err.Error() == "bad file descriptor" { 75 | logger.Info("%s: %v", t, err) 76 | } else { 77 | logger.Error("%s: %v", t, err) 78 | } 79 | } else { 80 | logger.Info("gazing at: %s", t) 81 | } 82 | } 83 | 84 | notify := &Notify{ 85 | Events: make(chan Event), 86 | watcher: watcher, 87 | isClosed: false, 88 | times: make(map[string]int64), 89 | pendingPeriod: 100, 90 | regardRenameAsModPeriod: 1000, 91 | detectCreate: true, 92 | candidates: candidates, 93 | } 94 | 95 | go notify.wait() 96 | 97 | return notify, nil 98 | } 99 | 100 | func findActualDirs(patterns []string, maxWatchDirs int) []string { 101 | targets := uniq.New() 102 | 103 | for _, pattern := range patterns { 104 | dirs := findDirsByPattern(pattern) 105 | targets.AddAll(dirs) 106 | 107 | if targets.Len() > maxWatchDirs { 108 | break 109 | } 110 | } 111 | return targets.List() 112 | } 113 | 114 | // ["aaa/bbb/ccc"] -> [".", "aaa", "aaa/bbb", "aaa/bbb/ccc"] 115 | // ["../aaa/bbb/ccc"] -> ["..", "../aaa", "../aaa/bbb", "../aaa/bbb/ccc"] 116 | // ["/aaa/bbb/ccc"] -> ["/", "/aaa", "/aaa/bbb", "/aaa/bbb/ccc"] 117 | func findCandidatesDirectories(patterns []string) []string { 118 | targets := uniq.New() 119 | 120 | for _, pattern := range patterns { 121 | paths := parsePathPattern(pattern) 122 | for i := len(paths) - 1; i >= 0; i-- { 123 | targets.Add(paths[i]) 124 | } 125 | } 126 | return targets.List() 127 | } 128 | 129 | // "aaa/bbb/ccc/*/ddd/eee/*" -> ["aaa/bbb/ccc/*/ddd/eee/*", "aaa/bbb/ccc/*/ddd/eee", "aaa/bbb/ccc/*/ddd", "aaa/bbb/ccc/*", "aaa/bbb/ccc", "aaa/bbb", "aaa", "."] 130 | func parsePathPattern(pathPattern string) []string { 131 | result := []string{} 132 | 133 | if len(pathPattern) == 0 { 134 | return result 135 | } 136 | if pathPattern == "/" || pathPattern == "\\" || pathPattern == "." || pathPattern == ".." { 137 | return []string{pathPattern} 138 | } 139 | 140 | result = append(result, pathPattern) 141 | 142 | isAbs := filepath.IsAbs(pathPattern) || pathPattern[0] == '/' 143 | isParent := strings.HasPrefix(pathPattern, "..") 144 | isWinAbs := pathPattern[0] != '/' && isAbs 145 | isExplicitCurrent := false 146 | isCurrent := !isAbs && !isParent 147 | if isCurrent { 148 | isExplicitCurrent = strings.HasPrefix(pathPattern, ".") 149 | } 150 | 151 | winFirstDelimiter := -1 152 | if isWinAbs { 153 | winFirstDelimiter = strings.Index(pathPattern, "\\") 154 | } 155 | 156 | for i := len(pathPattern) - 1; i >= 0; i-- { 157 | ch := pathPattern[i] 158 | 159 | if ch == '/' || ch == '\\' { 160 | 161 | if i > 0 { 162 | p := pathPattern[0:i] 163 | if winFirstDelimiter == i { 164 | p += "\\" 165 | } 166 | result = append(result, p) 167 | } else { 168 | result = append(result, "/") 169 | } 170 | } 171 | } 172 | 173 | if len(result) <= 1 { 174 | if !isAbs && !isExplicitCurrent { 175 | result = append(result, ".") 176 | } 177 | } else { 178 | if isCurrent && !isExplicitCurrent { 179 | result = append(result, ".") 180 | } 181 | } 182 | 183 | return result 184 | } 185 | 186 | func findDirsByPattern(pattern string) []string { 187 | patternDir := filepath.Dir(pattern) 188 | logger.Debug("pattern: %s", pattern) 189 | logger.Debug("patternDir: %s", patternDir) 190 | 191 | var targets []string 192 | 193 | realDir := findRealDirectory(patternDir) 194 | if len(realDir) > 0 { 195 | targets = append(targets, realDir) 196 | } 197 | 198 | _, dirs1 := gutil.Find(pattern) 199 | targets = append(targets, dirs1...) 200 | 201 | _, dirs2 := gutil.Find(patternDir) 202 | targets = append(targets, dirs2...) 203 | 204 | return targets 205 | } 206 | 207 | func findRealDirectory(path string) string { 208 | entries := strings.Split(filepath.ToSlash(filepath.Clean(path)), "/") 209 | 210 | currentPath := "" 211 | for i := 0; i < len(entries); i++ { 212 | if containsWildcard(entries[i]) { 213 | break 214 | } 215 | 216 | currentPath += entries[i] + string(filepath.Separator) 217 | } 218 | currentPath = strings.TrimSuffix(currentPath, string(filepath.Separator)) 219 | 220 | if gutil.IsDir(currentPath) { 221 | return currentPath 222 | } else { 223 | return "" 224 | } 225 | } 226 | 227 | func containsWildcard(path string) bool { 228 | return strings.ContainsAny(path, "*?[{") 229 | } 230 | 231 | func shouldWatch(dirPath string, candidates []string) bool { 232 | dirPathSlash := filepath.ToSlash(dirPath) 233 | if !gutil.IsDir(dirPathSlash) { 234 | return false 235 | } 236 | 237 | for _, pattern := range candidates { 238 | patternSlash := filepath.ToSlash(pattern) 239 | ok, _ := doublestar.Match(patternSlash, dirPathSlash) 240 | if ok { 241 | return true 242 | } 243 | } 244 | 245 | return false 246 | } 247 | 248 | func (n *Notify) watchNewDirRecursive(dirPath string) { 249 | n.watchNewDir(dirPath) 250 | subDirs, err := os.ReadDir(dirPath) 251 | if err != nil { 252 | logger.Error("ReadDir: %s", err) 253 | return 254 | } 255 | for _, subDir := range subDirs { 256 | if subDir.IsDir() { 257 | subDirPath := filepath.Join(dirPath, subDir.Name()) 258 | n.watchNewDirRecursive(subDirPath) 259 | } 260 | } 261 | } 262 | 263 | func (n *Notify) wait() { 264 | for { 265 | select { 266 | case event, ok := <-n.watcher.Events: 267 | normalizedName := filepath.Clean(event.Name) 268 | 269 | logger.Debug("IsDir: %s", gutil.IsDir(normalizedName)) 270 | if event.Has(fsnotify.Create) && shouldWatch(normalizedName, n.candidates) { 271 | logger.Info("gazing at: %s", normalizedName) 272 | n.watchNewDirRecursive(normalizedName) 273 | } 274 | 275 | if !ok { 276 | continue 277 | } 278 | if !n.shouldExecute(normalizedName, event) { 279 | continue 280 | } 281 | logger.Debug("notified: %s: %s", normalizedName, event.Op) 282 | now := time.Now().UnixNano() 283 | n.times[normalizedName] = now 284 | e := Event{ 285 | Name: normalizedName, 286 | Time: now, 287 | } 288 | n.Events <- e 289 | case err, ok := <-n.watcher.Errors: 290 | if !ok { 291 | continue 292 | } 293 | n.Errors <- err 294 | } 295 | } 296 | } 297 | 298 | func (n *Notify) watchNewDir(normalizedName string) { 299 | err := n.watcher.Remove(normalizedName) 300 | if err != nil { 301 | if strings.HasPrefix(err.Error(), "fsnotify: can't remove non-existent") { 302 | logger.Debug("watcher.Remove: %s", err) 303 | } else { 304 | logger.Error("watcher.Remove: %s", err) 305 | } 306 | } 307 | err = n.watcher.Add(normalizedName) 308 | if err != nil { 309 | logger.Error("watcher.Add: %s", err) 310 | } 311 | } 312 | 313 | func (n *Notify) shouldExecute(filePath string, ev fsnotify.Event) bool { 314 | const W = fsnotify.Write 315 | const R = fsnotify.Rename 316 | const C = fsnotify.Create 317 | 318 | if !ev.Has(W) && !ev.Has(R) && !(n.detectCreate && ev.Has(C)) { 319 | logger.Debug("skipped: %s: %s (Op is not applicable)", filePath, ev.Op) 320 | return false 321 | } 322 | 323 | lastExecutionTime := n.times[filePath] 324 | 325 | if !gutil.IsFile(filePath) { 326 | logger.Debug("skipped: %s: %s (not a file)", filePath, ev.Op) 327 | return false 328 | } 329 | 330 | if strings.Contains(filePath, "'") || strings.Contains(filePath, "\"") { 331 | logger.Debug("skipped: %s: %s (unsupported character)", filePath, ev.Op) 332 | return false 333 | } 334 | 335 | modifiedTime := gutil.GetFileModifiedTime(filePath) 336 | 337 | if ev.Has(W) || ev.Has(C) { 338 | elapsed := modifiedTime - lastExecutionTime 339 | logger.Debug("lastExecutionTime(%s): %d, %d", ev.Op, lastExecutionTime, elapsed) 340 | if elapsed < n.pendingPeriod*1000000 { 341 | logger.Debug("skipped: %s: %s (too frequent)", filePath, ev.Op) 342 | return false 343 | } 344 | } 345 | if ev.Has(R) { 346 | elapsed := time.Now().UnixNano() - modifiedTime 347 | logger.Debug("lastExecutionTime(%s): %d, %d", ev.Op, lastExecutionTime, elapsed) 348 | if elapsed > n.regardRenameAsModPeriod*1000000 { 349 | logger.Debug("skipped: %s: %s (unnatural rename)", filePath, ev.Op) 350 | return false 351 | } 352 | } 353 | 354 | return true 355 | } 356 | 357 | // PendingPeriod sets new pendingPeriod(ms). 358 | func (n *Notify) PendingPeriod(p int64) { 359 | n.pendingPeriod = p 360 | } 361 | 362 | // Requeue requeue an event. 363 | func (n *Notify) Requeue(event Event) { 364 | n.Events <- event 365 | } 366 | -------------------------------------------------------------------------------- /pkg/notify/notify_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package notify 8 | 9 | import ( 10 | "fmt" 11 | "log" 12 | "os" 13 | "path/filepath" 14 | "reflect" 15 | "sort" 16 | "strings" 17 | "testing" 18 | "time" 19 | 20 | "github.com/fsnotify/fsnotify" 21 | "github.com/wtetsu/gaze/pkg/logger" 22 | ) 23 | 24 | func TestUtilFunctions(t *testing.T) { 25 | logger.Level(logger.VERBOSE) 26 | 27 | tmpDir := createTempDir() 28 | 29 | if tmpDir == "" { 30 | t.Fatal("Temp files error") 31 | } 32 | 33 | os.MkdirAll(tmpDir+"/dir0", os.ModePerm) 34 | os.MkdirAll(tmpDir+"/dir1/dir2a/dir3a", os.ModePerm) 35 | os.MkdirAll(tmpDir+"/dir1/dir2a/dir3b", os.ModePerm) 36 | os.MkdirAll(tmpDir+"/dir1/dir2a/dir3c", os.ModePerm) 37 | os.MkdirAll(tmpDir+"/dir1/dir2b/dir3a", os.ModePerm) 38 | os.MkdirAll(tmpDir+"/dir1/dir2b/dir3b", os.ModePerm) 39 | os.MkdirAll(tmpDir+"/dir1/dir2b/dir3c", os.ModePerm) 40 | 41 | createTempFileWithDir(tmpDir+"/dir1/dir2b/dir3b", "*.tmp", `puts "Hello from Ruby`) 42 | 43 | actual1 := findActualDirs([]string{tmpDir + "/*"}, 100) 44 | sort.Strings(actual1) 45 | 46 | expected1 := []string{ 47 | tmpDir, 48 | tmpDir + "/dir0", 49 | tmpDir + "/dir1", 50 | } 51 | 52 | for i := 0; i < len(expected1); i++ { 53 | 54 | if filepath.Clean(actual1[i]) != filepath.Clean(expected1[i]) { 55 | t.Fatalf("%s != %s", actual1[i], expected1[i]) 56 | } 57 | } 58 | 59 | actual2 := findActualDirs([]string{tmpDir + "/**"}, 100) 60 | sort.Strings(actual2) 61 | 62 | expected2 := []string{ 63 | tmpDir, 64 | tmpDir + "/dir0", 65 | tmpDir + "/dir1", 66 | tmpDir + "/dir1/dir2a", 67 | tmpDir + "/dir1/dir2a/dir3a", 68 | tmpDir + "/dir1/dir2a/dir3b", 69 | tmpDir + "/dir1/dir2a/dir3c", 70 | tmpDir + "/dir1/dir2b", 71 | tmpDir + "/dir1/dir2b/dir3a", 72 | tmpDir + "/dir1/dir2b/dir3b", 73 | tmpDir + "/dir1/dir2b/dir3c", 74 | } 75 | 76 | for i := 0; i < len(expected2); i++ { 77 | 78 | if filepath.Clean(actual2[i]) != filepath.Clean(expected2[i]) { 79 | t.Fatalf("%s != %s", actual2[i], expected2[i]) 80 | } 81 | } 82 | } 83 | 84 | func TestFindRealDirectory(t *testing.T) { 85 | tmpDir := createTempDir() 86 | 87 | if tmpDir == "" { 88 | t.Fatal("Temp files error") 89 | } 90 | 91 | os.MkdirAll(tmpDir+"/dir0/dir1/dir2/dir3", os.ModePerm) 92 | 93 | var r string 94 | 95 | r = findRealDirectory(tmpDir + "/dir0/dir1/dir2/dir3") 96 | if r != filepath.Clean(tmpDir+"/dir0/dir1/dir2/dir3") { 97 | t.Fatal("Unexpected result:" + r) 98 | } 99 | 100 | r = findRealDirectory(tmpDir + "/dir0/dir1/dir2/**") 101 | if r != filepath.Clean(tmpDir+"/dir0/dir1/dir2") { 102 | t.Fatal("Unexpected result:" + r) 103 | } 104 | 105 | r = findRealDirectory(tmpDir + "/dir0/dir1/") 106 | if r != filepath.Clean(tmpDir+"/dir0/dir1") { 107 | t.Fatal("Unexpected result:" + r) 108 | } 109 | 110 | r = findRealDirectory(tmpDir + "/dir0/dir1/**/dir3") 111 | if r != filepath.Clean(tmpDir+"/dir0/dir1") { 112 | t.Fatal("Unexpected result:" + r) 113 | } 114 | 115 | r = findRealDirectory(tmpDir + "/?ir?/dir1/dir2/dir3") 116 | if r != filepath.Clean(tmpDir) { 117 | t.Fatal("Unexpected result:" + r) 118 | } 119 | 120 | r = findRealDirectory(tmpDir + "/dir0/dir1/\\*\\?\\[\\]/dir3") 121 | if r != filepath.Clean(tmpDir+"/dir0/dir1") { 122 | t.Fatal("Unexpected result:" + r) 123 | } 124 | 125 | r = findRealDirectory("invalid/path/") 126 | if r != "" { 127 | t.Fatal("Unexpected result:" + r) 128 | } 129 | } 130 | 131 | func TestTooManyDirectories(t *testing.T) { 132 | tmpDir := createTempDir() 133 | 134 | if tmpDir == "" { 135 | t.Fatal("Temp files error") 136 | } 137 | 138 | // Create 100 directories 139 | for i := 0; i < 9; i++ { 140 | for j := 0; j < 10; j++ { 141 | path := fmt.Sprintf("%s/%d/%d", tmpDir, i, j) 142 | os.MkdirAll(path, os.ModePerm) 143 | } 144 | } 145 | 146 | os.Chdir(tmpDir) 147 | 148 | // Safe 149 | _, err := New([]string{"**"}, 100) 150 | if err != nil { 151 | t.Fatal("Temp files error:" + err.Error()) 152 | } 153 | 154 | // Out 155 | _, err = New([]string{"**"}, 99) 156 | if err == nil { 157 | t.Fatal("Temp files error") 158 | } 159 | 160 | // Exceeds 100 directories 161 | path := fmt.Sprintf("%s/%d/%d/%d", tmpDir, 99, 99, 99) 162 | os.MkdirAll(path, os.ModePerm) 163 | 164 | // Safe 165 | _, err = New([]string{"**"}, 103) 166 | if err != nil { 167 | t.Fatal("Temp files error") 168 | } 169 | 170 | // Out 171 | _, err = New([]string{"**"}, 102) 172 | if err == nil { 173 | t.Fatal("Temp files error") 174 | } 175 | } 176 | 177 | func TestFindCandidatesDirectories(t *testing.T) { 178 | type testData struct { 179 | args []string 180 | expected []string 181 | } 182 | testDataList := []testData{ 183 | {[]string{"aaa/bbb/ccc"}, []string{".", "aaa", "aaa/bbb", "aaa/bbb/ccc"}}, 184 | {[]string{"../aaa/bbb/ccc"}, []string{"..", "../aaa", "../aaa/bbb", "../aaa/bbb/ccc"}}, 185 | {[]string{"/aaa/bbb/ccc"}, []string{"/", "/aaa", "/aaa/bbb", "/aaa/bbb/ccc"}}, 186 | {[]string{"aaa/bbb/ccc", "aaa/bbb/ddd", "."}, []string{".", "aaa", "aaa/bbb", "aaa/bbb/ccc", "aaa/bbb/ddd"}}, 187 | 188 | {[]string{"aaa\\bbb\\ccc"}, []string{".", "aaa", "aaa\\bbb", "aaa\\bbb\\ccc"}}, 189 | } 190 | 191 | if os.PathSeparator == '\\' { 192 | testDataList = append(testDataList, testData{[]string{"c:\\aaa\\bbb\\ccc"}, []string{"c:\\", "c:\\aaa", "c:\\aaa\\bbb", "c:\\aaa\\bbb\\ccc"}}) 193 | } 194 | 195 | for _, rawData := range testDataList { 196 | actual := findCandidatesDirectories(rawData.args) 197 | if !reflect.DeepEqual(actual, rawData.expected) { 198 | t.Fatalf("param: %s, actual:%s, expected:%s", 199 | strings.Join(rawData.args, ","), 200 | strings.Join(actual, ","), 201 | strings.Join(rawData.expected, ","), 202 | ) 203 | } 204 | } 205 | } 206 | 207 | func TestParsePathPattern(t *testing.T) { 208 | type testData struct { 209 | arg string 210 | expected []string 211 | } 212 | 213 | testDataList := []testData{ 214 | {"*", []string{"*", "."}}, 215 | {"*/aaa", []string{"*/aaa", "*", "."}}, 216 | {"aaa/*/bbb/ccc/*", []string{"aaa/*/bbb/ccc/*", "aaa/*/bbb/ccc", "aaa/*/bbb", "aaa/*", "aaa", "."}}, 217 | {"*/aaa/*/bbb/ccc/*", []string{"*/aaa/*/bbb/ccc/*", "*/aaa/*/bbb/ccc", "*/aaa/*/bbb", "*/aaa/*", "*/aaa", "*", "."}}, 218 | 219 | {"**", []string{"**", "."}}, 220 | {"**/aaa", []string{"**/aaa", "**", "."}}, 221 | {"aaa/**/bbb/ccc/**", []string{"aaa/**/bbb/ccc/**", "aaa/**/bbb/ccc", "aaa/**/bbb", "aaa/**", "aaa", "."}}, 222 | {"**/aaa/**/bbb/ccc/**", []string{"**/aaa/**/bbb/ccc/**", "**/aaa/**/bbb/ccc", "**/aaa/**/bbb", "**/aaa/**", "**/aaa", "**", "."}}, 223 | 224 | {"?", []string{"?", "."}}, 225 | {"?/aaa", []string{"?/aaa", "?", "."}}, 226 | {"aaa/?/bbb/ccc/?", []string{"aaa/?/bbb/ccc/?", "aaa/?/bbb/ccc", "aaa/?/bbb", "aaa/?", "aaa", "."}}, 227 | {"?/aaa/?/bbb/ccc/?", []string{"?/aaa/?/bbb/ccc/?", "?/aaa/?/bbb/ccc", "?/aaa/?/bbb", "?/aaa/?", "?/aaa", "?", "."}}, 228 | 229 | {"aaa/bbb/ccc/*/ddd/eee/*", []string{"aaa/bbb/ccc/*/ddd/eee/*", "aaa/bbb/ccc/*/ddd/eee", "aaa/bbb/ccc/*/ddd", "aaa/bbb/ccc/*", "aaa/bbb/ccc", "aaa/bbb", "aaa", "."}}, 230 | {"aaa/bbb/ccc/?/ddd/eee/*", []string{"aaa/bbb/ccc/?/ddd/eee/*", "aaa/bbb/ccc/?/ddd/eee", "aaa/bbb/ccc/?/ddd", "aaa/bbb/ccc/?", "aaa/bbb/ccc", "aaa/bbb", "aaa", "."}}, 231 | {"aaa/bbb/ccc/**/ddd/eee/*", []string{"aaa/bbb/ccc/**/ddd/eee/*", "aaa/bbb/ccc/**/ddd/eee", "aaa/bbb/ccc/**/ddd", "aaa/bbb/ccc/**", "aaa/bbb/ccc", "aaa/bbb", "aaa", "."}}, 232 | 233 | {"../aaa", []string{"../aaa", ".."}}, 234 | {"../aaa/bbb", []string{"../aaa/bbb", "../aaa", ".."}}, 235 | {"../aaa/bbb/ccc", []string{"../aaa/bbb/ccc", "../aaa/bbb", "../aaa", ".."}}, 236 | {"../aaa/bbb/ccc/ddd", []string{"../aaa/bbb/ccc/ddd", "../aaa/bbb/ccc", "../aaa/bbb", "../aaa", ".."}}, 237 | 238 | {"./aaa", []string{"./aaa", "."}}, 239 | {"./aaa/bbb", []string{"./aaa/bbb", "./aaa", "."}}, 240 | {"./aaa/bbb/ccc", []string{"./aaa/bbb/ccc", "./aaa/bbb", "./aaa", "."}}, 241 | {"./aaa/bbb/ccc/ddd", []string{"./aaa/bbb/ccc/ddd", "./aaa/bbb/ccc", "./aaa/bbb", "./aaa", "."}}, 242 | 243 | {"", []string{}}, 244 | {"/", []string{"/"}}, 245 | {".", []string{"."}}, 246 | {"..", []string{".."}}, 247 | 248 | {"/aaa", []string{"/aaa", "/"}}, 249 | {"/aaa/bbb", []string{"/aaa/bbb", "/aaa", "/"}}, 250 | {"/aaa/bbb/ccc", []string{"/aaa/bbb/ccc", "/aaa/bbb", "/aaa", "/"}}, 251 | {"/aaa/bbb/ccc/ddd", []string{"/aaa/bbb/ccc/ddd", "/aaa/bbb/ccc", "/aaa/bbb", "/aaa", "/"}}, 252 | 253 | {"aaa", []string{"aaa", "."}}, 254 | {"aaa/bbb", []string{"aaa/bbb", "aaa", "."}}, 255 | {"aaa/bbb/ccc", []string{"aaa/bbb/ccc", "aaa/bbb", "aaa", "."}}, 256 | {"aaa/bbb/ccc/ddd", []string{"aaa/bbb/ccc/ddd", "aaa/bbb/ccc", "aaa/bbb", "aaa", "."}}, 257 | 258 | {"aaa", []string{"aaa", "."}}, 259 | {"aaa\\bbb", []string{"aaa\\bbb", "aaa", "."}}, 260 | {"aaa\\bbb\\ccc", []string{"aaa\\bbb\\ccc", "aaa\\bbb", "aaa", "."}}, 261 | {"aaa\\bbb\\ccc\\ddd", []string{"aaa\\bbb\\ccc\\ddd", "aaa\\bbb\\ccc", "aaa\\bbb", "aaa", "."}}, 262 | 263 | {"..\\aaa", []string{"..\\aaa", ".."}}, 264 | {"..\\aaa\\bbb", []string{"..\\aaa\\bbb", "..\\aaa", ".."}}, 265 | {"..\\aaa\\bbb\\ccc", []string{"..\\aaa\\bbb\\ccc", "..\\aaa\\bbb", "..\\aaa", ".."}}, 266 | {"..\\aaa\\bbb\\ccc\\ddd", []string{"..\\aaa\\bbb\\ccc\\ddd", "..\\aaa\\bbb\\ccc", "..\\aaa\\bbb", "..\\aaa", ".."}}, 267 | 268 | {".\\aaa", []string{".\\aaa", "."}}, 269 | {".\\aaa\\bbb", []string{".\\aaa\\bbb", ".\\aaa", "."}}, 270 | {".\\aaa\\bbb\\ccc", []string{".\\aaa\\bbb\\ccc", ".\\aaa\\bbb", ".\\aaa", "."}}, 271 | {".\\aaa\\bbb\\ccc\\ddd", []string{".\\aaa\\bbb\\ccc\\ddd", ".\\aaa\\bbb\\ccc", ".\\aaa\\bbb", ".\\aaa", "."}}, 272 | } 273 | 274 | if filepath.IsAbs("c:\\aaa") { 275 | testDataList = append(testDataList, []testData{ 276 | {"c:\\aaa", []string{"c:\\aaa", "c:\\"}}, 277 | {"c:\\aaa\\bbb", []string{"c:\\aaa\\bbb", "c:\\aaa", "c:\\"}}, 278 | {"c:\\aaa\\bbb\\ccc", []string{"c:\\aaa\\bbb\\ccc", "c:\\aaa\\bbb", "c:\\aaa", "c:\\"}}, 279 | {"c:\\aaa\\bbb\\ccc\\ddd", []string{"c:\\aaa\\bbb\\ccc\\ddd", "c:\\aaa\\bbb\\ccc", "c:\\aaa\\bbb", "c:\\aaa", "c:\\"}}, 280 | }...) 281 | } 282 | 283 | for _, rawData := range testDataList { 284 | actual := parsePathPattern(rawData.arg) 285 | 286 | if !reflect.DeepEqual(actual, rawData.expected) { 287 | t.Fatalf("param: %s, actual:%s, expected:%s", 288 | rawData.arg, 289 | strings.Join(actual, ","), 290 | strings.Join(rawData.expected, ","), 291 | ) 292 | } 293 | } 294 | } 295 | 296 | func TestUpdate(t *testing.T) { 297 | logger.Level(logger.VERBOSE) 298 | 299 | rb := createTempFile("*.rb", `puts "Hello from Ruby`) 300 | py := createTempFile("*.py", `print("Hello from Python")`) 301 | 302 | if rb == "" || py == "" { 303 | t.Fatal("Temp files error") 304 | } 305 | 306 | patterns := []string{filepath.Dir(rb) + "/*.rb", filepath.Dir(rb) + "/*.py"} 307 | 308 | notify, err := New(patterns, 100) 309 | if err != nil { 310 | t.Fatal() 311 | } 312 | 313 | notify.PendingPeriod(10) 314 | 315 | count := 0 316 | go func() { 317 | for { 318 | select { 319 | case _, ok := <-notify.Events: 320 | if !ok { 321 | continue 322 | } 323 | count++ 324 | 325 | case err, ok := <-notify.Errors: 326 | if !ok { 327 | continue 328 | } 329 | log.Println("error:", err) 330 | count++ 331 | } 332 | } 333 | }() 334 | 335 | for i := 0; i < 50; i++ { 336 | touch(py) 337 | touch(rb) 338 | if count >= 2 { 339 | break 340 | } 341 | time.Sleep(20 * time.Millisecond) 342 | } 343 | if count < 2 { 344 | t.Fatalf("count:%d", count) 345 | } 346 | 347 | notify.Close() 348 | notify.Close() 349 | } 350 | 351 | func TestCreateAndMove(t *testing.T) { 352 | logger.Level(logger.VERBOSE) 353 | 354 | tmpDir := createTempDir() 355 | 356 | if tmpDir == "" { 357 | t.Fatal("Temp files error") 358 | } 359 | 360 | notify, err := New([]string{tmpDir}, 100) 361 | notify.regardRenameAsModPeriod = 10000 362 | notify.detectCreate = true 363 | if err != nil { 364 | t.Fatal() 365 | } 366 | 367 | notify.PendingPeriod(10) 368 | 369 | count := 0 370 | go func() { 371 | for { 372 | select { 373 | case _, ok := <-notify.Events: 374 | if !ok { 375 | continue 376 | } 377 | count++ 378 | 379 | case err, ok := <-notify.Errors: 380 | if !ok { 381 | continue 382 | } 383 | log.Println("error:", err) 384 | count++ 385 | } 386 | } 387 | }() 388 | 389 | for i := 0; i < 50; i++ { 390 | rb := createTempFileWithDir(tmpDir, "*.tmp", `puts "Hello from Ruby`) 391 | os.Rename(rb, rb+".rb") 392 | py := createTempFileWithDir(tmpDir, "*.tmp", `print("Hello from Python")`) 393 | os.Rename(py, py+".py") 394 | 395 | if count >= 4 { 396 | break 397 | } 398 | time.Sleep(20 * time.Millisecond) 399 | } 400 | 401 | if count < 4 { 402 | t.Fatalf("count:%d", count) 403 | } 404 | 405 | notify.Close() 406 | notify.Close() 407 | } 408 | 409 | func TestDelete(t *testing.T) { 410 | logger.Level(logger.VERBOSE) 411 | 412 | rb1 := createTempFile("*.rb", `puts "Hello from Ruby`) 413 | rb2 := createTempFile("*.rb", `puts "Hello from Ruby`) 414 | py1 := createTempFile("*.py", `print("Hello from Python")`) 415 | py2 := createTempFile("*.py", `print("Hello from Python")`) 416 | 417 | if rb1 == "" || rb2 == "" || py1 == "" || py2 == "" { 418 | t.Fatal("Temp files error") 419 | } 420 | 421 | patterns := []string{ 422 | filepath.Dir(rb1) + "/*.rb", 423 | filepath.Dir(rb2) + "/*.rb", 424 | filepath.Dir(py1) + "/*.py", 425 | filepath.Dir(py2) + "/*.py", 426 | } 427 | 428 | notify, err := New(patterns, 100) 429 | if err != nil { 430 | t.Fatal() 431 | } 432 | 433 | notify.PendingPeriod(10) 434 | 435 | count := 0 436 | go func() { 437 | for { 438 | select { 439 | case _, ok := <-notify.Events: 440 | if !ok { 441 | continue 442 | } 443 | count++ 444 | 445 | case err, ok := <-notify.Errors: 446 | if !ok { 447 | continue 448 | } 449 | log.Println("error:", err) 450 | count++ 451 | } 452 | } 453 | }() 454 | 455 | os.Remove(rb1) 456 | os.Remove(rb2) 457 | os.Remove(py1) 458 | os.Remove(py2) 459 | 460 | time.Sleep(20 * time.Millisecond) 461 | 462 | if count != 0 { 463 | t.Fatalf("count:%d", count) 464 | } 465 | 466 | notify.Close() 467 | notify.Close() 468 | } 469 | 470 | func TestQueue(t *testing.T) { 471 | logger.Level(logger.VERBOSE) 472 | 473 | rb := createTempFile("*.rb", `puts "Hello from Ruby`) 474 | py := createTempFile("*.py", `print("Hello from Python")`) 475 | 476 | if rb == "" || py == "" { 477 | t.Fatal("Temp files error") 478 | } 479 | 480 | rbCommand := fmt.Sprintf(`ruby "%s"`, rb) 481 | pyCommand := fmt.Sprintf(`python "%s"`, py) 482 | 483 | patterns := []string{filepath.Dir(rb) + "/*.rb", filepath.Dir(rb) + "/*.py"} 484 | 485 | notify, err := New(patterns, 100) 486 | if err != nil { 487 | t.Fatal() 488 | } 489 | 490 | notify.PendingPeriod(10) 491 | 492 | count := 0 493 | go func() { 494 | for { 495 | select { 496 | case _, ok := <-notify.Events: 497 | if !ok { 498 | continue 499 | } 500 | count++ 501 | 502 | case err, ok := <-notify.Errors: 503 | if !ok { 504 | continue 505 | } 506 | log.Println("error:", err) 507 | count++ 508 | } 509 | } 510 | }() 511 | 512 | notify.Requeue(Event{rbCommand, 3}) 513 | notify.Requeue(Event{pyCommand, 4}) 514 | notify.Requeue(Event{rbCommand, 5}) 515 | notify.Requeue(Event{pyCommand, 6}) 516 | for i := 0; i < 50; i++ { 517 | // touch(py) 518 | // touch(rb) 519 | if count >= 2 { 520 | break 521 | } 522 | time.Sleep(20 * time.Millisecond) 523 | } 524 | if count < 2 { 525 | t.Fatalf("count:%d", count) 526 | } 527 | 528 | notify.Close() 529 | notify.Close() 530 | } 531 | 532 | func createTempDir() string { 533 | dirpath, err := os.MkdirTemp("", "_gaze") 534 | if err != nil { 535 | return "" 536 | } 537 | return dirpath 538 | } 539 | 540 | func createTempFile(pattern string, content string) string { 541 | dirpath := createTempDir() 542 | return createTempFileWithDir(dirpath, pattern, content) 543 | } 544 | 545 | func createTempFileWithDir(dirpath string, pattern string, content string) string { 546 | file, err := os.CreateTemp(dirpath, pattern) 547 | if err != nil { 548 | return "" 549 | } 550 | file.WriteString(content) 551 | file.Close() 552 | 553 | return file.Name() 554 | } 555 | 556 | func touch(fileName string) { 557 | file, err := os.OpenFile(fileName, os.O_RDWR|os.O_APPEND, 0666) 558 | if err != nil { 559 | return 560 | } 561 | file.WriteString(" ") 562 | file.Close() 563 | } 564 | 565 | func TestShouldExecute(t *testing.T) { 566 | // create a temporary file to test with 567 | tmpFile := createTempFile("test-*.txt", "content") 568 | if tmpFile == "" { 569 | t.Fatal("failed to create temp file") 570 | } 571 | defer os.Remove(tmpFile) 572 | 573 | // Create a dummy Notify instance with minimal fields for testing 574 | n := &Notify{ 575 | times: make(map[string]int64), 576 | pendingPeriod: 10, // in ms 577 | regardRenameAsModPeriod: 1000, // in ms 578 | detectCreate: true, 579 | } 580 | 581 | // Test 1: Valid Write event. 582 | // Set lastExecutionTime to 0 so the elapsed time (file mod time - 0) is large. 583 | n.times[tmpFile] = 0 584 | eventWrite := fsnotify.Event{Name: tmpFile, Op: fsnotify.Write} 585 | if !n.shouldExecute(tmpFile, eventWrite) { 586 | t.Fatalf("shouldExecute returned false for a valid Write event") 587 | } 588 | 589 | // Test 2: Too frequent Write event. 590 | // Set lastExecutionTime to current time and update file mod time to now. 591 | now := time.Now().UnixNano() 592 | n.times[tmpFile] = now 593 | _, err := os.Stat(tmpFile) 594 | if err != nil { 595 | t.Fatal(err) 596 | } 597 | currentTime := time.Unix(0, now) 598 | err = os.Chtimes(tmpFile, currentTime, currentTime) 599 | if err != nil { 600 | t.Fatal(err) 601 | } 602 | if n.shouldExecute(tmpFile, eventWrite) { 603 | t.Fatalf("shouldExecute returned true for a too frequent Write event") 604 | } 605 | 606 | // Test 3: Valid Create event. 607 | n.times[tmpFile] = 0 608 | eventCreate := fsnotify.Event{Name: tmpFile, Op: fsnotify.Create} 609 | if !n.shouldExecute(tmpFile, eventCreate) { 610 | t.Fatalf("shouldExecute returned false for a valid Create event") 611 | } 612 | 613 | // Test 4: Valid Rename event. 614 | // For Rename, the elapsed time is measured as (now - file mod time). 615 | // Set file mod time to current time so that elapsed is small. 616 | now = time.Now().UnixNano() 617 | n.times[tmpFile] = 0 618 | currentTime = time.Unix(0, now) 619 | err = os.Chtimes(tmpFile, currentTime, currentTime) 620 | if err != nil { 621 | t.Fatal(err) 622 | } 623 | eventRename := fsnotify.Event{Name: tmpFile, Op: fsnotify.Rename} 624 | if !n.shouldExecute(tmpFile, eventRename) { 625 | t.Fatalf("shouldExecute returned false for a valid Rename event") 626 | } 627 | 628 | // Test 5: Too old Rename event. 629 | // Set the file modification time in the past such that 630 | // (current time - modifiedTime) > regardRenameAsModPeriod*1e6. 631 | pastTime := time.Now().UnixNano() - (n.regardRenameAsModPeriod*1000000 + 1) 632 | past := time.Unix(0, pastTime) 633 | err = os.Chtimes(tmpFile, past, past) 634 | if err != nil { 635 | t.Fatal(err) 636 | } 637 | if n.shouldExecute(tmpFile, eventRename) { 638 | t.Fatalf("shouldExecute returned true for a too old Rename event") 639 | } 640 | 641 | // Test 6: Non-existent file should be skipped. 642 | nonExistent := tmpFile + "_nonexistent" 643 | eventNonExistent := fsnotify.Event{Name: nonExistent, Op: fsnotify.Write} 644 | if n.shouldExecute(nonExistent, eventNonExistent) { 645 | t.Fatalf("shouldExecute returned true for a non-existent file") 646 | } 647 | 648 | // Test 7: File with unsupported characters. 649 | invalidName := tmpFile + `"` 650 | eventInvalidChar := fsnotify.Event{Name: invalidName, Op: fsnotify.Write} 651 | if n.shouldExecute(invalidName, eventInvalidChar) { 652 | t.Fatalf("shouldExecute returned true for a file with unsupported characters") 653 | } 654 | } 655 | -------------------------------------------------------------------------------- /pkg/uniq/uniq.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package uniq 8 | 9 | // Uniq can deal with unique list. 10 | type Uniq struct { 11 | list []string 12 | keys map[string]struct{} 13 | } 14 | 15 | // New returns a new Uniq. 16 | func New() *Uniq { 17 | return &Uniq{ 18 | list: []string{}, 19 | keys: map[string]struct{}{}, 20 | } 21 | } 22 | 23 | // Add adds a new entry. 24 | func (u *Uniq) Add(newEntry string) { 25 | _, ok := u.keys[newEntry] 26 | if ok { 27 | return 28 | } 29 | u.keys[newEntry] = struct{}{} 30 | u.list = (append(u.list, newEntry)) 31 | } 32 | 33 | // AddAll adds multiple new entries. 34 | func (u *Uniq) AddAll(newEntries []string) { 35 | for _, e := range newEntries { 36 | u.Add(e) 37 | } 38 | } 39 | 40 | // List returns a internal unique list. 41 | func (u *Uniq) List() []string { 42 | return u.list 43 | } 44 | 45 | // Len returns the length 46 | func (u *Uniq) Len() int { 47 | return len(u.list) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/uniq/uniq_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Gaze (https://github.com/wtetsu/gaze/) 3 | * Copyright 2020-present wtetsu 4 | * Licensed under MIT 5 | */ 6 | 7 | package uniq 8 | 9 | import ( 10 | "testing" 11 | ) 12 | 13 | func Test(t *testing.T) { 14 | uniq := New() 15 | if len(uniq.List()) != 0 { 16 | t.Fatal() 17 | } 18 | 19 | uniq.Add("aaa") 20 | uniq.Add("bbb") 21 | 22 | if len(uniq.List()) != 2 { 23 | t.Fatal() 24 | } 25 | 26 | uniq.Add("bbb") 27 | uniq.Add("bbb") 28 | uniq.Add("ccc") 29 | uniq.Add("ccc") 30 | 31 | if uniq.Len() != 3 { 32 | t.Fatal() 33 | } 34 | 35 | uniq.AddAll([]string{"bbb", "ccc", "ddd"}) 36 | if uniq.Len() != 4 { 37 | t.Fatal() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/e2e/files/append.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | fname = "test.py.log" 4 | 5 | print(f"Append a line to {fname}") 6 | 7 | with open(fname, "a") as file: 8 | file.write(str(datetime.datetime.now())) 9 | file.write("\n") 10 | -------------------------------------------------------------------------------- /test/e2e/files/append.rb: -------------------------------------------------------------------------------- 1 | 2 | fname = "test.rb.log" 3 | 4 | puts "Append a line to #{fname}" 5 | 6 | File.open(fname, "a") {|file| 7 | file.puts(Time.now) 8 | } 9 | -------------------------------------------------------------------------------- /test/e2e/files/hello.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func main() { 6 | fmt.Println("hello, world!(Go)") 7 | } 8 | -------------------------------------------------------------------------------- /test/e2e/files/hello.py: -------------------------------------------------------------------------------- 1 | print("hello, world!(Python)") 2 | -------------------------------------------------------------------------------- /test/e2e/files/hello.rb: -------------------------------------------------------------------------------- 1 | if ARGV.length >= 1 2 | sleep(ARGV[0].to_f) 3 | end 4 | puts("hello, world!(Ruby)") 5 | -------------------------------------------------------------------------------- /test/e2e/files/hello.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | println!("hello, world!(Rust)") 3 | } 4 | -------------------------------------------------------------------------------- /test/e2e/files/repeat.py: -------------------------------------------------------------------------------- 1 | import time 2 | import datetime 3 | 4 | while True: 5 | print(datetime.datetime.now()) 6 | time.sleep(1) 7 | -------------------------------------------------------------------------------- /test/e2e/files/repeat.rb: -------------------------------------------------------------------------------- 1 | 2 | while true 3 | puts Time.new 4 | sleep 1 5 | end 6 | -------------------------------------------------------------------------------- /test/e2e/run_forever.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | 4 | dir=$(cd $(dirname $0); pwd) 5 | filedir=$dir/files 6 | 7 | cd $dir 8 | 9 | gaze -q files/*.* & 10 | 11 | while true; do 12 | sleep 0.1 13 | touch $filedir/hello.rb 14 | sleep 0.1 15 | touch $filedir/hello.py 16 | sleep 0.1 17 | touch $filedir/hello.rs 18 | sleep 0.1 19 | touch $filedir/hello.rb 20 | sleep 0.1 21 | touch $filedir/hello.py 22 | sleep 0.1 23 | touch $filedir/hello.rs 24 | sleep 0.1 25 | touch $filedir/hello.rb 26 | sleep 0.1 27 | touch $filedir/hello.py 28 | sleep 0.1 29 | touch $filedir/hello.rs 30 | sleep 0.1 31 | done 32 | -------------------------------------------------------------------------------- /test/e2e/test01.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | gaze="${1:-gaze}" 4 | 5 | dir=$(cd $(dirname $0); pwd) 6 | filedir=$dir/files 7 | 8 | cd $dir 9 | rm -f test.*.log 10 | 11 | timeout -sKILL 6 ${gaze} -v files/*.* | tee test.log & 12 | 13 | sleep 1.0 14 | echo >> $filedir/hello.rb 15 | sleep 0.3 16 | echo >> $filedir/hello.go 17 | sleep 0.3 18 | echo >> $filedir/hello.py 19 | sleep 0.3 20 | echo >> $filedir/hello.rs 21 | sleep 0.3 22 | echo >> $filedir/hello.rb 23 | sleep 0.3 24 | echo >> $filedir/hello.go 25 | sleep 0.3 26 | echo >> $filedir/hello.py 27 | sleep 0.3 28 | echo >> $filedir/hello.rs 29 | sleep 0.3 30 | echo >> $filedir/hello.rb 31 | sleep 0.3 32 | echo >> $filedir/hello.py 33 | sleep 0.3 34 | echo >> $filedir/hello.rs 35 | sleep 0.3 36 | 37 | wait 38 | 39 | num=`cat test.log | grep "hello, world!" | wc -l` 40 | 41 | if [ $num -ne 11 ]; then 42 | echo "Failed:${num}" 43 | exit 1 44 | fi 45 | 46 | git checkout -- files 47 | 48 | echo "OK" 49 | exit 0 50 | -------------------------------------------------------------------------------- /test/e2e/test02.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | gaze="${1:-gaze}" 4 | 5 | dir=$(cd $(dirname $0); pwd) 6 | filedir=$dir/files 7 | 8 | cd $dir 9 | rm -f test.*.log 10 | 11 | timeout -sKILL 6 ${gaze} -v -c "ruby {{file}} 1" -r files/*.* | tee test.log & 12 | 13 | sleep 1.0 14 | echo >> $filedir/hello.rb 15 | sleep 0.3 16 | echo >> $filedir/hello.rb 17 | sleep 0.3 18 | echo >> $filedir/hello.rb 19 | sleep 0.3 20 | echo >> $filedir/hello.rb 21 | sleep 0.3 22 | echo >> $filedir/hello.rb 23 | sleep 0.3 24 | echo >> $filedir/hello.rb 25 | sleep 0.3 26 | echo >> $filedir/hello.rb 27 | sleep 0.3 28 | echo >> $filedir/hello.rb 29 | sleep 0.3 30 | echo >> $filedir/hello.rb 31 | sleep 0.3 32 | echo >> $filedir/hello.rb 33 | sleep 0.3 34 | 35 | wait 36 | 37 | num=`cat test.log | grep "hello, world!" | wc -l` 38 | 39 | if [ $num -ne 1 ]; then 40 | echo "Failed:${num}" 41 | exit 1 42 | fi 43 | 44 | git checkout -- files 45 | 46 | echo "OK" 47 | exit 0 48 | -------------------------------------------------------------------------------- /test/e2e/test03.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | gaze="${1:-gaze}" 4 | 5 | dir=$(cd $(dirname $0); pwd) 6 | filedir=$dir/files 7 | 8 | cd $dir 9 | rm -f test.*.log 10 | 11 | cp $filedir/hello.py "$filedir/he'llo.py" 12 | cp $filedir/hello.py "$filedir/he&llo.py" 13 | cp $filedir/hello.py "$filedir/he llo.py" 14 | cp $filedir/hello.py "$filedir/he(llo.py" 15 | 16 | timeout -sKILL 6 ${gaze} -v files/*.* | tee test.log & 17 | 18 | sleep 1.0 19 | echo >> "$filedir/he'llo.py" 20 | sleep 0.3 21 | echo >> "$filedir/he&llo.py" 22 | sleep 0.3 23 | echo >> "$filedir/he llo.py" 24 | sleep 0.3 25 | echo >> "$filedir/he(llo.py" 26 | sleep 0.3 27 | echo >> "$filedir/he'llo.py" 28 | sleep 0.3 29 | echo >> "$filedir/he&llo.py" 30 | sleep 0.3 31 | echo >> "$filedir/he llo.py" 32 | sleep 0.3 33 | echo >> "$filedir/he'llo.py" 34 | sleep 0.3 35 | echo >> "$filedir/he(llo.py" 36 | sleep 0.3 37 | echo >> "$filedir/he&llo.py" 38 | sleep 0.3 39 | echo >> "$filedir/he llo.py" 40 | sleep 0.3 41 | echo >> "$filedir/he(llo.py" 42 | 43 | wait 44 | 45 | rm "$filedir/he'llo.py" 46 | rm "$filedir/he&llo.py" 47 | rm "$filedir/he llo.py" 48 | rm "$filedir/he(llo.py" 49 | 50 | num=`cat test.log | grep "hello, world!" | wc -l` 51 | 52 | if [ $num -ne 9 ]; then 53 | echo "Failed:${num}" 54 | exit 1 55 | fi 56 | 57 | echo "OK" 58 | exit 0 59 | -------------------------------------------------------------------------------- /test/e2e/test04.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | gaze="${1:-gaze}" 4 | 5 | dir=$(cd $(dirname $0); pwd) 6 | filedir=$dir/files/ 7 | nested=$filedir/deep/path 8 | 9 | cd $dir 10 | rm -f test.*.log 11 | rm -rf $filedir/deep 12 | 13 | sleep 1.0 14 | 15 | timeout -sKILL 6 ${gaze} -v "files/**/*.*" --debug | tee test.log & 16 | 17 | sleep 1.0 18 | 19 | # Test new deep path after gaze started 20 | mkdir -p $nested 21 | echo "*" > $nested/.gitignore 22 | 23 | sleep 1.0 24 | cp $filedir/hello.rb $nested/hello.rb 25 | sleep 0.3 26 | cp $filedir/hello.py $nested/hello.py 27 | sleep 0.3 28 | cp $filedir/hello.rb $nested/hello.rb 29 | sleep 0.3 30 | cp $filedir/hello.py $nested/hello.py 31 | sleep 0.3 32 | 33 | wait 34 | 35 | num=`cat test.log | grep "hello, world!" | wc -l` 36 | 37 | if [ $num -ne 4 ]; then 38 | echo "Failed:${num}" 39 | exit 1 40 | fi 41 | 42 | git checkout -- files 43 | 44 | echo "OK" 45 | exit 0 46 | -------------------------------------------------------------------------------- /test/e2e/test_all.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | # dry-run 4 | sh test01.sh ./main 5 | 6 | sh test01.sh ./main 7 | r01=$? 8 | 9 | sh test02.sh ./main 10 | r02=$? 11 | 12 | sh test03.sh ./main 13 | r03=$? 14 | 15 | sh test04.sh ./main 16 | r04=$? 17 | 18 | 19 | echo "test01.sh: $r01" 20 | echo "test02.sh: $r02" 21 | echo "test03.sh: $r03" 22 | echo "test04.sh: $r04" 23 | 24 | if [ $r01 -ne 0 ] || [ $r02 -ne 0 ] || [ $r03 -ne 0 ] || [ $r04 -ne 0 ]; then 25 | exit 1 26 | fi --------------------------------------------------------------------------------