From 493d4d9c99ad5a1a35ae0f4631eaf1089cee0e6d Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Mon, 28 Sep 2026 20:24:31 +0300 Subject: [PATCH] Leave JBang directives at the top of a file as written JBang reads a script's configuration from line comments with the name right after the slashes, such as //DEPS, //JAVA and //SOURCES, and a shell runs the first line, ///usr/bin/env jbang "$0" "$@" ; exit $?, when the file is executed. The formatter put a space after the slashes of every line comment and wrapped long ones, so JBang silently dropped the dependencies and options, and the file no longer ran from a shell (#24, reported upstream as google/google-java-format#1217). JBang's documentation places directives "in the first comment block of the file (before any code)". There, JavaCommentsHelper now leaves two kinds of line comment exactly as written, with no space and no wrapping: a directive, which is one of the names in JBang's Directives.Names or a name with an integration prefix such as Quarkus's //Q:CONFIG, followed by whitespace or the end of the line; and the first line of the file when its first word is a path, as in ///usr/bin/env, //usr/bin/env or the long self-bootstrapping header. The helper now takes the JavaInput, whose first token marks where the code starts, since comments are numbered along with tokens. After the package declaration, an import or a class, the same text is an ordinary comment again, and //DEPSSS, //deps or //TODO still get their space everywhere. JBang's parser is more lenient than its documentation and reads every line that starts with // at column 0, so a directive after the imports still stops working; that follows the documented rule on purpose. Three goldens cover whole scripts: the one from the issue, one with a package and a license header, and a compact source file. JBangDirectivesTest checks every directive name, the syntax JBang allows after one, and comments that only look like directives. The 15,747 files of the JDK 21 sources format exactly as before; none of them has a directive or a shell line. --- .../palantir/javaformat/java/Formatter.java | 2 +- .../javaformat/java/JavaCommentsHelper.java | 63 ++++++++- .../javaformat/java/FileBasedTests.java | 3 +- .../javaformat/java/JBangDirectivesTest.java | 121 ++++++++++++++++++ .../ojf-issue-24-jbang-compact-source.input | 9 ++ .../ojf-issue-24-jbang-compact-source.output | 12 ++ .../testdata/ojf-issue-24-jbang-package.input | 20 +++ .../ojf-issue-24-jbang-package.output | 22 ++++ .../testdata/ojf-issue-24-jbang-script.input | 20 +++ .../testdata/ojf-issue-24-jbang-script.output | 24 ++++ 10 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 open-java-format/src/test/java/com/palantir/javaformat/java/JBangDirectivesTest.java create mode 100644 open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-compact-source.input create mode 100644 open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-compact-source.output create mode 100644 open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-package.input create mode 100644 open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-package.output create mode 100644 open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-script.input create mode 100644 open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-script.output diff --git a/open-java-format/src/main/java/com/palantir/javaformat/java/Formatter.java b/open-java-format/src/main/java/com/palantir/javaformat/java/Formatter.java index 00952cd69..2f702a474 100644 --- a/open-java-format/src/main/java/com/palantir/javaformat/java/Formatter.java +++ b/open-java-format/src/main/java/com/palantir/javaformat/java/Formatter.java @@ -316,7 +316,7 @@ public ImmutableList getFormatReplacements(String input, Collection // 'de-linting' changes (e.g. import ordering). javaInput = ModifierOrderer.reorderModifiers(javaInput, characterRanges); - JavaCommentsHelper commentsHelper = new JavaCommentsHelper(javaInput.getLineSeparator(), options); + JavaCommentsHelper commentsHelper = new JavaCommentsHelper(javaInput, options); JavaOutput javaOutput; try { javaOutput = format(javaInput, options, commentsHelper, debugMode); diff --git a/open-java-format/src/main/java/com/palantir/javaformat/java/JavaCommentsHelper.java b/open-java-format/src/main/java/com/palantir/javaformat/java/JavaCommentsHelper.java index 7cac97a7a..5f4419a76 100644 --- a/open-java-format/src/main/java/com/palantir/javaformat/java/JavaCommentsHelper.java +++ b/open-java-format/src/main/java/com/palantir/javaformat/java/JavaCommentsHelper.java @@ -15,6 +15,7 @@ package com.palantir.javaformat.java; import com.google.common.base.CharMatcher; +import com.google.common.collect.ImmutableSet; import com.palantir.javaformat.CommentsHelper; import com.palantir.javaformat.Input.Tok; import com.palantir.javaformat.Newlines; @@ -32,12 +33,16 @@ public final class JavaCommentsHelper implements CommentsHelper { private final String lineSeparator; private final JavaFormatterOptions options; + /** Comments are numbered along with tokens, so the comments before the first line of code have smaller indices. */ + private final int firstTokenIndex; + @Nullable private final JavadocFormatter javadocFormatter; - public JavaCommentsHelper(String lineSeparator, JavaFormatterOptions options) { - this.lineSeparator = lineSeparator; + public JavaCommentsHelper(JavaInput javaInput, JavaFormatterOptions options) { + this.lineSeparator = javaInput.getLineSeparator(); this.options = options; + this.firstTokenIndex = javaInput.getTokens().get(0).getTok().getIndex(); this.javadocFormatter = options.formatJavadoc() ? new JavadocFormatter(options.maxLineLength()) : null; } @@ -56,6 +61,9 @@ public String rewrite(Tok tok, int maxWidth, int column0) { lines.add(CharMatcher.whitespace().trimTrailingFrom(it.next())); } if (tok.isSlashSlashComment()) { + if (isJBangHeaderLine(tok)) { + return lines.get(0); + } return indentLineComments(lines, column0); } else if (javadocShaped(lines)) { return indentJavadoc(lines, column0); @@ -108,6 +116,57 @@ private String indentLineComments(List lines, int column0) { return builder.toString(); } + // JBang reads the directives of a script, such as `//DEPS info.picocli:picocli:4.7.6`, from the line comments + // before its first line of code, and a shell runs the first line, `///usr/bin/env jbang "$0" "$@" ; exit $?`, when + // the file is executed. A space after the slashes or a wrapped line breaks both, so these lines stay as written. + // https://www.jbang.dev/documentation/jbang/latest/script-directives.html + private boolean isJBangHeaderLine(Tok tok) { + if (tok.getIndex() >= firstTokenIndex) { + return false; + } + String text = tok.getOriginalText(); + boolean firstLine = tok.getIndex() == 0; + return isJBangDirective(text) + || (firstLine && SHELL_COMMAND.matcher(text).lookingAt()); + } + + // The directive names JBang knows, from dev.jbang.source.parser.Directives.Names. + private static final ImmutableSet JBANG_DIRECTIVE_NAMES = ImmutableSet.of( + "CDS", + "COMPILE_OPTIONS", + "DEPS", + "DESCRIPTION", + "DOCS", + "FILES", + "GAV", + "GROOVY", + "JAVA", + "JAVAAGENT", + "JAVAC_OPTIONS", + "JAVA_OPTIONS", + "KOTLIN", + "MAIN", + "MANIFEST", + "MODULE", + "NATIVE_OPTIONS", + "NOINTEGRATIONS", + "PREVIEW", + "REPOS", + "RUNTIME_OPTIONS", + "SOURCES"); + + // JBang's syntax: the name right after the slashes, then whitespace or the end of the line. A name with a prefix, + // such as Quarkus's `//Q:CONFIG`, belongs to a build integration, so it is kept whatever follows the prefix. + private static final Pattern JBANG_DIRECTIVE = Pattern.compile("//([A-Z]+:)?([A-Z_]+)(?=\\s|$)"); + + // A line comment whose first word is a path: the command a shell runs. + private static final Pattern SHELL_COMMAND = Pattern.compile("//+[^\\s/]+/"); + + private static boolean isJBangDirective(String text) { + Matcher matcher = JBANG_DIRECTIVE.matcher(text); + return matcher.lookingAt() && (matcher.group(1) != null || JBANG_DIRECTIVE_NAMES.contains(matcher.group(2))); + } + // Preserve special `//noinspection` and `//$NON-NLS-x$` comments used by IDEs, which cannot // contain leading spaces. private static final Pattern LINE_COMMENT_MISSING_SPACE_PREFIX = diff --git a/open-java-format/src/test/java/com/palantir/javaformat/java/FileBasedTests.java b/open-java-format/src/test/java/com/palantir/javaformat/java/FileBasedTests.java index 026265920..4f876343b 100644 --- a/open-java-format/src/test/java/com/palantir/javaformat/java/FileBasedTests.java +++ b/open-java-format/src/test/java/com/palantir/javaformat/java/FileBasedTests.java @@ -63,7 +63,8 @@ public final class FileBasedTests { "UnnamedPattern", "CompactSource", "MarkdownDoc", - "FlexibleConstructor") + "FlexibleConstructor", + "ojf-issue-24-jbang-compact-source") .putAll(23, "ModuleImport") .build(); diff --git a/open-java-format/src/test/java/com/palantir/javaformat/java/JBangDirectivesTest.java b/open-java-format/src/test/java/com/palantir/javaformat/java/JBangDirectivesTest.java new file mode 100644 index 000000000..1c49d6323 --- /dev/null +++ b/open-java-format/src/test/java/com/palantir/javaformat/java/JBangDirectivesTest.java @@ -0,0 +1,121 @@ +/* + * (c) Copyright 2026 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.javaformat.java; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.palantir.javaformat.java.JavaFormatterOptions.Style; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * JBang reads its directives from the line comments before the first line of code, and a shell runs the first line of + * a script, so those lines are left exactly as written. The goldens {@code ojf-issue-24-jbang-*} show whole scripts, + * with the same directives after the first line of code formatted like any other comment. + */ +@Execution(ExecutionMode.CONCURRENT) +final class JBangDirectivesTest { + + private static final Formatter FORMATTER = Formatter.createFormatter( + JavaFormatterOptions.builder().style(Style.OJF).build()); + + @ParameterizedTest + @ValueSource( + strings = { + // Every name JBang knows. + "//CDS", + "//COMPILE_OPTIONS -Xlint:all", + "//DEPS info.picocli:picocli:4.7.6", + "//DESCRIPTION Prints a greeting", + "//DOCS guide=./readme.md", + "//FILES application.properties", + "//GAV org.example:hello:1.0", + "//GROOVY 3.0.19", + "//JAVA 21+", + "//JAVAAGENT myagent.jar=option1,option2", + "//JAVAC_OPTIONS -parameters", + "//JAVA_OPTIONS -Xmx512m", + "//KOTLIN 2.0.21", + "//MAIN org.example.Hello", + "//MANIFEST Built-By=jbang", + "//MODULE org.example.hello", + "//NATIVE_OPTIONS --no-fallback", + "//NOINTEGRATIONS", + "//PREVIEW", + "//REPOS central,jitpack", + "//RUNTIME_OPTIONS -XX:+UseSerialGC", + "//SOURCES Helper.java", + // A directive for a build integration: Quarkus reads //Q:CONFIG. + "//Q:CONFIG quarkus.banner.enabled=false", + // What JBang's syntax allows after the name. + "//DEPS\tinfo.picocli:picocli:4.7.6", + "//DEPS info.picocli:picocli:4.7.6 // parses the command line", + "//DEPS org.postgresql:postgresql:${env.DB_VERSION:42.6.0}", + "//DEPS", + // Longer than the 120 columns at which a line comment is wrapped. + "//DEPS com.fasterxml.jackson.core:jackson-databind:2.17.2 org.slf4j:slf4j-simple:2.0.13" + + " com.squareup.okhttp3:okhttp:4.12.0", + }) + void keepsDirectiveBeforeFirstLineOfCode(String directive) throws FormatterException { + String script = directive + "\n\nclass Hello {}\n"; + assertThat(FORMATTER.formatSource(script)).isEqualTo(script); + } + + @ParameterizedTest + @CsvSource( + delimiterString = " => ", + value = { + // Not a name JBang knows. + "//DEPSSS a:b:1 => // DEPSSS a:b:1", + "//DEPS_ a:b:1 => // DEPS_ a:b:1", + "//TODO pin the versions => // TODO pin the versions", + // No whitespace after the name. + "//JAVA21+ => // JAVA21+", + "//DEPS:a:b:1 => // DEPS:a:b:1", + // Directive names are upper case. + "//deps a:b:1 => // deps a:b:1", + "//Deps a:b:1 => // Deps a:b:1", + // Three slashes, as in a Markdown doc comment. + "///DEPS a:b:1 => /// DEPS a:b:1", + // Switched off on purpose: JBang's own templates list optional dependencies this way. + "// DEPS a:b:1 => // DEPS a:b:1", + "// //DEPS a:b:1 => // //DEPS a:b:1", + }) + void formatsLookalikeAsOrdinaryComment(String comment, String expected) throws FormatterException { + assertThat(FORMATTER.formatSource(comment + "\n\nclass Hello {}\n")) + .isEqualTo(expected + "\n\nclass Hello {}\n"); + } + + @Test + void formatsShellLineAfterFirstLineAsOrdinaryComment() throws FormatterException { + // The shell runs the first line of the file only. + String script = "/* License */\n///usr/bin/env jbang \"$0\" \"$@\" ; exit $?\nclass Hello {}\n"; + assertThat(FORMATTER.formatSource(script)) + .isEqualTo("/* License */\n/// usr/bin/env jbang \"$0\" \"$@\" ; exit $?\nclass Hello {}\n"); + } + + @Test + void formatsFirstLineThatRunsNoCommandAsOrdinaryComment() throws FormatterException { + // The first word is not a path, so the shell would have nothing to run. + assertThat(FORMATTER.formatSource("//Hello from JBang\nclass Hello {}\n")) + .isEqualTo("// Hello from JBang\nclass Hello {}\n"); + } +} diff --git a/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-compact-source.input b/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-compact-source.input new file mode 100644 index 000000000..9a229712d --- /dev/null +++ b/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-compact-source.input @@ -0,0 +1,9 @@ +//usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 25+ +//PREVIEW +//DEPS com.fasterxml.jackson.core:jackson-databind:2.17.2 + +void main() { IO.println(greeting()); +} +//DEPS org.example:after-main:1.0 +String greeting() { return "Hello"; } diff --git a/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-compact-source.output b/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-compact-source.output new file mode 100644 index 000000000..0e66a7385 --- /dev/null +++ b/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-compact-source.output @@ -0,0 +1,12 @@ +//usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 25+ +//PREVIEW +//DEPS com.fasterxml.jackson.core:jackson-databind:2.17.2 + +void main() { + IO.println(greeting()); +} +// DEPS org.example:after-main:1.0 +String greeting() { + return "Hello"; +} diff --git a/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-package.input b/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-package.input new file mode 100644 index 000000000..59337e999 --- /dev/null +++ b/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-package.input @@ -0,0 +1,20 @@ +///usr/bin/env bash -c 'command -v jbang >/dev/null 2>&1 || { echo "Bootstrapping JBang..." >&2; curl -Ls https://sh.jbang.dev | bash -s - app setup --quiet ; export PATH="$HOME/.jbang/bin:$PATH"; }; exec jbang "$0" "$@"' "$0" "$@"; exit $? +/* + * Licensed under the Apache License, Version 2.0. + */ +//DESCRIPTION Starts a Quarkus REST service that greets whoever calls it, with the startup banner switched off for scripts +//CDS +//DEPS io.quarkus:quarkus-bom:${quarkus.version:3.15.1}@pom +//DEPS io.quarkus:quarkus-rest // the JAX-RS endpoints +//Q:CONFIG quarkus.banner.enabled=false +package org.example; +//SOURCES model/Greeting.java + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +@Path("/hello") +public class GreetingResource { + @GET public String hello() { + return "Hello"; } +} diff --git a/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-package.output b/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-package.output new file mode 100644 index 000000000..ff8b80da4 --- /dev/null +++ b/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-package.output @@ -0,0 +1,22 @@ +///usr/bin/env bash -c 'command -v jbang >/dev/null 2>&1 || { echo "Bootstrapping JBang..." >&2; curl -Ls https://sh.jbang.dev | bash -s - app setup --quiet ; export PATH="$HOME/.jbang/bin:$PATH"; }; exec jbang "$0" "$@"' "$0" "$@"; exit $? +/* + * Licensed under the Apache License, Version 2.0. + */ +//DESCRIPTION Starts a Quarkus REST service that greets whoever calls it, with the startup banner switched off for scripts +//CDS +//DEPS io.quarkus:quarkus-bom:${quarkus.version:3.15.1}@pom +//DEPS io.quarkus:quarkus-rest // the JAX-RS endpoints +//Q:CONFIG quarkus.banner.enabled=false +package org.example; +// SOURCES model/Greeting.java + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +@Path("/hello") +public class GreetingResource { + @GET + public String hello() { + return "Hello"; + } +} diff --git a/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-script.input b/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-script.input new file mode 100644 index 000000000..42d2a3049 --- /dev/null +++ b/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-script.input @@ -0,0 +1,20 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 21+ +//DEPS info.picocli:picocli:4.7.6 +//DEPS com.fasterxml.jackson.core:jackson-databind:2.17.2 org.slf4j:slf4j-simple:2.0.13 com.squareup.okhttp3:okhttp:4.12.0 org.jsoup:jsoup:1.18.1 +//JAVA_OPTIONS -Xmx512m +//TODO pin these versions in a BOM + +import picocli.CommandLine; +//SOURCES Helper.java +import picocli.CommandLine.Command; +//RUNTIME_OPTIONS --enable-preview +@Command(name = "hello", mixinStandardHelpOptions = true) +class hello implements Runnable { +//DEPS org.example:in-class:1.0 + public void run() {System.out.println("Hello"); //DEPS org.example:trailing:1.0 + } + + public static void main(String... args) { System.exit(new CommandLine(new hello()).execute(args)); } +} +//DEPS com.fasterxml.jackson.core:jackson-databind:2.17.2 org.slf4j:slf4j-simple:2.0.13 com.squareup.okhttp3:okhttp:4.12.0 org.jsoup:jsoup:1.18.1 diff --git a/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-script.output b/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-script.output new file mode 100644 index 000000000..26950cb7d --- /dev/null +++ b/open-java-format/src/test/resources/com/palantir/javaformat/java/testdata/ojf-issue-24-jbang-script.output @@ -0,0 +1,24 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 21+ +//DEPS info.picocli:picocli:4.7.6 +//DEPS com.fasterxml.jackson.core:jackson-databind:2.17.2 org.slf4j:slf4j-simple:2.0.13 com.squareup.okhttp3:okhttp:4.12.0 org.jsoup:jsoup:1.18.1 +//JAVA_OPTIONS -Xmx512m +// TODO pin these versions in a BOM + +import picocli.CommandLine; +// SOURCES Helper.java +import picocli.CommandLine.Command; +// RUNTIME_OPTIONS --enable-preview +@Command(name = "hello", mixinStandardHelpOptions = true) +class hello implements Runnable { + // DEPS org.example:in-class:1.0 + public void run() { + System.out.println("Hello"); // DEPS org.example:trailing:1.0 + } + + public static void main(String... args) { + System.exit(new CommandLine(new hello()).execute(args)); + } +} +// DEPS com.fasterxml.jackson.core:jackson-databind:2.17.2 org.slf4j:slf4j-simple:2.0.13 +// com.squareup.okhttp3:okhttp:4.12.0 org.jsoup:jsoup:1.18.1