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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@ Customize colors, fonts, and keybindings in the [configuration file](https://lus

## Non-interactive Output

Use `-o` / `--output` to print a JSON analysis report or a quoted JSON or Rust string literal and exit:
Use `-o` / `--output` to print a plain or JSON analysis report, or a quoted JSON or Rust string literal and exit:

```sh
sauva -o plain U+00BD
sauva -o json --text 'Á😀'
sauva -o json-string --text 'Á😀'
```

See [Non-interactive Usage](https://lusingander.github.io/sauva/getting-started/non-interactive-usage.html) for formats and examples.
Expand Down
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- [Installation](./getting-started/installation.md)
- [Basic Usage](./getting-started/basic-usage.md)
- [Non-interactive Usage](./getting-started/non-interactive-usage.md)
- [Plain Reports](./getting-started/plain-reports.md)
- [JSON Reports](./getting-started/json-reports.md)
- [Command Line Options](./getting-started/command-line-options.md)
- [Compatibility](./getting-started/compatibility.md)
Expand Down
4 changes: 2 additions & 2 deletions docs/src/getting-started/command-line-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Arguments:

Options:
-t, --text <TEXT> Treat the input as literal text, without code point notation parsing; - reads stdin
-o, --output <FORMAT> Print a result and exit [possible values: json, json-string, rust-string]
-o, --output <FORMAT> Print a result and exit [possible values: plain, json, json-string, rust-string]
-g, --graphics <MODE> Control glyph preview graphics [default: auto] [possible values: auto, force, iterm2, off]
--print-default-config Print the complete default configuration to standard output
-h, --help Print help
Expand Down Expand Up @@ -76,7 +76,7 @@ Since `-` selects stdin, use `sauva U+002D` to inspect the hyphen character itse

## `-o, --output <FORMAT>`

Print a JSON analysis report or a quoted JSON or Rust string literal and exit. See [Non-interactive Usage](./non-interactive-usage.md) for formats, examples, and exit codes.
Print a plain or JSON analysis report, or a quoted JSON or Rust string literal and exit. See [Non-interactive Usage](./non-interactive-usage.md) for formats, examples, and exit codes.

## `-g, --graphics <MODE>`

Expand Down
2 changes: 1 addition & 1 deletion docs/src/getting-started/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
- [Requirements](./requirements.md): supported systems and terminal requirements.
- [Installation](./installation.md): install with Cargo, Homebrew, or a release binary.
- [Basic Usage](./basic-usage.md): inspect a character or analyze a string in the TUI.
- [Non-interactive Usage](./non-interactive-usage.md): print JSON reports or JSON and Rust string literals.
- [Non-interactive Usage](./non-interactive-usage.md): print plain or JSON reports, or JSON and Rust string literals.
- [Command Line Options](./command-line-options.md): input formats and startup options.
- [Compatibility](./compatibility.md): terminal graphics and system fonts.
6 changes: 4 additions & 2 deletions docs/src/getting-started/non-interactive-usage.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Non-interactive Usage

Use `-o, --output <FORMAT>` to print a JSON analysis report or a quoted JSON or Rust string literal and exit. This requires no interactive terminal, fonts, or clipboard and does not load user configuration.
Use `-o, --output <FORMAT>` to print a plain or JSON analysis report, or a quoted JSON or Rust string literal and exit. This requires no interactive terminal, fonts, or clipboard and does not load user configuration.

## Usage

```sh
sauva -o plain U+00BD
sauva -o json U+00BD
sauva -o json --text - < input.txt > report.json
sauva -o json-string U+2192
Expand All @@ -15,12 +16,13 @@ Input is required and follows the shared [input interpretation](./command-line-o

An explicit `--graphics` option cannot be combined with `--output`, including `--graphics auto`. Omitting `--output` starts the TUI, even when stdout is redirected.

Each result ends with one LF. Input line endings are preserved in the parsed text and escaped inside JSON strings or string literals.
Each result ends with one LF. Input line endings are preserved in the parsed text and escaped inside reports or string literals.

## Output Formats

| Format | Output |
| --- | --- |
| `plain` | A human-readable Unicode analysis report; see [Plain Reports](./plain-reports.md) for its contents and an example |
| `json` | A Unicode analysis report; see [JSON Reports](./json-reports.md) for its contents and examples |
| `json-string` | A quoted JSON string literal, such as `"A\u0301\uD83D\uDE00"` |
| `rust-string` | A quoted Rust string literal, such as `"A\u{301}\u{1F600}"` |
Expand Down
113 changes: 113 additions & 0 deletions docs/src/getting-started/plain-reports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Plain Reports

Use `--output plain` to read a Unicode analysis report without starting the TUI. See [Non-interactive Usage](./non-interactive-usage.md) for input, redirection, and exit codes.

## Examples

### Single Code Point

```sh
sauva -o plain U+00BD
```

<details>
<summary>Show the complete plain output</summary>

```text
Identity
Character: "\u00BD"
Code Point: U+00BD
Primary Name: VULGAR FRACTION ONE HALF
Plane: 0 — Basic Multilingual Plane
Block: Latin-1 Supplement

Classification
General Category: No — Other Number
Script: Common
Age: 1.1
East Asian Width: A — Ambiguous
Bidi Class: ON — Other Neutral

Numeric
Numeric Type: Numeric
Numeric Value: 1/2

Encoding
UTF-8: C2 BD
UTF-16: 00BD
HTML Decimal: &#189;
HTML Hex: &#xBD;
Rust char: '\u{BD}'
Unicode Escape: \u00BD

Normalization
Decomposition Type: Compatibility — fraction
Decomposition:
U+0031 DIGIT ONE
U+2044 FRACTION SLASH
U+0032 DIGIT TWO

Data
Unicode Version: 17.0.0

Text Analysis
CP: 1 UTF-8 Bytes: 2 UTF-16 Units: 1 GC: 1

Normalization Forms
Form State CP UTF-8 Bytes UTF-16 Units GC Result
NFC unchanged 1 2 1 1 "\u00BD"
NFD unchanged 1 2 1 1 "\u00BD"
NFKC changed 3 5 3 3 "1\u20442"
NFKD changed 3 5 3 3 "1\u20442"
```

</details>

### Text

The accent below is U+0301 following U+0041:

```sh
sauva -o plain --text 'Á😀'
```

<details>
<summary>Show the complete plain output</summary>

```text
Input: "A\u0301\uD83D\uDE00"
CP: 3 UTF-8 Bytes: 7 UTF-16 Units: 4 GC: 2

Positions: CP# / GC# are 1-based; byte / unit ranges are 0-based, end-exclusive.
CP# GC# Code Point UTF-8 Bytes UTF-16 Units Name
1 1 U+0041 [0,1) [0,1) LATIN CAPITAL LETTER A
2 1 U+0301 [1,3) [1,2) COMBINING ACUTE ACCENT
3 2 U+1F600 [3,7) [2,4) GRINNING FACE

Normalization Forms
Form State CP UTF-8 Bytes UTF-16 Units GC Result
NFC changed 2 6 3 2 "\u00C1\uD83D\uDE00"
NFD unchanged 3 7 4 2 "A\u0301\uD83D\uDE00"
NFKC changed 2 6 3 2 "\u00C1\uD83D\uDE00"
NFKD unchanged 3 7 4 2 "A\u0301\uD83D\uDE00"

Unicode Version: 17.0.0
```

</details>

## Report Contents

| Input | Contents |
| --- | --- |
| One code point | The [Inspector's Relevant properties](../features/inspector.md#properties), followed by text counts and normalization results |
| Multiple code points | The original input, text counts, an ordered list of code points with names and positions, and normalization results |
| A surrogate code point | Unicode properties, with reasons for unavailable character, encoding, and text-analysis values |

CP counts code points; GC counts [extended grapheme clusters](../features/sequence.md#code-points-and-grapheme-clusters). UTF-8 counts bytes; UTF-16 counts 16-bit code units. In the code point list, CP# and GC# start at 1. Ranges start at 0, include the start, and exclude the end.

Each [normalization form](../features/normalization.md#forms) includes its changed or unchanged state, complete result, and all four counts. A single character's Decomposition property is its direct mapping, separate from the whole-text normalization results. Numeric values preserve exact fractions such as `1/2`.

Input, Character, and normalized text use the same quoted representation as `json-string`, escaping non-ASCII characters and controls.

Use [JSON Reports](./json-reports.md) for scripts or complete properties for every code point.
2 changes: 1 addition & 1 deletion docs/src/introduction/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Sauva is a terminal application for searching and browsing Unicode code points.

For example, use Sauva to identify an invisible character, find a symbol by name, or examine how an emoji or accented letter is represented in a string.

For scripts, Sauva also provides [non-interactive JSON and Rust string output](../getting-started/non-interactive-usage.md).
Sauva also provides [non-interactive reports and string literals](../getting-started/non-interactive-usage.md) for terminal output and scripts.

## Unicode Data

Expand Down
4 changes: 3 additions & 1 deletion src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::{

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
Plain,
Json,
JsonString,
RustString,
Expand Down Expand Up @@ -54,6 +55,7 @@ pub fn write(
) -> Result<(), OutputError> {
let mut writer = BufWriter::new(writer);
let result = match format {
OutputFormat::Plain => report::plain::write(target, &mut writer),
OutputFormat::Json => report::write(target, &mut writer),
OutputFormat::JsonString | OutputFormat::RustString => {
let text = match target {
Expand All @@ -68,7 +70,7 @@ pub fn write(
let format = match format {
OutputFormat::JsonString => TextFormat::JsonString,
OutputFormat::RustString => TextFormat::RustString,
OutputFormat::Json => unreachable!(),
OutputFormat::Plain | OutputFormat::Json => unreachable!(),
};
write_literal(format, &text, &mut writer)
}
Expand Down
2 changes: 2 additions & 0 deletions src/report.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub mod plain;

use std::{
io::{self, Write},
ops::Range,
Expand Down
168 changes: 168 additions & 0 deletions src/report/plain.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
use std::io::{self, Write};

use crate::{
cli::LaunchTarget,
inspector::{
InspectorDisplayMode, InspectorField, InspectorFieldId, InspectorSection,
UNAVAILABLE_ENCODING,
},
unicode::{
CodePoint, UnicodeDatabase,
representation::TextFormat,
text::{NormalizationForm, TextAnalysis, TextMetrics},
},
};

pub fn write(target: &LaunchTarget, mut writer: impl Write) -> io::Result<()> {
match target {
LaunchTarget::CodePoint(point) => write_code_point(*point, &mut writer),
LaunchTarget::Sequence(text) => {
write_sequence(&TextAnalysis::new(text.clone()), &mut writer)
}
}
}

fn write_code_point(point: CodePoint, writer: &mut impl Write) -> io::Result<()> {
let character = point.to_char();
let mut section = None;
for field in InspectorField::for_code_point(point, InspectorDisplayMode::Relevant) {
if section != Some(field.section()) {
if section.is_some() {
write!(writer, "\n\n")?;
}
// Relevant omits unavailable encoding fields; reports explain why.
if character.is_none() && field.section() == InspectorSection::Data {
write!(writer, "Encoding\n {UNAVAILABLE_ENCODING}\n\n")?;
}
write!(writer, "{}", field.section().label())?;
section = Some(field.section());
}

write!(writer, "\n {}:", field.label())?;
if field.id() == InspectorFieldId::Character {
write!(writer, " ")?;
if let Some(character) = character {
write_text(&character.to_string(), writer)?;
} else {
write!(writer, "{UNAVAILABLE_ENCODING}")?;
}
} else if field.values().len() == 1 && field.id() != InspectorFieldId::Decomposition {
write!(writer, " {}", field.values()[0])?;
} else {
for value in field.values() {
write!(writer, "\n {value}")?;
}
}
}

write!(writer, "\n\nText Analysis\n ")?;
if let Some(character) = character {
let analysis = TextAnalysis::new(character.to_string());
write_metrics(analysis.metrics(), writer)?;
write_normalization(&analysis, writer)
} else {
write!(
writer,
"{UNAVAILABLE_ENCODING}\n\nNormalization Forms\n {UNAVAILABLE_ENCODING}"
)
}
}

fn write_sequence(analysis: &TextAnalysis, writer: &mut impl Write) -> io::Result<()> {
write!(writer, "Input: ")?;
write_text(analysis.source(), writer)?;
writeln!(writer)?;
let metrics = analysis.metrics();
write_metrics(metrics, writer)?;
write!(
writer,
"\n\nPositions: CP# / GC# are 1-based; byte / unit ranges are 0-based, end-exclusive.\n"
)?;

let cp_width = metrics.code_points.to_string().len().max(3);
let gc_width = metrics.graphemes.to_string().len().max(3);
let utf8_width = (metrics.utf8_bytes.to_string().len() * 2 + 3).max(11);
let utf16_width = (metrics.utf16_units.to_string().len() * 2 + 3).max(12);
write!(
writer,
"{:<cp_width$} {:<gc_width$} {:<10} {:<utf8_width$} {:<utf16_width$} Name",
"CP#", "GC#", "Code Point", "UTF-8 Bytes", "UTF-16 Units"
)?;
for (index, entry) in analysis.code_points().iter().enumerate() {
let point = entry.code_point();
let utf8 = entry.byte_range();
let utf16 = entry.utf16_range();
write!(
writer,
"\n{:<cp_width$} {:<gc_width$} {:<10} {:<utf8_width$} {:<utf16_width$} {}",
index + 1,
entry.grapheme_index() + 1,
point.to_string(),
format!("[{},{})", utf8.start, utf8.end),
format!("[{},{})", utf16.start, utf16.end),
UnicodeDatabase::primary_name_or_fallback(point),
)?;
}
write_normalization(analysis, writer)?;
write!(
writer,
"\n\nUnicode Version: {}",
UnicodeDatabase::version()
)
}

fn write_metrics(metrics: TextMetrics, writer: &mut impl Write) -> io::Result<()> {
write!(
writer,
"CP: {} UTF-8 Bytes: {} UTF-16 Units: {} GC: {}",
metrics.code_points, metrics.utf8_bytes, metrics.utf16_units, metrics.graphemes
)
}

fn write_normalization(analysis: &TextAnalysis, writer: &mut impl Write) -> io::Result<()> {
let mut widths = [2, 11, 12, 2];
for form in NormalizationForm::ALL {
let metrics = analysis.normalization().get(form).metrics();
for (width, count) in widths.iter_mut().zip([
metrics.code_points,
metrics.utf8_bytes,
metrics.utf16_units,
metrics.graphemes,
]) {
*width = (*width).max(count.to_string().len());
}
}
let [cp_width, utf8_width, utf16_width, gc_width] = widths;
write!(
writer,
"\n\nNormalization Forms\n {:<4} {:<9} {:<cp_width$} {:<utf8_width$} {:<utf16_width$} {:<gc_width$} Result",
"Form", "State", "CP", "UTF-8 Bytes", "UTF-16 Units", "GC"
)?;
for form in NormalizationForm::ALL {
let result = analysis.normalization().get(form);
let metrics = result.metrics();
write!(
writer,
"\n {:<4} {:<9} {:<cp_width$} {:<utf8_width$} {:<utf16_width$} {:<gc_width$} ",
form.label(),
if result.is_changed() {
"changed"
} else {
"unchanged"
},
metrics.code_points,
metrics.utf8_bytes,
metrics.utf16_units,
metrics.graphemes,
)?;
write_text(analysis.normalized_text(form), writer)?;
}
Ok(())
}

fn write_text(text: &str, writer: &mut impl Write) -> io::Result<()> {
for atom in TextFormat::JsonString.atoms(text) {
writer.write_all(atom.as_bytes())?;
}
Ok(())
}
Loading
Loading