Skip to content

Commit 493d4d9

Browse files
committed
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.
1 parent bf5bdcd commit 493d4d9

10 files changed

Lines changed: 292 additions & 4 deletions

File tree

‎open-java-format/src/main/java/com/palantir/javaformat/java/Formatter.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ public ImmutableList<Replacement> getFormatReplacements(String input, Collection
316316
// 'de-linting' changes (e.g. import ordering).
317317
javaInput = ModifierOrderer.reorderModifiers(javaInput, characterRanges);
318318

319-
JavaCommentsHelper commentsHelper = new JavaCommentsHelper(javaInput.getLineSeparator(), options);
319+
JavaCommentsHelper commentsHelper = new JavaCommentsHelper(javaInput, options);
320320
JavaOutput javaOutput;
321321
try {
322322
javaOutput = format(javaInput, options, commentsHelper, debugMode);

‎open-java-format/src/main/java/com/palantir/javaformat/java/JavaCommentsHelper.java‎

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package com.palantir.javaformat.java;
1616

1717
import com.google.common.base.CharMatcher;
18+
import com.google.common.collect.ImmutableSet;
1819
import com.palantir.javaformat.CommentsHelper;
1920
import com.palantir.javaformat.Input.Tok;
2021
import com.palantir.javaformat.Newlines;
@@ -32,12 +33,16 @@ public final class JavaCommentsHelper implements CommentsHelper {
3233
private final String lineSeparator;
3334
private final JavaFormatterOptions options;
3435

36+
/** Comments are numbered along with tokens, so the comments before the first line of code have smaller indices. */
37+
private final int firstTokenIndex;
38+
3539
@Nullable
3640
private final JavadocFormatter javadocFormatter;
3741

38-
public JavaCommentsHelper(String lineSeparator, JavaFormatterOptions options) {
39-
this.lineSeparator = lineSeparator;
42+
public JavaCommentsHelper(JavaInput javaInput, JavaFormatterOptions options) {
43+
this.lineSeparator = javaInput.getLineSeparator();
4044
this.options = options;
45+
this.firstTokenIndex = javaInput.getTokens().get(0).getTok().getIndex();
4146
this.javadocFormatter = options.formatJavadoc() ? new JavadocFormatter(options.maxLineLength()) : null;
4247
}
4348

@@ -56,6 +61,9 @@ public String rewrite(Tok tok, int maxWidth, int column0) {
5661
lines.add(CharMatcher.whitespace().trimTrailingFrom(it.next()));
5762
}
5863
if (tok.isSlashSlashComment()) {
64+
if (isJBangHeaderLine(tok)) {
65+
return lines.get(0);
66+
}
5967
return indentLineComments(lines, column0);
6068
} else if (javadocShaped(lines)) {
6169
return indentJavadoc(lines, column0);
@@ -108,6 +116,57 @@ private String indentLineComments(List<String> lines, int column0) {
108116
return builder.toString();
109117
}
110118

119+
// JBang reads the directives of a script, such as `//DEPS info.picocli:picocli:4.7.6`, from the line comments
120+
// before its first line of code, and a shell runs the first line, `///usr/bin/env jbang "$0" "$@" ; exit $?`, when
121+
// the file is executed. A space after the slashes or a wrapped line breaks both, so these lines stay as written.
122+
// https://www.jbang.dev/documentation/jbang/latest/script-directives.html
123+
private boolean isJBangHeaderLine(Tok tok) {
124+
if (tok.getIndex() >= firstTokenIndex) {
125+
return false;
126+
}
127+
String text = tok.getOriginalText();
128+
boolean firstLine = tok.getIndex() == 0;
129+
return isJBangDirective(text)
130+
|| (firstLine && SHELL_COMMAND.matcher(text).lookingAt());
131+
}
132+
133+
// The directive names JBang knows, from dev.jbang.source.parser.Directives.Names.
134+
private static final ImmutableSet<String> JBANG_DIRECTIVE_NAMES = ImmutableSet.of(
135+
"CDS",
136+
"COMPILE_OPTIONS",
137+
"DEPS",
138+
"DESCRIPTION",
139+
"DOCS",
140+
"FILES",
141+
"GAV",
142+
"GROOVY",
143+
"JAVA",
144+
"JAVAAGENT",
145+
"JAVAC_OPTIONS",
146+
"JAVA_OPTIONS",
147+
"KOTLIN",
148+
"MAIN",
149+
"MANIFEST",
150+
"MODULE",
151+
"NATIVE_OPTIONS",
152+
"NOINTEGRATIONS",
153+
"PREVIEW",
154+
"REPOS",
155+
"RUNTIME_OPTIONS",
156+
"SOURCES");
157+
158+
// JBang's syntax: the name right after the slashes, then whitespace or the end of the line. A name with a prefix,
159+
// such as Quarkus's `//Q:CONFIG`, belongs to a build integration, so it is kept whatever follows the prefix.
160+
private static final Pattern JBANG_DIRECTIVE = Pattern.compile("//([A-Z]+:)?([A-Z_]+)(?=\\s|$)");
161+
162+
// A line comment whose first word is a path: the command a shell runs.
163+
private static final Pattern SHELL_COMMAND = Pattern.compile("//+[^\\s/]+/");
164+
165+
private static boolean isJBangDirective(String text) {
166+
Matcher matcher = JBANG_DIRECTIVE.matcher(text);
167+
return matcher.lookingAt() && (matcher.group(1) != null || JBANG_DIRECTIVE_NAMES.contains(matcher.group(2)));
168+
}
169+
111170
// Preserve special `//noinspection` and `//$NON-NLS-x$` comments used by IDEs, which cannot
112171
// contain leading spaces.
113172
private static final Pattern LINE_COMMENT_MISSING_SPACE_PREFIX =

‎open-java-format/src/test/java/com/palantir/javaformat/java/FileBasedTests.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ public final class FileBasedTests {
6363
"UnnamedPattern",
6464
"CompactSource",
6565
"MarkdownDoc",
66-
"FlexibleConstructor")
66+
"FlexibleConstructor",
67+
"ojf-issue-24-jbang-compact-source")
6768
.putAll(23, "ModuleImport")
6869
.build();
6970

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
* (c) Copyright 2026 Palantir Technologies Inc. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.palantir.javaformat.java;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
import com.palantir.javaformat.java.JavaFormatterOptions.Style;
22+
import org.junit.jupiter.api.Test;
23+
import org.junit.jupiter.api.parallel.Execution;
24+
import org.junit.jupiter.api.parallel.ExecutionMode;
25+
import org.junit.jupiter.params.ParameterizedTest;
26+
import org.junit.jupiter.params.provider.CsvSource;
27+
import org.junit.jupiter.params.provider.ValueSource;
28+
29+
/**
30+
* JBang reads its directives from the line comments before the first line of code, and a shell runs the first line of
31+
* a script, so those lines are left exactly as written. The goldens {@code ojf-issue-24-jbang-*} show whole scripts,
32+
* with the same directives after the first line of code formatted like any other comment.
33+
*/
34+
@Execution(ExecutionMode.CONCURRENT)
35+
final class JBangDirectivesTest {
36+
37+
private static final Formatter FORMATTER = Formatter.createFormatter(
38+
JavaFormatterOptions.builder().style(Style.OJF).build());
39+
40+
@ParameterizedTest
41+
@ValueSource(
42+
strings = {
43+
// Every name JBang knows.
44+
"//CDS",
45+
"//COMPILE_OPTIONS -Xlint:all",
46+
"//DEPS info.picocli:picocli:4.7.6",
47+
"//DESCRIPTION Prints a greeting",
48+
"//DOCS guide=./readme.md",
49+
"//FILES application.properties",
50+
"//GAV org.example:hello:1.0",
51+
"//GROOVY 3.0.19",
52+
"//JAVA 21+",
53+
"//JAVAAGENT myagent.jar=option1,option2",
54+
"//JAVAC_OPTIONS -parameters",
55+
"//JAVA_OPTIONS -Xmx512m",
56+
"//KOTLIN 2.0.21",
57+
"//MAIN org.example.Hello",
58+
"//MANIFEST Built-By=jbang",
59+
"//MODULE org.example.hello",
60+
"//NATIVE_OPTIONS --no-fallback",
61+
"//NOINTEGRATIONS",
62+
"//PREVIEW",
63+
"//REPOS central,jitpack",
64+
"//RUNTIME_OPTIONS -XX:+UseSerialGC",
65+
"//SOURCES Helper.java",
66+
// A directive for a build integration: Quarkus reads //Q:CONFIG.
67+
"//Q:CONFIG quarkus.banner.enabled=false",
68+
// What JBang's syntax allows after the name.
69+
"//DEPS\tinfo.picocli:picocli:4.7.6",
70+
"//DEPS info.picocli:picocli:4.7.6 // parses the command line",
71+
"//DEPS org.postgresql:postgresql:${env.DB_VERSION:42.6.0}",
72+
"//DEPS",
73+
// Longer than the 120 columns at which a line comment is wrapped.
74+
"//DEPS com.fasterxml.jackson.core:jackson-databind:2.17.2 org.slf4j:slf4j-simple:2.0.13"
75+
+ " com.squareup.okhttp3:okhttp:4.12.0",
76+
})
77+
void keepsDirectiveBeforeFirstLineOfCode(String directive) throws FormatterException {
78+
String script = directive + "\n\nclass Hello {}\n";
79+
assertThat(FORMATTER.formatSource(script)).isEqualTo(script);
80+
}
81+
82+
@ParameterizedTest
83+
@CsvSource(
84+
delimiterString = " => ",
85+
value = {
86+
// Not a name JBang knows.
87+
"//DEPSSS a:b:1 => // DEPSSS a:b:1",
88+
"//DEPS_ a:b:1 => // DEPS_ a:b:1",
89+
"//TODO pin the versions => // TODO pin the versions",
90+
// No whitespace after the name.
91+
"//JAVA21+ => // JAVA21+",
92+
"//DEPS:a:b:1 => // DEPS:a:b:1",
93+
// Directive names are upper case.
94+
"//deps a:b:1 => // deps a:b:1",
95+
"//Deps a:b:1 => // Deps a:b:1",
96+
// Three slashes, as in a Markdown doc comment.
97+
"///DEPS a:b:1 => /// DEPS a:b:1",
98+
// Switched off on purpose: JBang's own templates list optional dependencies this way.
99+
"// DEPS a:b:1 => // DEPS a:b:1",
100+
"// //DEPS a:b:1 => // //DEPS a:b:1",
101+
})
102+
void formatsLookalikeAsOrdinaryComment(String comment, String expected) throws FormatterException {
103+
assertThat(FORMATTER.formatSource(comment + "\n\nclass Hello {}\n"))
104+
.isEqualTo(expected + "\n\nclass Hello {}\n");
105+
}
106+
107+
@Test
108+
void formatsShellLineAfterFirstLineAsOrdinaryComment() throws FormatterException {
109+
// The shell runs the first line of the file only.
110+
String script = "/* License */\n///usr/bin/env jbang \"$0\" \"$@\" ; exit $?\nclass Hello {}\n";
111+
assertThat(FORMATTER.formatSource(script))
112+
.isEqualTo("/* License */\n/// usr/bin/env jbang \"$0\" \"$@\" ; exit $?\nclass Hello {}\n");
113+
}
114+
115+
@Test
116+
void formatsFirstLineThatRunsNoCommandAsOrdinaryComment() throws FormatterException {
117+
// The first word is not a path, so the shell would have nothing to run.
118+
assertThat(FORMATTER.formatSource("//Hello from JBang\nclass Hello {}\n"))
119+
.isEqualTo("// Hello from JBang\nclass Hello {}\n");
120+
}
121+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//usr/bin/env jbang "$0" "$@" ; exit $?
2+
//JAVA 25+
3+
//PREVIEW
4+
//DEPS com.fasterxml.jackson.core:jackson-databind:2.17.2
5+
6+
void main() { IO.println(greeting());
7+
}
8+
//DEPS org.example:after-main:1.0
9+
String greeting() { return "Hello"; }
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//usr/bin/env jbang "$0" "$@" ; exit $?
2+
//JAVA 25+
3+
//PREVIEW
4+
//DEPS com.fasterxml.jackson.core:jackson-databind:2.17.2
5+
6+
void main() {
7+
IO.println(greeting());
8+
}
9+
// DEPS org.example:after-main:1.0
10+
String greeting() {
11+
return "Hello";
12+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
///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 $?
2+
/*
3+
* Licensed under the Apache License, Version 2.0.
4+
*/
5+
//DESCRIPTION Starts a Quarkus REST service that greets whoever calls it, with the startup banner switched off for scripts
6+
//CDS
7+
//DEPS io.quarkus:quarkus-bom:${quarkus.version:3.15.1}@pom
8+
//DEPS io.quarkus:quarkus-rest // the JAX-RS endpoints
9+
//Q:CONFIG quarkus.banner.enabled=false
10+
package org.example;
11+
//SOURCES model/Greeting.java
12+
13+
import jakarta.ws.rs.GET;
14+
import jakarta.ws.rs.Path;
15+
16+
@Path("/hello")
17+
public class GreetingResource {
18+
@GET public String hello() {
19+
return "Hello"; }
20+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
///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 $?
2+
/*
3+
* Licensed under the Apache License, Version 2.0.
4+
*/
5+
//DESCRIPTION Starts a Quarkus REST service that greets whoever calls it, with the startup banner switched off for scripts
6+
//CDS
7+
//DEPS io.quarkus:quarkus-bom:${quarkus.version:3.15.1}@pom
8+
//DEPS io.quarkus:quarkus-rest // the JAX-RS endpoints
9+
//Q:CONFIG quarkus.banner.enabled=false
10+
package org.example;
11+
// SOURCES model/Greeting.java
12+
13+
import jakarta.ws.rs.GET;
14+
import jakarta.ws.rs.Path;
15+
16+
@Path("/hello")
17+
public class GreetingResource {
18+
@GET
19+
public String hello() {
20+
return "Hello";
21+
}
22+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
///usr/bin/env jbang "$0" "$@" ; exit $?
2+
//JAVA 21+
3+
//DEPS info.picocli:picocli:4.7.6
4+
//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
5+
//JAVA_OPTIONS -Xmx512m
6+
//TODO pin these versions in a BOM
7+
8+
import picocli.CommandLine;
9+
//SOURCES Helper.java
10+
import picocli.CommandLine.Command;
11+
//RUNTIME_OPTIONS --enable-preview
12+
@Command(name = "hello", mixinStandardHelpOptions = true)
13+
class hello implements Runnable {
14+
//DEPS org.example:in-class:1.0
15+
public void run() {System.out.println("Hello"); //DEPS org.example:trailing:1.0
16+
}
17+
18+
public static void main(String... args) { System.exit(new CommandLine(new hello()).execute(args)); }
19+
}
20+
//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
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
///usr/bin/env jbang "$0" "$@" ; exit $?
2+
//JAVA 21+
3+
//DEPS info.picocli:picocli:4.7.6
4+
//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
5+
//JAVA_OPTIONS -Xmx512m
6+
// TODO pin these versions in a BOM
7+
8+
import picocli.CommandLine;
9+
// SOURCES Helper.java
10+
import picocli.CommandLine.Command;
11+
// RUNTIME_OPTIONS --enable-preview
12+
@Command(name = "hello", mixinStandardHelpOptions = true)
13+
class hello implements Runnable {
14+
// DEPS org.example:in-class:1.0
15+
public void run() {
16+
System.out.println("Hello"); // DEPS org.example:trailing:1.0
17+
}
18+
19+
public static void main(String... args) {
20+
System.exit(new CommandLine(new hello()).execute(args));
21+
}
22+
}
23+
// DEPS com.fasterxml.jackson.core:jackson-databind:2.17.2 org.slf4j:slf4j-simple:2.0.13
24+
// com.squareup.okhttp3:okhttp:4.12.0 org.jsoup:jsoup:1.18.1

0 commit comments

Comments
 (0)