diff options
Diffstat (limited to 'vendor/scssphp/source-span/src')
17 files changed, 2178 insertions, 0 deletions
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:'."); + } +} |