Files
core/test/editor-vscode/navigation.test.cjs
T
2026-11-03 03:32:07 +00:00

247 lines
15 KiB
JavaScript

const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const Module = require('node:module');
const { pathToFileURL, fileURLToPath } = require('node:url');
// VS Code 是外部边界;源码关系由真实候选编译器生成,不在测试中伪造索引。
class Uri {
constructor(url) { this.url = url; }
static file(value) { return new Uri(pathToFileURL(value)); }
static parse(value) { return new Uri(new URL(value)); }
static joinPath(base, ...segments) { return Uri.file(path.join(base.fsPath, ...segments)); }
static from(value) {
const url = new URL(`${value.scheme}:/`);
url.pathname = value.path;
if (value.query) url.search = value.query;
return new Uri(url);
}
get scheme() { return this.url.protocol.slice(0, -1); }
get fsPath() { return this.scheme === 'file' ? fileURLToPath(this.url) : this.url.pathname; }
get query() { return decodeURIComponent(this.url.search.slice(1)); }
with(value) { const url = new URL(this.url); if (value.fragment !== undefined) url.hash = value.fragment; if (value.query !== undefined) url.search = value.query; return new Uri(url); }
toString() { return this.url.toString(); }
}
class Range {
constructor(first, second, third, fourth) {
this.start = typeof first === 'number' ? { line: first, character: second } : first;
this.end = typeof first === 'number' ? { line: third, character: fourth } : second;
}
}
class EventEmitter {
callbacks = new Set();
event = callback => { this.callbacks.add(callback); return { dispose: () => this.callbacks.delete(callback) }; };
fire(value) { for (const callback of this.callbacks) callback(value); }
dispose() { this.callbacks.clear(); }
}
test('generated dispatch remains in call hierarchy without hijacking source definitions', async () => {
const run = require('node:util').promisify(require('node:child_process').execFile);
const { at, relations } = require('../../editor/vscode/graph.cjs');
for (const name of ['auth', 'adapters']) {
const root = path.resolve(__dirname, '../rust/fixtures', name);
const result = await run(process.env.DEVER_TEST_BIN, ['inspect', root, '--json'], { maxBuffer: 8 * 1024 * 1024 });
const index = JSON.parse(result.stdout);
if (name === 'auth') {
for (const call of index.calls.filter(call => call.kind === 'http')) {
assert.deepEqual(at(index, call.site.file, call.site.start).map(symbol => symbol.id), [call.callee]);
assert.ok(relations(index, call.caller, false).some(group => group.calls.some(edge => edge.kind === 'verify')));
}
const rest = index.symbols.find(symbol => symbol.kind === 'rest');
assert.deepEqual(at(index, rest.declarations[0].file, rest.declarations[0].start).map(symbol => symbol.id), [rest.id]);
} else {
const port = index.symbols.find(symbol => symbol.name === 'demo.value.port.calculate');
assert.deepEqual(at(index, port.declarations[0].file, port.declarations[0].start).map(symbol => symbol.id), [port.id]);
assert.equal(relations(index, port.id, false).filter(group => group.calls.some(edge => edge.kind === 'adapter')).length, 2);
}
}
});
test('real compiler powers navigation, documentation hover and saved-source refresh', async () => {
assert.ok(process.env.DEVER_TEST_BIN, 'set DEVER_TEST_BIN to the built candidate compiler');
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dever editor '));
const sourcePath = path.join(root, 'main.dever');
const code = 'label(name: Text) (text: Text) {\n text = name\n}\nfirst(name: Text) (text: Text) {\n text = "😀" + label(name) + label("另")\n}\nsecond(name: Text) (text: Text) {\n text = label(name)\n}\n';
const source = code;
await fs.writeFile(sourcePath, source);
const providers = {};
const commands = {};
const executed = [];
const panels = [];
const opened = [];
const documents = new Map();
const saved = new EventEmitter();
const disposable = () => ({ dispose() {} });
const context = { subscriptions: [], extensionUri: Uri.file(path.resolve(__dirname, '../../editor/vscode')) };
function document(uri, text) {
return {
uri, languageId: uri.fsPath.endsWith('.md') ? 'markdown' : 'dever', isDirty: false, text,
offsetAt(position) {
return this.text.split('\n').slice(0, position.line).reduce((offset, line) => offset + line.length + 1, 0) + position.character;
},
getText(range) { return range ? this.text.slice(this.offsetAt(range.start), this.offsetAt(range.end)) : this.text; }
};
}
const vscode = {
Uri, Range, EventEmitter,
MarkdownString: class {
value = '';
appendMarkdown(text) { this.value += text; return this; }
appendCodeblock(text, language) { this.value += '```' + language + '\n' + text + '\n```\n'; return this; }
},
Hover: class { constructor(contents) { this.contents = contents; } },
Location: class { constructor(uri, range) { Object.assign(this, { uri, range }); } },
CodeLens: class { constructor(range, command) { Object.assign(this, { range, command }); } },
CallHierarchyItem: class { constructor(kind, name, detail, uri, range, selectionRange) { Object.assign(this, { kind, name, detail, uri, range, selectionRange }); } },
CallHierarchyIncomingCall: class { constructor(from, fromRanges) { Object.assign(this, { from, fromRanges }); } },
CallHierarchyOutgoingCall: class { constructor(to, fromRanges) { Object.assign(this, { to, fromRanges }); } },
SymbolKind: { Function: 11 },
ViewColumn: { One: 1, Beside: 2 },
window: {
createOutputChannel: () => ({ appendLine() {}, dispose() {} }),
showErrorMessage(message) { throw new Error(message); },
async showTextDocument(document, options) { opened.push({ document, options }); },
createWebviewPanel(type, title, column, options) {
const disposed = new EventEmitter();
const panel = { type, title, column, options, dispose() { disposed.fire(); }, onDidDispose: disposed.event,
webview: { cspSource: 'https://webview.test', asWebviewUri: uri => uri,
onDidReceiveMessage(callback) { panel.message = callback; return disposable(); } }
};
panels.push(panel);
return panel;
}
},
commands: { registerCommand(name, callback) { commands[name] = callback; return disposable(); }, executeCommand: async (...values) => { executed.push(values); } },
languages: {},
workspace: {
isTrusted: true,
get textDocuments() { return [...documents.values()]; },
getWorkspaceFolder: () => ({ uri: Uri.file(root) }),
getConfiguration: () => ({ get: () => [process.env.DEVER_TEST_BIN] }),
onDidSaveTextDocument: saved.event, onDidChangeConfiguration: disposable, onDidCloseTextDocument: disposable,
createFileSystemWatcher: () => ({ onDidChange: disposable, onDidCreate: disposable, onDidDelete: disposable, dispose() {} }),
registerTextDocumentContentProvider(scheme, provider) { providers[scheme] = provider; return disposable(); },
async openTextDocument(uri) {
const key = uri.toString();
if (!documents.has(key)) {
const text = uri.scheme === 'file' ? await fs.readFile(uri.fsPath, 'utf8') : await providers[uri.scheme].provideTextDocumentContent(uri);
documents.set(key, document(uri, text));
}
return documents.get(key);
}
}
};
for (const name of ['Definition', 'Reference', 'CallHierarchy', 'CodeLens', 'Hover']) {
vscode.languages[`register${name}Provider`] = (_, provider) => { providers[name] = provider; return disposable(); };
}
const originalLoad = Module._load;
Module._load = function(request, ...rest) { return request === 'vscode' ? vscode : originalLoad.call(this, request, ...rest); };
try {
const { registerNavigation } = require('../../editor/vscode/navigation.cjs');
registerNavigation(context);
Module._load = originalLoad;
const doc = await vscode.workspace.openTextDocument(Uri.file(sourcePath));
const callLine = source.split('\n').findIndex(line => line.includes('"😀"'));
const position = { line: callLine, character: source.split('\n')[callLine].indexOf('label') + 1 };
const definitions = await providers.Definition.provideDefinition(doc, position);
assert.equal(definitions.length, 1);
assert.equal(definitions[0].uri.fsPath, sourcePath);
assert.equal(definitions[0].range.start.line, source.split('\n').findIndex(line => line.startsWith('label(')));
const references = await providers.Reference.provideReferences(doc, position, { includeDeclaration: false });
assert.equal(references.length, 3);
assert.equal(references[0].range.start.character, source.split('\n')[callLine].indexOf('label'));
const [target] = await providers.CallHierarchy.prepareCallHierarchy(doc, position);
const incoming = await providers.CallHierarchy.provideCallHierarchyIncomingCalls(target);
assert.equal(incoming.length, 2);
assert.deepEqual(incoming.map(call => call.fromRanges.length).sort(), [1, 2]);
const outgoing = await providers.CallHierarchy.provideCallHierarchyOutgoingCalls(incoming[0].from);
assert.equal(outgoing[0].to.name, 'main.label');
const lenses = await providers.CodeLens.provideCodeLenses(doc);
assert.match(lenses[0].command.title, /2 个调用方 · 3 处调用/);
const preview = Uri.from({ scheme: 'dever-calls', path: '/calls.md', query: JSON.stringify({ root, name: 'main.label' }) });
const markdown = await providers['dever-calls'].provideTextDocumentContent(preview);
assert.match(markdown, /## 定义/);
assert.ok(markdown.includes('```dever'));
assert.match(markdown, /\| 函数 \| 领域/);
assert.match(markdown, /main.first/);
assert.match(markdown, /main.second/);
assert.ok(markdown.includes(`#L${callLine + 1}`));
await commands['dever.showCalls'](root, 'main.label');
const callsPanel = panels.at(-1);
assert.match(callsPanel.webview.html, /<table>/);
assert.match(callsPanel.webview.html, /<a href="file:/);
const sourceHref = /<a href="(file:[^"]+)"/.exec(callsPanel.webview.html)[1];
await callsPanel.message({ type: 'open', href: sourceHref });
assert.equal(opened.at(-1).document.uri.fsPath, sourcePath);
await callsPanel.message({ type: 'open', href: 'command:dever.showCalls?' + encodeURIComponent(JSON.stringify([root, 'main.first'])) });
assert.match(callsPanel.webview.html, /<h1>main.first<\/h1>/);
callsPanel.dispose();
doc.isDirty = true;
assert.deepEqual(await providers.Definition.provideDefinition(doc, position), []);
doc.isDirty = false;
doc.text = source.replace('text = label(name)', 'text = name');
await fs.writeFile(sourcePath, doc.text);
saved.fire(doc);
assert.equal((await providers.Reference.provideReferences(doc, position, { includeDeclaration: false })).length, 2);
doc.text = source.replace('label(name)', 'missing(name)');
await fs.writeFile(sourcePath, doc.text);
saved.fire(doc);
await assert.rejects(providers.Definition.provideDefinition(doc, position), /unresolved|unknown|not found/);
const project = path.join(root, 'application');
const run = require('node:util').promisify(require('node:child_process').execFile);
await run(process.env.DEVER_TEST_BIN, ['new', project]);
await run(process.env.DEVER_TEST_BIN, ['inspect', project, '--links']);
const app = await vscode.workspace.openTextDocument(Uri.file(path.join(project, 'module/hello/greeting/app.dever')));
vscode.window.activeTextEditor = { document: app, selection: { active: { line: 0, character: 1 } } };
const hover = await providers.Hover.provideHover(app, { line: 0, character: 1 });
assert.match(hover.contents[0].value, /greet\(name: Text\) \(message: Text\)/);
assert.match(hover.contents[0].value, /按原样保留/);
assert.match(hover.contents[0].value, /hello\.greeting\.api\.greet/);
assert.match(hover.contents[0].value, /command:dever.showCalls/);
const api = await vscode.workspace.openTextDocument(Uri.file(path.join(project, 'module/hello/greeting/api.dever')));
assert.match((await providers.Hover.provideHover(api, { line: 0, character: api.getText().indexOf('app.greet') + 5 })).contents[0].value, /按原样保留/);
const topic = await vscode.workspace.openTextDocument(Uri.file(path.join(project, 'module/hello/greeting/doc/greeting.md')));
topic.text = topic.text.replace('按原样保留', '输入不做改写');
topic.isDirty = true;
await assert.rejects(providers.Hover.provideHover(app, { line: 0, character: 1 }), /保存/);
await fs.writeFile(topic.uri.fsPath, topic.text);
topic.isDirty = false;
saved.fire(topic);
assert.match((await providers.Hover.provideHover(app, { line: 0, character: 1 })).contents[0].value, /输入不做改写/);
await commands['dever.previewDocumentation'](project, 'hello.greeting.greet');
assert.equal(executed.at(-1)[0], 'markdown.showPreviewToSide');
await commands['dever.openDomainLinks']();
const panel = panels.at(-1);
assert.equal(panel.type, 'dever.links');
assert.match(panel.webview.html, /language-mermaid/);
assert.match(panel.webview.html, /flowchart TD/);
assert.match(panel.webview.html, /flow-browser\.js/);
await panel.message({ type: 'open', href: 'app.dever#L15,3' });
assert.equal(opened.at(-1).document.uri.fsPath, app.uri.fsPath);
assert.deepEqual(opened.at(-1).options.selection.start, { line: 14, character: 2 });
const anchor = /id="(flow-[^"]+)"/.exec(panel.webview.html)[1];
await panel.message({ type: 'open', href: `link.md#${anchor}` });
assert.ok(panel.webview.html.includes(`data-anchor="${anchor}"`));
await assert.rejects(panel.message({ type: 'open', href: 'https://example.invalid/' }), /只打开项目/);
await assert.rejects(panel.message({ type: 'open', href: '../../../../outside.dever' }), /超出当前项目/);
topic.text = topic.text.replace('## hello.greeting.greet', '## hello.greeting.missing');
await fs.writeFile(topic.uri.fsPath, topic.text);
saved.fire(topic);
const invalidDocumentation = await providers.Hover.provideHover(app, { line: 0, character: 1 });
assert.match(invalidDocumentation.contents[0].value, /说明校验未通过/);
assert.match(invalidDocumentation.contents[0].value, /greet\(name: Text\)/);
await assert.rejects(commands['dever.openDomainLinks'](project, 'hello.greeting.greet'), /unknown documentation symbol/);
} finally {
Module._load = originalLoad;
for (const subscription of context.subscriptions) subscription.dispose();
for (const name of ['navigation', 'inspection', 'reading', 'hover']) {
delete require.cache[require.resolve(`../../editor/vscode/${name}.cjs`)];
}
delete require.cache[require.resolve('../../editor/vscode/dist/flow-preview.cjs')];
await fs.rm(root, { recursive: true });
}
});