Move pkl-formatter to Java (#1514)

This commit is contained in:
Islon Scherer
2026-04-14 16:29:42 +02:00
committed by GitHub
parent 4620992743
commit 1d74e2a869
14 changed files with 2303 additions and 2035 deletions
+5 -6
View File
@@ -24,22 +24,21 @@ org.jetbrains.kotlin:kotlin-daemon-client:2.2.21=kotlinBuildToolsApiClasspath
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable
org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.2.21=kotlinKlibCommonizerClasspath
org.jetbrains.kotlin:kotlin-metadata-jvm:2.2.21=kotlinInternalAbiValidation
org.jetbrains.kotlin:kotlin-native-prebuilt:2.0.21=kotlinNativeBundleConfiguration
org.jetbrains.kotlin:kotlin-reflect:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable
org.jetbrains.kotlin:kotlin-script-runtime:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable
org.jetbrains.kotlin:kotlin-scripting-common:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest
org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest
org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest
org.jetbrains.kotlin:kotlin-scripting-jvm:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.21=apiDependenciesMetadata,compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21=apiDependenciesMetadata,compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=apiDependenciesMetadata,compileClasspath,implementationDependenciesMetadata,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,runtimeClasspath,swiftExportClasspathResolvable,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.21=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.jetbrains.kotlin:swift-export-embeddable:2.2.21=swiftExportClasspathResolvable
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=swiftExportClasspathResolvable
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=swiftExportClasspathResolvable
org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=swiftExportClasspathResolvable
org.jetbrains:annotations:13.0=compileClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,runtimeClasspath,swiftExportClasspathResolvable,testCompileClasspath,testRuntimeClasspath
org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=testCompileClasspath,testImplementationDependenciesMetadata
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.3=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
@@ -50,4 +49,4 @@ org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
org.junit:junit-bom:6.0.3=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.msgpack:msgpack-core:0.9.11=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
empty=annotationProcessor,compileOnlyDependenciesMetadata,intransitiveDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDefExtensions,signatures,sourcesJar,testAnnotationProcessor,testApiDependenciesMetadata,testCompileOnlyDependenciesMetadata,testIntransitiveDependenciesMetadata,testKotlinScriptDefExtensions
empty=annotationProcessor,apiDependenciesMetadata,compileClasspath,compileOnlyDependenciesMetadata,implementationDependenciesMetadata,intransitiveDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDefExtensions,runtimeClasspath,sourcesJar,testAnnotationProcessor,testApiDependenciesMetadata,testCompileOnlyDependenciesMetadata,testIntransitiveDependenciesMetadata,testKotlinScriptDefExtensions
+2 -2
View File
@@ -1,5 +1,5 @@
/*
* Copyright © 2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2025-2026 Apple Inc. and the Pkl project authors. 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.
@@ -15,7 +15,7 @@
*/
plugins {
pklAllProjects
pklKotlinLibrary
pklJavaLibrary
pklPublishLibrary
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
/*
* Copyright © 2026 Apple Inc. and the Pkl project authors. 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
*
* https://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 org.pkl.formatter;
import java.util.List;
import java.util.Set;
sealed interface FormatNode
permits Text,
Empty,
Line,
ForceLine,
SpaceOrLine,
Space,
Indent,
Nodes,
Group,
MultilineStringGroup,
IfWrap {
default int width(Set<Integer> wrapped) {
if (this instanceof Nodes n) {
return n.nodes().stream().mapToInt(node -> node.width(wrapped)).sum();
} else if (this instanceof Group g) {
return g.nodes().stream().mapToInt(node -> node.width(wrapped)).sum();
} else if (this instanceof Indent i) {
return i.nodes().stream().mapToInt(node -> node.width(wrapped)).sum();
} else if (this instanceof IfWrap iw) {
return wrapped.contains(iw.id()) ? iw.ifWrap().width(wrapped) : iw.ifNotWrap().width(wrapped);
} else if (this instanceof Text t) {
return t.text().length();
} else if (this instanceof SpaceOrLine || this instanceof Space) {
return 1;
} else if (this instanceof ForceLine || this instanceof MultilineStringGroup) {
return Generator.MAX;
} else {
return 0;
}
}
}
record Text(String text) implements FormatNode {}
record Empty() implements FormatNode {
static final Empty INSTANCE = new Empty();
}
record Line() implements FormatNode {
static final Line INSTANCE = new Line();
}
record ForceLine() implements FormatNode {
static final ForceLine INSTANCE = new ForceLine();
}
record SpaceOrLine() implements FormatNode {
static final SpaceOrLine INSTANCE = new SpaceOrLine();
}
record Space() implements FormatNode {
static final Space INSTANCE = new Space();
}
record Indent(List<FormatNode> nodes) implements FormatNode {}
record Nodes(List<FormatNode> nodes) implements FormatNode {}
record Group(int id, List<FormatNode> nodes) implements FormatNode {}
record MultilineStringGroup(int endQuoteCol, List<FormatNode> nodes) implements FormatNode {}
record IfWrap(int id, FormatNode ifWrap, FormatNode ifNotWrap) implements FormatNode {}
@@ -0,0 +1,121 @@
/*
* Copyright © 2026 Apple Inc. and the Pkl project authors. 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
*
* https://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 org.pkl.formatter;
import java.io.IOException;
import java.io.Reader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.pkl.parser.GenericParser;
/**
* A formatter for Pkl files that applies canonical formatting rules.
*
* @see GrammarVersion
*/
public class Formatter {
private final GrammarVersion grammarVersion;
public Formatter(GrammarVersion grammarVersion) {
this.grammarVersion = grammarVersion;
}
public Formatter() {
this(GrammarVersion.latest());
}
/**
* Formats a Pkl file from the given file path.
*
* @param path the path to the Pkl file to format
* @param grammarVersion grammar compatibility version
* @return the formatted Pkl source code as a string
* @throws java.io.IOException if the file cannot be read
* @deprecated use {@code format(Files.readString(path))} instead
*/
@Deprecated
public String format(Path path, GrammarVersion grammarVersion) throws IOException {
return new Formatter(grammarVersion).format(Files.readString(path));
}
/**
* Formats a Pkl file from the given file path.
*
* @param path the path to the Pkl file to format
* @return the formatted Pkl source code as a string
* @throws java.io.IOException if the file cannot be read
* @deprecated use {@code format(Files.readString(path))} instead
*/
@Deprecated
public String format(Path path) throws IOException {
return format(path, GrammarVersion.latest());
}
/**
* Formats the given Pkl source code text.
*
* @param text the Pkl source code to format
* @param grammarVersion grammar compatibility version
* @return the formatted Pkl source code as a string
* @deprecated use {@code new Formatter(grammarVersion).format(text)} instead
*/
@Deprecated
public String format(String text, GrammarVersion grammarVersion) {
return new Formatter(grammarVersion).format(text);
}
/**
* Formats the given Pkl source code text.
*
* @param text the Pkl source code to format
* @return the formatted Pkl source code as a string
*/
public String format(String text) {
var sb = new StringBuilder();
format(text, sb);
return sb.toString();
}
/**
* Formats the given Pkl source code text.
*
* <p>It is the caller's responsibility to close {@code input}, and, if applicable, {@code
* output}.
*
* @param input the Pkl source code to format
* @param output the formatted Pkl source code
* @throws IOException if an I/O error occurs during reading or writing
*/
public void format(Reader input, Appendable output) throws IOException {
var sb = new StringBuilder();
var buf = new char[8192];
int n;
while ((n = input.read(buf)) != -1) {
sb.append(buf, 0, n);
}
format(sb.toString(), output);
}
private void format(String input, Appendable output) {
var ast = new GenericParser().parseModule(input);
var formatAst = new Builder(input, grammarVersion).format(ast);
// force a line at the end of the file
var nodes = new Nodes(List.of(formatAst, ForceLine.INSTANCE));
new Generator(output).generate(nodes);
}
}
@@ -0,0 +1,160 @@
/*
* Copyright © 2026 Apple Inc. and the Pkl project authors. 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
*
* https://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 org.pkl.formatter;
import java.util.HashSet;
import java.util.Set;
final class Generator {
static final int MAX = 100;
private static final String INDENT = " ";
private final Appendable buf;
private int indent = 0;
private int size = 0;
private final Set<Integer> wrapped = new HashSet<>();
private boolean shouldAddIndent = false;
Generator(Appendable buf) {
this.buf = buf;
}
void generate(FormatNode node) {
node(node, Wrap.DETECT);
}
@SuppressWarnings("StatementWithEmptyBody")
private void node(FormatNode node, Wrap wrap) {
if (node instanceof Empty) {
// nothing
} else if (node instanceof Nodes n) {
for (var child : n.nodes()) {
node(child, wrap);
}
} else if (node instanceof Group g) {
var width = 0;
for (var child : g.nodes()) {
width += child.width(wrapped);
}
var groupWrap = wrap;
if (size + width > MAX) {
wrapped.add(g.id());
groupWrap = Wrap.ENABLED;
} else {
groupWrap = Wrap.DETECT;
}
for (var child : g.nodes()) {
node(child, groupWrap);
}
} else if (node instanceof IfWrap iw) {
if (wrapped.contains(iw.id())) {
node(iw.ifWrap(), Wrap.ENABLED);
} else {
node(iw.ifNotWrap(), wrap);
}
} else if (node instanceof Text t) {
text(t.text());
} else if (node instanceof Line) {
if (wrap.isEnabled()) {
newline(true);
}
} else if (node instanceof ForceLine) {
newline(true);
} else if (node instanceof SpaceOrLine) {
if (wrap.isEnabled()) {
newline(true);
} else {
text(" ");
}
} else if (node instanceof Space) {
text(" ");
} else if (node instanceof Indent ind) {
if (wrap.isEnabled() && !ind.nodes().isEmpty()) {
size += INDENT.length();
indent++;
for (var child : ind.nodes()) {
node(child, wrap);
}
indent--;
} else {
for (var child : ind.nodes()) {
node(child, wrap);
}
}
} else if (node instanceof MultilineStringGroup multi) {
var indentLength = indent * INDENT.length();
var oldIndent = indentFor(multi);
var previousNewline = false;
var nodes = multi.nodes();
for (var i = 0; i < nodes.size(); i++) {
var child = nodes.get(i);
if (child instanceof ForceLine) {
newline(false);
} else if (child instanceof Text t
&& previousNewline
&& t.text().isBlank()
&& t.text().length() == oldIndent
&& nodes.get(i + 1) instanceof ForceLine) {
// skip blank line indentation that will be repositioned
} else if (child instanceof Text t && previousNewline) {
text(reposition(t.text(), multi.endQuoteCol() - 1, indentLength));
} else {
node(child, Wrap.DETECT);
}
previousNewline = child instanceof ForceLine;
}
}
}
private void text(String value) {
try {
if (shouldAddIndent) {
for (var i = 0; i < indent; i++) {
buf.append(INDENT);
}
shouldAddIndent = false;
}
size += value.length();
buf.append(value);
} catch (java.io.IOException e) {
throw new java.io.UncheckedIOException(e);
}
}
private void newline(boolean shouldIndent) {
try {
size = INDENT.length() * indent;
buf.append('\n');
shouldAddIndent = shouldIndent;
} catch (java.io.IOException e) {
throw new java.io.UncheckedIOException(e);
}
}
// accept text indented by originalOffset characters (tabs or spaces)
// and return it indented by newOffset characters (spaces only)
private static String reposition(String text, int originalOffset, int newOffset) {
return " ".repeat(newOffset) + text.substring(originalOffset);
}
private static int indentFor(MultilineStringGroup multi) {
var nodes = multi.nodes();
if (nodes.size() < 2) return 0;
var beforeLast = nodes.get(nodes.size() - 2);
return beforeLast instanceof Text t ? t.text().length() : 0;
}
}
@@ -0,0 +1,48 @@
/*
* Copyright © 2026 Apple Inc. and the Pkl project authors. 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
*
* https://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 org.pkl.formatter;
/** Grammar compatibility version. */
public enum GrammarVersion {
V1(1, "0.25 - 0.29"),
V2(2, "0.30+");
private final int version;
private final String versionSpan;
GrammarVersion(int version, String versionSpan) {
this.version = version;
this.versionSpan = versionSpan;
}
public int getVersion() {
return version;
}
public String getVersionSpan() {
return versionSpan;
}
public static GrammarVersion latest() {
var latest = V1;
for (var v : values()) {
if (v.version > latest.version) {
latest = v;
}
}
return latest;
}
}
@@ -0,0 +1,78 @@
/*
* Copyright © 2026 Apple Inc. and the Pkl project authors. 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
*
* https://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 org.pkl.formatter;
import java.util.Comparator;
final class NaturalOrderComparator implements Comparator<String> {
private final boolean ignoreCase;
NaturalOrderComparator(boolean ignoreCase) {
this.ignoreCase = ignoreCase;
}
NaturalOrderComparator() {
this(false);
}
@Override
public int compare(String s1, String s2) {
var i = 0;
var j = 0;
while (i < s1.length() && j < s2.length()) {
var c1 = ignoreCase ? Character.toLowerCase(s1.charAt(i)) : s1.charAt(i);
var c2 = ignoreCase ? Character.toLowerCase(s2.charAt(j)) : s2.charAt(j);
if (Character.isDigit(c1) && Character.isDigit(c2)) {
var pair1 = getNumber(s1, i);
var pair2 = getNumber(s2, j);
var numComparison = Long.compare(pair1.l, pair2.l);
if (numComparison != 0) {
return numComparison;
}
i = pair1.i;
j = pair2.i;
} else {
var charComparison = Character.compare(c1, c2);
if (charComparison != 0) {
return charComparison;
}
i++;
j++;
}
}
return Integer.compare(s1.length(), s2.length());
}
private static LongAndInt getNumber(String s, int startIndex) {
var i = startIndex;
while (i < s.length() && Character.isDigit(s.charAt(i))) {
i++;
}
try {
var number = Long.parseLong(s, startIndex, i, 10);
return new LongAndInt(number, i);
} catch (NumberFormatException e) {
return new LongAndInt(0L, i);
}
}
private record LongAndInt(long l, int i) {}
}
@@ -0,0 +1,25 @@
/*
* Copyright © 2026 Apple Inc. and the Pkl project authors. 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
*
* https://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 org.pkl.formatter;
enum Wrap {
ENABLED,
DETECT;
boolean isEnabled() {
return this == ENABLED;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,103 +0,0 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Pkl project authors. 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
*
* https://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 org.pkl.formatter
import java.io.IOException
import java.io.Reader
import java.nio.file.Files
import java.nio.file.Path
import kotlin.jvm.Throws
import org.pkl.formatter.ast.ForceLine
import org.pkl.formatter.ast.Nodes
import org.pkl.parser.GenericParser
/**
* A formatter for Pkl files that applies canonical formatting rules.
*
* @param grammarVersion grammar compatibility version
*/
class Formatter
@JvmOverloads
constructor(private val grammarVersion: GrammarVersion = GrammarVersion.latest()) {
/**
* Formats a Pkl file from the given file path.
*
* @param path the path to the Pkl file to format
* @param grammarVersion grammar compatibility version
* @return the formatted Pkl source code as a string
* @throws java.io.IOException if the file cannot be read
*/
@JvmOverloads
@Deprecated(message = "use format(path.readText()) instead")
fun format(path: Path, grammarVersion: GrammarVersion = GrammarVersion.latest()): String {
return Formatter(grammarVersion).format(Files.readString(path))
}
/**
* Formats the given Pkl source code text.
*
* @param text the Pkl source code to format
* @param grammarVersion grammar compatibility version
* @return the formatted Pkl source code as a string
*/
@Deprecated(message = "use Formatter(grammarVersion).format(text) instead")
fun format(text: String, grammarVersion: GrammarVersion): String {
return Formatter(grammarVersion).format(text)
}
/**
* Formats the given Pkl source code text.
*
* @param text the Pkl source code to format
* @return the formatted Pkl source code as a string
*/
fun format(text: String): String {
return buildString { format(text, this) }
}
/**
* Formats the given Pkl source code text.
*
* It is the caller's responsibility to close [input], and, if applicable, [output].
*
* @param input the Pkl source code to format
* @param output the formatted Pkl source code
* @throws java.io.IOException if an I/O error occurs during reading or writing
*/
@Throws(IOException::class)
fun format(input: Reader, output: Appendable) {
format(input.readText(), output)
}
private fun format(input: String, output: Appendable) {
val ast = GenericParser().parseModule(input)
val formatAst = Builder(input, grammarVersion).format(ast)
// force a line at the end of the file
val nodes = Nodes(listOf(formatAst, ForceLine))
Generator(output).generate(nodes)
}
}
/** Grammar compatibility version. */
enum class GrammarVersion(val version: Int, val versionSpan: String) {
V1(1, "0.25 - 0.29"),
V2(2, "0.30+");
companion object {
@JvmStatic fun latest(): GrammarVersion = entries.maxBy { it.version }
}
}
@@ -1,145 +0,0 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Pkl project authors. 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
*
* https://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 org.pkl.formatter
import org.pkl.formatter.ast.Empty
import org.pkl.formatter.ast.ForceLine
import org.pkl.formatter.ast.FormatNode
import org.pkl.formatter.ast.Group
import org.pkl.formatter.ast.IfWrap
import org.pkl.formatter.ast.Indent
import org.pkl.formatter.ast.Line
import org.pkl.formatter.ast.MultilineStringGroup
import org.pkl.formatter.ast.Nodes
import org.pkl.formatter.ast.Space
import org.pkl.formatter.ast.SpaceOrLine
import org.pkl.formatter.ast.Text
import org.pkl.formatter.ast.Wrap
internal class Generator(private val buf: Appendable) {
private var indent: Int = 0
private var size: Int = 0
private val wrapped: MutableSet<Int> = mutableSetOf()
private var shouldAddIndent = false
fun generate(node: FormatNode) {
node(node, Wrap.DETECT)
}
private fun node(node: FormatNode, wrap: Wrap) {
when (node) {
is Empty -> {}
is Nodes -> node.nodes.forEach { node(it, wrap) }
is Group -> {
val width = node.nodes.sumOf { it.width(wrapped) }
val wrap =
if (size + width > MAX) {
wrapped += node.id
Wrap.ENABLED
} else {
Wrap.DETECT
}
node.nodes.forEach { node(it, wrap) }
}
is IfWrap -> {
if (wrapped.contains(node.id)) {
node(node.ifWrap, Wrap.ENABLED)
} else {
node(node.ifNotWrap, wrap)
}
}
is Text -> text(node.text)
is Line -> {
if (wrap.isEnabled()) {
newline()
}
}
is ForceLine -> newline()
is SpaceOrLine -> {
if (wrap.isEnabled()) {
newline()
} else {
text(" ")
}
}
is Space -> text(" ")
is Indent -> {
if (wrap.isEnabled() && node.nodes.isNotEmpty()) {
size += INDENT.length
indent++
node.nodes.forEach { node(it, wrap) }
indent--
} else {
node.nodes.forEach { node(it, wrap) }
}
}
is MultilineStringGroup -> {
val indentLength = indent * INDENT.length
val oldIndent = indentFor(node)
var previousNewline = false
for ((i, child) in node.nodes.withIndex()) {
when {
child is ForceLine -> newline(shouldIndent = false) // don't indent
child is Text &&
previousNewline &&
child.text.isBlank() &&
child.text.length == oldIndent &&
node.nodes[i + 1] is ForceLine -> {}
child is Text && previousNewline ->
text(reposition(child.text, node.endQuoteCol - 1, indentLength))
else -> node(child, Wrap.DETECT) // always detect wrapping
}
previousNewline = child is ForceLine
}
}
}
}
private fun text(value: String) {
if (shouldAddIndent) {
repeat(times = indent) { buf.append(INDENT) }
shouldAddIndent = false
}
size += value.length
buf.append(value)
}
private fun newline(shouldIndent: Boolean = true) {
size = INDENT.length * indent
buf.append('\n')
shouldAddIndent = shouldIndent
}
// accept text indented by originalOffset characters (tabs or spaces)
// and return it indented by newOffset characters (spaces only)
private fun reposition(text: String, originalOffset: Int, newOffset: Int): String =
" ".repeat(newOffset) + text.drop(originalOffset)
// Returns the indent of this multiline string, which is the size of the last node before the
// closing quotes, or 0 if the closing quotes have no indentation
private fun indentFor(multi: MultilineStringGroup): Int {
val nodes = multi.nodes
if (nodes.size < 2) return 0
val beforeLast = nodes[nodes.lastIndex - 1]
return if (beforeLast is Text) beforeLast.text.length else 0
}
companion object {
// max line length
const val MAX = 100
private const val INDENT = " "
}
}
@@ -1,66 +0,0 @@
/*
* Copyright © 2025 Apple Inc. and the Pkl project authors. 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
*
* https://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 org.pkl.formatter
internal class NaturalOrderComparator(private val ignoreCase: Boolean = false) :
Comparator<String> {
override fun compare(s1: String, s2: String): Int {
var i = 0
var j = 0
while (i < s1.length && j < s2.length) {
val c1 = if (ignoreCase) s1[i].lowercaseChar() else s1[i]
val c2 = if (ignoreCase) s2[j].lowercaseChar() else s2[j]
if (c1.isDigit() && c2.isDigit()) {
val (num1, nextI) = getNumber(s1, i)
val (num2, nextJ) = getNumber(s2, j)
val numComparison = num1.compareTo(num2)
if (numComparison != 0) {
return numComparison
}
i = nextI
j = nextJ
} else {
val charComparison = c1.compareTo(c2)
if (charComparison != 0) {
return charComparison
}
i++
j++
}
}
return s1.length.compareTo(s2.length)
}
private fun getNumber(s: String, startIndex: Int): LongAndInt {
var i = startIndex
val start = i
while (i < s.length && s[i].isDigit()) {
i++
}
val numStr = s.substring(start, i)
val number = numStr.toLongOrNull() ?: 0L
return LongAndInt(number, i)
}
// use this instead of Pair to avoid boxing
private data class LongAndInt(val l: Long, var i: Int)
}
@@ -1,64 +0,0 @@
/*
* Copyright © 2025 Apple Inc. and the Pkl project authors. 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
*
* https://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 org.pkl.formatter.ast
import org.pkl.formatter.Generator
enum class Wrap {
ENABLED,
DETECT;
fun isEnabled(): Boolean = this == ENABLED
}
sealed interface FormatNode {
fun width(wrapped: Set<Int>): Int =
when (this) {
is Nodes -> nodes.sumOf { it.width(wrapped) }
is Group -> nodes.sumOf { it.width(wrapped) }
is Indent -> nodes.sumOf { it.width(wrapped) }
is IfWrap -> if (id in wrapped) ifWrap.width(wrapped) else ifNotWrap.width(wrapped)
is Text -> text.length
SpaceOrLine,
Space -> 1
ForceLine,
is MultilineStringGroup -> Generator.MAX
Empty -> 0
Line -> 0
}
}
data class Text(val text: String) : FormatNode
object Empty : FormatNode
object Line : FormatNode
object ForceLine : FormatNode
object SpaceOrLine : FormatNode
object Space : FormatNode
data class Indent(val nodes: List<FormatNode>) : FormatNode
data class Nodes(val nodes: List<FormatNode>) : FormatNode
data class Group(val id: Int, val nodes: List<FormatNode>) : FormatNode
data class MultilineStringGroup(val endQuoteCol: Int, val nodes: List<FormatNode>) : FormatNode
data class IfWrap(val id: Int, val ifWrap: FormatNode, val ifNotWrap: FormatNode) : FormatNode