diff --git a/src/Node/Block/CodeBlock.php b/src/Node/Block/CodeBlock.php index 3aa80a1..869a22f 100644 --- a/src/Node/Block/CodeBlock.php +++ b/src/Node/Block/CodeBlock.php @@ -9,9 +9,19 @@ */ class CodeBlock extends BlockNode { + /** + * @param string $content The code content + * @param string|null $language The language identifier (e.g., 'php', 'js') + * @param bool $showLineNumbers Whether to display line numbers + * @param int $lineNumberStart Starting line number (default 1) + * @param array $highlightLines Line numbers to highlight + */ public function __construct( protected string $content = '', protected ?string $language = null, + protected bool $showLineNumbers = false, + protected int $lineNumberStart = 1, + protected array $highlightLines = [], ) { } @@ -35,6 +45,42 @@ public function getLanguage(): ?string return $this->language; } + public function showLineNumbers(): bool + { + return $this->showLineNumbers; + } + + public function setShowLineNumbers(bool $show): void + { + $this->showLineNumbers = $show; + } + + public function getLineNumberStart(): int + { + return $this->lineNumberStart; + } + + public function setLineNumberStart(int $start): void + { + $this->lineNumberStart = $start; + } + + /** + * @return array + */ + public function getHighlightLines(): array + { + return $this->highlightLines; + } + + /** + * @param array $lines + */ + public function setHighlightLines(array $lines): void + { + $this->highlightLines = $lines; + } + public function getType(): string { return 'code_block'; diff --git a/src/Parser/Block/FencedBlockParser.php b/src/Parser/Block/FencedBlockParser.php index 284d8ca..805fdc0 100644 --- a/src/Parser/Block/FencedBlockParser.php +++ b/src/Parser/Block/FencedBlockParser.php @@ -208,4 +208,113 @@ public function removeIndent(string $line, int $indentLen): string return $line; } + + /** + * Parse code block info string for language, line numbers, and highlight lines. + * + * Syntax examples: + * - `php` - just language + * - `php #` - language with line numbers + * - `php #=5` - language with line numbers starting at 5 + * - `php {3,5-7}` - language with highlighted lines + * - `php # {3,5-7}` - language with line numbers and highlighted lines + * - `php #=5 {3,5-7}` - language with line numbers starting at 5 and highlighted lines + * - `#` - no language, just line numbers + * - `{3,5}` - no language, just highlighted lines + * + * @param string $info The info string after the opening fence + * + * @return array{language: string|null, showLineNumbers: bool, lineNumberStart: int, highlightLines: array} + */ + public function parseCodeBlockInfo(string $info): array + { + $result = [ + 'language' => null, + 'showLineNumbers' => false, + 'lineNumberStart' => 1, + 'highlightLines' => [], + ]; + + $info = trim($info); + if ($info === '') { + return $result; + } + + // Match the full pattern: [language] [#[=N]] [{lines}] + // Language: must start with word char or dot, can contain +, #, -, . (e.g., c++, c#, .net) + // Line numbers: # or #=N + // Highlight: {1,3-5,7} + $pattern = '/^(?[\w.][\w+#.-]*)?\s*(?#(?:=(?\d+))?)?(?:\s*\{(?[0-9,\-\s]+)\})?$/'; + + if (!preg_match($pattern, $info, $matches)) { + // If pattern doesn't match, treat entire info as language (backwards compatible) + $result['language'] = $info; + + return $result; + } + + // Extract language + if (!empty($matches['lang'])) { + $result['language'] = $matches['lang']; + } + + // Extract line numbers + if (!empty($matches['linenum'])) { + $result['showLineNumbers'] = true; + if (!empty($matches['start'])) { + $result['lineNumberStart'] = (int)$matches['start']; + } + } + + // Extract highlight lines + if (!empty($matches['highlight'])) { + $result['highlightLines'] = $this->parseHighlightLines($matches['highlight']); + } + + return $result; + } + + /** + * Parse highlight line specification into array of line numbers. + * + * @param string $spec Comma-separated list of line numbers and ranges (e.g., "1,3-5,7") + * + * @return array + */ + protected function parseHighlightLines(string $spec): array + { + $lines = []; + $parts = explode(',', $spec); + + foreach ($parts as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + + if (str_contains($part, '-')) { + // Range: 3-7 + [$start, $end] = explode('-', $part, 2); + $start = (int)trim($start); + $end = (int)trim($end); + if ($start > 0 && $end >= $start) { + for ($i = $start; $i <= $end; $i++) { + $lines[] = $i; + } + } + } else { + // Single line + $line = (int)$part; + if ($line > 0) { + $lines[] = $line; + } + } + } + + // Remove duplicates and sort + $lines = array_unique($lines); + sort($lines); + + return $lines; + } } diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php index 15afa6f..f5c7242 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -880,9 +880,16 @@ protected function tryParseCodeBlock(Node $parent, array $lines, int $start): ?i $this->addWarning('Unclosed code fence', $start, 1, true); } - $language = $info !== '' ? $info : null; - - $codeBlock = new CodeBlock(trim($content, "\n"), $language); + // Parse info string for language, line numbers, and highlight lines + $parsedInfo = $this->fencedBlockParser->parseCodeBlockInfo($info); + + $codeBlock = new CodeBlock( + trim($content, "\n"), + $parsedInfo['language'], + $parsedInfo['showLineNumbers'], + $parsedInfo['lineNumberStart'], + $parsedInfo['highlightLines'], + ); $this->applyPendingAttributes($codeBlock); $parent->appendChild($codeBlock); diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index 9eb6b57..1cc5014 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -480,6 +480,9 @@ protected function getPlainText(Node $node): string protected function renderCodeBlock(CodeBlock $node): string { $language = $node->getLanguage(); + $showLineNumbers = $node->showLineNumbers(); + $lineNumberStart = $node->getLineNumberStart(); + $highlightLines = $node->getHighlightLines(); $attrs = $this->renderAttributes($node); $code = $this->escape($node->getContent()); @@ -489,18 +492,104 @@ protected function renderCodeBlock(CodeBlock $node): string $code = str_replace("\t", str_repeat(' ', $this->codeBlockTabWidth), $code); } - // Add trailing newline inside code block (official djot behavior) - if ($code !== '' && !str_ends_with($code, "\n")) { - $code .= "\n"; + // Build pre classes + $preClasses = []; + if ($showLineNumbers) { + $preClasses[] = 'line-numbers'; + } + if ($highlightLines !== []) { + $preClasses[] = 'has-highlighted-lines'; + } + + // Build pre attributes + $preAttrs = $attrs; + if ($preClasses !== []) { + $preAttrs = $this->mergeClassAttribute($preAttrs, implode(' ', $preClasses)); + } + if ($showLineNumbers && $lineNumberStart !== 1) { + $preAttrs .= ' data-start="' . $lineNumberStart . '"'; + } + + // If we have line numbers or highlighting, wrap each line + if ($showLineNumbers || $highlightLines !== []) { + $code = $this->renderCodeWithLineWrappers($code, $lineNumberStart, $highlightLines); + } else { + // Add trailing newline inside code block (official djot behavior) + if ($code !== '' && !str_ends_with($code, "\n")) { + $code .= "\n"; + } } if ($language !== null) { $langClass = 'class="language-' . $this->escape($language) . '"'; - return '' . $code . "\n"; + return '' . $code . "\n"; + } + + return '' . $code . "\n"; + } + + /** + * Wrap each line in a span with optional highlighting. + * + * @param string $code The escaped code content + * @param int $startLine The starting line number + * @param array $highlightLines Lines to highlight + * + * @return string Code with line wrappers + */ + protected function renderCodeWithLineWrappers(string $code, int $startLine, array $highlightLines): string + { + $lines = explode("\n", $code); + + // Remove last empty line if present (we'll add it back at the end) + $hadTrailingNewline = end($lines) === ''; + if ($hadTrailingNewline) { + array_pop($lines); + } + + $result = []; + $lineNum = $startLine; + + foreach ($lines as $line) { + $classes = ['line']; + if (in_array($lineNum, $highlightLines, true)) { + $classes[] = 'highlighted'; + } + + $classAttr = implode(' ', $classes); + $result[] = '' . $line . ''; + $lineNum++; + } + + return implode("\n", $result) . "\n"; + } + + /** + * Merge a class into an existing attributes string. + * + * @param string $attrs Existing attributes string (may be empty or start with space) + * @param string $class Class(es) to add + * + * @return string Updated attributes string + */ + protected function mergeClassAttribute(string $attrs, string $class): string + { + if ($attrs === '') { + return ' class="' . $class . '"'; + } + + // Check if there's already a class attribute + if (preg_match('/\sclass="([^"]*)"/', $attrs, $matches)) { + // Append to existing class + $existingClass = $matches[1]; + $newClass = $existingClass . ' ' . $class; + + return (string)preg_replace('/\sclass="[^"]*"/', ' class="' . $newClass . '"', $attrs); } - return '' . $code . "\n"; + // No existing class, prepend new class attribute + return ' class="' . $class . '"' . $attrs; } protected function renderBlockQuote(BlockQuote $node): string diff --git a/tests/TestCase/Parser/BlockParserTest.php b/tests/TestCase/Parser/BlockParserTest.php index eda6268..745c1ec 100644 --- a/tests/TestCase/Parser/BlockParserTest.php +++ b/tests/TestCase/Parser/BlockParserTest.php @@ -532,4 +532,81 @@ public function testRawBlockTrimsLeadingAndTrailingBlankLines(): void $this->assertCount(1, $children); $this->assertSame('bold', $children[0]->getContent()); } + + public function testCodeBlockWithLineNumbers(): void + { + $doc = $this->parser->parse("```php #\necho 'hello';\n```"); + + $codeBlock = $doc->getChildren()[0]; + $this->assertInstanceOf(CodeBlock::class, $codeBlock); + $this->assertSame('php', $codeBlock->getLanguage()); + $this->assertTrue($codeBlock->showLineNumbers()); + $this->assertSame(1, $codeBlock->getLineNumberStart()); + } + + public function testCodeBlockWithLineNumbersStartOffset(): void + { + $doc = $this->parser->parse("```php #=5\necho 'hello';\n```"); + + $codeBlock = $doc->getChildren()[0]; + $this->assertInstanceOf(CodeBlock::class, $codeBlock); + $this->assertSame('php', $codeBlock->getLanguage()); + $this->assertTrue($codeBlock->showLineNumbers()); + $this->assertSame(5, $codeBlock->getLineNumberStart()); + } + + public function testCodeBlockWithHighlightLines(): void + { + $doc = $this->parser->parse("```php {2,4-6}\nline1\nline2\nline3\nline4\nline5\nline6\n```"); + + $codeBlock = $doc->getChildren()[0]; + $this->assertInstanceOf(CodeBlock::class, $codeBlock); + $this->assertSame('php', $codeBlock->getLanguage()); + $this->assertFalse($codeBlock->showLineNumbers()); + $this->assertSame([2, 4, 5, 6], $codeBlock->getHighlightLines()); + } + + public function testCodeBlockWithLineNumbersAndHighlight(): void + { + $doc = $this->parser->parse("```php # {2,4}\nline1\nline2\nline3\nline4\n```"); + + $codeBlock = $doc->getChildren()[0]; + $this->assertInstanceOf(CodeBlock::class, $codeBlock); + $this->assertSame('php', $codeBlock->getLanguage()); + $this->assertTrue($codeBlock->showLineNumbers()); + $this->assertSame([2, 4], $codeBlock->getHighlightLines()); + } + + public function testCodeBlockWithLineNumbersOffsetAndHighlight(): void + { + $doc = $this->parser->parse("```php #=10 {2,4}\nline1\nline2\nline3\nline4\n```"); + + $codeBlock = $doc->getChildren()[0]; + $this->assertInstanceOf(CodeBlock::class, $codeBlock); + $this->assertSame('php', $codeBlock->getLanguage()); + $this->assertTrue($codeBlock->showLineNumbers()); + $this->assertSame(10, $codeBlock->getLineNumberStart()); + $this->assertSame([2, 4], $codeBlock->getHighlightLines()); + } + + public function testCodeBlockLineNumbersOnlyNoLanguage(): void + { + $doc = $this->parser->parse("```#\ncode\n```"); + + $codeBlock = $doc->getChildren()[0]; + $this->assertInstanceOf(CodeBlock::class, $codeBlock); + $this->assertNull($codeBlock->getLanguage()); + $this->assertTrue($codeBlock->showLineNumbers()); + } + + public function testCodeBlockHighlightOnlyNoLanguage(): void + { + $doc = $this->parser->parse("```{1,3}\nline1\nline2\nline3\n```"); + + $codeBlock = $doc->getChildren()[0]; + $this->assertInstanceOf(CodeBlock::class, $codeBlock); + $this->assertNull($codeBlock->getLanguage()); + $this->assertFalse($codeBlock->showLineNumbers()); + $this->assertSame([1, 3], $codeBlock->getHighlightLines()); + } } diff --git a/tests/TestCase/Parser/FencedBlockParserTest.php b/tests/TestCase/Parser/FencedBlockParserTest.php new file mode 100644 index 0000000..1078a1b --- /dev/null +++ b/tests/TestCase/Parser/FencedBlockParserTest.php @@ -0,0 +1,146 @@ +parser = new FencedBlockParser(); + } + + public function testParseCodeBlockInfoLanguageOnly(): void + { + $result = $this->parser->parseCodeBlockInfo('php'); + + $this->assertSame('php', $result['language']); + $this->assertFalse($result['showLineNumbers']); + $this->assertSame(1, $result['lineNumberStart']); + $this->assertSame([], $result['highlightLines']); + } + + public function testParseCodeBlockInfoEmpty(): void + { + $result = $this->parser->parseCodeBlockInfo(''); + + $this->assertNull($result['language']); + $this->assertFalse($result['showLineNumbers']); + $this->assertSame(1, $result['lineNumberStart']); + $this->assertSame([], $result['highlightLines']); + } + + public function testParseCodeBlockInfoLineNumbers(): void + { + $result = $this->parser->parseCodeBlockInfo('php #'); + + $this->assertSame('php', $result['language']); + $this->assertTrue($result['showLineNumbers']); + $this->assertSame(1, $result['lineNumberStart']); + } + + public function testParseCodeBlockInfoLineNumbersWithOffset(): void + { + $result = $this->parser->parseCodeBlockInfo('php #=5'); + + $this->assertSame('php', $result['language']); + $this->assertTrue($result['showLineNumbers']); + $this->assertSame(5, $result['lineNumberStart']); + } + + public function testParseCodeBlockInfoHighlightLines(): void + { + $result = $this->parser->parseCodeBlockInfo('php {2,4-6}'); + + $this->assertSame('php', $result['language']); + $this->assertFalse($result['showLineNumbers']); + $this->assertSame([2, 4, 5, 6], $result['highlightLines']); + } + + public function testParseCodeBlockInfoLineNumbersAndHighlight(): void + { + $result = $this->parser->parseCodeBlockInfo('php # {2,4}'); + + $this->assertSame('php', $result['language']); + $this->assertTrue($result['showLineNumbers']); + $this->assertSame([2, 4], $result['highlightLines']); + } + + public function testParseCodeBlockInfoLineNumbersOffsetAndHighlight(): void + { + $result = $this->parser->parseCodeBlockInfo('php #=10 {2,4}'); + + $this->assertSame('php', $result['language']); + $this->assertTrue($result['showLineNumbers']); + $this->assertSame(10, $result['lineNumberStart']); + $this->assertSame([2, 4], $result['highlightLines']); + } + + public function testParseCodeBlockInfoLineNumbersOnlyNoLanguage(): void + { + $result = $this->parser->parseCodeBlockInfo('#'); + + $this->assertNull($result['language']); + $this->assertTrue($result['showLineNumbers']); + $this->assertSame(1, $result['lineNumberStart']); + } + + public function testParseCodeBlockInfoHighlightOnlyNoLanguage(): void + { + $result = $this->parser->parseCodeBlockInfo('{1,3}'); + + $this->assertNull($result['language']); + $this->assertFalse($result['showLineNumbers']); + $this->assertSame([1, 3], $result['highlightLines']); + } + + public function testParseCodeBlockInfoComplexLanguages(): void + { + // Test various language identifiers + $languages = ['c++', 'c#', 'f#', '.net', 'node.js']; + + foreach ($languages as $lang) { + $result = $this->parser->parseCodeBlockInfo($lang); + $this->assertSame($lang, $result['language'], "Failed for language: $lang"); + } + } + + public function testParseCodeBlockInfoHighlightRanges(): void + { + $result = $this->parser->parseCodeBlockInfo('{1-3, 5, 7-9}'); + + $this->assertSame([1, 2, 3, 5, 7, 8, 9], $result['highlightLines']); + } + + public function testParseCodeBlockInfoHighlightDuplicatesRemoved(): void + { + $result = $this->parser->parseCodeBlockInfo('{1,1,2,2-4}'); + + // Duplicates should be removed and sorted + $this->assertSame([1, 2, 3, 4], $result['highlightLines']); + } + + public function testParseCodeBlockInfoInvalidHighlightIgnored(): void + { + // Invalid values like 0 or negative should be ignored + $result = $this->parser->parseCodeBlockInfo('{0,1,-5,3}'); + + $this->assertSame([1, 3], $result['highlightLines']); + } + + public function testParseCodeBlockInfoBackwardsCompatibility(): void + { + // Info strings that don't match our pattern should be treated as language + $result = $this->parser->parseCodeBlockInfo('some weird info string'); + + $this->assertSame('some weird info string', $result['language']); + $this->assertFalse($result['showLineNumbers']); + $this->assertSame([], $result['highlightLines']); + } +} diff --git a/tests/TestCase/Renderer/HtmlRendererTest.php b/tests/TestCase/Renderer/HtmlRendererTest.php index 4e31d55..6928a1f 100644 --- a/tests/TestCase/Renderer/HtmlRendererTest.php +++ b/tests/TestCase/Renderer/HtmlRendererTest.php @@ -358,4 +358,87 @@ public function testGetCodeBlockTabWidth(): void $this->renderer->setCodeBlockTabWidth(null); $this->assertNull($this->renderer->getCodeBlockTabWidth()); } + + public function testCodeBlockWithLineNumbers(): void + { + $doc = new Document(); + $codeBlock = new CodeBlock("line1\nline2\nline3", 'php', true); + $doc->appendChild($codeBlock); + + $result = $this->renderer->render($doc); + + $this->assertStringContainsString('class="line-numbers"', $result); + $this->assertStringContainsString('class="line" data-line="1"', $result); + $this->assertStringContainsString('class="line" data-line="2"', $result); + $this->assertStringContainsString('class="line" data-line="3"', $result); + } + + public function testCodeBlockWithLineNumbersStartOffset(): void + { + $doc = new Document(); + $codeBlock = new CodeBlock("line1\nline2", 'php', true, 5); + $doc->appendChild($codeBlock); + + $result = $this->renderer->render($doc); + + $this->assertStringContainsString('class="line-numbers"', $result); + $this->assertStringContainsString('data-start="5"', $result); + $this->assertStringContainsString('data-line="5"', $result); + $this->assertStringContainsString('data-line="6"', $result); + } + + public function testCodeBlockWithHighlightedLines(): void + { + $doc = new Document(); + $codeBlock = new CodeBlock("line1\nline2\nline3", 'php', false, 1, [2]); + $doc->appendChild($codeBlock); + + $result = $this->renderer->render($doc); + + $this->assertStringContainsString('class="has-highlighted-lines"', $result); + $this->assertStringContainsString('class="line" data-line="1"', $result); + $this->assertStringContainsString('class="line highlighted" data-line="2"', $result); + $this->assertStringContainsString('class="line" data-line="3"', $result); + } + + public function testCodeBlockWithLineNumbersAndHighlight(): void + { + $doc = new Document(); + $codeBlock = new CodeBlock("line1\nline2\nline3", 'php', true, 1, [1, 3]); + $doc->appendChild($codeBlock); + + $result = $this->renderer->render($doc); + + $this->assertStringContainsString('class="line-numbers has-highlighted-lines"', $result); + $this->assertStringContainsString('class="line highlighted" data-line="1"', $result); + $this->assertStringContainsString('class="line" data-line="2"', $result); + $this->assertStringContainsString('class="line highlighted" data-line="3"', $result); + } + + public function testCodeBlockWithLineNumbersOffsetAndHighlight(): void + { + $doc = new Document(); + $codeBlock = new CodeBlock("line1\nline2\nline3", 'php', true, 10, [11]); + $doc->appendChild($codeBlock); + + $result = $this->renderer->render($doc); + + $this->assertStringContainsString('data-start="10"', $result); + $this->assertStringContainsString('class="line" data-line="10"', $result); + $this->assertStringContainsString('class="line highlighted" data-line="11"', $result); + $this->assertStringContainsString('class="line" data-line="12"', $result); + } + + public function testCodeBlockNoLineWrappersWithoutFeatures(): void + { + $doc = new Document(); + $codeBlock = new CodeBlock("line1\nline2", 'php'); + $doc->appendChild($codeBlock); + + $result = $this->renderer->render($doc); + + // Without line numbers or highlighting, no span wrappers + $this->assertStringNotContainsString('class="line"', $result); + $this->assertStringNotContainsString('data-line=', $result); + } }