Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ public ImmutableList<Replacement> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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);
Expand Down Expand Up @@ -108,6 +116,57 @@ private String indentLineComments(List<String> 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<String> 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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ public final class FileBasedTests {
"UnnamedPattern",
"CompactSource",
"MarkdownDoc",
"FlexibleConstructor")
"FlexibleConstructor",
"ojf-issue-24-jbang-compact-source")
.putAll(23, "ModuleImport")
.build();

Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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"; }
Original file line number Diff line number Diff line change
@@ -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";
}
Original file line number Diff line number Diff line change
@@ -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"; }
}
Original file line number Diff line number Diff line change
@@ -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";
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading