feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
+20
View File
@@ -0,0 +1,20 @@
Copyright (c) 2024-present, the Scssphp project authors
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+22
View File
@@ -0,0 +1,22 @@
# SourceSpan
`scssphp/source-span` is a library for tracking locations in source code. It's designed
to provide a standard representation for source code locations and spans so that
disparate packages can easily pass them among one another, and to make it easy
to generate human-friendly messages associated with a given piece of code.
The most commonly-used interface is the package's namesake, `SourceSpan\SourceSpan`. It
represents a span of characters in some source file, and is often attached to an
object that has been parsed to indicate where it was parsed from. It provides
access to the text of the span via `SourceSpan::getText()` and can be used to produce
human-friendly messages using `SourceSpan::message()`. It's most simple implementation
is `SourceSpan\SimpleSourceSpan` which holds directly the span information.
When parsing code from a file, `SourceSpan\SourceFile` is useful. Not only does it provide
an efficient means of computing line and column numbers, `SourceFile#span()`
returns special `FileSpan`s that are able to provide more context for their
error messages.
## Credits
This library is a PHP port of the [Dart `source_span` package](https://github.com/dart-lang/source_span).
+43
View File
@@ -0,0 +1,43 @@
{
"name": "scssphp/source-span",
"type": "library",
"description": "Provides a representation for source code locations and spans.",
"keywords": ["parsing"],
"license": [
"MIT"
],
"authors": [
{
"name": "Christophe Coevoet",
"homepage": "https://github.com/stof"
}
],
"autoload": {
"psr-4": { "SourceSpan\\": "src/" }
},
"autoload-dev": {
"psr-4": { "SourceSpan\\Tests\\": "tests/" }
},
"require": {
"php": ">=8.1",
"ext-mbstring": "*",
"league/uri": "^7.6",
"league/uri-interfaces": "^7.6"
},
"require-dev": {
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpunit/phpunit": "^9.5.6",
"squizlabs/php_codesniffer": "~3.5",
"symfony/phpunit-bridge": "^6.4 || ^7.3 || ^8.0",
"symfony/var-dumper": "^6.4 || ^7.3 || ^8.0"
},
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"config": {
"sort-packages": true
}
}
@@ -0,0 +1,156 @@
<?php
namespace SourceSpan;
use League\Uri\Contracts\UriInterface;
/**
* The implementation of {@see FileSpan} based on a {@see SourceFile}.
*
* @see SourceFile::span()
*
* @internal
*/
final class ConcreteFileSpan extends SourceSpanMixin implements FileSpan
{
/**
* @param int $start The offset of the beginning of the span.
* @param int $end The offset of the end of the span.
*/
public function __construct(
private readonly SourceFile $file,
private readonly int $start,
private readonly int $end,
) {
if ($this->end < $this->start) {
throw new \InvalidArgumentException("End $this->end must come after start $this->start.");
}
if ($this->end > $this->file->getLength()) {
throw new \OutOfRangeException("End $this->end not be greater than the number of characters in the file, {$this->file->getLength()}.");
}
if ($this->start < 0) {
throw new \OutOfRangeException("Start may not be negative, was $this->start.");
}
}
public function getFile(): SourceFile
{
return $this->file;
}
public function getSourceUrl(): ?UriInterface
{
return $this->file->getSourceUrl();
}
public function getLength(): int
{
return $this->end - $this->start;
}
public function getStart(): FileLocation
{
return new FileLocation($this->file, $this->start);
}
public function getEnd(): FileLocation
{
return new FileLocation($this->file, $this->end);
}
public function getText(): string
{
return $this->file->getText($this->start, $this->end);
}
public function getContext(): string
{
$endLine = $this->file->getLine($this->end);
$endColumn = $this->file->getColumn($this->end);
if ($endColumn === 0 && $endLine !== 0) {
// If $this->end is at the very beginning of the line, the span covers the
// previous newline, so we only want to include the previous line in the
// context...
if ($this->getLength() === 0) {
// ...unless this is a point span, in which case we want to include the
// next line (or the empty string if this is the end of the file).
return $endLine === $this->file->getLines() - 1 ? '' : $this->file->getText($this->file->getOffset($endLine), $this->file->getOffset($endLine + 1));
}
$endOffset = $this->end;
} elseif ($endLine === $this->file->getLines() - 1) {
// If the span covers the last line of the file, the context should go all
// the way to the end of the file.
$endOffset = $this->file->getLength();
} else {
// Otherwise, the context should cover the full line on which [end]
// appears.
$endOffset = $this->file->getOffset($endLine + 1);
}
return $this->file->getText($this->file->getOffset($this->file->getLine($this->start)), $endOffset);
}
public function compareTo(SourceSpan $other): int
{
if (!$other instanceof ConcreteFileSpan) {
return parent::compareTo($other);
}
$result = $this->start <=> $other->start;
if ($result !== 0) {
return $result;
}
return $this->end <=> $other->end;
}
public function union(SourceSpan $other): SourceSpan
{
if (!$other instanceof FileSpan) {
return parent::union($other);
}
$span = $this->expand($other);
if ($other instanceof ConcreteFileSpan) {
if ($this->start > $other->end || $other->start > $this->end) {
throw new \InvalidArgumentException("Spans are disjoint.");
}
} else {
if ($this->start > $other->getEnd()->getOffset() || $other->getStart()->getOffset() > $this->end) {
throw new \InvalidArgumentException("Spans are disjoint.");
}
}
return $span;
}
public function expand(FileSpan $other): FileSpan
{
if ($this->file->getSourceUrl() !== $other->getFile()->getSourceUrl()) {
throw new \InvalidArgumentException('Source map URLs don\'t match.');
}
$start = min($this->start, $other->getStart()->getOffset());
$end = max($this->end, $other->getEnd()->getOffset());
return new ConcreteFileSpan($this->file, $start, $end);
}
public function subspan(int $start, ?int $end = null): FileSpan
{
Util::checkValidRange($start, $end, $this->getLength());
if ($start === 0 && ($end === null || $end === $this->getLength())) {
return $this;
}
return $this->file->span($this->start + $start, $end === null ? $this->end : $this->start + $end);
}
}
@@ -0,0 +1,52 @@
<?php
namespace SourceSpan;
use League\Uri\Contracts\UriInterface;
/**
* The implementation of {@see SourceLocation} based on a {@see SourceFile}.
*
* @see SourceFile::location()
*/
final class FileLocation extends SourceLocationMixin
{
/**
* @internal
*/
public function __construct(
private readonly SourceFile $file,
private readonly int $offset,
) {
}
public function getFile(): SourceFile
{
return $this->file;
}
public function getOffset(): int
{
return $this->offset;
}
public function getLine(): int
{
return $this->file->getLine($this->offset);
}
public function getColumn(): int
{
return $this->file->getColumn($this->offset);
}
public function getSourceUrl(): ?UriInterface
{
return $this->file->getSourceUrl();
}
public function pointSpan(): FileSpan
{
return new ConcreteFileSpan($this->file, $this->offset, $this->offset);
}
}
@@ -0,0 +1,20 @@
<?php
namespace SourceSpan;
interface FileSpan extends SourceSpanWithContext
{
public function getFile(): SourceFile;
public function getStart(): FileLocation;
public function getEnd(): FileLocation;
public function expand(FileSpan $other): FileSpan;
/**
* Return a span from $start bytes (inclusive) to $end bytes
* (exclusive) after the beginning of this span
*/
public function subspan(int $start, ?int $end = null): FileSpan;
}
@@ -0,0 +1,18 @@
<?php
namespace SourceSpan\Highlighter;
/**
* @internal
*/
final class AsciiGlyph
{
public const horizontalLine = '-';
public const verticalLine = '|';
public const topLeftCorner = ',';
public const bottomLeftCorner = "'";
public const cross = '+';
public const upEnd = "'";
public const downEnd = ',';
public const horizontalLineBold = '=';
}
@@ -0,0 +1,205 @@
<?php
namespace SourceSpan\Highlighter;
use SourceSpan\SimpleSourceLocation;
use SourceSpan\SimpleSourceSpanWithContext;
use SourceSpan\SourceSpan;
use SourceSpan\SourceSpanWithContext;
use SourceSpan\Util;
/**
* Information about how to highlight a single section of a source file.
*
* @internal
*/
final class Highlight
{
/**
* The section of the source file to highlight.
*
* This is normalized to make it easier for {@see Highlighter} to work with.
*/
public readonly SourceSpanWithContext $span;
/**
* The label to include inline when highlighting {@see $span}.
*
* This helps distinguish clarify what each highlight means when multiple are
* used in the same message.
*/
public readonly ?string $label;
public function __construct(
SourceSpan $span,
private readonly bool $primary = false,
?string $label = null,
) {
$this->span = self::normalizeSpan($span);
$this->label = $label === null ? null : str_replace("\r\n", "\n", $label);
}
/**
* Whether this is the primary span in the highlight.
*
* The primary span is highlighted with a different character than
* non-primary spans.
*/
public function isPrimary(): bool
{
return $this->primary;
}
private static function normalizeSpan(SourceSpan $span): SourceSpanWithContext
{
$newSpan = self::normalizeContext($span);
$newSpan = self::normalizeNewlines($newSpan);
$newSpan = self::normalizeTrailingNewline($newSpan);
return self::normalizeEndOfLine($newSpan);
}
/**
* Normalizes $span to ensure that it's a {@see SourceSpanWithContext} whose
* context actually contains its text at the expected column.
*
* If it's not already a {@see SourceSpanWithContext}, adjust the start and end
* locations' line and column fields so that the highlighter can assume they
* match up with the context.
*/
private static function normalizeContext(SourceSpan $span): SourceSpanWithContext
{
if ($span instanceof SourceSpanWithContext && Util::findLineStart($span->getContext(), $span->getText(), $span->getStart()->getColumn()) !== null) {
return $span;
}
return new SimpleSourceSpanWithContext(
new SimpleSourceLocation($span->getStart()->getOffset(), $span->getSourceUrl(), 0, 0),
new SimpleSourceLocation($span->getEnd()->getOffset(), $span->getSourceUrl(), substr_count($span->getText(), "\n"), self::lastLineLength($span->getText())),
$span->getText(),
$span->getText()
);
}
/**
* Normalizes $span to replace Windows-style newlines with Unix-style
* newlines.
*/
private static function normalizeNewlines(SourceSpanWithContext $span): SourceSpanWithContext
{
$text = $span->getText();
if (!str_contains($text, "\r\n")) {
return $span;
}
$endOffset = $span->getEnd()->getOffset() - substr_count($text, "\r\n");
return new SimpleSourceSpanWithContext(
$span->getStart(),
new SimpleSourceLocation($endOffset, $span->getSourceUrl(), $span->getEnd()->getLine(), $span->getEnd()->getColumn()),
str_replace("\r\n", "\n", $text),
str_replace("\r\n", "\n", $span->getContext())
);
}
/**
* Normalizes $span to remove a trailing newline from `$span->getContext()`.
*
* If necessary, also adjust `$span->getEnd()` so that it doesn't point past where
* the trailing newline used to be.
*/
private static function normalizeTrailingNewline(SourceSpanWithContext $span): SourceSpanWithContext
{
if (!str_ends_with($span->getContext(), "\n")) {
return $span;
}
// If there's a full blank line on the end of `$span->getContext()`, it's probably
// significant, so we shouldn't trim it.
if (str_ends_with($span->getText(), "\n\n")) {
return $span;
}
$context = substr($span->getContext(), 0, -1);
$text = $span->getText();
$start = $span->getStart();
$end = $span->getEnd();
if (str_ends_with($text, "\n") && self::isTextAtEndOfContext($span)) {
$text = substr($text, 0, -1);
if ($text === '') {
$end = $start;
} else {
$end = new SimpleSourceLocation(
$end->getOffset() - 1,
$span->getSourceUrl(),
$end->getLine() - 1,
self::lastLineLength($context)
);
$start = $span->getStart()->getOffset() === $span->getEnd()->getOffset() ? $end : $span->getStart();
}
}
return new SimpleSourceSpanWithContext($start, $end, $text, $context);
}
/**
* Normalizes $span so that the end location is at the end of a line rather
* than at the beginning of the next line.
*/
private static function normalizeEndOfLine(SourceSpanWithContext $span): SourceSpanWithContext
{
if ($span->getEnd()->getColumn() !== 0) {
return $span;
}
if ($span->getEnd()->getLine() === $span->getStart()->getLine()) {
return $span;
}
$text = substr($span->getText(), 0, -1);
return new SimpleSourceSpanWithContext(
$span->getStart(),
new SimpleSourceLocation(
$span->getEnd()->getOffset() - 1,
$span->getSourceUrl(),
$span->getEnd()->getLine() - 1,
\strlen($text) - Util::lastIndexOf($text, "\n") - 1
),
$text,
// If the context also ends with a newline, it's possible that we don't
// have the full context for that line, so we shouldn't print it at all.
str_ends_with($span->getContext(), "\n") ? substr($span->getContext(), 0, -1) : $span->getContext()
);
}
/**
* Returns the length of the last line in $text, whether or not it ends in a
* newline.
*/
private static function lastLineLength(string $text): int
{
if ($text === '') {
return 0;
}
if ($text[\strlen($text) - 1] === '\n') {
return \strlen($text) === 1 ? 0 : \strlen($text) - Util::lastIndexOf($text, "\n", \strlen($text) - 2) - 1;
}
return \strlen($text) - Util::lastIndexOf($text, "\n") - 1;
}
/**
* Returns whether $span's text runs all the way to the end of its context.
*/
private static function isTextAtEndOfContext(SourceSpanWithContext $span): bool
{
$lineStart = Util::findLineStart($span->getContext(), $span->getText(), $span->getStart()->getColumn());
\assert($lineStart !== null);
return $lineStart + $span->getStart()->getColumn() + $span->getLength() === \strlen($span->getContext());
}
}
@@ -0,0 +1,538 @@
<?php
namespace SourceSpan\Highlighter;
use League\Uri\Contracts\UriInterface;
use SourceSpan\SourceSpan;
use SourceSpan\Util;
/**
* A class for writing a chunk of text with a particular span highlighted.
*
* @internal
*/
final class Highlighter
{
/**
* The number of spaces to render for hard tabs that appear in `_span.text`.
*
* We don't want to render raw tabs, because they'll mess up our character
* alignment.
*/
private const SPACES_PER_TAB = 4;
/**
* The lines to display, including context around the highlighted spans.
*
* @var list<Line>
*/
private array $lines;
/**
* The number of characters before the bar in the sidebar.
*/
private readonly int $paddingBeforeSidebar;
/**
* The maximum number of multiline spans that cover any part of a single
* line in {@see $lines}.
*/
private readonly int $maxMultilineSpans;
/**
* Whether {@see $lines} includes lines from multiple different files.
*/
private readonly bool $multipleFiles;
/**
* The buffer to which to write the result.
*/
private string $buffer = '';
/**
* Creates a {@see Highlighter} that will return a string highlighting $span
* within the text of its file when {@see highlight} is called.
*/
public static function create(SourceSpan $span): Highlighter
{
return new Highlighter(self::collateLines([new Highlight($span, primary: true)]));
}
/**
* Creates a {@see Highlighter} that will return a string highlighting
* $primarySpan as well as all the spans in $secondarySpans within the text
* of their file when {@see highlight} is called.
*
* Each span has an associated label that will be written alongside it. For
* $primarySpan this message is $primaryLabel, and for $secondarySpans the
* labels are the map keys.
*
* @param array<string, SourceSpan> $secondarySpans
*/
public static function multiple(SourceSpan $primarySpan, string $primaryLabel, array $secondarySpans): Highlighter
{
$highlights = [new Highlight($primarySpan, primary: true, label: $primaryLabel)];
foreach ($secondarySpans as $secondaryLabel => $secondarySpan) {
$highlights[] = new Highlight($secondarySpan, label: $secondaryLabel);
}
return new Highlighter(self::collateLines($highlights));
}
/**
* @param list<Line> $lines
*/
private function __construct(array $lines)
{
$this->lines = $lines;
$this->paddingBeforeSidebar = 1 + max(
\strlen((string) (Util::listLast($lines)->number + 1)),
// If $lines aren't contiguous, we'll write "..." in place of a
// line number.
self::contiguous($lines) ? 0 : 3
);
$this->maxMultilineSpans = array_reduce(array_map(fn (Line $line) => \count(array_filter($line->highlights, fn (Highlight $highlight) => Util::isMultiline($highlight->span))), $lines), 'max', 0);
$this->multipleFiles = !Util::isAllTheSame(array_map(fn (Line $line) => $line->url, $lines));
}
/**
* Returns whether $lines contains any adjacent lines from the same source
* file that aren't adjacent in the original file.
*
* @param list<Line> $lines
*/
private static function contiguous(array $lines): bool
{
for ($i = 0; $i < \count($lines) - 1; $i++) {
$thisLine = $lines[$i];
$nextLine = $lines[$i + 1];
if ($thisLine->number + 1 !== $nextLine->number && Util::isSame($thisLine->url, $nextLine->url)) {
return false;
}
}
return true;
}
/**
* Collect all the source lines from the contexts of all spans in
* $highlights, and associates them with the highlights that cover them.
*
* @param list<Highlight> $highlights
* @return list<Line>
*/
private static function collateLines(array $highlights): array
{
// Assign spans without URLs opaque strings as keys. Each such string will
// be different, but they can then be used later on to determine which lines
// came from the same span even if they'd all otherwise have `null` URLs.
$highlightsByUrl = [];
$urls = [];
foreach ($highlights as $highlight) {
$url = $highlight->span->getSourceUrl() ?? new \stdClass();
$key = $url instanceof UriInterface ? $url->toString() : spl_object_hash($url);
$highlightsByUrl[$key][] = $highlight;
$urls[$key] = $url;
}
foreach ($highlightsByUrl as &$list) {
usort($list, fn (Highlight $highlight1, Highlight $highlight2) => $highlight1->span->compareTo($highlight2->span));
}
return iterator_to_array(self::expandMapIterable($highlightsByUrl, function (array $highlightsForFile, string $urlKey) use ($urls) {
// First, create a list of all the lines in the current file that we have
// context for along with their line numbers.
$lines = [];
/** @var Highlight $highlight */
foreach ($highlightsForFile as $highlight) {
$context = $highlight->span->getContext();
// If `$highlight->span->getContext()` contains lines prior to the one
// `$highlight->span->getText()` appears on, write those first.
$lineStart = Util::findLineStart($context, $highlight->span->getText(), $highlight->span->getStart()->getColumn());
\assert($lineStart !== null);
$linesBeforeSpan = substr_count(substr($context, 0, $lineStart), "\n");
$lineNumber = $highlight->span->getStart()->getLine() - $linesBeforeSpan;
foreach (explode("\n", $context) as $line) {
// Only add a line if it hasn't already been added for a previous span
if ($lines === [] || $lineNumber > Util::listLast($lines)->number) {
$lines[] = new Line($line, $lineNumber, $urls[$urlKey]);
}
$lineNumber++;
}
}
// Next, associate each line with each highlight that covers it.
$activeHighlights = [];
$highlightIndex = 0;
foreach ($lines as $line) {
$activeHighlights = array_values(array_filter($activeHighlights, fn (Highlight $highlight) => $highlight->span->getEnd()->getLine() >= $line->number));
$oldHighlightLength = \count($activeHighlights);
foreach (array_slice($highlightsForFile, $highlightIndex) as $highlight) {
if ($highlight->span->getStart()->getLine() > $line->number) {
break;
}
$activeHighlights[] = $highlight;
}
$highlightIndex += \count($activeHighlights) - $oldHighlightLength;
foreach ($activeHighlights as $activeHighlight) {
$line->highlights[] = $activeHighlight;
}
}
return $lines;
}), false);
}
/**
* Returns the highlighted span text.
*
* This method should only be called once.
*/
public function highlight(): string
{
$this->writeFileStart($this->lines[0]->url);
// Each index of this list represents a column after the sidebar that could
// contain a line indicating an active highlight. If it's `null`, that
// column is empty; if it contains a highlight, it should be drawn for that
// column.
$highlightsByColumn = array_fill(0, $this->maxMultilineSpans, null);
foreach ($this->lines as $i => $line) {
if ($i > 0) {
$lastLine = $this->lines[$i - 1];
if (!Util::isSame($lastLine->url, $line->url)) {
$this->writeSidebar(end: AsciiGlyph::upEnd);
$this->buffer .= "\n";
$this->writeFileStart($line->url);
} elseif ($lastLine->number + 1 !== $line->number) {
$this->writeSidebar(text: '...');
$this->buffer .= "\n";
}
}
// If a highlight covers the entire first line other than initial
// whitespace, don't bother pointing out exactly where it begins. Iterate
// in reverse so that longer highlights (which are sorted after shorter
// highlights) appear further out, leading to fewer crossed lines.
foreach (array_reverse($line->highlights) as $highlight) {
if (Util::isMultiline($highlight->span) && $highlight->span->getStart()->getLine() === $line->number && $this->isOnlyWhitespace(substr($line->text, 0, $highlight->span->getStart()->getColumn()))) {
Util::replaceFirstNull($highlightsByColumn, $highlight);
}
}
$this->writeSidebar(line: $line->number);
$this->buffer .= ' ';
$this->writeMultilineHighlights($line, $highlightsByColumn);
if ($highlightsByColumn !== []) {
$this->buffer .= ' ';
}
$primaryIdx = Util::indexWhere($line->highlights, fn (Highlight $highlight) => $highlight->isPrimary());
$primary = $primaryIdx === null ? null : $line->highlights[$primaryIdx];
$this->writeText($line->text);
$this->buffer .= "\n";
// Always write the primary span's indicator first so that it's right next
// to the highlighted text.
if ($primary !== null) {
$this->writeIndicator($line, $primary, $highlightsByColumn);
}
foreach ($line->highlights as $highlight) {
if ($highlight->isPrimary()) {
continue;
}
$this->writeIndicator($line, $highlight, $highlightsByColumn);
}
}
$this->writeSidebar(end: AsciiGlyph::upEnd);
return $this->buffer;
}
/**
* Writes the beginning of the file highlight for the file with the given
* $url (or opaque object if it comes from a span with a null URL).
*/
private function writeFileStart(object $url): void
{
if (!$this->multipleFiles || !$url instanceof UriInterface) {
$this->writeSidebar(end: AsciiGlyph::downEnd);
} else {
$this->writeSidebar(end: AsciiGlyph::topLeftCorner);
$this->buffer .= str_repeat(AsciiGlyph::horizontalLine, 2) . '> ';
$this->buffer .= Util::prettyUri($url);
}
$this->buffer .= "\n";
}
/**
* Writes the post-sidebar highlight bars for $line according to
* $highlightsByColumn.
*
* If $current is passed, it's the highlight for which an indicator is being
* written. If it appears in $highlightsByColumn, a horizontal line is
* written from its column to the rightmost column.
*
* @param list<Highlight|null> $highlightsByColumn
*/
private function writeMultilineHighlights(Line $line, array $highlightsByColumn, ?Highlight $current = null): void
{
// Whether we've written a sidebar indicator for opening a new span on this
// line.
$openedOnThisLine = false;
$foundCurrent = false;
foreach ($highlightsByColumn as $highlight) {
$startLine = $highlight?->span->getStart()->getLine();
$endLine = $highlight?->span->getEnd()->getLine();
if ($current !== null && $highlight === $current) {
$foundCurrent = true;
\assert($startLine === $line->number || $endLine === $line->number);
$this->buffer .= $startLine === $line->number ? AsciiGlyph::topLeftCorner : AsciiGlyph::bottomLeftCorner;
} elseif ($foundCurrent) {
$this->buffer .= $highlight === null ? AsciiGlyph::horizontalLine : AsciiGlyph::cross;
} elseif ($highlight === null) {
if ($openedOnThisLine) {
$this->buffer .= AsciiGlyph::horizontalLine;
} else {
$this->buffer .= ' ';
}
} else {
$vertical = $openedOnThisLine ? AsciiGlyph::cross : AsciiGlyph::verticalLine;
if ($current !== null) {
$this->buffer .= $vertical;
} elseif ($startLine === $line->number) {
$this->buffer .= '/';
$openedOnThisLine = true;
} elseif ($endLine === $line->number && $highlight->span->getEnd()->getColumn() === \strlen($line->text)) {
$this->buffer .= $highlight->label === null ? '\\' : $vertical;
} else {
$this->buffer .= $vertical;
}
}
}
}
/**
* Writes an indicator for where $highlight starts, ends, or both below
* $line.
*
* This may either add or remove $highlight from $highlightsByColumn.
*
* @param list<Highlight|null> $highlightsByColumn
*/
private function writeIndicator(Line $line, Highlight $highlight, array &$highlightsByColumn): void
{
if (!Util::isMultiline($highlight->span)) {
$this->writeSidebar();
$this->buffer .= ' ';
$this->writeMultilineHighlights($line, $highlightsByColumn, $highlight);
if ($highlightsByColumn !== []) {
$this->buffer .= ' ';
}
$start = \strlen($this->buffer);
$this->writeUnderline($line, $highlight->span, $highlight->isPrimary() ? '^' : AsciiGlyph::horizontalLineBold);
$underlineLength = \strlen($this->buffer) - $start;
$this->writeLabel($highlight, $highlightsByColumn, $underlineLength);
} elseif ($highlight->span->getStart()->getLine() === $line->number) {
if (\in_array($highlight, $highlightsByColumn, true)) {
return;
}
Util::replaceFirstNull($highlightsByColumn, $highlight);
$this->writeSidebar();
$this->buffer .= ' ';
$this->writeMultilineHighlights($line, $highlightsByColumn, $highlight);
$this->writeArrow($line, $highlight->span->getStart()->getColumn());
$this->buffer .= "\n";
} elseif ($highlight->span->getEnd()->getLine() === $line->number) {
$coversWholeLine = $highlight->span->getEnd()->getColumn() === \strlen($line->text);
if ($coversWholeLine && $highlight->label === null) {
Util::replaceWithNull($highlightsByColumn, $highlight);
return;
}
$this->writeSidebar();
$this->buffer .= ' ';
$this->writeMultilineHighlights($line, $highlightsByColumn, $highlight);
$start = \strlen($this->buffer);
if ($coversWholeLine) {
$this->buffer .= str_repeat(AsciiGlyph::horizontalLine, 3);
} else {
$this->writeArrow($line, max($highlight->span->getEnd()->getColumn() - 1, 0), false);
}
$underlineLength = \strlen($this->buffer) - $start;
$this->writeLabel($highlight, $highlightsByColumn, $underlineLength);
Util::replaceWithNull($highlightsByColumn, $highlight);
}
}
/**
* Underlines the portion of $line covered by $span with repeated instances
* of $character.
*/
private function writeUnderline(Line $line, SourceSpan $span, string $character): void
{
\assert(!Util::isMultiline($span));
\assert(str_contains($line->text, $span->getText()));
$startColumn = $span->getStart()->getColumn();
$endColumn = $span->getEnd()->getColumn();
// Adjust the start and end columns to account for any tabs that were
// converted to spaces.
$tabsBefore = substr_count(substr($line->text, 0, $startColumn), "\t");
$tabsInside = substr_count(Util::substring($line->text, $startColumn, $endColumn), "\t");
$startColumn += $tabsBefore * (self::SPACES_PER_TAB - 1);
$endColumn += ($tabsBefore + $tabsInside) * (self::SPACES_PER_TAB - 1);
$this->buffer .= str_repeat(' ', $startColumn);
$this->buffer .= str_repeat($character, max($endColumn - $startColumn, 1));
}
/**
* Write an arrow pointing to column $column in $line.
*
* If the arrow points to a tab character, this will point to the beginning
* of the tab if $beginning is `true` and the end if it's `false`.
*/
private function writeArrow(Line $line, int $column, bool $beginning = true): void
{
$tabs = substr_count(substr($line->text, 0, $column + ($beginning ? 0 : 1)), "\t");
$this->buffer .= str_repeat(AsciiGlyph::horizontalLine, 1 + $column + $tabs * (self::SPACES_PER_TAB - 1));
$this->buffer .= '^';
}
/**
* Writes $highlight's label.
*
* The {@see $buffer} is assumed to be written to the point where the first line
* of `$highlight->label` can be written after a space, but this takes care of
* writing indentation and highlight columns for later lines.
*
* The $highlightsByColumn are used to write ongoing highlight lines if the
* label is more than one line long.
*
* The $underlineLength is the length of the line written between the
* highlights and the beginning of the first label.
*
* @param list<Highlight|null> $highlightsByColumn
*/
private function writeLabel(Highlight $highlight, array $highlightsByColumn, int $underlineLength): void
{
$label = $highlight->label;
if ($label === null) {
$this->buffer .= "\n";
return;
}
$lines = explode("\n", $label);
$this->buffer .= ' ';
$this->buffer .= $lines[0];
$this->buffer .= "\n";
foreach (array_slice($lines, 1) as $text) {
$this->writeSidebar();
$this->buffer .= ' ';
foreach ($highlightsByColumn as $columnHighlight) {
if ($columnHighlight === null || $columnHighlight === $highlight) {
$this->buffer .= ' ';
} else {
$this->buffer .= AsciiGlyph::verticalLine;
}
}
$this->buffer .= str_repeat(' ', $underlineLength + 1);
$this->buffer .= $text;
$this->buffer .= "\n";
}
}
/**
* Writes a snippet from the source text, converting hard tab characters into
* plain indentation.
*/
private function writeText(string $text): void
{
$this->buffer .= str_replace("\t", str_repeat(' ', self::SPACES_PER_TAB), $text);
}
/**
* Writes a sidebar to {@see $buffer} that includes $line as the line number if
* given and writes $end at the end (defaults to {@see AsciiGlyph::verticalLine}).
*
* If $text is given, it's used in place of the line number. It can't be
* passed at the same time as $line.
*/
private function writeSidebar(?int $line = null, ?string $text = null, ?string $end = null): void
{
\assert($line === null || $text === null);
if ($line !== null) {
// Add 1 to line to convert from computer-friendly 0-indexed line numbers to
// human-friendly 1-indexed line numbers.
$text = (string) ($line + 1);
}
$this->buffer .= str_pad($text ?? '', $this->paddingBeforeSidebar);
$this->buffer .= $end ?? AsciiGlyph::verticalLine;
}
/**
* Returns whether $text contains only space or tab characters.
*/
private function isOnlyWhitespace(string $text): bool
{
for ($i = 0; $i < \strlen($text); $i++) {
$char = $text[$i];
if ($char !== ' ' && $char !== "\t") {
return false;
}
}
return true;
}
/**
* @template K
* @template E
* @template T
* @param iterable<K, E> $elements
* @param callable(E, K): iterable<T> $callback
* @return \Traversable<T>
*
* @param-immediately-invoked-callable $callback
*/
private static function expandMapIterable(iterable $elements, callable $callback): \Traversable
{
foreach ($elements as $key => $element) {
yield from $callback($element, $key);
}
}
}
@@ -0,0 +1,40 @@
<?php
namespace SourceSpan\Highlighter;
/**
* A single line of the source file being highlighted.
*
* @internal
*/
final class Line
{
/**
* All highlights that cover any portion of this line, in source span order.
*
* This is populated after the initial line is created.
*
* @var list<Highlight>
*/
public array $highlights = [];
/**
* The URL of the source file in which this line appears.
*
* For lines created from spans without an explicit URL, this is an opaque
* object that differs between lines that come from different spans.
*/
public readonly object $url;
/**
* @param int $number The O-based line number in the source file
*/
public function __construct(
public readonly string $text,
public readonly int $number,
object $url,
) {
$this->url = $url;
}
}
@@ -0,0 +1,59 @@
<?php
namespace SourceSpan;
use League\Uri\Contracts\UriInterface;
final class SimpleSourceLocation extends SourceLocationMixin
{
private readonly int $line;
private readonly int $column;
/**
* Creates a new location indicating $offset within $sourceUrl.
*
* $line and $column default to assuming the source is a single ASCII line. This
* means that $line defaults to 0 and $column defaults to $offset.
*/
public function __construct(
private readonly int $offset,
private readonly ?UriInterface $sourceUrl = null,
?int $line = null,
?int $column = null,
) {
$this->line = $line ?? 0;
$this->column = $column ?? $offset;
if ($offset < 0) {
throw new \OutOfRangeException('Offset may not be negative.');
}
if ($line !== null && $line < 0) {
throw new \OutOfRangeException('Line may not be negative.');
}
if ($column !== null && $column < 0) {
throw new \OutOfRangeException('Column may not be negative.');
}
}
public function getOffset(): int
{
return $this->offset;
}
public function getLine(): int
{
return $this->line;
}
public function getColumn(): int
{
return $this->column;
}
public function getSourceUrl(): ?UriInterface
{
return $this->sourceUrl;
}
}
@@ -0,0 +1,53 @@
<?php
namespace SourceSpan;
final class SimpleSourceSpan extends SourceSpanMixin
{
public function __construct(
private readonly SourceLocation $start,
private readonly SourceLocation $end,
private readonly string $text,
) {
if (!Util::isSameUrl($start->getSourceUrl(), $end->getSourceUrl())) {
throw new \InvalidArgumentException("Source URLs \"{$start->getSourceUrl()}\" and \"{$end->getSourceUrl()}\" don't match.");
}
if ($this->end->getOffset() < $this->start->getOffset()) {
throw new \InvalidArgumentException('End must come after start.');
}
$distance = $this->start->distance($this->end);
if (\strlen($this->text) !== $distance) {
throw new \InvalidArgumentException("Text \"$text\" must be $distance characters long.");
}
}
public function getStart(): SourceLocation
{
return $this->start;
}
public function getEnd(): SourceLocation
{
return $this->end;
}
public function getText(): string
{
return $this->text;
}
public function subspan(int $start, ?int $end = null): SourceSpan
{
Util::checkValidRange($start, $end, $this->getLength());
if ($start === 0 && ($end === null || $end === $this->getLength())) {
return $this;
}
$locations = Util::subspanLocations($this, $start, $end);
return new SimpleSourceSpan($locations[0], $locations[1], Util::substring($this->text, $start, $end));
}
}
@@ -0,0 +1,68 @@
<?php
namespace SourceSpan;
final class SimpleSourceSpanWithContext extends SourceSpanMixin implements SourceSpanWithContext
{
public function __construct(
private readonly SourceLocation $start,
private readonly SourceLocation $end,
private readonly string $text,
private readonly string $context
) {
if (!Util::isSameUrl($start->getSourceUrl(), $end->getSourceUrl())) {
throw new \InvalidArgumentException("Source URLs \"{$start->getSourceUrl()}\" and \"{$end->getSourceUrl()}\" don't match.");
}
if ($this->end->getOffset() < $this->start->getOffset()) {
throw new \InvalidArgumentException('End must come after start.');
}
$distance = $this->start->distance($this->end);
if (\strlen($this->text) !== $distance) {
throw new \InvalidArgumentException("Text \"$text\" must be $distance characters long.");
}
if (!str_contains($this->context, $this->text)) {
throw new \InvalidArgumentException("The context line \"$context\" must contain \"$text\".");
}
if (Util::findLineStart($this->context, $this->text, $this->start->getColumn()) === null) {
$column = $this->start->getColumn() + 1;
throw new \InvalidArgumentException("The span text \"$text\" must start at column $column in a line within \"$context\".");
}
}
public function getStart(): SourceLocation
{
return $this->start;
}
public function getEnd(): SourceLocation
{
return $this->end;
}
public function getText(): string
{
return $this->text;
}
public function getContext(): string
{
return $this->context;
}
public function subspan(int $start, ?int $end = null): SourceSpanWithContext
{
Util::checkValidRange($start, $end, $this->getLength());
if ($start === 0 && ($end === null || $end === $this->getLength())) {
return $this;
}
$locations = Util::subspanLocations($this, $start, $end);
return new SimpleSourceSpanWithContext($locations[0], $locations[1], Util::substring($this->text, $start, $end), $this->context);
}
}
@@ -0,0 +1,285 @@
<?php
namespace SourceSpan;
use League\Uri\Contracts\UriInterface;
final class SourceFile
{
private readonly string $string;
private readonly ?UriInterface $sourceUrl;
/**
* @var list<int>
*/
private readonly array $lineStarts;
/**
* The 0-based last line that was returned by {@see getLine}
*
* This optimizes computation for successive accesses to
* the same line or to the next line.
* It is stored as 0-based to correspond to the indices
* in {@see $lineStarts}.
*
* @var int|null
*/
private ?int $cachedLine = null;
public static function fromString(string $content, ?UriInterface $sourceUrl = null): SourceFile
{
return new SourceFile($content, $sourceUrl);
}
private function __construct(string $content, ?UriInterface $sourceUrl = null)
{
$this->string = $content;
$this->sourceUrl = $sourceUrl;
// Extract line starts
$lineStarts = [0];
if ($content === '') {
$this->lineStarts = $lineStarts;
return;
}
$prev = 0;
while (true) {
$crPos = strpos($content, "\r", $prev);
$lfPos = strpos($content, "\n", $prev);
if ($crPos === false && $lfPos === false) {
break;
}
if ($crPos !== false) {
// Return not followed by newline is treated as a newline
if ($lfPos === false || $lfPos > $crPos + 1) {
$lineStarts[] = $crPos + 1;
$prev = $crPos + 1;
continue;
}
}
if ($lfPos !== false) {
$lineStarts[] = $lfPos + 1;
$prev = $lfPos + 1;
}
}
$this->lineStarts = $lineStarts;
}
public function getLength(): int
{
return \strlen($this->string);
}
/**
* The number of lines in the file.
*/
public function getLines(): int
{
return \count($this->lineStarts);
}
public function span(int $start, ?int $end = null): FileSpan
{
if ($end === null) {
$end = \strlen($this->string);
}
return new ConcreteFileSpan($this, $start, $end);
}
public function location(int $offset): FileLocation
{
if ($offset < 0) {
throw new \OutOfRangeException("Offset may not be negative, was $offset.");
}
if ($offset > \strlen($this->string)) {
$fileLength = \strlen($this->string);
throw new \OutOfRangeException("Offset $offset must not be greater than the number of characters in the file, $fileLength.");
}
return new FileLocation($this, $offset);
}
public function getSourceUrl(): ?UriInterface
{
return $this->sourceUrl;
}
public function getString(): string
{
return $this->string;
}
/**
* The 0-based line corresponding to that offset.
*/
public function getLine(int $offset): int
{
if ($offset < 0) {
throw new \OutOfRangeException('Position cannot be negative');
}
if ($offset > \strlen($this->string)) {
throw new \OutOfRangeException('Position cannot be greater than the number of characters in the string.');
}
if ($offset < $this->lineStarts[0]) {
return -1;
}
if ($offset >= Util::listLast($this->lineStarts)) {
return \count($this->lineStarts) - 1;
}
if ($this->isNearCacheLine($offset)) {
assert($this->cachedLine !== null);
return $this->cachedLine;
}
$this->cachedLine = $this->binarySearch($offset) - 1;
return $this->cachedLine;
}
/**
* Returns `true` if $offset is near {@see $cachedLine}.
*
* Checks on {@see $cachedLine} and the next line. If it's on the next line, it
* updates {@see $cachedLine} to point to that.
*/
private function isNearCacheLine(int $offset): bool
{
if ($this->cachedLine === null) {
return false;
}
if ($offset < $this->lineStarts[$this->cachedLine]) {
return false;
}
if (
$this->cachedLine >= \count($this->lineStarts) - 1 ||
$offset < $this->lineStarts[$this->cachedLine + 1]
) {
return true;
}
if (
$this->cachedLine >= \count($this->lineStarts) - 2 ||
$offset < $this->lineStarts[$this->cachedLine + 2]
) {
++$this->cachedLine;
return true;
}
return false;
}
/**
* Binary search through {@see $lineStarts} to find the line containing $offset.
*
* Returns the index of the line in {@see $lineStarts}.
*/
private function binarySearch(int $offset): int
{
$min = 0;
$max = \count($this->lineStarts) - 1;
while ($min < $max) {
$half = $min + intdiv($max - $min, 2);
if ($this->lineStarts[$half] > $offset) {
$max = $half;
} else {
$min = $half + 1;
}
}
return $max;
}
/**
* The 0-based column of that offset.
*
* Unlike offsets (which are byte-offsets), columns are computed based on Unicode
* codepoints to provide a better experience.
*/
public function getColumn(int $offset): int
{
$line = $this->getLine($offset);
return mb_strlen(substr($this->string, $this->lineStarts[$line], $offset - $this->lineStarts[$line]), 'UTF-8');
}
/**
* Gets the offset for a line and column.
*/
public function getOffset(int $line, int $column = 0): int
{
if ($line < 0) {
throw new \OutOfRangeException('Line may not be negative.');
}
if ($line >= \count($this->lineStarts)) {
throw new \OutOfRangeException('Line must be less than the number of lines in the file.');
}
if ($column < 0) {
throw new \OutOfRangeException('Column may not be negative.');
}
if ($column === 0) {
$result = $this->lineStarts[$line];
} else {
$lineContent = substr($this->string, $this->lineStarts[$line], $this->lineStarts[$line + 1] ?? null);
if ($column > mb_strlen($lineContent, 'UTF-8')) {
throw new \OutOfRangeException("Line $line doesn't have $column columns.");
}
$result = $this->lineStarts[$line] + \strlen(mb_substr($lineContent, 0, $column, 'UTF-8'));
}
if ($result > \strlen($this->string) || ($line + 1 < \count($this->lineStarts) && $result >= $this->lineStarts[$line + 1])) {
throw new \OutOfRangeException("Line $line doesn't have $column columns.");
}
return $result;
}
/**
* Returns the text of the file from $start to $end (exclusive).
*
* If $end isn't passed, it defaults to the end of the file.
*/
public function getText(int $start, ?int $end = null): string
{
if ($end !== null) {
if ($end < $start) {
throw new \InvalidArgumentException("End $end must come after start $start.");
}
if ($end > $this->getLength()) {
throw new \OutOfRangeException("End $end not be greater than the number of characters in the file, {$this->getLength()}.");
}
}
if ($start < 0) {
throw new \OutOfRangeException("Start may not be negative, was $start.");
}
return Util::substring($this->string, $start, $end);
}
}
@@ -0,0 +1,47 @@
<?php
namespace SourceSpan;
use League\Uri\Contracts\UriInterface;
interface SourceLocation
{
public function getOffset(): int;
/**
* The 0-based line of that location
*/
public function getLine(): int;
/**
* The 0-based column of that location
*/
public function getColumn(): int;
public function getSourceUrl(): ?UriInterface;
/**
* Returns the distance in characters between $this and $other.
*
* This always returns a non-negative value.
*
* @return int<0, max>
*/
public function distance(SourceLocation $other): int;
/**
* Returns a span that covers only a single point: this location.
*/
public function pointSpan(): SourceSpan;
/**
* Compares two locations.
*
* It returns a negative integer if $this is ordered before $other,
* a positive integer if $this is ordered after $other,
* and zero if $this and $other are ordered together.
*
* $other must have the same source URL as $this.
*/
public function compareTo(SourceLocation $other): int;
}
@@ -0,0 +1,34 @@
<?php
namespace SourceSpan;
/**
* A mixin for easily implementing {@see SourceLocation}.
*
* @internal
*/
abstract class SourceLocationMixin implements SourceLocation
{
public function distance(SourceLocation $other): int
{
if (!Util::isSameUrl($this->getSourceUrl(), $other->getSourceUrl())) {
throw new \InvalidArgumentException("Source URLs \"{$this->getSourceUrl()}\" and \"{$other->getSourceUrl()}\" don't match.");
}
return abs($this->getOffset() - $other->getOffset());
}
public function pointSpan(): SourceSpan
{
return new SimpleSourceSpan($this, $this, '');
}
public function compareTo(SourceLocation $other): int
{
if (!Util::isSameUrl($this->getSourceUrl(), $other->getSourceUrl())) {
throw new \InvalidArgumentException("Source URLs \"{$this->getSourceUrl()}\" and \"{$other->getSourceUrl()}\" don't match.");
}
return $this->getOffset() - $other->getOffset();
}
}
@@ -0,0 +1,110 @@
<?php
namespace SourceSpan;
use League\Uri\Contracts\UriInterface;
/**
* An interface that describes a segment of source text.
*/
interface SourceSpan
{
/**
* The start location of this span.
*/
public function getStart(): SourceLocation;
/**
* The end location of this span, exclusive.
*/
public function getEnd(): SourceLocation;
/**
* The source text for this span.
*/
public function getText(): string;
/**
* The URL of the source (typically a file) of this span.
*
* This may be null, indicating that the source URL is unknown or
* unavailable.
*/
public function getSourceUrl(): ?UriInterface;
/**
* The length of this span, in bytes.
*/
public function getLength(): int;
/**
* Creates a new span that's the union of $this and $other.
*
* The two spans must have the same source URL and may not be disjoint.
* {@see getText} is computed by combining `$this->getText()` and `$other->getText()`.
*/
public function union(SourceSpan $other): SourceSpan;
/**
* Compares two spans.
*
* It returns a negative integer if $this is ordered before $other,
* a positive integer if $this is ordered after $other,
* and zero if $this and $other are ordered together.
*
* $other must have the same source URL as `this`. This orders spans by
* {@see getStart} then {@see getLength}.
*/
public function compareTo(SourceSpan $other): int;
/**
* Formats $message in a human-friendly way associated with this span.
*
* @param string $message
*
* @return string
*/
public function message(string $message): string;
/**
* Like {@see message}, but also highlights $secondarySpans to provide
* the user with additional context.
*
* Each span takes a label ($label for this span, and the keys of the
* $secondarySpans map for the secondary spans) that's used to indicate to
* the user what that particular span represents.
*
* @throws \InvalidArgumentException if any secondary span has a different source URL than this span.
*
* @param array<string, SourceSpan> $secondarySpans
*/
public function messageMultiple(string $message, string $label, array $secondarySpans): string;
/**
* Prints the text associated with this span in a user-friendly way.
*
* This is identical to {@see message}, except that it doesn't print the file
* name, line number, column number, or message.
*/
public function highlight(): string;
/**
* Like {@see highlight}, but also highlights $secondarySpans to provide
* the user with additional context.
*
* Each span takes a label ($label for this span, and the keys of the
* $secondarySpans map for the secondary spans) that's used to indicate to
* the user what that particular span represents.
*
* @throws \InvalidArgumentException if any secondary span has a different source URL than this span.
*
* @param array<string, SourceSpan> $secondarySpans
*/
public function highlightMultiple(string $label, array $secondarySpans): string;
/**
* Return a span from $start bytes (inclusive) to $end bytes
* (exclusive) after the beginning of this span
*/
public function subspan(int $start, ?int $end = null): SourceSpan;
}
@@ -0,0 +1,132 @@
<?php
namespace SourceSpan;
use League\Uri\Contracts\UriInterface;
use SourceSpan\Highlighter\Highlighter;
/**
* A mixin for easily implementing {@see SourceSpan}.
*
* This implements the {@see SourceSpan} methods in terms of {@see getStart}, {@see getEnd}, and
* {@see getText}. This assumes that {@see getStart} and {@see getEnd} have the same source URL, that
* {@see getStart} comes before {@see getEnd}, and that {@see getText} has a number of characters equal
* to the distance between {@see getStart} and {@see getEnd}.
*
* @internal
*/
abstract class SourceSpanMixin implements SourceSpan
{
public function getSourceUrl(): ?UriInterface
{
return $this->getStart()->getSourceUrl();
}
public function getLength(): int
{
return $this->getEnd()->getOffset() - $this->getStart()->getOffset();
}
public function union(SourceSpan $other): SourceSpan
{
if (!Util::isSameUrl($this->getSourceUrl(), $other->getSourceUrl())) {
throw new \InvalidArgumentException("Source URLs \"{$this->getSourceUrl()}\" and \"{$other->getSourceUrl()}\" don't match.");
}
if ($this->getStart()->compareTo($other->getStart()) > 0) {
$start = $other->getStart();
$beginSpan = $other;
} else {
$start = $this->getStart();
$beginSpan = $this;
}
if ($this->getEnd()->compareTo($other->getEnd()) > 0) {
$end = $this->getEnd();
$endSpan = $this;
} else {
$end = $other->getEnd();
$endSpan = $other;
}
if ($beginSpan->getEnd()->compareTo($endSpan->getStart()) < 0) {
throw new \InvalidArgumentException("Spans are disjoint.");
}
$text = $beginSpan->getText() . substr($endSpan->getText(), $beginSpan->getEnd()->distance($endSpan->getStart()));
return new SimpleSourceSpan($start, $end, $text);
}
public function compareTo(SourceSpan $other): int
{
$result = $this->getStart()->compareTo($other->getStart());
if ($result !== 0) {
return $result;
}
return $this->getEnd()->compareTo($other->getEnd());
}
public function message(string $message): string
{
$startLine = $this->getStart()->getLine() + 1;
$startColumn = $this->getStart()->getColumn() + 1;
$sourceUrl = $this->getSourceUrl();
$buffer = "line $startLine, column $startColumn";
if ($sourceUrl !== null) {
$prettyUri = Util::prettyUri($sourceUrl);
$buffer .= " of $prettyUri";
}
$buffer .= ": $message";
$highlight = $this->highlight();
if ($highlight !== '') {
$buffer .= "\n";
$buffer .= $highlight;
}
return $buffer;
}
public function messageMultiple(string $message, string $label, array $secondarySpans): string
{
$startLine = $this->getStart()->getLine() + 1;
$startColumn = $this->getStart()->getColumn() + 1;
$sourceUrl = $this->getSourceUrl();
$buffer = "line $startLine, column $startColumn";
if ($sourceUrl !== null) {
$prettyUri = Util::prettyUri($sourceUrl);
$buffer .= " of $prettyUri";
}
$buffer .= ": $message";
$highlight = $this->highlightMultiple($label, $secondarySpans);
if ($highlight !== '') {
$buffer .= "\n";
$buffer .= $highlight;
}
return $buffer;
}
public function highlight(): string
{
if (!$this instanceof SourceSpanWithContext && $this->getLength() === 0) {
return '';
}
return Highlighter::create($this)->highlight();
}
public function highlightMultiple(string $label, array $secondarySpans): string
{
return Highlighter::multiple($this, $label, $secondarySpans)->highlight();
}
}
@@ -0,0 +1,16 @@
<?php
namespace SourceSpan;
/**
* An interface that describes a segment of source text with additional context.
*/
interface SourceSpanWithContext extends SourceSpan
{
/**
* Text around the span, which includes the line containing this span.
*/
public function getContext(): string;
public function subspan(int $start, ?int $end = null): SourceSpanWithContext;
}
+362
View File
@@ -0,0 +1,362 @@
<?php
namespace SourceSpan;
use League\Uri\Contracts\UriInterface;
use League\Uri\Uri;
/**
* @internal
*/
final class Util
{
/**
* @param iterable<object> $iter
*/
public static function isAllTheSame(iterable $iter): bool
{
$previousValue = null;
foreach ($iter as $value) {
if ($previousValue === null) {
$previousValue = $value;
continue;
}
if (!self::isSame($value, $previousValue)) {
return false;
}
}
return true;
}
/**
* Returns whether 2 objects are the same, considering URIs as the same by equality rather than reference.
*/
public static function isSame(object $object1, object $object2): bool
{
if ($object1 === $object2) {
return true;
}
if ($object1 instanceof UriInterface && $object2 instanceof UriInterface) {
return $object1->toString() === $object2->toString();
}
return false;
}
/**
* Returns whether $span covers multiple lines.
*/
public static function isMultiline(SourceSpan $span): bool
{
return $span->getStart()->getLine() !== $span->getEnd()->getLine();
}
/**
* Sets the first `null` element of $list to $element.
*
* @template E
* @param list<E|null> $list
* @param E $element
*/
public static function replaceFirstNull(array &$list, $element): void
{
$index = array_search(null, $list, true);
if ($index === false) {
throw new \InvalidArgumentException('The list contains no null elements.');
}
// @phpstan-ignore parameterByRef.type
$list[$index] = $element;
\assert(array_is_list($list));
}
/**
* Sets the element of $list that currently contains $element to `null`.
*
* @template E
* @param list<E|null> $list
* @param E $element
*/
public static function replaceWithNull(array &$list, $element): void
{
$index = array_search($element, $list, true);
if ($index === false) {
throw new \InvalidArgumentException('The list contains no matching elements.');
}
// @phpstan-ignore parameterByRef.type
$list[$index] = null;
\assert(array_is_list($list));
}
/**
* Finds a line in $context containing $text at the specified column.
*
* Returns the index in $context where that line begins, or null if none
* exists.
*/
public static function findLineStart(string $context, string $text, int $column): ?int
{
// If the text is empty, we just want to find the first line that has at least
// $column characters.
if ($text === '') {
$beginningOfLine = 0;
while (true) {
$index = strpos($context, "\n", $beginningOfLine);
if ($index === false) {
return \strlen($context) - $beginningOfLine >= $column ? $beginningOfLine : null;
}
if ($index - $beginningOfLine >= $column) {
return $beginningOfLine;
}
$beginningOfLine = $index + 1;
}
}
$index = strpos($context, $text);
while ($index !== false) {
// Start looking before $index in case $text starts with a newline.
$lineStart = $index === 0 ? 0 : Util::lastIndexOf($context, "\n", $index - 1) + 1;
$textColumn = $index - $lineStart;
if ($column === $textColumn) {
return $lineStart;
}
$index = strpos($context, $text, $index + 1);
}
return null;
}
/**
* Returns a two-element list containing the start and end locations of the
* span from $start bytes (inclusive) to $end bytes (exclusive)
* after the beginning of $span.
*
* @return array{SourceLocation, SourceLocation}
*/
public static function subspanLocations(SourceSpan $span, int $start, ?int $end = null): array
{
$text = $span->getText();
$startLocation = $span->getStart();
$line = $startLocation->getLine();
$column = $startLocation->getColumn();
// Adjust $line and $column as necessary if the character at $i in $text
// is a newline.
$consumeCodePoint = function (int $i) use ($text, &$line, &$column) {
$codeUnit = $text[$i];
if (
$codeUnit === "\n" ||
// A carriage return counts as a newline, but only if it's not
// followed by a line feed.
($codeUnit === "\r" && ($i + 1 === \strlen($text) || $text[$i + 1] !== "\n"))
) {
$line += 1;
$column = 0;
} else {
$column += 1;
}
};
for ($i = 0; $i < $start; $i++) {
$consumeCodePoint($i);
}
$newStartLocation = new SimpleSourceLocation($startLocation->getOffset() + $start, $span->getSourceUrl(), $line, $column);
if ($end === null || $end === $span->getLength()) {
$newEndLocation = $span->getEnd();
} elseif ($end === $start) {
$newEndLocation = $newStartLocation;
} else {
for ($i = $start; $i < $end; $i++) {
$consumeCodePoint($i);
}
$newEndLocation = new SimpleSourceLocation($startLocation->getOffset() + $end, $span->getSourceUrl(), $line, $column);
}
return [$newStartLocation, $newEndLocation];
}
/**
* The starting position of the last match $needle in this string.
*
* Finds a match of $needle by searching backward starting at $start.
* Returns -1 if $needle could not be found in this string.
* If $start is omitted, search starts from the end of the string.
*/
public static function lastIndexOf(string $string, string $needle, ?int $start = null): int
{
if ($start === null || $start === \strlen($string)) {
$position = strrpos($string, $needle);
} else {
if ($start < 0) {
throw new \InvalidArgumentException("Start must be a non-negative integer");
}
if ($start > \strlen($string)) {
throw new \InvalidArgumentException("Start must not be greater than the length of the string");
}
$position = strrpos($string, $needle, $start - \strlen($string));
}
return $position === false ? -1 : $position;
}
/**
* Returns the text of the string from $start to $end (exclusive).
*
* If $end isn't passed, it defaults to the end of the string.
*/
public static function substring(string $text, int $start, ?int $end = null): string
{
if ($end === null) {
return substr($text, $start);
}
if ($end < $start) {
$length = 0;
} else {
$length = $end - $start;
}
return substr($text, $start, $length);
}
public static function isSameUrl(?UriInterface $url1, ?UriInterface $url2): bool
{
if ($url1 === null) {
return $url2 === null;
}
if ($url2 === null) {
return false;
}
return (string) $url1 === (string) $url2;
}
/**
* Finds the first index in the list that satisfies the provided $test.
*
* @template E
*
* @param list<E> $list
* @param callable(E): bool $test
*/
public static function indexWhere(array $list, callable $test): ?int
{
foreach ($list as $index => $element) {
if ($test($element)) {
return $index;
}
}
return null;
}
/**
* Check that a range represents a slice of an indexable object.
*
* Throws if the range is not valid for an indexable object with
* the given length.
* A range is valid for an indexable object with a given $length
* if `0 <= $start <= $end <= $length`.
* An `end` of `null` is considered equivalent to `length`.
*
* @throws \OutOfRangeException
*/
public static function checkValidRange(int $start, ?int $end, int $length, ?string $startName = null, ?string $endName = null): void
{
if ($start < 0 || $start > $length) {
$startName ??= 'start';
$startNameDisplay = $startName ? " $startName" : '';
throw new \OutOfRangeException("Invalid value:$startNameDisplay must be between 0 and $length: $start.");
}
if ($end !== null) {
if ($end < $start || $end > $length) {
$endName ??= 'end';
$endNameDisplay = $endName ? " $endName" : '';
throw new \OutOfRangeException("Invalid value:$endNameDisplay must be between $start and $length: $end.");
}
}
}
/**
* @template T
*
* @param list<T> $list
*
* @return T
*/
public static function listLast(array $list)
{
$count = count($list);
if ($count === 0) {
throw new \LogicException('The list may not be empty.');
}
return $list[$count - 1];
}
/**
* Returns a pretty URI for a path
*/
public static function prettyUri(string|UriInterface $path): string
{
if ($path instanceof UriInterface) {
if ($path->getScheme() !== 'file') {
return (string) $path;
}
$path = self::pathFromUri($path);
}
$normalizedPath = $path;
$normalizedRootDirectory = getcwd() . '/';
if (\DIRECTORY_SEPARATOR === '\\') {
$normalizedRootDirectory = str_replace('\\', '/', $normalizedRootDirectory);
$normalizedPath = str_replace('\\', '/', $path);
}
if (str_starts_with($normalizedPath, $normalizedRootDirectory)) {
return substr($path, \strlen($normalizedRootDirectory));
}
return $path;
}
private static function pathFromUri(UriInterface $uri): string
{
if (!$uri instanceof Uri) {
$uri = Uri::new($uri);
}
if (\DIRECTORY_SEPARATOR === '\\') {
return $uri->toWindowsPath() ?? throw new \InvalidArgumentException("Uri $uri must have scheme 'file:'.");
}
return $uri->toUnixPath() ?? throw new \InvalidArgumentException("Uri $uri must have scheme 'file:'.");
}
}