langid and tocfind: what language a book is in, and what it says its chapters are

This commit is contained in:
funkpower
2026-09-20 10:02:15 -04:00
parent 05759ab902
commit d24e37bd12
15 changed files with 1928 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
//! What language a document is in.
//!
//! Two passes, because one does not do. The writing system settles most of
//! the world outright — a text in Hangul is Korean — and for the Latin and
//! Cyrillic alphabets it only narrows the field, so the commonest words
//! decide between what is left.
//!
//! No model, no download, no network. Function words make up a third of any
//! running text, and on a paragraph or more counting them is decisive. On a
//! single line it is not, which is why every answer carries a confidence and
//! why "could not tell" is one of the answers.
pub mod script;
pub mod words;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub use script::Script;
/// How much text is worth looking at.
///
/// A book's language does not change after the first few pages, and reading
/// all of it to say so is waste.
pub const SAMPLE_CHARS: usize = 20_000;
/// What language a text turned out to be.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Verdict {
/// Record version.
#[serde(default = "version")]
pub v: u32,
/// What was read.
pub path: String,
/// ISO 639-1, the two-letter code. Absent when it could not be told.
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// ISO 639-2/B, which is what the `doc` record carries.
#[serde(skip_serializing_if = "Option::is_none")]
pub bibliographic: Option<String>,
/// What the language is called in English.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// The writing system, which is an answer even when the language is not.
pub script: String,
/// How far ahead the winner was, from zero to one. One means nothing else
/// scored at all.
#[serde(with = "corpus_core::finite")]
#[schemars(with = "f64")]
pub confidence: f64,
/// What was worth saying about the reading.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
fn version() -> u32 {
corpus_core::V
}
impl corpus_core::Record for Verdict {
fn version(&self) -> u32 {
self.v
}
}
/// Read a text's language.
pub fn detect(path: &str, text: &str) -> Verdict {
let sample: String = text.chars().take(SAMPLE_CHARS).collect();
let (script, share) = script::detect(&sample);
let mut verdict = Verdict {
v: corpus_core::V,
path: path.to_string(),
language: None,
bibliographic: None,
name: None,
script: name_of(script).to_string(),
confidence: 0.0,
notes: Vec::new(),
};
// A script only one language uses is the whole answer, and no amount of
// word counting would improve on it.
if let Some(sole) = sole_language(script) {
verdict.language = Some(sole.0.to_string());
verdict.bibliographic = Some(sole.1.to_string());
verdict.name = Some(sole.2.to_string());
verdict.confidence = share;
verdict
.notes
.push(format!("{} is written only in {}", sole.2, name_of(script)));
return verdict;
}
let scored = words::score(&sample, script);
let Some((best, best_score)) = scored.first() else {
verdict.notes.push(match script {
Script::Other => "no letters to go on".to_string(),
script => format!(
"nothing here knows the languages written in {}",
name_of(script)
),
});
return verdict;
};
if *best_score == 0.0 {
verdict
.notes
.push("none of the commonest words of any language it knows".to_string());
return verdict;
}
// How far ahead of the runner-up, so a close call can be refused.
let runner_up = scored.get(1).map_or(0.0, |(_, score)| *score);
let ratio = match runner_up > 0.0 {
true => best_score / runner_up,
false => f64::INFINITY,
};
if ratio < words::MARGIN {
verdict.notes.push(format!(
"{} and {} score too alike to choose between",
best.name, scored[1].0.name
));
return verdict;
}
verdict.language = Some(best.code.to_string());
verdict.bibliographic = Some(best.bibliographic.to_string());
verdict.name = Some(best.name.to_string());
verdict.confidence = match runner_up > 0.0 {
true => (1.0 - runner_up / best_score).clamp(0.0, 1.0),
false => 1.0,
};
verdict
}
/// The one language a script is used for, where there is only one.
fn sole_language(script: Script) -> Option<(&'static str, &'static str, &'static str)> {
Some(match script {
Script::Kana => ("ja", "jpn", "Japanese"),
Script::Han => ("zh", "chi", "Chinese"),
Script::Hangul => ("ko", "kor", "Korean"),
Script::Hebrew => ("he", "heb", "Hebrew"),
Script::Arabic => ("ar", "ara", "Arabic"),
Script::Devanagari => ("hi", "hin", "Hindi"),
_ => return None,
})
}
/// What a script is called.
pub fn name_of(script: Script) -> &'static str {
match script {
Script::Latin => "Latin",
Script::Cyrillic => "Cyrillic",
Script::Greek => "Greek",
Script::Arabic => "Arabic",
Script::Hebrew => "Hebrew",
Script::Han => "Han",
Script::Kana => "Kana",
Script::Hangul => "Hangul",
Script::Devanagari => "Devanagari",
Script::Other => "unknown",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_paragraph_of_english_is_english() {
let found = detect(
"x.md",
"The obligation ran both ways, and contemporaries said so plainly whenever they were asked about it at all.",
);
assert_eq!(found.language.as_deref(), Some("en"));
assert_eq!(found.bibliographic.as_deref(), Some("eng"));
assert_eq!(found.script, "Latin");
assert!(found.confidence > 0.0);
}
#[test]
fn a_script_only_one_language_uses_needs_no_words() {
for (text, code) in [
("안녕하세요 세계 반갑습니다 오늘은", "ko"),
("これは日本語の文章です", "ja"),
("מרחבי שלום עולם וברוך הבא", "he"),
] {
assert_eq!(detect("x", text).language.as_deref(), Some(code), "{text}");
}
}
#[test]
fn nothing_to_read_is_no_answer_rather_than_a_wrong_one() {
let found = detect("x", "12345 -- ...");
assert_eq!(found.language, None);
assert_eq!(found.script, "unknown");
assert_eq!(found.notes, ["no letters to go on"]);
}
#[test]
fn latin_words_no_language_claims_are_refused() {
let found = detect("x", "zzzz qqqq xxxx vvvv wwww kkkk jjjj");
assert_eq!(found.language, None);
assert!(found.notes[0].starts_with("none of the commonest words"));
}
#[test]
fn a_close_call_is_refused_rather_than_guessed() {
// "de" is among the commonest words of four of these languages at
// once, and on its own settles none of them.
let found = detect("x", "de de de");
assert_eq!(found.language, None, "{:?}", found.notes);
assert!(found.notes[0].contains("too alike"), "{:?}", found.notes);
}
#[test]
fn only_the_opening_is_read() {
let long = format!(
"{}{}",
"The obligation ran both ways. ".repeat(2000),
"xyzzy"
);
assert!(long.len() > SAMPLE_CHARS * 2);
assert_eq!(detect("x", &long).language.as_deref(), Some("en"));
}
#[test]
fn the_script_is_reported_even_when_the_language_is_not() {
let found = detect("x", "zzzz qqqq xxxx vvvv");
assert_eq!(found.script, "Latin");
assert_eq!(found.language, None);
}
}
+93
View File
@@ -0,0 +1,93 @@
//! What language a document is in.
use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
use corpus_core::exit;
/// Say what language a text is in, one record per file.
///
/// The writing system settles most of the world outright; for the Latin and
/// Cyrillic alphabets the commonest words decide between what is left.
///
/// No model, no download, no network. A paragraph is plenty; a single line
/// often is not, and a close call is refused rather than guessed at.
#[derive(Parser)]
#[command(
version,
about,
after_help = "\
Exit codes: 0 every file was placed, 1 at least one could not be,
74 a file could not be read.
Example:
langid book.md
langid converted/*.md | jq -r 'select(.language != \"en\") | [.path, .name] | @tsv'"
)]
struct Args {
/// Files to read. Reads stdin when absent.
paths: Vec<PathBuf>,
/// Print the code alone, for one file.
#[arg(long)]
quiet: bool,
}
fn main() -> ExitCode {
let args: Args = match catena_cli::parse() {
Ok(args) => args,
Err(code) => return code,
};
let mut verdicts = Vec::new();
let mut unplaced = 0usize;
if args.paths.is_empty() {
let text = match catena_cli::read_lossy(None) {
Ok(text) => text,
Err((code, why)) => {
eprintln!("langid: {why}");
return ExitCode::from(code);
}
};
verdicts.push(langid::detect("-", &text));
} else {
for path in &args.paths {
let text = match catena_cli::read_lossy(Some(path)) {
Ok(text) => text,
Err((code, why)) => {
eprintln!("langid: {why}");
return ExitCode::from(code);
}
};
verdicts.push(langid::detect(&path.display().to_string(), &text));
}
}
unplaced += verdicts.iter().filter(|v| v.language.is_none()).count();
if args.quiet {
let codes: String = verdicts
.iter()
.map(|v| format!("{}\n", v.language.as_deref().unwrap_or("")))
.collect();
let wrote = catena_cli::text("langid", &codes);
if format!("{wrote:?}") != format!("{:?}", ExitCode::from(exit::OK)) {
return wrote;
}
return ExitCode::from(match unplaced {
0 => exit::OK,
_ => exit::NO,
});
}
let wrote = catena_cli::stream("langid", &verdicts);
if format!("{wrote:?}") != format!("{:?}", ExitCode::from(exit::OK)) {
return wrote;
}
ExitCode::from(match unplaced {
0 => exit::OK,
_ => exit::NO,
})
}
+175
View File
@@ -0,0 +1,175 @@
//! What writing system a text is in.
//!
//! For most of the world this is the whole answer: a text in Hangul is
//! Korean, a text in Hiragana is Japanese. For the Latin and Cyrillic
//! alphabets it narrows the field and no more, and the words have to decide.
use serde::{Deserialize, Serialize};
/// A writing system, as far as telling languages apart needs one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Script {
/// The alphabet most of Europe writes in.
Latin,
/// Russian, Ukrainian, Bulgarian, Serbian and their neighbours.
Cyrillic,
/// Greek.
Greek,
/// Arabic, Persian, Urdu.
Arabic,
/// Hebrew and Yiddish.
Hebrew,
/// Chinese characters, which Japanese also uses.
Han,
/// Japanese syllabaries, which only Japanese uses.
Kana,
/// Korean.
Hangul,
/// Devanagari: Hindi, Marathi, Sanskrit.
Devanagari,
/// Something else, or too little to tell.
Other,
}
/// How much of a text has to be in one script before it counts as that
/// script.
///
/// Well under half, because a book carries page numbers, names and quotations
/// in other alphabets, and an English word in a Russian paragraph does not
/// make it English.
const SHARE: f64 = 0.25;
/// The script a text is written in, and what share of its letters agree.
///
/// Counts letters only: digits and punctuation belong to every language and
/// would drown the signal in a text full of citations.
pub fn detect(text: &str) -> (Script, f64) {
let mut counts = [0usize; 10];
let mut letters = 0usize;
for c in text.chars() {
let Some(script) = of(c) else { continue };
letters += 1;
counts[index(script)] += 1;
}
if letters == 0 {
return (Script::Other, 0.0);
}
// Kana before Han: a Japanese text is mostly Han characters with kana
// between them, and it is the kana that say which language it is.
if counts[index(Script::Kana)] as f64 / letters as f64 > 0.05 {
return (
Script::Kana,
counts[index(Script::Kana)] as f64 / letters as f64,
);
}
let (at, count) = counts
.iter()
.enumerate()
.max_by_key(|(_, count)| **count)
.expect("ten scripts");
let share = *count as f64 / letters as f64;
match share >= SHARE {
true => (from_index(at), share),
false => (Script::Other, share),
}
}
fn index(script: Script) -> usize {
match script {
Script::Latin => 0,
Script::Cyrillic => 1,
Script::Greek => 2,
Script::Arabic => 3,
Script::Hebrew => 4,
Script::Han => 5,
Script::Kana => 6,
Script::Hangul => 7,
Script::Devanagari => 8,
Script::Other => 9,
}
}
fn from_index(at: usize) -> Script {
[
Script::Latin,
Script::Cyrillic,
Script::Greek,
Script::Arabic,
Script::Hebrew,
Script::Han,
Script::Kana,
Script::Hangul,
Script::Devanagari,
Script::Other,
][at]
}
/// The script one character belongs to, or `None` if it is not a letter.
///
/// By code point range rather than by a Unicode property table, because the
/// ranges that matter here are few, stable, and cheaper than carrying the
/// table.
pub fn of(c: char) -> Option<Script> {
let code = c as u32;
Some(match code {
0x41..=0x5A | 0x61..=0x7A => Script::Latin,
0xC0..=0x24F => Script::Latin, // Latin-1 through Extended-B
0x1E00..=0x1EFF => Script::Latin, // Latin Extended Additional
0x370..=0x3FF | 0x1F00..=0x1FFF => Script::Greek,
0x400..=0x52F => Script::Cyrillic,
0x590..=0x5FF => Script::Hebrew,
0x600..=0x6FF | 0x750..=0x77F => Script::Arabic,
0x900..=0x97F => Script::Devanagari,
0x3040..=0x30FF => Script::Kana, // hiragana and katakana, adjacent
0x4E00..=0x9FFF | 0x3400..=0x4DBF => Script::Han,
0xAC00..=0xD7AF | 0x1100..=0x11FF => Script::Hangul,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_script_is_recognised_from_its_own_words() {
for (text, script) in [
("The obligation ran both ways", Script::Latin),
("Молчание было единственным ответом", Script::Cyrillic),
("Καλημέρα κόσμε καὶ πάντες", Script::Greek),
("مرحبا بالعالم وأهلا", Script::Arabic),
("שלום עולם וברוך", Script::Hebrew),
("안녕하세요 세계 반갑습니다", Script::Hangul),
("नमस्ते दुनिया सब", Script::Devanagari),
] {
assert_eq!(detect(text).0, script, "{text}");
}
}
#[test]
fn kana_settles_japanese_against_chinese() {
// Both are mostly Han characters. The kana between them are the tell.
assert_eq!(detect("これは日本語の文章です").0, Script::Kana);
assert_eq!(detect("這是中文的句子沒有假名").0, Script::Han);
}
#[test]
fn a_stray_foreign_word_does_not_change_the_script() {
let mostly = "Молчание было единственным ответом на его вопрос about that";
assert_eq!(detect(mostly).0, Script::Cyrillic);
}
#[test]
fn digits_and_punctuation_are_not_evidence() {
assert_eq!(detect("123 456 -- ... 789").0, Script::Other);
assert_eq!(detect("").0, Script::Other);
}
#[test]
fn accented_latin_is_still_latin() {
assert_eq!(detect("naïve café résumé Grüße piñata").0, Script::Latin);
}
}
+355
View File
@@ -0,0 +1,355 @@
//! Telling apart the languages that share an alphabet, by their commonest
//! words.
//!
//! Function words — articles, prepositions, conjunctions, pronouns — make up
//! a third of any running text and differ sharply between languages even
//! closely related ones. Counting them needs no model, no training and no
//! network, and on a paragraph or more it is decisive.
//!
//! It is weak on a single line, which is why what comes out carries a margin
//! and a caller can refuse a close call.
use crate::script::Script;
/// One language, and the words that give it away.
pub struct Language {
/// ISO 639-1, the two-letter code everything else uses.
pub code: &'static str,
/// ISO 639-2/B, for the `doc` record's language field.
pub bibliographic: &'static str,
/// What it is called in English.
pub name: &'static str,
/// The script it is written in.
pub script: Script,
/// Its commonest function words, lowercased.
pub common: &'static [&'static str],
}
/// The languages this can tell apart.
///
/// General reference data, of the same kind as the HTML character references
/// and the ISBN check digit: true everywhere, not a fact about one
/// collection.
pub const LANGUAGES: &[Language] = &[
Language {
code: "en",
bibliographic: "eng",
name: "English",
script: Script::Latin,
common: &[
"the", "of", "and", "to", "in", "that", "is", "was", "it", "for", "with", "as", "his",
"on", "be", "at", "by", "this", "had", "not", "are", "but", "from", "or", "have", "an",
"they", "which", "were", "their",
],
},
Language {
code: "fr",
bibliographic: "fre",
name: "French",
script: Script::Latin,
common: &[
"le", "de", "un", "et", "être", "à", "il", "avoir", "ne", "je", "son", "que", "se",
"qui", "ce", "dans", "en", "du", "elle", "au", "pour", "pas", "que", "vous", "par",
"sur", "les", "des", "une", "plus",
],
},
Language {
code: "de",
bibliographic: "ger",
name: "German",
script: Script::Latin,
common: &[
"der", "die", "und", "in", "den", "von", "zu", "das", "mit", "sich", "des", "auf",
"für", "ist", "im", "dem", "nicht", "ein", "eine", "als", "auch", "es", "an", "werden",
"aus", "er", "hat", "dass", "sie", "nach",
],
},
Language {
code: "es",
bibliographic: "spa",
name: "Spanish",
script: Script::Latin,
common: &[
"de", "la", "que", "el", "en", "y", "a", "los", "del", "se", "las", "por", "un",
"para", "con", "no", "una", "su", "al", "es", "lo", "como", "más", "pero", "sus", "le",
"ya", "o", "este", "sí",
],
},
Language {
code: "it",
bibliographic: "ita",
name: "Italian",
script: Script::Latin,
common: &[
"di", "che", "e", "il", "la", "in", "un", "a", "per", "non", "una", "è", "con", "si",
"da", "le", "del", "i", "sono", "come", "al", "della", "nel", "più", "lo", "ma", "se",
"gli", "alla", "anche",
],
},
Language {
code: "pt",
bibliographic: "por",
name: "Portuguese",
script: Script::Latin,
common: &[
"de", "a", "o", "que", "e", "do", "da", "em", "um", "para", "com", "não", "uma", "os",
"no", "se", "na", "por", "mais", "as", "dos", "como", "mas", "ao", "ele", "das", "seu",
"sua", "ou", "quando",
],
},
Language {
code: "nl",
bibliographic: "dut",
name: "Dutch",
script: Script::Latin,
common: &[
"de", "van", "het", "een", "en", "in", "is", "dat", "op", "te", "zijn", "met", "voor",
"niet", "aan", "er", "om", "maar", "dan", "of", "door", "over", "ze", "bij", "ook",
"tot", "uit", "naar", "heeft", "werd",
],
},
Language {
code: "la",
bibliographic: "lat",
name: "Latin",
script: Script::Latin,
common: &[
"et", "in", "est", "non", "ad", "cum", "qui", "quod", "ut", "sed", "de", "si", "ex",
"quae", "per", "hoc", "esse", "enim", "autem", "nec", "atque", "sunt", "eius", "ab",
"quam", "tamen", "ita", "etiam", "ipse", "sive",
],
},
Language {
code: "pl",
bibliographic: "pol",
name: "Polish",
script: Script::Latin,
common: &[
"w", "i", "z", "na", "się", "do", "nie", "to", "że", "jest", "o", "a", "od", "po",
"za", "jak", "ale", "tym", "tego", "przez", "przy", "dla", "co", "który", "była",
"były", "być", "ma", "już", "tylko",
],
},
Language {
code: "sv",
bibliographic: "swe",
name: "Swedish",
script: Script::Latin,
common: &[
"och", "att", "det", "som", "en", "på", "är", "av", "för", "med", "till", "den", "har",
"de", "inte", "om", "ett", "han", "men", "var", "från", "vi", "så", "kan", "man",
"när", "hon", "sig", "eller", "efter",
],
},
Language {
code: "ru",
bibliographic: "rus",
name: "Russian",
script: Script::Cyrillic,
common: &[
"и", "в", "не", "на", "что", "он", "с", "как", "это", "по", "но", "они", "к", "у",
"же", "за", "из", "от", "то", "все", "был", "была", "было", "так", "его", "её", "для",
"бы", "или", "мы",
],
},
Language {
code: "uk",
bibliographic: "ukr",
name: "Ukrainian",
script: Script::Cyrillic,
common: &[
"і", "в", "не", "на", "що", "з", "як", "до", "це", "за", "від", "але", "той", "його",
"вона", "там", "бути", "має", "для", "та", "він", "ми", "ви", "чи", "або", "щоб",
"коли", "де", "так", "ще",
],
},
Language {
code: "el",
bibliographic: "gre",
name: "Greek",
script: Script::Greek,
common: &[
"και",
"το",
"της",
"των",
"του",
"στο",
"με",
"για",
"που",
"στη",
"είναι",
"από",
"σε",
"τα",
"ο",
"η",
"δεν",
"να",
"ως",
"κατά",
"μια",
"ένα",
"αλλά",
"αυτό",
"όπως",
"θα",
"τον",
"την",
"οι",
"στον",
],
},
];
/// How far ahead the best language must be before the answer is worth giving.
///
/// A ratio rather than a difference, so it means the same on a paragraph and
/// on a book.
pub const MARGIN: f64 = 1.25;
/// Score every language whose script matches, best first.
///
/// The score is the share of a text's words that are among a language's
/// commonest. It is comparable across languages because each list is the same
/// length.
pub fn score(text: &str, script: Script) -> Vec<(&'static Language, f64)> {
let words = tokens(text);
if words.is_empty() {
return Vec::new();
}
let mut scored: Vec<(&'static Language, f64)> = LANGUAGES
.iter()
.filter(|language| language.script == script)
.map(|language| {
let hits = words
.iter()
.filter(|word| language.common.contains(&word.as_str()))
.count();
(language, hits as f64 / words.len() as f64)
})
.collect();
scored.sort_by(|a, b| b.1.total_cmp(&a.1));
scored
}
/// A text's words, lowercased, with everything that is not a letter dropped.
fn tokens(text: &str) -> Vec<String> {
text.split(|c: char| crate::script::of(c).is_none())
.filter(|word| !word.is_empty())
.map(|word| word.to_lowercase())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn best(text: &str, script: Script) -> Option<&'static str> {
score(text, script)
.first()
.map(|(language, _)| language.code)
}
#[test]
fn each_language_is_recognised_from_a_sentence_of_it() {
for (text, code) in [
(
"The obligation ran both ways, and the lord who could not protect was not a lord at all in that time",
"en",
),
(
"Le seigneur qui ne pouvait pas protéger n'était pas un seigneur, et c'est ce que les rôles disent de lui",
"fr",
),
(
"Der Herr, der nicht schützen konnte, war kein Herr, und das ist es, was die Urkunden von ihm sagen",
"de",
),
(
"El señor que no podía proteger no era un señor, y eso es lo que dicen los registros de él en la época",
"es",
),
(
"Il signore che non poteva proteggere non era un signore, e questo è ciò che dicono i documenti di lui",
"it",
),
(
"O senhor que não podia proteger não era um senhor, e é isso que os registos dizem dele naquele tempo",
"pt",
),
(
"De heer die niet kon beschermen was geen heer, en dat is wat de bronnen over hem zeggen in die tijd",
"nl",
),
(
"Dominus qui non poterat protegere non erat dominus, et hoc est quod de eo dicunt scripta illius aetatis",
"la",
),
] {
assert_eq!(best(text, Script::Latin), Some(code), "{text}");
}
}
#[test]
fn the_cyrillic_pair_are_told_apart() {
assert_eq!(
best(
"Молчание было единственным ответом на его вопрос, и это всё что он услышал",
Script::Cyrillic
),
Some("ru")
);
assert_eq!(
best(
"Мовчання було єдиною відповіддю на його запитання, і це все що він почув від них",
Script::Cyrillic
),
Some("uk")
);
}
#[test]
fn greek_is_read_as_greek() {
assert_eq!(
best(
"Και το φως εν τη σκοτία φαίνει και η σκοτία αυτό δεν κατέλαβεν από την αρχή",
Script::Greek
),
Some("el")
);
}
#[test]
fn a_text_with_no_words_scores_nothing() {
assert!(score("", Script::Latin).is_empty());
assert!(score("123 456", Script::Latin).is_empty());
}
#[test]
fn only_languages_in_the_right_script_are_considered() {
let latin = score("the and of", Script::Latin);
assert!(
latin
.iter()
.all(|(language, _)| language.script == Script::Latin)
);
assert!(!latin.is_empty());
}
#[test]
fn every_language_has_the_same_number_of_words_so_scores_compare() {
let first = LANGUAGES[0].common.len();
for language in LANGUAGES {
assert_eq!(
language.common.len(),
first,
"{} has a different list",
language.code
);
}
}
}