Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dofs-bounded-filesystem.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/dofs": minor
---

Add stable directory pagination with metadata, bounded byte reads, single-character globs, and configurable bounded grep results.
8 changes: 8 additions & 0 deletions packages/dofs/src/fs/filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ describe("WorkspaceFilesystem", () => {
});
});

it("readRange returns only the requested bytes", async () => {
await withFs(async (fs) => {
await fs.writeFile("/bin", new Uint8Array([1, 2, 3, 4, 5]));
expect(Array.from(await fs.readRange("/bin", 1, 3))).toEqual([2, 3, 4]);
expect(await fs.readRange("/bin", 5, 3)).toEqual(new Uint8Array());
});
});

it("stat returns the documented shape for a file", async () => {
await withFs(async (fs) => {
await fs.writeFile("/a.txt", "ab");
Expand Down
6 changes: 5 additions & 1 deletion packages/dofs/src/fs/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { type GrepOptions, grep, type WorkspaceGrepMatch } from "./grep.js";
import { ls } from "./ls.js";
import { type MkdirOptions, mkdir } from "./mkdir.js";
import { type ReaddirOptions, readdir, type WorkspaceDirentResult } from "./readdir.js";
import { type ReadFileOptions, readFile } from "./readFile.js";
import { type ReadFileOptions, readFile, readRangeSync } from "./readFile.js";
import { readlink } from "./readlink.js";
import { type RmOptions, rm } from "./rm.js";
import { lstat, stat, type WorkspaceStatResult } from "./stat.js";
Expand Down Expand Up @@ -60,6 +60,10 @@ export class WorkspaceFilesystem {
return readFile(this.db, path, optionsOrEncoding as ReadFileOptions);
}

async readRange(path: string, offset: number, length: number): Promise<Uint8Array> {
return readRangeSync(this.db, path, offset, length);
}

async stat(path: string): Promise<WorkspaceStatResult> {
return stat(this.db, path);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/dofs/src/fs/find.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ describe("find", () => {
});
});

it("matches ? as one non-separator character", async () => {
await withDB(async (db) => {
mkdir(db, "/a", {}, () => 0);
await writeFile(db, "/a/a.ts", "", {}, () => 0);
await writeFile(db, "/a/ab.ts", "", {}, () => 0);
await writeFile(db, "/a/b.ts", "", {}, () => 0);
const paths = find(db, "/a", "?.ts").map((entry) => entry.path);
expect(paths).toEqual(["/a/a.ts", "/a/b.ts"]);
});
});

it("matches ** recursively", async () => {
await withDB(async (db) => {
mkdir(db, "/a/b/c", { recursive: true }, () => 0);
Expand Down
6 changes: 6 additions & 0 deletions packages/dofs/src/fs/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ function walk(db: Database, parentInode: number, parentPath: string, out: Worksp
// Compile a simple glob into a regex. Supported:
// * matches any run of characters except '/'
// ** matches any run of characters including '/'
// ? matches one character except '/'
// Anything else is a literal. Regex metacharacters in literals are
// escaped so '.' in '*.ts' doesn't match an arbitrary character.
function compileGlob(pattern: string): RegExp {
Expand All @@ -89,6 +90,11 @@ function compileGlob(pattern: string): RegExp {
}
continue;
}
if (ch === "?") {
re += "[^/]";
i += 1;
continue;
}
if (REGEX_METACHARS.has(ch)) {
re += `\\${ch}`;
} else {
Expand Down
53 changes: 52 additions & 1 deletion packages/dofs/src/fs/grep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,65 @@ describe("grep", () => {
});
});

it("respects ignoreCase", async () => {
it("respects explicit case options and the ignoreCase alias", async () => {
await withDB(async (db) => {
await writeFile(db, "/a.txt", "todo\nTODO\nTodo\n", {}, () => 0);
expect((await grep(db, "TODO", "/a.txt", { ignoreCase: true })).length).toBe(3);
expect((await grep(db, "TODO", "/a.txt", { caseSensitive: false })).length).toBe(3);
expect((await grep(db, "TODO", "/a.txt", { caseSensitive: true })).length).toBe(1);
expect((await grep(db, "TODO", "/a.txt")).length).toBe(1);
});
});

it("supports regular expressions and fixed strings", async () => {
await withDB(async (db) => {
await writeFile(db, "/a.txt", "task 12\ntask \\d+\ntask xx\n", {}, () => 0);
expect(
(await grep(db, String.raw`task \d+`, "/a.txt", { fixedString: false })).map(
(match) => match.line,
),
).toEqual([1]);
expect(
(await grep(db, String.raw`task \d+`, "/a.txt", { fixedString: true })).map(
(match) => match.line,
),
).toEqual([2]);
await expect(grep(db, "[", "/a.txt", { fixedString: false })).rejects.toThrow(
"Invalid regular expression",
);
});
});

it("returns numbered context around matches", async () => {
await withDB(async (db) => {
await writeFile(db, "/a.txt", "one\ntwo\nTODO\nfour\nfive\n", {}, () => 0);
expect(await grep(db, "TODO", "/a.txt", { contextLines: 1 })).toEqual([
{
path: "/a.txt",
line: 3,
text: "TODO",
context: [
{ line: 2, text: "two", isMatch: false },
{ line: 3, text: "TODO", isMatch: true },
{ line: 4, text: "four", isMatch: false },
],
},
]);
});
});

it("applies offset and limit across files in path and line order", async () => {
await withDB(async (db) => {
await writeFile(db, "/a.txt", "TODO a1\nTODO a2\n", {}, () => 0);
await writeFile(db, "/b.txt", "TODO b1\nTODO b2\n", {}, () => 0);
expect(
(await grep(db, "TODO", "/", { offset: 1, limit: 2 })).map(
(match) => `${match.path}:${match.line}`,
),
).toEqual(["/a.txt:2", "/b.txt:1"]);
});
});

it("matches across a chunk boundary", async () => {
await withDB(async (db) => {
// Lay out a file whose line straddles the 512KiB chunk boundary.
Expand Down
Loading
Loading