diff --git a/biz.aQute.bndlib.tests/test/test/BndEditModelTest.java b/biz.aQute.bndlib.tests/test/test/BndEditModelTest.java index c483e44779..10507056d0 100644 --- a/biz.aQute.bndlib.tests/test/test/BndEditModelTest.java +++ b/biz.aQute.bndlib.tests/test/test/BndEditModelTest.java @@ -352,4 +352,65 @@ private String getPortablePath(File base) { return path; } + @Test + void testGetLocalMergePropertyKey_baseKey() throws Exception { + BndEditModel model = new BndEditModel(); + model.loadFrom("-runrequires: osgi.identity;filter:='(osgi.identity=foo)'"); + assertThat(model.getLocalMergePropertyKey(Constants.RUNREQUIRES)) + .contains(Constants.RUNREQUIRES); + } + + @Test + void testGetLocalMergePropertyKey_variantKey() throws Exception { + BndEditModel model = new BndEditModel(); + model.loadFrom("-runrequires.shared: osgi.identity;filter:='(osgi.identity=foo)'"); + assertThat(model.getLocalMergePropertyKey(Constants.RUNREQUIRES)) + .contains("-runrequires.shared"); + } + + @Test + void testGetLocalMergePropertyKey_noKey() throws Exception { + BndEditModel model = new BndEditModel(); + model.loadFrom("# empty bndrun"); + assertThat(model.getLocalMergePropertyKey(Constants.RUNREQUIRES)) + .isEmpty(); + } + + @Test + void testGetLocalMergePropertyKey_exactKeyTakesPrecedence() throws Exception { + BndEditModel model = new BndEditModel(); + model.loadFrom("-runrequires.shared: foo\n-runrequires: bar"); + assertThat(model.getLocalMergePropertyKey(Constants.RUNREQUIRES)) + .contains(Constants.RUNREQUIRES); + } + + @Test + void testSetRunRequiresAtKey_baseKey_writesToRunrequires() throws Exception { + BndEditModel model = new BndEditModel(); + model.loadFrom("-runrequires: osgi.identity;filter:='(osgi.identity=foo)'"); + Requirement req = new CapReqBuilder(IdentityNamespace.IDENTITY_NAMESPACE) + .addDirective(Namespace.REQUIREMENT_FILTER_DIRECTIVE, "(osgi.identity=bar)") + .buildSyntheticRequirement(); + + model.setRunRequiresAtKey(Constants.RUNREQUIRES, List.of(req)); + + assertThat(model.getDocumentChanges()).containsKey(Constants.RUNREQUIRES); + assertThat(model.getDocumentChanges()).doesNotContainKey("-runrequires.shared"); + } + + @Test + void testSetRunRequiresAtKey_variantKey_writesToVariant() throws Exception { + BndEditModel model = new BndEditModel(); + model.loadFrom("-runrequires.shared: osgi.identity;filter:='(osgi.identity=foo)'"); + Requirement req = new CapReqBuilder(IdentityNamespace.IDENTITY_NAMESPACE) + .addDirective(Namespace.REQUIREMENT_FILTER_DIRECTIVE, "(osgi.identity=bar)") + .buildSyntheticRequirement(); + + model.setRunRequiresAtKey("-runrequires.shared", List.of(req)); + + assertThat(model.getDocumentChanges()).containsKey("-runrequires.shared"); + assertThat(model.getDocumentChanges()).doesNotContainKey(Constants.RUNREQUIRES); + } + } + diff --git a/biz.aQute.bndlib/bnd.bnd b/biz.aQute.bndlib/bnd.bnd index 94e8c4796b..9a3cc0db40 100644 --- a/biz.aQute.bndlib/bnd.bnd +++ b/biz.aQute.bndlib/bnd.bnd @@ -11,7 +11,7 @@ Bundle-Description: bndlib: A Swiss Army Knife for OSGi Export-Package: \ aQute.bnd.build;-noimport:=true,\ aQute.bnd.build.api;-noimport:=true,\ - aQute.bnd.build.model;-noimport:=true,\ + aQute.bnd.build.model;version='4.6.0';-noimport:=true,\ aQute.bnd.build.model.clauses;-noimport:=true,\ aQute.bnd.build.model.conversions;-noimport:=true,\ aQute.bnd.buildtool;-noimport:=true,\ diff --git a/biz.aQute.bndlib/src/aQute/bnd/build/model/BndEditModel.java b/biz.aQute.bndlib/src/aQute/bnd/build/model/BndEditModel.java index 86d95b6331..7a40c6020f 100644 --- a/biz.aQute.bndlib/src/aQute/bnd/build/model/BndEditModel.java +++ b/biz.aQute.bndlib/src/aQute/bnd/build/model/BndEditModel.java @@ -1204,6 +1204,31 @@ public void setRunRequires(List requires) { doSetObject(Constants.RUNREQUIRES, oldValue, requires, requirementListFormatter); } + /** Returns the local document property key matching the stem or a stem.* variant, or empty if none. */ + public Optional getLocalMergePropertyKey(String stem) { + if (documentProperties.containsKey(stem)) + return Optional.of(stem); + String prefix = stem + "."; + return documentProperties.stringPropertyNames() + .stream() + .filter(k -> k.startsWith(prefix)) + .findFirst(); + } + + /** + * Sets run requirements at the given key, which may be a stem.* variant (e.g. {@code -runrequires.shared}). + * Also fires a property change for the base {@link Constants#RUNREQUIRES} key so all subscribers are notified. + */ + public void setRunRequiresAtKey(String key, List requires) { + if (Constants.RUNREQUIRES.equals(key)) { + setRunRequires(requires); + return; + } + List oldValue = doGetObject(key, requirementListConverter); + doSetObject(key, oldValue, requires, requirementListFormatter); + propChangeSupport.firePropertyChange(Constants.RUNREQUIRES, oldValue, requires); + } + public List getRunBlacklist() { return doGetObject(Constants.RUNBLACKLIST, requirementListConverter, true); } diff --git a/bndtools.builder/src/org/bndtools/builder/handlers/baseline/BaselineErrorHandler.java b/bndtools.builder/src/org/bndtools/builder/handlers/baseline/BaselineErrorHandler.java index dc30a8a604..ac172bda75 100644 --- a/bndtools.builder/src/org/bndtools/builder/handlers/baseline/BaselineErrorHandler.java +++ b/bndtools.builder/src/org/bndtools/builder/handlers/baseline/BaselineErrorHandler.java @@ -2,10 +2,13 @@ import java.io.ByteArrayInputStream; import java.io.File; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.bndtools.api.BndtoolsConstants; import org.bndtools.api.ILogger; @@ -17,6 +20,7 @@ import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IMarker; import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.IWorkspace; import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IPath; @@ -62,6 +66,7 @@ public class BaselineErrorHandler extends AbstractBuildErrorDetailsHandler { private static final String PACKAGEINFO = "packageinfo"; private static final String PACKAGEINFOJAVA = "package-info.java"; private static final String PROP_SUGGESTED_VERSION = "suggestedVersion"; + private static final String PROP_PACKAGE_NAME = "packageName"; private static final String ANNOTATION_VERSION_BND_PKG = "aQute.bnd.annotation"; private static final String ANNOTATION_VERSION_OSGI_PKG = "org.osgi.annotation.versioning"; @@ -107,6 +112,7 @@ List generatePackageInfoMarkers(Info baselineInfo, IJavaProject java attribs.put(IMarker.MESSAGE, message.trim()); attribs.put(IJavaModelMarker.ID, 8088); attribs.put(PROP_SUGGESTED_VERSION, baselineInfo.suggestedVersion.toString()); + attribs.put(PROP_PACKAGE_NAME, baselineInfo.packageName); if (range != null) { attribs.put(IMarker.CHAR_START, range.getOffset()); attribs.put(IMarker.CHAR_END, range.getOffset() + range.getLength()); @@ -128,6 +134,7 @@ List generatePackageInfoMarkers(Info baselineInfo, IJavaProject java Map attribs = new HashMap<>(); attribs.put(IMarker.MESSAGE, message.trim()); attribs.put(PROP_SUGGESTED_VERSION, baselineInfo.suggestedVersion.toString()); + attribs.put(PROP_PACKAGE_NAME, baselineInfo.packageName); LineLocation lineLoc = findVersionLocation(pkgInfoFile.getLocation() .toFile()); @@ -150,6 +157,7 @@ List generatePackageInfoMarkers(Info baselineInfo, IJavaProject java Map attribs = new HashMap<>(); attribs.put(IMarker.MESSAGE, message.trim()); attribs.put(PROP_SUGGESTED_VERSION, baselineInfo.suggestedVersion.toString()); + attribs.put(PROP_PACKAGE_NAME, baselineInfo.packageName); IPath location = bndfile.getLocation(); if (location != null) { File file = bndfile.getLocation() @@ -250,12 +258,12 @@ List generateStructuralChangeMarkers(Info baselineInfo, IJavaProject Tree classMember = classMemberDiff.getNewer(); if (Type.METHOD == classMember.getType()) markers.addAll(generateAddedMethodMarker(javaProject, className, classMember.getName(), - classMember.ifAdded())); + classMember.ifAdded(), baselineInfo)); } else if (Delta.REMOVED == classMemberDiff.getDelta()) { Tree classMember = classMemberDiff.getOlder(); if (Type.METHOD == classMember.getType()) { markers.addAll(generateRemovedMethodMarker(javaProject, className, - classMember.getName(), classMember.ifRemoved())); + classMember.getName(), classMember.ifRemoved(), baselineInfo)); } } } @@ -268,7 +276,7 @@ List generateStructuralChangeMarkers(Info baselineInfo, IJavaProject } List generateAddedMethodMarker(IJavaProject javaProject, String className, final String methodName, - final Delta requiresDelta) throws JavaModelException { + final Delta requiresDelta, final Info baselineInfo) throws JavaModelException { final List markers = new ArrayList<>(); final CompilationUnit ast = createAST(javaProject, className); @@ -283,9 +291,11 @@ public boolean visit(MethodDeclaration methodDecl) { attribs.put(IMarker.MESSAGE, message); attribs.put(IMarker.CHAR_START, methodDecl.getStartPosition()); attribs.put(IMarker.CHAR_END, methodDecl.getStartPosition() + methodDecl.getLength()); + attribs.put(PROP_PACKAGE_NAME, baselineInfo.packageName); + attribs.put(PROP_SUGGESTED_VERSION, baselineInfo.suggestedVersion.toString()); markers.add(new MarkerData(ast.getJavaElement() - .getResource(), attribs, false)); + .getResource(), attribs, true)); } return false; } @@ -298,14 +308,16 @@ public boolean visit(MethodDeclaration methodDecl) { attribs.put(IMarker.MESSAGE, message); attribs.put(IMarker.CHAR_START, 0); attribs.put(IMarker.CHAR_END, 0); + attribs.put(PROP_PACKAGE_NAME, baselineInfo.packageName); + attribs.put(PROP_SUGGESTED_VERSION, baselineInfo.suggestedVersion.toString()); - markers.add(new MarkerData(javaProject.getResource(), attribs, false)); + markers.add(new MarkerData(javaProject.getResource(), attribs, true)); } return markers; } List generateRemovedMethodMarker(IJavaProject javaProject, final String className, - final String methodName, final Delta requiresDelta) throws JavaModelException { + final String methodName, final Delta requiresDelta, final Info baselineInfo) throws JavaModelException { final List markers = new ArrayList<>(); final CompilationUnit ast = createAST(javaProject, className); if (ast != null) { @@ -325,9 +337,11 @@ public boolean visit(TypeDeclaration typeDecl) { "The method '%s' was removed, which requires a %s change to the package.", methodName, requiresDelta); attribs.put(IMarker.MESSAGE, message); + attribs.put(PROP_PACKAGE_NAME, baselineInfo.packageName); + attribs.put(PROP_SUGGESTED_VERSION, baselineInfo.suggestedVersion.toString()); markers.add(new MarkerData(ast.getJavaElement() - .getResource(), attribs, false)); + .getResource(), attribs, true)); return false; } } @@ -343,12 +357,18 @@ public List getResolutions(IMarker marker) { List result = new ArrayList<>(); final String suggestedVersion = marker.getAttribute(PROP_SUGGESTED_VERSION, null); - if (suggestedVersion != null) { + if (suggestedVersion == null) + return result; + + IResource resource = marker.getResource(); + String fileName = resource instanceof IFile ? resource.getName() : ""; + + if (PACKAGEINFO.equals(fileName)) { result.add(new IMarkerResolution() { @Override public void run(IMarker marker) { - final IFile file = (IFile) marker.getResource(); - final IWorkspace workspace = file.getWorkspace(); + IFile file = (IFile) marker.getResource(); + IWorkspace workspace = file.getWorkspace(); try { workspace.run(monitor -> { String input = "version " + suggestedVersion; @@ -365,22 +385,152 @@ public String getLabel() { return "Change package version to " + suggestedVersion; } }); + } else if (Project.BNDFILE.equals(fileName)) { + result.add(new IMarkerResolution() { + @Override + public void run(IMarker marker) { + IFile file = (IFile) marker.getResource(); + try { + String content = IO.collect(file.getLocation() + .toFile()); + int pkgEnd = marker.getAttribute(IMarker.CHAR_END, 0); + String newContent = replaceVersionInExportPackage(content, pkgEnd, suggestedVersion); + if (newContent != null) { + IWorkspace workspace = file.getWorkspace(); + workspace.run(monitor -> { + file.setContents( + new ByteArrayInputStream(newContent.getBytes(StandardCharsets.UTF_8)), + false, true, monitor); + }, null); + } + } catch (Exception e) { + logger.logError("Error applying bnd.bnd baseline version quickfix.", e); + } + } + + @Override + public String getLabel() { + return "Change package version to " + suggestedVersion; + } + }); + } else { + final String packageName = marker.getAttribute(PROP_PACKAGE_NAME, null); + if (packageName != null) { + result.add(new IMarkerResolution() { + @Override + public void run(IMarker marker) { + IProject project = marker.getResource() + .getProject(); + IJavaProject javaProject = JavaCore.create(project); + try { + updatePackageVersion(javaProject, packageName, suggestedVersion); + } catch (Exception e) { + logger.logError("Error applying structural change version quickfix.", e); + } + } + @Override + public String getLabel() { + return "Update package " + packageName + " version to " + suggestedVersion; + } + }); + } } return result; } + static String replaceVersionInExportPackage(String content, int searchFrom, String suggestedVersion) { + int searchEnd = Math.min(searchFrom + 300, content.length()); + Pattern versionPattern = Pattern.compile(";\\s*version\\s*=\\s*\"([^\"]*)\""); + Matcher m = versionPattern.matcher(content); + m.region(searchFrom, searchEnd); + if (m.find()) { + return content.substring(0, m.start(1)) + suggestedVersion + content.substring(m.end(1)); + } + return null; + } + + private void updatePackageVersion(IJavaProject javaProject, String packageName, String suggestedVersion) + throws Exception { + for (IClasspathEntry entry : javaProject.getRawClasspath()) { + if (IClasspathEntry.CPE_SOURCE != entry.getEntryKind()) + continue; + + IPath pkgPath = entry.getPath() + .append(packageName.replace('.', '/')); + + IPackageFragment pkg = javaProject.findPackageFragment(pkgPath); + if (pkg != null) { + ICompilationUnit pkgInfoJava = pkg.getCompilationUnit(PACKAGEINFOJAVA); + if (pkgInfoJava != null && pkgInfoJava.exists()) { + ISourceRange range = findPackageInfoJavaVersionLocation(packageName, pkgInfoJava); + if (range != null) { + String source = pkgInfoJava.getSource(); + String quoted = "\"" + suggestedVersion + "\""; + String newSource = source.substring(0, range.getOffset()) + quoted + + source.substring(range.getOffset() + range.getLength()); + IFile file = (IFile) pkgInfoJava.getResource(); + file.getWorkspace() + .run(monitor -> file.setContents( + new ByteArrayInputStream(newSource.getBytes(StandardCharsets.UTF_8)), + false, true, monitor), null); + return; + } + } + } + + IFile pkgInfoFile = javaProject.getProject() + .getWorkspace() + .getRoot() + .getFile(pkgPath.append(PACKAGEINFO)); + if (pkgInfoFile != null && pkgInfoFile.exists()) { + String input = "version " + suggestedVersion; + pkgInfoFile.getWorkspace() + .run(monitor -> pkgInfoFile.setContents(new ByteArrayInputStream(input.getBytes()), + false, true, monitor), null); + return; + } + } + } + @Override public List getProposals(IMarker marker) { List proposals = new ArrayList<>(); String suggestedVersion = marker.getAttribute(PROP_SUGGESTED_VERSION, null); - int start = marker.getAttribute(IMarker.CHAR_START, 0); - int end = marker.getAttribute(IMarker.CHAR_END, 0); - CompletionProposal proposal = new CompletionProposal("version " + suggestedVersion, start, end - start, end, - null, "Change package version to " + suggestedVersion, null, null); - proposals.add(proposal); + if (suggestedVersion == null) + return proposals; + + IResource resource = marker.getResource(); + if (resource instanceof IFile && Project.BNDFILE.equals(resource.getName())) { + try { + String content = IO.collect(((IFile) resource).getLocation() + .toFile()); + int pkgEnd = marker.getAttribute(IMarker.CHAR_END, 0); + int searchEnd = Math.min(pkgEnd + 300, content.length()); + Pattern versionPattern = Pattern.compile(";\\s*version\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^,;\\s\\\\]+))"); + Matcher m = versionPattern.matcher(content); + m.region(pkgEnd, searchEnd); + if (m.find()) { + int comma = content.indexOf(',', pkgEnd); + if (comma != -1 && comma < m.start()) + return proposals; + int group = m.start(1) != -1 ? 1 : (m.start(2) != -1 ? 2 : 3); + int valueStart = m.start(group); + int valueEnd = m.end(group); + proposals.add(new CompletionProposal(suggestedVersion, valueStart, valueEnd - valueStart, valueEnd, + null, "Change package version to " + suggestedVersion, null, null)); + } + } catch (Exception e) { + logger.logError("Error computing bnd.bnd version proposal.", e); + } + } else { + int start = marker.getAttribute(IMarker.CHAR_START, 0); + int end = marker.getAttribute(IMarker.CHAR_END, 0); + proposals.add(new CompletionProposal("version " + suggestedVersion, start, end - start, end, + null, "Change package version to " + suggestedVersion, null, null)); + } return proposals; } diff --git a/bndtools.builder/test/org/bndtools/builder/handlers/baseline/BaselineErrorHandlerTest.java b/bndtools.builder/test/org/bndtools/builder/handlers/baseline/BaselineErrorHandlerTest.java new file mode 100644 index 0000000000..7051c889ee --- /dev/null +++ b/bndtools.builder/test/org/bndtools/builder/handlers/baseline/BaselineErrorHandlerTest.java @@ -0,0 +1,80 @@ +package org.bndtools.builder.handlers.baseline; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +public class BaselineErrorHandlerTest { + + @Test + void replaceVersionInExportPackage_simpleEntry() { + String content = "Export-Package: com.example;version=\"1.0.0\""; + int searchFrom = content.indexOf("com.example") + "com.example".length(); + assertThat(BaselineErrorHandler.replaceVersionInExportPackage(content, searchFrom, "2.0.0")) + .isEqualTo("Export-Package: com.example;version=\"2.0.0\""); + } + + @Test + void replaceVersionInExportPackage_spacesAroundEquals() { + String content = "Export-Package: com.example ; version = \"1.0.0\""; + int searchFrom = content.indexOf("com.example") + "com.example".length(); + assertThat(BaselineErrorHandler.replaceVersionInExportPackage(content, searchFrom, "2.0.0")) + .isEqualTo("Export-Package: com.example ; version = \"2.0.0\""); + } + + @Test + void replaceVersionInExportPackage_multiplePackages_firstMatched() { + String content = "Export-Package: com.a;version=\"1.0.0\",com.b;version=\"2.0.0\""; + int searchFrom = content.indexOf("com.a") + "com.a".length(); + assertThat(BaselineErrorHandler.replaceVersionInExportPackage(content, searchFrom, "1.5.0")) + .isEqualTo("Export-Package: com.a;version=\"1.5.0\",com.b;version=\"2.0.0\""); + } + + @Test + void replaceVersionInExportPackage_multiplePackages_secondMatched() { + String content = "Export-Package: com.a;version=\"1.0.0\",com.b;version=\"2.0.0\""; + int searchFrom = content.indexOf("com.b") + "com.b".length(); + assertThat(BaselineErrorHandler.replaceVersionInExportPackage(content, searchFrom, "3.0.0")) + .isEqualTo("Export-Package: com.a;version=\"1.0.0\",com.b;version=\"3.0.0\""); + } + + @Test + void replaceVersionInExportPackage_withAdditionalAttributes() { + String content = "Export-Package: com.example;uses:=\"other\";version=\"1.0.0\""; + int searchFrom = content.indexOf("com.example") + "com.example".length(); + assertThat(BaselineErrorHandler.replaceVersionInExportPackage(content, searchFrom, "2.0.0")) + .isEqualTo("Export-Package: com.example;uses:=\"other\";version=\"2.0.0\""); + } + + @Test + void replaceVersionInExportPackage_noVersionAttribute_returnsNull() { + String content = "Export-Package: com.example"; + int searchFrom = content.indexOf("com.example") + "com.example".length(); + assertThat(BaselineErrorHandler.replaceVersionInExportPackage(content, searchFrom, "2.0.0")) + .isNull(); + } + + @Test + void replaceVersionInExportPackage_singleQuotes() { + String content = "Export-Package: com.example;version='1.0.0'"; + int searchFrom = content.indexOf("com.example") + "com.example".length(); + assertThat(BaselineErrorHandler.replaceVersionInExportPackage(content, searchFrom, "2.0.0")) + .isEqualTo("Export-Package: com.example;version='2.0.0'"); + } + + @Test + void replaceVersionInExportPackage_doesNotTouchNextClauseWhenFirstHasNoVersion() { + String content = "Export-Package: com.a,com.b;version=\"2.0.0\""; + int searchFrom = content.indexOf("com.a") + "com.a".length(); + assertThat(BaselineErrorHandler.replaceVersionInExportPackage(content, searchFrom, "1.5.0")) + .isNull(); + } + + @Test + void replaceVersionInExportPackage_multilineWithContinuation() { + String content = "Export-Package: \\" + "\n com.example;version=\"1.0.0\""; + int searchFrom = content.indexOf("com.example") + "com.example".length(); + assertThat(BaselineErrorHandler.replaceVersionInExportPackage(content, searchFrom, "2.0.0")) + .isEqualTo("Export-Package: \\" + "\n com.example;version=\"2.0.0\""); + } +} diff --git a/bndtools.core/src/bndtools/editor/project/RunRequirementsPart.java b/bndtools.core/src/bndtools/editor/project/RunRequirementsPart.java index 589c82d9c6..d2fa40ea30 100644 --- a/bndtools.core/src/bndtools/editor/project/RunRequirementsPart.java +++ b/bndtools.core/src/bndtools/editor/project/RunRequirementsPart.java @@ -180,7 +180,9 @@ private void doResolve() { @Override protected void doCommitToModel(List requires) { if (isDirty()) { - model.setRunRequires(requires); + String key = model.getLocalMergePropertyKey(Constants.RUNREQUIRES) + .orElse(Constants.RUNREQUIRES); + model.setRunRequiresAtKey(key, requires); } }