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
23 changes: 21 additions & 2 deletions src/Exception/ParseWarning.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public function __construct(
protected string $message,
protected int $line,
protected int $column = 1,
protected ?string $category = null,
protected ?string $suggestion = null,
) {
}

Expand All @@ -31,20 +33,37 @@ public function getColumn(): int
return $this->column;
}

public function getCategory(): ?string
{
return $this->category;
}

public function getSuggestion(): ?string
{
return $this->suggestion;
}

/**
* @return array{message: string, line: int, column: int}
* @return array{message: string, line: int, column: int, category: string|null, suggestion: string|null}
*/
public function toArray(): array
{
return [
'message' => $this->message,
'line' => $this->line,
'column' => $this->column,
'category' => $this->category,
'suggestion' => $this->suggestion,
];
}

public function __toString(): string
{
return sprintf('%s at line %d, column %d', $this->message, $this->line, $this->column);
$str = sprintf('%s at line %d, column %d', $this->message, $this->line, $this->column);
if ($this->category !== null) {
$str = "[{$this->category}] " . $str;
}

return $str;
}
}
78 changes: 72 additions & 6 deletions src/Parser/BlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ class BlockParser
*/
protected array $warnings = [];

/**
* References that have been used (for validation)
* Only populated when collectWarnings is true
*
* @var array<string, int> Maps reference label to line where used
*/
protected array $usedReferences = [];

/**
* Current line offset for nested parsing (0-indexed internally, 1-indexed for errors)
*/
Expand Down Expand Up @@ -280,8 +288,14 @@ public function clearWarnings(): self
*
* @throws \Djot\Exception\ParseException In strict mode for errors
*/
protected function addWarning(string $message, int $line, int $column = 1, bool $isError = false): void
{
protected function addWarning(
string $message,
int $line,
int $column = 1,
bool $isError = false,
?string $category = null,
?string $suggestion = null,
): void {
// Convert from 0-indexed to 1-indexed for user-facing messages
$line = $line + $this->lineOffset + 1;

Expand All @@ -290,7 +304,7 @@ protected function addWarning(string $message, int $line, int $column = 1, bool
}

if ($this->collectWarnings) {
$this->warnings[] = new ParseWarning($message, $line, $column);
$this->warnings[] = new ParseWarning($message, $line, $column, $category, $suggestion);
}
}

Expand All @@ -301,6 +315,7 @@ public function parse(string $input): Document
$this->abbreviations = [];
$this->pendingAttributes = [];
$this->warnings = [];
$this->usedReferences = [];
$this->lineOffset = 0;
$document = new Document();
$lines = $this->splitLines($input);
Expand All @@ -319,6 +334,11 @@ public function parse(string $input): Document
$document->appendChild($footnote);
}

// Validate references if warnings are enabled
if ($this->collectWarnings) {
$this->validateReferences();
}

return $document;
}

Expand Down Expand Up @@ -372,7 +392,7 @@ protected function extractReferences(array $lines): void
}
}

$this->references[$label] = new ReferenceDefinition($url, $pendingAttrs);
$this->references[$label] = new ReferenceDefinition($url, $pendingAttrs, $i);
$pendingAttrs = [];
$i = $j;

Expand Down Expand Up @@ -578,7 +598,7 @@ protected function extractHeadingReferences(array $lines): void
// Use normalized heading text as the label (for [Heading][] style links)
$label = preg_replace('/\s+/', ' ', trim($headingText)) ?? $headingText;
if (!isset($this->references[$label])) {
$this->references[$label] = new ReferenceDefinition('#' . $id, []);
$this->references[$label] = new ReferenceDefinition('#' . $id, [], $i);
}
} else {
// Non-heading, non-attribute line - clear pending ID
Expand Down Expand Up @@ -2681,11 +2701,50 @@ protected function splitLines(string $input): array
return explode("\n", str_replace(["\r\n", "\r"], "\n", $input));
}

/**
* Validate reference definitions vs usage
* Generates warnings for unused references.
* Note: Undefined references are warned about inline during parsing.
*/
protected function validateReferences(): void
{
// Check for unused reference definitions (defined but never used)
// Skip heading auto-references (URLs start with #)
// Skip footnote definitions (labels start with ^)
foreach ($this->references as $label => $def) {
if (
!isset($this->usedReferences[$label])
&& !str_starts_with($def->url, '#')
&& !str_starts_with($label, '^')
) {
$this->addWarning(
"Reference '{$label}' defined but never used",
$def->line,
1,
false,
'reference',
null,
);
}
}
}

public function getReference(string $label): ?ReferenceDefinition
{
return $this->references[$label] ?? null;
}

/**
* Mark a reference as used (for validation warnings)
* Only tracks when collectWarnings is enabled.
*/
public function markReferenceUsed(string $label, int $line): void
{
if ($this->collectWarnings && !isset($this->usedReferences[$label])) {
$this->usedReferences[$label] = $line;
}
}

public function hasFootnote(string $label): bool
{
return isset($this->footnotes[$label]);
Expand Down Expand Up @@ -2714,7 +2773,14 @@ public function getAbbreviation(string $abbr): ?string
*/
public function addUndefinedReferenceWarning(string $ref, int $line, int $column): void
{
$this->addWarning("Undefined reference '{$ref}'", $line, $column, false);
$this->addWarning(
"Undefined reference '{$ref}'",
$line,
$column,
false,
'reference',
"Define with [{$ref}]: url or use inline link",
);
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/Parser/InlineParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,9 @@ protected function parseLink(string $text, int $pos): ?array

$refDef = $this->blockParser->getReference($ref);
if ($refDef !== null) {
// Track reference usage for validation
$this->blockParser->markReferenceUsed($ref, $this->currentLine);

$link = new Link($refDef->url);
$this->parseInlines($link, $linkText);

Expand Down
2 changes: 2 additions & 0 deletions src/Parser/ReferenceDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ class ReferenceDefinition
/**
* @param string $url
* @param array<string, string> $attributes
* @param int $line Line number where reference was defined (0-indexed)
*/
public function __construct(
public readonly string $url,
public readonly array $attributes = [],
public readonly int $line = 0,
) {
}
}
101 changes: 101 additions & 0 deletions tests/TestCase/DjotConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2568,4 +2568,105 @@ public function testSingleFootnoteReferenceNoSuffix(): void
$this->assertStringContainsString('href="#fnref1"', $result);
$this->assertStringNotContainsString('<sup>1</sup></a> <a', $result);
}

public function testWarningCategory(): void
{
$converter = new DjotConverter(warnings: true);
$converter->convert('[Missing][ref]');

$warnings = $converter->getWarnings();
$this->assertCount(1, $warnings);
$this->assertSame('reference', $warnings[0]->getCategory());
}

public function testWarningSuggestion(): void
{
$converter = new DjotConverter(warnings: true);
$converter->convert('[Missing][myref]');

$warnings = $converter->getWarnings();
$this->assertCount(1, $warnings);
$this->assertNotNull($warnings[0]->getSuggestion());
$this->assertStringContainsString('[myref]:', $warnings[0]->getSuggestion());
}

public function testWarningToArrayIncludesNewFields(): void
{
$converter = new DjotConverter(warnings: true);
$converter->convert('[Missing][ref]');

$warnings = $converter->getWarnings();
$array = $warnings[0]->toArray();

$this->assertArrayHasKey('category', $array);
$this->assertArrayHasKey('suggestion', $array);
$this->assertSame('reference', $array['category']);
}

public function testWarningToStringIncludesCategory(): void
{
$converter = new DjotConverter(warnings: true);
$converter->convert('[Missing][ref]');

$warnings = $converter->getWarnings();
$string = (string)$warnings[0];

$this->assertStringContainsString('[reference]', $string);
}

public function testUnusedReferenceWarning(): void
{
$converter = new DjotConverter(warnings: true);
$converter->convert("Some text.\n\n[unused]: https://example.com");

$warnings = $converter->getWarnings();
$this->assertCount(1, $warnings);
$this->assertStringContainsString("Reference 'unused' defined but never used", $warnings[0]->getMessage());
$this->assertSame('reference', $warnings[0]->getCategory());
}

public function testNoUnusedWarningForUsedReference(): void
{
$converter = new DjotConverter(warnings: true);
$converter->convert("[Link text][myref]\n\n[myref]: https://example.com");

$this->assertFalse($converter->hasWarnings());
}

public function testNoUnusedWarningForHeadingAutoReferences(): void
{
$converter = new DjotConverter(warnings: true);
// Heading creates auto-reference but we don't warn if unused
$converter->convert("# My Heading\n\nSome text without link.");

$this->assertFalse($converter->hasWarnings());
}

public function testMultipleReferenceWarningTypes(): void
{
$converter = new DjotConverter(warnings: true);
$djot = <<<'DJOT'
[Link][undefined]

[unused]: https://example.com
DJOT;
$converter->convert($djot);

$warnings = $converter->getWarnings();
$this->assertCount(2, $warnings);

$messages = array_map(fn ($w) => $w->getMessage(), $warnings);
$undefinedFound = false;
$unusedFound = false;
foreach ($messages as $msg) {
if (str_contains($msg, 'Undefined')) {
$undefinedFound = true;
}
if (str_contains($msg, 'never used')) {
$unusedFound = true;
}
}
$this->assertTrue($undefinedFound, 'Expected undefined reference warning');
$this->assertTrue($unusedFound, 'Expected unused reference warning');
}
}