legacytranslate 0.2.0

Internationalization library of legacylisten.
Documentation
/*
    legacytranslate – Internationalization library of legacylisten.
    Copyright (C) 2022  Matthias Kaak

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see <https://www.gnu.org/licenses/>.
*/

//! Internationalization library of legacylisten.
//!
//! The internationalization module of legacylisten was extracted and
//! made to it's own crate – this one – to improve readability and
//! reusability.
//!
//! [`L10n`] is the central type of this crate, it's supposed to hold
//! all language information of the program (although you can have
//! multiple `L10n`s if you e.g. want multiple languages).  It's
//! [`write`](L10n::write) method prints the message given to it.
//! Messages must implement the [`Message`] trait which converts an
//! easy to use type to all the data that are needed under the hood.
//!
//! `L10n`s are constructed out of a value that implements the
//! [`Lang`] trait.
//!
//! See [`legacylisten`](https://crates.io/crates/legacylisten) to see
//! how this crate is best used.
//!
//! ## Contributing
//! As every software `legacytranslate` too always can be improved.
//! While I'm trying to get it usable alone, I don't have unlimited
//! time and especially not always the best ideas.  If you can help
//! with that or on some other way (like with a feature request or
//! documentation improvements) **please help**.
//!
//! I assume that unless stated otherwise every contribution follows the
//! necessary license.
//!
//! ## License
//! Though unusual for a rust program, `legacytranslate` is released
//! under the GNU General Public License version 3 or (at your option)
//! any later version.
//!
//! For more see
//! [LICENSE.md](https://codeberg.org/zvavybir/legacytranslate/src/branch/master/LICENSE.md).

#![warn(
    clippy::all,
    clippy::pedantic,
    clippy::nursery,
    clippy::cargo_common_metadata
)]
// Anachronism
#![allow(clippy::non_ascii_literal)]
// More or less manual checked and documentation agrees with me that
// it's usually not needed.
#![allow(
    clippy::cast_possible_truncation,
    clippy::cast_sign_loss,
    clippy::cast_precision_loss,
    clippy::cast_lossless
)]
// Explicitly decided against; I think `let _ = …` is better than
// `mem::drop(…)`. TODO: align my opinion and community's one with
// each other.
#![allow(let_underscore_drop)]

use std::{sync::Mutex, thread};

use crossbeam_channel::{unbounded, Receiver, Sender};
use diskit::Diskit;
use either::Either;
use fluent::{types::FluentNumber, FluentArgs, FluentBundle, FluentResource, FluentValue};
use unic_langid::LanguageIdentifier;

#[cfg(feature = "log")]
use log::{debug, error, info, trace, warn};

mod message_handler;

mod err;

pub use err::Error;
pub use message_handler::{MessageBuffer, MessageHandler};

// Rustdoc complains without that since it can't see that it's missing
// due to an feature.
#[cfg_attr(not(feature = "log"), allow(rustdoc::broken_intra_doc_links))]
/// Customize terminal output
///
/// All output is done over the `Writer` given to [`L10n::new`].  The
/// default one is [`standard_write_handler`] (this requires the `log`
/// feature to be acitvated).  See it's source code on how to best
/// implement your own.
pub type Writer = crossbeam_channel::Sender<(LogLevel, String)>;

/// Selection of available languages.
///
/// This trait is used to specify the language that should be used.
pub trait Lang
{
    /// Deconstruct language
    ///
    /// This function returns the data associated with the language (and all it's backups).
    /// The first piece is the full list of all translated messages in
    /// the standard fluent syntax, the second one is a fluent language identifier.
    ///
    /// The first value of the `Vec` is the main language, the others
    /// are backups.  The languages are tried from front to back.
    ///
    /// # Errors
    /// If something fails it returns an error of type
    /// [`legacytranslate::Error`](Error).  If this type isn't
    /// sufficient, use the `Custom` variant of it.
    fn deconstruct_lang_id<D>(self, diskit: D) -> Result<Vec<(String, LanguageIdentifier)>, Error>
    where
        D: Diskit;
}

/// Message trait
///
/// Values implementing this trait can be used as messages in
/// `legacytranslate`.  Messages must be deconstructable into the
/// pieces that [`fluent`] needs to work.  See the methods
/// documentation for more information.
pub trait Message
{
    /// Returns message name.
    ///
    /// This function returns the message *name*, not the message
    /// *itself*.
    ///
    /// This example is taken from
    /// [`legacylisten`](https://crates.io/crates/legacylisten).
    /// ```no_run
    /// # use legacylisten::l10n::messages::Message;
    /// assert_eq!(
    ///     Message::TotalPlayingLikelihood(5).to_str(),
    ///     "total-playing-likelihood"
    /// );
    /// assert_ne!(
    ///     Message::TotalPlayingLikelihood(5).to_str(),
    ///     "Total playing likelihood: 5"
    /// );
    /// ```
    fn to_str(&self) -> &'static str;

    /// Deconstructs the message to use with fluent.
    ///
    /// This method deconstructs the additional data of a given
    /// message so that [`fluent`] can handle it.  Every element of
    /// the returned [`Vec`] consists out of the name of the value and
    /// then an [`Either`](either::Either) of the actuall value.
    fn into_vec(self) -> Vec<(&'static str, Either<String, FluentNumber>)>;

    /// Returns log level of a message.
    ///
    /// Returns the log level of the message. See [`LogLevel`] for
    /// more information.
    fn loglevel(&self) -> LogLevel;
}

/// Handle for getting translated messages.
///
/// The mathods on this function can be used to get the translation of
/// a message.
#[derive(Clone, Copy)]
pub struct L10n
{
    inner: &'static Mutex<(Command, Answer, Writer)>,
}

/// Available log levels for outputing.
///
/// If a message is outputed with [`L10n::write`](L10n::write) these
/// are the available ways to do that.  Most of these are log levels,
/// but some are just [`println!`-ing](std::println) or `panic`-ing
/// it.
#[derive(Copy, Clone, Debug)]
pub enum LogLevel
{
    Error,
    Warn,
    Info,
    Debug,
    Trace,
    Println,
    Unreachable,
}

struct L10nInner
{
    bundles: Vec<FluentBundle<FluentResource>>,
}

type KeyType = &'static str;
type ArgSliceType = Vec<(&'static str, Either<String, FluentNumber>)>;
type Command = Sender<(KeyType, ArgSliceType)>;
type Answer = Receiver<String>;

impl L10nInner
{
    fn new<L, D>(lang: L, diskit: D) -> Result<Self, Error>
    where
        L: Lang,
        D: Diskit,
    {
        fn inner(
            (s, lang): (String, LanguageIdentifier),
        ) -> Result<FluentBundle<FluentResource>, Error>
        {
            let mut bundle = FluentBundle::new(vec![lang]);
            bundle.add_resource(FluentResource::try_new(s).map_err(|(_, x)| x)?)?;
            Ok(bundle)
        }

        let langs = lang
            .deconstruct_lang_id(diskit)?
            .into_iter()
            .map(inner)
            .collect::<Result<Vec<_>, _>>()?;

        if langs.is_empty()
        {
            return Err(Error::NoLanguage);
        }

        Ok(Self { bundles: langs })
    }

    fn get_raw(
        bundle: &FluentBundle<FluentResource>,
        key: &str,
        args: Option<&FluentArgs>,
    ) -> Result<String, Error>
    {
        let mut errors = vec![];

        let msg = bundle.format_pattern(
            bundle
                .get_message(key)
                .ok_or_else(|| format!("Message doesn't exist: {key:?}"))
                .map(|msg| {
                    msg.value()
                        .ok_or_else(|| format!("Message has no value: {key:?}",))
                })
                .and_then(|x| x)?,
            args,
            &mut errors,
        );

        if !errors.is_empty()
        {
            return Err(errors.into());
        }

        Ok(msg.to_string())
    }

    fn get(&self, key: &KeyType, arg_slice: ArgSliceType) -> String
    {
        let args = if arg_slice.is_empty()
        {
            None
        }
        else
        {
            let mut args = FluentArgs::new();
            for (key, value) in arg_slice
            {
                match value
                {
                    Either::Left(s) => args.set(key, FluentValue::from(s)),
                    Either::Right(s) => args.set(key, FluentValue::from(s)),
                }
            }

            Some(args)
        };

        self.bundles
            .iter()
            .map(|bundle| Self::get_raw(bundle, key, args.as_ref()))
            .next()
            .expect("There should always be at least one language.")
            .expect("Error with l10n.")
    }
}

impl L10n
{
    /// Creates translation handle.
    ///
    /// Creates a new handle for getting the translation of messages.
    /// This function should not be called multiple times, because
    /// [`clone`-ing](std::clone::Clone) is often sufficient (except
    /// if you want multiple languages).
    ///
    /// With the `writer` argument you can customize how the messages
    /// get [`writ`-ten](Self::write).  See [`Writer`] for more
    /// details.
    ///
    /// # Errors
    /// It returns an error if the creation failed.
    /// # Panics
    /// It panics if something fails horrible and it's not the one
    /// case that can be catch be returning an error.
    pub fn new<L, D>(lang: L, writer: Writer, diskit: D) -> Result<Self, Error>
    where
        L: Lang + Send + 'static,
        D: Diskit + Send + 'static,
    {
        let (tx_error, rx_error) = unbounded();
        let (tx_com, rx_com) = unbounded();
        let (tx_data, rx_data) = unbounded();

        thread::spawn(move || match L10nInner::new(lang, diskit)
        {
            Ok(lang) =>
            {
                tx_error
                    .send(Ok(Self {
                        inner: Box::leak(Box::new(Mutex::new((tx_com, rx_data, writer)))),
                    }))
                    .expect("Failed to initialise l10n");

                while let Ok((key, arg_slice)) = rx_com.recv()
                {
                    tx_data
                        .send(lang.get(&key, arg_slice))
                        .expect("Failed to answer l10n info");
                }
            }
            Err(err) => tx_error
                .send(Err(err))
                .expect("Failed to signal the failing of initialising of l10n"),
        });

        rx_error.recv().map_err(Into::into).and_then(|x| x)
    }

    fn get_raw(self, key: &'static str, arg_slice: ArgSliceType) -> String
    {
        let lock = self
            .inner
            .lock()
            .expect("Lock over l10n struct is poisoned");

        lock.0
            .send((key, arg_slice))
            .expect("Can't request l10n info");
        lock.1.recv().expect("Can't get l10n info")
    }

    /// Returns the translation of a message.
    ///
    /// Returns the translation of a message with all values fitted in
    /// already.
    ///
    /// # Panics
    /// This function can panic if a message cannot be translated.
    /// This can only happen if neither the language itself nor any
    /// backup has it.  Because of this your best supported language
    /// (which is ideally English, since it's the *lingua franca* of
    /// the world and of the software world currently) should always
    /// be the final backup, so that it is guaranteed to work.
    #[must_use]
    pub fn get<M>(self, message: M) -> String
    where
        M: Message,
    {
        self.get_raw(message.to_str(), message.into_vec())
    }
}

impl<M> MessageHandler<M> for L10n
where
    M: Message,
{
    /// Outputs a message translated.
    ///
    /// Outputs the translation of a message in a way specified for
    /// the message.
    ///
    /// # Panics
    /// This function can panic if a message cannot be translated.
    /// This can only happen if neither the language itself nor any
    /// backup has it.  Because of this your best supported language
    /// (which is ideally English, since it's the *lingua franca* of
    /// the world and of the software world currently) should always
    /// be the final backup, so that it is guaranteed to work.
    fn write(&self, message: M)
    {
        let loglevel = message.loglevel();
        let msg = self.get(message);

        self.inner
            .lock()
            .expect("Panicing due to previous panic.")
            .2
            .send((loglevel, msg))
            .expect("Write handler has stopped.");
    }
}

#[cfg(feature = "log")]
/// The standard [`Writer`]
///
/// This is the standard [`Writer`] of legacylisten.  See [`Writer`]
/// for more information.
#[must_use]
pub fn standard_write_handler() -> crossbeam_channel::Sender<(LogLevel, String)>
{
    let (tx, rx) = unbounded();

    thread::spawn(move || {
        while let Ok((loglevel, msg)) = rx.recv()
        {
            match loglevel
            {
                LogLevel::Error => error!("{msg}"),
                LogLevel::Warn => warn!("{msg}"),
                LogLevel::Info => info!("{msg}"),
                LogLevel::Debug => debug!("{msg}"),
                LogLevel::Trace => trace!("{msg}"),
                LogLevel::Println => println!("{msg}"),
                LogLevel::Unreachable => unreachable!("{msg}"),
            }
        }
    });

    tx
}