|
1 | 1 | import { bold, gray } from 'ansis'; |
2 | 2 | import { type Options, bundleRequire } from 'bundle-require'; |
| 3 | +import * as fs from 'node:fs'; |
3 | 4 | import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'; |
4 | 5 | import path from 'node:path'; |
| 6 | +import * as readline from 'node:readline'; |
| 7 | +import type { SourceFileLocation } from '@code-pushup/models'; |
5 | 8 | import { formatBytes } from './formatting.js'; |
6 | 9 | import { logMultipleResults } from './log-results.js'; |
7 | 10 | import { ui } from './logging.js'; |
@@ -93,6 +96,7 @@ export type CrawlFileSystemOptions<T> = { |
93 | 96 | pattern?: string | RegExp; |
94 | 97 | fileTransform?: (filePath: string) => Promise<T> | T; |
95 | 98 | }; |
| 99 | + |
96 | 100 | export async function crawlFileSystem<T = string>( |
97 | 101 | options: CrawlFileSystemOptions<T>, |
98 | 102 | ): Promise<T[]> { |
@@ -159,3 +163,104 @@ export function filePathToCliArg(filePath: string): string { |
159 | 163 | export function projectToFilename(project: string): string { |
160 | 164 | return project.replace(/[/\\\s]+/g, '-').replace(/@/g, ''); |
161 | 165 | } |
| 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 | +} |
0 commit comments