Java Glob Pattern Syntax — What It Matches (and Doesn't)
Part 3 of 4 in Mastering Modern Java Jvm
The hidden-file rule
When you grep *.txt in bash, every .txt file in the current directory shows up. When you write the same pattern against a Java filesystem and get zero matches on your .gitignore, that’s not a bug — it’s by design.
Java’s PathMatcher (via FileSystem.getPathMatcher("glob:*")) uses Unix glob syntax with one critical difference: wildcards like * and ** do not match names starting with a dot. A dotfile must be explicitly named in the pattern.
That matters because .gitignore, .env, and hidden directories like .hidden_dir/ are everywhere in Java projects, and they silently disappear from glob matches unless you put a . in your pattern.
The code
The demo below walks a test tree, runs a handful of glob patterns against relative paths, and then reruns one pattern on an absolute path to show how the same text produces different results depending on whether the matched Path contains / separators at all.
import java.nio.file.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class GlobDemo {
static List<Path> allFiles(Path dir) throws IOException {
List<Path> result = new ArrayList<>();
Files.walk(dir).filter(Files::isRegularFile).forEach(result::add);
return result;
}
static void demoGlob(String pattern, Path baseDir)
throws IOException, InterruptedException {
System.out.println("--- Pattern: " + pattern);
PathMatcher matcher = FileSystems.getDefault()
.getPathMatcher("glob:" + pattern);
List<Path> all = allFiles(baseDir);
long totalMatches = 0;
for (Path p : all) {
Path rel = baseDir.relativize(p); // key line
if (matcher.matches(rel)) {
System.out.println(" MATCH: " + rel);
totalMatches++;
}
}
if (totalMatches == 0) {
System.out.println(" (no matches)");
} else {
System.out.println(" Total: " + totalMatches);
}
}
// Same logic, but never calls .relativize() — absolute paths only.
static void demoGlobAbsolute(String pattern, Path baseDir)
throws IOException, InterruptedException {
System.out.println("--- Pattern: " + pattern + " (on absolute path) ---");
PathMatcher matcher = FileSystems.getDefault()
.getPathMatcher("glob:" + pattern);
List<Path> all = allFiles(baseDir);
long totalMatches = 0;
for (Path p : all) {
if (matcher.matches(p)) { // absolute!
System.out.println(" MATCH: " + p);
totalMatches++;
}
}
if (totalMatches == 0) {
System.out.println(" (no matches)");
} else {
System.out.println(" Total: " + totalMatches);
}
}
public static void main(String[] args)
throws IOException, InterruptedException {
Path baseDir = Paths.get("/tmp/glob_test");
Files.createDirectories(baseDir.resolve(".hidden_dir"));
Files.createDirectories(baseDir.resolve("subdir/nested/deep"));
// Show directory structure for reference
System.out.println("=== Directory structure ===");
List<Path> all = allFiles(baseDir);
for (Path p : all) {
System.out.println(" " + baseDir.relativize(p));
}
System.out.println();
// Rule 1: dot-files require explicit '.' in the pattern
System.out.println("=== Glob '*' matches normal files, skips dot-files ===");
demoGlob("*.txt", baseDir);
// Explicitly matching hidden names
System.out.println("\n=== Explicitly matching hidden files/dirs ===");
demoGlob(".*", baseDir);
demoGlob(".hidden_dir/*", baseDir);
// Rule 2: the relative-path trap — same pattern text, different results
System.out.println("\n=== The relative-path trap ===");
demoGlob("file*.txt", baseDir);
demoGlobAbsolute("file*.txt", baseDir);
// Rule 3: '**' crosses directory boundaries
System.out.println("\n=== '**/' recursive matching ===");
demoGlob("**/*.java", baseDir);
demoGlob("**/log.*", baseDir);
// Rule 4: alternation with '{}'
System.out.println("\n=== Alternation with '{}' ===");
demoGlob("*.{txt,yml}", baseDir);
demoGlob("subdir/*.{java,txt}", baseDir);
// Rule 5: '/' is a literal separator in patterns
System.out.println("\n=== '/' in patterns separates components ===");
demoGlob("subdir/nested/*", baseDir);
demoGlob("*/nested/*", baseDir);
}
}
The test tree under /tmp/glob_test contains 9 files: two at the top level ending in .txt, two at the top level ending in .java, one dotfile (.gitignore), one file inside a hidden directory (.hidden_dir/test.txt), and three more nested under subdir/.
Running it
Here’s what the program prints when run against that tree:
Three observations stand out from this output:
1. *.txt matched exactly one file — file1.txt. The other .txt file (.hidden_dir/test.txt) is invisible because its path component starts with a dot. Java’s glob engine never crosses into or matches across a dot-component unless the pattern itself begins with a literal dot.
2. The relative-path trap. The pattern file*.txt matched file1.txt when matched against the relative path, but returned zero matches on the absolute path /tmp/glob_test/file1.txt. In Java’s glob syntax, * does not cross path separators (the forward slash). On the absolute path, the first component is /tmp, and * stops there — so file never appears at the start of any single segment.
3. subdir/*.{java,txt} matched two files. The brace expansion syntax {a,b} works inside Java globs as an alternation within a single path component: it matches *.java OR *.txt for whatever follows subdir/. Both code.java and data.txt were found in one pass.
Takeaway
Java’s glob engine follows Unix wildcards with two important deviations: dot-prefixed names are never matched unless the pattern starts with a literal dot, and the same wildcard text produces different results depending on whether you pass an absolute or relative Path. The fix for both problems is simple — put . in your pattern when needed, and always call baseDir.relativize(path) before matching.