packages feed

ogma-core-1.15.0: src/Language/YAMLSpec/Parser.hs

{-# LANGUAGE ScopedTypeVariables #-}
-- Copyright 2024 United States Government as represented by the Administrator
-- of the National Aeronautics and Space Administration. All Rights Reserved.
--
-- Disclaimers
--
-- Licensed under the Apache License, Version 2.0 (the "License"); you may
-- not use this file except in compliance with the License. You may obtain a
-- copy of the License at
--
--      https://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-- WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-- License for the specific language governing permissions and limitations
-- under the License.

-- | Parser for Ogma specs stored in YAML files.
module Language.YAMLSpec.Parser where

-- External imports
import           Control.Monad.Except   (ExceptT (..), runExceptT)
import           Control.Monad.IO.Class (liftIO)
import           Data.Aeson             (Value (..))
import           Data.Aeson.Key         (fromString)
import qualified Data.Aeson.KeyMap      as M
import           Data.Bifunctor         (first)
import qualified Data.ByteString        as BS
import           Data.Char              (isSpace)
import           Data.List              (intercalate)
import           Data.Text              (unpack)
import qualified Data.Vector            as V
import qualified Data.Yaml              as Y
import           System.FilePath        (takeBaseName, takeFileName)

-- External imports: ogma-spec
import Data.Either.Extra (mapLeft)
import Data.OgmaSpec     (ExternalVariableDef (..), InternalVariableDef (..),
                          Requirement (..), Spec (..))

-- | Field names of a spec listed in a YAML file.
data YAMLFormat = YAMLFormat
    { specInternalVars          :: Maybe String
    , specInternalVarId         :: String
    , specInternalVarExpr       :: String
    , specInternalVarType       :: Maybe String
    , specExternalVars          :: Maybe String
    , specExternalVarId         :: String
    , specExternalVarType       :: Maybe String
    , specRequirements          :: Maybe String
    , specRequirementId         :: Maybe FieldSource
    , specRequirementDesc       :: Maybe String
    , specRequirementExpr       :: String
    , specRequirementResultType :: Maybe String
    , specRequirementResultExpr :: Maybe String
    }
  deriving (Read)

-- | Source used to populate the value of a field in a spec.
data FieldSource
    = Field String -- ^ A field of the YAML header
    | FileName     -- ^ Filename with extension
    | BaseName     -- ^ Filename without extension
  deriving (Show)

-- | Custom instance to read a 'FieldSource' that allows YAML field names to be
-- written down as plain strings.
instance Read FieldSource where
  readsPrec prec str =
    case lex str of
      [("Field", rest)]    -> first Field <$> readsPrec prec rest
      [("FileName", rest)] -> [(FileName, rest)]
      [("BaseName", rest)] -> [(BaseName, rest)]
      -- If it doesn't match a constructor, we attempt to read a string and
      -- treat it as the name of a YAML field.
      _                    -> first Field <$> readsPrec prec str

-- | Parse a spec from a YAML file.
parseYAMLSpec :: forall a
              .  (String -> IO (Either String a))
              -> YAMLFormat
              -> FilePath
              -> BS.ByteString
              -> IO (Either String (Spec a))
parseYAMLSpec parseExpr yamlFormat filepath bs = runExceptT $ do
  value <- except $ mapLeft Y.prettyPrintParseException $ Y.decodeEither' bs

  let values :: [Value]
      values =
        maybe [] (objectFieldValueList value) (specInternalVars yamlFormat)

      internalVarDef :: Value -> Either String InternalVariableDef
      internalVarDef value = do
        let msg = "internal variable name"
        varId   <- valueToString msg =<<
                     listToEither
                       msg
                       (objectFieldValues (specInternalVarId yamlFormat) value)

        let msg = "internal variable type"
        varType <- maybe
                     (Right "")
                     (\e -> valueToString msg =<<
                              listToEither msg (objectFieldValues e value)
                     )
                     (specInternalVarType yamlFormat)

        let msg = "internal variable expr"
        varExpr <- valueToString msg =<<
                     listToEither
                       msg
                       ( objectFieldValues
                           (specInternalVarExpr yamlFormat)
                           value
                       )

        return $ InternalVariableDef
                   { internalVariableName = varId
                   , internalVariableType = varType
                   , internalVariableExpr = varExpr
                   }

  internalVariableDefs <- except $ mapM internalVarDef values

  let values :: [Value]
      values =
        maybe [] (objectFieldValueList value) (specExternalVars yamlFormat)

      externalVarDef :: Value -> Either String ExternalVariableDef
      externalVarDef value = do

        let msg = "external variable name"
        varId   <- valueToString msg
                      =<< listToEither
                            msg
                            ( objectFieldValues
                                (specExternalVarId yamlFormat)
                                value
                            )

        let msg = "external variable type"
        varType <- maybe
                     (Right "")
                     (\e -> valueToString msg =<<
                              listToEither msg (objectFieldValues e value)
                     )
                     (specExternalVarType yamlFormat)

        return $ ExternalVariableDef
                   { externalVariableName = varId
                   , externalVariableType = varType
                   }

  externalVariableDefs <- except $ mapM externalVarDef values

  let values :: [Value]
      values =
        maybe [value] (objectFieldValueList value) (specRequirements yamlFormat)

      requirementDef value = do
        let msg = "Requirement name"

        -- Handle the case where the requirement ID is the file name, with or
        -- without extension.
        reqId <- case specRequirementId yamlFormat of
          Nothing        -> return ""
          Just FileName  -> return $ takeFileName filepath
          Just BaseName  -> return $ takeBaseName filepath
          Just (Field p) -> except $
            valueToString msg =<< listToEither msg (objectFieldValues p value)

        let msg = "Requirement expression"
        reqExpr <- except $ valueToString msg =<<
                              listToEither
                                msg
                                ( objectFieldValues
                                    (specRequirementExpr yamlFormat)
                                    value
                                )
        reqExpr' <- ExceptT $ parseExpr reqExpr

        let msg = "Requirement description"
        reqDesc <- except $
                     maybe
                       (Right "")
                       (\e -> valueToString msg =<<
                                listToEither msg (objectFieldValues e value)
                       )
                       (specRequirementDesc yamlFormat)
        let reqDesc' = cleanString reqDesc

        let msg = "Requirement result type"
            ty :: Maybe (Either String String)
            ty = (\e -> valueToString msg =<<
                          listToEither msg (objectFieldValues e value)
                 )
             <$> specRequirementResultType yamlFormat
        reqResType <- except $ maybeEither ty

        let msg = "Requirement result expression"
            resultExpr :: Maybe (Either String String)
            resultExpr = (\e -> valueToString msg =<<
                                  listToEither
                                    msg
                                    (objectFieldValues e value)
                         )
                     <$> specRequirementResultExpr yamlFormat

        reqResExpr  <- except $ maybeEither resultExpr
        reqResExpr' <- ExceptT $ case reqResExpr of
                                   Nothing -> return $ Right Nothing
                                   Just x  -> fmap Just <$> parseExpr x

        return $ Requirement
                   { requirementName        = reqId
                   , requirementExpr        = reqExpr'
                   , requirementDescription = reqDesc'
                   , requirementResultType  = reqResType
                   , requirementResultExpr  = reqResExpr'
                   }

  requirements <- mapM requirementDef values

  return $ Spec internalVariableDefs externalVariableDefs requirements

-- * Auxiliary functions

-- ** JSON functions

-- | Convert a string JSON value into a 'String'.
valueToString :: String -> Value -> Either String String
valueToString msg (String x) = Right $ unpack x
valueToString msg _          = Left $
  "The YAML value provided for " ++ msg ++ " does not contain a string"

-- | Object the values associated to a key of an object.
--
-- If the values are an array, it returns the values in the array directly.
objectFieldValueList :: Value -> String -> [Value]
objectFieldValueList (Object o) key =
  case M.lookup (fromString key) o of
    Just (Array arr) -> V.toList arr
    Just v           -> [v]
    Nothing          -> []
objectFieldValueList _ _ = []

-- | Object the values associated to a key of an object.
--
-- If the values are an array, it returns the values in the array directly.
objectFieldValues :: String -> Value -> [Value]
objectFieldValues key (Object o) = maybe [] (:[]) $ M.lookup (fromString key) o
objectFieldValues _   _          = []

-- ** Either-related auxiliary functions

-- | Convert a string into an Either value.
--
-- Fails if there more or less than one value in the list.
listToEither :: String -> [a] -> Either String a
listToEither _   [x] = Right x
listToEither msg []  = Left $ "Failed to find a value for " ++ msg
listToEither msg _   = Left $ "Unexpectedly found multiple values for " ++ msg

-- | Wrap an 'Either' value in an @ExceptT m@ monad.
except :: Monad m => Either e a -> ExceptT e m a
except = ExceptT . return

-- | Swap the order in a Maybe and an Either monad.
maybeEither :: Maybe (Either a b) -> Either a (Maybe b)
maybeEither Nothing  = Right Nothing
maybeEither (Just e) = fmap Just e

-- ** String-related auxiliary functions

-- | Remove trailing spaces and lines from a multi-line string.
cleanString :: String -> String
cleanString =
      unlines'
    . dropWhile null
    . dropWhileEnd null
    . map strip
    . lines
  where
    strip          = dropWhile isSpace . dropWhileEnd isSpace
    dropWhileEnd x = reverse . dropWhile x . reverse

-- | Concatenate a list of strings into one string, separated by a line ending.
--
-- This variant of 'unlines' does not add a line break at the end of the last
-- line.
unlines' :: [String] -> String
unlines' = intercalate "\n"