From a263140db3d88ab19ec629dc6cc2d6312201d337 Mon Sep 17 00:00:00 2001 From: Rui Qiu Date: Wed, 31 Jan 2024 12:52:51 -0800 Subject: [PATCH 01/13] feat: add auto updating for tsconfig on core --- packages/lightning-lsp-common/src/context.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/lightning-lsp-common/src/context.ts b/packages/lightning-lsp-common/src/context.ts index 106a41ef..fb8dcd9d 100644 --- a/packages/lightning-lsp-common/src/context.ts +++ b/packages/lightning-lsp-common/src/context.ts @@ -80,8 +80,9 @@ async function findNamespaceRoots(root: string, maxDepth = 5): Promise<{ lwc: st for (const subdir of subdirs) { // Is a root if any subdir matches a name/name.js with name.js being a module const basename = path.basename(subdir); - const modulePath = path.join(subdir, basename + '.js'); - if (fs.existsSync(modulePath)) { + const modulePathJs = path.join(subdir, basename + '.js'); + const modulePathTs = path.join(subdir, basename + '.ts'); + if (fs.existsSync(modulePathJs) || fs.existsSync(modulePathTs)) { // TODO: check contents for: from 'lwc'? return true; } @@ -305,6 +306,11 @@ export class WorkspaceContext { await this.writeJsconfigJson(); await this.writeSettings(); await this.writeTypings(); + + const modules = await this.findAllModules(); + for (const ns in modules) { + console.log(ns); + } } /** @@ -449,6 +455,15 @@ export class WorkspaceContext { } } + private async updateTsConfigOnCore(): Promise { + if (this.type === WorkspaceType.CORE_ALL || this.type === WorkspaceType.CORE_PARTIAL) { + const modulesDirs = await this.getModulesDirs(); + for (const modulesDir of modulesDirs) { + console.log(modulesDir); + } + } + } + private async writeSettings(): Promise { switch (this.type) { case WorkspaceType.CORE_ALL: From 71a258e5474ca7bf2b04c70d1805ff4d59401138 Mon Sep 17 00:00:00 2001 From: Rui Qiu Date: Thu, 1 Feb 2024 09:37:24 -0800 Subject: [PATCH 02/13] feat: initial implementation --- packages/lightning-lsp-common/src/context.ts | 49 ++++++++++++++++---- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/lightning-lsp-common/src/context.ts b/packages/lightning-lsp-common/src/context.ts index fb8dcd9d..b3846161 100644 --- a/packages/lightning-lsp-common/src/context.ts +++ b/packages/lightning-lsp-common/src/context.ts @@ -54,6 +54,21 @@ async function findModulesIn(namespaceRoot: string): Promise { return files; } +async function generateLWCMappings(namespaceRoot: string): Promise> { + const componentMappings: Record = {}; + const subdirs = await findSubdirectories(namespaceRoot); + const namespace = path.basename(namespaceRoot); + for (const subdir of subdirs) { + const componentName = path.basename(subdir); + const componentPath = path.join(subdir, componentName + '.ts'); + if (await fs.pathExists(componentPath)) { + const componentFullName = `${namespace}/${componentName}`; + componentMappings[componentFullName] = [`./modules/${namespace}/${componentName}/${componentName}`]; + } + } + return componentMappings; +} + async function readSfdxProjectConfig(root: string): Promise { try { return JSON.parse(await fs.readFile(getSfdxProjectFile(root), 'utf8')); @@ -306,11 +321,7 @@ export class WorkspaceContext { await this.writeJsconfigJson(); await this.writeSettings(); await this.writeTypings(); - - const modules = await this.findAllModules(); - for (const ns in modules) { - console.log(ns); - } + await this.updateTsConfigOnCore(); } /** @@ -457,9 +468,31 @@ export class WorkspaceContext { private async updateTsConfigOnCore(): Promise { if (this.type === WorkspaceType.CORE_ALL || this.type === WorkspaceType.CORE_PARTIAL) { - const modulesDirs = await this.getModulesDirs(); - for (const modulesDir of modulesDirs) { - console.log(modulesDir); + const namespaceRoots = await this.findNamespaceRootsUsingTypeCache(); + for (const namespaceRoot of namespaceRoots.lwc) { + const tsConfigFile = path.join(namespaceRoot, '..', '..', 'tsconfig.json'); + if (await fs.pathExists(tsConfigFile)) { + const mapping = await generateLWCMappings(namespaceRoot); + if (Object.keys(mapping).length > 0) { + try { + const tsconfigString = await fs.readFile(tsConfigFile, 'utf8'); + // remove any trailing commas + const tsconfig = JSON.parse(tsconfigString.replace(/,([ |\t|\n]+[\}|\]|\)])/g, '$1')); + if (tsconfig?.compilerOptions?.paths) { + Object.assign(tsconfig.compilerOptions.paths, mapping); + const sortedKeys = Object.keys(tsconfig.compilerOptions.paths).sort(); + const sortedPaths: Record = {}; + sortedKeys.forEach(key => { + sortedPaths[key] = tsconfig.compilerOptions.paths[key]; + }); + tsconfig.compilerOptions.paths = sortedPaths; + utils.writeJsonSync(tsConfigFile, tsconfig); + } + } catch (error) { + console.warn(`Error updating core tsconfig. Continuing, but may be missing some config. ${error}`); + } + } + } } } } From b8ac916ab55462e9c4bb91f2c40a8746cfc63b9d Mon Sep 17 00:00:00 2001 From: Rui Qiu Date: Wed, 7 Feb 2024 17:13:26 -0800 Subject: [PATCH 03/13] feat: add tsconfig paths indexer --- packages/lightning-lsp-common/src/context.ts | 55 +--- .../lwc-language-server/src/lwc-server.ts | 8 + .../src/typescript/imports.ts | 70 +++++ .../src/typescript/tsconfig-path-indexer.ts | 282 ++++++++++++++++++ 4 files changed, 368 insertions(+), 47 deletions(-) create mode 100644 packages/lwc-language-server/src/typescript/imports.ts create mode 100644 packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts diff --git a/packages/lightning-lsp-common/src/context.ts b/packages/lightning-lsp-common/src/context.ts index b3846161..615b9be8 100644 --- a/packages/lightning-lsp-common/src/context.ts +++ b/packages/lightning-lsp-common/src/context.ts @@ -54,21 +54,6 @@ async function findModulesIn(namespaceRoot: string): Promise { return files; } -async function generateLWCMappings(namespaceRoot: string): Promise> { - const componentMappings: Record = {}; - const subdirs = await findSubdirectories(namespaceRoot); - const namespace = path.basename(namespaceRoot); - for (const subdir of subdirs) { - const componentName = path.basename(subdir); - const componentPath = path.join(subdir, componentName + '.ts'); - if (await fs.pathExists(componentPath)) { - const componentFullName = `${namespace}/${componentName}`; - componentMappings[componentFullName] = [`./modules/${namespace}/${componentName}/${componentName}`]; - } - } - return componentMappings; -} - async function readSfdxProjectConfig(root: string): Promise { try { return JSON.parse(await fs.readFile(getSfdxProjectFile(root), 'utf8')); @@ -273,6 +258,14 @@ export class WorkspaceContext { return document.languageId === 'javascript' && (await this.isInsideModulesRoots(document)); } + public async isLWCTypeScript(document: TextDocument): Promise { + return ( + (this.type === WorkspaceType.CORE_ALL || this.type === WorkspaceType.CORE_PARTIAL) && + document.languageId === 'typescript' && + (await this.isInsideModulesRoots(document)) + ); + } + public async isInsideAuraRoots(document: TextDocument): Promise { const file = utils.toResolvedPath(document.uri); for (const ws of this.workspaceRoots) { @@ -321,7 +314,6 @@ export class WorkspaceContext { await this.writeJsconfigJson(); await this.writeSettings(); await this.writeTypings(); - await this.updateTsConfigOnCore(); } /** @@ -466,37 +458,6 @@ export class WorkspaceContext { } } - private async updateTsConfigOnCore(): Promise { - if (this.type === WorkspaceType.CORE_ALL || this.type === WorkspaceType.CORE_PARTIAL) { - const namespaceRoots = await this.findNamespaceRootsUsingTypeCache(); - for (const namespaceRoot of namespaceRoots.lwc) { - const tsConfigFile = path.join(namespaceRoot, '..', '..', 'tsconfig.json'); - if (await fs.pathExists(tsConfigFile)) { - const mapping = await generateLWCMappings(namespaceRoot); - if (Object.keys(mapping).length > 0) { - try { - const tsconfigString = await fs.readFile(tsConfigFile, 'utf8'); - // remove any trailing commas - const tsconfig = JSON.parse(tsconfigString.replace(/,([ |\t|\n]+[\}|\]|\)])/g, '$1')); - if (tsconfig?.compilerOptions?.paths) { - Object.assign(tsconfig.compilerOptions.paths, mapping); - const sortedKeys = Object.keys(tsconfig.compilerOptions.paths).sort(); - const sortedPaths: Record = {}; - sortedKeys.forEach(key => { - sortedPaths[key] = tsconfig.compilerOptions.paths[key]; - }); - tsconfig.compilerOptions.paths = sortedPaths; - utils.writeJsonSync(tsConfigFile, tsconfig); - } - } catch (error) { - console.warn(`Error updating core tsconfig. Continuing, but may be missing some config. ${error}`); - } - } - } - } - } - } - private async writeSettings(): Promise { switch (this.type) { case WorkspaceType.CORE_ALL: diff --git a/packages/lwc-language-server/src/lwc-server.ts b/packages/lwc-language-server/src/lwc-server.ts index 3a046b75..07fabe44 100644 --- a/packages/lwc-language-server/src/lwc-server.ts +++ b/packages/lwc-language-server/src/lwc-server.ts @@ -33,6 +33,7 @@ import ComponentIndexer from './component-indexer'; import TypingIndexer from './typing-indexer'; import templateLinter from './template/linter'; import Tag from './tag'; +import TSConfigPathIndexer from './typescript/tsconfig-path-indexer'; import { URI } from 'vscode-uri'; export const propertyRegex = new RegExp(/\{(?\w+)\.*.*\}/); @@ -77,6 +78,7 @@ export default class Server { languageService: LanguageService; auraDataProvider: AuraDataProvider; lwcDataProvider: LWCDataProvider; + tsconfigPathIndexer: TSConfigPathIndexer; constructor() { this.connection.onInitialize(this.onInitialize.bind(this)); @@ -99,6 +101,8 @@ export default class Server { this.lwcDataProvider = new LWCDataProvider({ indexer: this.componentIndexer }); this.auraDataProvider = new AuraDataProvider({ indexer: this.componentIndexer }); this.typingIndexer = new TypingIndexer({ workspaceRoot: this.workspaceRoots[0] }); + // For maintaining tsconfig.json file paths on core workspace + this.tsconfigPathIndexer = new TSConfigPathIndexer(this.workspaceRoots); this.languageService = getLanguageService({ customDataProviders: [this.lwcDataProvider, this.auraDataProvider], useDefaultDataProvider: false, @@ -107,6 +111,7 @@ export default class Server { await this.context.configureProject(); await this.componentIndexer.init(); this.typingIndexer.init(); + await this.tsconfigPathIndexer.init(); return this.capabilities; } @@ -267,6 +272,9 @@ export default class Server { tag.updateMetadata(metadata); } } + } else if (await this.context.isLWCTypeScript(document)) { + // update tsconfig.json file paths when a TS file is saved + this.tsconfigPathIndexer.updateTSConfigFileForDocument(document); } } diff --git a/packages/lwc-language-server/src/typescript/imports.ts b/packages/lwc-language-server/src/typescript/imports.ts new file mode 100644 index 00000000..3b12c815 --- /dev/null +++ b/packages/lwc-language-server/src/typescript/imports.ts @@ -0,0 +1,70 @@ +import * as ts from 'typescript'; +import { TextDocument } from 'vscode-languageserver'; +import * as path from 'path'; +import { URI } from 'vscode-uri'; + +/** + * Exclude some special importees that we don't need to analyze. + * The importees that are not needed include the following. + * 'lwc', 'lightning/*', '@salesforce/*', './', '../', '*.html', '*.css'. + * @param moduleSpecifier name of the importee. + * @returns true if the importee should be included for analyzing. + */ +function shouldIncludeImports(moduleSpecifier: string): boolean { + // excludes a few special imports + const exclusions = ['lightning/', '@salesforce/', './', '../']; + for (const exclusion of exclusions) { + if (moduleSpecifier.startsWith(exclusion)) { + return false; + } + } + // exclude html, css imports, and lwc imports + return !moduleSpecifier.endsWith('.html') && !moduleSpecifier.endsWith('.css') && moduleSpecifier !== 'lwc'; +} + +/** + * Adds an importee specifier to a set of importees. + */ +function addImports(imports: Set, importText: string): void { + if (importText && shouldIncludeImports(importText)) { + imports.add(importText); + } +} + +/** + * Parse a typescript file and collects all importees that we need to analyze. + * @param src ts source file + * @returns a set of strings containing the importees + */ +export function collectImports(src: ts.SourceFile): Set { + const imports = new Set(); + const walk = (node: ts.Node): void => { + if (ts.isImportDeclaration(node)) { + // ES2015 import + const moduleSpecifier = node.moduleSpecifier as ts.StringLiteral; + addImports(imports, moduleSpecifier.text); + } else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + // Dynamic import() + const moduleSpecifier = node.arguments[0]; + if (ts.isStringLiteral(moduleSpecifier)) { + addImports(imports, moduleSpecifier.text); + } + } + ts.forEachChild(node, walk); + }; + walk(src); + return imports; +} + +/** + * Collect a set of importees for a TypeScript document. + * @param document a TypeScript document + * @returns a set of strings containing the importees + */ +export async function collectImportsForDocument(document: TextDocument): Promise> { + const filePath = URI.file(document.uri).fsPath; + const content = document.getText(); + const fileName = path.parse(filePath).base; + const srcFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext); + return collectImports(srcFile); +} diff --git a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts new file mode 100644 index 00000000..bdb84660 --- /dev/null +++ b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts @@ -0,0 +1,282 @@ +import * as path from 'path'; +import * as fs from 'fs-extra'; +import { shared } from '@salesforce/lightning-lsp-common'; +import { sync } from 'fast-glob'; +import normalize from 'normalize-path'; +import { TextDocument } from 'vscode-languageserver'; +import { URI } from 'vscode-uri'; +import { collectImportsForDocument } from './imports'; + +const { detectWorkspaceType, WorkspaceType } = shared; +const REGEX_PATTERN = /\/core\/(.+?)\/modules\/(.+?)\/(.+?)\//; + +type TSConfigPathItemAttribute = { + readonly tsPath: string; + readonly filePath: string; +}; + +// An internal object representing a path mapping for tsconfig.json file on core +class TSConfigPathItem { + // internal typescript path mapping, e.g., "ui-force-components/modules/force/wireUtils/wireUtils" + public readonly tsPath: string; + // actual file path for the ts file + public readonly filePath: string; + + constructor(attribute: TSConfigPathItemAttribute) { + this.tsPath = attribute.tsPath; + this.filePath = attribute.filePath; + } +} + +/** + * An indexer that stores the TypeScript path mapping info on Core workspace. + * + * When using TypeScript for LWCs on core, tsconfig.json file's 'paths' attribute needs to be maintained so that + * TypeScript compiler knows how to resolve imported LWC modules. This class maintains a mapping between LWCs + * and their paths in tsconfig.json and automatically updates tsconfig.json file when initialized and when a LWC + * TypeScript file is changed. + * + * This includes a map for all TypeScript LWCs on core, the key is a component's full name (namespace/cmpName) + * and the value is an object that contains info on how this component should be mapped to in tsconfig.json + * so that TypeScript can find the component on the file system. + */ +export default class TSConfigPathIndexer { + readonly workspaceRoots: string[]; + readonly workspaceType: number; + // the root path for core directory + readonly coreRoot: string; + // A map for all TypeScript LWCs on core + pathMapping: Map = new Map(); + + constructor(workspaceRoots: string[]) { + this.workspaceRoots = workspaceRoots; + this.workspaceType = detectWorkspaceType(this.workspaceRoots); + switch (this.workspaceType) { + case WorkspaceType.CORE_ALL: + this.coreRoot = this.workspaceRoots[0]; + break; + case WorkspaceType.CORE_PARTIAL: + this.coreRoot = path.join(this.workspaceRoots[0], '..'); + break; + } + } + + // gets all paths for TypeScript LWC components on core + get componentEntries(): string[] { + const defaultSource = normalize(`${this.coreRoot}/*/modules/*/*/*.ts`); + const files = sync(defaultSource); + return files.filter((item: string): boolean => { + const data = path.parse(item); + let cmpName = data.name; + // remove '.d' for any '.d.ts' files + if (cmpName.endsWith('.d')) { + cmpName = cmpName.replace('.d', ''); + } + return data.dir.endsWith(cmpName); + }); + } + + // Initialization: build the path mapping for Core workspace. + public async init(): Promise { + if (!this.isOnCore()) { + return; // no-op if this is not a Core workspace + } + this.componentEntries.forEach(entry => { + this.addNewPathMapping(entry); + }); + // update each project under the workspaceRoots + for (const workspaceRoot of this.workspaceRoots) { + this.updateTSConfigPaths(workspaceRoot); + } + } + + /** + * Given a typescript document, update its containing module's tsconfig.json file's paths attribute. + * @param document the specified TS document + */ + public async updateTSConfigFileForDocument(document: TextDocument): Promise { + if (!this.isOnCore()) { + return; // no-op if this is not a Core workspace + } + const filePath = URI.file(document.uri).fsPath; + this.addNewPathMapping(filePath); + const moduleName = this.getModuleName(filePath); + const projectRoot = this.getProjectRoot(filePath); + const mappings = this.getTSMappingsForModule(moduleName); + // add mappings for all imported LWCs + const imports = await collectImportsForDocument(document); + if (imports.size > 0) { + for (const importee of imports) { + const isInSameModule = this.doesModuleContainNS(projectRoot, importee.substring(0, importee.indexOf('/'))); + const tsPath = this.pathMapping.get(importee)?.tsPath; + if (tsPath) { + mappings.set(importee, this.getRelativeTSPath(tsPath, isInSameModule)); + } + } + } + const tsconfigFile = path.join(projectRoot, 'tsconfig.json'); + this.updateTSConfigFile(tsconfigFile, mappings); + } + + /** + * Update the tsconfig.json file for one module(project) in core. + * Note that this only updates the path for all TypeScript LWCs within the module. + * This does not analyze any imported LWCs outside of the module. + * @param projectRoot the core module(project)'s root path, e.g., 'core-workspace/core/ui-force-components' + */ + private updateTSConfigPaths(projectRoot: string): void { + const tsconfigFile = path.join(projectRoot, 'tsconfig.json'); + const moduleName = path.basename(projectRoot); + const mappings = this.getTSMappingsForModule(moduleName); + this.updateTSConfigFile(tsconfigFile, mappings); + } + + /** + * Update tsconfig.json file with updated mapping. + * @param tsconfigFile target tsconfig.json file path + * @param mapping updated map that contains path info to update + */ + private async updateTSConfigFile(tsconfigFile: string, mapping: Map): Promise { + if (!fs.pathExistsSync(tsconfigFile)) { + return; // file does not exist, exit early + } + try { + const tsconfigString = await fs.readFile(tsconfigFile, 'utf8'); + // remove any trailing commas + const tsconfig = JSON.parse(tsconfigString.replace(/,([ |\t|\n]+[\}|\]|\)])/g, '$1')); + if (tsconfig?.compilerOptions?.paths) { + const formattedMapping = new Map(); + mapping.forEach((value, key) => { + formattedMapping.set(key, [value]); + }); + const existingPaths = tsconfig.compilerOptions.paths; + let updated = false; + formattedMapping.forEach((value, key) => { + if (!existingPaths[key] || existingPaths[key][0] !== value[0]) { + updated = true; + existingPaths[key] = value; + } + }); + // only update tsconfig.json if any path mapping is updated + if (!updated) { + return; + } + // sort the path mappings before update the file + const sortedKeys = Object.keys(existingPaths).sort(); + const sortedPaths: Record = {}; + sortedKeys.forEach(key => { + sortedPaths[key] = existingPaths[key]; + }); + tsconfig.compilerOptions.paths = sortedPaths; + fs.writeJSONSync(tsconfigFile, tsconfig, { + spaces: 4, + }); + } + } catch (error) { + console.warn(`Error updating core tsconfig. Continuing, but may be missing some config. ${error}`); + } + } + + /** + * Get all the path mapping info for a given module name, e.g., 'ui-force-components'. + * @param moduleName a target module's name + */ + private getTSMappingsForModule(moduleName: string): Map { + const mappings = new Map(); + this.pathMapping.forEach((value, key) => { + if (value.filePath.includes(moduleName)) { + mappings.set(key, this.getRelativeTSPath(value.tsPath, true)); + } + }); + return mappings; + } + + /** + * Add a mapping for a TypeScript LWC file path. The file can be .ts or .d.ts. + * @param entry file path for a TypeScript LWC ts file, + * e.g., 'core-workspace/core/ui-force-components/modules/force/wireUtils/wireUtils.d.ts' + */ + private addNewPathMapping(entry: string): void { + const componentFullName = this.getComponentFullName(entry); + const tsPath = this.getTSPath(entry); + if (componentFullName && tsPath) { + this.pathMapping.set(componentFullName, new TSConfigPathItem({ tsPath, filePath: entry })); + } + } + + /** + * @param entry file path, e.g., 'core-workspace/core/ui-force-components/modules/force/wireUtils/wireUtils.d.ts' + * @returns component's full name, e.g., 'force/wireUtils' + */ + private getComponentFullName(entry: string): string { + const match = REGEX_PATTERN.exec(entry); + return match && match[2] + '/' + match[3]; + } + + /** + * @param entry file path, e.g., 'core-workspace/core/ui-force-components/modules/force/wireUtils/wireUtils.d.ts' + * @returns component name, e.g., 'wireUtils' + */ + private getComponentName(entry: string): string { + const match = REGEX_PATTERN.exec(entry); + return match && match[3]; + } + + /** + * @param entry file path, e.g., 'core-workspace/core/ui-force-components/modules/force/wireUtils/wireUtils.d.ts' + * @returns module (project) name, e.g., 'ui-force-components' + */ + private getModuleName(entry: string): string { + const match = REGEX_PATTERN.exec(entry); + return match && match[1]; + } + + /** + * @param entry file path, e.g., 'core-workspace/core/ui-force-components/modules/force/wireUtils/wireUtils.d.ts' + * @returns module (project) name, e.g., 'ui-force-components' + */ + private getProjectRoot(entry: string): string { + const moduleName = this.getModuleName(entry); + if (moduleName) { + return path.join(this.coreRoot, moduleName); + } + } + + /** + * @param entry file path, e.g., 'core-workspace/core/ui-force-components/modules/force/wireUtils/wireUtils.d.ts' + * @returns internal representation of a path mapping, e.g., 'ui-force-components/modules/force/wireUtils/wireUtils' + */ + private getTSPath(entry: string): string { + const moduleName = this.getModuleName(entry); + const componentName = this.getComponentName(entry); + const componentFullName = this.getComponentFullName(entry); + if (moduleName && componentName && componentFullName) { + return `${moduleName}/modules/${componentFullName}/${componentName}`; + } else { + return null; + } + } + + /** + * @param tsPath internal representation of a path mapping, e.g., 'ui-force-components/modules/force/wireUtils/wireUtils' + * @param isInSameModule whether this path mapping is used for the same module + * @returns a relative path for the mapping in tsconfig.json, e.g., './modules/force/wireUtils/wireUtils' + */ + private getRelativeTSPath(tsPath: string, isInSameModule: boolean): string { + return isInSameModule ? './' + tsPath.substring(tsPath.indexOf('/') + 1) : '../' + tsPath; + } + + /** + * @returns true if this is a core workspace; false otherwise. + */ + private isOnCore(): boolean { + return this.workspaceType === WorkspaceType.CORE_ALL || this.workspaceType === WorkspaceType.CORE_PARTIAL; + } + + /** + * Checks if a given namespace exists in a given module path. + */ + private doesModuleContainNS(modulePath: string, nsName: string): boolean { + return fs.pathExistsSync(path.join(modulePath, 'modules', nsName)); + } +} From 74d6b2f7ffc57acbfa64e092b89131c6e65a3b2b Mon Sep 17 00:00:00 2001 From: Rui Qiu Date: Thu, 8 Feb 2024 11:17:49 -0800 Subject: [PATCH 04/13] feat: remove deleted path mappings --- .../src/typescript/tsconfig-path-indexer.ts | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts index bdb84660..f2c26b96 100644 --- a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts +++ b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts @@ -107,7 +107,7 @@ export default class TSConfigPathIndexer { const imports = await collectImportsForDocument(document); if (imports.size > 0) { for (const importee of imports) { - const isInSameModule = this.doesModuleContainNS(projectRoot, importee.substring(0, importee.indexOf('/'))); + const isInSameModule = this.moduleContainsNS(projectRoot, importee.substring(0, importee.indexOf('/'))); const tsPath = this.pathMapping.get(importee)?.tsPath; if (tsPath) { mappings.set(importee, this.getRelativeTSPath(tsPath, isInSameModule)); @@ -115,7 +115,7 @@ export default class TSConfigPathIndexer { } } const tsconfigFile = path.join(projectRoot, 'tsconfig.json'); - this.updateTSConfigFile(tsconfigFile, mappings); + this.updateTSConfigFile(tsconfigFile, mappings, false); } /** @@ -128,17 +128,18 @@ export default class TSConfigPathIndexer { const tsconfigFile = path.join(projectRoot, 'tsconfig.json'); const moduleName = path.basename(projectRoot); const mappings = this.getTSMappingsForModule(moduleName); - this.updateTSConfigFile(tsconfigFile, mappings); + this.updateTSConfigFile(tsconfigFile, mappings, true); } /** * Update tsconfig.json file with updated mapping. * @param tsconfigFile target tsconfig.json file path * @param mapping updated map that contains path info to update + * @param isReIndex true if the deleted existing paths should be removed */ - private async updateTSConfigFile(tsconfigFile: string, mapping: Map): Promise { + private async updateTSConfigFile(tsconfigFile: string, mapping: Map, isReIndex: boolean): Promise { if (!fs.pathExistsSync(tsconfigFile)) { - return; // file does not exist, exit early + return; // file does not exist, no-op } try { const tsconfigString = await fs.readFile(tsconfigFile, 'utf8'); @@ -150,11 +151,22 @@ export default class TSConfigPathIndexer { formattedMapping.set(key, [value]); }); const existingPaths = tsconfig.compilerOptions.paths; + const updatedPaths = { ...existingPaths }; let updated = false; + // remove the existing paths that are not in the updated mapping + if (isReIndex) { + const projectRoot = path.join(tsconfigFile, '..'); + for (const key in existingPaths) { + if (!formattedMapping.has(key) && this.moduleContainsNS(projectRoot, key.substring(0, key.indexOf('/')))) { + updated = true; + delete updatedPaths[key]; + } + } + } formattedMapping.forEach((value, key) => { if (!existingPaths[key] || existingPaths[key][0] !== value[0]) { updated = true; - existingPaths[key] = value; + updatedPaths[key] = value; } }); // only update tsconfig.json if any path mapping is updated @@ -162,10 +174,10 @@ export default class TSConfigPathIndexer { return; } // sort the path mappings before update the file - const sortedKeys = Object.keys(existingPaths).sort(); + const sortedKeys = Object.keys(updatedPaths).sort(); const sortedPaths: Record = {}; sortedKeys.forEach(key => { - sortedPaths[key] = existingPaths[key]; + sortedPaths[key] = updatedPaths[key]; }); tsconfig.compilerOptions.paths = sortedPaths; fs.writeJSONSync(tsconfigFile, tsconfig, { @@ -276,7 +288,7 @@ export default class TSConfigPathIndexer { /** * Checks if a given namespace exists in a given module path. */ - private doesModuleContainNS(modulePath: string, nsName: string): boolean { + private moduleContainsNS(modulePath: string, nsName: string): boolean { return fs.pathExistsSync(path.join(modulePath, 'modules', nsName)); } } From 488d0376486ff4f291b551e65f728d2a4f48bc9e Mon Sep 17 00:00:00 2001 From: Rui Qiu Date: Fri, 9 Feb 2024 10:29:28 -0800 Subject: [PATCH 05/13] test: add test for context --- .../src/__tests__/context.test.ts | 20 +++++++++++++++++++ .../src/__tests__/test-utils.ts | 2 ++ .../src/typescript/tsconfig-path-indexer.ts | 4 ++-- .../modules/force/input-phone/input-phone.ts | 6 ++++++ .../modules/one/app-nav-bar/app-nav-bar.ts | 4 ++++ 5 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 test-workspaces/core-like-workspace/app/main/core/ui-force-components/modules/force/input-phone/input-phone.ts create mode 100644 test-workspaces/core-like-workspace/app/main/core/ui-global-components/modules/one/app-nav-bar/app-nav-bar.ts diff --git a/packages/lightning-lsp-common/src/__tests__/context.test.ts b/packages/lightning-lsp-common/src/__tests__/context.test.ts index 8aff4932..7ae13031 100644 --- a/packages/lightning-lsp-common/src/__tests__/context.test.ts +++ b/packages/lightning-lsp-common/src/__tests__/context.test.ts @@ -154,6 +154,26 @@ it('isLWCJavascript()', async () => { expect(await context.isLWCJavascript(document)).toBeTruthy(); }); +it('isLWCTypeScript()', async () => { + const context = new WorkspaceContext(CORE_PROJECT_ROOT); + + // lwc .ts + let document = readAsTextDocument(CORE_PROJECT_ROOT + '/modules/one/app-nav-bar/app-nav-bar.ts'); + expect(await context.isLWCTypeScript(document)).toBeTruthy(); + + // lwc .ts outside namespace root + document = readAsTextDocument(CORE_ALL_ROOT + '/ui-force-components/modules/force/input-phone/input-phone.ts'); + expect(await context.isLWCTypeScript(document)).toBeFalsy(); + + // lwc .html + document = readAsTextDocument(CORE_PROJECT_ROOT + '/modules/one/app-nav-bar/app-nav-bar.html'); + expect(await context.isLWCTypeScript(document)).toBeFalsy(); + + // lwc .js + document = readAsTextDocument(CORE_PROJECT_ROOT + '/modules/one/app-nav-bar/app-nav-bar.js'); + expect(await context.isLWCTypeScript(document)).toBeFalsy(); +}); + it('configureSfdxProject()', async () => { const context = new WorkspaceContext('test-workspaces/sfdx-workspace'); const jsconfigPathForceApp = FORCE_APP_ROOT + '/lwc/jsconfig.json'; diff --git a/packages/lightning-lsp-common/src/__tests__/test-utils.ts b/packages/lightning-lsp-common/src/__tests__/test-utils.ts index a2cdd3c3..35c54be9 100644 --- a/packages/lightning-lsp-common/src/__tests__/test-utils.ts +++ b/packages/lightning-lsp-common/src/__tests__/test-utils.ts @@ -19,6 +19,8 @@ function languageId(path: string): string { switch (suffix.substring(1)) { case 'js': return 'javascript'; + case 'ts': + return 'typescript'; case 'html': return 'html'; case 'app': diff --git a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts index f2c26b96..c43709da 100644 --- a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts +++ b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts @@ -18,9 +18,9 @@ type TSConfigPathItemAttribute = { // An internal object representing a path mapping for tsconfig.json file on core class TSConfigPathItem { // internal typescript path mapping, e.g., "ui-force-components/modules/force/wireUtils/wireUtils" - public readonly tsPath: string; + readonly tsPath: string; // actual file path for the ts file - public readonly filePath: string; + readonly filePath: string; constructor(attribute: TSConfigPathItemAttribute) { this.tsPath = attribute.tsPath; diff --git a/test-workspaces/core-like-workspace/app/main/core/ui-force-components/modules/force/input-phone/input-phone.ts b/test-workspaces/core-like-workspace/app/main/core/ui-force-components/modules/force/input-phone/input-phone.ts new file mode 100644 index 00000000..63a0993c --- /dev/null +++ b/test-workspaces/core-like-workspace/app/main/core/ui-force-components/modules/force/input-phone/input-phone.ts @@ -0,0 +1,6 @@ +import { LightningElement, api } from "lwc"; +import { contextLibraryLWC } from 'clients-context-library-lwc'; + +export default class InputPhone extends LightningElement { + @api value; +} diff --git a/test-workspaces/core-like-workspace/app/main/core/ui-global-components/modules/one/app-nav-bar/app-nav-bar.ts b/test-workspaces/core-like-workspace/app/main/core/ui-global-components/modules/one/app-nav-bar/app-nav-bar.ts new file mode 100644 index 00000000..01dbb20e --- /dev/null +++ b/test-workspaces/core-like-workspace/app/main/core/ui-global-components/modules/one/app-nav-bar/app-nav-bar.ts @@ -0,0 +1,4 @@ +import { LightningElement } from 'lwc'; + +export default class AppNavBar extends LightningElement { +} From adde3fb481324154026b45b32bb03f9cefad01cf Mon Sep 17 00:00:00 2001 From: Rui Qiu Date: Fri, 9 Feb 2024 15:19:41 -0800 Subject: [PATCH 06/13] test: add basic tests for updating tsconfig --- .../src/__tests__/context.test.ts | 2 +- .../src/__tests__/typescript.test.ts | 101 ++++++++++++++++++ .../src/typescript/tsconfig-path-indexer.ts | 50 +++++++-- .../coreTS/core/tsconfig.json | 19 ++++ .../context-library-lwc.ts | 3 + .../force/input-phone/input-phone.html | 4 + .../modules/force/input-phone/input-phone.ts | 6 ++ .../core/ui-force-components/tsconfig.json | 6 ++ .../app-nav-bar/__tests__/app-nav-bar.test.ts | 5 + .../modules/one/app-nav-bar/app-nav-bar.html | 4 + .../modules/one/app-nav-bar/app-nav-bar.ts | 4 + .../modules/one/app-nav-bar/utils.ts | 12 +++ .../core/ui-global-components/tsconfig.json | 6 ++ .../coreTS/core/workspace-user.xml | 14 +++ 14 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 packages/lwc-language-server/src/__tests__/typescript.test.ts create mode 100644 test-workspaces/core-like-workspace/coreTS/core/tsconfig.json create mode 100644 test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/clients/context-library-lwc/context-library-lwc.ts create mode 100644 test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.html create mode 100644 test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.ts create mode 100644 test-workspaces/core-like-workspace/coreTS/core/ui-force-components/tsconfig.json create mode 100644 test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/__tests__/app-nav-bar.test.ts create mode 100644 test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/app-nav-bar.html create mode 100644 test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/app-nav-bar.ts create mode 100644 test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/utils.ts create mode 100644 test-workspaces/core-like-workspace/coreTS/core/ui-global-components/tsconfig.json create mode 100644 test-workspaces/core-like-workspace/coreTS/core/workspace-user.xml diff --git a/packages/lightning-lsp-common/src/__tests__/context.test.ts b/packages/lightning-lsp-common/src/__tests__/context.test.ts index 7ae13031..a7f2c842 100644 --- a/packages/lightning-lsp-common/src/__tests__/context.test.ts +++ b/packages/lightning-lsp-common/src/__tests__/context.test.ts @@ -318,7 +318,7 @@ it('configureCoreMulti()', async () => { // verify newly created jsconfig.json verifyJsconfigCore(jsconfigPathGlobal); // verify jsconfig.json is not created when there is a tsconfig.json - expect(fs.existsSync(tsconfigPathForce)).not.toExist(); + expect(fs.existsSync(jsconfigPathForce)).not.toExist(); verifyTypingsCore(); fs.removeSync(tsconfigPathForce); diff --git a/packages/lwc-language-server/src/__tests__/typescript.test.ts b/packages/lwc-language-server/src/__tests__/typescript.test.ts new file mode 100644 index 00000000..2697377c --- /dev/null +++ b/packages/lwc-language-server/src/__tests__/typescript.test.ts @@ -0,0 +1,101 @@ +import * as path from 'path'; +import * as fs from 'fs-extra'; +import { shared } from '@salesforce/lightning-lsp-common'; +import TSConfigPathIndexer from '../typescript/tsconfig-path-indexer'; + +const { WorkspaceType } = shared; +const TEST_WORKSPACE_PARENT_DIR = path.resolve('../..'); +const CORE_ROOT = path.join(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'core-like-workspace', 'coreTS', 'core'); + +const tsConfigForce = path.join(CORE_ROOT, 'ui-force-components', 'tsconfig.json'); +const tsConfigGlobal = path.join(CORE_ROOT, 'ui-global-components', 'tsconfig.json'); + +function readTSConfigFile(tsconfigPath: string): object { + if (!fs.pathExistsSync(tsconfigPath)) { + return null; + } + return JSON.parse(fs.readFileSync(tsconfigPath, 'utf8')); +} + +function restoreTSConfigFiles(): void { + const tsconfig = { + extends: '../tsconfig.json', + compilerOptions: { + paths: {}, + }, + }; + const tsconfigPaths = [tsConfigForce, tsConfigGlobal]; + for (const tsconfigPath of tsconfigPaths) { + fs.writeJSONSync(tsconfigPath, tsconfig, { + spaces: 4, + }); + } +} + +beforeEach(async () => { + restoreTSConfigFiles(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + restoreTSConfigFiles(); +}); + +describe('ComponentIndexer', () => { + describe('new', () => { + it('initializes with the root of a core root dir', () => { + const expectedPath: string = path.resolve('../../test-workspaces/core-like-workspace/coreTS/core'); + const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]); + expect(tsconfigPathIndexer.coreModulesWithTSConfig.length).toEqual(2); + expect(tsconfigPathIndexer.coreModulesWithTSConfig[0]).toEqual(path.join(expectedPath, 'ui-force-components')); + expect(tsconfigPathIndexer.coreModulesWithTSConfig[1]).toEqual(path.join(expectedPath, 'ui-global-components')); + expect(tsconfigPathIndexer.workspaceType).toEqual(WorkspaceType.CORE_ALL); + expect(tsconfigPathIndexer.coreRoot).toEqual(expectedPath); + }); + + it('initializes with the root of a core project dir', () => { + const expectedPath: string = path.resolve('../../test-workspaces/core-like-workspace/coreTS/core'); + const tsconfigPathIndexer = new TSConfigPathIndexer([path.join(CORE_ROOT, 'ui-force-components')]); + expect(tsconfigPathIndexer.coreModulesWithTSConfig.length).toEqual(1); + expect(tsconfigPathIndexer.coreModulesWithTSConfig[0]).toEqual(path.join(expectedPath, 'ui-force-components')); + expect(tsconfigPathIndexer.workspaceType).toEqual(WorkspaceType.CORE_PARTIAL); + expect(tsconfigPathIndexer.coreRoot).toEqual(expectedPath); + }); + }); + + describe('instance methods', () => { + describe('#init', () => { + it('no-op on sfdx workspace root', async () => { + const tsconfigPathIndexer = new TSConfigPathIndexer([path.join(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'sfdx-workspace')]); + const spy = jest.spyOn(tsconfigPathIndexer, 'componentEntries', 'get'); + await tsconfigPathIndexer.init(); + expect(spy).not.toHaveBeenCalled(); + expect(tsconfigPathIndexer.coreRoot).toBeUndefined(); + }); + + it('generates paths mappings for all modules on core', async () => { + const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]); + await tsconfigPathIndexer.init(); + const tsConfigForceObj = readTSConfigFile(tsConfigForce); + expect(tsConfigForceObj).toEqual({ + extends: '../tsconfig.json', + compilerOptions: { + paths: { + 'clients/context-library-lwc': ['./modules/clients/context-library-lwc/context-library-lwc'], + 'force/input-phone': ['./modules/force/input-phone/input-phone'], + }, + }, + }); + const tsConfigGlobalObj = readTSConfigFile(tsConfigGlobal); + expect(tsConfigGlobalObj).toEqual({ + extends: '../tsconfig.json', + compilerOptions: { + paths: { + 'one/app-nav-bar': ['./modules/one/app-nav-bar/app-nav-bar'], + }, + }, + }); + }); + }); + }); +}); diff --git a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts index c43709da..3cae47a3 100644 --- a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts +++ b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts @@ -41,7 +41,7 @@ class TSConfigPathItem { * so that TypeScript can find the component on the file system. */ export default class TSConfigPathIndexer { - readonly workspaceRoots: string[]; + readonly coreModulesWithTSConfig: string[]; readonly workspaceType: number; // the root path for core directory readonly coreRoot: string; @@ -49,19 +49,30 @@ export default class TSConfigPathIndexer { pathMapping: Map = new Map(); constructor(workspaceRoots: string[]) { - this.workspaceRoots = workspaceRoots; - this.workspaceType = detectWorkspaceType(this.workspaceRoots); + this.workspaceType = detectWorkspaceType(workspaceRoots); switch (this.workspaceType) { case WorkspaceType.CORE_ALL: - this.coreRoot = this.workspaceRoots[0]; + this.coreRoot = workspaceRoots[0]; + const dirs = fs.readdirSync(this.coreRoot); + const subdirs: string[] = []; + for (const file of dirs) { + const subdir = path.join(this.coreRoot, file); + if (fs.statSync(subdir).isDirectory()) { + subdirs.push(subdir); + } + } + this.coreModulesWithTSConfig = this.getCoreModulesWithTSConfig(subdirs); break; case WorkspaceType.CORE_PARTIAL: - this.coreRoot = path.join(this.workspaceRoots[0], '..'); + this.coreRoot = path.join(workspaceRoots[0], '..'); + this.coreModulesWithTSConfig = this.getCoreModulesWithTSConfig(workspaceRoots); break; } } - // gets all paths for TypeScript LWC components on core + /** + * Gets all paths for TypeScript LWC components on core. + */ get componentEntries(): string[] { const defaultSource = normalize(`${this.coreRoot}/*/modules/*/*/*.ts`); const files = sync(defaultSource); @@ -76,7 +87,9 @@ export default class TSConfigPathIndexer { }); } - // Initialization: build the path mapping for Core workspace. + /** + * Initialization: build the path mapping for Core workspace. + */ public async init(): Promise { if (!this.isOnCore()) { return; // no-op if this is not a Core workspace @@ -85,8 +98,9 @@ export default class TSConfigPathIndexer { this.addNewPathMapping(entry); }); // update each project under the workspaceRoots - for (const workspaceRoot of this.workspaceRoots) { - this.updateTSConfigPaths(workspaceRoot); + + for (const workspaceRoot of this.coreModulesWithTSConfig) { + await this.updateTSConfigPaths(workspaceRoot); } } @@ -118,17 +132,31 @@ export default class TSConfigPathIndexer { this.updateTSConfigFile(tsconfigFile, mappings, false); } + /** + * @param coreModules A list of core modules root paths + * @returns A sublist of core modules from input that has a tsconfig.json file + */ + private getCoreModulesWithTSConfig(coreModules: string[]): string[] { + const modules = []; + for (const module of coreModules) { + if (fs.existsSync(path.join(module, 'tsconfig.json'))) { + modules.push(module); + } + } + return modules; + } + /** * Update the tsconfig.json file for one module(project) in core. * Note that this only updates the path for all TypeScript LWCs within the module. * This does not analyze any imported LWCs outside of the module. * @param projectRoot the core module(project)'s root path, e.g., 'core-workspace/core/ui-force-components' */ - private updateTSConfigPaths(projectRoot: string): void { + private async updateTSConfigPaths(projectRoot: string): Promise { const tsconfigFile = path.join(projectRoot, 'tsconfig.json'); const moduleName = path.basename(projectRoot); const mappings = this.getTSMappingsForModule(moduleName); - this.updateTSConfigFile(tsconfigFile, mappings, true); + await this.updateTSConfigFile(tsconfigFile, mappings, true); } /** diff --git a/test-workspaces/core-like-workspace/coreTS/core/tsconfig.json b/test-workspaces/core-like-workspace/coreTS/core/tsconfig.json new file mode 100644 index 00000000..ba39b5f9 --- /dev/null +++ b/test-workspaces/core-like-workspace/coreTS/core/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "allowJs": true, + "declaration": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "isolatedModules": true, + "moduleResolution": "node", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "strictPropertyInitialization": false, + "target": "es2020", + }, + "exclude": ["**/node_modules"] +} \ No newline at end of file diff --git a/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/clients/context-library-lwc/context-library-lwc.ts b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/clients/context-library-lwc/context-library-lwc.ts new file mode 100644 index 00000000..9aabadf1 --- /dev/null +++ b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/clients/context-library-lwc/context-library-lwc.ts @@ -0,0 +1,3 @@ +export function contextLibraryLWC() { + return null; +} \ No newline at end of file diff --git a/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.html b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.html new file mode 100644 index 00000000..4e64ab8b --- /dev/null +++ b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.html @@ -0,0 +1,4 @@ + diff --git a/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.ts b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.ts new file mode 100644 index 00000000..63a0993c --- /dev/null +++ b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.ts @@ -0,0 +1,6 @@ +import { LightningElement, api } from "lwc"; +import { contextLibraryLWC } from 'clients-context-library-lwc'; + +export default class InputPhone extends LightningElement { + @api value; +} diff --git a/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/tsconfig.json b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/tsconfig.json new file mode 100644 index 00000000..cd4284e7 --- /dev/null +++ b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "paths": {} + } +} diff --git a/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/__tests__/app-nav-bar.test.ts b/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/__tests__/app-nav-bar.test.ts new file mode 100644 index 00000000..2504432e --- /dev/null +++ b/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/__tests__/app-nav-bar.test.ts @@ -0,0 +1,5 @@ +describe('app-nav-bar', () => { + it('test', () => { + expect(2 + 3).toBe(5); + }); +}); diff --git a/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/app-nav-bar.html b/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/app-nav-bar.html new file mode 100644 index 00000000..9322f03d --- /dev/null +++ b/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/app-nav-bar.html @@ -0,0 +1,4 @@ + diff --git a/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/app-nav-bar.ts b/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/app-nav-bar.ts new file mode 100644 index 00000000..01dbb20e --- /dev/null +++ b/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/app-nav-bar.ts @@ -0,0 +1,4 @@ +import { LightningElement } from 'lwc'; + +export default class AppNavBar extends LightningElement { +} diff --git a/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/utils.ts b/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/utils.ts new file mode 100644 index 00000000..527b5fd4 --- /dev/null +++ b/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/modules/one/app-nav-bar/utils.ts @@ -0,0 +1,12 @@ +export function debounce(fn, wait) { + return function _debounce() { + if (!_debounce.pending) { + _debounce.pending = true; + // eslint-disable-next-line lwc/no-set-timeout + setTimeout(() => { + fn(); + _debounce.pending = false; + }, wait); + } + }; +} diff --git a/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/tsconfig.json b/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/tsconfig.json new file mode 100644 index 00000000..cd4284e7 --- /dev/null +++ b/test-workspaces/core-like-workspace/coreTS/core/ui-global-components/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "paths": {} + } +} diff --git a/test-workspaces/core-like-workspace/coreTS/core/workspace-user.xml b/test-workspaces/core-like-workspace/coreTS/core/workspace-user.xml new file mode 100644 index 00000000..9570ecf6 --- /dev/null +++ b/test-workspaces/core-like-workspace/coreTS/core/workspace-user.xml @@ -0,0 +1,14 @@ + + un + pw + + 15062234 + main + + + + :core + shared-app + + + \ No newline at end of file From c6a2cf50391bb7e14ff873e1e66b6a7d5b98fef8 Mon Sep 17 00:00:00 2001 From: Rui Qiu Date: Fri, 9 Feb 2024 16:06:02 -0800 Subject: [PATCH 07/13] test: add more tests --- .../src/__tests__/test-utils.ts | 2 + .../src/__tests__/typescript.test.ts | 80 ++++++++++++++++++- .../lwc-language-server/src/lwc-server.ts | 2 +- .../src/typescript/tsconfig-path-indexer.ts | 5 +- .../modules/force/input-phone/input-phone.ts | 5 +- 5 files changed, 87 insertions(+), 7 deletions(-) diff --git a/packages/lwc-language-server/src/__tests__/test-utils.ts b/packages/lwc-language-server/src/__tests__/test-utils.ts index 7f77aa97..bc78dfee 100644 --- a/packages/lwc-language-server/src/__tests__/test-utils.ts +++ b/packages/lwc-language-server/src/__tests__/test-utils.ts @@ -18,6 +18,8 @@ function languageId(path: string): string { switch (suffix.substring(1)) { case 'js': return 'javascript'; + case 'ts': + return 'typescript'; case 'html': return 'html'; case 'app': diff --git a/packages/lwc-language-server/src/__tests__/typescript.test.ts b/packages/lwc-language-server/src/__tests__/typescript.test.ts index 2697377c..681caf72 100644 --- a/packages/lwc-language-server/src/__tests__/typescript.test.ts +++ b/packages/lwc-language-server/src/__tests__/typescript.test.ts @@ -1,7 +1,10 @@ import * as path from 'path'; import * as fs from 'fs-extra'; import { shared } from '@salesforce/lightning-lsp-common'; +import { readAsTextDocument } from './test-utils'; import TSConfigPathIndexer from '../typescript/tsconfig-path-indexer'; +import { collectImportsForDocument } from '../typescript/imports'; +import { TextDocument } from 'vscode-languageserver-textdocument'; const { WorkspaceType } = shared; const TEST_WORKSPACE_PARENT_DIR = path.resolve('../..'); @@ -64,7 +67,7 @@ describe('ComponentIndexer', () => { }); describe('instance methods', () => { - describe('#init', () => { + describe('init', () => { it('no-op on sfdx workspace root', async () => { const tsconfigPathIndexer = new TSConfigPathIndexer([path.join(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'sfdx-workspace')]); const spy = jest.spyOn(tsconfigPathIndexer, 'componentEntries', 'get'); @@ -97,5 +100,80 @@ describe('ComponentIndexer', () => { }); }); }); + + describe('updateTSConfigFileForDocument', () => { + it('no-op on sfdx workspace root', async () => { + const tsconfigPathIndexer = new TSConfigPathIndexer([path.join(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'sfdx-workspace')]); + await tsconfigPathIndexer.init(); + const filePath = path.join(CORE_ROOT, 'ui-force-components', 'modules', 'force', 'input-phone', 'input-phone.ts'); + const spy = jest.spyOn(tsconfigPathIndexer as any, 'addNewPathMapping'); + await tsconfigPathIndexer.updateTSConfigFileForDocument(readAsTextDocument(filePath)); + expect(spy).not.toHaveBeenCalled(); + expect(tsconfigPathIndexer.coreRoot).toBeUndefined(); + }); + + it('updates tsconfig for all imports', async () => { + const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]); + await tsconfigPathIndexer.init(); + const filePath = path.join(CORE_ROOT, 'ui-force-components', 'modules', 'force', 'input-phone', 'input-phone.ts'); + await tsconfigPathIndexer.updateTSConfigFileForDocument(readAsTextDocument(filePath)); + const tsConfigForceObj = readTSConfigFile(tsConfigForce); + expect(tsConfigForceObj).toEqual({ + extends: '../tsconfig.json', + compilerOptions: { + paths: { + 'one/app-nav-bar': ['../ui-global-components/modules/one/app-nav-bar/app-nav-bar'], + 'clients/context-library-lwc': ['./modules/clients/context-library-lwc/context-library-lwc'], + 'force/input-phone': ['./modules/force/input-phone/input-phone'], + }, + }, + }); + }); + }); + }); +}); + +function createTextDocumentFromString(content: string): TextDocument { + return TextDocument.create('mockUri', 'typescript', 0, content); +} + +describe('imports', () => { + describe('collectImportsForDocument', () => { + it('should exclude special imports', async () => { + const document = createTextDocumentFromString(` + import {api} from 'lwc'; + import {obj1} from './abc'; + import {obj2} from '../xyz'; + import {obj3} from 'lightning/confirm'; + import {obj4} from '@salesforce/label/x'; + import {obj5} from 'x.html'; + import {obj6} from 'y.css'; + import {obj7} from 'namespace/cmpName'; + `); + const imports = await collectImportsForDocument(document); + expect(imports.size).toEqual(1); + expect(imports.has('namespace/cmpName')); + }); + + it('should work for partial file content', async () => { + const document = createTextDocumentFromString(` + import from + `); + const imports = await collectImportsForDocument(document); + expect(imports.size).toEqual(0); + }); + + it('dynamic imports', async () => { + const document = createTextDocumentFromString(` + const { + default: myDefault, + foo, + bar, + } = await import("force/wireUtils"); + `); + const imports = await collectImportsForDocument(document); + expect(imports.size).toEqual(1); + expect(imports.has('force/wireUtils')); + }); }); }); diff --git a/packages/lwc-language-server/src/lwc-server.ts b/packages/lwc-language-server/src/lwc-server.ts index 07fabe44..82696d04 100644 --- a/packages/lwc-language-server/src/lwc-server.ts +++ b/packages/lwc-language-server/src/lwc-server.ts @@ -274,7 +274,7 @@ export default class Server { } } else if (await this.context.isLWCTypeScript(document)) { // update tsconfig.json file paths when a TS file is saved - this.tsconfigPathIndexer.updateTSConfigFileForDocument(document); + await this.tsconfigPathIndexer.updateTSConfigFileForDocument(document); } } diff --git a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts index 3cae47a3..42fecb89 100644 --- a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts +++ b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts @@ -97,8 +97,7 @@ export default class TSConfigPathIndexer { this.componentEntries.forEach(entry => { this.addNewPathMapping(entry); }); - // update each project under the workspaceRoots - + // update each project under the workspaceRoots that has a tsconfig.json for (const workspaceRoot of this.coreModulesWithTSConfig) { await this.updateTSConfigPaths(workspaceRoot); } @@ -129,7 +128,7 @@ export default class TSConfigPathIndexer { } } const tsconfigFile = path.join(projectRoot, 'tsconfig.json'); - this.updateTSConfigFile(tsconfigFile, mappings, false); + await this.updateTSConfigFile(tsconfigFile, mappings, false); } /** diff --git a/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.ts b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.ts index 63a0993c..1faa936d 100644 --- a/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.ts +++ b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.ts @@ -1,6 +1,7 @@ import { LightningElement, api } from "lwc"; -import { contextLibraryLWC } from 'clients-context-library-lwc'; +import { contextLibraryLWC } from "clients-context-library-lwc"; +import AppNavBar from "one/app-nav-bar"; export default class InputPhone extends LightningElement { - @api value; + @api value; } From e5a0b7c1d3f49288f63ada2ca5a7ceeb327d1d53 Mon Sep 17 00:00:00 2001 From: Rui Qiu Date: Mon, 12 Feb 2024 10:29:45 -0800 Subject: [PATCH 08/13] test: add more test --- .../src/__tests__/typescript.test.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/lwc-language-server/src/__tests__/typescript.test.ts b/packages/lwc-language-server/src/__tests__/typescript.test.ts index 681caf72..6685e280 100644 --- a/packages/lwc-language-server/src/__tests__/typescript.test.ts +++ b/packages/lwc-language-server/src/__tests__/typescript.test.ts @@ -44,7 +44,7 @@ afterEach(() => { restoreTSConfigFiles(); }); -describe('ComponentIndexer', () => { +describe('TSConfigPathIndexer', () => { describe('new', () => { it('initializes with the root of a core root dir', () => { const expectedPath: string = path.resolve('../../test-workspaces/core-like-workspace/coreTS/core'); @@ -99,6 +99,32 @@ describe('ComponentIndexer', () => { }, }); }); + + it('removes paths mapping for deleted module on core', async () => { + const oldTSConfig = { + extends: '../tsconfig.json', + compilerOptions: { + paths: { + 'force/deleted': './modules/force/deleted/deleted', + }, + }, + }; + fs.writeJSONSync(tsConfigForce, oldTSConfig, { + spaces: 4, + }); + const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]); + await tsconfigPathIndexer.init(); + const tsConfigForceObj = readTSConfigFile(tsConfigForce); + expect(tsConfigForceObj).toEqual({ + extends: '../tsconfig.json', + compilerOptions: { + paths: { + 'clients/context-library-lwc': ['./modules/clients/context-library-lwc/context-library-lwc'], + 'force/input-phone': ['./modules/force/input-phone/input-phone'], + }, + }, + }); + }); }); describe('updateTSConfigFileForDocument', () => { From 09deeadc526ac2fcac77348b4f7a572c36a965bc Mon Sep 17 00:00:00 2001 From: Rui Qiu Date: Mon, 12 Feb 2024 11:08:21 -0800 Subject: [PATCH 09/13] fix: minor fix on comments --- .../src/typescript/tsconfig-path-indexer.ts | 16 ++++++++-------- .../modules/force/input-phone/input-phone.ts | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts index 42fecb89..af519623 100644 --- a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts +++ b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts @@ -29,16 +29,16 @@ class TSConfigPathItem { } /** - * An indexer that stores the TypeScript path mapping info on Core workspace. + * An indexer that stores the TypeScript path mapping info for the Core workspace. * - * When using TypeScript for LWCs on core, tsconfig.json file's 'paths' attribute needs to be maintained so that - * TypeScript compiler knows how to resolve imported LWC modules. This class maintains a mapping between LWCs - * and their paths in tsconfig.json and automatically updates tsconfig.json file when initialized and when a LWC - * TypeScript file is changed. + * When using TypeScript for LWCs in the core workspace, the tsconfig.json file's 'paths' attribute needs to be + * maintained to ensure that the TypeScript compiler can resolve imported LWC modules. This class serves to maintain + * a mapping between LWCs and their paths in tsconfig.json, automatically updating the file when initialized and + * whenever a LWC TypeScript file is changed. * - * This includes a map for all TypeScript LWCs on core, the key is a component's full name (namespace/cmpName) - * and the value is an object that contains info on how this component should be mapped to in tsconfig.json - * so that TypeScript can find the component on the file system. + * This mapping encompasses all TypeScript LWCs in the core workspace. Each component's full name (namespace/cmpName) + * serves as the key, with the corresponding value being an object containing information on how the component should be + * mapped in tsconfig.json, thereby enabling TypeScript to locate the component within the file system. */ export default class TSConfigPathIndexer { readonly coreModulesWithTSConfig: string[]; diff --git a/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.ts b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.ts index 1faa936d..22003342 100644 --- a/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.ts +++ b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.ts @@ -1,5 +1,5 @@ import { LightningElement, api } from "lwc"; -import { contextLibraryLWC } from "clients-context-library-lwc"; +import { contextLibraryLWC } from "clients/context-library-lwc"; import AppNavBar from "one/app-nav-bar"; export default class InputPhone extends LightningElement { From 2c81bdb8baa4597e823691cb06e3ee812e7a02ee Mon Sep 17 00:00:00 2001 From: Rui Qiu Date: Wed, 14 Feb 2024 14:20:13 -0800 Subject: [PATCH 10/13] fix: minor comment fix --- packages/lightning-lsp-common/src/__tests__/context.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/lightning-lsp-common/src/__tests__/context.test.ts b/packages/lightning-lsp-common/src/__tests__/context.test.ts index a7f2c842..418fdba0 100644 --- a/packages/lightning-lsp-common/src/__tests__/context.test.ts +++ b/packages/lightning-lsp-common/src/__tests__/context.test.ts @@ -155,13 +155,14 @@ it('isLWCJavascript()', async () => { }); it('isLWCTypeScript()', async () => { + // workspace root project is ui-global-components const context = new WorkspaceContext(CORE_PROJECT_ROOT); // lwc .ts let document = readAsTextDocument(CORE_PROJECT_ROOT + '/modules/one/app-nav-bar/app-nav-bar.ts'); expect(await context.isLWCTypeScript(document)).toBeTruthy(); - // lwc .ts outside namespace root + // lwc .ts outside workspace root in ui-force-components document = readAsTextDocument(CORE_ALL_ROOT + '/ui-force-components/modules/force/input-phone/input-phone.ts'); expect(await context.isLWCTypeScript(document)).toBeFalsy(); From 8eec70e7e036030647f01a8b0bc2abaff74a93ea Mon Sep 17 00:00:00 2001 From: Rui Qiu Date: Thu, 22 Feb 2024 17:00:45 -0800 Subject: [PATCH 11/13] fix: simplify logic and add more tests --- .../src/__tests__/typescript.test.ts | 86 +++++++++++++++++-- .../src/typescript/tsconfig-path-indexer.ts | 44 ++++++++-- .../force/input-phone-js/input-phone-js.js | 6 ++ 3 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone-js/input-phone-js.js diff --git a/packages/lwc-language-server/src/__tests__/typescript.test.ts b/packages/lwc-language-server/src/__tests__/typescript.test.ts index 6685e280..c92e7945 100644 --- a/packages/lwc-language-server/src/__tests__/typescript.test.ts +++ b/packages/lwc-language-server/src/__tests__/typescript.test.ts @@ -5,6 +5,7 @@ import { readAsTextDocument } from './test-utils'; import TSConfigPathIndexer from '../typescript/tsconfig-path-indexer'; import { collectImportsForDocument } from '../typescript/imports'; import { TextDocument } from 'vscode-languageserver-textdocument'; +import { file } from 'babel-types'; const { WorkspaceType } = shared; const TEST_WORKSPACE_PARENT_DIR = path.resolve('../..'); @@ -35,6 +36,10 @@ function restoreTSConfigFiles(): void { } } +function createTextDocumentFromString(content: string, uri?: string): TextDocument { + return TextDocument.create(uri ? uri : 'mockUri', 'typescript', 0, content); +} + beforeEach(async () => { restoreTSConfigFiles(); }); @@ -105,7 +110,61 @@ describe('TSConfigPathIndexer', () => { extends: '../tsconfig.json', compilerOptions: { paths: { - 'force/deleted': './modules/force/deleted/deleted', + 'force/deleted': ['./modules/force/deleted/deleted'], + 'one/deleted': ['../ui-global-components/modules/one/deleted/deleted'], + }, + }, + }; + fs.writeJSONSync(tsConfigForce, oldTSConfig, { + spaces: 4, + }); + const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]); + await tsconfigPathIndexer.init(); + const tsConfigForceObj = readTSConfigFile(tsConfigForce); + expect(tsConfigForceObj).toEqual({ + extends: '../tsconfig.json', + compilerOptions: { + paths: { + 'clients/context-library-lwc': ['./modules/clients/context-library-lwc/context-library-lwc'], + 'force/input-phone': ['./modules/force/input-phone/input-phone'], + }, + }, + }); + }); + + it('keep existing path mapping for any js cmp', async () => { + const oldTSConfig = { + extends: '../tsconfig.json', + compilerOptions: { + paths: { + 'force/input-phone-js': ['./modules/force/input-phone-js/input-phone-js'], + }, + }, + }; + fs.writeJSONSync(tsConfigForce, oldTSConfig, { + spaces: 4, + }); + const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]); + await tsconfigPathIndexer.init(); + const tsConfigForceObj = readTSConfigFile(tsConfigForce); + expect(tsConfigForceObj).toEqual({ + extends: '../tsconfig.json', + compilerOptions: { + paths: { + 'clients/context-library-lwc': ['./modules/clients/context-library-lwc/context-library-lwc'], + 'force/input-phone': ['./modules/force/input-phone/input-phone'], + 'force/input-phone-js': ['./modules/force/input-phone-js/input-phone-js'], + }, + }, + }); + }); + + it('update existing path mapping for cross-namespace cmp', async () => { + const oldTSConfig = { + extends: '../tsconfig.json', + compilerOptions: { + paths: { + 'one/app-nav-bar': ['../ui-global-components/modules/one/deletedOldPath/deletedOldPath'], }, }, }; @@ -121,6 +180,7 @@ describe('TSConfigPathIndexer', () => { paths: { 'clients/context-library-lwc': ['./modules/clients/context-library-lwc/context-library-lwc'], 'force/input-phone': ['./modules/force/input-phone/input-phone'], + 'one/app-nav-bar': ['../ui-global-components/modules/one/app-nav-bar/app-nav-bar'], }, }, }); @@ -155,14 +215,30 @@ describe('TSConfigPathIndexer', () => { }, }); }); + + it('do not update tsconfig for import that is not found', async () => { + const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]); + await tsconfigPathIndexer.init(); + const fileContent = ` + import { util } from 'ns/notFound'; + `; + const filePath = path.join(CORE_ROOT, 'ui-force-components', 'modules', 'force', 'input-phone', 'input-phone.ts'); + await tsconfigPathIndexer.updateTSConfigFileForDocument(createTextDocumentFromString(fileContent, filePath)); + const tsConfigForceObj = readTSConfigFile(tsConfigForce); + expect(tsConfigForceObj).toEqual({ + extends: '../tsconfig.json', + compilerOptions: { + paths: { + 'clients/context-library-lwc': ['./modules/clients/context-library-lwc/context-library-lwc'], + 'force/input-phone': ['./modules/force/input-phone/input-phone'], + }, + }, + }); + }); }); }); }); -function createTextDocumentFromString(content: string): TextDocument { - return TextDocument.create('mockUri', 'typescript', 0, content); -} - describe('imports', () => { describe('collectImportsForDocument', () => { it('should exclude special imports', async () => { diff --git a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts index af519623..eda96dbc 100644 --- a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts +++ b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts @@ -128,7 +128,7 @@ export default class TSConfigPathIndexer { } } const tsconfigFile = path.join(projectRoot, 'tsconfig.json'); - await this.updateTSConfigFile(tsconfigFile, mappings, false); + await this.updateTSConfigFile(tsconfigFile, mappings); } /** @@ -155,16 +155,15 @@ export default class TSConfigPathIndexer { const tsconfigFile = path.join(projectRoot, 'tsconfig.json'); const moduleName = path.basename(projectRoot); const mappings = this.getTSMappingsForModule(moduleName); - await this.updateTSConfigFile(tsconfigFile, mappings, true); + await this.updateTSConfigFile(tsconfigFile, mappings); } /** * Update tsconfig.json file with updated mapping. * @param tsconfigFile target tsconfig.json file path * @param mapping updated map that contains path info to update - * @param isReIndex true if the deleted existing paths should be removed */ - private async updateTSConfigFile(tsconfigFile: string, mapping: Map, isReIndex: boolean): Promise { + private async updateTSConfigFile(tsconfigFile: string, mapping: Map): Promise { if (!fs.pathExistsSync(tsconfigFile)) { return; // file does not exist, no-op } @@ -180,11 +179,24 @@ export default class TSConfigPathIndexer { const existingPaths = tsconfig.compilerOptions.paths; const updatedPaths = { ...existingPaths }; let updated = false; - // remove the existing paths that are not in the updated mapping - if (isReIndex) { - const projectRoot = path.join(tsconfigFile, '..'); - for (const key in existingPaths) { - if (!formattedMapping.has(key) && this.moduleContainsNS(projectRoot, key.substring(0, key.indexOf('/')))) { + const projectRoot = path.join(tsconfigFile, '..'); + for (const key in existingPaths) { + if (!formattedMapping.has(key)) { + const existingPath = path.join(projectRoot, existingPaths[key][0]); + if (this.isExistingPathMappingValid(existingPath)) { + // existing path mapping still exists + continue; + } + const tsPath = this.pathMapping.get(key)?.tsPath; + if (tsPath) { + // update path mapping when found a new path + const relativeTSPath = this.getRelativeTSPath(tsPath, false); + if (relativeTSPath !== existingPaths[key][0]) { + updated = true; + updatedPaths[key] = [relativeTSPath]; + } + } else { + // remove the existing paths that are deleted updated = true; delete updatedPaths[key]; } @@ -216,6 +228,20 @@ export default class TSConfigPathIndexer { } } + /** + * @param mappedPath An existing mapped path as a file path + * @returns true only if this path is still valid + */ + private isExistingPathMappingValid(mappedPath: string): boolean { + const exts = ['.ts', '.d.ts', '.js']; + for (const ext of exts) { + if (fs.pathExistsSync(mappedPath + ext)) { + return true; + } + } + return false; + } + /** * Get all the path mapping info for a given module name, e.g., 'ui-force-components'. * @param moduleName a target module's name diff --git a/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone-js/input-phone-js.js b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone-js/input-phone-js.js new file mode 100644 index 00000000..6c52d2d2 --- /dev/null +++ b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone-js/input-phone-js.js @@ -0,0 +1,6 @@ +import { LightningElement, api } from "lwc"; +import { contextLibraryLWC } from "clients/context-library-lwc"; + +export default class InputPhoneJS extends LightningElement { + @api value; +} From bdd421828c1b6e7f0132d968659c21ec546a4eef Mon Sep 17 00:00:00 2001 From: Rui Qiu Date: Mon, 26 Feb 2024 12:14:22 -0800 Subject: [PATCH 12/13] fix: attempt to fix tests on Windows --- .../src/__tests__/typescript.test.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/lwc-language-server/src/__tests__/typescript.test.ts b/packages/lwc-language-server/src/__tests__/typescript.test.ts index c92e7945..f1e37345 100644 --- a/packages/lwc-language-server/src/__tests__/typescript.test.ts +++ b/packages/lwc-language-server/src/__tests__/typescript.test.ts @@ -5,14 +5,13 @@ import { readAsTextDocument } from './test-utils'; import TSConfigPathIndexer from '../typescript/tsconfig-path-indexer'; import { collectImportsForDocument } from '../typescript/imports'; import { TextDocument } from 'vscode-languageserver-textdocument'; -import { file } from 'babel-types'; const { WorkspaceType } = shared; const TEST_WORKSPACE_PARENT_DIR = path.resolve('../..'); -const CORE_ROOT = path.join(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'core-like-workspace', 'coreTS', 'core'); +const CORE_ROOT = path.resolve(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'core-like-workspace', 'coreTS', 'core'); -const tsConfigForce = path.join(CORE_ROOT, 'ui-force-components', 'tsconfig.json'); -const tsConfigGlobal = path.join(CORE_ROOT, 'ui-global-components', 'tsconfig.json'); +const tsConfigForce = path.resolve(CORE_ROOT, 'ui-force-components', 'tsconfig.json'); +const tsConfigGlobal = path.resolve(CORE_ROOT, 'ui-global-components', 'tsconfig.json'); function readTSConfigFile(tsconfigPath: string): object { if (!fs.pathExistsSync(tsconfigPath)) { @@ -55,17 +54,17 @@ describe('TSConfigPathIndexer', () => { const expectedPath: string = path.resolve('../../test-workspaces/core-like-workspace/coreTS/core'); const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]); expect(tsconfigPathIndexer.coreModulesWithTSConfig.length).toEqual(2); - expect(tsconfigPathIndexer.coreModulesWithTSConfig[0]).toEqual(path.join(expectedPath, 'ui-force-components')); - expect(tsconfigPathIndexer.coreModulesWithTSConfig[1]).toEqual(path.join(expectedPath, 'ui-global-components')); + expect(tsconfigPathIndexer.coreModulesWithTSConfig[0]).toEqual(path.resolve(expectedPath, 'ui-force-components')); + expect(tsconfigPathIndexer.coreModulesWithTSConfig[1]).toEqual(path.resolve(expectedPath, 'ui-global-components')); expect(tsconfigPathIndexer.workspaceType).toEqual(WorkspaceType.CORE_ALL); expect(tsconfigPathIndexer.coreRoot).toEqual(expectedPath); }); it('initializes with the root of a core project dir', () => { const expectedPath: string = path.resolve('../../test-workspaces/core-like-workspace/coreTS/core'); - const tsconfigPathIndexer = new TSConfigPathIndexer([path.join(CORE_ROOT, 'ui-force-components')]); + const tsconfigPathIndexer = new TSConfigPathIndexer([path.resolve(CORE_ROOT, 'ui-force-components')]); expect(tsconfigPathIndexer.coreModulesWithTSConfig.length).toEqual(1); - expect(tsconfigPathIndexer.coreModulesWithTSConfig[0]).toEqual(path.join(expectedPath, 'ui-force-components')); + expect(tsconfigPathIndexer.coreModulesWithTSConfig[0]).toEqual(path.resolve(expectedPath, 'ui-force-components')); expect(tsconfigPathIndexer.workspaceType).toEqual(WorkspaceType.CORE_PARTIAL); expect(tsconfigPathIndexer.coreRoot).toEqual(expectedPath); }); @@ -74,7 +73,7 @@ describe('TSConfigPathIndexer', () => { describe('instance methods', () => { describe('init', () => { it('no-op on sfdx workspace root', async () => { - const tsconfigPathIndexer = new TSConfigPathIndexer([path.join(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'sfdx-workspace')]); + const tsconfigPathIndexer = new TSConfigPathIndexer([path.resolve(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'sfdx-workspace')]); const spy = jest.spyOn(tsconfigPathIndexer, 'componentEntries', 'get'); await tsconfigPathIndexer.init(); expect(spy).not.toHaveBeenCalled(); @@ -189,9 +188,9 @@ describe('TSConfigPathIndexer', () => { describe('updateTSConfigFileForDocument', () => { it('no-op on sfdx workspace root', async () => { - const tsconfigPathIndexer = new TSConfigPathIndexer([path.join(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'sfdx-workspace')]); + const tsconfigPathIndexer = new TSConfigPathIndexer([path.resolve(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'sfdx-workspace')]); await tsconfigPathIndexer.init(); - const filePath = path.join(CORE_ROOT, 'ui-force-components', 'modules', 'force', 'input-phone', 'input-phone.ts'); + const filePath = path.resolve(CORE_ROOT, 'ui-force-components', 'modules', 'force', 'input-phone', 'input-phone.ts'); const spy = jest.spyOn(tsconfigPathIndexer as any, 'addNewPathMapping'); await tsconfigPathIndexer.updateTSConfigFileForDocument(readAsTextDocument(filePath)); expect(spy).not.toHaveBeenCalled(); @@ -201,7 +200,7 @@ describe('TSConfigPathIndexer', () => { it('updates tsconfig for all imports', async () => { const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]); await tsconfigPathIndexer.init(); - const filePath = path.join(CORE_ROOT, 'ui-force-components', 'modules', 'force', 'input-phone', 'input-phone.ts'); + const filePath = path.resolve(CORE_ROOT, 'ui-force-components', 'modules', 'force', 'input-phone', 'input-phone.ts'); await tsconfigPathIndexer.updateTSConfigFileForDocument(readAsTextDocument(filePath)); const tsConfigForceObj = readTSConfigFile(tsConfigForce); expect(tsConfigForceObj).toEqual({ @@ -222,7 +221,7 @@ describe('TSConfigPathIndexer', () => { const fileContent = ` import { util } from 'ns/notFound'; `; - const filePath = path.join(CORE_ROOT, 'ui-force-components', 'modules', 'force', 'input-phone', 'input-phone.ts'); + const filePath = path.resolve(CORE_ROOT, 'ui-force-components', 'modules', 'force', 'input-phone', 'input-phone.ts'); await tsconfigPathIndexer.updateTSConfigFileForDocument(createTextDocumentFromString(fileContent, filePath)); const tsConfigForceObj = readTSConfigFile(tsConfigForce); expect(tsConfigForceObj).toEqual({ From 2fb8f07339c19bedb046765bb5f6f35eb9bb66dc Mon Sep 17 00:00:00 2001 From: Rui Qiu Date: Tue, 27 Feb 2024 09:55:13 -0800 Subject: [PATCH 13/13] fix: fix Windows file path --- .../src/typescript/tsconfig-path-indexer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts index eda96dbc..7be47a18 100644 --- a/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts +++ b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts @@ -111,7 +111,8 @@ export default class TSConfigPathIndexer { if (!this.isOnCore()) { return; // no-op if this is not a Core workspace } - const filePath = URI.file(document.uri).fsPath; + // replace Windows file separator + const filePath = path.normalize(URI.file(document.uri).fsPath).replace(/\\/g, '/'); this.addNewPathMapping(filePath); const moduleName = this.getModuleName(filePath); const projectRoot = this.getProjectRoot(filePath);