aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/scssphp/source-span
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/scssphp/source-span')
-rw-r--r--vendor/scssphp/source-span/LICENSE.md20
-rw-r--r--vendor/scssphp/source-span/README.md22
-rw-r--r--vendor/scssphp/source-span/composer.json42
-rw-r--r--vendor/scssphp/source-span/src/ConcreteFileSpan.php156
-rw-r--r--vendor/scssphp/source-span/src/FileLocation.php52
-rw-r--r--vendor/scssphp/source-span/src/FileSpan.php20
-rw-r--r--vendor/scssphp/source-span/src/Highlighter/AsciiGlyph.php18
-rw-r--r--vendor/scssphp/source-span/src/Highlighter/Highlight.php205
-rw-r--r--vendor/scssphp/source-span/src/Highlighter/Highlighter.php538
-rw-r--r--vendor/scssphp/source-span/src/Highlighter/Line.php40
-rw-r--r--vendor/scssphp/source-span/src/SimpleSourceLocation.php59
-rw-r--r--vendor/scssphp/source-span/src/SimpleSourceSpan.php53
-rw-r--r--vendor/scssphp/source-span/src/SimpleSourceSpanWithContext.php68
-rw-r--r--vendor/scssphp/source-span/src/SourceFile.php272
-rw-r--r--vendor/scssphp/source-span/src/SourceLocation.php47
-rw-r--r--vendor/scssphp/source-span/src/SourceLocationMixin.php34
-rw-r--r--vendor/scssphp/source-span/src/SourceSpan.php110
-rw-r--r--vendor/scssphp/source-span/src/SourceSpanMixin.php132
-rw-r--r--vendor/scssphp/source-span/src/SourceSpanWithContext.php16
-rw-r--r--vendor/scssphp/source-span/src/Util.php358
20 files changed, 2262 insertions, 0 deletions
diff --git a/vendor/scssphp/source-span/LICENSE.md b/vendor/scssphp/source-span/LICENSE.md
new file mode 100644
index 000000000..4b352a1f9
--- /dev/null
+++ b/vendor/scssphp/source-span/LICENSE.md
@@ -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.
diff --git a/vendor/scssphp/source-span/README.md b/vendor/scssphp/source-span/README.md
new file mode 100644
index 000000000..d7a7524e5
--- /dev/null
+++ b/vendor/scssphp/source-span/README.md
@@ -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).
diff --git a/vendor/scssphp/source-span/composer.json b/vendor/scssphp/source-span/composer.json
new file mode 100644
index 000000000..d76e32f58
--- /dev/null
+++ b/vendor/scssphp/source-span/composer.json
@@ -0,0 +1,42 @@
+{
+ "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",
+ "league/uri": "^7.4",
+ "league/uri-interfaces": "^7.4"
+ },
+ "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": "^5.1",
+ "symfony/var-dumper": "^6.3"
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.x-dev"
+ }
+ },
+ "config": {
+ "sort-packages": true
+ }
+}
diff --git a/vendor/scssphp/source-span/src/ConcreteFileSpan.php b/vendor/scssphp/source-span/src/ConcreteFileSpan.php
new file mode 100644
index 000000000..43a0bc149
--- /dev/null
+++ b/vendor/scssphp/source-span/src/ConcreteFileSpan.php
@@ -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);
+ }
+}
diff --git a/vendor/scssphp/source-span/src/FileLocation.php b/vendor/scssphp/source-span/src/FileLocation.php
new file mode 100644
index 000000000..8028196c6
--- /dev/null
+++ b/vendor/scssphp/source-span/src/FileLocation.php
@@ -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);
+ }
+}
diff --git a/vendor/scssphp/source-span/src/FileSpan.php b/vendor/scssphp/source-span/src/FileSpan.php
new file mode 100644
index 000000000..0ff936844
--- /dev/null
+++ b/vendor/scssphp/source-span/src/FileSpan.php
@@ -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;
+}
diff --git a/vendor/scssphp/source-span/src/Highlighter/AsciiGlyph.php b/vendor/scssphp/source-span/src/Highlighter/AsciiGlyph.php
new file mode 100644
index 000000000..70af345a4
--- /dev/null
+++ b/vendor/scssphp/source-span/src/Highlighter/AsciiGlyph.php
@@ -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 = '=';
+}
diff --git a/vendor/scssphp/source-span/src/Highlighter/Highlight.php b/vendor/scssphp/source-span/src/Highlighter/Highlight.php
new file mode 100644
index 000000000..16f9ceaea
--- /dev/null
+++ b/vendor/scssphp/source-span/src/Highlighter/Highlight.php
@@ -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());
+ }
+}
diff --git a/vendor/scssphp/source-span/src/Highlighter/Highlighter.php b/vendor/scssphp/source-span/src/Highlighter/Highlighter.php
new file mode 100644
index 000000000..b8b7aee24
--- /dev/null
+++ b/vendor/scssphp/source-span/src/Highlighter/Highlighter.php
@@ -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);
+ }
+ }
+}
diff --git a/vendor/scssphp/source-span/src/Highlighter/Line.php b/vendor/scssphp/source-span/src/Highlighter/Line.php
new file mode 100644
index 000000000..0dc800c38
--- /dev/null
+++ b/vendor/scssphp/source-span/src/Highlighter/Line.php
@@ -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;
+ }
+}
diff --git a/vendor/scssphp/source-span/src/SimpleSourceLocation.php b/vendor/scssphp/source-span/src/SimpleSourceLocation.php
new file mode 100644
index 000000000..591f6e4b0
--- /dev/null
+++ b/vendor/scssphp/source-span/src/SimpleSourceLocation.php
@@ -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 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;
+ }
+}
diff --git a/vendor/scssphp/source-span/src/SimpleSourceSpan.php b/vendor/scssphp/source-span/src/SimpleSourceSpan.php
new file mode 100644
index 000000000..7c29498bb
--- /dev/null
+++ b/vendor/scssphp/source-span/src/SimpleSourceSpan.php
@@ -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));
+ }
+}
diff --git a/vendor/scssphp/source-span/src/SimpleSourceSpanWithContext.php b/vendor/scssphp/source-span/src/SimpleSourceSpanWithContext.php
new file mode 100644
index 000000000..b854a52b0
--- /dev/null
+++ b/vendor/scssphp/source-span/src/SimpleSourceSpanWithContext.php
@@ -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);
+ }
+}
diff --git a/vendor/scssphp/source-span/src/SourceFile.php b/vendor/scssphp/source-span/src/SourceFile.php
new file mode 100644
index 000000000..33bc5502c
--- /dev/null
+++ b/vendor/scssphp/source-span/src/SourceFile.php
@@ -0,0 +1,272 @@
+<?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.
+ */
+ public function getColumn(int $offset): int
+ {
+ $line = $this->getLine($offset);
+
+ return $offset - $this->lineStarts[$line];
+ }
+
+ /**
+ * 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.');
+ }
+
+ $result = $this->lineStarts[$line] + $column;
+
+ 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);
+ }
+}
diff --git a/vendor/scssphp/source-span/src/SourceLocation.php b/vendor/scssphp/source-span/src/SourceLocation.php
new file mode 100644
index 000000000..99792350a
--- /dev/null
+++ b/vendor/scssphp/source-span/src/SourceLocation.php
@@ -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;
+}
diff --git a/vendor/scssphp/source-span/src/SourceLocationMixin.php b/vendor/scssphp/source-span/src/SourceLocationMixin.php
new file mode 100644
index 000000000..1649a3d1d
--- /dev/null
+++ b/vendor/scssphp/source-span/src/SourceLocationMixin.php
@@ -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();
+ }
+}
diff --git a/vendor/scssphp/source-span/src/SourceSpan.php b/vendor/scssphp/source-span/src/SourceSpan.php
new file mode 100644
index 000000000..5badc3adf
--- /dev/null
+++ b/vendor/scssphp/source-span/src/SourceSpan.php
@@ -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;
+}
diff --git a/vendor/scssphp/source-span/src/SourceSpanMixin.php b/vendor/scssphp/source-span/src/SourceSpanMixin.php
new file mode 100644
index 000000000..b9bbdb01b
--- /dev/null
+++ b/vendor/scssphp/source-span/src/SourceSpanMixin.php
@@ -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();
+ }
+}
diff --git a/vendor/scssphp/source-span/src/SourceSpanWithContext.php b/vendor/scssphp/source-span/src/SourceSpanWithContext.php
new file mode 100644
index 000000000..383a6c7c2
--- /dev/null
+++ b/vendor/scssphp/source-span/src/SourceSpanWithContext.php
@@ -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;
+}
diff --git a/vendor/scssphp/source-span/src/Util.php b/vendor/scssphp/source-span/src/Util.php
new file mode 100644
index 000000000..ccddb009c
--- /dev/null
+++ b/vendor/scssphp/source-span/src/Util.php
@@ -0,0 +1,358 @@
+<?php
+
+namespace SourceSpan;
+
+use League\Uri\BaseUri;
+use League\Uri\Contracts\UriInterface;
+
+/**
+ * @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 (\DIRECTORY_SEPARATOR === '\\') {
+ return BaseUri::from($uri)->windowsPath() ?? throw new \InvalidArgumentException("Uri $uri must have scheme 'file:'.");
+ }
+
+ return BaseUri::from($uri)->unixPath() ?? throw new \InvalidArgumentException("Uri $uri must have scheme 'file:'.");
+ }
+}