diff options
Diffstat (limited to 'cmd/incremental_javac_input/incremental_javac_input_lib')
3 files changed, 1019 insertions, 0 deletions
diff --git a/cmd/incremental_javac_input/incremental_javac_input_lib/Android.bp b/cmd/incremental_javac_input/incremental_javac_input_lib/Android.bp new file mode 100644 index 000000000..42219a222 --- /dev/null +++ b/cmd/incremental_javac_input/incremental_javac_input_lib/Android.bp @@ -0,0 +1,30 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// 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 +// +// http://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. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +bootstrap_go_package { + name: "soong-cmd-incremental_javac_input-lib", + pkgPath: "android/soong/cmd/incremental_javac_input/incremental_javac_input_lib", + deps: [ + "blueprint-pathtools", + "soong-cmd-find_input_delta-lib", + "golang-dependency-mapper-protoimpl", + ], + srcs: [ + "generate_incremental_input.go", + ], +} diff --git a/cmd/incremental_javac_input/incremental_javac_input_lib/generate_incremental_input.go b/cmd/incremental_javac_input/incremental_javac_input_lib/generate_incremental_input.go new file mode 100644 index 000000000..fdc7c9466 --- /dev/null +++ b/cmd/incremental_javac_input/incremental_javac_input_lib/generate_incremental_input.go @@ -0,0 +1,290 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// 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 +// +// http://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. + +package incremental_javac_input_lib + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + fid_lib "android/soong/cmd/find_input_delta/find_input_delta_lib" + "github.com/google/blueprint/pathtools" + dependency_proto "go.dependencymapper/protoimpl" + "google.golang.org/protobuf/proto" +) + +var fileSepRegex = regexp.MustCompile("[^[:space:]]+") + +type UsageMap struct { + FilePath string + + Usages []string + + IsDependencyToAll bool + + GeneratedClasses []string +} + +func GenerateIncrementalInput(classDir, srcs, deps, javacTarget, srcDeps, localHeaderJars string) (err error) { + incInputPath := javacTarget + ".inc.rsp" + removedClassesPath := javacTarget + ".rem.rsp" + inputPcState := javacTarget + ".input.pc_state" + depsPcState := javacTarget + ".deps.pc_state" + headersPcState := javacTarget + ".headers.pc_state" + + var classesForRemoval []string + var incAllSources bool + + // Read the srcRspFile contents + srcList := readRspFile(srcs) + // run find_input_delta, save [add + ch] as a []string, and [del] as another []string + addF, delF, chF := findInputDelta(srcList, inputPcState, javacTarget) + var incInputList []string + incInputList = append(incInputList, addF...) + incInputList = append(incInputList, chF...) + + // check if deps have changed + depsList := readRspFile(deps) + if addD, delD, chD := findInputDelta(depsList, depsPcState, javacTarget); len(addD)+len(delD)+len(chD) > 0 { + incAllSources = true + } + + // If we are not doing a partial compile, we can just return the full list. + // We can do this earlier as well, but allowing findInputDelta to run outputs + // the changed sources to build metrics as well as maintains the state of + // inputs we require if the partialCompile is switched on again. + if !usePartialCompile() { + // Remove the output class directory to prevent any stale files + os.RemoveAll(classDir) + return writeOutput(incInputPath, removedClassesPath, srcList, classesForRemoval) + } + + // if the output directory of javac which will contain .class files is not present, include all sources + if !dirExists(classDir) { + incAllSources = true + } + + // if javacTarget does not exist, we can include all sources + if incAllSources || !fileExists(javacTarget) { + incAllSources = true + } + + // if incInputList has the same size as srcList (all files touched), we can + // just include all sources + if incAllSources || len(incInputList) == len(srcList) { + incAllSources = true + } + + // if dependencyMap does not exist, we can include all sources + if incAllSources || !fileExists(srcDeps) { + incAllSources = true + } + + headersChanged := false + // if headers do not change, we can just keep the incInputList as is. + // Read the srcRspFile contents + headersList := readRspFile(localHeaderJars) + if addH, delH, chH := findInputDelta(headersList, headersPcState, javacTarget); len(addH)+len(delH)+len(chH) > 0 { + headersChanged = true + } + + // use revDepsMap to find all usages, add them to output, alongside [add + ch] files + if fileExists(srcDeps) { + usageMap, _ := generateUsageMap(srcDeps) + // if including all sources, no need to check the usageMap + if headersChanged && !incAllSources { + incInputList, incAllSources = getUsages(usageMap, incInputList, delF, headersChanged) + } + // use usageMap to add all classes that were generated from removed files. + classesForRemoval = generateRemovalList(usageMap, delF, classDir) + } + + if incAllSources { + incInputList = srcList + } + + // write the output to output path(s) + return writeOutput(incInputPath, removedClassesPath, incInputList, classesForRemoval) +} + +// Checks if Full Compile is enabled or not +func usePartialCompile() bool { + usePartialCompileVar := os.Getenv("SOONG_USE_PARTIAL_COMPILE") + if usePartialCompileVar == "true" { + return true + } + return false +} + +// Returns the list of files that use added, modified or deleted files. +// Returns whether to include all src Files in incremental src set +func getUsages(usageMap map[string]UsageMap, modifiedFiles, deletedFiles []string, headersChanged bool) ([]string, bool) { + usagesSet := make(map[string]bool) + + // First add all the modified files in the output + for _, incInput := range modifiedFiles { + usagesSet[incInput] = true + } + // Add all the usages of modified + deleted files + for _, modFile := range append(modifiedFiles, deletedFiles...) { + if um, exists := usageMap[modFile]; exists { + if um.IsDependencyToAll { + return nil, true + } + if headersChanged { + for _, usage := range um.Usages { + usagesSet[usage] = true + } + } + } + } + + var usages []string + for usage := range usagesSet { + usages = append(usages, usage) + } + return usages, false +} + +// Returns the list of class files to be removed, as a result of deleting a source file. +func generateRemovalList(usageMap map[string]UsageMap, delFiles []string, classesDir string) []string { + var classesForRemoval []string + for _, delFile := range delFiles { + if _, exists := usageMap[delFile]; exists { + for _, generatedClass := range usageMap[delFile].GeneratedClasses { + classesForRemoval = append(classesForRemoval, filepath.Join(classesDir, generatedClass)) + } + } + } + return classesForRemoval +} + +// Generates the usage map, by reading the supplied dependency map as a proto +// Throws error if the map is unparsable. +func generateUsageMap(srcDeps string) (map[string]UsageMap, error) { + var message = &dependency_proto.FileDependencyList{} + + usageMapSet := make(map[string]UsageMap) + + data, err := os.ReadFile(srcDeps) + if err != nil && errors.Is(err, fs.ErrNotExist) { + fmt.Println("err: ", err) + panic(err) + } + err = proto.Unmarshal(data, message) + if err != nil { + fmt.Println("err: ", err) + panic(err) + } + for _, dep := range message.FileDependency { + addUsageMapIfNotPresent(usageMapSet, *dep.FilePath) + for _, depV := range dep.FileDependencies { + addUsageMapIfNotPresent(usageMapSet, depV) + updatedUsageMap := usageMapSet[depV] + updatedUsageMap.Usages = append(updatedUsageMap.Usages, *dep.FilePath) + usageMapSet[depV] = updatedUsageMap + } + updatedUsageMap := usageMapSet[*dep.FilePath] + updatedUsageMap.IsDependencyToAll = *dep.IsDependencyToAll + updatedUsageMap.GeneratedClasses = dep.GeneratedClasses + usageMapSet[*dep.FilePath] = updatedUsageMap + } + return usageMapSet, nil +} + +func addUsageMapIfNotPresent(usageMapSet map[string]UsageMap, key string) { + if _, exists := usageMapSet[key]; !exists { + usageMap := UsageMap{ + FilePath: key, + Usages: []string{}, + IsDependencyToAll: false, + GeneratedClasses: []string{}, + } + usageMapSet[key] = usageMap + } +} + +func fileExists(filePath string) bool { + if file, err := os.Open(filePath); err != nil { + if os.IsNotExist(err) { + return false + } + panic(err) + } else { + file.Close() + } + return true +} + +func dirExists(dirPath string) bool { + if _, err := os.Stat(dirPath); err == nil || !os.IsNotExist(err) { + return true + } + return false +} + +func readRspFile(rspFile string) (list []string) { + data, err := os.ReadFile(rspFile) + if err != nil { + panic(err) + } + list = append(list, fileSepRegex.FindAllString(string(data), -1)...) + return list +} + +// Writes incInput and classesForRemoval to output paths +func writeOutput(incInputPath, removedClassesPath string, incInputList, classesForRemoval []string) (err error) { + err = pathtools.WriteFileIfChanged(incInputPath, []byte(strings.Join(incInputList, "\n")), 0644) + if err != nil { + return err + } + return pathtools.WriteFileIfChanged(removedClassesPath, []byte(strings.Join(classesForRemoval, "\n")), 0644) +} + +// Computes the diff of the inputs provided, saving the temp state in the +// priorStateFile. +func findInputDelta(inputs []string, priorStateFile, target string) ([]string, []string, []string) { + newStateFile := priorStateFile + ".new" + fileList, err := fid_lib.GenerateFileList(target, priorStateFile, newStateFile, inputs, false, fid_lib.OsFs) + if err != nil { + panic(err) + } + return flattenChanges(fileList) +} + +// Flattens the output of find_input_delta for javac's consumption. +func flattenChanges(root *fid_lib.FileList) ([]string, []string, []string) { + var allAdditions []string + var allDeletions []string + var allChangedFiles []string + + for _, addition := range root.Additions { + allAdditions = append(allAdditions, addition) + } + + for _, del := range root.Deletions { + allDeletions = append(allDeletions, del) + } + + for _, ch := range root.Changes { + allChangedFiles = append(allChangedFiles, ch.Name) + } + + return allAdditions, allDeletions, allChangedFiles +} diff --git a/cmd/incremental_javac_input/incremental_javac_input_lib/generate_incremental_input_test.go b/cmd/incremental_javac_input/incremental_javac_input_lib/generate_incremental_input_test.go new file mode 100644 index 000000000..99ea804fd --- /dev/null +++ b/cmd/incremental_javac_input/incremental_javac_input_lib/generate_incremental_input_test.go @@ -0,0 +1,699 @@ +package incremental_javac_input_lib + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + "time" + + fid_lib "android/soong/cmd/find_input_delta/find_input_delta_lib" + dependency_proto "go.dependencymapper/protoimpl" + "google.golang.org/protobuf/proto" +) + +// --- Tests for `getUsages` --- +func TestGetUsages(t *testing.T) { + usageMap := map[string]UsageMap{ + "file1.java": {Usages: []string{"file3.java", "file4.java"}}, + "file2.java": {Usages: []string{"file3.java"}}, + "file3.java": {Usages: []string{}}, + "fileAll.java": {Usages: []string{}, IsDependencyToAll: true}, + } + + testCases := []struct { + name string + modifiedFiles []string + deletedFiles []string + expected []string + expectedAll bool + }{ + { + name: "Basic", + modifiedFiles: []string{"file1.java"}, + deletedFiles: []string{"file2.java"}, + expected: []string{"file1.java", "file3.java", "file4.java"}, // file3 is used by both + expectedAll: false, + }, + { + name: "Empty", + modifiedFiles: []string{}, + deletedFiles: []string{}, + expected: nil, + expectedAll: false, + }, + { + name: "NonExistentFile", + modifiedFiles: []string{"nonexistent.java"}, + deletedFiles: []string{}, + expected: []string{"nonexistent.java"}, + expectedAll: false, + }, + { + name: "DependencyToAll", + modifiedFiles: []string{"file1.java"}, + deletedFiles: []string{"fileAll.java"}, + expected: nil, + expectedAll: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual, all := getUsages(usageMap, tc.modifiedFiles, tc.deletedFiles, true) + if all != tc.expectedAll { + t.Errorf("getUsages() all sources; expected %v, got %v", tc.expectedAll, all) + } + + // Sort for consistent comparison, as map iteration order isn't guaranteed + sort.Strings(actual) + sort.Strings(tc.expected) + if !reflect.DeepEqual(actual, tc.expected) { + t.Errorf("getUsages(); expected %v, got %v", tc.expected, actual) + } + }) + } +} + +// --- Tests for `generateRemovalList` --- +func TestGenerateRemovalList(t *testing.T) { + usageMap := map[string]UsageMap{ + "file1.java": {GeneratedClasses: []string{"Class1", "Class2"}}, + "file2.java": {GeneratedClasses: []string{"Class3"}}, + "file3.java": {}, + } + + testCases := []struct { + name string + classDir string + delFiles []string + expected []string + }{ + {"Basic", "out/classes", []string{"file1.java", "file2.java"}, []string{"out/classes/Class1", "out/classes/Class2", "out/classes/Class3"}}, + {"Empty", "out/classes", []string{}, nil}, + {"NonExistent", "out/classes", []string{"nonexistent.java"}, nil}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual := generateRemovalList(usageMap, tc.delFiles, tc.classDir) + sort.Strings(actual) + sort.Strings(tc.expected) + if !reflect.DeepEqual(actual, tc.expected) { + t.Errorf("generateRemovalList(); expected %v, got %v", tc.expected, actual) + } + }) + } +} + +// --- Tests for `generateUsageMap` --- +func TestGenerateUsageMap(t *testing.T) { + tmpDir := t.TempDir() + + // 1. Test with a valid proto file. + protoFile := filepath.Join(tmpDir, "deps.pb") + createProtoFile(t, protoFile) + + usageMap, err := generateUsageMap(protoFile) + if err != nil { + t.Fatalf("generateUsageMap() returned an error: %v", err) + } + + expectedUsageMap := map[string]UsageMap{ + "file1.java": {FilePath: "file1.java", Usages: []string{}, IsDependencyToAll: false, GeneratedClasses: []string{"ClassA", "ClassB"}}, + "file2.java": {FilePath: "file2.java", Usages: []string{"file1.java"}, IsDependencyToAll: true, GeneratedClasses: []string{"ClassC"}}, + "file3.java": {FilePath: "file3.java", Usages: []string{"file1.java"}, IsDependencyToAll: false, GeneratedClasses: []string{"ClassD"}}, + } + + if !reflect.DeepEqual(usageMap, expectedUsageMap) { + t.Errorf("generateUsageMap() returned unexpected map.\nGot: %+v\nWant:%+v", usageMap, expectedUsageMap) + } + + // 2. Test with a non-existent file (should panic) + t.Run("Panic on non-existent proto", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { //should have panicked + t.Errorf("generateUsageMap() did not panic on non-existent proto") + } + }() + _, _ = generateUsageMap("nonexistent.pb") + }) + + // 3. Test with an invalid proto file (should panic) + invalidProtoFile := filepath.Join(tmpDir, "invalid.pb") + writeFile(t, invalidProtoFile, "This is not a valid proto file") // Create invalid file + t.Run("Panic on invalid proto file", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { //should have panicked + t.Errorf("generateUsageMap() did not panic on invalid proto") + } + }() + _, _ = generateUsageMap(invalidProtoFile) + }) +} + +// --- Tests for `readRspFile` --- +func TestReadRspFile(t *testing.T) { + tmpDir := t.TempDir() + + testCases := []struct { + name string + content string + expected []string + }{ + {"Empty", "", nil}, + {"SingleLine", "file1.java", []string{"file1.java"}}, + {"MultipleLines", "file1.java\nfile2.java\nfile3.java", []string{"file1.java", "file2.java", "file3.java"}}, + {"WithSpaces", " file1.java \n file2.java ", []string{"file1.java", "file2.java"}}, //Should be trimmed. + {"WithEmptyLines", "file1.java\n\nfile2.java", []string{"file1.java", "file2.java"}}, + {"WithCarriageReturn", "file1.java\r\nfile2.java", []string{"file1.java", "file2.java"}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rspFile := filepath.Join(tmpDir, "test.rsp") + writeFile(t, rspFile, tc.content) + + actual := readRspFile(rspFile) + if !reflect.DeepEqual(actual, tc.expected) { + t.Errorf("readRspFile(); expected %v, got %v", tc.expected, actual) + } + }) + } + + //Test for panic + t.Run("Panic on non-existent file", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { //should have panicked + t.Errorf("readRspFile() did not panic on non-existent file") + } + }() + _ = readRspFile("nonexistent_file.rsp") + }) +} + +// --- Tests for `flattenChanges` --- + +func TestFlattenChanges(t *testing.T) { + fileList := &fid_lib.FileList{ + Additions: []string{"add1.java", "add2.java"}, + Deletions: []string{"del1.java"}, + Changes: []fid_lib.FileList{ + {Name: "change1.java"}, + {Name: "change2.java"}, + }, + } + + expectedAdditions := []string{"add1.java", "add2.java"} + expectedDeletions := []string{"del1.java"} + expectedChanges := []string{"change1.java", "change2.java"} + + actualAdditions, actualDeletions, actualChanges := flattenChanges(fileList) + + // Sort for consistent comparison + sort.Strings(expectedAdditions) + sort.Strings(actualAdditions) + sort.Strings(expectedDeletions) + sort.Strings(actualDeletions) + sort.Strings(expectedChanges) + sort.Strings(actualChanges) + + if !reflect.DeepEqual(actualAdditions, expectedAdditions) { + t.Errorf("flattenChanges() additions; expected %v, got %v", expectedAdditions, actualAdditions) + } + if !reflect.DeepEqual(actualDeletions, expectedDeletions) { + t.Errorf("flattenChanges() deletions; expected %v, got %v", expectedDeletions, actualDeletions) + } + if !reflect.DeepEqual(actualChanges, expectedChanges) { + t.Errorf("flattenChanges() changes; expected %v, got %v", expectedChanges, actualChanges) + } +} + +// --- Tests for `GenerateIncrementalInput` --- + +func TestGenerateIncrementalInput(t *testing.T) { + // Set the environment variable to enable inc-compilation + t.Setenv("SOONG_USE_PARTIAL_COMPILE", "true") + + // Shared setup for all subtests + tf := newTestFixture(t) + // No need for top-level defer os.RemoveAll(tmpDir) because t.TempDir() handles it + + // --- Subtest: Initial Full Compile --- + t.Run("InitialFullCompile", func(t *testing.T) { + // Arrange (already done by newTestFixture) + + // Act + tf.runGenerator() + + // Assert + checkOutput( + t, + tf.incOutputPath(), + fmt.Sprintf("%s\n%s\n%s", tf.JavaFile1, tf.JavaFile2, tf.JavaFile3), // All files included initially + tf.remOutputPath(), + "", // No removals initially + ) + tf.savePriorState() + }) + + // --- Subtest: Incremental - One File Modified --- + t.Run("Incremental_OneFileModified", func(t *testing.T) { + // Arrange: Modify one file (ensure timestamp changes) + modifyFile(t, tf.JavaFile3, "Incremental_OneFileModified") + + // Act + tf.runGenerator() + + // Assert: Only the modified file should be in inc.rsp + checkOutput( + t, + tf.incOutputPath(), + fmt.Sprintf("%s", tf.JavaFile3), + tf.remOutputPath(), + "", // No removals + ) + tf.savePriorState() + }) + + // --- Subtest: Incremental - One File and Header Modified --- + t.Run("Incremental_FileAndHeaderModified", func(t *testing.T) { + // Arrange: Modify a different file and the header jar + modifyFile(t, tf.JavaFile3, "Incremental_FileAndHeaderModified") + modifyFile(t, tf.HeaderJar, "Incremental_FileAndHeaderModified") + + // Act + tf.runGenerator() + + // Assert: All source files and their usages should be in inc.rsp + checkOutput( + t, + tf.incOutputPath(), + fmt.Sprintf("%s\n%s", tf.JavaFile1, tf.JavaFile3), + tf.remOutputPath(), + "", // No removals + ) + tf.savePriorState() + }) + + // --- Subtest: Incremental - Dependency Change --- + t.Run("Incremental_DependencyChanged", func(t *testing.T) { + // Arrange: Modify the DepJar + modifyFile(t, tf.DepJar, "Incremental_DependencyChanged") + + // Act + tf.runGenerator() + + // Assert: All source files should be in inc.rsp + checkOutput( + t, + tf.incOutputPath(), + fmt.Sprintf("%s\n%s\n%s", tf.JavaFile1, tf.JavaFile2, tf.JavaFile3), + tf.remOutputPath(), + "", // No removals + ) + tf.savePriorState() + }) + + // --- Subtest: Incremental - File which is dependency to all files is Changed --- + t.Run("Incremental_DependencyToAllChanged", func(t *testing.T) { + // Arrange: Modify the DepsRspFile or JavaSrcDeps proto + modifyFile(t, tf.JavaFile2, "Incremental_DependencyToAllChanged") + + // Act + tf.runGenerator() + + // Assert: All source files should be in inc.rsp + checkOutput( + t, + tf.incOutputPath(), + fmt.Sprintf("%s", tf.JavaFile2), + tf.remOutputPath(), + "", // No removals + ) + tf.savePriorState() + }) + + // --- Subtest: Incremental - File which is dependency to all files is changed along with headers--- + t.Run("Incremental_DependencyToAllChangedWithHeaders", func(t *testing.T) { + // Arrange: Modify the DepsRspFile or JavaSrcDeps proto + modifyFile(t, tf.JavaFile2, "Incremental_DependencyToAllChangedWithHeader") + modifyFile(t, tf.HeaderJar, "Incremental_DependencyToAllChangedWithHeader") + + // Act + tf.runGenerator() + + // Assert: All source files should be in inc.rsp + checkOutput( + t, + tf.incOutputPath(), + fmt.Sprintf("%s\n%s\n%s", tf.JavaFile1, tf.JavaFile2, tf.JavaFile3), + tf.remOutputPath(), + "", // No removals + ) + tf.savePriorState() + }) + + // --- Subtest: Incremental - One File Deleted, Header Modified --- + t.Run("Incremental_FileDeletedHeaderModified", func(t *testing.T) { + // Arrange: Delete one file and modify header + deleteFile(t, tf.JavaFile3, tf.SrcRspFile) + // Modify Headers + modifyFile(t, tf.HeaderJar, "Incremental_FileDeletedHeaderModified") + + // Act + tf.runGenerator() + + // Assert: Check usages of deleted file in inc.rsp, and class files + // corresponding to deleted files in rem.rsp + checkOutput( + t, + tf.incOutputPath(), + fmt.Sprintf("%s", tf.JavaFile1), // usages of deleted file + tf.remOutputPath(), + filepath.Join(tf.ClassDir, "org.another.ClassD"), // class name corresponding to the deleted file path + ) + tf.savePriorState() // Save state if needed for subsequent tests + }) +} + +func TestGenerateIncrementalInputPartialCompileOff(t *testing.T) { + // Set the environment variable to disable inc-compilation + t.Setenv("SOONG_USE_PARTIAL_COMPILE", "") + + // Shared setup for all subtests + tf := newTestFixture(t) + // No need for top-level defer os.RemoveAll(tmpDir) because t.TempDir() handles it + + // --- Subtest: Initial Full Compile --- + t.Run("InitialFullCompile", func(t *testing.T) { + // Arrange (already done by newTestFixture) + + // Act + tf.runGenerator() + + // Assert + checkOutput( + t, + tf.incOutputPath(), + fmt.Sprintf("%s\n%s\n%s", tf.JavaFile1, tf.JavaFile2, tf.JavaFile3), // All files included initially + tf.remOutputPath(), + "", // No removals initially + ) + tf.savePriorState() + }) + + // --- Subtest: Incremental - One File Modified, should add all files --- + t.Run("Incremental_OneFileModified_AddsAllFiles", func(t *testing.T) { + // Arrange: Modify one file (ensure timestamp changes) + modifyFile(t, tf.JavaFile3, "Incremental_OneFileModified") + + // Act + tf.runGenerator() + + // Assert: Only the modified file should be in inc.rsp + checkOutput( + t, + tf.incOutputPath(), + fmt.Sprintf("%s\n%s\n%s", tf.JavaFile1, tf.JavaFile2, tf.JavaFile3), + tf.remOutputPath(), + "", // No removals + ) + tf.savePriorState() + }) +} + +// --- Test Fixture Setup --- +// Struct to hold common test file paths +type testFixture struct { + t *testing.T + tmpDir string + ClassDir string + SrcRspFile string + DepsRspFile string + JavacTargetJar string + JavaSrcDeps string + HeadersRspFile string + JavaFile1 string + JavaFile2 string + JavaFile3 string + DepJar string + HeaderJar string +} + +// newTestFixture creates the temporary directory and necessary files +func newTestFixture(t *testing.T) *testFixture { + tmpDir := t.TempDir() // Use t.TempDir for automatic cleanup + + // Create dummy files needed for the tests + fixture := &testFixture{ + t: t, + tmpDir: tmpDir, + ClassDir: filepath.Join(tmpDir, "classes"), + SrcRspFile: filepath.Join(tmpDir, "sources.rsp"), + DepsRspFile: filepath.Join(tmpDir, "deps.rsp"), + JavacTargetJar: filepath.Join(tmpDir, "output.jar"), + JavaSrcDeps: filepath.Join(tmpDir, "srcdeps.pb"), // Example proto file path + HeadersRspFile: filepath.Join(tmpDir, "localHeaders.rsp"), + JavaFile1: filepath.Join(tmpDir, "src/com/example/ClassA.java"), + JavaFile2: filepath.Join(tmpDir, "src/com/example/ClassC.java"), + JavaFile3: filepath.Join(tmpDir, "src/org/another/ClassD.java"), // Example different package + DepJar: filepath.Join(tmpDir, "deps.jar"), + HeaderJar: filepath.Join(tmpDir, "headers.jar"), + } + + // Create directories and initial file contents + createDir(t, filepath.Dir(fixture.JavaFile1)) + createDir(t, filepath.Dir(fixture.JavaFile3)) + createDir(t, fixture.ClassDir) + + writeFile(t, fixture.JavaFile1, "package com.example; class File1 {}") + writeFile(t, fixture.JavaFile2, "package com.example; class File2 {}") + writeFile(t, fixture.JavaFile3, "package org.another; class ClassD {}") + + writeFile(t, fixture.DepJar, "Dep jar") + writeFile(t, fixture.HeaderJar, "Header jar") + + writeFile(t, fixture.SrcRspFile, fmt.Sprintf("%s\n%s\n%s", fixture.JavaFile1, fixture.JavaFile2, fixture.JavaFile3)) + writeFile(t, fixture.DepsRspFile, fmt.Sprintf("%s", fixture.DepJar)) + writeFile(t, fixture.HeadersRspFile, fmt.Sprintf("%s", fixture.HeaderJar)) + writeFile(t, fixture.JavacTargetJar, "Javac Jar") + writeFile(t, fixture.JavaSrcDeps, "") + createProtoFileWithActualPaths(t, fixture.JavaSrcDeps, fixture.JavaFile1, fixture.JavaFile2, fixture.JavaFile3) + + return fixture +} + +// runGenerator calls GenerateIncrementalInput for the testFixture +func (tf *testFixture) runGenerator() { + // Small delay often needed for filesystem timestamp granularity + time.Sleep(15 * time.Millisecond) + err := GenerateIncrementalInput(tf.ClassDir, tf.SrcRspFile, tf.DepsRspFile, tf.JavacTargetJar, tf.JavaSrcDeps, tf.HeadersRspFile) + if err != nil { + tf.t.Fatalf("GenerateIncrementalInput() returned an error: %v", err) + } +} + +// returns incOutputPath for testFixture +func (tf *testFixture) incOutputPath() string { + return tf.JavacTargetJar + ".inc.rsp" +} + +// returns remOutputPath for testFixture +func (tf *testFixture) remOutputPath() string { + return tf.JavacTargetJar + ".rem.rsp" +} + +// Verifies the test output against expected output +func checkOutput(t *testing.T, incOutputPath, expectedIncContent, remOutputPath, expectedRemContent string) { + checkFileContent(t, incOutputPath, expectedIncContent) + checkFileContent(t, remOutputPath, expectedRemContent) +} + +// Helper to check if the content of a file matches the expected content (order insensitive) +func checkFileContent(t *testing.T, filePath, expectedContent string) { + contentBytes, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read output file %q: %v", filePath, err) + } + actualContent := strings.TrimSpace(string(contentBytes)) + + actualLines := strings.Split(actualContent, "\n") + expectedLines := strings.Split(expectedContent, "\n") + sort.Strings(actualLines) + sort.Strings(expectedLines) + actualContent = strings.Join(actualLines, "\n") + expectedContent = strings.Join(expectedLines, "\n") + + if actualContent != expectedContent { + t.Errorf("Unexpected content in %q.\nGot:\n%s\nWant:\n%s", filePath, actualContent, expectedContent) + } +} + +// Helper to save prior state +func (tf *testFixture) savePriorState() { + tf.t.Helper() + // Implement your logic to save the necessary state files + // e.g., copy *.pc_state.new to *.pc_state + inputStateNew := tf.JavacTargetJar + ".input.pc_state.new" + inputState := tf.JavacTargetJar + ".input.pc_state" + depsStateNew := tf.JavacTargetJar + ".deps.pc_state.new" + depsState := tf.JavacTargetJar + ".deps.pc_state" + headerStateNew := tf.JavacTargetJar + ".headers.pc_state.new" + headerState := tf.JavacTargetJar + ".headers.pc_state" + + os.Rename(inputStateNew, inputState) + os.Rename(depsStateNew, depsState) + os.Rename(headerStateNew, headerState) +} + +// --- File Create/Mod/Delete helpers --- + +func createDir(t *testing.T, dirPath string) { + t.Helper() + err := os.MkdirAll(dirPath, 0755) + if err != nil { + t.Fatalf("Failed to create directory %q: %v", dirPath, err) + } +} + +func writeFile(t *testing.T, filePath, content string) { + t.Helper() + err := os.WriteFile(filePath, []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to write file %q: %v", filePath, err) + } +} + +func modifyFile(t *testing.T, filePath, newContentSuffix string) { + t.Helper() + // Append suffix to ensure modification time changes reliably + contentBytes, err := os.ReadFile(filePath) + if err != nil && !os.IsNotExist(err) { // Allow modification even if file was deleted before + t.Fatalf("Failed to read file for modification %q: %v", filePath, err) + } + newContent := string(contentBytes) + "// " + newContentSuffix + writeFile(t, filePath, newContent) +} + +func deleteFile(t *testing.T, filePath, srcRspFilePath string) { + t.Helper() + err := os.Remove(filePath) + if err != nil && !os.IsNotExist(err) { // Ignore error if already deleted + t.Fatalf("Failed to delete file %q: %v", filePath, err) + } + // Also update the source rsp file! + updateSrcRsp(t, srcRspFilePath, filePath, true) +} + +func updateSrcRsp(t *testing.T, rspPath, filePath string, remove bool) { + t.Helper() + contentBytes, err := os.ReadFile(rspPath) + if err != nil { + t.Fatalf("Failed to read source rsp file %q: %v", rspPath, err) + } + lines := strings.Split(string(contentBytes), "\n") + var newLines []string + found := false + for _, line := range lines { + trimmedLine := strings.TrimSpace(line) + if trimmedLine == "" { + continue + } + if trimmedLine == filePath { + found = true + if !remove { // Keep it if not removing + newLines = append(newLines, trimmedLine) + } + } else { + newLines = append(newLines, trimmedLine) + } + } + // Add back if it wasn't found and we are not removing (e.g., restoring) + if !found && !remove { + newLines = append(newLines, filePath) + } + + writeFile(t, rspPath, strings.Join(newLines, "\n")) +} + +// --- ProtoFile Creation helpers --- + +func createProtoFile(t *testing.T, filePath string) string { + t.Helper() + + dep1 := &dependency_proto.FileDependency{ + FilePath: proto.String("file1.java"), + FileDependencies: []string{"file2.java", "file3.java"}, + IsDependencyToAll: proto.Bool(false), + GeneratedClasses: []string{"ClassA", "ClassB"}, + } + dep2 := &dependency_proto.FileDependency{ + FilePath: proto.String("file2.java"), + FileDependencies: []string{}, + IsDependencyToAll: proto.Bool(true), + GeneratedClasses: []string{"ClassC"}, + } + dep3 := &dependency_proto.FileDependency{ + FilePath: proto.String("file3.java"), + FileDependencies: []string{}, + IsDependencyToAll: proto.Bool(false), + GeneratedClasses: []string{"ClassD"}, + } + + message := &dependency_proto.FileDependencyList{ + FileDependency: []*dependency_proto.FileDependency{dep1, dep2, dep3}, + } + + data, err := proto.Marshal(message) + if err != nil { + t.Fatalf("Failed to marshal proto message: %v", err) + } + + if err := os.WriteFile(filePath, data, 0644); err != nil { + t.Fatalf("Failed to write proto file: %v", err) + } + + return filePath +} + +func createProtoFileWithActualPaths(t *testing.T, protoFilePath, javaFile1, javaFile2, javaFile3 string) string { + t.Helper() + + dep1 := &dependency_proto.FileDependency{ + FilePath: proto.String(javaFile1), + FileDependencies: []string{javaFile2, javaFile3}, + IsDependencyToAll: proto.Bool(false), + GeneratedClasses: []string{"src/com/example/ClassA", "src/com/example/ClassB"}, + } + dep2 := &dependency_proto.FileDependency{ + FilePath: proto.String(javaFile2), + FileDependencies: []string{}, + IsDependencyToAll: proto.Bool(true), + GeneratedClasses: []string{"src/com/example/ClassC"}, + } + dep3 := &dependency_proto.FileDependency{ + FilePath: proto.String(javaFile3), + FileDependencies: []string{}, + IsDependencyToAll: proto.Bool(false), + GeneratedClasses: []string{"org.another.ClassD"}, + } + + message := &dependency_proto.FileDependencyList{ + FileDependency: []*dependency_proto.FileDependency{dep1, dep2, dep3}, + } + + data, err := proto.Marshal(message) + if err != nil { + t.Fatalf("Failed to marshal proto message: %v", err) + } + + if err := os.WriteFile(protoFilePath, data, 0644); err != nil { + t.Fatalf("Failed to write proto file: %v", err) + } + + return protoFilePath +} |
