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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ for GitHub Release notes, so every published version must have a matching

## [Unreleased]

## [0.6.3] - 2026-08-04

### Added

- Add a dedicated interactive explorer for CSV, TSV, and other delimiter-
separated files. It keeps a searchable field sidebar beside the complete
selected value, navigates records lazily, and reuses JSON/XML/scalar syntax
highlighting inside individual cells without changing redirected output.
Structured cells larger than 1 MiB stay in their complete raw layout to
avoid retaining another whole formatted copy.

## [0.6.2] - 2026-07-22

### Fixed
Expand Down
26 changes: 24 additions & 2 deletions Cargo.lock

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

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,22 @@ resolver = "3"

[package]
name = "fmtview"
version = "0.6.2"
version = "0.6.3"
edition = "2024"
rust-version = "1.85"
description = "Fast CLI viewer for highlighting, search, and diffs across JSON, JSONL, HTML/XML, Markdown, TOML, text, and Jinja"
description = "Fast CLI viewer for CSV, JSON, JSONL, HTML/XML, Markdown, TOML, text, and Jinja"
license = "MIT"
repository = "https://github.com/siriusctrl/fmtview"
readme = "README.md"
keywords = ["json", "jsonl", "markdown", "toml", "cli"]
keywords = ["csv", "json", "jsonl", "viewer", "cli"]
categories = ["command-line-utilities", "development-tools"]
exclude = [".github/", "npm/"]

[dependencies]
anyhow = "1.0.102"
clap = { version = "4.6.1", features = ["derive"] }
crossterm = "0.29.0"
fmtview-core = { version = "0.6.2", path = "crates/fmtview-core" }
fmtview-core = { version = "0.6.3", path = "crates/fmtview-core" }
ratatui = { version = "0.30.0", default-features = false, features = ["crossterm"] }
tempfile = "3.27.0"

Expand Down
51 changes: 47 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# fmtview

Fast CLI viewing, highlighting, search, and diffing for JSON, JSONL,
XML-compatible markup, HTML, Markdown, TOML, plain text, and Jinja templates.
Fast CLI viewing, highlighting, search, and diffing for CSV/TSV/XSV, JSON,
JSONL, XML-compatible markup, HTML, Markdown, TOML, plain text, and Jinja
templates.

`fmtview` is built for the workflow where you want to inspect data quickly in a
terminal: open large files without waiting for a full render, keep format
Expand All @@ -10,6 +11,7 @@ inputs without leaving the CLI.

```sh
fmtview payload.json
fmtview export.csv
fmtview events.jsonl
fmtview --follow events.jsonl
fmtview response.xml
Expand Down Expand Up @@ -39,6 +41,8 @@ embedded markup, wrapped records, or formatted diffs.

- View files in a terminal UI with line numbers, progress, and indent-aware
soft wrap.
- Explore delimiter-separated records with a searchable field sidebar and a
complete, wrapped value pane instead of squeezing wide records into a grid.
- Highlight JSON, XML-compatible markup, HTML, embedded markup in JSON strings,
Markdown, TOML, and Jinja templates.
- Search the visible text without loading rendered output into memory.
Expand Down Expand Up @@ -202,6 +206,12 @@ formatting errors instead of silently changing output semantics.

Known extensions still provide a fast, deterministic hint:

- `.csv` -> comma-separated record explorer in a TTY, exact passthrough when
redirected.
- `.tsv` and `.tab` -> tab-separated record explorer.
- `.xsv`, `.psv`, and `.ssv` -> delimiter-separated explorer; `.psv` and
`.ssv` select pipe and semicolon directly, while `.xsv` sniffs comma, tab,
pipe, or semicolon from at most 24 records and 256 KiB per candidate.
- `.json` -> JSON formatting.
- `.jsonl` and `.ndjson` -> lazy JSONL record formatting.
- `.xml` and `.xhtml` -> XML-compatible markup formatting.
Expand Down Expand Up @@ -237,6 +247,8 @@ Use `--type` when stdin or an unusual extension needs an explicit profile.

Other types are intentionally passthrough:

- CSV/TSV/XSV data keeps its exact source bytes on redirected stdout. In a TTY,
the first record supplies field names and later records are indexed lazily.
- Markdown is indexed, wrapped, and highlighted, but not rendered to HTML or
reformatted. Known fenced code blocks reuse the same highlighters as
top-level files.
Expand Down Expand Up @@ -437,6 +449,36 @@ toggle, next/previous change navigation, and line/inline diff shading.

## Viewer

### Delimited record explorer

CSV, TSV, and XSV files use a field/value explorer rather than the ordinary
line viewer. The left sidebar keeps field names visible and `/` filters them as
you type. The right pane shows the selected value completely with wrapping; it
does not replace long content with an ellipsis. JSON and XML values reuse the
matching formatter and highlighter, while scalar values use number, boolean,
null, or string colors.

```text
Up/Down or j/k previous/next field
Left/Right or h/l previous/next record
/ find a field
Enter focus the complete value
Esc return from the value to fields; quit from fields
```

While the value is focused, `Up`/`Down`, `j`/`k`, `PageUp`/`PageDown`,
`Home`, and `End` scroll it. `/` searches inside the value and `n`/`N` repeat
the search. Record positions are indexed lazily, including quoted CSV fields
that contain physical newlines. Redirected output remains the original file;
the explorer is a TTY-only presentation.

The CSV parser holds the selected logical record in memory so quoting and
multiline fields remain correct. Embedded JSON/XML values up to 1 MiB are
pretty-formatted for display. Larger structured values remain complete and
syntax-highlighted in their original layout, avoiding an additional formatted
copy; selecting an unusually large record still costs memory proportional to
that raw record.

The viewer is intentionally small and works with both keyboard and pointer
input:

Expand Down Expand Up @@ -658,8 +700,9 @@ rendered output in memory for browsing.
queued, so a burst of scroll events is not delayed by speculative work.
- Highlighting and wrapping scan only the visible prefix of long lines.
- Viewer search scans the indexed visible text in bounded chunks.
- JSON, JSONL, XML-compatible markup, HTML, Markdown, TOML, plain text, and Jinja
templates are processed incrementally where their load strategy allows it.
- CSV/TSV/XSV cell content and JSON, JSONL, XML-compatible markup, HTML,
Markdown, TOML, plain text, and Jinja templates are processed incrementally
where their load strategy allows it.
- JSON numbers are written from their original tokens instead of being coerced
through native integer or floating-point types.

Expand Down
3 changes: 2 additions & 1 deletion crates/fmtview-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "fmtview-core"
version = "0.6.2"
version = "0.6.3"
edition = "2024"
rust-version = "1.85"
description = "Headless viewer engine for fmtview"
Expand All @@ -16,6 +16,7 @@ path = "src/lib.rs"

[dependencies]
anyhow = "1.0.102"
csv = "1.3.1"
memchr = "2.8.0"
quick-xml = "0.39.2"
ratatui = { version = "0.30.0", default-features = false }
Expand Down
48 changes: 48 additions & 0 deletions crates/fmtview-core/src/formats.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod checkpoints;
pub(crate) mod delimited;
pub(crate) mod html;
mod indent;
pub(crate) mod jinja;
Expand Down Expand Up @@ -30,6 +31,9 @@ use crate::{
};

pub(crate) const FORMAT_SPECS: &[FormatSpec] = &[
delimited::CSV_SPEC,
delimited::TSV_SPEC,
delimited::XSV_SPEC,
json::SPEC,
jsonl::SPEC,
xml::SPEC,
Expand All @@ -54,6 +58,8 @@ pub enum ContentShape {
LineIndexed,
/// Input is a sequence of independent newline-delimited records.
RecordStream,
/// Input is a sequence of delimiter-aware records with named fields.
DelimitedRecords,
/// Formatting requires document-level parser state.
WholeDocument,
}
Expand Down Expand Up @@ -90,6 +96,9 @@ pub(crate) fn highlight_content_window_indexed(
let window_start = window_start.min(line.len());
let window_end = window_end.min(line.len()).max(window_start);
match format {
FormatKind::Csv | FormatKind::Tsv | FormatKind::Xsv => {
plain::highlight::highlight_plain_window(line, window_start, window_end)
}
FormatKind::Json | FormatKind::Jsonl => {
json::highlight::highlight_json_like_window(line, window_start, window_end, index)
}
Expand All @@ -114,6 +123,33 @@ pub(crate) fn highlight_content_window_indexed(
}
}

pub(crate) fn highlight_large_content_window(
line: &str,
format: FormatKind,
window_start: usize,
window_end: usize,
) -> Vec<Span<'static>> {
const CONTEXT_BYTES: usize = 4 * 1024;

let window_start = window_start.min(line.len());
let window_end = window_end.min(line.len()).max(window_start);
let mut context_start = window_start.saturating_sub(CONTEXT_BYTES);
while context_start > 0 && !line.is_char_boundary(context_start) {
context_start -= 1;
}
let mut context_end = window_end.saturating_add(CONTEXT_BYTES).min(line.len());
while context_end < line.len() && !line.is_char_boundary(context_end) {
context_end += 1;
}
let context = &line[context_start..context_end];
highlight_content_window(
context,
format,
window_start - context_start,
window_end - context_start,
)
}

pub(crate) fn highlight_structured_window(
line: &str,
window_start: usize,
Expand Down Expand Up @@ -160,6 +196,7 @@ pub(crate) fn structure_candidate_kind(
previous_line: Option<&str>,
) -> Option<StructureCandidateKind> {
match format {
FormatKind::Csv | FormatKind::Tsv | FormatKind::Xsv => None,
FormatKind::Json | FormatKind::Jsonl => json::structure::candidate_kind(line),
FormatKind::Xml | FormatKind::Html => xml::structure::is_start_tag(line.trim_start())
.then_some(StructureCandidateKind::XmlStartTag),
Expand All @@ -185,6 +222,7 @@ pub(crate) fn structure_candidate_kind_in_window(
offset: usize,
) -> Option<StructureCandidateKind> {
match format {
FormatKind::Csv | FormatKind::Tsv | FormatKind::Xsv => None,
FormatKind::Json | FormatKind::Jsonl => {
json::structure::candidate_kind_in_window(lines, offset)
}
Expand All @@ -210,6 +248,7 @@ pub(crate) fn structure_block_end(
line_count_exact: bool,
) -> Option<usize> {
match format {
FormatKind::Csv | FormatKind::Tsv | FormatKind::Xsv => None,
FormatKind::Json | FormatKind::Jsonl => {
json::structure::block_end(lines, read_start, start_offset, viewport_bottom)
}
Expand Down Expand Up @@ -364,6 +403,15 @@ mod tests {
assert_eq!(span_text(&spans), r#"{{ item.name }}</div>"#);
}

#[test]
fn large_highlight_preserves_only_the_requested_deep_window() {
let line = format!("[{}true]", "1234567890,".repeat(200_000));
let start = line.len() - 64;
let spans = highlight_large_content_window(&line, FormatKind::Json, start, line.len());

assert_eq!(span_text(&spans), &line[start..]);
}

fn span_text(spans: &[Span<'static>]) -> String {
spans
.iter()
Expand Down
Loading