const DEFAULT_WIDTH: usize = 80;
const MIN_WIDTH: usize = 40;
#[allow(dead_code)]
const DEFAULT_INDENT: usize = 2;
const DEFINITION_INDENT: usize = 2;
const DEFINITION_SPACING: usize = 2;
const MAX_TERM_WIDTH: usize = 24;
#[derive(Debug)]
pub struct HelpFormatter {
width: usize,
indent: usize,
buffer: String,
current_col: usize,
}
impl HelpFormatter {
pub fn new(width: usize) -> Self {
let width = if width < MIN_WIDTH {
DEFAULT_WIDTH
} else {
width
};
Self {
width,
indent: 0,
buffer: String::new(),
current_col: 0,
}
}
pub fn detect_width() -> Self {
let width = detect_terminal_width().unwrap_or(DEFAULT_WIDTH);
Self::new(width)
}
pub fn get_help(&self) -> &str {
&self.buffer
}
pub fn into_help(self) -> String {
self.buffer
}
pub fn width(&self) -> usize {
self.width
}
pub fn set_indent(&mut self, indent: usize) {
self.indent = indent;
}
pub fn indent(&mut self, amount: usize) {
self.indent += amount;
}
pub fn dedent(&mut self, amount: usize) {
self.indent = self.indent.saturating_sub(amount);
}
pub fn write_blank(&mut self) {
self.buffer.push('\n');
self.current_col = 0;
}
pub fn write_raw(&mut self, text: &str) {
self.buffer.push_str(text);
if let Some(last_newline) = text.rfind('\n') {
self.current_col = text.len() - last_newline - 1;
} else {
self.current_col += text.len();
}
}
pub fn write(&mut self, text: &str) {
let indent_str = " ".repeat(self.indent);
for line in text.lines() {
self.buffer.push_str(&indent_str);
self.buffer.push_str(line);
self.buffer.push('\n');
}
self.current_col = 0;
}
pub fn write_usage(&mut self, prog: &str, args: &str) {
self.buffer.push_str("Usage: ");
self.buffer.push_str(prog);
if !args.is_empty() {
self.buffer.push(' ');
self.buffer.push_str(args);
}
self.buffer.push('\n');
self.current_col = 0;
}
pub fn write_heading(&mut self, heading: &str) {
if !self.buffer.is_empty() && !self.buffer.ends_with("\n\n") {
self.buffer.push('\n');
}
self.buffer.push_str(heading);
self.buffer.push_str(":\n");
self.current_col = 0;
}
pub fn write_paragraph(&mut self, text: &str) {
if text.is_empty() {
return;
}
let max_width = self.width.saturating_sub(self.indent);
let indent_str = " ".repeat(self.indent);
for (i, paragraph) in text.split("\n\n").enumerate() {
if i > 0 {
self.buffer.push('\n');
}
let wrapped = wrap_text(paragraph, max_width);
for line in wrapped.lines() {
self.buffer.push_str(&indent_str);
self.buffer.push_str(line);
self.buffer.push('\n');
}
}
self.current_col = 0;
}
pub fn write_definition_list(&mut self, items: &[(&str, &str)]) {
let base_indent = " ".repeat(DEFINITION_INDENT);
let desc_indent = " ".repeat(DEFINITION_INDENT + MAX_TERM_WIDTH + DEFINITION_SPACING);
let desc_width = self.width.saturating_sub(desc_indent.len());
for (term, description) in items {
self.buffer.push_str(&base_indent);
self.buffer.push_str(term);
if term.len() <= MAX_TERM_WIDTH && !description.is_empty() {
let padding = MAX_TERM_WIDTH - term.len() + DEFINITION_SPACING;
self.buffer.push_str(&" ".repeat(padding));
let wrapped = wrap_text(description, desc_width);
let mut lines = wrapped.lines();
if let Some(first) = lines.next() {
self.buffer.push_str(first);
self.buffer.push('\n');
}
for line in lines {
self.buffer.push_str(&desc_indent);
self.buffer.push_str(line);
self.buffer.push('\n');
}
} else if !description.is_empty() {
self.buffer.push('\n');
let wrapped = wrap_text(description, desc_width);
for line in wrapped.lines() {
self.buffer.push_str(&desc_indent);
self.buffer.push_str(line);
self.buffer.push('\n');
}
} else {
self.buffer.push('\n');
}
}
self.current_col = 0;
}
pub fn write_definition_list_strings(&mut self, items: &[(String, String)]) {
let refs: Vec<(&str, &str)> = items
.iter()
.map(|(t, d)| (t.as_str(), d.as_str()))
.collect();
self.write_definition_list(&refs);
}
}
impl Default for HelpFormatter {
fn default() -> Self {
Self::detect_width()
}
}
pub fn wrap_text(text: &str, width: usize) -> String {
if width == 0 {
return text.to_string();
}
let mut result = String::new();
let mut current_line = String::new();
let mut current_width = 0;
for line in text.lines() {
if !result.is_empty() || !current_line.is_empty() {
if !current_line.is_empty() {
result.push_str(¤t_line);
current_line.clear();
current_width = 0;
}
result.push('\n');
}
for word in line.split_whitespace() {
let word_width = word.len();
if current_width == 0 {
current_line.push_str(word);
current_width = word_width;
} else if current_width + 1 + word_width <= width {
current_line.push(' ');
current_line.push_str(word);
current_width += 1 + word_width;
} else {
result.push_str(¤t_line);
result.push('\n');
current_line.clear();
current_line.push_str(word);
current_width = word_width;
}
}
}
if !current_line.is_empty() {
result.push_str(¤t_line);
}
result
}
pub fn detect_terminal_width() -> Option<usize> {
if let Ok(cols) = std::env::var("COLUMNS") {
if let Ok(width) = cols.parse::<usize>() {
if width >= MIN_WIDTH {
return Some(width);
}
}
}
if let Ok(term) = std::env::var("TERM_PROGRAM") {
match term.as_str() {
"vscode" | "iTerm.app" | "Apple_Terminal" | "Hyper" => {
return Some(DEFAULT_WIDTH);
}
_ => {}
}
}
None
}
pub fn get_terminal_width() -> usize {
detect_terminal_width().unwrap_or(DEFAULT_WIDTH)
}
pub fn make_rule(char: char, width: usize) -> String {
std::iter::repeat(char).take(width).collect()
}
pub fn truncate_text(text: &str, max_width: usize) -> String {
if text.len() <= max_width {
return text.to_string();
}
if max_width <= 3 {
return "...".to_string();
}
let mut result = text[..max_width - 3].to_string();
result.push_str("...");
result
}
pub fn split_into_lines(text: &str, width: usize) -> Vec<String> {
wrap_text(text, width).lines().map(String::from).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wrap_text() {
let text = "This is a test of the text wrapping functionality";
let wrapped = wrap_text(text, 20);
assert!(wrapped.lines().all(|l| l.len() <= 20));
}
#[test]
fn test_wrap_preserves_newlines() {
let text = "Line one\nLine two\nLine three";
let wrapped = wrap_text(text, 80);
assert_eq!(wrapped.lines().count(), 3);
}
#[test]
fn test_help_formatter_usage() {
let mut fmt = HelpFormatter::new(80);
fmt.write_usage("mycli", "[OPTIONS] COMMAND");
assert!(fmt.get_help().contains("Usage: mycli [OPTIONS] COMMAND"));
}
#[test]
fn test_help_formatter_heading() {
let mut fmt = HelpFormatter::new(80);
fmt.write_heading("Options");
assert!(fmt.get_help().contains("Options:\n"));
}
#[test]
fn test_help_formatter_definition_list() {
let mut fmt = HelpFormatter::new(80);
fmt.write_definition_list(&[("--help, -h", "Show help"), ("--version", "Show version")]);
let help = fmt.get_help();
assert!(help.contains("--help, -h"));
assert!(help.contains("Show help"));
}
#[test]
fn test_truncate_text() {
assert_eq!(truncate_text("hello", 10), "hello");
assert_eq!(truncate_text("hello world", 8), "hello...");
assert_eq!(truncate_text("hi", 3), "hi");
assert_eq!(truncate_text("hello", 3), "...");
}
}