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: introduce a new command "bit write-tsconfig" to write tsconfig files in the components directories #6506

Merged
merged 10 commits into from
Oct 4, 2022
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { CompilerMain } from '@teambit/compiler';
import { CompilerAspect } from '@teambit/compiler';

const babelConfig = require('./babel.config.json');
const tsconfig = require('./tsconfig.json');
const tsconfig = require('./ts/tsconfig.json');

export class MultipleCompilersEnv {
constructor(private react: ReactMain) {}
Expand Down
44 changes: 44 additions & 0 deletions e2e/typescript/write-tsconfig.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import chai, { expect } from 'chai';
import path from 'path';
import Helper from '../../src/e2e-helper/e2e-helper';

chai.use(require('chai-fs'));

describe('write-tsconfig command', function () {
this.timeout(0);
let helper: Helper;
before(() => {
helper = new Helper();
});
after(() => {
helper.scopeHelper.destroy();
});
describe('multiple components, most using one env', () => {
before(() => {
helper.scopeHelper.setNewLocalAndRemoteScopesHarmony();
helper.bitJsonc.setupDefault();
helper.fixtures.populateComponentsTS();
helper.command.setEnv('comp3', 'teambit.react/react');
helper.command.writeTsconfig();
});
it('should generate tsconfig.json file in the root-dir, and in the specific comp with the special env', () => {
expect(path.join(helper.scopes.localPath, 'tsconfig.json')).to.be.a.file();
expect(path.join(helper.scopes.localPath, 'comp3', 'tsconfig.json')).to.be.a.file();
});
it('bit show should not show the tsconfig.json as part of the component', () => {
const files = helper.command.getComponentFiles('comp3');
expect(files).to.not.include('tsconfig.json');
});
});
describe('adding tsconfig.json manually in an inner directory', () => {
before(() => {
helper.scopeHelper.reInitLocalScopeHarmony();
helper.fixtures.populateComponents(1, false);
helper.fs.outputFile('comp1/inner/tsconfig.json');
});
it('should not be ignored', () => {
const files = helper.command.getComponentFiles('comp1');
expect(files).to.include('inner/tsconfig.json');
});
});
});
62 changes: 62 additions & 0 deletions scopes/typescript/typescript/cmds/write-tsconfig.cmd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import path from 'path';
import { omit } from 'lodash';
import { Command, CommandOptions } from '@teambit/cli';
import chalk from 'chalk';
import { TypescriptMain } from '../typescript.main.runtime';

export default class WriteTsconfigCmd implements Command {
name = 'write-tsconfig';
description = 'EXPERIMENTAL. write tsconfig.json files in the component directories';
alias = '';
group = 'development';
options = [
['c', 'clean', 'delete tsconfig files from the workspace. highly recommended to run it with "--dry-run" first'],
['s', 'silent', 'do not prompt for confirmation'],
['', 'no-dedupe', "write tsconfig.json inside each one of the component's dir, avoid deduping"],
['', 'dry-run', 'show the paths that tsconfig will be written per env'],
['', 'dry-run-with-tsconfig', 'show the tsconfig.json content and the paths it will be written per env'],
] as CommandOptions;

constructor(private tsMain: TypescriptMain) {}

async report(
_args,
{
clean,
silent,
dryRun,
noDedupe,
dryRunWithTsconfig,
}: { dryRun?: boolean; noDedupe?: boolean; dryRunWithTsconfig?: boolean; clean?: boolean; silent?: boolean }
) {
const { writeResults, cleanResults } = await this.tsMain.writeTsconfigJson({
clean,
dedupe: !noDedupe,
dryRun: dryRun || dryRunWithTsconfig,
dryRunWithTsconfig,
silent,
});
const cleanResultsOutput = cleanResults
? `${chalk.green(`the following paths were deleted`)}\n${cleanResults.join('\n')}\n\n`
: '';
if (dryRunWithTsconfig) {
return JSON.stringify(writeResults, undefined, 2);
}
if (dryRun) {
const withoutTsconfig = writeResults.map((s) => omit(s, ['tsconfig']));
return JSON.stringify(withoutTsconfig, undefined, 2);
}
const totalFiles = writeResults.map((r) => r.paths.length).reduce((acc, current) => acc + current);
const writeTitle = chalk.green(`${totalFiles} files have been written successfully`);
const writeOutput = writeResults
.map((result) => {
const paths = result.paths
.map((p) => path.join(p, 'tsconfig.json'))
.map((str) => ` ${str}`)
.join('\n');
return `The following paths were written according to env ${chalk.bold(result.envId)}\n${paths}`;
})
.join('\n\n');
return `${cleanResultsOutput}${writeTitle}\n${writeOutput}`;
}
}
34 changes: 34 additions & 0 deletions scopes/typescript/typescript/dedupe-path.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { dedupePaths } from './tsconfig-writer';

describe('dedupePath', () => {
it('should return the root-dir if there is only one env involved', () => {
const input = [{ id: 'env1', paths: ['p1/e1, p2'] }];
const output = dedupePaths(input);
expect(output).toEqual([{ id: 'env1', paths: ['.'] }]);
});
it('should set the env with the most components', () => {
const input = [
{ id: 'env1', paths: ['p1/e1', 'p1/e2'] },
{ id: 'env2', paths: ['p1/e3'] },
];
const output = dedupePaths(input);
// @ts-ignore
expect(output).toEqual(
// @ts-ignore
expect.arrayContaining([
{ id: 'env1', paths: ['.'] },
{ id: 'env2', paths: ['p1/e3'] },
])
);
});
it('should not set any env to a shared dir if it no env has max components', () => {
const input = [
{ id: 'env1', paths: ['p1/e1'] },
{ id: 'env2', paths: ['p1/e2'] },
];
const output = dedupePaths(input);
expect(output.length).toEqual(2);
// @ts-ignore
expect(output).toEqual(expect.arrayContaining(input));
});
});
217 changes: 217 additions & 0 deletions scopes/typescript/typescript/tsconfig-writer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import fs from 'fs-extra';
import path from 'path';
import yesno from 'yesno';
import { PathLinuxRelative } from '@teambit/legacy/dist/utils/path';
import { compact, invertBy, uniq } from 'lodash';
import { PromptCanceled } from '@teambit/legacy/dist/prompts/exceptions';
import { Environment, ExecutionContext } from '@teambit/envs';
import { Workspace } from '@teambit/workspace';
import { Logger } from '@teambit/logger';
import chalk from 'chalk';
import { TsconfigWriterOptions } from './typescript.main.runtime';

export type TsconfigPathsPerEnv = { envId: string; tsconfig: Record<string, any>; paths: string[] };

type PathsPerEnv = { env: Environment; id: string; paths: string[] };

export class TsconfigWriter {
constructor(private workspace: Workspace, private logger: Logger) {}

async write(
envsExecutionContext: ExecutionContext[],
options: TsconfigWriterOptions
): Promise<TsconfigPathsPerEnv[]> {
const pathsPerEnvs = this.getPathsPerEnv(envsExecutionContext, options);
const tsconfigPathsPerEnv = pathsPerEnvs.map((pathsPerEnv) => ({
envId: pathsPerEnv.id,
tsconfig: pathsPerEnv.env.getTsConfig(),
paths: pathsPerEnv.paths,
}));
if (options.dryRun) return tsconfigPathsPerEnv;
if (!options.silent) await this.promptForWriting(tsconfigPathsPerEnv.map((p) => p.paths).flat());
await this.writeFiles(tsconfigPathsPerEnv);
return tsconfigPathsPerEnv;
}

async clean(envsExecutionContext: ExecutionContext[], { dryRun, silent }: TsconfigWriterOptions): Promise<string[]> {
const pathsPerEnvs = this.getPathsPerEnv(envsExecutionContext, { dedupe: false });
const componentPaths = pathsPerEnvs.map((p) => p.paths).flat();
const allPossibleDirs = getAllPossibleDirsFromPaths(componentPaths);
const dirsWithTsconfig = await filterDirsWithTsconfigFile(allPossibleDirs);
const tsconfigFiles = dirsWithTsconfig.map((dir) => path.join(dir, 'tsconfig.json'));
if (dryRun) return tsconfigFiles;
if (!dirsWithTsconfig.length) return [];
if (!silent) await this.promptForCleaning(tsconfigFiles);
await this.deleteFiles(tsconfigFiles);
return tsconfigFiles;
}

private async promptForWriting(dirs: string[]) {
this.logger.clearStatusLine();
const tsconfigFiles = dirs.map((dir) => path.join(dir, 'tsconfig.json'));
const ok = await yesno({
question: `${chalk.underline('The following paths will be written:')}
${tsconfigFiles.join('\n')}
${chalk.bold('Do you want to continue? [yes(y)/no(n)]')}`,
});
if (!ok) {
throw new PromptCanceled();
}
}

private async promptForCleaning(tsconfigFiles: string[]) {
this.logger.clearStatusLine();
const ok = await yesno({
question: `${chalk.underline('The following paths will be deleted:')}
${tsconfigFiles.join('\n')}
${chalk.bold('Do you want to continue? [yes(y)/no(n)]')}`,
});
if (!ok) {
throw new PromptCanceled();
}
}

private async deleteFiles(tsconfigFiles: string[]) {
await Promise.all(tsconfigFiles.map((f) => fs.remove(f)));
}

private async writeFiles(tsconfigPathsPerEnvs: TsconfigPathsPerEnv[]) {
await Promise.all(
tsconfigPathsPerEnvs.map((pathsPerEnv) => {
return Promise.all(
pathsPerEnv.paths.map((p) => fs.writeJSON(path.join(p, 'tsconfig.json'), pathsPerEnv.tsconfig, { spaces: 2 }))
);
})
);
}

private getPathsPerEnv(envsExecutionContext: ExecutionContext[], { dedupe }: TsconfigWriterOptions): PathsPerEnv[] {
const pathsPerEnvs = envsExecutionContext.map((envExecution) => {
return {
id: envExecution.id,
env: envExecution.env,
paths: envExecution.components.map((c) => this.workspace.componentDir(c.id, undefined, { relative: true })),
};
});
if (!dedupe) {
return pathsPerEnvs;
}

const pathsPerEnvId = pathsPerEnvs.map((p) => ({ id: p.id, paths: p.paths }));
const envsPerDedupedPaths = dedupePaths(pathsPerEnvId);
const dedupedPathsPerEnvs: PathsPerEnv[] = envsPerDedupedPaths.map((envWithDedupePaths) => {
const found = pathsPerEnvs.find((p) => p.id === envWithDedupePaths.id);
if (!found) throw new Error(`dedupedPathsPerEnvs, unable to find ${envWithDedupePaths.id}`);
return {
env: found.env,
id: found.id,
paths: envWithDedupePaths.paths,
};
});

return dedupedPathsPerEnvs;
}
}

type PathsPerEnvId = { id: string; paths: string[] };

async function filterDirsWithTsconfigFile(dirs: string[]): Promise<string[]> {
const dirsWithTsconfig = await Promise.all(
dirs.map(async (dir) => {
const hasTsconfig = await fs.pathExists(path.join(dir, 'tsconfig.json'));
return hasTsconfig ? dir : undefined;
})
);
return compact(dirsWithTsconfig);
}

function getAllPossibleDirsFromPaths(paths: PathLinuxRelative[]): PathLinuxRelative[] {
const dirs = paths.map((p) => getAllParentsDirOfPath(p)).flat();
dirs.push('.'); // add the root dir
return uniq(dirs);
}

function getAllParentsDirOfPath(p: PathLinuxRelative): PathLinuxRelative[] {
const all: string[] = [];
let current = p;
while (current !== '.') {
all.push(current);
current = path.dirname(current);
}
return all;
}

/**
* easier to understand by an example:
* input:
* [
* { id: react, paths: [ui/button, ui/form] },
* { id: aspect, paths: [p/a1, p/a2] },
* { id: node, paths: [p/n1] },
* ]
*
* output:
* [
* { id: react, paths: [ui] },
* { id: aspect, paths: [p] },
* { id: node, paths: [p/n1] },
* ]
*
* the goal is to minimize the amount of files to write per env if possible.
* when multiple components of the same env share a root-dir, then, it's enough to write a file in that shared dir.
* if in a shared-dir, some components using env1 and some env2, it finds the env that has the max number of
* components, this env will be optimized. other components, will have the files written inside their dirs.
*/
export function dedupePaths(pathsPerEnvId: PathsPerEnvId[]): PathsPerEnvId[] {
const rootDir = '.';
const individualPathPerEnvId: { [path: string]: string } = pathsPerEnvId.reduce((acc, current) => {
current.paths.forEach((p) => {
acc[p] = current.id;
});
return acc;
}, {});
const allPaths = Object.keys(individualPathPerEnvId);
const allPossibleDirs = getAllPossibleDirsFromPaths(allPaths);

const allPathsPerEnvId: { [path: string]: string | null } = {}; // null when parent-dir has same amount of comps per env.

const calculateBestEnvForDir = (dir: string) => {
if (individualPathPerEnvId[dir]) {
// it's the component dir, so it's the best env
allPathsPerEnvId[dir] = individualPathPerEnvId[dir];
return;
}
const allPathsShareSameDir = dir === rootDir ? allPaths : allPaths.filter((p) => p.startsWith(`${dir}/`));
const countPerEnv: { [env: string]: number } = {};
allPathsShareSameDir.forEach((p) => {
const envIdStr = individualPathPerEnvId[p];
if (countPerEnv[envIdStr]) countPerEnv[envIdStr] += 1;
else countPerEnv[envIdStr] = 1;
});
const max = Math.max(...Object.values(countPerEnv));
const envWithMax = Object.keys(countPerEnv).filter((env) => countPerEnv[env] === max);
if (!envWithMax.length) throw new Error(`must be at least one env related to path "${dir}"`);
if (envWithMax.length > 1) allPathsPerEnvId[dir] = null;
else allPathsPerEnvId[dir] = envWithMax[0];
};

allPossibleDirs.forEach((dirPath) => {
calculateBestEnvForDir(dirPath);
});

// this is the actual deduping. if found a shorter path with the same env, then no need for this path.
// in other words, return only the paths that their parent is null or has a different env.
const dedupedPathsPerEnvId = Object.keys(allPathsPerEnvId).reduce((acc, current) => {
if (allPathsPerEnvId[current] && allPathsPerEnvId[path.dirname(current)] !== allPathsPerEnvId[current]) {
acc[current] = allPathsPerEnvId[current];
}

return acc;
}, {});
// rootDir parent is always rootDir, so leave it as is.
if (allPathsPerEnvId[rootDir]) dedupedPathsPerEnvId[rootDir] = allPathsPerEnvId[rootDir];

const envIdPerDedupedPaths = invertBy(dedupedPathsPerEnvId);

return Object.keys(envIdPerDedupedPaths).map((envIdStr) => ({ id: envIdStr, paths: envIdPerDedupedPaths[envIdStr] }));
}
Loading