diff --git a/.gitignore b/.gitignore index 080b6e1..4e9bcbb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ *.swp examples/gmotest examples/gmodump -*.mo log hgettext messages.po diff --git a/Text/Localize/Locale.hs b/Text/Localize/Locale.hs deleted file mode 100644 index 0546129..0000000 --- a/Text/Localize/Locale.hs +++ /dev/null @@ -1,17 +0,0 @@ --- | This module contains definitions related to --- obtaining current localization settings from --- process's locale. -module Text.Localize.Locale where - -import Data.Maybe -import System.Locale.SetLocale - -import Text.Localize.Types - --- | Obtain language to be used from process's locale. -languageFromLocale :: IO LanguageId -languageFromLocale = do - mbLocale <- setLocale LC_MESSAGES (Just "") - let locale = fromMaybe "C" mbLocale - return $ takeWhile (/= '_') locale - diff --git a/examples/Makefile b/examples/Makefile index 0cd9462..e5a7504 100644 --- a/examples/Makefile +++ b/examples/Makefile @@ -21,4 +21,3 @@ clean: find . -name \*.hi -delete find . -name \*.mo -delete rm -f gmotest gmodump - diff --git a/examples/gmotest.hs b/examples/gmotest.hs index 74704ae..0697142 100644 --- a/examples/gmotest.hs +++ b/examples/gmotest.hs @@ -14,7 +14,7 @@ import Data.Text.Format.Heavy (Single (..)) import Text.Localize data LocState = LocState { - lsTranslations :: Translations, + lsTranslations :: Translations, lsLanguage :: LanguageId } deriving (Show) @@ -49,4 +49,3 @@ main = runLoc $ do hello name setLang "fr" hello name - diff --git a/examples/iotest.hs b/examples/iotest.hs index 00f0824..bbd5534 100644 --- a/examples/iotest.hs +++ b/examples/iotest.hs @@ -26,8 +26,7 @@ main = do TLIO.putStr =<< __ "Your name: " hFlush stdout name <- liftIO $ TLIO.getLine - + withLanguage "en" $ hello name withLanguage "ru" $ hello name withLanguage "fr" $ hello name - diff --git a/localize.cabal b/localize.cabal index 766a9ec..a1d3f20 100644 --- a/localize.cabal +++ b/localize.cabal @@ -1,44 +1,58 @@ +cabal-version: 2.2 name: localize version: 0.2.0.1 synopsis: GNU Gettext-based messages localization library -description: More or less fully functional translation framework, - based on @haskell-gettext@ and @text-format-heavy@ - packages. -license: BSD3 +description: Translation framework based on @haskell-gettext@ and + @text-format-heavy@ packages. +license: BSD-3-Clause license-file: LICENSE author: IlyaPortnov maintainer: portnov84@rambler.ru --- copyright: +copyright: © 2013–2022 category: Text build-type: Simple extra-doc-files: README.md -cabal-version: 1.18 +extra-source-files: test/mock-program/test.hs, + test/mock-program/en_GB.mo + +common shared + build-depends: base == 4.*, + ghc-options: -Wall + default-language: Haskell2010 library + import: shared exposed-modules: Text.Localize, - Text.Localize.Types, + Text.Localize.IO + other-modules: Text.Localize.Types, Text.Localize.Load, - Text.Localize.State, - Text.Localize.IO, - Text.Localize.Locale - build-depends: base > 4 && < 5, - mtl >= 2.2.1, - binary >=0.7, + Text.Localize.State + build-depends: binary >=0.7 && < 0.9, + containers >=0.5 && < 0.9, bytestring >= 0.10 && < 0.13, - text >= 1.2, - data-default >= 0.7, - containers >=0.5, + data-default >= 0.7 && < 0.9, + directory >= 1.3 && < 1.4, + filepath >= 1.4 && < 1.6, + Glob >= 0.7.14 && < 0.11, + haskell-gettext >= 0.1.1.0 && < 0.2, + mtl >= 2.2.1 && < 2.4, + text >= 1.2 && < 2.2, text-format-heavy >= 0.1.4.0 && < 1.6, - haskell-gettext >= 0.1.1.0, - time >=1.4, - transformers >=0.3, - filepath >= 1.4, - directory >= 1.3, - Glob >= 0.7.14, - setlocale >= 1.0 - default-language: Haskell2010 + time >=1.4 && < 1.15, + transformers >=0.3 && < 0.7, + setlocale >= 1.0 && < 1.1 + hs-source-dirs: src + +test-suite test + import: shared + main-is: Test.hs + other-modules: Unit.LocalizeSpec + build-depends: localize, + hspec >= 2.11 && < 2.12 + build-tool-depends: hspec-discover:hspec-discover + hs-source-dirs: test + type: exitcode-stdio-1.0 -Source-repository head +source-repository head type: git location: https://github.com/portnov/localize.git - diff --git a/Text/Localize.hs b/src/Text/Localize.hs similarity index 87% rename from Text/Localize.hs rename to src/Text/Localize.hs index 6d70cd3..5e19262 100644 --- a/Text/Localize.hs +++ b/src/Text/Localize.hs @@ -8,34 +8,54 @@ module Text.Localize -- * Most used functions __, __n, __f, -- * Basic functions - translate, translateN, translateNFormat, + translate, translateN, translateNFormat, lookup, withTranslation, + -- * Reexports - module Text.Localize.Types, - module Text.Localize.Load, - module Text.Localize.Locale + + -- ** Locale + languageFromLocale, + + -- ** Load + Facet, + LocatePolicy(..), + loadTranslations, + locateTranslations, + linuxLocation, + localLocation, + + -- ** Types + LanguageId, + Context, + TranslationSource, + Translations(..), + availableLanguages, + Localized(..), + toText, + + -- ** Monad + LocState(..), + Localize, + runLocalize, + LocalizeT, + runLocalizeT, -- TODO add execLocalizeT and execLocalize + setLanguage, + withLanguage, + setContext, + withContext + ) where import Prelude hiding (lookup) -import Control.Applicative -import Control.Monad import qualified Data.Map as M -import qualified Data.ByteString as B -import qualified Data.ByteString.Lazy as L import qualified Data.Text.Lazy as T -import qualified Data.Text.Lazy.Encoding as TLE -import qualified Data.Text.Encoding as TE -import Data.String -import Data.List hiding (lookup) -import Data.Monoid -import Data.Typeable import qualified Data.Gettext as Gettext import qualified Data.Text.Format.Heavy as F import Data.Text.Format.Heavy.Parse (parseFormat) import Text.Localize.Types import Text.Localize.Load -import Text.Localize.Locale +import Text.Localize.State -- $description -- @@ -54,16 +74,16 @@ import Text.Localize.Locale -- import qualified Data.Text as T -- import qualified Data.Text.Lazy.IO as TLIO -- import Text.Localize --- +-- -- newtype MyMonad a = MyMonad {unMyMonad :: ... } -- deriving (Monad) --- +-- -- instance Localized MyMonad where -- ... -- -- runMyMonad :: Translations -> MyMonad a -> IO a -- runMyMonad = ... --- +-- -- hello :: T.Text -> MyMonad () -- hello name = do -- liftIO $ TLIO.putStrLn =<< __ "Your name: " @@ -116,15 +136,15 @@ translate orig = do Nothing -> return $ Gettext.gettext gmo orig Just ctxt -> return $ Gettext.cgettext gmo ctxt orig --- | Short alias for @translate@. +-- | Short alias for 'translate'. __ :: (Localized m) => TranslationSource -> m T.Text __ = translate -- | Translate a string, taking plural forms into account. translateN :: (Localized m) - => TranslationSource -- ^ Single form in original language - -> TranslationSource -- ^ Plural form in original language - -> Int -- ^ Number + => TranslationSource -- ^ Single form in original language. + -> TranslationSource -- ^ Plural form in original language. + -> Int -- ^ Number. -> m T.Text translateN orig plural n = do t <- getTranslations @@ -140,8 +160,8 @@ translateN orig plural n = do -- | Translate a string and substitute variables into it. -- Data.Text.Format.Heavy.format syntax is used. translateFormat :: (Localized m, MonadFail m, F.VarContainer vars) - => TranslationSource -- ^ Original formatting string - -> vars -- ^ Substitution variables + => TranslationSource -- ^ Original formatting string. + -> vars -- ^ Substitution variables. -> m T.Text translateFormat orig vars = do fmtStr <- translate orig @@ -157,10 +177,10 @@ __f = translateFormat -- and substitute variables into it. -- Data.Text.Format.Heavy.format syntax is used. translateNFormat :: (Localized m, MonadFail m, F.VarContainer vars) - => TranslationSource -- ^ Single form of formatting string in original language - -> TranslationSource -- ^ Plural form of formatting string in original language - -> Int -- ^ Number - -> vars -- ^ Substitution variables + => TranslationSource -- ^ Single form of formatting string in original language. + -> TranslationSource -- ^ Plural form of formatting string in original language. + -> Int -- ^ Number. + -> vars -- ^ Substitution variables. -> m T.Text translateNFormat orig plural n vars = do fmtStr <- translateN orig plural n @@ -171,4 +191,3 @@ translateNFormat orig plural n vars = do -- | Short alias for @translateNFormat@. __n :: (Localized m, MonadFail m, F.VarContainer c) => TranslationSource -> TranslationSource -> Int -> c -> m T.Text __n = translateNFormat - diff --git a/Text/Localize/IO.hs b/src/Text/Localize/IO.hs similarity index 72% rename from Text/Localize/IO.hs rename to src/Text/Localize/IO.hs index 8bff6a1..2e85317 100644 --- a/Text/Localize/IO.hs +++ b/src/Text/Localize/IO.hs @@ -11,30 +11,8 @@ module Text.Localize.IO ) where import Data.IORef -import System.IO.Unsafe (unsafePerformIO) - import Text.Localize.Types import Text.Localize.Load -import Text.Localize.Locale - -currentContext :: IORef (Maybe Context) -currentContext = unsafePerformIO $ newIORef Nothing -{-# NOINLINE currentContext #-} - -currentLanguage :: IORef LanguageId -currentLanguage = unsafePerformIO $ do - language <- languageFromLocale - newIORef language -{-# NOINLINE currentLanguage #-} - -currentTranslations :: IORef Translations -currentTranslations = unsafePerformIO $ newIORef undefined -{-# NOINLINE currentTranslations #-} - -instance Localized IO where - getLanguage = readIORef currentLanguage - getTranslations = readIORef currentTranslations - getContext = readIORef currentContext -- | This function must be called before any translation function call, -- otherwise you will get runtime error. @@ -73,4 +51,3 @@ withContext ctxt actions = do result <- actions setContext oldContext return result - diff --git a/Text/Localize/Load.hs b/src/Text/Localize/Load.hs similarity index 95% rename from Text/Localize/Load.hs rename to src/Text/Localize/Load.hs index 33195ac..e5cd52a 100644 --- a/Text/Localize/Load.hs +++ b/src/Text/Localize/Load.hs @@ -1,11 +1,11 @@ {-# LANGUAGE TypeSynonymInstances, FlexibleInstances, ExistentialQuantification, DeriveDataTypeable, OverloadedStrings, RecordWildCards #-} -- | This module contains definitions for loading translation catalogs. module Text.Localize.Load - ( -- * Data types + ( -- * Data types. LocatePolicy (..), Facet, - -- * Main functions + -- * Main functions. loadTranslations, locateTranslations, - -- * Commonly used location policies + -- * Commonly used location policies. linuxLocation, localLocation ) where @@ -17,12 +17,12 @@ import qualified Data.Text.Lazy as T import qualified Data.Gettext as Gettext import Data.Text.Format.Heavy import System.Directory -import System.FilePath import System.FilePath.Glob import Text.Localize.Types --- | Load translations when path to each translation file is known. +-- | Load compiled translations (@mo/@) when path to each translation file +-- is known. loadTranslations :: [(LanguageId, FilePath)] -> IO Translations loadTranslations pairs = do res <- forM pairs $ \(lang, path) -> do @@ -61,13 +61,13 @@ instance Default LocatePolicy where -- | Usual Linux translations location policy. -- Catalog files are found under @\/usr\/[local\/]share\/locale\/{language}\/LC_MESSAGES\/{name}.mo@. -linuxLocation :: String -- ^ Catalog file name (text domain) +linuxLocation :: String -- ^ Catalog file name (text domain). -> LocatePolicy linuxLocation name = def {lcBasePaths = ["/usr/share/locale", "/usr/local/share/locale"], lcName = name} -- | Simple translations location polciy, assuming all catalog files located at -- @{base}\/{language}.mo@. -localLocation :: FilePath -- ^ Path to directory with translations +localLocation :: FilePath -- ^ Path to directory with translations. -> LocatePolicy localLocation base = def {lcBasePaths = [base], lcFormat = "{base}/{language}.mo"} @@ -98,8 +98,7 @@ locateTranslations (LocatePolicy {..}) = liftIO $ do let (hd, tl) = break isLanguage items in case tl of [] -> (hd, []) - _ -> (hd, tail tl) + (_:tl') -> (hd, tl') isLanguage (FVariable name _) = name == "language" isLanguage _ = False - diff --git a/Text/Localize/State.hs b/src/Text/Localize/State.hs similarity index 70% rename from Text/Localize/State.hs rename to src/Text/Localize/State.hs index 307590c..01fba94 100644 --- a/Text/Localize/State.hs +++ b/src/Text/Localize/State.hs @@ -6,28 +6,33 @@ module Text.Localize.State (-- * Data types LocState (..), + Localize (..), LocalizeT (..), -- * Functions + runLocalize, runLocalizeT, setLanguage, withLanguage, setContext, withContext ) where -import Control.Applicative import Control.Monad.State -import Control.Monad.Trans import Text.Localize.Types --- | Localization state +import qualified Control.Monad.Identity as I + +------------------------------------------------------------------------------- +-- Types + +-- | Localization state. data LocState = LocState { - lsTranslations :: Translations, + lsTranslations :: Translations, lsLanguage :: LanguageId, lsContext :: Maybe Context } deriving (Show) --- | Localization monad transformer +-- | Localization monad transformer. newtype LocalizeT m a = LocalizeT { unLocalizeT :: StateT LocState m a } @@ -42,7 +47,25 @@ instance Monad m => Localized (LocalizeT m) where runLocalizeT :: Monad m => LocalizeT m a -> LocState -> m a runLocalizeT actions st = evalStateT (unLocalizeT actions) st --- | Set current language +-- | Localization monad. +newtype Localize a = Localize { + unLocalize :: StateT LocState I.Identity a + } + deriving (Functor, Applicative, Monad, MonadState LocState) + +instance Localized Localize where + getTranslations = gets lsTranslations + getLanguage = gets lsLanguage + getContext = gets lsContext + +-- | Run a computation inside 'LocalizeT'. +runLocalize :: Localize a -> LocState -> a +runLocalize actions st = evalState (unLocalize actions) st + +------------------------------------------------------------------------------- +-- Operate + +-- | Set current language. setLanguage :: Monad m => LanguageId -> LocalizeT m () setLanguage lang = modify $ \st -> st {lsLanguage = lang} @@ -67,4 +90,3 @@ withContext ctxt actions = do result <- actions setContext oldContext return result - diff --git a/Text/Localize/Types.hs b/src/Text/Localize/Types.hs similarity index 52% rename from Text/Localize/Types.hs rename to src/Text/Localize/Types.hs index e4441f9..f67ee4b 100644 --- a/Text/Localize/Types.hs +++ b/src/Text/Localize/Types.hs @@ -2,32 +2,46 @@ -- | This module contains data type definitions for the @localize@ package. module Text.Localize.Types where -import Control.Applicative +import qualified Data.IORef as IR +import qualified Data.Maybe as MB import qualified Data.Map as M import qualified Data.ByteString as B import qualified Data.Text.Lazy as T import qualified Data.Text.Encoding as TE import qualified Data.Gettext as Gettext +import qualified System.IO.Unsafe as UI +import qualified System.Locale.SetLocale as SL --- | Language identifier + +-- | Language identifier. type LanguageId = String --- | Context name +-- | Context name. type Context = B.ByteString --- | String to be translated +-- | String to be translated. type TranslationSource = B.ByteString --- | Stores translation catalogs for all supported languages +-- | Stores translation catalogs for all supported languages. data Translations = Translations { tMap :: M.Map LanguageId Gettext.Catalog } +instance Semigroup Translations where + (<>) (Translations a) (Translations b) = Translations (a <> b) + +instance Monoid Translations where + mempty = Translations M.empty + +-- | List available languages. +availableLanguages :: Translations -> [LanguageId] +availableLanguages ts = M.keys (tMap ts) + instance Show Translations where show t = "" -- | This is the main type class of the package. -- All functions work with any instance of this type class. --- +-- -- Note that this class only supports **getting** current language -- and translation, not setting them. Though concrete implementation -- can have its own ways to change language or context, these ways are @@ -36,7 +50,7 @@ instance Show Translations where class (Monad m, Applicative m) => Localized m where -- | Obtain currently selected language ID. getLanguage :: m LanguageId - + -- | Obtain currently loaded translations. getTranslations :: m Translations @@ -49,3 +63,28 @@ class (Monad m, Applicative m) => Localized m where toText :: TranslationSource -> T.Text toText bstr = T.fromStrict $ TE.decodeUtf8 bstr +instance Localized IO where + getLanguage = IR.readIORef currentLanguage + getTranslations = IR.readIORef currentTranslations + getContext = IR.readIORef currentContext + +currentContext :: IR.IORef (Maybe Context) +currentContext = UI.unsafePerformIO $ IR.newIORef Nothing +{-# NOINLINE currentContext #-} + +currentLanguage :: IR.IORef LanguageId +currentLanguage = UI.unsafePerformIO $ do + language <- languageFromLocale + IR.newIORef language +{-# NOINLINE currentLanguage #-} + +currentTranslations :: IR.IORef Translations +currentTranslations = UI.unsafePerformIO $ IR.newIORef undefined +{-# NOINLINE currentTranslations #-} + +-- | Obtain language to be used from process's locale. +languageFromLocale :: IO LanguageId +languageFromLocale = do + mbLocale <- SL.setLocale SL.LC_MESSAGES (Just "") + let locale = MB.fromMaybe "C" mbLocale + return $ takeWhile (/= '_') locale diff --git a/test/Test.hs b/test/Test.hs new file mode 100644 index 0000000..a824f8c --- /dev/null +++ b/test/Test.hs @@ -0,0 +1 @@ +{-# OPTIONS_GHC -F -pgmF hspec-discover #-} diff --git a/test/Unit/LocalizeSpec.hs b/test/Unit/LocalizeSpec.hs new file mode 100644 index 0000000..20e56a5 --- /dev/null +++ b/test/Unit/LocalizeSpec.hs @@ -0,0 +1,20 @@ +{-# Language OverloadedStrings #-} + +module Unit.LocalizeSpec where + +import Text.Localize as L + +import Test.Hspec + +main :: IO () +main = hspec spec + +spec :: Spec +spec = do + + describe "lookup" $ do + it "returns source string if the translation is not found" $ + L.lookup mempty "en" "prova" `shouldBe` "prova" + it "returns a translated string if the translation is present" $ do + ts <- loadTranslations [("en_GB", "test/mock-program/en_GB.mo")] + L.lookup ts "en_GB" "prova" `shouldBe` "test" diff --git a/test/mock-program/en_GB.mo b/test/mock-program/en_GB.mo new file mode 100644 index 0000000..3145d6a Binary files /dev/null and b/test/mock-program/en_GB.mo differ diff --git a/test/mock-program/test.hs b/test/mock-program/test.hs new file mode 100644 index 0000000..a7e2e36 --- /dev/null +++ b/test/mock-program/test.hs @@ -0,0 +1,4 @@ +-- Run `hgettext -o messages.pot test.hs` to obtain `messages.pot`. +-- Then `msginit --locale=en_GB` to get `en_GB.po. +-- Finally `msgfmt en_GB.po -o en_GB.mo`. +string = __ "prova"