Classify the panel's code column with real token types
The panel guessed that any capitalised identifier was a type, so `Profiler.Frames.Count` came out as three type-coloured names where the editor shows a class and two members. The semantic overlay already fetches per-file tokens, so feed the same data to the webview. - codeSpans() returns the server's token spans per referenced line in the row's own trimmed coordinates; rows carry them and the webview colours from them, mapping token type names to its palette. Fields, properties, events and methods share the member colour, as they do in the stock themes. - The regex tokenizer stays as the fallback for servers that serve no semantic tokens, and coloredReferences.semanticTokens now gates both views rather than just the editor. Colours are still the approximated Dark+/Light+ palette — a webview is not given the theme's token colours — so only the editor view can be theme-exact. The README says so instead of promising this would fix it. Two things the new tests establish, both assumptions the code was already making: all files in a result share the origin's legend, so decoding tokens from every file against one legend is sound; and all 237 relocated tokens in the Profiler search report the same type name as their source token. Also relaxes an over-specific assertion: DotRush calls Profiler.Frames a field, not a property. Either way it is a member, which is what matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
import { SemanticOverlay, decodeTokens } from '../../semantic';
|
||||
import { buildRows, codeSpans } from '../../panel';
|
||||
import { CODE_INDENT, gather, renderDocument } from '../../references';
|
||||
|
||||
const TARGET_FILE = path.join('Nerfed.Runtime', 'Profiler.cs');
|
||||
@@ -131,6 +132,143 @@ suite('Semantic token overlay', () => {
|
||||
`${failures.length} mismatched token(s):\n${failures.slice(0, 10).join('\n')}`);
|
||||
});
|
||||
|
||||
test('every result file uses the same legend as the origin', async () => {
|
||||
// The overlay fetches one legend (from the origin) but tokens from every file,
|
||||
// so a file whose provider numbers its token types differently would decode to
|
||||
// the wrong names — and therefore the wrong colours.
|
||||
const locations = await vscode.commands.executeCommand<vscode.Location[]>(
|
||||
'vscode.executeReferenceProvider', sourceUri, position);
|
||||
const results = await gather(
|
||||
TARGET_SYMBOL, 'csharp', { uri: sourceUri, position }, locations ?? [], true);
|
||||
assert.ok(results.files.length > 1, 'need a multi-file result to test this');
|
||||
|
||||
const origin = await vscode.commands.executeCommand<vscode.SemanticTokensLegend>(
|
||||
LEGEND_COMMAND, results.origin.uri);
|
||||
assert.ok(origin);
|
||||
|
||||
const differences: string[] = [];
|
||||
for (const file of results.files) {
|
||||
await vscode.workspace.openTextDocument(file.uri);
|
||||
const legend = await vscode.commands.executeCommand<vscode.SemanticTokensLegend>(
|
||||
LEGEND_COMMAND, file.uri);
|
||||
if (!legend) {
|
||||
differences.push(`${path.basename(file.uri.fsPath)}: no legend`);
|
||||
continue;
|
||||
}
|
||||
if (legend.tokenTypes.join() !== origin.tokenTypes.join()) {
|
||||
differences.push(`${path.basename(file.uri.fsPath)}: token types differ`);
|
||||
}
|
||||
if (legend.tokenModifiers.join() !== origin.tokenModifiers.join()) {
|
||||
differences.push(`${path.basename(file.uri.fsPath)}: token modifiers differ`);
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(differences, [], differences.join('\n'));
|
||||
});
|
||||
|
||||
test('a relocated token reports the same type name as the source token', async () => {
|
||||
const locations = await vscode.commands.executeCommand<vscode.Location[]>(
|
||||
'vscode.executeReferenceProvider', sourceUri, position);
|
||||
const results = await gather(
|
||||
TARGET_SYMBOL, 'csharp', { uri: sourceUri, position }, locations ?? [], true);
|
||||
const rendered = renderDocument(results);
|
||||
const legend = await vscode.commands.executeCommand<vscode.SemanticTokensLegend>(
|
||||
LEGEND_COMMAND, sourceUri);
|
||||
assert.ok(legend);
|
||||
|
||||
const overlay = new SemanticOverlay();
|
||||
const uri = vscode.Uri.from({ scheme: 'colored-refs', path: '/semantic-names.cs', query: 't' });
|
||||
assert.ok(await overlay.build(uri, rendered.lines, results));
|
||||
const relocated = decodeTokens(overlay.peek(uri)!.data);
|
||||
|
||||
// Rebuild the source-side answer independently and compare type names.
|
||||
const mismatches: string[] = [];
|
||||
let compared = 0;
|
||||
for (const file of results.files) {
|
||||
await vscode.workspace.openTextDocument(file.uri);
|
||||
const raw = await vscode.commands.executeCommand<vscode.SemanticTokens>(
|
||||
TOKENS_COMMAND, file.uri);
|
||||
if (!raw?.data) {
|
||||
continue;
|
||||
}
|
||||
const sourceTokens = decodeTokens(raw.data);
|
||||
const displayed = new Map<number, number>(); // source line -> results row
|
||||
rendered.lines.forEach((line, row) => {
|
||||
if (line.kind === 'code' && line.file?.toString() === file.uri.toString() &&
|
||||
line.sourceLine !== undefined) {
|
||||
displayed.set(line.sourceLine, row);
|
||||
}
|
||||
});
|
||||
|
||||
for (const token of sourceTokens) {
|
||||
const row = displayed.get(token.line);
|
||||
if (row === undefined) {
|
||||
continue;
|
||||
}
|
||||
const here = relocated.filter(r => r.line === row);
|
||||
const match = here.find(r => r.length === token.length &&
|
||||
legend.tokenTypes[r.type] === legend.tokenTypes[token.type]);
|
||||
compared++;
|
||||
if (!match) {
|
||||
mismatches.push(`${path.basename(file.uri.fsPath)}:${token.line + 1} ` +
|
||||
`${legend.tokenTypes[token.type]} (len ${token.length}) has no ` +
|
||||
`counterpart on results row ${row}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[test] compared ${compared} source tokens, ${mismatches.length} unmatched`);
|
||||
assert.deepStrictEqual(mismatches.slice(0, 8), [],
|
||||
`${mismatches.length} unmatched:\n${mismatches.slice(0, 8).join('\n')}`);
|
||||
});
|
||||
|
||||
test('panel rows carry token spans that name the real token type', async () => {
|
||||
const locations = await vscode.commands.executeCommand<vscode.Location[]>(
|
||||
'vscode.executeReferenceProvider', sourceUri, position);
|
||||
const results = await gather(
|
||||
TARGET_SYMBOL, 'csharp', { uri: sourceUri, position }, locations ?? [], true);
|
||||
|
||||
const spans = await codeSpans(results);
|
||||
assert.ok(spans.size > 0, 'no token spans for the panel');
|
||||
const { rows } = buildRows(results, new Map(), new Map(), spans);
|
||||
assert.ok(rows.some(r => r.spans.length > 0), 'no row carries token spans');
|
||||
|
||||
// Spans are in the row's own trimmed coordinates, so slicing the code must give
|
||||
// back a real token, and the type must come from the server's legend.
|
||||
const failures: string[] = [];
|
||||
const seen = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
for (const span of row.spans) {
|
||||
const text = row.code.slice(span.start, span.end);
|
||||
if (span.start < 0 || span.end > row.code.length) {
|
||||
failures.push(`${row.file}:${row.line} span ${span.start}-${span.end} ` +
|
||||
`is outside ${JSON.stringify(row.code)}`);
|
||||
} else if (text.trim().length === 0) {
|
||||
failures.push(`${row.file}:${row.line} span covers whitespace`);
|
||||
}
|
||||
if (text.trim()) {
|
||||
seen.set(text, span.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(failures.slice(0, 8), [], failures.slice(0, 8).join('\n'));
|
||||
|
||||
// The regression this fixes: `Profiler.Frames.Count` used to render all three as
|
||||
// types because they are capitalised. The server calls them class + property.
|
||||
console.log('[test] sample classifications: ' + ['Profiler', 'Frames', 'Count', 'if']
|
||||
.filter(t => seen.has(t)).map(t => `${t}=${seen.get(t)}`).join(', '));
|
||||
assert.strictEqual(seen.get(TARGET_SYMBOL), 'class');
|
||||
// The point is that these are *members*, not types — which is what the regex
|
||||
// tokenizer called them, since they are capitalised. Field vs property vs method
|
||||
// does not matter here; they share a colour, as they do in the stock themes.
|
||||
const members = ['property', 'field', 'event', 'method'];
|
||||
for (const name of ['Frames', 'Count', 'IsRecording', 'SetActive']) {
|
||||
const type = seen.get(name);
|
||||
if (type) {
|
||||
assert.ok(members.includes(type),
|
||||
`${name} should be a member, not ${type}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the searched symbol is classified as a type', async () => {
|
||||
const legend = await vscode.commands.executeCommand<vscode.SemanticTokensLegend>(
|
||||
LEGEND_COMMAND, sourceUri);
|
||||
|
||||
Reference in New Issue
Block a user