use std::ops::Range;
use serde::Serialize;
use unicode_normalization::UnicodeNormalization;
use unicode_segmentation::UnicodeSegmentation;
use crate::unicode::CodePoint;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextAnalysis {
source: Box<str>,
code_points: Vec<AnalyzedCodePoint>,
graphemes: Vec<GraphemeCluster>,
metrics: TextMetrics,
normalization: NormalizationAnalysis,
}
impl TextAnalysis {
pub fn new(source: String) -> Self {
let source = source.into_boxed_str();
let mut code_points = Vec::new();
let mut graphemes = Vec::new();
let mut utf16_offset = 0;
for (grapheme_index, (byte_start, text)) in source.grapheme_indices(true).enumerate() {
let code_point_start = code_points.len();
let utf16_start = utf16_offset;
for (index_in_grapheme, (offset, character)) in text.char_indices().enumerate() {
let start = byte_start + offset;
let utf16_end = utf16_offset + character.len_utf16();
code_points.push(AnalyzedCodePoint {
code_point: CodePoint::from(character),
byte_range: start..start + character.len_utf8(),
utf16_range: utf16_offset..utf16_end,
grapheme_index,
index_in_grapheme,
});
utf16_offset = utf16_end;
}
graphemes.push(GraphemeCluster {
byte_range: byte_start..byte_start + text.len(),
code_point_range: code_point_start..code_points.len(),
utf16_range: utf16_start..utf16_offset,
});
}
let metrics = TextMetrics {
code_points: code_points.len(),
utf8_bytes: source.len(),
utf16_units: utf16_offset,
graphemes: graphemes.len(),
};
let normalization = NormalizationAnalysis::new(&source, metrics);
Self {
source,
code_points,
graphemes,
metrics,
normalization,
}
}
pub fn source(&self) -> &str {
&self.source
}
pub fn byte_count(&self) -> usize {
self.source.len()
}
pub fn code_points(&self) -> &[AnalyzedCodePoint] {
&self.code_points
}
pub fn graphemes(&self) -> &[GraphemeCluster] {
&self.graphemes
}
pub const fn metrics(&self) -> TextMetrics {
self.metrics
}
pub const fn normalization(&self) -> &NormalizationAnalysis {
&self.normalization
}
pub fn normalized_text(&self, form: NormalizationForm) -> &str {
self.normalization
.get(form)
.changed_text()
.unwrap_or(&self.source)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct TextMetrics {
pub code_points: usize,
pub utf8_bytes: usize,
pub utf16_units: usize,
pub graphemes: usize,
}
impl TextMetrics {
fn new(text: &str) -> Self {
let (code_points, utf16_units) = text.chars().fold((0, 0), |(points, units), character| {
(points + 1, units + character.len_utf16())
});
Self {
code_points,
utf8_bytes: text.len(),
utf16_units,
graphemes: text.graphemes(true).count(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnalyzedCodePoint {
code_point: CodePoint,
byte_range: Range<usize>,
utf16_range: Range<usize>,
grapheme_index: usize,
index_in_grapheme: usize,
}
impl AnalyzedCodePoint {
pub const fn code_point(&self) -> CodePoint {
self.code_point
}
pub fn byte_range(&self) -> Range<usize> {
self.byte_range.clone()
}
pub fn utf16_range(&self) -> Range<usize> {
self.utf16_range.clone()
}
pub const fn grapheme_index(&self) -> usize {
self.grapheme_index
}
pub const fn index_in_grapheme(&self) -> usize {
self.index_in_grapheme
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphemeCluster {
byte_range: Range<usize>,
code_point_range: Range<usize>,
utf16_range: Range<usize>,
}
impl GraphemeCluster {
pub fn byte_range(&self) -> Range<usize> {
self.byte_range.clone()
}
pub fn code_point_range(&self) -> Range<usize> {
self.code_point_range.clone()
}
pub fn utf16_range(&self) -> Range<usize> {
self.utf16_range.clone()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NormalizationForm {
Nfc,
Nfd,
Nfkc,
Nfkd,
}
impl NormalizationForm {
pub const ALL: [Self; 4] = [Self::Nfc, Self::Nfd, Self::Nfkc, Self::Nfkd];
pub const fn label(self) -> &'static str {
match self {
Self::Nfc => "NFC",
Self::Nfd => "NFD",
Self::Nfkc => "NFKC",
Self::Nfkd => "NFKD",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NormalizationAnalysis {
nfc: NormalizationResult,
nfd: NormalizationResult,
nfkc: NormalizationResult,
nfkd: NormalizationResult,
}
impl NormalizationAnalysis {
fn new(source: &str, metrics: TextMetrics) -> Self {
Self {
nfc: NormalizationResult::new(source, metrics, source.nfc()),
nfd: NormalizationResult::new(source, metrics, source.nfd()),
nfkc: NormalizationResult::new(source, metrics, source.nfkc()),
nfkd: NormalizationResult::new(source, metrics, source.nfkd()),
}
}
pub const fn get(&self, form: NormalizationForm) -> &NormalizationResult {
match form {
NormalizationForm::Nfc => &self.nfc,
NormalizationForm::Nfd => &self.nfd,
NormalizationForm::Nfkc => &self.nfkc,
NormalizationForm::Nfkd => &self.nfkd,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NormalizationResult {
output: Option<Box<str>>,
metrics: TextMetrics,
}
impl NormalizationResult {
fn new(
source: &str,
source_metrics: TextMetrics,
characters: impl Iterator<Item = char>,
) -> Self {
let output = characters.collect::<String>();
let metrics = if output == source {
source_metrics
} else {
TextMetrics::new(&output)
};
Self {
output: (output != source).then(|| output.into_boxed_str()),
metrics,
}
}
pub const fn is_changed(&self) -> bool {
self.output.is_some()
}
pub fn changed_text(&self) -> Option<&str> {
self.output.as_deref()
}
pub const fn code_point_count(&self) -> usize {
self.metrics.code_points
}
pub const fn metrics(&self) -> TextMetrics {
self.metrics
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
use crate::unicode::UnicodeDatabase;
const FORMS: [NormalizationForm; 4] = [
NormalizationForm::Nfc,
NormalizationForm::Nfd,
NormalizationForm::Nfkc,
NormalizationForm::Nfkd,
];
#[test]
fn algorithm_versions_match_the_bundled_ucd() {
let normalization = unicode_normalization::UNICODE_VERSION;
for (major, minor, patch) in [
unicode_segmentation::UNICODE_VERSION,
(
u64::from(normalization.0),
u64::from(normalization.1),
u64::from(normalization.2),
),
] {
assert_eq!(
format!("{major}.{minor}.{patch}"),
UnicodeDatabase::version()
);
}
}
#[test]
fn tracks_byte_ranges_and_membership_for_combining_text_and_zwj_emoji() {
let source = "A\u{0301} 👩💻";
let analysis = TextAnalysis::new(source.to_owned());
assert_eq!(analysis.source(), source);
assert_eq!(analysis.byte_count(), 15);
assert_eq!(analysis.code_points().len(), 6);
assert_eq!(analysis.graphemes().len(), 3);
assert_eq!(
analysis.metrics(),
TextMetrics {
code_points: 6,
utf8_bytes: 15,
utf16_units: 8,
graphemes: 3,
}
);
let expected = [
(0x0041, 0..1, 0..1, 0, 0),
(0x0301, 1..3, 1..2, 0, 1),
(0x0020, 3..4, 2..3, 1, 0),
(0x1f469, 4..8, 3..5, 2, 0),
(0x200d, 8..11, 5..6, 2, 1),
(0x1f4bb, 11..15, 6..8, 2, 2),
];
for (point, (value, bytes, units, grapheme, member)) in
analysis.code_points().iter().zip(expected)
{
assert_eq!(point.code_point().value(), value);
assert_eq!(point.byte_range(), bytes);
assert_eq!(point.utf16_range(), units);
assert_eq!(point.grapheme_index(), grapheme);
assert_eq!(point.index_in_grapheme(), member);
assert_eq!(
&source[point.byte_range()],
point.code_point().to_char().unwrap().to_string()
);
}
for (cluster, (bytes, points, units)) in analysis.graphemes().iter().zip([
(0..3, 0..2, 0..2),
(3..4, 2..3, 2..3),
(4..15, 3..6, 3..8),
]) {
assert_eq!(cluster.byte_range(), bytes);
assert_eq!(cluster.code_point_range(), points);
assert_eq!(cluster.utf16_range(), units);
}
let nfc = analysis.normalization().get(NormalizationForm::Nfc);
assert!(nfc.is_changed());
assert_eq!(nfc.code_point_count(), 5);
assert_eq!(
nfc.metrics(),
TextMetrics {
code_points: 5,
utf8_bytes: 14,
utf16_units: 7,
graphemes: 3,
}
);
assert_eq!(analysis.normalized_text(NormalizationForm::Nfc), "Á 👩💻");
let nfd = analysis.normalization().get(NormalizationForm::Nfd);
assert!(!nfd.is_changed());
assert_eq!(nfd.changed_text(), None);
assert_eq!(nfd.code_point_count(), 6);
assert_eq!(nfd.metrics(), analysis.metrics());
assert_eq!(analysis.normalized_text(NormalizationForm::Nfd), source);
}
#[rstest]
#[case("", &[])]
#[case("é", &[(0..2, 0..1)])]
#[case("🇯🇵", &[(0..8, 0..2)])]
#[case("🇯🇵🇺", &[(0..8, 0..2), (8..12, 2..3)])]
#[case("\r\n", &[(0..2, 0..2)])]
#[case("\u{0301}", &[(0..2, 0..1)])]
#[case("\u{0301}\u{0300}A", &[(0..4, 0..2), (4..5, 2..3)])]
#[case("❤️", &[(0..6, 0..2)])]
#[case("👍🏽", &[(0..8, 0..2)])]
#[case("\u{1100}\u{1161}\u{11a8}", &[(0..9, 0..3)])]
#[case("\u{0915}\u{094d}\u{0937}", &[(0..9, 0..3)])]
#[case("あAあ", &[(0..3, 0..1), (3..4, 1..2), (4..7, 2..3)])]
fn segments_extended_graphemes_and_keeps_contiguous_ranges(
#[case] source: &str,
#[case] expected: &[(Range<usize>, Range<usize>)],
) {
let analysis = TextAnalysis::new(source.to_owned());
assert_eq!(analysis.byte_count(), source.len());
assert_eq!(analysis.code_points().len(), source.chars().count());
assert_eq!(
analysis.metrics().utf16_units,
source.encode_utf16().count()
);
assert_eq!(
analysis
.graphemes()
.iter()
.map(|cluster| (cluster.byte_range(), cluster.code_point_range()))
.collect::<Vec<_>>(),
expected
);
let mut byte_end = 0;
let mut utf16_end = 0;
for (cluster_index, cluster) in analysis.graphemes().iter().enumerate() {
let cluster_text = &source[cluster.byte_range()];
let members = &analysis.code_points()[cluster.code_point_range()];
assert_eq!(cluster.byte_range().start, byte_end);
assert_eq!(cluster.utf16_range().start, utf16_end);
assert_eq!(cluster_text.chars().count(), members.len());
for (member_index, point) in members.iter().enumerate() {
assert_eq!(point.grapheme_index(), cluster_index);
assert_eq!(point.index_in_grapheme(), member_index);
assert_eq!(point.byte_range().start, byte_end);
byte_end = point.byte_range().end;
assert_eq!(point.utf16_range().start, utf16_end);
assert_eq!(
point.utf16_range().len(),
point.code_point().to_char().unwrap().len_utf16()
);
utf16_end = point.utf16_range().end;
}
assert_eq!(cluster.byte_range().end, byte_end);
assert_eq!(cluster.utf16_range().end, utf16_end);
}
assert_eq!(byte_end, source.len());
assert_eq!(utf16_end, source.encode_utf16().count());
}
#[rstest]
#[case("", ["", "", "", ""])]
#[case("ASCII", ["ASCII", "ASCII", "ASCII", "ASCII"])]
#[case("é", ["é", "e\u{0301}", "é", "e\u{0301}"])]
#[case("e\u{0301}", ["é", "e\u{0301}", "é", "e\u{0301}"])]
#[case("①", ["①", "①", "1", "1"])]
#[case("ffi", ["ffi", "ffi", "ffi", "ffi"])]
#[case("각", ["각", "\u{1100}\u{1161}\u{11a8}", "각", "\u{1100}\u{1161}\u{11a8}"])]
#[case("q\u{0301}\u{0323}", ["q\u{0323}\u{0301}"; 4])]
#[case("\0\r\né\u{200d}", ["\0\r\né\u{200d}", "\0\r\ne\u{0301}\u{200d}", "\0\r\né\u{200d}", "\0\r\ne\u{0301}\u{200d}"])]
fn retains_exact_normalized_outputs_without_changing_the_source(
#[case] source: &str,
#[case] expected: [&str; 4],
) {
let analysis = TextAnalysis::new(source.to_owned());
for (form, text) in FORMS.into_iter().zip(expected) {
let result = analysis.normalization().get(form);
assert_eq!(analysis.normalized_text(form), text);
assert_eq!(result.code_point_count(), text.chars().count());
assert_eq!(
result.metrics(),
TextMetrics {
code_points: text.chars().count(),
utf8_bytes: text.len(),
utf16_units: text.encode_utf16().count(),
graphemes: text.graphemes(true).count(),
}
);
assert_eq!(result.is_changed(), text != source);
assert_eq!(result.changed_text(), (text != source).then_some(text));
let normalized = TextAnalysis::new(text.to_owned());
assert_eq!(normalized.normalized_text(form), text);
}
assert_eq!(analysis.source(), source);
}
}