Skip to content

Commit 001498b

Browse files
authored
feat(utils): add file-system helper (#336)
1 parent 560802a commit 001498b

15 files changed

+305
-171
lines changed

packages/plugin-eslint/src/lib/runner/transform.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { IssueSeverity } from '@code-pushup/models';
44
import {
55
compareIssueSeverity,
66
countOccurrences,
7-
formatCount,
87
objectToEntries,
8+
pluralizeToken,
99
} from '@code-pushup/utils';
1010
import { ruleIdToSlug } from '../meta/hash';
1111
import type { LinterOutput } from './types';
@@ -43,7 +43,7 @@ function toAudit(slug: string, issues: LintIssue[]): AuditOutput {
4343
);
4444
const summaryText = objectToEntries(severityCounts)
4545
.sort((a, b) => -compareIssueSeverity(a[0], b[0]))
46-
.map(([severity, count = 0]) => formatCount(count, severity))
46+
.map(([severity, count = 0]) => pluralizeToken(severity, count))
4747
.join(', ');
4848
return {
4949
slug,

packages/utils/src/index.ts

+30-25
Original file line numberDiff line numberDiff line change
@@ -7,45 +7,50 @@ export {
77
executeProcess,
88
objectToCliArgs,
99
} from './lib/execute-process';
10-
export {
11-
FileResult,
12-
MultipleFileResults,
13-
ensureDirectoryExists,
14-
importEsmModule,
15-
logMultipleFileResults,
16-
pluginWorkDir,
17-
readJsonFile,
18-
readTextFile,
19-
toUnixPath,
20-
} from './lib/file-system';
2110
export { getLatestCommit, git } from './lib/git';
22-
export { logMultipleResults } from './lib/log-results';
23-
export { NEW_LINE } from './lib/md';
2411
export { ProgressBar, getProgressBar } from './lib/progress';
25-
export {
26-
isPromiseFulfilledResult,
27-
isPromiseRejectedResult,
28-
} from './lib/promise-result';
2912
export {
3013
CODE_PUSHUP_DOMAIN,
3114
FOOTER_PREFIX,
3215
README_LINK,
3316
calcDuration,
3417
compareIssueSeverity,
35-
formatBytes,
36-
formatCount,
3718
loadReport,
3819
} from './lib/report';
3920
export { reportToMd } from './lib/report-to-md';
4021
export { reportToStdout } from './lib/report-to-stdout';
4122
export { ScoredReport, scoreReport } from './lib/scoring';
4223
export {
24+
readJsonFile,
25+
readTextFile,
26+
toUnixPath,
27+
ensureDirectoryExists,
28+
FileResult,
29+
MultipleFileResults,
30+
logMultipleFileResults,
31+
importEsmModule,
32+
pluginWorkDir,
33+
crawlFileSystem,
34+
findLineNumberInText,
35+
} from './lib/file-system';
36+
export { verboseUtils } from './lib/verbose-utils';
37+
export {
38+
toArray,
39+
objectToKeys,
40+
objectToEntries,
4341
countOccurrences,
4442
distinct,
45-
objectToEntries,
46-
objectToKeys,
47-
pluralize,
48-
slugify,
49-
toArray,
5043
} from './lib/transformation';
51-
export { verboseUtils } from './lib/verbose-utils';
44+
export {
45+
slugify,
46+
pluralize,
47+
pluralizeToken,
48+
formatBytes,
49+
formatDuration,
50+
} from './lib/formatting';
51+
export { NEW_LINE } from './lib/md';
52+
export { logMultipleResults } from './lib/log-results';
53+
export {
54+
isPromiseFulfilledResult,
55+
isPromiseRejectedResult,
56+
} from './lib/guards';

packages/utils/src/lib/file-system.ts

+41-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { type Options, bundleRequire } from 'bundle-require';
22
import chalk from 'chalk';
3-
import { mkdir, readFile } from 'fs/promises';
3+
import { mkdir, readFile, readdir, stat } from 'fs/promises';
44
import { join } from 'path';
5+
import { formatBytes } from './formatting';
56
import { logMultipleResults } from './log-results';
6-
import { formatBytes } from './report';
77

88
export function toUnixPath(
99
path: string,
@@ -92,3 +92,42 @@ export async function importEsmModule<T = unknown>(
9292
export function pluginWorkDir(slug: string): string {
9393
return join('node_modules', '.code-pushup', slug);
9494
}
95+
96+
export async function crawlFileSystem<T = string>(options: {
97+
directory: string;
98+
pattern?: string | RegExp;
99+
fileTransform?: (filePath: string) => Promise<T> | T;
100+
}): Promise<T[]> {
101+
const {
102+
directory,
103+
pattern,
104+
fileTransform = (filePath: string) => filePath as T,
105+
} = options;
106+
107+
const files = await readdir(directory);
108+
const promises = files.map(async (file): Promise<T | T[]> => {
109+
const filePath = join(directory, file);
110+
const stats = await stat(filePath);
111+
112+
if (stats.isDirectory()) {
113+
return crawlFileSystem({ directory: filePath, pattern, fileTransform });
114+
}
115+
if (stats.isFile() && (!pattern || new RegExp(pattern).test(file))) {
116+
return fileTransform(filePath);
117+
}
118+
return [];
119+
});
120+
121+
const resultsNestedArray = await Promise.all(promises);
122+
return resultsNestedArray.flat() as T[];
123+
}
124+
125+
export function findLineNumberInText(
126+
content: string,
127+
pattern: string,
128+
): number | null {
129+
const lines = content.split(/\r?\n/); // Split lines, handle both Windows and UNIX line endings
130+
131+
const lineNumber = lines.findIndex(line => line.includes(pattern)) + 1; // +1 because line numbers are 1-based
132+
return lineNumber === 0 ? null : lineNumber; // If the package isn't found, return null
133+
}

packages/utils/src/lib/file-system.unit.test.ts

+85
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import { MEMFS_VOLUME } from '@code-pushup/models/testing';
77
import {
88
FileResult,
99
NoExportError,
10+
crawlFileSystem,
1011
ensureDirectoryExists,
12+
findLineNumberInText,
1113
importEsmModule,
1214
logMultipleFileResults,
1315
toUnixPath,
@@ -130,3 +132,86 @@ describe('importEsmModule', () => {
130132
).rejects.toThrow(new NoExportError(filepath));
131133
});
132134
});
135+
136+
describe('crawlFileSystem', () => {
137+
beforeEach(() => {
138+
vol.reset();
139+
vol.fromJSON(
140+
{
141+
['README.md']: '# Markdown',
142+
['src/README.md']: '# Markdown',
143+
['src/index.ts']: 'const var = "markdown";',
144+
},
145+
outputDir,
146+
);
147+
});
148+
149+
it('should list all files in file system', async () => {
150+
await expect(
151+
crawlFileSystem({
152+
directory: outputDir,
153+
}),
154+
).resolves.toEqual([
155+
expect.stringContaining(join('README.md')),
156+
expect.stringContaining(join('README.md')),
157+
expect.stringContaining(join('index.ts')),
158+
]);
159+
});
160+
161+
it('should list files matching a pattern', async () => {
162+
await expect(
163+
crawlFileSystem({
164+
directory: outputDir,
165+
pattern: /\.md$/,
166+
}),
167+
).resolves.toEqual([
168+
expect.stringContaining(join('README.md')),
169+
expect.stringContaining(join('README.md')),
170+
]);
171+
});
172+
173+
it('should apply sync fileTransform function if given', async () => {
174+
await expect(
175+
crawlFileSystem({
176+
directory: outputDir,
177+
pattern: /\.md$/,
178+
fileTransform: () => '42',
179+
}),
180+
).resolves.toEqual([
181+
expect.stringContaining('42'),
182+
expect.stringContaining('42'),
183+
]);
184+
});
185+
186+
it('should apply async fileTransform function if given', async () => {
187+
await expect(
188+
crawlFileSystem({
189+
directory: outputDir,
190+
pattern: /\.md$/,
191+
fileTransform: () => Promise.resolve('42'),
192+
}),
193+
).resolves.toEqual([
194+
expect.stringContaining('42'),
195+
expect.stringContaining('42'),
196+
]);
197+
});
198+
});
199+
200+
describe('findLineNumberInText', () => {
201+
it('should return correct line number', () => {
202+
expect(
203+
findLineNumberInText(
204+
`
205+
1
206+
2 xxx
207+
3
208+
`,
209+
'x',
210+
),
211+
).toBe(3);
212+
});
213+
214+
it('should return null if pattern not in content', () => {
215+
expect(findLineNumberInText(``, 'x')).toBeNull();
216+
});
217+
});

packages/utils/src/lib/formatting.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export function slugify(text: string): string {
2+
return text
3+
.trim()
4+
.toLowerCase()
5+
.replace(/\s+|\//g, '-')
6+
.replace(/[^a-z0-9-]/g, '');
7+
}
8+
9+
export function pluralize(text: string): string {
10+
if (text.endsWith('y')) {
11+
return text.slice(0, -1) + 'ies';
12+
}
13+
if (text.endsWith('s')) {
14+
return `${text}es`;
15+
}
16+
return `${text}s`;
17+
}
18+
19+
export function formatBytes(bytes: number, decimals = 2) {
20+
if (!+bytes) return '0 B';
21+
22+
const k = 1024;
23+
const dm = decimals < 0 ? 0 : decimals;
24+
const sizes = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
25+
26+
const i = Math.floor(Math.log(bytes) / Math.log(k));
27+
28+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
29+
}
30+
31+
export function pluralizeToken(token: string, times: number = 0): string {
32+
return `${times} ${Math.abs(times) === 1 ? token : pluralize(token)}`;
33+
}
34+
35+
export function formatDuration(duration: number): string {
36+
if (duration < 1000) {
37+
return `${duration} ms`;
38+
}
39+
return `${(duration / 1000).toFixed(2)} s`;
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
formatBytes,
4+
formatDuration,
5+
pluralize,
6+
pluralizeToken,
7+
slugify,
8+
} from './formatting';
9+
10+
describe('slugify', () => {
11+
it.each([
12+
['Largest Contentful Paint', 'largest-contentful-paint'],
13+
['cumulative-layout-shift', 'cumulative-layout-shift'],
14+
['max-lines-200', 'max-lines-200'],
15+
['rxjs/finnish', 'rxjs-finnish'],
16+
['@typescript-eslint/no-explicit-any', 'typescript-eslint-no-explicit-any'],
17+
['Code PushUp ', 'code-pushup'],
18+
])('should transform "%s" to valid slug "%s"', (text, slug) => {
19+
expect(slugify(text)).toBe(slug);
20+
});
21+
});
22+
23+
describe('pluralize', () => {
24+
it.each([
25+
['warning', 'warnings'],
26+
['error', 'errors'],
27+
['category', 'categories'],
28+
['status', 'statuses'],
29+
])('should pluralize "%s" as "%s"', (singular, plural) => {
30+
expect(pluralize(singular)).toBe(plural);
31+
});
32+
});
33+
34+
describe('formatBytes', () => {
35+
it.each([
36+
[0, '0 B'],
37+
[1000, '1000 B'],
38+
[10000, '9.77 kB'],
39+
[10000000, '9.54 MB'],
40+
[10000000000, '9.31 GB'],
41+
[10000000000000, '9.09 TB'],
42+
[10000000000000000, '8.88 PB'],
43+
[10000000000000000000, '8.67 EB'],
44+
[10000000000000000000000, '8.47 ZB'],
45+
[10000000000000000000000000, '8.27 YB'],
46+
[10000000000000000000000, '8.47 ZB'],
47+
[10000000000000000000000, '8.47 ZB'],
48+
])('should log file sizes correctly for %s`', (bytes, displayValue) => {
49+
expect(formatBytes(bytes)).toBe(displayValue);
50+
});
51+
52+
it('should log file sizes correctly with correct decimal`', () => {
53+
expect(formatBytes(10000, 1)).toBe('9.8 kB');
54+
});
55+
});
56+
57+
describe('pluralizeToken', () => {
58+
it.each([
59+
[undefined, '0 files'],
60+
[-2, '-2 files'],
61+
[-1, '-1 file'],
62+
[0, '0 files'],
63+
[1, '1 file'],
64+
[2, '2 files'],
65+
])('should log correct plural for %s`', (times, plural) => {
66+
expect(pluralizeToken('file', times)).toBe(plural);
67+
});
68+
});
69+
70+
describe('formatDuration', () => {
71+
it.each([
72+
[-1, '-1 ms'],
73+
[0, '0 ms'],
74+
[1, '1 ms'],
75+
[2, '2 ms'],
76+
[1200, '1.20 s'],
77+
])('should log correct plural for %s`', (ms, displayValue) => {
78+
expect(formatDuration(ms)).toBe(displayValue);
79+
});
80+
});

packages/utils/src/lib/promise-result.unit.test.ts renamed to packages/utils/src/lib/guards.unit.test.ts

+1-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import { describe } from 'vitest';
2-
import {
3-
isPromiseFulfilledResult,
4-
isPromiseRejectedResult,
5-
} from './promise-result';
2+
import { isPromiseFulfilledResult, isPromiseRejectedResult } from './guards';
63

74
describe('promise-result', () => {
85
it('should get fulfilled result', () => {

packages/utils/src/lib/log-results.ts

+1-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import {
2-
isPromiseFulfilledResult,
3-
isPromiseRejectedResult,
4-
} from './promise-result';
1+
import { isPromiseFulfilledResult, isPromiseRejectedResult } from './guards';
52

63
export function logMultipleResults<T>(
74
results: PromiseSettledResult<T>[],

packages/utils/src/lib/log-results.unit.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import chalk from 'chalk';
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
33
import { FileResult } from './file-system';
4+
import { formatBytes } from './formatting';
45
import { logMultipleResults, logPromiseResults } from './log-results';
5-
import { formatBytes } from './report';
66

77
const succeededCallback = (result: PromiseFulfilledResult<FileResult>) => {
88
const [fileName, size] = result.value;

0 commit comments

Comments
 (0)