diff --git a/docs/references/strategies/README.md b/docs/references/strategies/README.md index 66bed5b58..7af5c9a89 100644 --- a/docs/references/strategies/README.md +++ b/docs/references/strategies/README.md @@ -15,6 +15,10 @@ - Docker --> +### bazel + +- [bazel (Bzlmod)](languages/bazel/bazel.md) + ### clojure - [leiningen](languages/clojure/leiningen.md) @@ -168,6 +172,7 @@ Invoke strict analysis with the `--strict` flag when running `fossa analyze`. | [C#/.NET (paket)](https://github.com/fossas/fossa-cli/tree/master/docs/references/strategies/languages/dotnet/paket.md) | Static | ❌ | | [C#/.NET (projectassetsjson)](https://github.com/fossas/fossa-cli/tree/master/docs/references/strategies/languages/dotnet/projectassetsjson.md) | Static | ❌ | | [C#/.NET (projectjson)](https://github.com/fossas/fossa-cli/tree/master/docs/references/strategies/languages/dotnet/projectjson.md) | Static | ❌ | +| [Bazel (Bzlmod)](languages/bazel/bazel.md) | Dynamic with static fallback | ❌ | | [C](https://github.com/fossas/fossa-cli/tree/master/docs/references/strategies/languages/c-cpp/c-cpp.md) | Custom | ✅ | | [C++](https://github.com/fossas/fossa-cli/tree/master/docs/references/strategies/languages/c-cpp/c-cpp.md) | Custom | ✅ | | [Clojure (leiningen)](https://github.com/fossas/fossa-cli/blob/master/docs/references/strategies/languages/clojure/clojure.md) | Dynamic | ❌ | diff --git a/docs/references/strategies/languages/bazel/bazel.md b/docs/references/strategies/languages/bazel/bazel.md new file mode 100644 index 000000000..0f3a3b67b --- /dev/null +++ b/docs/references/strategies/languages/bazel/bazel.md @@ -0,0 +1,62 @@ +# Bazel Analysis + +Bazel projects using [Bzlmod](https://bazel.build/external/module) (`MODULE.bazel`) are analyzed for dependencies. This covers `bazel_dep()` entries (modules from the Bazel Central Registry) and Maven artifacts declared via the `rules_jvm_external` extension. + +**Requires Bazel 6+** (Bzlmod available). The legacy `WORKSPACE` system is not supported. Bazel 9 (Jan 2026) removed `WORKSPACE` entirely, making `MODULE.bazel` the only dependency management approach. + +Dependencies managed by other ecosystems (Go, Node, Cargo, pip, etc.) within a Bazel project are handled by their respective strategies. + +| Strategy | Direct Deps | Transitive Deps | Edges | Container Scanning | +| -------------- | ------------------ | ------------------ | ------------------ | ------------------ | +| MODULE.bazel | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | + +## Project Discovery + +Find a file named `MODULE.bazel`. Subdirectories are not scanned for nested Bazel projects, since Bazel projects are rooted at the `MODULE.bazel` location. + +## Analysis + +### Static analysis + +1. Parse `MODULE.bazel` to extract: + - `bazel_dep(name, version)` entries (reported as `bazel` dependency type) + - `maven.install(artifacts=[...])` from `rules_jvm_external` extensions (reported as `mvn` dependency type) + - Variable references are resolved (e.g. `ARTIFACTS = [...]` used in `maven.install(artifacts = ARTIFACTS)`) +2. If `maven_install.json` exists alongside `MODULE.bazel`, parse it for the full resolved Maven dependency tree with transitive edges. +3. If `maven_install.json` is absent but `maven.install(artifacts=[...])` is present in `MODULE.bazel`, report those artifacts as direct-only dependencies (graph breadth is `Partial`). + +### Dynamic analysis + +If `bazel` is available on `PATH`, run `bazel mod graph --output json` to obtain the full resolved Bazel module dependency tree with transitive edges. + +If `bazel` is not available, analysis falls back to static-only. Use `--static-only-analysis` to skip the dynamic step explicitly. + +## Supported files + +| File | Required | Purpose | +| --------------------- | -------- | ---------------------------------------------- | +| `MODULE.bazel` | Yes | Bazel module definition and dependency manifest | +| `maven_install.json` | No | Coursier-resolved Maven lockfile | + +## FAQ + +### How do I only perform analysis for Bazel? + +Specify the target in `.fossa.yml`: + +```yaml +# .fossa.yml + +version: 3 +targets: + only: + - type: bazel +``` + +### What about WORKSPACE-based projects? + +Only Bzlmod (`MODULE.bazel`) is supported. Bazel 9 removed `WORKSPACE` support entirely. For older projects, migrate to Bzlmod or use `WORKSPACE.bzlmod` as a transitional step. + +### What about Maven artifacts declared in `rules_jvm_external`? + +If you use `maven.install(artifacts=[...])` in `MODULE.bazel` and run `bazel run @maven//:pin` to generate `maven_install.json`, the lockfile will be parsed for the full transitive Maven dependency graph. Without the lockfile, only the directly declared artifact coordinates are reported. diff --git a/spectrometer.cabal b/spectrometer.cabal index 6b60b2e70..55c6f94d4 100644 --- a/spectrometer.cabal +++ b/spectrometer.cabal @@ -417,6 +417,10 @@ library Strategy.AlpineLinux.Types Strategy.Android.Util Strategy.ApkDatabase + Strategy.Bazel + Strategy.Bazel.BazelModGraph + Strategy.Bazel.MavenInstall + Strategy.Bazel.ModuleBazel Strategy.BerkeleyDB Strategy.BerkeleyDB.Internal Strategy.Bundler @@ -610,6 +614,9 @@ test-suite unit-tests App.Fossa.VSI.IAT.ResolveSpec App.Fossa.VSI.TypesSpec App.Fossa.VSIDepsSpec + Bazel.BazelModGraphSpec + Bazel.MavenInstallSpec + Bazel.ModuleBazelSpec BerkeleyDB.BerkeleyDBSpec BundlerSpec Cargo.CargoTomlSpec diff --git a/src/App/Fossa/Analyze/Discover.hs b/src/App/Fossa/Analyze/Discover.hs index a715fec77..ac60b182a 100644 --- a/src/App/Fossa/Analyze/Discover.hs +++ b/src/App/Fossa/Analyze/Discover.hs @@ -8,6 +8,7 @@ import Control.Effect.Reader (Has, Reader) import Data.Aeson qualified as Aeson import Discovery.Filters (AllFilters) import Path (Abs, Dir, Path) +import Strategy.Bazel qualified as Bazel import Strategy.Bundler qualified as Bundler import Strategy.Cargo qualified as Cargo import Strategy.Carthage qualified as Carthage @@ -48,7 +49,8 @@ import Types (DiscoveredProject) discoverFuncs :: DiscoverTaskEffs sig m => [DiscoverFunc m] discoverFuncs = - [ DiscoverFunc Bundler.discover + [ DiscoverFunc Bazel.discover + , DiscoverFunc Bundler.discover , DiscoverFunc Cabal.discover , DiscoverFunc Cargo.discover , DiscoverFunc Carthage.discover diff --git a/src/DepTypes.hs b/src/DepTypes.hs index f4d9a4fd2..7cea01b04 100644 --- a/src/DepTypes.hs +++ b/src/DepTypes.hs @@ -66,6 +66,8 @@ hydrateDepEnvs = hydrate dependencyEnvironments $ \envs dep -> data DepType = -- | An archive upload dependency. ArchiveType + | -- | Bazel module dependency (BCR) + BazelType | -- | Bower dependency BowerType | -- | A first-party subproject diff --git a/src/Srclib/Converter.hs b/src/Srclib/Converter.hs index f1f0d083b..3906b1a5d 100644 --- a/src/Srclib/Converter.hs +++ b/src/Srclib/Converter.hs @@ -150,6 +150,7 @@ verConstraintToRevision = \case depTypeToFetcher :: DepType -> Text depTypeToFetcher = \case ArchiveType -> "archive" + BazelType -> "bazel" BowerType -> "bower" CarthageType -> "cart" CargoType -> "cargo" diff --git a/src/Strategy/Bazel.hs b/src/Strategy/Bazel.hs new file mode 100644 index 000000000..e2be6373e --- /dev/null +++ b/src/Strategy/Bazel.hs @@ -0,0 +1,115 @@ +module Strategy.Bazel ( + discover, + BazelProject (..), +) where + +import App.Fossa.Analyze.Types (AnalyzeProject (analyzeProject, analyzeProjectStaticOnly)) +import Control.Effect.Diagnostics ( + Diagnostics, + Has, + context, + recover, + ) +import Control.Effect.Reader (Reader) +import Data.Aeson (ToJSON) +import Discovery.Filters (AllFilters) +import Discovery.Simple (simpleDiscover) +import Discovery.Walk ( + WalkStep (WalkSkipAll), + findFileNamed, + walkWithFilters', + ) +import Effect.Exec (AllowErr (Never), Command (..), Exec, execJson) +import Effect.ReadFS (ReadFS, readContentsJson, readContentsParser) +import GHC.Generics (Generic) +import Graphing (Graphing) +import Path (Abs, Dir, File, Path) +import Strategy.Bazel.BazelModGraph (BazelModGraphJson, buildModGraphDeps) +import Strategy.Bazel.MavenInstall (MavenInstallJson, buildMavenInstallGraph) +import Strategy.Bazel.ModuleBazel (buildBazelGraph, moduleBazelParser) +import Types ( + Dependency, + DependencyResults (..), + DiscoveredProject (..), + DiscoveredProjectType (BazelProjectType), + GraphBreadth (Complete, Partial), + ) + +discover :: (Has ReadFS sig m, Has Diagnostics sig m, Has (Reader AllFilters) sig m) => Path Abs Dir -> m [DiscoveredProject BazelProject] +discover = simpleDiscover findProjects mkProject BazelProjectType + +findProjects :: (Has ReadFS sig m, Has Diagnostics sig m, Has (Reader AllFilters) sig m) => Path Abs Dir -> m [BazelProject] +findProjects = walkWithFilters' $ \dir _ files -> do + case findFileNamed "MODULE.bazel" files of + Nothing -> pure ([], WalkSkipAll) + Just moduleBazel -> do + let mavenInstall = findFileNamed "maven_install.json" files + project = + BazelProject + { bazelDir = dir + , bazelModuleFile = moduleBazel + , bazelMavenInstall = mavenInstall + } + pure ([project], WalkSkipAll) + +data BazelProject = BazelProject + { bazelDir :: Path Abs Dir + , bazelModuleFile :: Path Abs File + , bazelMavenInstall :: Maybe (Path Abs File) + } + deriving (Eq, Ord, Show, Generic) + +instance ToJSON BazelProject + +instance AnalyzeProject BazelProject where + analyzeProject _ project = do + staticResult <- context "Bazel" $ analyzeStatic project + dynamicResult <- context "Bazel dynamic analysis" $ recover (analyzeDynamic (bazelDir project)) + case dynamicResult of + Just dynGraph -> + pure $ + staticResult + { dependencyGraph = dependencyGraph staticResult <> dynGraph + , dependencyGraphBreadth = Complete + } + Nothing -> pure staticResult + + analyzeProjectStaticOnly _ = context "Bazel" . analyzeStatic + +mkProject :: BazelProject -> DiscoveredProject BazelProject +mkProject project = + DiscoveredProject + { projectType = BazelProjectType + , projectBuildTargets = mempty + , projectPath = bazelDir project + , projectData = project + } + +-- | Static analysis: parse MODULE.bazel and optionally maven_install.json. +analyzeStatic :: (Has ReadFS sig m, Has Diagnostics sig m) => BazelProject -> m DependencyResults +analyzeStatic project = do + moduleFile <- context "Parsing MODULE.bazel" $ readContentsParser moduleBazelParser (bazelModuleFile project) + let bazelGraph = buildBazelGraph moduleFile + mavenGraph <- case bazelMavenInstall project of + Just installFile -> do + installJson <- context "Parsing maven_install.json" $ readContentsJson @MavenInstallJson installFile + pure $ buildMavenInstallGraph installJson + Nothing -> pure mempty + let combinedGraph = bazelGraph <> mavenGraph + breadth = case bazelMavenInstall project of + Just _ -> Complete + Nothing -> Partial + manifestFiles = [bazelModuleFile project] <> maybe [] pure (bazelMavenInstall project) + pure + DependencyResults + { dependencyGraph = combinedGraph + , dependencyGraphBreadth = breadth + , dependencyManifestFiles = manifestFiles + } + +-- | Dynamic analysis: run `bazel mod graph --output json` and parse the result. +analyzeDynamic :: (Has Exec sig m, Has Diagnostics sig m) => Path Abs Dir -> m (Graphing Dependency) +analyzeDynamic dir = do + let cmd = Command "bazel" ["mod", "graph", "--output", "json"] Never + modGraph <- execJson @BazelModGraphJson dir cmd + pure $ buildModGraphDeps modGraph diff --git a/src/Strategy/Bazel/BazelModGraph.hs b/src/Strategy/Bazel/BazelModGraph.hs new file mode 100644 index 000000000..e3d2aff9d --- /dev/null +++ b/src/Strategy/Bazel/BazelModGraph.hs @@ -0,0 +1,82 @@ +module Strategy.Bazel.BazelModGraph ( + BazelModGraphJson (..), + BazelModGraphNode (..), + buildModGraphDeps, +) where + +import Data.Aeson ( + FromJSON (parseJSON), + withObject, + (.:), + (.:?), + ) +import Data.Map.Strict qualified as Map +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import Data.Text qualified as Text +import DepTypes ( + DepType (BazelType), + Dependency (..), + VerConstraint (CEq), + ) +import Graphing (Graphing) +import Graphing qualified + +-- | Top-level JSON output from `bazel mod graph --output json`. +data BazelModGraphJson = BazelModGraphJson + { modGraphKey :: Text + , modGraphVersion :: Text + , modGraphDeps :: [BazelModGraphNode] + } + deriving (Eq, Ord, Show) + +-- | A node in the module dependency graph. +data BazelModGraphNode = BazelModGraphNode + { nodeKey :: Text + , nodeVersion :: Text + , nodeDeps :: [BazelModGraphNode] + } + deriving (Eq, Ord, Show) + +instance FromJSON BazelModGraphJson where + parseJSON = withObject "BazelModGraphJson" $ \obj -> + BazelModGraphJson + <$> obj .: "key" + <*> (fromMaybe "" <$> obj .:? "version") + <*> (fromMaybe [] <$> obj .:? "dependencies") + +instance FromJSON BazelModGraphNode where + parseJSON = withObject "BazelModGraphNode" $ \obj -> + BazelModGraphNode + <$> obj .: "key" + <*> (fromMaybe "" <$> obj .:? "version") + <*> (fromMaybe [] <$> obj .:? "dependencies") + +-- | Build a dependency graph from `bazel mod graph` JSON output. +buildModGraphDeps :: BazelModGraphJson -> Graphing Dependency +buildModGraphDeps root = + -- The root node represents the current module; its deps are direct dependencies. + let directDeps = modGraphDeps root + in mconcat (map (\node -> Graphing.direct (nodeToDep node) <> buildNodeEdges node) directDeps) + +buildNodeEdges :: BazelModGraphNode -> Graphing Dependency +buildNodeEdges node = + let parent = nodeToDep node + children = nodeDeps node + directEdges = mconcat [Graphing.edge parent (nodeToDep child) | child <- children] + childEdges = mconcat (map buildNodeEdges children) + in directEdges <> childEdges + +nodeToDep :: BazelModGraphNode -> Dependency +nodeToDep node = + Dependency + { dependencyType = BazelType + , dependencyName = nodeKey node + , dependencyVersion = + if Text.null (nodeVersion node) + then Nothing + else Just (CEq (nodeVersion node)) + , dependencyLocations = [] + , dependencyEnvironments = mempty + , dependencyTags = Map.empty + } diff --git a/src/Strategy/Bazel/MavenInstall.hs b/src/Strategy/Bazel/MavenInstall.hs new file mode 100644 index 000000000..6cd01d385 --- /dev/null +++ b/src/Strategy/Bazel/MavenInstall.hs @@ -0,0 +1,153 @@ +module Strategy.Bazel.MavenInstall ( + MavenInstallJson (..), + MavenArtifactInfo (..), + MavenDependencyTree (..), + TreeArtifact (..), + parseMavenInstall, + buildMavenInstallGraph, +) where + +import Data.Aeson ( + FromJSON (parseJSON), + withObject, + (.:), + (.:?), + ) +import Data.Map.Strict (Map) +import Data.Map.Strict qualified as Map +import Data.Maybe (fromMaybe) +import Data.Set qualified as Set +import Data.Text (Text) +import Data.Text qualified as Text +import DepTypes ( + DepType (MavenType), + Dependency (..), + VerConstraint (CEq), + ) +import Graphing (Graphing) +import Graphing qualified + +-- | Parsed maven_install.json lockfile. +data MavenInstallJson = MavenInstallJson + { mavenArtifacts :: Map Text MavenArtifactInfo + , mavenDependencyTree :: Maybe MavenDependencyTree + } + deriving (Eq, Ord, Show) + +-- | Info about a single Maven artifact. +data MavenArtifactInfo = MavenArtifactInfo + { artifactVersion :: Text + } + deriving (Eq, Ord, Show) + +-- | The dependency_tree section of maven_install.json (v2 format). +data MavenDependencyTree = MavenDependencyTree + { treeArtifacts :: [TreeArtifact] + } + deriving (Eq, Ord, Show) + +-- | A single artifact entry in the dependency tree. +data TreeArtifact = TreeArtifact + { treeCoord :: Text + , treeDeps :: [Text] + } + deriving (Eq, Ord, Show) + +instance FromJSON MavenInstallJson where + parseJSON = withObject "MavenInstallJson" $ \obj -> do + -- Try v2 format with dependency_tree first + mDepTree <- obj .:? "dependency_tree" + -- Try v1 format with artifacts map + mArtifacts <- obj .:? "artifacts" + pure + MavenInstallJson + { mavenArtifacts = fromMaybe Map.empty mArtifacts + , mavenDependencyTree = mDepTree + } + +instance FromJSON MavenArtifactInfo where + parseJSON = withObject "MavenArtifactInfo" $ \obj -> + MavenArtifactInfo <$> obj .: "version" + +instance FromJSON MavenDependencyTree where + parseJSON = withObject "MavenDependencyTree" $ \obj -> + MavenDependencyTree <$> obj .: "artifacts" + +instance FromJSON TreeArtifact where + parseJSON = withObject "TreeArtifact" $ \obj -> + TreeArtifact + <$> obj .: "coord" + <*> obj .: "dependencies" + +-- | Parse maven_install.json contents (provided externally via readContentsJson). +parseMavenInstall :: MavenInstallJson -> Graphing Dependency +parseMavenInstall = buildMavenInstallGraph + +-- | Build a dependency graph from maven_install.json. +buildMavenInstallGraph :: MavenInstallJson -> Graphing Dependency +buildMavenInstallGraph installJson = + case mavenDependencyTree installJson of + Just tree -> buildFromTree tree + Nothing -> buildFromArtifacts (mavenArtifacts installJson) + +-- | Build graph from v2 dependency_tree format (has transitive deps). +buildFromTree :: MavenDependencyTree -> Graphing Dependency +buildFromTree tree = + let artifacts = treeArtifacts tree + -- All coords that appear as dependencies of something else + childCoords = Set.fromList $ concatMap treeDeps artifacts + -- Roots are artifacts that are NOT children of anything + roots = filter (\a -> not (Set.member (treeCoord a) childCoords)) artifacts + -- Build edges + edgeGraph = mconcat [buildTreeEdges a | a <- artifacts] + -- Mark roots as direct + directGraph = Graphing.directs (map (coordToDep . treeCoord) roots) + in directGraph <> edgeGraph + +buildTreeEdges :: TreeArtifact -> Graphing Dependency +buildTreeEdges artifact = + let parent = coordToDep (treeCoord artifact) + children = map coordToDep (treeDeps artifact) + edgeGraphs = map (Graphing.edge parent) children + in mconcat edgeGraphs + +-- | Build graph from v1 artifacts map (no transitive info). +buildFromArtifacts :: Map Text MavenArtifactInfo -> Graphing Dependency +buildFromArtifacts artifacts = + Graphing.directs (map toArtifactDep (Map.toList artifacts)) + where + toArtifactDep :: (Text, MavenArtifactInfo) -> Dependency + toArtifactDep (coordKey, info) = + Dependency + { dependencyType = MavenType + , dependencyName = coordKey + , dependencyVersion = Just (CEq (artifactVersion info)) + , dependencyLocations = [] + , dependencyEnvironments = mempty + , dependencyTags = Map.empty + } + +-- | Parse a Maven coordinate string like "group:artifact:version" or +-- "group:artifact:packaging:version" into a Dependency. +coordToDep :: Text -> Dependency +coordToDep coord = + let parts = Text.splitOn ":" coord + (name, version) = case parts of + -- group:artifact:version + [g, a, v] -> (g <> ":" <> a, Just (CEq v)) + -- group:artifact:packaging:version + [g, a, _p, v] -> (g <> ":" <> a, Just (CEq v)) + -- group:artifact:packaging:classifier:version + [g, a, _p, _c, v] -> (g <> ":" <> a, Just (CEq v)) + -- group:artifact (no version) + [g, a] -> (g <> ":" <> a, Nothing) + -- Fallback: use the whole string as name + _ -> (coord, Nothing) + in Dependency + { dependencyType = MavenType + , dependencyName = name + , dependencyVersion = version + , dependencyLocations = [] + , dependencyEnvironments = mempty + , dependencyTags = Map.empty + } diff --git a/src/Strategy/Bazel/ModuleBazel.hs b/src/Strategy/Bazel/ModuleBazel.hs new file mode 100644 index 000000000..fbb45fdd6 --- /dev/null +++ b/src/Strategy/Bazel/ModuleBazel.hs @@ -0,0 +1,477 @@ +module Strategy.Bazel.ModuleBazel ( + BazelModuleFile (..), + BazelDep (..), + ModuleInfo (..), + moduleBazelParser, + buildBazelGraph, +) where + +import Control.Applicative (empty) +import Data.Foldable (asum) +import Data.Map.Strict qualified as Map +import Data.Maybe (catMaybes, fromMaybe, mapMaybe) +import Data.Set qualified as Set +import Data.Text (Text) +import Data.Text qualified as Text +import Data.Void (Void) +import DepTypes ( + DepType (BazelType, MavenType), + Dependency (..), + VerConstraint (CEq), + ) +import Graphing (Graphing) +import Graphing qualified +import Text.Megaparsec ( + MonadParsec (eof, lookAhead, notFollowedBy, try), + Parsec, + anySingle, + between, + choice, + many, + optional, + sepEndBy, + skipMany, + skipManyTill, + skipSome, + (<|>), + ) +import Text.Megaparsec.Char ( + alphaNumChar, + char, + letterChar, + space1, + ) +import Text.Megaparsec.Char.Lexer qualified as L + +type Parser = Parsec Void Text + +-- | Space consumer that skips whitespace and # line comments. +sc :: Parser () +sc = L.space space1 (L.skipLineComment "#") empty + +-- | Parse a lexeme, consuming trailing whitespace. +lexeme :: Parser a -> Parser a +lexeme = L.lexeme sc + +-- | Parse a symbol, consuming trailing whitespace. +symbol :: Text -> Parser Text +symbol = L.symbol sc + +-- | Data types for MODULE.bazel parse results. +data BazelModuleFile = BazelModuleFile + { moduleInfo :: Maybe ModuleInfo + , bazelDeps :: [BazelDep] + , mavenArtifacts :: [Text] + , mavenRepositories :: [Text] + } + deriving (Eq, Ord, Show) + +data ModuleInfo = ModuleInfo + { moduleName :: Text + , moduleVersion :: Text + } + deriving (Eq, Ord, Show) + +data BazelDep = BazelDep + { depName :: Text + , depVersion :: Text + , depRepoName :: Maybe Text + } + deriving (Eq, Ord, Show) + +-- Internal types for parsing +data Statement + = StmtModule ModuleInfo + | StmtBazelDep BazelDep + | StmtVarAssign Text StarlarkValue + | StmtUseExtension Text Text + -- ^ variable name, extension label + | StmtExtensionCall Text Text [(Text, StarlarkValue)] + -- ^ variable name, method name, keyword args + | StmtOther + deriving (Eq, Ord, Show) + +data StarlarkValue + = SString Text + | SList [Text] + | SBool Bool + | SInt Int + | SIdentifier Text + deriving (Eq, Ord, Show) + +-- | Parse the entire MODULE.bazel file. +moduleBazelParser :: Parser BazelModuleFile +moduleBazelParser = do + sc + stmts <- many (statement <* sc) + eof + let vars = Map.fromList [(n, v) | StmtVarAssign n v <- stmts] + extVars = Map.fromList [(n, lbl) | StmtUseExtension n lbl <- stmts] + modInfo = firstOf [m | StmtModule m <- stmts] + deps = [d | StmtBazelDep d <- stmts] + extCalls = [(v, method, args) | StmtExtensionCall v method args <- stmts] + mavenExts = Map.keysSet $ Map.filter isMavenExt extVars + mavenCalls = [(method, args) | (v, method, args) <- extCalls, Set.member v mavenExts] + artifacts = concatMap (extractMavenArtifacts vars) mavenCalls + repos = concatMap (extractMavenRepos vars) mavenCalls + pure + BazelModuleFile + { moduleInfo = modInfo + , bazelDeps = deps + , mavenArtifacts = artifacts + , mavenRepositories = repos + } + where + firstOf [] = Nothing + firstOf (x : _) = Just x + + isMavenExt lbl = + Text.isInfixOf "rules_jvm_external" lbl + || Text.isInfixOf "maven" lbl + + extractMavenArtifacts :: Map.Map Text StarlarkValue -> (Text, [(Text, StarlarkValue)]) -> [Text] + extractMavenArtifacts vars (_method, args) = + case lookup "artifacts" args of + Just (SList xs) -> xs + Just (SIdentifier ident) -> case Map.lookup ident vars of + Just (SList xs) -> xs + _ -> [] + _ -> [] + + extractMavenRepos :: Map.Map Text StarlarkValue -> (Text, [(Text, StarlarkValue)]) -> [Text] + extractMavenRepos vars (_method, args) = + case lookup "repositories" args of + Just (SList xs) -> xs + Just (SIdentifier ident) -> case Map.lookup ident vars of + Just (SList xs) -> xs + _ -> [] + _ -> [] + +-- | Parse a single top-level statement. +statement :: Parser Statement +statement = + choice + [ try moduleStatement + , try bazelDepStatement + , try useExtensionStatement + , try extensionCallStatement + , try varAssignStatement + , otherStatement + ] + +-- | Parse: module(name = "...", version = "...") +moduleStatement :: Parser Statement +moduleStatement = do + _ <- symbol "module" + args <- parens keywordArgs + let name = lookupString "name" args + ver = lookupString "version" args + case (name, ver) of + (Just n, Just v) -> pure $ StmtModule (ModuleInfo n v) + _ -> pure StmtOther + +-- | Parse: bazel_dep(name = "...", version = "...", ...) +bazelDepStatement :: Parser Statement +bazelDepStatement = do + _ <- symbol "bazel_dep" + args <- parens keywordArgs + case lookupString "name" args of + Nothing -> pure StmtOther + Just name -> do + let ver = lookupString "version" args + repo = lookupString "repo_name" args + pure $ + StmtBazelDep + BazelDep + { depName = name + , depVersion = fromMaybe "" ver + , depRepoName = repo + } + +-- | Parse: var = use_extension("label", "name") +useExtensionStatement :: Parser Statement +useExtensionStatement = do + varName <- try $ do + n <- identifier + _ <- symbol "=" + _ <- symbol "use_extension" + pure n + args <- parens positionalAndKeywordArgs + case args of + (lbl : _, _) -> pure $ StmtUseExtension varName (starlarkToText lbl) + _ -> pure StmtOther + +-- | Parse: maven.install(artifacts = [...], ...) +extensionCallStatement :: Parser Statement +extensionCallStatement = do + (varName, methodName) <- try $ do + v <- identifier + _ <- char '.' + m <- identifier + pure (v, m) + args <- parens keywordArgs + pure $ StmtExtensionCall varName methodName args + +-- | Parse: VAR_NAME = value +varAssignStatement :: Parser Statement +varAssignStatement = do + (name, val) <- try $ do + n <- identifier + _ <- symbol "=" + v <- starlarkValue + pure (n, v) + pure $ StmtVarAssign name val + +-- | Skip an unrecognized top-level statement (function call or other). +otherStatement :: Parser Statement +otherStatement = do + _ <- bareString <|> bareList <|> identifierStatement + pure StmtOther + where + -- Bare string literal at top level (e.g. docstrings like "bazel-contrib/rules_oci") + bareString = quotedString *> pure () + + -- Bare list at top level (e.g. list comprehensions like [maven.artifact(...) for ...]) + bareList = between (symbol "[") (symbol "]") (skipBalancedBrackets 0) *> pure () + + -- Identifier-led statement: function call, assignment, or other. + -- Handles: func(...), ident.method(...), ident = value, ident + identifierStatement = do + _ <- identifier <|> (Text.singleton <$> char '@') + -- After the initial identifier, consume either: + -- 1. A direct function call: (...) + -- 2. A method chain: .method(...) + -- 3. Nothing (bare identifier) + skipMany (try methodCallChain <|> try directParens) + + -- Direct parenthesized args: (...) + directParens = do + _ <- parens (skipBalancedParens 0) + pure () + + -- Method call chain: .identifier(...) or .identifier + methodCallChain = do + _ <- char '.' + _ <- identifier + _ <- optional (parens (skipBalancedParens 0)) + pure () + + +-- | Parse keyword arguments: name = value, ... +keywordArgs :: Parser [(Text, StarlarkValue)] +keywordArgs = catMaybes <$> arg `sepEndBy` symbol "," + where + arg = + optional $ + try $ do + name <- identifier + _ <- symbol "=" + val <- starlarkValue + pure (name, val) + +-- | Parse positional and keyword arguments. +positionalAndKeywordArgs :: Parser ([StarlarkValue], [(Text, StarlarkValue)]) +positionalAndKeywordArgs = do + items <- argItem `sepEndBy` symbol "," + let positional = [v | Left v <- items] + keyword = [(k, v) | Right (k, v) <- items] + pure (positional, keyword) + where + argItem = + try (Right <$> kwArg) <|> (Left <$> starlarkValue) + kwArg = do + name <- identifier + _ <- symbol "=" + val <- starlarkValue + pure (name, val) + +-- | Parse a Starlark value (string, list, bool, integer, identifier). +-- After parsing a base value, optionally consume trailing method calls +-- (.format(...), .replace(...)) and binary operators (+, %) that are +-- common in real MODULE.bazel files. +starlarkValue :: Parser StarlarkValue +starlarkValue = do + base <- + choice + [ SList <$> starlarkList + , SString <$> quotedString + , SBool True <$ symbol "True" + , SBool False <$ symbol "False" + , SInt <$> try (lexeme L.decimal) + , parenExpr + , identOrCall + ] + -- Consume optional trailing method calls and operators. + -- This handles patterns like "str".format(...), x + y, "str" % val, etc. + -- We don't try to evaluate them; we just consume the tokens. + skipMany (try methodCall <|> try binaryOp) + pure base + where + -- Parenthesized expression: (...) — consumed but not evaluated + parenExpr = parens (skipBalancedParens 0) *> pure (SString "") + -- An identifier optionally followed by a function call: foo(...) or just foo + identOrCall = do + name <- identifier + _ <- optional (parens (skipBalancedParens 0)) + pure (SIdentifier name) + methodCall = do + _ <- char '.' + _ <- identifier + _ <- parens (skipBalancedParens 0) + pure () + binaryOp = do + _ <- symbol "+" <|> symbol "%" + _ <- starlarkValue + pure () + +-- | Parse a list: [expr, expr, ...] +-- Handles both simple string lists and lists with complex expressions. +-- Uses balanced bracket counting to handle nested expressions. +starlarkList :: Parser [Text] +starlarkList = do + _ <- symbol "[" + items <- listItem `sepEndBy` symbol "," + _ <- symbol "]" + pure (catMaybes items) + where + listItem = do + -- Try to parse as a simple quoted string first + (Just <$> try quotedString) + -- If that fails, skip a complex expression and return Nothing + <|> (skipComplexExpr *> pure Nothing) + + -- Skip a complex expression (e.g. "str".format(...), x + y, comprehensions) + -- by consuming tokens until we hit a comma or closing bracket at depth 0 + skipComplexExpr :: Parser () + skipComplexExpr = skipSome (notFollowedBy (symbol "," <|> symbol "]") *> skipExprToken) + + skipExprToken :: Parser () + skipExprToken = + choice + [ quotedString *> pure () + , between (symbol "(") (symbol ")") (skipBalancedParens 0) *> pure () + , between (symbol "[") (symbol "]") (skipBalancedBrackets 0) *> pure () + , identifier *> pure () + , lexeme (L.decimal :: Parser Int) *> pure () + , lexeme (char '.' <|> char '+' <|> char '%' <|> char '-' <|> char '*') *> pure () + ] + +-- | Skip content inside balanced parentheses (stops before the closing ')'). +-- Call this after consuming '(' and before consuming ')'. +skipBalancedParens :: Int -> Parser () +skipBalancedParens _ = skipMany oneItem + where + oneItem = + choice + [ char '(' *> skipBalancedParens 0 *> char ')' *> pure () + , quotedString *> pure () + , do + notFollowedBy (char '(' <|> char ')') + _ <- anySingle + pure () + ] + +-- | Skip content inside balanced brackets (stops before the closing ']'). +skipBalancedBrackets :: Int -> Parser () +skipBalancedBrackets _ = skipMany oneItem + where + oneItem = + choice + [ char '[' *> skipBalancedBrackets 0 *> char ']' *> pure () + , quotedString *> pure () + , do + notFollowedBy (char '[' <|> char ']') + _ <- anySingle + pure () + ] + +-- | Parse a quoted string (single or double quotes). +quotedString :: Parser Text +quotedString = lexeme (doubleQuoted <|> singleQuoted) + where + doubleQuoted = between (char '"') (char '"') (Text.pack <$> many (stringChar '"')) + singleQuoted = between (char '\'') (char '\'') (Text.pack <$> many (stringChar '\'')) + stringChar quoteChar = + try (char '\\' *> anySingle) + <|> asum + [ char c + | c <- [' ' .. '~'] + , c /= quoteChar + , c /= '\\' + ] + +-- | Parse an identifier: [a-zA-Z_][a-zA-Z0-9_]* +identifier :: Parser Text +identifier = + lexeme $ + Text.pack <$> do + first <- letterChar <|> char '_' + rest <- many (alphaNumChar <|> char '_') + pure (first : rest) + +-- | Parse content between parentheses. +parens :: Parser a -> Parser a +parens = between (symbol "(") (symbol ")") + +-- Helper to lookup a string value from keyword args. +lookupString :: Text -> [(Text, StarlarkValue)] -> Maybe Text +lookupString key args = case lookup key args of + Just (SString s) -> Just s + _ -> Nothing + +-- Helper to convert StarlarkValue to Text. +starlarkToText :: StarlarkValue -> Text +starlarkToText (SString t) = t +starlarkToText (SIdentifier t) = t +starlarkToText _ = "" + +-- | Build a dependency graph from parsed MODULE.bazel data. +-- Maven install JSON (if provided) is handled separately. +buildBazelGraph :: BazelModuleFile -> Graphing Dependency +buildBazelGraph moduleFile = + bazelDepGraph <> mavenArtifactGraph + where + bazelDepGraph = + Graphing.directs (map toBazelDep (bazelDeps moduleFile)) + + toBazelDep :: BazelDep -> Dependency + toBazelDep dep = + Dependency + { dependencyType = BazelType + , dependencyName = depName dep + , dependencyVersion = + if Text.null (depVersion dep) + then Nothing + else Just (CEq (depVersion dep)) + , dependencyLocations = [] + , dependencyEnvironments = mempty + , dependencyTags = Map.empty + } + + mavenArtifactGraph = + Graphing.directs (mapMaybe parseMavenCoordinate (mavenArtifacts moduleFile)) + + parseMavenCoordinate :: Text -> Maybe Dependency + parseMavenCoordinate coord = + case Text.splitOn ":" coord of + [group, artifact, version] -> + Just + Dependency + { dependencyType = MavenType + , dependencyName = group <> ":" <> artifact + , dependencyVersion = Just (CEq version) + , dependencyLocations = [] + , dependencyEnvironments = mempty + , dependencyTags = Map.empty + } + [group, artifact] -> + Just + Dependency + { dependencyType = MavenType + , dependencyName = group <> ":" <> artifact + , dependencyVersion = Nothing + , dependencyLocations = [] + , dependencyEnvironments = mempty + , dependencyTags = Map.empty + } + _ -> Nothing diff --git a/src/Types.hs b/src/Types.hs index 416face72..d912d9180 100644 --- a/src/Types.hs +++ b/src/Types.hs @@ -66,6 +66,7 @@ instance Monoid FoundTargets where data DiscoveredProjectType = AlpineDatabaseProjectType | BerkeleyDBProjectType + | BazelProjectType | BinaryDepsProjectType | BundlerProjectType | CabalProjectType @@ -118,6 +119,7 @@ projectTypeToText :: DiscoveredProjectType -> Text projectTypeToText = \case AlpineDatabaseProjectType -> "apkdb" BerkeleyDBProjectType -> "berkeleydb" + BazelProjectType -> "bazel" BinaryDepsProjectType -> "binary-deps" BundlerProjectType -> "bundler" CabalProjectType -> "cabal" diff --git a/test/Bazel/BazelModGraphSpec.hs b/test/Bazel/BazelModGraphSpec.hs new file mode 100644 index 000000000..e0530879a --- /dev/null +++ b/test/Bazel/BazelModGraphSpec.hs @@ -0,0 +1,135 @@ +module Bazel.BazelModGraphSpec (spec) where + +import Data.Aeson qualified as Aeson +import Data.ByteString.Lazy qualified as BL +import Data.Map.Strict qualified as Map +import Data.Text (Text) +import DepTypes (DepType (BazelType), Dependency (..), VerConstraint (CEq)) +import GraphUtil (expectDeps, expectDirect, expectEdges) +import Strategy.Bazel.BazelModGraph ( + BazelModGraphJson (..), + BazelModGraphNode (..), + buildModGraphDeps, + ) +import Test.Hspec (Spec, describe, it, runIO, shouldBe) + +mkDep :: Text -> Text -> Dependency +mkDep name ver = + Dependency + { dependencyType = BazelType + , dependencyName = name + , dependencyVersion = Just (CEq ver) + , dependencyLocations = [] + , dependencyEnvironments = mempty + , dependencyTags = Map.empty + } + +spec :: Spec +spec = do + spec_parse + spec_buildGraph + spec_edgeCases + +spec_parse :: Spec +spec_parse = do + jsonInput <- runIO (BL.readFile "test/Bazel/testdata/bazel_mod_graph.json") + describe "bazel mod graph JSON parser" $ do + it "should parse the JSON output" $ do + case Aeson.eitherDecode jsonInput of + Left err -> fail err + Right parsed -> do + modGraphKey parsed `shouldBe` "my_project" + modGraphVersion parsed `shouldBe` "1.0.0" + length (modGraphDeps parsed) `shouldBe` 2 + + it "should parse nested dependencies" $ do + case Aeson.eitherDecode jsonInput of + Left err -> fail err + Right parsed -> do + case modGraphDeps parsed of + (rulesGo : _) -> do + nodeKey rulesGo `shouldBe` "rules_go" + length (nodeDeps rulesGo) `shouldBe` 1 + [] -> fail "Expected at least one dependency" + +spec_buildGraph :: Spec +spec_buildGraph = do + jsonInput <- runIO (BL.readFile "test/Bazel/testdata/bazel_mod_graph.json") + describe "bazel mod graph graph building" $ do + it "should mark root deps as direct" $ do + case Aeson.eitherDecode jsonInput of + Left err -> fail err + Right parsed -> do + let graph = buildModGraphDeps parsed + expectDirect + [ mkDep "rules_go" "0.57.0" + , mkDep "protobuf" "29.3" + ] + graph + + it "should include transitive deps" $ do + case Aeson.eitherDecode jsonInput of + Left err -> fail err + Right parsed -> do + let graph = buildModGraphDeps parsed + expectDeps + [ mkDep "rules_go" "0.57.0" + , mkDep "protobuf" "29.3" + , mkDep "platforms" "0.0.10" + , mkDep "abseil-cpp" "20240722.0" + ] + graph + + it "should have correct edges" $ do + case Aeson.eitherDecode jsonInput of + Left err -> fail err + Right parsed -> do + let graph = buildModGraphDeps parsed + rulesGo = mkDep "rules_go" "0.57.0" + protobuf = mkDep "protobuf" "29.3" + platforms = mkDep "platforms" "0.0.10" + abseil = mkDep "abseil-cpp" "20240722.0" + expectEdges + [ (rulesGo, platforms) + , (protobuf, platforms) + , (protobuf, abseil) + ] + graph + +spec_edgeCases :: Spec +spec_edgeCases = describe "bazel mod graph edge cases" $ do + it "should handle empty dependencies" $ do + let input = + BazelModGraphJson + { modGraphKey = "empty_project" + , modGraphVersion = "0.1.0" + , modGraphDeps = [] + } + graph = buildModGraphDeps input + expectDeps [] graph + expectDirect [] graph + + it "should handle nodes with no version" $ do + let input = + BazelModGraphJson + { modGraphKey = "root" + , modGraphVersion = "" + , modGraphDeps = + [ BazelModGraphNode + { nodeKey = "some_dep" + , nodeVersion = "" + , nodeDeps = [] + } + ] + } + graph = buildModGraphDeps input + depNoVer = + Dependency + { dependencyType = BazelType + , dependencyName = "some_dep" + , dependencyVersion = Nothing + , dependencyLocations = [] + , dependencyEnvironments = mempty + , dependencyTags = Map.empty + } + expectDirect [depNoVer] graph diff --git a/test/Bazel/MavenInstallSpec.hs b/test/Bazel/MavenInstallSpec.hs new file mode 100644 index 000000000..453ba1379 --- /dev/null +++ b/test/Bazel/MavenInstallSpec.hs @@ -0,0 +1,139 @@ +module Bazel.MavenInstallSpec (spec) where + +import Data.Aeson qualified as Aeson +import Data.ByteString.Lazy qualified as BL +import Data.Map.Strict qualified as Map +import Data.Text (Text) +import DepTypes (DepType (MavenType), Dependency (..), VerConstraint (CEq)) +import GraphUtil (expectDeps, expectDirect, expectEdges) +import Strategy.Bazel.MavenInstall ( + MavenArtifactInfo (..), + MavenDependencyTree (..), + MavenInstallJson (..), + TreeArtifact (..), + buildMavenInstallGraph, + ) +import Test.Hspec (Spec, describe, it, runIO, shouldSatisfy) + +mkMavenDep :: Text -> Text -> Dependency +mkMavenDep name ver = + Dependency + { dependencyType = MavenType + , dependencyName = name + , dependencyVersion = Just (CEq ver) + , dependencyLocations = [] + , dependencyEnvironments = mempty + , dependencyTags = Map.empty + } + +guava :: Dependency +guava = mkMavenDep "com.google.guava:guava" "33.4.0-jre" + +failureaccess :: Dependency +failureaccess = mkMavenDep "com.google.guava:failureaccess" "1.0.2" + +listenablefuture :: Dependency +listenablefuture = mkMavenDep "com.google.guava:listenablefuture" "9999.0-empty-to-avoid-conflict-with-guava" + +junit :: Dependency +junit = mkMavenDep "junit:junit" "4.13.2" + +hamcrest :: Dependency +hamcrest = mkMavenDep "org.hamcrest:hamcrest-core" "1.3" + +slf4j :: Dependency +slf4j = mkMavenDep "org.slf4j:slf4j-api" "2.0.16" + +spec :: Spec +spec = do + spec_parse + spec_buildGraph + spec_v1Fallback + spec_edgeCases + +spec_parse :: Spec +spec_parse = do + jsonInput <- runIO (BL.readFile "test/Bazel/testdata/maven_install.json") + describe "maven_install.json parser" $ do + it "should parse the dependency_tree format" $ do + case Aeson.eitherDecode jsonInput of + Left err -> fail err + Right (parsed :: MavenInstallJson) -> + mavenDependencyTree parsed `shouldSatisfy` \case + Just _ -> True + Nothing -> False + +spec_buildGraph :: Spec +spec_buildGraph = do + jsonInput <- runIO (BL.readFile "test/Bazel/testdata/maven_install.json") + describe "maven_install.json graph building" $ do + it "should include all 6 artifacts" $ do + case Aeson.eitherDecode jsonInput of + Left err -> fail err + Right parsed -> + expectDeps [guava, failureaccess, listenablefuture, junit, hamcrest, slf4j] (buildMavenInstallGraph parsed) + + it "should mark root artifacts as direct" $ do + case Aeson.eitherDecode jsonInput of + Left err -> fail err + Right parsed -> + expectDirect [guava, junit, slf4j] (buildMavenInstallGraph parsed) + + it "should have correct transitive edges" $ do + case Aeson.eitherDecode jsonInput of + Left err -> fail err + Right parsed -> + expectEdges + [ (guava, failureaccess) + , (guava, listenablefuture) + , (junit, hamcrest) + ] + (buildMavenInstallGraph parsed) + +spec_v1Fallback :: Spec +spec_v1Fallback = describe "maven_install.json v1 format (artifacts map)" $ do + it "should build graph from artifacts map when no dependency_tree" $ do + let v1Json = + MavenInstallJson + { mavenArtifacts = + Map.fromList + [ ("com.google.guava:guava", MavenArtifactInfo "33.4.0-jre") + , ("junit:junit", MavenArtifactInfo "4.13.2") + ] + , mavenDependencyTree = Nothing + } + graph = buildMavenInstallGraph v1Json + expectDirect [guava, junit] graph + -- v1 has no edge info + expectEdges [] graph + +spec_edgeCases :: Spec +spec_edgeCases = describe "maven_install.json edge cases" $ do + it "should handle empty lockfile" $ do + let emptyJson = + MavenInstallJson + { mavenArtifacts = Map.empty + , mavenDependencyTree = Nothing + } + graph = buildMavenInstallGraph emptyJson + expectDeps [] graph + + it "should parse coordinates with packaging (group:artifact:jar:version)" $ do + let result = buildMavenInstallGraph $ jsonWithCoord "com.example:lib:jar:2.0" + expectDirect [mkMavenDep "com.example:lib" "2.0"] result + + it "should parse coordinates with packaging and classifier (group:artifact:jar:sources:version)" $ do + let result = buildMavenInstallGraph $ jsonWithCoord "com.example:lib:jar:sources:3.0" + expectDirect [mkMavenDep "com.example:lib" "3.0"] result + +-- Helper to create a MavenInstallJson with a single coord in the dependency tree. +jsonWithCoord :: Text -> MavenInstallJson +jsonWithCoord coord = + MavenInstallJson + { mavenArtifacts = Map.empty + , mavenDependencyTree = + Just + MavenDependencyTree + { treeArtifacts = [TreeArtifact coord []] + } + } diff --git a/test/Bazel/ModuleBazelSpec.hs b/test/Bazel/ModuleBazelSpec.hs new file mode 100644 index 000000000..087a75dc6 --- /dev/null +++ b/test/Bazel/ModuleBazelSpec.hs @@ -0,0 +1,161 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Bazel.ModuleBazelSpec (spec) where + +import Data.Map.Strict qualified as Map +import Data.Text.IO qualified as TIO +import DepTypes (DepType (BazelType, MavenType), Dependency (..), VerConstraint (CEq)) +import Graphing qualified +import Strategy.Bazel.ModuleBazel ( + BazelDep (..), + BazelModuleFile (..), + ModuleInfo (..), + buildBazelGraph, + moduleBazelParser, + ) +import Test.Hspec (Spec, describe, it, runIO, shouldBe) +import Test.Hspec.Megaparsec (shouldParse) +import Text.Megaparsec (runParser) +import Text.RawString.QQ (r) + +spec :: Spec +spec = do + spec_parseSimple + spec_parseMaven + spec_parseVariables + spec_parseInline + spec_buildGraph + +spec_parseSimple :: Spec +spec_parseSimple = do + simpleInput <- runIO (TIO.readFile "test/Bazel/testdata/MODULE.bazel.simple") + describe "MODULE.bazel simple parser" $ do + it "should parse a simple MODULE.bazel with bazel_dep entries" $ do + runParser moduleBazelParser "" simpleInput + `shouldParse` BazelModuleFile + { moduleInfo = Just (ModuleInfo "my_project" "1.0.0") + , bazelDeps = + [ BazelDep "rules_go" "0.57.0" Nothing + , BazelDep "gazelle" "0.42.0" (Just "bazel_gazelle") + , BazelDep "protobuf" "29.3" Nothing + ] + , mavenArtifacts = [] + , mavenRepositories = [] + } + +spec_parseMaven :: Spec +spec_parseMaven = do + mavenInput <- runIO (TIO.readFile "test/Bazel/testdata/MODULE.bazel.maven") + describe "MODULE.bazel maven parser" $ do + it "should parse MODULE.bazel with maven extension" $ do + let result = runParser moduleBazelParser "" mavenInput + case result of + Left err -> fail (show err) + Right parsed -> do + moduleInfo parsed `shouldBe` Just (ModuleInfo "my_java_project" "2.0.0") + bazelDeps parsed `shouldBe` [BazelDep "rules_jvm_external" "6.6" Nothing] + mavenArtifacts parsed + `shouldBe` [ "com.google.guava:guava:33.4.0-jre" + , "junit:junit:4.13.2" + , "org.slf4j:slf4j-api:2.0.16" + ] + mavenRepositories parsed + `shouldBe` [ "https://repo1.maven.org/maven2" + , "https://maven.google.com" + ] + +spec_parseVariables :: Spec +spec_parseVariables = do + variablesInput <- runIO (TIO.readFile "test/Bazel/testdata/MODULE.bazel.variables") + describe "MODULE.bazel variable substitution" $ do + it "should resolve named constant lists" $ do + let result = runParser moduleBazelParser "" variablesInput + case result of + Left err -> fail (show err) + Right parsed -> do + moduleInfo parsed `shouldBe` Just (ModuleInfo "my_lib" "0.5.0") + mavenArtifacts parsed + `shouldBe` [ "com.google.guava:guava:33.4.0-jre" + , "com.google.protobuf:protobuf-java:4.29.3" + ] + mavenRepositories parsed `shouldBe` ["https://repo1.maven.org/maven2"] + +spec_parseInline :: Spec +spec_parseInline = describe "MODULE.bazel inline parsing" $ do + it "should parse a minimal bazel_dep" $ do + let input = [r|bazel_dep(name = "foo", version = "1.0") +|] + runParser moduleBazelParser "" input + `shouldParse` BazelModuleFile + { moduleInfo = Nothing + , bazelDeps = [BazelDep "foo" "1.0" Nothing] + , mavenArtifacts = [] + , mavenRepositories = [] + } + + it "should handle comments" $ do + let input = + [r|# This is a comment +module(name = "test", version = "0.1") +# Another comment +bazel_dep(name = "bar", version = "2.0") +|] + runParser moduleBazelParser "" input + `shouldParse` BazelModuleFile + { moduleInfo = Just (ModuleInfo "test" "0.1") + , bazelDeps = [BazelDep "bar" "2.0" Nothing] + , mavenArtifacts = [] + , mavenRepositories = [] + } + + it "should parse empty file" $ do + runParser moduleBazelParser "" "" + `shouldParse` BazelModuleFile + { moduleInfo = Nothing + , bazelDeps = [] + , mavenArtifacts = [] + , mavenRepositories = [] + } + +spec_buildGraph :: Spec +spec_buildGraph = describe "buildBazelGraph" $ do + it "should build graph with BazelType deps from bazel_dep entries" $ do + let moduleFile = + BazelModuleFile + { moduleInfo = Just (ModuleInfo "my_project" "1.0.0") + , bazelDeps = [BazelDep "rules_go" "0.57.0" Nothing] + , mavenArtifacts = [] + , mavenRepositories = [] + } + expected = + Graphing.directs + [ Dependency + { dependencyType = BazelType + , dependencyName = "rules_go" + , dependencyVersion = Just (CEq "0.57.0") + , dependencyLocations = [] + , dependencyEnvironments = mempty + , dependencyTags = Map.empty + } + ] + buildBazelGraph moduleFile `shouldBe` expected + + it "should build graph with MavenType deps from maven artifacts" $ do + let moduleFile = + BazelModuleFile + { moduleInfo = Nothing + , bazelDeps = [] + , mavenArtifacts = ["com.google.guava:guava:33.4.0-jre"] + , mavenRepositories = [] + } + graph = buildBazelGraph moduleFile + Graphing.directList graph + `shouldBe` [ Dependency + { dependencyType = MavenType + , dependencyName = "com.google.guava:guava" + , dependencyVersion = Just (CEq "33.4.0-jre") + , dependencyLocations = [] + , dependencyEnvironments = mempty + , dependencyTags = Map.empty + } + ] diff --git a/test/Bazel/testdata/MODULE.bazel.maven b/test/Bazel/testdata/MODULE.bazel.maven new file mode 100644 index 000000000..f199b28aa --- /dev/null +++ b/test/Bazel/testdata/MODULE.bazel.maven @@ -0,0 +1,21 @@ +# MODULE.bazel with maven extension +module( + name = "my_java_project", + version = "2.0.0", +) + +bazel_dep(name = "rules_jvm_external", version = "6.6") + +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +maven.install( + artifacts = [ + "com.google.guava:guava:33.4.0-jre", + "junit:junit:4.13.2", + "org.slf4j:slf4j-api:2.0.16", + ], + repositories = [ + "https://repo1.maven.org/maven2", + "https://maven.google.com", + ], +) +use_repo(maven, "maven") diff --git a/test/Bazel/testdata/MODULE.bazel.simple b/test/Bazel/testdata/MODULE.bazel.simple new file mode 100644 index 000000000..20ad6c36d --- /dev/null +++ b/test/Bazel/testdata/MODULE.bazel.simple @@ -0,0 +1,9 @@ +# A simple MODULE.bazel with only bazel_dep entries +module( + name = "my_project", + version = "1.0.0", +) + +bazel_dep(name = "rules_go", version = "0.57.0") +bazel_dep(name = "gazelle", version = "0.42.0", repo_name = "bazel_gazelle") +bazel_dep(name = "protobuf", version = "29.3") diff --git a/test/Bazel/testdata/MODULE.bazel.variables b/test/Bazel/testdata/MODULE.bazel.variables new file mode 100644 index 000000000..e085d062b --- /dev/null +++ b/test/Bazel/testdata/MODULE.bazel.variables @@ -0,0 +1,23 @@ +# MODULE.bazel with named constant lists +module( + name = "my_lib", + version = "0.5.0", +) + +bazel_dep(name = "rules_jvm_external", version = "6.6") + +ARTIFACTS = [ + "com.google.guava:guava:33.4.0-jre", + "com.google.protobuf:protobuf-java:4.29.3", +] + +REPOS = [ + "https://repo1.maven.org/maven2", +] + +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +maven.install( + artifacts = ARTIFACTS, + repositories = REPOS, +) +use_repo(maven, "maven") diff --git a/test/Bazel/testdata/bazel_mod_graph.json b/test/Bazel/testdata/bazel_mod_graph.json new file mode 100644 index 000000000..c4275f175 --- /dev/null +++ b/test/Bazel/testdata/bazel_mod_graph.json @@ -0,0 +1,33 @@ +{ + "key": "my_project", + "version": "1.0.0", + "dependencies": [ + { + "key": "rules_go", + "version": "0.57.0", + "dependencies": [ + { + "key": "platforms", + "version": "0.0.10", + "dependencies": [] + } + ] + }, + { + "key": "protobuf", + "version": "29.3", + "dependencies": [ + { + "key": "platforms", + "version": "0.0.10", + "dependencies": [] + }, + { + "key": "abseil-cpp", + "version": "20240722.0", + "dependencies": [] + } + ] + } + ] +} diff --git a/test/Bazel/testdata/maven_install.json b/test/Bazel/testdata/maven_install.json new file mode 100644 index 000000000..46e21f525 --- /dev/null +++ b/test/Bazel/testdata/maven_install.json @@ -0,0 +1,35 @@ +{ + "dependency_tree": { + "artifacts": [ + { + "coord": "com.google.guava:guava:33.4.0-jre", + "dependencies": [ + "com.google.guava:failureaccess:1.0.2", + "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava" + ] + }, + { + "coord": "com.google.guava:failureaccess:1.0.2", + "dependencies": [] + }, + { + "coord": "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava", + "dependencies": [] + }, + { + "coord": "junit:junit:4.13.2", + "dependencies": [ + "org.hamcrest:hamcrest-core:1.3" + ] + }, + { + "coord": "org.hamcrest:hamcrest-core:1.3", + "dependencies": [] + }, + { + "coord": "org.slf4j:slf4j-api:2.0.16", + "dependencies": [] + } + ] + } +}