packages feed

keiro-dsl-0.12.0.0: test/Keiro/Dsl/FrontendSurface.hs

{-# LANGUAGE NoFieldSelectors #-}

module Keiro.Dsl.FrontendSurface (frontendSurfaceSpec) where

import Control.Monad (forM_)
import Data.List (find)
import Data.Text (Text)
import Data.Text qualified as T
import Keiro.Dsl.Frontend
import Keiro.Dsl.FrontendCompatibility
  ( CompatibilityManifest (..),
    SourceExpectation (..),
    readCompatibilityManifest,
    readRepoText,
  )
import Keiro.Dsl.Grammar
import Keiro.Dsl.LanguageVersion
import Keiro.Dsl.Parser (parseSource, parseSourceDocument, parseSpec, parseSpecText)
import Keiro.Dsl.Source
import Keiro.Dsl.SourceIndex
import Keiro.Dsl.Syntax
import Test.Hspec hiding (Spec)
import Prelude hiding (span)

frontendSurfaceSpec :: SpecWith ()
frontendSurfaceSpec = do
  describe "source spans" $ do
    it "rejects invalid points and backwards spans" $ do
      mkSourcePoint (-1) 1 1 `shouldBe` Nothing
      mkSourcePoint 0 0 1 `shouldBe` Nothing
      mkSourcePoint 0 1 0 `shouldBe` Nothing
      let start = SourcePoint {offset = 3, line = 2, column = 2}
          end = SourcePoint {offset = 2, line = 2, column = 1}
      mkSourceSpan "invalid.keiro" start end `shouldBe` Nothing

    forM_ exactSpanCases $ \ExactSpanCase {caseLabel, caseSource, caseSelector, caseOwnedSyntax} ->
      it caseLabel $ do
        surface <- parseSurfaceRight (caseLabel <> ".keiro") caseSource
        actualSpan <- case selectSpan caseSource caseSelector surface of
          Left problem -> expectationFailure problem >> fail "unreachable"
          Right selected -> pure selected
        spanText caseSource actualSpan `shouldBe` caseOwnedSyntax
        spanPoints actualSpan `shouldBe` expectedSpanPoints caseSource caseOwnedSyntax

    it "locates Unicode-prefixed syntax and excludes trailing comments" $ do
      let leading = "# λ🙂\n"
          preambleText = "language keiro-dsl 2"
          contextText = "context spans"
          idText = "id ThingId prefix=thing"
          source = leading <> preambleText <> "\n" <> contextText <> "\n" <> idText <> "   # trailing\n\n"
      parsed <- parseSurfaceRight "unicode.keiro" source
      case parsed of
        SurfaceSource
          { preamble = Just (Located {span = preambleSpan}),
            spec =
              Located
                { span = bodySpan,
                  value =
                    SurfaceSpec
                      { context = Located {span = contextSpan},
                        items = [Located {span = idSpan, value = SurfaceId _}]
                      }
                }
          } -> do
            spanPoints preambleSpan `shouldBe` (pointAfter leading, pointAfter (leading <> preambleText))
            spanPoints contextSpan
              `shouldBe` ( pointAfter (leading <> preambleText <> "\n"),
                           pointAfter (leading <> preambleText <> "\n" <> contextText)
                         )
            spanPoints idSpan
              `shouldBe` ( pointAfter (leading <> preambleText <> "\n" <> contextText <> "\n"),
                           pointAfter (leading <> preambleText <> "\n" <> contextText <> "\n" <> idText)
                         )
            spanPoints bodySpan `shouldBe` (fst (spanPoints contextSpan), snd (spanPoints idSpan))
        other -> expectationFailure ("unexpected surface shape: " <> show other)

    it "covers a complete multi-line aggregate and an empty document body" $ do
      aggregateSource <- readRepoText "keiro-dsl/test/fixtures/aggregate-scalars.keiro"
      aggregateSurface <- parseSurfaceRight "aggregate-scalars.keiro" aggregateSource
      let aggregateStart = T.length (fst (T.breakOn "aggregate ScalarLedger" aggregateSource))
          aggregateEnd = T.length (T.stripEnd aggregateSource)
      case aggregateSurface of
        SurfaceSource
          { spec =
              Located
                { value =
                    SurfaceSpec
                      { items = [Located {span = aggregateSpan, value = SurfaceNode (NAggregate _)}],
                        elements
                      }
                }
          } -> do
            spanOffsets aggregateSpan `shouldBe` (aggregateStart, aggregateEnd)
            let fields = [(name, spanText aggregateSource fieldSpan) | Located {span = fieldSpan, value = SurfaceField name} <- elements]
                expressions = [spanText aggregateSource expressionSpan | Located {span = expressionSpan, value = SurfaceExpression _} <- elements]
            fields `shouldContain` [("observedAt", "observedAt:Time")]
            expressions `shouldContain` ["cmd.observedAt >= reg.observedAt && cmd.revision >= reg.revision"]
        other -> expectationFailure ("unexpected aggregate surface shape: " <> show other)
      emptySurface <- parseSurfaceRight "empty.keiro" "context empty   # trailing\n"
      case emptySurface of
        SurfaceSource
          { spec =
              Located
                { span = bodySpan,
                  value = SurfaceSpec {context = Located {span = contextSpan}, items = []}
                }
          } -> bodySpan `shouldBe` contextSpan
        other -> expectationFailure ("unexpected empty surface shape: " <> show other)

    it "owns exact terminal-state and complete generated, replay-only, Hole, and adjacent transition syntax" $ do
      let source =
            T.unlines
              [ "language keiro-dsl 2",
                "context semantic-source-index",
                "aggregate Journey",
                "  regs",
                "    count Natural = 0",
                "  states Empty Active Closed!",
                "  command Start { amount:Natural }",
                "  event Started = fields(Start)",
                "  Empty -- Start -->",
                "    guard cmd.amount > 0",
                "    write count := cmd.amount",
                "    emit Started",
                "    goto Active",
                "  replay-only Empty -- Start -->",
                "    implementation hole",
                "    goto Active",
                "  Active -- Start --> implementation hole; goto Closed",
                "  Closed -- Start --> implementation hole; goto Closed # trailing"
              ]
      surface <- parseSurfaceRight "semantic-source-index.keiro" source
      let SurfaceSource {spec = Located {value = SurfaceSpec {elements}}} = surface
          stateSpans =
            [ (aggregateName, stateName, spanText source stateSpan)
            | Located {span = stateSpan, value = SurfaceAggregateState aggregateName stateName} <- elements
            ]
          transitionSpans =
            [ (aggregateName, ordinal, spanText source transitionSpan)
            | Located {span = transitionSpan, value = SurfaceAggregateTransition aggregateName ordinal} <- elements
            ]
      stateSpans
        `shouldBe` [ ("Journey", "Empty", "Empty"),
                     ("Journey", "Active", "Active"),
                     ("Journey", "Closed", "Closed!")
                   ]
      transitionSpans
        `shouldBe` [ ( "Journey",
                       0,
                       "Empty -- Start -->\n    guard cmd.amount > 0\n    write count := cmd.amount\n    emit Started\n    goto Active"
                     ),
                     ( "Journey",
                       1,
                       "replay-only Empty -- Start -->\n    implementation hole\n    goto Active"
                     ),
                     ("Journey", 2, "Active -- Start --> implementation hole; goto Closed"),
                     ("Journey", 3, "Closed -- Start --> implementation hole; goto Closed")
                   ]
      document <- case parseSourceDocument "semantic-source-index.keiro" source of
        Left failure -> expectationFailure (show failure) >> fail "unreachable"
        Right value -> pure value
      let ParsedSourceDocument {documentParsedSource, documentSourceIndex} = document
      parseSource "semantic-source-index.keiro" source `shouldBe` Right documentParsedSource
      length (semanticSourceEntries documentSourceIndex) `shouldBe` 7
      case lookupSourceSpan (AggregateStateSubject "Journey" "Closed") documentSourceIndex of
        Just (ExactSourcePosition, SourceSpan {source = spanSource, start = SourcePoint {line, column}}) -> do
          spanSource `shouldBe` "semantic-source-index.keiro"
          (line, column) `shouldBe` (6, 23)
        other -> expectationFailure ("expected exact terminal-state location, got " <> show other)
      case lookupSourceSpan (AggregateTransitionSubject "Journey" (TransitionOrdinal 1)) documentSourceIndex of
        Just (ExactSourcePosition, SourceSpan {start = SourcePoint {line, column}}) ->
          (line, column) `shouldBe` (14, 3)
        other -> expectationFailure ("expected exact replay-only transition location, got " <> show other)

    it "refuses incomplete and duplicate semantic source indices" $ do
      let subject = AggregateStateSubject "Journey" "Empty"
          point = SourcePoint {offset = 0, line = 1, column = 1}
          sourceSpan = SourceSpan {source = "index.keiro", start = point, end = point}
      exactSemanticSourceIndex "index.keiro" [subject] []
        `shouldSatisfy` isFailure MissingSourceSubject
      exactSemanticSourceIndex "index.keiro" [subject] [(subject, sourceSpan), (subject, sourceSpan)]
        `shouldSatisfy` isFailure DuplicateSourceSubject

  describe "surface lowering" $ do
    it "keeps every public parser projection equal for one rich source" $ do
      surface <- parseSurfaceRight "parser-parity.keiro" richParitySource
      lowered <- lowerRight surface
      document <- case parseSourceDocument "parser-parity.keiro" richParitySource of
        Left failure -> expectationFailure (show failure) >> fail "unreachable"
        Right parsed -> pure parsed
      let ParsedSourceDocument {documentParsedSource} = document
      documentParsedSource `shouldBe` lowered
      parseSource "parser-parity.keiro" richParitySource `shouldBe` Right lowered
      parseSpec "parser-parity.keiro" richParitySource `shouldBe` Right (parsedSpec lowered)
      parseSpecText richParitySource `shouldBe` Right (parsedSpec lowered)

    it "preserves top-level source order before grouping the semantic graph" $ do
      let source = T.unlines ["context ordering", "id FirstId prefix=first", "enum Mode { On=on Off=off }", "id SecondId prefix=second"]
      surface <- parseSurfaceRight "ordering.keiro" source
      case surface of
        SurfaceSource {spec = Located {value = SurfaceSpec {items}}} ->
          map topItemKind items `shouldBe` ["id", "enum", "id"]
      lowered <- lowerRight surface
      map idName (specIds (parsedSpec lowered)) `shouldBe` ["FirstId", "SecondId"]
      map enumName (specEnums (parsedSpec lowered)) `shouldBe` ["Mode"]

    it "refuses surface evidence attributed to another source" $ do
      surface <- parseSurfaceRight "owned.keiro" "context owned\n"
      let corrupted = case surface of
            SurfaceSource {source, language, preamble, spec = Located {span = SourceSpan {start, end}, value}} ->
              SurfaceSource
                { source,
                  language,
                  preamble,
                  spec = Located {span = SourceSpan {source = "other.keiro", start, end}, value}
                }
      case lowerSurfaceSource corrupted of
        Left LoweringFailure {code = SourceNameMismatch} -> pure ()
        other -> expectationFailure ("expected source ownership failure, got " <> show other)

    it "lowers every accepted 0.7 fixture to the compatibility result" $ do
      manifest <- readCompatibilityManifest
      forM_ [row | row <- manifestSources manifest, sourceResult row == "accept"] $ \row -> do
        source <- readRepoText (sourcePath row)
        surface <- parseSurfaceRight (sourcePath row) source
        lowered <- lowerRight surface
        parseSource (sourcePath row) source `shouldBe` Right lowered

  describe "parser module boundaries" $ do
    it "keeps the public parser facade free of Megaparsec and grammar productions" $ do
      facade <- readRepoText "keiro-dsl/src/Keiro/Dsl/Parser.hs"
      facade `shouldNotSatisfy` T.isInfixOf "Text.Megaparsec"
      facade `shouldNotSatisfy` T.isInfixOf "keyword ::"
      facade `shouldNotSatisfy` T.isInfixOf "pAggregate ::"
      facade `shouldNotSatisfy` T.isInfixOf "    parseSurfaceSource,"

    it "keeps internal grammar modules from importing the public facade" $
      forM_ parserModulePaths $ \path -> do
        source <- readRepoText path
        filter isFacadeImport (T.lines source) `shouldBe` []

    it "keeps source, syntax, and semantic graph modules Megaparsec-free" $
      forM_ semanticModulePaths $ \path -> do
        source <- readRepoText path
        source `shouldNotSatisfy` T.isInfixOf "Text.Megaparsec"

    it "keeps semantic consumers independent of surface syntax and internal parsers" $
      forM_ semanticConsumerPaths $ \path -> do
        source <- readRepoText path
        source `shouldNotSatisfy` T.isInfixOf "import Keiro.Dsl.Syntax"
        source `shouldNotSatisfy` T.isInfixOf "import Keiro.Dsl.Parser."

    it "keeps workspace loading and parser internals on opposite sides of the facade" $ do
      workspace <- readRepoText "keiro-dsl/src/Keiro/Dsl/Workspace.hs"
      workspace `shouldNotSatisfy` T.isInfixOf "import Keiro.Dsl.Parser."
      forM_ parserModulePaths $ \path -> do
        source <- readRepoText path
        forM_ forbiddenParserDependencies $ \dependency ->
          source `shouldNotSatisfy` T.isInfixOf ("import Keiro.Dsl." <> dependency)

parseSurfaceRight :: FilePath -> Text -> IO SurfaceSource
parseSurfaceRight sourceName source =
  case parseSurfaceSource sourceName source of
    Left failure -> expectationFailure (T.unpack (renderFrontendFailure failure)) >> fail "unreachable"
    Right value -> pure value

lowerRight :: SurfaceSource -> IO ParsedSource
lowerRight source =
  case lowerSurfaceSource source of
    Left failure -> expectationFailure (T.unpack (renderLoweringFailure failure)) >> fail "unreachable"
    Right value -> pure value

spanOffsets :: SourceSpan -> (Int, Int)
spanOffsets SourceSpan {start = SourcePoint {offset = startOffset}, end = SourcePoint {offset = endOffset}} =
  (startOffset, endOffset)

spanPoints :: SourceSpan -> ((Int, Int, Int), (Int, Int, Int))
spanPoints SourceSpan {start, end} = (pointTuple start, pointTuple end)

pointTuple :: SourcePoint -> (Int, Int, Int)
pointTuple SourcePoint {offset, line, column} = (offset, line, column)

pointAfter :: Text -> (Int, Int, Int)
pointAfter prefix =
  ( T.length prefix,
    length linesFound,
    T.length (last linesFound) + 1
  )
  where
    linesFound = T.splitOn "\n" prefix

topItemKind :: Located SurfaceTopItem -> Text
topItemKind Located {value} = case value of
  SurfaceId _ -> "id"
  SurfaceEnum _ -> "enum"
  SurfaceRule _ -> "rule"
  SurfaceNominalScalar _ -> "nominal"
  SurfaceMapped _ -> "mapped"
  SurfaceNode _ -> "node"

spanText :: Text -> SourceSpan -> Text
spanText source SourceSpan {start = SourcePoint {offset = startOffset}, end = SourcePoint {offset = endOffset}} =
  T.take (endOffset - startOffset) (T.drop startOffset source)

data SpanSelector
  = SelectPreamble
  | SelectContext
  | SelectFirstItem
  | SelectDocumentBody
  | SelectElement Text

data ExactSpanCase = ExactSpanCase
  { caseLabel :: String,
    caseSource :: Text,
    caseSelector :: SpanSelector,
    caseOwnedSyntax :: Text
  }

exactSpanCases :: [ExactSpanCase]
exactSpanCases =
  [ ExactSpanCase
      { caseLabel = "tracks a leading tab with Megaparsec tab stops",
        caseSource = "\tcontext tabs\n",
        caseSelector = SelectContext,
        caseOwnedSyntax = "context tabs"
      },
    ExactSpanCase
      { caseLabel = "counts Unicode characters before owned syntax",
        caseSource = "# λ🙂\ncontext unicode\n",
        caseSelector = SelectContext,
        caseOwnedSyntax = "context unicode"
      },
    ExactSpanCase
      { caseLabel = "tracks LF lines and excludes trailing spaces and comments",
        caseSource = "# leading\n\ncontext lf\nid LfId prefix=lf   # trailing\n",
        caseSelector = SelectFirstItem,
        caseOwnedSyntax = "id LfId prefix=lf"
      },
    ExactSpanCase
      { caseLabel = "tracks CRLF lines and excludes trailing comments",
        caseSource = "# leading\r\ncontext crlf\r\nid CrlfId prefix=crlf  # trailing\r\n",
        caseSelector = SelectFirstItem,
        caseOwnedSyntax = "id CrlfId prefix=crlf"
      },
    ExactSpanCase
      { caseLabel = "tracks mixed LF and CRLF sequences",
        caseSource = "# first\r\n# second\ncontext mixed\r\nid MixedId prefix=mixed\n",
        caseSelector = SelectFirstItem,
        caseOwnedSyntax = "id MixedId prefix=mixed"
      },
    ExactSpanCase
      { caseLabel = "keeps a hash inside a string literal as owned syntax",
        caseSource = richParitySource,
        caseSelector = SelectElement richGuardExpression,
        caseOwnedSyntax = richGuardExpression
      },
    ExactSpanCase
      { caseLabel = "ends an empty document body at its context clause",
        caseSource = "context empty   # trailing\r\n",
        caseSelector = SelectDocumentBody,
        caseOwnedSyntax = "context empty"
      },
    ExactSpanCase
      { caseLabel = "locates a nested aggregate field",
        caseSource = richParitySource,
        caseSelector = SelectElement "amount:Natural",
        caseOwnedSyntax = "amount:Natural"
      },
    ExactSpanCase
      { caseLabel = "locates a nested boolean and arithmetic expression",
        caseSource = richParitySource,
        caseSelector = SelectElement richGuardExpression,
        caseOwnedSyntax = richGuardExpression
      }
  ]

richParitySource :: Text
richParitySource =
  T.concat
    [ "# leading λ🙂\r\n",
      "language keiro-dsl 4\n",
      "context parser-parity\r\n",
      "aggregate SpanMatrix\n",
      "  regs\r\n",
      "    count Natural = 0\n",
      "    limit Natural = 100\r\n",
      "    label Text = \"ready # literal\"\n",
      "  states Empty Active Closed!\r\n",
      "  command Advance { amount:Natural delta:Natural note:Text }\n",
      "  event Advanced = fields(Advance)\r\n",
      "  Empty -- Advance -->\n",
      "    guard " <> richGuardExpression <> "\r\n",
      "    write count := reg.count + cmd.amount\n",
      "    write limit := reg.limit + cmd.delta\r\n",
      "    write label := cmd.note\n",
      "    emit Advanced\r\n",
      "    goto Active   # trailing\n"
    ]

richGuardExpression :: Text
richGuardExpression = "cmd.amount + cmd.delta > reg.count && reg.limit >= cmd.amount && cmd.note == \"ready # literal\""

selectSpan :: Text -> SpanSelector -> SurfaceSource -> Either String SourceSpan
selectSpan source selector SurfaceSource {preamble, spec = Located {span = bodySpan, value = SurfaceSpec {context = locatedContext, items, elements}}} =
  case selector of
    SelectPreamble -> maybe (Left "expected a language preamble span") (Right . locatedSpan) preamble
    SelectContext -> Right (locatedSpan locatedContext)
    SelectFirstItem -> case items of
      Located {span = itemSpan} : _ -> Right itemSpan
      [] -> Left "expected at least one top-level item span"
    SelectDocumentBody -> Right bodySpan
    SelectElement expectedText ->
      case find ((== expectedText) . spanText source . locatedSpan) elements of
        Just Located {span = elementSpan} -> Right elementSpan
        Nothing -> Left ("expected an element span owning " <> show expectedText)

locatedSpan :: Located value -> SourceSpan
locatedSpan Located {span} = span

expectedSpanPoints :: Text -> Text -> ((Int, Int, Int), (Int, Int, Int))
expectedSpanPoints source ownedSyntax =
  case T.breakOn ownedSyntax source of
    (_, suffix) | T.null suffix -> error ("expected owned syntax in exact-span fixture: " <> T.unpack ownedSyntax)
    (prefix, _) -> (pointAfterMegaparsec prefix, pointAfterMegaparsec (prefix <> ownedSyntax))

pointAfterMegaparsec :: Text -> (Int, Int, Int)
pointAfterMegaparsec = T.foldl' advance (0, 1, 1)
  where
    advance (currentOffset, currentLine, currentColumn) character =
      case character of
        '\n' -> (currentOffset + 1, currentLine + 1, 1)
        '\t' ->
          ( currentOffset + 1,
            currentLine,
            currentColumn + 8 - ((currentColumn - 1) `mod` 8)
          )
        _ -> (currentOffset + 1, currentLine, currentColumn + 1)

isFailure :: SourceIndexFailureCode -> Either SourceIndexFailure value -> Bool
isFailure expected = \case
  Left SourceIndexFailure {failureCode} -> failureCode == expected
  Right _ -> False

isFacadeImport :: Text -> Bool
isFacadeImport line =
  let stripped = T.strip line
   in stripped == "import Keiro.Dsl.Parser"
        || "import Keiro.Dsl.Parser " `T.isPrefixOf` stripped

parserModulePaths :: [FilePath]
parserModulePaths =
  map
    ("keiro-dsl/src/Keiro/Dsl/Parser/" <>)
    [ "Aggregate.hs",
      "Coordination.hs",
      "Core.hs",
      "Declaration.hs",
      "Document.hs",
      "Expression.hs",
      "Integration.hs",
      "Mapped.hs",
      "Preamble.hs",
      "Queue.hs",
      "ReadModel.hs",
      "Workflow.hs"
    ]

semanticModulePaths :: [FilePath]
semanticModulePaths =
  map
    ("keiro-dsl/src/Keiro/Dsl/" <>)
    [ "Source.hs",
      "SourceIndex.hs",
      "Syntax.hs",
      "Grammar.hs",
      "Validate.hs"
    ]

semanticConsumerPaths :: [FilePath]
semanticConsumerPaths =
  map
    ("keiro-dsl/src/Keiro/Dsl/" <>)
    [ "Grammar.hs",
      "Validate.hs",
      "SemanticContract.hs",
      "Scaffold.hs",
      "Diff.hs",
      "FoldFingerprint.hs",
      "ReplayImpact.hs"
    ]

forbiddenParserDependencies :: [Text]
forbiddenParserDependencies =
  [ "Workspace",
    "Validate",
    "Scaffold",
    "Diff",
    "FoldFingerprint",
    "ReplayImpact"
  ]