diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,13 @@
+# Revision history for format-heavy
+
+## 0.1.0.2  -- 2026-09-11
+
+* Switch development and CI tooling to Cabal only.
+
+## 0.1.0.1  -- 2026-09-01
+
+* Add CI coverage for GHC 9.2 through 9.10.
+
+## 0.1.0.0  -- 2026-08-31
+
+* Initial release of format-heavy.
diff --git a/Data/Text/Format/Heavy.hs b/Data/Text/Format/Heavy.hs
new file mode 100644
--- /dev/null
+++ b/Data/Text/Format/Heavy.hs
@@ -0,0 +1,39 @@
+-- | This is the main module of @format-heavy@ library.
+--
+-- In most cases, you need to import only this module, and probably also the
+-- Data.Text.Format.Heavy.Time module, if you want to format time/date values.
+--
+-- This package exports the @format@ function and @Format@ data type.
+-- The Format type implements the instance of IsString, so in the code you may
+-- use formatting strings as literals, if you enable @OverloadedStrings@ extension.
+--
+-- Formatting strings syntax is based on Python's string.format() syntax.
+--
+-- The simple usage example is
+--
+-- @
+-- {-\# LANGUAGE OverloadedStrings #\-}
+-- module Main where
+--
+-- import Data.Time
+-- import qualified Data.Text.Lazy.IO as TLIO
+-- import Data.Text.Format.Heavy
+-- import Data.Text.Format.Heavy.Time
+--
+-- main :: IO ()
+-- main = do
+--   name <- getLine
+--   time <- getZonedTime
+--   TLIO.putStrLn $ format "Hello, {}! It is {:%H:%M:%S} now." (name, time)
+-- @
+module Data.Text.Format.Heavy (
+  module Data.Text.Format.Heavy.Types,
+  module Data.Text.Format.Heavy.Formats,
+  module Data.Text.Format.Heavy.Build,
+  module Data.Text.Format.Heavy.Instances,
+) where
+
+import Data.Text.Format.Heavy.Build (format)
+import Data.Text.Format.Heavy.Formats
+import Data.Text.Format.Heavy.Instances hiding (genericFloatFormat, genericIntFormat)
+import Data.Text.Format.Heavy.Types
diff --git a/Data/Text/Format/Heavy/Build.hs b/Data/Text/Format/Heavy/Build.hs
new file mode 100644
--- /dev/null
+++ b/Data/Text/Format/Heavy/Build.hs
@@ -0,0 +1,132 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Data.Text.Format.Heavy.Build (
+  format,
+  formatEither,
+  makeBuilder,
+
+  -- * Formatters building utilities
+  align,
+  applySign,
+  applySharp,
+  convertText,
+  formatInt,
+  formatStr,
+  formatFloat,
+  formatBool,
+)
+where
+
+import Control.Monad
+import Data.Maybe
+import Data.Monoid
+import qualified Data.Text as T
+import qualified Data.Text.Lazy as TL
+import qualified Data.Text.Lazy.Builder as B
+import Data.Text.Lazy.Builder.Int (
+  decimal,
+  hexadecimal,
+ )
+import Data.Text.Lazy.Builder.RealFloat
+
+import Data.Text.Format.Heavy.Formats
+import Data.Text.Format.Heavy.Types
+
+makeBuilder :: (VarContainer c) => Format -> c -> Either String B.Builder
+makeBuilder (Format items) vars = mconcat `fmap` mapM go items
+ where
+  go (FString s) = Right $ B.fromLazyText s
+  go (FVariable name fmt) = case lookupVar name vars of
+    Nothing -> Left $ "Parameter not found: " ++ TL.unpack name
+    Just var -> formatVar fmt var
+{-# INLINE makeBuilder #-}
+
+-- | The main formatting function.
+-- This function throws @error@ if some error detected during format string parsing or formatting itself.
+format :: (VarContainer vars) => Format -> vars -> TL.Text
+format fmt vars = either error id $ formatEither fmt vars
+
+-- | The main formatting function.
+-- This version returns @Left@ value with error description in case of error in
+-- format string or error during formatting.
+formatEither :: (VarContainer vars) => Format -> vars -> Either String TL.Text
+formatEither fmt vars = B.toLazyText `fmap` makeBuilder fmt vars
+
+align' :: Int -> Align -> Char -> B.Builder -> B.Builder
+align' width AlignLeft fill text =
+  B.fromLazyText $ TL.justifyLeft (fromIntegral width) fill $ B.toLazyText text
+align' width AlignRight fill text =
+  B.fromLazyText $ TL.justifyRight (fromIntegral width) fill $ B.toLazyText text
+align' width AlignCenter fill text =
+  B.fromLazyText $ TL.center (fromIntegral width) fill $ B.toLazyText text
+
+-- | Align text within available width according to format
+align :: GenericFormat -> B.Builder -> B.Builder
+align fmt text = case (gfAlign fmt, gfWidth fmt) of
+  (Just a, Just w) -> align' w a (gfFillChar fmt) text
+  _ -> text
+
+-- | Add @+/-@ sign to the number representation, if required
+applySign :: (Num a, Ord a) => Sign -> a -> B.Builder -> B.Builder
+applySign Always x text =
+  if x >= 0 then B.singleton '+' <> text else B.singleton '-' <> text
+applySign OnlyNegative x text =
+  if x >= 0 then text else B.singleton '-' <> text
+applySign SpaceForPositive x text =
+  if x >= 0 then B.singleton ' ' <> text else B.singleton '-' <> text
+
+-- | Add @0x@ to the number representation, if required
+applySharp :: Bool -> Radix -> B.Builder -> B.Builder
+applySharp False _ text = text
+applySharp True Decimal text = text
+applySharp True Hexadecimal text = B.fromLazyText "0x" <> text
+
+-- | Apply text conversion.
+convertText :: Maybe Conversion -> B.Builder -> B.Builder
+convertText Nothing builder = builder
+convertText (Just conv) builder =
+  B.fromLazyText
+    $ converter
+    $ B.toLazyText
+      builder
+ where
+  converter = case conv of
+    UpperCase -> TL.toUpper
+    LowerCase -> TL.toLower
+    TitleCase -> TL.toTitle
+
+-- | Format integer number according to GenericFormat
+formatInt :: (Integral a) => GenericFormat -> a -> B.Builder
+formatInt fmt x =
+  align fmt
+    $ applySign (gfSign fmt) x
+    $ applySharp
+      (gfLeading0x fmt)
+      radix
+      inRadix
+ where
+  radix = fromMaybe Decimal (gfRadix fmt)
+  conversion = fromMaybe LowerCase (gfConvert fmt)
+  inRadix = case radix of
+    Decimal -> decimal (abs x)
+    Hexadecimal -> case conversion of
+      LowerCase -> hexadecimal (abs x)
+      _ -> B.fromLazyText . TL.toUpper . B.toLazyText . hexadecimal . abs $ x
+
+-- | Format floating-point number according to GenericFormat
+formatFloat :: (RealFloat a) => GenericFormat -> a -> B.Builder
+formatFloat fmt x =
+  align fmt
+    $ applySign (gfSign fmt) x
+    $ formatRealFloat Fixed (gfPrecision fmt)
+    $ abs x
+
+-- | Format Text according to GenericFormat.
+formatStr :: GenericFormat -> TL.Text -> B.Builder
+formatStr fmt text =
+  convertText (gfConvert fmt) $ align fmt $ B.fromLazyText text
+
+-- | Format boolean value.
+formatBool :: BoolFormat -> Bool -> B.Builder
+formatBool fmt True = B.fromLazyText $ bfTrue fmt
+formatBool fmt False = B.fromLazyText $ bfFalse fmt
diff --git a/Data/Text/Format/Heavy/Formats.hs b/Data/Text/Format/Heavy/Formats.hs
new file mode 100644
--- /dev/null
+++ b/Data/Text/Format/Heavy/Formats.hs
@@ -0,0 +1,63 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | This module contains format descriptions for most used variable types.
+module Data.Text.Format.Heavy.Formats where
+
+import Data.Default
+import qualified Data.Text.Lazy as TL
+
+import Data.Text.Format.Heavy.Types
+
+-- | Alignment of string within specified width
+data Align = AlignLeft | AlignRight | AlignCenter
+  deriving (Eq, Show)
+
+-- | Whether to show the sign of number
+data Sign = Always | OnlyNegative | SpaceForPositive
+  deriving (Eq, Show)
+
+-- | Number base
+data Radix = Decimal | Hexadecimal
+  deriving (Eq, Show)
+
+-- | Supported text conversions
+data Conversion
+  = UpperCase
+  | LowerCase
+  | TitleCase
+  deriving (Eq, Show)
+
+-- | Generic format description. This is usable for integers, floats and strings.
+data GenericFormat = GenericFormat
+  { gfFillChar :: Char
+  , gfAlign :: Maybe Align
+  , gfSign :: Sign
+  , gfLeading0x :: Bool
+  , gfWidth :: Maybe Int
+  , gfPrecision :: Maybe Int
+  , gfRadix :: Maybe Radix
+  , gfConvert :: Maybe Conversion
+  }
+  deriving (Eq, Show)
+
+instance Default GenericFormat where
+  def =
+    GenericFormat
+      { gfFillChar = ' '
+      , gfAlign = Nothing
+      , gfSign = OnlyNegative
+      , gfLeading0x = False
+      , gfWidth = Nothing
+      , gfPrecision = Nothing
+      , gfRadix = Nothing
+      , gfConvert = Nothing
+      }
+
+data BoolFormat = BoolFormat
+  { bfTrue :: TL.Text
+  , bfFalse :: TL.Text
+  }
+  deriving (Eq, Show)
+
+instance Default BoolFormat where
+  def = BoolFormat "true" "false"
diff --git a/Data/Text/Format/Heavy/Instances.hs b/Data/Text/Format/Heavy/Instances.hs
new file mode 100644
--- /dev/null
+++ b/Data/Text/Format/Heavy/Instances.hs
@@ -0,0 +1,492 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+-- | This module contains Formatable and VarContainer instances for most used types.
+module Data.Text.Format.Heavy.Instances (
+  -- * Utility data types
+  Single (..),
+  Several (..),
+  Shown (..),
+
+  -- * Combinators
+  DefaultValue (..),
+  ThenCheck (..),
+  WithDefault,
+  withDefault,
+  optional,
+
+  -- * Generic formatters
+  genericIntFormat,
+  genericFloatFormat,
+) where
+
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy as BSL
+import Data.Char
+import Data.Default
+import Data.Int
+import Data.List (union)
+import qualified Data.Map as M
+import Data.Maybe
+import Data.String
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as TE
+import qualified Data.Text.Lazy as TL
+import qualified Data.Text.Lazy.Builder as B
+import Data.Text.Lazy.Builder.Int (decimal, hexadecimal)
+import qualified Data.Text.Lazy.Encoding as TLE
+import Data.Word
+
+import Data.Text.Format.Heavy.Build
+import Data.Text.Format.Heavy.Formats
+import Data.Text.Format.Heavy.Parse
+import Data.Text.Format.Heavy.Types
+
+instance IsString Format where
+  fromString str = parseFormat' (fromString str)
+
+----------------------- IsVarFormat instances --------------------------------------
+
+instance IsVarFormat GenericFormat where
+  parseVarFormat text = either (Left . show) Right $ parseGenericFormat text
+
+instance IsVarFormat BoolFormat where
+  parseVarFormat text = either (Left . show) Right $ parseBoolFormat text
+
+---------------------- Generic formatters -------------------------------------------
+
+-- | Generic formatter for integer types
+genericIntFormat :: (Integral a) => VarFormat -> a -> Either String B.Builder
+genericIntFormat Nothing x = Right $ formatInt def x
+genericIntFormat (Just fmtStr) x =
+  case parseGenericFormat fmtStr of
+    Left err -> Left $ show err
+    Right fmt -> Right $ formatInt fmt x
+
+-- | Generic formatter for floating-point types
+genericFloatFormat :: (RealFloat a) => VarFormat -> a -> Either String B.Builder
+genericFloatFormat Nothing x = Right $ formatFloat def x
+genericFloatFormat (Just fmtStr) x =
+  case parseGenericFormat fmtStr of
+    Left err -> Left $ show err
+    Right fmt -> Right $ formatFloat fmt x
+
+------------------------ Formatable instances -------------------------------------------
+
+-- | Unit type is formatted as empty string
+instance Formatable () where
+  formatVar _ _ = Right mempty
+
+instance Formatable Int where
+  formatVar fmt x = genericIntFormat fmt x
+
+instance Formatable Int8 where
+  formatVar fmt x = genericIntFormat fmt x
+
+instance Formatable Int16 where
+  formatVar fmt x = genericIntFormat fmt x
+
+instance Formatable Int32 where
+  formatVar fmt x = genericIntFormat fmt x
+
+instance Formatable Int64 where
+  formatVar fmt x = genericIntFormat fmt x
+
+instance Formatable Word8 where
+  formatVar fmt x = genericIntFormat fmt x
+
+instance Formatable Word16 where
+  formatVar fmt x = genericIntFormat fmt x
+
+instance Formatable Word32 where
+  formatVar fmt x = genericIntFormat fmt x
+
+instance Formatable Word64 where
+  formatVar fmt x = genericIntFormat fmt x
+
+instance Formatable Integer where
+  formatVar fmt x = genericIntFormat fmt x
+
+instance Formatable Float where
+  formatVar fmt x = genericFloatFormat fmt x
+
+instance Formatable Double where
+  formatVar fmt x = genericFloatFormat fmt x
+
+instance Formatable String where
+  formatVar Nothing text = Right $ formatStr def (fromString text)
+  formatVar (Just fmtStr) text =
+    case parseGenericFormat fmtStr of
+      Left err -> Left $ show err
+      Right fmt -> Right $ formatStr fmt (fromString text)
+
+instance Formatable T.Text where
+  formatVar Nothing text = Right $ formatStr def $ TL.fromStrict text
+  formatVar (Just fmtStr) text =
+    case parseGenericFormat fmtStr of
+      Left err -> Left $ show err
+      Right fmt -> Right $ formatStr fmt $ TL.fromStrict text
+
+instance Formatable TL.Text where
+  formatVar Nothing text = Right $ formatStr def text
+  formatVar (Just fmtStr) text =
+    case parseGenericFormat fmtStr of
+      Left err -> Left $ show err
+      Right fmt -> Right $ formatStr fmt text
+
+instance Formatable BS.ByteString where
+  formatVar Nothing text = Right $ formatStr def $ TL.fromStrict $ TE.decodeUtf8 text
+  formatVar (Just fmtStr) text =
+    case parseGenericFormat fmtStr of
+      Left err -> Left $ show err
+      Right fmt -> Right $ formatStr fmt $ TL.fromStrict $ TE.decodeUtf8 text
+
+instance Formatable BSL.ByteString where
+  formatVar Nothing text = Right $ formatStr def $ TLE.decodeUtf8 text
+  formatVar (Just fmtStr) text =
+    case parseGenericFormat fmtStr of
+      Left err -> Left $ show err
+      Right fmt -> Right $ formatStr fmt $ TLE.decodeUtf8 text
+
+instance Formatable Bool where
+  formatVar Nothing x = Right $ formatBool def x
+  formatVar (Just fmtStr) x =
+    case parseBoolFormat fmtStr of
+      Left err -> Left $ show err
+      Right fmt -> Right $ formatBool fmt x
+
+-- | Container for single parameter.
+-- Example usage:
+--
+-- @
+-- format "Hello, {}!" (Single name)
+-- @
+data Single a = Single {getSingle :: a}
+  deriving (Eq, Show)
+
+instance (Formatable a) => Formatable (Single a) where
+  formatVar fmt (Single x) = formatVar fmt x
+
+-- | Container for several parameters of the same type.
+-- Example usage:
+--
+-- @
+-- format "{} + {} = {}" $ Several [2, 3, 5]
+-- @
+data Several a = Several {getSeveral :: [a]}
+  deriving (Eq, Show)
+
+-- | Values packed in Shown will be formatted using their Show instance.
+--
+-- For example,
+--
+-- @
+-- formatText "values: {}." (Shown (True, False)) ==> "values: (True, False)."
+-- @
+data Shown a = Shown {shown :: a}
+  deriving (Eq)
+
+instance (Show a) => Show (Shown a) where
+  show (Shown x) = show x
+
+instance (Show a) => Formatable (Shown a) where
+  formatVar _ (Shown x) = Right $ B.fromLazyText $ TL.pack $ show x
+
+instance (Formatable a) => Formatable (Maybe a) where
+  formatVar Nothing Nothing = Right mempty
+  formatVar Nothing (Just x) = formatVar Nothing x
+  formatVar (Just fmtStr) m =
+    case parseMaybeFormat fmtStr of
+      Nothing -> case m of
+        Nothing -> Right mempty
+        Just x -> formatVar (Just fmtStr) x
+      Just (xFmtStr, nothingStr) ->
+        case m of
+          Nothing -> Right $ B.fromLazyText nothingStr
+          Just x -> formatVar (Just xFmtStr) x
+
+instance (Formatable a, Formatable b) => Formatable (Either a b) where
+  formatVar fmt (Left x) = formatVar fmt x
+  formatVar fmt (Right y) = formatVar fmt y
+
+------------------------------- VarContainer instances -------------------------------------
+
+instance (Formatable a) => VarContainer (Single a) where
+  lookupVar "0" (Single x) = Just $ Variable x
+  lookupVar _ _ = Nothing
+
+instance (Formatable a) => ClosedVarContainer (Single a) where
+  allVarNames _ = ["0"]
+
+instance VarContainer () where
+  lookupVar _ _ = Nothing
+
+instance ClosedVarContainer () where
+  allVarNames _ = []
+
+-- | Maybe container contains one variable (named 0); Nothing contains an empty string.
+instance (Formatable a) => VarContainer (Maybe a) where
+  lookupVar "0" (Just x) = Just $ Variable x
+  lookupVar "0" Nothing = Just $ Variable ()
+  lookupVar _ _ = Nothing
+
+instance (Formatable a) => ClosedVarContainer (Maybe a) where
+  allVarNames Nothing = []
+  allVarNames (Just _) = ["0"]
+
+instance (Formatable a, Formatable b) => VarContainer (a, b) where
+  lookupVar "0" (a, _) = Just $ Variable a
+  lookupVar "1" (_, b) = Just $ Variable b
+  lookupVar _ _ = Nothing
+
+instance (Formatable a, Formatable b) => ClosedVarContainer (a, b) where
+  allVarNames _ = ["0", "1"]
+
+instance (Formatable a, Formatable b, Formatable c) => VarContainer (a, b, c) where
+  lookupVar "0" (a, _, _) = Just $ Variable a
+  lookupVar "1" (_, b, _) = Just $ Variable b
+  lookupVar "2" (_, _, c) = Just $ Variable c
+  lookupVar _ _ = Nothing
+
+instance (Formatable a, Formatable b, Formatable c) => ClosedVarContainer (a, b, c) where
+  allVarNames _ = ["0", "1", "2"]
+
+instance (Formatable a, Formatable b, Formatable c, Formatable d) => VarContainer (a, b, c, d) where
+  lookupVar "0" (a, _, _, _) = Just $ Variable a
+  lookupVar "1" (_, b, _, _) = Just $ Variable b
+  lookupVar "2" (_, _, c, _) = Just $ Variable c
+  lookupVar "3" (_, _, _, d) = Just $ Variable d
+  lookupVar _ _ = Nothing
+
+instance (Formatable a, Formatable b, Formatable c, Formatable d) => ClosedVarContainer (a, b, c, d) where
+  allVarNames _ = ["0", "1", "2", "3"]
+
+instance
+  (Formatable a, Formatable b, Formatable c, Formatable d, Formatable e)
+  => VarContainer (a, b, c, d, e)
+  where
+  lookupVar "0" (a, _, _, _, _) = Just $ Variable a
+  lookupVar "1" (_, b, _, _, _) = Just $ Variable b
+  lookupVar "2" (_, _, c, _, _) = Just $ Variable c
+  lookupVar "3" (_, _, _, d, _) = Just $ Variable d
+  lookupVar "4" (_, _, _, _, e) = Just $ Variable e
+  lookupVar _ _ = Nothing
+
+instance
+  (Formatable a, Formatable b, Formatable c, Formatable d, Formatable e)
+  => ClosedVarContainer (a, b, c, d, e)
+  where
+  allVarNames _ = ["0", "1", "2", "3", "4"]
+
+instance
+  (Formatable a, Formatable b, Formatable c, Formatable d, Formatable e, Formatable f)
+  => VarContainer (a, b, c, d, e, f)
+  where
+  lookupVar "0" (a, _, _, _, _, _) = Just $ Variable a
+  lookupVar "1" (_, b, _, _, _, _) = Just $ Variable b
+  lookupVar "2" (_, _, c, _, _, _) = Just $ Variable c
+  lookupVar "3" (_, _, _, d, _, _) = Just $ Variable d
+  lookupVar "4" (_, _, _, _, e, _) = Just $ Variable e
+  lookupVar "5" (_, _, _, _, _, f) = Just $ Variable f
+  lookupVar _ _ = Nothing
+
+instance
+  (Formatable a, Formatable b, Formatable c, Formatable d, Formatable e, Formatable f)
+  => ClosedVarContainer (a, b, c, d, e, f)
+  where
+  allVarNames _ = ["0", "1", "2", "3", "4", "5"]
+
+instance
+  (Formatable a, Formatable b, Formatable c, Formatable d, Formatable e, Formatable f, Formatable g)
+  => VarContainer (a, b, c, d, e, f, g)
+  where
+  lookupVar "0" (a, _, _, _, _, _, _) = Just $ Variable a
+  lookupVar "1" (_, b, _, _, _, _, _) = Just $ Variable b
+  lookupVar "2" (_, _, c, _, _, _, _) = Just $ Variable c
+  lookupVar "3" (_, _, _, d, _, _, _) = Just $ Variable d
+  lookupVar "4" (_, _, _, _, e, _, _) = Just $ Variable e
+  lookupVar "5" (_, _, _, _, _, f, _) = Just $ Variable f
+  lookupVar "6" (_, _, _, _, _, _, g) = Just $ Variable g
+  lookupVar _ _ = Nothing
+
+instance
+  (Formatable a, Formatable b, Formatable c, Formatable d, Formatable e, Formatable f, Formatable g)
+  => ClosedVarContainer (a, b, c, d, e, f, g)
+  where
+  allVarNames _ = ["0", "1", "2", "3", "4", "5", "6"]
+
+instance
+  ( Formatable a
+  , Formatable b
+  , Formatable c
+  , Formatable d
+  , Formatable e
+  , Formatable f
+  , Formatable g
+  , Formatable h
+  )
+  => VarContainer (a, b, c, d, e, f, g, h)
+  where
+  lookupVar "0" (a, _, _, _, _, _, _, _) = Just $ Variable a
+  lookupVar "1" (_, b, _, _, _, _, _, _) = Just $ Variable b
+  lookupVar "2" (_, _, c, _, _, _, _, _) = Just $ Variable c
+  lookupVar "3" (_, _, _, d, _, _, _, _) = Just $ Variable d
+  lookupVar "4" (_, _, _, _, e, _, _, _) = Just $ Variable e
+  lookupVar "5" (_, _, _, _, _, f, _, _) = Just $ Variable f
+  lookupVar "6" (_, _, _, _, _, _, g, _) = Just $ Variable g
+  lookupVar "7" (_, _, _, _, _, _, _, h) = Just $ Variable h
+  lookupVar _ _ = Nothing
+
+instance
+  ( Formatable a
+  , Formatable b
+  , Formatable c
+  , Formatable d
+  , Formatable e
+  , Formatable f
+  , Formatable g
+  , Formatable h
+  )
+  => ClosedVarContainer (a, b, c, d, e, f, g, h)
+  where
+  allVarNames _ = ["0", "1", "2", "3", "4", "5", "6", "7"]
+
+instance
+  ( Formatable a
+  , Formatable b
+  , Formatable c
+  , Formatable d
+  , Formatable e
+  , Formatable f
+  , Formatable g
+  , Formatable h
+  , Formatable i
+  )
+  => VarContainer (a, b, c, d, e, f, g, h, i)
+  where
+  lookupVar "0" (a, _, _, _, _, _, _, _, _) = Just $ Variable a
+  lookupVar "1" (_, b, _, _, _, _, _, _, _) = Just $ Variable b
+  lookupVar "2" (_, _, c, _, _, _, _, _, _) = Just $ Variable c
+  lookupVar "3" (_, _, _, d, _, _, _, _, _) = Just $ Variable d
+  lookupVar "4" (_, _, _, _, e, _, _, _, _) = Just $ Variable e
+  lookupVar "5" (_, _, _, _, _, f, _, _, _) = Just $ Variable f
+  lookupVar "6" (_, _, _, _, _, _, g, _, _) = Just $ Variable g
+  lookupVar "7" (_, _, _, _, _, _, _, h, _) = Just $ Variable h
+  lookupVar "8" (_, _, _, _, _, _, _, _, i) = Just $ Variable i
+  lookupVar _ _ = Nothing
+
+instance
+  ( Formatable a
+  , Formatable b
+  , Formatable c
+  , Formatable d
+  , Formatable e
+  , Formatable f
+  , Formatable g
+  , Formatable h
+  , Formatable i
+  )
+  => ClosedVarContainer (a, b, c, d, e, f, g, h, i)
+  where
+  allVarNames _ = ["0", "1", "2", "3", "4", "5", "6", "7", "8"]
+
+instance
+  ( Formatable a
+  , Formatable b
+  , Formatable c
+  , Formatable d
+  , Formatable e
+  , Formatable f
+  , Formatable g
+  , Formatable h
+  , Formatable i
+  , Formatable j
+  )
+  => VarContainer (a, b, c, d, e, f, g, h, i, j)
+  where
+  lookupVar "0" (a, _, _, _, _, _, _, _, _, _) = Just $ Variable a
+  lookupVar "1" (_, b, _, _, _, _, _, _, _, _) = Just $ Variable b
+  lookupVar "2" (_, _, c, _, _, _, _, _, _, _) = Just $ Variable c
+  lookupVar "3" (_, _, _, d, _, _, _, _, _, _) = Just $ Variable d
+  lookupVar "4" (_, _, _, _, e, _, _, _, _, _) = Just $ Variable e
+  lookupVar "5" (_, _, _, _, _, f, _, _, _, _) = Just $ Variable f
+  lookupVar "6" (_, _, _, _, _, _, g, _, _, _) = Just $ Variable g
+  lookupVar "7" (_, _, _, _, _, _, _, h, _, _) = Just $ Variable h
+  lookupVar "8" (_, _, _, _, _, _, _, _, i, _) = Just $ Variable i
+  lookupVar "9" (_, _, _, _, _, _, _, _, _, j) = Just $ Variable j
+  lookupVar _ _ = Nothing
+
+instance
+  ( Formatable a
+  , Formatable b
+  , Formatable c
+  , Formatable d
+  , Formatable e
+  , Formatable f
+  , Formatable g
+  , Formatable h
+  , Formatable i
+  , Formatable j
+  )
+  => ClosedVarContainer (a, b, c, d, e, f, g, h, i, j)
+  where
+  allVarNames _ = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
+
+instance (Formatable a) => VarContainer (Several a) where
+  lookupVar name (Several lst) =
+    if not $ TL.all isDigit name
+      then Nothing
+      else
+        let n = read (TL.unpack name)
+         in if n >= length lst
+              then Nothing
+              else Just $ Variable (lst !! n)
+
+instance (Formatable a) => ClosedVarContainer (Several a) where
+  allVarNames (Several lst) = map (TL.pack . show) [0 .. length lst - 1]
+
+instance (Formatable x) => VarContainer [(TL.Text, x)] where
+  lookupVar name pairs = Variable `fmap` lookup name pairs
+
+instance (Formatable x) => ClosedVarContainer [(TL.Text, x)] where
+  allVarNames pairs = map fst pairs
+
+instance (Formatable x) => VarContainer (M.Map TL.Text x) where
+  lookupVar name pairs = Variable `fmap` M.lookup name pairs
+
+instance (Formatable x) => ClosedVarContainer (M.Map TL.Text x) where
+  allVarNames pairs = M.keys pairs
+
+-- | Variable container which contains fixed value for any variable name.
+data DefaultValue = DefaultValue Variable
+
+instance VarContainer DefaultValue where
+  lookupVar _ (DefaultValue var) = Just var
+
+-- | Combiled variable container, which uses parameters from @c1@,
+-- and if variable is not found there it will check in @c2@.
+data ThenCheck c1 c2 = ThenCheck c1 c2
+
+-- | Convenience type synonym.
+type WithDefault c = ThenCheck c DefaultValue
+
+instance (VarContainer c1, VarContainer c2) => VarContainer (ThenCheck c1 c2) where
+  lookupVar name (ThenCheck c1 c2) =
+    case lookupVar name c1 of
+      Just result -> Just result
+      Nothing -> lookupVar name c2
+
+instance (ClosedVarContainer c1, ClosedVarContainer c2) => ClosedVarContainer (ThenCheck c1 c2) where
+  allVarNames (ThenCheck c1 c2) =
+    allVarNames c1 `union` allVarNames c2
+
+-- | Use variables from specified container, or use default value if
+-- variable is not found in container.
+withDefault :: (VarContainer c) => c -> Variable -> WithDefault c
+withDefault c value = c `ThenCheck` DefaultValue value
+
+-- | Use variables from specified container, or use empty string
+-- variable is not found in container.
+optional :: (VarContainer c) => c -> WithDefault c
+optional c = c `withDefault` (Variable TL.empty)
diff --git a/Data/Text/Format/Heavy/Parse.hs b/Data/Text/Format/Heavy/Parse.hs
new file mode 100644
--- /dev/null
+++ b/Data/Text/Format/Heavy/Parse.hs
@@ -0,0 +1,64 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | This module contains parsers for formatting strings.
+-- We have to deal with two kinds of strings:
+--
+-- * String formats. This is the whole construct like @"Hello, {}! Your account balance is {1:+8.4}."@.
+-- * Variable formats. This is only part after colon in braces, i.e. the @+8.4@ thing in previous example.
+--
+-- The string format syntax is supposed to be very stable and simple.
+-- There are more than one commonly used formatting string syntax, though. This package provides the
+-- following syntaxes:
+--
+-- * The default syntax, which is basically defined by phrase "any part in braces is variable substitution".
+--   This syntax is defined in @Data.Text.Format.Heavy.Parse.Braces@ module.
+--
+-- * Shell-like syntax, which is basically defined by phrase "any part starting with dollar sign is variable
+--   substitution". This syntax is defined in @Data.Text.Format.Heavy.Parse.Shell@ module.
+--
+-- It is possible to define your own syntaxes: you just need to parse an instance of @Format@ type from some
+-- sort of string. The default syntax will still remain default, in sence that @instance IsString Format@ is
+-- defined in terms of this syntax in @Data.Text.Format.Heavy.Instances@ module.
+--
+-- Variable formats syntax depends on type of data which we are going to format. These formats can be
+-- pretty complex, for example they can include alignment, rounding, and so on.
+module Data.Text.Format.Heavy.Parse (
+  FormatParseItem (..),
+
+  -- * Parse functions
+  parse,
+  parseFormat,
+  parseFormat',
+  parseGenericFormat,
+  parseBoolFormat,
+  parseMaybeFormat,
+
+  -- * Parsec functions
+  pGenericFormat,
+  pBoolFormat,
+) where
+
+import Data.Maybe
+import qualified Data.Text as T
+import qualified Data.Text.Lazy as TL
+import qualified Data.Text.Lazy.Builder as B
+import Text.Parsec (ParseError)
+
+import Data.Text.Format.Heavy.Formats
+import Data.Text.Format.Heavy.Parse.Braces
+import Data.Text.Format.Heavy.Parse.VarFormat
+import Data.Text.Format.Heavy.Types
+
+data FormatParseItem
+  = FormatString TL.Text
+  | FormatReplacementField TL.Text (Maybe TL.Text)
+  deriving (Eq, Show)
+
+parse :: TL.Text -> Either ParseError [FormatParseItem]
+parse text = toParseItems <$> parseFormat text
+
+toParseItems :: Format -> [FormatParseItem]
+toParseItems (Format items) = map toParseItem items
+ where
+  toParseItem (FString text) = FormatString text
+  toParseItem (FVariable name fmt) = FormatReplacementField name fmt
diff --git a/Data/Text/Format/Heavy/Parse/Braces.hs b/Data/Text/Format/Heavy/Parse/Braces.hs
new file mode 100644
--- /dev/null
+++ b/Data/Text/Format/Heavy/Parse/Braces.hs
@@ -0,0 +1,100 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | This module defines the default syntax of format strings, generally described as
+-- "any part in braces is variable substitution".
+--
+-- Examples of valid variable substitutions are:
+--
+-- * @"Simple: {}"@
+--
+-- * @"Numbered: {0}"@
+--
+-- * @"Named: {var}"@
+--
+-- * @"Specifying variable formatting: {var:+8.4}"@
+module Data.Text.Format.Heavy.Parse.Braces (
+  -- * Parse functions
+  parseFormat,
+  parseFormat',
+
+  -- * Parsec functions
+  pBracesFormat,
+)
+where
+
+import Data.Maybe
+import qualified Data.Text as T
+import Data.Text.Format.Heavy.Formats
+import Data.Text.Format.Heavy.Parse.Types
+import Data.Text.Format.Heavy.Types
+import qualified Data.Text.Lazy as TL
+import qualified Data.Text.Lazy.Builder as B
+import Text.Parsec
+
+replaceWith :: a -> String -> Parser a
+replaceWith c s = c <$ string s
+
+unescapeBraces :: String -> String -> Parser Char
+unescapeBraces open close =
+  try (replaceWith '{' open)
+    <|> try (replaceWith '}' close)
+
+anyChar' :: Parser Char
+anyChar' =
+  unescapeBraces "{{" "}}"
+    <|> unescapeBraces "\\{" "\\}"
+    <|> noneOf "{}"
+
+formatSpecChar :: Parser String
+formatSpecChar = try nestedBraces <|> asString anyChar'
+ where
+  nestedBraces = do
+    char '{'
+    inner <-
+      concat
+        <$> many
+          ( try nestedBraces
+              <|> replaceWith "{{" "{"
+              <|> asString (noneOf "{}")
+          )
+    char '}'
+    return $ "{" ++ inner ++ "}"
+  asString = ((: []) <$>)
+
+pVerbatim :: Parser FormatItem
+pVerbatim = (FString . TL.pack) `fmap` many1 anyChar'
+
+pVariable :: Parser FormatItem
+pVariable = do
+  (name, fmt) <- between (char '{') (char '}') variable
+  return $ FVariable (TL.pack name) fmt
+ where
+  variable = do
+    name <- many $ try alphaNum <|> try (char '-') <|> char '.' <|> char '_'
+    mbColon <- optionMaybe $ char ':'
+    fmt <- case mbColon of
+      Nothing -> return Nothing
+      Just _ -> do
+        fmtStr <- concat <$> many formatSpecChar
+        return $ Just $ TL.pack fmtStr
+    name' <-
+      if null name
+        then do
+          st <- getState
+          let n = psNextIndex st
+          modifyState $ \st -> st{psNextIndex = psNextIndex st + 1}
+          return $ show n
+        else return name
+    return (name', fmt)
+
+-- | Parsec parser for string format.
+pBracesFormat :: Parser Format
+pBracesFormat = Format `fmap` many (try pVariable <|> pVerbatim)
+
+-- | Parse string format definition.
+parseFormat :: TL.Text -> Either ParseError Format
+parseFormat text = runParser pBracesFormat initParserState "<format string>" text
+
+-- | Version of parseFormat which throws @error@ in case of syntax error in the formatting string.
+parseFormat' :: TL.Text -> Format
+parseFormat' text = either (error . show) id $ parseFormat text
diff --git a/Data/Text/Format/Heavy/Parse/Shell.hs b/Data/Text/Format/Heavy/Parse/Shell.hs
new file mode 100644
--- /dev/null
+++ b/Data/Text/Format/Heavy/Parse/Shell.hs
@@ -0,0 +1,100 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | This module defines shell-like syntax of format strings, generally described as
+-- "any part after dollar sign is a variable substitution".
+--
+-- Examples of valid variable substitutions are:
+--
+-- * @"Simple: ${}"@. Note that to have auto-numbered placeholders in this syntax, you have to
+--   write @${}@; both dollar sign and braces are necessary.
+--
+-- * @"Numbered: $1"@ or @"Numbered: ${1}"@.
+--
+-- * @"Named: $var"@ or @"Named: ${var}"@.
+--
+-- * @"Specifying variable formatting: ${var:+8.4}"@. To specify variable format, you have to
+--   use braces.
+--
+-- This syntax is not the default, so to use it you have to explicitly call @parseShellFormat'@:
+--
+-- @
+-- {-\# LANGUAGE OverloadedStrings #\-}
+-- module Main where
+--
+-- import Data.Time
+-- import qualified Data.Text.Lazy.IO as TLIO
+-- import Data.Text.Format.Heavy
+-- import Data.Text.Format.Heavy.Parse.Shell
+--
+-- main :: IO ()
+-- main = do
+--   name <- getLine
+--   time <- getZonedTime
+--   TLIO.putStrLn $ format (parseShellFormat' "Hello, ${}! It is ${:%H:%M:%S} now.") (name, time)
+-- @
+module Data.Text.Format.Heavy.Parse.Shell (
+  -- * Parse functions
+  parseShellFormat,
+  parseShellFormat',
+
+  -- * Parsec functions
+  pShellFormat,
+) where
+
+import Data.Maybe
+import qualified Data.Text as T
+import qualified Data.Text.Lazy as TL
+import qualified Data.Text.Lazy.Builder as B
+import Text.Parsec
+
+import Data.Text.Format.Heavy.Formats
+import Data.Text.Format.Heavy.Parse.Types
+import Data.Text.Format.Heavy.Types
+
+-- TODO: proper handling of escaping
+anyChar' :: Parser Char
+anyChar' =
+  noneOf "$" <|> try ('$' <$ string "$$")
+
+pVerbatim :: Parser FormatItem
+pVerbatim = (FString . TL.pack) `fmap` many1 anyChar'
+
+pVariable :: Parser FormatItem
+pVariable = do
+  char '$'
+  (name, fmt) <- try bracedVariable <|> unbracedVariable
+  return $ FVariable (TL.pack name) fmt
+ where
+  bracedVariable = between (char '{') (char '}') $ do
+    name <- many $ try alphaNum <|> try (char '-') <|> char '.'
+    mbColon <- optionMaybe $ char ':'
+    fmt <- case mbColon of
+      Nothing -> return Nothing
+      Just _ -> do
+        fmtStr <- many (noneOf "}" <|> try ('}' <$ string "\\}"))
+        return $ Just $ TL.pack fmtStr
+    name' <-
+      if null name
+        then do
+          st <- getState
+          let n = psNextIndex st
+          modifyState $ \st -> st{psNextIndex = psNextIndex st + 1}
+          return $ show n
+        else return name
+    return (name', fmt)
+
+  unbracedVariable = do
+    name <- many1 alphaNum
+    return (name, Nothing)
+
+-- | Parsec parser for string format.
+pShellFormat :: Parser Format
+pShellFormat = Format `fmap` many (try pVariable <|> pVerbatim)
+
+-- | Parse string format definition.
+parseShellFormat :: TL.Text -> Either ParseError Format
+parseShellFormat text = runParser pShellFormat initParserState "<format string>" text
+
+-- | Version of parseShellFormat which throws @error@ in case of syntax error in the formatting string.
+parseShellFormat' :: TL.Text -> Format
+parseShellFormat' text = either (error . show) id $ parseShellFormat text
diff --git a/Data/Text/Format/Heavy/Parse/Types.hs b/Data/Text/Format/Heavy/Parse/Types.hs
new file mode 100644
--- /dev/null
+++ b/Data/Text/Format/Heavy/Parse/Types.hs
@@ -0,0 +1,19 @@
+module Data.Text.Format.Heavy.Parse.Types (
+  -- * Utility types
+  Parser,
+  ParserState (..),
+  initParserState,
+) where
+
+import qualified Data.Text.Lazy as TL
+import Text.Parsec
+
+data ParserState = ParserState
+  { psNextIndex :: Int
+  }
+  deriving (Eq, Show)
+
+initParserState :: ParserState
+initParserState = ParserState 0
+
+type Parser a = Parsec TL.Text ParserState a
diff --git a/Data/Text/Format/Heavy/Parse/VarFormat.hs b/Data/Text/Format/Heavy/Parse/VarFormat.hs
new file mode 100644
--- /dev/null
+++ b/Data/Text/Format/Heavy/Parse/VarFormat.hs
@@ -0,0 +1,178 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Data.Text.Format.Heavy.Parse.VarFormat
+where
+
+import Control.Applicative ((<|>))
+import Data.Maybe
+import qualified Data.Text as T
+import qualified Data.Text.Lazy as TL
+import qualified Data.Text.Lazy.Builder as B
+import Text.Parsec hiding ((<|>))
+
+import Data.Text.Format.Heavy.Formats
+import Data.Text.Format.Heavy.Types
+
+-- | Parsec parser for generic (Python-like) variable format.
+pGenericFormat :: Parsec TL.Text st GenericFormat
+pGenericFormat = do
+  mbFillAlign <- optionMaybe (try pFillAlign <?> "fill and align specification")
+  let fill = fromMaybe ' ' $ fst `fmap` mbFillAlign
+  let align = snd `fmap` mbFillAlign
+  mbSign <- optionMaybe (pSign <?> "sign specification")
+  let sign = fromMaybe OnlyNegative mbSign
+  mbLeading0x <- optionMaybe (pLeading0x <?> "leading 0x specification")
+  let leading0x = fromMaybe False mbLeading0x
+  mbWidth <- optionMaybe (pWidth <?> "width specification")
+  mbPrecision <- optionMaybe (pPrecision <?> "precision specification")
+  mbRadixConvert <- optionMaybe (pRadix <?> "radix specification")
+  mbConvert <- optionMaybe (pConvert <?> "conversion specification")
+  return $
+    GenericFormat
+      { gfFillChar = fill
+      , gfAlign = align
+      , gfSign = sign
+      , gfLeading0x = leading0x
+      , gfWidth = mbWidth
+      , gfPrecision = mbPrecision
+      , gfRadix = fst <$> mbRadixConvert
+      , gfConvert = mbConvert <|> fmap snd mbRadixConvert
+      }
+ where
+  pAlign :: Parsec TL.Text st Align
+  pAlign = do
+    alignChar <- oneOf "<>^"
+    align <- case alignChar of
+      '<' -> return AlignLeft
+      '>' -> return AlignRight
+      '^' -> return AlignCenter
+      _ -> fail $ "Unexpected align char: " ++ [alignChar]
+    return align
+
+  pAlignWithFill :: Parsec TL.Text st (Char, Align)
+  pAlignWithFill = do
+    fill <- noneOf "<>=^"
+    align <- pAlign
+    return (fill, align)
+
+  pAlignWithoutFill :: Parsec TL.Text st (Char, Align)
+  pAlignWithoutFill = do
+    align <- pAlign
+    return (' ', align)
+
+  pFillAlign :: Parsec TL.Text st (Char, Align)
+  pFillAlign = do
+    try pAlignWithoutFill <|> pAlignWithFill
+
+  pSign :: Parsec TL.Text st Sign
+  pSign = do
+    signChar <- oneOf "+- "
+    sign <- case signChar of
+      '+' -> return Always
+      '-' -> return OnlyNegative
+      ' ' -> return SpaceForPositive
+      _ -> fail $ "Unexpected sign char: " ++ [signChar]
+    return sign
+
+  pLeading0x :: Parsec TL.Text st Bool
+  pLeading0x = do
+    mbSharp <- optionMaybe $ char '#'
+    case mbSharp of
+      Nothing -> return False
+      Just _ -> return True
+
+  natural :: Parsec TL.Text st Int
+  natural = do
+    ws <- many1 $ oneOf "0123456789"
+    return $ read ws
+
+  pWidth :: Parsec TL.Text st Int
+  pWidth = natural
+
+  pPrecision :: Parsec TL.Text st Int
+  pPrecision = do
+    char '.'
+    natural
+
+  pRadix :: Parsec TL.Text st (Radix, Conversion)
+  pRadix = do
+    rc <- oneOf "xXhHd"
+    case rc of
+      'x' -> return (Hexadecimal, LowerCase)
+      'X' -> return (Hexadecimal, UpperCase)
+      'h' -> return (Hexadecimal, LowerCase)
+      'H' -> return (Hexadecimal, UpperCase)
+      'd' -> return (Decimal, LowerCase)
+
+  pConvert :: Parsec TL.Text st Conversion
+  pConvert = do
+    char '~'
+    conv <- oneOf "ult"
+    case conv of
+      'u' -> return UpperCase
+      'l' -> return LowerCase
+      't' -> return TitleCase
+
+-- | Parse generic variable format.
+--
+-- Syntax is:
+--
+-- @
+-- [[fill]align][sign][#][width][.precision][radix][~conversion]
+-- @
+--
+-- where:
+--
+-- * fill - padding character (space by default)
+-- * align - alignment indicator (@<@, @>@, or @^@)
+-- * sign - when to show number's sign (@+@, @-@, or space)
+-- * @#@ - if specified, then for hexadecimal numbers the leading @0x@ will be added
+-- * width - minimum length of the field
+-- * precision - number of decimal places after point, for floatting-point numbers
+-- * radix - @h@ or @x@ for hexadecimal, @d@ for decimal (default).
+-- * conversion - text conversion symbol. Supported are: @u@ - convert to upper case,
+--   @l@ - convert to lower case, @t@ - convert to title case (capitalize all words).
+parseGenericFormat :: TL.Text -> Either ParseError GenericFormat
+parseGenericFormat text = runParser pGenericFormat () "<variable format specification>" text
+
+-- | Parsec parser for Bool format
+pBoolFormat :: Parsec TL.Text st BoolFormat
+pBoolFormat = do
+  true <- many $ noneOf ":,;"
+  oneOf ":,;"
+  false <- many $ anyChar
+  return $ BoolFormat (TL.pack true) (TL.pack false)
+
+-- | Parse Bool format.
+--
+-- Syntax is:
+--
+-- @
+-- TRUE:FALSE
+-- @
+--
+-- Colon can be replaced with comma or semicolon.
+--
+-- For example, valid format specifications are @true:false@ (the default one),
+-- @True:False@, @yes:no@, and so on.
+parseBoolFormat :: TL.Text -> Either ParseError BoolFormat
+parseBoolFormat text = runParser pBoolFormat () "<boolean format specification>" text
+
+-- | Try to parse format for @Maybe x@ type.
+-- The syntax is:
+--
+-- @
+-- someformat|nothing
+-- @
+--
+-- where @someformat@ is format for the @x@ type, and @nothing@ is the string
+-- to be substituted for @Nothing@ value.
+--
+-- Returns Nothing, if format does not contain @|@. Otherwise, returns
+-- @Just (someformat, nothing)@.
+parseMaybeFormat :: TL.Text -> Maybe (TL.Text, TL.Text)
+parseMaybeFormat text =
+  let (xFmtStr, nothingStr) = TL.breakOnEnd "|" text
+   in if TL.null xFmtStr
+        then Nothing
+        else Just (TL.init xFmtStr, nothingStr)
diff --git a/Data/Text/Format/Heavy/Time.hs b/Data/Text/Format/Heavy/Time.hs
new file mode 100644
--- /dev/null
+++ b/Data/Text/Format/Heavy/Time.hs
@@ -0,0 +1,55 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+-- | This module contains Formatable instances for time/date values,
+-- which use Data.Time.Format notation for formats (like @%H:%M@).
+-- Default date/time format is RFC 822.
+--
+-- This module is not re-exported by Data.Text.Format.Heavy by default,
+-- because it defines only one of possible time formatting strings syntaxes.
+-- One may like other syntax for some reason; if we re-exported this module by
+-- default, it would be impossible to hide these instances to implement other.
+module Data.Text.Format.Heavy.Time where
+
+import Data.Char
+import Data.Default
+import Data.String
+import qualified Data.Text as T
+import qualified Data.Text.Lazy as TL
+import qualified Data.Text.Lazy.Builder as B
+import Data.Time
+import Data.Time.Format
+
+import Data.Text.Format.Heavy.Build
+import Data.Text.Format.Heavy.Parse
+import Data.Text.Format.Heavy.Types
+
+-- | Generic time formatter, using Data.Time.Format
+genericTimeFormat :: (FormatTime t) => VarFormat -> t -> Either String B.Builder
+genericTimeFormat Nothing x = Right $ B.fromString $ formatTime defaultTimeLocale rfc822DateFormat x
+genericTimeFormat (Just fmtStr) x =
+  Right $ B.fromString $ formatTime defaultTimeLocale (TL.unpack fmtStr) x
+
+------------------------ Formatable instances -------------------------------------------
+
+-- instance Formatable UniversalTime where
+--   formatVar fmt x = genericTimeFormat fmt x
+
+instance Formatable Day where
+  formatVar fmt x = genericTimeFormat fmt x
+
+instance Formatable UTCTime where
+  formatVar fmt x = genericTimeFormat fmt x
+
+instance Formatable TimeZone where
+  formatVar fmt x = genericTimeFormat fmt x
+
+instance Formatable TimeOfDay where
+  formatVar fmt x = genericTimeFormat fmt x
+
+instance Formatable LocalTime where
+  formatVar fmt x = genericTimeFormat fmt x
+
+instance Formatable ZonedTime where
+  formatVar fmt x = genericTimeFormat fmt x
diff --git a/Data/Text/Format/Heavy/Types.hs b/Data/Text/Format/Heavy/Types.hs
new file mode 100644
--- /dev/null
+++ b/Data/Text/Format/Heavy/Types.hs
@@ -0,0 +1,121 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE ExistentialQuantification #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeFamilies #-}
+
+-- | This module contains basic type definitions
+module Data.Text.Format.Heavy.Types where
+
+import Data.Default
+
+#if MIN_VERSION_base(4,9,0)
+import Data.Monoid (Monoid)
+import Data.Semigroup ((<>))
+import qualified Data.Semigroup as Semigroup
+#else
+import Data.Monoid
+#endif
+
+import qualified Data.Text as T
+import qualified Data.Text.Lazy as TL
+import qualified Data.Text.Lazy.Builder as B
+
+-- | Variable name
+type VarName = TL.Text
+
+-- | Variable format in text form. Nothing means default format.
+type VarFormat = Maybe TL.Text
+
+-- | String format item.
+data FormatItem
+  = -- | Verbatim text
+    FString TL.Text
+  | FVariable
+      { vName :: VarName
+      -- ^ Variable name
+      , vFormat :: VarFormat
+      -- ^ Variable format
+      }
+  deriving (Eq)
+
+instance Show FormatItem where
+  show (FString text) = TL.unpack text
+  show (FVariable name Nothing) = TL.unpack $ "{" <> name <> "}"
+  show (FVariable name (Just fmt)) = TL.unpack $ "{" <> name <> ":" <> fmt <> "}"
+
+-- | String format
+data Format = Format [FormatItem]
+  deriving (Eq)
+
+instance Show Format where
+  show (Format lst) = concat $ map show lst
+
+appendFormat :: Format -> Format -> Format
+appendFormat (Format xs) (Format ys) = Format (xs ++ ys)
+
+#if MIN_VERSION_base(4,9,0)
+instance Semigroup.Semigroup Format where
+  (<>) = appendFormat
+#endif
+
+instance Monoid Format where
+  mempty = Format []
+
+#if MIN_VERSION_base(4,11,0)
+  -- starting with base-4.11, mappend definitions are redundant;
+#elif MIN_VERSION_base(4,9,0)
+  -- this is redundant starting with base-4.11 / GHC 8.4
+  mappend = (Semigroup.<>)
+#else
+  -- prior to GHC 8.0 / base-4.9 where no `Semigroup` class existed
+  mappend = appendFormat
+#endif
+
+-- | Can be used for different data types describing formats of specific types.
+class (Default f, Show f) => IsVarFormat f where
+  -- | Left for errors.
+  parseVarFormat :: TL.Text -> Either String f
+
+instance IsVarFormat () where
+  parseVarFormat "" = Right ()
+  parseVarFormat fmt = Left $ "Unsupported format: " ++ TL.unpack fmt
+
+-- | Value that can be formatted to be substituted into format string.
+class Formatable a where
+  -- | Format variable according to format specification.
+  -- This function should usually parse format specification by itself.
+  formatVar
+    :: VarFormat
+    -- ^ Variable format specification in text form. Nothing is for default format.
+    -> a
+    -- ^ Variable value.
+    -> Either String B.Builder
+    -- ^ Left for errors in variable format syntax, or errors during formatting.
+
+-- | Any variable that can be substituted.
+-- This type may be also used to construct heterogeneous lists:
+-- @[Variable 1, Variable "x"] :: [Variable]@.
+data Variable = forall a. (Formatable a) => Variable a
+
+instance Show Variable where
+  show (Variable v) = either error toString $ formatVar Nothing v
+   where
+    toString :: B.Builder -> String
+    toString b = TL.unpack $ B.toLazyText b
+
+instance Formatable Variable where
+  formatVar fmt (Variable x) = formatVar fmt x
+
+-- | Format one variable according to format specification.
+formatAnyVar :: VarFormat -> Variable -> Either String B.Builder
+formatAnyVar fmt (Variable v) = formatVar fmt v
+
+-- | Data structure that contains some number of variables.
+class VarContainer c where
+  lookupVar :: VarName -> c -> Maybe Variable
+
+class (VarContainer c) => ClosedVarContainer c where
+  allVarNames :: c -> [VarName]
+
+------------------------------------------------------------------------------
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2017, Ilya Portnov
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Ilya Portnov nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,179 @@
+# format-heavy
+
+`format-heavy` is a continuation of [`text-format-heavy`](https://github.com/portnov/text-format-heavy),
+originally created by Ilya Portnov, and provides Haskell string formatting inspired by Python's `str.format()` syntax.
+
+It supports positional and named placeholders, per-value format specs, custom
+variable containers, and custom `Formatable` instances.
+
+## Quick Start
+
+```haskell
+{-# LANGUAGE OverloadedStrings #-}
+
+import Data.Text.Format.Heavy
+import qualified Data.Text.Lazy as TL
+
+hello :: TL.Text
+hello = format "Hello, {name}!" [("name", "world" :: TL.Text)]
+```
+
+## Placeholder Syntax
+
+```haskell
+format "{}" (Single ("hello" :: TL.Text))
+-- "hello"
+
+format "{0} {1}" ("hello" :: TL.Text, "world" :: TL.Text)
+-- "hello world"
+
+format "{name}" [("name", "world" :: TL.Text)]
+-- "world"
+
+format "{{name}}" ()
+-- "{name}"
+```
+
+The default syntax uses braces. Literal braces can be escaped as `{{` and `}}`.
+
+## Passing Variables
+
+Use `Single` for one value:
+
+```haskell
+format "value: {}" (Single (42 :: Int))
+-- "value: 42"
+```
+
+Use tuples or lists for positional placeholders:
+
+```haskell
+format "{0}, {1}" ("hello" :: TL.Text, "world" :: TL.Text)
+-- "hello, world"
+
+format "{0}, {2}" (Several ["zero", "one", "two" :: TL.Text])
+-- "zero, two"
+```
+
+Use association lists or maps for named placeholders:
+
+```haskell
+format "Hello, {name}!" [("name", "Alice" :: TL.Text)]
+-- "Hello, Alice!"
+```
+
+For heterogeneous named values, wrap each value in `Variable`:
+
+```haskell
+let vars =
+      [ ("name", Variable ("Alice" :: TL.Text))
+      , ("count", Variable (3 :: Int))
+      ]
+
+format "{name} has {count} messages" vars
+-- "Alice has 3 messages"
+```
+
+## Format Specs
+
+Format specs are written after `:` and interpreted by the value being formatted:
+
+```haskell
+format "hex: {:#x}" (Single (427 :: Int))
+-- "hex: 0x1ab"
+
+format "float: {:+6.4}" (Single (2.718281828 :: Double))
+-- "float: +2.7183"
+
+format "center: <{:^10}>" (Single ("hello" :: String))
+-- "center: <   hello  >"
+
+format "upper: {:~u}" (Single ("hello" :: TL.Text))
+-- "upper: HELLO"
+
+format "bool: {:yes:no}" (Single False)
+-- "bool: no"
+```
+
+`Maybe` values can specify a fallback after `|`:
+
+```haskell
+format "value: {:.3|<missing>}" (Single (Nothing :: Maybe Float))
+-- "value: <missing>"
+```
+
+Any value with a `Show` instance can be formatted through `Shown`:
+
+```haskell
+format "debug: {}" (Single (Shown (Just True)))
+-- "debug: Just True"
+```
+
+## Error Handling
+
+`format` is convenient and throws an error if formatting fails.
+
+Use `formatEither` when errors should be handled explicitly:
+
+```haskell
+import Data.Text.Format.Heavy.Build (formatEither)
+
+formatEither "missing: {1}" (Single ("value" :: TL.Text))
+-- Left "Parameter not found: 1"
+```
+
+## Parsing And Introspection
+
+Use `Data.Text.Format.Heavy.Parse.parse` to inspect a format string without
+formatting it:
+
+```haskell
+import qualified Data.Text.Format.Heavy.Parse as Format
+
+Format.parse "{name}"
+-- Right [FormatReplacementField "name" Nothing]
+
+Format.parse "{name:.2}"
+-- Right [FormatReplacementField "name" (Just ".2")]
+
+Format.parse "{{name}} {name}"
+-- Right [FormatString "{name} ", FormatReplacementField "name" Nothing]
+
+Format.parse "{value:{width}}"
+-- Right [FormatReplacementField "value" (Just "{width}")]
+```
+
+This API exposes the field name and the raw format spec parsed from the default
+braces syntax. It does not format values.
+
+## Shell-Like Syntax
+
+The default syntax uses braces. A shell-like syntax is also available:
+
+```haskell
+import Data.Text.Format.Heavy
+import Data.Text.Format.Heavy.Parse.Shell
+import qualified Data.Text.Lazy as TL
+
+format (parseShellFormat' "Hello, $name!") [("name", "world" :: TL.Text)]
+-- "Hello, world!"
+```
+
+Braced shell-style placeholders can also carry format specs:
+
+```haskell
+format (parseShellFormat' "hex: ${:#x}") (Single (427 :: Int))
+-- "hex: 0x1ab"
+```
+
+## Extending
+
+Applications can extend the library by defining:
+
+- `Formatable` instances for custom value types.
+- `VarContainer` instances for custom variable sources.
+- Custom parsers that produce `Format`.
+
+## License
+
+BSD-3-Clause. See `LICENSE`.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,3 @@
+import Distribution.Simple
+
+main = defaultMain
diff --git a/examples/Benchmarks.hs b/examples/Benchmarks.hs
new file mode 100644
--- /dev/null
+++ b/examples/Benchmarks.hs
@@ -0,0 +1,107 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+import Criterion.Main
+import qualified Data.Text as T
+import Data.Text.Format.Heavy
+import qualified Data.Text.Lazy as L
+import qualified Text.Printf as P
+
+printf1 :: (P.PrintfArg a) => String -> a -> String
+printf1 f a = P.printf f a
+
+printf2 :: (P.PrintfArg a, P.PrintfArg b) => String -> (a, b) -> String
+printf2 f (a, b) = P.printf f a b
+
+printf3
+  :: (P.PrintfArg a, P.PrintfArg b, P.PrintfArg c)
+  => String -> (a, b, c) -> String
+printf3 f (a, b, c) = P.printf f a b c
+
+main =
+  defaultMain
+    [ bgroup
+        "arity"
+        [ bench "0" $ nf (format "hi") ()
+        , bench "1" $ nf (format "hi {}") (Single $ T.pack "mom")
+        , bench "2" $
+            nf
+              (format "hi {}, how are {}")
+              (T.pack "mom", T.pack "you")
+        , bench "3" $
+            nf
+              (format "hi {}, how are {} keeping {}")
+              (T.pack "mom", T.pack "you", T.pack "now")
+        , bench "4" $
+            nf
+              (format "hi {}, {} - how are {} keeping {}")
+              (T.pack "mom", T.pack "hey", T.pack "you", T.pack "now")
+        ]
+    , bgroup
+        "comparison"
+        [ bench "format1" $ nf (format "hi mom {}\n") (Single (pi :: Double))
+        , bench "printf1" $ nf (printf1 "hi mom %f\n") (pi :: Double)
+        , bench "show1" $ nf (\d -> "hi mom " ++ show d ++ "\n") (pi :: Double)
+        , bench "format2" $ nf (format "hi mom {} {}\n") (pi :: Double, "yeah" :: T.Text)
+        , bench "printf2" $ nf (printf2 "hi mom %f %s\n") (pi :: Double, "yeah" :: String)
+        , bench "show2" $
+            nf (\(d, s) -> "hi mom " ++ show d ++ " " ++ show s ++ "\n") (pi :: Double, "yeah" :: String)
+        , bench "format3" $ nf (format "hi mom {} {} {}\n") (pi :: Double, "yeah" :: T.Text, 21212121 :: Int)
+        , bench "printf3" $ nf (printf3 "hi mom %f %s %d\n") (pi :: Double, "yeah" :: String, 21212121 :: Int)
+        , bench "show3" $
+            nf
+              (\(d, s, i) -> "hi mom " ++ show d ++ " " ++ show s ++ "\n")
+              (pi :: Double, "yeah" :: String, 21212121 :: Int)
+        ]
+    , bgroup
+        "types"
+        [ bench "unit" $ nf (format "hi") ()
+        , bgroup
+            "int"
+            [ bench "small" $ nf (format "hi {}") (Single (1 :: Int))
+            , bench "medium" $ nf (format "hi {}") (Single (1234 :: Int))
+            , bench "large" $ nf (format "hi {}") (Single (0x7fffffff :: Int))
+            ]
+        , bgroup
+            "float"
+            [ bench "small" $ nf (format "hi {}") (Single (1 :: Float))
+            , bench "medium" $ nf (format "hi {}") (Single (pi :: Float))
+            , bench "large" $ nf (format "hi {}") (Single (pi * 1e37 :: Float))
+            ]
+        , bgroup
+            "double"
+            [ bench "small" $ nf (format "hi {}") (Single (1 :: Double))
+            , bench "medium" $ nf (format "hi {}") (Single (pi :: Double))
+            , bench "large" $ nf (format "hi {}") (Single (pi * 1e37 :: Double))
+            ]
+        , bgroup
+            "string"
+            [ bench "small" $ nf (format "hi {}") (Single ("mom" :: String))
+            , bench "medium" $
+                nf
+                  (format "hi {}")
+                  (Single . concat . replicate 64 $ ("mom" :: String))
+            , bench "large" $
+                nf
+                  (format "hi {}")
+                  (Single . concat . replicate 1024 $ ("mom" :: String))
+            ]
+        , bgroup
+            "text"
+            [ bench "small" $ nf (format "hi {}") (Single (T.pack "mom"))
+            , bench "medium" $ nf (format "hi {}") (Single (T.replicate 64 "mom"))
+            , bench "large" $ nf (format "hi {}") (Single (T.replicate 1024 "mom"))
+            ]
+        , bgroup
+            "lazytext"
+            [ bench "small" $ nf (format "hi {}") (Single (L.pack "mom"))
+            , bench "medium" $
+                nf
+                  (format "hi {}")
+                  (Single . L.fromChunks . replicate 64 $ "mom")
+            , bench "large" $
+                nf
+                  (format "hi {}")
+                  (Single . L.fromChunks . replicate 1024 $ "mom")
+            ]
+        ]
+    ]
diff --git a/examples/test.hs b/examples/test.hs
new file mode 100644
--- /dev/null
+++ b/examples/test.hs
@@ -0,0 +1,45 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+import Data.Text.Format.Heavy
+import Data.Text.Format.Heavy.Time
+import qualified Data.Text.Lazy as TL
+import qualified Data.Text.Lazy.IO as TLIO
+import Data.Time
+
+main :: IO ()
+main = do
+  let template = "x: {:#x}, y: <{:^10}>, z: {:+6.4}, x(d): {0}, t: {}, y: {}, r: {}; bool: {:yes:no}."
+      -- xs = [Variable (18 :: Int), Variable ("hello" :: String), Variable (3 :: Int)]
+      rt = (Right 7) :: Either String Int
+      xs =
+        ( 18 :: Int
+        , "hello" :: String
+        , 2.718281828 :: Double
+        , Shown (Just (7 :: Int))
+        , Just (8 :: Int)
+        , rt
+        , True
+        )
+  TLIO.putStrLn $ format template xs
+  time <- getZonedTime
+  let vars :: [(TL.Text, Variable)]
+      vars =
+        [ ("name", Variable ("Ilya" :: String))
+        , ("time", Variable time)
+        , ("header.content-type", Variable ("text/json" :: String))
+        , ("upper", Variable ("this will be uppercase" :: String))
+        , ("lower", Variable ("This will be ALL lower case" :: String))
+        , ("title", Variable ("this is the title" :: String))
+        , ("noun", Variable ("string" :: String))
+        ]
+  TLIO.putStrLn $
+    format
+      "Hello, {name}! It is {time:%H:%M:%S} now. Test {noun}ification. Content: {header.content-type} x. Upper: <{upper:~u}>, lower <{lower:~l}>, title: <{title:~t}>."
+      vars
+  let mbX = Nothing :: Maybe Float
+      mbY = Just 7.37491 :: Maybe Float
+      mbZ = Nothing :: Maybe Float
+  TLIO.putStrLn $
+    format
+      "Maybe X: {:+8.4|<not defined>}, Maybe Y: {:+8.4|<not defined>}, Maybe Z: {:+8.4}."
+      (mbX, mbY, mbZ)
diff --git a/format-heavy.cabal b/format-heavy.cabal
new file mode 100644
--- /dev/null
+++ b/format-heavy.cabal
@@ -0,0 +1,84 @@
+cabal-version: 1.12
+
+-- Keep this file synchronized with package.yaml when changing package
+-- metadata, dependencies, exposed modules, or test configuration.
+
+name:           format-heavy
+version:        0.1.0.2
+synopsis:       Full-weight string formatting library, analog of Python's string.format
+description:    This package contains full-featured string formatting function, similar to Python's string.format. Features include:
+                * Automatically numbered variable placeholders;
+                * Positional variable placeholders;
+                * Named variable placeholders;
+                * Placeholders can be used in any order; one variable can be used several
+                  times or not used at all.
+                .
+                * Specific format can be used for each variable substitution.
+                This package prefers functionality over "light weight" and (probably) performance.  It also exposes all required interfaces to extend and customize it.
+                See the README and @examples/@ directory for details.
+category:       Text
+homepage:       https://github.com/lbobylev/format-heavy#format-heavy
+bug-reports:    https://github.com/lbobylev/format-heavy/issues
+author:         Ilya Portnov
+maintainer:     Leonid Bobylev <l3o6@proton.me>
+tested-with:    GHC == 9.2.8, GHC == 9.4.8, GHC == 9.6.7, GHC == 9.8.4, GHC == 9.10.3
+license:        BSD3
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    ChangeLog.md
+    README.md
+    examples/test.hs
+    examples/Benchmarks.hs
+
+source-repository head
+  type: git
+  location: https://github.com/lbobylev/format-heavy
+
+library
+  exposed-modules:
+      Data.Text.Format.Heavy
+      Data.Text.Format.Heavy.Build
+      Data.Text.Format.Heavy.Formats
+      Data.Text.Format.Heavy.Instances
+      Data.Text.Format.Heavy.Parse
+      Data.Text.Format.Heavy.Parse.Braces
+      Data.Text.Format.Heavy.Parse.Shell
+      Data.Text.Format.Heavy.Parse.Types
+      Data.Text.Format.Heavy.Parse.VarFormat
+      Data.Text.Format.Heavy.Time
+      Data.Text.Format.Heavy.Types
+  other-modules:
+       Paths_format_heavy
+  hs-source-dirs:
+      .
+  build-depends:
+      base >=4.16 && <5
+    , bytestring >=0.12 && <0.13
+    , containers >=0.7 && <0.8
+    , data-default >=0.8 && <0.9
+    , labels >=0.3 && <0.4
+    , parsec >=3.1.18 && <3.2
+    , template-haskell >=2.18 && <2.24
+    , text >=2.1 && <3
+    , th-lift >=0.8 && <0.9
+    , th-lift-instances >=0.1 && <0.2
+    , time >=1.12 && <1.15
+  default-language: Haskell2010
+
+test-suite spec
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+       Paths_format_heavy
+  hs-source-dirs:
+      tests
+  build-depends:
+      base >=4.16 && <5
+    , bytestring >=0.12 && <0.13
+    , hspec
+     , format-heavy
+    , time >=1.12 && <1.15
+    , containers
+    , text
+  default-language: Haskell2010
diff --git a/tests/Spec.hs b/tests/Spec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Spec.hs
@@ -0,0 +1,177 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy as BSL
+import Data.Map (Map)
+import qualified Data.Map as Map
+import qualified Data.Text as T
+import qualified Data.Text.Lazy as TL
+import Data.Time
+import Test.Hspec
+
+import Data.Text.Format.Heavy
+import Data.Text.Format.Heavy.Build (formatEither)
+import Data.Text.Format.Heavy.Parse (FormatParseItem (..), parse)
+import Data.Text.Format.Heavy.Parse.Shell
+import Data.Text.Format.Heavy.Time
+
+main :: IO ()
+main = hspec $ do
+  describe "trivial" $ do
+    it "formats string literal without formatting characters" $ do
+      format "hello world" () `shouldBe` "hello world"
+
+  describe "parse" $ do
+    it "returns replacement fields" $ do
+      parse "{name}"
+        `shouldBe` Right [FormatReplacementField "name" Nothing]
+      parse "{name:.2}"
+        `shouldBe` Right [FormatReplacementField "name" (Just ".2")]
+
+    it "keeps strings and fields in order" $ do
+      parse "{{name}} {name}"
+        `shouldBe` Right [FormatString "{name} ", FormatReplacementField "name" Nothing]
+
+    it "keeps nested format specs raw" $ do
+      parse "{value:{width}}"
+        `shouldBe` Right [FormatReplacementField "value" (Just "{width}")]
+
+  describe "simple" $ do
+    it "formats int properly" $ do
+      format "integer: {}" (Single (7 :: Int)) `shouldBe` "integer: 7"
+
+    it "formats strings properly" $ do
+      format "string: {}" (Single ("hello" :: String)) `shouldBe` "string: hello"
+
+    it "formats text values properly" $ do
+      format "strict: {}" (Single (T.pack "hello")) `shouldBe` "strict: hello"
+      format "lazy: {}" (Single (TL.pack "hello")) `shouldBe` "lazy: hello"
+
+    it "formats utf8 byte strings properly" $ do
+      format "strict: {}" (Single (BS.pack [104, 101, 108, 108, 111])) `shouldBe` "strict: hello"
+      format "lazy: {}" (Single (BSL.pack [104, 101, 108, 108, 111])) `shouldBe` "lazy: hello"
+
+    it "handles parameter numbers" $ do
+      format "one: {0}, two: {1}" ((1 :: Int), (2 :: Int)) `shouldBe` "one: 1, two: 2"
+      format "two: {1}, one: {0}" ((1 :: Int), (2 :: Int)) `shouldBe` "two: 2, one: 1"
+
+    describe "handles parameters names" $ do
+      it "with ascii characters" $ do
+        format
+          "one: {theKey}!"
+          ((Map.singleton "theKey" "the string") :: Map TL.Text TL.Text)
+          `shouldBe` "one: the string!"
+      it "with dots" $ do
+        format
+          "one: {the.key}!"
+          ((Map.singleton "the.key" "the string") :: Map TL.Text TL.Text)
+          `shouldBe` "one: the string!"
+      it "with dashes" $ do
+        format
+          "one: {the-key}!"
+          ((Map.singleton "the-key" "the string") :: Map TL.Text TL.Text)
+          `shouldBe` "one: the string!"
+      it "with underscores" $ do
+        format
+          "one: {the_key}!"
+          ((Map.singleton "the_key" "the string") :: Map TL.Text TL.Text)
+          `shouldBe` "one: the string!"
+
+    it "handles additional variable containers" $ do
+      format "{0}, {1}, {2}" (("one" :: String), (2 :: Int), ("three" :: String))
+        `shouldBe` "one, 2, three"
+      format "{0}, {2}" (Several ["zero", "one", "two" :: String])
+        `shouldBe` "zero, two"
+      format "hello {name}" ([("name", "world" :: TL.Text)] :: [(TL.Text, TL.Text)])
+        `shouldBe` "hello world"
+
+    it "handles defaulting containers" $ do
+      format
+        "present: {0}; missing: {1}"
+        (Single ("value" :: String) `withDefault` Variable ("fallback" :: TL.Text))
+        `shouldBe` "present: value; missing: fallback"
+      format "present: {0}; missing: {1}" (optional (Single ("value" :: String)))
+        `shouldBe` "present: value; missing: "
+
+    it "handles escaped braces" $ do
+      format "{{}}" () `shouldBe` "{}"
+      format "\\{name\\}" () `shouldBe` "{name}"
+      format "{{{name}}}" ([("name", "world" :: TL.Text)] :: [(TL.Text, TL.Text)])
+        `shouldBe` "{world}"
+      format
+        "json: {{\"answer\": \"{answer}\"}}"
+        ([("answer", "ok" :: TL.Text)] :: [(TL.Text, TL.Text)])
+        `shouldBe` "json: {\"answer\": \"ok\"}"
+
+  describe "documentation" $ do
+    it "formats examples from wiki" $ do
+      format "hex: {:#x}" (Single (427 :: Int)) `shouldBe` "hex: 0x1ab"
+      format "hex: {:#h}" (Single (427 :: Int)) `shouldBe` "hex: 0x1ab"
+      format "hex: {:#X}" (Single (427 :: Int)) `shouldBe` "hex: 0x1AB"
+      format "hex: {:#H}" (Single (427 :: Int)) `shouldBe` "hex: 0x1AB"
+      format "dec: {:#d}" (Single (17 :: Int)) `shouldBe` "dec: 17"
+      format "center: <{0:^10}>" (Single ("hello" :: String)) `shouldBe` "center: <   hello  >"
+      format "float: {:+6.4}" (Single (2.718281828 :: Double)) `shouldBe` "float: +2.7183"
+
+    it "formats alignment and text conversion options" $ do
+      format "left: <{:<8}>" (Single ("hi" :: String)) `shouldBe` "left: <hi      >"
+      format "right: <{:>8}>" (Single ("hi" :: String)) `shouldBe` "right: <      hi>"
+      format "center: <{:^7}>" (Single ("hi" :: String)) `shouldBe` "center: <   hi  >"
+      format "fill: <{:*>8}>" (Single ("hi" :: String)) `shouldBe` "fill: <******hi>"
+      format "upper: {:~u}" (Single ("hello" :: String)) `shouldBe` "upper: HELLO"
+      format "lower: {:~l}" (Single ("HELLO" :: String)) `shouldBe` "lower: hello"
+      format "title: {:~t}" (Single ("hello world" :: String)) `shouldBe` "title: Hello World"
+
+    it "formats numeric options" $ do
+      format "positive: {:+d}" (Single (7 :: Int)) `shouldBe` "positive: +7"
+      format "negative: {:+d}" (Single (-7 :: Int)) `shouldBe` "negative: -7"
+      format "space: {: d}" (Single (7 :: Int)) `shouldBe` "space:  7"
+      format "hex: {:#X}" (Single (255 :: Integer)) `shouldBe` "hex: 0xFF"
+
+    it "formats booleans" $ do
+      format "default: {}" (Single True) `shouldBe` "default: true"
+      format "enable: {:yes:no}" (Single False) `shouldBe` "enable: no"
+
+    it "formats maybes" $ do
+      format "Value: {:.3|<undefined>}." (Single (2.718281828 :: Float)) `shouldBe` "Value: 2.718."
+      format "Value: {:.3|<undefined>}." (Single (Nothing :: Maybe Float))
+        `shouldBe` "Value: <undefined>."
+      format "Value: {:.3}." (Single (Nothing :: Maybe Float)) `shouldBe` "Value: ."
+
+    it "formats either and shown values" $ do
+      format "left: {}" (Single (Left ("error" :: String) :: Either String Int)) `shouldBe` "left: error"
+      format "right: {}" (Single (Right (7 :: Int) :: Either String Int)) `shouldBe` "right: 7"
+      format "shown: {}" (Single (Shown (True, False))) `shouldBe` "shown: (True,False)"
+
+    it "formats time" $ do
+      let yektLocale =
+            defaultTimeLocale
+              { knownTimeZones = [TimeZone (5 * 60) False "YEKT"]
+              }
+          -- `defaultTimeLocale` does not know about Yekaterinburg.
+          Just time = parseTimeM True yektLocale rfc822DateFormat "Sat,  3 Jun 2017 19:06:01 YEKT" :: Maybe ZonedTime
+      format "time: {:%H:%M:%S}" (Single time) `shouldBe` "time: 19:06:01"
+      format "time: {:%H:%M:%S %Z}" (Single time) `shouldBe` "time: 19:06:01 YEKT"
+      format "default: {}" (Single time) `shouldBe` "default: Sat,  3 Jun 2017 19:06:01 YEKT"
+
+  describe "errors" $ do
+    it "reports missing parameters" $ do
+      formatEither "missing: {1}" (Single ("value" :: String))
+        `shouldBe` Left "Parameter not found: 1"
+      formatEither "missing: {2}" (Several ["zero", "one" :: String])
+        `shouldBe` Left "Parameter not found: 2"
+
+  describe "shell syntax" $ do
+    it "formats shell-style substitutions" $ do
+      format (parseShellFormat' "hello $name") ([("name", "world" :: TL.Text)] :: [(TL.Text, TL.Text)])
+        `shouldBe` "hello world"
+      format
+        (parseShellFormat' "hello ${name}")
+        ([("name", "world" :: TL.Text)] :: [(TL.Text, TL.Text)])
+        `shouldBe` "hello world"
+      format (parseShellFormat' "${} ${}") (("one" :: String), ("two" :: String))
+        `shouldBe` "one two"
+      format
+        (parseShellFormat' "cost: $$${amount}")
+        ([("amount", "10" :: TL.Text)] :: [(TL.Text, TL.Text)])
+        `shouldBe` "cost: $10"
