packages feed

mcm-0.6.8.1: Parser.hs

-- MCM - Machine Configuration Manager; manages the contents of files and directories
-- Copyright (c) 2013-2018 Anthony Doggett <mcm@interfaces.org.uk>
--
-- Licence:
--     This program is free software: you can redistribute it and/or modify
--     it under the terms of the GNU General Public License as published by
--     the Free Software Foundation, either version 3 of the License, or
--     (at your option) any later version.
--
--     This program is distributed in the hope that it will be useful,
--     but WITHOUT ANY WARRANTY; without even the implied warranty of
--     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
--     GNU General Public License for more details.
--
--     You should have received a copy of the GNU General Public License
--     along with this program.  If not, see <http://www.gnu.org/licenses/>.

module Parser (mcmParse, mcmLoadAndParse, mcmParsePackagePath)
where

import Control.Monad(unless, when)
import Data.Char (isLower, isUpper, isAlpha, isAlphaNum, isSpace, generalCategory, GeneralCategory(..))
import Data.Int (Int64)
import Data.Foldable (foldlM)
import qualified Data.Map as Map
import qualified Data.Text.Lazy as T
import qualified Data.Text.Lazy.IO as TextIO
import Text.ParserCombinators.Poly.StateText

import ParserTypes

-- State is (LineNumber, [ParseErrors])
newtype State = State (Int, [String])
    deriving Show

type P = Parser State

initState :: State
initState = State (1, [])

incNewline :: P ()
incNewline = stUpdate (\(State (n, es)) -> State(n+1, es))

lineNo :: P Int
lineNo = stQuery (\(State (n, _)) -> n)

addError :: String -> P ()
addError e = stUpdate (\(State (n, es)) -> State(n, prep n:es))
    where
        prep n = "line " ++ show n ++ ": " ++ e

adjustError :: P a -> String -> P a
adjustError pa e = do
    n <- stQuery (\(State (n, _)) -> n)
    pa `adjustErr` (("line " ++ show n ++ ": " ++ e ++ "\n")++)

prettyFail :: String -> P ()
prettyFail e = do
    n <- stQuery (\(State (n, _)) -> n)
    fail $ "line " ++ show n ++ ": " ++ e

prettyFailBad :: String -> P ()
prettyFailBad = commit . prettyFail

ensureEof :: P ()
ensureEof =
    oneOf
        [eof
        ,do
            n <- stQuery (\(State (n, _)) -> n)
            r <- manySatisfy(/= '\n')
            failBad $ "Parsing unexpectedly ended at line " ++ show n ++
                " with the rest of the line being " ++ show r
        ]

toContent' :: Int -> ContentType -> ContentLine -> P [Content]
toContent' lineno ct cl = case toContent lineno ct cl of
    Left e -> addError e >> return []
    Right cs -> return cs

checkUniqueAndInsert :: (Show t, Ord t) => String -> Map.Map t a -> (t, a) -> P (Map.Map t a)
checkUniqueAndInsert s m (k,v) = do
    when (k `Map.member` m) $ addError $ s ++ " \"" ++ show k ++ "\" is defined multiple times"
    return $ Map.insert k v m

checkUniqueAndUnion :: (Show t, Ord t) => String -> Map.Map t a -> Map.Map t a -> P (Map.Map t a)
checkUniqueAndUnion s m1 m2 = do
    let i = m1 `Map.intersection` m2
    unless (Map.null i) $ mapM_ ((\k -> addError $ s ++ "\" \"" ++ k ++ "\" is defined multiple times") . show) $ Map.keys i
    return $ Map.union m1 m2

char :: Char -> P ()
char c = do
    n <- next
    unless (c == n) $ prettyFail $ "expected " ++ show c ++ " but got " ++ show n

isSpaceOrTab :: Char -> Bool
isSpaceOrTab '\t' = True
isSpaceOrTab ' ' = True
isSpaceOrTab _ = False

pSpaceButCheckOthers :: P ()
pSpaceButCheckOthers = do
    w <- manySatisfy isSpaceOrTab
    unless (T.length w == 1 && T.unpack w == " ") $
        addError $ "was expecting a single space character but got " ++ show w

pSpaceButCheckSingleTab :: P ()
pSpaceButCheckSingleTab = do
    c <- next
    when (c == '\t') $
        addError $ "was expecting a single space character but got " ++ show c
    unless (isSpaceOrTab c) $
        prettyFail $ "was expecting a single space character but got " ++ show c

nToWord :: Int64 -> String -> String -> String
nToWord 0 _ plural = "no " ++ plural
nToWord 1 single _ = "one " ++ single
nToWord 2 _ plural = "two " ++ plural
nToWord 3 _ plural = "three " ++ plural
nToWord 4 _ plural = "four " ++ plural
nToWord 5 _ plural = "five " ++ plural
nToWord 6 _ plural = "six " ++ plural
nToWord 7 _ plural = "seven " ++ plural
nToWord 8 _ plural = "eight " ++ plural
nToWord 9 _ plural = "nine " ++ plural
nToWord 10 _ plural = "ten " ++ plural
nToWord n _ plural = show n ++ " " ++ plural

pIndentN :: Int64 -> P ()
pIndentN n = do
    w <- many1Satisfy isSpaceOrTab `adjustError`
        "was expecting at least one tab"
    let tabs = nToWord n "tab character" "tab characters"
        nspaces = T.count (T.pack " ") w
        spaces = nToWord nspaces "space" "spaces"
    when (nspaces /= 0) $
        prettyFailBad $ "was expecting an indent using just tabs but found " ++ spaces ++ ": " ++ show w
    unless (T.length w == n && T.count (T.pack "\t") w == n) $
        prettyFail $ "was expecting an indent with " ++ tabs ++ " but got " ++ show w

pIndent :: P ()
pIndent = pIndentN 1

pIndent2 :: P ()
pIndent2 = pIndentN 2

pIndent3 :: P ()
pIndent3 = pIndentN 3

keyword :: T.Text -> P ()
keyword s = do
    let fmsg = "was expecting the keyword '" ++ T.unpack s ++ "'"
    found <- many1Satisfy isAlpha `adjustError` fmsg
    when (s /= found) $ prettyFail $ fmsg ++ " but found '" ++ T.unpack found ++ "'"
    return ()

isOtherLetter :: Char -> Bool
isOtherLetter c = generalCategory c == OtherLetter

isUnderscore :: Char -> Bool
isUnderscore c = c == '_'

isAt :: Char -> Bool
isAt c = c == '@'

pOUword_ :: P T.Text
pOUword_ = do
    let fmsg = "expected a word_ starting with an uppercase letter"
    as <- many1Satisfy (\c -> isAlphaNum c || isUnderscore c) `adjustError` fmsg
    let f c = isUpper c || isOtherLetter c
    unless (f $ T.head as) $ prettyFail fmsg
    return as

pOLwordMaker :: (Char -> Bool) -> P T.Text
pOLwordMaker validChar = do
    let fmsg = "expected a word starting with a lowercase letter"
    as <- many1Satisfy validChar `adjustError` fmsg
    let f c = isLower c || isOtherLetter c
    unless (f $ T.head as) $ prettyFail fmsg
    return as

pOLword_ :: P T.Text
pOLword_ = pOLwordMaker (\c -> isAlphaNum c || isUnderscore c)

pOLwordAt_ :: P T.Text
pOLwordAt_ = do
    let validChar c = isAlphaNum c || isUnderscore c || isAt c
    let fmsg = "expected a word starting with a lowercase letter or '@'"
    as <- many1Satisfy validChar `adjustError` fmsg
    let f c = isLower c || isOtherLetter c || isAt c
    unless (f $ T.head as) $ prettyFail fmsg
    return as

mcmParsePackagePath :: T.Text -> Either [String] PackagePath
mcmParsePackagePath t = case runParser pPackagePath' initState t of
    (Left s, State(_, es), _) -> Left (s:reverse es)
    (Right pp, State(_, []), v) | T.null v -> Right pp
    (Right _, State(_, es), v) | T.null v -> Left $ reverse es
    (Right _, State(_, es), v) -> Left (("Failed to parse end: " ++ T.unpack v):reverse es)
    where
        pPackagePath' = do {r <- pPackagePath; eof; return r}

mcmParse :: T.Text -> Either [String] MCMFile
mcmParse t = case runParser pFile initState t of
    (Left s, State(_, es), _) -> Left (s:reverse es)
    (Right pp, State(_, []), v) | T.null v -> Right pp
    (Right _, State(_, es), v) | T.null v -> Left $ reverse es
    (Right _, State(_, es), v) -> Left (("Failed to parse end: " ++ T.unpack v):reverse es)

mcmLoadAndParse :: FilePath -> IO (Either [String] MCMFile)
mcmLoadAndParse f = do
    t <- TextIO.readFile f
    return $ mcmParse t

pFile :: P MCMFile
pFile = do
    pp <- pHeader
    pMaybeEmptyLinesAndComments
    is <- many pImport
    when (is /= []) pMaybeEmptyLinesAndComments
    packagelocals <- many pPackageLocals
    packagelocals' <- foldlM (checkUniqueAndUnion "packagelocal") Map.empty packagelocals
    unless (Map.null packagelocals') pMaybeEmptyLinesAndComments
    defines <- many (do {d <- pDefine; pMaybeEmptyLinesAndComments; return d})
    defines' <- foldlM (checkUniqueAndInsert "define") Map.empty defines
    ensureEof
    return $ MCMFile pp (Section is packagelocals' defines')

pHeader :: P PackagePath
pHeader = do
    keyword (T.pack "MCM")
    pSpaceButCheckOthers
    pp <- pPackagePath
    pJustNewline
    return pp

pMaybeEmptyLinesAndComments :: P ()
pMaybeEmptyLinesAndComments = do
    _ <- many $ oneOf [pJustNewline, pComment]
    return ()

pIndentAndMaybeComments :: P ()
pIndentAndMaybeComments = do
    pIndent
    _ <- many (do {pComment; pIndent `adjustError`
        "expected a tab to indent the line following the comment"})
    return ()

pIndent3AndMaybeComments :: P ()
pIndent3AndMaybeComments = do
    pIndent3
    _ <- many (do {pComment; pIndent3 `adjustError`
        "expected three tabs to indent the line following the comment"})
    return ()

pJustNewline :: P ()
pJustNewline = do
    c <- next
    unless (c == '\n') $ prettyFail "failed to match end of line"
    incNewline

pManyUntilNewline :: P T.Text
pManyUntilNewline = manySatisfy (/= '\n')

pComment :: P ()
pComment = do
    char '#'
    commit nop
    _ <- pManyUntilNewline
    pJustNewline

nop :: P ()
nop = return ()

pPackagePath :: P PackagePath
pPackagePath = do
    ps <- sepBy1 pOUword_ (char '.' >> commit nop) `adjustError`
        "failed to parse package path"
    return $ PackagePath ps

pImport :: P Import
pImport = do
    keyword (T.pack "import")
    pSpaceButCheckOthers
    pp <- pPackagePath
    pSpaceButCheckOthers
    keyword (T.pack "as")
    pSpaceButCheckOthers
    label <- pOUword_
    pJustNewline
    return $ Import pp label

pPackageLocals :: P (Map.Map Ident [Content])
pPackageLocals = pLocals

pLocals :: P (Map.Map Ident [Content])
pLocals = do
    keyword (T.pack "let")
    ls <- oneOf
        [do pSpaceButCheckSingleTab
            l <- pImmediateLocal
            ls <- many pIndentedLocal
            return (l:ls)
        ,pJustNewline >> many pIndentedLocal
        ] `adjustError` "Invalid syntax for 'let'"
    foldlM (checkUniqueAndInsert "local") Map.empty ls

pImmediateLocal :: P (Ident, [Content])
pImmediateLocal = do
    i <- fmap Ident pOLword_
    pSpaceButCheckOthers
    char '='
    c <- oneOf
        [pContent
        ,return []
        ]
    cs <- pArgMore
    pJustNewline
    return (i, c ++ cs)

pIndentedLocal :: P (Ident, [Content])
pIndentedLocal = do
    pIndent2
    pImmediateLocal

pContent :: P [Content]
pContent = do
    lineno <- lineNo
    ct <- pContentType pSpaceButCheckSingleTab
    c <- pManyUntilNewline
    toContent' lineno ct (Plain c)

pDefine :: P (DefName, Define)
pDefine = do
    keyword (T.pack "define")
    pSpaceButCheckOthers
    name <- fmap DefName pOLword_
    char '('
    args <- sepBy (fmap Ident pOLword_) (char ' ') `adjustError`
        "failed to parse define arguments"
    optargs <- oneOf
        [do {char ')'; return []}
        ,do {pJustNewline; r <- many pIndentedLocal; pIndent; char ')'; return r}
        ] `adjustError` "failed to parse arguments list"
    optargs' <- OptArgs <$> foldlM (checkUniqueAndInsert "optarg") Map.empty optargs
    pJustNewline
    (locals, condlocals, invokes) <- oneOf
        [do {pIndentAndMaybeComments; pDefine'}
        ,return (Locals Map.empty, [], [])
        ]
    return (name, Define name args optargs' locals condlocals invokes)

-- NB. Must parse something before the end of the define
--  (Comments don't count as "something")
pDefine' :: P (Locals, [CondLocal], [Invocation])
pDefine' = do
    locals <- sepBy pLocals pIndentAndMaybeComments `adjustError`
        "failed to parse define locals"
    (condlocals, invokes) <- if locals /= []
        then oneOf
            [do {pIndentAndMaybeComments; pDefine''}
            ,return ([], [])
            ]
        else pDefine''
    locals' <- Locals <$> foldlM (checkUniqueAndUnion "local") Map.empty locals
    return (locals', condlocals, invokes)

pDefine'' :: P ([CondLocal], [Invocation])
pDefine'' = do
    condlocals <- sepBy pCondLocal pIndentAndMaybeComments
    invokes <- if condlocals /= []
        then oneOf
            [do {pIndentAndMaybeComments; sepBy pInvoke pIndentAndMaybeComments}
            ,return []
            ]
        else sepBy pInvoke pIndentAndMaybeComments
    return (condlocals, invokes)

pCondLocal :: P CondLocal
pCondLocal = do
    keyword (T.pack "case")
    pSpaceButCheckOthers
    cond <- fmap Ident pOLword_
    pJustNewline
    whens <- many pWhen
    whensMaps <- foldlM (checkUniqueAndInsert "when") Map.empty whens
    let whenKeys = map (Map.keys . fromLocals . snd) whens
    let allTheSame [] = True
        allTheSame (x:xs) = all (==x) xs
    unless (allTheSame whenKeys) $
        addError $ unwords
            ["conditional mismatch:"
            ,show whenKeys
            ]
    return $ CondLocal cond whensMaps

pWhen :: P (Value, Locals)
pWhen = do
    pIndent
    keyword (T.pack "when")
    pSpaceButCheckOthers
    v <- fmap Value pManyUntilNewline
    pJustNewline
    locals <- many pIndentedLocal
    locals' <- foldlM (checkUniqueAndInsert "when local") Map.empty locals
    return (v, Locals locals')

pInvoke :: P Invocation
pInvoke = do
    cmd <- pInvokationCmd
    args <- many pArg
    args' <- InvocationArgs <$> foldlM (checkUniqueAndInsert "arg") Map.empty args
    pJustNewline
    return $ Invocation cmd args'

pInvokationCmd :: P InvocationCmd
pInvokationCmd = oneOf
    [do {char '.'; (InvLocal . UnexpandedDefName) <$> pOLwordAt_}
    ,do k <- pOUword_
        case T.unpack k of
            "Absent"   -> return InvAbsent
            "Dir"      -> return InvDir
            "Symlink"  -> return InvSymlink
            "File"     -> return InvFile
            "Fragment" -> return InvFragment
            _ -> oneOf
                [do char '.'
                    d <- pOLwordAt_
                    return $ InvImport k (UnexpandedDefName d)
                ,do addError $ "Unexpected command " ++ show k
                    return $ InvImport k (UnexpandedDefName $ T.pack "unexpectedCommand")
                ]
    ]

pArg :: P (Ident, [Content])
pArg = do
    oneOf
        [do {pJustNewline; pIndent2}
        ,pSpaceButCheckOthers
        ]
    i <- fmap Ident pOLword_
    a <- pInitialArg
    as <- pArgMore
    return (i, a ++ as)

pContentType :: P () -> P ContentType
pContentType spaceparser = oneOf
    [char '$' >> return CTDollar
    ,pNothingIfAtEndOfLine >> return CTSpace
    ,spaceparser >> return CTSpace
    ]

pInitialArg :: P [Content]
pInitialArg = oneOf
    [do char ':'
        lineno <- lineNo
        ct <- pContentType pSpaceButCheckSingleTab
        l <- pManyUntilNewline
        toContent' lineno ct (Plain l)
    ,do char '>'
        lineno <- lineNo
        ct <- pContentType pSpaceButCheckOthers
        l <- many1Satisfy (not . isSpace)
        toContent' lineno ct (Plain l)
    ]

pArgMore :: P [Content]
pArgMore = concat <$> many pArgCont

pArgCont :: P [Content]
pArgCont = do
    pJustNewline
    pIndent3AndMaybeComments
    lineno <- lineNo
    t <- oneOf
        [do {char '+'; return PrependNewline}
        ,do {char '\\'; return Plain}
        ] `adjustError` "was expecting '+' or '\\'"
    ct <- pContentType pSpaceButCheckSingleTab
    l <- pManyUntilNewline
    toContent' lineno ct (t l)

-- Parse nothing if at the end of the line already
pNothingIfAtEndOfLine :: P T.Text
pNothingIfAtEndOfLine = do
    cs <- pManyUntilNewline
    unless (T.null cs) $ prettyFail "expected end of line"
    return T.empty