apple/pkl · error · ParserError

closingStringDelimiterMustBeginOnNewLine

closingStringDelimiterMustBeginOnNewLine

Error message

The closing delimiter of a multi-line string must begin on a new line.

What it means

In a Pkl multi-line string, the closing `"""` delimiter must sit on its own line. getCommonIndent inspects the last node of the string's content; if that node is a raw text part with no token boundary (meaning the closer shares a line with content, leaving no trailing-newline token), it throws closingStringDelimiterMustBeginOnNewLine at the last node's span.

Source

Thrown at pkl-parser/src/main/java/org/pkl/parser/ParserImpl.java:1673

  private String parseUnicodeEscape(FullToken tk) {
    var text = tk.text(lexer);
    var lastIndex = text.length() - 1;
    var startIndex = text.indexOf('{', 2);
    try {
      var codepoint = Integer.parseInt(text.substring(startIndex + 1, lastIndex), 16);
      return Character.toString(codepoint);
    } catch (NumberFormatException e) {
      throw new ParserError(
          ErrorMessages.create("invalidUnicodeEscapeSequence", text, text.substring(0, startIndex)),
          tk.span);
    }
  }

  private String getCommonIndent(List<TempNode> nodes, Span span) {
    var lastNode = nodes.get(nodes.size() - 1);
    if (lastNode.token == null) {
      throw new ParserError(
          ErrorMessages.create("closingStringDelimiterMustBeginOnNewLine"), lastNode.span());
    }
    if (lastNode.token.token == Token.STRING_NEWLINE) return "";
    var beforeLast = nodes.get(nodes.size() - 2);
    if (beforeLast.token != null && beforeLast.token.token == Token.STRING_NEWLINE) {
      var indent = getTrailingIndent(lastNode);
      if (indent != null) {
        return indent;
      }
    }
    throw new ParserError(ErrorMessages.create("closingStringDelimiterMustBeginOnNewLine"), span);
  }

  private @Nullable String getTrailingIndent(TempNode node) {
    var token = node.token;
    if (token == null || token.token != Token.STRING_PART) return null;
    var text = token.text(lexer);
    for (var i = 0; i < text.length(); i++) {

View on GitHub (pinned to f3efcbfc9b)

Solutions

  1. Place the closing `"""` on its own new line after the last content line
  2. Disable trimming of the final newline if a build/template step strips it before parsing
  3. Reformat the string block so a line break precedes the closing delimiter

Example fix

// before
msg: """
  hello"""
// after
msg: """
  hello
  """
Defensive patterns

Strategy: validation

Validate before calling

function closingDelimiterOnOwnLine(src) {
  return [...src.matchAll(/"""/g)].length % 2 === 0 &&
    !/[^\n]"""\s*$|[^\n]"""/m.test(src.split('"""')[1] ?? '');
}
// simpler: for each multi-line string, assert the closing '"""' is the first non-space token on its line

Type guard

null

Try / catch

try {
  const result = parser.parse(source);
} catch (e) {
  if (e.errorId === 'closingStringDelimiterMustBeginOnNewLine') {
    // insert a newline before the closing triple quote at e.span
  } else { throw e; }
}

Prevention

When it happens

Trigger: Parsing a multi-line string whose closing delimiter appears on the same line as text, e.g. `""" hello"""` — the last content node lacks a token so the parser cannot compute the string's common indent.

Common situations: Writing Kotlin/Scala-style raw strings where the closer trails content; collapsing the final newline during copy-paste or minification; template engines trimming the trailing newline before the closer.

Related errors


AI-assisted analysis of apple/pkl@f3efcbfc9b (2026-09-08). Data as JSON: /api/errors/680318ad4a4d6388. Report an issue: GitHub.