diff --git a/.changeset/perky-streets-fetch.md b/.changeset/perky-streets-fetch.md new file mode 100644 index 000000000..8042f6c4e --- /dev/null +++ b/.changeset/perky-streets-fetch.md @@ -0,0 +1,5 @@ +--- +"@flint.fyi/core": minor +--- + +feat(core): introduce `LinterHost` diff --git a/cspell.json b/cspell.json index 72b27a9ab..f7253cd3e 100644 --- a/cspell.json +++ b/cspell.json @@ -42,6 +42,7 @@ "contentinfo", "deoptimizations", "destructures", + "dirents", "dustinspecker", "extlang", "Focusables", diff --git a/packages/core/src/host/createDiskBackedLinterHost.test.ts b/packages/core/src/host/createDiskBackedLinterHost.test.ts new file mode 100644 index 000000000..a425f67e3 --- /dev/null +++ b/packages/core/src/host/createDiskBackedLinterHost.test.ts @@ -0,0 +1,623 @@ +// flint-disable-file unnecessaryBlocks +import fs from "node:fs"; +import path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createDiskBackedLinterHost } from "./createDiskBackedLinterHost.ts"; +import { normalizePath } from "./normalizePath.ts"; + +const INTEGRATION_DIR_NAME = ".flint-disk-backed-linter-host-integration-tests"; + +function findUpNodeModules(startDir: string): string { + let current = startDir; + while (true) { + const candidate = path.join(current, "node_modules"); + if (fs.existsSync(candidate)) { + return candidate; + } + const parent = path.dirname(current); + if (parent === current) { + throw new Error("Could not find node_modules directory."); + } + current = parent; + } +} + +/* eslint @typescript-eslint/no-unused-vars: ["error", { "varsIgnorePattern": "^_$" }] */ + +describe("createDiskBackedLinterHost", () => { + const integrationRoot = path.join( + findUpNodeModules(import.meta.dirname), + INTEGRATION_DIR_NAME, + ); + + beforeEach(() => { + fs.rmSync(integrationRoot, { force: true, recursive: true }); + fs.mkdirSync(integrationRoot, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(integrationRoot, { force: true, recursive: true }); + }); + + it("normalizes cwd", () => { + const host = createDiskBackedLinterHost(integrationRoot + "/dir/.."); + + expect(host.getCurrentDirectory()).toEqual( + normalizePath(integrationRoot, host.isCaseSensitiveFS()), + ); + }); + + it("stats files and directories", () => { + const host = createDiskBackedLinterHost(integrationRoot); + const filePath = path.join(integrationRoot, "file.txt"); + const dirPath = path.join(integrationRoot, "dir"); + const missingPath = path.join(integrationRoot, "missing.txt"); + + fs.writeFileSync(filePath, "hello"); + fs.mkdirSync(dirPath, { recursive: true }); + + expect(host.stat(filePath)).toEqual("file"); + expect(host.stat(dirPath)).toEqual("directory"); + expect(host.stat(missingPath)).toEqual(undefined); + }); + + it("reads file contents", () => { + const host = createDiskBackedLinterHost(integrationRoot); + const filePath = path.join(integrationRoot, "file.txt"); + + fs.writeFileSync(filePath, "hello"); + + expect(host.readFile(filePath)).toEqual("hello"); + }); + + it("lists directory entries and resolves symlinks", () => { + const host = createDiskBackedLinterHost(integrationRoot); + const filePath = path.join(integrationRoot, "file.txt"); + const dirPath = path.join(integrationRoot, "dir"); + const fileLink = path.join(integrationRoot, "file-link.txt"); + const dirLink = path.join(integrationRoot, "dir-link"); + const brokenLink = path.join(integrationRoot, "broken-link"); + const missingPath = path.join(integrationRoot, "missing.txt"); + + fs.writeFileSync(filePath, "hello"); + fs.mkdirSync(dirPath, { recursive: true }); + fs.symlinkSync(filePath, fileLink); + fs.symlinkSync(dirPath, dirLink, "junction"); + fs.symlinkSync(missingPath, brokenLink); + + const entries = host.readDirectory(integrationRoot); + const sortedEntries = entries + .map((entry) => ({ name: entry.name, type: entry.type })) + .toSorted((a, b) => a.name.localeCompare(b.name)); + + expect(sortedEntries).toStrictEqual([ + { name: "dir", type: "directory" }, + { name: "dir-link", type: "directory" }, + { name: "file-link.txt", type: "file" }, + { name: "file.txt", type: "file" }, + ]); + }); + + describe("watchFile", () => { + it("reports creation", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const filePath = path.join(integrationRoot, "watch.txt"); + const onEvent = vi.fn(); + using _ = host.watchFile(filePath, onEvent, 10); + + await sleep(50); + + fs.writeFileSync(filePath, "first"); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("created"); + }); + }); + + it("reports editing", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const filePath = path.join(integrationRoot, "watch-change.txt"); + fs.writeFileSync(filePath, "first"); + const onEvent = vi.fn(); + using _ = host.watchFile(filePath, onEvent); + + await sleep(50); + + fs.writeFileSync(filePath, "second"); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("changed"); + }); + }); + + it("reports deletion", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const filePath = path.join(integrationRoot, "watch-delete.txt"); + fs.writeFileSync(filePath, "first"); + const onEvent = vi.fn(); + using _ = host.watchFile(filePath, onEvent); + + await sleep(50); + + fs.rmSync(filePath); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("deleted"); + }); + }); + + it("watches missing files through create-delete-create", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const filePath = path.join(integrationRoot, "watch-recreate.txt"); + const onEvent = vi.fn(); + using _ = host.watchFile(filePath, onEvent, 10); + + await sleep(50); + + fs.writeFileSync(filePath, "first"); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("created"); + }); + onEvent.mockClear(); + + fs.rmSync(filePath); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("deleted"); + }); + onEvent.mockClear(); + + fs.writeFileSync(filePath, "second"); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("created"); + }); + }); + + it("watches deeply nested files across directory removal and recreation", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const firstDir = path.join(integrationRoot, "first"); + const secondDir = path.join(firstDir, "second"); + const filePath = path.join(secondDir, "deep.txt"); + const onEvent = vi.fn(); + using _ = host.watchFile(filePath, onEvent, 10); + + await sleep(50); + + fs.mkdirSync(firstDir, { recursive: true }); + fs.mkdirSync(secondDir, { recursive: true }); + fs.writeFileSync(filePath, "content"); + + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("created"); + }); + onEvent.mockClear(); + + fs.rmSync(secondDir, { force: true, recursive: true }); + + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("deleted"); + }); + onEvent.mockClear(); + + fs.mkdirSync(secondDir, { recursive: true }); + fs.writeFileSync(filePath, "content"); + + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("created"); + }); + onEvent.mockClear(); + + fs.rmSync(firstDir, { force: true, recursive: true }); + + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("deleted"); + }); + onEvent.mockClear(); + + fs.mkdirSync(secondDir, { recursive: true }); + fs.writeFileSync(filePath, "content"); + + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("created"); + }); + }); + + it("disposes file watchers", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const filePath = path.join(integrationRoot, "disposed.txt"); + const onEvent = vi.fn(); + { + using _ = host.watchFile(filePath, onEvent); + } + + fs.writeFileSync(filePath, "content"); + + await sleep(50); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it("disposes file watchers after created", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const filePath = path.join(integrationRoot, "disposed.txt"); + const onEvent = vi.fn(); + { + using _ = host.watchFile(filePath, onEvent, 10); + await sleep(50); + fs.writeFileSync(filePath, "first"); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("created"); + }); + } + onEvent.mockClear(); + + fs.writeFileSync(filePath, "second"); + await sleep(50); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it("disposes file watchers after deleted", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const filePath = path.join(integrationRoot, "disposed.txt"); + const onEvent = vi.fn(); + fs.writeFileSync(filePath, "first"); + { + using _ = host.watchFile(filePath, onEvent, 10); + await sleep(50); + fs.rmSync(filePath); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("deleted"); + }); + } + onEvent.mockClear(); + + fs.writeFileSync(filePath, "second"); + await sleep(50); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it("ignores other files", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const targetPath = path.join(integrationRoot, "target.txt"); + const otherPath = path.join(integrationRoot, "other.txt"); + const onEvent = vi.fn(); + using _ = host.watchFile(targetPath, onEvent, 10); + + await sleep(50); + + fs.writeFileSync(otherPath, "content"); + + await sleep(50); + expect(onEvent).not.toHaveBeenCalledWith(); + }); + + it("watches directory", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const dirPath = path.join(integrationRoot, "directory"); + const onEvent = vi.fn(); + fs.mkdirSync(dirPath, { recursive: true }); + using _ = host.watchFile(dirPath, onEvent, 10); + + await sleep(50); + + fs.rmSync(dirPath, { force: true, recursive: true }); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("deleted"); + }); + onEvent.mockClear(); + + fs.mkdirSync(dirPath, { recursive: true }); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith("created"); + }); + }); + + it("does not report child file changes when watches directory", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const dirPath = path.join(integrationRoot, "directory"); + const filePath = path.join(dirPath, "file.txt"); + const onEvent = vi.fn(); + fs.mkdirSync(dirPath, { recursive: true }); + using _ = host.watchFile(dirPath, onEvent, 10); + + await sleep(50); + + fs.writeFileSync(filePath, "content"); + await sleep(50); + expect(onEvent).not.toHaveBeenCalled(); + }); + }); + + describe("watchDirectory", () => { + it("watches directories non-recursively", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const directoryPath = path.join(integrationRoot, "dir"); + const nestedPath = path.join(directoryPath, "nested"); + fs.mkdirSync(nestedPath, { recursive: true }); + + const onEvent = vi.fn(); + using _ = host.watchDirectory(directoryPath, false, onEvent); + + await sleep(50); + + const nestedFile = path.join(nestedPath, "nested.txt"); + const directFile = path.join(directoryPath, "direct.txt"); + fs.writeFileSync(nestedFile, "nested"); + fs.writeFileSync(directFile, "direct"); + + const normalizedDirect = normalizePath( + directFile, + host.isCaseSensitiveFS(), + ); + + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(normalizedDirect); + }); + }); + + it("watches directories recursively", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const directoryPath = path.join(integrationRoot, "dir"); + const nestedPath = path.join(directoryPath, "nested"); + fs.mkdirSync(nestedPath, { recursive: true }); + fs.writeFileSync(path.join(directoryPath, "existing.txt"), "content"); + const onEvent = vi.fn(); + using _ = host.watchDirectory(directoryPath, true, onEvent); + + await sleep(50); + + const nestedFile = path.join(nestedPath, "nested.txt"); + fs.writeFileSync(nestedFile, "nested"); + + const normalizedNested = normalizePath( + nestedFile, + host.isCaseSensitiveFS(), + ); + + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(normalizedNested); + }); + }); + + it("ignores .git directories within watched paths", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const baseDir = path.join(integrationRoot, "base-git"); + fs.mkdirSync(baseDir, { recursive: true }); + const onEvent = vi.fn(); + using _ = host.watchDirectory(baseDir, true, onEvent); + + await sleep(50); + + fs.mkdirSync(path.join(baseDir, ".git"), { recursive: true }); + fs.writeFileSync(path.join(baseDir, ".git", "config"), "content"); + fs.writeFileSync(path.join(baseDir, "src.txt"), "content"); + + const normalizedFile = normalizePath( + path.join(baseDir, "src.txt"), + host.isCaseSensitiveFS(), + ); + await sleep(50); + expect(onEvent).toHaveBeenCalledWith(normalizedFile); + }); + + it("ignores node_modules directories within watched paths", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const baseDir = path.join(integrationRoot, "base-node-modules"); + fs.mkdirSync(baseDir, { recursive: true }); + const onEvent = vi.fn(); + using _ = host.watchDirectory(baseDir, true, onEvent); + + await sleep(50); + + fs.mkdirSync(path.join(baseDir, "node_modules", "pkg"), { + recursive: true, + }); + fs.writeFileSync( + path.join(baseDir, "node_modules", "pkg", "index.js"), + "content", + ); + fs.writeFileSync(path.join(baseDir, "src.txt"), "content"); + + const normalizedFile = normalizePath( + path.join(baseDir, "src.txt"), + host.isCaseSensitiveFS(), + ); + await sleep(50); + expect(onEvent).toHaveBeenCalledWith(normalizedFile); + }); + + it("does not ignore lookalike names such as .gitignore", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const baseDir = path.join(integrationRoot, "lookalike"); + fs.mkdirSync(baseDir, { recursive: true }); + const onEvent = vi.fn(); + using _ = host.watchDirectory(baseDir, true, onEvent); + + await sleep(50); + + const filePath = path.join(baseDir, ".gitignore"); + fs.writeFileSync(filePath, "content"); + + const normalizedFile = normalizePath(filePath, host.isCaseSensitiveFS()); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(normalizedFile); + }); + }); + + it("emits when watching directory is created", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const directoryPath = path.join(integrationRoot, "recreate-dir"); + const normalizedDirectory = normalizePath( + directoryPath, + host.isCaseSensitiveFS(), + ); + const onEvent = vi.fn(); + using _ = host.watchDirectory(directoryPath, false, onEvent, 10); + + await sleep(50); + + fs.mkdirSync(directoryPath, { recursive: true }); + + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(normalizedDirectory); + }); + }); + + it("emits when watching directory is deleted", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const directoryPath = path.join(integrationRoot, "recreate-dir"); + const normalizedDirectory = normalizePath( + directoryPath, + host.isCaseSensitiveFS(), + ); + const onEvent = vi.fn(); + fs.mkdirSync(directoryPath, { recursive: true }); + + using _ = host.watchDirectory(directoryPath, false, onEvent, 10); + + await sleep(50); + + fs.rmSync(directoryPath, { force: true, recursive: true }); + + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(normalizedDirectory); + }); + }); + + it("don't reattach watchers on child deletion", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const directoryPath = path.join(integrationRoot, "recreate-dir"); + const onEvent = vi.fn(); + fs.mkdirSync(directoryPath, { recursive: true }); + + const firstFile = path.join(directoryPath, "first.txt"); + fs.writeFileSync(firstFile, "first"); + const normalizedFirst = normalizePath( + firstFile, + host.isCaseSensitiveFS(), + ); + const secondFile = path.join(directoryPath, "second.txt"); + fs.writeFileSync(secondFile, "second"); + const normalizedSecond = normalizePath( + secondFile, + host.isCaseSensitiveFS(), + ); + + using _ = host.watchDirectory(directoryPath, false, onEvent, 10); + + await sleep(50); + + fs.rmSync(firstFile); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(normalizedFirst); + }); + onEvent.mockClear(); + + fs.rmSync(secondFile); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(normalizedSecond); + }); + onEvent.mockClear(); + }); + + it("reattaches watchers after deletion and recreation", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const directoryPath = path.join(integrationRoot, "recreate-dir"); + const onEvent = vi.fn(); + fs.mkdirSync(directoryPath, { recursive: true }); + using _ = host.watchDirectory(directoryPath, false, onEvent, 10); + + await sleep(50); + + const firstFile = path.join(directoryPath, "first.txt"); + fs.writeFileSync(firstFile, "first"); + + const normalizedFirst = normalizePath( + firstFile, + host.isCaseSensitiveFS(), + ); + + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(normalizedFirst); + }); + onEvent.mockClear(); + + fs.rmSync(directoryPath, { force: true, recursive: true }); + + const normalizedDirectory = normalizePath( + directoryPath, + host.isCaseSensitiveFS(), + ); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(normalizedDirectory); + }); + onEvent.mockClear(); + + fs.mkdirSync(directoryPath, { recursive: true }); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(normalizedDirectory); + }); + onEvent.mockClear(); + + const secondFile = path.join(directoryPath, "second.txt"); + fs.writeFileSync(secondFile, "second"); + + const normalizedSecond = normalizePath( + secondFile, + host.isCaseSensitiveFS(), + ); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(normalizedSecond); + }); + }); + + it("correctly reports when dir and its child have the same name", async () => { + const host = createDiskBackedLinterHost(integrationRoot); + const directoryPath = normalizePath( + path.join(integrationRoot, "dir"), + host.isCaseSensitiveFS(), + ); + const subDirectoryPath = normalizePath( + path.join(directoryPath, "dir"), + host.isCaseSensitiveFS(), + ); + const onEvent = vi.fn(); + using _ = host.watchDirectory(directoryPath, false, onEvent, 10); + + await sleep(50); + + fs.mkdirSync(directoryPath, { recursive: true }); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(directoryPath); + }); + onEvent.mockClear(); + + fs.mkdirSync(subDirectoryPath, { recursive: true }); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(subDirectoryPath); + }); + onEvent.mockClear(); + + fs.rmSync(subDirectoryPath, { force: true, recursive: true }); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(subDirectoryPath); + }); + onEvent.mockClear(); + + fs.rmSync(directoryPath, { force: true, recursive: true }); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(directoryPath); + }); + onEvent.mockClear(); + + fs.mkdirSync(subDirectoryPath, { recursive: true }); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(directoryPath); + }); + onEvent.mockClear(); + + fs.rmSync(directoryPath, { recursive: true }); + await vi.waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(directoryPath); + }); + onEvent.mockClear(); + }); + }); +}); diff --git a/packages/core/src/host/createDiskBackedLinterHost.ts b/packages/core/src/host/createDiskBackedLinterHost.ts new file mode 100644 index 000000000..4bd204602 --- /dev/null +++ b/packages/core/src/host/createDiskBackedLinterHost.ts @@ -0,0 +1,257 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { + LinterHost, + LinterHostDirectoryEntry, + LinterHostFileWatcherEvent, +} from "../types/host.ts"; +import { isFileSystemCaseSensitive } from "./isFileSystemCaseSensitive.ts"; +import { normalizePath } from "./normalizePath.ts"; + +const ignoredPaths = ["/node_modules", "/.git", "/.jj"]; + +export function createDiskBackedLinterHost(cwd: string): LinterHost { + const caseSensitiveFS = isFileSystemCaseSensitive(); + cwd = normalizePath(cwd, caseSensitiveFS); + + function createWatcher( + normalizedWatchPath: string, + recursive: boolean, + pollingInterval: number, + callback: ( + normalizedChangedFilePath: null | string, + event: LinterHostFileWatcherEvent, + ) => void, + ): Disposable { + const normalizedWatchBasename = normalizedWatchPath.slice( + normalizedWatchPath.lastIndexOf("/") + 1, + ); + let exists = fs.existsSync(normalizedWatchPath); + let unwatch: () => void = exists ? watchPresent() : watchMissing(); + + function statAndEmitIfChanged( + changedFileName: null | string, + existsNow: boolean | null = null, + ) { + if (changedFileName != null) { + changedFileName = normalizePath(changedFileName, caseSensitiveFS); + } + existsNow ??= fs.existsSync(normalizedWatchPath); + if (existsNow) { + callback(changedFileName, exists ? "changed" : "created"); + } else { + callback(changedFileName, "deleted"); + } + exists = existsNow; + return exists; + } + + // fs.watch is more performant than fs.watchFile, + // we use it when file exists on disk + function watchPresent() { + const watcher = fs + .watch( + normalizedWatchPath, + { persistent: false, recursive }, + (event, filename) => { + if (unwatched) { + return; + } + // C:/foo is a directory + // fs.watch('C:/foo') + // C:/foo deleted + // fs.watch emits \\?\C:\foo + // See https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats + if (filename?.startsWith("\\\\?\\")) { + filename = filename.slice("\\\\?\\".length); + } + if (filename === normalizedWatchBasename) { + let changedPath = normalizedWatchPath; + // /foo/bar is a directory + // /foo/bar/bar is a file + // fs.watch('/foo/bar') + // /foo/bar/bar deleted -> filename === bar + // /foo/bar deleted -> filename === bar + if ( + fs + .statSync(normalizedWatchPath, { throwIfNoEntry: false }) + ?.isDirectory() + ) { + changedPath = normalizePath( + path.resolve(normalizedWatchPath, filename), + caseSensitiveFS, + ); + } + if (statAndEmitIfChanged(changedPath)) { + return; + } + } + if (!fs.existsSync(normalizedWatchPath)) { + statAndEmitIfChanged(normalizedWatchPath, false); + } else if ( + statAndEmitIfChanged( + filename == null + ? null + : normalizePath( + path.resolve(normalizedWatchPath, filename), + caseSensitiveFS, + ), + ) + ) { + return; + } + unwatchSelf(); + unwatch = watchMissing(); + }, + ) + .on("error", () => { + // parent dir deleted + if (unwatched) { + return; + } + unwatchSelf(); + unwatch = watchMissing(); + }); + let unwatched = false; + const unwatchSelf = () => { + unwatched = true; + watcher.close(); + }; + return unwatchSelf; + } + + // fs.watchFile uses polling and therefore is less performant, + // we fallback to it when the file doesn't exist on disk + function watchMissing() { + const listener: fs.StatsListener = (curr, prev) => { + if (unwatched) { + return; + } + if (curr.mtimeMs === prev.mtimeMs || curr.mtimeMs === 0) { + return; + } + if (!statAndEmitIfChanged(normalizedWatchPath)) { + return; + } + fs.unwatchFile(normalizedWatchPath, listener); + unwatchSelf(); + unwatch = watchPresent(); + }; + fs.watchFile( + normalizedWatchPath, + { interval: pollingInterval, persistent: false }, + listener, + ); + let unwatched = false; + const unwatchSelf = () => { + unwatched = true; + fs.unwatchFile(normalizedWatchPath, listener); + }; + return unwatchSelf; + } + return { + [Symbol.dispose]() { + unwatch(); + }, + }; + } + + return { + getCurrentDirectory() { + return cwd; + }, + isCaseSensitiveFS() { + return caseSensitiveFS; + }, + readDirectory(directoryPathAbsolute) { + const result: LinterHostDirectoryEntry[] = []; + const dirents = fs.readdirSync(directoryPathAbsolute, { + withFileTypes: true, + }); + + for (const entry of dirents) { + let stat = entry as Pick; + if (entry.isSymbolicLink()) { + try { + stat = fs.statSync(path.join(directoryPathAbsolute, entry.name)); + } catch { + continue; + } + } + if (stat.isDirectory()) { + result.push({ name: entry.name, type: "directory" }); + } + if (stat.isFile()) { + result.push({ name: entry.name, type: "file" }); + } + } + + return result; + }, + readFile(filePathAbsolute) { + return fs.readFileSync(filePathAbsolute, "utf8"); + }, + stat(pathAbsolute) { + try { + const stat = fs.statSync(pathAbsolute); + if (stat.isDirectory()) { + return "directory"; + } + if (stat.isFile()) { + return "file"; + } + } catch {} // eslint-disable-line no-empty + return undefined; + }, + watchDirectory( + directoryPathAbsolute, + recursive, + callback, + pollingInterval = 2_000, + ) { + directoryPathAbsolute = normalizePath( + directoryPathAbsolute, + caseSensitiveFS, + ); + + return createWatcher( + directoryPathAbsolute, + recursive, + pollingInterval, + (normalizedChangedFilePath) => { + normalizedChangedFilePath ??= directoryPathAbsolute; + if (normalizedChangedFilePath !== directoryPathAbsolute) { + let relative = normalizedChangedFilePath; + if (relative.startsWith(directoryPathAbsolute + "/")) { + relative = relative.slice(directoryPathAbsolute.length); + } + for (const ignored of ignoredPaths) { + if ( + relative.endsWith(ignored) || + relative.includes(ignored + "/") + ) { + return; + } + } + } + callback(normalizedChangedFilePath); + }, + ); + }, + watchFile(filePathAbsolute, callback, pollingInterval = 2_000) { + filePathAbsolute = normalizePath(filePathAbsolute, caseSensitiveFS); + + return createWatcher( + filePathAbsolute, + false, + pollingInterval, + (normalizedChangedFilePath, event) => { + if (normalizedChangedFilePath === filePathAbsolute) { + callback(event); + } + }, + ); + }, + }; +} diff --git a/packages/core/src/host/createVFSLinterHost.test.ts b/packages/core/src/host/createVFSLinterHost.test.ts new file mode 100644 index 000000000..37d474a7d --- /dev/null +++ b/packages/core/src/host/createVFSLinterHost.test.ts @@ -0,0 +1,627 @@ +// flint-disable-file unnecessaryBlocks +import { describe, expect, it, vi } from "vitest"; + +import { createVFSLinterHost } from "./createVFSLinterHost.ts"; + +/* eslint @typescript-eslint/no-unused-vars: ["error", { "varsIgnorePattern": "^_$" }] */ +/* eslint-disable @typescript-eslint/no-empty-function */ + +describe(createVFSLinterHost, () => { + it("normalizes cwd", () => { + const host = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root/../root2/", + }); + + expect(host.getCurrentDirectory()).toEqual("/root2"); + expect(host.isCaseSensitiveFS()).toEqual(true); + }); + + it("normalizes cwd case-insensitively", () => { + const host = createVFSLinterHost({ + caseSensitive: false, + cwd: "C:\\HELLO\\world\\", + }); + + expect(host.getCurrentDirectory()).toEqual("c:/hello/world"); + expect(host.isCaseSensitiveFS()).toEqual(false); + }); + + it("inherits cwd and case sensitivity from base host", () => { + const baseHost = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const host = createVFSLinterHost({ baseHost }); + + expect(host.getCurrentDirectory()).toEqual("/root"); + expect(host.isCaseSensitiveFS()).toEqual(true); + }); + + describe("stat", () => { + it("existing file", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + + host.vfsUpsertFile("/root/file.ts", "content"); + host.vfsUpsertFile("/root/nested/file.ts", "content"); + + expect(host.stat("/root/file.ts")).toEqual("file"); + expect(host.stat("/root/nested/file.ts")).toEqual("file"); + }); + + it("existing directory", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + + host.vfsUpsertFile("/root/nested/file.ts", "content"); + + expect(host.stat("/root/nested")).toEqual("directory"); + }); + + it("non-existent file", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + + expect(host.stat("/root/missing")).toBeUndefined(); + }); + + it("propagates to base host", () => { + const baseHost = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const host = createVFSLinterHost({ baseHost }); + + baseHost.vfsUpsertFile("/root/file.ts", "content"); + + expect(host.stat("/root/file.ts")).toEqual("file"); + }); + + it("prefers overlay file over base dir", () => { + const baseHost = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const host = createVFSLinterHost({ baseHost }); + + baseHost.vfsUpsertFile("/root/file.ts/file.ts", "content"); + host.vfsUpsertFile("/root/file.ts", "content"); + + expect(host.stat("/root/file.ts")).toEqual("file"); + }); + + it("prefers overlay dir over base file", () => { + const baseHost = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const host = createVFSLinterHost({ baseHost }); + + baseHost.vfsUpsertFile("/root/file.ts", "content"); + host.vfsUpsertFile("/root/file.ts/file.ts", "content"); + + expect(host.stat("/root/file.ts")).toEqual("directory"); + }); + }); + + describe("readFile", () => { + it("returns undefined when reading a missing file", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + + expect(host.readFile("/root/missing.txt")).toBeUndefined(); + }); + + it("reads existing file", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + host.vfsUpsertFile("/root/file.ts", "content"); + + expect(host.readFile("/root/file.ts")).toEqual("content"); + }); + + it("propagates to base host", () => { + const baseHost = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + baseHost.vfsUpsertFile("/root/base.txt", "base"); + + const host = createVFSLinterHost({ baseHost }); + + expect(host.readFile("/root/base.txt")).toEqual("base"); + }); + + it("prefers overlay over base", () => { + const baseHost = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + baseHost.vfsUpsertFile("/root/file.txt", "base"); + + const host = createVFSLinterHost({ baseHost }); + host.vfsUpsertFile("/root/file.txt", "vfs"); + + expect(host.readFile("/root/file.txt")).toEqual("vfs"); + }); + + it("returns undefined when reading directory", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + host.vfsUpsertFile("/root/nested/file.txt", "vfs"); + + expect(host.readFile("/root/nested")).toBeUndefined(); + }); + }); + + describe("readDirectory", () => { + it("skips non-matching files when reading a directory", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + host.vfsUpsertFile("/root/other/file.txt", "content"); + + expect(host.readDirectory("/root/dir")).toEqual([]); + }); + + it("returns nothing when reading file", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + host.vfsUpsertFile("/root/file.txt", "content"); + + expect(host.readDirectory("/root/file.txt")).toEqual([]); + }); + + it("lists files", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + host.vfsUpsertFile("/root/file.txt", "content"); + host.vfsUpsertFile("/root/sub/file.txt", "content"); + + expect(host.readDirectory("/root")).toEqual([ + { + name: "file.txt", + type: "file", + }, + { + name: "sub", + type: "directory", + }, + ]); + }); + + it("filters out duplicates", () => { + const baseHost = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + baseHost.vfsUpsertFile("/root/file.txt", "base"); + baseHost.vfsUpsertFile("/root/sub/file.txt", "base"); + + const host = createVFSLinterHost({ baseHost }); + host.vfsUpsertFile("/root/file.txt", "vfs"); + host.vfsUpsertFile("/root/sub/file.txt", "vfs"); + + const entries = host.readDirectory("/root"); + + expect(entries).toEqual([ + { + name: "file.txt", + type: "file", + }, + { + name: "sub", + type: "directory", + }, + ]); + }); + + it("propagates from base", () => { + const baseHost = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + baseHost.vfsUpsertFile("/root/base.txt", "base"); + baseHost.vfsUpsertFile("/root/base-sub/file.txt", "base"); + + const host = createVFSLinterHost({ baseHost }); + host.vfsUpsertFile("/root/vfs.txt", "vfs"); + host.vfsUpsertFile("/root/vfs-sub/file.txt", "vfs"); + + const entries = host.readDirectory("/root"); + + expect(entries).toEqual([ + { + name: "vfs.txt", + type: "file", + }, + { + name: "vfs-sub", + type: "directory", + }, + { + name: "base.txt", + type: "file", + }, + { + name: "base-sub", + type: "directory", + }, + ]); + }); + + it("prefers overlay file over base dir", () => { + const baseHost = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + baseHost.vfsUpsertFile("/root/file.txt/file.txt", "base"); + + const host = createVFSLinterHost({ baseHost }); + host.vfsUpsertFile("/root/file.txt", "vfs"); + + const entries = host.readDirectory("/root"); + + expect(entries).toEqual([ + { + name: "file.txt", + type: "file", + }, + ]); + }); + + it("prefers overlay dir over base file", () => { + const baseHost = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + baseHost.vfsUpsertFile("/root/file.txt", "base"); + + const host = createVFSLinterHost({ baseHost }); + host.vfsUpsertFile("/root/file.txt/file.txt", "host"); + + const entries = host.readDirectory("/root"); + + expect(entries).toEqual([ + { + name: "file.txt", + type: "directory", + }, + ]); + }); + }); + + describe("vfsUpsertFile", () => { + it("creates file", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + + expect(host.vfsListFiles()).toEqual(new Map()); + + host.vfsUpsertFile("/root/file.txt", "content"); + + expect(host.vfsListFiles()).toEqual( + new Map([["/root/file.txt", "content"]]), + ); + }); + + it("updates file", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + + expect(host.vfsListFiles()).toEqual(new Map()); + + host.vfsUpsertFile("/root/file.txt", "content"); + host.vfsUpsertFile("/root/file.txt", "new content"); + + expect(host.vfsListFiles()).toEqual( + new Map([["/root/file.txt", "new content"]]), + ); + }); + }); + + describe("vfsDeleteFile", () => { + it("deletes file", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + + expect(host.vfsListFiles()).toEqual(new Map()); + + host.vfsUpsertFile("/root/file.txt", "content"); + host.vfsDeleteFile("/root/file.txt"); + + expect(host.vfsListFiles()).toEqual(new Map()); + }); + + it("does nothing when file does not exist", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + + expect(host.vfsListFiles()).toEqual(new Map()); + + host.vfsUpsertFile("/root/file.txt", "content"); + host.vfsDeleteFile("/root/file2.txt"); + + expect(host.vfsListFiles()).toEqual( + new Map([["/root/file.txt", "content"]]), + ); + }); + }); + + describe("watchFile", () => { + it("reports creation", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + const onEvent = vi.fn(); + + using _ = host.watchFile("/root/file.txt", onEvent); + expect(onEvent).not.toHaveBeenCalled(); + host.vfsUpsertFile("/root/file.txt", "content"); + expect(onEvent).toHaveBeenCalledExactlyOnceWith("created"); + }); + + it("reports editing", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + const onEvent = vi.fn(); + + host.vfsUpsertFile("/root/file.txt", "content"); + using _ = host.watchFile("/root/file.txt", onEvent); + + expect(onEvent).not.toHaveBeenCalled(); + + host.vfsUpsertFile("/root/file.txt", "new content"); + + expect(onEvent).toHaveBeenCalledExactlyOnceWith("changed"); + }); + + it("reports deletion", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + const onEvent = vi.fn(); + + host.vfsUpsertFile("/root/file.txt", "content"); + using _ = host.watchFile("/root/file.txt", onEvent); + + expect(onEvent).not.toHaveBeenCalled(); + + host.vfsDeleteFile("/root/file.txt"); + + expect(onEvent).toHaveBeenCalledExactlyOnceWith("deleted"); + }); + + it("disposes onEvent", () => { + const host = createVFSLinterHost({ caseSensitive: true, cwd: "/root" }); + const onEvent = vi.fn(); + + { + using _ = host.watchFile("/root/file.txt", onEvent); + } + host.vfsUpsertFile("/root/file.txt", "content"); + + expect(onEvent).not.toHaveBeenCalled(); + }); + + it("propagates base host events", () => { + const baseHost = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const host = createVFSLinterHost({ baseHost }); + const onEvent = vi.fn(); + + using _ = host.watchFile("/root/file.txt", onEvent); + expect(onEvent).not.toHaveBeenCalled(); + + baseHost.vfsUpsertFile("/root/file.txt", "content"); + + expect(onEvent).toHaveBeenCalledExactlyOnceWith("created"); + }); + + it("propagates correct params to base host watcher", () => { + const baseHost = { + ...createVFSLinterHost({ caseSensitive: true, cwd: "/root" }), + watchFile: vi.fn(() => ({ [Symbol.dispose]() {} })), + }; + const host = createVFSLinterHost({ baseHost }); + + using _ = host.watchFile("/root/file.txt", () => {}, 555); + + expect(baseHost.watchFile).toHaveBeenCalledExactlyOnceWith( + "/root/file.txt", + expect.any(Function), + 555, + ); + }); + + it("disposes base host watcher", () => { + const dispose = vi.fn(); + const baseHost = { + ...createVFSLinterHost({ caseSensitive: true, cwd: "/root" }), + watchFile: () => ({ [Symbol.dispose]: dispose }), + }; + const host = createVFSLinterHost({ baseHost }); + + { + using _ = host.watchFile("/root/file.txt", () => {}); + expect(dispose).not.toHaveBeenCalled(); + } + + expect(dispose).toHaveBeenCalledExactlyOnceWith(); + }); + }); + + describe("watchDirectory", () => { + describe("non-recursive", () => { + it("reports file creation", () => { + const host = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const onEvent = vi.fn(); + + using _ = host.watchDirectory("/root", false, onEvent); + host.vfsUpsertFile("/root/file.txt", "content"); + + expect(onEvent).toHaveBeenCalledExactlyOnceWith("/root/file.txt"); + }); + + it("reports directory creation", () => { + const host = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const onEvent = vi.fn(); + + using _ = host.watchDirectory("/root", false, onEvent); + host.vfsUpsertFile("/root/dir/file.txt", "content"); + + expect(onEvent).toHaveBeenCalledExactlyOnceWith("/root/dir"); + }); + + it("reports directory creation 2", () => { + const host = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const onEvent = vi.fn(); + + using _ = host.watchDirectory("/", false, onEvent); + host.vfsUpsertFile("/root/dir/file.txt", "content"); + + expect(onEvent).toHaveBeenCalledExactlyOnceWith("/root"); + }); + + it("reports file creation win32", () => { + const host = createVFSLinterHost({ + caseSensitive: false, + cwd: "C:/", + }); + const onEvent = vi.fn(); + + using _ = host.watchDirectory("C:\\", false, onEvent); + host.vfsUpsertFile("C:\\file.txt", "content"); + + expect(onEvent).toHaveBeenCalledExactlyOnceWith("c:/file.txt"); + }); + + it("reports file editing", () => { + const host = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const onEvent = vi.fn(); + + host.vfsUpsertFile("/root/file.txt", "content"); + using _ = host.watchDirectory("/root", false, onEvent); + expect(onEvent).not.toHaveBeenCalled(); + + host.vfsUpsertFile("/root/file.txt", "new content"); + expect(onEvent).toHaveBeenCalledExactlyOnceWith("/root/file.txt"); + }); + + it("reports file deletion", () => { + const host = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const onEvent = vi.fn(); + + host.vfsUpsertFile("/root/file.txt", "content"); + using _ = host.watchDirectory("/root", false, onEvent); + expect(onEvent).not.toHaveBeenCalled(); + + host.vfsDeleteFile("/root/file.txt"); + expect(onEvent).toHaveBeenCalledExactlyOnceWith("/root/file.txt"); + }); + + it("reports directory deletion", () => { + const host = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const onEvent = vi.fn(); + + host.vfsUpsertFile("/root/nested/file.txt", "content"); + using _ = host.watchDirectory("/root", false, onEvent); + expect(onEvent).not.toHaveBeenCalled(); + + host.vfsDeleteFile("/root/nested/file.txt"); + expect(onEvent).toHaveBeenCalledExactlyOnceWith("/root/nested"); + }); + }); + + describe("recursive", () => { + it("reports file creation", () => { + const host = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const onEvent = vi.fn(); + + using _ = host.watchDirectory("/root", true, onEvent); + + host.vfsUpsertFile("/root/nested/file.txt", "content"); + + expect(onEvent).toHaveBeenCalledExactlyOnceWith( + "/root/nested/file.txt", + ); + }); + + it("reports file editing", () => { + const host = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const onEvent = vi.fn(); + + host.vfsUpsertFile("/root/nested/file.txt", "content"); + using _ = host.watchDirectory("/root", true, onEvent); + expect(onEvent).not.toHaveBeenCalled(); + + host.vfsUpsertFile("/root/nested/file.txt", "new content"); + + expect(onEvent).toHaveBeenCalledExactlyOnceWith( + "/root/nested/file.txt", + ); + }); + + it("reports file deletion", () => { + const host = createVFSLinterHost({ + caseSensitive: true, + cwd: "/root", + }); + const onEvent = vi.fn(); + + host.vfsUpsertFile("/root/nested/file.txt", "content"); + using _ = host.watchDirectory("/root", true, onEvent); + expect(onEvent).not.toHaveBeenCalled(); + + host.vfsDeleteFile("/root/nested/file.txt"); + + expect(onEvent).toHaveBeenCalledExactlyOnceWith( + "/root/nested/file.txt", + ); + }); + }); + + it("propagates correct params to base host watcher", () => { + const baseHost = { + ...createVFSLinterHost({ caseSensitive: true, cwd: "/root" }), + watchDirectory: vi.fn(() => ({ [Symbol.dispose]() {} })), + }; + const host = createVFSLinterHost({ baseHost }); + + using _ = host.watchDirectory("/root/file.txt", false, () => {}, 555); + + expect(baseHost.watchDirectory).toHaveBeenCalledExactlyOnceWith( + "/root/file.txt", + false, + expect.any(Function), + 555, + ); + }); + + it("disposes base host watcher", () => { + const dispose = vi.fn(); + const baseHost = { + ...createVFSLinterHost({ caseSensitive: true, cwd: "/root" }), + watchDirectory: () => ({ [Symbol.dispose]: dispose }), + }; + const host = createVFSLinterHost({ baseHost }); + + { + using _ = host.watchDirectory("/root/file.txt", false, () => {}); + expect(dispose).not.toHaveBeenCalled(); + } + + expect(dispose).toHaveBeenCalledExactlyOnceWith(); + }); + }); +}); + +/* eslint-enable @typescript-eslint/no-empty-function */ diff --git a/packages/core/src/host/createVFSLinterHost.ts b/packages/core/src/host/createVFSLinterHost.ts new file mode 100644 index 000000000..d6562f236 --- /dev/null +++ b/packages/core/src/host/createVFSLinterHost.ts @@ -0,0 +1,232 @@ +import type { + LinterHost, + LinterHostDirectoryEntry, + LinterHostDirectoryWatcher, + LinterHostFileWatcher, + LinterHostFileWatcherEvent, + VFSLinterHost, +} from "../types/host.ts"; +import { isFileSystemCaseSensitive } from "./isFileSystemCaseSensitive.ts"; +import { normalizedDirname, normalizePath } from "./normalizePath.ts"; + +export type CreateVFSLinterHostOpts = + | { + baseHost: LinterHost; + caseSensitive?: never; + cwd?: string | undefined; + } + | { + baseHost?: never; + caseSensitive?: boolean | undefined; + cwd: string; + }; + +/** + * Current limitations in watch mode: + * + * VFS is not directory-aware: + * - In non-recursive watchDirectory, every change to deeply nested children + * emits event on the immediate watched directory child. + * - created/deleted events are emitted without acknowledging whether + * the base host has directories containing the target file path. + * - Base host events are not filtered; if you delete a file from the base host, + * but not from the VFS, a deleted event will still be emitted. + * - You cannot watch directory via watchFile. + * + * Other limitations: + * - VFS is file-only; empty directories cannot be represented, and directory + * existence is inferred from file paths. + */ +export function createVFSLinterHost( + opts: CreateVFSLinterHostOpts, +): VFSLinterHost { + let cwd: string; + let baseHost: LinterHost | undefined; + let caseSensitiveFS: boolean; + if (opts.baseHost == null) { + caseSensitiveFS = opts.caseSensitive ?? isFileSystemCaseSensitive(); + cwd = normalizePath(opts.cwd, caseSensitiveFS); + } else { + baseHost = opts.baseHost; + cwd = opts.cwd ?? baseHost.getCurrentDirectory(); + caseSensitiveFS = baseHost.isCaseSensitiveFS(); + } + + const fileMap = new Map(); + const fileWatchers = new Map>(); + const directoryWatchers = new Map>(); + const recursiveDirectoryWatchers = new Map< + string, + Set + >(); + function watchEvent( + normalizedFilePathAbsolute: string, + fileEvent: LinterHostFileWatcherEvent, + ) { + for (const watcher of fileWatchers.get(normalizedFilePathAbsolute) ?? []) { + watcher(fileEvent); + } + + let currentFile = normalizedFilePathAbsolute; + let currentDir = normalizedDirname(currentFile); + do { + for (const watcher of directoryWatchers.get(currentDir) ?? []) { + watcher(currentFile); + } + currentFile = currentDir; + currentDir = normalizedDirname(currentFile); + } while (currentFile !== currentDir); + + let dir = normalizedDirname(normalizedFilePathAbsolute); + while (true) { + for (const watcher of recursiveDirectoryWatchers.get(dir) ?? []) { + watcher(normalizedFilePathAbsolute); + } + const prevDir = dir; + dir = normalizedDirname(dir); + if (prevDir === dir) { + break; + } + } + } + return { + getCurrentDirectory() { + return cwd; + }, + isCaseSensitiveFS() { + return caseSensitiveFS; + }, + readDirectory(directoryPathAbsolute) { + directoryPathAbsolute = + normalizePath(directoryPathAbsolute, caseSensitiveFS) + "/"; + const result = new Map(); + + for (let filePath of fileMap.keys()) { + if (!filePath.startsWith(directoryPathAbsolute)) { + continue; + } + filePath = filePath.slice(directoryPathAbsolute.length); + const slashIndex = filePath.indexOf("/"); + let dirent: LinterHostDirectoryEntry = { + name: filePath, + type: "file", + }; + if (slashIndex >= 0) { + dirent = { + name: filePath.slice(0, slashIndex), + type: "directory", + }; + } + if (!result.get(dirent.name)) { + result.set(dirent.name, dirent); + } + } + + return [ + ...result.values(), + ...(baseHost?.stat(directoryPathAbsolute) === "directory" + ? baseHost + .readDirectory(directoryPathAbsolute) + .filter(({ name }) => !result.has(name)) + : []), + ]; + }, + readFile(filePathAbsolute) { + filePathAbsolute = normalizePath(filePathAbsolute, caseSensitiveFS); + const file = fileMap.get(filePathAbsolute); + if (file != null) { + return file; + } + if (baseHost?.stat(filePathAbsolute) === "file") { + return baseHost.readFile(filePathAbsolute); + } + return undefined; + }, + stat(pathAbsolute) { + pathAbsolute = normalizePath(pathAbsolute, caseSensitiveFS); + for (const filePath of fileMap.keys()) { + if (pathAbsolute === filePath) { + return "file"; + } + if (filePath.startsWith(pathAbsolute + "/")) { + return "directory"; + } + } + return baseHost?.stat(pathAbsolute); + }, + vfsDeleteFile(filePathAbsolute) { + filePathAbsolute = normalizePath(filePathAbsolute, caseSensitiveFS); + if (!fileMap.delete(filePathAbsolute)) { + return; + } + watchEvent(filePathAbsolute, "deleted"); + }, + vfsListFiles() { + return fileMap; + }, + vfsUpsertFile(filePathAbsolute, content) { + filePathAbsolute = normalizePath(filePathAbsolute, caseSensitiveFS); + const fileEvent = fileMap.has(filePathAbsolute) ? "changed" : "created"; + fileMap.set(filePathAbsolute, content); + watchEvent(filePathAbsolute, fileEvent); + }, + watchDirectory( + directoryPathAbsolute, + recursive, + callback, + pollingInterval, + ) { + directoryPathAbsolute = normalizePath( + directoryPathAbsolute, + caseSensitiveFS, + ); + const collection = recursive + ? recursiveDirectoryWatchers + : directoryWatchers; + let watchers = collection.get(directoryPathAbsolute); + if (watchers == null) { + watchers = new Set(); + collection.set(directoryPathAbsolute, watchers); + } + watchers.add(callback); + const baseWatcher = baseHost?.watchDirectory( + directoryPathAbsolute, + recursive, + callback, + pollingInterval, + ); + return { + [Symbol.dispose]() { + watchers.delete(callback); + if (watchers.size === 0) { + collection.delete(directoryPathAbsolute); + } + baseWatcher?.[Symbol.dispose](); + }, + }; + }, + watchFile(filePathAbsolute, callback, pollingInterval) { + filePathAbsolute = normalizePath(filePathAbsolute, caseSensitiveFS); + let watchers = fileWatchers.get(filePathAbsolute); + if (watchers == null) { + watchers = new Set(); + fileWatchers.set(filePathAbsolute, watchers); + } + watchers.add(callback); + const baseWatcher = baseHost?.watchFile( + filePathAbsolute, + callback, + pollingInterval, + ); + return { + [Symbol.dispose]() { + watchers.delete(callback); + if (watchers.size === 0) { + fileWatchers.delete(filePathAbsolute); + } + baseWatcher?.[Symbol.dispose](); + }, + }; + }, + }; +} diff --git a/packages/core/src/host/isFileSystemCaseSensitive.ts b/packages/core/src/host/isFileSystemCaseSensitive.ts new file mode 100644 index 000000000..7bdde37e0 --- /dev/null +++ b/packages/core/src/host/isFileSystemCaseSensitive.ts @@ -0,0 +1,15 @@ +import fs from "node:fs"; +import process from "node:process"; + +let cached: boolean | null = null; + +export function isFileSystemCaseSensitive(): boolean { + cached ??= !( + process.platform === "win32" || + fs.existsSync( + import.meta.filename.slice(0, -1) + + import.meta.filename.slice(-1).toUpperCase(), + ) + ); + return cached; +} diff --git a/packages/core/src/host/normalizePath.test.ts b/packages/core/src/host/normalizePath.test.ts new file mode 100644 index 000000000..acef92145 --- /dev/null +++ b/packages/core/src/host/normalizePath.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { normalizedDirname, normalizePath } from "./normalizePath.ts"; + +describe("normalizePath", () => { + it("normalizes Windows path", () => { + const normalized = normalizePath("C:\\my-PATH\\foo\\", false); + + expect(normalized).toEqual("c:/my-path/foo"); + }); + + it("normalizes POSIX path", () => { + const normalized = normalizePath("/my-PATH/foo/", true); + + expect(normalized).toEqual("/my-PATH/foo"); + }); + + it("strips unnecessary path segments", () => { + const normalized = normalizePath("/foo//bar/../baz/.//", true); + + expect(normalized).toEqual("/foo/baz"); + }); + + it("doesn't strip root '/'", () => { + const normalized = normalizePath("/", true); + + expect(normalized).toEqual("/"); + }); + + it("doesn't strip root 'C:\\'", () => { + const normalized = normalizePath("C:\\", false); + + expect(normalized).toEqual("c:/"); + }); +}); + +describe("normalizedDirname", () => { + it("works with Windows path", () => { + const dirname = normalizedDirname("c:/foo/bar"); + + expect(dirname).toEqual("c:/foo"); + }); + + it("recognizes Windows root", () => { + const dirname = normalizedDirname("c:/foo"); + + expect(dirname).toEqual("c:/"); + }); + + it("recognizes bare Windows root", () => { + const dirname = normalizedDirname("c:/"); + + expect(dirname).toEqual("c:/"); + }); + + it("works with POSIX path", () => { + const dirname = normalizedDirname("/foo/bar"); + + expect(dirname).toEqual("/foo"); + }); + + it("recognizes POSIX root", () => { + const dirname = normalizedDirname("/foo"); + + expect(dirname).toEqual("/"); + }); + + it("recognizes bare POSIX root", () => { + const dirname = normalizedDirname("/"); + + expect(dirname).toEqual("/"); + }); +}); diff --git a/packages/core/src/host/normalizePath.ts b/packages/core/src/host/normalizePath.ts new file mode 100644 index 000000000..382e2d3df --- /dev/null +++ b/packages/core/src/host/normalizePath.ts @@ -0,0 +1,21 @@ +import { normalize } from "node:path"; + +export function normalizedDirname(path: string) { + const lastSlashIdx = path.lastIndexOf("/"); + path = path.slice(0, lastSlashIdx + 1); + if (path.indexOf("/") === lastSlashIdx && path.endsWith("/")) { + return path; + } + return path.slice(0, lastSlashIdx); +} + +export function normalizePath(path: string, caseSensitiveFS: boolean): string { + let result = normalize(path).replaceAll("\\", "/"); + if (result.indexOf("/") !== result.lastIndexOf("/") && result.endsWith("/")) { + result = result.slice(0, -1); + } + if (!caseSensitiveFS) { + result = result.toLowerCase(); + } + return result; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d849c9f68..8450e268a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,13 @@ export { isConfig } from "./configs/isConfig.ts"; export { DirectivesCollector } from "./directives/DirectivesCollector.ts"; export { directiveReports } from "./directives/reports/directiveReports.ts"; export { globs } from "./globs/index.ts"; +export { createDiskBackedLinterHost } from "./host/createDiskBackedLinterHost.ts"; +export { + createVFSLinterHost, + type CreateVFSLinterHostOpts, +} from "./host/createVFSLinterHost.ts"; +export { isFileSystemCaseSensitive } from "./host/isFileSystemCaseSensitive.ts"; +export { normalizedDirname, normalizePath } from "./host/normalizePath.ts"; export { createLanguage } from "./languages/createLanguage.ts"; export { createPlugin } from "./plugins/createPlugin.ts"; export { formatReportPrimary } from "./reporting/formatReportPrimary.ts"; @@ -18,6 +25,7 @@ export * from "./types/changes.ts"; export * from "./types/configs.ts"; export * from "./types/directives.ts"; export * from "./types/formatting.ts"; +export * from "./types/host.ts"; export * from "./types/languages.ts"; export * from "./types/linting.ts"; export * from "./types/modes.ts"; diff --git a/packages/core/src/types/host.ts b/packages/core/src/types/host.ts new file mode 100644 index 000000000..ed21e460b --- /dev/null +++ b/packages/core/src/types/host.ts @@ -0,0 +1,34 @@ +export interface LinterHost { + getCurrentDirectory(): string; + isCaseSensitiveFS(): boolean; + readDirectory(directoryPathAbsolute: string): LinterHostDirectoryEntry[]; + readFile(filePathAbsolute: string): string | undefined; + stat(pathAbsolute: string): "directory" | "file" | undefined; + watchDirectory( + directoryPathAbsolute: string, + recursive: boolean, + callback: LinterHostDirectoryWatcher, + pollingInterval?: number, + ): Disposable; + watchFile( + filePathAbsolute: string, + callback: LinterHostFileWatcher, + pollingInterval?: number, + ): Disposable; +} + +export interface LinterHostDirectoryEntry { + name: string; + type: "directory" | "file"; +} +export type LinterHostDirectoryWatcher = (filePathAbsolute: string) => void; + +export type LinterHostFileWatcher = (event: LinterHostFileWatcherEvent) => void; + +export type LinterHostFileWatcherEvent = "changed" | "created" | "deleted"; + +export interface VFSLinterHost extends LinterHost { + vfsDeleteFile(filePathAbsolute: string): void; + vfsListFiles(): ReadonlyMap; + vfsUpsertFile(filePathAbsolute: string, content: string): void; +}