diff --git a/.vscode/launch.json b/.vscode/launch.json index 991528c9..572d996e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,7 +20,8 @@ "name": "Attach to Server", "port": 9523, "restart": true, - "outFiles": ["${workspaceRoot}/server/out/**/*.js"] + "outFiles": ["${workspaceRoot}/server/out/**/*.js"], + "sourceMaps": true }, { "name": "Language Server E2E Test", diff --git a/README.md b/README.md index 463b711b..1af9839b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ Features: +
Intelligent module import enhanced + +![Import enhanced](screenshot/import_enhancement.gif) + +
+
Supports importing ECMAScript modules ![Import](screenshot/ecma.gif) diff --git a/client/src/extension.ts b/client/src/extension.ts index 99ab3e47..adf18a3f 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -32,11 +32,6 @@ import execa from "execa"; import * as semver from "semver"; import { TreeViewProvider } from "./tree_view_provider"; -import { - ImportEnhancementCompletionProvider, - CACHE_STATE, -} from "./import_enhancement_provider"; - import { ImportMap } from "../../core/import_map"; import { HashMeta } from "../../core/hash_meta"; import { isInDeno } from "../../core/deno"; @@ -118,9 +113,6 @@ export class Extension { }, executablePath: "", }; - // CGQAQ: ImportEnhancementCompletionProvider instance - private import_enhancement_completion_provider = new ImportEnhancementCompletionProvider(); - // get configuration of Deno public getConfiguration(uri?: Uri): ConfigurationField { const config: ConfigurationField = {}; @@ -473,14 +465,6 @@ Executable ${this.denoInfo.executablePath}`; await window.showInformationMessage(`Copied to clipboard.`); }); - // CGQAQ: deno._clear_import_enhencement_cache - this.registerCommand("_clear_import_enhencement_cache", async () => { - this.import_enhancement_completion_provider - .clearCache() - .then(() => window.showInformationMessage("Clear success!")) - .catch(() => window.showErrorMessage("Clear failed!")); - }); - this.registerQuickFix({ _fetch_remote_module: async (editor, text) => { const config = this.getConfiguration(editor.document.uri); @@ -611,23 +595,6 @@ Executable ${this.denoInfo.executablePath}`; window.registerTreeDataProvider("deno", treeView) ); - // CGQAQ: activate import enhance feature - this.import_enhancement_completion_provider.activate(this.context); - - // CGQAQ: Start caching full module list - this.import_enhancement_completion_provider - .cacheModList() - .then((state) => { - if (state === CACHE_STATE.CACHE_SUCCESS) { - window.showInformationMessage( - "deno.land/x module list cached successfully!" - ); - } - }) - .catch(() => - window.showErrorMessage("deno.land/x module list failed to cache!") - ); - this.sync(window.activeTextEditor?.document); const extension = extensions.getExtension(this.id); @@ -640,8 +607,6 @@ Executable ${this.denoInfo.executablePath}`; public async deactivate(context: ExtensionContext): Promise { this.context = context; - this.import_enhancement_completion_provider.dispose(); - if (this.client) { await this.client.stop(); this.client = undefined; diff --git a/client/src/import_enhancement_provider.ts b/client/src/import_enhancement_provider.ts deleted file mode 100644 index f18a0df9..00000000 --- a/client/src/import_enhancement_provider.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { - CompletionItemProvider, - TextDocument, - Position, - CompletionItem, - Disposable, - CompletionItemKind, - CompletionList, - DocumentSelector, - languages, - ExtensionContext, - Range, - Command, - window, - ProgressLocation, -} from "vscode"; - -import Semver from "semver"; - -import VC = require("vscode-cache"); - -import { - listVersionsOfMod, - modTreeOf, - parseImportStatement, - searchX, - fetchModList, - ModuleInfo, -} from "./import_utils"; - -export enum CACHE_STATE { - ALREADY_CACHED, - CACHE_SUCCESS, -} - -export class ImportEnhancementCompletionProvider - implements CompletionItemProvider, Disposable { - vc?: VC; - async provideCompletionItems( - document: TextDocument, - position: Position - // _token: CancellationToken, - // _context: CompletionContext - ): Promise { - const line_text = document.lineAt(position).text; - - if (/import.+?from\W+['"].*?['"]/.test(line_text)) { - // We're at import statement line - const imp_info = parseImportStatement(line_text); - if (imp_info?.domain !== "deno.land") { - return undefined; - } - // We'll handle the completion only if the domain is `deno.land` and mod name is not empty - const at_index = line_text.indexOf("@"); - if ( - /.*?deno.land\/(x\/)?\w+@[\w.-]*$/.test( - line_text.substring(0, position.character) - ) && - position.character > at_index - ) { - // Version completion - const vers = await listVersionsOfMod(imp_info.module); - - const result = vers.versions - .sort((a, b) => { - const av = Semver.clean(a); - const bv = Semver.clean(b); - if ( - av === null || - bv === null || - !Semver.valid(av) || - !Semver.valid(bv) - ) { - return 0; - } - return Semver.gt(av, bv) ? -1 : 1; - }) - .map((it, i) => { - // let latest version on top - const ci = new CompletionItem(it, CompletionItemKind.Value); - ci.sortText = `a${String.fromCharCode(i) + 1}`; - ci.filterText = it; - ci.range = new Range( - position.line, - at_index + 1, - position.line, - position.character - ); - return ci; - }); - return new CompletionList(result); - } - - if ( - /.*?deno\.land\/x\/\w*$/.test( - line_text.substring(line_text.indexOf("'") + 1, position.character) - ) - ) { - // x module name completion - if (this.vc !== undefined) { - const result: { name: string; description: string }[] = await searchX( - this.vc, - imp_info.module - ); - const r = result.map((it) => { - const ci = new CompletionItem(it.name, CompletionItemKind.Module); - ci.detail = it.description; - ci.sortText = String.fromCharCode(1); - ci.filterText = it.name; - return ci; - }); - return r; - } else { - return []; - } - } - - if ( - !/.*?deno\.land\/(x\/)?\w+(@[\w.-]*)?\//.test( - line_text.substring(0, position.character) - ) - ) { - return []; - } - - const result = await modTreeOf( - this.vc, - imp_info.module, - imp_info.version - ); - const arr_path = imp_info.path.split("/"); - const path = arr_path.slice(0, arr_path.length - 1).join("/") + "/"; - - const r = result.directory_listing - .filter((it) => it.path.startsWith(path)) - .map((it) => ({ - path: - path.length > 1 ? it.path.replace(path, "") : it.path.substring(1), - size: it.size, - type: it.type, - })) - .filter((it) => it.path.split("/").length < 2) - .filter( - (it) => - // exclude tests - !(it.path.endsWith("_test.ts") || it.path.endsWith("_test.js")) && - // include only js and ts - (it.path.endsWith(".ts") || - it.path.endsWith(".js") || - it.path.endsWith(".tsx") || - it.path.endsWith(".jsx") || - it.path.endsWith(".mjs") || - it.type !== "file") && - // exclude privates - !it.path.startsWith("_") && - // exclude hidden file/folder - !it.path.startsWith(".") && - // exclude testdata dir - (it.path !== "testdata" || it.type !== "dir") && - it.path.length !== 0 - ) - // .sort((a, b) => a.path.length - b.path.length) - .map((it) => { - const r = new CompletionItem( - it.path, - it.type === "dir" - ? CompletionItemKind.Folder - : CompletionItemKind.File - ); - r.sortText = it.type === "dir" ? "a" : "b"; - r.insertText = it.type === "dir" ? it.path + "/" : it.path; - r.range = new Range( - position.line, - line_text.substring(0, position.character).lastIndexOf("/") + 1, - position.line, - position.character - ); - if (it.type === "dir") { - // https://github.com/microsoft/vscode-extension-samples/blob/bb4a0c3a5dd9460a5cd64290b4d5c4f6bd79bdc4/completions-sample/src/extension.ts#L37 - r.command = { - command: "editor.action.triggerSuggest", - title: "Re-trigger completions...", - }; - } - return r; - }); - return new CompletionList(r, false); - } - } - - async clearCache(): Promise { - await this.vc?.flush(); - } - - async cacheModList(): Promise { - return window.withProgress( - { - location: ProgressLocation.Notification, - title: "Fetching deno.land/x module list...", - }, - async (progress) => { - const mod_list_key = "mod_list"; - if (this.vc?.isExpired(mod_list_key) || !this.vc?.has(mod_list_key)) { - this.vc?.forget(mod_list_key); - progress.report({ increment: 0 }); - for await (const modules of fetchModList()) { - if (this.vc?.has(mod_list_key)) { - const list_in_cache = this.vc?.get(mod_list_key) as ModuleInfo[]; - list_in_cache.push(...modules.data); - await this.vc?.put( - mod_list_key, - list_in_cache, - 60 * 60 * 24 * 7 /* expiration in a week */ - ); - } else { - this.vc?.put( - mod_list_key, - modules.data, - 60 * 60 * 24 * 7 /* expiration in a week */ - ); - } - progress.report({ - increment: (1 / modules.total) * 100, - }); - } - return CACHE_STATE.CACHE_SUCCESS; - } - return CACHE_STATE.ALREADY_CACHED; - } - ); - } - - activate(ctx: ExtensionContext): void { - this.vc = new VC(ctx, "import-enhanced"); - - const document_selector = [ - { language: "javascript" }, - { language: "typescript" }, - { language: "javascriptreact" }, - { language: "typescriptreact" }, - ]; - const trigger_word = ["@", "/"]; - ctx.subscriptions.push( - languages.registerCompletionItemProvider( - document_selector, - this, - ...trigger_word - ) - ); - } - - dispose(): void { - /* eslint-disable */ - } -} diff --git a/client/src/types/vscode-cache.d.ts b/client/src/types/vscode-cache.d.ts deleted file mode 100644 index 48279d11..00000000 --- a/client/src/types/vscode-cache.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* eslint-disable */ -declare module "vscode-cache" { - export = Cache; - class Cache { - constructor(context: any, namespace?: string); - put(key: string, value: any, expiration?: number): Promise; - set(key: any, value: any, expiration: any): Promise; - save(key: any, value: any, expiration: any): Promise; - store(key: any, value: any, expiration: any): Promise; - cache: (key: any, value: any, expiration: any) => Promise; - get(key: string, defaultValue?: any): any; - fetch(key: any, defaultValue: any): any; - retrieve(key: any, defaultValue: any): any; - has(key: string): boolean; - exists(key: any): boolean; - forget(key: string): any; - remove(key: any): any; - delete(key: any): any; - keys(): string[]; - all(): object; - getAll(): object; - flush(): any; - clearAll(): any; - getExpiration(key: string): number; - isExpired(key: any): boolean; - } -} diff --git a/client/src/import_utils.test.ts b/core/import_enhanced.test.ts similarity index 69% rename from client/src/import_utils.test.ts rename to core/import_enhanced.test.ts index 3f92cc35..4d339a67 100644 --- a/client/src/import_utils.test.ts +++ b/core/import_enhanced.test.ts @@ -2,21 +2,48 @@ import { listVersionsOfMod, modTreeOf, parseImportStatement, -} from "./import_utils"; + ModTree, + VERSION_REG, +} from "./import_enhanced"; -test("import-enhance listVersionsOfMod", async () => { +import { PermCache } from "./permcache"; + +test("core / import_enhance: test VERSION_REG", async () => { + const test_cases: { text: string; result: boolean }[] = [ + { text: "1.0.0", result: true }, + { text: "1.0.0@", result: false }, + { text: "1.0.0嗯?", result: false }, + { text: "1.0.0-alpha", result: true }, + { text: "1.0.0-beta_1", result: true }, + { text: "v1", result: true }, + { text: "v1/", result: false }, + { text: "/v1", result: false }, + ]; + + for (const test_case of test_cases) { + expect(VERSION_REG.test(test_case.text)).toEqual(test_case.result); + } +}); + +test("core / import_enhance: listVersionsOfMod", async () => { const result = await listVersionsOfMod("std"); expect(result.latest).toBeTruthy(); expect(result.versions.length).not.toEqual(0); }); -test("import-enhance modTreeOf", async () => { - const result = await modTreeOf(undefined, "std"); +test("core / import_enhance: modTreeOf", async () => { + const cache = await PermCache.create>( + "import_enhance-test", + undefined + ); + await cache.destroy_cache(); + const result = await modTreeOf("std", undefined, cache); expect(result.uploaded_at).toBeTruthy(); expect(result.directory_listing.length).not.toEqual(0); + await cache.destroy_cache(); }); -test("import-enhance parseImportStatement", async () => { +test("core / import_enhance: parseImportStatement", async () => { const test_cases = [ { imp: "import * from 'http://a.c/xx/a.ts'", diff --git a/client/src/import_utils.ts b/core/import_enhanced.ts similarity index 75% rename from client/src/import_utils.ts rename to core/import_enhanced.ts index 8c75cd43..ed2359c6 100644 --- a/client/src/import_utils.ts +++ b/core/import_enhanced.ts @@ -1,9 +1,9 @@ -/* eslint @typescript-eslint/triple-slash-reference: "off" */ -// CGQAQ: Without next line the test will fail, using import won't work -/// - import got from "got"; -import VC from "vscode-cache"; +import { PermCache } from "./permcache"; + +export type ModList = ModuleInfo[]; + +export type ModListCache = PermCache; export interface ModuleInfo { name: string; @@ -44,12 +44,12 @@ export async function* fetchModList(): AsyncGenerator<{ // this function now is search from cache only export async function searchX( - cache: VC, + cache: PermCache, keyword: string -): Promise { - if (cache.has("mod_list")) { - const buff = cache.get("mod_list") as ModuleInfo[]; - return buff +): Promise { + const arr = cache.get(); + if (arr !== undefined) { + return arr .filter((it) => it.name.startsWith(keyword)) .sort((a, b) => b.search_score - a.search_score); } else { @@ -79,14 +79,18 @@ interface ModTreeItem { type: string; } -interface ModTree { +export interface ModTree { uploaded_at: string; // Use this to update cache directory_listing: ModTreeItem[]; } + +export type ModTreeCacheItem = Record; +export type ModTreeCache = PermCache; + export async function modTreeOf( - vc: VC | undefined, module_name: string, - version = "latest" + version = "latest", + cache?: PermCache> ): Promise { // https://cdn.deno.land/$MODULE/versions/$VERSION/meta/meta.json let ver = version; @@ -95,10 +99,11 @@ export async function modTreeOf( ver = vers.latest; } - const cache_key = `mod_tree:${module_name}@${ver}`; - if (vc?.has(cache_key)) { + const cache_key = `${module_name}@${ver}`; + const cache_content = cache?.get(); + if (cache_content?.hasOwnProperty(cache_key)) { // use cache - return vc.get(cache_key) as ModTree; + return cache_content[cache_key] as ModTree; } const response: ModTree = await got( @@ -108,7 +113,14 @@ export async function modTreeOf( ).json(); // cache it - vc?.put(cache_key, response); + if (cache_content !== undefined) { + cache_content[cache_key] = response; + await cache?.set(cache_content); + } else { + const obj: Record = {}; + obj[cache_key] = response; + await cache?.set(obj); + } return response; } @@ -120,8 +132,12 @@ interface ImportUrlInfo { path: string; } +export const IMP_REG = /^.*?import.+?from.+?['"](?[0-9a-zA-Z-_@~:/.?#:&=%+]*)/; +export const VERSION_REG = /^([\w.\-_]+)$/; +export const MOD_NAME_REG = /^[\w-_]+$/; + export function parseImportStatement(text: string): ImportUrlInfo | undefined { - const reg_groups = text.match(/.*?['"](?.*?)['"]/)?.groups; + const reg_groups = text.match(IMP_REG)?.groups; if (!reg_groups) { return undefined; } @@ -153,7 +169,7 @@ export function parseImportStatement(text: string): ImportUrlInfo | undefined { }; } }; - if (components.length > 2) { + if (components.length > 1) { const m = components[1]; if (m === "x") { return parse(components.slice(2, components.length)); diff --git a/core/permcache.test.ts b/core/permcache.test.ts new file mode 100644 index 00000000..a946aba9 --- /dev/null +++ b/core/permcache.test.ts @@ -0,0 +1,52 @@ +import { PermCache, TRANSACTION_STATE } from "./permcache"; +import { sleep } from "./util"; + +test("core / permcache", async () => { + const data = [1, 2]; + const cache = await PermCache.create("permcache-test", 1); + + // test expired + await sleep(500); + expect(cache.expired()).toBeFalsy(); + await sleep(600); + expect(cache.expired()).toBeTruthy(); + + expect(cache.get()).toEqual(undefined); + await cache.set(data); + expect(cache.get()).toEqual(data); + expect(await cache.reload_get()).toEqual(data); + + // test transaction + expect(cache.transaction_begin()).toEqual(TRANSACTION_STATE.SUCCESS); + expect(cache.transaction_begin()).toEqual( + TRANSACTION_STATE.ALREADY_IN_TRANSACTION + ); + cache + .set([2, 3]) + .then(() => fail()) + .catch((reason) => + expect(reason).toEqual(TRANSACTION_STATE.ALREADY_IN_TRANSACTION) + ); + expect(cache.transaction_set([2, 3])).toEqual(TRANSACTION_STATE.SUCCESS); + expect(cache.get()).toEqual(data); + expect(cache.transaction_get().state).toEqual(TRANSACTION_STATE.SUCCESS); + expect(cache.transaction_get().data).toEqual([2, 3]); + expect(await cache.transaction_commit()).toEqual(TRANSACTION_STATE.SUCCESS); + expect(cache.get()).toEqual([2, 3]); + expect(await cache.reload_get()).toEqual([2, 3]); + + // test transaction_abort + expect(cache.transaction_begin()).toEqual(TRANSACTION_STATE.SUCCESS); + expect(cache.transaction_set([1, 2])); + expect(cache.transaction_abort()).toEqual(TRANSACTION_STATE.SUCCESS); + expect(cache.transaction_abort()).toEqual( + TRANSACTION_STATE.NOT_IN_TRANSACTION + ); + expect(cache.get()).toEqual([2, 3]); + expect(await cache.reload_get()).toEqual([2, 3]); + + // test destroy_cache + await cache.destroy_cache(); + expect(cache.get()).toEqual(undefined); + expect(await cache.reload_get()).toEqual(undefined); +}); diff --git a/core/permcache.ts b/core/permcache.ts new file mode 100644 index 00000000..dcccbcad --- /dev/null +++ b/core/permcache.ts @@ -0,0 +1,232 @@ +// Permanent version of the Cache from cache.ts in the same folder +import { promises as fsp } from "fs"; +import fs from "fs"; +import path from "path"; + +export function getVSCodeDenoDir(): string { + // ref https://deno.land/manual.html + // On Linux/Redox: $XDG_CACHE_HOME/deno or $HOME/.cache/deno + // On Windows: %LOCALAPPDATA%/deno (%LOCALAPPDATA% = FOLDERID_LocalAppData) + // On macOS: $HOME/Library/Caches/deno + // If something fails, it falls back to $HOME/.deno + let vscodeDenoDir = + process.env.VSCODE_DENO_EXTENSION_PATH !== undefined + ? path.join(process.env.VSCODE_DENO_EXTENSION_PATH, "cache") + : undefined; + if (vscodeDenoDir === undefined) { + switch (process.platform) { + /* istanbul ignore next */ + case "win32": + vscodeDenoDir = `${process.env.LOCALAPPDATA}\\vscode_deno`; + break; + /* istanbul ignore next */ + case "darwin": + vscodeDenoDir = `${process.env.HOME}/Library/Caches/vscode_deno`; + break; + /* istanbul ignore next */ + case "linux": + vscodeDenoDir = process.env.XDG_CACHE_HOME + ? `${process.env.XDG_CACHE_HOME}/vscode_deno` + : `${process.env.HOME}/.cache/vscode_deno`; + break; + /* istanbul ignore next */ + default: + vscodeDenoDir = `${process.env.HOME}/.vscode_deno`; + } + } + + return vscodeDenoDir; +} + +interface CacheFormat { + expiring_date: number | undefined; + data: T | undefined; +} + +export enum TRANSACTION_STATE { + SUCCESS = 1, + ALREADY_IN_TRANSACTION = -1, + NOT_IN_TRANSACTION = -2, +} + +export class PermCache { + private cache_file_path: string; + + // In transition mode, write to this instead + private transaction: CacheFormat | undefined; + + private inner_data: CacheFormat | undefined; + + static async create( + namespace: string, + timeout?: number + ): Promise> { + const vscode_deno_path = getVSCodeDenoDir(); + const cache_file_path = path.join(vscode_deno_path, `${namespace}.json`); + const expiring_date = + timeout === undefined ? undefined : new Date().getTime() + timeout * 1000; // to millis + if (!fs.existsSync(vscode_deno_path)) { + await fsp.mkdir(vscode_deno_path, { recursive: true }); + } + + let cache_file: fsp.FileHandle; + if (!fs.existsSync(cache_file_path)) { + // Cache don't exist, create one + cache_file = await fsp.open(cache_file_path, "w"); + + const cache = { expiring_date, data: undefined } as CacheFormat; + await cache_file.writeFile(JSON.stringify(cache)); + await cache_file.close(); + + return new PermCache(cache_file_path, cache); + } else { + // Cache maybe exist, try read cache from cache file + const file_content = await fsp.readFile(cache_file_path, "utf-8"); + const cache = JSON.parse(file_content) as CacheFormat; + + if ( + !Object.getOwnPropertyNames(cache).every( + (it) => it === "expiring_date" || it === "data" + ) + ) { + // If cache format not correct, clear the file + await fsp.writeFile(cache_file_path, ""); + return new PermCache(cache_file_path, { + expiring_date, + data: undefined, + }); + } + + if (cache.data == undefined) { + return new PermCache(cache_file_path, { + expiring_date: cache.expiring_date, + data: undefined, + }); + } + + return new PermCache(cache_file_path, cache); + } + } + + private constructor(file: string, cache: CacheFormat | undefined) { + this.transaction = undefined; + this.cache_file_path = file; + this.inner_data = cache; + } + + async reload(): Promise { + if (fs.existsSync(this.cache_file_path)) { + const cache_file_content = await fsp.readFile( + this.cache_file_path, + "utf-8" + ); + this.inner_data = JSON.parse(cache_file_content) as CacheFormat; + return; + } + this.inner_data = undefined; + } + + get(): T | undefined { + return this.inner_data?.data; + } + + expired(): boolean { + const now = new Date().getTime(); + if ( + this.inner_data !== undefined && + this.inner_data.expiring_date !== undefined + ) { + return now >= this.inner_data.expiring_date; + } + return false; + } + + async reload_expired(): Promise { + await this.reload(); + return this.expired(); + } + + async reload_get(): Promise { + await this.reload(); + return this.inner_data?.data; + } + + async set(data: T | undefined): Promise { + if (this.transaction !== undefined) { + return Promise.reject(TRANSACTION_STATE.ALREADY_IN_TRANSACTION); + } + if (this.inner_data !== undefined) { + this.inner_data.data = data; + await fsp.writeFile( + this.cache_file_path, + JSON.stringify(this.inner_data), + "utf-8" + ); + } else { + this.inner_data = { expiring_date: undefined, data }; + await fsp.writeFile( + this.cache_file_path, + JSON.stringify(this.inner_data), + "utf-8" + ); + } + } + + // WARNING: this function will completely delete the cache from disk + // AND MUST await on this function + async destroy_cache(): Promise { + if (fs.existsSync(this.cache_file_path)) { + await fsp.unlink(this.cache_file_path); + this.transaction = undefined; + this.inner_data = undefined; + } + } + + transaction_begin(): TRANSACTION_STATE { + if (this.transaction !== undefined) { + return TRANSACTION_STATE.ALREADY_IN_TRANSACTION; + } + this.transaction = { + expiring_date: undefined, + data: undefined, + ...this.inner_data, + } as CacheFormat; + return TRANSACTION_STATE.SUCCESS; + } + + transaction_get(): { state: TRANSACTION_STATE; data: T | undefined } { + if (this.transaction !== undefined) { + return { state: TRANSACTION_STATE.SUCCESS, data: this.transaction.data }; + } + return { state: TRANSACTION_STATE.NOT_IN_TRANSACTION, data: undefined }; + } + + transaction_set(data: T | undefined): TRANSACTION_STATE { + if (this.transaction !== undefined) { + this.transaction.data = data; + return TRANSACTION_STATE.SUCCESS; + } + return TRANSACTION_STATE.NOT_IN_TRANSACTION; + } + + transaction_abort(): TRANSACTION_STATE { + if (this.transaction !== undefined) { + this.transaction = undefined; + return TRANSACTION_STATE.SUCCESS; + } + return TRANSACTION_STATE.NOT_IN_TRANSACTION; + } + + async transaction_commit(): Promise { + if (this.transaction !== undefined) { + await fsp.writeFile( + this.cache_file_path, + JSON.stringify(this.transaction) + ); + await this.reload_get(); + this.transaction = undefined; + return TRANSACTION_STATE.SUCCESS; + } + return TRANSACTION_STATE.NOT_IN_TRANSACTION; + } +} diff --git a/package.json b/package.json index 770d2710..d5eea00f 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,6 @@ "format": "prettier \"**/*.md\" \"**/*.json\" \"**/*.ts\" \"**/*.yml\" --config ./.prettierrc.json --write", "test": "jest --coverage", "test-coveralls": "jest --coverage --coverageReporters=text-lcov | coveralls", - "test.import-enhance": "jest -t 'import-enhance'", "build": "npx vsce package -o ./vscode-deno.vsix", "lint": "eslint . --ext .js,.jsx,.ts,.tsx" }, @@ -198,7 +197,6 @@ "got": "^11.5.2", "json5": "^2.1.3", "semver": "7.3.2", - "typescript-deno-plugin": "./typescript-deno-plugin", - "vscode-cache": "^0.3.0" + "typescript-deno-plugin": "./typescript-deno-plugin" } } diff --git a/screenshot/import_enhancement.gif b/screenshot/import_enhancement.gif new file mode 100644 index 00000000..1d42063d Binary files /dev/null and b/screenshot/import_enhancement.gif differ diff --git a/server/src/language/completion.ts b/server/src/language/completion.ts index 3ca5d2b8..03843b36 100644 --- a/server/src/language/completion.ts +++ b/server/src/language/completion.ts @@ -12,6 +12,8 @@ import { getDenoDir } from "../../../core/deno"; import { getAllDenoCachedDeps, Deps } from "../../../core/deno_deps"; import { Cache } from "../../../core/cache"; +import { ImportCompletionEnhanced } from "./import_completion_enhanced"; + // Cache for 30 second or 30 references const cache = Cache.create(1000 * 30, 30); @@ -24,7 +26,11 @@ getAllDenoCachedDeps() }); export class Completion { - constructor(connection: IConnection, documents: TextDocuments) { + constructor( + connection: IConnection, + documents: TextDocuments, + import_enhanced: ImportCompletionEnhanced + ) { connection.onCompletion(async (params) => { const { position, partialResultToken, textDocument } = params; @@ -39,7 +45,7 @@ export class Completion { ); const IMPORT_REG = /import\s['"][a-zA-Z._-]$/; - const IMPORT_FROM_REG = /import\s(([^\s]*)|(\*\sas\s[^\s]*))\sfrom\s['"][a-zA-Z._-]$/; + const IMPORT_FROM_REG = /import\s(([^\s]*)|(\*\sas\s[^\s]*))\sfrom\s['"][a-zA-Z._-]?$/; const DYNAMIC_REG = /import\s*\(['"][a-zA-Z._-]['"]?$/; const isImport = @@ -51,7 +57,7 @@ export class Completion { currentLine.length > 1000 || // if is a large file !isImport ) { - return []; + return import_enhanced.please(params); } let deps = cache.get(); @@ -66,12 +72,34 @@ export class Completion { position ); + if (/.*?import[^'"]*?'$/.test(currentLine)) { + deps = deps.map((it) => { + const url = new URL(it.url); + return { + filepath: it.filepath, + url: url.hostname === "deno.land" ? `${url.origin}` : it.url, + } as Deps; + }); + const dedup_arr: string[] = []; + deps = deps.filter((it) => { + if (dedup_arr.includes(it.url)) { + return false; + } else { + dedup_arr.push(it.url); + return true; + } + }); + } + const completes: CompletionItem[] = deps.map((dep) => { return { label: dep.url, detail: dep.url, sortText: dep.url, - documentation: dep.filepath.replace(getDenoDir(), "$DENO_DIR"), + documentation: + dep.url === "https://deno.land" + ? "" + : dep.filepath.replace(getDenoDir(), "$DENO_DIR"), kind: CompletionItemKind.File, insertText: dep.url, cancel: partialResultToken, @@ -79,6 +107,8 @@ export class Completion { } as CompletionItem; }); + completes.push(...(await import_enhanced.please(params)).items); + return completes; }); } diff --git a/server/src/language/import_completion_enhanced.ts b/server/src/language/import_completion_enhanced.ts new file mode 100644 index 00000000..8d8d6658 --- /dev/null +++ b/server/src/language/import_completion_enhanced.ts @@ -0,0 +1,252 @@ +import { + IConnection, + TextDocument, + TextDocuments, + Range, + CompletionItem, + CompletionList, + CompletionItemKind, + CompletionParams, +} from "vscode-languageserver"; + +import semver from "semver"; + +import { + parseImportStatement, + // listVersionsOfMod, + searchX, + modTreeOf, + fetchModList, + ModList, + IMP_REG, + VERSION_REG, + listVersionsOfMod, + ModListCache, + ModTreeCache, + ModTreeCacheItem, +} from "../../../core/import_enhanced"; + +import { PermCache, TRANSACTION_STATE } from "../../../core/permcache"; + +export enum CACHE_STATE { + ALREADY_CACHED = 1, + CACHE_SUCCESS = 0, + UNKNOWN_ERROR = -1, +} + +export class ImportCompletionEnhanced { + mod_tree_cache?: ModTreeCache; + mod_list_cache?: ModListCache; + + private connection: IConnection; + private documents: TextDocuments; + + constructor(connection: IConnection, documents: TextDocuments) { + this.connection = connection; + this.documents = documents; + } + + async please(param: CompletionParams): Promise { + if (!this.mod_list_cache) { + this.mod_list_cache = await PermCache.create( + "mod_list", + 60 * 60 * 24 /* expiring in a day */ + ); + } + if (!this.mod_tree_cache) { + this.mod_tree_cache = await PermCache.create( + "mod_tree" + ); + } + const { textDocument, position } = param; + const doc = this.documents.get(textDocument.uri); + if (typeof doc !== "undefined") { + const current_line_text = doc.getText( + Range.create(position.line, 0, position.line, position.character) + ); + + if (IMP_REG.test(current_line_text)) { + // We're at import statement line + const imp_info = parseImportStatement(current_line_text); + if (imp_info?.domain !== "deno.land") { + return CompletionList.create(); + } + + const index_of_at_symbol = current_line_text.indexOf("@"); + if (index_of_at_symbol !== -1) { + const maybe_version = current_line_text.substring( + index_of_at_symbol + 1 + ); + if ( + (VERSION_REG.test(maybe_version) || maybe_version.length === 0) && + position.character > index_of_at_symbol + ) { + const vers = await listVersionsOfMod(imp_info.module); + const result = vers.versions + .sort((a, b) => { + const av = semver.clean(a); + const bv = semver.clean(b); + if ( + av === null || + bv === null || + !semver.valid(av) || + !semver.valid(bv) + ) { + return 0; + } + return semver.gt(av, bv) ? -1 : 1; + }) + .map((it, i) => { + const ci = CompletionItem.create(it); + ci.sortText = `a${String.fromCharCode(i) + 1}`; + ci.filterText = it; + ci.kind = CompletionItemKind.Value; + return ci; + }); + return CompletionList.create(result); + } + } + + if (/.*deno.land\/$/.test(current_line_text)) { + // x or std + return CompletionList.create([ + { + label: "std", + insertText: "std", + kind: CompletionItemKind.Module, + }, + { label: "x", insertText: "x/", kind: CompletionItemKind.Module }, + ]); + } + + if (/.*deno.land\/x\/([\w-_]+)?$/.test(current_line_text)) { + // x modules + if (this.mod_list_cache !== undefined) { + const result = await searchX(this.mod_list_cache, imp_info.module); + const r = result.map((it) => { + const ci = CompletionItem.create(it.name); + ci.kind = CompletionItemKind.Module; + ci.detail = it.description; + ci.insertText = `${it.name}@`; + ci.sortText = String.fromCharCode(1); + ci.filterText = it.name; + ci.command = { + command: "editor.action.triggerSuggest", + title: "Re-trigger completions...", + }; + return ci; + }); + return CompletionList.create(r); + } else { + return CompletionList.create(); + } + } + + if (/.*deno.land(\/x)?\/.+?(@.+)?\//.test(current_line_text)) { + // modules tree completion + const result = await modTreeOf( + imp_info.module, + imp_info.version, + this.mod_tree_cache + ); + const arr_path = imp_info.path.split("/"); + const path = arr_path.slice(0, arr_path.length - 1).join("/") + "/"; + return CompletionList.create( + result.directory_listing + .filter((it) => it.path.startsWith(path)) + .map((it) => ({ + path: + path.length > 1 + ? it.path.replace(path, "") + : it.path.substring(1), + size: it.size, + type: it.type, + })) + .filter((it) => it.path.split("/").length < 2) + .filter( + (it) => + !( + it.path.endsWith("_test.ts") || it.path.endsWith("_test.js") + ) && + (it.path.endsWith(".ts") || + it.path.endsWith(".js") || + it.path.endsWith(".tsx") || + it.path.endsWith(".jsx") || + it.path.endsWith(".mjs") || + it.type !== "file") && + !it.path.startsWith("_") && + !it.path.startsWith(".") && + (it.path !== "testdata" || it.type !== "dir") && + it.path.length !== 0 + ) + .map((it) => { + const r = CompletionItem.create(it.path); + r.kind = + it.type === "dir" + ? CompletionItemKind.Folder + : CompletionItemKind.File; + r.sortText = it.type === "dir" ? "a" : "b"; + r.insertText = it.type === "dir" ? it.path + "/" : it.path; + if (it.type === "dir") { + r.command = { + command: "editor.action.triggerSuggest", + title: "Re-trigger completions...", + }; + } + return r; + }) + ); + } + return CompletionList.create(); + } + } + return CompletionList.create(); + } + + async clearCache(): Promise { + await this.mod_list_cache?.destroy_cache(); + await this.mod_tree_cache?.destroy_cache(); + } + + async cacheModList(): Promise { + const progress = await this.connection.window.createWorkDoneProgress(); + progress.begin("Fetching deno.land/x module list...", 0); + if (!this.mod_list_cache) { + this.mod_list_cache = await PermCache.create( + "mod_list", + 60 * 60 * 24 /* expiring in a day */ + ); + } + + if ( + this.mod_list_cache.expired() || + !this.mod_list_cache.get() || + this.mod_list_cache.get()?.length === 0 + ) { + await this.mod_list_cache.set([]); + progress.report(0); + if ( + this.mod_list_cache.transaction_begin() === TRANSACTION_STATE.SUCCESS + ) { + for await (const modules of fetchModList()) { + const list_in_cache = this.mod_list_cache.transaction_get() + .data as ModList; + list_in_cache.push(...modules.data); + this.mod_list_cache.transaction_set(list_in_cache); + progress.report((modules.current / modules.total) * 100); + } + if ( + (await this.mod_list_cache.transaction_commit()) === + TRANSACTION_STATE.SUCCESS + ) { + progress.done(); + return CACHE_STATE.CACHE_SUCCESS; + } + } + progress.done(); + return CACHE_STATE.UNKNOWN_ERROR; + } + progress.done(); + return CACHE_STATE.ALREADY_CACHED; + } +} diff --git a/server/src/server.ts b/server/src/server.ts index 350321ef..d211f501 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -9,6 +9,7 @@ import { InitializeResult, TextDocumentSyncKind, CodeActionKind, + ExecuteCommandParams, } from "vscode-languageserver"; import { TextDocument } from "vscode-languageserver-textdocument"; @@ -23,6 +24,10 @@ import { DocumentFormatting } from "./language/document_formatting"; import { Hover } from "./language/hover"; import { Completion } from "./language/completion"; import { CodeLens } from "./language/code_lens"; +import { + ImportCompletionEnhanced, + CACHE_STATE, +} from "./language/import_completion_enhanced"; import { getDenoDir, getDenoDts } from "../../core/deno"; import { pathExists } from "../../core/util"; @@ -42,6 +47,7 @@ const connection: IConnection = createConnection( const documents = new TextDocuments(TextDocument); const bridge = new Bridge(connection); +const import_enhanced = new ImportCompletionEnhanced(connection, documents); new DependencyTree(connection, bridge); new Diagnostics(SERVER_NAME, connection, bridge, documents); new Definition(connection, documents); @@ -49,11 +55,9 @@ new References(connection, documents); new DocumentHighlight(connection, documents); new DocumentFormatting(connection, documents, bridge); new Hover(connection, documents); -new Completion(connection, documents); +new Completion(connection, documents, import_enhanced); new CodeLens(connection, documents); -connection; - connection.onInitialize( (): InitializeResult => { return { @@ -65,7 +69,7 @@ connection.onInitialize( change: TextDocumentSyncKind.Full, }, completionProvider: { - triggerCharacters: ["http", "https"], + triggerCharacters: ["http", "https", "@", '"', "'", "/"], }, codeActionProvider: { codeActionKinds: [CodeActionKind.QuickFix], @@ -75,6 +79,9 @@ connection.onInitialize( referencesProvider: true, definitionProvider: true, codeLensProvider: {}, + executeCommandProvider: { + commands: ["deno._clear_import_enhencement_cache"], + }, }, }; } @@ -118,6 +125,28 @@ connection.onInitialized(async () => { executablePath: deno.executablePath, DENO_DIR: getDenoDir(), }); + connection.onExecuteCommand(async (params: ExecuteCommandParams) => { + if (params.command === "deno._clear_import_enhencement_cache") { + import_enhanced + .clearCache() + .then(() => connection.window.showInformationMessage("Clear success!")) + .catch(() => connection.window.showErrorMessage("Clear failed!")); + } + }); + import_enhanced + .cacheModList() + .then((it) => { + if (it === CACHE_STATE.CACHE_SUCCESS) { + connection.window.showInformationMessage( + "deno.land/x module list cached successfully!" + ); + } + }) + .catch(() => + connection.window.showErrorMessage( + "deno.land/x module list failed to cache!" + ) + ); connection.console.log("server initialized."); }); diff --git a/snippets/import_enhancement.json b/snippets/import_enhancement.json index c8cbf1a9..2152071c 100644 --- a/snippets/import_enhancement.json +++ b/snippets/import_enhancement.json @@ -8,7 +8,7 @@ "Import deno.land/x module": { "scope": "javascript,typescript,javascriptreact,typescriptreact", "prefix": ["impx", "importx"], - "body": "import {$4} from 'https://deno.land/x/$1@$2/$3';$0", + "body": "import {$3} from 'https://deno.land/x/$1/$2';$0", "description": "Use this snippet to import a deno.land/x module." } }