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, /