Skip to content

Commit b48a838

Browse files
authored
Merge pull request #91 from openjavaformat/jbang-directives
Leave JBang directives at the top of a file as written
2 parents bf5bdcd + 493d4d9 commit b48a838

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)