diff --git a/client/src/extension.ts b/client/src/extension.ts index adf18a3f..99ab3e47 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -32,6 +32,11 @@ 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"; @@ -113,6 +118,9 @@ 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 = {}; @@ -465,6 +473,14 @@ 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); @@ -595,6 +611,23 @@ 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); @@ -607,6 +640,8 @@ 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 new file mode 100644 index 00000000..f18a0df9 --- /dev/null +++ b/client/src/import_enhancement_provider.ts @@ -0,0 +1,255 @@ +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/import_utils.test.ts b/client/src/import_utils.test.ts new file mode 100644 index 00000000..3f92cc35 --- /dev/null +++ b/client/src/import_utils.test.ts @@ -0,0 +1,106 @@ +import { + listVersionsOfMod, + modTreeOf, + parseImportStatement, +} from "./import_utils"; + +test("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"); + expect(result.uploaded_at).toBeTruthy(); + expect(result.directory_listing.length).not.toEqual(0); +}); + +test("import-enhance parseImportStatement", async () => { + const test_cases = [ + { + imp: "import * from 'http://a.c/xx/a.ts'", + expect: { + domain: "a.c", + module: "xx", + version: "latest", + path: "/a.ts", + }, + }, + { + imp: "import * from 'http://deno.land/std@0.66.0/fs/copy.ts'", + expect: { + domain: "deno.land", + module: "std", + version: "0.66.0", + path: "/fs/copy.ts", + }, + }, + { + imp: "import * from 'https://deno.land/x/sha2@1.0.0/mod/sha224.ts'", + expect: { + domain: "deno.land", + module: "sha2", + version: "1.0.0", + path: "/mod/sha224.ts", + }, + }, + { + imp: "import {} from 'https://deno.land/std@/';", + expect: { + domain: "deno.land", + module: "std", + version: "latest", + path: "/", + }, + }, + // non semver verions + { + imp: "import {} from 'https://deno.land/std@1.0.0-alpha/';", + expect: { + domain: "deno.land", + module: "std", + version: "1.0.0-alpha", + path: "/", + }, + }, + { + imp: "import {} from 'https://deno.land/std@v1/';", + expect: { + domain: "deno.land", + module: "std", + version: "v1", + path: "/", + }, + }, + { + imp: "import {} from 'https://deno.land/x/@/';", + expect: { + domain: "deno.land", + module: "", + version: "latest", + path: "/", + }, + }, + { + imp: "import { } from 'https://deno.land/x/sq'", + expect: { + domain: "deno.land", + module: "sq", + version: "latest", + path: "/", + }, + }, + ] as { + imp: string; + expect: { domain: string; module: string; version: string; path: string }; + }[]; + + for (const test_case of test_cases) { + const result = parseImportStatement(test_case.imp); + expect(result?.domain).toEqual(test_case.expect.domain); + expect(result?.module).toEqual(test_case.expect.module); + expect(result?.version).toEqual(test_case.expect.version); + expect(result?.path).toEqual(test_case.expect.path); + } +}); diff --git a/client/src/import_utils.ts b/client/src/import_utils.ts new file mode 100644 index 00000000..8c75cd43 --- /dev/null +++ b/client/src/import_utils.ts @@ -0,0 +1,167 @@ +/* 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"; + +export interface ModuleInfo { + name: string; + description: string; + star_count: string; + search_score: number; +} + +export async function* fetchModList(): AsyncGenerator<{ + current: number; + total: number; + data: ModuleInfo[]; +}> { + // https://api.deno.land/modules?limit=100&query=$QUERY + + let response: { + success: boolean; + data: { + total_count: number; + results: ModuleInfo[]; + }; + }; + let page = 1; + do { + response = await got( + `https://api.deno.land/modules?limit=100&page=${page}` + ).json(); + if (Array.isArray(response.data.results)) { + yield { + current: page, + total: Math.ceil(response.data.total_count / 100), + data: response.data.results, + }; + } + page++; + } while (response.success && response.data.results.length > 0); +} + +// this function now is search from cache only +export async function searchX( + cache: VC, + keyword: string +): Promise { + if (cache.has("mod_list")) { + const buff = cache.get("mod_list") as ModuleInfo[]; + return buff + .filter((it) => it.name.startsWith(keyword)) + .sort((a, b) => b.search_score - a.search_score); + } else { + return []; + } +} + +interface ModVersions { + latest: string; + versions: string[]; +} +export async function listVersionsOfMod( + module_name: string +): Promise { + // https://cdn.deno.land/$MODULE/meta/versions.json + const response: ModVersions = await got( + `https://cdn.deno.land/${encodeURIComponent( + module_name + )}/meta/versions.json` + ).json(); + return response; +} + +interface ModTreeItem { + path: string; + size: number; + type: string; +} + +interface ModTree { + uploaded_at: string; // Use this to update cache + directory_listing: ModTreeItem[]; +} +export async function modTreeOf( + vc: VC | undefined, + module_name: string, + version = "latest" +): Promise { + // https://cdn.deno.land/$MODULE/versions/$VERSION/meta/meta.json + let ver = version; + if (ver === "latest") { + const vers = await listVersionsOfMod(module_name); + ver = vers.latest; + } + + const cache_key = `mod_tree:${module_name}@${ver}`; + if (vc?.has(cache_key)) { + // use cache + return vc.get(cache_key) as ModTree; + } + + const response: ModTree = await got( + `https://cdn.deno.land/${encodeURIComponent( + module_name + )}/versions/${ver}/meta/meta.json` + ).json(); + + // cache it + vc?.put(cache_key, response); + + return response; +} + +interface ImportUrlInfo { + domain: string; + module: string; + version: string; + path: string; +} + +export function parseImportStatement(text: string): ImportUrlInfo | undefined { + const reg_groups = text.match(/.*?['"](?.*?)['"]/)?.groups; + if (!reg_groups) { + return undefined; + } + const import_url = reg_groups["url"] ?? ""; + try { + const url = new URL(import_url); + const components = url.pathname.split("/"); + const parse = (components: string[]) => { + const module_info = components[0].split("@"); + if (module_info.length > 1) { + const module = module_info[0]; + const version = module_info[1].length === 0 ? "latest" : module_info[1]; + const path = "/" + components.slice(1, components.length).join("/"); + return { + domain: url.hostname, + module, + version, + path, + }; + } else { + const module = module_info[0]; + const version = "latest"; + const path = "/" + components.slice(1, components.length).join("/"); + return { + domain: url.hostname, + module, + version, + path, + }; + } + }; + if (components.length > 2) { + const m = components[1]; + if (m === "x") { + return parse(components.slice(2, components.length)); + } else { + return parse(components.slice(1, components.length)); + } + } + } catch { + return undefined; + } +} diff --git a/client/src/types/vscode-cache.d.ts b/client/src/types/vscode-cache.d.ts new file mode 100644 index 00000000..48279d11 --- /dev/null +++ b/client/src/types/vscode-cache.d.ts @@ -0,0 +1,27 @@ +/* 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/package.json b/package.json index 4a217571..d6063fee 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,11 @@ "light": "resource/icon/refresh.light.svg", "dark": "resource/icon/refresh.dark.svg" } + }, + { + "command": "deno._clear_import_enhencement_cache", + "title": "Clear import enhencement cache", + "category": "deno" } ], "views": { @@ -134,6 +139,24 @@ ], "url": "./schemas/import_map.schema.json" } + ], + "snippets": [ + { + "language": "javascript", + "path": "snippets/import_enhancement.json" + }, + { + "language": "typescript", + "path": "snippets/import_enhancement.json" + }, + { + "language": "javascriptreact", + "path": "snippets/import_enhancement.json" + }, + { + "language": "typescriptreact", + "path": "snippets/import_enhancement.json" + } ] }, "scripts": { @@ -146,6 +169,7 @@ "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" }, @@ -171,8 +195,10 @@ "dependencies": { "deep-equal": "2.0.3", "deepmerge": "^4.2.2", + "got": "^11.5.2", "json5": "^2.1.3", + "semver": "7.3.2", "typescript-deno-plugin": "./typescript-deno-plugin", - "semver": "7.3.2" + "vscode-cache": "^0.3.0" } } diff --git a/snippets/import_enhancement.json b/snippets/import_enhancement.json new file mode 100644 index 00000000..c8cbf1a9 --- /dev/null +++ b/snippets/import_enhancement.json @@ -0,0 +1,14 @@ +{ + "Import deno.land/std": { + "scope": "javascript,typescript,javascriptreact,typescriptreact", + "prefix": ["impstd", "importstd"], + "body": "import {$3} from 'https://deno.land/std@$1/$2';$0", + "description": "Use this snippet to import a deno.land/std module." + }, + "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", + "description": "Use this snippet to import a deno.land/x module." + } +}