Initial Dever 0.0.1
This commit is contained in:
commit
81233d098c
1479 files changed
+163782
No files matched your search
@@ -0,0 +1,246 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user