packages feed

keiro-dsl-0.12.0.0: src/Keiro/Dsl/SourceIndex.hs

{-# LANGUAGE NoFieldSelectors #-}

-- | Exact source provenance for semantic aggregate subjects.
--
-- The index deliberately lives beside 'Keiro.Dsl.Grammar.Spec'. Source
-- movement must not participate in semantic equality, fingerprints, diffs, or
-- generated output.
module Keiro.Dsl.SourceIndex
  ( TransitionOrdinal (..),
    SourceSubject (..),
    SourcePositionQuality (..),
    SemanticSourceIndex,
    ParsedSourceDocument (..),
    SourceIndexFailureCode (..),
    SourceIndexFailure (..),
    semanticSourceSubjects,
    exactSemanticSourceIndex,
    compatibilitySemanticSourceIndex,
    repathSemanticSourceIndex,
    unionSemanticSourceIndexes,
    emptySemanticSourceIndex,
    semanticSourceEntries,
    lookupSourceSpan,
  )
where

import Data.List (find, sort)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import GHC.Generics (Generic)
import Keiro.Dsl.Grammar
  ( Aggregate (..),
    Loc (..),
    Name,
    Node (..),
    Spec (..),
    StateDecl (..),
    Transition (..),
  )
import Keiro.Dsl.LanguageVersion (ParsedSource)
import Keiro.Dsl.Source (SourcePoint (..), SourceSpan (..))

newtype TransitionOrdinal = TransitionOrdinal Int
  deriving stock (Eq, Ord, Show, Generic)

-- | A syntax subject whose location may change without changing semantics.
data SourceSubject
  = AggregateStateSubject !Name !Name
  | AggregateTransitionSubject !Name !TransitionOrdinal
  deriving stock (Eq, Ord, Show, Generic)

-- | Whether a position came from exact parsing or from a compatibility
-- projection over line-only semantic values.
data SourcePositionQuality
  = ExactSourcePosition
  | CompatibilityLineOnly
  deriving stock (Eq, Ord, Show, Generic)

data IndexedSourcePosition = IndexedSourcePosition
  { quality :: !SourcePositionQuality,
    span :: !SourceSpan
  }
  deriving stock (Eq, Show, Generic)

newtype SemanticSourceIndex = SemanticSourceIndex
  { positions :: Map SourceSubject IndexedSourcePosition
  }
  deriving stock (Eq, Show, Generic)

-- | The semantic parse result and its independently comparable source index.
data ParsedSourceDocument = ParsedSourceDocument
  { documentParsedSource :: !ParsedSource,
    documentSourceIndex :: !SemanticSourceIndex
  }
  deriving stock (Eq, Show, Generic)

data SourceIndexFailureCode
  = DuplicateSourceSubject
  | MissingSourceSubject
  | UnexpectedSourceSubject
  | SourceIndexFileMismatch
  deriving stock (Eq, Ord, Show, Generic)

data SourceIndexFailure = SourceIndexFailure
  { failureCode :: !SourceIndexFailureCode,
    failureSubject :: !(Maybe SourceSubject),
    failureSpan :: !(Maybe SourceSpan),
    failureMessage :: !Text
  }
  deriving stock (Eq, Show, Generic)

-- | The complete aggregate state and transition subject inventory of a
-- semantic graph, in semantic source order.
semanticSourceSubjects :: Spec -> [SourceSubject]
semanticSourceSubjects spec = concatMap aggregateSubjects aggregates
  where
    aggregates = [aggregate | NAggregate aggregate <- specNodes spec]
    aggregateSubjects aggregate =
      [ AggregateStateSubject (aggName aggregate) (stName state)
      | state <- aggStates aggregate
      ]
        <> [ AggregateTransitionSubject (aggName aggregate) (TransitionOrdinal ordinal)
           | (ordinal, _) <- zip [0 ..] (aggTransitions aggregate)
           ]

-- | Construct a complete exact index for one parsed file. The expected
-- inventory comes from the just-lowered semantic graph, so missing and stale
-- surface anchors are refused at the lowering boundary.
exactSemanticSourceIndex ::
  FilePath ->
  [SourceSubject] ->
  [(SourceSubject, SourceSpan)] ->
  Either SourceIndexFailure SemanticSourceIndex
exactSemanticSourceIndex source expected entries = do
  case find ((/= source) . sourceOf . snd) entries of
    Just (subject, sourceSpan) ->
      Left
        SourceIndexFailure
          { failureCode = SourceIndexFileMismatch,
            failureSubject = Just subject,
            failureSpan = Just sourceSpan,
            failureMessage = "source-index span belongs to a different source file"
          }
    Nothing -> pure ()
  checkedIndex ExactSourcePosition expected entries
  where
    sourceOf SourceSpan {source = spanSource} = spanSource

-- | Derive an explicitly line-only index for a compatibility 'Spec'. The
-- synthetic column is never advertised as exact.
compatibilitySemanticSourceIndex :: FilePath -> Spec -> Either SourceIndexFailure SemanticSourceIndex
compatibilitySemanticSourceIndex source spec =
  checkedIndex CompatibilityLineOnly expected entries
  where
    expected = semanticSourceSubjects spec
    entries = concatMap aggregateEntries [aggregate | NAggregate aggregate <- specNodes spec]
    aggregateEntries aggregate =
      [ (AggregateStateSubject (aggName aggregate) (stName state), lineSpan (stLoc state))
      | state <- aggStates aggregate
      ]
        <> [ (AggregateTransitionSubject (aggName aggregate) (TransitionOrdinal ordinal), lineSpan (tLoc transition))
           | (ordinal, transition) <- zip [0 ..] (aggTransitions aggregate)
           ]
    lineSpan (Loc lineNumber) =
      SourceSpan
        { source,
          start = point,
          end = point
        }
      where
        point = SourcePoint {offset = 0, line = max 1 lineNumber, column = 1}

-- | Replace the one source name in an index after checking the caller's
-- expected name. Workspace composition uses this to turn loader paths into
-- canonical manifest-relative member paths without relocating points.
repathSemanticSourceIndex ::
  FilePath ->
  FilePath ->
  SemanticSourceIndex ->
  Either SourceIndexFailure SemanticSourceIndex
repathSemanticSourceIndex expected replacement (SemanticSourceIndex index) =
  case find ((/= expected) . sourceOf . spanOf . snd) (Map.toAscList index) of
    Just (subject, position) ->
      Left
        SourceIndexFailure
          { failureCode = SourceIndexFileMismatch,
            failureSubject = Just subject,
            failureSpan = Just (spanOf position),
            failureMessage = "source-index span does not match the workspace member source path"
          }
    Nothing ->
      Right
        ( SemanticSourceIndex
            (Map.map replaceSource index)
        )
  where
    spanOf IndexedSourcePosition {span = sourceSpan} = sourceSpan
    sourceOf SourceSpan {source} = source
    replaceSource position@IndexedSourcePosition {span = sourceSpan} =
      position {span = sourceSpan {source = replacement}}

-- | Union complete member indices, refusing any duplicate semantic subject.
unionSemanticSourceIndexes :: [SemanticSourceIndex] -> Either SourceIndexFailure SemanticSourceIndex
unionSemanticSourceIndexes indexes =
  checkedIndexWithPositions
    [ (subject, position)
    | SemanticSourceIndex index <- indexes,
      (subject, position) <- Map.toAscList index
    ]

emptySemanticSourceIndex :: SemanticSourceIndex
emptySemanticSourceIndex = SemanticSourceIndex Map.empty

semanticSourceEntries :: SemanticSourceIndex -> [(SourceSubject, SourcePositionQuality, SourceSpan)]
semanticSourceEntries (SemanticSourceIndex index) =
  [ (subject, quality, sourceSpan)
  | (subject, IndexedSourcePosition {quality, span = sourceSpan}) <- Map.toAscList index
  ]

lookupSourceSpan :: SourceSubject -> SemanticSourceIndex -> Maybe (SourcePositionQuality, SourceSpan)
lookupSourceSpan subject (SemanticSourceIndex index) = do
  IndexedSourcePosition {quality, span = sourceSpan} <- Map.lookup subject index
  pure (quality, sourceSpan)

checkedIndex ::
  SourcePositionQuality ->
  [SourceSubject] ->
  [(SourceSubject, SourceSpan)] ->
  Either SourceIndexFailure SemanticSourceIndex
checkedIndex quality expected entries = do
  index <- checkedIndexWithPositions [(subject, IndexedSourcePosition {quality, span = sourceSpan}) | (subject, sourceSpan) <- entries]
  let actualSubjects = Set.fromList [subject | (subject, _, _) <- semanticSourceEntries index]
      expectedSubjects = Set.fromList expected
  case Set.lookupMin (actualSubjects Set.\\ expectedSubjects) of
    Just subject ->
      Left
        SourceIndexFailure
          { failureCode = UnexpectedSourceSubject,
            failureSubject = Just subject,
            failureSpan = snd <$> find ((== subject) . fst) entries,
            failureMessage = "source index contains a subject absent from the semantic graph"
          }
    Nothing -> pure ()
  case Set.lookupMin (expectedSubjects Set.\\ actualSubjects) of
    Just subject ->
      Left
        SourceIndexFailure
          { failureCode = MissingSourceSubject,
            failureSubject = Just subject,
            failureSpan = Nothing,
            failureMessage = "semantic graph subject has no source-index entry"
          }
    Nothing -> Right index

checkedIndexWithPositions ::
  [(SourceSubject, IndexedSourcePosition)] ->
  Either SourceIndexFailure SemanticSourceIndex
checkedIndexWithPositions entries =
  case firstDuplicate (sort (map fst entries)) of
    Just duplicate ->
      Left
        SourceIndexFailure
          { failureCode = DuplicateSourceSubject,
            failureSubject = Just duplicate,
            failureSpan = spanOf <$> find ((== duplicate) . fst) entries,
            failureMessage = "source index contains more than one entry for a semantic subject"
          }
    Nothing -> Right (SemanticSourceIndex (Map.fromList entries))
  where
    firstDuplicate values = fst <$> find (uncurry (==)) (zip values (drop 1 values))
    spanOf (_, IndexedSourcePosition {span = sourceSpan}) = sourceSpan