diff --git a/CHANGELOG.md b/CHANGELOG.md index 953aaaa..3cf3092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [Unreleased] + +- Add `highlight()` and `annotateText()` for literal text annotations. + ## [0.7.0] - 2026-09-20 - Initial release. diff --git a/README.md b/README.md index 9d65b72..d0cbcb3 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/usage.md b/docs/usage.md index 50dc0bc..30af978 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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 diff --git a/src/CodeSnippet.php b/src/CodeSnippet.php index d07f7e9..a6bd0a3 100644 --- a/src/CodeSnippet.php +++ b/src/CodeSnippet.php @@ -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 $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; diff --git a/tests/TextAnnotationTest.php b/tests/TextAnnotationTest.php new file mode 100644 index 0000000..2992bd4 --- /dev/null +++ b/tests/TextAnnotationTest.php @@ -0,0 +1,119 @@ +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 + */ + 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]; + } +}