diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..3031ad2 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,49 @@ +# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + analyze: + permissions: + actions: read # for github/codeql-action/init to get workflow details + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/autobuild to send a status report + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: ['go'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: ./go.mod + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/depsreview.yaml b/.github/workflows/depsreview.yaml new file mode 100644 index 0000000..03f3c5b --- /dev/null +++ b/.github/workflows/depsreview.yaml @@ -0,0 +1,17 @@ +name: 'Dependency Review' +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + dependency-review: + name: Run dependency review + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + uses: actions/checkout@v4 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..fc5b5d2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,43 @@ +--- +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + test: + name: Run tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: ./go.mod + + - name: Unit tests + run: | + mv .testdata src/builtins + go test -coverprofile=coverage.out ./src && go tool cover -func=coverage.out + + - name: Check that we didn't lose anything + run: diff <(cat testlogs/* | go run ./main.go | sed 's/\x1b\[[0-9;]*m//g') <(cat testlogs/*) + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.3.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml new file mode 100644 index 0000000..ccb9357 --- /dev/null +++ b/.github/workflows/typos.yml @@ -0,0 +1,15 @@ +name: "Spell Check" +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + typos: + name: Spell Check with Typos + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: crate-ci/typos@v1.20.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4776ad6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +coverage.out +.logalize.yaml diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..84ad137 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,38 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 1 + +before: + hooks: + - go mod tidy + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + +archives: + - format: tar.gz + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + format: zip + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/.testdata/logformats/bad.yaml b/.testdata/logformats/bad.yaml new file mode 100644 index 0000000..0bb57e1 --- /dev/null +++ b/.testdata/logformats/bad.yaml @@ -0,0 +1,3 @@ +formats: + test: + pattern: bad: diff --git a/.testdata/logformats/good.yaml b/.testdata/logformats/good.yaml new file mode 100644 index 0000000..0886f19 --- /dev/null +++ b/.testdata/logformats/good.yaml @@ -0,0 +1,43 @@ +# $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" +formats: + nginx-combined: + # $remote_addr + - pattern: (\d{1,3}(\.\d{1,3}){3} ) + fg: "#f5ce42" + # - + - pattern: (- ) + fg: "#807e7a" + # $remote_user + - pattern: ([^ ]+ ) + fg: "#764a9e" + # [$time_local] + - pattern: (\[.+\] ) + fg: "#148dd9" + # "$request" + - pattern: ("[^"]+" ) + fg: "#9ddb56" + # $status + - pattern: (\d\d\d ) + fg: "#ffffff" + alternatives: + - pattern: (2\d\d ) + fg: "#00ff00" + style: bold + - pattern: (3\d\d ) + fg: "#00ffff" + style: bold + - pattern: (4\d\d ) + fg: "#ff0000" + style: bold + - pattern: (5\d\d ) + fg: "#ff00ff" + style: bold + # $body_bytes_sent + - pattern: ([\d]+ ) + fg: "#7d7d7d" + # "$http_referer" + - pattern: ("[^"]+" ) + fg: "#3ae1f0" + # "$http_user_agent" + - pattern: ("[^"]+") + fg: "#aa7dd1" diff --git a/.testdata/words/bad.yaml b/.testdata/words/bad.yaml new file mode 100644 index 0000000..c2d1848 --- /dev/null +++ b/.testdata/words/bad.yaml @@ -0,0 +1,2 @@ +words: + bad: bad: diff --git a/.testdata/words/good.yaml b/.testdata/words/good.yaml new file mode 100644 index 0000000..4f54438 --- /dev/null +++ b/.testdata/words/good.yaml @@ -0,0 +1,66 @@ +words: + good: + fg: "#52fa8a" + style: bold + list: + - "active" + - "attempt" + - "clean" + - "complete" + - "configure" + - "connect" + - "done" + - "enable" + - "finish" + - "found" + - "listen" + - "load" + - "ok" + - "online" + - "open" + - "ready" + - "register" + - "start" + - "succeed" + - "success" + - "successful" + - "successfully" + - "true" + - "valid" + bad: + fg: "#f06c62" + style: bold + list: + - "alarm" + - "block" + - "close" + - "critical" + - "deny" + - "disable" + - "down" + - "empty" + - "end" + - "error" + - "exit" + - "fail" + - "false" + - "fatal" + - "invalid" + - "offline" + - "shut" + - "stop" + - "terminate" + - "unable" + - "unreach" + warning: + fg: "#fcba03" + style: bold + list: + - "detected" + - "ignore" + - "miss" + - "readonly" + - "reload" + - "restart" + - "skip" + - "warn" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e43ce4e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Rufus Deponian + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/builtins/logformats/nginx-combined.yaml b/builtins/logformats/nginx-combined.yaml new file mode 100644 index 0000000..0886f19 --- /dev/null +++ b/builtins/logformats/nginx-combined.yaml @@ -0,0 +1,43 @@ +# $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" +formats: + nginx-combined: + # $remote_addr + - pattern: (\d{1,3}(\.\d{1,3}){3} ) + fg: "#f5ce42" + # - + - pattern: (- ) + fg: "#807e7a" + # $remote_user + - pattern: ([^ ]+ ) + fg: "#764a9e" + # [$time_local] + - pattern: (\[.+\] ) + fg: "#148dd9" + # "$request" + - pattern: ("[^"]+" ) + fg: "#9ddb56" + # $status + - pattern: (\d\d\d ) + fg: "#ffffff" + alternatives: + - pattern: (2\d\d ) + fg: "#00ff00" + style: bold + - pattern: (3\d\d ) + fg: "#00ffff" + style: bold + - pattern: (4\d\d ) + fg: "#ff0000" + style: bold + - pattern: (5\d\d ) + fg: "#ff00ff" + style: bold + # $body_bytes_sent + - pattern: ([\d]+ ) + fg: "#7d7d7d" + # "$http_referer" + - pattern: ("[^"]+" ) + fg: "#3ae1f0" + # "$http_user_agent" + - pattern: ("[^"]+") + fg: "#aa7dd1" diff --git a/builtins/logformats/nginx-ingress-controller.yaml b/builtins/logformats/nginx-ingress-controller.yaml new file mode 100644 index 0000000..c54e8eb --- /dev/null +++ b/builtins/logformats/nginx-ingress-controller.yaml @@ -0,0 +1,83 @@ +# $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" $request_length $request_time [$proxy_upstream_name] [$proxy_alternative_upstream_name] $upstream_addr $upstream_response_length $upstream_response_time $upstream_status $req_id +formats: + nginx-ingress-controller: + # $remote_addr + - pattern: (\d{1,3}(\.\d{1,3}){3} ) + fg: "#f5ce42" + # - + - pattern: (- ) + fg: "#807e7a" + # $remote_user + - pattern: ([^ ]+ ) + fg: "#764a9e" + # [$time_local] + - pattern: (\[.+\] ) + fg: "#148dd9" + # "$request" + - pattern: ("[^"]+" ) + fg: "#9ddb56" + # $status + - pattern: (\d\d\d ) + fg: "#ffffff" + alternatives: + - pattern: (2\d\d ) + fg: "#00ff00" + style: bold + - pattern: (3\d\d ) + fg: "#00ffff" + style: bold + - pattern: (4\d\d ) + fg: "#ff0000" + style: bold + - pattern: (5\d\d ) + fg: "#ff00ff" + style: bold + # $body_bytes_sent + - pattern: ([\d]+ ) + fg: "#7d7d7d" + # "$http_referer" + - pattern: ("[^"]+" ) + fg: "#3cc2d6" + # "$http_user_agent" + - pattern: ("[^"]+" ) + fg: "#aa7dd1" + # $request_length + - pattern: (\d+ ) + fg: "#3cc2d6" + # $request_time + - pattern: ([\d\.]+ ) + fg: "#e3973b" + # [$proxy_upstream_name] + - pattern: (\[.+\] ) + fg: "#148dd9" + # [$proxy_alternative_upstream_name] + - pattern: (\[.*\] ) + fg: "#7d7d7d" + # $upstream_addr + - pattern: ((\d{1,3}(\.\d{1,3}){3}:\d+|-) ) + fg: "#9ddb56" + # $upstream_response_length + - pattern: ((\d+|-) ) + fg: "#aa7dd1" + # $upstream_response_time + - pattern: (([\d\.]+|-) ) + fg: "#cfcc3e" + # $upstream_status + - pattern: ((\d\d\d|-) ) + fg: "#ffffff" + alternatives: + - pattern: (2\d\d ) + fg: "#00ff00" + style: bold + - pattern: (3\d\d ) + fg: "#00ffff" + style: bold + - pattern: (4\d\d ) + fg: "#ff0000" + style: bold + - pattern: (5\d\d ) + fg: "#ff00ff" + style: bold + # $req_id + - pattern: ([[:xdigit:]]{32}) + fg: "#e3973b" diff --git a/builtins/words/bad.yaml b/builtins/words/bad.yaml new file mode 100644 index 0000000..cd3ebd9 --- /dev/null +++ b/builtins/words/bad.yaml @@ -0,0 +1,26 @@ +words: + bad: + fg: "#f06c62" + style: bold + list: + - "alarm" + - "block" + - "close" + - "critical" + - "deny" + - "disable" + - "down" + - "empty" + - "end" + - "error" + - "exit" + - "fail" + - "false" + - "fatal" + - "invalid" + - "offline" + - "shut" + - "stop" + - "terminate" + - "unable" + - "unreach" diff --git a/builtins/words/good.yaml b/builtins/words/good.yaml new file mode 100644 index 0000000..4dbc91b --- /dev/null +++ b/builtins/words/good.yaml @@ -0,0 +1,29 @@ +words: + good: + fg: "#52fa8a" + style: bold + list: + - "active" + - "attempt" + - "clean" + - "complete" + - "configure" + - "connect" + - "done" + - "enable" + - "finish" + - "found" + - "listen" + - "load" + - "ok" + - "online" + - "open" + - "ready" + - "register" + - "start" + - "succeed" + - "success" + - "successful" + - "successfully" + - "true" + - "valid" diff --git a/builtins/words/warning.yaml b/builtins/words/warning.yaml new file mode 100644 index 0000000..5b1d2ea --- /dev/null +++ b/builtins/words/warning.yaml @@ -0,0 +1,13 @@ +words: + warning: + fg: "#fcba03" + style: bold + list: + - "detected" + - "ignore" + - "miss" + - "readonly" + - "reload" + - "restart" + - "skip" + - "warn" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d35edd9 --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/deponian/logalize + +go 1.22.3 + +require ( + github.com/aaaton/golem/v4 v4.0.1 + github.com/aaaton/golem/v4/dicts/en v1.0.1 + github.com/alexflint/go-arg v1.4.3 + github.com/google/go-cmp v0.6.0 + github.com/knadh/koanf/parsers/yaml v0.1.0 + github.com/knadh/koanf/providers/file v0.1.0 + github.com/knadh/koanf/providers/rawbytes v0.1.0 + github.com/knadh/koanf/v2 v2.1.1 + github.com/muesli/termenv v0.15.2 +) + +require ( + github.com/alexflint/go-scalar v1.2.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect + github.com/knadh/koanf/maps v0.1.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sys v0.19.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ccc3a35 --- /dev/null +++ b/go.sum @@ -0,0 +1,61 @@ +github.com/aaaton/golem/v4 v4.0.0/go.mod h1:OfK/S5v9Exsx1yO21WorREuIVV+Y5K2hygP0A9oJCCI= +github.com/aaaton/golem/v4 v4.0.1 h1:jvnnTmzdfZC8cUGIo6obIcnmB3stTaf5Uw64OMx3C84= +github.com/aaaton/golem/v4 v4.0.1/go.mod h1:OfK/S5v9Exsx1yO21WorREuIVV+Y5K2hygP0A9oJCCI= +github.com/aaaton/golem/v4/dicts/en v1.0.1 h1:/BsOsh8JTgTkuevwM9axPnAi9CD4rK7TWHNdW/6V3Uo= +github.com/aaaton/golem/v4/dicts/en v1.0.1/go.mod h1:1YKRrQNng+KbS+peA7sj3TIa8eqR6T2UqdJ+Tc9xeoA= +github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/uo= +github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA= +github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= +github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c= +github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= +github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w= +github.com/knadh/koanf/parsers/yaml v0.1.0/go.mod h1:cvbUDC7AL23pImuQP0oRw/hPuccrNBS2bps8asS0CwY= +github.com/knadh/koanf/providers/file v0.1.0 h1:fs6U7nrV58d3CFAFh8VTde8TM262ObYf3ODrc//Lp+c= +github.com/knadh/koanf/providers/file v0.1.0/go.mod h1:rjJ/nHQl64iYCtAW2QQnF0eSmDEX/YZ/eNFj5yR6BvA= +github.com/knadh/koanf/providers/rawbytes v0.1.0 h1:dpzgu2KO6uf6oCb4aP05KDmKmAmI51k5pe8RYKQ0qME= +github.com/knadh/koanf/providers/rawbytes v0.1.0/go.mod h1:mMTB1/IcJ/yE++A2iEZbY1MLygX7vttU+C+S/YmPu9c= +github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM= +github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/images/avif/logo-dark.avif b/images/avif/logo-dark.avif new file mode 100644 index 0000000..8015bee Binary files /dev/null and b/images/avif/logo-dark.avif differ diff --git a/images/avif/logo-light.avif b/images/avif/logo-light.avif new file mode 100644 index 0000000..be98767 Binary files /dev/null and b/images/avif/logo-light.avif differ diff --git a/images/avif/screenshot-dark.avif b/images/avif/screenshot-dark.avif new file mode 100644 index 0000000..c13a404 Binary files /dev/null and b/images/avif/screenshot-dark.avif differ diff --git a/images/avif/screenshot-light.avif b/images/avif/screenshot-light.avif new file mode 100644 index 0000000..f09094a Binary files /dev/null and b/images/avif/screenshot-light.avif differ diff --git a/images/png/logo-dark.png b/images/png/logo-dark.png new file mode 100644 index 0000000..0083b99 Binary files /dev/null and b/images/png/logo-dark.png differ diff --git a/images/png/logo-light.png b/images/png/logo-light.png new file mode 100644 index 0000000..f37476d Binary files /dev/null and b/images/png/logo-light.png differ diff --git a/images/png/screenshot-dark.png b/images/png/screenshot-dark.png new file mode 100644 index 0000000..8a4af40 Binary files /dev/null and b/images/png/screenshot-dark.png differ diff --git a/images/png/screenshot-light.png b/images/png/screenshot-light.png new file mode 100644 index 0000000..bd1d3e2 Binary files /dev/null and b/images/png/screenshot-light.png differ diff --git a/images/png/social-preview.png b/images/png/social-preview.png new file mode 100644 index 0000000..e807912 Binary files /dev/null and b/images/png/social-preview.png differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..6ace1ad --- /dev/null +++ b/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "embed" + "fmt" + "log" + "os" + + arg "github.com/alexflint/go-arg" + logalize "github.com/deponian/logalize/src" + + "github.com/aaaton/golem/v4" + "github.com/aaaton/golem/v4/dicts/en" +) + +var ( + version string = "0.1.0" + releaseDate string = "2024-05-12" +) + +//go:embed builtins/* +var builtins embed.FS + +func main() { + logalize.SetGlobals(version, releaseDate) + + // parse options + options := logalize.Options{} + parser, err := logalize.ParseOptions(os.Args[1:], &options) + switch { + case err == arg.ErrHelp: + parser.WriteHelp(os.Stdout) + os.Exit(0) + case err == arg.ErrVersion: + fmt.Fprintln(os.Stdout, options.Version()) + os.Exit(0) + case err != nil: + parser.Fail(err.Error()) + } + + // build config + lemmatizer, err := golem.New(en.New()) + if err != nil { + log.Fatal(err) + } + config, err := logalize.InitConfig(options, builtins) + if err != nil { + log.Fatal(err) + } + + // run the app + err = logalize.Run(os.Stdin, os.Stdout, config, builtins, lemmatizer) + if err != nil { + log.Fatal(err) + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..0ae824d --- /dev/null +++ b/readme.md @@ -0,0 +1,232 @@ + + + + Screenshot + + +93% of all logs are not colored[^1]. It's sad. Maybe even illegal. It's time to logalize them. **Logalize** is a log colorizer like [colorize](https://github.com/raszi/colorize) and [ccze](https://github.com/cornet/ccze). But it's faster and, much more importantly, it's extensible. No more hardcoded templates for logs and keywords. Logalize is fully customizable via `logalize.yaml` where you can define your log formats, keyword patterns and more. + +

+ Build Status + + Go Report Card + License + Github Release +

+ +Usage +----- + +```bash +cat /path/to/logs/file.log | logalize +``` + + + + + Screenshot + + +How it works +------------ + +Logalize reads one line at a time and then checks if it matches one of log formats (`formats`), general regular expressions (`patterns`) or plain English words and their [inflected](https://en.wikipedia.org/wiki/Inflection) forms (`words`). See configuration below for more details. + +Simplified version of the main loop: +1. Read a line from stdin +2. If the entire line matches one of the `formats`, print colored line and go to step 1, otherwise go to step 3 +3. Find and color all `patterns` in the line and go to step 4 +4. Find and color all `words`, print colored line and go to step 1 + +Configuration +------------- + +Logalize looks for configuration files in these places: +- `/etc/logalize/logalize.yaml` +- `~/.config/logalize/logalize.yaml` +- `.logalize.yaml` +- path from `-c/--config` option + +If more than one configuration file is found, they are merged. The lower the file in the list, the higher its priority. + +A configuration file can contain three top-level keys: `formats`, `patterns` and `words`. + +### Log formats + +Configuration example: + +```yaml +formats: + kuvaq: + - pattern: (\d{1,3}(\.\d{1,3}){3} ) + fg: "#f5ce42" + - pattern: (- ) + bg: "#807e7a" + style: bold + - pattern: ("[^"]+" ) + fg: "#9ddb56" + bg: "#f5ce42" + - pattern: (\d\d\d) + fg: "#ffffff" + alternatives: + - pattern: (2\d\d) + fg: "#00ff00" + style: bold + - pattern: (3\d\d) + fg: "#00ffff" + style: bold +``` + +`formats` describe complete log formats. A line must match a format completely to be colored. For example, the full regular expression for the "kuvaq" format above is `^(\d{1,3}(\.\d{1,3}){3} )(- )("[^"]+" )(\d\d\d)$`. Only lines below will match this format: +- `127.0.0.1 - "menetekel" 777` +- `7.7.7.7 - "m" 000` + +But not these: +- `127.0.0.1 - "menetekel" 777 lower ascension station` +- `Upper ascension station 127.0.0.1 - "menetekel" 777` +- `127.0.0.1 - "menetekel" 777000` + +For an overview of the pattern syntax, see the [regexp/syntax](https://pkg.go.dev/regexp/syntax) package. + +Full log format example using all available fields: + +```yaml +formats: + # name of a log format + elysium: + # pattern must begin with an opening parenthesis `(` + # and it must end with a closing parenthesis `)` + # pattern can't be empty `()` + - pattern: (\d\d\d ) + # color can be a hex value like #ff0000 + # or a number between 0 and 255 for ANSI colors + fg: "#00ff00" + bg: "#0000ff" + # available styles are bold, faint, italic, + # underline, overline, crossout, reverse + style: bold + # alternatives are useful when you have general regular expression + # but you want different colors for some specific subset of cases + # within this regular expression + # a common example is HTTP status code + alternatives: + # every pattern here has the same "fg", "bg" and "style" fields + # but no "alternatives" field + - pattern: (2\d\d ) + fg: "#00ff00" + bg: "#0000ff" + style: bold + - pattern: (4\d\d ) + fg: "#ff0000" + bg: "#0000ff" + style: underline + # each next pattern is added to the previous one + # and together they form a complete pattern for the whole string + - pattern: (--- ) + # . . . . . + - pattern: ([[:xdigit:]]{32}) + # . . . . . + # full pattern for this whole example is: + # ^(\d\d\d )(--- )([[:xdigit:]]{32})$ +``` + +You can find built-in `formats` [here](builtins/logformats). If you want to customize them or turn them off completely, overwrite the corresponding values in your `logalize.yaml`. + +### Patterns + +Configuration example: + +```yaml +patterns: + string: + priority: 500 + pattern: ("[^"]+"|'[^']+') + fg: "#00ff00" + + number: + pattern: (\d+) + bg: "#00ffff" + style: bold + + http-status-code: + pattern: (\d\d\d) + fg: "#ffffff" + alternatives: + - pattern: (2\d\d) + fg: "#00ff00" + - pattern: (3\d\d) + fg: "#00ffff" + - pattern: (4\d\d) + fg: "#ff0000" + - pattern: (5\d\d) + fg: "#ff00ff" +``` + +`patterns` are standard regular expressions. You can highlight any sequence of characters in a string that matches a regular expression. The same `fg`, `bg`, `style` and `alternatives` fields are used here, see more details above under [Log formats](#log-formats). The only new field here is `priority`. Patterns with higher priority will be painted earlier. Default priority is 0. + +### Words + +Configuration example: + +```yaml +words: + good: + fg: "#52fa8a" + style: bold + list: + - "complete" + - "enable" + - "online" + - "succeed" + - "success" + - "successful" + - "successfully" + - "true" + - "valid" + + bad: + bg: "#f06c62" + style: underline + list: + - "block" + - "critical" + - "deny" + - "disable" + - "error" + - "fail" + - "false" + - "fatal" + - "invalid" + + your-word-group: + bg: "#0b78f1" + list: + - "lonzo" + - "gizmo" + - "lotek" + - "toni" +``` + +`words` are just lists of words that will be colored using values from `fg`, `bg` and `style` fields (see more details about these fields above under [Log formats](#log-formats)). `words` could have been implemented using patterns, if it weren't for one feature. + +Words from these lists are used not only literally, but also as [lemmas](https://en.wikipedia.org/wiki/Lemma_(morphology)). It means that by listing the word "complete", you will also highlight the words "completes", "completed" and "completing" in any line. Similarly, if you add the word "sing" to a list, the words "sang" and "sung" will also be highlighted. It works only for the English language. + +There are two special word groups: `good` and `bad`. The negation of a word from `good` group will be colored using values from `bad` group and vice versa. For example, if `good` group has the word "complete" then "not completed", "wasn't completed", "cannot be completed" and other negative forms will be colored using values from `bad` word group. + +You can find built-in `words` [here](builtins/words). If you want to customize them or turn them off completely, overwrite the corresponding values in your `logalize.yaml`. + +Acknowledgements +---------------- + +Thanks to my brother [@emptysad](https://github.com/emptysad) for coming up with the name Logalize and for the logo idea. + +Thanks to [@ekivoka](https://github.com/ekivoka) for the help with choosing the design and testing the logo. + +Thanks to the authors of these awesome libraries: + +- [go-arg](https://github.com/alexflint/go-arg) +- [golem](https://github.com/aaaton/golem) +- [koanf](https://github.com/knadh/koanf) +- [termenv](https://github.com/muesli/termenv) + +[^1]: I made that up diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..ddd00b6 --- /dev/null +++ b/src/.gitignore @@ -0,0 +1 @@ +builtins/ diff --git a/src/config.go b/src/config.go new file mode 100644 index 0000000..1fc5b23 --- /dev/null +++ b/src/config.go @@ -0,0 +1,96 @@ +package logalize + +import ( + "embed" + "os" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/providers/rawbytes" + "github.com/knadh/koanf/v2" +) + +func InitConfig(opts Options, builtins embed.FS) (*koanf.Koanf, error) { + config := koanf.New(".") + + // load built-in configuration + if !opts.NoBuiltins { + if err := loadBuiltinConfig(config, builtins); err != nil { + return nil, err + } + } + + // read configuration from default paths + if err := loadDefaultConfig(config); err != nil { + return nil, err + } + + // read configuration from user defined path + if opts.ConfigPath != "" { + if err := loadUserDefinedConfig(config, opts.ConfigPath); err != nil { + return nil, err + } + } + + return config, nil +} + +func loadBuiltinConfig(config *koanf.Koanf, builtins embed.FS) error { + builtinLogFormats, err := builtins.ReadDir("builtins/logformats") + if err != nil { + return err + } + for _, entry := range builtinLogFormats { + file, _ := builtins.ReadFile("builtins/logformats/" + entry.Name()) + if err = config.Load(rawbytes.Provider(file), yaml.Parser()); err != nil { + return err + } + } + + builtinWords, err := builtins.ReadDir("builtins/words") + if err != nil { + return err + } + for _, entry := range builtinWords { + file, _ := builtins.ReadFile("builtins/words/" + entry.Name()) + if err = config.Load(rawbytes.Provider(file), yaml.Parser()); err != nil { + return err + } + } + return nil +} + +func loadDefaultConfig(config *koanf.Koanf) error { + defaultConfigPaths := [...]string{ + "/etc/logalize/logalize.yaml", + "~/.config/logalize/logalize.yaml", + ".logalize.yaml", + } + for _, path := range defaultConfigPaths { + if ok, err := checkFileIsReadable(path); ok { + if err := config.Load(file.Provider(path), yaml.Parser()); err != nil { + return err + } + // ignore only errors about non-existent files + } else if !os.IsNotExist(err) { + return err + } + } + return nil +} + +func loadUserDefinedConfig(config *koanf.Koanf, path string) error { + if ok, err := checkFileIsReadable(path); ok { + if err := config.Load(file.Provider(path), yaml.Parser()); err != nil { + return err + } + } else { + return err + } + return nil +} + +func checkFileIsReadable(filePath string) (bool, error) { + _, err := os.Open(filePath) + return err == nil, err +} diff --git a/src/config_test.go b/src/config_test.go new file mode 100644 index 0000000..92ace3a --- /dev/null +++ b/src/config_test.go @@ -0,0 +1,526 @@ +package logalize + +import ( + "bytes" + "embed" + "io/fs" + "log" + "os" + "strings" + "testing" + + "github.com/aaaton/golem/v4" + "github.com/aaaton/golem/v4/dicts/en" + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/rawbytes" + "github.com/muesli/termenv" +) + +//go:embed builtins/logformats/good.yaml +//go:embed builtins/words/good.yaml +var builtinsAllGood embed.FS + +func TestConfigLoadBuiltinGood(t *testing.T) { + colorProfile = termenv.TrueColor + configData := ` +formats: + menetekel: + - pattern: (\d{1,3}(\.\d{1,3}){3} ) + fg: "#f5ce42" + - pattern: ([^ ]+ ) + bg: "#764a9e" + - pattern: (\[.+\] ) + style: bold + - pattern: ("[^"]+") + fg: "#9daf99" + bg: "#76fb99" + style: underline + +patterns: + string: + priority: 500 + pattern: ("[^"]+"|'[^']+') + fg: "#00ff00" + + ipv4-address: + priority: 400 + pattern: (\d{1,3}(\.\d{1,3}){3}) + fg: "#ff0000" + bg: "#ffff00" + style: bold + + number: + pattern: (\d+) + bg: "#005050" + + http-status-code: + priority: 300 + pattern: (\d\d\d) + fg: "#ffffff" + alternatives: + - pattern: (1\d\d) + fg: "#505050" + - pattern: (2\d\d) + fg: "#00ff00" + style: overline + - pattern: (3\d\d) + fg: "#00ffff" + style: crossout + - pattern: (4\d\d) + fg: "#ff0000" + style: reverse + - pattern: (5\d\d) + fg: "#ff00ff" + +words: + friends: + fg: "#f834b2" + style: underline + list: + - "toni" + - "wenzel" + foes: + fg: "#120fbb" + style: underline + list: + - "argus" + - "cletus" +` + tests := []struct { + plain string + colored string + }{ + // log format + {`127.0.0.1 - [test] "testing"`, "\x1b[38;2;245;206;65m127.0.0.1 \x1b[0m\x1b[48;2;118;73;158m- \x1b[0m\x1b[1m[test] \x1b[0m\x1b[38;2;157;175;153;48;2;118;251;153;4m\"testing\"\x1b[0m\n"}, + {`127.0.0.2 test [test hello] "testing again"`, "\x1b[38;2;245;206;65m127.0.0.2 \x1b[0m\x1b[48;2;118;73;158mtest \x1b[0m\x1b[1m[test hello] \x1b[0m\x1b[38;2;157;175;153;48;2;118;251;153;4m\"testing again\"\x1b[0m\n"}, + {`127.0.0.3 ___ [.] "_"`, "\x1b[38;2;245;206;65m127.0.0.3 \x1b[0m\x1b[48;2;118;73;158m___ \x1b[0m\x1b[1m[.] \x1b[0m\x1b[38;2;157;175;153;48;2;118;251;153;4m\"_\"\x1b[0m\n"}, + {`127.0.0.1 - - [16/Feb/2024:00:01:01 +0000] "GET / HTTP/1.1" 301 162 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1"`, "\x1b[38;2;245;206;65m127.0.0.1 \x1b[0m\x1b[38;2;128;126;121m- \x1b[0m\x1b[38;2;118;73;158m- \x1b[0m\x1b[38;2;20;141;217m[16/Feb/2024:00:01:01 +0000] \x1b[0m\x1b[38;2;157;219;86m\"GET / HTTP/1.1\" \x1b[0m\x1b[38;2;0;255;255;1m301 \x1b[0m\x1b[38;2;125;125;125m162 \x1b[0m\x1b[38;2;58;225;240m\"-\" \x1b[0m\x1b[38;2;170;125;209m\"Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1\"\x1b[0m\n"}, + + // pattern + {`"string"`, "\x1b[38;2;0;255;0m\"string\"\x1b[0m\n"}, + {"42", "\x1b[48;2;0;80;80m42\x1b[0m\n"}, + {"127.0.0.1", "\x1b[38;2;255;0;0;48;2;255;255;0;1m127.0.0.1\x1b[0m\n"}, + {`"test": 127.7.7.7 hello 101`, "\x1b[38;2;0;255;0m\"test\"\x1b[0m: \x1b[38;2;255;0;0;48;2;255;255;0;1m127.7.7.7\x1b[0m hello \x1b[38;2;80;80;80m101\x1b[0m\n"}, + {`"true"`, "\x1b[38;2;0;255;0m\"true\"\x1b[0m\n"}, + {`"42"`, "\x1b[38;2;0;255;0m\"42\"\x1b[0m\n"}, + {`"237.7.7.7"`, "\x1b[38;2;0;255;0m\"237.7.7.7\"\x1b[0m\n"}, + {`status 103`, "status \x1b[38;2;80;80;80m103\x1b[0m\n"}, + {`status 200`, "status \x1b[38;2;0;255;0;53m200\x1b[0m\n"}, + {`status 302`, "status \x1b[38;2;0;255;255;9m302\x1b[0m\n"}, + {`status 404`, "status \x1b[38;2;255;0;0;7m404\x1b[0m\n"}, + {`status 503`, "status \x1b[38;2;255;0;255m503\x1b[0m\n"}, + {`status 700`, "status \x1b[38;2;255;255;255m700\x1b[0m\n"}, + + // words + {"untrue", "untrue\n"}, + {"true", "\x1b[38;2;81;250;138;1mtrue\x1b[0m\n"}, + {"fail", "\x1b[38;2;240;108;97;1mfail\x1b[0m\n"}, + {"failed", "\x1b[38;2;240;108;97;1mfailed\x1b[0m\n"}, + {"wenzel", "\x1b[38;2;248;52;178;4mwenzel\x1b[0m\n"}, + {"argus", "\x1b[38;2;18;15;187;4margus\x1b[0m\n"}, + + {"not true", "\x1b[38;2;240;108;97;1mnot true\x1b[0m\n"}, + {"Not true", "\x1b[38;2;240;108;97;1mNot true\x1b[0m\n"}, + {"wasn't true", "\x1b[38;2;240;108;97;1mwasn't true\x1b[0m\n"}, + {"won't true", "\x1b[38;2;240;108;97;1mwon't true\x1b[0m\n"}, + {"cannot complete", "\x1b[38;2;240;108;97;1mcannot complete\x1b[0m\n"}, + {"won't be completed", "\x1b[38;2;240;108;97;1mwon't be completed\x1b[0m\n"}, + {"cannot be completed", "\x1b[38;2;240;108;97;1mcannot be completed\x1b[0m\n"}, + {"should not be completed", "\x1b[38;2;240;108;97;1mshould not be completed\x1b[0m\n"}, + + {"not false", "\x1b[38;2;81;250;138;1mnot false\x1b[0m\n"}, + {"Not false", "\x1b[38;2;81;250;138;1mNot false\x1b[0m\n"}, + {"wasn't false", "\x1b[38;2;81;250;138;1mwasn't false\x1b[0m\n"}, + {"won't false", "\x1b[38;2;81;250;138;1mwon't false\x1b[0m\n"}, + {"cannot fail", "\x1b[38;2;81;250;138;1mcannot fail\x1b[0m\n"}, + {"won't be failed", "\x1b[38;2;81;250;138;1mwon't be failed\x1b[0m\n"}, + {"cannot be failed", "\x1b[38;2;81;250;138;1mcannot be failed\x1b[0m\n"}, + {"should not be failed", "\x1b[38;2;81;250;138;1mshould not be failed\x1b[0m\n"}, + + {"not toni", "not \x1b[38;2;248;52;178;4mtoni\x1b[0m\n"}, + {"Not wenzel", "Not \x1b[38;2;248;52;178;4mwenzel\x1b[0m\n"}, + {"wasn't argus", "wasn't \x1b[38;2;18;15;187;4margus\x1b[0m\n"}, + {"won't cletus", "won't \x1b[38;2;18;15;187;4mcletus\x1b[0m\n"}, + {"cannot toni", "cannot \x1b[38;2;248;52;178;4mtoni\x1b[0m\n"}, + {"won't be wenzel", "won't be \x1b[38;2;248;52;178;4mwenzel\x1b[0m\n"}, + {"cannot be argus", "cannot be \x1b[38;2;18;15;187;4margus\x1b[0m\n"}, + {"should not be cletus", "should not be \x1b[38;2;18;15;187;4mcletus\x1b[0m\n"}, + + // patterns and words + {`true bad fail 7.7.7.7`, "\x1b[38;2;81;250;138;1mtrue\x1b[0m bad \x1b[38;2;240;108;97;1mfail\x1b[0m \x1b[38;2;255;0;0;48;2;255;255;0;1m7.7.7.7\x1b[0m\n"}, + {`"true" and true`, "\x1b[38;2;0;255;0m\"true\"\x1b[0m and \x1b[38;2;81;250;138;1mtrue\x1b[0m\n"}, + {`wenzel failed 127 times`, "\x1b[38;2;248;52;178;4mwenzel\x1b[0m \x1b[38;2;240;108;97;1mfailed\x1b[0m \x1b[38;2;80;80;80m127\x1b[0m times\n"}, + } + + lemmatizer, err := golem.New(en.New()) + if err != nil { + t.Errorf("golem.New(en.New()) failed with this error: %s", err) + } + + options := Options{ + ConfigPath: "", + NoBuiltins: false, + } + + config, err := InitConfig(options, builtinsAllGood) + if err != nil { + t.Errorf("InitConfig() failed with this error: %s", err) + } + configRaw := []byte(configData) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + + for _, tt := range tests { + testname := tt.plain + input := strings.NewReader(tt.plain) + output := bytes.Buffer{} + + t.Run(testname, func(t *testing.T) { + Run(input, &output, config, builtinsAllGood, lemmatizer) + + if output.String() != tt.colored { + t.Errorf("got %v, want %v", output.String(), tt.colored) + } + }) + } +} + +//go:embed builtins/logformats/good.yaml +var builtinsLogformatsGood embed.FS + +//go:embed builtins/words/good.yaml +var builtinsWordsGood embed.FS + +//go:embed builtins/logformats/bad.yaml +var builtinsLogformatsBad embed.FS + +//go:embed builtins/logformats/good.yaml +//go:embed builtins/words/bad.yaml +var builtinsWordsBad embed.FS + +func TestConfigLoadBuiltinBad(t *testing.T) { + colorProfile = termenv.TrueColor + + options := Options{ + ConfigPath: "", + NoBuiltins: false, + } + + t.Run("TestConfigLoadBuiltinLogformatsDoesntExist", func(t *testing.T) { + _, err := InitConfig(options, builtinsLogformatsGood) + if _, ok := err.(*fs.PathError); !ok { + t.Errorf("InitConfig() should have failed with *fs.PathError, got: [%T] %s", err, err) + } + }) + + t.Run("TestConfigLoadBuiltinWordsDoesntExist", func(t *testing.T) { + _, err := InitConfig(options, builtinsWordsGood) + if _, ok := err.(*fs.PathError); !ok { + t.Errorf("InitConfig() should have failed with *fs.PathError, got: [%T] %s", err, err) + } + }) + + t.Run("TestConfigLoadBuiltinLogformatsBadYAML", func(t *testing.T) { + _, err := InitConfig(options, builtinsLogformatsBad) + if err.Error() != "yaml: line 3: mapping values are not allowed in this context" { + t.Errorf("InitConfig() should have failed with *errors.errorString, got: [%T] %s", err, err) + } + }) + + t.Run("TestConfigLoadBuiltinWordsBadYAML", func(t *testing.T) { + _, err := InitConfig(options, builtinsWordsBad) + if err.Error() != "yaml: line 2: mapping values are not allowed in this context" { + t.Errorf("InitConfig() should have failed with *errors.errorString, got: [%T] %s", err, err) + } + }) +} + +func TestConfigLoadUserDefinedGood(t *testing.T) { + colorProfile = termenv.TrueColor + configData := ` +formats: + menetekel: + - pattern: (\d{1,3}(\.\d{1,3}){3} ) + fg: "#f5ce42" + - pattern: ([^ ]+ ) + bg: "#764a9e" + - pattern: (\[.+\] ) + style: bold + - pattern: ("[^"]+") + fg: "#9daf99" + bg: "#76fb99" + style: underline + +patterns: + string: + priority: 500 + pattern: ("[^"]+"|'[^']+') + fg: "#00ff00" + + ipv4-address: + priority: 400 + pattern: (\d{1,3}(\.\d{1,3}){3}) + fg: "#ff0000" + bg: "#ffff00" + style: bold + + number: + pattern: (\d+) + bg: "#005050" + + http-status-code: + priority: 300 + pattern: (\d\d\d) + fg: "#ffffff" + alternatives: + - pattern: (1\d\d) + fg: "#505050" + - pattern: (2\d\d) + fg: "#00ff00" + style: overline + - pattern: (3\d\d) + fg: "#00ffff" + style: crossout + - pattern: (4\d\d) + fg: "#ff0000" + style: reverse + - pattern: (5\d\d) + fg: "#ff00ff" + +words: + friends: + fg: "#f834b2" + style: underline + list: + - "toni" + - "wenzel" + foes: + fg: "#120fbb" + style: underline + list: + - "argus" + - "cletus" +` + tests := []struct { + plain string + colored string + }{ + // log format + {`127.0.0.1 - [test] "testing"`, "\x1b[38;2;245;206;65m127.0.0.1 \x1b[0m\x1b[48;2;118;73;158m- \x1b[0m\x1b[1m[test] \x1b[0m\x1b[38;2;157;175;153;48;2;118;251;153;4m\"testing\"\x1b[0m\n"}, + {`127.0.0.2 test [test hello] "testing again"`, "\x1b[38;2;245;206;65m127.0.0.2 \x1b[0m\x1b[48;2;118;73;158mtest \x1b[0m\x1b[1m[test hello] \x1b[0m\x1b[38;2;157;175;153;48;2;118;251;153;4m\"testing again\"\x1b[0m\n"}, + {`127.0.0.3 ___ [.] "_"`, "\x1b[38;2;245;206;65m127.0.0.3 \x1b[0m\x1b[48;2;118;73;158m___ \x1b[0m\x1b[1m[.] \x1b[0m\x1b[38;2;157;175;153;48;2;118;251;153;4m\"_\"\x1b[0m\n"}, + + // pattern + {`"string"`, "\x1b[38;2;0;255;0m\"string\"\x1b[0m\n"}, + {"42", "\x1b[48;2;0;80;80m42\x1b[0m\n"}, + {"127.0.0.1", "\x1b[38;2;255;0;0;48;2;255;255;0;1m127.0.0.1\x1b[0m\n"}, + {`"test": 127.7.7.7 hello 101`, "\x1b[38;2;0;255;0m\"test\"\x1b[0m: \x1b[38;2;255;0;0;48;2;255;255;0;1m127.7.7.7\x1b[0m hello \x1b[38;2;80;80;80m101\x1b[0m\n"}, + {`"true"`, "\x1b[38;2;0;255;0m\"true\"\x1b[0m\n"}, + {`"42"`, "\x1b[38;2;0;255;0m\"42\"\x1b[0m\n"}, + {`"237.7.7.7"`, "\x1b[38;2;0;255;0m\"237.7.7.7\"\x1b[0m\n"}, + {`status 103`, "status \x1b[38;2;80;80;80m103\x1b[0m\n"}, + {`status 200`, "status \x1b[38;2;0;255;0;53m200\x1b[0m\n"}, + {`status 302`, "status \x1b[38;2;0;255;255;9m302\x1b[0m\n"}, + {`status 404`, "status \x1b[38;2;255;0;0;7m404\x1b[0m\n"}, + {`status 503`, "status \x1b[38;2;255;0;255m503\x1b[0m\n"}, + {`status 700`, "status \x1b[38;2;255;255;255m700\x1b[0m\n"}, + + // words + {"untrue", "untrue\n"}, + {"true", "\x1b[38;2;81;250;138;1mtrue\x1b[0m\n"}, + {"fail", "\x1b[38;2;240;108;97;1mfail\x1b[0m\n"}, + {"failed", "\x1b[38;2;240;108;97;1mfailed\x1b[0m\n"}, + {"wenzel", "\x1b[38;2;248;52;178;4mwenzel\x1b[0m\n"}, + {"argus", "\x1b[38;2;18;15;187;4margus\x1b[0m\n"}, + + {"not true", "\x1b[38;2;240;108;97;1mnot true\x1b[0m\n"}, + {"Not true", "\x1b[38;2;240;108;97;1mNot true\x1b[0m\n"}, + {"wasn't true", "\x1b[38;2;240;108;97;1mwasn't true\x1b[0m\n"}, + {"won't true", "\x1b[38;2;240;108;97;1mwon't true\x1b[0m\n"}, + {"cannot complete", "\x1b[38;2;240;108;97;1mcannot complete\x1b[0m\n"}, + {"won't be completed", "\x1b[38;2;240;108;97;1mwon't be completed\x1b[0m\n"}, + {"cannot be completed", "\x1b[38;2;240;108;97;1mcannot be completed\x1b[0m\n"}, + {"should not be completed", "\x1b[38;2;240;108;97;1mshould not be completed\x1b[0m\n"}, + + {"not false", "\x1b[38;2;81;250;138;1mnot false\x1b[0m\n"}, + {"Not false", "\x1b[38;2;81;250;138;1mNot false\x1b[0m\n"}, + {"wasn't false", "\x1b[38;2;81;250;138;1mwasn't false\x1b[0m\n"}, + {"won't false", "\x1b[38;2;81;250;138;1mwon't false\x1b[0m\n"}, + {"cannot fail", "\x1b[38;2;81;250;138;1mcannot fail\x1b[0m\n"}, + {"won't be failed", "\x1b[38;2;81;250;138;1mwon't be failed\x1b[0m\n"}, + {"cannot be failed", "\x1b[38;2;81;250;138;1mcannot be failed\x1b[0m\n"}, + {"should not be failed", "\x1b[38;2;81;250;138;1mshould not be failed\x1b[0m\n"}, + + {"not toni", "not \x1b[38;2;248;52;178;4mtoni\x1b[0m\n"}, + {"Not wenzel", "Not \x1b[38;2;248;52;178;4mwenzel\x1b[0m\n"}, + {"wasn't argus", "wasn't \x1b[38;2;18;15;187;4margus\x1b[0m\n"}, + {"won't cletus", "won't \x1b[38;2;18;15;187;4mcletus\x1b[0m\n"}, + {"cannot toni", "cannot \x1b[38;2;248;52;178;4mtoni\x1b[0m\n"}, + {"won't be wenzel", "won't be \x1b[38;2;248;52;178;4mwenzel\x1b[0m\n"}, + {"cannot be argus", "cannot be \x1b[38;2;18;15;187;4margus\x1b[0m\n"}, + {"should not be cletus", "should not be \x1b[38;2;18;15;187;4mcletus\x1b[0m\n"}, + + // patterns and words + {`true bad fail 7.7.7.7`, "\x1b[38;2;81;250;138;1mtrue\x1b[0m bad \x1b[38;2;240;108;97;1mfail\x1b[0m \x1b[38;2;255;0;0;48;2;255;255;0;1m7.7.7.7\x1b[0m\n"}, + {`"true" and true`, "\x1b[38;2;0;255;0m\"true\"\x1b[0m and \x1b[38;2;81;250;138;1mtrue\x1b[0m\n"}, + {`wenzel failed 127 times`, "\x1b[38;2;248;52;178;4mwenzel\x1b[0m \x1b[38;2;240;108;97;1mfailed\x1b[0m \x1b[38;2;80;80;80m127\x1b[0m times\n"}, + } + + lemmatizer, err := golem.New(en.New()) + if err != nil { + t.Errorf("golem.New(en.New()) failed with this error: %s", err) + } + + userConfig := t.TempDir() + "/userConfig.yaml" + configRaw := []byte(configData) + err = os.WriteFile(userConfig, configRaw, 0644) + if err != nil { + t.Errorf("Wasn't able to write test file to %s: %s", userConfig, err) + } + + options := Options{ + ConfigPath: userConfig, + NoBuiltins: false, + } + + config, err := InitConfig(options, builtinsAllGood) + if err != nil { + t.Errorf("InitConfig() failed with this error: %s", err) + } + + for _, tt := range tests { + testname := tt.plain + input := strings.NewReader(tt.plain) + output := bytes.Buffer{} + + t.Run(testname, func(t *testing.T) { + Run(input, &output, config, builtinsAllGood, lemmatizer) + + if output.String() != tt.colored { + t.Errorf("got %v, want %v", output.String(), tt.colored) + } + }) + } +} + +func TestConfigLoadUserDefinedBad(t *testing.T) { + colorProfile = termenv.TrueColor + + configDataBadYAML := ` +formats: + test: + pattern: bad: +` + + userConfig := t.TempDir() + "/userConfig.yaml" + configRaw := []byte(configDataBadYAML) + err := os.WriteFile(userConfig, configRaw, 0644) + if err != nil { + t.Errorf("Wasn't able to write test file to %s: %s", userConfig, err) + } + + options := Options{ + ConfigPath: userConfig, + NoBuiltins: true, + } + + t.Run("TestConfigLoadUserDefinedBadYAML", func(t *testing.T) { + _, err := InitConfig(options, builtinsAllGood) + if err.Error() != "yaml: line 4: mapping values are not allowed in this context" { + t.Errorf("InitConfig() should have failed with *errors.errorString, got: [%T] %s", err, err) + } + }) + + options = Options{ + ConfigPath: userConfig + "error", + NoBuiltins: true, + } + + t.Run("TestConfigLoadUserDefinedFileDoesntExist", func(t *testing.T) { + _, err := InitConfig(options, builtinsAllGood) + if _, ok := err.(*fs.PathError); !ok { + t.Errorf("InitConfig() should have failed with *fs.PathError, got: [%T] %s", err, err) + } + }) + + userConfigReadOnly := t.TempDir() + "/userConfigReadOnly.yaml" + configRaw = []byte(configDataBadYAML) + err = os.WriteFile(userConfigReadOnly, configRaw, 0200) + if err != nil { + t.Errorf("Wasn't able to write test file to %s: %s", userConfig, err) + } + + options = Options{ + ConfigPath: userConfigReadOnly, + NoBuiltins: false, + } + + t.Run("TestConfigLoadUserDefinedReadOnly", func(t *testing.T) { + _, err := InitConfig(options, builtinsAllGood) + if _, ok := err.(*fs.PathError); !ok { + t.Errorf("InitConfig() should have failed with *fs.PathError, got: [%T] %s", err, err) + } + }) +} + +func TestConfigLoadDefault(t *testing.T) { + colorProfile = termenv.TrueColor + configDataBadYAML := ` +formats: + test: + pattern: bad: +` + + wd, err := os.Getwd() + if err != nil { + log.Println(err) + } + defaultConfig := wd + "/.logalize.yaml" + configRaw := []byte(configDataBadYAML) + if ok, err := checkFileIsReadable(defaultConfig); ok { + if err != nil { + t.Errorf("checkFileIsReadable() failed with this error: %s", err) + } + err = os.Remove(defaultConfig) + if err != nil { + t.Errorf("Wasn't able to delete %s: %s", defaultConfig, err) + } + } + + err = os.WriteFile(defaultConfig, configRaw, 0644) + if err != nil { + t.Errorf("Wasn't able to write test file to %s: %s", defaultConfig, err) + } + + t.Cleanup(func() { + err = os.Remove(defaultConfig) + if err != nil { + t.Errorf("Wasn't able to delete %s: %s", defaultConfig, err) + } + }) + + options := Options{ + ConfigPath: "", + NoBuiltins: true, + } + + t.Run("TestConfigLoadDefaultBadYAML", func(t *testing.T) { + _, err := InitConfig(options, builtinsAllGood) + if err.Error() != "yaml: line 4: mapping values are not allowed in this context" { + t.Errorf("InitConfig() should have failed with *errors.errorString, got: [%T] %s", err, err) + } + }) + + err = os.Chmod(defaultConfig, 0200) + if err != nil { + t.Errorf("Wasn't able to change mode of %s: %s", defaultConfig, err) + } + + t.Run("TestConfigLoadDefaultReadOnly", func(t *testing.T) { + _, err := InitConfig(options, builtinsAllGood) + if _, ok := err.(*fs.PathError); !ok { + t.Errorf("InitConfig() should have failed with *fs.PathError, got: [%T] %s", err, err) + } + }) +} diff --git a/src/core.go b/src/core.go new file mode 100644 index 0000000..08b23d1 --- /dev/null +++ b/src/core.go @@ -0,0 +1,52 @@ +package logalize + +import ( + "bufio" + "embed" + "io" + + "github.com/aaaton/golem/v4" + "github.com/knadh/koanf/v2" +) + +func Run(reader io.Reader, writer io.StringWriter, config *koanf.Koanf, builtins embed.FS, lemmatizer *golem.Lemmatizer) error { + patterns, err := initPatterns(config) + if err != nil { + return err + } + + words, err := initWords(config, lemmatizer) + if err != nil { + return err + } + + logFormats, err := initLogFormats(config) + if err != nil { + return err + } + + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + // try one of the log formats + formatDetected := false + for _, logFormat := range logFormats { + if logFormat.Regexp.MatchString(line) { + _, err := writer.WriteString(logFormat.highlight(line) + "\n") + if err != nil { + return err + } + formatDetected = true + break + } + } + // highlight patterns and words if log format wasn't detected + if !formatDetected { + _, err := writer.WriteString(highlightPatternsAndWords(line, patterns, words) + "\n") + if err != nil { + return err + } + } + } + return scanner.Err() +} diff --git a/src/core_test.go b/src/core_test.go new file mode 100644 index 0000000..066413a --- /dev/null +++ b/src/core_test.go @@ -0,0 +1,325 @@ +package logalize + +import ( + "bytes" + "embed" + "io/fs" + "os" + "strings" + "testing" + + "github.com/aaaton/golem/v4" + "github.com/aaaton/golem/v4/dicts/en" + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/rawbytes" + "github.com/muesli/termenv" +) + +func TestRunGood(t *testing.T) { + colorProfile = termenv.TrueColor + var builtins embed.FS + configData := ` +formats: + menetekel: + - pattern: (\d{1,3}(\.\d{1,3}){3} ) + fg: "#f5ce42" + - pattern: ([^ ]+ ) + bg: "#764a9e" + - pattern: (\[.+\] ) + style: bold + - pattern: ("[^"]+") + fg: "#9daf99" + bg: "#76fb99" + style: underline + +patterns: + string: + priority: 500 + pattern: ("[^"]+"|'[^']+') + fg: "#00ff00" + + ipv4-address: + priority: 400 + pattern: (\d{1,3}(\.\d{1,3}){3}) + fg: "#ff0000" + bg: "#ffff00" + style: bold + + number: + pattern: (\d+) + bg: "#005050" + + http-status-code: + priority: 300 + pattern: (\d\d\d) + fg: "#ffffff" + alternatives: + - pattern: (1\d\d) + fg: "#505050" + - pattern: (2\d\d) + fg: "#00ff00" + style: overline + - pattern: (3\d\d) + fg: "#00ffff" + style: crossout + - pattern: (4\d\d) + fg: "#ff0000" + style: reverse + - pattern: (5\d\d) + fg: "#ff00ff" + +words: + good: + fg: "#52fa8a" + style: bold + list: + - "true" + - "complete" + bad: + bg: "#f06c62" + list: + - "false" + - "fail" + friends: + fg: "#f834b2" + style: underline + list: + - "toni" + - "wenzel" + foes: + fg: "#120fbb" + style: underline + list: + - "argus" + - "cletus" +` + tests := []struct { + plain string + colored string + }{ + // log format + {`127.0.0.1 - [test] "testing"`, "\x1b[38;2;245;206;65m127.0.0.1 \x1b[0m\x1b[48;2;118;73;158m- \x1b[0m\x1b[1m[test] \x1b[0m\x1b[38;2;157;175;153;48;2;118;251;153;4m\"testing\"\x1b[0m\n"}, + {`127.0.0.2 test [test hello] "testing again"`, "\x1b[38;2;245;206;65m127.0.0.2 \x1b[0m\x1b[48;2;118;73;158mtest \x1b[0m\x1b[1m[test hello] \x1b[0m\x1b[38;2;157;175;153;48;2;118;251;153;4m\"testing again\"\x1b[0m\n"}, + {`127.0.0.3 ___ [.] "_"`, "\x1b[38;2;245;206;65m127.0.0.3 \x1b[0m\x1b[48;2;118;73;158m___ \x1b[0m\x1b[1m[.] \x1b[0m\x1b[38;2;157;175;153;48;2;118;251;153;4m\"_\"\x1b[0m\n"}, + + // pattern + {`"string"`, "\x1b[38;2;0;255;0m\"string\"\x1b[0m\n"}, + {"42", "\x1b[48;2;0;80;80m42\x1b[0m\n"}, + {"127.0.0.1", "\x1b[38;2;255;0;0;48;2;255;255;0;1m127.0.0.1\x1b[0m\n"}, + {`"test": 127.7.7.7 hello 101`, "\x1b[38;2;0;255;0m\"test\"\x1b[0m: \x1b[38;2;255;0;0;48;2;255;255;0;1m127.7.7.7\x1b[0m hello \x1b[38;2;80;80;80m101\x1b[0m\n"}, + {`"true"`, "\x1b[38;2;0;255;0m\"true\"\x1b[0m\n"}, + {`"42"`, "\x1b[38;2;0;255;0m\"42\"\x1b[0m\n"}, + {`"237.7.7.7"`, "\x1b[38;2;0;255;0m\"237.7.7.7\"\x1b[0m\n"}, + {`status 103`, "status \x1b[38;2;80;80;80m103\x1b[0m\n"}, + {`status 200`, "status \x1b[38;2;0;255;0;53m200\x1b[0m\n"}, + {`status 302`, "status \x1b[38;2;0;255;255;9m302\x1b[0m\n"}, + {`status 404`, "status \x1b[38;2;255;0;0;7m404\x1b[0m\n"}, + {`status 503`, "status \x1b[38;2;255;0;255m503\x1b[0m\n"}, + {`status 700`, "status \x1b[38;2;255;255;255m700\x1b[0m\n"}, + + // words + {"untrue", "untrue\n"}, + {"true", "\x1b[38;2;81;250;138;1mtrue\x1b[0m\n"}, + {"fail", "\x1b[48;2;240;108;97mfail\x1b[0m\n"}, + {"failed", "\x1b[48;2;240;108;97mfailed\x1b[0m\n"}, + {"wenzel", "\x1b[38;2;248;52;178;4mwenzel\x1b[0m\n"}, + {"argus", "\x1b[38;2;18;15;187;4margus\x1b[0m\n"}, + + {"not true", "\x1b[48;2;240;108;97mnot true\x1b[0m\n"}, + {"Not true", "\x1b[48;2;240;108;97mNot true\x1b[0m\n"}, + {"wasn't true", "\x1b[48;2;240;108;97mwasn't true\x1b[0m\n"}, + {"won't true", "\x1b[48;2;240;108;97mwon't true\x1b[0m\n"}, + {"cannot complete", "\x1b[48;2;240;108;97mcannot complete\x1b[0m\n"}, + {"won't be completed", "\x1b[48;2;240;108;97mwon't be completed\x1b[0m\n"}, + {"cannot be completed", "\x1b[48;2;240;108;97mcannot be completed\x1b[0m\n"}, + {"should not be completed", "\x1b[48;2;240;108;97mshould not be completed\x1b[0m\n"}, + + {"not false", "\x1b[38;2;81;250;138;1mnot false\x1b[0m\n"}, + {"Not false", "\x1b[38;2;81;250;138;1mNot false\x1b[0m\n"}, + {"wasn't false", "\x1b[38;2;81;250;138;1mwasn't false\x1b[0m\n"}, + {"won't false", "\x1b[38;2;81;250;138;1mwon't false\x1b[0m\n"}, + {"cannot fail", "\x1b[38;2;81;250;138;1mcannot fail\x1b[0m\n"}, + {"won't be failed", "\x1b[38;2;81;250;138;1mwon't be failed\x1b[0m\n"}, + {"cannot be failed", "\x1b[38;2;81;250;138;1mcannot be failed\x1b[0m\n"}, + {"should not be failed", "\x1b[38;2;81;250;138;1mshould not be failed\x1b[0m\n"}, + + {"not toni", "not \x1b[38;2;248;52;178;4mtoni\x1b[0m\n"}, + {"Not wenzel", "Not \x1b[38;2;248;52;178;4mwenzel\x1b[0m\n"}, + {"wasn't argus", "wasn't \x1b[38;2;18;15;187;4margus\x1b[0m\n"}, + {"won't cletus", "won't \x1b[38;2;18;15;187;4mcletus\x1b[0m\n"}, + {"cannot toni", "cannot \x1b[38;2;248;52;178;4mtoni\x1b[0m\n"}, + {"won't be wenzel", "won't be \x1b[38;2;248;52;178;4mwenzel\x1b[0m\n"}, + {"cannot be argus", "cannot be \x1b[38;2;18;15;187;4margus\x1b[0m\n"}, + {"should not be cletus", "should not be \x1b[38;2;18;15;187;4mcletus\x1b[0m\n"}, + + // patterns and words + {`true bad fail 7.7.7.7`, "\x1b[38;2;81;250;138;1mtrue\x1b[0m bad \x1b[48;2;240;108;97mfail\x1b[0m \x1b[38;2;255;0;0;48;2;255;255;0;1m7.7.7.7\x1b[0m\n"}, + {`"true" and true`, "\x1b[38;2;0;255;0m\"true\"\x1b[0m and \x1b[38;2;81;250;138;1mtrue\x1b[0m\n"}, + {`wenzel failed 127 times`, "\x1b[38;2;248;52;178;4mwenzel\x1b[0m \x1b[48;2;240;108;97mfailed\x1b[0m \x1b[38;2;80;80;80m127\x1b[0m times\n"}, + } + + lemmatizer, err := golem.New(en.New()) + if err != nil { + t.Errorf("golem.New(en.New()) failed with this error: %s", err) + } + + options := Options{ + ConfigPath: "", + NoBuiltins: true, + } + config, err := InitConfig(options, builtins) + if err != nil { + t.Errorf("InitConfig() failed with this error: %s", err) + } + configRaw := []byte(configData) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + + for _, tt := range tests { + testname := tt.plain + input := strings.NewReader(tt.plain) + output := bytes.Buffer{} + + t.Run(testname, func(t *testing.T) { + Run(input, &output, config, builtins, lemmatizer) + + if output.String() != tt.colored { + t.Errorf("got %v, want %v", output.String(), tt.colored) + } + }) + } +} + +func TestRunBadInit(t *testing.T) { + colorProfile = termenv.TrueColor + var builtins embed.FS + options := Options{ + ConfigPath: "", + NoBuiltins: true, + } + input := strings.NewReader("test") + output := bytes.Buffer{} + lemmatizer, err := golem.New(en.New()) + if err != nil { + t.Errorf("golem.New(en.New()) failed with this error: %s", err) + } + + configDataBadFormats := ` +formats: + test: + pattern: bad +` + config, err := InitConfig(options, builtins) + if err != nil { + t.Errorf("InitConfig() failed with this error: %s", err) + } + configRaw := []byte(configDataBadFormats) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + t.Run("TestRunBadFormats", func(t *testing.T) { + err := Run(input, &output, config, builtins, lemmatizer) + if err.Error() != `[log format: test] capture group pattern bad doesn't match ^\(.+\)$ pattern` { + t.Errorf("Run() should have failed with *errors.errorString, got: [%T] %s", err, err) + } + }) + + configDataBadPatterns := ` +patterns: + string:priority: 100 +` + config, err = InitConfig(options, builtins) + if err != nil { + t.Errorf("InitConfig() failed with this error: %s", err) + } + configRaw = []byte(configDataBadPatterns) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + t.Run("TestRunBadPatterns", func(t *testing.T) { + err := Run(input, &output, config, builtins, lemmatizer) + if err.Error() != "'' expected a map, got 'int'" { + t.Errorf("Run() should have failed with *errors.errorString, got: [%T] %s", err, err) + } + }) + + configDataBadWords := ` +words: + good:err: bad +` + config, err = InitConfig(options, builtins) + if err != nil { + t.Errorf("InitConfig() failed with this error: %s", err) + } + configRaw = []byte(configDataBadWords) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + t.Run("TestRunBadWords", func(t *testing.T) { + err := Run(input, &output, config, builtins, lemmatizer) + if err.Error() != "'' expected a map, got 'string'" { + t.Errorf("Run() should have failed with *errors.errorString, got: [%T] %s", err, err) + } + }) +} + +func TestRunBadWriter(t *testing.T) { + colorProfile = termenv.TrueColor + var builtins embed.FS + options := Options{ + ConfigPath: "", + NoBuiltins: true, + } + lemmatizer, err := golem.New(en.New()) + if err != nil { + t.Errorf("golem.New(en.New()) failed with this error: %s", err) + } + configData := ` +formats: + test: + - pattern: (\d{1,3}(\.\d{1,3}){3} ) + fg: "#f5ce42" + - pattern: ("[^"]+") + fg: "#9daf99" + bg: "#76fb99" + style: underline +` + config, err := InitConfig(options, builtins) + if err != nil { + t.Errorf("InitConfig() failed with this error: %s", err) + } + configRaw := []byte(configData) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + filename := t.TempDir() + "/test.yaml" + file, err := os.Create(filename) + if err != nil { + t.Errorf("Wasn't able to create test %s: %s", filename, err) + } + err = file.Chmod(0444) + if err != nil { + t.Errorf("Wasn't able to change mode of %s: %s", filename, err) + } + err = file.Close() + if err != nil { + t.Errorf("Wasn't able to close %s: %s", filename, err) + } + + input := strings.NewReader("test") + t.Run("TestRunBadWriterNotLogFormat", func(t *testing.T) { + err := Run(input, file, config, builtins, lemmatizer) + if _, ok := err.(*fs.PathError); !ok { + t.Errorf("Run() should have failed with *fs.PathError, got: [%T] %s", err, err) + } + }) + + input = strings.NewReader(`127.0.0.1 "testing"`) + t.Run("TestRunBadWriterLogFormat", func(t *testing.T) { + err := Run(input, file, config, builtins, lemmatizer) + if _, ok := err.(*fs.PathError); !ok { + t.Errorf("Run() should have failed with *fs.PathError, got: [%T] %s", err, err) + } + }) +} diff --git a/src/globals.go b/src/globals.go new file mode 100644 index 0000000..fe4425e --- /dev/null +++ b/src/globals.go @@ -0,0 +1,48 @@ +package logalize + +import ( + "regexp" + + "github.com/muesli/termenv" +) + +// values from configuration files will be checked using these regular expressions +var ( + capGroupRegexp = regexp.MustCompile(`^\(.+\)$`) + colorRegexp = regexp.MustCompile(`^(#[[:xdigit:]]{6}|[[:digit:]]{1,3})?$`) + styleRegexp = regexp.MustCompile(`^(bold|faint|italic|underline|overline|crossout|reverse)?$`) + wordRegexp = regexp.MustCompile(`[A-Za-z]+`) + negatedWordRegexp = regexp.MustCompile(`(` + + // complex negation + // can't be, shouldn't be, etc. + `[A-Za-z]+n't be` + + `|[Cc]annot be` + + // should not be, will not be, etc. + `|[A-Za-z]+ not be` + + // simple negation + // wasn't, aren't, won't, etc. + `|[A-Za-z]+n't` + + `|[Cc]annot` + + // just plain "not something" + `| not` + + `|^not` + + `|^Not` + + `)` + + // negated word itself + ` ([A-Za-z]+)`, + ) +) + +// global color profile for all colorization +var colorProfile = termenv.ColorProfile() + +// will be used for --version flag +var ( + logalizeVersion string + logalizeReleaseDate string +) + +func SetGlobals(version, releaseDate string) { + logalizeVersion = version + logalizeReleaseDate = releaseDate +} diff --git a/src/logformat.go b/src/logformat.go new file mode 100644 index 0000000..7afc397 --- /dev/null +++ b/src/logformat.go @@ -0,0 +1,184 @@ +package logalize + +import ( + "fmt" + "regexp" + "strconv" + + "github.com/knadh/koanf/v2" + "github.com/muesli/termenv" +) + +// representation of one capture group in a config file +type CapGroup struct { + Pattern string `koanf:"pattern"` + Foreground string `koanf:"fg"` + Background string `koanf:"bg"` + Style string `koanf:"style"` + Alternatives CapGroupList `koanf:"alternatives"` + Regexp *regexp.Regexp +} + +// representation of a list of capture groups +type CapGroupList []CapGroup + +// representation of log format +type LogFormat struct { + Name string + CapGroups CapGroupList + Regexp *regexp.Regexp +} + +// InitLogFormats returns list of LogFormats collected +// from *koanf.Koanf configuration +func initLogFormats(config *koanf.Koanf) ([]LogFormat, error) { + var logFormats []LogFormat + + for _, formatName := range config.MapKeys("formats") { + var logFormat LogFormat + logFormat.Name = formatName + if err := config.Unmarshal("formats."+formatName, &logFormat.CapGroups); err != nil { + return nil, err + } + logFormats = append(logFormats, logFormat) + } + + for i, format := range logFormats { + // check that all patterns are valid regular expressions + if err := format.checkCapGroups(); err != nil { + return nil, err + } + + // build regexp for whole log format line + logFormats[i].Regexp = format.buildRegexp() + + // build regexps for capture groups' alternatives + for _, cg := range format.CapGroups { + if len(cg.Alternatives) > 0 { + for k, alt := range cg.Alternatives { + cg.Alternatives[k].Regexp = regexp.MustCompile(alt.Pattern) + } + } + } + } + return logFormats, nil +} + +// highlight colorizes string and applies a style +func highlight(str, fg, bg, style string) string { + coloredStr := termenv.String(str) + if fg != "" { + coloredStr = coloredStr.Foreground(colorProfile.Color(fg)) + } + if bg != "" { + coloredStr = coloredStr.Background(colorProfile.Color(bg)) + } + switch style { + case "bold": + coloredStr = coloredStr.Bold() + case "faint": + coloredStr = coloredStr.Faint() + case "italic": + coloredStr = coloredStr.Italic() + case "underline": + coloredStr = coloredStr.Underline() + case "overline": + coloredStr = coloredStr.Overline() + case "crossout": + coloredStr = coloredStr.CrossOut() + case "reverse": + coloredStr = coloredStr.Reverse() + } + return coloredStr.String() +} + +// highlight colorizes string and applies a style +func (cg *CapGroup) highlight(str string) string { + if len(cg.Alternatives) > 0 { + for _, alt := range cg.Alternatives { + if alt.Regexp.MatchString(str) { + return highlight(str, alt.Foreground, alt.Background, alt.Style) + } + } + } + + return highlight(str, cg.Foreground, cg.Background, cg.Style) +} + +// check checks one capture group's fields match corresponding patterns +func (cg *CapGroup) check() error { + // check pattern + if cg.Pattern == "" { + return fmt.Errorf("empty patterns are not allowed") + } + if !capGroupRegexp.MatchString(cg.Pattern) { + return fmt.Errorf( + "capture group pattern %s doesn't match %s pattern", + cg.Pattern, capGroupRegexp) + } + + // check foreground + if !colorRegexp.MatchString(cg.Foreground) { + return fmt.Errorf( + "[capture group: %s] foreground color %s doesn't match %s pattern", + cg.Pattern, cg.Foreground, colorRegexp) + } + + // check background + if !colorRegexp.MatchString(cg.Background) { + return fmt.Errorf( + "[capture group: %s] background color %s doesn't match %s pattern", + cg.Pattern, cg.Background, colorRegexp) + } + + // check style + if !styleRegexp.MatchString(cg.Style) { + return fmt.Errorf( + "[capture group: %s] style %s doesn't match %s pattern", + cg.Pattern, cg.Style, styleRegexp) + } + + // check alternatives + if len(cg.Alternatives) > 0 { + return cg.Alternatives.check() + } + return nil +} + +// check checks that capture groups' fields match corresponding patterns +func (cgl *CapGroupList) check() error { + for _, cg := range *cgl { + if err := cg.check(); err != nil { + return err + } + } + return nil +} + +// checkCapGroups checks that all capture groups' fields match corresponding patterns +func (lf *LogFormat) checkCapGroups() error { + if err := lf.CapGroups.check(); err != nil { + return fmt.Errorf("[log format: %s] %s", lf.Name, err) + } + return nil +} + +// buildRegexp builds full regexp string from the list of capture groups +func (lf *LogFormat) buildRegexp() (formatRegexp *regexp.Regexp) { + var format string + for i, cg := range lf.CapGroups { + // add name for the capture group + format += fmt.Sprintf("(?P", i) + cg.Pattern[1:] + } + format = "^" + format + "$" + return regexp.MustCompile(format) +} + +func (lf *LogFormat) highlight(str string) (coloredStr string) { + matches := lf.Regexp.FindStringSubmatch(str) + for i, cg := range lf.CapGroups { + match := matches[lf.Regexp.SubexpIndex("capGroup"+strconv.Itoa(i))] + coloredStr += cg.highlight(match) + } + return coloredStr +} diff --git a/src/logformat_test.go b/src/logformat_test.go new file mode 100644 index 0000000..4f9540b --- /dev/null +++ b/src/logformat_test.go @@ -0,0 +1,311 @@ +package logalize + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/rawbytes" + "github.com/knadh/koanf/v2" + "github.com/muesli/termenv" +) + +func TestLogFormatsInit(t *testing.T) { + configData := ` +formats: + test: + - pattern: (\d{1,3}(\.\d{1,3}){3} ) + fg: "#f5ce42" + - pattern: ([^ ]+ ) + bg: "#764a9e" + - pattern: (\[.+\] ) + style: bold + - pattern: ("[^"]+") + fg: "#9daf99" + bg: "#76fb99" + style: underline + - pattern: (\d\d\d) + fg: "#ffffff" + alternatives: + - pattern: (1\d\d) + fg: "#505050" + - pattern: (2\d\d) + fg: "#00ff00" + style: overline + - pattern: (3\d\d) + fg: "#00ffff" + style: crossout + - pattern: (4\d\d) + fg: "#ff0000" + style: reverse + - pattern: (5\d\d) + fg: "#ff00ff" +` + + correctCapGroups := CapGroupList{ + {`(\d{1,3}(\.\d{1,3}){3} )`, "#f5ce42", "", "", nil, nil}, + {`([^ ]+ )`, "", "#764a9e", "", nil, nil}, + {`(\[.+\] )`, "", "", "bold", nil, nil}, + {`("[^"]+")`, "#9daf99", "#76fb99", "underline", nil, nil}, + { + `(\d\d\d)`, "#ffffff", "", "", + CapGroupList{ + {`(1\d\d)`, "#505050", "", "", nil, nil}, + {`(2\d\d)`, "#00ff00", "", "overline", nil, nil}, + {`(3\d\d)`, "#00ffff", "", "crossout", nil, nil}, + {`(4\d\d)`, "#ff0000", "", "reverse", nil, nil}, + {`(5\d\d)`, "#ff00ff", "", "", nil, nil}, + }, + nil, + }, + } + + colorProfile = termenv.TrueColor + + config := koanf.New(".") + configRaw := []byte(configData) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + t.Run("TestFormatsInit", func(t *testing.T) { + formats, err := initLogFormats(config) + if err != nil { + t.Errorf("InitLogFormats() failed with this error: %s", err) + } + + // check alternatives' regexps + // yeah, I know, it's disgusting, don't look at it, look away + checkRegexp := func(format *LogFormat, alt int, correctRegexp string) { + if format.CapGroups[4].Alternatives[alt].Regexp.String() != correctRegexp { + t.Errorf("got %v, want %v", formats[0].CapGroups[4].Alternatives[alt].Regexp.String(), correctRegexp) + } + format.CapGroups[4].Alternatives[alt].Regexp = nil + } + checkRegexp(&formats[0], 0, `(1\d\d)`) + checkRegexp(&formats[0], 1, `(2\d\d)`) + checkRegexp(&formats[0], 2, `(3\d\d)`) + checkRegexp(&formats[0], 3, `(4\d\d)`) + checkRegexp(&formats[0], 4, `(5\d\d)`) + + // check other fields + if !cmp.Equal(formats[0].CapGroups, correctCapGroups) { + t.Errorf("got %v, want %v", formats[0].CapGroups, correctCapGroups) + } + }) + + configDataBadYAML := ` +formats: + test: + pattern: bad +` + config = koanf.New(".") + configRaw = []byte(configDataBadYAML) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + t.Run("TestFormatsInitBadYAML", func(t *testing.T) { + _, err := initLogFormats(config) + if err == nil { + t.Errorf("InitLogFormats() should have failed") + } + }) + + configDataBadPattern := ` +formats: + test: + - pattern: 'd{1,3}(\.\d{1,3}){3}' + fg: "#f5ce42" + - pattern: '[^ ]+' + bg: "#764a9e" +` + config = koanf.New(".") + configRaw = []byte(configDataBadPattern) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + t.Run("TestFormatsInitBadPattern", func(t *testing.T) { + _, err := initLogFormats(config) + if err == nil { + t.Errorf("InitLogFormats() should have failed") + } + }) +} + +func TestLogFormatCheckCapGroups(t *testing.T) { + tests := []struct { + err string + lf LogFormat + }{ + { + "%!s()", + LogFormat{ + "testNoErr", CapGroupList{ + {`(\d+:)`, "", "", "", CapGroupList{}, nil}, + {`(\d+:)`, "", "", "bold", CapGroupList{}, nil}, + {`(\d+:)`, "", "#ff00ff", "", CapGroupList{}, nil}, + {`(\d+:)`, "", "#ff0000", "underline", CapGroupList{}, nil}, + {`(\d+:)`, "#0f0f0f", "", "", CapGroupList{}, nil}, + {`(\d+:)`, "#0f0f0f", "", "faint", CapGroupList{}, nil}, + {`(\d+:)`, "#0f0f0f", "#ff00ff", "", CapGroupList{}, nil}, + {`(\d+:)`, "#0f0f0f", "#ff0000", "italic", CapGroupList{}, nil}, + {`(\d+:)`, "#0f0f0f", "1", "overline", CapGroupList{}, nil}, + {`(\d+:)`, "37", "#ff0000", "crossout", CapGroupList{}, nil}, + {`(\d+:)`, "214", "15", "reverse", CapGroupList{}, nil}, + }, nil, + }, + }, + { + fmt.Sprintf(`[log format: testPatternErr1] capture group pattern () doesn't match %s pattern`, capGroupRegexp), + LogFormat{ + "testPatternErr1", CapGroupList{ + {`()`, "", "", "", CapGroupList{}, nil}, + }, nil, + }, + }, + { + `[log format: testPatternErr2] empty patterns are not allowed`, + LogFormat{ + "testPatternErr2", CapGroupList{ + {``, "", "", "", CapGroupList{}, nil}, + }, nil, + }, + }, + { + fmt.Sprintf(`[log format: testPatternErr3] capture group pattern ) doesn't match %s pattern`, capGroupRegexp), + LogFormat{ + "testPatternErr3", CapGroupList{ + {`)`, "", "", "", CapGroupList{}, nil}, + }, nil, + }, + }, + { + fmt.Sprintf(`[log format: testPatternErr4] capture group pattern (\d\d-\d\d-\d\d doesn't match %s pattern`, capGroupRegexp), + LogFormat{ + "testPatternErr4", CapGroupList{ + {`(\d\d-\d\d-\d\d`, "", "", "", CapGroupList{}, nil}, + }, nil, + }, + }, + { + fmt.Sprintf(`[log format: testForegroundErr1] [capture group: (\d+)] foreground color ff00df doesn't match %s pattern`, colorRegexp), + LogFormat{ + "testForegroundErr1", CapGroupList{ + {`(\d+)`, "ff00df", "", "", CapGroupList{}, nil}, + }, nil, + }, + }, + { + fmt.Sprintf(`[log format: testBackgroundErr1] [capture group: (\d+)] background color 7000 doesn't match %s pattern`, colorRegexp), + LogFormat{ + "testBackgroundErr1", CapGroupList{ + {`(\d+)`, "", "7000", "", CapGroupList{}, nil}, + }, nil, + }, + }, + { + fmt.Sprintf(`[log format: testStyleErr1] [capture group: (\d+)] style NotAStyle doesn't match %s pattern`, styleRegexp), + LogFormat{ + "testStyleErr1", CapGroupList{ + {`(\d+)`, "", "", "NotAStyle", CapGroupList{}, nil}, + }, nil, + }, + }, + } + + colorProfile = termenv.TrueColor + + for _, tt := range tests { + testname := tt.lf.Name + t.Run(testname, func(t *testing.T) { + if err := fmt.Sprintf("%s", tt.lf.checkCapGroups()); err != tt.err { + t.Errorf("got %s, want %s", err, tt.err) + } + }) + } +} + +func TestLogFormatBuildRegexp(t *testing.T) { + tests := []struct { + correctRegexp string + logFormat LogFormat + }{ + { + `^(?P\d+:)(?P[^\[\]]*)(?P\[test\])$`, + LogFormat{ + "test1", CapGroupList{ + {`(\d+:)`, "#ff0000", "", "", CapGroupList{}, nil}, + {`([^\[\]]*)`, "164", "", "underline", CapGroupList{}, nil}, + {`(\[test\])`, "#00ff00", "#ffff00", "", CapGroupList{}, nil}, + }, nil, + }, + }, + { + `^(?P\d+:)(?Phello)$`, + LogFormat{ + "test2", CapGroupList{ + {`(\d+:)`, "", "", "", CapGroupList{}, nil}, + {`(hello)`, "", "", "underline", CapGroupList{}, nil}, + }, nil, + }, + }, + } + + colorProfile = termenv.TrueColor + + for _, tt := range tests { + testname := tt.logFormat.Name + t.Run(testname, func(t *testing.T) { + reStr := tt.logFormat.buildRegexp().String() + if reStr != tt.correctRegexp { + t.Errorf("got %s, want %s", reStr, tt.correctRegexp) + } + }) + } +} + +func TestLogFormatHighlight(t *testing.T) { + configData := ` +formats: + test: + - pattern: (\d{1,3}(\.\d{1,3}){3} ) + fg: "#f5ce42" + - pattern: ([^ ]+ ) + bg: "#764a9e" + - pattern: (\[.+\] ) + style: bold + - pattern: ("[^"]+") + fg: "#9daf99" + bg: "#76fb99" + style: underline +` + tests := []struct { + plain string + colored string + }{ + {`127.0.0.1 - [test] "testing"`, "\x1b[38;2;245;206;65m127.0.0.1 \x1b[0m\x1b[48;2;118;73;158m- \x1b[0m\x1b[1m[test] \x1b[0m\x1b[38;2;157;175;153;48;2;118;251;153;4m\"testing\"\x1b[0m"}, + {`127.0.0.2 test [test hello] "testing again"`, "\x1b[38;2;245;206;65m127.0.0.2 \x1b[0m\x1b[48;2;118;73;158mtest \x1b[0m\x1b[1m[test hello] \x1b[0m\x1b[38;2;157;175;153;48;2;118;251;153;4m\"testing again\"\x1b[0m"}, + {`127.0.0.3 ___ [.] "_"`, "\x1b[38;2;245;206;65m127.0.0.3 \x1b[0m\x1b[48;2;118;73;158m___ \x1b[0m\x1b[1m[.] \x1b[0m\x1b[38;2;157;175;153;48;2;118;251;153;4m\"_\"\x1b[0m"}, + } + + colorProfile = termenv.TrueColor + + for _, tt := range tests { + testname := tt.plain + config := koanf.New(".") + configRaw := []byte(configData) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + formats, err := initLogFormats(config) + if err != nil { + t.Errorf("InitLogFormats() failed with this error: %s", err) + } + t.Run(testname, func(t *testing.T) { + colored := formats[0].highlight(tt.plain) + if colored != tt.colored { + t.Errorf("got %s, want %s", colored, tt.colored) + } + }) + } +} diff --git a/src/options.go b/src/options.go new file mode 100644 index 0000000..8656027 --- /dev/null +++ b/src/options.go @@ -0,0 +1,27 @@ +package logalize + +import ( + "fmt" + + arg "github.com/alexflint/go-arg" +) + +// Options stores the values of command-line options +type Options struct { + ConfigPath string `arg:"-c, --config" help:"path to configuration file"` + NoBuiltins bool `arg:"-n, --no-builtins" help:"disable built-in log formats and words"` +} + +func (Options) Version() string { + return fmt.Sprintf("%s (%s)", logalizeVersion, logalizeReleaseDate) +} + +// ParseOptions parses command-line options +func ParseOptions(args []string, opts interface{}) (*arg.Parser, error) { + parser, err := arg.NewParser(arg.Config{}, opts) + if err != nil { + return parser, err + } + err = parser.Parse(args) + return parser, err +} diff --git a/src/options_test.go b/src/options_test.go new file mode 100644 index 0000000..f012bad --- /dev/null +++ b/src/options_test.go @@ -0,0 +1,73 @@ +package logalize + +import ( + "strings" + "testing" + + arg "github.com/alexflint/go-arg" + "github.com/google/go-cmp/cmp" +) + +func TestOptionsVersion(t *testing.T) { + SetGlobals("0.0.0", "1970-01-01") + options := Options{ + ConfigPath: "", + NoBuiltins: true, + } + t.Run("TestOptionsVersion", func(t *testing.T) { + if options.Version() != "0.0.0 (1970-01-01)" { + t.Errorf("Options.Version() failed: got %v, want %v", options.Version(), "0.0.0 (test) 1970-01-01") + } + }) +} + +func TestOptionsParse(t *testing.T) { + tests := []struct { + args []string + options Options + }{ + {[]string{}, Options{ConfigPath: "", NoBuiltins: false}}, + {[]string{"-c", "logalize.yaml"}, Options{ConfigPath: "logalize.yaml", NoBuiltins: false}}, + {[]string{"-n"}, Options{ConfigPath: "", NoBuiltins: true}}, + {[]string{"-c", "logalize.yaml", "-n"}, Options{ConfigPath: "logalize.yaml", NoBuiltins: true}}, + {[]string{"-n", "-c", "logalize.yaml"}, Options{ConfigPath: "logalize.yaml", NoBuiltins: true}}, + } + for _, tt := range tests { + testname := strings.Join(tt.args, "_") + + t.Run(testname, func(t *testing.T) { + options := Options{} + _, err := ParseOptions(tt.args, &options) + if err != nil { + t.Errorf("ParseOptions() failed with error: %v", err) + } + if !cmp.Equal(options, tt.options) { + t.Errorf("got %v, want %v", options, tt.options) + } + }) + } + + t.Run("TestOptionsParseHelp", func(t *testing.T) { + options := Options{} + _, err := ParseOptions([]string{"-h"}, &options) + if err != arg.ErrHelp { + t.Errorf("ParseOptions() should have failed with error %v, got %v", arg.ErrHelp, err) + } + }) + + t.Run("TestOptionsParseVersion", func(t *testing.T) { + options := Options{} + _, err := ParseOptions([]string{"--version"}, &options) + if err != arg.ErrVersion { + t.Errorf("ParseOptions() should have failed with error %v, got %v", arg.ErrVersion, err) + } + }) + + t.Run("TestOptionsParseWrongOpts", func(t *testing.T) { + options := Pattern{} + _, err := ParseOptions([]string{}, &options) + if err.Error() != "Pattern.CapGroup: *logalize.CapGroup fields are not supported" { + t.Errorf("ParseOptions() should have failed with error *errors.errorString, got [%T] %v", err, err) + } + }) +} diff --git a/src/pattern.go b/src/pattern.go new file mode 100644 index 0000000..f1920b2 --- /dev/null +++ b/src/pattern.go @@ -0,0 +1,76 @@ +package logalize + +import ( + "fmt" + "regexp" + "sort" + + "github.com/knadh/koanf/v2" +) + +type Pattern struct { + Name string + Priority int + CapGroup *CapGroup +} + +// InitPatterns returns global list of patterns collected +// from *koanf.Koanf configuration +func initPatterns(config *koanf.Koanf) ([]Pattern, error) { + patterns := []Pattern{} + + for _, patternName := range config.MapKeys("patterns") { + var pattern Pattern + pattern.Name = patternName + pattern.Priority = config.Int("patterns." + patternName + ".priority") + if err := config.Unmarshal("patterns."+patternName, &pattern.CapGroup); err != nil { + return nil, err + } + patterns = append(patterns, pattern) + } + + for _, pattern := range patterns { + // validate patterns' capture groups + if err := pattern.CapGroup.check(); err != nil { + return nil, fmt.Errorf("[pattern: %s] %s", pattern.Name, err) + } + + // build main regexp + pattern.CapGroup.Regexp = regexp.MustCompile(pattern.CapGroup.Pattern) + + // build regexps for capture groups' alternatives + if len(pattern.CapGroup.Alternatives) > 0 { + for k, alt := range pattern.CapGroup.Alternatives { + pattern.CapGroup.Alternatives[k].Regexp = regexp.MustCompile(alt.Pattern) + } + } + } + // sort by priority + sort.Slice(patterns, func(i, j int) bool { + iv, jv := patterns[i], patterns[j] + return iv.Priority > jv.Priority + }) + return patterns, nil +} + +// HighlightPatternsAndWords colorizes various patterns +// like IP address, date, HTTP response code and special words +func highlightPatternsAndWords(str string, patterns []Pattern, words Words) string { + if str == "" { + return str + } + + // patterns + for _, pattern := range patterns { + matches := pattern.CapGroup.Regexp.FindStringSubmatchIndex(str) + if matches != nil { + leftPart := highlightPatternsAndWords(str[0:matches[0]], patterns, words) + match := pattern.CapGroup.highlight(str[matches[0]:matches[1]]) + rightPart := highlightPatternsAndWords(str[matches[1]:], patterns, words) + return leftPart + match + rightPart + } + } + + // words + return words.highlight(str) +} diff --git a/src/pattern_test.go b/src/pattern_test.go new file mode 100644 index 0000000..31b616d --- /dev/null +++ b/src/pattern_test.go @@ -0,0 +1,201 @@ +package logalize + +import ( + "testing" + + "github.com/aaaton/golem/v4" + "github.com/aaaton/golem/v4/dicts/en" + "github.com/google/go-cmp/cmp" + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/rawbytes" + "github.com/knadh/koanf/v2" + "github.com/muesli/termenv" +) + +func TestPatternsInit(t *testing.T) { + configData := ` +patterns: + string: + priority: 500 + pattern: ("[^"]+"|'[^']+') + fg: "#00ff00" + + number: + pattern: (\d+) + bg: "#00ffff" + + ipv4-address: + pattern: (\d{1,3}(\.\d{1,3}){3}) + fg: "#ff0000" + bg: "#ffff00" + style: bold +` + correctPatterns := []Pattern{ + {"string", 500, &CapGroup{`("[^"]+"|'[^']+')`, "#00ff00", "", "", nil, nil}}, + {"ipv4-address", 0, &CapGroup{`(\d{1,3}(\.\d{1,3}){3})`, "#ff0000", "#ffff00", "bold", nil, nil}}, + {"number", 0, &CapGroup{`(\d+)`, "", "#00ffff", "", nil, nil}}, + } + + colorProfile = termenv.TrueColor + + config := koanf.New(".") + configRaw := []byte(configData) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + t.Run("TestPatternsInit", func(t *testing.T) { + patterns, err := initPatterns(config) + if err != nil { + t.Errorf("InitPatterns() failed with this error: %s", err) + } + for i, pattern := range patterns { + pattern.CapGroup.Regexp = nil + if !cmp.Equal(pattern.Name, correctPatterns[i].Name) { + t.Errorf("got %v, want %v", pattern.Name, correctPatterns[i].Name) + } + if !cmp.Equal(pattern.Priority, correctPatterns[i].Priority) { + t.Errorf("got %v, want %v", pattern.Priority, correctPatterns[i].Priority) + } + if !cmp.Equal(*pattern.CapGroup, *correctPatterns[i].CapGroup) { + t.Errorf("got %v, want %v", *pattern.CapGroup, *correctPatterns[i].CapGroup) + } + } + }) + + configDataBadYAML := ` +patterns: + string:priority: 100 +` + config = koanf.New(".") + configRaw = []byte(configDataBadYAML) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + t.Run("TestPatternsInitBadYAML", func(t *testing.T) { + _, err := initPatterns(config) + if err == nil { + t.Errorf("InitPatterns() should have failed") + } + }) + + configDataBadPattern := ` +patterns: + string: + priority: 100 + pattern: .* +` + config = koanf.New(".") + configRaw = []byte(configDataBadPattern) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + t.Run("TestPatternsInitBadPattern", func(t *testing.T) { + _, err := initPatterns(config) + if err == nil { + t.Errorf("InitPatterns() should have failed") + } + }) +} + +func TestHighlightPatternsAndWords(t *testing.T) { + configDataGood := ` +patterns: + string: + priority: 500 + pattern: ("[^"]+"|'[^']+') + fg: "#00ff00" + + ipv4-address: + priority: 400 + pattern: (\d{1,3}(\.\d{1,3}){3}) + fg: "#ff0000" + bg: "#ffff00" + style: bold + + number: + pattern: (\d+) + bg: "#005050" + + http-status-code: + priority: 300 + pattern: (\d\d\d) + fg: "#ffffff" + alternatives: + - pattern: (1\d\d) + fg: "#505050" + - pattern: (2\d\d) + fg: "#00ff00" + style: overline + - pattern: (3\d\d) + fg: "#00ffff" + style: crossout + - pattern: (4\d\d) + fg: "#ff0000" + style: reverse + - pattern: (5\d\d) + fg: "#ff00ff" + +words: + good: + fg: "#52fa8a" + style: bold + list: + - "true" + bad: + bg: "#f06c62" + style: underline + list: + - "fail" + - "fatal" +` + tests := []struct { + plain string + colored string + }{ + {"hello", "hello"}, + {`"string"`, "\x1b[38;2;0;255;0m\"string\"\x1b[0m"}, + {"42", "\x1b[48;2;0;80;80m42\x1b[0m"}, + {"127.0.0.1", "\x1b[38;2;255;0;0;48;2;255;255;0;1m127.0.0.1\x1b[0m"}, + {`"test": 127.7.7.7 hello 101`, "\x1b[38;2;0;255;0m\"test\"\x1b[0m: \x1b[38;2;255;0;0;48;2;255;255;0;1m127.7.7.7\x1b[0m hello \x1b[38;2;80;80;80m101\x1b[0m"}, + {`true bad fail`, "\x1b[38;2;81;250;138;1mtrue\x1b[0m bad \x1b[48;2;240;108;97;4mfail\x1b[0m"}, + {`"true"`, "\x1b[38;2;0;255;0m\"true\"\x1b[0m"}, + {`"42"`, "\x1b[38;2;0;255;0m\"42\"\x1b[0m"}, + {`"237.7.7.7"`, "\x1b[38;2;0;255;0m\"237.7.7.7\"\x1b[0m"}, + {`status 103`, "status \x1b[38;2;80;80;80m103\x1b[0m"}, + {`status 200`, "status \x1b[38;2;0;255;0;53m200\x1b[0m"}, + {`status 302`, "status \x1b[38;2;0;255;255;9m302\x1b[0m"}, + {`status 404`, "status \x1b[38;2;255;0;0;7m404\x1b[0m"}, + {`status 503`, "status \x1b[38;2;255;0;255m503\x1b[0m"}, + {`status 700`, "status \x1b[38;2;255;255;255m700\x1b[0m"}, + } + + lemmatizer, err := golem.New(en.New()) + if err != nil { + t.Errorf("golem.New(en.New()) failed with this error: %s", err) + } + + colorProfile = termenv.TrueColor + + for _, tt := range tests { + testname := tt.plain + config := koanf.New(".") + configRaw := []byte(configDataGood) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + patterns, err := initPatterns(config) + if err != nil { + t.Errorf("InitPatterns() failed with this error: %s", err) + } + words, err := initWords(config, lemmatizer) + if err != nil { + t.Errorf("InitWords() failed with this error: %s", err) + } + t.Run(testname, func(t *testing.T) { + colored := highlightPatternsAndWords(tt.plain, patterns, words) + if colored != tt.colored { + t.Errorf("got %v, want %v", colored, tt.colored) + } + }) + } +} diff --git a/src/word.go b/src/word.go new file mode 100644 index 0000000..64ae700 --- /dev/null +++ b/src/word.go @@ -0,0 +1,109 @@ +package logalize + +import ( + "slices" + + "github.com/aaaton/golem/v4" + "github.com/knadh/koanf/v2" +) + +type WordGroup struct { + Name string `koanf:"name"` + List []string `koanf:"list"` + Foreground string `koanf:"fg"` + Background string `koanf:"bg"` + Style string `koanf:"style"` +} + +type Words struct { + Good WordGroup + Bad WordGroup + Other []WordGroup + Lemmatizer *golem.Lemmatizer +} + +// InitWords returns global list of words collected +// from *koanf.Koanf configuration +func initWords(config *koanf.Koanf, lemmatizer *golem.Lemmatizer) (Words, error) { + var words Words + + for _, wordGroupName := range config.MapKeys("words") { + var wordGroup WordGroup + wordGroup.Name = wordGroupName + if err := config.Unmarshal("words."+wordGroupName, &wordGroup); err != nil { + return Words{}, err + } + switch wordGroupName { + case "good": + words.Good = wordGroup + case "bad": + words.Bad = wordGroup + default: + words.Other = append(words.Other, wordGroup) + } + } + + words.Lemmatizer = lemmatizer + + return words, nil +} + +// highlightWord colors single word in a string +func (words *Words) highlightWord(word string) string { + allWordGroups := append(words.Other, words.Good, words.Bad) + for _, wordGroup := range allWordGroups { + lemma := words.Lemmatizer.Lemma(word) + if slices.Contains(wordGroup.List, lemma) || slices.Contains(wordGroup.List, word) { + word = highlight(word, wordGroup.Foreground, wordGroup.Background, wordGroup.Style) + break + } + } + + return word +} + +// highlightNegated colors a phrase with negated word in a string +// if the word is good, then color the whole phrase as bad and vice versa +// if the word is neither good nor bad, then don't color the phrase +func (words *Words) highlightNegatedWord(phrase, negator, word string) string { + lemma := words.Lemmatizer.Lemma(word) + // good + if slices.Contains(words.Good.List, lemma) || slices.Contains(words.Good.List, word) { + return highlight(phrase, words.Bad.Foreground, words.Bad.Background, words.Bad.Style) + } + // bad + if slices.Contains(words.Bad.List, lemma) || slices.Contains(words.Bad.List, word) { + return highlight(phrase, words.Good.Foreground, words.Good.Background, words.Good.Style) + } + // other + for _, wordGroup := range words.Other { + if slices.Contains(wordGroup.List, lemma) || slices.Contains(wordGroup.List, word) { + return negator + " " + highlight(word, wordGroup.Foreground, wordGroup.Background, wordGroup.Style) + } + } + + return phrase +} + +// highlight colors all words in a string +func (words *Words) highlight(str string) string { + if str == "" { + return str + } + + for { + if m := negatedWordRegexp.FindStringSubmatchIndex(str); m != nil { + leftPart := words.highlight(str[0:m[0]]) + match := words.highlightNegatedWord(str[m[0]:m[1]], str[m[2]:m[3]], str[m[4]:m[5]]) + rightPart := words.highlight(str[m[1]:]) + return leftPart + match + rightPart + } else if m := wordRegexp.FindStringIndex(str); m != nil { + leftPart := words.highlight(str[0:m[0]]) + match := words.highlightWord(str[m[0]:m[1]]) + rightPart := words.highlight(str[m[1]:]) + return leftPart + match + rightPart + } else { + return str + } + } +} diff --git a/src/word_test.go b/src/word_test.go new file mode 100644 index 0000000..402a0bb --- /dev/null +++ b/src/word_test.go @@ -0,0 +1,341 @@ +package logalize + +import ( + "testing" + + "github.com/aaaton/golem/v4" + "github.com/aaaton/golem/v4/dicts/en" + "github.com/google/go-cmp/cmp" + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/rawbytes" + "github.com/knadh/koanf/v2" + "github.com/muesli/termenv" +) + +func TestWordsInit(t *testing.T) { + configDataGood := ` +words: + good: + fg: "#52fa8a" + style: bold + list: + - "true" + bad: + bg: "#f06c62" + list: + - "fail" + - "fatal" + friends: + fg: "#f834b2" + style: underline + list: + - "toni" + - "wenzel" + foes: + fg: "#120fbb" + style: underline + list: + - "argus" + - "cletus" +` + goodGroup := WordGroup{"good", []string{"true"}, "#52fa8a", "", "bold"} + badGroup := WordGroup{"bad", []string{"fail", "fatal"}, "", "#f06c62", ""} + otherGroups := []WordGroup{ + {"foes", []string{"argus", "cletus"}, "#120fbb", "", "underline"}, + {"friends", []string{"toni", "wenzel"}, "#f834b2", "", "underline"}, + } + + colorProfile = termenv.TrueColor + + lemmatizer, err := golem.New(en.New()) + if err != nil { + t.Errorf("golem.New(en.New()) failed with this error: %s", err) + } + + config := koanf.New(".") + configRaw := []byte(configDataGood) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + t.Run("TestWordsInitGood", func(t *testing.T) { + words, err := initWords(config, lemmatizer) + if err != nil { + t.Errorf("InitWords() failed with this error: %s", err) + } + if !cmp.Equal(words.Good, goodGroup) { + t.Errorf("got %v, want %v", words.Good, goodGroup) + } + if !cmp.Equal(words.Bad, badGroup) { + t.Errorf("got %v, want %v", words.Bad, badGroup) + } + if !cmp.Equal(words.Other, otherGroups) { + t.Errorf("got %v, want %v", words.Other, otherGroups) + } + }) + + configDataBadYAML := ` +words: + good:err: bad +` + config = koanf.New(".") + configRaw = []byte(configDataBadYAML) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + t.Run("TestWordsInitBad", func(t *testing.T) { + _, err := initWords(config, lemmatizer) + if err == nil { + t.Errorf("InitWords() should have failed") + } + }) +} + +func TestWordsHighlightWord(t *testing.T) { + configData := ` +words: + good: + fg: "#52fa8a" + style: bold + list: + - "true" + bad: + bg: "#f06c62" + style: underline + list: + - "fail" + - "fatal" + friends: + fg: "#f834b2" + style: faint + list: + - "toni" + - "wenzel" + foes: + fg: "#120fbb" + style: italic + list: + - "argus" + - "cletus" +` + tests := []struct { + plain string + colored string + }{ + {"hello", "hello"}, + {"untrue", "untrue"}, + {"true", "\x1b[38;2;81;250;138;1mtrue\x1b[0m"}, + {"fail", "\x1b[48;2;240;108;97;4mfail\x1b[0m"}, + {"failed", "\x1b[48;2;240;108;97;4mfailed\x1b[0m"}, + {"wenzel", "\x1b[38;2;248;52;178;2mwenzel\x1b[0m"}, + {"argus", "\x1b[38;2;18;15;187;3margus\x1b[0m"}, + } + + colorProfile = termenv.TrueColor + + lemmatizer, err := golem.New(en.New()) + if err != nil { + t.Errorf("golem.New(en.New()) failed with this error: %s", err) + } + + for _, tt := range tests { + testname := tt.plain + config := koanf.New(".") + configRaw := []byte(configData) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + words, err := initWords(config, lemmatizer) + if err != nil { + t.Errorf("InitWords() failed with this error: %s", err) + } + t.Run(testname, func(t *testing.T) { + colored := words.highlightWord(tt.plain) + if colored != tt.colored { + t.Errorf("got %s, want %s", colored, tt.colored) + } + }) + } +} + +func TestWordsHighlightNegatedWord(t *testing.T) { + configData := ` +words: + good: + fg: "#52fa8a" + style: bold + list: + - "true" + - "complete" + bad: + bg: "#f06c62" + list: + - "false" + - "fail" + friends: + fg: "#f834b2" + style: underline + list: + - "toni" + - "wenzel" + foes: + fg: "#120fbb" + style: underline + list: + - "argus" + - "cletus" +` + tests := []struct { + plain string + colored string + }{ + {"not hello", "not hello"}, + + {"not true", "\x1b[48;2;240;108;97mnot true\x1b[0m"}, + {"Not true", "\x1b[48;2;240;108;97mNot true\x1b[0m"}, + {"wasn't true", "\x1b[48;2;240;108;97mwasn't true\x1b[0m"}, + {"won't true", "\x1b[48;2;240;108;97mwon't true\x1b[0m"}, + {"cannot complete", "\x1b[48;2;240;108;97mcannot complete\x1b[0m"}, + {"won't be completed", "\x1b[48;2;240;108;97mwon't be completed\x1b[0m"}, + {"cannot be completed", "\x1b[48;2;240;108;97mcannot be completed\x1b[0m"}, + {"should not be completed", "\x1b[48;2;240;108;97mshould not be completed\x1b[0m"}, + + {"not false", "\x1b[38;2;81;250;138;1mnot false\x1b[0m"}, + {"Not false", "\x1b[38;2;81;250;138;1mNot false\x1b[0m"}, + {"wasn't false", "\x1b[38;2;81;250;138;1mwasn't false\x1b[0m"}, + {"won't false", "\x1b[38;2;81;250;138;1mwon't false\x1b[0m"}, + {"cannot fail", "\x1b[38;2;81;250;138;1mcannot fail\x1b[0m"}, + {"won't be failed", "\x1b[38;2;81;250;138;1mwon't be failed\x1b[0m"}, + {"cannot be failed", "\x1b[38;2;81;250;138;1mcannot be failed\x1b[0m"}, + {"should not be failed", "\x1b[38;2;81;250;138;1mshould not be failed\x1b[0m"}, + + {"not toni", "not \x1b[38;2;248;52;178;4mtoni\x1b[0m"}, + {"Not wenzel", "Not \x1b[38;2;248;52;178;4mwenzel\x1b[0m"}, + {"wasn't argus", "wasn't \x1b[38;2;18;15;187;4margus\x1b[0m"}, + {"won't cletus", "won't \x1b[38;2;18;15;187;4mcletus\x1b[0m"}, + {"cannot toni", "cannot \x1b[38;2;248;52;178;4mtoni\x1b[0m"}, + {"won't be wenzel", "won't be \x1b[38;2;248;52;178;4mwenzel\x1b[0m"}, + {"cannot be argus", "cannot be \x1b[38;2;18;15;187;4margus\x1b[0m"}, + {"should not be cletus", "should not be \x1b[38;2;18;15;187;4mcletus\x1b[0m"}, + } + + colorProfile = termenv.TrueColor + + lemmatizer, err := golem.New(en.New()) + if err != nil { + t.Errorf("golem.New(en.New()) failed with this error: %s", err) + } + + for _, tt := range tests { + testname := tt.plain + config := koanf.New(".") + configRaw := []byte(configData) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + words, err := initWords(config, lemmatizer) + if err != nil { + t.Errorf("InitWords() failed with this error: %s", err) + } + t.Run(testname, func(t *testing.T) { + m := negatedWordRegexp.FindStringSubmatchIndex(tt.plain) + colored := words.highlightNegatedWord(tt.plain[m[0]:m[1]], tt.plain[m[2]:m[3]], tt.plain[m[4]:m[5]]) + if colored != tt.colored { + t.Errorf("got %s, want %s", colored, tt.colored) + } + }) + } +} + +func TestWordsHighlight(t *testing.T) { + configData := ` +words: + good: + fg: "#52fa8a" + style: bold + list: + - "true" + - "complete" + bad: + bg: "#f06c62" + list: + - "false" + - "fail" + friends: + fg: "#f834b2" + style: underline + list: + - "toni" + - "wenzel" + foes: + fg: "#120fbb" + style: underline + list: + - "argus" + - "cletus" +` + tests := []struct { + plain string + colored string + }{ + {"hello", "hello"}, + {"untrue", "untrue"}, + {"true", "\x1b[38;2;81;250;138;1mtrue\x1b[0m"}, + {"fail", "\x1b[48;2;240;108;97mfail\x1b[0m"}, + {"failed", "\x1b[48;2;240;108;97mfailed\x1b[0m"}, + {"wenzel", "\x1b[38;2;248;52;178;4mwenzel\x1b[0m"}, + + {"argus", "\x1b[38;2;18;15;187;4margus\x1b[0m"}, + {"not true", "\x1b[48;2;240;108;97mnot true\x1b[0m"}, + {"Not true", "\x1b[48;2;240;108;97mNot true\x1b[0m"}, + {"wasn't true", "\x1b[48;2;240;108;97mwasn't true\x1b[0m"}, + {"won't true", "\x1b[48;2;240;108;97mwon't true\x1b[0m"}, + {"cannot complete", "\x1b[48;2;240;108;97mcannot complete\x1b[0m"}, + {"won't be completed", "\x1b[48;2;240;108;97mwon't be completed\x1b[0m"}, + {"cannot be completed", "\x1b[48;2;240;108;97mcannot be completed\x1b[0m"}, + {"should not be completed", "\x1b[48;2;240;108;97mshould not be completed\x1b[0m"}, + + {"not false", "\x1b[38;2;81;250;138;1mnot false\x1b[0m"}, + {"Not false", "\x1b[38;2;81;250;138;1mNot false\x1b[0m"}, + {"wasn't false", "\x1b[38;2;81;250;138;1mwasn't false\x1b[0m"}, + {"won't false", "\x1b[38;2;81;250;138;1mwon't false\x1b[0m"}, + {"cannot fail", "\x1b[38;2;81;250;138;1mcannot fail\x1b[0m"}, + {"won't be failed", "\x1b[38;2;81;250;138;1mwon't be failed\x1b[0m"}, + {"cannot be failed", "\x1b[38;2;81;250;138;1mcannot be failed\x1b[0m"}, + {"should not be failed", "\x1b[38;2;81;250;138;1mshould not be failed\x1b[0m"}, + + {"not toni", "not \x1b[38;2;248;52;178;4mtoni\x1b[0m"}, + {"Not wenzel", "Not \x1b[38;2;248;52;178;4mwenzel\x1b[0m"}, + {"wasn't argus", "wasn't \x1b[38;2;18;15;187;4margus\x1b[0m"}, + {"won't cletus", "won't \x1b[38;2;18;15;187;4mcletus\x1b[0m"}, + {"cannot toni", "cannot \x1b[38;2;248;52;178;4mtoni\x1b[0m"}, + {"won't be wenzel", "won't be \x1b[38;2;248;52;178;4mwenzel\x1b[0m"}, + {"cannot be argus", "cannot be \x1b[38;2;18;15;187;4margus\x1b[0m"}, + {"should not be cletus", "should not be \x1b[38;2;18;15;187;4mcletus\x1b[0m"}, + } + + colorProfile = termenv.TrueColor + + lemmatizer, err := golem.New(en.New()) + if err != nil { + t.Errorf("golem.New(en.New()) failed with this error: %s", err) + } + + for _, tt := range tests { + testname := tt.plain + config := koanf.New(".") + configRaw := []byte(configData) + if err := config.Load(rawbytes.Provider(configRaw), yaml.Parser()); err != nil { + t.Errorf("Error during config loading: %s", err) + } + words, err := initWords(config, lemmatizer) + if err != nil { + t.Errorf("InitWords() failed with this error: %s", err) + } + t.Run(testname, func(t *testing.T) { + colored := words.highlight(tt.plain) + if colored != tt.colored { + t.Errorf("got %s, want %s", colored, tt.colored) + } + }) + } +} diff --git a/testlogs/argocd-server.log b/testlogs/argocd-server.log new file mode 100644 index 0000000..6f987af --- /dev/null +++ b/testlogs/argocd-server.log @@ -0,0 +1,5 @@ +time="2024-02-17T08:31:14Z" level=info msg="Alloc=17298 TotalAlloc=87016574 Sys=69477 NumGC=11189 Goroutines=106" +time="2024-02-17T08:32:22Z" level=info msg="invalidated cache for resource in namespace: argocd with the name: argocd-notifications-secret" +time="2024-02-17T08:32:22Z" level=info msg="invalidated cache for resource in namespace: argocd with the name: argocd-notifications-cm" +time="2024-02-17T08:35:22Z" level=info msg="invalidated cache for resource in namespace: argocd with the name: argocd-notifications-secret" +time="2024-02-17T08:35:22Z" level=info msg="invalidated cache for resource in namespace: argocd with the name: argocd-notifications-cm" diff --git a/testlogs/cert-manager.log b/testlogs/cert-manager.log new file mode 100644 index 0000000..1765a9e --- /dev/null +++ b/testlogs/cert-manager.log @@ -0,0 +1,12 @@ +I0201 19:41:04.835633 1 util.go:83] "cert-manager/controller/certificaterequests-issuer-acme/handleOwnedResource: owning resource not found in cache" resource_name="example-tls-tbbq9-1067584438" resource_namespace="example" resource_kind="Order" resource_version="v1" related_resource_namespace="example" related_resource_name="example-tls-tbbq9" related_resource_kind="CertificateRequest" +I0201 19:41:04.835803 1 util.go:83] "cert-manager/controller/certificaterequests-issuer-acme/handleOwnedResource: owning resource not found in cache" resource_name="example-tls-x49tr-1067584438" resource_namespace="example" resource_kind="Order" resource_version="v1" related_resource_namespace="example" related_resource_name="example-tls-x49tr" related_resource_kind="CertificateRequest" +I0201 19:41:04.835971 1 util.go:83] "cert-manager/controller/certificaterequests-issuer-acme/handleOwnedResource: owning resource not found in cache" resource_name="example-tls-z7rt9-1067584438" resource_namespace="example" resource_kind="Order" resource_version="v1" related_resource_namespace="example" related_resource_name="example-tls-z7rt9" related_resource_kind="CertificateRequest" +I0201 19:41:04.836063 1 util.go:83] "cert-manager/controller/certificaterequests-issuer-acme/handleOwnedResource: owning resource not found in cache" resource_name="example-tls-zgrgj-1067584438" resource_namespace="example" resource_kind="Order" resource_version="v1" related_resource_namespace="example" related_resource_name="example-tls-zgrgj" related_resource_kind="CertificateRequest" +I0201 19:41:09.692504 1 setup.go:208] "cert-manager/clusterissuers: skipping re-verifying ACME account as cached registration details look sufficient" resource_name="letsencrypt-production" resource_namespace="" resource_kind="ClusterIssuer" resource_version="v1" related_resource_name="letsencrypt-production" related_resource_namespace="cert-manager" related_resource_kind="Secret" +I0410 13:18:43.647834 1 controller.go:435] "serving insecurely as tls certificate data not provided" logger="cert-manager.controller" +I0410 13:18:43.647851 1 controller.go:102] "listening for insecure connections" logger="cert-manager.controller" address="0.0.0.0:9402" +I0410 13:18:43.650517 1 controller.go:129] "starting metrics server" logger="cert-manager.controller" address="[::]:9402" +I0410 13:18:43.650599 1 controller.go:175] "starting healthz server" logger="cert-manager.controller" address="[::]:9403" +I0410 13:18:43.650671 1 controller.go:182] "starting leader election" logger="cert-manager.controller" +I0410 13:18:43.655728 1 leaderelection.go:250] attempting to acquire leader lease cert-manager/cert-manager-controller... +I0410 13:18:43.680075 1 leaderelection.go:260] successfully acquired lease cert-manager/cert-manager-controller diff --git a/testlogs/grafana.log b/testlogs/grafana.log new file mode 100644 index 0000000..ac455e3 --- /dev/null +++ b/testlogs/grafana.log @@ -0,0 +1,5 @@ +logger=cleanup t=2024-02-17T07:46:32.791278225Z level=info msg="Completed cleanup jobs" duration=4.140739ms +logger=grafana.update.checker t=2024-02-17T07:46:32.900579622Z level=info msg="Update check succeeded" duration=35.764472ms +logger=plugins.update.checker t=2024-02-17T07:46:32.968630268Z level=info msg="Update check succeeded" duration=69.479795ms +logger=infra.usagestats t=2024-02-17T07:47:15.842776507Z level=info msg="Usage stats are ready to report" +logger=cleanup t=2024-02-17T07:56:32.791837625Z level=info msg="Completed cleanup jobs" duration=4.402219ms diff --git a/testlogs/haproxy.log b/testlogs/haproxy.log new file mode 100644 index 0000000..8a75741 --- /dev/null +++ b/testlogs/haproxy.log @@ -0,0 +1,5 @@ +[WARNING] (1) : Server redis-backend/node0 was DOWN and now enters maintenance (DNS timeout status). +[WARNING] (1) : redis-backend/node0 changed its IP from (none) to 10.64.0.6 by k8s/10.124.16.10. +[WARNING] (1) : Server redis-backend/node0 ('node0.name.namespace.svc.cluster.local') is UP/READY (resolves again). +[WARNING] (1) : Server redis-backend/node0 administratively READY thanks to valid DNS answer. +[WARNING] (1) : redis-backend/node2 changed its IP from (none) to 10.64.2.8 by k8s/10.124.16.10. diff --git a/testlogs/imgproxy.log b/testlogs/imgproxy.log new file mode 100644 index 0000000..2963ac5 --- /dev/null +++ b/testlogs/imgproxy.log @@ -0,0 +1,5 @@ +INFO [2024-02-17T09:09:44Z] Completed in 74.051µs /favicon.ico request_id=df6ed152823aa941868c9dc4ed5b523d method=GET status=200 client_ip=34.96.5.1 +INFO [2024-02-17T09:09:45Z] Started /health request_id=413a0974f99f20e5c6449f884be9c8d5 method=GET client_ip=10.64.9.2 +INFO [2024-02-17T09:09:45Z] Completed in 97.763µs /health request_id=413a0974f99f20e5c6449f884be9c8d5 method=GET status=200 client_ip=10.64.9.2 +INFO [2024-02-17T09:09:49Z] Started /health request_id=yfyjFiu4AYUSiSdNImc-v method=GET client_ip=10.64.2.1 +INFO [2024-02-17T09:09:49Z] Completed in 115.419µs /health request_id=yfyjFiu4AYUSiSdNImc-v method=GET status=200 client_ip=10.64.2.1 diff --git a/testlogs/loki.log b/testlogs/loki.log new file mode 100644 index 0000000..3f5cba9 --- /dev/null +++ b/testlogs/loki.log @@ -0,0 +1,5 @@ +level=info ts=2024-02-17T09:10:31.867530876Z caller=index_set.go:86 msg="uploading table loki_index_19762" +level=info ts=2024-02-17T09:10:31.867535341Z caller=index_set.go:107 msg="finished uploading table loki_index_19762" +level=info ts=2024-02-17T09:10:31.867540288Z caller=index_set.go:185 msg="cleaning up unwanted indexes from table loki_index_19762" +level=info ts=2024-02-17T09:10:31.867545952Z caller=index_set.go:86 msg="uploading table loki_index_19765" +level=info ts=2024-02-17T09:10:31.867549945Z caller=index_set.go:107 msg="finished uploading table loki_index_19765" diff --git a/testlogs/nginx-access.log b/testlogs/nginx-access.log new file mode 100644 index 0000000..4d0ccba --- /dev/null +++ b/testlogs/nginx-access.log @@ -0,0 +1,5 @@ +127.0.0.1 - - [16/Feb/2024:00:01:01 +0000] "GET / HTTP/1.1" 301 162 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1" +127.0.0.1 - - [16/Feb/2024:00:12:33 +0000] "GET / HTTP/1.1" 200 478 "-" "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.2623.112 Safari/537.36" +127.0.0.1 - - [16/Feb/2024:00:12:46 +0000] "GET /robots.txt HTTP/1.1" 200 23 "-" "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36 QIHU 360SE" +127.0.0.1 - - [16/Feb/2024:00:25:50 +0000] "GET / HTTP/1.1" 301 162 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.85 Safari/537.36 Edg/127.0.0.1" +127.0.0.1 - - [16/Feb/2024:00:28:48 +0000] "GET / HTTP/1.1" 301 162 "-" "-" diff --git a/testlogs/nginx-error.log b/testlogs/nginx-error.log new file mode 100644 index 0000000..eaabbb1 --- /dev/null +++ b/testlogs/nginx-error.log @@ -0,0 +1,5 @@ +2024/02/16 00:12:57 [crit] 341169#341169: *121575 SSL_do_handshake() failed (SSL: error:0A00006C:SSL routines::bad key share) while SSL handshaking, client: 127.0.0.1, server: 127.0.0.1:443 +2024/02/16 14:44:53 [crit] 341169#341169: *122058 SSL_do_handshake() failed (SSL: error:0A00006C:SSL routines::bad key share) while SSL handshaking, client: 127.0.0.1, server: 127.0.0.1:443 +2024/02/16 15:21:02 [error] 341169#341169: *122114 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 127.0.0.1, server: test.example.com, request: "GET /icons/www.example.com/icon.png HTTP/2.0", upstream: "http://127.0.0.1:80/icons/www.example.com/icon.png", host: "test.example.com" +2024/02/16 19:13:44 [crit] 341169#341169: *122264 SSL_do_handshake() failed (SSL: error:0A00006C:SSL routines::bad key share) while SSL handshaking, client: 127.0.0.1, server: 127.0.0.1:443 +2024/02/16 20:41:36 [error] 341169#341169: *122380 client intended to send too large body: 10485761 bytes, client: 127.0.0.1, server: example.com, request: "POST / HTTP/1.1", host: "127.0.0.1" diff --git a/testlogs/nginx-ingress.log b/testlogs/nginx-ingress.log new file mode 100644 index 0000000..4b9778a --- /dev/null +++ b/testlogs/nginx-ingress.log @@ -0,0 +1,5 @@ +127.0.0.102 - - [27/Jun/2023:07:13:16 +0000] "GET /language/en-GB/en-GB.xml HTTP/1.1" 403 9 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36" 619 0.003 [imgproxy-imgproxy-imgproxy-80] [] 10.64.6.9:8080 9 0.003 403 07d2cd60741517a6d8222f40757b94c4 +127.0.0.105 - - [27/Jun/2023:07:13:21 +0000] "GET /_health HTTP/1.1" 308 164 "-" "Blackbox Exporter/0.23.0" 113 0.000 [example-example-puma-3000] [] - - - - 6908e7e7a9b7b10695edb441277603a7 +127.0.0.106 - - [27/Jun/2023:07:13:21 +0000] "GET / HTTP/2.0" 200 40043 "-" "Blackbox Exporter/0.23.0" 37 0.108 [example-example-web-80] [] 10.64.4.3:80 40095 0.107 200 ccb80c4774bfc5b3858ffeb5eb773efa +127.0.0.108 - - [27/Jun/2023:07:13:23 +0000] "GET /_health HTTP/1.1" 308 164 "-" "Blackbox Exporter/0.23.0" 113 0.000 [example-example-puma-3000] [] - - - - e59e9233e58fc62eeda7d13f702092f3 +127.0.0.109 - - [27/Jun/2023:07:13:25 +0000] "GET / HTTP/1.1" 200 0 "-" "Go-http-client/1.1" 133 0.004 [imgproxy-imgproxy-imgproxy-80] [] 10.64.6.9:8080 0 0.003 200 cbaa610f2b69af2cff28dfd202ae4466 diff --git a/testlogs/prometheus.log b/testlogs/prometheus.log new file mode 100644 index 0000000..963caae --- /dev/null +++ b/testlogs/prometheus.log @@ -0,0 +1,5 @@ +ts=2024-02-16T23:00:02.938Z caller=compact.go:523 level=info component=tsdb msg="write block" mint=1708113600007 maxt=1708120800000 ulid=01HPT2BTJ20P44XFQTMPA5HGM4 duration=2.743374482s +ts=2024-02-16T23:00:02.953Z caller=db.go:1619 level=info component=tsdb msg="Deleting obsolete block" block=01HPMPPNAFV8CDMF9NN3NTG92N +ts=2024-02-16T23:00:03.055Z caller=head.go:1299 level=info component=tsdb msg="Head GC completed" caller=truncateMemory duration=102.767338ms +ts=2024-02-16T23:00:03.063Z caller=checkpoint.go:100 level=info component=tsdb msg="Creating checkpoint" from_segment=14118 to_segment=14119 mint=1708120800000 +ts=2024-02-16T23:00:04.876Z caller=head.go:1267 level=info component=tsdb msg="WAL checkpoint complete" first=14118 last=14119 duration=1.812954304s diff --git a/testlogs/promtail.log b/testlogs/promtail.log new file mode 100644 index 0000000..2ba663e --- /dev/null +++ b/testlogs/promtail.log @@ -0,0 +1,5 @@ +level=info ts=2024-02-17T06:56:10.636960544Z caller=filetargetmanager.go:181 msg="received file watcher event" name=/var/log/pods/argocd_argocd-notifications-controller-6f59f54dd4-jxwd6_f16d4e05-c279-45fa-b06c-5f42f3e41faf/notifications-controller/0.log.20240217-065610 op=CREATE +level=info ts=2024-02-17T06:56:10.638579404Z caller=filetargetmanager.go:181 msg="received file watcher event" name=/var/log/pods/argocd_argocd-notifications-controller-6f59f54dd4-jxwd6_f16d4e05-c279-45fa-b06c-5f42f3e41faf/notifications-controller/0.log op=CREATE +ts=2024-02-17T06:56:10.730706334Z caller=log.go:168 level=info msg="Re-opening moved/deleted file /var/log/pods/argocd_argocd-notifications-controller-6f59f54dd4-jxwd6_f16d4e05-c279-45fa-b06c-5f42f3e41faf/notifications-controller/0.log ..." +ts=2024-02-17T06:56:10.731227194Z caller=log.go:168 level=info msg="Successfully reopened /var/log/pods/argocd_argocd-notifications-controller-6f59f54dd4-jxwd6_f16d4e05-c279-45fa-b06c-5f42f3e41faf/notifications-controller/0.log" +level=info ts=2024-02-17T08:00:19.053912958Z caller=filetargetmanager.go:181 msg="received file watcher event" name=/var/log/pods/argocd_argocd-repo-server-8f757b6fb-zznjt_1fa1590f-4e35-4d12-b969-0b37c6b527a7/repo-server/0.log.20240217-054220.tmp op=CREATE diff --git a/testlogs/redis.argocd.log b/testlogs/redis.argocd.log new file mode 100644 index 0000000..6a9aa64 --- /dev/null +++ b/testlogs/redis.argocd.log @@ -0,0 +1,5 @@ +1:C 01 Feb 2024 19:41:07.224 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo +1:C 01 Feb 2024 19:41:07.224 # Redis version=7.0.13, bits=64, commit=00000000, modified=0, pid=1, just started +1:C 01 Feb 2024 19:41:07.224 # Configuration loaded +1:M 01 Feb 2024 19:41:07.226 * monotonic clock: POSIX clock_gettime +1:M 01 Feb 2024 19:41:07.228 * Running mode=standalone, port=6379. diff --git a/testlogs/redis.brainwashing.log b/testlogs/redis.brainwashing.log new file mode 100644 index 0000000..1ef91a1 --- /dev/null +++ b/testlogs/redis.brainwashing.log @@ -0,0 +1,5 @@ +1:S 17 Feb 2024 00:39:12.500 * Starting automatic rewriting of AOF on 3886% growth +1:S 17 Feb 2024 00:39:12.501 * Background append only file rewriting started by pid 4018569 +1:S 17 Feb 2024 00:39:12.556 * AOF rewrite child asks to stop sending diffs. +4018569:C 17 Feb 2024 00:39:12.557 * Parent agreed to stop sending diffs. Finalizing AOF... +4018569:C 17 Feb 2024 00:39:12.557 * Concatenating 0.00 MB of AOF diff received from parent. diff --git a/testlogs/sealed-secrets-controller.log b/testlogs/sealed-secrets-controller.log new file mode 100644 index 0000000..a05f2ae --- /dev/null +++ b/testlogs/sealed-secrets-controller.log @@ -0,0 +1,5 @@ +Event(v1.ObjectReference{Kind:"SealedSecret", Namespace:"argocd", Name:"argocd-notifications-secret", UID:"e5ecb257-2605-45cd-951f-438f581c4c9b", APIVersion:"bitnami.com/v1alpha1", ResourceVersion:"102547201", FieldPath:""}): type: 'Normal' reason: 'Unsealed' SealedSecret unsealed successfully +Updating argocd/example-repo-creds +Event(v1.ObjectReference{Kind:"SealedSecret", Namespace:"argocd", Name:"argocd-secret", UID:"9c9dd09f-a700-40ac-8d9d-829ac8e3a6f3", APIVersion:"bitnami.com/v1alpha1", ResourceVersion:"358280727", FieldPath:""}): type: 'Normal' reason: 'Unsealed' SealedSecret unsealed successfully +Updating argocd/example-repo-creds +Event(v1.ObjectReference{Kind:"SealedSecret", Namespace:"argocd", Name:"example-repo-creds", UID:"d9282d1a-80ea-4695-a5c2-6e80e321e464", APIVersion:"bitnami.com/v1alpha1", ResourceVersion:"50012177", FieldPath:""}): type: 'Normal' reason: 'Unsealed' SealedSecret unsealed successfully