From 816a4d8ccd518ea4227f00a4e3af1cd7d06cc3cd Mon Sep 17 00:00:00 2001 From: Jonathan Cammisuli Date: Tue, 23 Aug 2022 16:45:19 -0400 Subject: [PATCH] feat(nxls): path completion for files and directories (#1326) --- .vscode/launch.json | 1 + apps/nxls/src/completions/index.ts | 71 ++++++++++ apps/nxls/src/completions/path-completion.ts | 125 ++++++++++++++++++ .../nxls/src/completions/target-completion.ts | 13 ++ apps/nxls/src/main.ts | 53 ++++++-- .../language-model-cache.ts} | 0 apps/nxls/src/utils/merge-arrays.ts | 8 ++ apps/nxls/src/utils/node-types.ts | 18 +++ apps/nxls/src/{ => utils}/runtime.ts | 0 apps/nxls/tsconfig.app.json | 3 +- libs/json-schema/src/index.ts | 1 + libs/json-schema/src/lib/completion-type.ts | 19 +++ .../src/lib/project-json-schema.ts | 29 +++- libs/utils/src/shared.ts | 7 + .../src/lib/task-execution-form.component.ts | 5 +- package.json | 1 + tsconfig.base.json | 1 + yarn.lock | 2 +- 18 files changed, 339 insertions(+), 18 deletions(-) create mode 100644 apps/nxls/src/completions/index.ts create mode 100644 apps/nxls/src/completions/path-completion.ts create mode 100644 apps/nxls/src/completions/target-completion.ts rename apps/nxls/src/{languageModelCache.ts => utils/language-model-cache.ts} (100%) create mode 100644 apps/nxls/src/utils/merge-arrays.ts create mode 100644 apps/nxls/src/utils/node-types.ts rename apps/nxls/src/{ => utils}/runtime.ts (100%) create mode 100644 libs/json-schema/src/lib/completion-type.ts create mode 100644 libs/utils/src/shared.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index bc7a1e6190..1ca86f6b34 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -47,6 +47,7 @@ "request": "attach", "name": "Attach to LSP Server", "port": 6009, + "timeout": 30000, "restart": true, "sourceMaps": true, "skipFiles": [ diff --git a/apps/nxls/src/completions/index.ts b/apps/nxls/src/completions/index.ts new file mode 100644 index 0000000000..7e23483156 --- /dev/null +++ b/apps/nxls/src/completions/index.ts @@ -0,0 +1,71 @@ +import { + ASTNode, + CompletionItem, + JSONSchema, + TextDocument, +} from 'vscode-json-languageservice'; +import { + CompletionType, + hasCompletionGlob, + hasCompletionType, + X_COMPLETION_GLOB, + X_COMPLETION_TYPE, +} from '@nx-console/json-schema'; +import { pathCompletion } from './path-completion'; +import { targetCompletion } from './target-completion'; + +export async function getCompletionItems( + workingPath: string | undefined, + schema: JSONSchema, + node: ASTNode, + document: TextDocument +): Promise { + if (!workingPath) { + return []; + } + + const items = completionItems(workingPath, node, document); + + if (hasCompletionType(schema)) { + const completion = schema[X_COMPLETION_TYPE]; + if (hasCompletionGlob(schema)) { + return items(completion, schema[X_COMPLETION_GLOB]); + } + + return items(completion); + } else { + return []; + } +} + +function completionItems( + workingPath: string, + node: ASTNode, + document: TextDocument +) { + return async ( + completion: CompletionType, + glob?: string + ): Promise => { + switch (completion) { + case 'file': { + return pathCompletion(workingPath, node, document, { + glob: glob ?? '**/*.*', + searchType: 'file', + }); + } + case 'directory': { + return pathCompletion(workingPath, node, document, { + glob: glob ?? '*', + searchType: 'directory', + }); + } + case 'target': { + return targetCompletion(workingPath, node, document); + } + default: { + return []; + } + } + }; +} diff --git a/apps/nxls/src/completions/path-completion.ts b/apps/nxls/src/completions/path-completion.ts new file mode 100644 index 0000000000..49bb5d8324 --- /dev/null +++ b/apps/nxls/src/completions/path-completion.ts @@ -0,0 +1,125 @@ +import fastGlob from 'fast-glob'; +import { + ASTNode, + CompletionItem, + CompletionItemKind, + TextDocument, +} from 'vscode-json-languageservice'; +import { + isObjectNode, + isPropertyNode, + isStringNode, +} from '../utils/node-types'; + +export async function pathCompletion( + workingPath: string | undefined, + node: ASTNode, + document: TextDocument, + options?: { + glob: string; + searchType: 'file' | 'directory'; + supportsInterpolation?: boolean; + } +): Promise { + const items: CompletionItem[] = []; + + if (!workingPath) { + return items; + } + + const { supportsInterpolation, glob, searchType } = { + supportsInterpolation: false, + ...options, + }; + + if (!isStringNode(node)) { + return items; + } + + const projectRoot = findProjectRoot(node); + + const files = await fastGlob([workingPath + '/**/' + glob], { + ignore: ['**/node_modules/**'], + dot: true, + onlyFiles: searchType === 'file', + onlyDirectories: searchType === 'directory', + objectMode: true, + }); + + for (const file of files) { + if ( + supportsInterpolation && + file.path.startsWith(workingPath + '/' + projectRoot) + ) { + const label = + '{projectRoot}' + + file.path.replace(workingPath + '/' + projectRoot, ''); + + items.push(addCompletionPathItem(label, file.path, node, document)); + } + + if (file.path.startsWith(workingPath)) { + const label = file.path.replace(workingPath + '/', ''); + items.push(addCompletionPathItem(label, file.path, node, document)); + + if (supportsInterpolation) { + const label = '{workspaceRoot}' + file.path.replace(workingPath, ''); + items.push(addCompletionPathItem(label, file.path, node, document)); + } + } + } + + return items; +} + +/** + * Get the first `root` property from the current node to determine `${projectRoot}` + * @param node + * @returns + */ +function findProjectRoot(node: ASTNode): string { + if (isObjectNode(node)) { + for (const child of node.children) { + if (isPropertyNode(child)) { + if ( + (child.keyNode.value === 'root' || + child.keyNode.value === 'sourceRoot') && + isStringNode(child.valueNode) + ) { + return child.valueNode?.value; + } + } + } + } + + if (node.parent) { + return findProjectRoot(node.parent); + } + + return ''; +} + +function addCompletionPathItem( + label: string, + path: string, + node: ASTNode, + document: TextDocument +): CompletionItem { + const startPosition = document.positionAt(node.offset); + const endPosition = document.positionAt(node.offset + node.length); + label = `"${label}"`; + return { + label, + kind: CompletionItemKind.File, + insertText: label, + insertTextFormat: 2, + textEdit: { + newText: label, + range: { + start: startPosition, + end: endPosition, + }, + }, + detail: path, + }; +} diff --git a/apps/nxls/src/completions/target-completion.ts b/apps/nxls/src/completions/target-completion.ts new file mode 100644 index 0000000000..efa8e87947 --- /dev/null +++ b/apps/nxls/src/completions/target-completion.ts @@ -0,0 +1,13 @@ +import { + ASTNode, + CompletionItem, + TextDocument, +} from 'vscode-json-languageservice'; + +export async function targetCompletion( + workingPath: string | undefined, + node: ASTNode, + document: TextDocument +): Promise { + return []; +} diff --git a/apps/nxls/src/main.ts b/apps/nxls/src/main.ts index 74d210de92..6314734460 100644 --- a/apps/nxls/src/main.ts +++ b/apps/nxls/src/main.ts @@ -7,6 +7,7 @@ import { } from '@nx-console/json-schema'; import { ClientCapabilities, + CompletionList, getLanguageService, TextDocument, } from 'vscode-json-languageservice'; @@ -18,8 +19,12 @@ import { TextDocumentSyncKind, } from 'vscode-languageserver/node'; import { URI, Utils } from 'vscode-uri'; -import { getLanguageModelCache } from './languageModelCache'; -import { getSchemaRequestService } from './runtime'; +import { getCompletionItems } from './completions'; +import { getLanguageModelCache } from './utils/language-model-cache'; +import { getSchemaRequestService } from './utils/runtime'; +import { mergeArrays } from './utils/merge-arrays'; + +let WORKING_PATH: string | undefined = undefined; const workspaceContext = { resolveRelativePath: (relativePath: string, resource: string) => { @@ -54,12 +59,16 @@ connection.onInitialize(async (params) => { }); try { - const workingPath = + WORKING_PATH = workspacePath ?? params.rootPath ?? URI.parse(params.rootUri ?? '').fsPath; - // get schemas - const collections = await getExecutors(workingPath, projects, false); + + if (!WORKING_PATH) { + throw 'Unable to determine workspace path'; + } + + const collections = await getExecutors(WORKING_PATH, projects, false); const workspaceSchema = getWorkspaceJsonSchema(collections); const projectSchema = getProjectJsonSchema(collections); languageService.configure({ @@ -101,11 +110,35 @@ connection.onCompletion(async (completionParams) => { } const { jsonAst, document } = getJsonDocument(changedDocument); - return languageService.doComplete( - document, - completionParams.position, - jsonAst - ); + + const completionResults = + (await languageService.doComplete( + document, + completionParams.position, + jsonAst + )) ?? CompletionList.create([]); + + const offset = document.offsetAt(completionParams.position); + const node = jsonAst.getNodeFromOffset(offset); + if (!node) { + return completionResults; + } + + const schemas = await languageService.getMatchingSchemas(document, jsonAst); + for (const s of schemas) { + if (s.node === node) { + const pathItems = await getCompletionItems( + WORKING_PATH, + s.schema, + node, + document + ); + mergeArrays(completionResults.items, pathItems); + break; + } + } + + return completionResults; }); connection.onHover(async (hoverParams) => { diff --git a/apps/nxls/src/languageModelCache.ts b/apps/nxls/src/utils/language-model-cache.ts similarity index 100% rename from apps/nxls/src/languageModelCache.ts rename to apps/nxls/src/utils/language-model-cache.ts diff --git a/apps/nxls/src/utils/merge-arrays.ts b/apps/nxls/src/utils/merge-arrays.ts new file mode 100644 index 0000000000..6d83042cd4 --- /dev/null +++ b/apps/nxls/src/utils/merge-arrays.ts @@ -0,0 +1,8 @@ +/** + * Combines the second array with the first array, without having to loop or change the reference of the first array. + * @param arr1 + * @param arr2 + */ +export function mergeArrays(arr1: Array, arr2: Array) { + Array.prototype.push.apply(arr1, arr2); +} diff --git a/apps/nxls/src/utils/node-types.ts b/apps/nxls/src/utils/node-types.ts new file mode 100644 index 0000000000..8f56b1fd7c --- /dev/null +++ b/apps/nxls/src/utils/node-types.ts @@ -0,0 +1,18 @@ +import { + ASTNode, + ObjectASTNode, + PropertyASTNode, + StringASTNode, +} from 'vscode-json-languageservice'; + +export function isPropertyNode(node?: ASTNode): node is PropertyASTNode { + return node?.type === 'property'; +} + +export function isObjectNode(node?: ASTNode): node is ObjectASTNode { + return node?.type === 'object'; +} + +export function isStringNode(node?: ASTNode): node is StringASTNode { + return node?.type === 'string'; +} diff --git a/apps/nxls/src/runtime.ts b/apps/nxls/src/utils/runtime.ts similarity index 100% rename from apps/nxls/src/runtime.ts rename to apps/nxls/src/utils/runtime.ts diff --git a/apps/nxls/tsconfig.app.json b/apps/nxls/tsconfig.app.json index 908dff6b33..cdba942ebe 100644 --- a/apps/nxls/tsconfig.app.json +++ b/apps/nxls/tsconfig.app.json @@ -3,8 +3,9 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "module": "ESNext", + "esModuleInterop": true, "types": ["node"] }, "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"], - "include": ["**/*.ts"] + "include": ["**/*.ts", "../../libs/json-schema/src/lib/completion-type.ts"] } diff --git a/libs/json-schema/src/index.ts b/libs/json-schema/src/index.ts index 412433042e..4cf16bd929 100644 --- a/libs/json-schema/src/index.ts +++ b/libs/json-schema/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/workspace-json-schema'; export * from './lib/project-json-schema'; +export * from './lib/completion-type'; diff --git a/libs/json-schema/src/lib/completion-type.ts b/libs/json-schema/src/lib/completion-type.ts new file mode 100644 index 0000000000..91a825f78a --- /dev/null +++ b/libs/json-schema/src/lib/completion-type.ts @@ -0,0 +1,19 @@ +import { JSONSchema } from 'vscode-json-languageservice'; +import { hasKey } from '@nx-console/utils/shared'; + +export const X_COMPLETION_TYPE = 'x-completion-type' as const; +export const X_COMPLETION_GLOB = 'x-completion-glob' as const; + +export type CompletionType = 'file' | 'directory' | 'target'; + +export function hasCompletionType( + schema: JSONSchema +): schema is JSONSchema & { [X_COMPLETION_TYPE]: CompletionType } { + return hasKey(schema, X_COMPLETION_TYPE); +} + +export function hasCompletionGlob( + schema: JSONSchema +): schema is JSONSchema & { [X_COMPLETION_GLOB]: string } { + return hasKey(schema, X_COMPLETION_GLOB); +} diff --git a/libs/json-schema/src/lib/project-json-schema.ts b/libs/json-schema/src/lib/project-json-schema.ts index 4f49db6c7a..71031ffaad 100644 --- a/libs/json-schema/src/lib/project-json-schema.ts +++ b/libs/json-schema/src/lib/project-json-schema.ts @@ -1,6 +1,11 @@ import { CollectionInfo } from '@nx-console/schema'; -import { JSONSchema } from 'vscode-json-languageservice'; +import type { JSONSchema } from 'vscode-json-languageservice'; import { createBuildersAndExecutorsSchema } from './create-builders-and-executors-schema'; +import { + CompletionType, + X_COMPLETION_GLOB, + X_COMPLETION_TYPE, +} from './completion-type'; export function getProjectJsonSchema(collections: CollectionInfo[]) { const [, executors] = createBuildersAndExecutorsSchema(collections); @@ -8,14 +13,34 @@ export function getProjectJsonSchema(collections: CollectionInfo[]) { return contents; } -function createJsonSchema(executors: JSONSchema[]): JSONSchema { +interface EnhancedJsonSchema extends JSONSchema { + [X_COMPLETION_TYPE]?: CompletionType; + [X_COMPLETION_GLOB]?: string; +} + +function createJsonSchema(executors: JSONSchema[]): EnhancedJsonSchema { return { type: 'object', properties: { + root: { + type: 'string', + 'x-completion-type': 'directory', + } as EnhancedJsonSchema, + sourceRoot: { + type: 'string', + 'x-completion-type': 'directory', + } as EnhancedJsonSchema, targets: { additionalProperties: { type: 'object', properties: { + outputs: { + type: 'array', + items: { + type: 'string', + 'x-completion-type': 'directory', + } as EnhancedJsonSchema, + }, executor: { type: 'string', }, diff --git a/libs/utils/src/shared.ts b/libs/utils/src/shared.ts new file mode 100644 index 0000000000..2e31d32627 --- /dev/null +++ b/libs/utils/src/shared.ts @@ -0,0 +1,7 @@ +/** + * Shared utility functions between vscode and the lsp + */ + +export function hasKey(obj: T, key: PropertyKey): key is keyof T { + return key in obj; +} diff --git a/libs/vscode-ui/feature-task-execution-form/src/lib/task-execution-form.component.ts b/libs/vscode-ui/feature-task-execution-form/src/lib/task-execution-form.component.ts index 4fc326c7ca..1eed42cdeb 100644 --- a/libs/vscode-ui/feature-task-execution-form/src/lib/task-execution-form.component.ts +++ b/libs/vscode-ui/feature-task-execution-form/src/lib/task-execution-form.component.ts @@ -48,6 +48,7 @@ import { OptionType, Option, } from '@nx-console/schema'; +import { hasKey } from '@nx-console/utils/shared'; declare global { interface Window { @@ -321,10 +322,6 @@ export class TaskExecutionFormComponent implements OnInit, AfterViewChecked { schema: Option, contextValues?: typeof architect['contextValues'] ): string | undefined => { - function hasKey(obj: T, key: PropertyKey): key is keyof T { - return key in obj; - } - if (!contextValues) { return; } diff --git a/package.json b/package.json index ca060beba7..9d9258d18f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@nrwl/devkit": "14.4.1", "@yarnpkg/fslib": "2.6.1-rc.5", "@yarnpkg/libzip": "2.2.3-rc.5", + "fast-glob": "^3.2.11", "find-cache-dir": "^3.3.2", "jsonc-parser": "^3.0.0", "request-light": "^0.5.8", diff --git a/tsconfig.base.json b/tsconfig.base.json index 518a2337cf..dcbaf823e9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -29,6 +29,7 @@ "@nx-console/schema/normalize": ["libs/schema/src/normalize-schema.ts"], "@nx-console/typescript-plugin": ["libs/typescript-plugin/src/index.ts"], "@nx-console/utils": ["libs/utils/src/index.ts"], + "@nx-console/utils/shared": ["libs/utils/src/shared.ts"], "@nx-console/vscode-ui/argument-list": [ "libs/vscode-ui/argument-list/src/index.ts" ], diff --git a/yarn.lock b/yarn.lock index 0344e88d29..07d4e0d365 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11765,7 +11765,7 @@ fast-glob@^2.2.6: merge2 "^1.2.3" micromatch "^3.1.10" -fast-glob@^3.0.3, fast-glob@^3.2.9: +fast-glob@^3.0.3, fast-glob@^3.2.11, fast-glob@^3.2.9: version "3.2.11" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9" integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==