use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use std::path::Path;
pub mod docgen;
pub mod exit;
mod formats;
pub use formats::*;
pub mod keys;
pub mod man;
pub mod settings;
pub mod units;
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
pub enum CompressionFormat {
Gzip,
Zstd,
Bzip2,
Xz,
}
impl CompressionFormat {
pub fn from_extension(path: &Path) -> Option<Self> {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
match ext.to_lowercase().as_str() {
"gz" => Some(Self::Gzip),
"zst" | "zstd" => Some(Self::Zstd),
"bz2" | "bz" => Some(Self::Bzip2),
"xz" => Some(Self::Xz),
_ => None,
}
} else {
None
}
}
pub fn extension(&self) -> &'static str {
match self {
Self::Gzip => "gz",
Self::Zstd => "zst",
Self::Bzip2 => "bz2",
Self::Xz => "xz",
}
}
}
pub const NUMBER_FORMAT_VALUES: &[&str] = &[
"none",
"thousands",
"european",
"si",
"swiss",
"indian",
"underscore",
"system",
];
pub const EXAMPLES_TOML: &str = include_str!("../examples.toml");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExampleTest {
Run,
Network,
Interactive,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Example {
pub command: String,
pub description: String,
pub test: ExampleTest,
pub expect: Option<String>,
pub pages: Vec<String>,
pub files: Vec<ExampleFile>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExampleFile {
pub name: String,
pub text: String,
}
pub fn examples() -> Vec<Example> {
let file: toml::Table = EXAMPLES_TOML.parse().expect("examples.toml is TOML");
let entries = file
.get("example")
.and_then(toml::Value::as_array)
.expect("examples.toml has [[example]] entries");
entries
.iter()
.map(|entry| {
let table = entry.as_table().expect("an [[example]] is a table");
for key in table.keys() {
assert!(
matches!(
key.as_str(),
"command" | "description" | "test" | "expect" | "pages" | "files"
),
"examples.toml: unknown key {key}"
);
}
let text = |key: &str| {
table
.get(key)
.and_then(toml::Value::as_str)
.map(str::to_string)
};
let test = match text("test").as_deref() {
Some("run") => ExampleTest::Run,
Some("network") => ExampleTest::Network,
Some("interactive") => ExampleTest::Interactive,
other => panic!("examples.toml: test = {other:?}"),
};
Example {
command: text("command").expect("an example's command"),
description: text("description").expect("an example's description"),
test,
expect: text("expect"),
pages: match table.get("pages") {
None => vec!["datui.1".to_string()],
Some(pages) => pages
.as_array()
.expect("an example's pages are a list")
.iter()
.map(|p| p.as_str().expect("a page is NAME.SECTION").to_string())
.collect(),
},
files: table
.get("files")
.and_then(toml::Value::as_array)
.map(|files| {
files
.iter()
.map(|f| {
let text = |key: &str| {
f.get(key)
.and_then(toml::Value::as_str)
.unwrap_or_else(|| panic!("an example's file has a {key}"))
.to_string()
};
ExampleFile {
name: text("name"),
text: text("text"),
}
})
.collect()
})
.unwrap_or_default(),
}
})
.collect()
}
pub fn examples_of(page: &str) -> Vec<Example> {
examples()
.into_iter()
.filter(|e| e.pages.iter().any(|p| p == page))
.collect()
}
pub fn examples_help() -> String {
let mut out = String::from("Examples:\n");
for example in examples_of("datui.1") {
out.push_str(&format!(
" {}\n {}\n",
example.command, example.description
));
}
out.push_str("\nDocs: https://derekwisong.github.io/datui/ and `datui man`\n");
out.push_str("Keys: datui man keys\n");
out
}
fn command_examples(command: &str) -> String {
let mut out = String::from("Examples:\n");
for example in examples_of(&format!("datui-{command}.1")) {
out.push_str(&format!(
" {}\n {}\n",
example.command, example.description
));
}
let files: Vec<String> = examples_of(&format!("datui-{command}.1"))
.into_iter()
.flat_map(|e| e.files.into_iter().map(|f| f.name))
.collect();
if !files.is_empty() {
out.push_str(&format!(
"\nThe files they read ({}) are in the manual.",
files.join(", ")
));
}
out.push_str(&format!("\nManual: datui man {command}\n"));
out
}
#[derive(Clone, Parser, Debug)]
#[command(
name = "datui",
version,
about = "Terminal UI for tabular data",
long_about = include_str!("../long_about.txt"),
after_help = examples_help()
)]
pub struct Args {
#[arg(num_args = 0.., value_name = "PATH")]
pub paths: Vec<std::path::PathBuf>,
#[arg(short = 'F', long = "format", value_name = "FMT", value_parser = parse_format, help = format_help(), help_heading = "Open")]
pub format: Option<FormatChoice>,
#[arg(short = 't', long = "table", value_name = "NAME", help = table_help(), help_heading = "Open")]
pub table: Option<String>,
#[arg(long = "hive", action, help_heading = "Open")]
pub hive: bool,
#[arg(
long = "compression",
value_name = "C",
value_enum,
hide_possible_values = true,
help_heading = "Open"
)]
pub compression: Option<CompressionFormat>,
#[arg(long = "dict", value_name = "FILE", help_heading = "Open")]
pub dict: Vec<std::path::PathBuf>,
#[arg(short = 'f', long = "follow", action, help_heading = "Open")]
pub follow: bool,
#[arg(long = "tee", value_name = "FILE", help_heading = "Open")]
pub tee: Option<std::path::PathBuf>,
#[arg(long = "tee-raw", requires = "tee", action, help_heading = "Open")]
pub tee_raw: bool,
#[arg(long = "force", action, requires = "tee", help_heading = "Open")]
pub force: bool,
#[arg(long = "hex", action, help_heading = "Open")]
pub hex: bool,
#[arg(long = "hex-width", value_name = "N", value_parser = clap::value_parser!(u16).range(1..=4096), help_heading = "Open")]
pub hex_width: Option<u16>,
#[arg(long = "view", value_name = "NAME", help_heading = "Open")]
pub view: Option<String>,
#[arg(long = "temp-dir", value_name = "DIR", help = settings::flag_help("temp-dir"), help_heading = "Open")]
pub temp_dir: Option<std::path::PathBuf>,
#[arg(long = "delimiter", value_name = "C", value_parser = parse_delimiter, help_heading = "Delimited text")]
pub delimiter: Option<u8>,
#[arg(long = "no-header", action, help_heading = "Delimited text")]
pub no_header: bool,
#[arg(
long = "header-rows",
value_name = "N[,M...]",
value_delimiter = ',',
value_parser = clap::value_parser!(u64).range(1..),
help_heading = "Delimited text"
)]
pub header_rows: Vec<u64>,
#[arg(
long = "footer-rows",
value_name = "N",
help_heading = "Delimited text"
)]
pub footer_rows: Option<usize>,
#[arg(long = "skip-rows", value_name = "N", help_heading = "Delimited text")]
pub skip_rows: Option<usize>,
#[arg(long = "skip-lines", value_name = "N", help_heading = "Delimited text")]
pub skip_lines: Option<usize>,
#[arg(long = "comment", value_name = "PREFIX", value_parser = parse_comment_char, help = settings::flag_help("comment"), help_heading = "Delimited text")]
pub comment: Option<String>,
#[arg(long = "skip-initial-space", value_name = "BOOL", num_args = 0..=1, require_equals = true, default_missing_value = "true", value_parser = settings::parse_bool, help = settings::flag_help("skip-initial-space"), help_heading = "Delimited text")]
pub skip_initial_space: Option<bool>,
#[arg(long = "null", value_name = "VAL", help = settings::flag_help("null"), help_heading = "Delimited text")]
pub null: Vec<String>,
#[arg(long = "infer-types", value_name = "COLS|off", num_args = 0..=1, require_equals = true, default_missing_value = "all", value_parser = parse_infer_types, help = settings::flag_help("infer-types"), help_heading = "Delimited text")]
pub infer_types: Option<InferTypes>,
#[arg(long = "infer-rows", value_name = "N", help = settings::flag_help("infer-rows"), help_heading = "Delimited text")]
pub infer_rows: Option<usize>,
#[arg(long = "ignore-errors", value_name = "BOOL", num_args = 0..=1, require_equals = true, default_missing_value = "true", value_parser = settings::parse_bool, help = settings::flag_help("ignore-errors"), help_heading = "Delimited text")]
pub ignore_errors: Option<bool>,
#[arg(long = "row-numbers", value_name = "BOOL", num_args = 0..=1, require_equals = true, default_missing_value = "true", value_parser = settings::parse_bool, help = settings::flag_help("row-numbers"), help_heading = "Display")]
pub row_numbers: Option<bool>,
#[arg(long = "number-format", value_name = "F", value_parser = clap::builder::PossibleValuesParser::new(NUMBER_FORMAT_VALUES), hide_possible_values = true, help = settings::flag_help("number-format"), help_heading = "Display")]
pub number_format: Option<String>,
#[arg(long = "mouse", value_name = "BOOL", num_args = 0..=1, require_equals = true, default_missing_value = "true", value_parser = settings::parse_bool, help = settings::flag_help("mouse"), help_heading = "Display")]
pub mouse: Option<bool>,
#[arg(long = "sample-rows", value_name = "N", help = settings::flag_help("sample-rows"), help_heading = "Display")]
pub sample_rows: Option<usize>,
#[arg(
short = 'c',
long = "config",
value_name = "KEY=VALUE",
global = true,
help_heading = "Config"
)]
pub config: Vec<settings::Override>,
#[arg(long = "log-file", value_name = "PATH", help = settings::flag_help("log-file"), help_heading = "Logging")]
pub log_file: Option<std::path::PathBuf>,
#[arg(long = "log-level", value_name = "LEVEL", value_parser = clap::builder::PossibleValuesParser::new(LOG_LEVELS), hide_possible_values = true, help = settings::flag_help("log-level"), help_heading = "Logging")]
pub log_level: Option<String>,
#[command(subcommand)]
pub command: Option<Command>,
}
pub const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace", "off"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InferTypes {
All,
Off,
Columns(Vec<String>),
}
fn parse_infer_types(text: &str) -> Result<InferTypes, String> {
match text.trim() {
"" | "all" | "true" => Ok(InferTypes::All),
"off" | "false" | "none" => Ok(InferTypes::Off),
cols => {
let mut columns: Vec<String> = Vec::new();
for col in cols.split(',').map(str::trim).filter(|c| !c.is_empty()) {
if !columns.iter().any(|c| c == col) {
columns.push(col.to_string());
}
}
Ok(InferTypes::Columns(columns))
}
}
}
pub fn parse_delimiter(text: &str) -> Result<u8, String> {
let byte = match text {
"tab" | "\\t" | "\t" => b'\t',
"space" => b' ',
_ => {
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
u8::from_str_radix(hex, 16)
.map_err(|_| format!("\"{text}\" is not a byte code such as 0x1f"))?
} else {
let mut chars = text.chars();
match (chars.next(), chars.next()) {
(Some(c), None) if c.is_ascii() => c as u8,
_ => {
return Err(format!(
"\"{text}\" is not one ASCII character, tab, \\t, or a code such as 0x1f"
));
}
}
}
}
};
if matches!(byte, b'\n' | b'\r' | b'"') {
return Err(format!("{byte:#04x} cannot separate columns"));
}
Ok(byte)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormatChoice {
Builtin(FileFormat),
Spec(String),
File(std::path::PathBuf),
}
impl FormatChoice {
pub fn builtin(&self) -> Option<FileFormat> {
match self {
Self::Builtin(format) => Some(*format),
Self::Spec(_) | Self::File(_) => None,
}
}
pub fn read_mode(&self, stored: Stored) -> Option<ReadMode> {
match self {
Self::Builtin(format) => format.read_mode(stored),
Self::Spec(_) | Self::File(_) => match stored {
Stored::Plain => Some(ReadMode::Lazy),
Stored::Compressed { .. } => Some(ReadMode::Decompressed),
Stored::Stream => None,
},
}
}
pub fn bucket_object(&self, stored: Stored) -> RemoteRead {
match self {
Self::Builtin(format) => format.bucket_object(stored),
Self::Spec(_) | Self::File(_) => RemoteRead::Downloaded,
}
}
pub fn http_file(&self) -> RemoteRead {
match self {
Self::Builtin(format) => format.http_file(),
Self::Spec(_) | Self::File(_) => RemoteRead::Downloaded,
}
}
pub fn bucket_prefix(&self, stored: Stored) -> Option<RemoteRead> {
match self {
Self::Builtin(format) => format.bucket_prefix(stored),
Self::Spec(_) | Self::File(_) => None,
}
}
pub fn spec(&self) -> Option<&str> {
match self {
Self::Spec(name) => Some(name),
Self::Builtin(_) | Self::File(_) => None,
}
}
pub fn spec_file(&self) -> Option<&std::path::Path> {
match self {
Self::File(path) => Some(path),
Self::Builtin(_) | Self::Spec(_) => None,
}
}
}
fn parse_format(text: &str) -> Result<FormatChoice, String> {
let is_path = text.contains('/')
|| (cfg!(windows) && text.contains('\\'))
|| text.to_ascii_lowercase().ends_with(".toml");
if is_path {
return Ok(FormatChoice::File(std::path::PathBuf::from(text)));
}
if let Some(format) = FileFormat::from_name(&text.to_ascii_lowercase()) {
return Ok(FormatChoice::Builtin(format));
}
let spec_like = text.contains('.')
&& !text.starts_with('.')
&& !text.ends_with('.')
&& text
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
if spec_like {
return Ok(FormatChoice::Spec(text.to_string()));
}
let names: Vec<&str> = FileFormat::ALL.iter().map(|f| f.name()).collect();
let file = if std::path::Path::new(text).is_file() {
format!(" A spec file in this directory needs ./ in front: --format ./{text}.")
} else {
String::new()
};
Err(format!(
"\"{text}\" is not a format: {}, a spec's name (`datui formats` lists them), or a spec file (a path with a / or ending .toml).{file}",
names.join(", ")
))
}
#[derive(Clone, Debug, Subcommand)]
pub enum Command {
#[command(after_help = command_examples("formats"))]
Formats {
#[command(subcommand)]
action: Option<FormatsAction>,
},
#[command(after_help = command_examples("config"))]
Config {
#[command(subcommand)]
action: ConfigAction,
},
#[command(after_help = command_examples("catalog"))]
Catalog {
#[command(subcommand)]
action: CatalogAction,
},
#[command(after_help = command_examples("theme"))]
Theme {
#[command(subcommand)]
action: ThemeAction,
},
#[command(after_help = command_examples("cache"))]
Cache {
#[command(subcommand)]
action: CacheAction,
},
#[command(after_help = command_examples("views"))]
Views {
#[command(subcommand)]
action: ViewsAction,
},
#[command(after_help = command_examples("completions"))]
Completions {
#[arg(value_name = "SHELL")]
shell: clap_complete::Shell,
},
#[command(after_help = command_examples("man"))]
Man {
#[arg(value_name = "PAGE")]
page: Option<String>,
#[arg(long, conflicts_with_all = ["page", "dir"])]
list: bool,
#[arg(long, value_name = "DIR", conflicts_with = "page")]
dir: Option<std::path::PathBuf>,
},
}
#[derive(Clone, Debug, Subcommand)]
pub enum ConfigAction {
Init {
#[arg(long)]
force: bool,
},
Path,
Keys,
}
pub const COMPLETION_FILES: &[(clap_complete::Shell, &str)] = &[
(clap_complete::Shell::Bash, "datui.bash"),
(clap_complete::Shell::Zsh, "_datui"),
(clap_complete::Shell::Fish, "datui.fish"),
(clap_complete::Shell::PowerShell, "_datui.ps1"),
(clap_complete::Shell::Elvish, "datui.elv"),
];
pub fn completions(shell: clap_complete::Shell) -> String {
let mut out = Vec::new();
clap_complete::generate(shell, &mut Args::command(), "datui", &mut out);
String::from_utf8_lossy(&out).into_owned()
}
#[derive(Clone, Debug, Subcommand)]
pub enum CatalogAction {
Show {
#[arg(value_name = "NAME")]
name: Option<String>,
},
Check {
#[arg(value_name = "FILE")]
file: std::path::PathBuf,
},
}
#[derive(Clone, Debug, Subcommand)]
pub enum ThemeAction {
List,
Show {
#[arg(value_name = "NAME")]
name: String,
},
}
#[derive(Clone, Debug, Subcommand)]
pub enum CacheAction {
Clear {
#[arg(long)]
recents: bool,
},
}
#[derive(Clone, Debug, Subcommand)]
pub enum ViewsAction {
List,
Rm {
#[arg(value_name = "NAME")]
name: String,
},
Clear,
}
#[derive(Clone, Debug, Subcommand)]
pub enum FormatsAction {
Check {
#[arg(value_name = "SPEC")]
spec: String,
#[arg(value_name = "FILE")]
file: Option<std::path::PathBuf>,
},
}
pub fn check_comment_char(c: &str) -> Result<(), String> {
if c.is_empty() {
return Err("must not be empty".into());
}
if c.contains(['\n', '\r']) {
return Err("must not contain a line break".into());
}
Ok(())
}
fn parse_comment_char(text: &str) -> Result<String, String> {
check_comment_char(text).map(|()| text.to_string())
}
fn escape_table_cell(s: &str) -> String {
s.replace('|', "\\|").replace(['\n', '\r'], " ")
}
fn option_label(arg: &clap::Arg) -> String {
let names = |arg: &clap::Arg| -> String {
arg.get_value_names()
.map(|names| {
names
.iter()
.map(|n| format!("<{}>", n.as_str()))
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default()
};
if arg.is_positional() {
return if arg.is_required_set() {
names(arg)
} else {
format!("[{}]...", names(arg))
};
}
let mut parts = Vec::new();
if let Some(s) = arg.get_short() {
parts.push(format!("-{s}"));
}
if let Some(l) = arg.get_long() {
parts.push(format!("--{l}"));
}
let op = parts.join(", ");
let value = if arg.get_action().takes_values() {
names(arg)
} else {
String::new()
};
if value.is_empty() {
op
} else if arg.get_num_args().is_some_and(|n| n.min_values() == 0) {
format!("{op}[={value}]")
} else {
format!("{op} {value}")
}
}
pub fn render_options_markdown() -> String {
let mut cmd = Args::command();
cmd.build();
let mut out = String::from(
"# Command-line options\n\n\
<!-- Generated from crates/datui-cli by `gen_docs`. Do not edit. -->\n\n\
`datui --help` prints these; `datui COMMAND --help` a command's own.\n\n```text\n",
);
out.push_str(&cmd.render_usage().to_string());
out.push_str("\n```\n");
let mut groups: Vec<Option<String>> = vec![None];
for arg in cmd.get_arguments() {
let heading = arg.get_help_heading().map(str::to_string);
if !groups.contains(&heading) {
groups.push(heading);
}
}
for group in groups {
let args: Vec<&clap::Arg> = cmd
.get_arguments()
.filter(|a| a.get_help_heading().map(str::to_string) == group)
.filter(|a| !a.is_hide_set())
.filter(|a| !matches!(a.get_id().as_str(), "help" | "version"))
.collect();
if args.is_empty() {
continue;
}
out.push_str(&format!(
"\n## {}\n\n| Option | Description |\n|---|---|\n",
group.as_deref().unwrap_or("Arguments")
));
for arg in args {
let help = arg
.get_help()
.map(|h| escape_table_cell(&h.to_string()))
.unwrap_or_default();
out.push_str(&format!(
"| `{}` | {help} |\n",
escape_table_cell(&option_label(arg))
));
}
}
out.push_str("\n`-h`, `--help` prints help; `-V`, `--version` the version.\n");
out.push_str("\n## Commands\n\n| Command | Does |\n|---|---|\n");
for sub in cmd.get_subcommands().filter(|c| c.get_name() != "help") {
let about = |c: &clap::Command| {
c.get_about()
.map(|a| escape_table_cell(&a.to_string()))
.unwrap_or_default()
};
out.push_str(&format!(
"| `datui {}` | {} |\n",
sub.get_name(),
about(sub)
));
for action in sub.get_subcommands().filter(|c| c.get_name() != "help") {
let operands: Vec<String> = action
.get_arguments()
.filter(|a| a.is_positional())
.filter_map(|a| {
let name = a.get_value_names()?.first()?.to_string();
Some(if a.is_required_set() {
name
} else {
format!("[{name}]")
})
})
.collect();
let command = format!(
"datui {} {} {}",
sub.get_name(),
action.get_name(),
operands.join(" ")
);
out.push_str(&format!(
"| `{}` | {} |\n",
command.trim_end(),
about(action)
));
}
}
out.push_str("\n## Examples\n\n| Command | Does |\n|---|---|\n");
for example in examples_of("datui.1") {
out.push_str(&format!(
"| `{}` | {} |\n",
escape_table_cell(&example.command),
escape_table_cell(&example.description)
));
}
out
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod format_tests;