jbang – scripting for Java

5 minute read

Ever had a small task to script? Ever felt Java was not up to the task despite being your primary language? JBang is coming to the rescue.

jbang logo

Use case

When I migrated my blog, I had a need to change the Front Matter part of my blog entries. Blogs themselves are in pure Markdown with no tie to the website generator structure but there is some metadata at the top that tends to be specific to the platform. This structure is called Front Matter and is standardized.

---
title: Some blog entry
date: 2021-01-10
author: Emmanuel Bernard
layout: blog-layout.html.haml
header:
  teaser: /images/some-teasing-iamge.png
---
And now come my _markdown_ syntax.

## why

Well, I needed an example

See the header between each --- is the Front Matter syntax and as you see, it hosts metadata. In my case, I needed to remove the layout metadata piece as well as write down the date. The layout template name is different and Jekyll has a nicer template name abstraction logic they call scope for this class of problem.

Anyways I needed a small script that was reading files, removing one specific line and adding a specific line with specific content.

Java

Java is not the stack you tend to use for that. There are multiple reasons.

Some feel that the JDK is powerful but kinda verbose for these targeted needs. It has gotten way better with a compounded advent of lambda, default methods in interfaces and the addition of more helper methods.

The main issue to me for such task that my problem fits in one Java class. Why do I need to also have:

  • a pom.xml or build.gradle to express my dependencies
  • a three level deep src/main/java directory structure
  • some sort of long incantation to start my script or in practice wrap that incantation in a bash file of sort

People just give up and write the migration in bash or Ruby or something else.

JBang!

Enters JBang.

JBang is a way to make a one file Java code be executable and remain a one file project:

  • it lets you run java files from your terminal e.g ./hello.java
  • it lets you define your dependencies at the top of your single Java file
  • it lets you define your targeted Java from that same Java file
  • it even installs Java if it’s not there
  • it will start an IDE to let you edit your file

First, install jbang.

Second (optional), get a minimal script file created for you.

jbang init migrate.java

Third (optional), open your script in an IDE. JBang can create an environment that your IDE will understand (dependency file, src structure etc). Alternatively, you can just use a plain editor (like Vim) but most Java developers like their IDE.

# start VSCode to edit my script file
code `jbang edit migrate.java`

Forth, write your Java script (not JavaScript ahah). Here is the migrate.java script I wrote – the code is pretty bad but that’s almost the point, it’s a one off script!

///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 15
// //DEPS <dependency1> <dependency2>

import static java.lang.System.*;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileWriter;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.Charset;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.FileTime;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class migrate {
    private static Pattern DATE_PATTERN = Pattern.compile("([0-9]{4})-([0-9]{2})-([0-9]{2}).*");

    public static void main(String... args) throws IOException {
        File source = new File("~/Sites/emmanuelbernard.com/blog");
        String destinationName = "~/Sites/emmanuelbernard.com/_posts";
        for (File file: new File(destinationName).listFiles())
            if (!file.isDirectory())
                file.delete();

        for (final File fileEntry : source.listFiles( (FilenameFilter) (dir, name) -> name.endsWith(".md") || name.endsWith(".erb"))) {
            out.println(fileEntry.getName());
            FileSystem fileSystem = FileSystems.getDefault();

            Path fileToRead = fileSystem.getPath(fileEntry.getAbsolutePath());
            List<String> lines = readAllLines(fileToRead, Charset.defaultCharset());
            if (!isFrontmatter(fileToRead, lines)) {
                continue;
            }
            if (!isDatePresent(fileEntry, fileToRead, lines)) {
                Matcher m = DATE_PATTERN.matcher(fileEntry.getName());
                StringBuilder date = new StringBuilder("date: ");
                if (!m.find()) {
                    // in case a file has no date
                    FileTime fileTime = Files.getLastModifiedTime(fileToRead);
                    DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
                    date.append(dateFormat.format(fileTime.toMillis()));
                    lines.add(frontmatterIndex(fileToRead, lines), "draft: true");
                    err.println("Cannot extract date from file name: " + fileEntry.getName() + " | file timestamp: " + date.toString() + " |  Mark as draft");
                }
                else { 
                    date.append(m.group(1)).append("-").append(m.group(2)).append("-").append(m.group(3));
                }
                lines.add(frontmatterIndex(fileToRead, lines), date.toString());
            }
            removeLayout(fileEntry, fileToRead, lines);
            
            //Files.copy(
            //    fileToRead,
            //    fileSystem.getPath(destinationName, fileEntry.getName()),
            //    StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
            
            // convert erb files into html ones
            File destination = new File(fileSystem.getPath(destinationName, fileEntry.getName()).toString().replace(".html.erb", ".html"));
            PrintWriter fw = new PrintWriter(destination);
            for (String line : lines) {
                fw.println(line);
            }
            fw.close();
            destination.setLastModified(fileToRead.toFile().lastModified());

        }
    }

    private static boolean isDatePresent(final File fileEntry, Path fileToRead, List<String> lines) {
        boolean isDatePresent = false;
        for (int i = 1 ; i < frontmatterIndex(fileToRead, lines) ; i++) {
            if (lines.get(i).startsWith("date")) {
                err.println("date already present in frontmatter for " + fileEntry.getName());
                isDatePresent = true;
            }
        }
        return isDatePresent;
    }

    private static void removeLayout(final File fileEntry, Path fileToRead, List<String> lines) {
        int layoutIndex = -1;
        for (int i = 1 ; i < frontmatterIndex(fileToRead, lines) ; i++) {
            if (lines.get(i).startsWith("layout")) {
                layoutIndex = i;
                break;
            }
        }
        if (layoutIndex != -1) {
            lines.remove(layoutIndex);

        }
    }

    private static int frontmatterIndex(Path fileToRead, List<String> lines) {
        for (int i = 1 ; i < 30 ; i++) {
            if ("---".equals(lines.get(i))) return i;
        }
        return 0;
    }
    private static boolean isFrontmatter(Path fileToRead, List<String> lines) {
        if (!"---".equals(lines.get(0))) {
            err.println("Unable to recognize frontmatter for (first line) " + fileToRead.toString());
            return false;
        }
        if ( 0 == frontmatterIndex(fileToRead, lines)) {
            err.println("Unable to recognize frontmatter for (last line) " + fileToRead.toString());
            return false;
        }
        return true;
    }

    public static List<String> readAllLines(Path path, Charset cs) throws IOException {
        try (BufferedReader reader = Files.newBufferedReader(path, cs)) {
            List<String> result = new ArrayList<>();
            for (;;) {
                String line = reader.readLine();
                if (line == null)
                    break;
                result.add(line);
            }
            return result;
        }
    }
}

You see at the top of the files, a set of comments that are interpreted by jbang. The first line will direct your environment to use Java to run that script. The second line shows which Java version I want to target (optional). The third one, I don’t use but that’s where you would put your dependencies. Here is an example of dependencies declaration.

//usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 15
//DEPS log4j:log4j:1.2.17

import static java.lang.System.out;

import org.apache.log4j.Logger;
import org.apache.log4j.BasicConfigurator;

import java.util.Arrays;

class classpath_example {
   // ...
}

Finally, it is time to run the script.

jbang migrate.java
# or
chmod u+x migrate.java #do it once
./migrate.java

Voilà! You have a one line runnable Java script.

Conclusion

JBang is the perfect tool to write one file scripts in Java. It allows fluent Java developers to not have to move away from their comfy language of choice.

JBang can do a ton more than that, make sure to read the documentation:

  • script repos
  • multiple source files and other resource files
  • debugging mode, JFR
  • and many more

Install JBang and keep that tool in your toolbox.

Tags:

Updated:

Comments