Skip to content

Add pkl:syntax module #1569

Open
stackoverflow wants to merge 4 commits into
apple:mainfrom
stackoverflow:pkl-syntax
Open

Add pkl:syntax module #1569
stackoverflow wants to merge 4 commits into
apple:mainfrom
stackoverflow:pkl-syntax

Conversation

@stackoverflow

Copy link
Copy Markdown
Contributor

This new stdlib module allows for full manipulation of Pkl source code in Pkl: parsing text to a Pkl CST and formatting a Pkl CST back to text.

@odenix

odenix commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Have you considered offering the equivalent Java library? Wrapping that library instead of implementing pkl:syntax in Pkl would also improve (especially cold/warm) performance of pkl:syntax.

@HT154

HT154 commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

The major thing missing from this PR is the ability to whole-cloth construct and then format SyntaxNode instances without laboriously creating the underlying Nodes, especially given how common and important code generation is for the Pkl ecosystem. I'm not opposed to offering a Java API (pklpoet?) for this as long as the SyntaxNode classes remain instantiable (not external).

@stackoverflow

Copy link
Copy Markdown
Contributor Author

The Java library is already Node. We still need the classes to be implemented in Pkl because we don't want to offer a generic API to users. As Jen mentioned, we don't want the classes to be external.

@odenix

odenix commented May 22, 2026

Copy link
Copy Markdown
Contributor

As Jen mentioned, we don't want the classes to be external.

As far as I know, external members of non-external classes can still be implemented in Java.

If this stdlib module is implemented in Pkl, it should not be cached (as it is now), as this is very likely to cause multithreading issues.

@stackoverflow

Copy link
Copy Markdown
Contributor Author

As Jen mentioned, we don't want the classes to be external.

As far as I know, external members of non-external classes can still be implemented in Java.

I don't think there would be much difference performance wise in implementing it in java. All the parsing and formatting is already in java. The Pkl classes are just a sugar layer.

If this stdlib module is implemented in Pkl, it should not be cached (as it is now), as this is very likely to cause multithreading issues.

What do you mean by cached?

@stackoverflow stackoverflow marked this pull request as ready for review May 27, 2026 15:59
let (result = parse("x: \(typeSource) = 0"))
(result as syntax.ModuleNode).properties.first.typeAnnotation!!.type

examples {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these be facts instead since they're all booleans?

Comment on lines +43 to +49
private static final Identifier TYPE_ID = Identifier.get("type");
private static final Identifier CHILDREN_ID = Identifier.get("children");
private static final Identifier SPAN_ID = Identifier.get("span");
private static final Identifier LINE_START_ID = Identifier.get("lineStart");
private static final Identifier COL_START_ID = Identifier.get("colStart");
private static final Identifier LINE_END_ID = Identifier.get("lineEnd");
private static final Identifier COL_END_ID = Identifier.get("colEnd");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should probably be members of Identifier with everything else.

Comment thread stdlib/syntax.pkl
Comment on lines +21 to +31
/// Parse the string as a Pkl module, returning either a typed AST node or an error.
function parse(source: String): ModuleNode | ParserError =
let (result = parseNodes(source))
if (result is ParserError)
result
else
new ModuleNode { node = result }

/// Parse a resource as a Pkl module, returning either a typed AST node or an error.
function parseResource(resourceURI: String): ModuleNode | ParserError =
parse(read(resourceURI).text)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've found that it's better for parser APIs to accept Resource directly to avoid confusion when parseResource("./foo.pkl") doesn't work as expected because the read is relative to pkl:syntax (which won't work).

Suggested change
/// Parse the string as a Pkl module, returning either a typed AST node or an error.
function parse(source: String): ModuleNode | ParserError =
let (result = parseNodes(source))
if (result is ParserError)
result
else
new ModuleNode { node = result }
/// Parse a resource as a Pkl module, returning either a typed AST node or an error.
function parseResource(resourceURI: String): ModuleNode | ParserError =
parse(read(resourceURI).text)
/// Parse the string as a Pkl module, returning either a typed AST node or an error.
function parse(source: String | Resource): ModuleNode | ParserError =
let (src = if (source is String) source else source.text)
let (result = parseNodes(source))
if (result is ParserError)
result
else
new ModuleNode { node = result }

(or parseNodes should also accept a Resource as well and parse just passes it through)

Comment thread stdlib/syntax.pkl
Comment on lines +2065 to +2069
/// Affix nodes (comments, semicolons) to prepend before this node.
prefixes: List<Node>

/// Affix nodes (comments, semicolons) to append after this node.
suffixes: List<Node>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not seeing if/how these properties are used

Comment thread stdlib/syntax.pkl
suffixes: List<Node>

/// Build the typed syntax node.
abstract function build(): SyntaxNode

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be a fixed property instead?

Suggested change
abstract function build(): SyntaxNode
abstract fixed result: SyntaxNode

Not drawn to a specific name here. result and syntaxNode both seem reasonable to me.

Comment thread stdlib/syntax.pkl
extractStringConstant(sc)

/// Base class for all typed syntax nodes.
abstract class SyntaxNode {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isn't toBuilder()/builder defined on SyntaxNode?

Comment thread stdlib/syntax.pkl
Comment on lines +196 to +202
// Find first child of a given type within a node.
local const function findChild(n: Node, t: NodeType): Node? =
n.children.findOrNull((c) -> c.type == t)

// Find all children of a given type.
local const function findChildren(n: Node, t: NodeType): List<Node> =
n.children.filter((c) -> c.type == t)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason not to implement all of these as methods on node. I don't see a reason not to make them public API.

Comment thread stdlib/syntax.pkl
Comment on lines +3819 to +3833
children =
(if (self.declaration == null) List() else List(self.declaration.build().node))
+ (
if (self.imports.isEmpty)
List()
else
List(new Node {
type = "import_list"
children = self.imports.map((i) -> i.build().node)
})
)
+ self.classes.map((c) -> c.build().node)
+ self.typeAliases.map((t) -> t.build().node)
+ self.properties.map((p) -> p.build().node)
+ self.methods.map((m) -> m.build().node)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: style. I ted to find List.filterNonNull makes things like this a little easier to read and maintain.

Suggested change
children =
(if (self.declaration == null) List() else List(self.declaration.build().node))
+ (
if (self.imports.isEmpty)
List()
else
List(new Node {
type = "import_list"
children = self.imports.map((i) -> i.build().node)
})
)
+ self.classes.map((c) -> c.build().node)
+ self.typeAliases.map((t) -> t.build().node)
+ self.properties.map((p) -> p.build().node)
+ self.methods.map((m) -> m.build().node)
List(
self.declaration?.build().node,
if (self.imports.isEmpty)
null
else
new Node {
type = "import_list"
children = self.imports.map((i) -> i.build().node)
},
).filterNonNull()
+ self.classes.map((c) -> c.build().node)
+ self.typeAliases.map((t) -> t.build().node)
+ self.properties.map((p) -> p.build().node)
+ self.methods.map((m) -> m.build().node)

Or better yet, throw together a helper method:

function buildList(items: List): List = items.flatMap((it) ->
  if (it is List) it
  else if (it == null) List()
  else List(it)
)

Then

Suggested change
children =
(if (self.declaration == null) List() else List(self.declaration.build().node))
+ (
if (self.imports.isEmpty)
List()
else
List(new Node {
type = "import_list"
children = self.imports.map((i) -> i.build().node)
})
)
+ self.classes.map((c) -> c.build().node)
+ self.typeAliases.map((t) -> t.build().node)
+ self.properties.map((p) -> p.build().node)
+ self.methods.map((m) -> m.build().node)
children =
buildList(List(
self.declaration?.build().node,
if (self.imports.isEmpty)
null
else
new Node {
type = "import_list"
children = self.imports.map((i) -> i.build().node)
},
self.classes.map((c) -> c.build().node),
self.typeAliases.map((t) -> t.build().node),
self.properties.map((p) -> p.build().node),
self.methods.map((m) -> m.build().node),
))

Varargs would make this even nicer, but probably not worth it here since those are special-cased in AstBuilder.

Part of me also wonders if paying the object tax is worth it here for the readability benefit:

Suggested change
children =
(if (self.declaration == null) List() else List(self.declaration.build().node))
+ (
if (self.imports.isEmpty)
List()
else
List(new Node {
type = "import_list"
children = self.imports.map((i) -> i.build().node)
})
)
+ self.classes.map((c) -> c.build().node)
+ self.typeAliases.map((t) -> t.build().node)
+ self.properties.map((p) -> p.build().node)
+ self.methods.map((m) -> m.build().node)
children = new Listing {
when (self.declaration != null) {
self.declaration.build().node
}
when (self.imports.isNotEmpty) {
new Node {
type = "import_list"
children = self.imports.map((i) -> i.build().node)
}
}
...self.classes.map((c) -> c.build().node)
...self.typeAliases.map((t) -> t.build().node)
...self.properties.map((p) -> p.build().node)
...self.methods.map((m) -> m.build().node)
}.toList()

Comment thread stdlib/syntax.pkl
+ self.classes.map((c) -> c.build().node)
+ self.typeAliases.map((t) -> t.build().node)
+ self.properties.map((p) -> p.build().node)
+ self.methods.map((m) -> m.build().node)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will necessarily rearrange a module's members, producing all classes, then aliases, then properties, and finally methods. This seems less than ideal from a round-tripping perspective. As an alternative, it might be better to use an approach similar to object members for classes and modules.

Comment on lines +5 to +15
local function formatExpr(builder: syntax.ExprBuilder): String =
syntax.format(builder.build().node)

local function formatType(builder: syntax.TypeBuilder): String =
syntax.format(builder.build().node)

local function formatBody(builder: syntax.ObjectBodyBuilder): String =
syntax.format(builder.build().node)

local function formatModule(builder: syntax.ModuleBuilder): String =
syntax.format(builder.build().node)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems odd to repeat this verbatim:

Suggested change
local function formatExpr(builder: syntax.ExprBuilder): String =
syntax.format(builder.build().node)
local function formatType(builder: syntax.TypeBuilder): String =
syntax.format(builder.build().node)
local function formatBody(builder: syntax.ObjectBodyBuilder): String =
syntax.format(builder.build().node)
local function formatModule(builder: syntax.ModuleBuilder): String =
syntax.format(builder.build().node)
local function format(builder: syntax.Builder): String =
syntax.format(builder.build().node)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants