Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/ast-dumper.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ JsonDocument
│ ├── endOffset: 29
│ ├── depth: 0
│ ├── indent: " "
│ ├── openingLineIndentation: ""
│ ├── newline: "\n"
│ └── originalText: |-
│ {
Expand Down
13 changes: 13 additions & 0 deletions docs/node-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,22 @@ Built-in attribute names are available in `Boundwize\JsonRecast\Attribute\NodeAt
| `END_OFFSET` | End offset in the original source. |
| `ORIGINAL_TEXT` | Exact source substring for the node. |
| `DEPTH` | Original nesting depth, where the root JSON value and document are depth `0`. |
| `OPENING_LINE_INDENTATION` | Leading spaces or tabs on the source line where a parsed object or array opens. Stored on container nodes only. |
| `SOURCE` | Original source used as document-framing provenance; a replacement document that adopts host framing also adopts the host source. |
| `NEWLINE` | Detected newline sequence, stored on the document and parsed nodes. |
| `INDENT` | Detected indentation unit, stored on the document and parsed nodes, and used when printing newly-created nested structures. |
| `TRAILING_NEWLINE` | Whether the document ended with a newline. |

`OPENING_LINE_INDENTATION` is not necessarily the same as `INDENT` repeated `DEPTH` times. A container can open inline after a shallower value, as the object does here:

```json
{
"items": [1, {
"enabled": true
}]
}
```

The inner object's depth is `2`, but its opening line begins with one two-space indent. The preserving printer uses this source coordinate when changing indent units so closing delimiters remain aligned without inventing off-grid residual whitespace.

Attributes are useful for tooling and debugging. JsonRecast does not use a mutable "has changed" node attribute; changes are tracked in the traversal result.
2 changes: 2 additions & 0 deletions docs/parsing-and-printers.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ echo JsonRecast::print($result);

After traversal, pass the `JsonRecastResult` itself to `JsonRecast::print()` so explicit change records are retained. The preserving printer also detects direct changes to document-level printer metadata: `NodeAttributes::INDENT`, `NodeAttributes::NEWLINE`, and `NodeAttributes::TRAILING_NEWLINE`. These attributes can therefore be changed on a parsed document and printed without requiring a traversal solely to mark the document as changed.

When `INDENT` changes, structural indentation adopts the new unit while intentional off-grid residual whitespace remains intact. Parsed object and array nodes record `NodeAttributes::OPENING_LINE_INDENTATION`, the actual leading whitespace on the line where the container opens. This matters for containers opened inline: their opening and closing alignment follows that source line rather than assuming every container begins at `DEPTH × INDENT`.

The preserving printer keeps the document newline style and trailing newline when they were present in the parsed source.

## Pretty Printer
Expand Down
2 changes: 2 additions & 0 deletions src/Attribute/NodeAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ final class NodeAttributes

public const INDENT = 'indent';

public const OPENING_LINE_INDENTATION = 'openingLineIndentation';

public const TRAILING_NEWLINE = 'trailingNewline';

public const DEPTH = 'depth';
Expand Down
44 changes: 40 additions & 4 deletions src/Parser/JsonParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use function json_decode;
use function str_ends_with;
use function strlen;
use function strspn;
use function substr;

use const JSON_THROW_ON_ERROR;
Expand Down Expand Up @@ -110,7 +111,13 @@ private function parseCollection(int $depth): ObjectNode|ArrayNode
$node = $isObject
? new ObjectNode([], afterOpenBrace: $beforeItem, beforeCloseBrace: $beforeItem)
: new ArrayNode([], afterOpenBracket: $beforeItem, beforeCloseBracket: $beforeItem);
$this->setSourceMetadata($node, $token->startOffset, $close->endOffset, $depth);
$this->setSourceMetadata(
$node,
$token->startOffset,
$close->endOffset,
$depth,
$this->lineIndentationAt($token),
);

return $node;
}
Expand Down Expand Up @@ -167,7 +174,13 @@ private function parseCollection(int $depth): ObjectNode|ArrayNode
$node = $isObject
? new ObjectNode($objectItems, $objectItems[0]->beforeKey, $afterValue)
: new ArrayNode($arrayItems, $arrayItems[0]->beforeValue, $afterValue);
$this->setSourceMetadata($node, $token->startOffset, $close->endOffset, $depth);
$this->setSourceMetadata(
$node,
$token->startOffset,
$close->endOffset,
$depth,
$this->lineIndentationAt($token),
);

return $node;
}
Expand Down Expand Up @@ -288,19 +301,42 @@ private function unexpectedToken(string $expected): ParseError
);
}

private function setSourceMetadata(NodeJson $nodeJson, int $startOffset, int $endOffset, int $depth): void
{
private function setSourceMetadata(
NodeJson $nodeJson,
int $startOffset,
int $endOffset,
int $depth,
?string $lineIndentation = null,
): void {
$nodeJson->setAttribute(NodeAttributes::START_OFFSET, $startOffset);
$nodeJson->setAttribute(NodeAttributes::END_OFFSET, $endOffset);
$nodeJson->setAttribute(NodeAttributes::DEPTH, $depth);
$nodeJson->setAttribute(NodeAttributes::INDENT, $this->indent);

if ($lineIndentation !== null) {
$nodeJson->setAttribute(NodeAttributes::OPENING_LINE_INDENTATION, $lineIndentation);
}

$nodeJson->setAttribute(NodeAttributes::NEWLINE, $this->newline);
$nodeJson->setAttribute(
NodeAttributes::ORIGINAL_TEXT,
substr($this->source, $startOffset, $endOffset - $startOffset),
);
}

private function lineIndentationAt(Token $token): string
{
$linePrefixLength = $token->startOffset - $token->lineStartOffset;
$indentLength = strspn(
$this->source,
" \t",
$token->lineStartOffset,
$linePrefixLength,
);

return substr($this->source, $token->lineStartOffset, $indentLength);
}

private function hasTrailingNewline(string $source): bool
{
return str_ends_with($source, "\n") || str_ends_with($source, "\r");
Expand Down
23 changes: 21 additions & 2 deletions src/Parser/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ final class Lexer

private int $column = 1;

private int $lineStartOffset = 0;

private bool $previousWasCarriageReturn = false;

private string $source = '';
Expand All @@ -53,6 +55,7 @@ public function tokenize(string $source): array
$this->offset = 0;
$this->line = 1;
$this->column = 1;
$this->lineStartOffset = 0;
$this->previousWasCarriageReturn = false;
$tokens = [];

Expand Down Expand Up @@ -82,6 +85,7 @@ public function tokenize(string $source): array
$this->offset,
$this->line,
$this->column,
$this->lineStartOffset,
);

return $tokens;
Expand Down Expand Up @@ -118,7 +122,15 @@ private function keywordOrNumberToken(int $startOffset, int $line, int $column):
$this->column += $length;
$this->previousWasCarriageReturn = false;

return new Token(self::KEYWORD_TOKENS[$text], $text, $startOffset, $this->offset, $line, $column);
return new Token(
self::KEYWORD_TOKENS[$text],
$text,
$startOffset,
$this->offset,
$line,
$column,
$this->lineStartOffset,
);
}

/**
Expand All @@ -129,11 +141,13 @@ private function singleCharacterToken(string $tokenType, int $startOffset, int $
$text = $this->currentChar();
$this->advance();

return new Token($tokenType, $text, $startOffset, $this->offset, $line, $column);
return new Token($tokenType, $text, $startOffset, $this->offset, $line, $column, $this->lineStartOffset);
}

private function whitespaceToken(int $startOffset, int $line, int $column): Token
{
$lineStartOffset = $this->lineStartOffset;

while (! $this->isAtEnd() && isset(self::WHITESPACE_CHARS[$this->currentChar()])) {
$this->advance();
}
Expand All @@ -145,6 +159,7 @@ private function whitespaceToken(int $startOffset, int $line, int $column): Toke
$this->offset,
$line,
$column,
$lineStartOffset,
);
}

Expand All @@ -165,6 +180,7 @@ private function stringToken(int $startOffset, int $line, int $column): Token
$this->offset,
$line,
$column,
$this->lineStartOffset,
);
}

Expand Down Expand Up @@ -234,6 +250,7 @@ private function numberToken(int $startOffset, int $line, int $column): Token
$this->offset,
$line,
$column,
$this->lineStartOffset,
);
}

Expand All @@ -255,6 +272,7 @@ private function advance(): void
if ($char === "\r") {
$this->line++;
$this->column = 1;
$this->lineStartOffset = $this->offset;
$this->previousWasCarriageReturn = true;

return;
Expand All @@ -266,6 +284,7 @@ private function advance(): void
}

$this->column = 1;
$this->lineStartOffset = $this->offset;
$this->previousWasCarriageReturn = false;

return;
Expand Down
1 change: 1 addition & 0 deletions src/Parser/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public function __construct(
public int $endOffset,
public int $line,
public int $column,
public int $lineStartOffset = 0,
) {
}
}
Loading
Loading