diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index a10ce775337..47d1607fd70 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -16,6 +16,7 @@ words: - runcontext - unmarshals - usgovcloudapi + - jdbc languageSettings: - languageId: go ignoreRegExpList: diff --git a/cli/azd/internal/appdetect/appdetect.go b/cli/azd/internal/appdetect/appdetect.go index e6103d3cbd7..b35dc531000 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,9 @@ 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..5f83a6606fa 100644 --- a/cli/azd/internal/appdetect/java.go +++ b/cli/azd/internal/appdetect/java.go @@ -6,13 +6,15 @@ import ( "fmt" "io/fs" "maps" - "os" "path/filepath" "slices" "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/tools/maven" ) type javaDetector struct { + mvnCli *maven.Cli rootProjects []mavenProject } @@ -24,7 +26,7 @@ func (jd *javaDetector) DetectProject(ctx context.Context, path string, entries for _, entry := range entries { if strings.ToLower(entry.Name()) == "pom.xml" { pomFile := filepath.Join(path, entry.Name()) - project, err := readMavenProject(pomFile) + project, err := readMavenProject(ctx, jd.mvnCli, pomFile) if err != nil { return nil, fmt.Errorf("error reading pom.xml: %w", err) } @@ -104,19 +106,16 @@ type plugin struct { Version string `xml:"version"` } -func readMavenProject(filePath string) (*mavenProject, error) { - bytes, err := os.ReadFile(filePath) +func readMavenProject(ctx context.Context, mvnCli *maven.Cli, filePath string) (*mavenProject, error) { + effectivePom, err := mvnCli.EffectivePom(ctx, filePath) if err != nil { return nil, err } - var project mavenProject - if err := xml.Unmarshal(bytes, &project); err != nil { + if err := xml.Unmarshal([]byte(effectivePom), &project); err != nil { return nil, fmt.Errorf("parsing xml: %w", err) } - project.path = filepath.Dir(filePath) - return &project, nil } diff --git a/cli/azd/internal/appdetect/java_test.go b/cli/azd/internal/appdetect/java_test.go new file mode 100644 index 00000000000..0d62858ae19 --- /dev/null +++ b/cli/azd/internal/appdetect/java_test.go @@ -0,0 +1,307 @@ +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 TestToMavenProject(t *testing.T) { + path, err := osexec.LookPath("java") + if err != nil { + t.Skip("Skip readMavenProject 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 readMavenProject 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", + }, + }, + }, + { + name: "Test pom with multi modules", + testPoms: []testPom{ + { + pomFilePath: "pom.xml", + pomContentString: ` + + 4.0.0 + org.springframework + gs-multi-module + 0.1.0 + pom + + library + application + + + `, + }, + { + pomFilePath: filepath.Join("application", "pom.xml"), + pomContentString: ` + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.0 + + + com.example + application + 0.0.1-SNAPSHOT + application + Demo project for Spring Boot + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + `, + }, + { + pomFilePath: filepath.Join("library", "pom.xml"), + pomContentString: ` + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.2.2 + + + com.example + library + 0.0.1-SNAPSHOT + library + Demo project for Spring Boot + + + org.springframework.boot + spring-boot + + + org.springframework.boot + spring-boot-starter-test + test + + + + `, + }, + }, + expected: []dependency{}, + }, + } + + 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) + } + testPom := tt.testPoms[0] + pomFilePath := filepath.Join(workingDir, testPom.pomFilePath) + + mavenProject, err := readMavenProject(context.TODO(), maven.NewCli(exec.NewCommandRunner(nil)), + pomFilePath) + if err != nil { + t.Fatalf("readMavenProject failed: %v", err) + } + + if len(mavenProject.Dependencies) != len(tt.expected) { + t.Fatalf("Expected: %d\nActual: %d", len(tt.expected), len(mavenProject.Dependencies)) + } + + for i, dep := range mavenProject.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..0b974d1a803 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" @@ -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,