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 @@
+
+
+
+
+
+
+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.
+
+
+
+
+
+
+
+
+
+Usage
+-----
+
+```bash
+cat /path/to/logs/file.log | logalize
+```
+
+
+
+
+
+
+
+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