Skip to content

Commit 560f427

Browse files
committed
feat(utils): add find in file logic
1 parent c677fa2 commit 560f427

File tree

2 files changed

+146
-0
lines changed

2 files changed

+146
-0
lines changed

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

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { bold, gray } from 'ansis';
22
import { type Options, bundleRequire } from 'bundle-require';
3+
import * as fs from 'node:fs';
34
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
45
import path from 'node:path';
6+
import * as readline from 'node:readline';
7+
import type { SourceFileLocation } from '@code-pushup/models';
58
import { formatBytes } from './formatting.js';
69
import { logMultipleResults } from './log-results.js';
710
import { ui } from './logging.js';
@@ -93,6 +96,7 @@ export type CrawlFileSystemOptions<T> = {
9396
pattern?: string | RegExp;
9497
fileTransform?: (filePath: string) => Promise<T> | T;
9598
};
99+
96100
export async function crawlFileSystem<T = string>(
97101
options: CrawlFileSystemOptions<T>,
98102
): Promise<T[]> {
@@ -159,3 +163,104 @@ export function filePathToCliArg(filePath: string): string {
159163
export function projectToFilename(project: string): string {
160164
return project.replace(/[/\\\s]+/g, '-').replace(/@/g, '');
161165
}
166+
167+
export type LineHit = {
168+
startColumn: number;
169+
endColumn: number;
170+
};
171+
172+
export type FileHit = Pick<SourceFileLocation, 'file'> &
173+
Exclude<SourceFileLocation['position'], undefined>;
174+
175+
const escapeRegExp = (str: string): string =>
176+
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
177+
const ensureGlobalRegex = (pattern: RegExp): RegExp =>
178+
new RegExp(
179+
pattern.source,
180+
pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`,
181+
);
182+
183+
const findAllMatches = (
184+
line: string,
185+
searchPattern: string | RegExp | ((line: string) => LineHit[] | null),
186+
): LineHit[] => {
187+
if (typeof searchPattern === 'string') {
188+
return [...line.matchAll(new RegExp(escapeRegExp(searchPattern), 'g'))].map(
189+
({ index = 0 }) => ({
190+
startColumn: index,
191+
endColumn: index + searchPattern.length,
192+
}),
193+
);
194+
}
195+
196+
if (searchPattern instanceof RegExp) {
197+
return [...line.matchAll(ensureGlobalRegex(searchPattern))].map(
198+
({ index = 0, 0: match }) => ({
199+
startColumn: index,
200+
endColumn: index + match.length,
201+
}),
202+
);
203+
}
204+
205+
return searchPattern(line) || [];
206+
};
207+
208+
/**
209+
* Reads a file line-by-line and checks if it contains the search pattern.
210+
* @param file - The file path to check.
211+
* @param searchPattern - The pattern to match.
212+
* @param options - Additional options. If true, the search will stop after the first hit.
213+
* @returns Promise<FileHit[]> - List of hits with matching details.
214+
*/
215+
export async function findInFile(
216+
file: string,
217+
searchPattern: string | RegExp | ((line: string) => LineHit[] | null),
218+
options?: { bail?: boolean },
219+
): Promise<FileHit[]> {
220+
const { bail = false } = options || {};
221+
const hits: FileHit[] = [];
222+
223+
return new Promise((resolve, reject) => {
224+
const stream = fs.createReadStream(file, { encoding: 'utf8' });
225+
const rl = readline.createInterface({ input: stream });
226+
// eslint-disable-next-line functional/no-let
227+
let lineNumber = 0;
228+
// eslint-disable-next-line functional/no-let
229+
let isResolved = false;
230+
231+
rl.on('line', line => {
232+
lineNumber++;
233+
const matches = findAllMatches(line, searchPattern);
234+
235+
matches.forEach(({ startColumn, endColumn }) => {
236+
// eslint-disable-next-line functional/immutable-data
237+
hits.push({
238+
file,
239+
startLine: lineNumber,
240+
startColumn,
241+
endLine: lineNumber,
242+
endColumn,
243+
});
244+
245+
if (bail && !isResolved) {
246+
isResolved = true;
247+
stream.destroy();
248+
resolve(hits);
249+
}
250+
});
251+
});
252+
rl.once('close', () => {
253+
if (!isResolved) {
254+
isResolved = true;
255+
}
256+
resolve(hits); // Resolve only once after closure
257+
});
258+
259+
rl.once('error', error => {
260+
if (!isResolved) {
261+
isResolved = true;
262+
reject(error);
263+
}
264+
});
265+
});
266+
}

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
crawlFileSystem,
99
ensureDirectoryExists,
1010
filePathToCliArg,
11+
findInFile,
1112
findLineNumberInText,
1213
findNearestFile,
1314
logMultipleFileResults,
@@ -263,3 +264,43 @@ describe('projectToFilename', () => {
263264
expect(projectToFilename(project)).toBe(file);
264265
});
265266
});
267+
268+
describe('findInFile', () => {
269+
const file = 'file.txt';
270+
const content =
271+
'line 1 - even:false\nline 2 - even:true\nline 3 - even:false\nline 4 - even:true\nline 5 - even:false\n';
272+
const filePath = path.join(MEMFS_VOLUME, file);
273+
274+
beforeEach(() => {
275+
vol.reset();
276+
vol.fromJSON({ [file]: content }, MEMFS_VOLUME);
277+
});
278+
279+
it('should find pattern in a file if a string is given', async () => {
280+
const result = await findInFile(filePath, 'line 3');
281+
expect(result).toStrictEqual([
282+
{
283+
file: filePath,
284+
endColumn: 6,
285+
endLine: 3,
286+
startColumn: 0,
287+
startLine: 3,
288+
},
289+
]);
290+
});
291+
292+
// @TODO any second test will fail
293+
// Error: EBADF: bad file descriptor, close
294+
it.todo('should find pattern in a file if a RegEx is given', async () => {
295+
const result = await findInFile('file.txt', new RegExp('line 3', 'g'));
296+
expect(result).toStrictEqual([
297+
{
298+
file: 'file.txt',
299+
endColumn: 6,
300+
endLine: 3,
301+
startColumn: 0,
302+
startLine: 3,
303+
},
304+
]);
305+
});
306+
});

0 commit comments

Comments
 (0)