packages feed

ychr-0.1.0.0: src/YCHR/DSL.hs

{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wno-orphans #-}

-- | A Haskell-embedded DSL for building CHR programs without going through
-- @.chr@ source files. Use it when @ychr@ is embedded as a library: build
-- one or more 'Module' values, then compile and run them with 'runDSL'.
--
-- = Worked example: less-than-or-equal
--
-- > {-# LANGUAGE OverloadedStrings #-}
-- > import YCHR.DSL
-- >
-- > orderModule :: Module
-- > orderModule =
-- >   module' "Order"
-- >     `declaring` ["leq" // 2]
-- >     `defining`
-- >       [ "refl" @: [term "leq" [var "X", var "X"]] <=> [bool True]
-- >       , "antisymm"
-- >           @: [term "leq" [var "X", var "Y"]] \\\\ [term "leq" [var "Y", var "X"]]
-- >             <=> [var "X" .=. var "Y"]
-- >       , "trans"
-- >           @: [term "leq" [var "X", var "Y"], term "leq" [var "Y", var "Z"]]
-- >             ==> [term "leq" [var "X", var "Z"]]
-- >       ]
-- >
-- > main :: IO ()
-- > main = do
-- >   bindings <- runDSL [orderModule] (term "leq" [var "A", var "B"])
-- >   print bindings
--
-- The DSL is a thin layer over 'YCHR.Internal.Parsed.Module': every combinator is a
-- pure function that builds AST nodes the parser would otherwise produce
-- from @.chr@ text. It does not validate the program — undeclared
-- constraints, ill-typed bodies, etc. are caught downstream by the
-- compilation pipeline ('compileParsedModules') exactly as for parsed
-- input.
--
-- = Two things to know before you start
--
-- __Constraint positions are partial.__ '<=>', '==>', @\\\\@ and 'runDSL'
-- expect each rule-head and goal 'Term' to be a compound or an atom — the
-- shapes 'term', 'qterm' and 'atom' build. Handing them a bare 'var' or
-- 'int' throws an 'error' rather than returning a diagnostic, because
-- there is no failure channel in a pure combinator. (The equivalent
-- mistake through "YCHR.Convert" is reported as a @MalformedGoal@
-- 'YCHR.Convert.ConvertError' instead.) Malformed /programs/ are still
-- reported properly by the pipeline; it is only malformed /Haskell/ that
-- fails this way.
--
-- __This module defines an orphan @instance Num Term@__ so that numeric
-- literals and '+' \/ '-' \/ '*' work in term position. It changes what
-- arithmetic on 'Term' means anywhere both this module and 'Term' are in
-- scope: @1 + 2 :: Term@ builds the /symbolic/ compound @+(1, 2)@, it does
-- not evaluate to @3@. That is the intent — a DSL body is CHR source, not
-- Haskell arithmetic — but it is worth knowing before importing this
-- module alongside "YCHR".
--
-- Negative literals work: @-1 :: Term@ is @'IntTerm' (-1)@, because GHC
-- routes them through 'negate', which folds them into the literal. But
-- 'negate' on a /non-literal/, 'abs', and 'signum' build @-(x)@,
-- @abs(x)@, and @sign(x)@ compounds, and the prelude declares none of
-- those — so they only work if your own module declares @-\/1@,
-- @abs\/1@, or @sign\/1@. Prefer '.-' and friends. There is also no
-- 'Fractional' instance, so a fractional literal needs the explicit
-- 'float' constructor.
module YCHR.DSL
  ( -- * Modules
    Module,
    module',
    importing,
    library,
    declaring,
    defining,
    withEquations,
    withExtensions,
    withClassExtensions,
    chrType,
    exporting,

    -- * Declarations
    Declaration,
    (//),
    function,
    openFunction,
    class_,
    openClass,
    extendClassType,
    typeExport,
    typeExportWith,
    op,
    OpType (..),

    -- * Type definitions
    TypeDefinition,
    TypeKind (..),
    DataConstructor,
    TypeExpr (..),
    tyDef,
    tyOpaque,
    dataCtor,

    -- * Rules
    Rule,
    Simpa,
    IsRuleHead,
    (@:),
    (<=>),
    (==>),
    (\\),
    (|-),

    -- * Terms
    Term,
    term,
    qterm,
    quote,
    var,
    atom,
    int,
    float,
    bool,
    text,
    wildcard,

    -- * Goal sugar
    (.=.),
    is,
    hostCall,

    -- * Function equations and lambdas
    FunctionEquation,
    equation,
    equationSeq,
    lambda,
    funRef,
    call_,

    -- * Numeric and comparison sugar

    --
    -- $numericSugar
    (.+),
    (.-),
    (.*),
    (./),
    (.<),
    (.<=),
    (.>),
    (.>=),
    (.==),

    -- * Compiling and running
    runDSL,
    runDSLWithHostCallRegistry,
    HostCallRegistry,
  )
where

import Control.Exception (throwIO)
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict (Map)
import Data.Text (Text)
import YCHR.Convert (quote)
import YCHR.Internal.Meta (metaHostCallRegistry)
import YCHR.Internal.Parsed
import YCHR.Internal.Runtime.Registry (HostCallRegistry, baseHostCallRegistry)
import YCHR.Run
  ( CompiledProgram,
    Warning,
    compileParsedModules,
    runProgramWithGoalDSL,
  )

-- ---------------------------------------------------------------------------
-- Modules
-- ---------------------------------------------------------------------------

-- | An empty module with the given name.
module' :: Text -> Module
module' name =
  Module
    { name = name,
      nameLoc = dummyLoc,
      imports = [],
      decls = [],
      extensionTypes = [],
      typeDecls = [],
      rules = [],
      equations = [],
      extensions = [],
      classExtensions = [],
      exports = Nothing
    }

-- | Append plain @use_module(M)@ imports to a module.
--
-- > module' "Logic" `importing` ["Order", "Util"]
--
-- Note: this combinator appends to any imports already present.
importing :: Module -> [Text] -> Module
importing m imps =
  m {imports = m.imports ++ map (noAnnP . (`ModuleImport` Nothing)) imps}

-- | Append a single @use_module(library(L))@ import (a stdlib library or
-- bundled library, as opposed to a user-written sibling module).
--
-- > module' "MyApp" `library` "lists" `library` "math"
library :: Module -> Text -> Module
library m libName =
  m {imports = m.imports ++ [noAnnP (LibraryImport libName Nothing)]}

-- | Append constraint, function, operator, or type-export declarations.
--
-- > module' "M" `declaring` ["leq" // 2, function "factorial" 1]
declaring :: Module -> [Declaration] -> Module
declaring m ds = m {decls = m.decls ++ map noAnn ds}

-- | Append rules to a module.
defining :: Module -> [Rule] -> Module
defining m rls = m {rules = m.rules ++ rls}

-- | Append function-definition equations to a module.
--
-- > module' "M"
-- >   `declaring` [function "factorial" 1]
-- >   `withEquations`
-- >     [ equation "factorial" [int 0]    [] (int 1)
-- >     , equation "factorial" [var "N"]  [var "N" .> int 0]
-- >         (var "N" .* call_ (funRef "factorial" 1) [var "N" .- int 1])
-- >     ]
withEquations :: Module -> [FunctionEquation] -> Module
withEquations m eqs = m {equations = m.equations ++ map noAnnP eqs}

-- | Append function-equation /extensions/ to a module. Mirrors
-- @:- extend_function name(args) -> body@ directives in source
-- form: the equations contribute to an open function declared in
-- another module (resolved through this module's imports).
withExtensions :: Module -> [FunctionEquation] -> Module
withExtensions m eqs = m {extensions = m.extensions ++ map noAnnP eqs}

-- | Append class-equation /extensions/ to a module. Mirrors
-- @:- extend_class name(args) -> body@ directives in source form:
-- the equations contribute to an open class declared in another
-- module (resolved through this module's imports).
withClassExtensions :: Module -> [FunctionEquation] -> Module
withClassExtensions m eqs =
  m {classExtensions = m.classExtensions ++ map noAnnP eqs}

-- | Append a CHR-type definition (@:- chr_type ...@).
chrType :: Module -> TypeDefinition -> Module
chrType m ty = m {typeDecls = m.typeDecls ++ [noAnn ty]}

-- | Replace the export list of a module.
--
-- An absent export list (the default after 'module'') means the
-- module exports everything by name. Calling 'exporting' switches the
-- module to an explicit export list. Subsequent calls /append/ to that
-- list rather than replacing it.
exporting :: Module -> [Declaration] -> Module
exporting m ds = case m.exports of
  Nothing -> m {exports = Just (noAnnP ds)}
  Just (AnnP existing loc origin) ->
    m {exports = Just (AnnP (existing ++ ds) loc origin)}

-- ---------------------------------------------------------------------------
-- Declarations
-- ---------------------------------------------------------------------------

-- | Constraint declaration. Mirrors @:- chr_constraint name/arity@.
--
-- > "leq" // 2
(//) :: Text -> Int -> Declaration
(//) name arity = ConstraintDecl name arity Nothing Nothing

-- | Function declaration: @:- function name/arity@.
function :: Text -> Int -> Declaration
function name arity =
  FunctionDecl
    { name = name,
      arity = arity,
      argTypes = Nothing,
      returnType = Nothing,
      isOpen = False,
      kind = DKFunction,
      requiring = Nothing
    }

-- | Open-function declaration: @:- open_function name/arity@. Open functions
-- can be extended with new equations from other modules.
openFunction :: Text -> Int -> Declaration
openFunction name arity =
  FunctionDecl
    { name = name,
      arity = arity,
      argTypes = Nothing,
      returnType = Nothing,
      isOpen = True,
      kind = DKFunction,
      requiring = Nothing
    }

-- | Class declaration: @:- class name/arity@. A class enables
-- multi-signature overloading.
class_ :: Text -> Int -> Declaration
class_ name arity =
  FunctionDecl
    { name = name,
      arity = arity,
      argTypes = Nothing,
      returnType = Nothing,
      isOpen = False,
      kind = DKClass,
      requiring = Nothing
    }

-- | Open-class declaration: @:- open_class name/arity@. Open classes
-- can be extended with new signatures and equations from other modules.
openClass :: Text -> Int -> Declaration
openClass name arity =
  FunctionDecl
    { name = name,
      arity = arity,
      argTypes = Nothing,
      returnType = Nothing,
      isOpen = True,
      kind = DKClass,
      requiring = Nothing
    }

-- | Extension type declaration: @:- extend_class_type (name(args) -> ret)@.
-- Adds an overloaded signature to an open class declared in another
-- module. The renamer resolves the class name through the importing
-- module's imports.
extendClassType :: Text -> [TypeExpr] -> TypeExpr -> Declaration
extendClassType name argTypes returnType =
  ExtendClassTypeDecl
    { name = name,
      arity = length argTypes,
      argTypes = Just argTypes,
      returnType = Just returnType,
      target = Nothing
    }

-- | Type-export declaration: @:- module(m, [type(name/arity)])@. Exports
-- the type and all of its data constructors.
typeExport :: Text -> Int -> Declaration
typeExport n a = TypeExportDecl n a Nothing

-- | Type-export declaration with a constructor allowlist:
-- @:- module(m, [type(name/arity, [c1, c2])])@. Exports the type and only
-- the listed constructors. Pass @[]@ to export the type without any
-- constructors.
typeExportWith :: Text -> Int -> [Text] -> Declaration
typeExportWith n a cs = TypeExportDecl n a (Just cs)

-- | Operator declaration. Mirrors @:- op(Fixity, OpType, Name)@.
--
-- > op 700 Xfx "is"
op :: Int -> OpType -> Text -> Declaration
op fixity opType opName = OperatorDecl OpDecl {fixity, opType, opName}

-- ---------------------------------------------------------------------------
-- Type definitions
-- ---------------------------------------------------------------------------

-- | Build a type definition: name, type variables, constructors.
--
-- > tyDef "color" [] [dataCtor "red" [], dataCtor "green" [], dataCtor "blue" []]
tyDef :: Text -> [Text] -> [DataConstructor] -> TypeDefinition
tyDef n vs cs =
  TypeDefinition
    { name = Unqualified n,
      typeVars = vs,
      kind = Algebraic cs,
      loc = dummyLoc
    }

-- | Build an opaque type definition: a nominal type name with zero or
-- more type parameters and no data constructors.
--
-- > tyOpaque "set" ["X"]
tyOpaque :: Text -> [Text] -> TypeDefinition
tyOpaque n vs =
  TypeDefinition
    { name = Unqualified n,
      typeVars = vs,
      kind = Opaque,
      loc = dummyLoc
    }

-- | Build a data constructor: name and argument types.
--
-- > dataCtor "cons" [TypeVar "a", TypeCon (Unqualified "list") [TypeVar "a"]]
dataCtor :: Text -> [TypeExpr] -> DataConstructor
dataCtor n args = DataConstructor {conName = Unqualified n, conArgs = args}

-- ---------------------------------------------------------------------------
-- Rules
-- ---------------------------------------------------------------------------

-- | A simpagation kept/removed pair, awaiting a body via '<=>'.
--
-- Produced by '\\'; consumed by '<=>' through 'IsRuleHead'.
data Simpa = Simpa
  { kept :: [Term],
    removed :: [Term]
  }

-- | The left-hand side of a '<=>': either a list of terms (simplification)
-- or a 'Simpa' (simpagation).
class IsRuleHead h where
  toRuleHead :: h -> Head

instance IsRuleHead [Term] where
  toRuleHead = Simplification . map termToConstraint

instance IsRuleHead Simpa where
  toRuleHead s =
    Simpagation (map termToConstraint s.kept) (map termToConstraint s.removed)

-- | Convert a 'Term' built by 'term' / 'qterm' / 'atom' into a head
-- 'Constraint' occurrence. Compound and atom terms map to a constraint;
-- anything else (a bare variable, integer, etc.) is rejected with the
-- same shape of error the parser raises for a 'MalformedConstraint'.
termToConstraint :: Term -> Constraint
termToConstraint (CompoundTerm n args) = Constraint n args
termToConstraint t =
  errorWithoutStackTrace $
    "YCHR.DSL: term is not a valid constraint occurrence: " <> show t

-- | Attach a name to a rule.
--
-- > "trans" @: [term "leq" [var "X", var "Y"], ...] ==> [...]
(@:) :: Text -> Rule -> Rule
n @: (Rule _ h g b) = Rule (Just (noAnn n)) h g b

-- | Simplification rule (@head \<=\> body@) or simpagation rule
-- (@kept \\ removed \<=\> body@), depending on the LHS.
--
-- > [term "p" []]                <=> [bool True]   -- simplification
-- > [term "k" []] \\ [term "r" []] <=> [bool True] -- simpagation
(<=>) :: (IsRuleHead h) => h -> [Term] -> Rule
h <=> body =
  Rule Nothing (noAnnP (toRuleHead h)) (noAnnP []) (noAnnP body)

-- | Propagation rule (@head ==\> body@).
--
-- > [term "leq" [var "X", var "Y"], term "leq" [var "Y", var "Z"]]
-- >   ==> [term "leq" [var "X", var "Z"]]
(==>) :: [Term] -> [Term] -> Rule
lhs ==> rhs =
  Rule
    Nothing
    (noAnnP (Propagation (map termToConstraint lhs)))
    (noAnnP [])
    (noAnnP rhs)

-- | Simpagation split: @kept \\ removed@. Followed by '<=>' body.
(\\) :: [Term] -> [Term] -> Simpa
k \\ r = Simpa {kept = k, removed = r}

-- | Attach a guard to a rule.
--
-- > [term "p" [var "X"]] <=> [bool True] |- [var "X" .> int 0]
(|-) :: Rule -> [Term] -> Rule
r |- g = let Rule n h _ b = r in Rule n h (noAnnP g) b

infix 4 .=.

infix 4 .==, .<, .<=, .>, .>=

infixl 6 .+, .-

infixl 7 .*, ./

infixr 3 \\

infixr 3 `is`

infix 2 <=>, ==>

infixl 1 |-

infixr 0 @:

-- ---------------------------------------------------------------------------
-- Terms
-- ---------------------------------------------------------------------------

-- | Compound term with an unqualified functor.
--
-- The same constructor serves for constraint occurrences (in rule heads
-- or as body goals), function calls, and data-constructor terms — the
-- surface language draws no distinction between them, so the DSL doesn't
-- either. Classification happens later, in the renamer and desugarer.
--
-- > term "leq" [var "X", var "Y"]
term :: Text -> [Term] -> Term
term n args = CompoundTerm (Unqualified n) args

-- | Compound term with a fully-qualified functor.
--
-- > qterm "Order" "leq" [var "X", var "Y"]
qterm :: Text -> Text -> [Term] -> Term
qterm m n args = CompoundTerm (Qualified m n) args

-- | Variable term: 'var' \"X\" produces the same AST as the surface @X@.
var :: Text -> Term
var = VarTerm

-- | Atom term. Surface atoms are represented as 0-arity unqualified
-- compounds in the AST; the runtime collapses them to 'VAtom'.
atom :: Text -> Term
atom s = CompoundTerm (Unqualified s) []

-- | Integer literal term (arbitrary precision).
int :: Integer -> Term
int = IntTerm

-- | Floating-point literal term.
float :: Double -> Term
float = FloatTerm

-- | Boolean literal — produces the canonical @true@ / @false@ atom term
-- the renamer expects. Equivalent to @atom \"true\"@ / @atom \"false\"@.
bool :: Bool -> Term
bool True = CompoundTerm (Unqualified "true") []
bool False = CompoundTerm (Unqualified "false") []

-- | Text/string literal term.
text :: Text -> Term
text = TextTerm

-- | Wildcard pattern: matches anything without binding.
wildcard :: Term
wildcard = Wildcard

-- ---------------------------------------------------------------------------
-- Goal sugar
-- ---------------------------------------------------------------------------

-- | Structural unification goal, written @=@ in the surface language.
(.=.) :: Term -> Term -> Term
l .=. r = CompoundTerm (Unqualified "=") [l, r]

-- | Arithmetic-evaluation goal: @V is Expr@.
--
-- > var "X" `is` (int 1 .+ var "Y")
is :: Term -> Term -> Term
is v e = CompoundTerm (Unqualified "is") [v, e]

-- | Host-language call, written @host:f(args)@ in the surface language.
--
-- > hostCall "print" [var "X"]
hostCall :: Text -> [Term] -> Term
hostCall f args = CompoundTerm (Qualified "host" f) args

-- ---------------------------------------------------------------------------
-- Function equations and lambdas
-- ---------------------------------------------------------------------------

-- | A single function-defining equation with a single-expression body.
-- Use 'equationSeq' for a sequenced body (@A1, A2, ..., Return@).
--
-- > equation "factorial" [int 0]   [] (int 1)
-- > equation "factorial" [var "N"] [var "N" .> int 0]
-- >   (var "N" .* call_ (funRef "factorial" 1) [var "N" .- int 1])
equation :: Text -> [Term] -> [Term] -> Term -> FunctionEquation
equation n args guard rhs = equationSeq n args guard (NE.singleton rhs)

-- | A function-defining equation whose body is a non-empty sequence of
-- terms. The last term is the return expression; earlier terms must be
-- either an @is@ binding or an IO action (host call / discardable function
-- call). Validated in the desugarer.
equationSeq ::
  Text -> [Term] -> [Term] -> NE.NonEmpty Term -> FunctionEquation
equationSeq n args guard rhs =
  FunctionEquation
    { funName = Unqualified n,
      args = args,
      guard = noAnnP guard,
      rhs = noAnnP rhs
    }

-- | An anonymous function (lambda) term. Mirrors @fun(args) -> body end@.
-- Internally a lambda is the compound @'->'(fun(args), body)@; lambda lifting
-- happens during desugaring.
--
-- > lambda [var "X"] (var "X" .+ int 1)
lambda :: [Term] -> Term -> Term
lambda args body =
  CompoundTerm
    (Unqualified "->")
    [CompoundTerm (Unqualified "fun") args, body]

-- | Reference a named function as a first-class value: @fun name/arity@.
--
-- The surface syntax @fun foo/2@ produces the AST below, callable via 'call_'.
funRef :: Text -> Int -> Term
funRef n arity =
  CompoundTerm
    (Unqualified "fun")
    [ CompoundTerm
        (Unqualified "/")
        [CompoundTerm (Unqualified n) [], IntTerm (fromIntegral arity)]
    ]

-- | Call a first-class function value (a 'lambda' or 'funRef') with the
-- given arguments. Mirrors the surface @'$call'(F, A1, A2, ...)@.
call_ :: Term -> [Term] -> Term
call_ f args = CompoundTerm (Unqualified "$call") (f : args)

-- ---------------------------------------------------------------------------
-- Numeric and comparison sugar
-- ---------------------------------------------------------------------------

-- $numericSugar
--
-- The 'Term' type is an instance of 'Num' so integer literals can be
-- written without 'int' and the standard arithmetic operators
-- (@+@, @-@, @*@, 'negate') compile to the corresponding compound terms
-- the surface language recognises.
--
-- > var "X" `is` (1 + 2 * var "Y")
--
-- For users who prefer to keep the AST literal-explicit, the prefixed
-- operators ('.+', '.-', '.*', './') do the same job without the
-- 'Num' machinery, and the comparison operators ('.<', '.<=', '.>',
-- '.>=', '.==') build comparison goals usable in guards.

instance Num Term where
  fromInteger n = IntTerm n
  l + r = CompoundTerm (Unqualified "+") [l, r]
  l - r = CompoundTerm (Unqualified "-") [l, r]
  l * r = CompoundTerm (Unqualified "*") [l, r]

  -- GHC desugars a negative literal through 'negate', so @-1 :: Term@
  -- arrives here as @negate (IntTerm 1)@. Fold it into the literal: the
  -- prelude has no unary minus, so the @-(1)@ compound these used to
  -- build reached the runtime as a one-argument call to @-@ and died with
  -- an arity error at tell time.
  negate (IntTerm n) = IntTerm (negate n)
  negate (FloatTerm x) = FloatTerm (negate x)
  -- Non-literals keep the compound form. The functor is /unqualified/, so
  -- it resolves against the program's own functions — a module that
  -- declares @-\/1@, @abs\/1@, or @sign\/1@ gets a working call. Nothing
  -- in the prelude provides them, so without such a declaration these
  -- fail at tell time; prefer '.-' and friends, which are explicit.
  negate x = CompoundTerm (Unqualified "-") [x]
  abs x = CompoundTerm (Unqualified "abs") [x]
  signum x = CompoundTerm (Unqualified "sign") [x]

(.+), (.-), (.*), (./) :: Term -> Term -> Term
l .+ r = CompoundTerm (Unqualified "+") [l, r]
l .- r = CompoundTerm (Unqualified "-") [l, r]
l .* r = CompoundTerm (Unqualified "*") [l, r]
l ./ r = CompoundTerm (Unqualified "/") [l, r]

(.<), (.<=), (.>), (.>=), (.==) :: Term -> Term -> Term
l .< r = CompoundTerm (Unqualified "<") [l, r]
l .<= r = CompoundTerm (Unqualified "=<") [l, r]
l .> r = CompoundTerm (Unqualified ">") [l, r]
l .>= r = CompoundTerm (Unqualified ">=") [l, r]
l .== r = CompoundTerm (Unqualified "==") [l, r]

-- ---------------------------------------------------------------------------
-- Compiling and running
-- ---------------------------------------------------------------------------

-- | Compile DSL-built modules and run a single goal against them, using
-- the same default host-call registry as the @ychr@ CLI
-- (@baseHostCallRegistry <> metaHostCallRegistry@). Includes the stdlib.
--
-- The goal is built with 'term' / 'qterm' just like rule heads. Returns
-- the final unification map for the variables mentioned in the goal.
-- Compilation or runtime errors are raised as exceptions.
--
-- > main = do
-- >   bindings <- runDSL [orderModule] (term "leq" [var "A", var "B"])
-- >   print bindings
runDSL :: [Module] -> Term -> IO (Map Text Term)
runDSL = runDSLWithHostCallRegistry (baseHostCallRegistry <> metaHostCallRegistry)

-- | Like 'runDSL', but takes an explicit host-call registry. Use this when
-- the program calls custom @host:_@ functions registered by the embedder.
runDSLWithHostCallRegistry ::
  HostCallRegistry -> [Module] -> Term -> IO (Map Text Term)
runDSLWithHostCallRegistry hostCalls modules goal = do
  cp <- compileOrThrow modules
  runProgramWithGoalDSL cp hostCalls (termToConstraint goal)

compileOrThrow :: [Module] -> IO CompiledProgram
compileOrThrow modules = case compileParsedModules True modules of
  Left err -> throwIO err
  Right (cp, _warnings :: [Warning]) -> pure cp