Initial Dever 0.0.1
This commit is contained in:
commit
81233d098c
1479 files changed
+163782
No files matched your search
@@ -0,0 +1,98 @@
|
||||
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 } = require('node:url');
|
||||
const { promisify } = require('node:util');
|
||||
const run = promisify(require('node:child_process').execFile);
|
||||
|
||||
test('generated flowcharts render offline and source clicks retain the actual location', async () => {
|
||||
assert.ok(process.env.DEVER_TEST_BIN);
|
||||
assert.ok(process.env.DEVER_BROWSER_TOOLS, 'directory containing existing playwright-core and @sparticuz/chromium');
|
||||
const tools = Module.createRequire(path.join(process.env.DEVER_BROWSER_TOOLS, '..', 'package.json'));
|
||||
const { chromium: playwright } = tools('playwright-core');
|
||||
const chromium = tools('@sparticuz/chromium').default;
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dever flow browser '));
|
||||
const extension = path.resolve(__dirname, '../../editor/vscode');
|
||||
const project = path.join(root, 'project');
|
||||
const subscriptions = [];
|
||||
let browser;
|
||||
const originalLoad = Module._load;
|
||||
try {
|
||||
await run(process.env.DEVER_TEST_BIN, ['new', project]);
|
||||
await run(process.env.DEVER_TEST_BIN, ['inspect', project, '--links']);
|
||||
const uri = filename => ({ fsPath: filename, toString: () => pathToFileURL(filename).toString() });
|
||||
const panels = [];
|
||||
const vscode = {
|
||||
Uri: { joinPath: (base, ...parts) => uri(path.join(base.fsPath, ...parts)) },
|
||||
ViewColumn: { Beside: 2 },
|
||||
window: { createWebviewPanel() {
|
||||
const panel = { dispose() {}, onDidDispose() {}, webview: {
|
||||
cspSource: 'https://dever-preview.test',
|
||||
asWebviewUri: uri => `https://dever-preview.test/${path.basename(uri.fsPath)}`,
|
||||
onDidReceiveMessage: () => ({ dispose() {} })
|
||||
} };
|
||||
panels.push(panel);
|
||||
return panel;
|
||||
} }
|
||||
};
|
||||
Module._load = function(request, ...rest) { return request === 'vscode' ? vscode : originalLoad.call(this, request, ...rest); };
|
||||
const { openFlowPreview } = require('../../editor/vscode/dist/flow-preview.cjs');
|
||||
Module._load = originalLoad;
|
||||
browser = await playwright.launch({ executablePath: await chromium.executablePath(), args: chromium.args, headless: true });
|
||||
const page = await browser.newPage();
|
||||
page.on('pageerror', error => console.error('Browser error:', error.message));
|
||||
page.on('console', message => { if (message.type() === 'error') console.error('Browser console:', message.text()); });
|
||||
const scripts = new Map(await Promise.all(['flow-browser.js', 'mermaid.js'].map(async name => [`https://dever-preview.test/${name}`, await fs.readFile(path.join(extension, 'dist', name), 'utf8')])));
|
||||
const messages = [];
|
||||
await page.exposeFunction('deverMessage', message => messages.push(message));
|
||||
await page.route('**/*', route => scripts.has(route.request().url())
|
||||
? route.fulfill({ body: scripts.get(route.request().url()), contentType: 'text/javascript' }) : route.abort());
|
||||
const projects = [project];
|
||||
for (const relative of ['test/rust/fixtures/auth', 'test/rust/fixtures/adapters', 'skills/assets/examples/command', 'skills/assets/examples/external-lib']) {
|
||||
const copy = path.join(root, path.basename(relative));
|
||||
await fs.cp(path.resolve(__dirname, '../..', relative), copy, { recursive: true });
|
||||
await run(process.env.DEVER_TEST_BIN, ['inspect', copy, '--links']);
|
||||
projects.push(copy);
|
||||
}
|
||||
if (process.env.DEVER_FLOW_PROJECT) projects.push(process.env.DEVER_FLOW_PROJECT);
|
||||
const targets = [];
|
||||
for (const owner of projects) {
|
||||
for await (const filename of fs.glob('module/*/*/link.md', { cwd: owner })) targets.push({ filename: path.join(owner, filename), owner });
|
||||
}
|
||||
let count = 0;
|
||||
for (const { filename, owner } of targets) {
|
||||
console.log(`Rendering ${filename}`);
|
||||
await openFlowPreview({ subscriptions, extensionUri: uri(extension) }, uri(filename), owner);
|
||||
const html = panels.at(-1).webview.html;
|
||||
const expected = (html.match(/class="language-mermaid"/g) || []).length;
|
||||
const nonce = /nonce="([^"]+)"/.exec(html)[1];
|
||||
const bridge = `<script nonce="${nonce}">window.acquireVsCodeApi = () => ({postMessage: message => window.deverMessage(message)});</script>`;
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(html.replace('<body ', `${bridge}<body `));
|
||||
assert.equal(await page.evaluate(() => typeof globalThis.mermaid?.render), 'function');
|
||||
await page.waitForSelector('body[data-rendered="true"]', { timeout: 60000 });
|
||||
assert.deepEqual(await page.locator('.dever-flow-error').allTextContents(), [], filename);
|
||||
assert.equal(await page.locator('.dever-flow > svg').count(), expected, filename);
|
||||
assert.ok(expected > 0, filename);
|
||||
count += expected;
|
||||
const source = page.locator('.dever-flow a').first();
|
||||
const href = await source.getAttribute('href') || await source.getAttribute('xlink:href');
|
||||
await source.click();
|
||||
await page.waitForTimeout(20);
|
||||
assert.deepEqual(messages.at(-1), { type: 'open', href });
|
||||
assert.match(href, /\.dever(?:\.md)?#L\d+,\d+$/);
|
||||
if (process.env.DEVER_FLOW_SCREENSHOT && path.basename(path.dirname(filename)) === 'audit') {
|
||||
await page.screenshot({ path: process.env.DEVER_FLOW_SCREENSHOT, fullPage: true });
|
||||
}
|
||||
}
|
||||
console.log(`Rendered ${count} real generated diagrams in ${targets.length} pages without network dependencies`);
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
await browser?.close();
|
||||
for (const subscription of subscriptions) subscription.dispose();
|
||||
await fs.rm(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const extension = path.join(root, 'editor/vscode');
|
||||
const extensionRequire = createRequire(path.join(extension, 'package.json'));
|
||||
const textmate = extensionRequire('vscode-textmate');
|
||||
const oniguruma = extensionRequire('vscode-oniguruma');
|
||||
const cliArguments = process.argv.slice(2);
|
||||
assert.equal(cliArguments.length, 2, 'pass --markdown-grammar followed by the real VS Code grammar path');
|
||||
assert.equal(cliArguments[0], '--markdown-grammar');
|
||||
const markdownPath = path.resolve(cliArguments[1]);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(extension, 'package.json'), 'utf8'));
|
||||
const configuration = JSON.parse(fs.readFileSync(path.join(extension, 'language-configuration.json'), 'utf8'));
|
||||
|
||||
async function loadGrammars() {
|
||||
const wasm = fs.readFileSync(extensionRequire.resolve('vscode-oniguruma/release/onig.wasm'));
|
||||
await oniguruma.loadWASM(wasm.buffer.slice(wasm.byteOffset, wasm.byteOffset + wasm.byteLength));
|
||||
const catalog = new Map();
|
||||
for (const contribution of manifest.contributes.grammars) {
|
||||
const filename = path.join(extension, contribution.path);
|
||||
catalog.set(contribution.scopeName, textmate.parseRawGrammar(fs.readFileSync(filename, 'utf8'), filename));
|
||||
}
|
||||
const markdown = textmate.parseRawGrammar(fs.readFileSync(markdownPath, 'utf8'), markdownPath);
|
||||
assert.equal(markdown.scopeName, 'text.html.markdown');
|
||||
catalog.set(markdown.scopeName, markdown);
|
||||
const registry = new textmate.Registry({
|
||||
onigLib: Promise.resolve({
|
||||
createOnigScanner: patterns => new oniguruma.OnigScanner(patterns),
|
||||
createOnigString: value => new oniguruma.OnigString(value)
|
||||
}),
|
||||
loadGrammar: scope => catalog.get(scope) || null,
|
||||
getInjections: scope => manifest.contributes.grammars
|
||||
.filter(contribution => contribution.injectTo?.includes(scope))
|
||||
.map(contribution => contribution.scopeName)
|
||||
});
|
||||
return {
|
||||
source: await registry.loadGrammarWithConfiguration('source.dever', 1, {}),
|
||||
markdown: await registry.loadGrammarWithConfiguration('text.html.markdown', 2, {
|
||||
embeddedLanguages: { 'meta.embedded.block.dever': 1 },
|
||||
tokenTypes: { 'meta.embedded.block.dever': 0 }
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const grammars = loadGrammars();
|
||||
|
||||
function tokenize(grammar, document) {
|
||||
let state = textmate.INITIAL;
|
||||
return document.split(/\r\n|\r|\n/).map(line => {
|
||||
const encoded = grammar.tokenizeLine2(line, state);
|
||||
const result = grammar.tokenizeLine(line, state);
|
||||
state = result.ruleStack;
|
||||
return { line, tokens: result.tokens, encoded: encoded.tokens };
|
||||
});
|
||||
}
|
||||
|
||||
function tokenAt(rows, line, fragment) {
|
||||
const row = rows[line];
|
||||
const offset = row.line.indexOf(fragment);
|
||||
assert.ok(offset >= 0, `line ${line + 1} does not contain ${fragment}`);
|
||||
const token = row.tokens.find(candidate => candidate.startIndex <= offset && candidate.endIndex > offset);
|
||||
assert.ok(token, `no token for ${fragment} on line ${line + 1}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
function hasScope(rows, line, fragment, scope) {
|
||||
const actual = tokenAt(rows, line, fragment).scopes;
|
||||
assert.ok(actual.includes(scope), `${fragment}: expected ${scope}, received ${actual.join(' ')}`);
|
||||
}
|
||||
|
||||
function sourceFiles(directory, suffix) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
||||
const filename = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? sourceFiles(filename, suffix) : filename.endsWith(suffix) ? [filename] : [];
|
||||
});
|
||||
}
|
||||
|
||||
test('types, fields and named function inputs/outputs receive Dever scopes', async () => {
|
||||
const rows = tokenize((await grammars).source, [
|
||||
'type Profile {',
|
||||
' id: Int?',
|
||||
' name: Text',
|
||||
'}',
|
||||
'greet(name: Text) (message: Text) {',
|
||||
' message = app.format(name)',
|
||||
'}'
|
||||
].join('\n'));
|
||||
hasScope(rows, 0, 'type', 'storage.type.dever');
|
||||
hasScope(rows, 0, 'Profile', 'entity.name.type.dever');
|
||||
hasScope(rows, 1, 'id', 'variable.other.member.dever');
|
||||
hasScope(rows, 1, 'Int', 'support.type.dever');
|
||||
hasScope(rows, 4, 'greet', 'entity.name.function.dever');
|
||||
hasScope(rows, 4, 'name', 'variable.other.member.dever');
|
||||
hasScope(rows, 4, 'message', 'variable.other.member.dever');
|
||||
hasScope(rows, 5, 'app', 'entity.name.namespace.dever');
|
||||
hasScope(rows, 5, 'format', 'entity.name.function.dever');
|
||||
});
|
||||
|
||||
test('typed local declarations and direct JSON capture retain ordinary scopes', async () => {
|
||||
const rows = tokenize((await grammars).source, [
|
||||
'read(payload: Json) (value: Text) {',
|
||||
' input: CreateInput = dever.json.decode(payload)',
|
||||
' decoded: DecodeResult = result(dever.json.decode(payload))',
|
||||
' value = input.name',
|
||||
'}'
|
||||
].join('\n'));
|
||||
hasScope(rows, 1, 'input', 'variable.other.member.dever');
|
||||
hasScope(rows, 1, 'CreateInput', 'entity.name.type.dever');
|
||||
hasScope(rows, 1, 'decode', 'entity.name.function.dever');
|
||||
hasScope(rows, 2, 'DecodeResult', 'entity.name.type.dever');
|
||||
hasScope(rows, 2, 'result', 'entity.name.function.dever');
|
||||
});
|
||||
|
||||
test('API, Model, Job and external Adapter declarations are covered', async () => {
|
||||
const source = (await grammars).source;
|
||||
for (const method of ['get', 'post', 'put', 'delete', 'cmd', 'rest']) {
|
||||
const rows = tokenize(source, `${method} profile = app.profile`);
|
||||
hasScope(rows, 0, method, 'keyword.control.api.dever');
|
||||
}
|
||||
const rows = tokenize(source, [
|
||||
'public get profile = app.profile',
|
||||
' private owner user_id: user.account.model.id = dever.auth.userId()',
|
||||
'index(user_id, created_at)',
|
||||
'sql ownedArticles(id: Int) (articles: List<Article>) {',
|
||||
'job send(message: Text) () fails app.Error {',
|
||||
'schedule send_every_minute {',
|
||||
'external npm "worker.js" {}'
|
||||
].join('\n'));
|
||||
hasScope(rows, 0, 'public', 'storage.modifier.dever');
|
||||
hasScope(rows, 1, 'owner', 'storage.modifier.dever');
|
||||
hasScope(rows, 2, 'index', 'keyword.other.declaration.dever');
|
||||
hasScope(rows, 3, 'sql', 'keyword.other.declaration.dever');
|
||||
hasScope(rows, 4, 'job', 'storage.modifier.dever');
|
||||
hasScope(rows, 4, 'fails', 'storage.modifier.dever');
|
||||
hasScope(rows, 5, 'schedule', 'keyword.other.declaration.dever');
|
||||
hasScope(rows, 6, 'npm', 'keyword.other.external.dever');
|
||||
});
|
||||
|
||||
test('hash comments, quoted text and escapes do not leak into subsequent lines', async () => {
|
||||
const rows = tokenize((await grammars).source, [
|
||||
'# type Fake and return',
|
||||
String.raw`message = "中文 # and type \n \u{4E2D} \""`,
|
||||
String.raw`invalid = "\q"`,
|
||||
'broken = "unfinished',
|
||||
'type Next { value: Text }'
|
||||
].join('\n'));
|
||||
hasScope(rows, 0, 'type', 'comment.line.number-sign.dever');
|
||||
hasScope(rows, 1, '#', 'string.quoted.double.dever');
|
||||
hasScope(rows, 1, '\\n', 'constant.character.escape.dever');
|
||||
hasScope(rows, 1, '\\u{4E2D}', 'constant.character.escape.dever');
|
||||
hasScope(rows, 2, '\\q', 'invalid.illegal.escape.dever');
|
||||
hasScope(rows, 4, 'type', 'storage.type.dever');
|
||||
assert.ok(!tokenAt(rows, 4, 'type').scopes.includes('string.quoted.double.dever'));
|
||||
});
|
||||
|
||||
test('integer division is an operator, not a JavaScript line comment', async () => {
|
||||
const rows = tokenize((await grammars).source, 'count = 12 // 3\namount = 1.25e-2\nvalid = true and not false');
|
||||
hasScope(rows, 0, '//', 'keyword.operator.dever');
|
||||
hasScope(rows, 0, '3', 'constant.numeric.dever');
|
||||
assert.ok(!tokenAt(rows, 0, '3').scopes.some(scope => scope.startsWith('comment.')));
|
||||
hasScope(rows, 1, '1.25e-2', 'constant.numeric.dever');
|
||||
hasScope(rows, 2, 'true', 'constant.language.dever');
|
||||
hasScope(rows, 2, 'and', 'keyword.operator.logical.dever');
|
||||
});
|
||||
|
||||
test('excluded control-flow syntax is not presented as a valid function', async () => {
|
||||
const rows = tokenize((await grammars).source, 'if(value)\nreturn value\nimport other\npackage main');
|
||||
for (const [line, keyword] of ['if', 'return', 'import', 'package'].entries()) {
|
||||
hasScope(rows, line, keyword, 'invalid.illegal.keyword.dever');
|
||||
}
|
||||
});
|
||||
|
||||
test('Markdown fences reuse source.dever while prose keeps Markdown scopes', async () => {
|
||||
for (const marker of ['```', '~~~']) {
|
||||
for (const language of ['dever', 'typescript dever']) {
|
||||
const rows = tokenize((await grammars).markdown, [
|
||||
'# 文档', '', '## 类型', `${marker}${language}`,
|
||||
'type Profile { name: Text }', marker, '', '正文 type Profile'
|
||||
].join('\n'));
|
||||
hasScope(rows, 4, 'type', 'storage.type.dever');
|
||||
hasScope(rows, 4, 'Text', 'support.type.dever');
|
||||
hasScope(rows, 4, 'Profile', 'meta.embedded.block.dever');
|
||||
assert.ok(!tokenAt(rows, 7, 'type').scopes.includes('source.dever'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('embedded tokens carry the Dever language ID and are not string tokens', async () => {
|
||||
const rows = tokenize((await grammars).markdown, '```dever\ntype Profile {}\n```');
|
||||
const metadata = rows[1].encoded[1];
|
||||
assert.equal(metadata & 0xff, 1);
|
||||
assert.equal((metadata >>> 8) & 3, 0);
|
||||
});
|
||||
|
||||
test('fence closing honors its character and minimum opening length', async () => {
|
||||
const rows = tokenize((await grammars).markdown, [
|
||||
'````dever', 'type First {}', '```', 'type Second {}',
|
||||
'~~~~', 'type Third {}', '`````', '正文 type Outside'
|
||||
].join('\n'));
|
||||
for (const line of [1, 3, 5]) hasScope(rows, line, 'type', 'storage.type.dever');
|
||||
assert.ok(!tokenAt(rows, 7, 'type').scopes.includes('source.dever'));
|
||||
const waves = tokenize((await grammars).markdown, '~~~dever\ntype Inside {}\n~~~~\n正文 type Outside');
|
||||
assert.ok(!tokenAt(waves, 3, 'type').scopes.includes('source.dever'));
|
||||
});
|
||||
|
||||
test('valid indentation and CRLF work without taking over other language fences', async () => {
|
||||
const rows = tokenize((await grammars).markdown, ' ```dever\r\ntype Profile {}\r\n ```\r\n正文');
|
||||
hasScope(rows, 1, 'type', 'storage.type.dever');
|
||||
for (const language of ['typescript', 'javascript', 'dever extra', 'typescript dever']) {
|
||||
const other = tokenize((await grammars).markdown, `\`\`\`${language}\ntype Profile {}\n\`\`\``);
|
||||
assert.ok(!tokenAt(other, 1, 'type').scopes.includes('source.dever'), language);
|
||||
}
|
||||
});
|
||||
|
||||
test('quotes, lists, showcase fences, indented code and comments retain Markdown scopes', async () => {
|
||||
for (const [document, line] of [
|
||||
['> ```dever\n> type Quoted {}\n> ```', 1],
|
||||
['- 示例\n\n ```dever\n type Nested {}\n ```', 3],
|
||||
['````markdown\n```dever\ntype Showcase {}\n```\n````', 2],
|
||||
[' ```dever\n type Indented {}\n ```', 1],
|
||||
['<!--\n```dever\ntype Comment {}\n```\n-->', 2]
|
||||
]) {
|
||||
const rows = tokenize((await grammars).markdown, document);
|
||||
assert.ok(!tokenAt(rows, line, 'type').scopes.includes('source.dever'), document);
|
||||
}
|
||||
});
|
||||
|
||||
test('language configuration uses Dever comments, pairs and indentation', () => {
|
||||
assert.equal(configuration.comments.lineComment, '#');
|
||||
assert.equal(configuration.comments.blockComment, undefined);
|
||||
assert.deepEqual(configuration.brackets, [['{', '}'], ['[', ']'], ['(', ')']]);
|
||||
const increase = new RegExp(configuration.indentationRules.increaseIndentPattern);
|
||||
const decrease = new RegExp(configuration.indentationRules.decreaseIndentPattern);
|
||||
for (const line of ['greet(name: Text) (message: Text) {', ' values = [', 'type Profile { # 字段']) {
|
||||
assert.ok(increase.test(line), line);
|
||||
}
|
||||
for (const line of ['# example {', 'message = "literal {"', 'message = "value" # {']) {
|
||||
assert.ok(!increase.test(line), line);
|
||||
}
|
||||
assert.ok(decrease.test(' }'));
|
||||
assert.ok(decrease.test(' ]'));
|
||||
assert.ok(configuration.autoClosingPairs.every(pair => pair.notIn.includes('string') && pair.notIn.includes('comment')));
|
||||
assert.deepEqual(manifest.contributes.languages[0].extensions, ['.dever']);
|
||||
const injection = manifest.contributes.grammars.find(contribution => contribution.injectTo?.includes('text.html.markdown'));
|
||||
assert.deepEqual(injection.embeddedLanguages, { 'meta.embedded.block.dever': 'dever' });
|
||||
assert.deepEqual(injection.tokenTypes, { 'meta.embedded.block.dever': 'other' });
|
||||
assert.equal(manifest.main, './extension.cjs');
|
||||
assert.equal(manifest.dependencies, undefined);
|
||||
});
|
||||
|
||||
test('maintained plain and Markdown CMS files tokenize without illegal tokens', async () => {
|
||||
const loaded = await grammars;
|
||||
for (const [directory, suffix, grammar] of [
|
||||
['examples/cms/dever', '.dever', loaded.source]
|
||||
]) {
|
||||
const files = sourceFiles(path.join(root, directory), suffix);
|
||||
assert.ok(files.length > 0, directory);
|
||||
let highlighted = 0;
|
||||
for (const filename of files) {
|
||||
const rows = tokenize(grammar, fs.readFileSync(filename, 'utf8'));
|
||||
let fileHighlighted = 0;
|
||||
for (const row of rows) {
|
||||
for (const token of row.tokens) {
|
||||
assert.ok(!token.scopes.some(scope => scope.startsWith('invalid.illegal.')), `${filename}: ${row.line}`);
|
||||
if (token.scopes.includes('source.dever')) fileHighlighted += 1;
|
||||
}
|
||||
}
|
||||
assert.ok(fileHighlighted > 0, `${filename} must actually enter the Dever grammar`);
|
||||
highlighted += fileHighlighted;
|
||||
}
|
||||
assert.ok(highlighted > files.length, `${directory} must actually enter the Dever grammar`);
|
||||
process.stdout.write(`CMS ${directory}: ${files.length} files, ${highlighted} Dever tokens\n`);
|
||||
}
|
||||
});
|
||||
@@ -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