diff --git a/packages/lightning-lsp-common/src/__tests__/context.test.ts b/packages/lightning-lsp-common/src/__tests__/context.test.ts index 8aff4932..418fdba0 100644 --- a/packages/lightning-lsp-common/src/__tests__/context.test.ts +++ b/packages/lightning-lsp-common/src/__tests__/context.test.ts @@ -154,6 +154,27 @@ it('isLWCJavascript()', async () => { expect(await context.isLWCJavascript(document)).toBeTruthy(); }); +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 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(); + + // 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'; @@ -298,7 +319,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/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/lightning-lsp-common/src/context.ts b/packages/lightning-lsp-common/src/context.ts index 106a41ef..615b9be8 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; } @@ -257,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) { 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 new file mode 100644 index 00000000..f1e37345 --- /dev/null +++ b/packages/lwc-language-server/src/__tests__/typescript.test.ts @@ -0,0 +1,280 @@ +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('../..'); +const CORE_ROOT = path.resolve(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'core-like-workspace', 'coreTS', 'core'); + +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)) { + 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, + }); + } +} + +function createTextDocumentFromString(content: string, uri?: string): TextDocument { + return TextDocument.create(uri ? uri : 'mockUri', 'typescript', 0, content); +} + +beforeEach(async () => { + restoreTSConfigFiles(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + restoreTSConfigFiles(); +}); + +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'); + const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]); + expect(tsconfigPathIndexer.coreModulesWithTSConfig.length).toEqual(2); + 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.resolve(CORE_ROOT, 'ui-force-components')]); + expect(tsconfigPathIndexer.coreModulesWithTSConfig.length).toEqual(1); + expect(tsconfigPathIndexer.coreModulesWithTSConfig[0]).toEqual(path.resolve(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.resolve(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'], + }, + }, + }); + }); + + it('removes paths mapping for deleted module on core', async () => { + const oldTSConfig = { + extends: '../tsconfig.json', + compilerOptions: { + paths: { + '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'], + }, + }, + }; + 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'], + 'one/app-nav-bar': ['../ui-global-components/modules/one/app-nav-bar/app-nav-bar'], + }, + }, + }); + }); + }); + + describe('updateTSConfigFileForDocument', () => { + it('no-op on sfdx workspace root', async () => { + const tsconfigPathIndexer = new TSConfigPathIndexer([path.resolve(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'sfdx-workspace')]); + await tsconfigPathIndexer.init(); + 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(); + expect(tsconfigPathIndexer.coreRoot).toBeUndefined(); + }); + + it('updates tsconfig for all imports', async () => { + const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]); + await tsconfigPathIndexer.init(); + 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({ + 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'], + }, + }, + }); + }); + + 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.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({ + 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('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 3a046b75..82696d04 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 + await 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..7be47a18 --- /dev/null +++ b/packages/lwc-language-server/src/typescript/tsconfig-path-indexer.ts @@ -0,0 +1,348 @@ +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" + readonly tsPath: string; + // actual file path for the ts file + readonly filePath: string; + + constructor(attribute: TSConfigPathItemAttribute) { + this.tsPath = attribute.tsPath; + this.filePath = attribute.filePath; + } +} + +/** + * An indexer that stores the TypeScript path mapping info for the Core workspace. + * + * 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 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[]; + 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.workspaceType = detectWorkspaceType(workspaceRoots); + switch (this.workspaceType) { + case WorkspaceType.CORE_ALL: + 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(workspaceRoots[0], '..'); + this.coreModulesWithTSConfig = this.getCoreModulesWithTSConfig(workspaceRoots); + 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 that has a tsconfig.json + for (const workspaceRoot of this.coreModulesWithTSConfig) { + await 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 + } + // 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); + 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.moduleContainsNS(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'); + await this.updateTSConfigFile(tsconfigFile, mappings); + } + + /** + * @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 async updateTSConfigPaths(projectRoot: string): Promise { + const tsconfigFile = path.join(projectRoot, 'tsconfig.json'); + const moduleName = path.basename(projectRoot); + const mappings = this.getTSMappingsForModule(moduleName); + 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 + */ + private async updateTSConfigFile(tsconfigFile: string, mapping: Map): Promise { + if (!fs.pathExistsSync(tsconfigFile)) { + return; // file does not exist, no-op + } + 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; + const updatedPaths = { ...existingPaths }; + let updated = false; + 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]; + } + } + } + formattedMapping.forEach((value, key) => { + if (!existingPaths[key] || existingPaths[key][0] !== value[0]) { + updated = true; + updatedPaths[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(updatedPaths).sort(); + const sortedPaths: Record = {}; + sortedKeys.forEach(key => { + sortedPaths[key] = updatedPaths[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}`); + } + } + + /** + * @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 + */ + 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 moduleContainsNS(modulePath: string, nsName: string): boolean { + return fs.pathExistsSync(path.join(modulePath, 'modules', nsName)); + } +} 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 { +} 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-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; +} 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..22003342 --- /dev/null +++ b/test-workspaces/core-like-workspace/coreTS/core/ui-force-components/modules/force/input-phone/input-phone.ts @@ -0,0 +1,7 @@ +import { LightningElement, api } from "lwc"; +import { contextLibraryLWC } from "clients/context-library-lwc"; +import AppNavBar from "one/app-nav-bar"; + +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