packages feed

ychr-0.1.0.0: src/YCHR/Internal/Repl.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Interactive REPL for compiled CHR programs.
--
-- Owns all REPL UI: outer command loop (one-off queries plus colon
-- commands like @:list_declarations@), live-session loop (persistent
-- constraint store between queries, exit with @:end@), prompts, and
-- the help text. Line input (history, tab completion, EOF handling)
-- is delegated to 'YCHR.Internal.LineInput', whose implementation differs
-- between GHC (haskeline) and MicroHS (bare 'getLine'). Only
-- 'runRepl' is exported; everything else is internal.
module YCHR.Internal.Repl
  ( runRepl,
  )
where

import Control.Exception (SomeException, displayException, fromException, try)
import Control.Monad (unless, when)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Reader (ask, runReaderT)
import Data.List (intercalate, sort, stripPrefix)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import System.Exit (exitFailure)
import System.IO (hPutStr, hPutStrLn, stderr, stdout)
import YCHR.Internal.Collected (CollectedModule (..))
import YCHR.Internal.Compile.Pipeline (CompiledProgram (..), ExportResolution (..))
import YCHR.Internal.Desugared qualified as D
import YCHR.Internal.Display (Display (..), displayMsg)
import YCHR.Internal.LineInput (LineInput (..), LineInputSettings (..), mkLineInput)
import YCHR.Internal.PExpr qualified as P
import YCHR.Internal.Parsed qualified as Parsed
import YCHR.Internal.Parser (opTableEntries, parseTermWith)
import YCHR.Internal.Pretty
  ( DeclKind (..),
    prettyConstraintDecl,
    prettyFunctionDecl,
    prettyQualifiedName,
    prettyQueryResult,
    prettyTypeDecl,
    renderAtom,
  )
import YCHR.Internal.Runtime.Interpreter (HostCallRegistry)
import YCHR.Internal.Runtime.Monad (Chr)
import YCHR.Internal.Runtime.Session
  ( toSessionInput,
    withCHR,
    withCHRExtra,
    withCHRExtraTraced,
    withTraceHandler,
  )
import YCHR.Internal.Runtime.Trace (defaultTraceHandler)
import YCHR.Internal.TypeCheck (typeCheckProgram)
import YCHR.Internal.Types
  ( BoundSig,
    DataConstructor (..),
    Name (..),
    QualifiedName (..),
    Term (..),
    TypeDefinition (..),
    TypeExpr,
    typeConstructors,
  )
import YCHR.Internal.Types qualified as Types
import YCHR.Run
  ( Error (..),
    PreparedQuery (..),
    Warning,
    compileFiles,
    executePreparedQuery,
    prepareQuery,
  )

-- ---------------------------------------------------------------------------
-- Entry point
-- ---------------------------------------------------------------------------

-- | Run the interactive REPL on the given files. Compiles the inputs
-- (or an empty program if @files@ is empty), prints any warnings and
-- type errors to stderr, then enters the outer command loop. The
-- session continues until the user types @:quit@ or hits EOF. When
-- @werror@ is set, warnings during the initial load abort startup;
-- during @:recompile@ they keep the previous program loaded.
runRepl :: HostCallRegistry -> Bool -> Bool -> [FilePath] -> IO ()
runRepl hostCalls quietMode werror files = do
  result <- compileFiles True files
  case result of
    Left err -> do
      putStr (displayMsg err)
      exitFailure
    Right (prog, warnings) -> do
      let warnsFatal = werror && not (null warnings)
      when (warnsFatal || not quietMode) (printWarnings warnings)
      when warnsFatal exitFailure
      unless quietMode (printTypeErrors prog)
      let exported = exportedNames prog
      outerInput <-
        mkLineInput
          LineInputSettings
            { historyFile = Just "ychr/history",
              completionCandidates = commandNames ++ exported
            }
      liveInput <-
        mkLineInput
          LineInputSettings
            { historyFile = Just "ychr/history",
              completionCandidates = ":end" : exported
            }
      outerLoop hostCalls quietMode werror files outerInput liveInput prog

-- ---------------------------------------------------------------------------
-- Outer REPL loop
-- ---------------------------------------------------------------------------

outerLoop ::
  HostCallRegistry ->
  Bool ->
  Bool ->
  [FilePath] ->
  LineInput ->
  LineInput ->
  CompiledProgram ->
  IO ()
outerLoop hostCalls quietMode werror files outerInput liveInput = go
  where
    prompt = if quietMode then "" else "ychr> "
    go prog = do
      minput <- outerInput.readLine prompt
      case minput of
        Nothing -> pure ()
        Just input -> dispatch prog input
    dispatch prog input = case input of
      ":quit" -> pure ()
      ":q" -> pure ()
      ":help" -> showHelp *> go prog
      ":h" -> showHelp *> go prog
      ":recompile" -> recompile prog
      ":r" -> recompile prog
      ":list_files" -> showFiles files *> go prog
      ":list_modules" -> showModules prog *> go prog
      ":list_declarations" -> showDeclarations prog *> go prog
      ":list_operators" -> showOperators prog *> go prog
      ":info" -> showInfoUsage *> go prog
      ":i" -> showInfoUsage *> go prog
      ":trace" -> showTraceUsage *> go prog
      ":begin" -> do
        runLiveSession hostCalls liveInput quietMode werror prog
        go prog
      "" -> go prog
      line
        | Just rest <- stripPrefix ":info " line -> showInfo prog rest *> go prog
        | Just rest <- stripPrefix ":i " line -> showInfo prog rest *> go prog
        | Just rest <- stripPrefix ":trace " line ->
            runTracedQuery hostCalls werror prog rest *> go prog
        | otherwise -> runOuterQuery hostCalls werror prog line *> go prog
    recompile prog = do
      result <- compileFiles True files
      case result of
        Left err -> putStr (displayMsg err) *> go prog
        Right (prog', warnings)
          | werror && not (null warnings) -> do
              printWarnings warnings
              -- Surface type errors of the rejected program too, so the
              -- user sees the full diagnostic picture before deciding
              -- what to fix; the previous program stays loaded.
              printTypeErrors prog'
              go prog
          | otherwise -> do
              printWarnings warnings
              printTypeErrors prog'
              go prog'

-- | Run a one-off query in the outer REPL: parse, typecheck, execute
-- in a fresh CHR session. Distinct exception classes are surfaced
-- differently — 'Error' values via 'displayMsg', everything else as
-- @"Error: " ++ displayException@. Under @--Werror@, query-rename
-- warnings short-circuit before execution; the constraint store is
-- untouched (one-shot queries get a fresh store anyway).
runOuterQuery :: HostCallRegistry -> Bool -> CompiledProgram -> String -> IO ()
runOuterQuery hostCalls werror prog line = do
  prepResult <-
    try @SomeException $
      prepareQuery prog (T.pack line)
  case prepResult of
    Left exc -> reportException exc
    Right (prep, ws) -> do
      printWarnings ws
      unless (werror && not (null ws)) $ do
        execResult <-
          try @SomeException $
            withCHRExtra (toSessionInput prog) hostCalls prep.extraProcs $
              executePreparedQuery prep.liftedGoals
        case execResult of
          Left exc -> reportException exc
          Right bindings -> putStr (prettyQueryResult bindings)
  where
    reportException exc = case fromException exc of
      Just err -> putStr (displayMsg (err :: Error))
      Nothing -> putStrLn ("Error: " ++ displayException exc)

-- | Run a one-off query in the outer REPL with refined-operational-
-- semantics tracing enabled. Output is the trace stream only —
-- bindings are intentionally not printed, matching the documented
-- @:trace@ behaviour. To see bindings, re-run the goal without
-- @:trace@.
runTracedQuery :: HostCallRegistry -> Bool -> CompiledProgram -> String -> IO ()
runTracedQuery hostCalls werror prog line = do
  prepResult <-
    try @SomeException $
      prepareQuery prog (T.pack line)
  case prepResult of
    Left exc -> reportException exc
    Right (prep, ws) -> do
      printWarnings ws
      unless (werror && not (null ws)) $ do
        execResult <-
          try @SomeException $
            withCHRExtraTraced
              (toSessionInput prog)
              hostCalls
              prep.extraProcs
              (defaultTraceHandler stdout)
              (executePreparedQuery prep.liftedGoals)
        case execResult of
          Left exc -> reportException exc
          Right _ -> pure ()
  where
    reportException exc = case fromException exc of
      Just err -> putStr (displayMsg (err :: Error))
      Nothing -> putStrLn ("Error: " ++ displayException exc)

showTraceUsage :: IO ()
showTraceUsage =
  putStrLn ":trace GOAL  -- run GOAL with refined-operational-semantics tracing"

-- ---------------------------------------------------------------------------
-- Live REPL session
-- ---------------------------------------------------------------------------

-- | Run an interactive live session. A single 'withCHR' call wraps
-- the whole session, so the constraint store, propagation history,
-- and reactivation queue persist across queries entered at the
-- @ychr live>@ prompt. The session ends when the user types @:end@,
-- hits EOF, or a runtime error aborts execution.
runLiveSession ::
  HostCallRegistry ->
  LineInput ->
  Bool ->
  Bool ->
  CompiledProgram ->
  IO ()
runLiveSession hostCalls liveInput quietMode werror cp =
  withCHR (toSessionInput cp) hostCalls liveLoop
  where
    prompt = if quietMode then "" else "ychr live> "
    liveLoop :: Chr ()
    liveLoop = do
      mline <- liftIO (liveInput.readLine prompt)
      case mline of
        Nothing -> pure ()
        Just line -> dispatch line
    dispatch line
      | stripped == ":end" = pure ()
      | T.null stripped = liveLoop
      | Just rest <- T.stripPrefix ":trace " stripped = do
          outcome <-
            withTraceHandler (defaultTraceHandler stdout) $
              handleLiveQuery cp werror rest
          case outcome of
            QueryOk _ -> liveLoop
            QueryRecoverable msg -> do
              liftIO (hPutStr stderr msg)
              liveLoop
            QueryFatal msg -> liftIO $ do
              hPutStr stderr msg
              hPutStrLn stderr "live session aborted due to runtime error."
      | stripped == ":trace" = do
          liftIO showTraceUsage
          liveLoop
      | otherwise = do
          outcome <- handleLiveQuery cp werror (T.pack line)
          case outcome of
            QueryOk bindings -> do
              liftIO (putStr (prettyQueryResult bindings))
              liveLoop
            QueryRecoverable msg -> do
              liftIO (hPutStr stderr msg)
              liveLoop
            QueryFatal msg -> liftIO $ do
              hPutStr stderr msg
              hPutStrLn stderr "live session aborted due to runtime error."
      where
        stripped = T.strip (T.pack line)

-- | Outcome of executing a single live-session query.
data QueryOutcome
  = -- | Query ran to completion. Carries the resulting bindings.
    QueryOk (Map Text Term)
  | -- | A pre-execution problem (parse, type, lambdas-rejected) that
    -- left the constraint store untouched. The session can continue.
    QueryRecoverable String
  | -- | A runtime exception thrown during goal execution. The store
    -- and bindings may be inconsistent; the live session must abort.
    QueryFatal String

-- | Handle one query inside an existing live session: parse / typecheck
-- in 'IO' (catching 'Error'), reject lifted lambdas (live sessions
-- cannot grow the procedure map), then execute and catch any runtime
-- exception as a fatal outcome. Under @--Werror@, query-rename
-- warnings short-circuit before execution: the warnings are printed
-- to stderr and the query is treated as recoverable, leaving the
-- session's constraint store untouched.
handleLiveQuery ::
  CompiledProgram ->
  Bool ->
  Text ->
  Chr QueryOutcome
handleLiveQuery cp werror src = do
  prepResult <- liftIO (try @SomeException (prepareQuery cp src))
  case prepResult of
    Left exc -> pure (classifyAsRecoverable exc)
    Right (prep, ws) -> do
      liftIO (printWarnings ws)
      if werror && not (null ws)
        then pure (QueryRecoverable "")
        else case prep.queryLambdas of
          (lam : _) ->
            let lamEqs = lam.equations :: Parsed.AnnP [D.Equation]
                Parsed.AnnP _ loc origin = lamEqs
             in pure (QueryRecoverable (displayMsg (LambdasInLiveQuery loc origin)))
          [] -> do
            env <- ask
            execResult <-
              liftIO $
                try @SomeException $
                  runReaderT (executePreparedQuery prep.liftedGoals) env
            case execResult of
              Left exc -> pure (QueryFatal (renderFatal exc))
              Right bindings -> pure (QueryOk bindings)
  where
    classifyAsRecoverable exc = case fromException exc :: Maybe Error of
      Just err -> QueryRecoverable (displayMsg err)
      Nothing -> QueryFatal (renderFatal exc)
    -- A fatal exception is most often an 'Error' (e.g. a runtime error
    -- escaping 'executePreparedQuery'); rendering through 'displayMsg'
    -- gives the same nicely-formatted output the outer REPL produces.
    -- Anything else falls back to 'displayException'.
    renderFatal exc = case fromException exc :: Maybe Error of
      Just err -> displayMsg err
      Nothing -> displayException exc ++ "\n"

-- ---------------------------------------------------------------------------
-- Commands
-- ---------------------------------------------------------------------------

-- | A REPL colon-command. The names list is the command plus its
-- aliases; @description@ is the help text. This list is the single
-- source of truth for tab-completion and the @:help@ output.
data Command = Command
  { names :: [String],
    description :: String
  }

commands :: [Command]
commands =
  [ Command [":help", ":h"] "Show this help message",
    Command [":recompile", ":r"] "Recompile the loaded files",
    Command [":list_files"] "List the compiled files",
    Command [":list_modules"] "List the compiled modules",
    Command [":list_declarations"] "List visible declarations",
    Command [":list_operators"] "List defined operators",
    Command [":info", ":i"] "Show information about an identifier",
    Command [":trace"] "Run a goal with refined-operational-semantics tracing",
    Command [":begin"] "Start a live CHR session (end with :end)",
    Command [":quit", ":q"] "Exit the REPL"
  ]

commandNames :: [String]
commandNames = concatMap (\c -> c.names) commands

-- ---------------------------------------------------------------------------
-- Command handlers
-- ---------------------------------------------------------------------------

showHelp :: IO ()
showHelp = do
  putStrLn "Commands:"
  mapM_ (putStrLn . renderCommand) commands
  where
    -- Align descriptions at column 25 to match the original layout.
    renderCommand c =
      let label = intercalate ", " c.names
          padding = replicate (max 1 (23 - length label)) ' '
       in "  " ++ label ++ padding ++ c.description

showFiles :: [FilePath] -> IO ()
showFiles = mapM_ putStrLn

showModules :: CompiledProgram -> IO ()
showModules prog =
  mapM_ (\(CollectedModule {name = n}) -> putStrLn (T.unpack n)) prog.allModules

showDeclarations :: CompiledProgram -> IO ()
showDeclarations prog = mapM_ putStrLn declLines
  where
    declLines = dedup entries
    entries =
      [ (kw, m.name, n, a)
      | m <- prog.allModules,
        Parsed.Ann d _ <- m.decls,
        (kw, n, a) <- case d of
          Parsed.ConstraintDecl {name = n, arity = a} -> [("chr_constraint", n, a)]
          Parsed.FunctionDecl {name = n, arity = a, isOpen = o, kind = k} ->
            let kw = case (o, k) of
                  (False, Parsed.DKFunction) -> "function"
                  (True, Parsed.DKFunction) -> "open_function"
                  (False, Parsed.DKClass) -> "class"
                  (True, Parsed.DKClass) -> "open_class"
             in [(kw, n, a)]
          Parsed.ExtendClassTypeDecl {name = n, arity = a} ->
            [("extend_class_type", n, a)]
          _ -> []
      ]
    dedup = go Set.empty
      where
        go _ [] = []
        go seen (e@(kw, modName, n, a) : rest)
          | Set.member e seen = go seen rest
          | otherwise = renderDecl kw modName n a : go (Set.insert e seen) rest
    renderDecl kw modName name arity =
      ":- "
        ++ kw
        ++ " "
        ++ renderAtom modName
        ++ ":"
        ++ renderAtom name
        ++ "/"
        ++ show arity
        ++ "."

showOperators :: CompiledProgram -> IO ()
showOperators prog = mapM_ (putStrLn . renderOp) entries
  where
    entries = sort [(fix, opTypeStr ty, name) | (fix, ty, name) <- opTableEntries prog.opTable]
    renderOp (fix, ty, name) =
      "op(" ++ show fix ++ ", " ++ ty ++ ", " ++ renderAtom name ++ ")"
    opTypeStr ty = case ty of
      P.Xfx -> "xfx"
      P.Xfy -> "xfy"
      P.Yfx -> "yfx"
      P.Fx -> "fx"
      P.Fy -> "fy"
      P.Xf -> "xf"
      P.Yf -> "yf"

-- ---------------------------------------------------------------------------
-- :info / :i — identifier inspection
-- ---------------------------------------------------------------------------

-- | Hard-coded set of names recognized as base types of the
-- @'$typechecker'@ module. These match the @ty@ ADT constructors
-- treated specially by 'YCHR.Internal.TypeCheck.encodeTypeExpr'.
builtinTypeNames :: [Text]
builtinTypeNames = ["int", "float", "string", "any"]

showInfoUsage :: IO ()
showInfoUsage = putStrLn "usage: :info <identifier>"

-- | Parse the argument to @:info@. Accepts @name@, @name/arity@,
-- @mod:name@, or @mod:name/arity@. Returns 'Nothing' if the parse
-- fails or the resulting term is not a recognized identifier shape,
-- in which case 'showInfo' falls back to "unknown identifier".
parseInfoArg :: CompiledProgram -> Text -> Maybe (Either Text QualifiedName, Maybe Int)
parseInfoArg prog raw = case parseTermWith prog.opTable "<:info>" raw of
  Left _ -> Nothing
  Right t -> termToInfoArg t

termToInfoArg :: Term -> Maybe (Either Text QualifiedName, Maybe Int)
termToInfoArg = \case
  CompoundTerm (Unqualified "/") [headT, IntTerm a] -> do
    (nm, _) <- termToInfoArg headT
    pure (nm, Just (fromInteger a))
  CompoundTerm (Unqualified n) [] ->
    Just (Left n, Nothing)
  CompoundTerm (Qualified m n) [] ->
    Just (Right (QualifiedName m n), Nothing)
  _ -> Nothing

-- | One discovered fact about an identifier. The qualified name is
-- always attached so 'renderInfoEntry' does not need to re-derive it.
data InfoEntry
  = -- | The identifier is a constraint. The @Maybe [TypeExpr]@ is
    -- 'Just' when the constraint is typed (declared via
    -- @:- chr_constraint name(T1, ..., Tn)@), 'Nothing' otherwise.
    IEConstraint QualifiedName Int (Maybe [TypeExpr]) [BoundSig]
  | -- | The identifier is a function or class. The 'DeclKind' comes
    -- from the original 'Parsed.Declaration' in 'allModules'.
    IEFunction D.Function DeclKind
  | -- | The identifier is a user-declared type.
    IEType TypeDefinition
  | -- | The identifier is a data constructor of the carried type.
    IEDataCtor TypeDefinition DataConstructor
  | -- | The identifier is one of the four typechecker-module base
    -- types (@int@, @float@, @string@, @any@).
    IEBuiltinType QualifiedName
  | -- | The identifier exists at this arity but is exported by
    -- multiple modules; the user must qualify to disambiguate. The
    -- list of module names mirrors the @AmbiguousExport@ entry in
    -- 'CompiledProgram.exportMap'.
    IEAmbiguous Text Int [Text]
  deriving (Show)

-- | Look up every fact known about an identifier. Order matters: the
-- caller prints entries in the order returned, so the most specific
-- match is emitted first.
lookupInfo :: CompiledProgram -> Either Text QualifiedName -> Maybe Int -> [InfoEntry]
lookupInfo prog name mArity =
  let arityMatches a = maybe True (== a) mArity
   in case name of
        Right qn -> qualifiedLookup prog qn mArity
        Left n ->
          let builtinEntries =
                [ IEBuiltinType (QualifiedName "$typechecker" n)
                | n `elem` builtinTypeNames,
                  arityMatches 0
                ]
              exportEntries =
                concatMap (resolveExportArity prog n) (matchingArities prog n mArity)
              typeEntries =
                [ IEType td
                | td <- prog.desugaredProgram.typeDefinitions,
                  typeBaseName td == n,
                  arityMatches (length td.typeVars)
                ]
              ctorEntries =
                [ IEDataCtor td c
                | td <- prog.desugaredProgram.typeDefinitions,
                  c <- typeConstructors td,
                  ctorBaseName c == n,
                  arityMatches (length c.conArgs),
                  isCtorExportedByParent prog td c
                ]
           in builtinEntries ++ exportEntries ++ typeEntries ++ ctorEntries

-- | Arities to inspect for an unqualified name. When the user supplied
-- an arity, only that one is returned; otherwise every arity present
-- in 'exportMap' under this name is returned (in ascending order so
-- output is deterministic).
matchingArities :: CompiledProgram -> Text -> Maybe Int -> [Int]
matchingArities prog n = \case
  Just a -> [a | Map.member (Types.UnqualifiedIdentifier n a) prog.exportMap]
  Nothing ->
    sort
      [ a
      | Types.UnqualifiedIdentifier n' a <- Map.keys prog.exportMap,
        n' == n
      ]

-- | Resolve an unqualified name at a specific arity through 'exportMap'.
-- An unambiguous match dispatches to 'classifyQualified'; an ambiguous
-- one becomes a single 'IEAmbiguous' entry that the user must clear
-- by qualifying.
resolveExportArity :: CompiledProgram -> Text -> Int -> [InfoEntry]
resolveExportArity prog n a =
  case Map.lookup (Types.UnqualifiedIdentifier n a) prog.exportMap of
    Nothing -> []
    Just (UniqueExport qn) -> classifyQualified prog qn a
    Just (AmbiguousExport ms) -> [IEAmbiguous n a ms]

-- | Look up a qualified name directly in the compiled program. No
-- 'exportMap' traversal because the user has already disambiguated.
-- Types and constructors are matched regardless of arity when the user
-- omitted it (matching the behavior of the unqualified path).
qualifiedLookup :: CompiledProgram -> QualifiedName -> Maybe Int -> [InfoEntry]
qualifiedLookup prog qn mArity =
  let arityMatches a = maybe True (== a) mArity
      classifyAt = case mArity of
        Just a -> classifyQualified prog qn a
        Nothing ->
          -- Without an arity, scan every arity the program declares
          -- for this qualified name across constraints/functions.
          let cTyArities =
                [ length args
                | (qn', args) <- Map.toList prog.desugaredProgram.constraintTypes,
                  qn' == qn
                ]
              fArities =
                [f.arity | f <- prog.desugaredProgram.functions, f.name == qn]
              exportArities =
                [ a
                | Types.QualifiedIdentifier m n a <- Set.toAscList prog.exportedSet,
                  m == qn.moduleName && n == qn.baseName
                ]
              allArities =
                sort (Set.toAscList (Set.fromList (cTyArities ++ fArities ++ exportArities)))
           in concatMap (classifyQualified prog qn) allArities
      typeEntries =
        [ IEType td
        | td <- prog.desugaredProgram.typeDefinitions,
          typeMatchesQN td qn,
          arityMatches (length td.typeVars)
        ]
      ctorEntries =
        [ IEDataCtor td c
        | td <- prog.desugaredProgram.typeDefinitions,
          c <- typeConstructors td,
          ctorMatchesQN c qn,
          arityMatches (length c.conArgs),
          isCtorExportedByParent prog td c
        ]
      builtinEntries =
        [ IEBuiltinType qn
        | qn.moduleName == "$typechecker",
          qn.baseName `elem` builtinTypeNames,
          arityMatches 0
        ]
   in builtinEntries ++ classifyAt ++ typeEntries ++ ctorEntries

-- | Decide what kind of declaration a @(qn, arity)@ pair refers to.
-- Priority: function over constraint — an identifier cannot be both
-- at the same arity (the resolver rejects that as
-- @ConstraintFunctionCollision@). Every declared constraint, typed
-- or not, lives in 'constraintTypes' (untyped constraints have an
-- @any@-filled signature synthesized by the resolver), so a single
-- lookup there covers both forms.
classifyQualified :: CompiledProgram -> QualifiedName -> Int -> [InfoEntry]
classifyQualified prog qn arity =
  case [f | f <- prog.desugaredProgram.functions, f.name == qn, f.arity == arity] of
    (f : _) -> [IEFunction f (functionDeclKind prog qn arity)]
    [] -> case Map.lookup qn prog.desugaredProgram.constraintTypes of
      Just args
        | length args == arity ->
            let bs = Map.findWithDefault [] qn prog.desugaredProgram.constraintBounds
             in [IEConstraint qn arity (Just args) bs]
      _ -> []

-- | Recover the @function@ / @open_function@ / @class@ / @open_class@
-- keyword for a 'D.Function' by scanning every parsed module's
-- declarations. Returns the first matching 'Parsed.FunctionDecl' (in
-- 'allModules' iteration order); defaults to 'DKFunction' if none is
-- found, which only happens for synthetic declarations like lifted
-- lambdas (@__lambda_N@) that users would not normally inspect.
functionDeclKind :: CompiledProgram -> QualifiedName -> Int -> DeclKind
functionDeclKind prog qn arity =
  let matches =
        [ (d.isOpen, d.kind)
        | m <- prog.allModules,
          m.name == qn.moduleName,
          Parsed.Ann d _ <- m.decls,
          Parsed.FunctionDecl {} <- [d],
          d.name == qn.baseName,
          d.arity == arity
        ]
   in case matches of
        ((False, Parsed.DKFunction) : _) -> DKFunction
        ((True, Parsed.DKFunction) : _) -> DKOpenFunction
        ((False, Parsed.DKClass) : _) -> DKClass
        ((True, Parsed.DKClass) : _) -> DKOpenClass
        [] -> DKFunction

-- The renamer guarantees that 'TypeDefinition.name' and
-- 'DataConstructor.conName' are always 'Qualified' once resolution
-- completes (see 'renameDataConstructor' and 'renameTypeDefinition'
-- in "YCHR.Internal.Rename"). The 'Unqualified' arms below are defensive
-- fallbacks that compare on the base name only; they should never
-- fire in a well-formed compiled program.

typeBaseName :: TypeDefinition -> Text
typeBaseName td = case td.name of
  Unqualified n -> n
  Qualified _ n -> n

ctorBaseName :: DataConstructor -> Text
ctorBaseName c = case c.conName of
  Unqualified n -> n
  Qualified _ n -> n

typeMatchesQN :: TypeDefinition -> QualifiedName -> Bool
typeMatchesQN td qn = case td.name of
  Qualified m n -> m == qn.moduleName && n == qn.baseName
  Unqualified _ -> False

ctorMatchesQN :: DataConstructor -> QualifiedName -> Bool
ctorMatchesQN c qn = case c.conName of
  Qualified m n -> m == qn.moduleName && n == qn.baseName
  Unqualified _ -> False

-- | Is the given data constructor visible through its parent type's
-- module export list? @:info@ uses this to refuse direct queries on
-- hidden constructors while still listing them inside the parent
-- type's declaration body (when the user asks about the type or
-- another visible constructor of the same type).
--
-- The rule mirrors 'YCHR.Internal.Rename.exporterAllowance': a module with no
-- @:- module(_, [...])@ exports everything; an explicit
-- @type(T\/A)@ entry exports every constructor; @type(T\/A, [C1,
-- C2])@ exports only the listed names; and a type missing from the
-- export list exports no constructors at all.
isCtorExportedByParent ::
  CompiledProgram -> TypeDefinition -> DataConstructor -> Bool
isCtorExportedByParent prog td c =
  let tn = typeBaseName td
      ta = length td.typeVars
      cn = ctorBaseName c
      parentModule = case td.name of
        Qualified m _ -> Just m
        Unqualified _ -> Nothing
   in case parentModule >>= findModule prog of
        Nothing -> True
        Just m -> case m.exports of
          Nothing -> True
          Just (Parsed.AnnP exports _ _) ->
            case [cs | Parsed.TypeExportDecl tn' ta' cs <- exports, tn' == tn, ta' == ta] of
              (Nothing : _) -> True
              (Just xs : _) -> cn `elem` xs
              [] -> False

findModule :: CompiledProgram -> Text -> Maybe CollectedModule
findModule prog modName =
  case [m | m <- prog.allModules, m.name == modName] of
    (m : _) -> Just m
    [] -> Nothing

-- | Print the result of an @:info@ query. The qualified name and the
-- declaration form are printed on consecutive lines for each match;
-- multiple matches are separated by blank lines. An empty result set
-- (no matches in any category) prints "unknown identifier: <raw>".
showInfo :: CompiledProgram -> String -> IO ()
showInfo prog raw =
  let trimmed = T.strip (T.pack raw)
   in if T.null trimmed
        then showInfoUsage
        else case parseInfoArg prog trimmed of
          Nothing -> printUnknown (T.unpack trimmed)
          Just (name, mArity) ->
            case lookupInfo prog name mArity of
              [] -> printUnknown (T.unpack trimmed)
              entries -> putStr (renderInfo entries)
  where
    printUnknown s = putStrLn ("unknown identifier: " ++ s)

renderInfo :: [InfoEntry] -> String
renderInfo = intercalate "\n" . map renderInfoEntry

renderInfoEntry :: InfoEntry -> String
renderInfoEntry = \case
  IEBuiltinType qn ->
    prettyQualifiedName qn ++ "\nbuilt-in type\n"
  IEConstraint qn arity mArgs bounds ->
    prettyQualifiedName qn
      ++ "\n"
      ++ prettyConstraintDecl qn arity mArgs bounds
      ++ "\n"
  IEFunction f kind ->
    prettyQualifiedName f.name
      ++ "\n"
      ++ prettyFunctionDecl f.name f.arity f.signatures f.requiring kind
      ++ "\n"
  IEType td ->
    let qn = case td.name of
          Unqualified n -> QualifiedName "" n
          Qualified m n -> QualifiedName m n
     in prettyQualifiedName qn ++ "\n" ++ prettyTypeDecl td ++ "\n"
  IEDataCtor td c ->
    let qn = case c.conName of
          Unqualified n -> QualifiedName "" n
          Qualified m n -> QualifiedName m n
     in prettyQualifiedName qn ++ "\n" ++ prettyTypeDecl td ++ "\n"
  IEAmbiguous n arity ms ->
    "ambiguous identifier: "
      ++ T.unpack n
      ++ "/"
      ++ show arity
      ++ " is exported by "
      ++ intercalate ", " (map T.unpack ms)
      ++ "; qualify with mod:name to disambiguate\n"

-- ---------------------------------------------------------------------------
-- Reporting helpers
-- ---------------------------------------------------------------------------

-- | Sorted list of unqualified exported constraint names from a
-- compiled program. Used as completion candidates in both modes.
exportedNames :: CompiledProgram -> [String]
exportedNames prog =
  Set.toAscList . Set.fromList $
    [T.unpack n | Types.UnqualifiedIdentifier n _ <- Map.keys prog.exportMap]

printWarnings :: [Warning] -> IO ()
printWarnings = mapM_ (hPutStr stderr . displayMsg)

printTypeErrors :: CompiledProgram -> IO ()
printTypeErrors prog = do
  errs <- typeCheckProgram prog.desugaredProgram
  mapM_ (hPutStr stderr . displayMsg) errs