packages feed

typed-peg-0.3.0.0: src/PEG/Analysis.hs

-- | Nullability, FIRST sets and well-formedness, computed in Haskell.
--
-- This module is the value-level twin of "PEG.TyLevel" and "PEG.Grammar":
-- it computes, from the grammar DSL's syntax tree, exactly the environment
-- that GHC would otherwise derive by reducing 'PEG.Syntax.SeqTy',
-- 'PEG.Syntax.ChoiceTy' and 'PEG.TyLevel.Union' while type-checking a
-- 'PEG.Grammar.Rules' value.
--
-- == Why it moved here
--
-- It used to be a type-level computation, and that is what made left
-- recursion a type error.  It was billed to every compilation of every module
-- that mentioned the grammar, and its cost grew sharply with the grammar's
-- size: every entry of the environment carried a FIRST set, so the
-- environment was quadratic in the number of rules, and each of the two
-- reference constraints per rule had to be solved against it.  A 64-rule
-- grammar cost GHC 15 s.  The same fixpoint runs here, at splice time and
-- once, in 6 ms for that grammar and 286 ms for one of 256 rules.  See
-- @bench-compile/@ for the measurements and "PEG.Type" for the trade.
--
-- == This module is now load-bearing
--
-- While the FIRST sets were also in the types, this module could be wrong
-- without being dangerous: GHC recomputed everything and rejected a grammar
-- whose environment did not match.  It no longer does.  A left-recursive
-- grammar that this module accepts is a parser that loops.
--
-- What replaces the type checker is @tests/typed-peg-analysis@, which checks
-- the results here against a straightforward statement of what they mean —
-- nullability as a least fixpoint, and a FIRST set as the transitive closure
-- of the one-step head relation — over the grammars in @examples/@ and over a
-- few hundred generated ones.
--
-- == The fixpoint
--
-- Nullability and FIRST are both computed as the least solution of the
-- equations the type families state.  For a grammar without left recursion
-- that solution is the only one, which is why GHC can find it by unification
-- alone; for a left-recursive grammar the least solution is the one that puts
-- a non-terminal in its own FIRST set, which is precisely what
-- 'PEG.Grammar.Acyclic' rejects.
module PEG.Analysis
  ( Ty (..)
  , RuleEnv
  , Diagnostic (..)
  , World (..)
  , analyse
  , analyseWith
  , exprTy
  , seqTy
  , choiceTy
  , insertSym
  , unionSym
  , renderEnv
  , renderDiagnostic
  , spannable
  ) where

import Data.List (foldl1', nub)
import Data.Maybe (fromMaybe)

import PEG.QQ.Syntax (Def (..), Item (..), PExpr (..))

-- | The value-level image of 'PEG.Type.Ty': a nullability flag and a FIRST
-- set of non-terminal names.
--
-- The FIRST set is kept strictly sorted by 'compare', which agrees with
-- 'GHC.TypeLits.CmpSymbol' on the identifiers the DSL admits.  Sortedness is
-- what makes the set canonical, so that a generated environment is
-- /syntactically/ the type GHC computes rather than merely an equivalent one.
data Ty = Ty
  { tyNullable :: !Bool
  , tyFirst    :: ![String]
  } deriving (Eq, Show)

-- | A grammar environment in definition order: the value-level image of
-- 'PEG.Type.Env', minus the result types, which only the type checker knows.
type RuleEnv = [(String, Ty)]

-- | Something that makes the grammar ill-formed.
--
-- Each of these used to be a type error — or, in the case of 'NullableStar',
-- a type error whose message mentioned neither the rule nor the repetition
-- that caused it.  Reporting them here means naming the rule, and is now the
-- only place any of them is reported.
data Diagnostic
  = -- | A non-terminal is in its own FIRST set, with the chain of head
    -- references that puts it there.
    LeftRecursive String [String]
  | -- | @e*@ or @e+@ where @e@ can match the empty string: the repetition
    -- would not consume input and the parser would not terminate.
    NullableStar String
  | -- | A rule body references a name that no rule defines; the second field
    -- lists the names that are defined.
    UndefinedNT String [String]
  | -- | Two rules with the same name.
    DuplicateRule String
  deriving (Eq, Show)

--------------------------------------------------------------------------------
-- Sorted sets, mirroring PEG.TyLevel
--------------------------------------------------------------------------------

-- | The image of 'PEG.TyLevel.ConsIfAbsent'.
insertSym :: String -> [String] -> [String]
insertSym x [] = [x]
insertSym x (y:ys) = case compare x y of
  LT -> x : y : ys
  EQ -> y : ys
  GT -> y : insertSym x ys

-- | The image of 'PEG.TyLevel.Union': a single merge pass over two sorted
-- sets.
unionSym :: [String] -> [String] -> [String]
unionSym [] ys = ys
unionSym xs [] = xs
unionSym (x:xs) (y:ys) = case compare x y of
  LT -> x : unionSym xs (y:ys)
  EQ -> x : unionSym xs ys
  GT -> y : unionSym (x:xs) ys

-- | The image of 'PEG.Syntax.SeqTy'.
seqTy :: Ty -> Ty -> Ty
seqTy t1 t2 =
  Ty (tyNullable t1 && tyNullable t2)
     (unionSym (tyFirst t1) (if tyNullable t1 then tyFirst t2 else []))

-- | The image of 'PEG.Syntax.ChoiceTy'.
choiceTy :: Ty -> Ty -> Ty
choiceTy t1 t2 =
  Ty (tyNullable t1 || tyNullable t2)
     (unionSym (tyFirst t1) (tyFirst t2))

nullTy, termTy :: Ty
nullTy = Ty True  []
termTy = Ty False []

--------------------------------------------------------------------------------
-- The type of one expression
--------------------------------------------------------------------------------

-- | The 'Ty' of a DSL expression, given the 'Ty' of every non-terminal it may
-- reference.
--
-- This has to follow @PEG.QQ.translateExpr@ case for case, including its
-- optimisations: a repetition of a bare class, character or dot compiles to
-- 'PEG.Syntax.Span' or 'PEG.Syntax.Span1' rather than to
-- 'PEG.Syntax.Star', and those two have different FIRST sets from the generic
-- form.  A case that disagrees with the translation produces an environment
-- GHC will reject.
exprTy :: (String -> Ty) -> PExpr -> Ty
exprTy look = go
  where
    go (EChar _)        = termTy
    go EDot             = termTy
    go (EClass _ _)     = termTy
    go (EString s)
      | null s          = nullTy          -- pureP ""
      | otherwise       = termTy
    -- NTGo: the reference adds its own name to the rule's FIRST set.
    go (ENT n)          = let t = look n
                          in Ty (tyNullable t) (insertSym n (tyFirst t))
    -- Both lookaheads are 'Not' at bottom, which is nullable and keeps the
    -- FIRST set of its operand.  @&e@ is @Not (Not e)@.
    go (EAnd e)         = Ty True (tyFirst (go e))
    go (ENot e)         = Ty True (tyFirst (go e))
    go (EOpt e)         = choiceTy (go e) nullTy
    go (EStar e)
      | spannable e     = nullTy          -- spanOf
      | otherwise       = Ty True (tyFirst (go e))
    go (EPlus e)
      | spannable e     = termTy          -- spanOf1
      | otherwise       = let t = go e in seqTy t (Ty True (tyFirst t))
    go (EIndent _ e)    = go e
    go (EPos _ e)       = go e
    go (EAlign e)       = go e
    go (EChoice es)     = foldl1' choiceTy (map go es)
    go (ESeq [] _)      = nullTy          -- pureP
    go (ESeq items _)   = foldl1' seqTy [ go e | Item _ e <- items ]

-- | Does a repetition of this expression compile to a 'PEG.Syntax.Span'?
spannable :: PExpr -> Bool
spannable (EClass _ _) = True
spannable (EChar _)    = True
spannable EDot         = True
spannable _            = False

--------------------------------------------------------------------------------
-- The grammar
--------------------------------------------------------------------------------

-- | Is this the whole grammar, or part of one?
--
-- 'PEG.Grammar.RCons' is exported, so two quasi-quoted blocks can be spliced
-- into one rule set and a rule in the first may reference a rule in the
-- second.  A block analysed 'Open' therefore treats an unknown name as
-- opaque — non-nullable, with an empty FIRST set — instead of reporting it.
--
-- Under-approximating a FIRST set loses a 'LeftRecursive' or a
-- 'NullableStar'; over-approximating would reject a grammar that is fine.
-- The second is the worse failure, so an unknown name is treated as opaque —
-- but nothing catches what that loses, since the type checker no longer
-- computes FIRST sets of its own.  Left recursion that closes across two
-- blocks spliced together is reported by nobody; a grammar written as a
-- single 'PEG.QQ.pegGrammar' is 'Closed' and has no such gap.  The
-- environment returned for an 'Open' block is, for the same reason, not the
-- grammar's environment: only the diagnostics are meaningful.
data World = Closed | Open
  deriving (Eq, Show)

-- | Compute the environment of a complete set of rules, or report why it has
-- none.
--
-- All diagnostics of a kind are reported together, so a grammar with three
-- undefined non-terminals names all three rather than one per recompilation.
analyse :: [Def] -> Either [Diagnostic] RuleEnv
analyse = analyseWith Closed

-- | 'analyse', over a whole grammar or a fragment of one.
analyseWith :: World -> [Def] -> Either [Diagnostic] RuleEnv
analyseWith world defs
  | not (null dups)      = Left dups
  | not (null undefs)    = Left undefs
  | not (null illFormed) = Left illFormed
  | not (null leftRecs)  = Left leftRecs
  | otherwise            = Right env
  where
    names = [ n | Def n _ _ <- defs ]

    dups = [ DuplicateRule n
           | n <- nub names, length (filter (== n) names) > 1 ]

    undefs = case world of
      Open   -> []
      Closed -> [ UndefinedNT n names
                | n <- nub (concatMap (refs . body) defs), n `notElem` names ]
      where body (Def _ _ e) = e

    -- Kleene iteration from the empty environment.  Every clause of 'exprTy'
    -- is monotone in the environment and the lattice is finite, so this
    -- terminates; it is the least solution of the equations the type families
    -- state.
    env = fix [ (n, Ty False []) | n <- names ]
      where
        fix m = let m' = step m in if m' == m then m else fix m'
        step m = [ (n, exprTy (at m) e) | Def n _ e <- defs ]

    at m n = fromMaybe (Ty False []) (lookup n m)

    -- A repetition must consume input, which 'PEG.Syntax.Star' states as a
    -- non-nullable operand.  Checking it here names the rule it is in.
    illFormed = [ NullableStar n | Def n _ e <- defs, hasNullableRep (at env) e ]

    -- Every rule on a cycle is left-recursive, and reporting each of them
    -- prints the same cycle once per entry point.  Two paths that are
    -- rotations of each other are the same cycle, so only the first is kept.
    leftRecs = dedupe [] [ LeftRecursive n (cycleFrom n)
                         | (n, t) <- env, n `elem` tyFirst t ]
      where
        dedupe _ [] = []
        dedupe seen (d@(LeftRecursive _ path) : rest)
          | key `elem` seen = dedupe seen rest
          | otherwise       = d : dedupe (key : seen) rest
          where key = canonical path
        dedupe seen (d : rest) = d : dedupe seen rest

    -- A cycle is written as @n -> ... -> n@; drop the repeated end and turn
    -- it so that it starts at its least name.
    canonical path = case reverse (drop 1 (reverse path)) of
      []    -> []
      nodes -> minimum [ rotate k nodes | k <- [0 .. length nodes - 1] ]
      where rotate k xs = drop k xs ++ take k xs

    -- The FIRST set is already transitive, so it says /that/ a rule is
    -- left-recursive but not /how/.  The chain is recovered from the graph of
    -- direct head references, which is 'exprTy' again with the environment
    -- cut back to nullability alone.
    heads n = tyFirst (exprTy (\k -> Ty (tyNullable (at env k)) []) (bodyOf n))

    bodyOf n = case [ e | Def m _ e <- defs, m == n ] of
                 (e:_) -> e
                 []    -> ESeq [] Nothing

    cycleFrom n = go [n] n
      where
        go path cur = case [ h | h <- heads cur, h == n ] of
          (_:_) -> reverse (n : path)
          []    -> case [ p | h <- heads cur
                            , h `notElem` path
                            , n `elem` tyFirst (at env h)
                            , p <- [go (h : path) h]
                            , not (null p) ] of
                     (p:_) -> p
                     []    -> []

-- | Every non-terminal a body references, at any position.
refs :: PExpr -> [String]
refs (ENT n)       = [n]
refs (EAnd e)      = refs e
refs (ENot e)      = refs e
refs (EOpt e)      = refs e
refs (EStar e)     = refs e
refs (EPlus e)     = refs e
refs (EIndent _ e) = refs e
refs (EPos _ e)    = refs e
refs (EAlign e)    = refs e
refs (EChoice es)  = concatMap refs es
refs (ESeq its _)  = concat [ refs e | Item _ e <- its ]
refs _             = []

-- | Does the expression contain a repetition whose operand is nullable?
hasNullableRep :: (String -> Ty) -> PExpr -> Bool
hasNullableRep look = go
  where
    go (EStar e)     = (not (spannable e) && tyNullable (exprTy look e)) || go e
    go (EPlus e)     = (not (spannable e) && tyNullable (exprTy look e)) || go e
    go (EAnd e)      = go e
    go (ENot e)      = go e
    go (EOpt e)      = go e
    go (EIndent _ e) = go e
    go (EPos _ e)    = go e
    go (EAlign e)    = go e
    go (EChoice es)  = any go es
    go (ESeq its _)  = or [ go e | Item _ e <- its ]
    go _             = False

--------------------------------------------------------------------------------
-- Rendering
--------------------------------------------------------------------------------

-- | Render an environment as the source of a 'PEG.Type.Env' type, given a
-- result type for each rule.
--
-- Used to tell a user what to write while the environment still has to be
-- written by hand.  An entry no longer carries a FIRST set, so the analysis
-- contributes only the rule names and their order; what used to be the
-- interesting half of this function is now something no one has to write
-- down.
renderEnv :: (String -> String) -> RuleEnv -> String
renderEnv resultOf entries = unlines (zipWith line prefixes entries) ++ "   ]"
  where
    prefixes = "  '[ " : repeat "   , "
    line p (n, _) =
      p ++ "'(" ++ show n ++ ", 'EnvEntry " ++ resultOf n ++ ")"

-- | A one-paragraph explanation of a 'Diagnostic', in the shape the
-- quasi-quoter reports it.
renderDiagnostic :: Diagnostic -> String
renderDiagnostic (LeftRecursive n path) =
  "left-recursive non-terminal: " ++ n
    ++ (if null path then "" else "\n  the cycle is " ++ arrows path)
    ++ "\n  a PEG cannot backtrack into a committed choice, so this rule\n"
    ++ "  would not consume input before calling itself"
  where arrows = foldr1 (\a b -> a ++ " -> " ++ b)
renderDiagnostic (NullableStar n) =
  "in rule " ++ n ++ ": a repetition whose operand can match the empty\n"
    ++ "  string; it would not consume input and the parse would not terminate"
renderDiagnostic (UndefinedNT n defined) =
  "undefined non-terminal: " ++ n
    ++ "\n  the grammar defines " ++ unwords defined
renderDiagnostic (DuplicateRule n) =
  "the rule " ++ n ++ " is defined twice"