Skip to content

Commit 64931c3

Browse files
authored
fix: Remove flat-cache dependency (#7636)
1 parent c0957a8 commit 64931c3

File tree

5 files changed

+346
-299
lines changed

5 files changed

+346
-299
lines changed

packages/cspell/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@
8888
"@cspell/cspell-types": "workspace:*",
8989
"@cspell/dynamic-import": "workspace:*",
9090
"@cspell/url": "workspace:*",
91-
"@types/flat-cache": "^2.0.2",
9291
"chalk": "^5.4.1",
9392
"chalk-template": "^1.1.0",
9493
"commander": "^14.0.0",
@@ -99,7 +98,7 @@
9998
"cspell-io": "workspace:*",
10099
"cspell-lib": "workspace:*",
101100
"fast-json-stable-stringify": "^2.1.0",
102-
"flat-cache": "^5.0.0",
101+
"flatted": "^3.3.3",
103102
"semver": "^7.7.2",
104103
"tinyglobby": "^0.2.14"
105104
},
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import crypto from 'node:crypto';
2+
import type { Stats } from 'node:fs';
3+
import fs from 'node:fs/promises';
4+
import path from 'node:path';
5+
6+
import type { FlatCache } from './flatCache.js';
7+
import { loadCacheFile as loadFlatCache } from './flatCache.js';
8+
9+
export async function createFromFile(
10+
filePath: string,
11+
useChecksum?: boolean,
12+
currentWorkingDir?: string,
13+
): Promise<FileEntryCache> {
14+
const cache = await loadFlatCache<Meta>(filePath);
15+
const fec = new ImplFileEntryCache(cache, useChecksum ?? false, currentWorkingDir);
16+
await fec.removeNotFoundFiles();
17+
return fec;
18+
}
19+
20+
class ImplFileEntryCache implements FileEntryCache {
21+
readonly cache: FlatCache<Meta>;
22+
readonly useChecksum: boolean;
23+
readonly #normalizedEntries: Map<string, CacheEntry> = new Map();
24+
25+
/**
26+
* To enable relative paths as the key with current working directory
27+
*/
28+
readonly currentWorkingDir: string | undefined;
29+
30+
constructor(cache: FlatCache<Meta>, useChecksum?: boolean, currentWorkingDir?: string) {
31+
this.cache = cache;
32+
this.useChecksum = useChecksum || false;
33+
this.currentWorkingDir = currentWorkingDir;
34+
}
35+
36+
async removeNotFoundFiles() {
37+
// Remove not found entries
38+
for (const fPath of this.cache.keys()) {
39+
try {
40+
const filePath = this.resolveKeyToFile(fPath);
41+
await fs.stat(filePath);
42+
} catch (error) {
43+
if (isNodeError(error) && error.code === 'ENOENT') {
44+
this.cache.removeKey(fPath);
45+
}
46+
}
47+
}
48+
}
49+
50+
/**
51+
* Given a buffer, calculate md5 hash of its content.
52+
* @param buffer buffer to calculate hash on
53+
* @return content hash digest
54+
*/
55+
#getHash(buffer: Buffer | string): string {
56+
return crypto.createHash('md5').update(buffer).digest('hex');
57+
}
58+
59+
async getFileDescriptor(file: string): Promise<FileDescriptor> {
60+
let fstat: Stats;
61+
62+
try {
63+
fstat = await fs.stat(file);
64+
} catch (error) {
65+
this.#removeEntry(file);
66+
return { key: file, notFound: true, err: toError(error) };
67+
}
68+
69+
if (this.useChecksum) {
70+
return this.#getFileDescriptorUsingChecksum(file);
71+
}
72+
73+
return this.#getFileDescriptorUsingMtimeAndSize(file, fstat);
74+
}
75+
76+
#getFileDescriptorUsingMtimeAndSize(file: string, fstat: Stats): FileDescriptor {
77+
const key = this.#getFileKey(file);
78+
let meta = this.cache.get(key);
79+
const cacheExists = !!meta;
80+
81+
const cSize = fstat.size;
82+
const cTime = fstat.mtime.getTime();
83+
84+
let isDifferentDate;
85+
let isDifferentSize;
86+
87+
if (meta) {
88+
isDifferentDate = cTime !== meta.mtime;
89+
isDifferentSize = cSize !== meta.size;
90+
} else {
91+
meta = { size: cSize, mtime: cTime };
92+
}
93+
94+
const nEntry = {
95+
key,
96+
changed: !cacheExists || isDifferentDate || isDifferentSize,
97+
meta,
98+
};
99+
100+
this.#normalizedEntries.set(key, nEntry);
101+
102+
return nEntry;
103+
}
104+
105+
async #getFileDescriptorUsingChecksum(file: string): Promise<FileDescriptor> {
106+
const key = this.#getFileKey(file);
107+
let meta = this.cache.get(key);
108+
const cacheExists = !!meta;
109+
110+
let contentBuffer;
111+
try {
112+
contentBuffer = await fs.readFile(file);
113+
} catch {
114+
contentBuffer = '';
115+
}
116+
117+
let isDifferent = true;
118+
const hash = this.#getHash(contentBuffer);
119+
120+
if (meta) {
121+
isDifferent = hash !== meta.hash;
122+
} else {
123+
meta = { hash };
124+
}
125+
126+
const nEntry = {
127+
key,
128+
changed: !cacheExists || isDifferent,
129+
meta,
130+
};
131+
132+
this.#normalizedEntries.set(key, nEntry);
133+
134+
return nEntry;
135+
}
136+
137+
/**
138+
* Remove an entry from the file-entry-cache. Useful to force the file to still be considered
139+
* modified the next time the process is run
140+
*/
141+
#removeEntry(file: string): void {
142+
const key = this.#getFileKey(file);
143+
this.#normalizedEntries.delete(key);
144+
this.cache.removeKey(key);
145+
}
146+
147+
/**
148+
* Deletes the cache file from the disk and clears the memory cache
149+
*/
150+
async destroy(): Promise<void> {
151+
this.#normalizedEntries.clear();
152+
await this.cache.destroy();
153+
}
154+
155+
async #getMetaForFileUsingCheckSum(cacheEntry: CacheEntry): Promise<Meta> {
156+
const filePath = this.resolveKeyToFile(cacheEntry.key);
157+
const contentBuffer = await fs.readFile(filePath);
158+
const hash = this.#getHash(contentBuffer);
159+
const meta: Meta = { ...cacheEntry.meta, hash };
160+
delete meta.size;
161+
delete meta.mtime;
162+
return meta;
163+
}
164+
165+
async #getMetaForFileUsingMtimeAndSize(cacheEntry: CacheEntry): Promise<Meta> {
166+
const filePath = this.resolveKeyToFile(cacheEntry.key);
167+
const stat = await fs.stat(filePath);
168+
const meta = { ...cacheEntry.meta, size: stat.size, mtime: stat.mtime.getTime() };
169+
delete meta.hash;
170+
return meta;
171+
}
172+
173+
/**
174+
* Sync the files and persist them to the cache
175+
*/
176+
async reconcile(): Promise<void> {
177+
await this.removeNotFoundFiles();
178+
179+
for (const [entryKey, cacheEntry] of this.#normalizedEntries.entries()) {
180+
try {
181+
const meta = this.useChecksum
182+
? await this.#getMetaForFileUsingCheckSum(cacheEntry)
183+
: await this.#getMetaForFileUsingMtimeAndSize(cacheEntry);
184+
this.cache.set(entryKey, meta);
185+
} catch (error) {
186+
// If the file does not exists we don't save it
187+
// other errors are just thrown
188+
if (!isNodeError(error) || error.code !== 'ENOENT') {
189+
throw error;
190+
}
191+
}
192+
}
193+
194+
this.cache.save();
195+
}
196+
197+
resolveKeyToFile(entryKey: string): string {
198+
if (this.currentWorkingDir) {
199+
return path.resolve(this.currentWorkingDir, entryKey);
200+
}
201+
return entryKey;
202+
}
203+
204+
#getFileKey(file: string): string {
205+
if (this.currentWorkingDir && path.isAbsolute(file)) {
206+
return normalizePath(path.relative(this.currentWorkingDir, file));
207+
}
208+
return normalizePath(file);
209+
}
210+
}
211+
212+
interface NodeError extends Error {
213+
code?: string;
214+
}
215+
216+
function isNodeError(error: unknown): error is NodeError {
217+
return typeof error === 'object' && error !== null && 'code' in error;
218+
}
219+
220+
function toError(error: unknown): Error {
221+
if (error instanceof Error) {
222+
return error;
223+
}
224+
if (typeof error === 'string') {
225+
return new Error(error);
226+
}
227+
return new Error('Unknown error', { cause: error });
228+
}
229+
230+
export interface AnalyzedFilesInfo {
231+
readonly changedFiles: string[];
232+
readonly notFoundFiles: string[];
233+
readonly notChangedFiles: string[];
234+
}
235+
236+
interface UserData {
237+
data?: unknown;
238+
}
239+
240+
interface Meta extends UserData {
241+
size?: number | undefined;
242+
mtime?: number | undefined;
243+
hash?: string | undefined;
244+
}
245+
246+
interface CacheEntry {
247+
key: string;
248+
notFound?: boolean;
249+
err?: Error | undefined;
250+
changed?: boolean | undefined;
251+
meta?: Meta | undefined;
252+
}
253+
254+
export type FileDescriptor = Readonly<CacheEntry>;
255+
256+
export interface FileEntryCache {
257+
getFileDescriptor(file: string): Promise<FileDescriptor>;
258+
259+
/**
260+
* Deletes the cache file from the disk and clears the memory cache
261+
*/
262+
destroy(): Promise<void>;
263+
264+
/**
265+
* Sync the files and persist them to the cache
266+
*/
267+
reconcile(): Promise<void>;
268+
}
269+
270+
export function normalizePath(filePath: string): string {
271+
if (path.sep === '/') return filePath;
272+
return filePath.split(path.sep).join('/');
273+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import fs from 'node:fs/promises';
2+
import path from 'node:path';
3+
4+
import { parse, stringify } from 'flatted';
5+
6+
export class FlatCache<T> {
7+
#cache: Map<string, T>;
8+
9+
constructor(readonly cacheFilename: string) {
10+
this.#cache = new Map<string, T>();
11+
}
12+
13+
keys(): MapIterator<string> {
14+
return this.#cache.keys();
15+
}
16+
17+
set(key: string, value: T): this {
18+
this.#cache.set(key, value);
19+
return this;
20+
}
21+
22+
removeKey(key: string): void {
23+
this.#cache.delete(key);
24+
}
25+
26+
get(key: string): T | undefined {
27+
return this.#cache.get(key);
28+
}
29+
30+
async load(ifFound: boolean = true): Promise<this> {
31+
this.#cache.clear();
32+
try {
33+
const content = await fs.readFile(this.cacheFilename, 'utf8');
34+
this.#cache = new Map<string, T>(Object.entries(parse(content)));
35+
} catch (error) {
36+
if (!ifFound) {
37+
throw error;
38+
}
39+
}
40+
return this;
41+
}
42+
43+
async save(): Promise<void> {
44+
const dir = path.dirname(this.cacheFilename);
45+
await fs.mkdir(dir, { recursive: true });
46+
const content = stringify(Object.fromEntries(this.#cache.entries()));
47+
await fs.writeFile(this.cacheFilename, content, 'utf8');
48+
}
49+
50+
/**
51+
* Clear the cache and remove the cache file from disk.
52+
*/
53+
async destroy(): Promise<void> {
54+
this.#cache.clear();
55+
try {
56+
await fs.unlink(this.cacheFilename);
57+
} catch {
58+
// Ignore errors when deleting the cache file.
59+
// It may not exist or may not be writable.
60+
}
61+
}
62+
}
63+
64+
export function loadCacheFile<T>(file: string): Promise<FlatCache<T>> {
65+
const cache = new FlatCache<T>(file);
66+
return cache.load();
67+
}

0 commit comments

Comments
 (0)