packages feed

ollama-holes-plugin-0.1.1.0: src/GHC/Plugin/OllamaHoles.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

-- | The Ollama plugin for GHC
module GHC.Plugin.OllamaHoles (plugin) where

import Control.Monad (unless, when)
import Data.Char (isSpace)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import GHC.Plugins hiding ((<>))
import GHC.Tc.Types
import GHC.Tc.Types.Constraint (Hole (..), ctLocEnv, ctLocSpan)
import GHC.Tc.Utils.Monad (getGblEnv, newTcRef)

import GHC.Plugin.OllamaHoles.Backend

import GHC.Plugin.OllamaHoles.Backend.Ollama (ollamaBackend)
import GHC.Plugin.OllamaHoles.Backend.OpenAI (openAICompatibleBackend)
import GHC.Plugin.OllamaHoles.Backend.Gemini (geminiBackend)

promptTemplate :: Text
promptTemplate =
    "You are a typed-hole plugin within GHC, the Glasgow Haskell Compiler.\n"
        <> "You are given a hole in a Haskell program, and you need to fill it in.\n"
        <> "The hole is represented by the following information:\n"
        <> "{module}\n{location}\n{imports}\n{hole_var}\n{hole_type}\n{relevant_constraints}\n{local_env}\n{global_env}\n{candidate_fits}\n\n"
        <> "Provide one or more Haskell expressions that could fill this hole.\n"
        <> "This means coming up with an expression of the correct type that satisfies the constraints.\n"
        <> "Pay special attention to the type of the hole, specifically whether it is a function.\n"
        <> "Make sure you synthesize an expression that matches the type of the hole.\n"
        <> "Output ONLY the raw Haskell expression(s), one per line.\n"
        <> "Do not include explanations, introductions, or any surrounding text.\n"
        <> "Feel free to include any other functions from the list of imports to generate more complicated expressions.\n"
        <> "Output a maximum of {numexpr} expresssions.\n"

getBackend :: Flags -> Backend
getBackend Flags {backend_name = "ollama"} = ollamaBackend
getBackend Flags {backend_name = "gemini"}= geminiBackend
getBackend Flags{backend_name = "openai", ..}  = openAICompatibleBackend openai_base_url openai_key_name
getBackend Flags{..} = error $ "unknown backend: " <> T.unpack backend_name

pluginName :: Text
pluginName = "Ollama Plugin"

-- | Ollama plugin for GHC
plugin :: Plugin
plugin =
    defaultPlugin
        { holeFitPlugin = \opts ->
            Just $
                HoleFitPluginR
                    { hfPluginInit = newTcRef ()
                    , hfPluginStop = \_ -> return ()
                    , hfPluginRun =
                        const
                            HoleFitPlugin
                                { candPlugin = \_ c -> return c -- Don't filter candidates
                                , fitPlugin = fitPlugin opts
                                }
                    }
        }
  where
    fitPlugin opts hole fits = do
        let flags@Flags{..} = parseFlags opts
        dflags <- getDynFlags
        gbl_env <- getGblEnv
        let mod_name = moduleNameString $ moduleName $ tcg_mod gbl_env
            imports = tcg_imports gbl_env
        let backend = getBackend flags
        liftIO $ do
            available_models <- listModels backend
            case available_models of
                Nothing ->
                    error $
                        "--- " <> T.unpack pluginName <> ": No models available, check your configuration ---"
                Just models -> do
                    unless (model_name `elem` models) $
                        error $
                            "--- "
                                <> T.unpack pluginName
                                <> ": Model "
                                <> T.unpack model_name
                                <> " not found. "
                                <> ( if backend_name == "ollama"
                                        then "Use `ollama pull` to download the model, or "
                                        else ""
                                   )
                                <> "specify another model using "
                                <> "`-fplugin-opt=GHC.Plugin.OllamaHoles:model=<model_name>` ---"
                                <> "--- Availble models: "
                                <> T.unpack (T.unlines models)
                                <> " ---"
                    when debug $ T.putStrLn $ "--- " <> pluginName <> ": Hole Found ---"
                    let mn = "Module: " <> mod_name
                    let lc = "Location: " <> showSDoc dflags (ppr $ ctLocSpan . hole_loc <$> th_hole hole)
                    let im = "Imports: " <> showSDoc dflags (ppr $ moduleEnvKeys $ imp_mods imports)

                    case th_hole hole of
                        Just h -> do
                            let lcl_env = ctLocEnv (hole_loc h)
                            let hv = "Hole variable: _" <> occNameString (occName $ hole_occ h)
                            let ht = "Hole type: " <> showSDoc dflags (ppr $ hole_ty h)
                            let rc = "Relevant constraints: " <> showSDoc dflags (ppr $ th_relevant_cts hole)
                            let le = "Local environment (bindings): " <> showSDoc dflags (ppr $ tcl_rdr lcl_env)
                            let ge = "Global environment (bindings): " <> showSDoc dflags (ppr $ tcg_binds gbl_env)
                            let cf = "Candidate fits: " <> showSDoc dflags (ppr fits)
                            let prompt' =
                                    replacePlaceholders
                                        promptTemplate
                                        [ ("{module}", mn)
                                        , ("{location}", lc)
                                        , ("{imports}", im)
                                        , ("{hole_var}", hv)
                                        , ("{hole_type}", ht)
                                        , ("{relevant_constraints}", rc)
                                        , ("{local_env}", le)
                                        , ("{global_env}", ge)
                                        , ("{candidate_fits}", cf)
                                        , ("{numexpr}", show num_expr)
                                        ]
                            res <- generateFits backend prompt' model_name
                            case res of
                                Right rsp -> do
                                    let lns = (preProcess . T.lines) rsp
                                    when debug $ do
                                        T.putStrLn $ "--- " <> pluginName <> ": Prompt ---\n" <> prompt'
                                        T.putStrLn $ "--- " <> pluginName <> ": Response ---\n" <> rsp
                                    let fits' = map (RawHoleFit . text . T.unpack) lns
                                    -- Return the generated fits
                                    return fits'
                                Left err -> do
                                    when debug $
                                        putStrLn $
                                            T.unpack pluginName <> " failed to generate a response.\n" <> err
                                    -- Return the original fits without modification
                                    return fits
                        Nothing -> return fits

-- | Preprocess the response to remove empty lines, lines with only spaces, and code blocks
preProcess :: [Text] -> [Text]
preProcess [] = []
-- \| Remove lines between <think> and </think> tags from e.g. deepseek
preProcess (ln : lns)
    | T.isPrefixOf "<think>" ln =
        preProcess (tail $ dropWhile (not . T.isPrefixOf "</think>") lns)
preProcess (ln : lns) | should_drop = preProcess lns
  where
    should_drop :: Bool
    should_drop =
        T.null ln
            || T.all isSpace ln
            || T.isPrefixOf "```" ln
preProcess (ln : lns) = transform ln : preProcess lns
  where
    transform :: Text -> Text
    transform = T.strip

-- | Command line options for the plugin
data Flags = Flags
    { model_name :: Text
    , backend_name :: Text
    , num_expr :: Int
    , debug :: Bool
    , openai_base_url :: Text
    , openai_key_name :: Text
    }

-- | Default flags for the plugin
defaultFlags :: Flags
defaultFlags =
    Flags
        { model_name = "gemma3:27b-it-qat"
        , backend_name = "ollama"
        , num_expr = 5
        , debug = False
        , openai_base_url = "https://api.openai.com"
        , openai_key_name = "OPENAI_API_KEY"
        }

-- | Parse command line options
parseFlags :: [CommandLineOption] -> Flags
parseFlags = parseFlags' defaultFlags
  where
    parseFlags' :: Flags -> [CommandLineOption] -> Flags
    parseFlags' flags [] = flags
    parseFlags' flags (opt : opts)
        | T.isPrefixOf "model=" (T.pack opt) =
            let model_name = T.drop (T.length "model=") (T.pack opt)
             in parseFlags' flags{model_name = model_name} opts
    parseFlags' flags (opt : opts)
        | T.isPrefixOf "backend=" (T.pack opt) =
            let backend_name = T.drop (T.length "backend=") (T.pack opt)
             in parseFlags' flags{backend_name = backend_name} opts
    parseFlags' flags (opt : opts)
        | T.isPrefixOf "openai_base_url=" (T.pack opt) =
            let openai_base_url = T.drop (T.length "openai_base_url=") (T.pack opt)
             in parseFlags' flags{openai_base_url = openai_base_url} opts
    parseFlags' flags (opt : opts)
        | T.isPrefixOf "openai_key_name=" (T.pack opt) =
            let openai_key_name = T.drop (T.length "openai_key_name=") (T.pack opt)
             in parseFlags' flags{openai_key_name = openai_key_name} opts
    parseFlags' flags (opt : opts)
        | T.isPrefixOf "debug=" (T.pack opt) =
            let debug = T.unpack $ T.drop (T.length "debug=") (T.pack opt)
             in parseFlags' flags{debug = read debug} opts
    parseFlags' flags (opt : opts)
        | T.isPrefixOf "n=" (T.pack opt) =
            let num_expr = T.unpack $ T.drop (T.length "n=") (T.pack opt)
             in parseFlags' flags{num_expr = read num_expr} opts
    parseFlags' flags _ = flags

-- | Helper function to replace placeholders in a template string
replacePlaceholders :: Text -> [(Text, String)] -> Text
replacePlaceholders = foldl replacePlaceholder
  where
    replacePlaceholder :: Text -> (Text, String) -> Text
    replacePlaceholder str (placeholder, value) = T.replace placeholder (T.pack value) str