diff --git a/.chronus/changes/improvements-for-the-intellisense-of-tspconfig.yaml-2024-11-19-2-35-1.md b/.chronus/changes/improvements-for-the-intellisense-of-tspconfig.yaml-2024-11-19-2-35-1.md new file mode 100644 index 0000000000..f4866cc4b1 --- /dev/null +++ b/.chronus/changes/improvements-for-the-intellisense-of-tspconfig.yaml-2024-11-19-2-35-1.md @@ -0,0 +1,10 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Improvements for the intellisense of tspconfig.yaml +- Support the auto completion for extends, imports, rule, rule sets and variables in tspconfig.yaml +- Show required/optional information in the details of emitter's options completion item in tspconfig.yaml diff --git a/.chronus/changes/improvements-for-the-intellisense-of-tspconfig.yaml-2024-11-19-2-35-2.md b/.chronus/changes/improvements-for-the-intellisense-of-tspconfig.yaml-2024-11-19-2-35-2.md new file mode 100644 index 0000000000..04d558cae3 --- /dev/null +++ b/.chronus/changes/improvements-for-the-intellisense-of-tspconfig.yaml-2024-11-19-2-35-2.md @@ -0,0 +1,9 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Fix bug in tspconfig.yaml +- Fix the issue that extra " will be added when auto completing emitter options inside "" diff --git a/packages/compiler/src/server/emitter-provider.ts b/packages/compiler/src/server/lib-provider.ts similarity index 57% rename from packages/compiler/src/server/emitter-provider.ts rename to packages/compiler/src/server/lib-provider.ts index 59d54e05da..746fa83dc4 100644 --- a/packages/compiler/src/server/emitter-provider.ts +++ b/packages/compiler/src/server/lib-provider.ts @@ -1,16 +1,19 @@ import { joinPaths } from "../core/path-utils.js"; import { NpmPackage, NpmPackageProvider } from "./npm-package-provider.js"; -export class EmitterProvider { - private isEmitterPackageCache = new Map(); - constructor(private npmPackageProvider: NpmPackageProvider) {} +export class LibraryProvider { + private libPackageFilterResultCache = new Map(); + constructor( + private npmPackageProvider: NpmPackageProvider, + private filter: (libExports: Record) => boolean, + ) {} /** * - * @param startFolder folder starts to search for package.json with emitters defined as dependencies + * @param startFolder folder starts to search for package.json with library defined as dependencies * @returns */ - async listEmitters(startFolder: string): Promise> { + async listLibraries(startFolder: string): Promise> { const packageJsonFolder = await this.npmPackageProvider.getPackageJsonFolder(startFolder); if (!packageJsonFolder) return {}; @@ -18,37 +21,37 @@ export class EmitterProvider { const data = await pkg?.getPackageJsonData(); if (!data) return {}; - const emitters: Record = {}; + const libs: Record = {}; const allDep = { ...(data.dependencies ?? {}), ...(data.devDependencies ?? {}), }; for (const dep of Object.keys(allDep)) { - const depPkg = await this.getEmitterFromDep(packageJsonFolder, dep); + const depPkg = await this.getLibraryFromDep(packageJsonFolder, dep); if (depPkg) { - emitters[dep] = depPkg; + libs[dep] = depPkg; } } - return emitters; + return libs; } /** * - * @param startFolder folder starts to search for package.json with emitters defined as dependencies - * @param emitterName + * @param startFolder folder starts to search for package.json with library defined as dependencies + * @param libName * @returns */ - async getEmitter(startFolder: string, emitterName: string): Promise { + async getLibrary(startFolder: string, libName: string): Promise { const packageJsonFolder = await this.npmPackageProvider.getPackageJsonFolder(startFolder); if (!packageJsonFolder) { return undefined; } - return this.getEmitterFromDep(packageJsonFolder, emitterName); + return this.getLibraryFromDep(packageJsonFolder, libName); } - private async isEmitter(depName: string, pkg: NpmPackage) { - if (this.isEmitterPackageCache.has(depName)) { - return this.isEmitterPackageCache.get(depName); + private async getLibFilterResult(depName: string, pkg: NpmPackage) { + if (this.libPackageFilterResultCache.has(depName)) { + return this.libPackageFilterResultCache.get(depName); } const data = await pkg.getPackageJsonData(); @@ -61,19 +64,20 @@ export class EmitterProvider { const exports = await pkg.getModuleExports(); // don't add to cache when failing to load exports which is unexpected if (!exports) return false; - const isEmitter = exports.$onEmit !== undefined; - this.isEmitterPackageCache.set(depName, isEmitter); - return isEmitter; + + const filterResult = this.filter(exports); + this.libPackageFilterResultCache.set(depName, filterResult); + return filterResult; } else { - this.isEmitterPackageCache.set(depName, false); + this.libPackageFilterResultCache.set(depName, false); return false; } } - private async getEmitterFromDep(packageJsonFolder: string, depName: string) { + private async getLibraryFromDep(packageJsonFolder: string, depName: string) { const depFolder = joinPaths(packageJsonFolder, "node_modules", depName); const depPkg = await this.npmPackageProvider.get(depFolder); - if (depPkg && (await this.isEmitter(depName, depPkg))) { + if (depPkg && (await this.getLibFilterResult(depName, depPkg))) { return depPkg; } return undefined; diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index 0e883c04b7..b071526ee9 100644 --- a/packages/compiler/src/server/serverlib.ts +++ b/packages/compiler/src/server/serverlib.ts @@ -80,9 +80,9 @@ import { createCompileService } from "./compile-service.js"; import { resolveCompletion } from "./completion.js"; import { Commands } from "./constants.js"; import { convertDiagnosticToLsp } from "./diagnostics.js"; -import { EmitterProvider } from "./emitter-provider.js"; import { createFileService } from "./file-service.js"; import { createFileSystemCache } from "./file-system-cache.js"; +import { LibraryProvider } from "./lib-provider.js"; import { NpmPackageProvider } from "./npm-package-provider.js"; import { getSymbolStructure } from "./symbol-structure.js"; import { provideTspconfigCompletionItems } from "./tspconfig/completion.js"; @@ -114,7 +114,14 @@ export function createServer(host: ServerHost): Server { }); const compilerHost = createCompilerHost(); const npmPackageProvider = new NpmPackageProvider(compilerHost); - const emitterProvider = new EmitterProvider(npmPackageProvider); + const emitterProvider = new LibraryProvider( + npmPackageProvider, + (exports) => exports.$onEmit !== undefined, + ); + const linterProvider = new LibraryProvider( + npmPackageProvider, + (exports) => exports.$linter !== undefined, + ); const compileService = createCompileService({ fileService, @@ -703,7 +710,9 @@ export function createServer(host: ServerHost): Server { if (doc) { const items = await provideTspconfigCompletionItems(doc, params.position, { fileService, + compilerHost, emitterProvider, + linterProvider, log, }); return CompletionList.create(items); diff --git a/packages/compiler/src/server/tspconfig/completion.ts b/packages/compiler/src/server/tspconfig/completion.ts index 691fa605f8..e1cb5acef9 100644 --- a/packages/compiler/src/server/tspconfig/completion.ts +++ b/packages/compiler/src/server/tspconfig/completion.ts @@ -1,10 +1,29 @@ import { TextDocument } from "vscode-languageserver-textdocument"; -import { CompletionItem, CompletionItemKind, Position } from "vscode-languageserver/node.js"; +import { + CompletionItem, + CompletionItemKind, + Position, + Range, + TextEdit, +} from "vscode-languageserver/node.js"; +import { Document, isMap, isPair, Node } from "yaml"; import { emitterOptionsSchema, TypeSpecConfigJsonSchema } from "../../config/config-schema.js"; -import { JSONSchemaType, ServerLog } from "../../index.js"; +import { + getDirectoryPath, + getNormalizedAbsolutePath, + joinPaths, + normalizeSlashes, +} from "../../core/path-utils.js"; +import { + CompilerHost, + DiagnosticMessages, + JSONSchemaType, + LinterRuleDefinition, + ServerLog, +} from "../../index.js"; import { distinctArray } from "../../utils/misc.js"; -import { EmitterProvider } from "../emitter-provider.js"; import { FileService } from "../file-service.js"; +import { LibraryProvider } from "../lib-provider.js"; import { resolveYamlScalarTarget, YamlScalarTarget } from "../yaml-resolver.js"; type ObjectJSONSchemaType = JSONSchemaType; @@ -14,24 +33,50 @@ export async function provideTspconfigCompletionItems( tspConfigPosition: Position, context: { fileService: FileService; - emitterProvider: EmitterProvider; + compilerHost: CompilerHost; + emitterProvider: LibraryProvider; + linterProvider: LibraryProvider; log: (log: ServerLog) => void; }, ): Promise { - const { fileService, emitterProvider, log } = context; + const { fileService, compilerHost, emitterProvider, linterProvider, log } = context; const target = resolveYamlScalarTarget(tspConfigDoc, tspConfigPosition, log); if (target === undefined) { return []; } - const items = resolveTspConfigCompleteItems(await fileService.getPath(tspConfigDoc), target); + + // Variable interpolation + if (target.sourceRange) { + const variableInterpolationItems = resolveVariableInterpolationCompleteItems( + target.yamlDoc, + target.path, + tspConfigDoc.getText().slice(target.sourceRange.pos, target.cursorPosition), + ); + if (variableInterpolationItems.length > 0) { + return variableInterpolationItems; + } + } + + const items = resolveTspConfigCompleteItems( + await fileService.getPath(tspConfigDoc), + target, + tspConfigPosition, + log, + ); return items; async function resolveTspConfigCompleteItems( tspConfigFile: string, target: YamlScalarTarget, + tspConfigPosition: Position, + log: (log: ServerLog) => void, ): Promise { - const { path: nodePath, type: targetType, siblings } = target; + const { path: nodePath, type: targetType, siblings, source } = target; const CONFIG_PATH_LENGTH_FOR_EMITTER_LIST = 2; + const CONFIG_PATH_LENGTH_FOR_LINTER_LIST = 2; + const CONFIG_PATH_LENGTH_FOR_EXTENDS = 1; + const CONFIG_PATH_LENGTH_FOR_IMPORTS = 2; + if ( (nodePath.length === CONFIG_PATH_LENGTH_FOR_EMITTER_LIST && nodePath[0] === "options" && @@ -40,30 +85,29 @@ export async function provideTspconfigCompletionItems( nodePath[0] === "emit" && targetType === "arr-item") ) { - const emitters = await emitterProvider.listEmitters(tspConfigFile); + const emitters = await emitterProvider.listLibraries(tspConfigFile); const items: CompletionItem[] = []; for (const [name, pkg] of Object.entries(emitters)) { if (!siblings.includes(name)) { - const item: CompletionItem = { - label: name, - kind: CompletionItemKind.Field, - documentation: (await pkg.getPackageJsonData())?.description ?? `Emitter from ${name}`, - insertText: `"${name}"`, - }; + const item = createCompletionItemWithQuote( + name, + (await pkg.getPackageJsonData())?.description ?? `Emitter from ${name}`, + tspConfigPosition, + target, + ); items.push(item); } } return items; } else if (nodePath.length > CONFIG_PATH_LENGTH_FOR_EMITTER_LIST && nodePath[0] === "options") { const emitterName = nodePath[CONFIG_PATH_LENGTH_FOR_EMITTER_LIST - 1]; - const emitter = await emitterProvider.getEmitter(tspConfigFile, emitterName); + const emitter = await emitterProvider.getLibrary(tspConfigFile, emitterName); if (!emitter) { return []; } - const exports = await emitter.getModuleExports(); + const exports = await emitter.getModuleExports(); const builtInEmitterSchema = emitterOptionsSchema; - const itemsFromBuiltIn = builtInEmitterSchema ? resolveCompleteItems(builtInEmitterSchema, { ...target, @@ -80,6 +124,93 @@ export async function provideTspconfigCompletionItems( itemsFromEmitter.push(...more); } return [...itemsFromBuiltIn, ...itemsFromEmitter]; + } else if (nodePath.length > CONFIG_PATH_LENGTH_FOR_LINTER_LIST && nodePath[0] === "linter") { + const extendKeyWord = nodePath[CONFIG_PATH_LENGTH_FOR_LINTER_LIST - 1]; + const items: CompletionItem[] = []; + const linters = await linterProvider.listLibraries(tspConfigFile); + + if (extendKeyWord === "extends") { + for (const [name, pkg] of Object.entries(linters)) { + // If a ruleSet exists for the linter, add it to the end of the library name. + const exports = await pkg.getModuleExports(); + if (exports?.$linter?.ruleSets !== undefined) { + // Below ruleSets are objects rather than arrays + for (const [ruleSet] of Object.entries(exports?.$linter?.ruleSets)) { + const labelName = `${name}/${ruleSet}`; + if (!siblings.includes(labelName)) { + const item = createCompletionItemWithQuote( + labelName, + (await pkg.getPackageJsonData())?.description ?? `Linters from ${labelName}`, + tspConfigPosition, + target, + ); + items.push(item); + } + } + } + + // Add the library name directly. + if (!siblings.includes(name)) { + const item = createCompletionItemWithQuote( + name, + (await pkg.getPackageJsonData())?.description ?? `Linters from ${name}`, + tspConfigPosition, + target, + ); + items.push(item); + } + } + } else if (extendKeyWord === "enable" || extendKeyWord === "disable") { + // enable/disable rules + for (const [name, pkg] of Object.entries(linters)) { + const exports = await pkg.getModuleExports(); + + if (exports?.$linter?.rules !== undefined) { + for (const [, rule] of Object.entries>( + exports?.$linter?.rules, + )) { + const labelName = `${name}/${rule.name}`; + const item = createCompletionItemWithQuote( + labelName, + rule.description, + tspConfigPosition, + target, + ); + items.push(item); + } + } + } + } else { + log({ + level: "warning", + message: "Unknown linter keyword, it should be 'extends', 'enable' or 'disable'", + }); + } + return items; + } else if (nodePath.length === CONFIG_PATH_LENGTH_FOR_EXTENDS && nodePath[0] === "extends") { + const currentFolder = getDirectoryPath(tspConfigFile); + const newFolderPath = joinPaths(currentFolder, source); + + // Exclude the current yaml configuration file + const relativeFiles = await findFilesOrDirsWithSameExtension( + compilerHost, + newFolderPath, + ".yaml", + [normalizeSlashes(tspConfigFile)], + ); + return getFilePathCompletionItems(relativeFiles, siblings, source); + } else if (nodePath.length >= CONFIG_PATH_LENGTH_FOR_IMPORTS && nodePath[0] === "imports") { + const currentFolder = getDirectoryPath(tspConfigFile); + const newFolderPath = joinPaths(currentFolder, source); + + // Exclude main.tsp files that are the same as the current yaml configuration directory + const relativeFiles = await findFilesOrDirsWithSameExtension( + compilerHost, + newFolderPath, + ".tsp", + [joinPaths(currentFolder, "main.tsp")], + ); + return getFilePathCompletionItems(relativeFiles, siblings, source); } else { const schema = TypeSpecConfigJsonSchema; return schema ? resolveCompleteItems(schema, target) : []; @@ -174,7 +305,9 @@ export async function provideTspconfigCompletionItems( const item: CompletionItem = { label: key, kind: CompletionItemKind.Field, - documentation: cur.properties[key].description, + documentation: cur.required?.includes(key) + ? "[required]\n" + (cur.properties[key].description ?? "") + : "[optional]\n" + (cur.properties[key].description ?? ""), }; return item; }); @@ -211,3 +344,199 @@ export async function provideTspconfigCompletionItems( return distinctArray(result, (t) => t.label); } } + +/** + * Create the common CompletionItem object, which value is included in quotes + * @param labelName The value of the label attribute of the CompletionItem object + * @param description The value of the documentation attribute of the CompletionItem object + * @param tspConfigPosition Input current line location object, see {@link Position} + * @param target The target object of the current configuration file, see {@link YamlScalarTarget} + * @returns CompletionItem object + */ +function createCompletionItemWithQuote( + labelName: string, + description: string, + tspConfigPosition: Position, + target: YamlScalarTarget, +): CompletionItem { + if ( + target.sourceRange && + target.cursorPosition >= target.sourceRange.pos && + target.cursorPosition <= target.sourceRange.end + ) { + // If it is a quoted string, the relative position needs to be reduced by 1 + const lenRelativeToStartPos = + target.sourceType === "QUOTE_SINGLE" || target.sourceType === "QUOTE_DOUBLE" + ? target.cursorPosition - target.sourceRange.pos - 1 + : target.cursorPosition - target.sourceRange.pos; + return { + label: labelName, + kind: CompletionItemKind.Field, + documentation: description, + textEdit: TextEdit.replace( + Range.create( + Position.create( + tspConfigPosition.line, + tspConfigPosition.character - lenRelativeToStartPos, + ), + Position.create(tspConfigPosition.line, tspConfigPosition.character), + ), + target.sourceType === "QUOTE_SINGLE" || target.sourceType === "QUOTE_DOUBLE" + ? labelName + : `"${labelName}"`, + ), + }; + } + + return { + label: labelName, + kind: CompletionItemKind.Field, + documentation: description, + insertText: `"${labelName}"`, + }; +} + +/** + * Find a set of dirs/files with the same suffix + * @param compilerHost CompilerHost object for file operations, see {@link CompilerHost} + * @param rootPath The absolute input path or relative path of the current configuration file + * @param fileExtension File extension + * @param excludeFiles exclude files array, default is [] + * @returns dirs/files array + */ +async function findFilesOrDirsWithSameExtension( + compilerHost: CompilerHost, + rootPath: string, + fileExtension: ".yaml" | ".tsp", + excludeFiles: string[] = [], +): Promise { + const exclude = ["node_modules", "tsp-output", ".vs", ".vscode"]; + + const files: string[] = []; + try { + // When reading the content under the path, an error may be reported if the path is incorrect. + const dirs = await compilerHost.readDir(rootPath); + for (const d of dirs) { + if (!exclude.includes(d) && !excludeFiles.includes(getNormalizedAbsolutePath(d, rootPath))) { + try { + const stat = await compilerHost.stat(joinPaths(rootPath, d)); + if (stat.isDirectory() || (stat.isFile() && d.endsWith(fileExtension))) { + files.push(d); + } + } catch { + // If the path is incorrect, the error is ignored + continue; + } + } + } + } catch { + return files; + } + return files; +} + +/** + * Get the CompletionItem object array of the relative path of the file + * @param relativeFiles File relative path array + * @param siblings Sibling node array + * @param source The input source of the current node + * @returns CompletionItem object array + */ +function getFilePathCompletionItems( + relativeFiles: string[], + siblings: string[], + source: string, +): CompletionItem[] { + return relativeFiles + .filter((file) => !siblings.includes(joinPaths(source, file))) + .map((file) => { + return { + label: file, + kind: CompletionItemKind.File, + }; + }); +} + +/** + * resolve variable interpolation completion items + * @param yamlDocNodes tsp config yaml document nodes , see {@link Document} + * @param path current path of the target node, such as ["linter", "extends","- ┆"] + * @param curText current editing text, from startPos to curPos + * @returns variable interpolation completion items + */ +function resolveVariableInterpolationCompleteItems( + yamlDocNodes: Document, + path: string[], + curText: string, +): CompletionItem[] { + if (/{\s*env\.[^}]*$/.test(curText)) { + // environment-variables + return getVariableCompletionItem( + yamlDocNodes, + "environment-variables", + "Custom environment variables", + ); + } else if (/{[^}]*$/.test(curText)) { + // parameters and built-in variables + const result = [ + ...getVariableCompletionItem(yamlDocNodes, "parameters", "Custom paramters variables"), + ...["cwd", "project-root"].map((value) => { + const item: CompletionItem = { + label: value, + kind: CompletionItemKind.Value, + documentation: "Built-in variables", + }; + return item; + }), + ]; + + // if the current path is options, add output-dir and emitter-name + const CONFIG_PATH_LENGTH_FOR_OPTIONS = 2; + if (path.length > CONFIG_PATH_LENGTH_FOR_OPTIONS && path[0] === "options") { + result.push( + ...["output-dir", "emitter-name"].map((value) => { + const item: CompletionItem = { + label: value, + kind: CompletionItemKind.Value, + documentation: "Built-in variables", + }; + return item; + }), + ); + } + return result; + } + + return []; +} + +/** + * Get the corresponding CompletionItem array based on the filter name + * @param yamlDoc The contents of the YAML configuration file + * @param filterName The filter name + * @param description The description of the CompletionItem object + * @returns CompletionItem object array + */ +function getVariableCompletionItem( + yamlDoc: Document, + filterName: "parameters" | "environment-variables", + description: string, +): CompletionItem[] { + const result: CompletionItem[] = []; + if (isMap(yamlDoc.contents)) { + const yamlMap = yamlDoc.contents.items.find((item) => (item.key).source === filterName); + if (yamlMap && yamlMap.value !== null && isMap(yamlMap.value)) { + yamlMap.value.items.forEach((i) => { + if (isPair(i)) { + result.push({ + label: (i.key as any).source ?? "", + kind: CompletionItemKind.Value, + documentation: description, + }); + } + }); + } + } + + return result; +} diff --git a/packages/compiler/src/server/yaml-resolver.ts b/packages/compiler/src/server/yaml-resolver.ts index 07877ee792..5d53c72a7b 100644 --- a/packages/compiler/src/server/yaml-resolver.ts +++ b/packages/compiler/src/server/yaml-resolver.ts @@ -12,6 +12,7 @@ import { Scalar, visit, } from "yaml"; +import { TextRange } from "../core/index.js"; import { firstNonWhitespaceCharacterIndex, isWhitespaceStringOrUndefined } from "../utils/misc.js"; import { ServerLog } from "./types.js"; @@ -31,10 +32,26 @@ export interface YamlScalarTarget { * actual value of target in the doc */ source: string; + /** + * The input quotes (double quotes or single quotes) + */ + sourceType: Scalar.Type; + /** + * The position range of the text in the document, such as [startPos, endPos], see {@link TextRange} + */ + sourceRange: TextRange | undefined; /** * The siblings of the target node */ siblings: string[]; + /** + * The parsed yaml document + */ + yamlDoc: Document; + /** + * The cursor current position + */ + cursorPosition: number; } interface YamlVisitScalarNode { @@ -85,7 +102,11 @@ export function resolveYamlScalarTarget( path: [""], type: "key", source: "", + sourceType: "PLAIN", siblings: rootProperties, + yamlDoc, + sourceRange: undefined, + cursorPosition: pos, }; } for (let i = position.line - 1; i >= 0; i--) { @@ -114,7 +135,7 @@ export function resolveYamlScalarTarget( }); return undefined; } - const yp = createYamlPathFromVisitScalarNode(found, pos, log); + const yp = createYamlPathFromVisitScalarNode(found, pos, log, yamlDoc); if (!yp || yp.path.length === 0) { log({ level: "debug", @@ -131,7 +152,11 @@ export function resolveYamlScalarTarget( path: [...yp.path.slice(0, yp.path.length - 1), ""], type: "key", source: "", + sourceType: "PLAIN", siblings: [...yp.siblings, yp.source], + yamlDoc, + sourceRange: yp.sourceRange, + cursorPosition: pos, }; } break; @@ -159,7 +184,7 @@ export function resolveYamlScalarTarget( isMap(last.value) || (isScalar(last.value) && isWhitespaceStringOrUndefined(last.value.source))) ) { - const yp = createYamlPathFromVisitScalarNode(found, pos, log); + const yp = createYamlPathFromVisitScalarNode(found, pos, log, yamlDoc); if (!yp || yp.path.length === 0) { log({ level: "debug", @@ -172,9 +197,13 @@ export function resolveYamlScalarTarget( path: [...yp.path, ""], type: "key", source: "", + sourceType: "PLAIN", siblings: isMap(last.value) ? (last.value?.items.map((item) => (item.key as any).source ?? "") ?? []) : [], + yamlDoc, + sourceRange: yp.sourceRange, + cursorPosition: pos, }; } break; @@ -190,7 +219,7 @@ export function resolveYamlScalarTarget( }); return undefined; } - const yp = createYamlPathFromVisitScalarNode(found, pos, log); + const yp = createYamlPathFromVisitScalarNode(found, pos, log, yamlDoc); return yp; } } @@ -199,6 +228,7 @@ function createYamlPathFromVisitScalarNode( info: YamlVisitScalarNode, offset: number, log: (log: ServerLog) => void, + yamlDoc: Document, ): YamlScalarTarget | undefined { const { key, n, path: nodePath } = info; if (nodePath.length === 0) { @@ -208,6 +238,7 @@ function createYamlPathFromVisitScalarNode( }); return undefined; } + const path: string[] = []; for (let i = 0; i < nodePath.length; i++) { @@ -230,13 +261,23 @@ function createYamlPathFromVisitScalarNode( } const last = nodePath[nodePath.length - 1]; + let curSourceRange = undefined; + if (n.range) { + const [startPos, endPos] = n.range; + curSourceRange = { pos: startPos, end: endPos }; + } + if (isDocument(last)) { // we are at the root and the content is a pure text (otherwise there should be a Map under document node first) return { path: [], type: key === null ? "key" : "value", source: n.source ?? "", + sourceType: n.type ?? "PLAIN", siblings: [], + yamlDoc, + sourceRange: curSourceRange, + cursorPosition: offset, }; } else if (isPair(last)) { if (nodePath.length < 2) { @@ -251,22 +292,32 @@ function createYamlPathFromVisitScalarNode( // if the scalar node is marked as value but separated by newline from the key, it's more likely that the user is inputting the first property of an object // so build the target as an object key path.push(n.source ?? ""); + return { path, type: "key", source: n.source ?? "", + sourceType: n.type ?? "PLAIN", siblings: [], + yamlDoc, + sourceRange: curSourceRange, + cursorPosition: offset, }; } else { - const parent = nodePath[nodePath.length - 2]; + const parent = nodePath.length >= 2 ? nodePath[nodePath.length - 2] : undefined; const targetSiblings = isMap(parent) ? parent.items.filter((item) => item !== last).map((item) => (item.key as any).source ?? "") : []; + return { path: path, type: key === "key" ? "key" : "value", source: n.source ?? "", siblings: targetSiblings, + sourceType: n.type ?? "PLAIN", + yamlDoc, + sourceRange: curSourceRange, + cursorPosition: offset, }; } } else if (isSeq(last)) { @@ -274,9 +325,13 @@ function createYamlPathFromVisitScalarNode( path: path, type: "arr-item", source: n.source ?? "", + sourceType: n.type ?? "PLAIN", siblings: last.items .filter((i) => i !== n) .map((item) => (isScalar(item) ? (item.source ?? "") : "")), + yamlDoc, + sourceRange: curSourceRange, + cursorPosition: offset, }; } else { log({ diff --git a/packages/compiler/test/server/completion.tspconfig.test.ts b/packages/compiler/test/server/completion.tspconfig.test.ts index 2f3a12dd6f..5a9a5f70e7 100644 --- a/packages/compiler/test/server/completion.tspconfig.test.ts +++ b/packages/compiler/test/server/completion.tspconfig.test.ts @@ -2,6 +2,7 @@ import { join } from "path"; import { describe, expect, it } from "vitest"; import { CompletionList } from "vscode-languageserver/node.js"; import { createTestServerHost, extractCursor } from "../../src/testing/test-server-host.js"; +import { resolveVirtualPath } from "../../src/testing/test-utils.js"; const rootOptions = [ "extends", @@ -71,12 +72,28 @@ describe("Test completion items for options and emitters", () => { it.each([ { config: `emit:\n - ┆`, + expected: ['"fake-emitter"', '"fake-emitter-no-schema"'], + }, + { + config: `emit:\n - "┆"`, expected: ["fake-emitter", "fake-emitter-no-schema"], }, { - config: `options:\n\n fak┆`, + config: `emit:\n - "┆`, + expected: ["fake-emitter", "fake-emitter-no-schema"], + }, + { + config: `emit:\n - '┆`, + expected: ["fake-emitter", "fake-emitter-no-schema"], + }, + { + config: `emit:\n - '┆'`, expected: ["fake-emitter", "fake-emitter-no-schema"], }, + { + config: `options:\n\n fak┆`, + expected: ['"fake-emitter"', '"fake-emitter-no-schema"'], + }, ])("#%# Test emitters: $config", async ({ config, expected }) => { await checkCompletionItems(config, true, expected); }); @@ -167,7 +184,27 @@ describe("Test completion items for emitters options", () => { expected: ["a", "b", "c"], }, ])("#%# Test emitter options: $config", async ({ config, expected }) => { - await checkCompletionItems(config, true, expected, "./subfolder/tspconfig.yaml"); + await checkCompletionItems(config, true, expected, false, "./subfolder/tspconfig.yaml"); + }); +}); + +describe("Test whether the completion items description of the emitters options is optional or required", () => { + it.each([ + { + config: `options:\n fake-emitter:\n ┆`, + expected: [ + "[required]\nThe name of the target to emit to.", //"target-name", + "[optional]\nWhether the target is valid.", //"is-valid", + "[required]\n", //"type", + "[optional]\n", //"emitter-output-dir", + "[optional]\n", //"options", + "[optional]\n", //"options-b", + "[optional]\n", //"options-arr-obj", + "[optional]\n", //"options-arr-boolean", + ], + }, + ])("#%# Test emitter options: $config", async ({ config, expected }) => { + await checkCompletionItems(config, true, expected, true, "./subfolder/tspconfig.yaml"); }); }); @@ -209,23 +246,47 @@ describe("Test completion items for linter", () => { expected: ["extends", "disable"], }, { - config: `linter:\n enable:\n linter-one: ┆`, - expected: ["true", "false"], + config: `linter:\n extends:\n - "┆`, + expected: ["fake-linter-no-schema", "fake-linter/recommended", "fake-linter"], + }, + { + config: `linter:\n extends:\n - "fake-linter/recommended"\n - "┆`, + expected: ["fake-linter-no-schema", "fake-linter"], + }, + { + config: `linter:\n extends:\n - "fake-linter"\n enable:\n "┆`, + expected: ["fake-linter/casing", "fake-linter/no-model-doc", "fake-linter/testing"], }, { - config: `linter:\n enable:\n linter-one┆:`, + config: `linter:\n extends:\n - "fake-linter/recommended"\n enable:\n "┆`, + expected: ["fake-linter/casing", "fake-linter/no-model-doc", "fake-linter/testing"], + }, + { + config: `linter:\n extends:\n - "fake-linter/recommended"\n disable:\n "┆`, + expected: ["fake-linter/casing", "fake-linter/no-model-doc", "fake-linter/testing"], + }, + { + config: `linter:\n extends:\n - "fake-linter/recommended"\n - "fake-linter-no-schema"\n enable:\n "┆`, + expected: ["fake-linter/casing", "fake-linter/no-model-doc", "fake-linter/testing"], + }, + { + config: `linter:\n extends:\n - "fake-linter/recommended"\n enable:\n "fake-linter/casing": true\n disable:\n "┆`, + expected: ["fake-linter/casing", "fake-linter/no-model-doc", "fake-linter/testing"], + }, + { + config: `linter:\n extends:\n - "fake-linter-no-schema" - "fake-linter/recommended"┆`, expected: [], }, { - config: `linter:\n disable:\n linter-one: true\n ┆`, + config: `linter:\n extends:\n enable: "fak┆e"`, expected: [], }, { - config: `linter:\n disable:\n linter-one: true\n linter-two: ┆`, + config: `linter:\n extends:\n - "fake-linter-no-schema" - "fake"┆`, expected: [], }, - ])("#%# Test linter: $config", async ({ config, expected }) => { - await checkCompletionItems(config, true, expected); + ])("#%# Test emitter options: $config", async ({ config, expected }) => { + await checkCompletionItems(config, true, expected, false, "./subfolder/tspconfig.yaml"); }); }); @@ -252,6 +313,179 @@ describe("Test completion items for additionalProperties", () => { }); }); +describe("Test completion items that use parameters and environment variables and built-in variables", () => { + it.each([ + { + config: `environment-variables:\n BASE_DIR:\n default: "{cwd}"\n test-env:\n default: ""\noutput-dir: "{env.┆}"`, + expected: ["BASE_DIR", "test-env"], + }, + { + config: `environment-variables:\n BASE_DIR:\n default: "{cwd}"\n test-env:\n default: ""\noutput-dir: "{ env.┆}"`, + expected: ["BASE_DIR", "test-env"], + }, + { + config: `environment-variables:\n BASE_DIR:\n default: "{cwd}"\n test-env:\n default: ""\noutput-dir: "outdir/{env.┆}"`, + expected: ["BASE_DIR", "test-env"], + }, + { + config: `environment-variables:\n BASE_DIR:\n default: "{cwd}"\n test-env:\n default: ""\noutput-dir: "outdir/{env.┆}/myDir"`, + expected: ["BASE_DIR", "test-env"], + }, + { + config: `environment-variables:\n BASE_DIR:\n default: "{cwd}"\n test-env:\n default: ""\noutput-dir: "{}env.┆}"`, + expected: [], + }, + { + config: `environment-variables:\n BASE_DIR:\n default: "{cwd}"\n test-env:\n default: ""\noutput-dir: "outdir/{env.}/my┆Dir"`, + expected: [], + }, + { + config: `environment-variables:\n BASE_DIR:\n default: "{cwd}"\n test-env:\n default: ""\noutput-dir: "{ abcenv.┆}"`, + expected: ["cwd", "project-root"], + }, + { + config: `environment-variables:\n BASE_DIR:\n default: "{cwd}"\n test-env:\n default: ""\noutput-dir: ┆"{ env.}"`, + expected: [], + }, + { + config: `parameters:\n base-dir:\n default: "{cwd}"\n test-param: default: ""\noutput-dir: "{┆}"`, + expected: ["cwd", "project-root", "base-dir", "test-param"], + }, + { + config: `parameters:\n base-dir:\n default: "{cwd}"\n test-param: default: ""\noutput-dir: "{cw┆}"`, + expected: ["cwd", "project-root", "base-dir", "test-param"], + }, + { + config: `environment-variables:\n BASE_DIR:\n default: "{cwd}"\n test-env:\n default: ""\nparameters:\n base-dir:\n default: "{cwd}"\n test-param: default: ""\noutput-dir: "{env.┆}"`, + expected: ["BASE_DIR", "test-env"], + }, + { + config: `parameters:\n base-dir:\n default: "{cwd}"\n test-param: default: ""\noutput-dir: "test/{cw┆}"`, + expected: ["cwd", "project-root", "base-dir", "test-param"], + }, + { + config: `parameters:\n base-dir:\n default: "{cwd}"\n test-param: default: ""\noutput-dir: "outDir/{cw┆}/myDir"`, + expected: ["cwd", "project-root", "base-dir", "test-param"], + }, + { + config: `parameters:\n base-dir:\n default: "{cwd}"\n test-param: default: ""\noptions:\n emitter-sub-folder:\n sub-folder: "{cw┆}/myDir"`, + expected: ["cwd", "project-root", "base-dir", "test-param", "output-dir", "emitter-name"], + }, + { + config: `parameters:\n base-dir:\n default: "{cwd}"\n test-param: default: ""\noutput-dir: "{env.┆}"`, + expected: [], + }, + { + config: `parameters:\n base-dir:\n default: "{cwd}"\n test-param: default: ""\noutput-dir: "{}┆"`, + expected: [], + }, + { + config: `parameters:\n base-dir:\n default: "{cwd}"\n test-param: default: ""\noutput-dir: ┆"{}"`, + expected: [], + }, + ])("#%# Test addProp: $config", async ({ config, expected }) => { + await checkCompletionItems(config, false, expected); + }); +}); + +describe("Test completion items for extends", () => { + const path = resolveVirtualPath("Z:/test/workspace"); + it.each([ + { + config: `extends: "┆`, + expected: ["tspconfigtest0.yaml", "demo_yaml", "demo_tsp"], + }, + { + config: `extends: "./┆`, + expected: ["tspconfigtest0.yaml", "demo_yaml", "demo_tsp"], + }, + { + config: `extends: "./demo_yaml┆"`, + expected: ["tspconfigtest2.yaml"], + }, + { + config: `extends: "${path}┆"`, + expected: ["tspconfigtest0.yaml", "demo_yaml", "demo_tsp"], + }, + { + config: `extends: \n┆`, + expected: [ + "emit", + "environment-variables", + "imports", + "linter", + "options", + "output-dir", + "parameters", + "trace", + "warn-as-error", + ], + }, + { + config: `extends: "./demo┆"`, + expected: [], + }, + { + config: `extends: "./tspconfigtest0.yaml"┆`, + expected: [], + }, + { + config: `extends: "./┆demo"`, + expected: [], + }, + { + config: `extends: "${path}/demo┆"`, + expected: [], + }, + ])("#%# Test addProp: $config", async ({ config, expected }) => { + await checkCompletionItems(config, true, expected); + }); +}); + +describe("Test completion items for imports", () => { + const path = resolveVirtualPath("Z:/test/workspace/"); + it.each([ + { + config: `imports:\n - "./┆`, + expected: ["demo_yaml", "demo_tsp"], + }, + { + config: `imports:\n - "./demo_tsp"\n - "./┆`, + expected: ["demo_yaml"], + }, + { + config: `imports:\n - "./demo_tsp/┆`, + expected: ["test1.tsp", "test3.tsp"], + }, + { + config: `imports:\n - "./demo_tsp/test1.tsp"\n - "./demo_tsp/┆`, + expected: ["test3.tsp"], + }, + { + config: `imports:\n - "${path}┆`, + expected: ["demo_yaml", "demo_tsp"], + }, + { + config: `imports:\n - "┆./demo"`, + expected: [], + }, + { + config: `imports:\n - "${path}demo┆`, + expected: [], + }, + { + config: `imports:\n - "./demo_tsp/test┆`, + expected: [], + }, + { + config: `imports:\n "./┆"`, + expected: [], + }, + ])("#%# Test addProp: $config", async ({ config, expected }) => { + await checkCompletionItems(config, true, expected); + }); +}); + describe("Test completion items with comments", () => { it.each([ { @@ -428,11 +662,14 @@ async function checkCompletionItems( configWithPosition: string, includeWorkspace: boolean, expected: string[], + isTestDesc: boolean = false, tspconfigPathUnderWorkspace: string = "./tspconfig.yaml", ) { const items = (await complete(configWithPosition, includeWorkspace, tspconfigPathUnderWorkspace)) .items; - expect(items.map((i) => i.label).sort()).toEqual(expected.sort()); + isTestDesc + ? expect(items.map((i) => i.documentation ?? "").sort()).toEqual(expected.sort()) + : expect(items.map((i) => i.textEdit?.newText ?? i.label).sort()).toEqual(expected.sort()); } async function complete( diff --git a/packages/compiler/test/server/workspace/.gitignore b/packages/compiler/test/server/workspace/.gitignore index d994bdc059..8f513e6006 100644 --- a/packages/compiler/test/server/workspace/.gitignore +++ b/packages/compiler/test/server/workspace/.gitignore @@ -1,3 +1,7 @@ # not ignore here which is for testing !node_modules/ +!demo_tsp/ +!demo_yaml/ !**/package.json +!tspconfigtest0.yaml + \ No newline at end of file diff --git a/packages/compiler/test/server/workspace/demo_tsp/test1.tsp b/packages/compiler/test/server/workspace/demo_tsp/test1.tsp new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/compiler/test/server/workspace/demo_tsp/test3.tsp b/packages/compiler/test/server/workspace/demo_tsp/test3.tsp new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/compiler/test/server/workspace/demo_tsp/tspconfigtest1.yaml b/packages/compiler/test/server/workspace/demo_tsp/tspconfigtest1.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/compiler/test/server/workspace/demo_yaml/test2.tsp b/packages/compiler/test/server/workspace/demo_yaml/test2.tsp new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/compiler/test/server/workspace/demo_yaml/tspconfigtest2.yaml b/packages/compiler/test/server/workspace/demo_yaml/tspconfigtest2.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/compiler/test/server/workspace/node_modules/fake-emitter/lib/index.js b/packages/compiler/test/server/workspace/node_modules/fake-emitter/lib/index.js index 99190288ed..993eca2d5f 100644 --- a/packages/compiler/test/server/workspace/node_modules/fake-emitter/lib/index.js +++ b/packages/compiler/test/server/workspace/node_modules/fake-emitter/lib/index.js @@ -3,8 +3,8 @@ const EmitterOptionsSchema = { type: "object", additionalProperties: false, properties: { - "target-name": { type: "string", nullable: true }, - "is-valid": { type: "boolean", nullable: true }, + "target-name": { type: "string", nullable: true, description: "The name of the target to emit to." }, + "is-valid": { type: "boolean", nullable: true, description: "Whether the target is valid." }, "type": { type: "string", nullable: true, enum: ["a", "b", "c"] }, "options": { type: "object", @@ -61,7 +61,7 @@ const EmitterOptionsSchema = { } } }, - required: [], + required: ["target-name","type"], }; export const $lib = { diff --git a/packages/compiler/test/server/workspace/node_modules/fake-linter-no-schema/lib/index.js b/packages/compiler/test/server/workspace/node_modules/fake-linter-no-schema/lib/index.js new file mode 100644 index 0000000000..7f03c37a5d --- /dev/null +++ b/packages/compiler/test/server/workspace/node_modules/fake-linter-no-schema/lib/index.js @@ -0,0 +1,5 @@ +import { defineLinter } from "@typespec/compiler"; + +export const $linter = defineLinter({ + rules: [] +}); diff --git a/packages/compiler/test/server/workspace/node_modules/fake-linter-no-schema/package.json b/packages/compiler/test/server/workspace/node_modules/fake-linter-no-schema/package.json new file mode 100644 index 0000000000..f2a60f8b9c --- /dev/null +++ b/packages/compiler/test/server/workspace/node_modules/fake-linter-no-schema/package.json @@ -0,0 +1,33 @@ +{ + "name": "fake-linter-no-schema", + "version": "0.1.2", + "main": "lib/index.js", + "dependencies": { + "@readme/openapi-parser": "~2.6.0", + "yaml": "~2.4.5" + }, + "peerDependencies": { + "@typespec/compiler": "~0.60.0", + "@typespec/http": "~0.60.0", + "@typespec/openapi": "~0.60.0", + "@typespec/versioning": "~0.60.0" + }, + "devDependencies": { + "@types/node": "~18.11.19", + "@types/yargs": "~17.0.32", + "@vitest/coverage-v8": "^2.0.4", + "@vitest/ui": "^2.0.4", + "c8": "^10.1.2", + "cross-env": "~7.0.3", + "rimraf": "~6.0.1", + "typescript": "~5.5.4", + "vitest": "^2.0.4", + "@typespec/compiler": "~0.60.0", + "@typespec/http": "~0.60.0", + "@typespec/library-linter": "~0.60.0", + "@typespec/openapi": "~0.60.0", + "@typespec/rest": "~0.60.0", + "@typespec/tspd": "~0.46.0", + "@typespec/versioning": "~0.60.0" + } +} diff --git a/packages/compiler/test/server/workspace/node_modules/fake-linter/lib/index.js b/packages/compiler/test/server/workspace/node_modules/fake-linter/lib/index.js new file mode 100644 index 0000000000..a2d335b063 --- /dev/null +++ b/packages/compiler/test/server/workspace/node_modules/fake-linter/lib/index.js @@ -0,0 +1,24 @@ +import { createRule ,defineLinter } from "@typespec/compiler"; + + +export const requiredDocRule = createRule({ + name: "no-model-doc", + severity: "warning", +}); +export const casingRule = createRule({ + name: "casing", + severity: "warning", +}); +export const testingRule = createRule({ + name: "testing", + severity: "warning", +}); + +export const $linter = defineLinter({ + rules: [requiredDocRule,casingRule,testingRule], + ruleSets: { + recommended: { + enable: {[`fake-linter/${testingRule.name}`]: true} + } + } +}); diff --git a/packages/compiler/test/server/workspace/node_modules/fake-linter/package.json b/packages/compiler/test/server/workspace/node_modules/fake-linter/package.json new file mode 100644 index 0000000000..48e6636c53 --- /dev/null +++ b/packages/compiler/test/server/workspace/node_modules/fake-linter/package.json @@ -0,0 +1,33 @@ +{ + "name": "fake-linter", + "version": "0.0.2", + "main": "lib/index.js", + "dependencies": { + "@readme/openapi-parser": "~2.6.0", + "yaml": "~2.4.5" + }, + "peerDependencies": { + "@typespec/compiler": "~0.60.0", + "@typespec/http": "~0.60.0", + "@typespec/openapi": "~0.60.0", + "@typespec/versioning": "~0.60.0" + }, + "devDependencies": { + "@types/node": "~18.11.19", + "@types/yargs": "~17.0.32", + "@vitest/coverage-v8": "^2.0.4", + "@vitest/ui": "^2.0.4", + "c8": "^10.1.2", + "cross-env": "~7.0.3", + "rimraf": "~6.0.1", + "typescript": "~5.5.4", + "vitest": "^2.0.4", + "@typespec/compiler": "~0.60.0", + "@typespec/http": "~0.60.0", + "@typespec/library-linter": "~0.60.0", + "@typespec/openapi": "~0.60.0", + "@typespec/rest": "~0.60.0", + "@typespec/tspd": "~0.46.0", + "@typespec/versioning": "~0.60.0" + } +} diff --git a/packages/compiler/test/server/workspace/package.json b/packages/compiler/test/server/workspace/package.json index 81d5d56bc4..a117767823 100644 --- a/packages/compiler/test/server/workspace/package.json +++ b/packages/compiler/test/server/workspace/package.json @@ -9,10 +9,12 @@ "author": "", "license": "ISC", "devDependencies": { - "fake-emitter-no-schema": "0.1.2" + "fake-emitter-no-schema": "0.1.2", + "fake-linter-no-schema": "0.1.2" }, "dependencies": { "fake-emitter": "0.0.2", + "fake-linter": "0.0.2", "fake-yaml": "0.0.1", "not-exist": "0.0.1" } diff --git a/packages/compiler/test/server/workspace/tspconfigtest0.yaml b/packages/compiler/test/server/workspace/tspconfigtest0.yaml new file mode 100644 index 0000000000..e69de29bb2