use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Entry {
pub source: String,
pub target: String,
}
impl Entry {
pub fn new(source: impl Into<String>, target: impl Into<String>) -> Self {
Self { source: source.into(), target: target.into() }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
Cjk,
Latin,
Other,
}
impl SourceKind {
#[must_use]
pub fn of(source: &str) -> Self {
if source.chars().any(is_cjk) {
SourceKind::Cjk
} else if source.is_ascii() && source.chars().any(|c| c.is_ascii_alphanumeric()) {
SourceKind::Latin
} else {
SourceKind::Other
}
}
fn word_bounded(self) -> bool {
matches!(self, SourceKind::Latin)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Substitution {
pub source: String,
pub target: String,
pub count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Applied {
pub output: String,
pub substitutions: Vec<Substitution>,
}
impl Applied {
#[must_use]
pub fn total_substitutions(&self) -> usize {
self.substitutions.iter().map(|s| s.count).sum()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Glossary {
#[serde(default)]
entries: Vec<Entry>,
}
impl Glossary {
pub fn from_entries(entries: impl IntoIterator<Item = Entry>) -> Result<Self> {
let entries: Vec<Entry> = entries.into_iter().collect();
if let Some(bad) = entries.iter().position(|e| e.source.is_empty()) {
return Err(anyhow!("glossary entry {bad} has an empty source term"));
}
Ok(Self { entries })
}
pub fn from_text(text: &str) -> Result<Self> {
let mut entries = Vec::new();
for (i, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (source, target) = line
.split_once('=')
.ok_or_else(|| anyhow!("glossary line {}: expected `source = target`, got {raw:?}", i + 1))?;
let source = source.trim();
if source.is_empty() {
return Err(anyhow!("glossary line {}: empty source term", i + 1));
}
entries.push(Entry::new(source, target.trim()));
}
Ok(Self { entries })
}
#[must_use]
pub fn entries(&self) -> &[Entry] {
&self.entries
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn apply(&self, text: &str) -> Applied {
let prepared: Vec<PreparedEntry> = self
.entries
.iter()
.enumerate()
.map(|(idx, e)| PreparedEntry {
idx,
source: e.source.chars().collect(),
kind: SourceKind::of(&e.source),
})
.collect();
let mut order: Vec<usize> = (0..prepared.len()).collect();
order.sort_by(|&a, &b| prepared[b].source.len().cmp(&prepared[a].source.len()));
let chars: Vec<char> = text.chars().collect();
let mut output = String::with_capacity(text.len());
let mut counts = vec![0usize; self.entries.len()];
let mut i = 0;
while i < chars.len() {
let mut matched = false;
for &oi in &order {
let pe = &prepared[oi];
let len = pe.source.len();
if len == 0 || i + len > chars.len() {
continue;
}
if chars[i..i + len] != pe.source[..] {
continue;
}
if pe.kind.word_bounded() && !word_boundaries_ok(&chars, i, len) {
continue;
}
output.push_str(&self.entries[pe.idx].target);
counts[pe.idx] += 1;
i += len;
matched = true;
break;
}
if !matched {
output.push(chars[i]);
i += 1;
}
}
let substitutions = self
.entries
.iter()
.enumerate()
.filter(|(idx, _)| counts[*idx] > 0)
.map(|(idx, e)| Substitution {
source: e.source.clone(),
target: e.target.clone(),
count: counts[idx],
})
.collect();
Applied { output, substitutions }
}
}
struct PreparedEntry {
idx: usize,
source: Vec<char>,
kind: SourceKind,
}
fn word_boundaries_ok(chars: &[char], start: usize, len: usize) -> bool {
let left_ok = start == 0 || !is_word_char(chars[start - 1]);
let after = start + len;
let right_ok = after >= chars.len() || !is_word_char(chars[after]);
left_ok && right_ok
}
fn is_word_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
fn is_cjk(c: char) -> bool {
matches!(c as u32,
0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x2_0000..=0x2_A6DF | 0x2_A700..=0x2_EBEF )
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replaces_cjk_terms_in_a_sentence_and_reports_them() {
let g = Glossary::from_entries([
Entry::new("华龙一号", "Hualong One"),
Entry::new("数字孪生", "digital twin"),
])
.unwrap();
let applied = g.apply("华龙一号的数字孪生模型");
assert_eq!(applied.output, "Hualong One的digital twin模型");
assert_eq!(
applied.substitutions,
vec![
Substitution { source: "华龙一号".into(), target: "Hualong One".into(), count: 1 },
Substitution { source: "数字孪生".into(), target: "digital twin".into(), count: 1 },
]
);
assert_eq!(applied.total_substitutions(), 2);
}
#[test]
fn longest_source_wins_over_shorter_prefix() {
let g = Glossary::from_entries([
Entry::new("华龙", "Hualong"), Entry::new("华龙一号", "Hualong One"), ])
.unwrap();
let applied = g.apply("华龙一号");
assert_eq!(applied.output, "Hualong One");
assert_eq!(applied.total_substitutions(), 1);
assert_eq!(applied.substitutions[0].source, "华龙一号");
let applied2 = g.apply("华龙集团");
assert_eq!(applied2.output, "Hualong集团");
}
#[test]
fn latin_source_respects_word_boundaries() {
let g = Glossary::from_entries([Entry::new("core", "reactor core")]).unwrap();
let applied = g.apply("the core melted during the encore, core.");
assert_eq!(applied.output, "the reactor core melted during the encore, reactor core.");
assert_eq!(applied.total_substitutions(), 2);
assert_eq!(applied.substitutions[0].count, 2);
}
#[test]
fn cjk_source_matches_every_occurrence() {
let g = Glossary::from_entries([Entry::new("乏燃料", "spent fuel")]).unwrap();
let applied = g.apply("乏燃料池储存乏燃料");
assert_eq!(applied.output, "spent fuel池储存spent fuel");
assert_eq!(applied.substitutions[0].count, 2);
}
#[test]
fn apply_is_idempotent_and_deterministic() {
let g = Glossary::from_entries([
Entry::new("压水堆", "PWR"),
Entry::new("数字孪生", "digital twin"),
])
.unwrap();
let input = "压水堆的数字孪生,压水堆机组";
let once = g.apply(input);
let twice = g.apply(&once.output);
assert_eq!(twice.output, once.output, "apply twice == apply once");
assert!(twice.substitutions.is_empty(), "second pass substitutes nothing");
let again = Glossary::from_entries([
Entry::new("压水堆", "PWR"),
Entry::new("数字孪生", "digital twin"),
])
.unwrap();
assert_eq!(again.apply(input).output, once.output);
}
#[test]
fn empty_glossary_is_a_noop() {
let g = Glossary::default();
assert!(g.is_empty());
let applied = g.apply("华龙一号 core 数字孪生");
assert_eq!(applied.output, "华龙一号 core 数字孪生");
assert!(applied.substitutions.is_empty());
assert_eq!(applied.total_substitutions(), 0);
}
#[test]
fn report_counts_occurrences_and_omits_unfired_terms() {
let g = Glossary::from_entries([
Entry::new("压水堆", "PWR"),
Entry::new("沸水堆", "BWR"), Entry::new("数字孪生", "digital twin"),
])
.unwrap();
let applied = g.apply("压水堆、压水堆、压水堆 和 数字孪生");
assert_eq!(applied.substitutions.len(), 2, "unfired 沸水堆 omitted");
assert_eq!(applied.substitutions[0].source, "压水堆");
assert_eq!(applied.substitutions[0].count, 3);
assert_eq!(applied.substitutions[1].source, "数字孪生");
assert_eq!(applied.substitutions[1].count, 1);
}
#[test]
fn source_kind_classification() {
assert_eq!(SourceKind::of("华龙一号"), SourceKind::Cjk);
assert_eq!(SourceKind::of("PWR"), SourceKind::Latin);
assert_eq!(SourceKind::of("core"), SourceKind::Latin);
assert_eq!(SourceKind::of("华龙1号"), SourceKind::Cjk);
}
#[test]
fn latin_matching_is_case_sensitive() {
let g = Glossary::from_entries([Entry::new("PWR", "pressurised water reactor")]).unwrap();
let applied = g.apply("PWR and pwr");
assert_eq!(applied.output, "pressurised water reactor and pwr");
assert_eq!(applied.total_substitutions(), 1);
}
#[test]
fn from_text_parses_source_equals_target() {
let g = Glossary::from_text(
"# nuclear terms\n\
华龙一号 = Hualong One\n\
\n\
数字孪生 = digital twin\n\
ratio = a = b\n",
)
.unwrap();
assert_eq!(g.entries().len(), 3);
assert_eq!(g.entries()[0], Entry::new("华龙一号", "Hualong One"));
assert_eq!(g.entries()[2], Entry::new("ratio", "a = b"));
}
#[test]
fn empty_source_is_rejected() {
assert!(Glossary::from_entries([Entry::new("", "x")]).is_err());
assert!(Glossary::from_text(" = target").is_err());
}
#[test]
fn serde_derives_are_present() {
fn assert_serde<T: serde::Serialize + serde::de::DeserializeOwned>() {}
assert_serde::<Glossary>();
assert_serde::<Entry>();
assert_serde::<Substitution>();
assert_serde::<Applied>();
assert_serde::<SourceKind>();
}
}