Skip to main content

lib_humus/language/
language_engine.rs

1// SPDX-FileCopyrightText: 2026 Slatian <baschdel@disroot.org>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5use fluent::FluentArgs;
6use fluent::FluentResource;
7use fluent::FluentValue;
8use fluent::bundle::FluentBundle;
9use lib_humus_configuration::read_from_toml_file;
10use log::warn;
11
12use std::collections::HashMap;
13use std::error::Error;
14use std::fmt::Debug;
15use std::fmt::Display;
16use std::fs;
17use std::path::Path;
18use std::sync::Arc;
19
20use crate::language::LanguageEngineLoaderError;
21use crate::language::LanguageManifest;
22use crate::language::UnicodeLanguageIdentifier;
23use crate::language::variable_description::Variable;
24
25/// Specifies a [FluentBundle] with concurrency features enabled.
26///
27/// See <https://github.com/projectfluent/fluent-rs/issues/299>.
28pub type Bundle = FluentBundle<FluentResource, intl_memoizer::concurrent::IntlLangMemoizer>;
29
30/// Language engine that loads the translation files for fluent for multiple languages and provides them as an API.
31///
32/// It uses the concurrent variant of the [FluentBundle] under the hood.
33#[derive(Clone)]
34pub struct LanguageEngine {
35	/// Manifest data loaded from the `manifest.toml` file.
36	language_manifest: LanguageManifest,
37	/// Language bundles loaded from colocated fluent template files.
38	loaded_fluent_bundles: Arc<HashMap<UnicodeLanguageIdentifier, Bundle>>,
39}
40
41impl Debug for LanguageEngine {
42	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43		f.debug_struct("LanguageEngine")
44			.field("language_manifest", &self.language_manifest)
45			.field(
46				"loaded_fluent_bundles",
47				&self.loaded_fluent_bundles.keys().collect::<Vec<_>>(),
48			)
49			.finish()
50	}
51}
52
53impl LanguageEngine {
54	/// Create a new language engine without any entries. It uses an empty LanguageManifest that only contains the language undefined (`und`).
55	pub fn new_empty() -> Self {
56		let language: UnicodeLanguageIdentifier = unic_langid::langid!("und").into();
57		Self {
58			language_manifest: LanguageManifest::new_empty(),
59			loaded_fluent_bundles: Arc::new(
60				[(
61					language.clone(),
62					FluentBundle::new_concurrent(vec![language.into()]),
63				)]
64				.into_iter()
65				.collect(),
66			),
67		}
68	}
69
70	/// Loads translation data from the directory at `path`.
71	/// Expected layout:
72	/// * `{path}`
73	/// 	* `manifest.toml`
74	/// 	* `{language}.flt`
75	///
76	/// The `manifest.toml` file has the same schema as [LanguageManifest].
77	/// For each language defined in the manifest there must be a `.flt` file containing **all** messages needed for that language.
78	/// If a file is missing this will throw an error. Graceful degredation is not planned, if a language is missing it shouldn't be in the manifest.
79	pub fn load_from_directory(path: impl AsRef<Path>) -> Result<Self, LanguageEngineLoaderError> {
80		let base_dir = path.as_ref();
81		if !base_dir.is_dir() {
82			return Err(LanguageEngineLoaderError::BaseDirectoryDoesNotExist {
83				path: base_dir.to_path_buf(),
84				is_other: base_dir.exists(),
85			});
86		}
87		let manifest_file = base_dir.join("manifest.toml");
88		let manifest: LanguageManifest = read_from_toml_file(manifest_file)
89			.map_err(LanguageEngineLoaderError::ErrorReadingManifest)?;
90
91		// Test if default language fullfills its requirements
92		match manifest.languages.get(&manifest.default_language) {
93			Some(description) if description.is_hidden => {
94				return Err(LanguageEngineLoaderError::DefaultLanguageMustNotBeHidden {
95					default_language: manifest.default_language,
96				});
97			}
98			None => {
99				return Err(LanguageEngineLoaderError::DefaultLanguageNotInManifest {
100					default_language: manifest.default_language,
101				});
102			}
103			_ => { /* checks okay, do nothing */ }
104		}
105
106		let mut missing_messages: Vec<(UnicodeLanguageIdentifier, String)> = vec![];
107		let mut loaded_fluent_bundles: HashMap<UnicodeLanguageIdentifier, Bundle> = HashMap::new();
108		for (language, description) in &manifest.languages {
109			let path = base_dir.join(format!("{language}.ftl"));
110			let text = fs::read_to_string(&path).map_err(|e| {
111				LanguageEngineLoaderError::ErrorReadingLanguageFile {
112					language: language.clone(),
113					path: path.clone(),
114					io_error: e,
115				}
116			})?;
117			let resource = FluentResource::try_new(text).map_err(|(_, e)| {
118				LanguageEngineLoaderError::ErrorParsingLanguageFile {
119					language: language.clone(),
120					path: path.clone(),
121					errors: e,
122				}
123			})?;
124			let mut bundle = FluentBundle::new_concurrent(vec![language.clone().into()]);
125			// Custom functions could be added here if needed
126
127			bundle.add_resource(resource).map_err(|e| {
128				LanguageEngineLoaderError::ErrorAddingLanguageFile {
129					language: language.clone(),
130					path: path.clone(),
131					errors: e,
132				}
133			})?;
134
135			// Check translation for completeness
136			if !description.is_hidden {
137				for message_id in &manifest.available_messages {
138					if !bundle.has_message(message_id) {
139						missing_messages.push((language.clone(), message_id.clone()))
140					}
141				}
142			}
143
144			loaded_fluent_bundles.insert(language.clone(), bundle);
145		}
146
147		if !missing_messages.is_empty() {
148			let mut missing_messages_text: String = "".to_string();
149			for (lang, id) in missing_messages {
150				missing_messages_text = format!("{missing_messages_text}\t* in {lang}: {id:?}\n");
151			}
152			warn!("Some non-hidden languages are incomplete!");
153			warn!("Missing messages:\n{missing_messages_text}");
154			warn!("Those missing messages will cause template errors!");
155		}
156
157		Ok(Self {
158			language_manifest: manifest,
159			loaded_fluent_bundles: Arc::new(loaded_fluent_bundles),
160		})
161	}
162
163	/// Naive implementation of a text getter that always returns a text.
164	///
165	/// When errors occur they are logged using the log crate.
166	/// A placeholder text is used in case a message can't be reandered `[NOT TRANSLATEABLE {id} {args}]`.
167	pub fn naive_get_text(
168		&self,
169		language: Option<UnicodeLanguageIdentifier>,
170		message_id: &str,
171		args: Option<&FluentArgs>,
172	) -> String {
173		match self.get_text_with_raw_args(language, message_id, args) {
174			Ok(text) => {
175				return text;
176			}
177			Err(e) => {
178				log::error!("{e:?}");
179			}
180		}
181		if let Some(args) = args {
182			format!("[NOT TRANSLATEABLE {message_id:?} {args:?}]")
183		} else {
184			format!("[NOT TRANSLATEABLE {message_id:?}]")
185		}
186	}
187
188	/// Returns the localized message for a given message id without passing any arguments to the message template.
189	pub fn get_text_with_args(
190		&self,
191		language: Option<UnicodeLanguageIdentifier>,
192		message_id: &str,
193		args: impl IntoIterator<Item = (impl Into<String>, Variable)>,
194	) -> Result<String, TextFunctionError> {
195		let language = language.unwrap_or_else(|| self.language_manifest.default_language.clone());
196		if let Some(message_description) =
197			self.language_manifest.message_descriptions.get(message_id)
198		{
199			let mut arg_problems: TextFunctionArgumentProblems = Default::default();
200			let mut fluent_args = FluentArgs::new();
201
202			for (name, value) in args {
203				let name = name.into();
204				if let Some(arg_description) = message_description.arguments.get(&name) {
205					if !arg_description.matches_variable(&value) {
206						arg_problems
207							.description_mismatch
208							.push((name.clone(), value));
209						fluent_args.set(name, FluentValue::Error);
210						continue;
211					}
212					// set even on mismatch to not confuse the the missing value check
213					fluent_args.set(name, value.into_fluent_value(arg_description));
214				} else {
215					arg_problems.too_many.push(name);
216				}
217			}
218
219			// Find missing arguments
220			for (name, desc) in &message_description.arguments {
221				if fluent_args.get(name).is_some() {
222					continue;
223				}
224				if let Some(value) = &desc.default_value {
225					fluent_args.set(name, value.clone().into_fluent_value(desc));
226				} else if !desc.optional {
227					arg_problems.missing.push(name.to_owned());
228				}
229			}
230
231			if !arg_problems.is_empty() {
232				return Err(TextFunctionError::new(
233					&language,
234					message_id,
235					TextFunctionErrorKind::ArgumentError(Box::new(arg_problems)),
236				));
237			}
238
239			self.get_text_with_raw_args(Some(language), message_id, Some(&fluent_args))
240		} else {
241			let mut too_many_arguments: Vec<String> = vec![];
242			for (key, _) in args {
243				too_many_arguments.push(key.into())
244			}
245			if too_many_arguments.is_empty() {
246				self.get_text_with_raw_args(Some(language), message_id, None)
247			} else {
248				Err(TextFunctionError::new(
249					&language,
250					message_id,
251					TextFunctionErrorKind::ArgumentError(Box::new(TextFunctionArgumentProblems {
252						too_many: too_many_arguments,
253						missing: vec![],
254						description_mismatch: vec![],
255					})),
256				))
257			}
258		}
259	}
260
261	/// Returns the localized message for a given message id without passing any arguments to the message template.
262	pub fn get_text(
263		&self,
264		language: Option<UnicodeLanguageIdentifier>,
265		message_id: &str,
266	) -> Result<String, TextFunctionError> {
267		let language = language.unwrap_or_else(|| self.language_manifest.default_language.clone());
268
269		// Test if the the function requires any arguments
270		if let Some(message_description) =
271			self.language_manifest.message_descriptions.get(message_id)
272			&& !message_description.arguments.is_empty()
273		{
274			let mut arg_problems: TextFunctionArgumentProblems = Default::default();
275			for (name, desc) in &message_description.arguments {
276				if !(desc.optional || desc.default_value.is_some()) {
277					arg_problems.missing.push(name.to_owned());
278				}
279			}
280			if !arg_problems.is_empty() {
281				return Err(TextFunctionError::new(
282					&language,
283					message_id,
284					TextFunctionErrorKind::ArgumentError(Box::new(arg_problems)),
285				));
286			}
287		}
288		self.get_text_with_raw_args(Some(language), message_id, None)
289	}
290
291	/// Returns the translated text for a given language, message id and potential arguments
292	///
293	/// This will bypass argument checking from the language manifest file, use with care and document well.
294	pub fn get_text_with_raw_args(
295		&self,
296		language: Option<UnicodeLanguageIdentifier>,
297		message_id: &str,
298		args: Option<&FluentArgs>,
299	) -> Result<String, TextFunctionError> {
300		let language = language.unwrap_or_else(|| self.language_manifest.default_language.clone());
301		if !self
302			.language_manifest
303			.available_messages
304			.contains(message_id)
305		{
306			return Err(TextFunctionError::new(
307				&language,
308				message_id,
309				TextFunctionErrorKind::MessageIdNotListedAvailable,
310			));
311		}
312		let bundle = self.loaded_fluent_bundles.get(&language).ok_or_else(|| {
313			TextFunctionError::new(
314				&language,
315				message_id,
316				TextFunctionErrorKind::LanguageIsNotLoadedOrPresent,
317			)
318		})?;
319		let message = bundle.get_message(message_id).ok_or_else(|| {
320			TextFunctionError::new(
321				&language,
322				message_id,
323				TextFunctionErrorKind::MessageNotPresentInLanguage,
324			)
325		})?;
326		let pattern = message.value().ok_or_else(|| {
327			TextFunctionError::new(
328				&language,
329				message_id,
330				TextFunctionErrorKind::NoPatternForMessage,
331			)
332		})?;
333		let mut errors = vec![];
334		let out = bundle.format_pattern(pattern, args, &mut errors);
335		if !errors.is_empty() {
336			log::error!(
337				"Non fatal problems occurred while translating text {message_id:?} for language {language}: {errors:#?}"
338			);
339		}
340		Ok(out.to_string())
341	}
342
343	/// Returns access to the underlying language manifest parsed from the `languages/manifest.toml` file.
344	pub fn language_manifest(&self) -> &LanguageManifest {
345		&self.language_manifest
346	}
347}
348
349#[derive(Debug, Default)]
350pub struct TextFunctionArgumentProblems {
351	pub too_many: Vec<String>,
352	pub missing: Vec<String>,
353	pub description_mismatch: Vec<(String, Variable)>,
354}
355
356impl TextFunctionArgumentProblems {
357	pub fn is_empty(&self) -> bool {
358		self.too_many.is_empty() && self.missing.is_empty() && self.description_mismatch.is_empty()
359	}
360}
361
362impl Display for TextFunctionArgumentProblems {
363	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364		write!(
365			f,
366			"uneccessary arguments: {:?}; missing_arguments: {:?}; description mismatches: {:?}",
367			self.too_many, self.missing, self.description_mismatch
368		)
369	}
370}
371
372/// Error that is returned when retrieving a translated text fails
373#[derive(Debug)]
374pub struct TextFunctionError {
375	/// The language that was translated to while the error happened
376	pub language: UnicodeLanguageIdentifier,
377	/// The message id the error happened for
378	pub message_id: String,
379	/// The kind of problem that occurred
380	pub kind: TextFunctionErrorKind,
381}
382
383impl TextFunctionError {
384	/// Convenience contructor to create a new TextFunctionError form borrowed values
385	pub fn new(
386		language: &UnicodeLanguageIdentifier,
387		message_id: &str,
388		kind: TextFunctionErrorKind,
389	) -> Self {
390		Self {
391			language: language.clone(),
392			message_id: message_id.to_owned(),
393			kind,
394		}
395	}
396}
397
398#[derive(Debug)]
399pub enum TextFunctionErrorKind {
400	MessageIdNotListedAvailable,
401	NoPatternForMessage,
402	MessageNotPresentInLanguage,
403	LanguageIsNotLoadedOrPresent,
404	ArgumentError(Box<TextFunctionArgumentProblems>),
405}
406
407impl Display for TextFunctionError {
408	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409		let id = &self.message_id;
410		let language = &self.language;
411		match &self.kind {
412			TextFunctionErrorKind::MessageIdNotListedAvailable => write!(
413				f,
414				"The messagge id {id:?} isn't listed as an available id in the language manifest. If this message should exist please register its name in the available_ids array!"
415			),
416			TextFunctionErrorKind::NoPatternForMessage => write!(
417				f,
418				"Unable to translate text {id:?} for language {language}, message present but it does not carry a pattern"
419			),
420			TextFunctionErrorKind::MessageNotPresentInLanguage => write!(
421				f,
422				"Unable to translate text {id:?} for language {language}, message not present in this language"
423			),
424			TextFunctionErrorKind::LanguageIsNotLoadedOrPresent => write!(
425				f,
426				"Unable to translate texts for language {language}, language is not loaded or not present in template."
427			),
428			TextFunctionErrorKind::ArgumentError(e) => {
429				write!(f, "Problem with passed arguments for message {id:?}: {e}")
430			}
431		}
432	}
433}
434
435impl Error for TextFunctionError {
436	fn source(&self) -> Option<&(dyn Error + 'static)> {
437		None
438	}
439}