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
8 changes: 7 additions & 1 deletion src/Parser/BlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
use Djot\Parser\Utility\AttributeParser;
use Djot\Parser\Utility\IndentationHelper;
use Djot\Renderer\HeadingIdTracker;
use Djot\Util\StringUtil;

/**
* Block-level parser for Djot
Expand Down Expand Up @@ -642,7 +643,12 @@ protected function extractFootnotes(array $lines): void

// Match footnote definition: [^label]: content (requires whitespace after colon)
if (preg_match('/^\[\^([^\]]+)\]:(?:[ \t]+(.*))?[ \t]*$/', $line, $matches)) {
$label = $matches[1];
// The marker is matched against ONE line, so a definition label
// can never cross a line ending. It is normalized for the same
// reason a link reference definition is (see the `[label]: url`
// branch above): the reference side normalizes too, and the two
// have to meet on the same key.
$label = StringUtil::normalizeLabel($matches[1]);
$content = $matches[2] ?? '';

// Determine base indentation (2 spaces for footnotes)
Expand Down
12 changes: 7 additions & 5 deletions src/Parser/InlineParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use Djot\Node\Inline\Text;
use Djot\Node\Node;
use Djot\Parser\Utility\AttributeParser;
use Djot\Util\StringUtil;

/**
* Inline parser for Djot
Expand Down Expand Up @@ -1957,14 +1958,15 @@ protected function applyConsecutiveAttributes(Node $node, string $text, int $sta
*/
protected function parseFootnoteRef(string $text, int $pos): ?array
{
// A footnote label cannot cross a physical line. The definition marker
// is one line too, so accepting a newline here creates an identifier
// that no valid definition can bind (jgm/djot#208).
if (!preg_match('/\G\[\^([^\]\r\n]+)\]/', $text, $matches, 0, $pos)) {
// A footnote REFERENCE may cross a line ending, like a reference link.
// The label is normalized before lookup, so a reference a text editor
// has wrapped still binds to the one-line definition. The definition
// marker itself stays single-line; that half is the block parser's.
if (!preg_match('/\G\[\^([^\]]+)\]/', $text, $matches, 0, $pos)) {
return null;
}

$label = $matches[1];
$label = StringUtil::normalizeLabel($matches[1]);

// Warn if footnote is not defined
if (!$this->blockParser->hasFootnote($label)) {
Expand Down
20 changes: 20 additions & 0 deletions src/Util/StringUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,24 @@ public static function escapeHtml(string $value): string

return str_replace("\u{E000}", ' ', $escaped);
}

/**
* Normalize a reference or footnote label for lookup.
*
* Leading and trailing whitespace is removed and every internal run of
* whitespace becomes a single space, so a label a text editor has wrapped
* still matches a definition written on one line.
*
* The character class is exactly djot.js's `normalizeLabel`
* (`label.trim().replace(/[ \t\r\n]+/g, " ")`) rather than PHP's `\s`,
* which also covers form feed and vertical tab. That difference is
* observable: djot.js does not bind `[t][a<FF>b]` to `[a b]: url` and a
* `\s`-based normalizer does.
*/
public static function normalizeLabel(string $label): string
{
$collapsed = preg_replace('/[ \t\r\n]+/', ' ', $label) ?? $label;

return trim($collapsed, " \t\r\n");
}
}
71 changes: 0 additions & 71 deletions tests/TestCase/FootnoteLabelIsSingleLineTest.php

This file was deleted.

116 changes: 116 additions & 0 deletions tests/TestCase/FootnoteLabelNormalizationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

declare(strict_types=1);

namespace Djot\Test\TestCase;

use Djot\DjotConverter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* A footnote label is normalized before lookup, and only the DEFINITION marker
* is confined to one line.
*
* The two sides are deliberately asymmetric: a reference may be wrapped by an
* editor and still bind, while the definition marker stays a single-line
* construct the block parser can find without scanning ahead.
*/
class FootnoteLabelNormalizationTest extends TestCase
{
/**
* @return iterable<string, array{string}>
*/
public static function lineEndings(): iterable
{
yield 'LF' => ["\n"];
yield 'CRLF' => ["\r\n"];
yield 'CR' => ["\r"];
}

#[DataProvider('lineEndings')]
public function testAWrappedReferenceBindsToTheOneLineDefinition(string $ending): void
{
$source = "before[^two{$ending}words].\n\n[^two words]: note.\n";
$converter = new DjotConverter(warnings: true);

$html = $converter->convert($source);

self::assertStringContainsString('doc-noteref', $html);
self::assertStringContainsString('note.', $html);
self::assertSame([], array_filter(
$converter->getWarnings(),
static fn ($warning): bool => str_contains($warning->getMessage(), 'Undefined footnote'),
));
}

public function testAWrappedReferenceKeepsTheNormalizedLabelInTheAst(): void
{
$document = (new DjotConverter())->parse("see[^two\n words].\n\n[^two words]: note.\n");

$refs = [];
foreach ($document->getChildren()[0]->getChildren() as $node) {
if ($node->getType() === 'footnote_ref') {
$refs[] = $node;
}
}

self::assertCount(1, $refs);
self::assertSame('two words', $refs[0]->getLabel());
}

/**
* @return iterable<string, array{string, string}>
*/
public static function whitespaceVariants(): iterable
{
yield 'a run of spaces in the reference' => ['two words', 'two words'];
yield 'a tab in the reference' => ["two\twords", 'two words'];
yield 'padding around the reference' => [' two words ', 'two words'];
yield 'a run of spaces in the definition' => ['two words', 'two words'];
yield 'a tab in the definition' => ['two words', "two\twords"];
}

#[DataProvider('whitespaceVariants')]
public function testWhitespaceIsNormalizedOnBothSides(string $reference, string $definition): void
{
$html = (new DjotConverter())->convert("see[^{$reference}].\n\n[^{$definition}]: note.\n");

self::assertStringContainsString('doc-noteref', $html);
self::assertStringContainsString('note.', $html);
}

public function testADefinitionMarkerDoesNotCrossALineEnding(): void
{
$source = "see[^two words].\n\n[^two\nwords]: note.\n";
$converter = new DjotConverter();

$document = $converter->parse($source);

self::assertNotContains('footnote', array_map(
static fn ($node): string => $node->getType(),
$document->getChildren(),
));

// The line is a paragraph, so its own `[^two\nwords]` is an ordinary
// (unresolved) reference and the `: note.` tail stays visible text.
$html = $converter->convert($source);
self::assertStringContainsString(': note.', $html);
}

public function testAnUndefinedWrappedReferenceWarnsUnderItsNormalizedLabel(): void
{
$converter = new DjotConverter(warnings: true);
$converter->convert("see[^two\nwords].\n");

$messages = array_map(
static fn ($warning): string => $warning->getMessage(),
$converter->getWarnings(),
);

self::assertNotSame([], array_filter(
$messages,
static fn (string $message): bool => str_contains($message, 'two words'),
));
}
}
Loading