phino-0.0.114: test/XMIRSpec.hs
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-}
-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com
-- SPDX-License-Identifier: MIT
module XMIRSpec where
import AST (Attribute (AtLabel, AtRho), Binding (BiMeta, BiTau, BiVoid), Expression (ExFormation))
import Control.Exception (SomeException, displayException, try)
import Control.Monad (forM_, unless, void)
import Data.Aeson
import Data.Char (isDigit)
import Data.List (intercalate)
import Data.Map qualified as M
import Data.Text qualified as T
import Data.Yaml qualified as Yaml
import Files (allPathsIn)
import GHC.Generics (Generic)
import Parser (parseExpressionThrows)
import System.FilePath (makeRelative)
import Test.Hspec (Spec, anyException, describe, expectationFailure, it, runIO, shouldBe, shouldContain, shouldThrow)
import Text.XML (Document (..), Element (..), Node (NodeElement), Prologue (..))
import Text.XML.Cursor qualified as C
import XMIR (XmirContext (XmirContext), defaultXmirContext, escapeXML, expressionToXMIR, parseXMIRThrows, printXMIR, toName, xmirToPhi)
data ParsePack = ParsePack
{ failure :: Maybe Bool
, xmir :: String
, phi :: String
}
deriving (Generic, Show, FromJSON)
data PrintPack = PrintPack
{ phi :: String
, xpaths :: [String]
}
deriving (Generic, Show, FromJSON)
parsePack :: FilePath -> IO ParsePack
parsePack = Yaml.decodeFileThrow
printPack :: FilePath -> IO PrintPack
printPack = Yaml.decodeFileThrow
-- | An XPath predicate that filters cursors.
data Predicate
= AttrEquals String String
| ChildText String String
| ChildExists String [Predicate]
| PositionIs Int
| AndPred Predicate Predicate
deriving (Show)
-- | An XPath step with element name and predicates.
data Step = Step String [Predicate]
deriving (Show)
{- | Parse a simple XPath expression into steps.
Supports: /element/element[@attr="val" and child="val" and child[N][@attr="val"]]
-}
xpath :: String -> [Step]
xpath ('/' : rest) = steps rest
xpath _ = []
steps :: String -> [Step]
steps "" = []
steps str =
let (step, rest) = span (\c -> c /= '/' && c /= '[') str
(preds, remaining) = parsePredicate rest
in Step step preds : steps (dropWhile (== '/') remaining)
parsePredicate :: String -> ([Predicate], String)
parsePredicate ('[' : rest) =
let (inner, after) = splitBracket rest
pred' = parsePredicateInner inner
(more, final) = parsePredicate after
in (pred' : more, final)
parsePredicate str = ([], str)
splitBracket :: String -> (String, String)
splitBracket = go (0 :: Int) ""
where
go :: Int -> String -> String -> (String, String)
go _ acc "" = (reverse acc, "")
go 0 acc (']' : rest) = (reverse acc, rest)
go n acc ('[' : rest) = go (n + 1) ('[' : acc) rest
go n acc (']' : rest) = go (n - 1) (']' : acc) rest
go n acc (c : rest) = go n (c : acc) rest
parsePredicateInner :: String -> Predicate
parsePredicateInner str
| " and " `isInfixOf'` str =
let parts = splitAnd str
in foldr1 AndPred (map parsePredicateInner parts)
| all isDigit str = PositionIs (read str)
| '@' : rest <- str = parseAttrPred rest
| otherwise = parseChildPred str
where
isInfixOf' needle haystack = needle `elem` tails haystack
tails [] = [[]]
tails s@(_ : xs) = s : tails xs
splitAnd :: String -> [String]
splitAnd = go (0 :: Int) ""
where
go :: Int -> String -> String -> [String]
go _ acc "" = [reverse acc | not (null acc)]
go n acc ('[' : rest) = go (n + 1) ('[' : acc) rest
go n acc (']' : rest) = go (n - 1) (']' : acc) rest
go 0 acc (' ' : 'a' : 'n' : 'd' : ' ' : rest) = reverse acc : go 0 "" rest
go n acc (c : rest) = go n (c : acc) rest
parseAttrPred :: String -> Predicate
parseAttrPred str =
let (name, rest) = break (== '=') str
val = extractQuoted (drop 1 rest)
in AttrEquals name val
parseChildPred :: String -> Predicate
parseChildPred str
| '[' `elem` str =
let (name, rest) = break (== '[') str
(preds, _) = parsePredicate rest
in ChildExists name preds
| '=' `elem` str =
let (name, rest) = break (== '=') str
val = extractQuoted (drop 1 rest)
in ChildText name val
| otherwise = ChildExists str []
extractQuoted :: String -> String
extractQuoted (q : rest)
| q == '"' || q == '\'' = takeWhile (/= q) rest
extractQuoted s = s
{- | Evaluate an XPath expression on a document, returning matched cursors.
Note: fromDocument returns cursor at root element, so first step must match root.
-}
evaluate :: Document -> [Step] -> [C.Cursor]
evaluate doc [] = [C.fromDocument doc]
evaluate doc (Step name preds : rest) =
let root = C.fromDocument doc
rootName = elementName (documentRoot doc)
in if rootName == toName name
then foldl applyStep (applyPredicates [root] preds) rest
else []
applyStep :: [C.Cursor] -> Step -> [C.Cursor]
applyStep curs (Step name preds) = do
cur <- curs
child <- cur C.$/ C.element (toName name)
applyPredicates [child] preds
applyPredicates :: [C.Cursor] -> [Predicate] -> [C.Cursor]
applyPredicates = foldl applyPredicate
applyPredicate :: [C.Cursor] -> Predicate -> [C.Cursor]
applyPredicate curs pred' = case pred' of
AttrEquals name val -> filter (hasAttrValue name val) curs
ChildText name val -> filter (hasChildText name val) curs
ChildExists name nested -> filter (hasChild name nested) curs
PositionIs n -> take 1 (drop (n - 1) curs)
AndPred p1 p2 -> applyPredicate (applyPredicate curs p1) p2
hasAttrValue :: String -> String -> C.Cursor -> Bool
hasAttrValue name val cur = C.attribute (toName name) cur == [T.pack val]
hasChildText :: String -> String -> C.Cursor -> Bool
hasChildText name val cur =
let children = cur C.$/ C.element (toName name)
in any (hasTextContent val) children
hasTextContent :: String -> C.Cursor -> Bool
hasTextContent val cur =
let txt = concatMap T.unpack (cur C.$/ C.content)
in txt == val
hasChild :: String -> [Predicate] -> C.Cursor -> Bool
hasChild name nested cur =
let children = cur C.$/ C.element (toName name)
in not (null (applyPredicates children nested))
-- | Check if an XPath expression matches anything in the document.
matches :: Document -> String -> Bool
matches doc path = not (null (evaluate doc (xpath path)))
spec :: Spec
spec = do
describe "XMIR parsing packs" $ do
let resources = "test-resources/xmir-parsing-packs"
packs <- runIO (allPathsIn resources)
forM_
packs
( \pth -> it (makeRelative resources pth) $ do
pack <- parsePack pth
let ParsePack{phi = phi'} = pack
xmir' = do
doc <- parseXMIRThrows (xmir pack)
xmirToPhi doc
case failure pack of
Just True -> xmir' `shouldThrow` anyException
_ -> do
xmir'' <- xmir'
phi'' <- parseExpressionThrows phi'
xmir'' `shouldBe` phi''
)
describe "prohibit to convert to XMIR" $
forM_
[ "[[ ]]"
, "T"
, "[[ x -> ? ]]"
, "[[ ^ -> 5 ]]"
, "Q.x.y.z"
, "\"Hello\""
, "Q"
, "$"
, "[[ x -> T ]]"
, "[[ top -> [[ x -> T ]] ]]"
, "[[ x -> [[ !t1 -> 5 ]] ]]"
, "[[ org -> [[ z -> ?, L> Package ]] ]]"
]
( \phi' -> it phi' $ do
expr <- parseExpressionThrows phi'
expressionToXMIR expr defaultXmirContext `shouldThrow` anyException
)
describe "XMIR printing packs" $ do
let resources = "test-resources/xmir-printing-packs"
packs <- runIO (allPathsIn resources)
forM_
packs
( \pth ->
it (makeRelative resources pth) $ do
pack <- printPack pth
let PrintPack{phi = phi', xpaths = xpaths'} = pack
expr <- parseExpressionThrows phi'
xmir' <- expressionToXMIR expr defaultXmirContext
let failed = filter (not . matches xmir') xpaths'
unless
(null failed)
(expectationFailure ("Failed xpaths:\n - " ++ intercalate "\n - " failed ++ "\nXMIR is:\n" ++ printXMIR xmir'))
)
describe "XMIR round-trip" $
forM_
[ ("keeps λ function name and bound ρ", "[[ k -> [[ x -> ?, L> Lorg_eolang_number_plus, ^ -> [[ y -> ? ]] ]] ]]")
, ("keeps Δ data bound to a named attribute", "[[ k -> [[ a -> [[ D> 01-02 ]], ^ -> [[ D> 03-04 ]] ]] ]]")
, ("keeps Δ data in a dispatched formation", "[[ k -> [[ D> 01-02 ]].plus ]]")
, ("keeps a bare 'Q' bound to a named attribute", "[[ x -> Q ]]")
]
( \(desc, source) -> it desc $ do
expr <- parseExpressionThrows source
xmir' <- expressionToXMIR expr defaultXmirContext
back <- xmirToPhi xmir'
back `shouldBe` expr
)
describe "XMIR exception messages" $
forM_
[
( "explains an unsupported top-level expression"
, do
expr <- parseExpressionThrows "[[ x -> $ ]]"
try (void (expressionToXMIR expr defaultXmirContext)) :: IO (Either SomeException ())
, ["XMIR does not support such top-level expression"]
)
,
( "explains an unsupported nested expression"
, do
expr <- parseExpressionThrows "[[ x -> [[ y -> T ]] ]]"
try (void (expressionToXMIR expr defaultXmirContext)) :: IO (Either SomeException ())
, ["XMIR does not support such expression"]
)
,
( "explains an unsupported binding"
, try (void (expressionToXMIR (ExFormation [BiTau (AtLabel "x") (ExFormation [BiMeta "n", BiVoid AtRho]), BiVoid AtRho]) defaultXmirContext)) ::
IO (Either SomeException ())
, ["XMIR does not support such bindings"]
)
,
( "explains a parse failure"
, try (void (parseXMIRThrows "not-xml-at-all <<<")) :: IO (Either SomeException ())
, ["Couldn't parse given XMIR"]
)
,
( "explains an invalid XMIR structure, including the offending element"
, do
doc <- parseXMIRThrows "<object><o name=\"app\"><o/></o></object>"
try (void (xmirToPhi doc)) :: IO (Either SomeException ())
, ["Couldn't traverse though given XMIR", "XMIR:"]
)
]
( \(desc, action, messages) -> it desc $ do
result <- action
case result of
Left err -> mapM_ (displayException err `shouldContain`) messages
Right () -> expectationFailure "expected an exception"
)
describe "escapeXML" $
it "escapes an apostrophe alongside the other reserved characters" $
escapeXML "it's a & <b> \"quote\"" `shouldBe` "it's a & <b> "quote""
describe "XMIR document structure" $ do
it "produces an empty prologue and epilogue" $ do
expr <- parseExpressionThrows "[[ x -> 5 ]]"
Document prologue _ epilogue <- expressionToXMIR expr defaultXmirContext
prologue `shouldBe` Prologue [] Nothing []
epilogue `shouldBe` []
it "renders package metas with empty attributes and a matching part" $ do
expr <- parseExpressionThrows "[[ org -> [[ eolang -> [[ foo -> [[ x -> 5 ]], L> Package ]], L> Package ]] ]]"
xmir' <- expressionToXMIR expr defaultXmirContext
let root = C.fromDocument xmir'
case root C.$/ C.element (toName "metas") of
[metasCur] -> case C.node metasCur of
NodeElement metasEl -> elementAttributes metasEl `shouldBe` M.empty
_ -> expectationFailure "expected <metas> to be an element"
_ -> expectationFailure "expected exactly one <metas> element"
case root C.$/ C.element (toName "metas") C.&/ C.element (toName "meta") of
[metaCur] -> case C.node metaCur of
NodeElement metaEl -> elementAttributes metaEl `shouldBe` M.empty
_ -> expectationFailure "expected <meta> to be an element"
_ -> expectationFailure "expected exactly one <meta> element"
let parts = root C.$/ C.element (toName "metas") C.&/ C.element (toName "meta") C.&/ C.element (toName "part") C.&/ C.content
parts `shouldBe` ["org.eolang"]
describe "XMIR comments" $ do
let commentedContext :: XmirContext
commentedContext = XmirContext True False (const "")
it "includes a decimal comment for a number when comments aren't omitted" $ do
expr <- parseExpressionThrows "[[ x -> 5 ]]"
xmir' <- expressionToXMIR expr commentedContext
printXMIR xmir' `shouldContain` "<!-- 5 -->"
it "includes a quoted comment for a string when comments aren't omitted" $ do
expr <- parseExpressionThrows "[[ x -> \"foo\" ]]"
xmir' <- expressionToXMIR expr commentedContext
printXMIR xmir' `shouldContain` "<!-- \"foo\" -->"
describe "XMIR printing edge cases" $ do
it "wraps a chained dispatch on a formation literal with a @base attribute" $ do
expr <- parseExpressionThrows "[[ x -> [[ y -> 5 ]].plus.minus ]]"
xmir' <- expressionToXMIR expr defaultXmirContext
let root = C.fromDocument xmir'
xCur = root C.$/ C.element (toName "o")
outer = filter (\cur -> C.attribute (toName "base") cur == [".minus"]) xCur
inner =
concatMap
(filter (\cur -> C.attribute (toName "base") cur == [".plus"]) . (C.$/ C.element (toName "o")))
xCur
length outer `shouldBe` 1
length inner `shouldBe` 1
it "renders a void φ binding as a nested formation" $ do
expr <- parseExpressionThrows "[[ x -> [[ @ -> ? ]] ]]"
xmir' <- expressionToXMIR expr defaultXmirContext
let root = C.fromDocument xmir'
nested = root C.$/ C.element (toName "o") C.&/ C.element (toName "o")
phiVoid =
filter
(\cur -> C.attribute (toName "name") cur == ["φ"] && C.attribute (toName "base") cur == ["∅"])
nested
length phiVoid `shouldBe` 1
it "renders a bare global reference as the top-level value" $ do
expr <- parseExpressionThrows "[[ x -> Q ]]"
xmir' <- expressionToXMIR expr defaultXmirContext
let root = C.fromDocument xmir'
xCur = filter (\cur -> C.attribute (toName "base") cur == ["Φ"]) (root C.$/ C.element (toName "o"))
length xCur `shouldBe` 1
it "omits @base when an application argument is a bare formation" $ do
expr <- parseExpressionThrows "[[ foo -> Q.bar(x -> 5, α1 -> [[ z -> ? ]]) ]]"
xmir' <- expressionToXMIR expr defaultXmirContext
let root = C.fromDocument xmir'
args = root C.$/ C.element (toName "o") C.&/ C.element (toName "o")
namedArg = filter (\cur -> C.attribute (toName "as") cur == ["x"]) args
formationArg = filter (\cur -> C.attribute (toName "as") cur == ["α1"]) args
case namedArg of
[argCur] -> C.attribute (toName "base") argCur `shouldBe` ["Φ.number"]
_ -> expectationFailure "expected exactly one 'x' argument"
case formationArg of
[argCur] -> C.attribute (toName "base") argCur `shouldBe` []
_ -> expectationFailure "expected exactly one α1 argument"
describe "XMIR malformed input containing a processing instruction" $
it "embeds a processing instruction verbatim when rendering the offending element" $ do
doc <-
parseXMIRThrows
"<object><o name=\"x\" base=\"∅\"/><?a-pi some-data?><o name=\"y\" base=\"∅\"/></object>"
result <- try (xmirToPhi doc) :: IO (Either SomeException Expression)
case result of
Left exc -> displayException exc `shouldContain` "Couldn't traverse though given XMIR"
Right _ -> expectationFailure "expected an exception"