packages feed

typed-peg-0.2.0.0: src/PEG/TyLevel.hs

{-# LANGUAGE DataKinds            #-}
{-# LANGUAGE PolyKinds            #-}
{-# LANGUAGE TypeFamilies         #-}
{-# LANGUAGE TypeOperators        #-}
{-# LANGUAGE UndecidableInstances #-}

-- | Type-level utilities: boolean logic, symbol equality, set operations,
-- and environment lookup.
--
-- These type families are used internally to compute the FIRST sets and
-- nullability of PEG expressions at the kind level, enabling the
-- 'PEG.Grammar.Acyclic' constraint to be resolved at compile time.
--
-- == Representation of FIRST sets
--
-- A FIRST set is a @['Symbol']@ kept /strictly sorted/ by 'CmpSymbol'.
-- Sortedness is the whole point: it makes the representation canonical (one
-- set, one type), so 'Union' is a single-pass merge and 'Elem' can stop at
-- the first symbol greater than the one it is looking for.
--
-- == Why the families are written this way
--
-- Every clause below mentions each of its arguments — and in particular each
-- recursive call — /exactly once/ on the right-hand side.  This is not a
-- style choice.  A clause such as
--
-- @
-- ConsIfAbsent x xs = If (Elem x xs) xs (x ': xs)   -- DON'T
-- @
--
-- mentions @xs@ three times, and @xs@ is normally an unreduced application
-- of 'Union'.  GHC therefore has three copies of the pending computation to
-- reduce, each of which triples again one level down: a union of two sets of
-- size @n@ costs @3^n@ reductions rather than @n@.  Dispatching on an
-- already-computed 'Ordering' in a separate family keeps every right-hand
-- side linear in its arguments.
module PEG.TyLevel
  ( If
  , And
  , Or
  , SymEq
  , Elem
  , Union
  , ConsIfAbsent
  , Lookup
  , Names
  ) where

import GHC.TypeLits (CmpSymbol, ErrorMessage (..), Symbol, TypeError)

import PEG.Type

type family If (c :: Bool) (t :: k) (e :: k) :: k where
  If 'True  t _ = t
  If 'False _ e = e

type family And (a :: Bool) (b :: Bool) :: Bool where
  And 'True  b = b
  And 'False _ = 'False

type family Or (a :: Bool) (b :: Bool) :: Bool where
  Or 'True  _ = 'True
  Or 'False b = b

type family SymEq (a :: Symbol) (b :: Symbol) :: Bool where
  SymEq a b = IsEQ (CmpSymbol a b)

type family IsEQ (o :: Ordering) :: Bool where
  IsEQ 'EQ = 'True
  IsEQ _   = 'False

-- | Is @x@ a member of the sorted set @xs@?
--
-- Stops as soon as it reaches a symbol greater than @x@, so a miss costs
-- half a scan on average rather than a full one.
type family Elem (x :: Symbol) (xs :: [Symbol]) :: Bool where
  Elem _ '[]       = 'False
  Elem x (y ': ys) = ElemGo (CmpSymbol x y) x ys

type family ElemGo (o :: Ordering) (x :: Symbol) (ys :: [Symbol]) :: Bool where
  ElemGo 'EQ _ _  = 'True
  ElemGo 'LT _ _  = 'False
  ElemGo 'GT x ys = Elem x ys

-- | Insert @x@ into the sorted set @xs@, keeping it sorted and duplicate-free.
type family ConsIfAbsent (x :: Symbol) (xs :: [Symbol]) :: [Symbol] where
  ConsIfAbsent x '[]       = '[x]
  ConsIfAbsent x (y ': ys) = InsGo (CmpSymbol x y) x y ys

type family InsGo (o :: Ordering) (x :: Symbol) (y :: Symbol)
                  (ys :: [Symbol]) :: [Symbol] where
  InsGo 'LT x y ys = x ': y ': ys
  InsGo 'EQ _ y ys = y ': ys
  InsGo 'GT x y ys = y ': ConsIfAbsent x ys

-- | Union of two sorted sets: a single merge pass, @O(|xs| + |ys|)@.
--
-- The merge nests one type-family reduction per element of the result, so a
-- FIRST set of more than about a hundred non-terminals runs into GHC's
-- default reduction limit and reports @Reduction stack overflow@.  That is a
-- limit, not a slowdown: @-freduction-depth=0@ lifts it, and a union of two
-- 128-element sets then takes about 0.3 s.
type family Union (xs :: [Symbol]) (ys :: [Symbol]) :: [Symbol] where
  Union '[]       ys        = ys
  Union (x ': xs) '[]       = x ': xs
  Union (x ': xs) (y ': ys) = MergeGo (CmpSymbol x y) x xs y ys

type family MergeGo (o :: Ordering) (x :: Symbol) (xs :: [Symbol])
                    (y :: Symbol) (ys :: [Symbol]) :: [Symbol] where
  MergeGo 'LT x xs y ys = x ': Union xs (y ': ys)
  MergeGo 'EQ x xs _ ys = x ': Union xs ys
  MergeGo 'GT x xs y ys = y ': Union (x ': xs) ys

-- | Look up a non-terminal's entry in the environment.
--
-- This is the hot path: there is one lookup per occurrence of every
-- non-terminal in the grammar, so it is written to do as little as possible
-- per entry scanned.
--
-- Two things matter.  The search proper ('LookupMb') carries only the tail it
-- still has to scan — threading the /whole/ environment through it so the
-- not-found case could name the available non-terminals costs a traversal of
-- that environment at every step, and an environment of @n@ rules is itself
-- @O(n^2)@ type nodes because every rule carries a FIRST set.  The
-- environment is therefore named once, in 'Found', which only reduces after
-- the search has finished.
--
-- And the match is on a /non-linear/ pattern — @s@ appears twice in the
-- second clause — rather than on @CmpSymbol s t@ dispatched through a helper
-- family.  GHC decides the clause by syntactic equality and by apartness for
-- the fall-through, which is one type-family reduction per entry instead of
-- two.  (The trick is @Data.Type.Map@'s, from @type-level-sets@.)  It costs
-- nothing here: unlike 'Elem', this search has no sortedness to exploit, so
-- there was never a third case to short-circuit on.
type family Lookup (s :: Symbol) (env :: Env) :: EnvEntry where
  Lookup s env = Found s env (LookupMb s env)

type family LookupMb (s :: Symbol) (env :: Env) :: Maybe EnvEntry where
  LookupMb _ '[]               = 'Nothing
  LookupMb s ('(s, e) ': rest) = 'Just e
  LookupMb s (_ ': rest)       = LookupMb s rest

type family Found (s :: Symbol) (env :: Env)
                  (r :: Maybe EnvEntry) :: EnvEntry where
  Found _ _ ('Just e) = e
  Found s env 'Nothing =
    TypeError ('Text "Undefined non-terminal: " ':<>: 'ShowType s
         ':$$: 'Text "Available non-terminals: " ':<>: 'ShowType (Names env))

type family Names (env :: Env) :: [Symbol] where
  Names '[]               = '[]
  Names ('(s, _) ': rest) = s ': Names rest