packages feed

safe-coloured-text-parsing (empty) → 0.1.0.0

raw patch · 4 files changed

+414/−0 lines, 4 filesdep +attoparsecdep +basedep +safe-coloured-text

Dependencies added: attoparsec, base, safe-coloured-text, text, validity

Files

+ CHANGELOG.md view
@@ -0,0 +1,25 @@+# Changelog for safe-coloured-text-parsing++## [0.1.0.0] - 2026-05-13++### Added++* `OscCommand` type representing the payload of an OSC escape sequence,+  with `OscHyperlink` for OSC 8 hyperlinks and `OscOther` for all other+  OSC commands+* `OscSequence` token now carries an `OscCommand` value instead of being+  a unit constructor+* OSC sequences are now parsed and tokenized rather than leaking as plain+  text (fixes rendering of GHC warning messages that include OSC 8+  hyperlinks to error documentation)+* `tokensToChunks` now threads OSC 8 hyperlink URLs into `chunkStyleHyperlink`++## [0.0.0.0] - 2026-04-12++### Added++* Initial release+* Two-layer ANSI escape code parsing: tokenization and interpretation+* Strict and lazy Text support+* SGR parameter interpretation for all standard attributes+* Parser threads `ChunkStyle` for SGR state
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2021 Tom Sydney Kerckhove++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ safe-coloured-text-parsing.cabal view
@@ -0,0 +1,40 @@+cabal-version: 1.12++-- This file has been generated from package.yaml by hpack version 0.38.3.+--+-- see: https://github.com/sol/hpack++name:           safe-coloured-text-parsing+version:        0.1.0.0+synopsis:       Parse coloured text+category:       User Interfaces+homepage:       https://github.com/NorfairKing/safe-coloured-text#readme+bug-reports:    https://github.com/NorfairKing/safe-coloured-text/issues+author:         Tom Sydney Kerckhove+maintainer:     syd@cs-syd.eu+copyright:      Copyright (c) 2026 Tom Sydney Kerckhove+license:        MIT+license-file:   LICENSE+build-type:     Simple+extra-source-files:+    LICENSE+    CHANGELOG.md++source-repository head+  type: git+  location: https://github.com/NorfairKing/safe-coloured-text++library+  exposed-modules:+      Text.Colour.Chunk.Parsing+  other-modules:+      Paths_safe_coloured_text_parsing+  hs-source-dirs:+      src+  build-depends:+      attoparsec+    , base >=4.7 && <5+    , safe-coloured-text >=0.6+    , text+    , validity+  default-language: Haskell2010
+ src/Text/Colour/Chunk/Parsing.hs view
@@ -0,0 +1,328 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedStrings #-}++-- | Parsing ANSI escape codes from text into styled 'Chunk's.+--+-- This module is the inverse of 'Text.Colour.Chunk': instead of rendering+-- 'Chunk's to ANSI-escaped 'Text', it parses ANSI-escaped 'Text' back into+-- 'Chunk's.+--+-- The parsing happens in two layers:+--+-- 1. __Tokenization__: 'Text' is split into 'AnsiToken's — plain text+--    segments and ANSI escape sequences.+-- 2. __Interpretation__: 'AnsiToken's are folded into styled 'Chunk's by+--    tracking the current SGR state.+--+-- Both layers support lazy 'Data.Text.Lazy.Text' for streaming.+module Text.Colour.Chunk.Parsing+  ( -- * High-level API+    parseAnsiChunks,+    parseAnsiChunksLazy,++    -- * Two-layer parsing++    -- ** Layer 1: Tokenization+    AnsiToken (..),+    OscCommand (..),+    ansiTokenP,+    parseAnsiTokens,+    parseAnsiTokensLazy,++    -- ** Layer 2: Token interpretation+    tokensToChunks,+  )+where++import Control.Applicative ((<|>))+import Control.Monad (void)+import Data.Attoparsec.Text+  ( Parser,+    anyChar,+    char,+    endOfInput,+    many',+    parseOnly,+    peekChar',+    satisfy,+    takeWhile1,+  )+import qualified Data.Attoparsec.Text.Lazy as AL+import Data.Char (isDigit)+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.Lazy as Lazy+import Data.Validity+import Data.Word+import GHC.Generics (Generic)+import Text.Colour.Chunk+import Text.Colour.Code+import Text.Read (readMaybe)++-- | The payload of an OSC (Operating System Command) escape sequence.+data OscCommand+  = -- | OSC 8 hyperlink.+    -- @'OscHyperlink' params uri@: @params@ are optional link attributes+    -- (commonly empty); @uri@ is the target URL, or empty to close the link.+    OscHyperlink !Text !Text+  | -- | Any other OSC sequence, identified by its command number and+    -- the raw parameter string that followed it.+    OscOther !Word !Text+  deriving (Show, Eq, Generic)++instance Validity OscCommand++-- | A token in a stream of text with ANSI escape codes.+data AnsiToken+  = -- | Plain text (no escape codes)+    PlainText !Text+  | -- | An SGR (Select Graphic Rendition) sequence with its parameter bytes+    SgrSequence ![Word8]+  | -- | A non-SGR CSI sequence (to be discarded)+    OtherCsiSequence+  | -- | An OSC (Operating System Command) sequence.+    -- Begins with @ESC ]@ and ends with the String Terminator @ESC \@ or @BEL@.+    OscSequence !OscCommand+  | -- | A carriage return (@\r@)+    CarriageReturn+  deriving (Show, Eq, Generic)++instance Validity AnsiToken++-- | Parse strict 'Text' into a list of 'AnsiToken's.+-- Any incomplete escape sequence at the end of input is emitted as 'PlainText'.+parseAnsiTokens :: Text -> [AnsiToken]+parseAnsiTokens t =+  case parseOnly (many' ansiTokenP <* endOfInput) t of+    Left _ -> [PlainText t]+    Right tokens -> tokens++-- | Parse lazy 'Lazy.Text' into a list of 'AnsiToken's, produced lazily.+-- Tokens are yielded as soon as enough input has been consumed to complete them.+parseAnsiTokensLazy :: Lazy.Text -> [AnsiToken]+parseAnsiTokensLazy = go+  where+    go remaining+      | Lazy.null remaining = []+      | otherwise =+          case AL.parse ansiTokenP remaining of+            AL.Done rest token -> token : go rest+            AL.Fail _ _ _ ->+              -- This shouldn't happen since ansiTokenP always succeeds on+              -- non-empty input (incompleteEscapeP and plainTextP are catch-alls).+              [PlainText (Lazy.toStrict remaining)]++-- | Parse a single ANSI token: a CSI sequence, an OSC sequence, a bare ESC,+-- a carriage return, or plain text.+ansiTokenP :: Parser AnsiToken+ansiTokenP = csiSequenceP <|> oscSequenceP <|> incompleteEscapeP <|> carriageReturnP <|> plainTextP++plainTextP :: Parser AnsiToken+plainTextP = PlainText <$> takeWhile1 (\c -> c /= '\ESC' && c /= '\r')++carriageReturnP :: Parser AnsiToken+carriageReturnP = do+  _ <- char '\r'+  pure CarriageReturn++-- | Parse a lone ESC (possibly followed by '[') that doesn't start a valid CSI sequence.+incompleteEscapeP :: Parser AnsiToken+incompleteEscapeP = do+  _ <- char '\ESC'+  mBracket <- (char '[' >> pure True) <|> pure False+  pure $ PlainText $ if mBracket then "\ESC[" else "\ESC"++csiSequenceP :: Parser AnsiToken+csiSequenceP = do+  _ <- char '\ESC'+  _ <- char '['+  (params, hasIntermediateBytes) <- csiParamsP+  finalByte <- satisfy (\c -> c >= '\x40' && c <= '\x7E')+  pure $+    if finalByte == 'm' && not hasIntermediateBytes+      then SgrSequence params+      else OtherCsiSequence++-- | Parse an OSC (Operating System Command) sequence.+-- An OSC sequence begins with @ESC ]@, followed by a numeric command,+-- a @;@, a parameter string, and ends with the String Terminator @ESC \@+-- or @BEL@ (@\a@).+-- If no terminator is found before end of input the whole remainder+-- is consumed and emitted as 'OscSequence'.+oscSequenceP :: Parser AnsiToken+oscSequenceP = do+  _ <- char '\ESC'+  _ <- char ']'+  cmd <- oscCommandNumberP+  _ <- void (char ';') <|> pure ()+  params <- oscParamsP+  oscStringTerminatorP+  pure $ OscSequence $ case cmd of+    8 -> case Text.breakOn ";" params of+      (linkParams, rest)+        | not (Text.null rest) -> OscHyperlink linkParams (Text.drop 1 rest)+      _ -> OscHyperlink "" params+    _ -> OscOther cmd params++-- | Parse an OSC command number (a decimal integer).+-- Returns 0 if no digits are present (bare @ESC]@).+oscCommandNumberP :: Parser Word+oscCommandNumberP =+  ( do+      digits <- takeWhile1 isDigit+      case readMaybe (Text.unpack digits) of+        Just n -> pure n+        Nothing -> pure 0+  )+    <|> pure 0++-- | Parse the OSC parameter string: everything up to the string terminator.+oscParamsP :: Parser Text+oscParamsP = do+  chunks <- many' (satisfy (\c -> c /= '\ESC' && c /= '\BEL'))+  pure $ Text.pack chunks++-- | Consume the OSC string terminator: @ESC \@ or @BEL@.+-- If neither is present (end of input), succeeds consuming nothing.+oscStringTerminatorP :: Parser ()+oscStringTerminatorP =+  (char '\ESC' >> void (char '\\'))+    <|> void (char '\BEL')+    <|> pure ()++csiParamsP :: Parser ([Word8], Bool)+csiParamsP = do+  (revAcc, hasIntermediate) <- go [] False+  pure (reverse revAcc, hasIntermediate)+  where+    go :: [Word8] -> Bool -> Parser ([Word8], Bool)+    go acc hasIntermediate = do+      c <- peekChar'+      if isDigit c+        then do+          digits <- takeWhile1 isDigit+          case readMaybe (Text.unpack digits) of+            Just n+              | n <= (255 :: Word) -> go (fromIntegral n : acc) hasIntermediate+            _ -> go acc hasIntermediate+        else+          if c == ';'+            then anyChar >> go acc hasIntermediate+            else+              if c >= '\x20' && c <= '\x2F'+                then anyChar >> go acc True+                else pure (acc, hasIntermediate)++-- | Convert a stream of 'AnsiToken's into styled 'Chunk's.+--+-- The first argument is the initial style state.+-- Returns the final style state and the list of chunks.+--+-- The output list is produced lazily: each 'Chunk' is yielded as soon as+-- the corresponding 'PlainText' token is encountered.  The final style+-- state is only available after the entire input has been consumed.+tokensToChunks :: ChunkStyle -> [AnsiToken] -> (ChunkStyle, [Chunk])+tokensToChunks style tokens =+  let (finalStyle, chunks) = go style tokens+   in (finalStyle, chunks)+  where+    go :: ChunkStyle -> [AnsiToken] -> (ChunkStyle, [Chunk])+    go s [] = (s, [])+    go s (token : rest) = case token of+      PlainText t+        | Text.null t -> go s rest+        | otherwise ->+            let (finalS, restChunks) = go s rest+             in (finalS, Chunk {chunkText = t, chunkStyle = s} : restChunks)+      SgrSequence params -> go (applySGRParams s params) rest+      OtherCsiSequence -> go s rest+      OscSequence (OscHyperlink _ url)+        | Text.null url -> go (s {chunkStyleHyperlink = Nothing}) rest+        | otherwise -> go (s {chunkStyleHyperlink = Just url}) rest+      OscSequence _ -> go s rest+      CarriageReturn ->+        case rest of+          (PlainText _ : _) ->+            let (finalS, restChunks) = go s rest+             in (finalS, Chunk {chunkText = "\n", chunkStyle = s} : restChunks)+          _ -> go s rest++-- | Parse strict 'Text' containing ANSI escape codes into styled 'Chunk's.+--+-- The first argument is the current style state. The returned 'ChunkStyle'+-- is the style state after processing all escape codes, suitable for+-- threading to the next line.+parseAnsiChunks :: ChunkStyle -> Text -> (ChunkStyle, [Chunk])+parseAnsiChunks style = tokensToChunks style . parseAnsiTokens++-- | Parse lazy 'Lazy.Text' containing ANSI escape codes into styled 'Chunk's.+--+-- Both the token stream and the chunk list are produced lazily.+parseAnsiChunksLazy :: ChunkStyle -> Lazy.Text -> (ChunkStyle, [Chunk])+parseAnsiChunksLazy style = tokensToChunks style . parseAnsiTokensLazy++-- | Apply a list of SGR parameter bytes to a 'ChunkStyle'.+-- An empty parameter list is equivalent to reset (SGR 0).+applySGRParams :: ChunkStyle -> [Word8] -> ChunkStyle+applySGRParams _ [] = noStyle+applySGRParams style params = goSGR style params++goSGR :: ChunkStyle -> [Word8] -> ChunkStyle+goSGR s [] = s+goSGR s (p : ps)+  | p == 0 = goSGR noStyle ps+  | p == 1 = goSGR (s {chunkStyleConsoleIntensity = Just BoldIntensity}) ps+  | p == 2 = goSGR (s {chunkStyleConsoleIntensity = Just FaintIntensity}) ps+  | p == 3 = goSGR (s {chunkStyleItalic = Just True}) ps+  | p == 4 = goSGR (s {chunkStyleUnderlining = Just SingleUnderline}) ps+  | p == 5 = goSGR (s {chunkStyleBlinking = Just SlowBlinking}) ps+  | p == 6 = goSGR (s {chunkStyleBlinking = Just RapidBlinking}) ps+  | p == 7 = goSGR (s {chunkStyleSwapForegroundBackground = Just True}) ps+  | p == 8 = goSGR (s {chunkStyleConcealed = Just True}) ps+  | p == 9 = goSGR (s {chunkStyleStrikethrough = Just True}) ps+  | p == 21 = goSGR (s {chunkStyleUnderlining = Just DoubleUnderline}) ps+  | p == 22 = goSGR (s {chunkStyleConsoleIntensity = Just NormalIntensity}) ps+  | p == 23 = goSGR (s {chunkStyleItalic = Just False}) ps+  | p == 24 = goSGR (s {chunkStyleUnderlining = Just NoUnderline}) ps+  | p == 25 = goSGR (s {chunkStyleBlinking = Just NoBlinking}) ps+  | p == 27 = goSGR (s {chunkStyleSwapForegroundBackground = Just False}) ps+  | p == 28 = goSGR (s {chunkStyleConcealed = Just False}) ps+  | p == 29 = goSGR (s {chunkStyleStrikethrough = Just False}) ps+  -- Standard foreground colours (30-37)+  | p >= 30 && p <= 37 =+      case terminalColourFromIndex (p - 30) of+        Just tc -> goSGR (s {chunkStyleForeground = Just (Colour8 Dull tc)}) ps+        Nothing -> goSGR s ps+  -- Extended foreground colour+  | p == 38 = case ps of+      5 : n : rest -> goSGR (s {chunkStyleForeground = Just (Colour8Bit n)}) rest+      2 : r : g : b : rest -> goSGR (s {chunkStyleForeground = Just (Colour24Bit r g b)}) rest+      _ -> goSGR s ps+  -- Default foreground+  | p == 39 = goSGR (s {chunkStyleForeground = Nothing}) ps+  -- Standard background colours (40-47)+  | p >= 40 && p <= 47 =+      case terminalColourFromIndex (p - 40) of+        Just tc -> goSGR (s {chunkStyleBackground = Just (Colour8 Dull tc)}) ps+        Nothing -> goSGR s ps+  -- Extended background colour+  | p == 48 = case ps of+      5 : n : rest -> goSGR (s {chunkStyleBackground = Just (Colour8Bit n)}) rest+      2 : r : g : b : rest -> goSGR (s {chunkStyleBackground = Just (Colour24Bit r g b)}) rest+      _ -> goSGR s ps+  -- Default background+  | p == 49 = goSGR (s {chunkStyleBackground = Nothing}) ps+  | p == 53 = goSGR (s {chunkStyleOverlined = Just True}) ps+  | p == 55 = goSGR (s {chunkStyleOverlined = Just False}) ps+  -- Bright foreground colours (90-97)+  | p >= 90 && p <= 97 =+      case terminalColourFromIndex (p - 90) of+        Just tc -> goSGR (s {chunkStyleForeground = Just (Colour8 Bright tc)}) ps+        Nothing -> goSGR s ps+  -- Bright background colours (100-107)+  | p >= 100 && p <= 107 =+      case terminalColourFromIndex (p - 100) of+        Just tc -> goSGR (s {chunkStyleBackground = Just (Colour8 Bright tc)}) ps+        Nothing -> goSGR s ps+  -- Unknown code, skip+  | otherwise = goSGR s ps