diff --git a/packages/amazonq/test/e2e/amazonq/review.test.ts b/packages/amazonq/test/e2e/amazonq/review.test.ts index 5828d58e8d2..5914e3eaa29 100644 --- a/packages/amazonq/test/e2e/amazonq/review.test.ts +++ b/packages/amazonq/test/e2e/amazonq/review.test.ts @@ -5,7 +5,7 @@ */ import assert from 'assert' -import vscode from 'vscode' +import * as vscode from 'vscode' import { qTestingFramework } from './framework/framework' import sinon from 'sinon' import { Messenger } from './framework/messenger' @@ -16,9 +16,14 @@ import { invalidFileTypeChatMessage, CodeAnalysisScope, SecurityScanStep, + amazonqCodeIssueDetailsTabTitle, + CodeWhispererConstants, + CodeScanIssue, } from 'aws-core-vscode/codewhisperer' import path from 'path' import { ScanAction, scanProgressMessage } from '../../../src/app/amazonqScan/models/constants' +import { SecurityIssueProvider } from 'aws-core-vscode/codewhisperer' +import { fs, waitUntil, processUtils } from 'aws-core-vscode/shared' function getWorkspaceFolder(): string { return vscode.workspace.workspaceFolders![0].uri.fsPath @@ -59,23 +64,20 @@ describe('Amazon Q Code Review', function () { return issues } - function hasExactlyMatchingSecurityDiagnostic( - diagnostics: vscode.Diagnostic[], - code: string, - message: string, - startLine: number, - endLine: number, - count: number = 1 - ) { - const matchingDiagnostics = diagnostics.filter( - (diagnostic) => - diagnostic.code === code && - diagnostic.message === message && - diagnostic.range.start.line === startLine && - diagnostic.range.end.line === endLine - ) + function matchingSecurityDiagnosticCount(diagnostics: vscode.Diagnostic[], message: string, lineNumber?: number) { + const matchingDiagnostics = diagnostics.filter((diagnostic) => { + let matches = diagnostic.message === message + + // Only filter by startLine if it's provided + if (lineNumber !== undefined) { + matches = + matches && diagnostic.range.start.line <= lineNumber && diagnostic.range.end.line >= lineNumber + } - assert.deepEqual(matchingDiagnostics.length, count) + return matches + }) + + return matchingDiagnostics.length } async function waitForChatItems(index: number, waitTimeoutInMs: number = 5000, waitIntervalInMs: number = 1000) { @@ -172,17 +174,15 @@ describe('Amazon Q Code Review', function () { }) }) - describe('review insecure file or project', async () => { - const testFolder = path.join(getWorkspaceFolder(), 'QCAFolder') - const fileName = 'ProblematicCode.java' - const filePath = path.join(testFolder, fileName) + describe('review insecure file and then fix file', async () => { + it('/review file gives correct critical and high security issues, clicks on view details, generate fix, verify diff, apply fix', async () => { + const testFolder = path.join(getWorkspaceFolder(), 'QCAFolder') + const fileName = 'ProblematicCode.java' + const filePath = path.join(testFolder, fileName) - beforeEach(async () => { await validateInitialChatMessage() - }) - - it.skip('/review file gives correct critical and high security issues', async () => { const document = await vscode.workspace.openTextDocument(filePath) + const originalContent = document.getText() await vscode.window.showTextDocument(document) tab.clickButton(ScanAction.RUN_FILE_SCAN) @@ -204,25 +204,203 @@ describe('Amazon Q Code Review', function () { ) const uri = vscode.Uri.file(filePath) - const securityDiagnostics: vscode.Diagnostic[] = vscode.languages + const securityDiagnostics = vscode.languages .getDiagnostics(uri) .filter((diagnostic) => diagnostic.source === codewhispererDiagnosticSourceLabel) - // 1 exact critical issue matches - hasExactlyMatchingSecurityDiagnostic( - securityDiagnostics, - 'java-do-not-hardcode-database-password', - 'CWE-798 - Hardcoded credentials', - 20, - 21 + assert.ok(securityDiagnostics.length > 0, 'No security diagnostics found') + + // at least 1 exact critical issue matches + assert.ok( + matchingSecurityDiagnosticCount(securityDiagnostics, 'CWE-798 - Hardcoded credentials', 21) >= 1 ) - }) - it('/review project gives findings', async () => { - tab.clickButton(ScanAction.RUN_PROJECT_SCAN) + // Find one diagnostic + const sampleDiagnostic = securityDiagnostics[0] + assert.ok(sampleDiagnostic, 'Could not find critical issue diagnostic') - const scanResultBody = await waitForReviewResults(tab) - extractAndValidateIssues(scanResultBody) + const range = new vscode.Range(sampleDiagnostic.range.start, sampleDiagnostic.range.end) + + const codeActions = await vscode.commands.executeCommand( + 'vscode.executeCodeActionProvider', + uri, + range + ) + + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // Find the "View details" code action + const viewDetailsAction = codeActions?.find((action) => action.title.includes('View details')) + assert.ok(viewDetailsAction, 'Could not find View details code action') + + // Execute the view details command + if (viewDetailsAction?.command) { + await vscode.commands.executeCommand( + viewDetailsAction.command.command, + ...viewDetailsAction.command.arguments! + ) + } + + // Wait for the webview panel to open with polling + const webviewPanel = await waitUntil( + async () => { + const panels = vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .filter((tab) => tab.label === amazonqCodeIssueDetailsTabTitle) + .map((tab) => tab.input) + .filter((input): input is vscode.WebviewPanel => input !== undefined) + + return panels.length > 0 ? panels[0] : undefined + }, + { + timeout: 20_000, + interval: 1000, + truthy: false, + } + ) + + await new Promise((resolve) => setTimeout(resolve, 1000)) + + assert.ok(webviewPanel, 'Security issue webview panel did not open after waiting') + + // Wait until viewDetailsAction.command is defined + const viewDetailsActionDefined = await waitUntil( + async () => { + return viewDetailsAction.command?.arguments !== undefined + ? viewDetailsAction.command + : undefined + }, + { + timeout: 10_000, + interval: 500, + truthy: true, + } + ) + await new Promise((resolve) => setTimeout(resolve, 1000)) + + assert.ok(viewDetailsActionDefined, 'viewDetailsAction.command was not defined after waiting') + + const issue = viewDetailsActionDefined.arguments?.[0] as CodeScanIssue + console.log('issue', issue) + + // Wait for the fix to be generated with polling + const updatedIssue = await waitUntil( + async () => { + const foundIssue = SecurityIssueProvider.instance.issues + .flatMap(({ issues }) => issues) + .find((i) => i.findingId === issue.findingId) + + return foundIssue?.suggestedFixes?.length !== undefined && + foundIssue?.suggestedFixes?.length > 0 + ? foundIssue + : undefined + }, + { + timeout: CodeWhispererConstants.codeFixJobTimeoutMs + 20_000, + interval: CodeWhispererConstants.codeFixJobPollingIntervalMs, + truthy: true, + } + ) + + await new Promise((resolve) => setTimeout(resolve, 1000)) + + console.log('updated issue', updatedIssue) + console.log('original issue', issue) + + // Verify the fix was generated by checking if the issue has suggestedFixes + assert.ok(updatedIssue, 'Could not find updated issue') + assert.ok(updatedIssue.suggestedFixes.length > 0, 'No suggested fixes were generated') + + // Get the suggested fix and verify it contains diff markers + const suggestedFix = updatedIssue.suggestedFixes[0] + const suggestedFixDiff = suggestedFix.code + assert.ok(suggestedFixDiff, 'No suggested fix code was found') + assert.ok( + suggestedFixDiff.includes('-') && suggestedFixDiff.includes('+'), + 'Suggested fix does not contain diff markers' + ) + + // Parse the diff to extract removed and added lines + const diffLines = suggestedFixDiff.split('\n') + const removedLines = diffLines + .filter((line) => line.startsWith('-') && !line.startsWith('---')) + .map((line) => line.substring(1).trim()) + const addedLines = diffLines + .filter((line) => line.startsWith('+') && !line.startsWith('+++')) + .map((line) => line.substring(1).trim()) + + // Make sure we found some changes in the diff + assert.ok(addedLines.length + removedLines.length > 0, 'No added or deleted lines found in the diff') + + // Apply the fix + await vscode.commands.executeCommand('aws.amazonq.applySecurityFix', updatedIssue, filePath, 'webview') + + // Wait for the fix to be applied + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // Verify the fix was applied to the file + const updatedDocument = await vscode.workspace.openTextDocument(filePath) + const updatedContent = updatedDocument.getText() + + // Check that the content has changed + assert.notStrictEqual( + updatedContent, + originalContent, + 'File content did not change after applying the fix' + ) + + // Count occurrences of each line in original and updated content + const countOccurrences = (text: string, line: string): number => { + const regex = new RegExp(`^\\s*${line.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'gm') + const matches = text.match(regex) + return matches ? matches.length : 0 + } + + // Create a dictionary to track expected line count changes + const lineCountChanges: Record = {} + + // Process removed lines (decrement count) + for (const removedLine of removedLines) { + if (removedLine.trim()) { + // Skip empty lines + const trimmedLine = removedLine.trim() + lineCountChanges[trimmedLine] = (lineCountChanges[trimmedLine] || 0) - 1 + } + } + + // Process added lines (increment count) + for (const addedLine of addedLines) { + if (addedLine.trim()) { + // Skip empty lines + const trimmedLine = addedLine.trim() + lineCountChanges[trimmedLine] = (lineCountChanges[trimmedLine] || 0) + 1 + } + } + + // Verify all line count changes match expectations + for (const [line, expectedChange] of Object.entries(lineCountChanges)) { + const originalCount = countOccurrences(originalContent, line) + const updatedCount = countOccurrences(updatedContent, line) + const actualChange = updatedCount - originalCount + + assert.strictEqual( + actualChange, + expectedChange, + `Line "${line}" count change mismatch: expected ${expectedChange}, got ${actualChange} (original: ${originalCount}, updated: ${updatedCount})` + ) + } + + // Revert the changes + await fs.writeFile(uri, originalContent) + + // Wait a moment for the file system to update + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // Verify the file was reverted + const revertedDocument = await vscode.workspace.openTextDocument(filePath) + const revertedContent = revertedDocument.getText() + + assert.deepStrictEqual(revertedContent, originalContent, 'File content was not properly reverted') }) }) @@ -230,11 +408,12 @@ describe('Amazon Q Code Review', function () { const testFolder = path.join(getWorkspaceFolder(), 'QCAFolder') const fileName = 'ProblematicCode.java' const filePath = path.join(testFolder, fileName) + let document: vscode.TextDocument beforeEach(async () => { await validateInitialChatMessage() - const document = await vscode.workspace.openTextDocument(filePath) + document = await vscode.workspace.openTextDocument(filePath) await vscode.window.showTextDocument(document) const editor = vscode.window.activeTextEditor @@ -244,6 +423,7 @@ describe('Amazon Q Code Review', function () { await editor.edit((editBuilder) => { editBuilder.insert(position, '// amazonq-ignore-next-line\n') }) + await document.save() } }) @@ -264,12 +444,8 @@ describe('Amazon Q Code Review', function () { .filter((diagnostic) => diagnostic.source === codewhispererDiagnosticSourceLabel) // cannot find this ignored issue - hasExactlyMatchingSecurityDiagnostic( - securityDiagnostics, - 'java-do-not-hardcode-database-password', - 'CWE-798 - Hardcoded credentials', - 21, - 22, + assert.equal( + matchingSecurityDiagnosticCount(securityDiagnostics, 'CWE-798 - Hardcoded credentials', 22), 0 ) @@ -279,6 +455,155 @@ describe('Amazon Q Code Review', function () { const lineRange = editor.document.lineAt(20).rangeIncludingLineBreak editBuilder.delete(lineRange) }) + await document.save() + } + }) + }) + + describe('Project and file scans should return at least 1 LLM findings', async () => { + const testFolder = path.join(getWorkspaceFolder(), 'QCAFolder') + const fileName = 'RLinker.java' + const filePath = path.join(testFolder, fileName) + let document: vscode.TextDocument + + function assertAtLeastOneLLMFindings(securityDiagnostics: vscode.Diagnostic[]) { + const readabilityIssuesCount = matchingSecurityDiagnosticCount( + securityDiagnostics, + 'Readability and maintainability issues detected.' + ) + const performanceIssuesCount = matchingSecurityDiagnosticCount( + securityDiagnostics, + 'Performance inefficiencies detected in code.' + ) + const errorHandlingIssuesCount = matchingSecurityDiagnosticCount( + securityDiagnostics, + 'Inadequate error handling detected.' + ) + const namingIssuesCount = matchingSecurityDiagnosticCount( + securityDiagnostics, + 'Inconsistent or unclear naming detected.' + ) + const loggingIssuesCount = matchingSecurityDiagnosticCount( + securityDiagnostics, + 'Insufficient or improper logging found.' + ) + assert.ok( + readabilityIssuesCount + + performanceIssuesCount + + errorHandlingIssuesCount + + namingIssuesCount + + loggingIssuesCount > + 0, + 'No LLM findings were found' + ) + } + + beforeEach(async () => { + await validateInitialChatMessage() + }) + + it('file scan returns at least 1 LLM findings', async () => { + document = await vscode.workspace.openTextDocument(filePath) + await vscode.window.showTextDocument(document) + + tab.clickButton(ScanAction.RUN_FILE_SCAN) + + await waitForChatItems(6) + const scanningInProgressMessage = tab.getChatItems()[6] + assert.deepStrictEqual( + scanningInProgressMessage.body, + scanProgressMessage(SecurityScanStep.CREATE_SCAN_JOB, CodeAnalysisScope.FILE_ON_DEMAND, fileName) + ) + + const scanResultBody = await waitForReviewResults(tab) + + const issues = extractAndValidateIssues(scanResultBody) + + assert.deepStrictEqual( + issues.Critical + issues.High + issues.Medium + issues.Low + issues.Info >= 1, + true, + `There are no issues detected when there should be at least 1` + ) + + const uri = vscode.Uri.file(filePath) + const securityDiagnostics: vscode.Diagnostic[] = vscode.languages + .getDiagnostics(uri) + .filter((diagnostic) => diagnostic.source === codewhispererDiagnosticSourceLabel) + + assertAtLeastOneLLMFindings(securityDiagnostics) + }) + + it('project scan returns at least 1 LLM findings', async () => { + const fileDir = path.join(getWorkspaceFolder()) + + try { + // Initialize git repository to make RLinker.java appear in git diff + await processUtils.ChildProcess.run('git', ['init'], { spawnOptions: { cwd: fileDir } }) + await processUtils.ChildProcess.run('git', ['add', 'QCAFolder/RLinker.java'], { + spawnOptions: { cwd: fileDir }, + }) + await processUtils.ChildProcess.run('git', ['config', 'user.name', 'Test'], { + spawnOptions: { cwd: fileDir }, + }) + await processUtils.ChildProcess.run('git', ['config', 'user.email', 'test@example.com'], { + spawnOptions: { cwd: fileDir }, + }) + await processUtils.ChildProcess.run('git', ['commit', '-m', 'Initial commit'], { + spawnOptions: { cwd: fileDir }, + }) + + document = await vscode.workspace.openTextDocument(filePath) + await vscode.window.showTextDocument(document) + + const editor = vscode.window.activeTextEditor + + if (editor) { + const position = new vscode.Position(20, 0) + await editor.edit((editBuilder) => { + editBuilder.insert(position, '\n') + }) + // save file + await document.save() + } + + // Run the project scan + tab.clickButton(ScanAction.RUN_PROJECT_SCAN) + + await waitForChatItems(6) + const scanningInProgressMessage = tab.getChatItems()[6] + assert.deepStrictEqual( + scanningInProgressMessage.body, + scanProgressMessage(SecurityScanStep.CREATE_SCAN_JOB, CodeAnalysisScope.PROJECT) + ) + + const scanResultBody = await waitForReviewResults(tab) + + const issues = extractAndValidateIssues(scanResultBody) + + assert.deepStrictEqual( + issues.Critical + issues.High + issues.Medium + issues.Low + issues.Info >= 1, + true, + `There are no issues detected when there should be at least 1` + ) + + const uri = vscode.Uri.file(filePath) + const securityDiagnostics: vscode.Diagnostic[] = vscode.languages + .getDiagnostics(uri) + .filter((diagnostic) => diagnostic.source === codewhispererDiagnosticSourceLabel) + + assertAtLeastOneLLMFindings(securityDiagnostics) + + const editor2 = vscode.window.activeTextEditor + if (editor2) { + await editor2.edit((editBuilder) => { + const lineRange = editor2.document.lineAt(20).rangeIncludingLineBreak + editBuilder.delete(lineRange) + }) + await document.save() + } + } finally { + // Clean up git repository + await fs.delete(path.join(fileDir, '.git'), { recursive: true }) } }) }) diff --git a/packages/core/src/codewhisperer/commands/basicCommands.ts b/packages/core/src/codewhisperer/commands/basicCommands.ts index c2978b56568..7fe6078a1d7 100644 --- a/packages/core/src/codewhisperer/commands/basicCommands.ts +++ b/packages/core/src/codewhisperer/commands/basicCommands.ts @@ -369,6 +369,9 @@ export const openSecurityIssuePanel = Commands.declare( const targetFilePath: string = issue instanceof IssueItem ? issue.filePath : filePath await showSecurityIssueWebview(context.extensionContext, targetIssue, targetFilePath) + if (targetIssue.suggestedFixes.length === 0) { + await generateFix.execute(targetIssue, targetFilePath, 'webview', true, false) + } telemetry.codewhisperer_codeScanIssueViewDetails.emit({ findingId: targetIssue.findingId, detectorId: targetIssue.detectorId, @@ -387,9 +390,6 @@ export const openSecurityIssuePanel = Commands.declare( undefined, !!targetIssue.suggestedFixes.length ) - if (targetIssue.suggestedFixes.length === 0) { - await generateFix.execute(targetIssue, targetFilePath, 'webview', true, false) - } } ) diff --git a/packages/core/src/testFixtures/workspaceFolder/QCAFolder/RLinker.java b/packages/core/src/testFixtures/workspaceFolder/QCAFolder/RLinker.java new file mode 100644 index 00000000000..0012ec73813 --- /dev/null +++ b/packages/core/src/testFixtures/workspaceFolder/QCAFolder/RLinker.java @@ -0,0 +1,248 @@ +package recall; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.clyze.utils.ContainerUtils; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** A linker of R-class data. + * + * Given a list of AAR files, this linker extracts R.txt from each + * file and creates the corresponding R classes that are needed so + * that Doop does not report them as phantom classes. This linker does + * not mimic the full logic of the aapt tool, it only generates code + * that is good enough for linking (in the form of a JAR file + * containing all R.java and R*.class files). + * + * This code calls 'javac' and 'jar', so it may fail when these + * programs are not in the path. + */ +public class RLinker { + + private static final String R_AUTOGEN_JAR = "doop-autogen-R.jar"; + + // A map from package names -> nested class -> field -> element + // ids. Used by lookupConst() and XML parsing for layout controls. + private final Map > > constants; + + // A map from package names -> nested class -> set of text + // entries. Used for code generation. + private final Map > > rs; + + // Singleton instance. + private static RLinker instance; + + private RLinker() { + this.constants = new HashMap<>(); + this.rs = new HashMap<>(); + } + + public static RLinker getInstance() { + if (instance == null) + instance = new RLinker(); + return instance; + } + + Integer lookupConst(String packageName, String nestedName, String fld) { + Map > pkgEntry = constants.get(packageName); + if (pkgEntry != null) { + Map fieldEntry = pkgEntry.get(nestedName); + if (fieldEntry != null) { + Integer c = fieldEntry.get(fld); + if (c != null) + return c; + } + } + return null; + } + + /** + * The entry point of the linker. Takes a list of archives + * (containing paths of AAR files) and a map of AAR paths to + * package names. Returns the path of the generated JAR (or null + * if no code generation was done). + */ + String linkRs(String rDir, Set tmpDirs) { + if ((rDir == null) || rs.isEmpty()) { + return null; + } else { + final String tmpDir = ContainerUtils.createTmpDir(tmpDirs); + rs.forEach ((k, v) -> runProcess("javac " + genR(tmpDir, k, v))); + + // Compile JAR and optionally copy to output directory. + String tmpJarName = tmpDir + "/" + R_AUTOGEN_JAR; + runProcess("jar cf " + tmpJarName + " -C " + tmpDir + " ."); + String outJarName = rDir + "/" + R_AUTOGEN_JAR; + try { + FileUtils.copyFile(new File(tmpJarName), new File(outJarName)); + return outJarName; + } catch (IOException ex) { + System.err.println("Failed to copy "); + } + + return tmpJarName; + } + } + + /** + * Given an AAR input and a package name, the R constants are read + * from the R.txt file contained in the archive and the + * appropriate data structures of the linker are filled in. + * + * @param ar The path of the input. + * @param pkg The package name of the input. + */ + public void readRConstants(String ar, String pkg) { + if (!ar.endsWith(".aar")) + return; + try { + String rText = getZipEntry(new ZipFile(ar), "R.txt"); + if (rText != null) { + for (String line : rText.split("\n|\r")) + if (line.length() != 0) + processRLine(ar, line, pkg); + } + } catch (IOException ex) { + System.err.println("Error while reading R.txt: " + ar); + System.err.println(ex.getMessage()); + } + } + + /** + * Process each line in R.txt and (a) generate Java code for later + * use (in 'rs') and (b) remember constant ids (in 'constants'). + * + * @param ar The path of the archive. + * @param line The line of text to be processed. + * @param pkg The package name of the archive. + */ + private void processRLine(String ar, String line, String pkg) { + final String delim = " "; + String[] parts = line.split(delim); + if (parts.length < 2) { + System.err.println("Error processing R.txt"); + } else if (pkg == null) { + System.err.println("WARNING: no package: " + ar); + } else { + + // Extract information from the line text. + String nestedR = parts[1]; + // String rName = pkg + "." + "R$" + nestedR; + String[] newParts = new String[parts.length]; + newParts[0] = parts[0]; + newParts[1] = parts[2]; + newParts[2] = "="; + System.arraycopy(parts, 3, newParts, 3, parts.length - 3); + + // Remember int constants. + if (newParts[0].equals("int") && (newParts.length > 3)) { + String num = newParts[3]; + int val = num.startsWith("0x") ? + (int)(Long.parseLong(num.substring(2), 16)) : + Integer.parseInt(num); + addConstant(pkg, nestedR, newParts[1], val); + } + + // Generate Java code. + Map> pkgEntry = rs.getOrDefault(pkg, new HashMap<>()); + Set set = pkgEntry.getOrDefault(nestedR, new HashSet<>()); + String declaration = " public static "; + boolean first= true; + for (String part: newParts){ + if (first) { + declaration+=part; + first=false; + } else { + declaration+=delim+part; + } + } + declaration+=";"; + set.add(declaration); + pkgEntry.put(nestedR, set); + rs.put(pkg, pkgEntry); + } + } + + /** + * Adds a tuple (packageName, nested, f, c) to 'constants'. + */ + private void addConstant(String packageName, String nested, String f, int c) { + Map> packageEntry = constants.getOrDefault(packageName, new HashMap<>()); + Map nestedEntry = packageEntry.getOrDefault(nested, new HashMap<>()); + Integer val = nestedEntry.get(f); + if (val == null) + nestedEntry.put(f, c); + else if (!val.equals(c)) + System.err.println("WARNING: duplicate values"); + packageEntry.put(nested, nestedEntry); + constants.put(packageName, packageEntry); + } + + private static void runProcess(String cmd) { + try { + Process p = Runtime.getRuntime().exec(cmd); p.waitFor(); int exitVal = p.exitValue(); + if (exitVal != 0) { + System.out.println(cmd + " exit value = " + exitVal); + } + } catch (Exception ex) { + System.err.println("Error invoking command"); + } + } + + private static String genR(String tmpDir, String pkg, + Map> rData) { + String subdir = tmpDir + File.separator + pkg.replaceAll("\\.", File.separator); + if (new File(subdir).mkdirs()) + System.out.println("Created directory: " + subdir); + String rFile = subdir + "/R.java"; + System.out.println("Generating " + rFile); + Collection lines = new ArrayList<>(); + lines.add("// Auto-generated R.java by Doop.\n"); + lines.add("package " + pkg + ";\n"); + lines.add("public final class R {"); + rData.forEach ((k, v) -> genNestedR(k, v, lines)); + lines.add("}"); + + try { + Files.write(Paths.get(rFile), lines, StandardCharsets.UTF_8); + } catch (IOException ex) { + System.err.println("Error generating R class for package: " + pkg); + ex.printStackTrace(); + return null; + } + return rFile; + } + + private static void genNestedR(String nestedName, Collection data, + Collection lines) { + lines.add(" public static final class " + nestedName + " {\n"); + lines.addAll(data); + lines.add(" }\n"); + } + + private static String getZipEntry(ZipFile zip, String entryName) { + try { + Enumeration entries = zip.entries(); + while(entries.hasMoreElements()) { + ZipEntry e = entries.nextElement(); + if (e.getName().equals(entryName)) { + InputStream is = zip.getInputStream(e); + return IOUtils.toString(is, StandardCharsets.UTF_8); + } + } + } catch (IOException ex) { + System.err.println("Error reading zip file"); + } + return null; + } + +} \ No newline at end of file