diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/syntax/sexp.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/syntax/sexp.pkl new file mode 100644 index 000000000..ed78f45e7 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/syntax/sexp.pkl @@ -0,0 +1,58 @@ +amends "../snippetTest.pkl" + +import "pkl:syntax" + +local renderer = new syntax.SExpressionRenderer { renderTerminalText = false } +local textRenderer = new syntax.SExpressionRenderer {} + +local function parseExpr(source: String): syntax.Node = + new syntax.Parser {}.parseExpression(source) + +local function parseModule(source: String): syntax.Node = + new syntax.Parser {}.parseModule(source) + +facts { + ["terminal"] { + renderer.renderValue(parseExpr("5")) == "(int_literal_expr)" + } + + ["terminal with text"] { + textRenderer.renderValue(parseExpr("5")) == #"(int_literal_expr "5")"# + } + +} + +examples { + ["nested nodes are indented"] { + renderer.renderValue(parseExpr("1 + 2")) + } + + ["nested nodes with terminal text"] { + textRenderer.renderValue(parseExpr("1 + 2")) + } + + ["keywords and punctuation are rendered without text"] { + textRenderer.renderValue(parseExpr("if (true) x else 0")) + } + + ["terminal text is escaped"] { + textRenderer.renderValue(parseExpr(#""a \"b\"\n""#)) + } + + ["module"] { + renderer.renderValue(parseModule("x = 1")) + textRenderer.renderValue(parseModule("x = 1")) + } + + ["class"] { + renderer.renderValue( + parseModule( + """ + class Bird { + name: String + } + """, + ) + ) + } +} diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/syntax/sexp.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/syntax/sexp.pcf new file mode 100644 index 000000000..05b19ad4f --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/syntax/sexp.pcf @@ -0,0 +1,100 @@ +facts { + ["terminal"] { + true + } + ["terminal with text"] { + true + } +} +examples { + ["nested nodes are indented"] { + """ + (binary_op_expr + (int_literal_expr) + (operator) + (int_literal_expr)) + """ + } + ["nested nodes with terminal text"] { + """ + (binary_op_expr + (int_literal_expr "1") + (operator "+") + (int_literal_expr "2")) + """ + } + ["keywords and punctuation are rendered without text"] { + """ + (if_expr + (if_header + (if_keyword) + (if_condition + (lparen) + (if_condition_expr + (bool_literal_expr "true")) + (rparen))) + (if_then_expr + (unqualified_access_expr + (identifier "x"))) + (else_keyword) + (if_else_expr + (int_literal_expr "0"))) + """ + } + ["terminal text is escaped"] { + """ + (single_line_string_literal_expr + (string_start "\\"") + (string_chars "a ") + (string_escape "\\\\\\"") + (string_chars "b") + (string_escape "\\\\\\"") + (string_escape "\\\\n") + (string_end "\\"")) + """ + } + ["module"] { + """ + (module + (class_property + (class_property_header + (class_property_header_begin + (identifier))) + (assign) + (class_property_body + (int_literal_expr)))) + """ + """ + (module + (class_property + (class_property_header + (class_property_header_begin + (identifier "x"))) + (assign) + (class_property_body + (int_literal_expr "1")))) + """ + } + ["class"] { + """ + (module + (class + (class_header + (class_keyword) + (identifier)) + (class_body + (lbrace) + (class_body_elements + (class_property + (class_property_header + (class_property_header_begin + (identifier))) + (type_annotation + (colon) + (declared_type + (qualified_identifier + (identifier)))))) + (rbrace)))) + """ + } +} diff --git a/stdlib/syntax.pkl b/stdlib/syntax.pkl index d861386bb..a44b6bdaf 100644 --- a/stdlib/syntax.pkl +++ b/stdlib/syntax.pkl @@ -49,6 +49,71 @@ class FormattingRenderer { external function renderValue(node: Node): String } +/// Renders a [Node] as an [S-expression](https://en.wikipedia.org/wiki/S-expression). +/// +/// Each node is rendered as its [type][Node.type] in parentheses, followed by its +/// [children][Node.children], each on its own line and indented by two spaces. +/// Nodes without children are rendered on a single line. +/// +/// Example: +/// ``` +/// new SExpressionRenderer {}.renderValue(new Parser {}.parseExpression("1 + 2")) +/// ``` +/// +/// The above renders: +/// +/// ``` +/// (binary_op_expr +/// (int_literal_expr "1") +/// (operator "+") +/// (int_literal_expr "2")) +/// ``` +class SExpressionRenderer { + /// Whether to render the [Node.text] of terminal nodes whose text is not implied by their + /// [Node.type]. + /// + /// If [true], the text follows the type as a quoted, escaped string, as in + /// `(int_literal_expr "5")`. + /// If [false], only the type is rendered. + renderTerminalText: Boolean = true + + /// The types of the terminal nodes that [renderTerminalText] applies to. + local textTypes: Set = + Set( + "identifier", + "modifier", + "operator", + "bool_literal_expr", + "int_literal_expr", + "float_literal_expr", + "string_start", + "string_multi_start", + "string_end", + "interpolation_start", + "string_chars", + "string_escape", + "string_continuation", + "shebang", + "line_comment", + "block_comment", + "doc_comment_line", + ) + + /// Renders [value] as a S-expression. + function renderValue(value: Node): String = renderIndented(value, "") + + local function renderIndented(node: Node, indent: String): String = + if (node.children.isEmpty) + if (renderTerminalText && textTypes.contains(node.type)) + "\(indent)(\(node.type) \(new PcfRenderer {}.renderValue(node.text)))" + else + "\(indent)(\(node.type))" + else + "\(indent)(\(node.type)\n" + + node.children.map((child) -> renderIndented(child, indent + " ")).join("\n") + + ")" +} + /// A generic, untyped node in the Pkl syntax tree. /// /// A node is either a *leaf*, carrying no [children], or a *branch*, whose content is its