247 lines
15 KiB
JavaScript
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 });
|
|
}
|
|
});
|