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