From ff2f58daf40fda2cabcc13e88c757c69f48a9f25 Mon Sep 17 00:00:00 2001 From: NickSdot Date: Tue, 21 Jul 2026 16:52:58 +0700 Subject: [PATCH] perf: optimised source range resolving --- src/Source/File.php | 73 +++++++++++++-------- src/Violation/SourceRange.php | 2 +- tests/Unit/Source/FileTest.php | 20 +++--- tests/Unit/Violation/AffectedRangesTest.php | 2 - 4 files changed, 57 insertions(+), 40 deletions(-) diff --git a/src/Source/File.php b/src/Source/File.php index 9f66a32..02f8be5 100644 --- a/src/Source/File.php +++ b/src/Source/File.php @@ -36,30 +36,9 @@ public function lines(): \Generator } /** @throws \OutOfBoundsException if the offset lies outside the source */ - public function lineAtOffset(int $offset): Line + public function lineNumberAtOffset(int $offset): int { - $sourceLength = strlen($this->content); - if ($offset < 0 || $offset > $sourceLength) { - throw new \OutOfBoundsException( - sprintf('Source offset %d is outside the valid range 0..%d.', $offset, $sourceLength), - ); - } - - $lineBeginOffsets = $this->lineBeginOffsets(); - $low = 0; - $high = count($lineBeginOffsets) - 1; - - while ($low < $high) { - $middle = intdiv($low + $high + 1, 2); - - if ($lineBeginOffsets[$middle] <= $offset) { - $low = $middle; - } else { - $high = $middle - 1; - } - } - - return $this->createLineAtOffset($low + 1, $lineBeginOffsets[$low]); + return $this->lineIndexAtOffset($offset, $this->lineBeginOffsets()) + 1; } public function withContent(string $content): self @@ -77,18 +56,58 @@ private function lineBeginOffsets(): array } $lineBeginOffsets = [0]; + $sourceLength = strlen($this->content); + $lineBeginOffset = 0; + + while ($lineBeginOffset < $sourceLength) { + $lineLength = strcspn($this->content, "\r\n", $lineBeginOffset); + $lineEndingOffset = $lineBeginOffset + $lineLength; - foreach ($this->lines() as $line) { - if ($line->number === 1) { - continue; + if ($lineEndingOffset === $sourceLength) { + break; } - $lineBeginOffsets[] = $line->beginOffset; + $lineBeginOffset = $lineEndingOffset + ( + $this->content[$lineEndingOffset] === "\r" + && ($this->content[$lineEndingOffset + 1] ?? null) === "\n" + ? 2 + : 1 + ); + $lineBeginOffsets[] = $lineBeginOffset; } return $this->lineBeginOffsets = $lineBeginOffsets; } + /** + * @param non-empty-list $lineBeginOffsets + * @throws \OutOfBoundsException if the offset lies outside the source + */ + private function lineIndexAtOffset(int $offset, array $lineBeginOffsets): int + { + $sourceLength = strlen($this->content); + if ($offset < 0 || $offset > $sourceLength) { + throw new \OutOfBoundsException( + sprintf('Source offset %d is outside the valid range 0..%d.', $offset, $sourceLength), + ); + } + + $low = 0; + $high = count($lineBeginOffsets) - 1; + + while ($low < $high) { + $middle = intdiv($low + $high + 1, 2); + + if ($lineBeginOffsets[$middle] <= $offset) { + $low = $middle; + } else { + $high = $middle - 1; + } + } + + return $low; + } + private function createLineAtOffset(int $lineNumber, int $lineBeginOffset): Line { $sourceLength = strlen($this->content); diff --git a/src/Violation/SourceRange.php b/src/Violation/SourceRange.php index 3a062fb..9451107 100644 --- a/src/Violation/SourceRange.php +++ b/src/Violation/SourceRange.php @@ -15,7 +15,7 @@ public static function fromFile(File $file, int $beginOffset, int $untilOffset): self { return new self( - line: $file->lineAtOffset($beginOffset)->number, + line: $file->lineNumberAtOffset($beginOffset), beginOffset: $beginOffset, untilOffset: $untilOffset, content: substr($file->content, $beginOffset, $untilOffset - $beginOffset), diff --git a/tests/Unit/Source/FileTest.php b/tests/Unit/Source/FileTest.php index ad7a524..7cca8e3 100644 --- a/tests/Unit/Source/FileTest.php +++ b/tests/Unit/Source/FileTest.php @@ -39,17 +39,17 @@ public function itRepresentsLinesAndTheirEndings(): void } #[Test] - public function itResolvesOffsetsToLines(): void + public function itResolvesOffsetsToLineNumbers(): void { $file = new File('file.xml', "one\r\ntwo\nthree"); - self::assertSame(1, $file->lineAtOffset(0)->number); - self::assertSame(1, $file->lineAtOffset(3)->number); - self::assertSame(1, $file->lineAtOffset(4)->number); - self::assertSame(2, $file->lineAtOffset(5)->number); - self::assertSame(2, $file->lineAtOffset(8)->number); - self::assertSame(3, $file->lineAtOffset(9)->number); - self::assertSame(3, $file->lineAtOffset(14)->number); + self::assertSame(1, $file->lineNumberAtOffset(0)); + self::assertSame(1, $file->lineNumberAtOffset(3)); + self::assertSame(1, $file->lineNumberAtOffset(4)); + self::assertSame(2, $file->lineNumberAtOffset(5)); + self::assertSame(2, $file->lineNumberAtOffset(8)); + self::assertSame(3, $file->lineNumberAtOffset(9)); + self::assertSame(3, $file->lineNumberAtOffset(14)); } #[Test] @@ -62,7 +62,7 @@ public function itIncludesAnEmptyLineAfterTheFinalLineEnding(): void new Line(2, '', '', 4), ], iterator_to_array($file->lines())); - self::assertSame(2, $file->lineAtOffset(4)->number); + self::assertSame(2, $file->lineNumberAtOffset(4)); } #[Test] @@ -91,6 +91,6 @@ public function itRejectsOffsetsOutsideTheSource(): void $file = new File('file.xml', 'one'); $this->expectException(\OutOfBoundsException::class); - $file->lineAtOffset(4); + $file->lineNumberAtOffset(4); } } diff --git a/tests/Unit/Violation/AffectedRangesTest.php b/tests/Unit/Violation/AffectedRangesTest.php index 7c698ba..d9c050d 100644 --- a/tests/Unit/Violation/AffectedRangesTest.php +++ b/tests/Unit/Violation/AffectedRangesTest.php @@ -5,7 +5,6 @@ namespace DocbookCS\Tests\Unit\Violation; use DocbookCS\Source\File; -use DocbookCS\Source\Line; use DocbookCS\Violation\Severity; use DocbookCS\Violation\SourceRange; use DocbookCS\Violation\Violation; @@ -20,7 +19,6 @@ CoversClass(Violation::class), // UsesClass(File::class), - UsesClass(Line::class), ] final class AffectedRangesTest extends TestCase {