apple/pkl · error · GenericParserError

stringIndentationMustMatchLastLine

stringIndentationMustMatchLastLine

Error message

Line must match or exceed indentation of the String's last line.

What it means

In a multi-line string, every line after the first must be indented at least as much as the closing delimiter's line, so Pkl knows how much common indentation to strip. validateStringIndentation computes the closer's indent and throws stringIndentationMustMatchLastLine for any line that starts with less indentation.

Source

Thrown at pkl-parser/src/main/java/org/pkl/parser/GenericParserImpl.java:1064

    if (beforeLast.type == NodeType.STRING_NEWLINE) return;
    var text = beforeLast.text(lexer.getSource());
    if (!text.isBlank()) {
      throw parserError(
          ErrorMessages.create("closingStringDelimiterMustBeginOnNewLine"), beforeLast.span);
    }
  }

  private void validateStringIndentation(List<Node> nodes) {
    var indentNode = nodes.get(nodes.size() - 2);
    if (indentNode.type == NodeType.STRING_NEWLINE) return;
    var indent = indentNode.text(lexer.getSource());
    var previousNewline = false;
    for (var i = 1; i < nodes.size() - 2; i++) {
      var child = nodes.get(i);
      if (child.type != NodeType.STRING_NEWLINE && previousNewline) {
        var text = child.text(lexer.getSource());
        if (!text.startsWith(indent)) {
          throw parserError(ErrorMessages.create("stringIndentationMustMatchLastLine"), child.span);
        }
      }
      previousNewline =
          child.type == NodeType.STRING_NEWLINE || child.type == NodeType.STRING_CONTINUATION;
    }
  }

  private Node parseParenthesizedExpr() {
    var children = new ArrayList<Node>();
    expect(Token.LPAREN, children, "unexpectedToken", "(");
    if (lookahead() == Token.RPAREN) {
      ff(children);
      children.add(makeTerminal(next()));
      return new Node(NodeType.PARENTHESIZED_EXPR, children);
    }
    var elements = new ArrayList<Node>();
    ff(elements);
    elements.add(parseExpr(")"));

View on GitHub (pinned to f3efcbfc9b)

Solutions

  1. Indent every content line of the string to at least the closing `"""`'s indentation.
  2. Use consistent indentation (spaces, not mixed tabs) throughout the block.
  3. Dedent pasted text uniformly first, then re-indent the whole block together.
  4. Keep the closing `"""` at the lowest indentation of the block and indent content lines beyond it.

Example fix

// before
sql = """
    SELECT 1
FROM t
    """

// after
sql = """
    SELECT 1
    FROM t
    """
Defensive patterns

Strategy: validation

Validate before calling

// Every content line must be indented >= the closing """ line's indent
function stringIndentationOk(block) {
  const lines = block.split('\n');
  const closerIndent = lines[lines.length - 1].match(/^\s*/)[0].length;
  return lines.slice(1, -1).every(l => l.trim() === '' || l.match(/^\s*/)[0].length >= closerIndent);
}

Prevention

When it happens

Trigger: A line inside a `"""..."""` block is dedented relative to the closing delimiter's indentation — e.g. the closer is indented two spaces but one content line starts at column 0.

Common situations: Copy-pasting text with varying indentation into a multi-line string; editors auto-dedenting blank-ish lines; mixed tabs/spaces reducing effective indent; generated files with inconsistent indentation.

Understand the failure class

Background: "Invalid ... format", "must be in format X", "does not look like a ..." — invalid argument format errors across CLI tools and libraries — this error's family across 17 libraries.

Related errors


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