wireform-proto-0.2.0.0: src/Proto/IDL/Parser/Resolver.hs
{- | Import resolution for .proto files with include directory support.
When a proto file contains @import "google/protobuf/timestamp.proto"@,
the resolver searches the include directories in order to find the
file, then parses it and transitively resolves its imports.
-}
module Proto.IDL.Parser.Resolver (
-- * Configuration
ResolveConfig (..),
defaultResolveConfig,
-- * Resolution
resolveProtoFile,
resolveProtoImports,
ResolvedProto (..),
ResolveError (..),
-- * Bundled well-known types
bundledIncludeDir,
getBundledIncludeDir,
) where
import Control.Monad (foldM)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Paths_wireform_proto qualified
import Proto.IDL.AST
import Proto.IDL.Parser (parseProtoFile, renderParseError)
import System.Directory (doesFileExist)
import System.FilePath (takeDirectory, (</>))
-- | Configuration for proto file resolution.
data ResolveConfig = ResolveConfig
{ rcIncludeDirs :: ![FilePath]
{- ^ Directories to search for imported .proto files, in order.
The directory containing the importing file is always searched first.
-}
, rcBundledDir :: !(Maybe FilePath)
{- ^ Path to bundled well-known proto files (google/protobuf/*.proto).
If Nothing, uses the built-in bundled protos from the package.
-}
}
deriving stock (Show, Eq)
-- | Default resolution config with no extra include directories and automatic bundled dir.
defaultResolveConfig :: ResolveConfig
defaultResolveConfig =
ResolveConfig
{ rcIncludeDirs = []
, rcBundledDir = Nothing
}
-- | A fully resolved proto file with all its transitive imports.
data ResolvedProto = ResolvedProto
{ rpFile :: !ProtoFile
, rpPath :: !FilePath
, rpImports :: !(Map Text ResolvedProto)
}
deriving stock (Show)
-- | Errors that can occur during proto file resolution.
data ResolveError
= -- | A proto file failed to parse.
ParseError !FilePath !String
| -- | An imported file could not be found in any include directory.
FileNotFound !FilePath !Text ![FilePath]
| -- | A circular import chain was detected.
CircularImport ![Text]
deriving stock (Show, Eq)
{- | The path to bundled well-known protobuf definitions shipped with this package.
This is a fallback for development; prefer 'getBundledIncludeDir' which uses
'Paths_wireform_proto' to locate the data-files at install time.
-}
bundledIncludeDir :: FilePath
bundledIncludeDir = "proto"
{- | Locate the bundled well-known .proto files using 'Paths_wireform_proto'.
The data-files are installed under the package data-dir; we look up
a known file and derive the root directory.
-}
getBundledIncludeDir :: IO FilePath
getBundledIncludeDir = do
refFile <- Paths_wireform_proto.getDataFileName "proto/google/protobuf/timestamp.proto"
let dir =
takeDirectory (takeDirectory (takeDirectory (takeDirectory refFile)))
</> "proto"
exists <- doesFileExist refFile
pure (if exists then dir else bundledIncludeDir)
-- | Resolve a proto file and all its transitive imports.
resolveProtoFile
:: ResolveConfig
-> FilePath
-> IO (Either ResolveError ResolvedProto)
resolveProtoFile cfg path = do
bundled <- bundledDirs cfg
let cfg' = cfg {rcIncludeDirs = takeDirectory path : rcIncludeDirs cfg <> bundled}
resolve cfg' Map.empty [] path
bundledDirs :: ResolveConfig -> IO [FilePath]
bundledDirs cfg = case rcBundledDir cfg of
Just d -> pure [d]
Nothing -> do
dir <- getBundledIncludeDir
pure [dir]
resolve
:: ResolveConfig
-> Map Text ResolvedProto
-> [Text]
-> FilePath
-> IO (Either ResolveError ResolvedProto)
resolve cfg cache visiting path = do
let pathT = T.pack path
if pathT `elem` visiting
then pure (Left (CircularImport (reverse (pathT : visiting))))
else case Map.lookup pathT cache of
Just resolved -> pure (Right resolved)
Nothing -> do
contents <- TIO.readFile path
case parseProtoFile path contents of
Left e -> pure (Left (ParseError path (renderParseError e)))
Right pf -> do
let imports = protoImports pf
result <- foldM (resolveImport cfg (pathT : visiting)) (Right Map.empty) imports
case result of
Left e -> pure (Left e)
Right importMap -> do
let resolved =
ResolvedProto
{ rpFile = pf
, rpPath = path
, rpImports = importMap
}
pure (Right resolved)
resolveImport
:: ResolveConfig
-> [Text]
-> Either ResolveError (Map Text ResolvedProto)
-> ImportDef
-> IO (Either ResolveError (Map Text ResolvedProto))
resolveImport _ _ (Left e) _ = pure (Left e)
resolveImport cfg visiting (Right acc) imp = do
let importPath = T.unpack (Proto.IDL.AST.importPath imp)
found <- findFile (rcIncludeDirs cfg) importPath
case found of
Nothing -> pure (Left (FileNotFound importPath (Proto.IDL.AST.importPath imp) (rcIncludeDirs cfg)))
Just fullPath -> do
result <- resolve cfg acc visiting fullPath
case result of
Left e -> pure (Left e)
Right resolved -> pure (Right (Map.insert (Proto.IDL.AST.importPath imp) resolved acc))
findFile :: [FilePath] -> FilePath -> IO (Maybe FilePath)
findFile [] _ = pure Nothing
findFile (dir : dirs) rel = do
let full = dir </> rel
exists <- doesFileExist full
if exists
then pure (Just full)
else findFile dirs rel
-- | Resolve all imports from a proto file, returning the full import graph.
resolveProtoImports
:: [FilePath]
-- ^ Include directories
-> FilePath
-- ^ Proto file path
-> IO (Either ResolveError ResolvedProto)
resolveProtoImports includeDirs =
resolveProtoFile (defaultResolveConfig {rcIncludeDirs = includeDirs})