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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## [Unreleased]

- Add `highlight()` and `annotateText()` for literal text annotations.

## [0.7.0] - 2026-09-20

- Initial release.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ $annotated = $snippet->annotate(
Annotations may overlap or cross line breaks. Each `CodeLine` clips and shifts them to its own
content. `segments()` derives contiguous text regions with stable annotation sets.

When the caller knows the text instead of its byte offsets, `highlight('sum')` adds `focus`
annotations to every literal match. `annotateText()` accepts another type, optional data, and a
one-based occurrence number.

## Slicing and indentation

`slice()` projects an already annotated snippet onto a half-open byte range:
Expand Down
23 changes: 23 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,29 @@ $annotated = $snippet->annotate(

Snippet offsets are relative to `code()`. `CodeLine::annotations()` clips an
annotation to the line and shifts its offset to the beginning of that line.
Use `highlight()` when callers know the text rather than its byte offset:

```php
$snippet = CodeSnippet::fromCode('sum + sum')->highlight('sum');
```

Both occurrences receive a `focus` annotation. Pass `occurrence: 2` to target
only the second match. For another annotation type or additional data, use
`annotateText()`:

```php
$snippet = $snippet->annotateText(
'sum',
'warning',
['label' => 'Check this value'],
occurrence: 1,
);
```

Matches are literal, case-sensitive, non-overlapping byte ranges. Missing text
leaves the snippet unchanged; empty text, an empty type, or a non-positive
occurrence raises `InvalidArgumentException`.

Annotations may overlap or cross line breaks.

```text
Expand Down
52 changes: 52 additions & 0 deletions src/CodeSnippet.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,58 @@ public function isLineSelected(int $line): bool
return in_array($line, $this->selectedLines, true);
}

/**
* Highlight literal text with focus annotations, optionally at one occurrence.
*/
public function highlight(string $text, ?int $occurrence = null): self
{
return $this->annotateText($text, 'focus', occurrence: $occurrence);
}

/**
* Annotate case-sensitive, non-overlapping literal matches.
*
* A null occurrence selects all matches; otherwise occurrences are one-based.
* Missing text or occurrences leave the snippet unchanged.
*
* @param array<string, mixed> $data
*/
public function annotateText(string $text, string $type, array $data = [], ?int $occurrence = null): self
{
if ('' === $text) {
throw new \InvalidArgumentException('The annotation text cannot be empty.');
}

if (null !== $occurrence && $occurrence < 1) {
throw new \InvalidArgumentException('The text occurrence must be positive.');
}

if ('' === trim($type)) {
throw new \InvalidArgumentException('The annotation type cannot be empty.');
}

$length = strlen($text);
$offset = 0;
$match = 0;
$annotations = [];

while (false !== ($offset = strpos($this->code, $text, $offset))) {
++$match;

if (null === $occurrence || $occurrence === $match) {
$annotations[] = new CodeAnnotation($offset, $length, $type, $data);

if (null !== $occurrence) {
break;
}
}

$offset += $length;
}

return [] === $annotations ? $this : $this->annotate(...$annotations);
}

public function annotate(CodeAnnotation ...$annotations): self
{
$all = $this->annotations;
Expand Down
119 changes: 119 additions & 0 deletions tests/TextAnnotationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

declare(strict_types=1);

/*
* This file is part of the ALTO library.
*
* © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
*/

namespace Alto\Code\Snippet\Tests;

use Alto\Code\Snippet\CodeAnnotation;
use Alto\Code\Snippet\CodeSnippet;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class TextAnnotationTest extends TestCase
{
public function testHighlightPreservesCodeMetadataAndExistingAnnotations(): void
{
$source = CodeSnippet::fromCode('sum + sum', 'php', 'test.php', 12)
->selectLines(1)
->annotate(new CodeAnnotation(0, 3, 'syntax'));
$snippet = $source->highlight('sum');

self::assertSame('sum + sum', $snippet->code());
self::assertSame($source->language(), $snippet->language());
self::assertSame('test.php', $snippet->sourceName());
self::assertSame(12, $snippet->startLine());
self::assertSame([1], $snippet->selectedLines());
self::assertCount(1, $source->annotations());
self::assertEquals([
new CodeAnnotation(0, 3, 'focus'),
new CodeAnnotation(0, 3, 'syntax'),
new CodeAnnotation(6, 3, 'focus'),
], $snippet->annotations());
self::assertEquals($snippet->toArray(), $snippet->highlight('sum')->toArray());
}

public function testItCanTargetOneOccurrenceAndAttachCustomData(): void
{
$source = CodeSnippet::fromCode('sum sum sum');
self::assertEquals([new CodeAnnotation(4, 3, 'focus')], $source->highlight('sum', occurrence: 2)->annotations());
self::assertEquals([new CodeAnnotation(8, 3, 'warning', ['label' => 'Check this'])], $source->annotateText(
'sum',
'warning',
['label' => 'Check this'],
occurrence: 3,
)->annotations());
self::assertEquals([new CodeAnnotation(0, 3, 'focus')], $source->highlight('sum', occurrence: 1)->annotations());
}

public function testMissingMatchesLeaveTheSnippetUnchanged(): void
{
$source = CodeSnippet::fromCode('sum');
self::assertSame($source, $source->highlight('SUM'));
self::assertSame($source, $source->highlight('sum', occurrence: 2));
$empty = CodeSnippet::fromCode('');
self::assertSame($empty, $empty->highlight('sum'));
}

public function testMatchesAreLiteralNonOverlappingSubstrings(): void
{
self::assertEquals([
new CodeAnnotation(0, 2, 'focus'),
new CodeAnnotation(2, 2, 'focus'),
], CodeSnippet::fromCode('aaaaa')->highlight('aa')->annotations());
self::assertEquals([new CodeAnnotation(2, 2, 'focus')], CodeSnippet::fromCode('x .* y')->highlight('.*')->annotations());
self::assertEquals([new CodeAnnotation(0, 3, 'focus')], CodeSnippet::fromCode('summary')->highlight('sum')->annotations());
}

public function testUnicodeRangesRemainAlignedThroughIndentationAndSlicing(): void
{
$source = CodeSnippet::fromCode(" café();\r\n café();", startLine: 10)->highlight('café');
self::assertEquals([
new CodeAnnotation(4, 5, 'focus'),
new CodeAnnotation(18, 5, 'focus'),
], $source->annotations());

$snippet = $source->dedent()->indent(2);
self::assertSame(" café();\r\n café();", $snippet->code());
foreach ($snippet->annotations() as $annotation) {
self::assertSame('café', substr($snippet->code(), $annotation->offset, $annotation->length));
}
$slice = $snippet->slice(12, strlen($snippet->code()));
self::assertSame(11, $slice->startLine());
self::assertEquals([new CodeAnnotation(2, 5, 'focus')], $slice->annotations());
}

public function testItSupportsMultilineText(): void
{
$snippet = CodeSnippet::fromCode("first\r\nsecond")->highlight("st\r\nse");
self::assertEquals([new CodeAnnotation(3, 6, 'focus')], $snippet->annotations());
self::assertEquals([new CodeAnnotation(3, 2, 'focus')], $snippet->lines()[0]->annotations());
self::assertEquals([new CodeAnnotation(0, 2, 'focus')], $snippet->lines()[1]->annotations());
}

#[DataProvider('invalidArguments')]
public function testItRejectsInvalidArgumentsEvenWhenNothingMatches(string $text, string $type, ?int $occurrence): void
{
$this->expectException(\InvalidArgumentException::class);
CodeSnippet::fromCode('')->annotateText($text, $type, occurrence: $occurrence);
}

/**
* @return iterable<string, array{string, string, int|null}>
*/
public static function invalidArguments(): iterable
{
yield 'empty text' => ['', 'focus', null];
yield 'empty type' => ['sum', ' ', null];
yield 'zero occurrence' => ['sum', 'focus', 0];
yield 'negative occurrence' => ['sum', 'focus', -1];
}
}
Loading