diff --git a/java/java.freeform/nbproject/project.properties b/java/java.freeform/nbproject/project.properties index e0dd024a473c..89ef7f22b33f 100644 --- a/java/java.freeform/nbproject/project.properties +++ b/java/java.freeform/nbproject/project.properties @@ -16,7 +16,7 @@ # under the License. javac.compilerargs=-Xlint -Xlint:-serial -javac.release=17 +javac.release=21 javadoc.arch=${basedir}/arch.xml javadoc.apichanges=${basedir}/apichanges.xml diff --git a/java/java.freeform/src/org/netbeans/modules/java/freeform/Classpaths.java b/java/java.freeform/src/org/netbeans/modules/java/freeform/Classpaths.java index d996af7deba9..c4fc132a4c7e 100644 --- a/java/java.freeform/src/org/netbeans/modules/java/freeform/Classpaths.java +++ b/java/java.freeform/src/org/netbeans/modules/java/freeform/Classpaths.java @@ -42,6 +42,7 @@ import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.logging.Logger; +import java.util.regex.Pattern; import org.netbeans.api.annotations.common.NonNull; import org.netbeans.api.java.classpath.ClassPath; import org.netbeans.api.java.classpath.GlobalPathRegistry; @@ -70,9 +71,13 @@ import org.netbeans.spi.project.support.ant.PropertyEvaluator; import org.netbeans.spi.project.support.ant.PropertyUtils; import org.openide.ErrorManager; +import org.openide.filesystems.FileChangeAdapter; +import org.openide.filesystems.FileEvent; import org.openide.filesystems.FileObject; +import org.openide.filesystems.FileRenameEvent; import org.openide.filesystems.FileUtil; import org.openide.util.Mutex; +import org.openide.util.RequestProcessor; import org.openide.util.Utilities; import org.openide.util.WeakListeners; import org.openide.xml.XMLUtil; @@ -104,6 +109,11 @@ final class Classpaths implements ClassPathProvider, AntProjectListener, Propert private static final ErrorManager err = ErrorManager.getDefault().getInstance(Classpaths.class.getName()); + /// Recomputes wildcard classpaths off the file-event thread; see `wildcardListener`. + private static final RequestProcessor WILDCARD_RP = new RequestProcessor(Classpaths.class.getName() + ".wildcard", 1); // NOI18N + /// Coalescing delay (ms) so that a build creating many JARs triggers a single refresh. + private static final int WILDCARD_REFRESH_DELAY = 500; + //for tests only: static CountDownLatch TESTING_LATCH = null; @@ -403,22 +413,33 @@ private List createSourcePath(List packageRootNames) { return roots; } - private List createCompileClasspath(Element compilationUnitEl) { + private List createCompileClasspath(Element compilationUnitEl, Set watchedDirs) { for (Element e : XMLUtil.findSubElements(compilationUnitEl)) { if (e.getLocalName().equals("classpath") && e.getAttribute("mode").equals("compile")) { // NOI18N - return createClasspath(e, new RemoveSources(helper, sfbqImpl)); + return createClasspath(e, new RemoveSources(helper, sfbqImpl), watchedDirs); } } // None specified; assume it is empty. return Collections.emptyList(); } - + /** * Create a classpath from a <classpath> element. + *

+ * A path entry whose last path component contains a wildcard (* + * or ?) is expanded to the archives in the preceding directory + * whose names match the glob; e.g. build/lib/* or + * build/lib/*.jar pick up every JAR in build/lib. + * This mirrors the wildcard classpath syntax understood by the {@code java} + * launcher and lets freeform projects reference a directory of libraries + * without listing each JAR. The directories backing any wildcards are added + * to {@code watchedDirs} so the caller can recompute when their contents + * change (e.g. after a build produces new JARs). */ private List createClasspath( final Element classpathEl, - final Function> translate) { + final Function> translate, + final Set watchedDirs) { String cp = XMLUtil.findText(classpathEl); if (cp == null) { cp = ""; @@ -430,26 +451,92 @@ private List createClasspath( final String[] path = PropertyUtils.tokenizePath(cpEval); final List res = new ArrayList<>(); for (String pathElement : path) { - res.addAll(translate.apply(createClasspathEntry(pathElement))); + for (URL entry : createClasspathEntries(pathElement, watchedDirs)) { + res.addAll(translate.apply(entry)); + } } return res; } - + + /** + * Turn a single (already property-evaluated) classpath token into zero or + * more classpath root URLs. Ordinary tokens map to exactly one URL; a token + * whose last path component is a filename glob is expanded to the matching + * archives in the directory it names. + */ + private List createClasspathEntries(String text, Set watchedDirs) { + final int slash = Math.max(text.lastIndexOf('/'), text.lastIndexOf(File.separatorChar)); + final String lastComponent = slash >= 0 ? text.substring(slash + 1) : text; + if (lastComponent.indexOf('*') < 0 && lastComponent.indexOf('?') < 0) { + return Collections.singletonList(createClasspathEntry(text)); + } + final String prefix = slash >= 0 ? text.substring(0, slash) : ""; // NOI18N + final File dir = helper.resolveFile(prefix.isEmpty() ? "." : prefix); // NOI18N + if (watchedDirs != null) { + // Watch the directory even if it does not exist yet: a build may + // create it (and the matching JARs) after the project is opened. + watchedDirs.add(dir); + } + final File[] kids = dir.listFiles(); + if (kids == null) { + return Collections.emptyList(); + } + // Sort for a stable classpath order independent of directory listing order. + Arrays.sort(kids); + final Pattern pattern = wildcardToRegex(lastComponent); + final List res = new ArrayList<>(); + for (File kid : kids) { + if (!kid.isFile() || !pattern.matcher(kid.getName()).matches()) { + continue; + } + // urlForArchiveOrDir returns null for an existing file that is not a + // valid archive, so this keeps only archives (matches the java + // launcher's JARs-only rule for a dir/* entry). + final URL entry = FileUtil.urlForArchiveOrDir(kid); + if (entry != null) { + res.add(entry); + } + } + return res; + } + + /** + * Translate a filename glob (* matches any run of characters, + * ? matches a single character) into a case-insensitive regex. + */ + private static Pattern wildcardToRegex(String glob) { + final StringBuilder sb = new StringBuilder(glob.length() + 8); + for (int i = 0; i < glob.length(); i++) { + final char c = glob.charAt(i); + switch (c) { + case '*' -> sb.append(".*"); // NOI18N + case '?' -> sb.append('.'); + default -> { + if ("\\.[]{}()+-^$|".indexOf(c) >= 0) { // NOI18N + sb.append('\\'); + } + sb.append(c); + } + } + } + return Pattern.compile(sb.toString(), Pattern.CASE_INSENSITIVE); + } + private URL createClasspathEntry(String text) { File entryFile = helper.resolveFile(text); return FileUtil.urlForArchiveOrDir(entryFile); } - - private List createExecuteClasspath(List packageRoots, Element compilationUnitEl) { + + private List createExecuteClasspath(List packageRoots, Element compilationUnitEl, Set watchedDirs) { for (Element e : XMLUtil.findSubElements(compilationUnitEl)) { if (e.getLocalName().equals("classpath") && e.getAttribute("mode").equals("execute")) { // NOI18N - return createClasspath(e, new RemoveSources(helper, sfbqImpl)); + return createClasspath(e, new RemoveSources(helper, sfbqImpl), watchedDirs); } } // None specified; assume it is same as compile classpath plus (cf. #49113) dirs/JARs // if there are any (else include the source dir(s) as a fallback for the I18N wizard to work). Set urls = new LinkedHashSet<>(); - urls.addAll(createCompileClasspath(compilationUnitEl)); + urls.addAll(createCompileClasspath(compilationUnitEl, watchedDirs)); final Project prj = FileOwnerQuery.getOwner(helper.getProjectDirectory()); if (prj != null) { for (URL src : createSourcePath(packageRoots)) { @@ -459,19 +546,19 @@ private List createExecuteClasspath(List packageRoots, Element comp return new ArrayList<>(urls); } - private List createProcessorClasspath(Element compilationUnitEl) { + private List createProcessorClasspath(Element compilationUnitEl, Set watchedDirs) { final Element ap = XMLUtil.findElement(compilationUnitEl, AnnotationProcessingQueryImpl.EL_ANNOTATION_PROCESSING, JavaProjectNature.NS_JAVA_LASTEST); if (ap != null) { final Element path = XMLUtil.findElement(ap, AnnotationProcessingQueryImpl.EL_PROCESSOR_PATH, JavaProjectNature.NS_JAVA_LASTEST); if (path != null) { - return createClasspath(path, new RemoveSources(helper, sfbqImpl)); + return createClasspath(path, new RemoveSources(helper, sfbqImpl), watchedDirs); } } // None specified; assume it is the same as the compile classpath. - return createCompileClasspath(compilationUnitEl); + return createCompileClasspath(compilationUnitEl, watchedDirs); } - private List createBootClasspath(Element compilationUnitEl) { + private List createBootClasspath(Element compilationUnitEl, Set watchedDirs) { for (Element e : XMLUtil.findSubElements(compilationUnitEl)) { if (e.getLocalName().equals("classpath") && e.getAttribute("mode").equals("boot")) { // NOI18N return createClasspath(e, new Function>() { @@ -479,7 +566,7 @@ private List createBootClasspath(Element compilationUnitEl) { public Collection apply(URL p) { return Collections.singleton(p); } - }); + }, watchedDirs); } } // None specified; @@ -540,7 +627,25 @@ private final class MutableClassPathImplementation implements ClassPathImplement private final PropertyChangeSupport pcs = new PropertyChangeSupport(this); private List roots; // should always be non-null private List resources; - + /** Directories backing wildcard classpath entries we currently listen to. */ + private final Set watchedWildcardDirs = new HashSet(); + /** + * Coalesces the refresh triggered by wildcard-directory changes and, just + * as importantly, moves it off the file-event thread: {@link #syncWildcardListeners} + * registers listeners while holding this object's monitor, so refreshing + * synchronously from an event could invert lock order with that registration. + */ + private final RequestProcessor.Task wildcardRefreshTask = WILDCARD_RP.create(new Runnable() { + public @Override void run() { pathsChanged(); } + }); + /** Refreshes this path when the contents of a watched wildcard directory change. */ + private final FileChangeAdapter wildcardListener = new FileChangeAdapter() { + public @Override void fileDataCreated(FileEvent fe) { wildcardRefreshTask.schedule(WILDCARD_REFRESH_DELAY); } + public @Override void fileFolderCreated(FileEvent fe) { wildcardRefreshTask.schedule(WILDCARD_REFRESH_DELAY); } + public @Override void fileDeleted(FileEvent fe) { wildcardRefreshTask.schedule(WILDCARD_REFRESH_DELAY); } + public @Override void fileRenamed(FileRenameEvent fe) { wildcardRefreshTask.schedule(WILDCARD_REFRESH_DELAY); } + }; + public MutableClassPathImplementation(List packageRootNames, String type, Element initialCompilationUnit) { this.packageRootNames = packageRootNames; this.type = type; @@ -569,24 +674,28 @@ private Element findCompilationUnit() { */ private boolean initRoots(Element compilationUnitEl) { List oldRoots = roots; + // Directories backing any wildcard entries encountered while (re)computing + // the roots; SOURCE paths never use wildcards so the set stays empty there. + Set watchedDirs = new HashSet(); if (compilationUnitEl != null) { if (type.equals(ClassPath.SOURCE)) { roots = createSourcePath(packageRootNames); } else if (type.equals(ClassPath.COMPILE)) { - roots = createCompileClasspath(compilationUnitEl); + roots = createCompileClasspath(compilationUnitEl, watchedDirs); } else if (type.equals(ClassPath.EXECUTE)) { - roots = createExecuteClasspath(packageRootNames, compilationUnitEl); + roots = createExecuteClasspath(packageRootNames, compilationUnitEl, watchedDirs); } else if (type.equals(JavaClassPathConstants.PROCESSOR_PATH)) { - roots = createProcessorClasspath(compilationUnitEl); + roots = createProcessorClasspath(compilationUnitEl, watchedDirs); } else { assert type.equals(ClassPath.BOOT) : type; - roots = createBootClasspath(compilationUnitEl); + roots = createBootClasspath(compilationUnitEl, watchedDirs); } } else { // Dead. roots = Collections.emptyList(); } assert roots != null; + syncWildcardListeners(watchedDirs); if (!roots.equals(oldRoots)) { resources = new ArrayList(roots.size()); for (URL root : roots) { @@ -607,6 +716,26 @@ private boolean initRoots(Element compilationUnitEl) { } } + /** + * Register file listeners on exactly the set of directories backing the + * current wildcard entries, so newly built (or removed) JARs refresh the + * path. Listeners for directories no longer referenced are dropped. + */ + private void syncWildcardListeners(Set newDirs) { + for (Iterator it = watchedWildcardDirs.iterator(); it.hasNext(); ) { + File dir = it.next(); + if (!newDirs.contains(dir)) { + FileUtil.removeFileChangeListener(wildcardListener, dir); + it.remove(); + } + } + for (File dir : newDirs) { + if (watchedWildcardDirs.add(dir)) { + FileUtil.addFileChangeListener(wildcardListener, dir); + } + } + } + public List getResources() { assert resources != null; return resources; diff --git a/java/java.freeform/src/org/netbeans/modules/java/freeform/resources/freeform-project-java-5.xsd b/java/java.freeform/src/org/netbeans/modules/java/freeform/resources/freeform-project-java-5.xsd index 65c24a5da07d..a73fb5aa8c88 100644 --- a/java/java.freeform/src/org/netbeans/modules/java/freeform/resources/freeform-project-java-5.xsd +++ b/java/java.freeform/src/org/netbeans/modules/java/freeform/resources/freeform-project-java-5.xsd @@ -44,6 +44,17 @@ Cf. http://projects.netbeans.org/buildsys/design.html#freeform + + + A path (elements separated by ':' or ';') of directories and archives. + A path element whose last component is a filename glob (containing + '*' or '?') is expanded to the archives in the preceding directory + whose names match, e.g. "build/lib/*" or "build/lib/*.jar" reference + every JAR in "build/lib". This mirrors the wildcard classpath syntax + of the java launcher and avoids having to list each JAR; the matching + directory is watched so newly built JARs are picked up automatically. + + diff --git a/java/java.freeform/test/unit/src/org/netbeans/modules/java/freeform/ClasspathsTest.java b/java/java.freeform/test/unit/src/org/netbeans/modules/java/freeform/ClasspathsTest.java index 2317d448fac0..da81dbcaf80d 100644 --- a/java/java.freeform/test/unit/src/org/netbeans/modules/java/freeform/ClasspathsTest.java +++ b/java/java.freeform/test/unit/src/org/netbeans/modules/java/freeform/ClasspathsTest.java @@ -22,9 +22,12 @@ import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.io.File; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.lang.reflect.Method; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -548,4 +551,120 @@ public void propertyChange(PropertyChangeEvent e) { assertTrue(cpe2.includes("whatever/")); } + /** A "dir/*.jar" entry expands to every matching JAR in the directory (#116185). */ + public void testWildcardClasspath() throws Exception { + clearWorkDir(); + File d = getWorkDir(); + AntProjectHelper helper = FreeformProjectGenerator.createProject(d, d, "prj", null); + Project p = ProjectManager.getDefault().findProject(helper.getProjectDirectory()); + FileObject src = FileUtil.createFolder(new File(d, "src")); + File libDir = new File(d, "lib"); + assertTrue("created lib dir", libDir.mkdirs()); + File jarA = createJar(new File(libDir, "a.jar")); + File jarB = createJar(new File(libDir, "b.jar")); + createJar(new File(libDir, "readme.zip")); // does not match *.jar + configureCompileClasspath(helper, p, "src", "lib/*.jar"); + + ClassPath cp = ClassPath.getClassPath(src, ClassPath.COMPILE); + assertNotNull("have COMPILE classpath for src/", cp); + SortedSet expected = new TreeSet(); + expected.add(jarRoot(jarA)); + expected.add(jarRoot(jarB)); + assertEquals("wildcard expanded to the matching jars", expected, urlsOfCp(cp)); + } + + /** A bare "dir/*" entry expands to the archives only, skipping other files. */ + public void testWildcardClasspathIncludesOnlyArchives() throws Exception { + clearWorkDir(); + File d = getWorkDir(); + AntProjectHelper helper = FreeformProjectGenerator.createProject(d, d, "prj", null); + Project p = ProjectManager.getDefault().findProject(helper.getProjectDirectory()); + FileObject src = FileUtil.createFolder(new File(d, "src")); + File libDir = new File(d, "lib"); + assertTrue("created lib dir", libDir.mkdirs()); + File jarA = createJar(new File(libDir, "a.jar")); + writeText(new File(libDir, "notes.txt"), "not an archive"); // must be skipped + configureCompileClasspath(helper, p, "src", "lib/*"); + + ClassPath cp = ClassPath.getClassPath(src, ClassPath.COMPILE); + assertNotNull("have COMPILE classpath for src/", cp); + assertEquals("only the archive was included", + Collections.singleton(jarRoot(jarA)), urlsOfCp(cp)); + } + + /** JARs produced after the project is opened are picked up without editing project.xml. */ + public void testWildcardClasspathReactsToNewJars() throws Exception { + clearWorkDir(); + File d = getWorkDir(); + AntProjectHelper helper = FreeformProjectGenerator.createProject(d, d, "prj", null); + Project p = ProjectManager.getDefault().findProject(helper.getProjectDirectory()); + FileObject src = FileUtil.createFolder(new File(d, "src")); + File libDir = new File(d, "lib"); + assertTrue("created lib dir", libDir.mkdirs()); + File jarA = createJar(new File(libDir, "a.jar")); + configureCompileClasspath(helper, p, "src", "lib/*.jar"); + + ClassPath cp = ClassPath.getClassPath(src, ClassPath.COMPILE); + assertNotNull("have COMPILE classpath for src/", cp); + assertEquals("initially just the one jar", + Collections.singleton(jarRoot(jarA)), urlsOfCp(cp)); + + // Wait for the classpath to fire rather than sleep-polling, so the test is + // deterministic: the directory listener coalesces for WILDCARD_REFRESH_DELAY + // before recomputing, after which the ClassPath fires an entries/roots change. + final CountDownLatch refreshed = new CountDownLatch(1); + cp.addPropertyChangeListener(new PropertyChangeListener() { + public @Override void propertyChange(PropertyChangeEvent e) { + if (ClassPath.PROP_ENTRIES.equals(e.getPropertyName()) + || ClassPath.PROP_ROOTS.equals(e.getPropertyName())) { + refreshed.countDown(); + } + } + }); + + // A build drops a new jar into the watched directory. + File jarB = createJar(new File(libDir, "b.jar")); + FileObject libFO = FileUtil.toFileObject(FileUtil.normalizeFile(libDir)); + assertNotNull(libFO); + libFO.refresh(); // fire the filesystem event the directory listener waits for + + assertTrue("wildcard classpath refreshed after a new jar appeared", + refreshed.await(10, TimeUnit.SECONDS)); + assertTrue("newly built jar picked up by the wildcard classpath", + urlsOfCp(cp).contains(jarRoot(jarB))); + } + + private void configureCompileClasspath(AntProjectHelper helper, Project p, String packageRoot, String classpath) throws Exception { + String ns = JavaProjectNature.NS_JAVA_LASTEST; + Element data = Util.getPrimaryConfigurationData(helper); + Document doc = data.getOwnerDocument(); + Element jd = doc.createElementNS(ns, JavaProjectNature.EL_JAVA); + Element cu = (Element) jd.appendChild(doc.createElementNS(ns, "compilation-unit")); + cu.appendChild(doc.createElementNS(ns, "package-root")).appendChild(doc.createTextNode(packageRoot)); + Element cpEl = (Element) cu.appendChild(doc.createElementNS(ns, "classpath")); + cpEl.setAttribute("mode", "compile"); + cpEl.appendChild(doc.createTextNode(classpath)); + p.getLookup().lookup(AuxiliaryConfiguration.class).putConfigurationFragment(jd, true); + ProjectManager.getDefault().saveProject(p); + } + + private static String jarRoot(File jar) throws Exception { + return FileUtil.getArchiveRoot(Utilities.toURI(jar).toURL()).toExternalForm(); + } + + private static File createJar(File f) throws IOException { + try (ZipOutputStream zos = new ZipOutputStream(new java.io.FileOutputStream(f))) { + zos.putNextEntry(new ZipEntry("dummy")); // NOI18N + zos.write(new byte[] {1, 2, 3, 4}); + zos.closeEntry(); + } + return f; + } + + private static void writeText(File f, String text) throws IOException { + try (java.io.Writer w = new java.io.FileWriter(f)) { + w.write(text); + } + } + }