Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: automatically updating tsconfig.json's paths mapping for core workspaces. #587

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
23 changes: 22 additions & 1 deletion packages/lightning-lsp-common/src/__tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand why this is outside of the namespace root.

Copy link
Contributor Author

@rui-rayqiu rui-rayqiu Feb 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to test that if a ts file that is outside of the VSCode workspace is modified, we do not update tsconfig.json. Here the context workspace root is CORE_PROJECT_ROOT which is ui-global-components so if a file in ui-force-components is modified the return value of isLWCTypeScript should be false. I'll add an inline comment to explain this.

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';
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/lightning-lsp-common/src/__tests__/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
13 changes: 11 additions & 2 deletions packages/lightning-lsp-common/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -257,6 +258,14 @@ export class WorkspaceContext {
return document.languageId === 'javascript' && (await this.isInsideModulesRoots(document));
}

public async isLWCTypeScript(document: TextDocument): Promise<boolean> {
return (
(this.type === WorkspaceType.CORE_ALL || this.type === WorkspaceType.CORE_PARTIAL) &&
document.languageId === 'typescript' &&
(await this.isInsideModulesRoots(document))
);
}

public async isInsideAuraRoots(document: TextDocument): Promise<boolean> {
const file = utils.toResolvedPath(document.uri);
for (const ws of this.workspaceRoots) {
Expand Down
2 changes: 2 additions & 0 deletions packages/lwc-language-server/src/__tests__/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
280 changes: 280 additions & 0 deletions packages/lwc-language-server/src/__tests__/typescript.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Copy link

@ravijayaramappa ravijayaramappa Feb 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is "lightning/confirm" excluded because it is an off-core component? Saw your comment below about special cases.

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'));
});
});
});
Loading
Loading