Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions features/understand/semantic_tokens.feature
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ Feature: Semantic tokens
Then a "typeParameter" token covers "int" in "/turbofish.xphp"
And a "typeParameter" token covers "User" in "/turbofish.xphp"

Scenario: Multiline block comment highlights on every physical line
Given the file at "/doc.xphp" contains the following lines:
"""
<?php
/**
*
*/
"""
And the FQN index has been warmed on initialize
When I request "textDocument/semanticTokens/full" for "/doc.xphp"
Then a "comment" token covers "/**" in "/doc.xphp"
And a "comment" token covers " *" in "/doc.xphp"
And a "comment" token covers " */" in "/doc.xphp"

Scenario: Highlight an interpolated variable inside a double-quoted string
Given the file at "/Str.xphp" contains the following lines:
"""
Expand Down
27 changes: 26 additions & 1 deletion src/Handler/SemanticTokens/AstVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ private function collectFromTokens(array &$out, array $reclassifyVariableAt = []
$type = 'keyword';
}
if ($type !== null) {
$this->emit($out, $token->pos, strlen($token->text), $type);
$this->emitSplitting($out, $token->pos, $token->text, $type);
}
}

Expand Down Expand Up @@ -567,6 +567,31 @@ private static function classLikeType(ClassLike $node): string
*
* @param list<TokenSpec> $out
*/
/**
* Emit one token per physical line covered by $text, starting at
* $originalOffset. Required by the LSP spec: "Tokens cannot … span
* multiple lines." Single-line tokens take the fast path.
*
* @param list<TokenSpec> $out
*/
private function emitSplitting(array &$out, int $originalOffset, string $text, string $type, array $modifiers = []): void
{
if (!str_contains($text, "\n")) {
$this->emit($out, $originalOffset, strlen($text), $type, $modifiers);
return;
}

$offset = $originalOffset;
foreach (explode("\n", $text) as $segment) {
// Strip a trailing \r so \r\n line endings don't inflate the length.
$visibleLen = strlen(rtrim($segment, "\r"));
if ($visibleLen > 0) {
$this->emit($out, $offset, $visibleLen, $type, $modifiers);
}
$offset += strlen($segment) + 1; // +1 for the consumed \n
}
}

public function emit(array &$out, int $originalOffset, int $length, string $type, array $modifiers = []): void
{
if ($length <= 0) {
Expand Down
48 changes: 48 additions & 0 deletions test/Handler/SemanticTokens/AstVisitorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,54 @@ public function testDocCommentIsClassifiedAsComment(): void
$this->assertTokenSubstring($specs, $source, '/** doc */', 'comment');
}

public function testMultilineBlockCommentEmitsTokenOnEachLine(): void
{
// LSP spec: tokens cannot span line boundaries. A three-line docblock
// must produce one comment token per physical line.
$source = "<?php\n/**\n *\n */";
$specs = $this->collect($source);

$commentLines = array_values(array_unique(array_map(
fn (TokenSpec $s) => $s->line,
array_filter($specs, fn (TokenSpec $s) => $s->type === 'comment'),
)));
sort($commentLines);
self::assertSame(
[1, 2, 3],
$commentLines,
'each physical line of the docblock must carry a comment token',
);
}

public function testNoTokenSpansMultipleLines(): void
{
// General LSP invariant: no emitted token may have a length that
// carries past the end of its own line.
$source = "<?php\n/**\n * @param int \$x\n * @return void\n */\nfunction f(int \$x): void {}";
$specs = $this->collect($source);
$lines = explode("\n", $source);

foreach ($specs as $spec) {
$lineContent = $lines[$spec->line] ?? '';
// UTF-16 length of the content from startChar to end of line.
$lineFromStart = substr($lineContent, $spec->startChar);
$maxLen = PositionMap::lengthInUtf16($lineFromStart);
self::assertLessThanOrEqual(
$maxLen,
$spec->length,
sprintf(
'token %s at L%d C%d has length %d which extends past line end (max %d): %s',
$spec->type,
$spec->line,
$spec->startChar,
$spec->length,
$maxLen,
json_encode($lineContent),
),
);
}
}

// --- Pass 2: AST -------------------------------------------------------

public function testClassNameIsClassified(): void
Expand Down
Loading