Add pkl:syntax stdlib module (#1821)

This commit is contained in:
Islon Scherer authored and GitHub committed 2026-10-23 15:51:22 +02:00
1 parent 0cb9843af7
commit 939e844c03
48 files changed
+3149 -214

No files matched your search

+1
View File
@@ -61,6 +61,7 @@ dependencies {
compileOnly(projects.pklExecutor)
implementation(projects.pklParser)
implementation(projects.pklFormatter)
implementation(libs.msgpack)
implementation(libs.truffleApi)
implementation(libs.graalSdk)
@@ -279,7 +279,7 @@ import org.pkl.parser.syntax.Type.ModuleType;
import org.pkl.parser.syntax.Type.NothingType;
import org.pkl.parser.syntax.Type.NullableType;
import org.pkl.parser.syntax.Type.ParenthesizedType;
import org.pkl.parser.syntax.Type.StringConstantType;
import org.pkl.parser.syntax.Type.StringLiteralType;
import org.pkl.parser.syntax.Type.ThisType;
import org.pkl.parser.syntax.Type.UnionType;
import org.pkl.parser.syntax.Type.UnknownType;
@@ -474,7 +474,7 @@ public class AstBuilder extends AbstractAstBuilder<Object> {
}
@Override
public UnresolvedTypeNode visitStringConstantType(StringConstantType type) {
public UnresolvedTypeNode visitStringLiteralType(StringLiteralType type) {
return new UnresolvedTypeNode.StringLiteral(
createSourceSection(type), type.getStr().getString());
}
@@ -565,7 +565,7 @@ public class AstBuilder extends AbstractAstBuilder<Object> {
boolean isUnionOfStringLiterals = true;
for (var typ : elementTypes) {
if (!(typ instanceof StringConstantType)) {
if (!(typ instanceof StringLiteralType)) {
isUnionOfStringLiterals = false;
break;
}
@@ -576,7 +576,7 @@ public class AstBuilder extends AbstractAstBuilder<Object> {
return new UnresolvedTypeNode.UnionOfStringLiterals(
createSourceSection(type),
defaultIndex,
elementTypes.stream().map(it -> ((StringConstantType) it).getStr().getString()).toList());
elementTypes.stream().map(it -> ((StringLiteralType) it).getStr().getString()).toList());
}
var elements = new UnresolvedTypeNode[elementTypes.size()];
@@ -181,6 +181,18 @@ public final class Identifier implements Comparable<Identifier> {
// common in lambdas etc
public static final Identifier IT = get("it");
// members of pkl.syntax#Node, pkl.syntax#Span and pkl.syntax#SourceLocation
public static final Identifier TYPE = get("type");
public static final Identifier CHILDREN = get("children");
public static final Identifier SPAN = get("span");
public static final Identifier START = get("start");
public static final Identifier END = get("end");
public static final Identifier LINE = get("line");
public static final Identifier COLUMN = get("column");
// members of pkl.syntax#FormattingRenderer
public static final Identifier GRAMMAR_VERSION = get("grammarVersion");
private final String name;
private Identifier(String name) {
@@ -107,7 +107,7 @@ public final class MirrorFactories {
new VmObjectFactory<>(ReflectModule::getNothingTypeClass);
public static final VmObjectFactory<SourceSection> sourceLocationFactory =
new VmObjectFactory<>(ReflectModule::getSourceLocationClass);
new VmObjectFactory<>(SyntaxModule::getSourceLocationClass);
static {
moduleFactory
@@ -117,6 +117,8 @@ public final class ModuleCache {
case "settings":
// always needed if ~/.config/pkl/settings.pkl is present
return SettingsModule.getModule();
case "syntax":
return SyntaxModule.getModule();
case "test":
return TestModule.getModule();
case "xml":
@@ -104,10 +104,6 @@ public final class ReflectModule extends StdLibModule {
return TypeVariableClass.instance;
}
public static VmClass getSourceLocationClass() {
return SourceLocationClass.instance;
}
private static final class ModuleClass {
static final VmClass instance = loadClass("Module");
}
@@ -176,10 +172,6 @@ public final class ReflectModule extends StdLibModule {
static final VmClass instance = loadClass("TypeVariable");
}
private static final class SourceLocationClass {
static final VmClass instance = loadClass("SourceLocation");
}
@TruffleBoundary
private static VmClass loadClass(String className) {
var theModule = getModule();
@@ -0,0 +1,61 @@
/*
* 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.core.runtime;
import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary;
import java.net.URI;
public final class SyntaxModule extends StdLibModule {
private static final VmTyped instance = VmUtils.createEmptyModule();
static {
loadModule(URI.create("pkl:syntax"), instance);
}
public static VmTyped getModule() {
return instance;
}
public static VmClass getNodeClass() {
return NodeClass.instance;
}
public static VmClass getSpanClass() {
return SpanClass.instance;
}
public static VmClass getSourceLocationClass() {
return SourceLocationClass.instance;
}
private static final class NodeClass {
static final VmClass instance = loadClass("Node");
}
private static final class SpanClass {
static final VmClass instance = loadClass("Span");
}
private static final class SourceLocationClass {
static final VmClass instance = loadClass("SourceLocation");
}
@TruffleBoundary
private static VmClass loadClass(String className) {
var theModule = getModule();
return (VmClass) VmUtils.readMember(theModule, Identifier.get(className));
}
}
@@ -947,22 +947,28 @@ public final class VmUtils {
}
public static String getDisplayUri(SourceSection section, StackFrameTransformer transformer) {
var sourceUri = section.getSource().getURI();
var frame =
new StackFrame(
sourceUri.toString(),
null,
List.of(),
section.getStartLine(),
section.getStartColumn(),
section.getEndLine(),
section.getEndColumn());
StackFrame transformed = transformer.apply(frame);
return transformed.getModuleUri();
return getDisplayUri(
section.getSource().getURI().toString(),
section.getStartLine(),
section.getStartColumn(),
section.getEndLine(),
section.getEndColumn(),
transformer);
}
public static String getDisplayUri(URI moduleUri, StackFrameTransformer transformer) {
var frame = new StackFrame(moduleUri.toString(), null, List.of(), 1, 1, 1, 1);
return getDisplayUri(moduleUri.toString(), 1, 1, 1, 1, transformer);
}
public static String getDisplayUri(
String moduleUri,
int startLine,
int startColumn,
int endLine,
int endColumn,
StackFrameTransformer transformer) {
var frame =
new StackFrame(moduleUri, null, List.of(), startLine, startColumn, endLine, endColumn);
StackFrame transformed = transformer.apply(frame);
return transformed.getModuleUri();
}
@@ -118,6 +118,15 @@ public final class VmObjectFactory<E> {
return result;
}
@TruffleBoundary
public VmTyped create(@Nullable E extraStorage, VmTyped parent) {
var clazz = classSupplier.get();
assert parent.getVmClass() == clazz;
var result = new VmTyped(VmUtils.createEmptyMaterializedFrame(), parent, clazz, members);
result.setExtraStorage(extraStorage);
return result;
}
@FunctionalInterface
public interface IntProperty<E> {
long evaluate(E extraStorage);
@@ -0,0 +1,55 @@
/*
* 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.core.stdlib.syntax;
import com.oracle.truffle.api.CompilerDirectives;
import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary;
import com.oracle.truffle.api.dsl.Cached;
import com.oracle.truffle.api.dsl.Specialization;
import com.oracle.truffle.api.nodes.IndirectCallNode;
import org.pkl.core.runtime.Identifier;
import org.pkl.core.runtime.VmTyped;
import org.pkl.core.runtime.VmUtils;
import org.pkl.core.stdlib.ExternalMethod1Node;
import org.pkl.formatter.Formatter;
import org.pkl.formatter.GrammarVersion;
import org.pkl.parser.syntax.generic.Node;
public final class FormattingRendererNodes {
private FormattingRendererNodes() {}
public abstract static class render extends ExternalMethod1Node {
@Specialization
protected String eval(
VmTyped self, VmTyped nodeVm, @Cached("create()") IndirectCallNode callNode) {
var grammarVersion = (String) VmUtils.readMember(self, Identifier.GRAMMAR_VERSION, callNode);
var node = SyntaxNodes.convertVmToNode(nodeVm, SyntaxNodes.ZERO_SPAN, callNode);
try {
return format(grammarVersion, node);
} catch (RuntimeException e) {
CompilerDirectives.transferToInterpreter();
throw exceptionBuilder()
.evalError("cannotRenderSyntaxNode", VmUtils.readMember(nodeVm, Identifier.TYPE))
.build();
}
}
@TruffleBoundary
private static String format(String grammarVersion, Node node) {
return new Formatter(GrammarVersion.valueOf(grammarVersion)).format(node);
}
}
}
@@ -0,0 +1,410 @@
/*
* 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.core.stdlib.syntax;
import com.oracle.truffle.api.CompilerDirectives;
import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary;
import com.oracle.truffle.api.dsl.Specialization;
import com.oracle.truffle.api.nodes.IndirectCallNode;
import com.oracle.truffle.api.nodes.LoopNode;
import com.oracle.truffle.api.nodes.Node;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import org.jspecify.annotations.Nullable;
import org.pkl.core.ast.lambda.ApplyVmFunction1Node;
import org.pkl.core.ast.lambda.ApplyVmFunction2Node;
import org.pkl.core.ast.lambda.ApplyVmFunction2NodeGen;
import org.pkl.core.runtime.Identifier;
import org.pkl.core.runtime.VmFunction;
import org.pkl.core.runtime.VmList;
import org.pkl.core.runtime.VmNull;
import org.pkl.core.runtime.VmTyped;
import org.pkl.core.runtime.VmUtils;
import org.pkl.core.stdlib.ExternalMethod1Node;
import org.pkl.core.stdlib.ExternalMethod2Node;
import org.pkl.core.stdlib.syntax.SyntaxNodes.Rebuild;
/** Backs the methods of {@code pkl.syntax#Node}. */
public final class NodeNodes {
private NodeNodes() {}
public abstract static class fold extends ExternalMethod2Node {
@Child private ApplyVmFunction2Node applyAccumulate = ApplyVmFunction2NodeGen.create();
@Child private IndirectCallNode callNode = IndirectCallNode.create();
@Specialization
protected Object eval(VmTyped self, Object initial, VmFunction operator) {
var pending = new ArrayDeque<VmTyped>();
pending.push(self);
var result = initial;
var visited = 0;
while (!pending.isEmpty()) {
var node = pending.pop();
result = applyAccumulate.execute(operator, result, node);
var children = (VmList) VmUtils.readMember(node, Identifier.CHILDREN, callNode);
for (var i = children.getLength() - 1; i >= 0; i--) {
pending.push((VmTyped) children.get(i));
}
visited += 1;
}
LoopNode.reportLoopCount(this, visited);
return result;
}
}
public abstract static class findChild extends ExternalMethod1Node {
@Child private ApplyVmFunction1Node applyPredicate = ApplyVmFunction1Node.create();
@Child private IndirectCallNode callNode = IndirectCallNode.create();
@Specialization
protected VmTyped eval(VmTyped self, VmFunction predicate) {
var result = findFirstChild(self, new PredicateMatcher(predicate, applyPredicate), callNode);
if (result == null) {
CompilerDirectives.transferToInterpreter();
throw exceptionBuilder().evalError("cannotFindMatchingChildNode").build();
}
return result;
}
}
public abstract static class findChildOrNull extends ExternalMethod1Node {
@Child private ApplyVmFunction1Node applyPredicate = ApplyVmFunction1Node.create();
@Child private IndirectCallNode callNode = IndirectCallNode.create();
@Specialization
protected Object eval(VmTyped self, VmFunction predicate) {
return VmNull.lift(
findFirstChild(self, new PredicateMatcher(predicate, applyPredicate), callNode));
}
}
public abstract static class findChildren extends ExternalMethod1Node {
@Child private ApplyVmFunction1Node applyPredicate = ApplyVmFunction1Node.create();
@Child private IndirectCallNode callNode = IndirectCallNode.create();
@Specialization
protected VmList eval(VmTyped self, VmFunction predicate) {
return VmList.create(
findMatchingChildren(
this, self, new PredicateMatcher(predicate, applyPredicate), callNode));
}
}
public abstract static class findChildOfType extends ExternalMethod1Node {
@Child private IndirectCallNode callNode = IndirectCallNode.create();
@Specialization
protected VmTyped eval(VmTyped self, String type) {
var result = findFirstChild(self, new TypeMatcher(type), callNode);
if (result == null) {
CompilerDirectives.transferToInterpreter();
throw exceptionBuilder().evalError("cannotFindChildNodeOfType", type).build();
}
return result;
}
}
public abstract static class findChildOfTypeOrNull extends ExternalMethod1Node {
@Child private IndirectCallNode callNode = IndirectCallNode.create();
@Specialization
protected Object eval(VmTyped self, String type) {
return VmNull.lift(findFirstChild(self, new TypeMatcher(type), callNode));
}
}
public abstract static class findChildrenOfType extends ExternalMethod1Node {
@Child private IndirectCallNode callNode = IndirectCallNode.create();
@Specialization
protected VmList eval(VmTyped self, String type) {
return VmList.create(findMatchingChildren(this, self, new TypeMatcher(type), callNode));
}
}
public abstract static class replaceChild extends ExternalMethod2Node {
@Child private ApplyVmFunction1Node applyPredicate = ApplyVmFunction1Node.create();
@Child private IndirectCallNode callNode = IndirectCallNode.create();
@Specialization
protected VmTyped eval(VmTyped self, VmFunction predicate, VmFunction replacer) {
var matcher = new PredicateMatcher(predicate, applyPredicate);
return replaceMatchingChildren(self, matcher, true, replacer, callNode);
}
}
public abstract static class replaceChildren extends ExternalMethod2Node {
@Child private ApplyVmFunction1Node applyPredicate = ApplyVmFunction1Node.create();
@Child private IndirectCallNode callNode = IndirectCallNode.create();
@Specialization
protected VmTyped eval(VmTyped self, VmFunction predicate, VmFunction replacer) {
var matcher = new PredicateMatcher(predicate, applyPredicate);
return replaceMatchingChildren(self, matcher, false, replacer, callNode);
}
}
public abstract static class replaceChildOfType extends ExternalMethod2Node {
@Child private IndirectCallNode callNode = IndirectCallNode.create();
@Specialization
protected VmTyped eval(VmTyped self, String type, VmFunction replacer) {
return replaceMatchingChildren(self, new TypeMatcher(type), true, replacer, callNode);
}
}
public abstract static class replaceChildrenOfType extends ExternalMethod2Node {
@Child private IndirectCallNode callNode = IndirectCallNode.create();
@Specialization
protected VmTyped eval(VmTyped self, String type, VmFunction replacer) {
return replaceMatchingChildren(self, new TypeMatcher(type), false, replacer, callNode);
}
}
public abstract static class findParent extends ExternalMethod1Node {
@Child private ApplyVmFunction1Node applyPredicate = ApplyVmFunction1Node.create();
@Specialization
protected VmTyped eval(VmTyped self, VmFunction predicate) {
var result = findFirstParent(this, self, new PredicateMatcher(predicate, applyPredicate));
if (result == null) {
CompilerDirectives.transferToInterpreter();
throw exceptionBuilder().evalError("cannotFindMatchingParentNode").build();
}
return result;
}
}
public abstract static class findParentOrNull extends ExternalMethod1Node {
@Child private ApplyVmFunction1Node applyPredicate = ApplyVmFunction1Node.create();
@Specialization
protected Object eval(VmTyped self, VmFunction predicate) {
return VmNull.lift(
findFirstParent(this, self, new PredicateMatcher(predicate, applyPredicate)));
}
}
public abstract static class findParentOfType extends ExternalMethod1Node {
@Specialization
protected VmTyped eval(VmTyped self, String type) {
var result = findFirstParent(this, self, new TypeMatcher(type));
if (result == null) {
CompilerDirectives.transferToInterpreter();
throw exceptionBuilder().evalError("cannotFindParentNodeOfType", type).build();
}
return result;
}
}
public abstract static class findParentOfTypeOrNull extends ExternalMethod1Node {
@Specialization
protected Object eval(VmTyped self, String type) {
return VmNull.lift(findFirstParent(this, self, new TypeMatcher(type)));
}
}
public abstract static class findParents extends ExternalMethod1Node {
@Child private ApplyVmFunction1Node applyPredicate = ApplyVmFunction1Node.create();
@Specialization
protected VmList eval(VmTyped self, VmFunction predicate) {
var matcher = new PredicateMatcher(predicate, applyPredicate);
var matches = VmList.EMPTY.builder();
var visited = 0;
for (var node = parentOf(self); node != null; node = parentOf(node)) {
visited += 1;
if (matcher.matches(node)) {
matches.add(node);
}
}
LoopNode.reportLoopCount(this, visited);
return matches.build();
}
}
public abstract static class hasParent extends ExternalMethod1Node {
@Child private ApplyVmFunction1Node applyPredicate = ApplyVmFunction1Node.create();
@Specialization
protected boolean eval(VmTyped self, VmFunction predicate) {
return findFirstParent(this, self, new PredicateMatcher(predicate, applyPredicate)) != null;
}
}
public abstract static class hasParentOfType extends ExternalMethod1Node {
@Specialization
protected boolean eval(VmTyped self, String type) {
return findFirstParent(this, self, new TypeMatcher(type)) != null;
}
}
/** Decides whether a node is a match for a search. */
private interface NodeMatcher {
boolean matches(VmTyped node);
}
/** Matches the nodes a Pkl predicate accepts. */
private record PredicateMatcher(VmFunction predicate, ApplyVmFunction1Node applyPredicate)
implements NodeMatcher {
@Override
public boolean matches(VmTyped node) {
return applyPredicate.executeBoolean(predicate, node);
}
}
/** Matches the nodes of a given type. */
private record TypeMatcher(String type) implements NodeMatcher {
@Override
public boolean matches(VmTyped node) {
return type.equals(VmUtils.readMember(node, Identifier.TYPE));
}
}
private static @Nullable VmTyped findFirstChild(
VmTyped self, NodeMatcher matcher, IndirectCallNode callNode) {
var pending = new ArrayDeque<VmTyped>();
pushChildren(pending, self, callNode);
while (!pending.isEmpty()) {
var node = pending.pop();
if (matcher.matches(node)) {
return node;
}
pushChildren(pending, node, callNode);
}
return null;
}
/**
* Collect the descendants of {@code self} matching {@code matcher}, searching depth-first in
* pre-order.
*
* <p>A match is searched for further matches, so a match may contain another.
*/
private static List<VmTyped> findMatchingChildren(
Node owner, VmTyped self, NodeMatcher matcher, IndirectCallNode callNode) {
List<VmTyped> matches = new ArrayList<>();
var pending = new ArrayDeque<VmTyped>();
pushChildren(pending, self, callNode);
var visited = 0;
while (!pending.isEmpty()) {
var node = pending.pop();
visited += 1;
if (matcher.matches(node)) {
matches.add(node);
}
pushChildren(pending, node, callNode);
}
LoopNode.reportLoopCount(owner, visited);
return matches;
}
private static void pushChildren(
ArrayDeque<VmTyped> pending, VmTyped node, IndirectCallNode callNode) {
var children = (VmList) VmUtils.readMember(node, Identifier.CHILDREN, callNode);
for (var i = children.getLength() - 1; i >= 0; i--) {
pending.push((VmTyped) children.get(i));
}
}
/**
* Returns {@code self} rebuilt with the descendants matching {@code matcher} replaced by {@code
* replacer}'s results, searching depth-first in pre-order and stopping at the first match if
* {@code firstOnly}.
*
* <p>A match is not searched for further matches.
*/
private static VmTyped replaceMatchingChildren(
VmTyped self,
NodeMatcher matcher,
boolean firstOnly,
VmFunction replacer,
IndirectCallNode callNode) {
var search = new ReplaceSearch(matcher, firstOnly, replacer, callNode);
return SyntaxNodes.rebuild(self, search.replaceBelow(self));
}
/** Finds and replaces the matches of a {@code Node.replaceChild*} call. */
private static final class ReplaceSearch {
private final NodeMatcher matcher;
private final boolean firstOnly;
private final VmFunction replacer;
private final IndirectCallNode callNode;
private boolean done;
private ReplaceSearch(
NodeMatcher matcher, boolean firstOnly, VmFunction replacer, IndirectCallNode callNode) {
this.matcher = matcher;
this.firstOnly = firstOnly;
this.replacer = replacer;
this.callNode = callNode;
}
/**
* Returns the children of {@code node} with the matches below it replaced, or {@code null} if
* nothing below it matches.
*
* <p>Each element is either a {@link Rebuild} or a node to keep as it is.
*/
@TruffleBoundary
private Object @Nullable [] replaceBelow(VmTyped node) {
var children = (VmList) VmUtils.readMember(node, Identifier.CHILDREN, callNode);
Object @Nullable [] result = null;
for (var i = 0; i < children.getLength() && !done; i++) {
var child = (VmTyped) children.get(i);
Object replacement;
if (matcher.matches(child)) {
replacement = replacer.apply(child);
done = firstOnly;
} else {
var grandchildren = replaceBelow(child);
replacement = grandchildren == null ? null : new Rebuild(child, grandchildren);
}
if (replacement != null) {
if (result == null) {
result = children.toArray();
}
result[i] = replacement;
}
}
return result;
}
}
private static @Nullable VmTyped findFirstParent(Node owner, VmTyped self, NodeMatcher matcher) {
VmTyped result = null;
var visited = 0;
for (var node = parentOf(self); node != null; node = parentOf(node)) {
visited += 1;
if (matcher.matches(node)) {
result = node;
break;
}
}
LoopNode.reportLoopCount(owner, visited);
return result;
}
private static @Nullable VmTyped parentOf(VmTyped node) {
return (VmTyped) VmNull.unwrap(VmUtils.readMember(node, Identifier.PARENT));
}
}
@@ -0,0 +1,146 @@
/*
* 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.core.stdlib.syntax;
import com.oracle.truffle.api.CompilerDirectives;
import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary;
import com.oracle.truffle.api.dsl.Cached;
import com.oracle.truffle.api.dsl.Specialization;
import com.oracle.truffle.api.nodes.IndirectCallNode;
import org.jspecify.annotations.Nullable;
import org.pkl.core.runtime.Identifier;
import org.pkl.core.runtime.VmNull;
import org.pkl.core.runtime.VmTyped;
import org.pkl.core.runtime.VmUtils;
import org.pkl.core.stdlib.ExternalMethod1Node;
import org.pkl.core.stdlib.syntax.SyntaxNodes.NodeData;
import org.pkl.parser.GenericParser;
import org.pkl.parser.GenericParserError;
/** Backs the {@code parse*} methods of {@code pkl.syntax#Parser}. */
public final class ParserNodes {
private ParserNodes() {}
private static GenericParser parser = new GenericParser();
public abstract static class parseModule extends ExternalMethod1Node {
@Specialization
protected Object evalString(VmTyped ignored, String source) {
try {
return parseModuleNode(source, null);
} catch (GenericParserError e) {
CompilerDirectives.transferToInterpreter();
throw exceptionBuilder().evalError("parserError").withHint(e.toString()).build();
}
}
@Specialization
protected Object evalResource(
VmTyped ignored, VmTyped source, @Cached("create()") IndirectCallNode callNode) {
var text = (String) VmUtils.readMember(source, Identifier.TEXT, callNode);
var uri = (String) VmUtils.readMember(source, Identifier.URI, callNode);
try {
return parseModuleNode(text, uri);
} catch (GenericParserError e) {
CompilerDirectives.transferToInterpreter();
throw exceptionBuilder().evalError("parserError").withHint(e.toString()).build();
}
}
}
public abstract static class parseModuleOrNull extends ExternalMethod1Node {
@Specialization
protected Object evalString(VmTyped ignored, String source) {
try {
return parseModuleNode(source, null);
} catch (GenericParserError e) {
return VmNull.withoutDefault();
}
}
@Specialization
protected Object evalResource(
VmTyped ignored, VmTyped source, @Cached("create()") IndirectCallNode callNode) {
var text = (String) VmUtils.readMember(source, Identifier.TEXT, callNode);
var uri = (String) VmUtils.readMember(source, Identifier.URI, callNode);
try {
return parseModuleNode(text, uri);
} catch (GenericParserError e) {
return VmNull.withoutDefault();
}
}
}
public abstract static class parseExpression extends ExternalMethod1Node {
@Specialization
protected Object evalString(VmTyped ignored, String source) {
try {
return parseExpressionNode(source, null);
} catch (GenericParserError e) {
CompilerDirectives.transferToInterpreter();
throw exceptionBuilder().evalError("parserError").withHint(e.toString()).build();
}
}
@Specialization
protected Object evalResource(
VmTyped ignored, VmTyped source, @Cached("create()") IndirectCallNode callNode) {
var text = (String) VmUtils.readMember(source, Identifier.TEXT, callNode);
var uri = (String) VmUtils.readMember(source, Identifier.URI, callNode);
try {
return parseExpressionNode(text, uri);
} catch (GenericParserError e) {
CompilerDirectives.transferToInterpreter();
throw exceptionBuilder().evalError("parserError").withHint(e.toString()).build();
}
}
}
public abstract static class parseExpressionOrNull extends ExternalMethod1Node {
@Specialization
protected Object evalString(VmTyped ignored, String source) {
try {
return parseExpressionNode(source, null);
} catch (GenericParserError e) {
return VmNull.withoutDefault();
}
}
@Specialization
protected Object evalResource(
VmTyped ignored, VmTyped source, @Cached("create()") IndirectCallNode callNode) {
var text = (String) VmUtils.readMember(source, Identifier.TEXT, callNode);
var uri = (String) VmUtils.readMember(source, Identifier.URI, callNode);
try {
return parseExpressionNode(text, uri);
} catch (GenericParserError e) {
return VmNull.withoutDefault();
}
}
}
@TruffleBoundary
private static VmTyped parseModuleNode(String src, @Nullable String sourceUri) {
var root = parser.parseModule(src);
return SyntaxNodes.createNode(new NodeData(root, src.toCharArray(), sourceUri, null));
}
@TruffleBoundary
private static VmTyped parseExpressionNode(String src, @Nullable String sourceUri) {
var root = parser.parseExpressionInput(src);
return SyntaxNodes.createNode(new NodeData(root, src.toCharArray(), sourceUri, null));
}
}
@@ -0,0 +1,305 @@
/*
* 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.core.stdlib.syntax;
import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary;
import com.oracle.truffle.api.nodes.ExplodeLoop;
import com.oracle.truffle.api.nodes.IndirectCallNode;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.jspecify.annotations.Nullable;
import org.pkl.core.runtime.Identifier;
import org.pkl.core.runtime.SyntaxModule;
import org.pkl.core.runtime.VmContext;
import org.pkl.core.runtime.VmList;
import org.pkl.core.runtime.VmNull;
import org.pkl.core.runtime.VmTyped;
import org.pkl.core.runtime.VmUtils;
import org.pkl.core.stdlib.VmObjectFactory;
import org.pkl.parser.syntax.generic.FullSpan;
import org.pkl.parser.syntax.generic.Node;
import org.pkl.parser.syntax.generic.NodeType;
public final class SyntaxNodes {
private SyntaxNodes() {}
static final FullSpan ZERO_SPAN = new FullSpan(0, 0, 0, 0, 0, 0);
record SpanData(FullSpan span, @Nullable String sourceUri) {}
private record SourceLocationData(int line, int column, @Nullable String sourceUri) {}
private static final VmObjectFactory<SourceLocationData> sourceLocationFactory =
new VmObjectFactory<SourceLocationData>(SyntaxModule::getSourceLocationClass)
.addIntProperty("line", SourceLocationData::line)
.addIntProperty("column", SourceLocationData::column)
.addStringProperty(
"displayUri", sl -> displayUri(sl.sourceUri(), sl.line(), sl.column()));
static final VmObjectFactory<SpanData> spanFactory =
new VmObjectFactory<SpanData>(SyntaxModule::getSpanClass)
.addTypedProperty(
"start",
sd ->
sourceLocationFactory.create(
new SourceLocationData(
sd.span().lineBegin(), sd.span().colBegin(), sd.sourceUri())))
.addTypedProperty(
"end",
sd ->
sourceLocationFactory.create(
new SourceLocationData(
sd.span().lineEnd(), sd.span().colEnd(), sd.sourceUri())))
.addStringProperty(
"displayUri",
sd ->
displayUri(
sd.sourceUri(),
sd.span().lineBegin(),
sd.span().colBegin(),
sd.span().lineEnd(),
sd.span().colEnd()));
private static String displayUri(@Nullable String sourceUri, int line, int column) {
return displayUri(sourceUri, line, column, line, column);
}
@TruffleBoundary
private static String displayUri(
@Nullable String sourceUri, int startLine, int startColumn, int endLine, int endColumn) {
if (sourceUri == null) {
return "";
}
return VmUtils.getDisplayUri(
sourceUri,
startLine,
startColumn,
endLine,
endColumn,
VmContext.get(null).getFrameTransformer());
}
/** Extra storage backing a Pkl {@code Node} parsed from source. */
static final class NodeData {
final Node node;
// the source {@link #node} was parsed from
final char[] source;
// the URI of {@link #source}, or {@code null} if unknown
private final @Nullable String sourceUri;
private final @Nullable VmTyped parentVm;
// the object this storage backs, so that lazily created children can be parented at it
private @Nullable VmTyped selfVm;
NodeData(Node node, char[] source, @Nullable String sourceUri, @Nullable VmTyped parentVm) {
this.node = node;
this.source = source;
this.sourceUri = sourceUri;
this.parentVm = parentVm;
}
String text() {
return node.text(source);
}
@ExplodeLoop
VmList children() {
var childNodes = node.children;
if (childNodes.isEmpty()) {
return VmList.EMPTY;
}
var builder = VmList.EMPTY.builder();
for (var childNode : childNodes) {
builder.add(createNode(new NodeData(childNode, source, sourceUri, selfVm)));
}
return builder.build();
}
VmTyped span() {
return spanFactory.create(new SpanData(node.span, sourceUri));
}
}
static final VmObjectFactory<NodeData> genericNodeFactory =
new VmObjectFactory<NodeData>(SyntaxModule::getNodeClass)
.addStringProperty("type", nd -> nd.node.type.name().toLowerCase(Locale.ROOT))
.addListProperty("children", NodeData::children)
.addProperty("parent", nd -> VmNull.lift(nd.parentVm))
.addProperty("text", NodeData::text)
.addProperty("span", NodeData::span);
/** Create the Pkl {@code Node} backed by {@code data}. */
static VmTyped createNode(NodeData data) {
var result = genericNodeFactory.create(data);
data.selfVm = result;
return result;
}
/**
* A node to rebuild with new children.
*
* <p>Each of {@code children} is either a {@code Rebuild} or a node to keep as it is.
*/
record Rebuild(VmTyped basis, Object[] children) {}
/**
* Extra storage backing a Pkl {@code Node} rebuilt from {@link #build(VmTyped, Object[],
* VmTyped)}.
*/
private static final class BuiltNodeData {
private final @Nullable VmTyped parentVm;
// set right after the node is created, so that the children can be parented at it
private VmList children = VmList.EMPTY;
private BuiltNodeData(@Nullable VmTyped parentVm) {
this.parentVm = parentVm;
}
}
private static final VmObjectFactory<BuiltNodeData> builtNodeFactory =
new VmObjectFactory<BuiltNodeData>(SyntaxModule::getNodeClass)
.addListProperty("children", bd -> bd.children)
.addProperty("parent", bd -> VmNull.lift(bd.parentVm));
/**
* Build the tree rooted at {@code root}, with its children replaced by {@code children} if not
* {@code null}.
*
* <p>The returned root has no parent, and every other node is parented in the returned tree. The
* tree {@code root} is left untouched.
*/
@TruffleBoundary
static VmTyped rebuild(VmTyped root, Object @Nullable [] children) {
return children == null ? reparent(root, null) : build(root, children, null);
}
private static VmTyped build(VmTyped basis, Object[] children, @Nullable VmTyped parent) {
var data = new BuiltNodeData(parent);
var result = builtNodeFactory.create(data, basis);
var listBuilder = VmList.EMPTY.builder();
for (var child : children) {
var newChild =
child instanceof Rebuild rebuild
? build(rebuild.basis(), rebuild.children(), result)
: reparent((VmTyped) child, result);
listBuilder.add(newChild);
}
// A node and its children point at each other, so neither can be complete before the other is
// created. This node is therefore created first, without children, so its children can be built
// with it as their parent, and only then are they set here.
// This is safe because factory properties are evaluated lazily, on first read, and no Pkl code
// can see this node before `build` returns, so `children` can't be read before it's set.
data.children = listBuilder.build();
return result;
}
/** Copy {@code node} and its subtree to sit below {@code parent}. */
private static VmTyped reparent(VmTyped node, @Nullable VmTyped parent) {
// a parsed node's subtree is created lazily from its parse-time node, so only the node is
// copied
if (node.hasExtraStorage() && node.getExtraStorage() instanceof NodeData data) {
return createNode(new NodeData(data.node, data.source, data.sourceUri, parent));
}
var children = (VmList) VmUtils.readMember(node, Identifier.CHILDREN);
return build(node, children.toArray(), parent);
}
/**
* Convert a Pkl {@code Node} to a generic {@link Node}, reusing the parse-time node when present.
*
* <p>{@code fallbackSpan} is used for constructed nodes (and their descendants) that carry no
* meaningful span of their own, so that a subtree spliced into reused siblings lines up with
* them.
*/
@TruffleBoundary
static Node convertVmToNode(VmTyped nodeVm, FullSpan fallbackSpan, IndirectCallNode callNode) {
if (nodeVm.hasExtraStorage()) {
var storage = nodeVm.getExtraStorage();
// a node still carrying its parse-time storage is verbatim from `parse`: reuse it wholesale
if (storage instanceof NodeData data) {
materializeText(data.node, data.source);
return data.node;
}
}
var typeStr = (String) VmUtils.readMember(nodeVm, Identifier.TYPE, callNode);
var nodeType = NodeType.valueOf(typeStr.toUpperCase(Locale.ROOT));
var ownSpan = readSpan(optSpan(nodeVm, callNode), callNode);
// a constructed node that did not set its own span inherits the insertion point's span
var span = ownSpan.equals(ZERO_SPAN) ? fallbackSpan : ownSpan;
var childrenVm = (VmList) VmUtils.readMember(nodeVm, Identifier.CHILDREN, callNode);
var children = new ArrayList<Node>(childrenVm.getLength());
for (var i = 0; i < childrenVm.getLength(); i++) {
children.add(convertVmToNode((VmTyped) childrenVm.get(i), span, callNode));
}
return makeJavaNode(
nodeType, span, children, VmUtils.readMember(nodeVm, Identifier.TEXT, callNode));
}
private static @Nullable VmTyped optSpan(VmTyped nodeVm, IndirectCallNode callNode) {
return (VmTyped) VmNull.unwrap(VmUtils.readMember(nodeVm, Identifier.SPAN, callNode));
}
private static FullSpan readSpan(@Nullable VmTyped spanVm, IndirectCallNode callNode) {
if (spanVm == null) {
return ZERO_SPAN;
}
var start = (VmTyped) VmUtils.readMember(spanVm, Identifier.START, callNode);
var end = (VmTyped) VmUtils.readMember(spanVm, Identifier.END, callNode);
return new FullSpan(
0,
0,
readPosition(start, Identifier.LINE, callNode),
readPosition(start, Identifier.COLUMN, callNode),
readPosition(end, Identifier.LINE, callNode),
readPosition(end, Identifier.COLUMN, callNode));
}
private static int readPosition(
VmTyped sourceLocationVm, Identifier name, IndirectCallNode callNode) {
return ((Long) VmUtils.readMember(sourceLocationVm, name, callNode)).intValue();
}
private static Node makeJavaNode(
NodeType nodeType, FullSpan span, List<Node> children, Object textObj) {
var node = children.isEmpty() ? new Node(nodeType, span) : new Node(nodeType, span, children);
if (textObj instanceof String text) {
node.setText(text);
}
return node;
}
/**
* Materialize the text of the nodes in {@code node}'s subtree that the formatter reads directly.
*
* <p>{@link org.pkl.formatter.Formatter#format(Node)} has no access to the source, so a subtree
* reused verbatim from a parse must carry its own text by the time it is handed over.
*/
private static void materializeText(Node node, char[] source) {
// `string_constant` is read by the formatter but is not a leaf
if (node.children.isEmpty() || node.type == NodeType.STRING_CONSTANT) {
node.text(source);
}
for (var child : node.children) {
materializeText(child, source);
}
}
}
@@ -0,0 +1,4 @@
@NullMarked
package org.pkl.core.stdlib.syntax;
import org.jspecify.annotations.NullMarked;
@@ -1226,6 +1226,24 @@ Redirected to: `{1}`
invalidReferenceTypeAnnotationWithConstraint=\
`Reference` referent type argument may not include type constraints.
parserError=\
Could not parse Pkl source.
cannotRenderSyntaxNode=\
Could not render node of type `{0}` as Pkl source because it is not a well-formed syntax tree.
cannotFindMatchingChildNode=\
Cannot find a child node matching the predicate.
cannotFindChildNodeOfType=\
Cannot find a child node of type `{0}`.
cannotFindMatchingParentNode=\
Cannot find a parent node matching the predicate.
cannotFindParentNodeOfType=\
Cannot find a parent node of type `{0}`.
cannotInstallPackageWithNoCache=\
Cannot install package to module cache dir when module cache is disabled.
@@ -0,0 +1,5 @@
x = 1
foo {
bar = "baz"
}
@@ -0,0 +1,5 @@
import "pkl:syntax"
local parser = new syntax.Parser {}
res = parser.parseExpression("1 +")
@@ -0,0 +1,12 @@
import "pkl:syntax"
local parser = new syntax.Parser {}
res =
parser.parseModule(
"""
foo {
bar =
}
""",
)
@@ -0,0 +1,8 @@
import "pkl:syntax"
mod =
new syntax.Parser {}.parseModule(
"""
import "foo\nbar"
""",
)
@@ -0,0 +1,257 @@
amends "../snippetTest.pkl"
import "pkl:syntax"
import "pkl:test"
local parser = new syntax.Parser {}
local renderer = new syntax.FormattingRenderer {}
local function mod(source: String): syntax.Node = parser.parseModule(source)
local function intLiteral(value: Int): syntax.Node = new syntax.Node {
type = "int_literal_expr"
text = value.toString()
}
// a node built from scratch, whose children have no parent of their own
local constructedAccess = new syntax.Node {
type = "unqualified_access_expr"
children = List(new syntax.Node { type = "identifier"; text = "foo" })
}
local sample: syntax.Node =
mod(
"""
class Point {
x: Int
y: Int
}
origin = new Point { x = 0; y = 0 }
""",
)
facts {
["findChild returns the first match in pre-order"] {
sample.findChild((n) -> n.type == "identifier").text == "Point"
sample.findChild((n) -> n.type == "int_literal_expr").text == "0"
}
["findChild throws when nothing matches"] {
test
.catch(() -> sample.findChild((n) -> n.type == "when_generator"))
.contains("Cannot find a child node matching the predicate")
}
["findChildOrNull returns null when nothing matches"] {
sample.findChildOrNull((n) -> n.type == "when_generator") == null
sample.findChildOrNull((n) -> n.type == "identifier")!!.text == "Point"
}
["findChild does not consider this node"] {
sample.findChildOrNull((n) -> n.type == "module") == null
sample.type == "module"
}
["findChildren returns every match in pre-order"] {
sample
.findChildren((n) -> n.type == "identifier")
.map((n) -> n.text)
== List("Point", "x", "Int", "y", "Int", "origin", "Point", "x", "y")
sample.findChildren((n) -> n.type == "when_generator") == List()
}
["findChildren searches a match for further matches"] {
// the outer object body contains the two entries, which are themselves matches
mod("foo { bar { baz = 1 } }").findChildren((n) -> n.type == "object_body").length == 2
}
["findChildOfType agrees with findChild"] {
sample.findChildOfType("class").text == sample.findChild((n) -> n.type == "class").text
sample.findChildrenOfType("identifier").length == 9
}
["findChildOfType throws when nothing matches"] {
test
.catch(() -> sample.findChildOfType("when_generator"))
.contains("Cannot find a child node of type `when_generator`")
}
["findChildOfTypeOrNull returns null when nothing matches"] {
sample.findChildOfTypeOrNull("when_generator") == null
sample.findChildOfTypeOrNull("class")!!.text == sample.findChildOfType("class").text
}
["findParent returns the nearest match"] {
local inner = mod("foo { bar { baz = 1 } }").findChild((n) -> n.text == "baz")
inner.findParent((n) -> n.type == "object_body").text == "{ baz = 1 }"
inner.findParent((n) -> n.type == "module").type == "module"
test
.catch(() -> inner.findParent((n) -> n.type == "class"))
.contains("Cannot find a parent node matching the predicate")
}
["findParentOrNull returns null when nothing matches"] {
local inner = mod("foo { bar { baz = 1 } }").findChild((n) -> n.text == "baz")
inner.findParentOrNull((n) -> n.type == "class") == null
inner.findParentOrNull((n) -> n.type == "object_body")!!.text == "{ baz = 1 }"
}
["findParentOfType agrees with findParent"] {
local inner = mod("foo { bar { baz = 1 } }").findChild((n) -> n.text == "baz")
inner.findParentOfType("object_body").text == "{ baz = 1 }"
test
.catch(() -> inner.findParentOfType("class"))
.contains("Cannot find a parent node of type `class`")
}
["findParentOfTypeOrNull returns null when nothing matches"] {
local inner = mod("foo { bar { baz = 1 } }").findChild((n) -> n.text == "baz")
inner.findParentOfTypeOrNull("class") == null
inner.findParentOfTypeOrNull("object_body")!!.text == "{ baz = 1 }"
}
["findParents returns every match, nearest first"] {
local inner = mod("foo { bar { baz = 1 } }").findChild((n) -> n.text == "baz")
inner
.findParents((n) -> n.type == "object_body")
.map((n) -> n.text)
== List("{ baz = 1 }", "{ bar { baz = 1 } }")
}
["parent lookups do not consider this node"] {
sample.findParentOrNull((n) -> n.type == "module") == null
sample.findParents((n) -> true) == List()
!sample.hasParentOfType("module")
}
["hasParent and hasParentOfType follow findParent"] {
local inner = mod("foo { bar = 1 }").findChild((n) -> n.text == "bar")
inner.hasParent((n) -> n.type == "object_body")
inner.hasParentOfType("class_property")
!inner.hasParentOfType("class")
}
["replaceChild replaces only the first match"] {
renderer.render(
mod("x = 1 + 1").replaceChild((n) -> n.type == "int_literal_expr", (_n) -> intLiteral(9)),
) == "x = 9 + 1\n"
}
["replaceChild leaves the tree unchanged when nothing matches"] {
renderer.render(
mod("x = 1").replaceChild((n) -> n.type == "when_generator", (_n) -> intLiteral(9)),
) == "x = 1\n"
}
["replaceChildren replaces every match"] {
renderer.render(
mod("x = 1 + 1").replaceChildren((n) -> n.type == "int_literal_expr", (_n) -> intLiteral(9)),
) == "x = 9 + 9\n"
}
["a replacer sees the node it replaces"] {
renderer.render(
mod("x = 1 + 2")
.replaceChildren(
(n) -> n.type == "int_literal_expr",
(n) -> intLiteral(n.text!!.toInt() * 10),
),
) == "x = 10 + 20\n"
}
["a replacement is not searched for further matches"] {
// replacing an identifier with an identifier terminates instead of looping
renderer.render(
mod("foo = 1").replaceChildrenOfType("identifier", (n) -> (n) { text = "\(n.text!!)Bar" }),
) == "fooBar = 1\n"
}
["replaceChildOfType agrees with replaceChild"] {
renderer.render(mod("x = 1 + 1").replaceChildOfType("int_literal_expr", (_n) -> intLiteral(9)))
== renderer.render(
mod("x = 1 + 1").replaceChild((n) -> n.type == "int_literal_expr", (_n) -> intLiteral(9)),
)
}
["a replacer sees where its node sits in the tree being built"] {
local result =
mod("x = 1")
.replaceChildOfType("int_literal_expr", (n) ->
if (n.hasParentOfType("class_property")) intLiteral(9) else intLiteral(0)
)
renderer.render(result) == "x = 9\n"
}
["a replace does not change the tree it walks"] {
local original = mod("x = 1")
local rewritten = original.replaceChildrenOfType("int_literal_expr", (_n) -> intLiteral(9))
let (rendered = renderer.render(rewritten))
rendered == "x = 9\n" && renderer.render(original) == "x = 1\n"
}
["the root of a replaced tree has no parent"] {
mod("x = 1").replaceChildrenOfType("int_literal_expr", (_n) -> intLiteral(9)).parent == null
mod("x = 1").replaceChildrenOfType("when_generator", (_n) -> intLiteral(9)).parent == null
}
["only the root of a replaced tree has no parent"] {
mod("x = 1")
.replaceChildrenOfType("int_literal_expr", (_n) -> intLiteral(9))
.fold(0, (acc, n) -> if (n.parent == null) acc + 1 else acc) == 1
// the constructed node's child has no parent of its own, so the returned tree must supply one
mod("x = 1")
.replaceChildrenOfType("int_literal_expr", (_) -> constructedAccess)
.fold(0, (acc, n) -> if (n.parent == null) acc + 1 else acc) == 1
}
["replacing below a node does not detach it from its own tree"] {
local property = mod("x = 1").findChildOfType("class_property")
local replaced = property.replaceChildrenOfType("int_literal_expr", (_) -> intLiteral(9))
replaced.parent == null && property.parent != null
}
["a replacer sees this tree, not the one being built"] {
// both literals see their unreplaced sibling
local sumOfSiblings = (n: syntax.Node) ->
intLiteral(
n.parent!!
.findChildrenOfType("int_literal_expr")
.fold(0, (acc, c) -> acc + c.text!!.toInt()),
)
renderer.render(mod("x = 1 + 2").replaceChildrenOfType("int_literal_expr", sumOfSiblings))
== "x = 3 + 3\n"
}
["the children of an amended replacement are parented at the replacement"] {
mod("x = 1 + 2")
.replaceChildOfType("binary_op_expr", (n) -> (n) { text = "changed" })
.findChildOfType("int_literal_expr")
.parent!!.text
== "changed"
}
["rebuilds ancestors of a replacement"] {
renderer.render(
mod("x = if (cond) 41 else 0")
.replaceChildrenOfType("int_literal_expr", (n) -> intLiteral(n.text!!.toInt() + 1)),
) == "x = if (cond) 42 else 1\n"
}
["a match is not searched for further matches"] {
local predicate = (n: syntax.Node) ->
if (n.type == "int_literal_expr")
throw("searched inside a match")
else
n.type == "parenthesized_expr"
renderer.render(mod("x = (1)").replaceChildren(predicate, (_) -> intLiteral(9))) == "x = 9\n"
}
["replacement is eager"] {
test
.catch(() -> mod("x = 1").replaceChildrenOfType("int_literal_expr", (_) -> throw("boom")))
.contains("boom")
}
}
@@ -0,0 +1,413 @@
amends "../snippetTest.pkl"
import "pkl:syntax"
import "pkl:test"
local renderer = new syntax.FormattingRenderer {}
local function roundTrip(source: String) = renderer.render(parseNode(source))
local function parseNode(source: String): syntax.Node =
new syntax.Parser {}.parseModule(source)
local function replaceLeaf(
node: syntax.Node,
targetType: syntax.NodeType,
oldText: String,
newText: String,
): syntax.Node =
if (node.type == targetType && node.text == oldText)
(node) { text = newText }
else
(node) {
children = node.children.map((c) -> replaceLeaf(c, targetType, oldText, newText))
}
local function transformFirst(
node: syntax.Node,
targetType: syntax.NodeType,
transform: (syntax.Node) -> syntax.Node,
): syntax.Node =
if (node.type == targetType)
transform.apply(node)
else
(node) {
children = node.children.map((c) -> transformFirst(c, targetType, transform))
}
facts {
["empty module"] {
roundTrip("") == "\n"
}
["simple property"] {
roundTrip("x = 1") == "x = 1\n"
}
["multiple properties"] {
roundTrip(
"""
x = 1
y = 2
""",
)
== """
x = 1
y = 2
"""
}
["string literals"] {
roundTrip(#"x = "hello""#) == #"x = "hello"\#n"#
roundTrip(#"x = "hello\nworld""#) == #"x = "hello\nworld"\#n"#
roundTrip(#"x = "hello \(name)""#) == #"x = "hello \(name)"\#n"#
}
["multiline string"] {
roundTrip(
#"""
x = """
hello
world
"""
"""#,
)
== #"""
x =
"""
hello
world
"""
"""#
}
["comments"] {
roundTrip(
"""
// this is a comment
x = 1
""",
)
== """
// this is a comment
x = 1
"""
}
["doc comments"] {
roundTrip(
"""
/// A documented property.
x = 1
""",
)
== """
/// A documented property.
x = 1
"""
}
["class declaration"] {
roundTrip(
"""
class Bird {
name: String
canFly: Boolean = true
}
""",
)
== """
class Bird {
name: String
canFly: Boolean = true
}
"""
}
["module declaration"] {
roundTrip("module my.app") == "module my.app\n"
}
["imports"] {
roundTrip(
"""
import "foo.pkl"
import "bar.pkl" as myBar
""",
)
== """
import "bar.pkl" as myBar
import "foo.pkl"
"""
}
["typealias"] {
roundTrip("typealias Positive = Int(this>0)") == "typealias Positive = Int(this > 0)\n"
}
["boolean literals"] {
roundTrip("x = true") == "x = true\n"
roundTrip("x = false") == "x = false\n"
}
["numeric literals"] {
roundTrip("x = 42") == "x = 42\n"
roundTrip("x = 3.14") == "x = 3.14\n"
roundTrip("x = 0xFF") == "x = 0xFF\n"
}
["null literal"] {
roundTrip("x = null") == "x = null\n"
}
["keyword expressions"] {
roundTrip("x = this") == "x = this\n"
roundTrip("x = outer") == "x = outer\n"
roundTrip("x = module") == "x = module\n"
}
["binary operators"] {
roundTrip("x = 1 + 2") == "x = 1 + 2\n"
roundTrip("x = a && b") == "x = a && b\n"
roundTrip("x = a is String") == "x = a is String\n"
}
["unary operators"] {
roundTrip("x = -1") == "x = -1\n"
roundTrip("x = !flag") == "x = !flag\n"
roundTrip("x = value!!") == "x = value!!\n"
}
["if expression"] {
roundTrip("x = if(a) b else c") == "x = if (a) b else c\n"
}
["function literal"] {
roundTrip("x = (a) -> a + 1") == "x = (a) -> a + 1\n"
}
["method declaration"] {
roundTrip("function greet(name: String): String = name")
== "function greet(name: String): String = name\n"
}
["modifiers"] {
roundTrip("hidden x = 1") == "hidden x = 1\n"
roundTrip("local x = 1") == "local x = 1\n"
}
["object body"] {
roundTrip(
"""
x { z ->
a = 1
b = 2
}
""",
)
== """
x { z ->
a = 1
b = 2
}
"""
}
["annotations"] {
roundTrip(
"""
@Deprecated { message = "use other" }
x = 1
""",
)
== """
@Deprecated { message = "use other" }
x = 1
"""
}
["type annotations"] {
roundTrip("x: String = \"hello\"") == "x: String = \"hello\"\n"
roundTrip("x: Int? = null") == "x: Int? = null\n"
roundTrip("x: String|Int = 1") == "x: String | Int = 1\n"
}
["for generator"] {
roundTrip(
"""
x {
for (k, v in items) {
[k] = v
}
}
""",
)
== """
x {
for (k, v in items) {
[k] = v
}
}
"""
}
["when generator"] {
roundTrip(
"""
x {
when (flag) {
a = 1
} else {
b = 2
}
}
""",
)
== """
x {
when (flag) {
a = 1
} else {
b = 2
}
}
"""
}
["let expression"] {
roundTrip("x = let (y = 1) y + 1") == "x = let (y = 1) y + 1\n"
}
["new expression"] {
roundTrip(
"""
x = new Dynamic {
a = 1
}
""",
)
== """
x = new Dynamic {
a = 1
}
"""
}
["qualified access"] {
roundTrip("x = foo.bar.baz") == "x = foo.bar.baz\n"
}
["subscript"] {
roundTrip("x = list[0]") == "x = list[0]\n"
}
["parenthesized expression"] {
roundTrip("x = (1 + 2)") == "x = (1 + 2)\n"
}
["quoted identifiers"] {
roundTrip("const `my prop` = 0") == "const `my prop` = 0\n"
roundTrip("x = `some`.`path`") == "x = `some`.`path`\n"
roundTrip("module `my mod`.`sub pkg`") == "module `my mod`.`sub pkg`\n"
roundTrip("function `do it`(`the arg`: Int) = `the arg`")
== "function `do it`(`the arg`: Int) = `the arg`\n"
roundTrip(
"""
class `My Class` {
`a prop`: String
}
""",
)
== """
class `My Class` {
`a prop`: String
}
"""
}
["modify identifier"] {
local root = parseNode("x = 1")
local modified = replaceLeaf(root, "identifier", "x", "y")
renderer.render(modified) == "y = 1\n"
}
["modify modifier"] {
local root = parseNode("hidden x = 1")
local modified = replaceLeaf(root, "modifier", "hidden", "local")
renderer.render(modified) == "local x = 1\n"
}
["modify string content"] {
local root = parseNode(#"x = "hello""#)
local modified = replaceLeaf(root, "string_chars", "hello", "world")
renderer.render(modified) == #"x = "world"\#n"#
}
["modify int literal"] {
local root = parseNode("x = 42")
local modified = replaceLeaf(root, "int_literal_expr", "42", "99")
renderer.render(modified) == "x = 99\n"
}
["modify boolean literal"] {
local root = parseNode("x = true")
local modified = replaceLeaf(root, "bool_literal_expr", "true", "false")
renderer.render(modified) == "x = false\n"
}
["modify float literal"] {
local root = parseNode("x = 3.14")
local modified = replaceLeaf(root, "float_literal_expr", "3.14", "2.72")
renderer.render(modified) == "x = 2.72\n"
}
["add new modifier"] {
local root = parseNode("local x = 1")
local constModifier = new syntax.Node {
type = "modifier"
text = "const"
}
local modified =
transformFirst(root, "modifier_list", (n) -> (n) {
children = List(constModifier) + n.children
})
// modifier order is switched by the formatter
renderer.render(modified) == """
local const x = 1
"""
}
["a default renderer targets the V2 grammar"] {
local node = parseNode("x = 1")
renderer.render(node) == new syntax.FormattingRenderer { grammarVersion = "V2" }.render(node)
}
["renderer honors the grammar version"] {
local node =
parseNode("x = fn(argument1, argument2, argument3, argument4, argument5, argument6, argument7, argument8, argument9)")
new syntax.FormattingRenderer { grammarVersion = "V1" }.render(node)
!= new syntax.FormattingRenderer { grammarVersion = "V2" }.render(node)
}
["a malformed tree is reported as such"] {
local malformed = new syntax.Node {
type = "object_element"
children = List()
}
test.catch(() -> renderer.render(malformed))
.contains("Could not render node of type `object_element` as Pkl source")
}
}
@@ -0,0 +1,80 @@
amends "../snippetTest.pkl"
import "pkl:syntax"
local parser = new syntax.Parser {}
local stringModule = parser.parseModule("""
x = 1
foo {
bar = "baz"
}
""")
local resourceModule = parser.parseModule(read(".../input-helper/syntax/spans/sample.pkl"))
local function nestedProperty(mod: syntax.Node): syntax.Node =
mod
.fold(List(), (acc: List<syntax.Node>, n) -> if (n.type == "object_property") acc.add(n) else acc)
.last
examples {
["span of a module parsed from a string"] {
stringModule.span
}
["span of a leaf parsed from a string"] {
nestedProperty(stringModule).span
}
["span of a module parsed from a resource carries the resource URI"] {
resourceModule.span
}
["span of a leaf parsed from a resource carries the resource URI"] {
nestedProperty(resourceModule).span
}
}
facts {
["span positions are 1-based"] {
stringModule.span!!.start.line == 1
stringModule.span!!.start.column == 1
}
["end is exclusive"] {
local span = nestedProperty(stringModule).span!!
span.start.line == 4
span.start.column == 3
span.end.line == 4
span.end.column == 14
}
["a child's span is contained in its parent's"] {
local child = nestedProperty(stringModule)
local parent = child.parent!!
parent.span!!.start.line <= child.span!!.start.line
parent.span!!.end.line >= child.span!!.end.line
}
["nodes constructed from scratch have no span"] {
new syntax.Node { type = "int_literal_expr"; text = "42" }.span == null
}
["a spanless constructed node still renders"] {
new syntax.FormattingRenderer {}
.render(new syntax.Node { type = "int_literal_expr"; text = "42" })
.trim() == "42"
}
["parsing the same text from a string and a resource differs only in displayUri"] {
local fromString = stringModule.span!!
local fromResource = resourceModule.span!!
fromString.start.line == fromResource.start.line
fromString.start.column == fromResource.start.column
fromString.end.line == fromResource.end.line
fromString.end.column == fromResource.end.column
fromString.displayUri != fromResource.displayUri
}
}
@@ -0,0 +1,101 @@
amends "../snippetTest.pkl"
import "pkl:syntax"
local function mod(source: String): syntax.Node = new syntax.Parser {}.parseModule(source)
local sample: syntax.Node =
mod(
"""
import "foo.pkl"
import "bar.pkl" as baz
class Point {
x: Int
y: Int
}
origin = if (useOrigin) new Point { x = 0; y = 0 } else null
scaled = origin.x * 2
""",
)
facts {
["fold counts nodes by predicate"] {
sample.fold(0, (acc, n) -> if (n.type == "import") acc + 1 else acc) == 2
sample.fold(0, (acc, n) -> if (n.type == "if_expr") acc + 1 else acc) == 1
sample.fold(0, (acc, n) -> if (n.type == "when_generator") acc + 1 else acc) == 0
}
["fold accumulates into a collection"] {
sample.fold(List(), (acc, n) -> if (n.type == "identifier") acc.add(n.text) else acc)
== List(
"baz",
"Point",
"x",
"Int",
"y",
"Int",
"origin",
"useOrigin",
"Point",
"x",
"y",
"scaled",
"origin",
"x",
)
}
["fold visits a node before its children (pre-order)"] {
// the module node is visited first
sample.fold(List(), (acc, n) -> acc.add(n.type)).first == "module"
}
["terminals have no children"] {
sample.fold(true, (acc, n) -> if (n.isTerminal) acc && n.children.isEmpty else acc)
}
["a parsed module is a branch"] {
!sample.isTerminal
}
["identifiers are terminals"] {
sample.fold(true, (acc, n) -> if (n.type == "identifier") acc && n.isTerminal else acc)
}
["terminals carry text"] {
sample.fold(true, (acc, n) -> if (n.isTerminal) acc && n.text != null else acc)
}
["branches carry the text of their entire subtree"] {
sample.fold(true, (acc, n) -> acc && n.text != null)
// the module node spans the whole source
sample.text == """
import "foo.pkl"
import "bar.pkl" as baz
class Point {
x: Int
y: Int
}
origin = if (useOrigin) new Point { x = 0; y = 0 } else null
scaled = origin.x * 2
"""
// a branch's text includes the whitespace and comments between its children
sample.fold(List(), (acc, n) -> if (n.type == "class_body") acc.add(n.text) else acc)
== List("""
{
x: Int
y: Int
}
""")
}
}
@@ -27,6 +27,7 @@ pkl:release
pkl:semver
pkl:settings
pkl:shell
pkl:syntax
pkl:test
pkl:xml
pkl:yaml
@@ -0,0 +1,16 @@
–– Pkl Error ––
Could not parse Pkl source.
x | res = parser.parseExpression("1 +")
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
at syntaxParseExpression#res (file:///$snippetsDir/input/errors/syntaxParseExpression.pkl)
Unexpected end of file. at (1:4)
xxx | renderer.renderDocument(value)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
at pkl.base#Module.output.text (pkl:base)
xxx | if (renderer is BytesRenderer) renderer.renderDocument(value) else text.encodeToBytes("UTF-8")
^^^^
at pkl.base#Module.output.bytes (pkl:base)
@@ -0,0 +1,16 @@
–– Pkl Error ––
Could not parse Pkl source.
x | parser.parseModule(
^^^^^^^^^^^^^^^^^^^
at syntaxParseModule#res (file:///$snippetsDir/input/errors/syntaxParseModule.pkl)
Unexpected token `}`. Expected `}`. at (3:1 - 3:2)
xxx | renderer.renderDocument(value)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
at pkl.base#Module.output.text (pkl:base)
xxx | if (renderer is BytesRenderer) renderer.renderDocument(value) else text.encodeToBytes("UTF-8")
^^^^
at pkl.base#Module.output.bytes (pkl:base)
@@ -0,0 +1,16 @@
–– Pkl Error ––
Could not parse Pkl source.
x | new syntax.Parser {}.parseModule(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
at syntaxParseModuleLexerError#mod (file:///$snippetsDir/input/errors/syntaxParseModuleLexerError.pkl)
Missing `"` delimiter. at (1:9 - 2:1)
xxx | renderer.renderDocument(value)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
at pkl.base#Module.output.text (pkl:base)
xxx | if (renderer is BytesRenderer) renderer.renderDocument(value) else text.encodeToBytes("UTF-8")
^^^^
at pkl.base#Module.output.bytes (pkl:base)
@@ -0,0 +1,115 @@
facts {
["findChild returns the first match in pre-order"] {
true
true
}
["findChild throws when nothing matches"] {
true
}
["findChildOrNull returns null when nothing matches"] {
true
true
}
["findChild does not consider this node"] {
true
true
}
["findChildren returns every match in pre-order"] {
true
true
}
["findChildren searches a match for further matches"] {
true
}
["findChildOfType agrees with findChild"] {
true
true
}
["findChildOfType throws when nothing matches"] {
true
}
["findChildOfTypeOrNull returns null when nothing matches"] {
true
true
}
["findParent returns the nearest match"] {
true
true
true
}
["findParentOrNull returns null when nothing matches"] {
true
true
}
["findParentOfType agrees with findParent"] {
true
true
}
["findParentOfTypeOrNull returns null when nothing matches"] {
true
true
}
["findParents returns every match, nearest first"] {
true
}
["parent lookups do not consider this node"] {
true
true
true
}
["hasParent and hasParentOfType follow findParent"] {
true
true
true
}
["replaceChild replaces only the first match"] {
true
}
["replaceChild leaves the tree unchanged when nothing matches"] {
true
}
["replaceChildren replaces every match"] {
true
}
["a replacer sees the node it replaces"] {
true
}
["a replacement is not searched for further matches"] {
true
}
["replaceChildOfType agrees with replaceChild"] {
true
}
["a replacer sees where its node sits in the tree being built"] {
true
}
["a replace does not change the tree it walks"] {
true
}
["the root of a replaced tree has no parent"] {
true
true
}
["only the root of a replaced tree has no parent"] {
true
true
}
["replacing below a node does not detach it from its own tree"] {
true
}
["a replacer sees this tree, not the one being built"] {
true
}
["the children of an amended replacement are parented at the replacement"] {
true
}
["rebuilds ancestors of a replacement"] {
true
}
["a match is not searched for further matches"] {
true
}
["replacement is eager"] {
true
}
}
@@ -0,0 +1,146 @@
facts {
["empty module"] {
true
}
["simple property"] {
true
}
["multiple properties"] {
true
}
["string literals"] {
true
true
true
}
["multiline string"] {
true
}
["comments"] {
true
}
["doc comments"] {
true
}
["class declaration"] {
true
}
["module declaration"] {
true
}
["imports"] {
true
}
["typealias"] {
true
}
["boolean literals"] {
true
true
}
["numeric literals"] {
true
true
true
}
["null literal"] {
true
}
["keyword expressions"] {
true
true
true
}
["binary operators"] {
true
true
true
}
["unary operators"] {
true
true
true
}
["if expression"] {
true
}
["function literal"] {
true
}
["method declaration"] {
true
}
["modifiers"] {
true
true
}
["object body"] {
true
}
["annotations"] {
true
}
["type annotations"] {
true
true
true
}
["for generator"] {
true
}
["when generator"] {
true
}
["let expression"] {
true
}
["new expression"] {
true
}
["qualified access"] {
true
}
["subscript"] {
true
}
["parenthesized expression"] {
true
}
["quoted identifiers"] {
true
true
true
true
true
}
["modify identifier"] {
true
}
["modify modifier"] {
true
}
["modify string content"] {
true
}
["modify int literal"] {
true
}
["modify boolean literal"] {
true
}
["modify float literal"] {
true
}
["add new modifier"] {
true
}
["a default renderer targets the V2 grammar"] {
true
}
["renderer honors the grammar version"] {
true
}
["a malformed tree is reported as such"] {
true
}
}
@@ -0,0 +1,91 @@
facts {
["span positions are 1-based"] {
true
true
}
["end is exclusive"] {
true
true
true
true
}
["a child's span is contained in its parent's"] {
true
true
}
["nodes constructed from scratch have no span"] {
true
}
["a spanless constructed node still renders"] {
true
}
["parsing the same text from a string and a resource differs only in displayUri"] {
true
true
true
true
true
}
}
examples {
["span of a module parsed from a string"] {
new {
start {
line = X
column = 1
displayUri = ""
}
end {
line = X
column = 2
displayUri = ""
}
displayUri = ""
}
}
["span of a leaf parsed from a string"] {
new {
start {
line = X
column = 3
displayUri = ""
}
end {
line = X
column = 14
displayUri = ""
}
displayUri = ""
}
}
["span of a module parsed from a resource carries the resource URI"] {
new {
start {
line = X
column = 1
displayUri = "file:///$snippetsDir/input-helper/syntax/spans/sample.pkl"
}
end {
line = X
column = 2
displayUri = "file:///$snippetsDir/input-helper/syntax/spans/sample.pkl"
}
displayUri = "file:///$snippetsDir/input-helper/syntax/spans/sample.pkl"
}
}
["span of a leaf parsed from a resource carries the resource URI"] {
new {
start {
line = X
column = 3
displayUri = "file:///$snippetsDir/input-helper/syntax/spans/sample.pkl"
}
end {
line = X
column = 14
displayUri = "file:///$snippetsDir/input-helper/syntax/spans/sample.pkl"
}
displayUri = "file:///$snippetsDir/input-helper/syntax/spans/sample.pkl"
}
}
}
@@ -0,0 +1,30 @@
facts {
["fold counts nodes by predicate"] {
true
true
true
}
["fold accumulates into a collection"] {
true
}
["fold visits a node before its children (pre-order)"] {
true
}
["terminals have no children"] {
true
}
["a parsed module is a branch"] {
true
}
["identifiers are terminals"] {
true
}
["terminals carry text"] {
true
}
["branches carry the text of their entire subtree"] {
true
true
true
}
}