diff --git a/cli/azd/internal/appdetect/appdetect.go b/cli/azd/internal/appdetect/appdetect.go
index e6103d3cbd7..e05222a9ca3 100644
--- a/cli/azd/internal/appdetect/appdetect.go
+++ b/cli/azd/internal/appdetect/appdetect.go
@@ -14,6 +14,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet"
+ "github.com/azure/azure-dev/cli/azd/pkg/tools/maven"
"github.com/bmatcuk/doublestar/v4"
)
@@ -179,7 +180,8 @@ type projectDetector interface {
var allDetectors = []projectDetector{
// Order here determines precedence when two projects are in the same directory.
// This is unlikely to occur in practice, but reordering could help to break the tie in these cases.
- &javaDetector{},
+ &javaDetector{
+ mvnCli: maven.NewCli(exec.NewCommandRunner(nil))},
&dotNetAppHostDetector{
// TODO(ellismg): Remove ambient authority.
dotnetCli: dotnet.NewCli(exec.NewCommandRunner(nil)),
diff --git a/cli/azd/internal/appdetect/java.go b/cli/azd/internal/appdetect/java.go
index fe6fec3ea65..cc4e4fc49f9 100644
--- a/cli/azd/internal/appdetect/java.go
+++ b/cli/azd/internal/appdetect/java.go
@@ -2,18 +2,16 @@ package appdetect
import (
"context"
- "encoding/xml"
"fmt"
"io/fs"
- "maps"
- "os"
"path/filepath"
- "slices"
"strings"
+
+ "github.com/azure/azure-dev/cli/azd/pkg/tools/maven"
)
type javaDetector struct {
- rootProjects []mavenProject
+ mvnCli *maven.Cli
}
func (jd *javaDetector) Language() Language {
@@ -23,28 +21,18 @@ func (jd *javaDetector) Language() Language {
func (jd *javaDetector) DetectProject(ctx context.Context, path string, entries []fs.DirEntry) (*Project, error) {
for _, entry := range entries {
if strings.ToLower(entry.Name()) == "pom.xml" {
- pomFile := filepath.Join(path, entry.Name())
- project, err := readMavenProject(pomFile)
+ pomFilePath := filepath.Join(path, entry.Name())
+ project, err := toMavenProject(ctx, jd.mvnCli, pomFilePath)
if err != nil {
return nil, fmt.Errorf("error reading pom.xml: %w", err)
}
- if len(project.Modules) > 0 {
+ if len(project.pom.Modules) > 0 {
// This is a multi-module project, we will capture the analysis, but return nil
// to continue recursing
- jd.rootProjects = append(jd.rootProjects, *project)
return nil, nil
}
- var currentRoot *mavenProject
- for _, rootProject := range jd.rootProjects {
- // we can say that the project is in the root project if the path is under the project
- if inRoot := strings.HasPrefix(pomFile, rootProject.path); inRoot {
- currentRoot = &rootProject
- }
- }
-
- _ = currentRoot // use currentRoot here in the analysis
result, err := detectDependencies(project, &Project{
Language: Java,
Path: path,
@@ -60,84 +48,3 @@ func (jd *javaDetector) DetectProject(ctx context.Context, path string, entries
return nil, nil
}
-
-// mavenProject represents the top-level structure of a Maven POM file.
-type mavenProject struct {
- XmlName xml.Name `xml:"project"`
- Parent parent `xml:"parent"`
- Modules []string `xml:"modules>module"` // Capture the modules
- Dependencies []dependency `xml:"dependencies>dependency"`
- DependencyManagement dependencyManagement `xml:"dependencyManagement"`
- Build build `xml:"build"`
- path string
-}
-
-// Parent represents the parent POM if this project is a module.
-type parent struct {
- GroupId string `xml:"groupId"`
- ArtifactId string `xml:"artifactId"`
- Version string `xml:"version"`
-}
-
-// Dependency represents a single Maven dependency.
-type dependency struct {
- GroupId string `xml:"groupId"`
- ArtifactId string `xml:"artifactId"`
- Version string `xml:"version"`
- Scope string `xml:"scope,omitempty"`
-}
-
-// DependencyManagement includes a list of dependencies that are managed.
-type dependencyManagement struct {
- Dependencies []dependency `xml:"dependencies>dependency"`
-}
-
-// Build represents the build configuration which can contain plugins.
-type build struct {
- Plugins []plugin `xml:"plugins>plugin"`
-}
-
-// Plugin represents a build plugin.
-type plugin struct {
- GroupId string `xml:"groupId"`
- ArtifactId string `xml:"artifactId"`
- Version string `xml:"version"`
-}
-
-func readMavenProject(filePath string) (*mavenProject, error) {
- bytes, err := os.ReadFile(filePath)
- if err != nil {
- return nil, err
- }
-
- var project mavenProject
- if err := xml.Unmarshal(bytes, &project); err != nil {
- return nil, fmt.Errorf("parsing xml: %w", err)
- }
-
- project.path = filepath.Dir(filePath)
-
- return &project, nil
-}
-
-func detectDependencies(mavenProject *mavenProject, project *Project) (*Project, error) {
- databaseDepMap := map[DatabaseDep]struct{}{}
- for _, dep := range mavenProject.Dependencies {
- if dep.GroupId == "com.mysql" && dep.ArtifactId == "mysql-connector-j" {
- databaseDepMap[DbMySql] = struct{}{}
- }
-
- if dep.GroupId == "org.postgresql" && dep.ArtifactId == "postgresql" {
- databaseDepMap[DbPostgres] = struct{}{}
- }
- }
-
- if len(databaseDepMap) > 0 {
- project.DatabaseDeps = slices.SortedFunc(maps.Keys(databaseDepMap),
- func(a, b DatabaseDep) int {
- return strings.Compare(string(a), string(b))
- })
- }
-
- return project, nil
-}
diff --git a/cli/azd/internal/appdetect/maven_project.go b/cli/azd/internal/appdetect/maven_project.go
new file mode 100644
index 00000000000..746113e7e3c
--- /dev/null
+++ b/cli/azd/internal/appdetect/maven_project.go
@@ -0,0 +1,45 @@
+package appdetect
+
+import (
+ "context"
+ "maps"
+ "slices"
+ "strings"
+
+ "github.com/azure/azure-dev/cli/azd/pkg/tools/maven"
+)
+
+type mavenProject struct {
+ pom pom
+}
+
+func toMavenProject(ctx context.Context, mvnCli *maven.Cli, pomFilePath string) (mavenProject, error) {
+ pom, err := toPom(ctx, mvnCli, pomFilePath)
+ if err != nil {
+ return mavenProject{}, err
+ }
+ return mavenProject{pom: pom}, nil
+}
+
+func detectDependencies(mavenProject mavenProject, project *Project) (*Project, error) {
+ databaseDepMap := map[DatabaseDep]struct{}{}
+ for _, dep := range mavenProject.pom.Dependencies {
+ if dep.GroupId == "com.mysql" && dep.ArtifactId == "mysql-connector-j" {
+ databaseDepMap[DbMySql] = struct{}{}
+ }
+
+ if dep.GroupId == "org.postgresql" && dep.ArtifactId == "postgresql" ||
+ dep.GroupId == "com.azure.spring" && dep.ArtifactId == "spring-cloud-azure-starter-jdbc-postgresql" {
+ databaseDepMap[DbPostgres] = struct{}{}
+ }
+ }
+
+ if len(databaseDepMap) > 0 {
+ project.DatabaseDeps = slices.SortedFunc(maps.Keys(databaseDepMap),
+ func(a, b DatabaseDep) int {
+ return strings.Compare(string(a), string(b))
+ })
+ }
+
+ return project, nil
+}
diff --git a/cli/azd/internal/appdetect/pom.go b/cli/azd/internal/appdetect/pom.go
new file mode 100644
index 00000000000..737f8ac1db2
--- /dev/null
+++ b/cli/azd/internal/appdetect/pom.go
@@ -0,0 +1,71 @@
+package appdetect
+
+import (
+ "context"
+ "encoding/xml"
+ "path/filepath"
+
+ "github.com/azure/azure-dev/cli/azd/pkg/tools/maven"
+)
+
+func toPom(ctx context.Context, mvnCli *maven.Cli, pomFilePath string) (pom, error) {
+ result, err := toEffectivePom(ctx, mvnCli, pomFilePath)
+ if err != nil {
+ return pom{}, err
+ }
+ result.path = filepath.Dir(pomFilePath)
+ return result, err
+}
+
+func toEffectivePom(ctx context.Context, mvnCli *maven.Cli, pomFilePath string) (pom, error) {
+ effectivePom, err := mvnCli.EffectivePom(ctx, pomFilePath)
+ if err != nil {
+ return pom{}, err
+ }
+ var resultPom pom
+ err = xml.Unmarshal([]byte(effectivePom), &resultPom)
+ return resultPom, err
+}
+
+// pom represents the top-level structure of a Maven POM file.
+type pom struct {
+ XmlName xml.Name `xml:"project"`
+ Parent parent `xml:"parent"`
+ Modules []string `xml:"modules>module"` // Capture the modules
+ Dependencies []dependency `xml:"dependencies>dependency"`
+ DependencyManagement dependencyManagement `xml:"dependencyManagement"`
+ Build build `xml:"build"`
+ path string
+}
+
+// Parent represents the parent POM if this project is a module.
+type parent struct {
+ GroupId string `xml:"groupId"`
+ ArtifactId string `xml:"artifactId"`
+ Version string `xml:"version"`
+}
+
+// Dependency represents a single Maven dependency.
+type dependency struct {
+ GroupId string `xml:"groupId"`
+ ArtifactId string `xml:"artifactId"`
+ Version string `xml:"version"`
+ Scope string `xml:"scope,omitempty"`
+}
+
+// DependencyManagement includes a list of dependencies that are managed.
+type dependencyManagement struct {
+ Dependencies []dependency `xml:"dependencies>dependency"`
+}
+
+// Build represents the build configuration which can contain plugins.
+type build struct {
+ Plugins []plugin `xml:"plugins>plugin"`
+}
+
+// Plugin represents a build plugin.
+type plugin struct {
+ GroupId string `xml:"groupId"`
+ ArtifactId string `xml:"artifactId"`
+ Version string `xml:"version"`
+}
diff --git a/cli/azd/internal/appdetect/pom_test.go b/cli/azd/internal/appdetect/pom_test.go
new file mode 100644
index 00000000000..8bc2b8f8cd7
--- /dev/null
+++ b/cli/azd/internal/appdetect/pom_test.go
@@ -0,0 +1,227 @@
+package appdetect
+
+import (
+ "context"
+ "log/slog"
+ "os"
+ osexec "os/exec"
+ "path/filepath"
+ "testing"
+
+ "github.com/azure/azure-dev/cli/azd/pkg/exec"
+ "github.com/azure/azure-dev/cli/azd/pkg/tools/maven"
+)
+
+func TestCreateEffectivePom(t *testing.T) {
+ path, err := osexec.LookPath("java")
+ if err != nil {
+ t.Skip("Skip TestCreateEffectivePom because java command doesn't exist.")
+ } else {
+ slog.Info("Java command found.", "path", path)
+ }
+ path, err = osexec.LookPath("mvn")
+ if err != nil {
+ t.Skip("Skip TestCreateEffectivePom because mvn command doesn't exist.")
+ } else {
+ slog.Info("Java command found.", "path", path)
+ }
+ tests := []struct {
+ name string
+ testPoms []testPom
+ expected []dependency
+ }{
+ {
+ name: "Test with two dependencies",
+ testPoms: []testPom{
+ {
+ pomFilePath: "pom.xml",
+ pomContentString: `
+
+ 4.0.0
+ com.example
+ example-project
+ 1.0.0
+
+
+ org.springframework
+ spring-core
+ 5.3.8
+ compile
+
+
+ junit
+ junit
+ 4.13.2
+ test
+
+
+
+ `,
+ },
+ },
+ expected: []dependency{
+ {
+ GroupId: "org.springframework",
+ ArtifactId: "spring-core",
+ Version: "5.3.8",
+ Scope: "compile",
+ },
+ {
+ GroupId: "junit",
+ ArtifactId: "junit",
+ Version: "4.13.2",
+ Scope: "test",
+ },
+ },
+ },
+ {
+ name: "Test with no dependencies",
+ testPoms: []testPom{
+ {
+ pomFilePath: "pom.xml",
+ pomContentString: `
+
+ 4.0.0
+ com.example
+ example-project
+ 1.0.0
+
+
+
+ `,
+ },
+ },
+ expected: []dependency{},
+ },
+ {
+ name: "Test with one dependency which version is decided by dependencyManagement",
+ testPoms: []testPom{
+ {
+ pomFilePath: "pom.xml",
+ pomContentString: `
+
+ 4.0.0
+ com.example
+ example-project
+ 1.0.0
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-dependencies
+ 3.0.0
+ pom
+ import
+
+
+
+
+ `,
+ },
+ },
+ expected: []dependency{
+ {
+ GroupId: "org.slf4j",
+ ArtifactId: "slf4j-api",
+ Version: "2.0.4",
+ Scope: "compile",
+ },
+ },
+ },
+ {
+ name: "Test with one dependency which version is decided by parent",
+ testPoms: []testPom{
+ {
+ pomFilePath: "pom.xml",
+ pomContentString: `
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.0.0
+
+
+ 4.0.0
+ com.example
+ example-project
+ 1.0.0
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+ `,
+ },
+ },
+ expected: []dependency{
+ {
+ GroupId: "org.slf4j",
+ ArtifactId: "slf4j-api",
+ Version: "2.0.4",
+ Scope: "compile",
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ workingDir, err := prepareTestPomFiles(tt.testPoms)
+ if err != nil {
+ t.Fatalf("%v", err)
+ }
+ for _, testPom := range tt.testPoms {
+ pomFilePath := filepath.Join(workingDir, testPom.pomFilePath)
+
+ effectivePom, err := toEffectivePom(context.TODO(), maven.NewCli(exec.NewCommandRunner(nil)),
+ pomFilePath)
+ if err != nil {
+ t.Fatalf("createEffectivePom failed: %v", err)
+ }
+
+ if len(effectivePom.Dependencies) != len(tt.expected) {
+ t.Fatalf("Expected: %d\nActual: %d", len(tt.expected), len(effectivePom.Dependencies))
+ }
+
+ for i, dep := range effectivePom.Dependencies {
+ if dep != tt.expected[i] {
+ t.Errorf("\nExpected: %s\nActual: %s", tt.expected[i], dep)
+ }
+ }
+ }
+ })
+ }
+}
+
+type testPom struct {
+ pomFilePath string
+ pomContentString string
+}
+
+func prepareTestPomFiles(testPoms []testPom) (string, error) {
+ tempDir, err := os.MkdirTemp("", "prepareTestPomFiles")
+ if err != nil {
+ return "", err
+ }
+ for _, testPom := range testPoms {
+ pomPath := filepath.Join(tempDir, testPom.pomFilePath)
+ err := os.MkdirAll(filepath.Dir(pomPath), 0755)
+ if err != nil {
+ return "", err
+ }
+ err = os.WriteFile(pomPath, []byte(testPom.pomContentString), 0600)
+ if err != nil {
+ return "", err
+ }
+ }
+ return tempDir, nil
+}
diff --git a/cli/azd/internal/appdetect/testdata/java/pom.xml b/cli/azd/internal/appdetect/testdata/java/pom.xml
index 09cb26061ae..1ff4713938a 100644
--- a/cli/azd/internal/appdetect/testdata/java/pom.xml
+++ b/cli/azd/internal/appdetect/testdata/java/pom.xml
@@ -2,7 +2,6 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
4.0.0
-
nothing
basic-pom
0.0.1-SNAPSHOT
diff --git a/cli/azd/pkg/tools/maven/maven.go b/cli/azd/pkg/tools/maven/maven.go
index 43d8d91db59..beee3b4a12a 100644
--- a/cli/azd/pkg/tools/maven/maven.go
+++ b/cli/azd/pkg/tools/maven/maven.go
@@ -1,6 +1,7 @@
package maven
import (
+ "bufio"
"context"
"errors"
"fmt"
@@ -30,47 +31,47 @@ type Cli struct {
mvnCmdErr error
}
-func (m *Cli) Name() string {
+func (cli *Cli) Name() string {
return "Maven"
}
-func (m *Cli) InstallUrl() string {
+func (cli *Cli) InstallUrl() string {
return "https://maven.apache.org"
}
-func (m *Cli) CheckInstalled(ctx context.Context) error {
- _, err := m.mvnCmd()
+func (cli *Cli) CheckInstalled(ctx context.Context) error {
+ _, err := cli.mvnCmd()
if err != nil {
return err
}
- if ver, err := m.extractVersion(ctx); err == nil {
+ if ver, err := cli.extractVersion(ctx); err == nil {
log.Printf("maven version: %s", ver)
}
return nil
}
-func (m *Cli) SetPath(projectPath string, rootProjectPath string) {
- m.projectPath = projectPath
- m.rootProjectPath = rootProjectPath
+func (cli *Cli) SetPath(projectPath string, rootProjectPath string) {
+ cli.projectPath = projectPath
+ cli.rootProjectPath = rootProjectPath
}
-func (m *Cli) mvnCmd() (string, error) {
- m.mvnCmdOnce.Do(func() {
- mvnCmd, err := getMavenPath(m.projectPath, m.rootProjectPath)
+func (cli *Cli) mvnCmd() (string, error) {
+ cli.mvnCmdOnce.Do(func() {
+ mvnCmd, err := getMavenPath(cli.projectPath, cli.rootProjectPath)
if err != nil {
- m.mvnCmdErr = err
+ cli.mvnCmdErr = err
} else {
- m.mvnCmdStr = mvnCmd
+ cli.mvnCmdStr = mvnCmd
}
})
- if m.mvnCmdErr != nil {
- return "", m.mvnCmdErr
+ if cli.mvnCmdErr != nil {
+ return "", cli.mvnCmdErr
}
- return m.mvnCmdStr, nil
+ return cli.mvnCmdStr, nil
}
func getMavenPath(projectPath string, rootProjectPath string) (string, error) {
@@ -237,6 +238,51 @@ func (cli *Cli) GetProperty(ctx context.Context, propertyPath string, projectPat
return result, nil
}
+func (cli *Cli) EffectivePom(ctx context.Context, pomPath string) (string, error) {
+ mvnCmd, err := cli.mvnCmd()
+ if err != nil {
+ return "", err
+ }
+ pomDir := filepath.Dir(pomPath)
+ runArgs := exec.NewRunArgs(mvnCmd, "help:effective-pom", "-f", pomPath).WithCwd(pomDir)
+ result, err := cli.commandRunner.Run(ctx, runArgs)
+ if err != nil {
+ return "", fmt.Errorf("mvn help:effective-pom on project '%s' failed: %w", pomPath, err)
+ }
+ return getEffectivePomFromConsoleOutput(result.Stdout)
+}
+
+var projectStart = regexp.MustCompile(`^\s*\s*$`)
+
+func getEffectivePomFromConsoleOutput(consoleOutput string) (string, error) {
+ var builder strings.Builder
+ scanner := bufio.NewScanner(strings.NewReader(consoleOutput))
+ inProject := false
+
+ for scanner.Scan() {
+ line := scanner.Text()
+ if projectStart.MatchString(line) {
+ inProject = true
+ builder.Reset() // for a pom which contains submodule, the effective pom for root pom appears at last.
+ } else if projectEnd.MatchString(line) {
+ builder.WriteString(line)
+ inProject = false
+ }
+ if inProject {
+ builder.WriteString(line)
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return "", fmt.Errorf("failed to scan console output: %w", err)
+ }
+ result := builder.String()
+ if result == "" {
+ return "", fmt.Errorf("failed to get effective pom from console: empty content")
+ }
+ return result, nil
+}
+
func NewCli(commandRunner exec.CommandRunner) *Cli {
return &Cli{
commandRunner: commandRunner,