feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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:'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user