Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: r7kamura/rust-problem-matchers@v1
- uses: actions/checkout@v4
- run: cargo check --workspace --all-targets
- run: cargo check --workspace --all-targets --all-features

clippy:
runs-on: ubuntu-latest
Expand All @@ -40,7 +40,7 @@ jobs:
components: clippy
- uses: r7kamura/rust-problem-matchers@v1
- uses: actions/checkout@v4
- run: cargo clippy --workspace --all-targets
- run: cargo clippy --workspace --all-targets --all-features

test:
runs-on: ubuntu-latest
Expand All @@ -57,7 +57,7 @@ jobs:
toolchain: ${{ matrix.toolchain }}
- uses: r7kamura/rust-problem-matchers@v1
- uses: actions/checkout@v4
- run: cargo test --workspace
- run: cargo test --workspace --all-features

semver-check:
runs-on: ubuntu-latest
Expand Down
23 changes: 15 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ keywords = ["table", "tabular", "terminal", "text", "unicode"]
exclude = ["/.github"]

[dependencies]
fallible-iterator = { version = "0.3", optional = true }
unicode-width = { version = "0.2", default-features = false }
unicode-segmentation = "1.12"

Expand All @@ -23,3 +24,7 @@ harness = false

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tinytable_profile_alloc)'] }

[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A tiny text table drawing library for Rust.
* Small code size (it's one function!)
* Minimal dependencies (not zero, because Unicode is hard)
* Iterator support (you don't need to collect all the data to display at once, it can be streamed)
* Optional support for the [`fallible-iterator`](https://crates.io/crates/fallible-iterator) crate
* Unicode support
* Nothing more!

Expand Down
220 changes: 220 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
#![allow(clippy::items_after_statements)]
#![allow(clippy::missing_panics_doc)]
#![allow(clippy::uninlined_format_args)]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![cfg_attr(docsrs, feature(doc_cfg))]

//! A tiny text table drawing library.
//!
Expand All @@ -24,9 +26,15 @@ use std::fmt::{self, Display};
use std::io::{self, BufWriter, Write};
use std::num::NonZeroUsize;

#[cfg(feature = "fallible-iterator")]
use std::fmt::Debug;

use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

#[cfg(feature = "fallible-iterator")]
use fallible_iterator::FallibleIterator;

const HORIZONTAL_LINE: &str = "─";
const VERTICAL_LINE: &str = "│";
const TOP_LEFT: &str = "╭";
Expand Down Expand Up @@ -286,6 +294,130 @@ fn draw_cell<W: Write>(writer: &mut BufWriter<W>, value: &str, space: usize) ->
writer.write_all(VERTICAL_LINE.as_bytes())
}

/// Render a table from a fallible iterator.
///
/// It differs from [`write_table`] in that `iter` is a [`FallibleIterator`] from the [`fallible-iterator`] crate.
///
/// [`FallibleIterator`]: FallibleIterator
/// [`fallible-iterator`]: fallible_iterator
///
/// # Errors
///
/// If an I/O error is encountered while writing to the `to` writer, [`FallibleIteratorTableWriteError::Io`]
/// is returned. If the iterator produces an error when getting the next row,
/// [`FallibleIteratorTableWriteError::Iterator`] is returned.
#[cfg(feature = "fallible-iterator")]
pub fn write_table_fallible<Cell: Display, Row: IntoIterator<Item = Cell>, IteratorError, const COLUMN_COUNT: usize>(
to: impl Write,
mut iter: impl FallibleIterator<Item = Row, Error = IteratorError>,
column_names: &[&str; COLUMN_COUNT],
column_widths: &[NonZeroUsize; COLUMN_COUNT],
) -> Result<(), FallibleIteratorTableWriteError<IteratorError>> {
let mut writer = write_table_start(to, column_names, column_widths)?;

let mut value = String::new();
let ret = loop {
match iter.next() {
Ok(Some(row)) => {
writer.write_all(VERTICAL_LINE.as_bytes())?;

let mut row_iter = row.into_iter();
for space in column_widths.iter().copied().map(NonZeroUsize::get) {
if let Some(col) = row_iter.next() {
write!(&mut value, "{}", col).expect("formatting to a string shouldn't fail");
}
draw_cell(&mut writer, &value, space)?;
value.clear();
}

writer.write_all("\n".as_bytes())?;
}
Ok(None) => break Ok(()),
Err(err) => break Err(FallibleIteratorTableWriteError::Iterator(err)),
}
};

write_table_end(writer, column_widths)?;
ret
}

/// Render a table from a fallible iterator using custom formatters.
///
/// It differs from [`write_table_with_fmt`] in that `iter` is a [`FallibleIterator`] from the [`fallible-iterator`]
/// crate.
///
/// [`FallibleIterator`]: FallibleIterator
/// [`fallible-iterator`]: fallible_iterator
///
/// # Errors
///
/// If an I/O error is encountered while writing to the `to` writer, [`FallibleIteratorTableWriteError::Io`]
/// is returned. If the iterator produces an error when getting the next row,
/// [`FallibleIteratorTableWriteError::Iterator`] is returned.
#[cfg(feature = "fallible-iterator")]
pub fn write_table_with_fmt_fallible<Row, IteratorError, const COLUMN_COUNT: usize>(
to: impl Write,
mut iter: impl FallibleIterator<Item = Row, Error = IteratorError>,
formatters: &[impl Fn(&Row, &mut String) -> fmt::Result; COLUMN_COUNT],
column_names: &[&str; COLUMN_COUNT],
column_widths: &[NonZeroUsize; COLUMN_COUNT],
) -> Result<(), FallibleIteratorTableWriteError<IteratorError>> {
let mut writer = write_table_start(to, column_names, column_widths)?;

let mut value = String::new();
let ret = loop {
match iter.next() {
Ok(Some(row)) => {
writer.write_all(VERTICAL_LINE.as_bytes())?;

let mut formatters = formatters.iter();
for space in column_widths.iter().copied().map(NonZeroUsize::get) {
if let Some(formatter) = formatters.next() {
formatter(&row, &mut value).expect("formatting to a string shouldn't fail");
}
draw_cell(&mut writer, &value, space)?;
value.clear();
}

writer.write_all("\n".as_bytes())?;
}
Ok(None) => break Ok(()),
Err(err) => break Err(FallibleIteratorTableWriteError::Iterator(err)),
}
};

write_table_end(writer, column_widths)?;
ret
}

/// Error type of [`write_table_fallible`].
#[cfg(feature = "fallible-iterator")]
#[derive(Debug)]
pub enum FallibleIteratorTableWriteError<IteratorError> {
Io(io::Error),
Iterator(IteratorError),
}

#[cfg(feature = "fallible-iterator")]
impl<E> From<io::Error> for FallibleIteratorTableWriteError<E> {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}

#[cfg(feature = "fallible-iterator")]
impl<E: Display> Display for FallibleIteratorTableWriteError<E> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
match self {
FallibleIteratorTableWriteError::Io(err) => write!(f, "failed to write table: {}", err),
FallibleIteratorTableWriteError::Iterator(err) => write!(f, "failed to get next table row: {}", err),
}
}
}

#[cfg(feature = "fallible-iterator")]
impl<E: Debug + Display> std::error::Error for FallibleIteratorTableWriteError<E> {}

#[allow(clippy::inline_always)]
#[inline(always)]
const fn unlikely(b: bool) -> bool {
Expand Down Expand Up @@ -534,6 +666,94 @@ awefz 234 23
│AAA│BBB│CCC│DDD│EEE│
│FFF│GGG│HHH│III│JJJ│
╰───┴───┴───┴───┴───╯
"
);
assert_consistent_width(&output);
}
}

#[cfg(feature = "fallible-iterator")]
mod fallible_iterator {
use super::*;
use ::fallible_iterator::FallibleIterator;

#[test]
fn fallible_ok() {
let data = [["q3rrq", "qfqh843f9", "qa"], ["123", "", "aaaaaa"]];
let mut output = Vec::new();
write_table_fallible(
&mut output,
::fallible_iterator::convert(data.iter().map(Ok::<_, ()>)),
&["A", "B", "C"],
&[nz!(5), nz!(10), nz!(4)],
)
.expect("write_table failed");

let output = String::from_utf8(output).expect("valid UTF-8");
assert_eq!(
output,
"╭─────┬──────────┬────╮
│ A │ B │ C │
├─────┼──────────┼────┤
│q3rrq│ qfqh843f9│ qa │
│ 123 │ │aaa…│
╰─────┴──────────┴────╯
"
);
assert_consistent_width(&output);
}

#[test]
fn fallible_err() {
let data = [["q3rrq", "qfqh843f9", "qa"], ["123", "", "aaaaaa"]];
let mut output = Vec::new();
let result = write_table_fallible(
&mut output,
::fallible_iterator::convert(data.iter().map(Ok::<_, &str>))
.take(1)
.chain(::fallible_iterator::once_err("error")),
&["A", "B", "C"],
&[nz!(5), nz!(10), nz!(4)],
);
assert!(matches!(result, Err(FallibleIteratorTableWriteError::Iterator(_))));

let output = String::from_utf8(output).expect("valid UTF-8");
assert_eq!(
output,
"╭─────┬──────────┬────╮
│ A │ B │ C │
├─────┼──────────┼────┤
│q3rrq│ qfqh843f9│ qa │
╰─────┴──────────┴────╯
"
);
assert_consistent_width(&output);
}

#[test]
fn fallible_fmt() {
let data = [["q3rrq", "qfqh843f9", "qa"], ["123", "", "aaaaaa"]];
let len = |index: usize| move |row: &&[&str; 3], f: &mut String| write!(f, "{}", row[index].len());

let mut output = Vec::new();
write_table_with_fmt_fallible(
&mut output,
::fallible_iterator::convert(data.iter().map(Ok::<_, ()>)),
&[len(0), len(1), len(2)],
&["A", "B", "C"],
&[nz!(3); 3],
)
.expect("write_table failed");

let output = String::from_utf8(output).expect("valid UTF-8");
assert_eq!(
output,
"╭───┬───┬───╮
│ A │ B │ C │
├───┼───┼───┤
│ 5 │ 9 │ 2 │
│ 3 │ 0 │ 6 │
╰───┴───┴───╯
"
);
assert_consistent_width(&output);
Expand Down