packages feed

tala-compiler-0.1.0: src/Progmasoft/Tala/Driver/Command.hs

-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1

{- | Private command model shared by the Haskell shared-library boundary and
the small command-line adapter. It returns owned bytes instead of opening an
output artifact: the native driver consumes Core in memory and is the only
component that writes user-requested outputs.
-}
module Progmasoft.Tala.Driver.Command
    ( FrontendOutcome (..)
    , FrontendOutputKind (..)
    , runFrontendArguments
    ) where

import Data.ByteString qualified as ByteString
import Data.Char (isAlpha, isAlphaNum)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Progmasoft.Tala.AST
import Progmasoft.Tala.Compiler
import Progmasoft.Tala.Core (CoreModule)
import Progmasoft.Tala.Core.Wire
import Progmasoft.Tala.Diagnostic
import Progmasoft.Tala.SourceSet
import Progmasoft.Tala.SourceWarnings

{- | Output tags are part of the private in-process ABI, not public artifact
names. Each output is borrowed only for the synchronous native callback.
-}
data FrontendOutputKind
    = CoreWireOutput
    | ProjectSourceListOutput
    | DiagnosticWireOutput
    | ErrorTextOutput
    deriving (Bounded, Enum, Eq, Ord, Read, Show)

{- | Owned result of one private frontend request, ready for native consumption.

A success carries the warnings of the sources it compiled. A rejection lists
its errors first and the warnings after them, because a warning often
explains the error that follows it in the same statement.
-}
data FrontendOutcome
    = FrontendSuccess FrontendOutputKind ByteString.ByteString [Diagnostic]
    | FrontendDiagnostics [Diagnostic]
    | FrontendFailure String

data FrontendCommand
    = CompileFile FilePath
    | CompileFiles QualifiedName [FilePath]
    | CompileProject FilePath QualifiedName [FilePath] [FilePath]
    | ListProjectSources FilePath [FilePath] [FilePath]

data CommandOptions = CommandOptions
    { optionSourceFiles :: [FilePath]
    , optionProjectRoot :: Maybe FilePath
    , optionEntry :: Maybe String
    , optionSourceRoots :: [FilePath]
    , optionExcludes :: [FilePath]
    , optionListSources :: Bool
    }

emptyOptions :: CommandOptions
emptyOptions = CommandOptions [] Nothing Nothing [] [] False

{- | Parse the deliberately narrow private argument vocabulary. Values remain
separate from option names so file names and glob patterns cannot be
reinterpreted by a shell or by option-prefix heuristics.
-}
runFrontendArguments :: [String] -> IO FrontendOutcome
runFrontendArguments arguments = case parseCommand arguments of
    Left problem -> pure (FrontendFailure problem)
    Right command -> executeCommand command

parseCommand :: [String] -> Either String FrontendCommand
parseCommand arguments = parseOptions emptyOptions arguments >>= finishCommand

parseOptions :: CommandOptions -> [String] -> Either String CommandOptions
parseOptions options [] = Right options
parseOptions options (name : remaining) = case name of
    "--source-file" -> repeatedValue name addSourceFile options remaining
    "--project-root" -> uniqueValue name optionProjectRoot setProjectRoot options remaining
    "--entry" -> uniqueValue name optionEntry setEntry options remaining
    "--source-root" -> repeatedValue name addSourceRoot options remaining
    "--exclude" -> repeatedValue name addExclude options remaining
    "--list-sources"
        | optionListSources options -> Left "--list-sources may be specified only once"
        | otherwise -> parseOptions options {optionListSources = True} remaining
    _ -> Left ("unknown private frontend option: " ++ name)

uniqueValue ::
    String ->
    (CommandOptions -> Maybe String) ->
    (String -> CommandOptions -> CommandOptions) ->
    CommandOptions ->
    [String] ->
    Either String CommandOptions
uniqueValue name getter setter options remaining = case remaining of
    [] -> Left (name ++ " requires one value")
    value : rest
        | null value -> Left (name ++ " value is empty")
        | getter options /= Nothing -> Left (name ++ " may be specified only once")
        | otherwise -> parseOptions (setter value options) rest

repeatedValue ::
    String ->
    (String -> CommandOptions -> CommandOptions) ->
    CommandOptions ->
    [String] ->
    Either String CommandOptions
repeatedValue name setter options remaining = case remaining of
    [] -> Left (name ++ " requires one value")
    value : rest
        | null value -> Left (name ++ " value is empty")
        | otherwise -> parseOptions (setter value options) rest

setProjectRoot, setEntry :: String -> CommandOptions -> CommandOptions
setProjectRoot value options = options {optionProjectRoot = Just value}
setEntry value options = options {optionEntry = Just value}

addSourceFile, addSourceRoot, addExclude :: String -> CommandOptions -> CommandOptions
addSourceFile value options = options {optionSourceFiles = optionSourceFiles options ++ [value]}
addSourceRoot value options = options {optionSourceRoots = optionSourceRoots options ++ [value]}
addExclude value options = options {optionExcludes = optionExcludes options ++ [value]}

finishCommand :: CommandOptions -> Either String FrontendCommand
finishCommand options = case ( optionSourceFiles options
                             , optionProjectRoot options
                             , optionEntry options
                             , optionListSources options
                             ) of
    (sources@(_ : _), Nothing, entryText, False)
        | not (null (optionSourceRoots options) && null (optionExcludes options)) ->
            Left "file compilation cannot declare project roots or exclusions"
        | otherwise -> case (entryText, sources) of
            (Just written, _) -> fmap (`CompileFiles` sources) (parseQualifiedName written)
            (Nothing, [source]) -> Right (CompileFile source)
            (Nothing, _) -> Left "several source files require --entry"
    ([], Just root, Just entryText, False) -> do
        entry <- parseQualifiedName entryText
        if null (optionSourceRoots options)
            then Left "project compilation requires at least one --source-root"
            else Right (CompileProject root entry (optionSourceRoots options) (optionExcludes options))
    ([], Just root, Nothing, True)
        | not (null (optionSourceRoots options)) ->
            Right (ListProjectSources root (optionSourceRoots options) (optionExcludes options))
        | otherwise -> Left "project source listing requires at least one --source-root"
    _ -> Left "arguments must describe exactly one file compile, project compile, or project source listing"

{- | Load every explicitly named source. A file named twice is one source;
the first failure of any file rejects the whole set, as project loading does.
-}
loadSourceFiles :: [FilePath] -> IO (Either [Diagnostic] [LoadedSource])
loadSourceFiles paths = do
    loaded <- mapM loadSourceFile paths
    let problems = concat [issues | Left issues <- loaded]
        sources = [source | Right source <- loaded]
    pure $ if null problems then Right (uniqueSources sources) else Left problems
    where
        uniqueSources = foldr keepFirst []
        keepFirst source rest =
            source : filter ((/= loadedSourcePath source) . loadedSourcePath) rest

parseQualifiedName :: String -> Either String QualifiedName
parseQualifiedName text =
    let parts = splitDot text
     in if length parts < 2
            then Left "--entry must name a namespace-qualified class"
            else
                if all validIdentifier parts
                    then Right (QualifiedName (map Identifier parts))
                    else Left ("--entry contains an invalid identifier: " ++ text)

validIdentifier :: String -> Bool
validIdentifier [] = False
validIdentifier (first : remaining) =
    (isAlpha first || first == '_') && all (\value -> isAlphaNum value || value == '_') remaining

splitDot :: String -> [String]
splitDot [] = [""]
splitDot ('.' : remaining) = "" : splitDot remaining
splitDot (value : remaining) = case splitDot remaining of
    [] -> [[value]]
    first : rest -> (value : first) : rest

executeCommand :: FrontendCommand -> IO FrontendOutcome
executeCommand command = case command of
    CompileFile source -> do
        loaded <- loadSourceFile source
        case loaded of
            Left diagnostics -> pure (FrontendDiagnostics diagnostics)
            Right document ->
                let warnings = loadedSourceWarnings document
                 in case compileToCorePrep (CompilerInput (loadedSourceRelativePath document) (loadedSourceText document)) of
                        Left diagnostics -> pure (FrontendDiagnostics (diagnostics ++ warnings))
                        Right artifacts -> encodeCoreOutcome warnings (artifactOptimizedCore artifacts)
    CompileFiles entry sources -> do
        loaded <- loadSourceFiles sources
        case loaded of
            Left diagnostics -> pure (FrontendDiagnostics diagnostics)
            Right documents ->
                let warnings = concatMap loadedSourceWarnings documents
                    inputs = map toProjectCompilerInput documents
                    -- One source keeps the single-file pipeline, whose Core
                    -- carries no source catalog; several are one program.
                    compiled = case inputs of
                        [input] -> fmap artifactOptimizedCore (compileEntryToCorePrep entry input)
                        _ -> fmap projectEntryCore (compileProjectToCorePrep entry inputs)
                 in case compiled of
                        Left diagnostics -> pure (FrontendDiagnostics (diagnostics ++ warnings))
                        Right core -> encodeCoreOutcome warnings core
    CompileProject root entry roots excludes -> do
        loaded <- loadSourceSet (SourceSetRequest root roots excludes)
        case loaded of
            Left diagnostics -> pure (FrontendDiagnostics diagnostics)
            Right documents ->
                let warnings = concatMap loadedSourceWarnings documents
                 in case compileProjectToCorePrep entry (map toProjectCompilerInput documents) of
                        Left diagnostics -> pure (FrontendDiagnostics (diagnostics ++ warnings))
                        Right artifacts -> encodeCoreOutcome warnings (projectEntryCore artifacts)
    ListProjectSources root roots excludes -> do
        discovered <- discoverSourceSet (SourceSetRequest root roots excludes)
        pure $ case discovered of
            Left diagnostics -> FrontendDiagnostics diagnostics
            Right paths ->
                FrontendSuccess
                    ProjectSourceListOutput
                    (Text.encodeUtf8 (Text.pack (concatMap (++ "\0") paths)))
                    []

toProjectCompilerInput :: LoadedSource -> CompilerInput
toProjectCompilerInput source = CompilerInput (loadedSourceRelativePath source) (loadedSourceText source)

loadedSourceWarnings :: LoadedSource -> [Diagnostic]
loadedSourceWarnings source = sourceWarnings (loadedSourceRelativePath source) (loadedSourceText source)

encodeCoreOutcome :: [Diagnostic] -> CoreModule -> IO FrontendOutcome
encodeCoreOutcome warnings core = pure $ case encodeCore defaultCoreWireLimits core of
    Left issue -> FrontendFailure ("could not encode verified Core: " ++ show issue)
    Right bytes -> FrontendSuccess CoreWireOutput (ByteString.pack bytes) warnings