scrod-0.2026.2.21: source/library/Scrod/Unlit.hs
{-# LANGUAGE TemplateHaskellQuotes #-}
-- | Convert literate Haskell source to plain Haskell.
--
-- Supports both bird-track (@>@) and LaTeX (@\\begin{code}@ ... @\\end{code}@)
-- styles. Non-code lines are replaced with blank lines to preserve line
-- numbering in the output.
module Scrod.Unlit where
import qualified Control.Monad as Monad
import qualified Data.Char as Char
import qualified Scrod.Spec as Spec
-- | Strip literate markup from the input, returning plain Haskell source.
-- Returns 'Left' with an error message if the input is malformed (e.g.,
-- unterminated LaTeX blocks, bird tracks adjacent to comments, or no code
-- at all).
unlit :: String -> Either String String
unlit input =
let go lines_ prev inLatex hasCode acc = case lines_ of
[] ->
if inLatex
then Left "unterminated \\begin{code}"
else Right (hasCode, acc)
line : rest ->
let cls = classifyLine line
in case inLatex of
True -> case cls of
EndCode -> go rest EndCode False hasCode ("" : acc)
BeginCode -> Left "nested \\begin{code}"
_ -> go rest cls True True (line : acc)
False -> case cls of
Blank -> go rest Blank False hasCode ("" : acc)
Bird -> do
Monad.when (prev == Comment) $ Left "bird track adjacent to comment"
go rest Bird False True ((' ' : drop 1 line) : acc)
BeginCode -> go rest BeginCode True True ("" : acc)
EndCode -> Left "unexpected \\end{code}"
Comment -> do
Monad.when (prev == Bird) $ Left "bird track adjacent to comment"
go rest Comment False hasCode ("" : acc)
in do
let ls = lines input
(hasCode, outputLines) <- go ls Blank False False []
Monad.unless hasCode $ Left "no code in literate file"
Right $ unlines (reverse outputLines)
data LineClass
= Blank
| Bird
| BeginCode
| EndCode
| Comment
deriving (Eq, Ord, Show)
classifyLine :: String -> LineClass
classifyLine line
| all Char.isSpace line = Blank
| '>' : _ <- line = Bird
| matchesDelimiter "\\begin{code}" line = BeginCode
| matchesDelimiter "\\end{code}" line = EndCode
| otherwise = Comment
-- | Check whether a line starts with the given LaTeX delimiter, ignoring
-- leading whitespace and case.
matchesDelimiter :: String -> String -> Bool
matchesDelimiter delim line =
let stripped = dropWhile Char.isSpace line
(prefix, _) = splitAt (length delim) stripped
in fmap Char.toLower prefix == delim
spec :: (Applicative m, Monad n) => Spec.Spec m n -> n ()
spec s = do
Spec.named s 'unlit $ do
Spec.it s "fails with no code" $ do
Spec.assertEq s (unlit "") $ Left "no code in literate file"
Spec.it s "fails with only comments" $ do
Spec.assertEq s (unlit "hello") $ Left "no code in literate file"
Spec.it s "works with empty latex block" $ do
Spec.assertEq s (unlit "\\begin{code}\n\\end{code}") $ Right "\n\n"
Spec.it s "works with bird style" $ do
Spec.assertEq s (unlit "> x = 0") $ Right " x = 0\n"
Spec.it s "works with bird style without space" $ do
Spec.assertEq s (unlit ">x = 0") $ Right " x = 0\n"
Spec.it s "works with latex style" $ do
Spec.assertEq s (unlit "\\begin{code}\nx = 0\n\\end{code}") $ Right "\nx = 0\n\n"
Spec.describe s "bird adjacency" $ do
Spec.it s "fails with comment before bird" $ do
Spec.assertEq s (unlit "before\n> x = 0") $ Left "bird track adjacent to comment"
Spec.it s "works with blank before bird" $ do
Spec.assertEq s (unlit "before\n\n> x = 0") $ Right "\n\n x = 0\n"
Spec.it s "fails with comment after bird" $ do
Spec.assertEq s (unlit "> x = 0\nafter") $ Left "bird track adjacent to comment"
Spec.it s "works with blank after bird" $ do
Spec.assertEq s (unlit "> x = 0\n\nafter") $ Right " x = 0\n\n\n"
Spec.describe s "latex blocks" $ do
Spec.it s "fails with unclosed begin" $ do
Spec.assertEq s (unlit "\\begin{code}") $ Left "unterminated \\begin{code}"
Spec.it s "fails with extra end" $ do
Spec.assertEq s (unlit "\\begin{code}\n\\end{code}\n\\end{code}") $ Left "unexpected \\end{code}"
Spec.it s "fails with end without begin" $ do
Spec.assertEq s (unlit "\\end{code}") $ Left "unexpected \\end{code}"
Spec.it s "fails with nested begin" $ do
Spec.assertEq s (unlit "\\begin{code}\n\\begin{code}") $ Left "nested \\begin{code}"
Spec.describe s "latex indentation" $ do
Spec.it s "works with indented delimiters" $ do
Spec.assertEq s (unlit " \\begin{code}\nx = 0\n \\end{code}") $ Right "\nx = 0\n\n"
Spec.it s "fails with spaces in delimiter" $ do
Spec.assertEq s (unlit " \\ begin { code }\nx = 0\n \\end{code}") $ Left "unexpected \\end{code}"
Spec.describe s "latex trailing text" $ do
Spec.it s "works with text after delimiters" $ do
Spec.assertEq s (unlit "\\begin{code} foo\nx = 0\n\\end{code} bar") $ Right "\nx = 0\n\n"
Spec.it s "fails with text before delimiter" $ do
Spec.assertEq s (unlit "foo \\begin{code}\nx = 0\nbar \\end{code}") $ Left "no code in literate file"
Spec.it s "is case insensitive for latex" $ do
Spec.assertEq s (unlit "\\BEGIN{CODE}\nx = 0\n\\END{CODE}") $ Right "\nx = 0\n\n"
Spec.it s "converts comment lines to blank lines" $ do
Spec.assertEq s (unlit "comment\n\n> x = 0\n\ncomment") $ Right "\n\n x = 0\n\n\n"