Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support completion for tspconfig.yaml file in vscode #4790

Merged
merged 38 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
b09a0d4
support load packages with cache
RodgeFu Sep 23, 2024
95b5b14
Merge remote-tracking branch 'upstream/main' into tspconfig-auto-comp…
RodgeFu Sep 24, 2024
ed1c6a7
support tspconfig.yaml auto-complete
RodgeFu Sep 27, 2024
83ae96d
Merge remote-tracking branch 'upstream/main' into tspconfig-auto-comp…
RodgeFu Sep 27, 2024
5b17b89
some refactor
RodgeFu Sep 27, 2024
d1577cd
add unit test and some refine
RodgeFu Oct 9, 2024
90e1eee
Merge remote-tracking branch 'upstream/main' into tspconfig-auto-comp…
RodgeFu Oct 9, 2024
c6b1ddc
add test for tspconfig auto complete
RodgeFu Oct 10, 2024
a051b7a
some refine in code
RodgeFu Oct 11, 2024
c39038e
refine code
RodgeFu Oct 11, 2024
0966eda
fix some issues
RodgeFu Oct 16, 2024
c3d898b
fix mapping file
RodgeFu Oct 16, 2024
b8dfd5d
small refine
RodgeFu Oct 17, 2024
349d83d
some more update
RodgeFu Oct 18, 2024
fe026be
update to watch folder instead of file to get add/delete event
RodgeFu Oct 18, 2024
7a647f8
add changelog
RodgeFu Oct 18, 2024
b013f6c
Merge remote-tracking branch 'upstream/main' into tspconfig-auto-comp…
RodgeFu Oct 18, 2024
39df8ff
add some comment
RodgeFu Oct 18, 2024
3d56196
update changelog
RodgeFu Oct 18, 2024
3ab3733
add changelog
RodgeFu Oct 18, 2024
1856276
add changelog
RodgeFu Oct 18, 2024
8e885f9
move completion of tspconfig to compiler
RodgeFu Oct 23, 2024
a385854
some small update
RodgeFu Oct 23, 2024
f03be39
merge from upstream/main
RodgeFu Oct 23, 2024
85accbc
update pnpm-lock
RodgeFu Oct 23, 2024
bbd87b3
update watch func in host
RodgeFu Oct 23, 2024
295074b
Merge branch 'main' into tspconfig-auto-complete
RodgeFu Oct 23, 2024
e8e4b65
update pnpm lock
RodgeFu Oct 23, 2024
1ea469b
update for prettier warning
RodgeFu Oct 23, 2024
d47f262
fix watch's close impl
RodgeFu Oct 23, 2024
3bced9b
update per comment
RodgeFu Oct 24, 2024
424a86c
revert unexpected change
RodgeFu Oct 24, 2024
a49ba7c
add changelog for compiler watch
RodgeFu Oct 24, 2024
8f7be84
update per prettier comment
RodgeFu Oct 24, 2024
ef41b8b
Merge remote-tracking branch 'upstream/main' into tspconfig-auto-comp…
RodgeFu Oct 24, 2024
57c33b9
remove .orig file
RodgeFu Oct 24, 2024
034ae29
remove watch from compilerhost
RodgeFu Oct 29, 2024
54f1739
some update
RodgeFu Oct 29, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
timotheeguerin marked this conversation as resolved.
Show resolved Hide resolved
3 changes: 2 additions & 1 deletion packages/compiler/src/config/config-schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { JSONSchemaType } from "ajv";
import { EmitterOptions, TypeSpecRawConfig } from "./types.js";

const emitterOptionsSchema: JSONSchemaType<EmitterOptions> = {
export const emitterOptionsSchema: JSONSchemaType<EmitterOptions> = {
type: "object",
additionalProperties: true,
required: [],
Expand Down Expand Up @@ -79,6 +79,7 @@ export const TypeSpecConfigJsonSchema: JSONSchemaType<TypeSpecRawConfig> = {
emitters: {
type: "object",
nullable: true,
deprecated: true,
required: [],
additionalProperties: {
oneOf: [{ type: "boolean" }, emitterOptionsSchema],
Expand Down
81 changes: 81 additions & 0 deletions packages/compiler/src/server/emitter-provider.ts
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;
}
}
225 changes: 225 additions & 0 deletions packages/compiler/src/server/npm-package-provider.ts
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;
}
}
}
Loading
Loading