-
Notifications
You must be signed in to change notification settings - Fork 234
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Support completion for tspconfig.yaml file in vscode (#4790)
Support completion for tspconfig.yaml file in vscode #2049
- Loading branch information
Showing
23 changed files
with
1,687 additions
and
30 deletions.
There are no files selected for viewing
8 changes: 8 additions & 0 deletions
8
.chronus/changes/tspconfig-auto-complete-2024-9-18-13-48-34.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
--- | ||
changeKind: feature | ||
packages: | ||
- "@typespec/compiler" | ||
- typespec-vscode | ||
--- | ||
|
||
Support completion for tspconfig.yaml file in vscode |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
import { joinPaths } from "../core/path-utils.js"; | ||
import { NpmPackage, NpmPackageProvider } from "./npm-package-provider.js"; | ||
|
||
export class EmitterProvider { | ||
private isEmitterPackageCache = new Map<string, boolean>(); | ||
constructor(private npmPackageProvider: NpmPackageProvider) {} | ||
|
||
/** | ||
* | ||
* @param startFolder folder starts to search for package.json with emitters defined as dependencies | ||
* @returns | ||
*/ | ||
async listEmitters(startFolder: string): Promise<Record<string, NpmPackage>> { | ||
const packageJsonFolder = await this.npmPackageProvider.getPackageJsonFolder(startFolder); | ||
if (!packageJsonFolder) return {}; | ||
|
||
const pkg = await this.npmPackageProvider.get(packageJsonFolder); | ||
const data = await pkg?.getPackageJsonData(); | ||
if (!data) return {}; | ||
|
||
const emitters: Record<string, NpmPackage> = {}; | ||
const allDep = { | ||
...(data.dependencies ?? {}), | ||
...(data.devDependencies ?? {}), | ||
}; | ||
for (const dep of Object.keys(allDep)) { | ||
const depPkg = await this.getEmitterFromDep(packageJsonFolder, dep); | ||
if (depPkg) { | ||
emitters[dep] = depPkg; | ||
} | ||
} | ||
return emitters; | ||
} | ||
|
||
/** | ||
* | ||
* @param startFolder folder starts to search for package.json with emitters defined as dependencies | ||
* @param emitterName | ||
* @returns | ||
*/ | ||
async getEmitter(startFolder: string, emitterName: string): Promise<NpmPackage | undefined> { | ||
const packageJsonFolder = await this.npmPackageProvider.getPackageJsonFolder(startFolder); | ||
if (!packageJsonFolder) { | ||
return undefined; | ||
} | ||
return this.getEmitterFromDep(packageJsonFolder, emitterName); | ||
} | ||
|
||
private async isEmitter(depName: string, pkg: NpmPackage) { | ||
if (this.isEmitterPackageCache.has(depName)) { | ||
return this.isEmitterPackageCache.get(depName); | ||
} | ||
|
||
const data = await pkg.getPackageJsonData(); | ||
// don't add to cache when failing to load package.json which is unexpected | ||
if (!data) return false; | ||
if ( | ||
(data.devDependencies && data.devDependencies["@typespec/compiler"]) || | ||
(data.dependencies && data.dependencies["@typespec/compiler"]) | ||
) { | ||
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; | ||
} else { | ||
this.isEmitterPackageCache.set(depName, false); | ||
return false; | ||
} | ||
} | ||
|
||
private async getEmitterFromDep(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))) { | ||
return depPkg; | ||
} | ||
return undefined; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,225 @@ | ||
import { FileEvent } from "vscode-languageserver"; | ||
import { getDirectoryPath, joinPaths, normalizePath } from "../core/path-utils.js"; | ||
import { loadJsFile } from "../core/source-loader.js"; | ||
import { CompilerHost, NoTarget } from "../core/types.js"; | ||
import { NodePackage, resolveModule } from "../index.js"; | ||
import { distinctArray, isWhitespaceStringOrUndefined, tryParseJson } from "../utils/misc.js"; | ||
export class NpmPackageProvider { | ||
private pkgCache = new Map<string, NpmPackage>(); | ||
|
||
constructor(private host: CompilerHost) {} | ||
|
||
notify(changes: FileEvent[]) { | ||
let folders = changes | ||
.map((c) => normalizePath(this.host.fileURLToPath(c.uri))) | ||
.filter((c) => c.endsWith("/package.json")) | ||
.map((c) => getDirectoryPath(c)); | ||
folders = distinctArray(folders, (f) => f); | ||
|
||
for (const folder of folders) { | ||
const pkg = this.pkgCache.get(folder); | ||
if (pkg) { | ||
pkg.resetCache(); | ||
// since we may not get the notification for changes under node_modules | ||
// just reset those for safety | ||
const nodeModulesFolder = joinPaths(folder, "node_modules"); | ||
this.pkgCache.forEach((nmPkg, key) => { | ||
if (key.startsWith(nodeModulesFolder)) { | ||
nmPkg.resetCache(); | ||
} | ||
}); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Search for the nearest package.json file starting from the given folder to its parent/grandparent/... folders | ||
* @param startFolder the folder to start searching for package.json file | ||
* @returns | ||
*/ | ||
async getPackageJsonFolder(startFolder: string): Promise<string | undefined> { | ||
if (isWhitespaceStringOrUndefined(startFolder)) { | ||
return undefined; | ||
} | ||
|
||
let lastFolder = ""; | ||
let curFolder = startFolder; | ||
while (curFolder !== lastFolder) { | ||
const packageJsonPath = joinPaths(curFolder, "package.json"); | ||
try { | ||
const stat = await this.host.stat(packageJsonPath); | ||
if (stat.isFile()) { | ||
return curFolder; | ||
} | ||
} catch (e) { | ||
// ignore | ||
} | ||
lastFolder = curFolder; | ||
curFolder = getDirectoryPath(curFolder); | ||
} | ||
return undefined; | ||
} | ||
|
||
/** | ||
* Get the NpmPackage instance from the folder containing the package.json file. | ||
* | ||
* @param packageJsonFolder the dir containing the package.json file. This method won't search for the package.json file, use getPackageJsonFolder to search for the folder containing the package.json file if needed. | ||
* @returns the NpmPackage instance or undefined if no proper package.json file found | ||
*/ | ||
public async get(packageJsonFolder: string): Promise<NpmPackage | undefined> { | ||
const key = normalizePath(packageJsonFolder); | ||
const r = this.pkgCache.get(key); | ||
if (r) { | ||
return r; | ||
} else { | ||
const pkg = await NpmPackage.createFrom(this.host, packageJsonFolder); | ||
if (pkg) { | ||
this.pkgCache.set(key, pkg); | ||
return pkg; | ||
} else { | ||
return undefined; | ||
} | ||
} | ||
} | ||
|
||
private resetCache() { | ||
const t = this.pkgCache; | ||
this.pkgCache = new Map(); | ||
t.forEach((pkg) => { | ||
pkg.resetCache(); | ||
}); | ||
} | ||
|
||
/** | ||
* reset the status of the provider with all the caches properly cleaned up | ||
*/ | ||
public reset() { | ||
this.resetCache(); | ||
} | ||
} | ||
|
||
export class NpmPackage { | ||
private constructor( | ||
private host: CompilerHost, | ||
private packageJsonFolder: string, | ||
private packageJsonData: NodePackage | undefined, | ||
) {} | ||
|
||
async getPackageJsonData(): Promise<NodePackage | undefined> { | ||
if (!this.packageJsonData) { | ||
this.packageJsonData = await NpmPackage.loadNodePackage(this.host, this.packageJsonFolder); | ||
} | ||
return this.packageJsonData; | ||
} | ||
|
||
private packageModule: Record<string, any> | undefined; | ||
async getModuleExports(): Promise<Record<string, any> | undefined> { | ||
if (!this.packageModule) { | ||
const data = await this.getPackageJsonData(); | ||
if (!data) return undefined; | ||
this.packageModule = await NpmPackage.loadModuleExports( | ||
this.host, | ||
this.packageJsonFolder, | ||
data.name, | ||
); | ||
} | ||
return this.packageModule; | ||
} | ||
|
||
resetCache() { | ||
this.packageJsonData = undefined; | ||
this.packageModule = undefined; | ||
} | ||
|
||
/** | ||
* Create a NpmPackage instance from a folder containing a package.json file. Make sure to dispose the instance when you finish using it. | ||
* @param packageJsonFolder the folder containing the package.json file | ||
* @returns | ||
*/ | ||
public static async createFrom( | ||
host: CompilerHost, | ||
packageJsonFolder: string, | ||
): Promise<NpmPackage | undefined> { | ||
if (!packageJsonFolder) { | ||
return undefined; | ||
} | ||
const data = await NpmPackage.loadNodePackage(host, packageJsonFolder); | ||
if (!data) { | ||
return undefined; | ||
} | ||
return new NpmPackage(host, packageJsonFolder, data); | ||
} | ||
|
||
/** | ||
* | ||
* @param packageJsonFolder the folder containing the package.json file | ||
* @returns | ||
*/ | ||
private static async loadNodePackage( | ||
host: CompilerHost, | ||
packageJsonFolder: string, | ||
): Promise<NodePackage | undefined> { | ||
if (!packageJsonFolder) { | ||
return undefined; | ||
} | ||
const packageJsonPath = joinPaths(packageJsonFolder, "package.json"); | ||
try { | ||
if (!(await host.stat(packageJsonPath)).isFile()) { | ||
return undefined; | ||
} | ||
|
||
const content = await host.readFile(packageJsonPath); | ||
const data = tryParseJson(content.text) as NodePackage; | ||
|
||
if (!data || !data.name) { | ||
return undefined; | ||
} | ||
return data; | ||
} catch { | ||
return undefined; | ||
} | ||
} | ||
|
||
private static async loadModuleExports( | ||
host: CompilerHost, | ||
baseDir: string, | ||
packageName: string, | ||
): Promise<object | undefined> { | ||
try { | ||
const module = await resolveModule( | ||
{ | ||
realpath: host.realpath, | ||
readFile: async (path: string) => { | ||
const sf = await host.readFile(path); | ||
return sf.text; | ||
}, | ||
stat: host.stat, | ||
}, | ||
packageName, | ||
{ baseDir }, | ||
); | ||
if (!module) { | ||
return undefined; | ||
} | ||
const entrypoint = module.type === "file" ? module.path : module.mainFile; | ||
const oldExit = process.exit; | ||
try { | ||
// override process.exit to prevent the process from exiting because of it's called in loaded js file | ||
let result: any; | ||
process.exit = (() => { | ||
// for module that calls process.exit when being imported, create an empty object as it's exports to avoid load it again | ||
result = {}; | ||
throw new Error( | ||
"process.exit is called unexpectedly when loading js file: " + entrypoint, | ||
); | ||
}) as any; | ||
const [file] = await loadJsFile(host, entrypoint, NoTarget); | ||
return result ?? file?.esmExports; | ||
} finally { | ||
process.exit = oldExit; | ||
} | ||
} catch (e) { | ||
return undefined; | ||
} | ||
} | ||
} |
Oops, something went wrong.