├── .envrc ├── tests ├── files │ └── empty.log ├── utils.rs └── integration_tests.rs ├── rustfmt.toml ├── src ├── core │ ├── utils │ │ ├── mod.rs │ │ ├── split_and_apply.rs │ │ └── normalizer.rs │ ├── mod.rs │ ├── highlighters │ │ ├── key_value.rs │ │ ├── number.rs │ │ ├── mod.rs │ │ ├── unix_process.rs │ │ ├── regex.rs │ │ ├── uuid.rs │ │ ├── keyword.rs │ │ ├── ip_v4.rs │ │ ├── pointer.rs │ │ ├── unix_path.rs │ │ ├── ip_v6.rs │ │ ├── json.rs │ │ ├── quote.rs │ │ ├── url.rs │ │ ├── date_time.rs │ │ └── date_dash.rs │ ├── style.rs │ ├── tests │ │ └── escape_code_converter.rs │ └── config.rs ├── io │ ├── mod.rs │ ├── writer │ │ ├── stdout.rs │ │ ├── mod.rs │ │ └── temp_file.rs │ ├── presenter │ │ ├── stdout.rs │ │ ├── mod.rs │ │ └── pager.rs │ ├── reader │ │ ├── mod.rs │ │ ├── stdin.rs │ │ ├── command.rs │ │ ├── file_line_counter.rs │ │ └── linemux.rs │ └── controller │ │ └── mod.rs ├── cli │ ├── styles.rs │ ├── completions.rs │ ├── keywords.rs │ └── mod.rs ├── initial_read.rs ├── highlighter_builder │ ├── mod.rs │ ├── builtins.rs │ └── groups.rs ├── theme │ ├── reader.rs │ ├── mod.rs │ └── mappers.rs ├── lib.rs ├── main.rs └── config │ └── mod.rs ├── assets ├── main.png ├── tailspin.png └── examples │ ├── ip.png │ ├── kv.png │ ├── dates.png │ ├── http.png │ ├── otf.png │ ├── paths.png │ ├── urls.png │ ├── uuids.png │ ├── numbers.png │ ├── quotes.png │ ├── keywords.png │ ├── pointers.png │ └── processes.png ├── rust-toolchain.toml ├── util ├── generate_all.sh ├── generate_man_pages.sh ├── generate_shell_completions.sh └── tspin.adoc ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── BuildAndTest.yml │ ├── ReleaseStaticBinaries.yml │ └── Publish.yml ├── .gitignore ├── config.toml ├── flake.nix ├── Cargo.toml ├── LICENCE ├── completions ├── tspin.fish ├── tspin.zsh └── tspin.bash ├── example-logs ├── example5 ├── example1 └── example4 ├── flake.lock └── man └── tspin.1 /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /tests/files/empty.log: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 120 -------------------------------------------------------------------------------- /src/core/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod normalizer; 2 | pub mod split_and_apply; 3 | -------------------------------------------------------------------------------- /assets/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/main.png -------------------------------------------------------------------------------- /assets/tailspin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/tailspin.png -------------------------------------------------------------------------------- /assets/examples/ip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/examples/ip.png -------------------------------------------------------------------------------- /assets/examples/kv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/examples/kv.png -------------------------------------------------------------------------------- /assets/examples/dates.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/examples/dates.png -------------------------------------------------------------------------------- /assets/examples/http.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/examples/http.png -------------------------------------------------------------------------------- /assets/examples/otf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/examples/otf.png -------------------------------------------------------------------------------- /assets/examples/paths.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/examples/paths.png -------------------------------------------------------------------------------- /assets/examples/urls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/examples/urls.png -------------------------------------------------------------------------------- /assets/examples/uuids.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/examples/uuids.png -------------------------------------------------------------------------------- /src/io/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod controller; 2 | pub mod presenter; 3 | pub mod reader; 4 | pub mod writer; 5 | -------------------------------------------------------------------------------- /assets/examples/numbers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/examples/numbers.png -------------------------------------------------------------------------------- /assets/examples/quotes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/examples/quotes.png -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | components = ["rust-src", "rust-analyzer"] 4 | -------------------------------------------------------------------------------- /assets/examples/keywords.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/examples/keywords.png -------------------------------------------------------------------------------- /assets/examples/pointers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/examples/pointers.png -------------------------------------------------------------------------------- /assets/examples/processes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensadeh/tailspin/HEAD/assets/examples/processes.png -------------------------------------------------------------------------------- /util/generate_all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ./generate_man_pages.sh 6 | ./generate_shell_completions.sh 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.1 linguist-vendored 2 | *.sh linguist-vendored 3 | *.zsh linguist-vendored 4 | *.bash linguist-vendored 5 | *.fish linguist-vendored 6 | *.adoc linguist-vendored 7 | *.nix linguist-vendored 8 | -------------------------------------------------------------------------------- /src/core/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod highlighter; 2 | pub mod style; 3 | 4 | pub mod config; 5 | mod highlighters; 6 | mod utils; 7 | 8 | #[cfg(test)] 9 | mod tests { 10 | pub(crate) mod escape_code_converter; 11 | } 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | groups: 8 | dependencies: 9 | patterns: 10 | - "*" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # MSVC Windows builds of rustc generate these, which store debugging information 10 | *.pdb 11 | 12 | .direnv/ 13 | -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | [pointer] 2 | number = { fg = "green" } 3 | letter = { fg = "blue" } 4 | separator = { fg = "red" } 5 | separator_token = "a" 6 | x = { fg = "red" } 7 | 8 | [[regexps]] 9 | regex = 'Started (.*)\.' 10 | style = { fg = "red" } 11 | 12 | [[regexps]] 13 | regex = 'Stopped .*' 14 | style = { fg = "magenta" } 15 | -------------------------------------------------------------------------------- /util/generate_man_pages.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e # If any command fails, stop the script immediately 4 | 5 | full_version=$(cargo run -- -V) 6 | version_number=$(echo "$full_version" | awk '{print $2}') 7 | 8 | touch tspin.adoc 9 | 10 | asciidoctor -b manpage tspin.adoc \ 11 | --destination=../man/ \ 12 | --attribute release-version="$version_number" -------------------------------------------------------------------------------- /src/cli/styles.rs: -------------------------------------------------------------------------------- 1 | use clap::builder::{Styles, styling}; 2 | use styling::AnsiColor; 3 | 4 | pub const fn get_styles() -> Styles { 5 | Styles::styled() 6 | .header(AnsiColor::Magenta.on_default().bold()) 7 | .usage(AnsiColor::Green.on_default().bold()) 8 | .literal(AnsiColor::Blue.on_default().bold()) 9 | .placeholder(AnsiColor::Yellow.on_default()) 10 | } 11 | -------------------------------------------------------------------------------- /src/io/writer/stdout.rs: -------------------------------------------------------------------------------- 1 | use crate::io::writer::AsyncLineWriter; 2 | use async_trait::async_trait; 3 | use miette::Result; 4 | 5 | pub struct StdoutWriter { 6 | _private: (), 7 | } 8 | 9 | impl StdoutWriter { 10 | pub const fn new() -> StdoutWriter { 11 | StdoutWriter { _private: () } 12 | } 13 | } 14 | 15 | #[async_trait] 16 | impl AsyncLineWriter for StdoutWriter { 17 | async fn write(&mut self, line: &str) -> Result<()> { 18 | println!("{}", line); 19 | 20 | Ok(()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /util/generate_shell_completions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e # If any command fails, stop the script immediately 4 | 5 | # Go to the project directory 6 | cd .. 7 | 8 | # Build your Rust program 9 | cargo build 10 | 11 | # Path to the built binary 12 | spin_path=./target/debug/tspin 13 | 14 | # Generate shell completions 15 | $spin_path --generate-zsh-completions > completions/tspin.zsh 16 | $spin_path --generate-bash-completions > completions/tspin.bash 17 | $spin_path --generate-fish-completions > completions/tspin.fish 18 | -------------------------------------------------------------------------------- /src/io/writer/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod stdout; 2 | pub mod temp_file; 3 | 4 | use crate::io::controller::Writer; 5 | use async_trait::async_trait; 6 | use miette::Result; 7 | 8 | #[async_trait] 9 | pub trait AsyncLineWriter { 10 | async fn write(&mut self, line: &str) -> Result<()>; 11 | } 12 | 13 | #[async_trait] 14 | impl AsyncLineWriter for Writer { 15 | async fn write(&mut self, line: &str) -> Result<()> { 16 | match self { 17 | Writer::TempFile(w) => w.write(line).await, 18 | Writer::Stdout(w) => w.write(line).await, 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 4 | rust-overlay.url = "github:oxalica/rust-overlay"; 5 | }; 6 | 7 | outputs = { nixpkgs, rust-overlay, ... }: 8 | let 9 | system = "x86_64-linux"; 10 | 11 | pkgs = import nixpkgs { 12 | inherit system; 13 | 14 | overlays = [ rust-overlay.overlays.default ]; 15 | }; 16 | 17 | rust-toolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; 18 | in 19 | { 20 | devShells.${system}.default = pkgs.mkShell { 21 | packages = [ rust-toolchain ]; 22 | }; 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/io/presenter/stdout.rs: -------------------------------------------------------------------------------- 1 | use crate::io::presenter::Present; 2 | use miette::Result; 3 | use std::future::pending; 4 | 5 | /// `StdoutPresenter` does not require any special presentation logic because 6 | /// the output is already directly handled by a dedicated stdout writer. 7 | /// Writing to stdout is sufficient, eliminating the need for additional 8 | /// presentation mechanisms. 9 | pub struct StdoutPresenter { 10 | _private: (), 11 | } 12 | 13 | impl StdoutPresenter { 14 | pub const fn new() -> StdoutPresenter { 15 | Self { _private: () } 16 | } 17 | } 18 | 19 | impl Present for StdoutPresenter { 20 | async fn present(&self) -> Result<()> { 21 | pending::<()>().await; 22 | 23 | Ok(()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/BuildAndTest.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: [ push, pull_request ] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | build_and_test: 10 | name: Build and Test 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 1 18 | 19 | - name: Build and test 20 | run: | 21 | cargo build --verbose 22 | cargo test --verbose 23 | cargo clippy --verbose 24 | 25 | - name: Check Cargo.lock 26 | run: | 27 | git diff --exit-code -- Cargo.lock 28 | if [ $? -ne 0 ]; then 29 | echo "Cargo.lock was modified. Please commit the changes." 30 | exit 1 31 | fi -------------------------------------------------------------------------------- /src/io/presenter/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::io::controller::Presenter; 2 | use miette::Result; 3 | 4 | pub mod pager; 5 | pub mod stdout; 6 | 7 | /// Presenters are responsible for displaying output to the user. 8 | /// Different implementations handle output differently—e.g., direct stdout, 9 | /// paging via `less`, or using a custom pager. 10 | /// 11 | /// When `present()` returns, the application terminates. For continuous 12 | /// output scenarios, implementations should ensure they never return. 13 | pub trait Present: Send { 14 | async fn present(&self) -> Result<()>; 15 | } 16 | 17 | impl Present for Presenter { 18 | async fn present(&self) -> Result<()> { 19 | match self { 20 | Presenter::Pager(p) => p.present().await, 21 | Presenter::StdOut(p) => p.present().await, 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/io/reader/mod.rs: -------------------------------------------------------------------------------- 1 | mod buffer_line_counter; 2 | pub mod command; 3 | mod file_line_counter; 4 | pub mod linemux; 5 | pub mod stdin; 6 | 7 | use crate::io::controller::Reader; 8 | use async_trait::async_trait; 9 | use miette::Result; 10 | 11 | #[derive(Debug)] 12 | pub enum StreamEvent { 13 | Started, 14 | Ended, 15 | Line(String), 16 | Lines(Vec), 17 | } 18 | 19 | #[async_trait] 20 | pub trait AsyncLineReader { 21 | async fn next(&mut self) -> Result; 22 | } 23 | 24 | #[async_trait] 25 | impl AsyncLineReader for Reader { 26 | async fn next(&mut self) -> Result { 27 | match self { 28 | Reader::Linemux(r) => r.next().await, 29 | Reader::Stdin(r) => r.next().await, 30 | Reader::Command(r) => r.next().await, 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/cli/completions.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::Arguments; 2 | use clap::{Command, CommandFactory}; 3 | use clap_complete::{Generator, Shell, generate}; 4 | use std::io; 5 | use std::process::exit; 6 | 7 | pub fn generate_shell_completions_and_exit_or_continue(cli: &Arguments) { 8 | let mut cmd = Arguments::command(); 9 | 10 | if cli.generate_bash_completions { 11 | print_completions(Shell::Bash, &mut cmd); 12 | exit(0); 13 | } 14 | 15 | if cli.generate_fish_completions { 16 | print_completions(Shell::Fish, &mut cmd); 17 | exit(0); 18 | } 19 | 20 | if cli.generate_zsh_completions { 21 | print_completions(Shell::Zsh, &mut cmd); 22 | exit(0); 23 | } 24 | } 25 | 26 | fn print_completions(generator: G, cmd: &mut Command) { 27 | generate(generator, cmd, cmd.get_name().to_string(), &mut io::stdout()); 28 | } 29 | -------------------------------------------------------------------------------- /tests/utils.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | use std::path::PathBuf; 3 | use std::process::{Command, Stdio}; 4 | 5 | pub fn build_binary() -> PathBuf { 6 | Command::new("cargo") 7 | .arg("build") 8 | .status() 9 | .expect("Failed to execute cargo build"); 10 | 11 | PathBuf::from("./target/debug/tspin") 12 | } 13 | 14 | pub fn run_binary_with_input(binary_path: PathBuf, input: &str) -> String { 15 | let mut child = Command::new(binary_path) 16 | .stdin(Stdio::piped()) 17 | .stdout(Stdio::piped()) 18 | .spawn() 19 | .expect("Failed to spawn child process"); 20 | 21 | if let Some(stdin) = child.stdin.as_mut() { 22 | stdin.write_all(input.as_bytes()).expect("Failed to write to stdin"); 23 | } 24 | 25 | let output = child.wait_with_output().expect("Failed to read output"); 26 | 27 | String::from_utf8_lossy(&output.stdout).into_owned() 28 | } 29 | -------------------------------------------------------------------------------- /src/cli/keywords.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{Arguments, KeywordColor}; 2 | use tailspin::config::KeywordConfig; 3 | use tailspin::style::{Color, Style}; 4 | 5 | pub fn get_keywords_from_cli(cli: &Arguments) -> Vec { 6 | cli.color_word 7 | .iter() 8 | .flat_map(|(color, words)| { 9 | words.iter().map(move |word| KeywordConfig { 10 | style: Style::new().fg(Color::from(*color)), 11 | words: vec![word.clone()], 12 | }) 13 | }) 14 | .collect() 15 | } 16 | 17 | impl From for Color { 18 | fn from(value: KeywordColor) -> Self { 19 | match value { 20 | KeywordColor::Red => Self::Red, 21 | KeywordColor::Green => Self::Green, 22 | KeywordColor::Yellow => Self::Yellow, 23 | KeywordColor::Blue => Self::Blue, 24 | KeywordColor::Magenta => Self::Magenta, 25 | KeywordColor::Cyan => Self::Cyan, 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/io/writer/temp_file.rs: -------------------------------------------------------------------------------- 1 | use crate::io::writer::AsyncLineWriter; 2 | use async_trait::async_trait; 3 | use miette::{Context, IntoDiagnostic, Result}; 4 | use tokio::fs::File; 5 | use tokio::io::{AsyncWriteExt, BufWriter}; 6 | 7 | pub struct TempFile { 8 | writer: BufWriter, 9 | } 10 | 11 | impl TempFile { 12 | pub async fn new(writer: BufWriter) -> Self { 13 | TempFile { writer } 14 | } 15 | } 16 | 17 | #[async_trait] 18 | impl AsyncLineWriter for TempFile { 19 | async fn write(&mut self, line: &str) -> Result<()> { 20 | let line_with_newline = format!("{}\n", line); 21 | 22 | self.writer 23 | .write_all(line_with_newline.as_bytes()) 24 | .await 25 | .into_diagnostic() 26 | .wrap_err("Failed to write line to file")?; 27 | 28 | self.writer 29 | .flush() 30 | .await 31 | .into_diagnostic() 32 | .wrap_err("Error flushing temp file")?; 33 | 34 | Ok(()) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/ReleaseStaticBinaries.yml: -------------------------------------------------------------------------------- 1 | name: Release static binaries 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | release-binaries: 12 | name: Release ${{ matrix.os }} 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | include: 17 | - os: ubuntu-latest 18 | target: x86_64-unknown-linux-musl 19 | - os: ubuntu-latest 20 | target: aarch64-unknown-linux-musl 21 | - os: macos-latest 22 | target: x86_64-apple-darwin 23 | - os: macos-latest 24 | target: aarch64-apple-darwin 25 | - os: windows-latest 26 | target: x86_64-pc-windows-msvc 27 | 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: taiki-e/upload-rust-binary-action@v1 31 | with: 32 | bin: tspin 33 | target: ${{ matrix.target }} 34 | archive: tailspin-$target 35 | token: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tailspin" 3 | version = "5.6.0" 4 | edition = "2024" 5 | authors = ["Ben Sadeh"] 6 | description = "A log file highlighter" 7 | repository = "https://github.com/bensadeh/tailspin" 8 | keywords = ["log", "syntax-highlighting", "tail", "less"] 9 | license = "MIT" 10 | rust-version = "1.85" 11 | 12 | [[bin]] 13 | path = "src/main.rs" 14 | name = "tspin" 15 | 16 | [dependencies] 17 | aho-corasick = "1.1.4" 18 | async-trait = "0.1.89" 19 | clap = { version = "4.5.53", features = ["derive", "env", "wrap_help"] } 20 | clap_complete = "4.5.62" 21 | ctrlc = "3.5.1" 22 | linemux = "0.3.0" 23 | memchr = "2.7.6" 24 | miette = { version = "7.6.0", features = ["fancy"] } 25 | nu-ansi-term = "0.50.3" 26 | rayon = "1.11.0" 27 | regex = "1.12.2" 28 | serde = { version = "1.0.228", features = ["derive"] } 29 | serde_json = { version = "1.0.145", features = ["preserve_order"] } 30 | shell-words = "1.1.1" 31 | shellexpand = "3.1.1" 32 | tempfile = "3.23.0" 33 | thiserror = "2.0.17" 34 | tokio = { version = "1.48.0", features = ["full"] } 35 | toml = "0.9.10" 36 | uuid = { version = "1.19.0", features = ["v4"] } 37 | winsplit = "0.1" -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ben Sadeh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/io/reader/stdin.rs: -------------------------------------------------------------------------------- 1 | use crate::io::reader::buffer_line_counter::{BUFF_READER_CAPACITY, ReadResult, read_lines}; 2 | use crate::io::reader::{AsyncLineReader, StreamEvent}; 3 | use async_trait::async_trait; 4 | use miette::Result; 5 | use tokio::io::{BufReader, Stdin, stdin}; 6 | 7 | pub struct StdinReader { 8 | reader: BufReader, 9 | stream_started: bool, 10 | } 11 | 12 | impl StdinReader { 13 | pub fn new() -> StdinReader { 14 | let reader = BufReader::with_capacity(BUFF_READER_CAPACITY, stdin()); 15 | let stream_started = false; 16 | 17 | StdinReader { reader, stream_started } 18 | } 19 | } 20 | 21 | #[async_trait] 22 | impl AsyncLineReader for StdinReader { 23 | async fn next(&mut self) -> Result { 24 | if !self.stream_started { 25 | self.stream_started = true; 26 | 27 | return Ok(StreamEvent::Started); 28 | } 29 | 30 | match read_lines(&mut self.reader).await? { 31 | ReadResult::Eof => Ok(StreamEvent::Ended), 32 | ReadResult::Line(line) => Ok(StreamEvent::Line(line)), 33 | ReadResult::Batch(lines) => Ok(StreamEvent::Lines(lines)), 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /completions/tspin.fish: -------------------------------------------------------------------------------- 1 | complete -c tspin -l config-path -d 'Provide a custom path to a configuration file' -r -F 2 | complete -c tspin -s e -l exec -d 'Run command and view the output in a pager' -r 3 | complete -c tspin -l highlight -d 'Highlights in the form color:word1,word2' -r 4 | complete -c tspin -l enable -d 'Enable specific highlighters' -r -f -a "numbers\t'' 5 | urls\t'' 6 | pointers\t'' 7 | dates\t'' 8 | paths\t'' 9 | quotes\t'' 10 | key-value-pairs\t'' 11 | uuids\t'' 12 | ip-addresses\t'' 13 | processes\t'' 14 | json\t''" 15 | complete -c tspin -l disable -d 'Disable specific highlighters' -r -f -a "numbers\t'' 16 | urls\t'' 17 | pointers\t'' 18 | dates\t'' 19 | paths\t'' 20 | quotes\t'' 21 | key-value-pairs\t'' 22 | uuids\t'' 23 | ip-addresses\t'' 24 | processes\t'' 25 | json\t''" 26 | complete -c tspin -l pager -d 'Override the default pager command used by tspin. (e.g. `--pager="ov -f [FILE]"`)' -r 27 | complete -c tspin -s f -l follow -d 'Follow the contents of a file' 28 | complete -c tspin -s p -l print -d 'Print the output to stdout' 29 | complete -c tspin -l disable-builtin-keywords -d 'Disable the highlighting of all builtin keyword groups (booleans, nulls, log severities and common REST verbs)' 30 | complete -c tspin -l generate-bash-completions -d 'Print bash completions to stdout' 31 | complete -c tspin -l generate-fish-completions -d 'Print fish completions to stdout' 32 | complete -c tspin -l generate-zsh-completions -d 'Print zsh completions to stdout' 33 | complete -c tspin -s h -l help -d 'Print help (see more with \'--help\')' 34 | complete -c tspin -s V -l version -d 'Print version' 35 | -------------------------------------------------------------------------------- /src/io/reader/command.rs: -------------------------------------------------------------------------------- 1 | use crate::io::reader::buffer_line_counter::{BUFF_READER_CAPACITY, ReadResult, read_lines}; 2 | use crate::io::reader::{AsyncLineReader, StreamEvent}; 3 | use async_trait::async_trait; 4 | use miette::{Context, IntoDiagnostic, Result, miette}; 5 | use std::process::Stdio; 6 | use tokio::io::BufReader; 7 | use tokio::process::{ChildStdout, Command}; 8 | 9 | pub struct CommandReader { 10 | reader: BufReader, 11 | ready: bool, 12 | } 13 | 14 | impl CommandReader { 15 | pub async fn new(command: String) -> Result { 16 | let trap_command = format!("trap '' INT; {}", command); 17 | 18 | let child = Command::new("sh") 19 | .arg("-c") 20 | .arg(trap_command) 21 | .stdout(Stdio::piped()) 22 | .spawn() 23 | .into_diagnostic() 24 | .wrap_err("Could not spawn process")?; 25 | 26 | let stdout = child 27 | .stdout 28 | .ok_or_else(|| miette!("Could not capture stdout of spawned process"))?; 29 | 30 | let reader = BufReader::with_capacity(BUFF_READER_CAPACITY, stdout); 31 | 32 | Ok(CommandReader { reader, ready: false }) 33 | } 34 | } 35 | 36 | #[async_trait] 37 | impl AsyncLineReader for CommandReader { 38 | async fn next(&mut self) -> Result { 39 | if !self.ready { 40 | self.ready = !self.ready; 41 | 42 | return Ok(StreamEvent::Started); 43 | } 44 | 45 | read_lines(&mut self.reader).await.map(|res| match res { 46 | ReadResult::Eof => StreamEvent::Ended, 47 | ReadResult::Line(line) => StreamEvent::Line(line), 48 | ReadResult::Batch(lines) => StreamEvent::Lines(lines), 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/initial_read.rs: -------------------------------------------------------------------------------- 1 | use miette::{Diagnostic, Result}; 2 | use thiserror::Error; 3 | use tokio::sync::oneshot; 4 | 5 | pub fn initial_read_complete_channel() -> (InitialReadCompleteSender, InitialReadCompleteReceiver) { 6 | let (tx, rx) = oneshot::channel(); 7 | (InitialReadCompleteSender::new(tx), InitialReadCompleteReceiver::new(rx)) 8 | } 9 | 10 | #[derive(Debug)] 11 | pub struct InitialReadCompleteReceiver(oneshot::Receiver<()>); 12 | 13 | #[derive(Debug, Diagnostic, Error)] 14 | #[error("Failed to receive initial-read-complete signal")] 15 | #[diagnostic(help("Ensure the IO task completes correctly and signals initial read completion."))] 16 | pub struct InitialReadCompleteRecvError(#[source] oneshot::error::RecvError); 17 | 18 | impl InitialReadCompleteReceiver { 19 | pub const fn new(receiver: oneshot::Receiver<()>) -> Self { 20 | InitialReadCompleteReceiver(receiver) 21 | } 22 | 23 | pub async fn receive(self) -> Result<()> { 24 | self.0.await.map_err(InitialReadCompleteRecvError)?; 25 | 26 | Ok(()) 27 | } 28 | } 29 | 30 | #[derive(Debug)] 31 | pub struct InitialReadCompleteSender(Option>); 32 | 33 | #[derive(Debug, Diagnostic, Error)] 34 | #[error("Failed to send initial-read-complete signal")] 35 | #[diagnostic(help("The receiver was dropped early. Ensure it remains alive until initial read completes."))] 36 | pub struct InitialReadCompleteSendError; 37 | 38 | impl InitialReadCompleteSender { 39 | pub const fn new(sender: oneshot::Sender<()>) -> Self { 40 | InitialReadCompleteSender(Some(sender)) 41 | } 42 | 43 | pub fn send(&mut self) -> Result<()> { 44 | match self.0.take() { 45 | Some(sender) => Ok(sender.send(()).map_err(|_| InitialReadCompleteSendError)?), 46 | None => Ok(()), 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/highlighter_builder/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod builtins; 2 | pub mod groups; 3 | 4 | use crate::highlighter_builder::groups::HighlighterGroups; 5 | use crate::theme::Theme; 6 | use tailspin::config::KeywordConfig; 7 | use tailspin::*; 8 | 9 | pub fn get_highlighter( 10 | highlighter_groups: HighlighterGroups, 11 | theme: Theme, 12 | keywords: Vec, 13 | ) -> Result { 14 | let mut builder = Highlighter::builder(); 15 | 16 | if highlighter_groups.json { 17 | builder.with_json_highlighter(theme.json); 18 | } 19 | 20 | for regex in theme.regexes { 21 | builder.with_regex_highlighter(regex); 22 | } 23 | 24 | if highlighter_groups.dates { 25 | builder.with_date_time_highlighters(theme.dates); 26 | } 27 | 28 | if highlighter_groups.ip_addresses { 29 | builder.with_ip_v6_highlighter(theme.ip_v6_addresses); 30 | builder.with_ip_v4_highlighter(theme.ip_v4_addresses); 31 | } 32 | 33 | if highlighter_groups.urls { 34 | builder.with_url_highlighter(theme.urls); 35 | } 36 | 37 | if highlighter_groups.paths { 38 | builder.with_unix_path_highlighter(theme.paths); 39 | } 40 | 41 | if highlighter_groups.key_value_pairs { 42 | builder.with_key_value_highlighter(theme.key_value_pairs); 43 | } 44 | 45 | if highlighter_groups.uuids { 46 | builder.with_uuid_highlighter(theme.uuids); 47 | } 48 | 49 | if highlighter_groups.pointers { 50 | builder.with_pointer_highlighter(theme.pointers); 51 | } 52 | 53 | if highlighter_groups.processes { 54 | builder.with_unix_process_highlighter(theme.processes); 55 | } 56 | 57 | if highlighter_groups.numbers { 58 | builder.with_number_highlighter(theme.numbers); 59 | } 60 | 61 | builder.with_keyword_highlighter(keywords); 62 | 63 | if highlighter_groups.quotes { 64 | builder.with_quote_highlighter(theme.quotes); 65 | } 66 | 67 | builder.build() 68 | } 69 | -------------------------------------------------------------------------------- /src/theme/reader.rs: -------------------------------------------------------------------------------- 1 | use crate::theme::{Theme, TomlTheme}; 2 | use miette::Diagnostic; 3 | use std::env; 4 | use std::env::VarError; 5 | use std::fs; 6 | use std::io; 7 | use std::path::{Path, PathBuf}; 8 | use thiserror::Error; 9 | 10 | pub fn parse_theme(custom_config_path: &Option) -> Result { 11 | if let Some(path) = custom_config_path { 12 | let toml_theme = read_and_parse_toml(path)?; 13 | return Ok(Theme::from(toml_theme)); 14 | } 15 | 16 | let default_path = get_config_dir()?.join("tailspin").join("theme.toml"); 17 | 18 | let toml_theme = match read_and_parse_toml(&default_path) { 19 | Ok(theme) => theme, 20 | Err(ThemeError::FileNotFound) => TomlTheme::default(), 21 | Err(e) => return Err(e), 22 | }; 23 | 24 | Ok(Theme::from(toml_theme)) 25 | } 26 | 27 | fn get_config_dir() -> Result { 28 | expand_var_os("XDG_CONFIG_HOME") 29 | .or_else(|| expand_var_os("HOME").map(|home| home.join(".config"))) 30 | .or_else(|| expand_var_os("USERPROFILE")) 31 | .ok_or(ThemeError::HomeEnvironment(VarError::NotPresent)) 32 | } 33 | 34 | fn expand_var_os(key: &str) -> Option { 35 | env::var_os(key) 36 | .and_then(|os_str| os_str.into_string().ok()) 37 | .map(|s| shellexpand::tilde(&s).into_owned().into()) 38 | } 39 | fn read_and_parse_toml(path: &Path) -> Result { 40 | let content = fs::read_to_string(path).map_err(|err| match err.kind() { 41 | io::ErrorKind::NotFound => ThemeError::FileNotFound, 42 | _ => ThemeError::Read(err), 43 | })?; 44 | 45 | toml::from_str::(&content).map_err(ThemeError::Parsing) 46 | } 47 | 48 | #[derive(Debug, Error, Diagnostic)] 49 | pub enum ThemeError { 50 | #[error("could not read the TOML file: {0}")] 51 | Read(#[source] io::Error), 52 | 53 | #[error(transparent)] 54 | Parsing(#[from] toml::de::Error), 55 | 56 | #[error("could not find the TOML file")] 57 | FileNotFound, 58 | 59 | #[error("could not determine the home environment: {0}")] 60 | HomeEnvironment(#[source] VarError), 61 | } 62 | -------------------------------------------------------------------------------- /example-logs/example5: -------------------------------------------------------------------------------- 1 | 2022-08-29 08:10:00 | INFO | MessageMonitor: Outgoing message process already started for thread [4] 2 | 2022-08-29 08:10:00 | INFO | New ProcessId [c66a5398-2aa5-11ec-8631-0242ac110089] for user 123456 3 | 2022-08-29 08:11:00 | TRACE | User 123456 has [0] new messages 4 | 2022-08-29 08:11:00 | TRACE | Syncing tables and updating modules for user 123456 5 | 2022-08-29 08:11:00 | DEBUG | Ping to 192.168.0.1 failed 6 | 2022-08-29 08:11:00 | DEBUG | MessageMonitor: Checking outgoing messages on thread [5] 7 | 2022-08-29 08:11:00 | DEBUG | MessageMonitor: Checking outgoing messages on thread [4] 8 | 2022-08-29 08:11:00 | ERROR | MessageValidator: Could not parse message, input was null 9 | 2022-08-29 08:11:01 | ERROR | Could not generate key, "hasMoreResults" was false 10 | 2022-08-29 08:11:36 | TRACE | No new messages on queue 5 11 | 2022-08-29 08:11:37 | TRACE | No new messages on queue 4 12 | 2022-08-29 08:10:00 | INFO | skip non existing resourceDirectory /var/logs 13 | 2022-08-29 08:10:00 | INFO | Using JarLifecycleMapping lifecycle mapping for Project 14 | 2022-08-29 08:11:00 | INFO | Using "ASCII" encoding to copy filtered resources 15 | 2022-08-29 08:11:00 | INFO | Using "ASCII" encoding to copy filtered properties files 16 | 2022-08-29 08:11:00 | TRACE | User 123456 has [0] new messages 17 | 2022-08-29 08:11:00 | TRACE | Syncing tables and updating modules for user 123456 18 | 2022-08-29 08:11:00 | WARN | ProcessId [c66a5398-2aa5-11ec-8631-0242ac110089] finished early 19 | 2022-08-29 08:11:36 | WARN | Fetching contact points for user 123456, but no contact points found 20 | 2022-08-29 08:11:36 | WARN | POST to https://www.google.com/search?field=value failed 21 | 2022-08-29 08:11:00 | DEBUG | Using "ASCII" encoding to copy filtered resources 22 | 2022-08-29 08:11:01 | DEBUG | Using "ASCII" encoding to copy filtered properties files 23 | 2022-08-29 08:11:00 | INFO | MessageMonitor: Outgoing message process already started for thread [6] 24 | 2022-08-29 08:11:36 | INFO | MessageMonitor: Outgoing message process already started for thread [7] 25 | 2022-08-29 08:11:36 | INFO | Copying 1 resource 26 | 2022-08-29 08:11:37 | INFO | Copying 1 resource -------------------------------------------------------------------------------- /completions/tspin.zsh: -------------------------------------------------------------------------------- 1 | #compdef tspin 2 | 3 | autoload -U is-at-least 4 | 5 | _tspin() { 6 | typeset -A opt_args 7 | typeset -a _arguments_options 8 | local ret=1 9 | 10 | if is-at-least 5.2; then 11 | _arguments_options=(-s -S -C) 12 | else 13 | _arguments_options=(-s -C) 14 | fi 15 | 16 | local context curcontext="$curcontext" state line 17 | _arguments "${_arguments_options[@]}" : \ 18 | '--config-path=[Provide a custom path to a configuration file]:CONFIG_PATH:_files' \ 19 | '-e+[Run command and view the output in a pager]:EXEC:_default' \ 20 | '--exec=[Run command and view the output in a pager]:EXEC:_default' \ 21 | '*--highlight=[Highlights in the form color\:word1,word2]:COLOR_WORD:_default' \ 22 | '*--enable=[Enable specific highlighters]:ENABLED_HIGHLIGHTERS:(numbers urls pointers dates paths quotes key-value-pairs uuids ip-addresses processes json)' \ 23 | '*--disable=[Disable specific highlighters]:DISABLED_HIGHLIGHTERS:(numbers urls pointers dates paths quotes key-value-pairs uuids ip-addresses processes json)' \ 24 | '--pager=[Override the default pager command used by tspin. (e.g. \`--pager="ov -f \[FILE\]"\`)]:PAGER:_default' \ 25 | '-f[Follow the contents of a file]' \ 26 | '--follow[Follow the contents of a file]' \ 27 | '-p[Print the output to stdout]' \ 28 | '--print[Print the output to stdout]' \ 29 | '--disable-builtin-keywords[Disable the highlighting of all builtin keyword groups (booleans, nulls, log severities and common REST verbs)]' \ 30 | '--generate-bash-completions[Print bash completions to stdout]' \ 31 | '--generate-fish-completions[Print fish completions to stdout]' \ 32 | '--generate-zsh-completions[Print zsh completions to stdout]' \ 33 | '-h[Print help (see more with '\''--help'\'')]' \ 34 | '--help[Print help (see more with '\''--help'\'')]' \ 35 | '-V[Print version]' \ 36 | '--version[Print version]' \ 37 | '::FILE -- Filepath:_files' \ 38 | && ret=0 39 | } 40 | 41 | (( $+functions[_tspin_commands] )) || 42 | _tspin_commands() { 43 | local commands; commands=() 44 | _describe -t commands 'tspin commands' commands "$@" 45 | } 46 | 47 | if [ "$funcstack[1]" = "_tspin" ]; then 48 | _tspin "$@" 49 | else 50 | compdef _tspin tspin 51 | fi 52 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //!

2 | //! 3 | //! tailspin logo 4 | //! 5 | //!

6 | //! 7 | //! # 8 | //! 9 | //! `tailspin` is a log file highlighter. This crate exposes the [`Highlighter`] type, 10 | //! allowing you to programmatically apply the same pattern-driven highlighting used by the CLI. 11 | //! 12 | //! In order to configure the highlighter, use the [`HighlighterBuilder`]. Otherwise, use 13 | //! [`Highlighter::default()`](crate::Highlighter::default) for reasonable defaults. 14 | //! 15 | //! 16 | //! ## Example 17 | //! 18 | //! ```rust 19 | //! use tailspin::config::*; 20 | //! use tailspin::Highlighter; 21 | //! use tailspin::style::{Color, Style}; 22 | //! 23 | //! let mut builder = Highlighter::builder(); 24 | //! 25 | //! builder 26 | //! .with_number_highlighter(NumberConfig { 27 | //! style: Style { 28 | //! fg: Some(Color::Cyan), 29 | //! ..Style::default() 30 | //! }, 31 | //! }) 32 | //! .with_quote_highlighter(QuotesConfig { 33 | //! quotes_token: '"', 34 | //! style: Style { 35 | //! fg: Some(Color::Yellow), 36 | //! ..Style::default() 37 | //! }, 38 | //! }) 39 | //! .with_uuid_highlighter(UuidConfig::default()); 40 | //! 41 | //! // Using the highlight builder can fail if the regexes inside don't compile 42 | //! let highlighter = match builder.build() { 43 | //! Ok(h) => h, 44 | //! Err(_) => panic!("Failed to build highlighter"), 45 | //! }; 46 | //! 47 | //! let input = "Hello 42 world"; 48 | //! let output = highlighter.apply(input); 49 | //! 50 | //! // "\x1b[36m" = ANSI cyan start, "\x1b[0m" = reset 51 | //! assert_eq!(output, "Hello \x1b[36m42\x1b[0m world"); 52 | //! ``` 53 | 54 | mod core; 55 | 56 | pub use core::highlighter::{Error, Highlighter, HighlighterBuilder}; 57 | 58 | /// Configuration support for custom highlighting themes and regex rules. 59 | pub mod config { 60 | pub use super::core::config::*; 61 | } 62 | 63 | /// ANSI style and color definitions for highlighted output. 64 | pub mod style { 65 | pub use super::core::style::{Color, Style}; 66 | } 67 | -------------------------------------------------------------------------------- /src/highlighter_builder/builtins.rs: -------------------------------------------------------------------------------- 1 | use tailspin::config::KeywordConfig; 2 | use tailspin::style::{Color, Style}; 3 | 4 | pub fn get_builtin_keywords(disable_builtin_keywords: bool) -> Vec { 5 | match disable_builtin_keywords { 6 | true => vec![], 7 | false => builtin_keywords(), 8 | } 9 | } 10 | 11 | fn builtin_keywords() -> Vec { 12 | let severity_levels = vec![ 13 | KeywordConfig { 14 | words: vec!["ERROR".to_string()], 15 | style: Style::new().fg(Color::Red), 16 | }, 17 | KeywordConfig { 18 | words: vec!["WARN".to_string(), "WARNING".to_string()], 19 | style: Style::new().fg(Color::Yellow), 20 | }, 21 | KeywordConfig { 22 | words: vec!["INFO".to_string()], 23 | style: Style::new().fg(Color::White), 24 | }, 25 | KeywordConfig { 26 | words: vec!["SUCCESS".to_string(), "DEBUG".to_string()], 27 | style: Style::new().fg(Color::Green), 28 | }, 29 | KeywordConfig { 30 | words: vec!["TRACE".to_string()], 31 | style: Style::new().faint(), 32 | }, 33 | ]; 34 | 35 | let rest_keywords = vec![ 36 | KeywordConfig { 37 | words: vec!["GET".to_string()], 38 | style: Style::new().fg(Color::Black).on(Color::Green), 39 | }, 40 | KeywordConfig { 41 | words: vec!["POST".to_string()], 42 | style: Style::new().fg(Color::Black).on(Color::Yellow), 43 | }, 44 | KeywordConfig { 45 | words: vec!["PUT".to_string(), "PATCH".to_string()], 46 | style: Style::new().fg(Color::Black).on(Color::Magenta), 47 | }, 48 | KeywordConfig { 49 | words: vec!["DELETE".to_string()], 50 | style: Style::new().fg(Color::Black).on(Color::Red), 51 | }, 52 | ]; 53 | 54 | let booleans = [KeywordConfig { 55 | words: vec!["null".to_string(), "true".to_string(), "false".to_string()], 56 | style: Style::new().fg(Color::Red).italic(), 57 | }]; 58 | 59 | vec![] 60 | .into_iter() 61 | .chain(severity_levels) 62 | .chain(rest_keywords) 63 | .chain(booleans) 64 | .collect() 65 | } 66 | -------------------------------------------------------------------------------- /src/core/highlighters/key_value.rs: -------------------------------------------------------------------------------- 1 | use crate::core::config::KeyValueConfig; 2 | use crate::core::highlighter::Highlight; 3 | use nu_ansi_term::Style as NuStyle; 4 | use regex::{Captures, Error, Regex, RegexBuilder}; 5 | use std::borrow::Cow; 6 | 7 | pub struct KeyValueHighlighter { 8 | regex: Regex, 9 | key: NuStyle, 10 | separator: NuStyle, 11 | } 12 | 13 | impl KeyValueHighlighter { 14 | pub fn new(config: KeyValueConfig) -> Result { 15 | let pattern = r"(?P(^)|\s)(?P\w+\b)(?P=)"; 16 | let regex = RegexBuilder::new(pattern).unicode(false).build()?; 17 | 18 | Ok(Self { 19 | regex, 20 | key: config.key.into(), 21 | separator: config.separator.into(), 22 | }) 23 | } 24 | } 25 | 26 | impl Highlight for KeyValueHighlighter { 27 | fn apply<'a>(&self, input: &'a str) -> Cow<'a, str> { 28 | self.regex.replace_all(input, |captures: &Captures| { 29 | let space_or_start = captures.name("space_or_start").map(|s| s.as_str()).unwrap_or_default(); 30 | let key = captures 31 | .name("key") 32 | .map(|k| format!("{}", self.key.paint(k.as_str()))) 33 | .unwrap_or_default(); 34 | let equals_sign = captures 35 | .name("equals") 36 | .map(|e| format!("{}", self.separator.paint(e.as_str()))) 37 | .unwrap_or_default(); 38 | 39 | format!("{}{}{}", space_or_start, key, equals_sign) 40 | }) 41 | } 42 | } 43 | 44 | #[cfg(test)] 45 | mod tests { 46 | use super::*; 47 | use crate::core::tests::escape_code_converter::ConvertEscapeCodes; 48 | use crate::style::{Color, Style}; 49 | 50 | #[test] 51 | fn test_number_highlighter() { 52 | let highlighter = KeyValueHighlighter::new(KeyValueConfig { 53 | key: Style::new().fg(Color::Red), 54 | separator: Style::new().fg(Color::Yellow), 55 | }) 56 | .unwrap(); 57 | 58 | let cases = vec![ 59 | ("Entry key=value", "Entry [red]key[reset][yellow]=[reset]value"), 60 | ("No numbers here!", "No numbers here!"), 61 | ]; 62 | 63 | for (input, expected) in cases { 64 | let actual = highlighter.apply(input); 65 | assert_eq!(expected, actual.to_string().convert_escape_codes()); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/integration_tests.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use tailspin::config::*; 3 | use tailspin::style::{Color, Style}; 4 | use tailspin::*; 5 | 6 | mod utils; 7 | 8 | #[test] 9 | fn test_binary_with_various_inputs() { 10 | let binary_path = utils::build_binary(); 11 | 12 | let test_cases = [ 13 | ("Hello null", "Hello \u{1b}[3;31mnull\u{1b}[0m"), 14 | ("Hello world", "Hello world"), 15 | ("", ""), 16 | ]; 17 | 18 | for (input, expected_output) in test_cases { 19 | let output = utils::run_binary_with_input(binary_path.clone(), input); 20 | assert_eq!(output.trim(), expected_output, "Failed on input: {}", input); 21 | } 22 | } 23 | 24 | #[test] 25 | fn default_constructor_should_not_panic() { 26 | let result = std::panic::catch_unwind(Highlighter::default); 27 | 28 | assert!(result.is_ok(), "Default constructor should never fail"); 29 | } 30 | 31 | #[test] 32 | fn no_highlights_should_return_reference_to_the_input_str() { 33 | let highlighter = Highlighter::default(); 34 | let input = "Nothing will be highlighted in this string"; 35 | 36 | let output = highlighter.apply(input); 37 | 38 | match output { 39 | Cow::Borrowed(s) => { 40 | assert!( 41 | std::ptr::eq(s, input), 42 | "Expected borrowed reference to equal input reference" 43 | ); 44 | } 45 | Cow::Owned(_) => panic!("Expected a borrowed reference, got owned"), 46 | } 47 | } 48 | 49 | #[test] 50 | fn it_works() { 51 | let mut builder = Highlighter::builder(); 52 | 53 | builder 54 | .with_number_highlighter(NumberConfig { 55 | style: Style { 56 | fg: Some(Color::Cyan), 57 | ..Style::default() 58 | }, 59 | }) 60 | .with_quote_highlighter(QuotesConfig { 61 | quotes_token: '"', 62 | style: Style { 63 | fg: Some(Color::Yellow), 64 | ..Style::default() 65 | }, 66 | }) 67 | .with_uuid_highlighter(UuidConfig::default()); 68 | 69 | let highlighter = match builder.build() { 70 | Ok(h) => h, 71 | Err(_) => panic!("Failed to build highlighter"), 72 | }; 73 | 74 | let actual = highlighter.apply("Hello 123 world! "); 75 | let expected = "Hello \u{1b}[36m123\u{1b}[0m world! ".to_string(); 76 | 77 | assert_eq!(actual, expected); 78 | } 79 | -------------------------------------------------------------------------------- /util/tspin.adoc: -------------------------------------------------------------------------------- 1 | = tspin(1) 2 | :doctype: manpage 3 | :manmanual: tailspin 4 | :man source: tailspin {release-version} 5 | :revdate: {docdate} 6 | 7 | ifdef::env-github[] 8 | :toc: 9 | :toc-title: 10 | :toc-placement!: 11 | :numbered: 12 | endif::[] 13 | 14 | == NAME 15 | 16 | tspin - A log file highlighter 17 | 18 | == SYNOPSIS 19 | 20 | *tspin* [_OPTION_]... [_FILE_]... 21 | 22 | == DESCRIPTION 23 | 24 | tailspin is a command line tool that highlights log files. 25 | 26 | == OPTIONS 27 | 28 | _-f, --follow_:: 29 | Follow (tail) the contents of the file. 30 | Always true when using the _--exec_ flag. 31 | 32 | _-p, --print_:: 33 | Print the output to stdout instead of viewing the contents in the pager _less_. 34 | Always true if using stdin. 35 | 36 | _--config-path_ *CONFIG_PATH*:: 37 | Specify the path to a custom configuration file. 38 | Defaults to *XDG_CONFIG_HOME/tailspin/theme.toml* or *~/.config/tailspin/theme.toml* if not set. 39 | 40 | _-e, --exec_ *COMMAND*:: 41 | Run command and view the output in a pager. 42 | The command traps the interrupt signal to allow for cancelling and resuming follow mode while inside _less_. 43 | 44 | + 45 | .Example: 46 | ---- 47 | tspin --listen-command 'kubectl logs -f pod_name' 48 | ---- 49 | 50 | _--highlight_ *COLOR1*:__word1,word2,...__ *COLOR2*:__word3,word4,...__:: 51 | Highlight the provided comma-separated words in the specified color. 52 | Possible colors are red, green, yellow, blue, magenta, and cyan. 53 | 54 | + 55 | .Example: 56 | ---- 57 | tspin logfile.txt --highlight red:error,fail --highlight green:success,ok 58 | ---- 59 | 60 | _--enable=[HIGHLIGHT_GROUP]_:: 61 | Disable all highlighting groups except the ones specified. 62 | Comma separated list of groups. 63 | Cannot be used with *--disable=[HIGHLIGHT_GROUP]*. 64 | 65 | _--disable=[HIGHLIGHT_GROUP]_:: 66 | Disable the specified highlighting groups. 67 | Comma separated list of groups. 68 | Cannot be used with *--enable=[HIGHLIGHT_GROUP]*. 69 | 70 | _--disable-builtin-keywords_:: 71 | Disables the highlighting of all builtin keyword groups (booleans, severity and REST). 72 | 73 | == ENVIRONMENT VARIABLES 74 | 75 | *TAILSPIN_PAGER*:: 76 | Set the _TAILSPIN_PAGER_ environment variable to override the default pager. 77 | The command must include the string _[FILE]_ which will be replaced with the file path internally. 78 | For example, _"ov -f [FILE]"_. 79 | 80 | == SEE ALSO 81 | 82 | *less*(1), *tail*(1) 83 | 84 | == About 85 | 86 | Ben Sadeh (github.com/bensadeh/tailspin) 87 | 88 | Released under the MIT License 89 | -------------------------------------------------------------------------------- /src/core/highlighters/number.rs: -------------------------------------------------------------------------------- 1 | use crate::core::config::NumberConfig; 2 | use crate::core::highlighter::Highlight; 3 | use nu_ansi_term::Style as NuStyle; 4 | use regex::{Error, Regex, RegexBuilder}; 5 | use std::borrow::Cow; 6 | use std::fmt::Write as _; 7 | 8 | pub struct NumberHighlighter { 9 | regex: Regex, 10 | style: NuStyle, 11 | } 12 | 13 | impl NumberHighlighter { 14 | pub fn new(config: NumberConfig) -> Result { 15 | let pattern = r"(?x) 16 | \b # start of number 17 | \d+ # integer part 18 | (?:\.\d+)? # optional fractional 19 | \b # end of number 20 | "; 21 | 22 | let regex = RegexBuilder::new(pattern).unicode(false).build()?; 23 | 24 | Ok(Self { 25 | regex, 26 | style: config.style.into(), 27 | }) 28 | } 29 | } 30 | 31 | impl Highlight for NumberHighlighter { 32 | fn apply<'a>(&self, input: &'a str) -> Cow<'a, str> { 33 | let mut it = self.regex.find_iter(input).peekable(); 34 | if it.peek().is_none() { 35 | return Cow::Borrowed(input); 36 | } 37 | 38 | let mut out = String::with_capacity(input.len() + 32); 39 | let mut last = 0usize; 40 | 41 | for m in self.regex.find_iter(input) { 42 | out.push_str(&input[last..m.start()]); 43 | let _ = write!(out, "{}", self.style.paint(&input[m.start()..m.end()])); 44 | last = m.end(); 45 | } 46 | out.push_str(&input[last..]); 47 | 48 | Cow::Owned(out) 49 | } 50 | } 51 | 52 | #[cfg(test)] 53 | mod tests { 54 | use super::*; 55 | use crate::core::tests::escape_code_converter::ConvertEscapeCodes; 56 | use crate::style::{Color, Style}; 57 | 58 | #[test] 59 | fn test_number_highlighter() { 60 | let highlighter = NumberHighlighter::new(NumberConfig { 61 | style: Style::new().fg(Color::Red), 62 | }) 63 | .unwrap(); 64 | 65 | let cases = vec![ 66 | ( 67 | "The fox jumps over 13 dogs. The number 42.5 is here.", 68 | "The fox jumps over [red]13[reset] dogs. The number [red]42.5[reset] is here.", 69 | ), 70 | ( 71 | "There are 1001 nights in the tale.", 72 | "There are [red]1001[reset] nights in the tale.", 73 | ), 74 | ("No numbers here!", "No numbers here!"), 75 | ]; 76 | 77 | for (input, expected) in cases { 78 | let actual = highlighter.apply(input); 79 | assert_eq!(expected, actual.to_string().convert_escape_codes()); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /completions/tspin.bash: -------------------------------------------------------------------------------- 1 | _tspin() { 2 | local i cur prev opts cmd 3 | COMPREPLY=() 4 | if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then 5 | cur="$2" 6 | else 7 | cur="${COMP_WORDS[COMP_CWORD]}" 8 | fi 9 | prev="$3" 10 | cmd="" 11 | opts="" 12 | 13 | for i in "${COMP_WORDS[@]:0:COMP_CWORD}" 14 | do 15 | case "${cmd},${i}" in 16 | ",$1") 17 | cmd="tspin" 18 | ;; 19 | *) 20 | ;; 21 | esac 22 | done 23 | 24 | case "${cmd}" in 25 | tspin) 26 | opts="-f -p -e -h -V --follow --print --config-path --exec --highlight --enable --disable --disable-builtin-keywords --pager --generate-bash-completions --generate-fish-completions --generate-zsh-completions --help --version [FILE]" 27 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then 28 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 29 | return 0 30 | fi 31 | case "${prev}" in 32 | --config-path) 33 | COMPREPLY=($(compgen -f "${cur}")) 34 | return 0 35 | ;; 36 | --exec) 37 | COMPREPLY=($(compgen -f "${cur}")) 38 | return 0 39 | ;; 40 | -e) 41 | COMPREPLY=($(compgen -f "${cur}")) 42 | return 0 43 | ;; 44 | --highlight) 45 | COMPREPLY=($(compgen -f "${cur}")) 46 | return 0 47 | ;; 48 | --enable) 49 | COMPREPLY=($(compgen -W "numbers urls pointers dates paths quotes key-value-pairs uuids ip-addresses processes json" -- "${cur}")) 50 | return 0 51 | ;; 52 | --disable) 53 | COMPREPLY=($(compgen -W "numbers urls pointers dates paths quotes key-value-pairs uuids ip-addresses processes json" -- "${cur}")) 54 | return 0 55 | ;; 56 | --pager) 57 | COMPREPLY=($(compgen -f "${cur}")) 58 | return 0 59 | ;; 60 | *) 61 | COMPREPLY=() 62 | ;; 63 | esac 64 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 65 | return 0 66 | ;; 67 | esac 68 | } 69 | 70 | if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then 71 | complete -F _tspin -o nosort -o bashdefault -o default tspin 72 | else 73 | complete -F _tspin -o bashdefault -o default tspin 74 | fi 75 | -------------------------------------------------------------------------------- /src/io/presenter/pager.rs: -------------------------------------------------------------------------------- 1 | use crate::io::presenter::Present; 2 | use miette::{Diagnostic, Result}; 3 | use std::path::{Path, PathBuf}; 4 | use std::process::Command; 5 | use thiserror::Error; 6 | 7 | pub struct Pager { 8 | path: PathBuf, 9 | pager_options: PagerOptions, 10 | } 11 | 12 | pub enum PagerOptions { 13 | Less(LessPagerOptions), 14 | Custom(CustomPagerOptions), 15 | } 16 | 17 | pub struct LessPagerOptions { 18 | pub follow: bool, 19 | } 20 | 21 | pub struct CustomPagerOptions { 22 | pub command: String, 23 | pub args: Vec, 24 | } 25 | 26 | #[derive(Debug, Error, Diagnostic)] 27 | pub enum PagerError { 28 | #[error(transparent)] 29 | CtrlCError(#[from] ctrlc::Error), 30 | 31 | #[error("Could not run pager")] 32 | #[diagnostic(code(less::command_spawn))] 33 | CommandSpawn(#[source] std::io::Error), 34 | 35 | #[error("Pager exited with non-zero status: {0}")] 36 | #[diagnostic(code(less::non_zero_exit))] 37 | NonZeroExit(std::process::ExitStatus), 38 | } 39 | 40 | impl Pager { 41 | pub const fn new(path: PathBuf, pager_options: PagerOptions) -> Self { 42 | Self { path, pager_options } 43 | } 44 | } 45 | 46 | impl Present for Pager { 47 | async fn present(&self) -> Result<()> { 48 | ctrlc::set_handler(|| {}).map_err(PagerError::CtrlCError)?; 49 | 50 | let mut command = match &self.pager_options { 51 | PagerOptions::Less(less) => get_less_pager_command(less.follow, &self.path), 52 | PagerOptions::Custom(custom) => { 53 | get_custom_pager_command(custom.command.clone(), custom.args.clone(), &self.path) 54 | } 55 | }; 56 | 57 | let status = command.status().map_err(PagerError::CommandSpawn)?; 58 | 59 | status.success().then_some(()).ok_or(PagerError::NonZeroExit(status))?; 60 | 61 | Ok(()) 62 | } 63 | } 64 | 65 | fn get_less_pager_command(follow: bool, path: &PathBuf) -> Command { 66 | let mut args = vec![ 67 | "--ignore-case".to_string(), 68 | "--RAW-CONTROL-CHARS".to_string(), 69 | "--".to_string(), // End of option arguments 70 | ]; 71 | 72 | if follow { 73 | args.insert(0, "+F".to_string()); 74 | } 75 | 76 | let mut cmd = Command::new("less"); 77 | 78 | cmd.env("LESSSECURE", "1").args(&args).arg(path); 79 | 80 | cmd 81 | } 82 | 83 | fn get_custom_pager_command(command: String, args: Vec, path: &Path) -> Command { 84 | let replaced_args = replace_file_placeholder(args, path.to_str().unwrap()); 85 | 86 | let mut cmd = Command::new(command); 87 | 88 | cmd.args(replaced_args); 89 | 90 | cmd 91 | } 92 | 93 | fn replace_file_placeholder(args: Vec, path: &str) -> Vec { 94 | args.into_iter().map(|arg| arg.replace("[FILE]", path)).collect() 95 | } 96 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1681202837, 9 | "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "cfacdce06f30d2b68473a46042957675eebb3401", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1701253981, 24 | "narHash": "sha256-ztaDIyZ7HrTAfEEUt9AtTDNoCYxUdSd6NrRHaYOIxtk=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "e92039b55bcd58469325ded85d4f58dd5a4eaf58", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs_2": { 38 | "locked": { 39 | "lastModified": 1681358109, 40 | "narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=", 41 | "owner": "NixOS", 42 | "repo": "nixpkgs", 43 | "rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "NixOS", 48 | "ref": "nixpkgs-unstable", 49 | "repo": "nixpkgs", 50 | "type": "github" 51 | } 52 | }, 53 | "root": { 54 | "inputs": { 55 | "nixpkgs": "nixpkgs", 56 | "rust-overlay": "rust-overlay" 57 | } 58 | }, 59 | "rust-overlay": { 60 | "inputs": { 61 | "flake-utils": "flake-utils", 62 | "nixpkgs": "nixpkgs_2" 63 | }, 64 | "locked": { 65 | "lastModified": 1701483183, 66 | "narHash": "sha256-MDH3oUajqTaYClCiq1QK7jWVMtMFDJWxVBCFAnkt6J4=", 67 | "owner": "oxalica", 68 | "repo": "rust-overlay", 69 | "rev": "47fe4578cb64a365f400e682a70e054657c42fa5", 70 | "type": "github" 71 | }, 72 | "original": { 73 | "owner": "oxalica", 74 | "repo": "rust-overlay", 75 | "type": "github" 76 | } 77 | }, 78 | "systems": { 79 | "locked": { 80 | "lastModified": 1681028828, 81 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 82 | "owner": "nix-systems", 83 | "repo": "default", 84 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 85 | "type": "github" 86 | }, 87 | "original": { 88 | "owner": "nix-systems", 89 | "repo": "default", 90 | "type": "github" 91 | } 92 | } 93 | }, 94 | "root": "root", 95 | "version": 7 96 | } 97 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | 3 | use crate::initial_read::InitialReadCompleteSender; 4 | use crate::io::controller::{Reader, Writer}; 5 | use io::controller::initialize_io; 6 | use io::presenter::Present; 7 | use io::reader::{AsyncLineReader, StreamEvent}; 8 | use io::writer::AsyncLineWriter; 9 | use miette::{IntoDiagnostic, Result}; 10 | use rayon::iter::ParallelIterator; 11 | use rayon::prelude::IntoParallelRefIterator; 12 | use tailspin::Highlighter; 13 | use tokio::task::JoinHandle; 14 | 15 | mod cli; 16 | mod config; 17 | mod highlighter_builder; 18 | mod initial_read; 19 | mod io; 20 | mod theme; 21 | 22 | #[tokio::main] 23 | async fn main() -> Result<()> { 24 | let (reader, writer, presenter, highlighter, initial_read_complete_tx, initial_read_complete_rx, _temp_dir) = 25 | initialize_io().await?; 26 | 27 | let mut process_stream_task = tokio::spawn(process_stream(reader, writer, highlighter, initial_read_complete_tx)); 28 | 29 | initial_read_complete_rx.receive().await?; 30 | 31 | let mut presenter_task = tokio::spawn(async move { presenter.present().await }); 32 | 33 | tokio::select! { 34 | presenter_result = &mut presenter_task => { 35 | abort_and_drain(&mut process_stream_task).await; 36 | presenter_result.into_diagnostic()??; 37 | }, 38 | process_stream_result = &mut process_stream_task => { 39 | abort_and_drain(&mut presenter_task).await; 40 | process_stream_result.into_diagnostic()??; 41 | }, 42 | } 43 | 44 | Ok(()) 45 | } 46 | 47 | async fn abort_and_drain(handle: &mut JoinHandle) { 48 | handle.abort(); 49 | let _drain = handle.await; 50 | } 51 | 52 | async fn process_stream( 53 | mut reader: Reader, 54 | mut writer: Writer, 55 | highlighter: Highlighter, 56 | mut initial_read_complete: InitialReadCompleteSender, 57 | ) -> Result<()> { 58 | loop { 59 | match reader.next().await? { 60 | StreamEvent::Started => initial_read_complete.send()?, 61 | StreamEvent::Ended => return Ok(()), 62 | StreamEvent::Line(line) => write_line(&mut writer, &highlighter, line.as_str()).await?, 63 | StreamEvent::Lines(lines) => write_lines(&mut writer, &highlighter, lines).await?, 64 | } 65 | } 66 | } 67 | 68 | async fn write_line(writer: &mut Writer, highlighter: &Highlighter, line: &str) -> Result<()> { 69 | let highlighted = highlighter.apply(line); 70 | 71 | writer.write(&highlighted).await?; 72 | 73 | Ok(()) 74 | } 75 | 76 | async fn write_lines(writer: &mut Writer, highlighter: &Highlighter, lines: Vec) -> Result<()> { 77 | let highlighted = lines 78 | .par_iter() 79 | .map(|line| highlighter.apply(line.as_str())) 80 | .collect::>() 81 | .join("\n"); 82 | 83 | writer.write(&highlighted).await?; 84 | 85 | Ok(()) 86 | } 87 | -------------------------------------------------------------------------------- /src/core/highlighters/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::core::highlighter::Highlight; 2 | use crate::core::highlighters::date_dash::DateDashHighlighter; 3 | use crate::core::highlighters::date_time::TimeHighlighter; 4 | use crate::core::highlighters::ip_v4::IpV4Highlighter; 5 | use crate::core::highlighters::ip_v6::IpV6Highlighter; 6 | use crate::core::highlighters::json::JsonHighlighter; 7 | use crate::core::highlighters::key_value::KeyValueHighlighter; 8 | use crate::core::highlighters::keyword::KeywordHighlighter; 9 | use crate::core::highlighters::number::NumberHighlighter; 10 | use crate::core::highlighters::pointer::PointerHighlighter; 11 | use crate::core::highlighters::quote::QuoteHighlighter; 12 | use crate::core::highlighters::regex::RegexpHighlighter; 13 | use crate::core::highlighters::unix_path::UnixPathHighlighter; 14 | use crate::core::highlighters::unix_process::UnixProcessHighlighter; 15 | use crate::core::highlighters::url::UrlHighlighter; 16 | use crate::core::highlighters::uuid::UuidHighlighter; 17 | use std::borrow::Cow; 18 | 19 | pub mod date_dash; 20 | pub mod date_time; 21 | pub mod ip_v4; 22 | pub mod ip_v6; 23 | pub mod json; 24 | pub mod key_value; 25 | pub mod keyword; 26 | pub mod number; 27 | pub mod pointer; 28 | pub mod quote; 29 | pub mod regex; 30 | pub mod unix_path; 31 | pub mod unix_process; 32 | pub mod url; 33 | pub mod uuid; 34 | 35 | pub enum StaticHighlight { 36 | DateDash(DateDashHighlighter), 37 | Time(TimeHighlighter), 38 | IpV4(IpV4Highlighter), 39 | IpV6(IpV6Highlighter), 40 | Json(JsonHighlighter), 41 | KeyValue(KeyValueHighlighter), 42 | Keyword(KeywordHighlighter), 43 | Number(NumberHighlighter), 44 | Pointer(PointerHighlighter), 45 | Quote(QuoteHighlighter), 46 | Regexp(RegexpHighlighter), 47 | UnixPath(UnixPathHighlighter), 48 | UnixProcess(UnixProcessHighlighter), 49 | Url(UrlHighlighter), 50 | Uuid(UuidHighlighter), 51 | } 52 | 53 | impl Highlight for StaticHighlight { 54 | fn apply<'a>(&self, input: &'a str) -> Cow<'a, str> { 55 | match self { 56 | StaticHighlight::DateDash(h) => h.apply(input), 57 | StaticHighlight::Time(h) => h.apply(input), 58 | StaticHighlight::IpV4(h) => h.apply(input), 59 | StaticHighlight::IpV6(h) => h.apply(input), 60 | StaticHighlight::Json(h) => h.apply(input), 61 | StaticHighlight::KeyValue(h) => h.apply(input), 62 | StaticHighlight::Keyword(h) => h.apply(input), 63 | StaticHighlight::Number(h) => h.apply(input), 64 | StaticHighlight::Pointer(h) => h.apply(input), 65 | StaticHighlight::Quote(h) => h.apply(input), 66 | StaticHighlight::Regexp(h) => h.apply(input), 67 | StaticHighlight::UnixPath(h) => h.apply(input), 68 | StaticHighlight::UnixProcess(h) => h.apply(input), 69 | StaticHighlight::Url(h) => h.apply(input), 70 | StaticHighlight::Uuid(h) => h.apply(input), 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/core/highlighters/unix_process.rs: -------------------------------------------------------------------------------- 1 | use crate::core::config::UnixProcessConfig; 2 | use crate::core::highlighter::Highlight; 3 | use nu_ansi_term::Style as NuStyle; 4 | use regex::{Error, Regex, RegexBuilder}; 5 | use std::borrow::Cow; 6 | 7 | pub struct UnixProcessHighlighter { 8 | regex: Regex, 9 | name: NuStyle, 10 | id: NuStyle, 11 | bracket: NuStyle, 12 | } 13 | 14 | impl UnixProcessHighlighter { 15 | pub fn new(config: UnixProcessConfig) -> Result { 16 | let pattern = r"(?P\([A-Za-z0-9._ +:/-]+\)|[A-Za-z0-9_/-]+)\[(?P\d+)]"; 17 | let regex = RegexBuilder::new(pattern).unicode(false).build()?; 18 | 19 | Ok(Self { 20 | regex, 21 | name: config.name.into(), 22 | id: config.id.into(), 23 | bracket: config.bracket.into(), 24 | }) 25 | } 26 | } 27 | 28 | impl Highlight for UnixProcessHighlighter { 29 | fn apply<'a>(&self, input: &'a str) -> Cow<'a, str> { 30 | self.regex.replace_all(input, |captures: ®ex::Captures| { 31 | let process_name = captures 32 | .name("process_name") 33 | .map(|p| format!("{}", self.name.paint(p.as_str()))) 34 | .unwrap_or_default(); 35 | let process_num = captures 36 | .name("process_id") 37 | .map(|n| format!("{}", self.id.paint(n.as_str()))) 38 | .unwrap_or_default(); 39 | 40 | format!( 41 | "{}{}{}{}", 42 | process_name, 43 | self.bracket.paint("["), 44 | process_num, 45 | self.bracket.paint("]") 46 | ) 47 | }) 48 | } 49 | } 50 | 51 | #[cfg(test)] 52 | mod tests { 53 | use super::*; 54 | use crate::core::tests::escape_code_converter::ConvertEscapeCodes; 55 | use crate::style::{Color, Style}; 56 | 57 | #[test] 58 | fn test_unix_process_highlighter() { 59 | let highlighter = UnixProcessHighlighter::new(UnixProcessConfig { 60 | name: Style::new().fg(Color::Magenta), 61 | id: Style::new().fg(Color::Green), 62 | bracket: Style::new().fg(Color::Blue), 63 | }) 64 | .unwrap(); 65 | 66 | let cases = vec![ 67 | ( 68 | "process[1]", 69 | "[magenta]process[reset][blue][[reset][green]1[reset][blue]][reset]", 70 | ), 71 | ( 72 | "postfix/postscreen[1894]: CONNECT from [192.168.1.22]:12345 to [127.0.0.1]:25", 73 | "[magenta]postfix/postscreen[reset][blue][[reset][green]1894[reset][blue]][reset]: CONNECT from [192.168.1.22]:12345 to [127.0.0.1]:25", 74 | ), 75 | ("No process here!", "No process here!"), 76 | ]; 77 | 78 | for (input, expected) in cases { 79 | let actual = highlighter.apply(input); 80 | assert_eq!(expected, actual.to_string().convert_escape_codes()); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/io/reader/file_line_counter.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::{BufReader, Read}; 3 | use std::path::Path; 4 | 5 | use memchr::memchr_iter; 6 | use miette::{Context, IntoDiagnostic, Result}; 7 | 8 | pub const BUFF_CAPACITY: usize = 64 * 1024; 9 | 10 | pub fn count_lines>(file_path: P) -> Result { 11 | let file = File::open(&file_path) 12 | .into_diagnostic() 13 | .wrap_err_with(|| format!("Could not open file: {:?}", file_path.as_ref()))?; 14 | 15 | let mut reader = BufReader::new(file); 16 | let mut count = 0usize; 17 | 18 | let mut buffer = [0u8; BUFF_CAPACITY]; 19 | loop { 20 | let bytes_read = reader 21 | .read(&mut buffer) 22 | .into_diagnostic() 23 | .wrap_err_with(|| format!("Error reading file: {:?}", file_path.as_ref()))?; 24 | 25 | if bytes_read == 0 { 26 | // EOF reached 27 | break; 28 | } 29 | 30 | count += memchr_iter(b'\n', &buffer[..bytes_read]).count(); 31 | } 32 | 33 | Ok(count) 34 | } 35 | 36 | #[cfg(test)] 37 | mod tests { 38 | use super::*; 39 | use std::fs::File; 40 | use std::io::Write; 41 | use tempfile::tempdir; 42 | 43 | #[test] 44 | fn test_count_lines_basic() { 45 | let dir = tempdir().unwrap(); 46 | let file_path = dir.path().join("test-file.txt"); 47 | 48 | let mut file = File::create(&file_path).unwrap(); 49 | writeln!(file, "Hello").unwrap(); 50 | writeln!(file, "World").unwrap(); 51 | writeln!(file, "Rust!").unwrap(); 52 | 53 | let result = count_lines(&file_path); 54 | 55 | assert!(result.is_ok()); 56 | assert_eq!(result.unwrap(), 3); 57 | } 58 | 59 | #[test] 60 | fn test_count_lines_empty_file() { 61 | let dir = tempdir().unwrap(); 62 | let file_path = dir.path().join("empty.txt"); 63 | 64 | File::create(&file_path).unwrap(); 65 | 66 | let result = count_lines(&file_path); 67 | assert!(result.is_ok()); 68 | assert_eq!(result.unwrap(), 0); 69 | } 70 | 71 | #[test] 72 | fn test_count_lines_no_newline() { 73 | let dir = tempdir().unwrap(); 74 | let file_path = dir.path().join("no-newline.txt"); 75 | 76 | let mut file = File::create(&file_path).unwrap(); 77 | write!(file, "No newline at the end").unwrap(); 78 | 79 | let result = count_lines(&file_path); 80 | 81 | assert!(result.is_ok()); 82 | assert_eq!(result.unwrap(), 0, "Should have 0 newlines"); 83 | } 84 | 85 | #[test] 86 | fn test_count_lines_large_file() { 87 | let dir = tempdir().unwrap(); 88 | let file_path = dir.path().join("large.txt"); 89 | 90 | let mut file = File::create(&file_path).unwrap(); 91 | for i in 0..10000 { 92 | writeln!(file, "Line {}", i).unwrap(); 93 | } 94 | 95 | let result = count_lines(&file_path); 96 | assert!(result.is_ok()); 97 | 98 | assert_eq!(result.unwrap(), 10000); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/core/highlighters/regex.rs: -------------------------------------------------------------------------------- 1 | use crate::core::config::RegexConfig; 2 | use crate::core::highlighter::Highlight; 3 | use nu_ansi_term::Style as NuStyle; 4 | use regex::{Error, Regex}; 5 | use std::borrow::Cow; 6 | 7 | pub struct RegexpHighlighter { 8 | regex: Regex, 9 | style: NuStyle, 10 | } 11 | 12 | impl RegexpHighlighter { 13 | /// This constructor takes a regular expression pattern and a `Style` object, 14 | /// returning a `RegexpHighlighter` that will apply the specified style 15 | /// to any text matching the regular expression. 16 | /// 17 | /// It supports one capture group `()`. When found, it will apply the style to the captured text. 18 | /// 19 | /// (If you are just interested in highlighting a specific keyword, you can use the simpler `KeywordHighlighter` 20 | /// instead.) 21 | /// # Example 22 | /// 23 | /// Given the regular expression pattern `'Started (.*)\.'`, the highlighter will 24 | /// apply the style to any text that matches the pattern within the capture group. 25 | /// For example, in the text `'Started process.'`, only the word `'process'` will be styled. 26 | /// 27 | pub fn new(config: RegexConfig) -> Result { 28 | Ok(Self { 29 | regex: Regex::new(config.regex.as_str())?, 30 | style: config.style.into(), 31 | }) 32 | } 33 | } 34 | 35 | impl Highlight for RegexpHighlighter { 36 | fn apply<'a>(&self, input: &'a str) -> Cow<'a, str> { 37 | let mut new_string = String::new(); 38 | let mut last_end = 0; 39 | let mut changed = false; 40 | let capture_groups = self.regex.captures_len() - 1; 41 | 42 | for caps in self.regex.captures_iter(input) { 43 | if let Some(entire_match) = caps.get(0) { 44 | changed = true; 45 | // Append text before the match (this is a slice from the original input) 46 | new_string.push_str(&input[last_end..entire_match.start()]); 47 | 48 | match capture_groups { 49 | 1 => { 50 | if let Some(captured) = caps.get(1) { 51 | // Append text from the start of the match until the capture group. 52 | new_string.push_str(&input[entire_match.start()..captured.start()]); 53 | // Append the highlighted capture group. 54 | new_string.push_str(&format!("{}", self.style.paint(captured.as_str()))); 55 | // Append text from after the capture group until the end of the match. 56 | new_string.push_str(&input[captured.end()..entire_match.end()]); 57 | } 58 | } 59 | _ => { 60 | // Highlight the entire match. 61 | new_string.push_str(&format!("{}", self.style.paint(entire_match.as_str()))); 62 | } 63 | } 64 | last_end = entire_match.end(); 65 | } 66 | } 67 | // Append any remaining text after the last match. 68 | new_string.push_str(&input[last_end..]); 69 | 70 | if changed { 71 | Cow::Owned(new_string) 72 | } else { 73 | Cow::Borrowed(input) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /.github/workflows/Publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v3 18 | with: 19 | token: ${{secrets.GH_PAT}} 20 | fetch-depth: 0 21 | 22 | - name: Build and Test 23 | run: | 24 | cargo build --verbose 25 | cargo test --verbose 26 | 27 | - name: Check Cargo.lock 28 | run: | 29 | git diff --exit-code -- Cargo.lock 30 | if [ $? -ne 0 ]; then 31 | echo "Cargo.lock was modified. Please commit the changes." 32 | exit 1 33 | fi 34 | 35 | - name: Publish to crates.io 36 | env: 37 | CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} 38 | run: | 39 | cargo login $CARGO_REGISTRY_TOKEN 40 | cargo publish 41 | 42 | - name: Get release notes 43 | id: release_notes 44 | run: | 45 | TAG_NAME=${{ github.ref_name }} 46 | RELEASE_NOTES=$(awk '/'"^## $TAG_NAME"'/{flag=1;next}/##/{flag=0}flag' CHANGELOG.md) 47 | RELEASE_NOTES="${RELEASE_NOTES//'%'/'%25'}" 48 | RELEASE_NOTES="${RELEASE_NOTES//$'\n'/'%0A'}" 49 | RELEASE_NOTES="${RELEASE_NOTES//$'\r'/'%0D'}" 50 | echo "::set-output name=notes::$RELEASE_NOTES" 51 | 52 | - name: Create GitHub Release 53 | uses: ncipollo/release-action@v1 54 | with: 55 | artifacts: "none" 56 | token: ${{ secrets.GH_PAT }} 57 | tag: ${{ github.ref }} 58 | name: ${{ github.ref_name }} 59 | body: ${{ steps.release_notes.outputs.notes }} 60 | 61 | - name: Bump minor version and update CHANGELOG 62 | run: | 63 | BRANCH_NAME="main" 64 | 65 | # Fetch the branch 66 | git fetch origin $BRANCH_NAME 67 | 68 | # Switch to the branch that triggered the workflow 69 | git checkout "$BRANCH_NAME" 70 | 71 | # Bump minor version and reset patch version in Cargo.toml 72 | VERSION_LINE=$(grep "^version" ./Cargo.toml | head -1) 73 | VERSION=$(echo $VERSION_LINE | grep -oP '\d+\.\d+\.\d+') 74 | MAJOR_VERSION=$(echo $VERSION | awk -F'.' '{print $1}') 75 | MINOR_VERSION=$(echo $VERSION | awk -F'.' '{print $2}') 76 | BUMPED_MINOR_VERSION=$((MINOR_VERSION + 1)) 77 | BUMPED_VERSION="$MAJOR_VERSION.$BUMPED_MINOR_VERSION.0" 78 | BUMPED_VERSION_LINE=$(echo $VERSION_LINE | sed "s/$VERSION/$BUMPED_VERSION/") 79 | sed -i "s/$VERSION_LINE/$BUMPED_VERSION_LINE/" ./Cargo.toml 80 | 81 | # Update cargo.lock 82 | cargo build 83 | 84 | # Add new entry to CHANGELOG.md 85 | NEW_CHANGELOG_ENTRY="## $BUMPED_VERSION\n\n" 86 | sed -i "/^# Changelog/a\\ 87 | $NEW_CHANGELOG_ENTRY\\ 88 | " CHANGELOG.md 89 | 90 | # Commit and push changes 91 | git config user.name '${{ github.actor }}' 92 | git config user.email '${{ github.actor }}@users.noreply.github.com' 93 | git add . 94 | git commit -m "Bump version and update CHANGELOG" 95 | git push -------------------------------------------------------------------------------- /man/tspin.1: -------------------------------------------------------------------------------- 1 | '\" t 2 | .\" Title: tspin 3 | .\" Author: [see the "AUTHOR(S)" section] 4 | .\" Generator: Asciidoctor 2.0.23 5 | .\" Date: 2025-06-08 6 | .\" Manual: tailspin 7 | .\" Source: tailspin 5.4.5 8 | .\" Language: English 9 | .\" 10 | .TH "TSPIN" "1" "2025-06-08" "tailspin 5.4.5" "tailspin" 11 | .ie \n(.g .ds Aq \(aq 12 | .el .ds Aq ' 13 | .ss \n[.ss] 0 14 | .nh 15 | .ad l 16 | .de URL 17 | \fI\\$2\fP <\\$1>\\$3 18 | .. 19 | .als MTO URL 20 | .if \n[.g] \{\ 21 | . mso www.tmac 22 | . am URL 23 | . ad l 24 | . . 25 | . am MTO 26 | . ad l 27 | . . 28 | . LINKSTYLE blue R < > 29 | .\} 30 | .SH "NAME" 31 | tspin \- A log file highlighter 32 | .SH "SYNOPSIS" 33 | .sp 34 | \fBtspin\fP [\fIOPTION\fP]... [\fIFILE\fP]... 35 | .SH "DESCRIPTION" 36 | .sp 37 | tailspin is a command line tool that highlights log files. 38 | .SH "OPTIONS" 39 | .sp 40 | \fI\-f, \-\-follow\fP 41 | .RS 4 42 | Follow (tail) the contents of the file. 43 | Always true when using the \fI\-\-follow\-command\fP flag. 44 | .RE 45 | .sp 46 | \fI\-p, \-\-print\fP 47 | .RS 4 48 | Print the output to stdout instead of viewing the contents in the pager \fIless\fP. 49 | Always true if using stdin. 50 | .RE 51 | .sp 52 | \fI\-\-config\-path\fP \fBCONFIG_PATH\fP 53 | .RS 4 54 | Specify the path to a custom configuration file. 55 | Defaults to \fBXDG_CONFIG_HOME/tailspin/theme.toml\fP or \fB~/.config/tailspin/theme.toml\fP if not set. 56 | .RE 57 | .sp 58 | \fI\-e, \-\-exec\fP \fBCOMMAND\fP 59 | .RS 4 60 | Run command and view the output in a pager. 61 | The command traps the interrupt signal to allow for cancelling and resuming follow mode while inside \fIless\fP. 62 | .sp 63 | .B Example: 64 | .br 65 | .sp 66 | .if n .RS 4 67 | .nf 68 | .fam C 69 | tspin \-\-listen\-command \*(Aqkubectl logs \-f pod_name\*(Aq 70 | .fam 71 | .fi 72 | .if n .RE 73 | .RE 74 | .sp 75 | \fI\-\-highlight\fP \fBCOLOR1\fP:\fIword1,word2,...\fP \fBCOLOR2\fP:\fIword3,word4,...\fP 76 | .RS 4 77 | Highlight the provided comma\-separated words in the specified color. 78 | Possible colors are red, green, yellow, blue, magenta, and cyan. 79 | .sp 80 | .B Example: 81 | .br 82 | .sp 83 | .if n .RS 4 84 | .nf 85 | .fam C 86 | tspin logfile.txt \-\-highlight red:error,fail \-\-highlight green:success,ok 87 | .fam 88 | .fi 89 | .if n .RE 90 | .RE 91 | .sp 92 | \fI\-\-enable=[HIGHLIGHT_GROUP]\fP 93 | .RS 4 94 | Disable all highlighting groups except the ones specified. 95 | Comma separated list of groups. 96 | Cannot be used with \fB\-\-disable=[HIGHLIGHT_GROUP]\fP. 97 | .RE 98 | .sp 99 | \fI\-\-disable=[HIGHLIGHT_GROUP]\fP 100 | .RS 4 101 | Disable the specified highlighting groups. 102 | Comma separated list of groups. 103 | Cannot be used with \fB\-\-enable=[HIGHLIGHT_GROUP]\fP. 104 | .RE 105 | .sp 106 | \fI\-\-disable\-builtin\-keywords\fP 107 | .RS 4 108 | Disables the highlighting of all builtin keyword groups (booleans, severity and REST). 109 | .RE 110 | .SH "ENVIRONMENT VARIABLES" 111 | .sp 112 | \fBTAILSPIN_PAGER\fP 113 | .RS 4 114 | Set the \fITAILSPIN_PAGER\fP environment variable to override the default pager. 115 | The command must include the string \fI[FILE]\fP which will be replaced with the file path internally. 116 | For example, \fI"ov \-f [FILE]"\fP. 117 | .RE 118 | .SH "SEE ALSO" 119 | .sp 120 | \fBless\fP(1), \fBtail\fP(1) 121 | .SH "ABOUT" 122 | .sp 123 | Ben Sadeh (github.com/bensadeh/tailspin) 124 | .sp 125 | Released under the MIT License -------------------------------------------------------------------------------- /src/theme/mod.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use tailspin::config::*; 3 | use tailspin::style::Style; 4 | 5 | mod mappers; 6 | pub mod reader; 7 | 8 | pub struct Theme { 9 | pub keywords: Vec, 10 | pub regexes: Vec, 11 | pub numbers: NumberConfig, 12 | pub uuids: UuidConfig, 13 | pub quotes: QuotesConfig, 14 | pub ip_v4_addresses: IpV4Config, 15 | pub ip_v6_addresses: IpV6Config, 16 | pub dates: DateTimeConfig, 17 | pub paths: UnixPathConfig, 18 | pub urls: UrlConfig, 19 | pub pointers: PointerConfig, 20 | pub processes: UnixProcessConfig, 21 | pub key_value_pairs: KeyValueConfig, 22 | pub json: JsonConfig, 23 | } 24 | 25 | #[derive(Deserialize, Debug, Default)] 26 | pub struct TomlTheme { 27 | pub keywords: Option>, 28 | pub regexes: Option>, 29 | pub numbers: Option, 30 | pub uuids: Option, 31 | pub quotes: Option, 32 | pub ip_addresses: Option, 33 | pub dates: Option, 34 | pub paths: Option, 35 | pub urls: Option, 36 | pub pointers: Option, 37 | pub processes: Option, 38 | pub key_value_pairs: Option, 39 | pub json: Option, 40 | } 41 | 42 | #[derive(Deserialize, Debug)] 43 | pub struct KeywordToml { 44 | pub words: Vec, 45 | pub style: Style, 46 | } 47 | 48 | #[derive(Deserialize, Debug)] 49 | pub struct RegexToml { 50 | pub regex: String, 51 | pub style: Style, 52 | } 53 | 54 | #[derive(Deserialize, Debug)] 55 | pub struct NumberToml { 56 | pub number: Option