use std::fmt::Write as FmtWrite;
use std::io::{self, IsTerminal};
use std::process::ExitCode;
use std::path::PathBuf;
use tess::app::{self, RebuildSpec};
use tess::batch::{self, BatchDestination, BatchSpec};
use tess::cli::Args;
use tess::error::{Error, Result};
use tess::filter::{CompiledFilter, FilterSpec};
use tess::grep::GrepPredicate;
use tess::format;
use tess::line_index::LineIndex;
use tess::prettify::{self, PrettifyMode, ResolvedType};
use tess::source::{find_tail_offset, MockSource, Source, StdinSource, TransformingSource};
use tess::terminal::{install_panic_hook, install_signal_flag, TerminalGuard};
use tess::viewport::Viewport;
use clap::Parser;
const MANUAL_TEXT: &str = include_str!("../MANUAL.md");
use colored::Colorize;
fn examples_section(buf: &mut String, title: &str) {
let _ = writeln!(buf, " {}", title.yellow().bold());
let _ = writeln!(buf);
}
fn examples_example(buf: &mut String, desc: &str, commands: &[&str]) {
let _ = writeln!(buf, " {}", desc.bold());
for cmd in commands {
let _ = writeln!(buf, " {}", cmd.cyan());
}
let _ = writeln!(buf);
}
fn examples_note(buf: &mut String, text: &str) {
let _ = writeln!(buf, " {} {}", "note:".dimmed().bold(), text.dimmed());
let _ = writeln!(buf);
}
fn build_examples_text() -> String {
let mut buf = String::new();
let _ = writeln!(buf);
let _ = writeln!(buf, "{}", "tess — usage examples".bold());
let _ = writeln!(buf);
examples_section(&mut buf, "Plain viewing");
examples_example(&mut buf, "Open a file", &[
"tess Cargo.toml",
"tess -N -S src/main.rs",
"tess --tab-width 4 Makefile",
]);
examples_section(&mut buf, "Piped input");
examples_example(&mut buf, "Pipe output through tess", &[
"git log | tess",
"cargo build 2>&1 | tess",
"ls --color=always | tess",
]);
examples_section(&mut buf, "Big files: --head / --tail");
examples_example(&mut buf, "Cheap views of large files", &[
"tess --head 50 schema.sql",
"tess --tail 1000 huge.log",
"tess -f --tail 1000 huge.log",
]);
examples_section(&mut buf, "Pretty-printing structured files");
examples_example(&mut buf, "Auto-detect from extension or force a content type", &[
"tess --prettify config.json",
"tess --prettify schema.yaml",
"tess --prettify Cargo.toml",
"tess --prettify page.html",
"tess --prettify rows.csv",
"tess --content-type=json data.bin",
]);
examples_note(&mut buf, "Inside the pager: Shift-P toggles, -Pj/y/t/x/h/c/a/r switches type.");
examples_section(&mut buf, "Following live output");
examples_example(&mut buf, "Watch a log file or a file rewritten in place", &[
"tess -f /var/log/syslog",
"tail -F /var/log/access.log | tess -f",
"tess --live src/main.rs",
"tess --live notes.md",
]);
examples_section(&mut buf, "Plain-text grep (no format needed)");
examples_example(&mut buf, "Filter lines by regex — no format required", &[
r"tess --grep error access.log",
r"tess --grep error --grep '^\[' access.log",
]);
examples_section(&mut buf, "Apache log analysis (built-in formats)");
examples_example(&mut buf, "Filter by status code, URL, or combine grep and filter", &[
"tess --format apache-combined --filter status~^5 access.log",
"tess --format apache-combined --filter status~^5 --filter url~^/api/ access.log",
"tess --format apache-combined --filter 'status!=200' access.log",
"tess --format apache-combined --filter 'status>=500' access.log",
"tess --format apache-combined --filter status~^5 --dim access.log",
"tess -f --tail 100 --format apache-combined --filter status~^5 access.log",
"tess --format apache-combined --filter status=500 --grep timeout access.log",
]);
examples_note(&mut buf, "Single-quote filters that use `!` or `<`/`>` — bash's history expansion eats `!`, and `<`/`>` are I/O redirection without quotes.");
examples_section(&mut buf, "Batch (non-interactive) output");
examples_example(&mut buf, "Write filtered output to a file or stdout", &[
"tess --filter status~^5 --format apache-combined -o errors.log access.log",
"tess --head 1000 --stdout huge.log | grep -c something",
"tess --prettify --stdout config.json > pretty.json",
"tess -f --format app --filter level=ERROR -o /tmp/live-errors.log app.log",
]);
examples_section(&mut buf, "Display templates (reformat each line)");
examples_example(&mut buf, "Reformat matched lines with a custom template", &[
"tess --format apache-combined --display '[<status>] <method> <url>' access.log",
"tess --format app --display '[<ts>] <level> <msg>' --filter 'level>=WARN' app.log",
r"tess --format apache-combined --display '<status>: <url>' \",
r" --filter 'status>=500' -o errors.log access.log",
]);
examples_note(&mut buf, "Or set the default per format: add `display = '[<ts>] <level> <msg>'` under `[format.app]` in ~/.config/tess/formats.toml.");
examples_section(&mut buf, "Custom format (declare in ~/.config/tess/formats.toml)");
examples_example(&mut buf, "Define a named format with a regex and optional display template", &[
r"# [format.app]",
r"# regex = '^(?P<ts>\S+ \S+) (?P<level>\w+) \[(?P<reqid>[0-9a-f]+)\] (?P<msg>.*)$'",
"",
"tess --list-formats",
"tess --format app --filter level=ERROR app.log",
"tess --format app --filter 'level~^(ERROR|WARN)$' app.log",
"tess -f --tail 200 --format app --filter level=ERROR app.log",
]);
examples_section(&mut buf, "Groups (shortcut bundles, also in formats.toml)");
examples_example(&mut buf, "Expand --<groupname> into a fixed flag bundle", &[
"# [group.errorlog]",
r#"# format = "app""#,
r#"# file = "/var/log/myapp/app.log""#,
"# follow = true",
"# tail = 1000",
r#"# filter = ["level=ERROR"]"#,
"",
"tess --errorlog",
"tess --errorlog 'msg~timeout'",
"tess --errorlog --tail 50",
]);
examples_section(&mut buf, "Interactive keys (inside tess)");
examples_example(&mut buf, "Search, scroll, and toggle display options", &[
"/ pat <Enter> forward regex search n / N repeat search",
"? pat <Enter> backward regex search g / G top / bottom",
"Space / b page down / up Shift-F toggle follow",
"-N / -S / -F toggle line numbers / chop / follow",
"R force-reload from disk (with --live)",
"q quit",
]);
let _ = writeln!(buf, " {}", "See `tess --manual` for the full reference, or `tess --help` for a flag list.".dimmed());
let _ = writeln!(buf);
buf
}
fn main() -> ExitCode {
install_panic_hook();
match real_main() {
Ok(()) => ExitCode::from(0),
Err(e) => {
eprintln!("{}", e);
ExitCode::from(e.exit_code() as u8)
}
}
}
#[cfg(unix)]
fn redirect_stdin_to_tty() -> std::io::Result<()> {
use std::os::unix::io::AsRawFd;
let tty = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")?;
unsafe {
if libc::dup2(tty.as_raw_fd(), libc::STDIN_FILENO) < 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
}
fn resolve_ansi_mode(args: &Args) -> tess::render::AnsiMode {
use tess::render::AnsiMode;
if args.raw_control_chars {
return AnsiMode::Raw;
}
if args.no_color {
return AnsiMode::Strict;
}
if let Ok(v) = std::env::var("NO_COLOR") {
if !v.is_empty() {
return AnsiMode::Strict;
}
}
if std::env::var("CLICOLOR").as_deref() == Ok("0") {
return AnsiMode::Strict;
}
AnsiMode::Interpret
}
fn page_bytes(label: &str, content: &[u8], ansi_mode: tess::render::AnsiMode) -> Result<()> {
let src = MockSource::new();
src.append(content);
src.finish();
#[cfg(unix)]
if !io::stdin().is_terminal() {
let _ = redirect_stdin_to_tty();
}
let sigterm = install_signal_flag();
let _guard = TerminalGuard::enter(false)
.map_err(|e| Error::Runtime(format!("terminal init: {}", e)))?;
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
let mut viewport = Viewport::new(cols, rows, label.to_string());
viewport.set_ansi_mode(ansi_mode);
let idx = LineIndex::new();
let keymap = tess::keys::KeyMap::load_from_default_path()
.unwrap_or_else(|_| tess::keys::KeyMap::empty());
let file_set = tess::file_set::FileSet::new(vec![std::path::PathBuf::from(label)]);
let stub_args = Args::parse_from(["tess"]);
app::run(Box::new(src), viewport, idx, sigterm, RebuildSpec::default(), keymap, file_set, None, stub_args, None, None)?;
Ok(())
}
fn real_main() -> Result<()> {
let groups = format::load_groups().map_err(Error::Runtime)?;
let argv: Vec<String> = std::env::args().collect();
let argv = format::expand_argv(argv, &groups);
let args = Args::parse_from(argv);
let ansi_mode = resolve_ansi_mode(&args);
if args.manual {
if io::stdout().is_terminal() {
return page_bytes("(manual)", MANUAL_TEXT.as_bytes(), ansi_mode);
}
print!("{}", MANUAL_TEXT);
return Ok(());
}
if args.examples {
let is_tty = io::stdout().is_terminal();
colored::control::set_override(is_tty);
let text = build_examples_text();
if is_tty {
return page_bytes("(examples)", text.as_bytes(), ansi_mode);
}
print!("{}", text);
return Ok(());
}
if args.list_formats {
let formats = format::load_all().map_err(Error::Runtime)?;
format::print_format_list(&formats);
return Ok(());
}
if !args.filter.is_empty() && args.format.is_none() {
return Err(Error::Runtime(
"--filter requires --format".to_string(),
));
}
if args.display.is_some() && args.format.is_none() {
return Err(Error::Runtime(
"--display requires --format".to_string(),
));
}
if args.dim && args.filter.is_empty() && args.grep.is_empty() {
return Err(Error::Runtime(
"--dim has no effect without --filter or --grep".to_string(),
));
}
if args.live && args.files.is_empty() {
return Err(Error::Runtime(
"--live requires a file path (stdin can't be re-stat'd)".to_string(),
));
}
let batch_destination: Option<BatchDestination> = if args.stdout {
Some(BatchDestination::Stdout)
} else if let Some(path) = args.output.as_deref() {
if path == "-" { Some(BatchDestination::Stdout) }
else { Some(BatchDestination::File(PathBuf::from(path))) }
} else {
None
};
if batch_destination.is_some() && args.live {
return Err(Error::Runtime(
"--output / --stdout is not compatible with --live".to_string(),
));
}
let explicit_content_type: Option<PrettifyMode> = match args.content_type.as_deref() {
Some(name) => prettify::parse_content_type(name).map_err(Error::Runtime)?,
None => None,
};
let want_prettify = match explicit_content_type {
Some(PrettifyMode::Off) => false,
Some(_) => true,
None => args.prettify,
};
if want_prettify {
if args.follow {
return Err(Error::Runtime(
"--prettify is not supported with --follow (streaming partial \
documents can't be parsed)".to_string(),
));
}
if args.live {
return Err(Error::Runtime(
"--prettify is not supported with --live".to_string(),
));
}
if !args.filter.is_empty() {
return Err(Error::Runtime(
"--prettify is not supported with --filter".to_string(),
));
}
if !args.grep.is_empty() {
return Err(Error::Runtime(
"--prettify is not supported with --grep".to_string(),
));
}
if args.display.is_some() {
return Err(Error::Runtime(
"--prettify is not supported with --display".to_string(),
));
}
}
let preprocessor: Option<tess::preprocess::Preprocessor> = if args.no_preprocess {
None
} else {
let raw = args.preprocess.clone()
.or_else(|| std::env::var("LESSOPEN").ok());
match raw {
Some(r) => Some(tess::preprocess::Preprocessor::parse(&r)
.map_err(Error::Runtime)?),
None => None,
}
};
let file_set = tess::file_set::FileSet::new(args.files.clone());
let mut consumed_stdin = false;
let mut source_supports_tail = true;
let mut preprocess_failure: Option<String> = None;
let (src, label): (Box<dyn Source>, String) = match args.files.first() {
Some(path) => {
let (s, l, pf) = tess::open::open_source_for_path(path, &args, preprocessor.as_ref())?;
preprocess_failure = pf;
(s, l)
}
None if !io::stdin().is_terminal() => {
let ss = if args.follow {
source_supports_tail = false;
StdinSource::spawn_streaming()
.map_err(|e| Error::Runtime(format!("stdin: {}", e)))?
} else {
StdinSource::read_all()
.map_err(|e| Error::Runtime(format!("stdin: {}", e)))?
};
consumed_stdin = true;
(Box::new(ss), "(stdin)".to_string())
}
None => {
return Err(Error::NoInput);
}
};
let (src, prettify_label): (Box<dyn Source>, Option<String>) = if want_prettify {
let head = src.bytes(0..src.len().min(512)).to_vec();
let path_for_detect = args.files.first().map(|p| p.as_path());
let resolved = prettify::resolve(explicit_content_type, path_for_detect, &head);
match resolved {
ResolvedType::Mode(PrettifyMode::Off) => (src, None),
ResolvedType::Mode(mode) => {
let label = mode.label().to_string();
let wrapped = TransformingSource::wrap(src, mode);
if let Some(err) = wrapped.last_error() {
eprintln!("tess: prettify ({label}) failed: {err} \u{2014} showing raw");
(Box::new(wrapped), Some(format!("{label}:err")))
} else {
(Box::new(wrapped), Some(label))
}
}
ResolvedType::Undetected => {
eprintln!(
"tess: --prettify requested but content type could not be detected; \
showing raw (use --content-type=NAME to override)"
);
(src, None)
}
}
} else {
(src, None)
};
let mut idx = match args.tail {
Some(n) if source_supports_tail => {
let off = find_tail_offset(src.as_ref(), n);
LineIndex::new_starting_at(off)
}
Some(_) => {
eprintln!("tess: --tail is not supported on streaming stdin (-f); ignoring");
LineIndex::new()
}
None => LineIndex::new(),
};
if let Some(n) = args.head {
idx.set_head_cap(n);
}
let record_start_regex: Option<regex::bytes::Regex> = {
let fmt_record_start: Option<String> = if let Some(name) = args.format.as_deref() {
let formats = format::load_all().map_err(Error::Runtime)?;
formats.get(name).and_then(|f| {
f.record_start.as_ref().map(|r| r.as_str().to_string())
})
} else {
None
};
let record_start_pattern: Option<String> = args.record_start
.clone()
.or(fmt_record_start);
if let Some(pat) = record_start_pattern {
let re = regex::bytes::Regex::new(&pat)
.map_err(|e| Error::Runtime(format!("--record-start: {e}")))?;
idx.set_record_start(re.clone());
Some(re)
} else {
None
}
};
#[cfg(unix)]
if consumed_stdin {
let _ = redirect_stdin_to_tty();
}
let compiled_grep = if !args.grep.is_empty() {
Some(
GrepPredicate::compile(&args.grep)
.map_err(Error::Runtime)?,
)
} else {
None
};
let (compiled_filter, display_renderer) = if let Some(name) = args.format.as_deref() {
let formats = format::load_all().map_err(Error::Runtime)?;
let fmt = formats.get(name).ok_or_else(|| {
Error::Runtime(format!(
"unknown format `{name}` (run --list-formats to see available)"
))
})?;
let filter = if !args.filter.is_empty() {
let specs: Vec<FilterSpec> = args.filter.iter()
.map(|s| FilterSpec::parse(s).map_err(Error::Runtime))
.collect::<Result<_>>()?;
Some(CompiledFilter::compile(fmt, specs).map_err(Error::Runtime)?)
} else {
None
};
let template = if let Some(cli_tmpl) = args.display.as_deref() {
Some(
format::DisplayTemplate::compile(cli_tmpl, &fmt.field_names)
.map_err(|e| Error::Runtime(format!("--display: {e}")))?,
)
} else {
fmt.display.clone()
};
let renderer = template.map(|t| format::DisplayRenderer::new(t, fmt.regex.clone()));
(filter, renderer)
} else {
(None, None)
};
let sigterm = install_signal_flag();
if let Some(destination) = batch_destination {
let _ = prettify_label; let spec = BatchSpec {
destination,
follow: args.follow,
poll_interval: std::time::Duration::from_millis(250),
};
return batch::run(src, idx, compiled_filter, compiled_grep, display_renderer, spec, sigterm);
}
let _guard = TerminalGuard::enter(args.mouse)
.map_err(|e| Error::Runtime(format!("terminal init: {}", e)))?;
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
let mut viewport = Viewport::new(cols, rows, label);
if args.line_numbers { viewport.toggle_line_numbers(); }
if args.chop { viewport.toggle_chop(); }
viewport.opts.tab_width = args.tab_width;
viewport.set_follow_mode(args.follow);
viewport.set_live_mode(args.live);
viewport.set_prettify_label(prettify_label);
if let Some(f) = compiled_filter {
viewport.set_filter(Some(f));
}
if let Some(g) = compiled_grep {
viewport.set_grep(Some(g));
}
if args.dim {
viewport.set_dim_mode(true);
}
if let Some(d) = display_renderer {
viewport.set_display(Some(d));
}
if args.hex {
viewport.set_hex_mode(true);
let bpg = tess::hex::hex_chars_to_bytes_per_group(args.hex_group)
.ok_or_else(|| Error::Runtime(format!(
"--hex-group must be one of 2, 4, 8, 16, 32 (got {})",
args.hex_group
)))?;
viewport.set_hex_group_size(bpg);
}
viewport.set_ansi_mode(ansi_mode);
viewport.set_preprocess_failure(preprocess_failure);
{
let fmt_prompt: Option<tess::prompt::ParsedPrompt> = if let Some(name) = args.format.as_deref() {
let formats = format::load_all().map_err(Error::Runtime)?;
formats.get(name).and_then(|f| f.prompt.clone())
} else {
None
};
let prompt_template: Option<tess::prompt::ParsedPrompt> = match args.prompt.as_deref() {
Some(s) => Some(tess::prompt::ParsedPrompt::parse(s)
.map_err(|e| Error::Runtime(format!("--prompt: {e}")))?),
None => fmt_prompt,
};
viewport.set_prompt(prompt_template);
}
viewport.set_format_label(args.format.clone());
viewport.set_file_index(0, file_set.len());
let rebuild_spec = RebuildSpec {
head: args.head,
tail: if source_supports_tail { args.tail } else { None },
};
let keymap = tess::keys::KeyMap::load_from_default_path()
.map_err(Error::Runtime)?;
let tag_file: Option<tess::tags::TagFile> = if let Some(path) = &args.tag_file {
match tess::tags::TagFile::load(path) {
Ok(tf) => Some(tf),
Err(e) => {
eprintln!("tess: {e}");
std::process::exit(1);
}
}
} else {
let start = args
.files
.first()
.map(|p| p.as_path())
.unwrap_or_else(|| std::path::Path::new("."));
if let Some(found) = tess::tags::TagFile::find_walking_up(start) {
tess::tags::TagFile::load(&found).ok()
} else {
None
}
};
if args.tag.is_some() && tag_file.is_none() {
eprintln!("tess: tags file not found (required by -t)");
std::process::exit(1);
}
app::run(src, viewport, idx, sigterm, rebuild_spec, keymap, file_set, record_start_regex, args, preprocessor, tag_file)?;
Ok(())
}