Yes! you can write an executable script in Java

Combining shell scripting with Java

Java's aim recently is to be more beginner friendly, but this also makes it possible to write shorter programs such as scripts

6th Nov 2024

javascripting

Writing Shell Scripts in Java: Yes, Really! 🤔

Ever thought about writing a shell script in Java? No? Well, buckle up because I'm about to show you how modern Java features make this not only possible but actually pretty neat!

The Mission 🎯

I wanted to create a simple tool called nodesnitch that hunts down all those pesky node_modules directories eating up space on your machine. You know the ones - they're probably consuming half your hard drive right now! The script finds these directories and sorts them by size, making it easier for you to decide which ones to rm -rf into oblivion.

Prerequisites 🛠️

You'll need JDK 23 (might work on 21, but I haven't tested it). The cool part is we'll be using Java's shell shebang feature: #!/path/to/java --source version.

Let's Build It! 👨‍💻

  1. First, create your script file:
touch nodesnitch
  1. Here's where the magic happens. We'll use a shebang at the top of our file:
#!/usr/bin/env java --enable-preview --source 23
  1. Now for the actual code (and this is where it gets interesting):
#!/usr/bin/env java --enable-preview --source 23
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public long calculateSize(Path path) {
    if (path == null || !Files.exists(path)) {
        return 0L;
    }

    try {
        if (Files.isDirectory(path)) {
            try(Stream<Path> stream = Files.walk(path)) {

                return stream
                        .filter(Files::isRegularFile)
                        .mapToLong(p -> {
                            try {
                                return Files.size(p);
                            } catch (IOException e) {
                                return 0L;
                            }
                        })
                        .sum();
            }

        } else {
            return Files.size(path);
        }
    } catch (IOException e) {
        throw new RuntimeException("Error calculating size for: " + path, e);
    }
}

public String humanReadableSize(long bytes) {
    if (bytes <= 0) {
        return "0 B";
    }

    final String[] units = new String[] { "B", "KB", "MB", "GB", "TB", "PB", "EB" };
    int digitGroups = (int) (Math.log10(Math.max(1, bytes)) / Math.log10(1024));

    // Stay within array bounds
    digitGroups = Math.min(digitGroups, units.length - 1);

    DecimalFormat df = new DecimalFormat("#,##0.##");
    return df.format(bytes / Math.pow(1024, digitGroups)) + " " + units[digitGroups];
}

public void searchDirectories(Path startPath, Predicate<Path> criteria, Consumer<Path> action) {
    try {
        if (startPath == null || !Files.isDirectory(startPath) ||
                startPath.getFileName().toString().startsWith(".") || startPath.toString().contains("/Library")) {
            return;
        }

        if (criteria.test(startPath)) {
            action.accept(startPath);
            return;
        }

        try (Stream<Path> stream = Files.list(startPath)) {
            stream.filter(Files::isDirectory)
                    .forEach(subDir -> searchDirectories(subDir, criteria, action));
        } catch (IOException e) {
            // Just ignore it
        }

    } catch (Exception e) {
        // Silently continue
    }
}

public Path getUserHomeDirectory() {
    return Path.of(System.getProperty("user.home"));
}

public  boolean isNodeModulesDirectory(Path path) {
    if (path == null) {
        return false;
    }
    String dirName = path.getFileName().toString();
    return Files.isDirectory(path) && "node_modules".equals(dirName);
}


void main(String... args) {
    Map<Path, Long> results = new HashMap<>();;

    System.out.println("Scanning for node_modules directories...");

    searchDirectories(
            getUserHomeDirectory(),
            this::isNodeModulesDirectory,
            path -> {
                results.put(path, calculateSize(path));
            }
    );


    System.out.println("Found node_modules:");
    results.entrySet()
            .stream()
            .sorted((a, b) -> Math.toIntExact(b.getValue() - a.getValue()))
            .forEach(e -> System.out.printf("%s (%s)\n", e.getKey(), humanReadableSize(e.getValue())));

    long totalNodeModulesSize = results.values().stream().collect(Collectors.summarizingLong(Long::longValue)).getSum();
    System.out.printf("\n\nTotal space used: %s\n", humanReadableSize(totalNodeModulesSize));
}
  1. Make it executable:
chmod +x nodesnitch
  1. Run it:
./nodesnitch

Voilà! You've just written Java(Script)! 😉

Why This Works: Java's Evolution 🌱

Here's the really cool part - Java has been quietly becoming more beginner-friendly, and these changes accidentally made it great for scripting too! Two key features make this possible:

  1. JEP 330 (Launch Single-File Source-Code Programs)
  • Lets us run Java files directly without the usual compilation step
  • Perfect for quick scripts like this one
  1. JEP 445 (Unnamed Classes)
  • Makes our code less verbose
  • Lets us compose functions more naturally
  • No more public static void main boilerplate!

The Takeaway 💡

While Java isn't trying to become a scripting language, these beginner-friendly features have made it surprisingly capable for quick scripts. Our nodesnitch tool is a perfect example - it's concise, functional, and actually useful.

Want to check out the complete code? You can find it at my GitHub repo.

Happy hunting those node_modules! 🗑️✨