packages feed

hmp3-ng-2.20.0: src/Text.hs

-- Copyright (c) 2019-2026 Galen Huntington
-- SPDX-License-Identifier: GPL-2.0-or-later

-- This module provides basic text string functions.

module Text (
    SText, matches,
    trim, spaces, guessEncoding, dropLastUTF8,
    readIntM, showInt, show02d,
    width, toMaxWidth, toWidth,
    fromBS, toBS, fromChar,
    notNull, encodeFS,isLineSafe,
) where

import Base

import Data.ByteString.Char8 qualified as P
import Data.ByteString.UTF8 qualified as UTF8
import Foreign.C.Types (CWchar(..), CInt(..))
import GHC.Foreign qualified as GHC
import GHC.IO.Encoding (getFileSystemEncoding)
import Text.Regex.Posix (match, makeRegexOptsM, compIgnoreCase, compExtended, compNoSub)


-- SText type and functions.

-- | Screen/Sanitized/Safe text:
-- A string of valid UTF-8 with only printable characters.
data SText = SText
    { string :: !ByteString
    , width  :: !Int
    } deriving stock (Eq, Show)

instance Semigroup SText where
    s <> t = SText (s.string <> t.string) (s.width + t.width)
instance Monoid SText where
    mempty = SText "" 0
    mconcat l = SText (P.concat $ map (.string) l) (sum $ map (.width) l)
instance IsString SText where
    fromString s = let bs = UTF8.fromString $ toPrintable s in SText bs (stringWidth bs)

toBS :: SText -> ByteString
toBS = (.string)

width :: SText -> Int
width = (.width)

spaces :: Int -> SText
spaces n | n > 0 = SText (P.replicate n ' ') n
         | True  = ""

-- More convenient than null, I find.
notNull :: SText -> Bool
notNull = not . P.null . (.string)

-- | Swappable API for searching
matches :: SText -> Maybe (SText -> Bool)
matches (SText s _) =
    match' <$> makeRegexOptsM (compIgnoreCase + compExtended + compNoSub) 0 s
  where match' re (SText bs _) = match re bs

-- | Possible number.
readIntM :: SText -> Maybe Int
readIntM = fmap fst . P.readInt . toBS

showInt :: Int -> SText
showInt = unsafeFromAsciiBS . P.pack . show

-- | Show Int from 0 to 99 as two digits.
show02d :: Int -> SText
show02d n = SText (P.pack [dtc d1, dtc d0]) 2 where
    (d1, d0) = (n `mod` 100) `quotRem` 10
    dtc = toEnum . (48 +)

replacementChar :: Char
replacementChar =
    if charWidth UTF8.replacement_char == 1 then UTF8.replacement_char else '='

-- | If seeming ISO-8859-1, convert to UTF-8.
guessEncoding :: ByteString -> SText
guessEncoding bs =
    if UTF8.replacement_char `elem` UTF8.toString bs
        then fromString $ P.unpack bs else fromBS bs

-- | Test if printable according to wcwidth.
isPrintable :: Char -> Bool
isPrintable c = c /= '\0' && charWidth c >= 0

-- | Blot out control and other unprintable characters.
toPrintable :: String -> String
toPrintable = map \c -> if isPrintable c then c else replacementChar

-- | ByteString to displayable text.
-- Pre-checks for common case of already printable.
fromBS :: ByteString -> SText
fromBS bs = SText s (stringWidth s) where
    (_, bad) = UTF8.span (\c -> c /= UTF8.replacement_char && isPrintable c) bs
    s = if P.null bad then bs else UTF8.fromString $ toPrintable $ UTF8.toString bs

unsafeFromAsciiBS :: ByteString -> SText
unsafeFromAsciiBS s = SText s (P.length s)

fromChar :: Char -> SText
fromChar c = SText (UTF8.fromChar c') (charWidth c')
    where c' = if isPrintable c then c else replacementChar


-- ByteString utilities.

-- | Strip leading and trailing whitespace.
trim :: ByteString -> ByteString
trim = P.dropWhileEnd isSpace . P.dropSpace

-- | Drop last UTF-8 codepoint.
dropLastUTF8 :: ByteString -> ByteString
dropLastUTF8 = P.dropEnd 1 . P.dropWhileEnd isCB
    where isCB b = b >= '\128' && b < '\192'

-- | Can file be sent to decoder?
isLineSafe :: ByteString -> Bool
isLineSafe = P.all (`notElem` ['\0', '\r', '\n'])

-- XXX when we drop GHC 9.4 we can use its filepath's function
-- | Filesystem encoding for CLI (PEP 383).
encodeFS :: String -> IO ByteString
encodeFS str = do
    enc <- getFileSystemEncoding
    GHC.withCStringLen enc str P.packCStringLen


-- Width operations on 'SText', using libc 'wcwidth'.
-- A UTF-8 runtime locale is presumed; counts may differ otherwise.

-- | These functions truncate with ellipses if needed to get width ≤'w'.
-- 'toWidth' adds padding as needed so the width is exactly 'w'.
toMaxWidth, toWidth :: Int -> SText -> SText
toMaxWidth = sizer False
toWidth = sizer True

ellipsis :: ByteString
ellipsis = if charWidth '…' == 1 then UTF8.fromChar '…' else "-"

sizer :: Bool -> Int -> SText -> SText
sizer pad w s@(SText bs dw)
    | dw <= w = if pad then s <> spaces (w-dw) else s
    | True    = SText (walk 0 bs) w
  where
    walk !l rest
        | l' >= w = P.take (P.length bs - P.length rest) bs
                        <> mconcat (replicate (w-l) ellipsis)
        | True    = walk l' rest'
      where
        (c, rest') = fromJust $ UTF8.uncons rest -- can't be at end since dw>w
        l'         = l + charWidth c

stringWidth :: ByteString -> Int
stringWidth = UTF8.foldl (\acc c -> acc + charWidth c) 0

charWidth :: Char -> Int
charWidth = fromIntegral . wcwidth . toEnum . fromEnum

foreign import ccall unsafe
    wcwidth :: CWchar -> CInt