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 inclusive and exclusive literal line-boundary selectors.

## [0.7.0] - 2026-09-20

- Extract immutable source-code slices by line, text, or language structure.
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ $slice = $source->slice()
->before('// example:end');
```

Line-boundary selectors include or exclude complete matching lines:

```php
$slice = $source->slice()
->afterLine('<!-- example:start -->')
->beforeLine('<!-- example:end -->');
```

Missing boundaries and attempts to expand a slice throw explicit exceptions rather than returning
an approximate result.

Expand Down
2 changes: 2 additions & 0 deletions docs/languages/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
259 changes: 259 additions & 0 deletions docs/languages/text.md
Original file line number Diff line number Diff line change
@@ -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
<?php

require __DIR__.'/vendor/autoload.php';

use Alto\Code\Slicer\CodeSource;

$source = CodeSource::fromString("first\nsecond\nthird");
echo $source->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
<main>
<form action="/orders" method="post">
<input name="email" type="email" required>
<button type="submit">Confirm</button>
</form>
<p>Payment is collected on confirmation.</p>
</main>
```

**Selection**

```php
use Alto\Code\Slicer\CodeSource;

$slice = CodeSource::fromFile('checkout.html')->slice()
->fromLine('<form ')
->throughLine('</form>');
echo $slice->content();
```

**Result**

```html
<form action="/orders" method="post">
<input name="email" type="email" required>
<button type="submit">Confirm</button>
</form>
```

To keep only its contents, exclude the matching tag lines:

**Selection**

```php
use Alto\Code\Slicer\CodeSource;

$slice = CodeSource::fromFile('checkout.html')->slice()
->afterLine('<form ')
->beforeLine('</form>');
echo $slice->content();
```

**Result**

```html
<input name="email" type="email" required>
<button type="submit">Confirm</button>
```

## SVG: select a group

**Source: `confirmed.svg`**

```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>Order confirmed</title>
<g id="badge" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10"/>
<path d="m7 12 3 3 7-7"/>
</g>
</svg>
```

**Selection**

```php
use Alto\Code\Slicer\CodeSource;

$slice = CodeSource::fromFile('confirmed.svg')->slice()
->fromLine('<g id="badge"')
->throughLine('</g>');
echo $slice->content();
```

**Result**

```svg
<g id="badge" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10"/>
<path d="m7 12 3 3 7-7"/>
</g>
```

This group has no nested `g`. With nested groups, `throughLine('</g>')` 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).
6 changes: 5 additions & 1 deletion docs/selectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
63 changes: 63 additions & 0 deletions src/CodeSlice.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading