Skip to main content

documents/
encoding.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Which characters a document version's bytes spell
7//! (`crates/documents/spec/Encoding.lean`, D4).
8//!
9//! A byte-order mark decides first, so a UTF-16 file - which holds a NUL
10//! beside every ASCII character - is text. Without a mark the bytes are
11//! UTF-8 or nothing, and a NUL still says "not text": no text file holds
12//! one, and every store the city keeps (a redb file, a CAS blob) does.
13//! UTF-16 without a mark is not guessed at, because a guess that is wrong
14//! shows a reader characters the file does not hold.
15
16use kernel::{AxCode, AxError};
17use serde::{Deserialize, Serialize};
18
19const UTF8_MARK: [u8; 3] = [0xEF, 0xBB, 0xBF];
20const UTF16_LE_MARK: [u8; 2] = [0xFF, 0xFE];
21const UTF16_BE_MARK: [u8; 2] = [0xFE, 0xFF];
22
23/// The character encoding one document version is read in.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27pub enum Encoding {
28    /// UTF-8 with no byte-order mark and no NUL.
29    Utf8,
30    /// UTF-8 behind its byte-order mark.
31    Utf8Bom,
32    /// UTF-16, low byte first, behind its byte-order mark.
33    Utf16Le,
34    /// UTF-16, high byte first, behind its byte-order mark.
35    Utf16Be,
36}
37
38/// What one version's bytes are to a reader.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Reading {
41    /// Text in this encoding, every byte of it.
42    Text(Encoding),
43    /// Not text in any encoding this city reads: a reader is given the
44    /// version and the size, never characters.
45    Opaque,
46}
47
48impl Reading {
49    /// Judges one whole version.
50    pub fn of(bytes: &[u8]) -> Reading {
51        let encoding = Encoding::of_mark(bytes);
52        if encoding.spells(bytes) {
53            Reading::Text(encoding)
54        } else {
55            Reading::Opaque
56        }
57    }
58}
59
60impl Encoding {
61    /// The encoding the byte-order mark at the front of a version names,
62    /// and UTF-8 when there is none. Reads at most the first three bytes,
63    /// so a window of a stored version learns its encoding from them.
64    pub fn of_mark(front: &[u8]) -> Encoding {
65        if front.starts_with(&UTF8_MARK) {
66            Encoding::Utf8Bom
67        } else if front.starts_with(&UTF16_LE_MARK) {
68            Encoding::Utf16Le
69        } else if front.starts_with(&UTF16_BE_MARK) {
70            Encoding::Utf16Be
71        } else {
72            Encoding::Utf8
73        }
74    }
75
76    /// Whether `bytes` - a whole version, or a stretch of one cut at
77    /// character boundaries - spell text in this encoding.
78    fn spells(self, bytes: &[u8]) -> bool {
79        match self {
80            Encoding::Utf8 => !bytes.contains(&0) && std::str::from_utf8(bytes).is_ok(),
81            Encoding::Utf8Bom => std::str::from_utf8(bytes).is_ok(),
82            Encoding::Utf16Le | Encoding::Utf16Be => {
83                let (units, rest) = bytes.as_chunks::<2>();
84                rest.is_empty() && char::decode_utf16(self.units(units)).all(|unit| unit.is_ok())
85            }
86        }
87    }
88
89    /// The characters a stretch cut at character boundaries spells.
90    ///
91    /// A mark at the front decodes as U+FEFF, the first character of the
92    /// text (D5), so the text encodes back to exactly these bytes.
93    ///
94    /// # Errors
95    /// `E_INVALID_ARGS` naming the encoding when the bytes do not spell
96    /// text in it.
97    pub fn decode(self, bytes: &[u8]) -> Result<String, AxError> {
98        let text = match self {
99            Encoding::Utf8 | Encoding::Utf8Bom => std::str::from_utf8(bytes)
100                .ok()
101                .filter(|text| matches!(self, Encoding::Utf8Bom) || !text.contains('\0'))
102                .map(str::to_owned),
103            Encoding::Utf16Le | Encoding::Utf16Be => {
104                let (units, rest) = bytes.as_chunks::<2>();
105                rest.is_empty()
106                    .then(|| char::decode_utf16(self.units(units)).collect::<Result<String, _>>())
107                    .and_then(Result::ok)
108            }
109        };
110        text.ok_or_else(|| {
111            AxError::failure(
112                AxCode::InvalidArgs,
113                "read a document window",
114                format!("{} bytes that are not {self:?} text", bytes.len()),
115            )
116            .with_recovery("open the document again: its answer says whether it reads as text")
117        })
118    }
119
120    /// Whether a character begins at byte `index` of `bytes`, where
121    /// `bytes[0]` is byte `origin` of the version. `index == bytes.len()`
122    /// is a beginning only when the caller has said the version ends
123    /// there, which is [`crate::window`]'s business, not this one's.
124    pub(crate) fn begins_at(self, bytes: &[u8], index: usize, origin: u64) -> bool {
125        match self {
126            Encoding::Utf8 | Encoding::Utf8Bom => {
127                bytes.get(index).is_some_and(|byte| byte & 0xC0 != 0x80)
128            }
129            Encoding::Utf16Le | Encoding::Utf16Be => {
130                let at = origin.saturating_add(crate::span::offset(index));
131                let unit = bytes
132                    .get(index..)
133                    .and_then(<[u8]>::first_chunk::<2>)
134                    .map(|pair| self.unit(*pair));
135                at.is_multiple_of(2) && unit.is_some_and(|unit| !(0xDC00..=0xDFFF).contains(&unit))
136            }
137        }
138    }
139
140    /// The bytes `text` is spelled in this encoding. Every encoding here
141    /// spells every character, so this cannot fail; a U+FEFF in `text`
142    /// becomes this encoding's mark (D5, D11).
143    pub(crate) fn encode(self, text: &str) -> Vec<u8> {
144        match self {
145            Encoding::Utf8 | Encoding::Utf8Bom => text.as_bytes().to_vec(),
146            Encoding::Utf16Le => text.encode_utf16().flat_map(u16::to_le_bytes).collect(),
147            Encoding::Utf16Be => text.encode_utf16().flat_map(u16::to_be_bytes).collect(),
148        }
149    }
150
151    fn units(self, pairs: &[[u8; 2]]) -> impl Iterator<Item = u16> {
152        pairs.iter().map(move |pair| self.unit(*pair))
153    }
154
155    fn unit(self, pair: [u8; 2]) -> u16 {
156        match self {
157            Encoding::Utf16Be => u16::from_be_bytes(pair),
158            Encoding::Utf8 | Encoding::Utf8Bom | Encoding::Utf16Le => u16::from_le_bytes(pair),
159        }
160    }
161}
162
163#[cfg(test)]
164#[allow(
165    clippy::unwrap_used,
166    clippy::expect_used,
167    clippy::panic,
168    clippy::indexing_slicing,
169    reason = "test code"
170)]
171mod tests;