diff --git a/CHANGELOG.md b/CHANGELOG.md index f631cb1..1e8049c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [Unreleased] + +- Add inclusive and exclusive literal line-boundary selectors. + ## [0.7.0] - 2026-09-20 - Extract immutable source-code slices by line, text, or language structure. diff --git a/README.md b/README.md index b9bb25b..5e5d953 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,14 @@ $slice = $source->slice() ->before('// example:end'); ``` +Line-boundary selectors include or exclude complete matching lines: + +```php +$slice = $source->slice() + ->afterLine('') + ->beforeLine(''); +``` + Missing boundaries and attempts to expand a slice throw explicit exceptions rather than returning an approximate result. diff --git a/docs/languages/index.md b/docs/languages/index.md index ca41e08..dcaa548 100644 --- a/docs/languages/index.md +++ b/docs/languages/index.md @@ -11,6 +11,8 @@ and literal-text selection. ## Text and lines +[Text](text.md) covers line ranges and literal line boundaries for HTML, SVG, YAML, Markdown, environment files, and unknown source types. + HTML, SVG, YAML, Markdown, environment files, and unknown source types remain ordinary text. Select an inclusive range of original lines with `lines()`, or chain `after()` and `before()` around known literal boundaries. These selectors diff --git a/docs/languages/text.md b/docs/languages/text.md new file mode 100644 index 0000000..49ac519 --- /dev/null +++ b/docs/languages/text.md @@ -0,0 +1,259 @@ +# Text + +Use original line numbers for a fixed range, or literal source text for known +boundaries. Prefer a structural selector when a supported declaration has a +stable name. HTML, SVG, YAML, Markdown, +and environment files can all be sliced this way; no comment markers need to be added. + +| Selector | Boundary behavior | +| --- | --- | +| `fromLine($text)` | Start at the first matching line, including it. | +| `throughLine($text)` | End at the first matching line, including its content. | +| `afterLine($text)` | Start after the first matching line. | +| `beforeLine($text)` | End before the first matching line. | + +All four search case-sensitive substrings inside the current slice. They do not match nested +HTML elements, YAML mappings, or Markdown sections structurally. Use `method()`, `rule()`, or +`block()` when the source language provides them. + +The PHP selections below assume [installation](../installation.md) is complete +and Composer's autoloader is loaded. Save each input under the filename shown before running its PHP selection. +Choose these selectors when you know the exact text or original line numbers. + +## Select exact source lines + +This standalone example selects one original source line without relying on +language detection: + +```php +lines(2, 2)->content(), "\n"; +``` + +Save it beside `vendor`, then run it with PHP. It prints: + +```text +second +``` + +Use `after()` and `before()` for boundaries within a line; use the line helpers +below when the matching lines themselves should be included or excluded. + +## HTML: include a complete form + +**Source: `checkout.html`** + +```html +
+
+ + +
+

Payment is collected on confirmation.

+
+``` + +**Selection** + +```php +use Alto\Code\Slicer\CodeSource; + +$slice = CodeSource::fromFile('checkout.html')->slice() + ->fromLine('
throughLine('
'); +echo $slice->content(); +``` + +**Result** + +```html +
+ + +
+``` + +To keep only its contents, exclude the matching tag lines: + +**Selection** + +```php +use Alto\Code\Slicer\CodeSource; + +$slice = CodeSource::fromFile('checkout.html')->slice() + ->afterLine('
beforeLine('
'); +echo $slice->content(); +``` + +**Result** + +```html + + +``` + +## SVG: select a group + +**Source: `confirmed.svg`** + +```svg + + Order confirmed + + + + + +``` + +**Selection** + +```php +use Alto\Code\Slicer\CodeSource; + +$slice = CodeSource::fromFile('confirmed.svg')->slice() + ->fromLine('throughLine(''); +echo $slice->content(); +``` + +**Result** + +```svg + + + + +``` + +This group has no nested `g`. With nested groups, `throughLine('')` would stop at the first +closing line, so it cannot stand in for an element selector. + +## YAML: select known settings + +**Source: `checkout.yaml`** + +```yaml +app: + name: Checkout + currency: EUR +services: + App\Checkout: + arguments: + $taxRate: 1.2 + $currency: '%app.currency%' +receipt: + subject: Your order is confirmed +``` + +**Selection** + +```php +use Alto\Code\Slicer\CodeSource; + +$slice = CodeSource::fromFile('checkout.yaml')->slice() + ->fromLine('services:') + ->beforeLine('receipt:'); +echo $slice->content(); +``` + +**Result** + +```yaml +services: + App\Checkout: + arguments: + $taxRate: 1.2 + $currency: '%app.currency%' +``` + +The keys are text boundaries. This selection does not infer YAML indentation or resolve a key path. + +## Markdown: select a section between headings + +**Source: `checkout.md`** + +```markdown +# Checkout + +## Installation + +Install the package with Composer. + +## Usage + +Load your source and select a method. +``` + +**Selection** + +```php +use Alto\Code\Slicer\CodeSource; + +$slice = CodeSource::fromFile('checkout.md')->slice() + ->fromLine('## Installation') + ->beforeLine('## Usage'); +echo $slice->content(); +``` + +**Result** + +```markdown +## Installation + +Install the package with Composer. + +``` + +The blank line before the next heading is preserved. Both heading texts are supplied explicitly. + +## Environment files: select variable assignments + +**Source: `.env.example`** + +```dotenv +APP_ENV=dev +APP_NAME="Checkout demo" +APP_URL=http://localhost:8000 +ASSET_URL=${APP_URL}/assets +MAILER_DSN=null://null +``` + +**Selection** + +```php +use Alto\Code\Slicer\CodeSource; + +$slice = CodeSource::fromFile('.env.example', 'dotenv')->slice() + ->fromLine('APP_NAME=') + ->throughLine('ASSET_URL='); +echo $slice->content(); +``` + +**Result** + +```dotenv +APP_NAME="Checkout demo" +APP_URL=http://localhost:8000 +ASSET_URL=${APP_URL}/assets +``` + +The source is read as text. Environment variables are not loaded or expanded. + +## Exact lines and text within a line + +`lines(4, 8)` selects inclusive line numbers from the original file. `after('return ')` and +`before(';')` cut at exact text inside a line and exclude that text. These methods also work +when the source language is unknown. + +Every selection retains the original bytes and source line numbers. Empty or multiline search +text is rejected by the line helpers; missing text throws `SourceTextNotFound`. + +See the [public API](../selectors.md) or return to the [language guides](index.md). diff --git a/docs/selectors.md b/docs/selectors.md index 12c5cef..84bcae9 100644 --- a/docs/selectors.md +++ b/docs/selectors.md @@ -43,12 +43,16 @@ case-sensitive occurrence fully contained in the current slice. | `lines(int $start, int $end): CodeSlice` | Select inclusive original source lines. Their complete range must fit inside the current slice. | | `after(string $text): CodeSlice` | Keep bytes after the matched text; exclude the match. | | `before(string $text): CodeSlice` | Keep bytes before the matched text; exclude the match. | +| `fromLine(string $text): CodeSlice` | Start at the first line containing the text; include that line. | +| `throughLine(string $text): CodeSlice` | End at the first line containing the text; include that line. | +| `afterLine(string $text): CodeSlice` | Start after the first line containing the text. | +| `beforeLine(string $text): CodeSlice` | End before the first line containing the text. | `lines()` throws `InvalidSourceRange` for invalid line numbers or a range outside the current slice. Text selectors throw `SourceTextNotFound` when no complete match is found. Empty search text throws `InvalidArgumentException`. -See [Languages](languages/index.md#text-and-lines) for a complete chained example. +See [Text](languages/text.md) for complete HTML, SVG, YAML, Markdown, and environment-file examples. ## Select declarations diff --git a/src/CodeSlice.php b/src/CodeSlice.php index be180ec..3016a22 100644 --- a/src/CodeSlice.php +++ b/src/CodeSlice.php @@ -84,6 +84,69 @@ public function before(string $text): self return $this->withRange(new SourceRange($this->range->start, $position)); } + /** + * Keep the content after the first line containing the text, excluding its line ending. + */ + public function afterLine(string $text): self + { + $line = $this->matchingLine($text); + $start = $line < $this->source->lineCount() + ? $this->source->rangeForLines($line + 1, $line + 1)->start + : $this->range->end; + + return $this->withRange(new SourceRange(min($start, $this->range->end), $this->range->end)); + } + + /** + * Keep the content before the first line containing the text, excluding the preceding line ending. + */ + public function beforeLine(string $text): self + { + $line = $this->matchingLine($text); + $end = $line > 1 + ? $this->source->rangeForLines($line - 1, $line - 1)->end + : $this->range->start; + + return $this->withRange(new SourceRange($this->range->start, max($end, $this->range->start))); + } + + /** + * Keep the content from the first line containing the text, including that line. + */ + public function fromLine(string $text): self + { + $line = $this->matchingLine($text); + $start = $this->source->rangeForLines($line, $line)->start; + + return $this->withRange(new SourceRange(max($start, $this->range->start), $this->range->end)); + } + + /** + * Keep the content through the first line containing the text, excluding its line ending. + */ + public function throughLine(string $text): self + { + $line = $this->matchingLine($text); + $end = $this->source->rangeForLines($line, $line)->end; + + return $this->withRange(new SourceRange($this->range->start, min($end, $this->range->end))); + } + + private function matchingLine(string $text): int + { + if ('' === $text || str_contains($text, "\n") || str_contains($text, "\r")) { + throw new \InvalidArgumentException('The searched text must be non-empty and contain no line breaks.'); + } + + $position = strpos($this->source->content(), $text, $this->range->start); + + if (false === $position || $position + strlen($text) > $this->range->end) { + throw SourceTextNotFound::forText($text); + } + + return $this->source->lineAtOffset($position); + } + public function beforeNextClass(): self { $class = $this->classes()->nextClass($this->range); diff --git a/tests/LineBoundaryTest.php b/tests/LineBoundaryTest.php new file mode 100644 index 0000000..ce59436 --- /dev/null +++ b/tests/LineBoundaryTest.php @@ -0,0 +1,199 @@ +', + ' ', + '

Order

', + '', + '

Confirmed

', + '
', + '', + ]); + $source = CodeSource::fromString($code, name: 'checkout.html.twig'); + $original = $source->slice(); + $slice = $original->fromLine('')->throughLine(''); + + self::assertSame(implode($eol, [ + ' ', + '

Order

', + '', + '

Confirmed

', + '
', + ]), $slice->content()); + self::assertSame(2, $slice->startLine()); + self::assertSame(6, $slice->endLine()); + self::assertSame($source, $slice->source()); + self::assertSame($source->rangeForLines(2, 6)->start, $slice->range()->start); + self::assertSame($source->rangeForLines(2, 6)->end, $slice->range()->end); + self::assertSame($code, $original->content()); + } + } + + public function testInclusiveSearchUsesTheFirstMatchInsideTheSlice(): void + { + $source = CodeSource::fromString("call();\nfirst\ncall();\nsecond\ncall();\nlast"); + $slice = $source->lines(2, 6); + + self::assertSame("call();\nsecond\ncall();\nlast", $slice->fromLine('call()')->content()); + self::assertSame("first\ncall();", $slice->throughLine('call()')->content()); + self::assertSame(3, $slice->fromLine('call()')->startLine()); + } + + public function testInclusiveBoundariesCanSelectOneLineIncludingAtFileEdges(): void + { + foreach (['', "\n", "\r\n", "\r"] as $eol) { + $source = CodeSource::fromString(' ' . $eol); + $slice = $source->slice()->fromLine('throughLine('/>'); + + self::assertSame(' ', $slice->content()); + self::assertSame(1, $slice->lineCount()); + } + } + + public function testInclusiveBoundariesPreserveUtf8AndMixedLineEndings(): void + { + $slice = CodeSource::fromString("préface\r\n\tété();\n\n\tfin();\rsuffix") + ->slice()->fromLine('été()')->throughLine('fin()'); + + self::assertSame("\tété();\n\n\tfin();", $slice->content()); + self::assertSame(2, $slice->startLine()); + self::assertSame(4, $slice->endLine()); + } + + public function testMarkersDoNotRequireCommentSyntaxIndentationOrLineEndings(): void + { + foreach (["\n", "\r\n", "\r"] as $eol) { + foreach (['', '// %s', '# %s', '{# %s #}', '/* %s */'] as $comment) { + $code = implode($eol, [ + 'outside', + ' ' . sprintf($comment, 'checkout:start'), + ' first();', + '', + ' second();', + "\t" . sprintf($comment, 'checkout:end'), + 'outside', + ]); + $source = CodeSource::fromString($code, name: 'example'); + $original = $source->slice(); + $slice = $original->afterLine('checkout:start')->beforeLine('checkout:end'); + + self::assertSame(' first();' . $eol . $eol . ' second();', $slice->content()); + self::assertSame(3, $slice->startLine()); + self::assertSame(5, $slice->endLine()); + self::assertSame($source, $slice->source()); + self::assertSame(strpos($code, ' first();'), $slice->range()->start); + self::assertSame($code, $original->content()); + } + } + } + + public function testSearchUsesTheFirstMatchWithinTheCurrentSlice(): void + { + $source = CodeSource::fromString("marker\nfirst\nmarker\nsecond\nmarker\nlast"); + $slice = $source->lines(2, 6); + + self::assertSame('first', $slice->beforeLine('marker')->content()); + self::assertSame("second\nmarker\nlast", $slice->afterLine('marker')->content()); + self::assertSame(4, $slice->afterLine('marker')->startLine()); + } + + public function testMarkersAtTheEdgesCanProduceEmptySlices(): void + { + foreach (['marker', "marker\n", "marker\r\n", "marker\r"] as $code) { + foreach (['afterLine', 'beforeLine'] as $method) { + $slice = CodeSource::fromString($code)->slice()->{$method}('marker'); + + self::assertSame('', $slice->content()); + self::assertSame(0, $slice->lineCount()); + } + } + + $slice = CodeSource::fromString("start\nend")->slice()->afterLine('start')->beforeLine('end'); + self::assertSame('', $slice->content()); + } + + public function testPartialLinesNeverExpandTheCurrentSlice(): void + { + $slice = CodeSource::fromString("prefix marker suffix\nlast") + ->slice()->after('prefix ')->before(' suffix'); + + self::assertSame('', $slice->beforeLine('marker')->content()); + self::assertSame($slice->range()->start, $slice->beforeLine('marker')->range()->end); + self::assertSame('', $slice->afterLine('marker')->content()); + self::assertSame($slice->range()->end, $slice->afterLine('marker')->range()->start); + self::assertSame('marker', $slice->fromLine('marker')->content()); + self::assertSame('marker', $slice->throughLine('marker')->content()); + self::assertEquals($slice->range(), $slice->fromLine('marker')->throughLine('marker')->range()); + } + + public function testMissingOrPartiallyExcludedMarkersAreRejected(): void + { + foreach (['afterLine', 'beforeLine', 'fromLine', 'throughLine'] as $method) { + foreach (['outside', 'mar', 'Marker'] as $code) { + $source = CodeSource::fromString("marker\n" . $code . "\nmarker"); + try { + $source->lines(2, 2)->{$method}('marker'); + self::fail('A marker outside the current slice was accepted.'); + } catch (SourceTextNotFound) { + self::addToAssertionCount(1); + } + } + + try { + CodeSource::fromString('marker')->slice()->before('ker')->{$method}('marker'); + self::fail('A partially excluded marker was accepted.'); + } catch (SourceTextNotFound) { + self::addToAssertionCount(1); + } + } + } + + public function testNeedlesMustBeNonEmptyAndStayOnOneLine(): void + { + foreach (['afterLine', 'beforeLine', 'fromLine', 'throughLine'] as $method) { + foreach (['', "a\nb", "a\rb", "a\r\nb"] as $text) { + $this->expectInvalidNeedle($method, $text); + } + } + } + + public function testUtf8MarkersAndMixedLineEndingsKeepExactContent(): void + { + $slice = CodeSource::fromString("préface\r\n\r\n\tété();\n\n\rsuffix") + ->slice()->afterLine('début')->beforeLine('fin'); + + self::assertSame("\tété();\n", $slice->content()); + self::assertSame(3, $slice->startLine()); + } + + private function expectInvalidNeedle(string $method, string $text): void + { + try { + CodeSource::fromString($text)->slice()->{$method}($text); + self::fail('An invalid marker was accepted.'); + } catch (\InvalidArgumentException) { + self::addToAssertionCount(1); + } + } +}