Skip to content
Merged
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
122 changes: 113 additions & 9 deletions src/Parser/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,12 @@ private function parseDocument(): Document
} elseif ($token->is(TokenType::Newline)) {
$this->advance();
} elseif ($token->is(TokenType::Invalid)) {
$this->error("Invalid token: `{$token->value}`", $token->span);
$hint = $this->getInvalidTokenHint($token->value);
$this->error("Invalid token: `{$token->value}`", $token->span, $hint);
$this->synchronize();
} else {
$this->error("Unexpected token: `{$token->type->value}`", $token->span);
$hint = $this->getUnexpectedTokenHint($token);
$this->error("Unexpected token: `{$token->type->value}`", $token->span, $hint);
$this->synchronize();
}
}
Expand Down Expand Up @@ -201,9 +203,11 @@ private function parseKeyValue(): ?KeyValue
if ($value === null) {
$token = $this->current();
if ($token->is(TokenType::Invalid)) {
$this->error("Invalid token: `{$token->value}`", $token->span);
$hint = $this->getInvalidTokenHint($token->value);
$this->error("Invalid token: `{$token->value}`", $token->span, $hint);
} else {
$this->error('Expected value', $token->span);
$hint = $this->getExpectedValueHint($token);
$this->error('Expected value', $token->span, $hint);
}
$this->synchronize();

Expand Down Expand Up @@ -256,7 +260,7 @@ private function parseKey(): ?Key
} elseif ($token->is(TokenType::Integer)) {
// Integer-looking tokens can be bare keys when they are decimal integers.
if (preg_match('/^[+-]?\d[\d_]*$/', $token->value) !== 1) {
$this->error('Expected key', $token->span);
$this->error('Expected key', $token->span, 'Only decimal integers can be used as bare keys.');

return null;
}
Expand All @@ -265,7 +269,8 @@ private function parseKey(): ?Key
$this->advance();
} elseif ($token->is(TokenType::Invalid)) {
if (preg_match('/^[A-Za-z0-9_-]+$/', $token->value) !== 1) {
$this->error('Expected key', $token->span);
$hint = $this->getExpectedKeyHint($token);
$this->error('Expected key', $token->span, $hint);

return null;
}
Expand Down Expand Up @@ -303,7 +308,8 @@ private function parseKey(): ?Key
$styles[] = KeyStyle::Bare;
$this->advance();
} else {
$this->error('Expected key', $token->span);
$hint = $this->getExpectedKeyHint($token);
$this->error('Expected key', $token->span, $hint);

return null;
}
Expand Down Expand Up @@ -677,11 +683,95 @@ private function expect(TokenType $type): bool
return true;
}

$this->error("Expected {$type->value}", $this->current()->span);
$hint = $this->getExpectHint($type);
$this->error("Expected {$type->value}", $this->current()->span, $hint);

return false;
}

private function getExpectHint(TokenType $expected): ?string
{
$current = $this->current();

return match ($expected) {
TokenType::Equals => $this->getEqualsHint($current),
TokenType::RightBracket => 'Unclosed bracket. Table headers use [name] syntax.',
TokenType::RightBrace => 'Unclosed brace. Inline tables use { key = value } syntax.',
default => null,
};
}

private function getEqualsHint(Token $current): ?string
{
// YAML-style colon
if ($current->value === ':') {
return 'TOML uses `=` for key-value pairs, not `:`. Example: key = "value"';
}

// Missing equals - bare word after key
if ($current->is(TokenType::BareKey, TokenType::BasicString, TokenType::LiteralString)) {
return 'Key and value must be separated by `=`. Example: key = "value"';
}

return null;
}

private function getExpectedValueHint(Token $token): ?string
{
// Check for YAML-style booleans
$yamlBooleans = ['yes', 'no', 'on', 'off', 'y', 'n'];
if ($token->is(TokenType::BareKey) && in_array(strtolower($token->value), $yamlBooleans, true)) {
return "TOML booleans are `true` or `false`, not `{$token->value}`.";
}

// Check for unquoted string (bare key in value position)
if ($token->is(TokenType::BareKey)) {
return "Strings must be quoted in TOML. Did you mean `\"{$token->value}\"`?";
}

return null;
}

private function getInvalidTokenHint(string $value): ?string
{
// Check for semver-like patterns (e.g., 1.0.0, 2.1.3)
if (preg_match('/^\d+\.\d+\.\d+/', $value) === 1) {
return "This looks like a version string. Strings must be quoted: `\"{$value}\"`";
}

// Check for multiple dots in what looks like a number
if (preg_match('/^\d+\./', $value) === 1 && substr_count($value, '.') > 1) {
return 'Numbers can only have one decimal point. If this is a string, it must be quoted.';
}

return null;
}

private function getUnexpectedTokenHint(Token $token): ?string
{
// Equals without a key
if ($token->is(TokenType::Equals)) {
return 'A key is required before `=`. Example: key = "value"';
}

return null;
}

private function getExpectedKeyHint(Token $token): ?string
{
// Consecutive dots (empty key segment)
if ($token->is(TokenType::Dot)) {
return 'Empty key segment. Keys cannot have consecutive dots.';
}

// Check for special characters that aren't allowed in bare keys
if ($token->is(TokenType::Invalid) && preg_match('/[^A-Za-z0-9_-]/', $token->value) === 1) {
return 'Bare keys can only contain A-Za-z0-9_-. Use quotes for other characters.';
}

return null;
}

private function skipTrivia(): void
{
while ($this->check(TokenType::Whitespace) || $this->check(TokenType::Comment) || $this->check(TokenType::Newline)) {
Expand Down Expand Up @@ -736,6 +826,7 @@ private function checkTableHeaderTerminator(): void
private function checkKeyValueTerminator(): void
{
$token = $this->current();
$hintToken = $token;

// Direct valid terminators
if ($token->is(TokenType::Newline, TokenType::Eof, TokenType::Comment)) {
Expand All @@ -755,13 +846,26 @@ private function checkKeyValueTerminator(): void
if ($nextToken->is(TokenType::Newline, TokenType::Eof, TokenType::Comment)) {
return;
}
// Use the actual problematic token for the hint
$hintToken = $nextToken;
} else {
// End of tokens
return;
}
}

$this->error('Expected newline or end of input after value', $token->span);
$hint = $this->getKeyValueTerminatorHint($hintToken);
$this->error('Expected newline or end of input after value', $token->span, $hint);
}

private function getKeyValueTerminatorHint(Token $token): ?string
{
// Check if it looks like another key-value pair on the same line
if ($token->is(TokenType::BareKey, TokenType::BasicString, TokenType::LiteralString)) {
return 'Each key-value pair must be on its own line.';
}

return null;
}

private function skipTriviaInCollection(): void
Expand Down
Loading