├── .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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
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 | 
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 | 
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 | 
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 | 
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 | 
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
--------------------------------------------------------------------------------