Przeglądaj źródła

fix(multi-get): support brace expansion patterns in glob matching (#424)

Brace expansion patterns like `{doc1,doc2}.md` or `collection/{a,b}.md`
were incorrectly parsed as comma-separated file lists instead of being
passed to the glob matcher (picomatch). This happened because the
comma-detection heuristic only checked for `*` and `?` but not `{`.

Also adds `collection/path` matching in `matchFilesByGlob` so patterns
like `my-collection/{file1,file2}.md` work — previously the glob only
matched against `qmd://collection/path` (virtual) and `path` (relative
to collection root), missing the `collection/path` form.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Antonio Mello 1 miesiąc temu
rodzic
commit
ef062e1b54
3 zmienionych plików z 94 dodań i 3 usunięć
  1. 1 1
      src/cli/qmd.ts
  2. 2 2
      src/store.ts
  3. 91 0
      test/store.test.ts

+ 1 - 1
src/cli/qmd.ts

@@ -995,7 +995,7 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
   const db = getDb();
 
   // Check if it's a comma-separated list or a glob pattern
-  const isCommaSeparated = pattern.includes(',') && !pattern.includes('*') && !pattern.includes('?');
+  const isCommaSeparated = pattern.includes(',') && !pattern.includes('*') && !pattern.includes('?') && !pattern.includes('{');
 
   let files: { filepath: string; displayPath: string; bodyLength: number; collection?: string; path?: string }[];
 

+ 2 - 2
src/store.ts

@@ -2362,7 +2362,7 @@ export function matchFilesByGlob(db: Database, pattern: string): { filepath: str
 
   const isMatch = picomatch(pattern);
   return allFiles
-    .filter(f => isMatch(f.virtual_path) || isMatch(f.path))
+    .filter(f => isMatch(f.virtual_path) || isMatch(f.path) || isMatch(f.collection + '/' + f.path))
     .map(f => ({
       filepath: f.virtual_path,  // Virtual path for precise lookup
       displayPath: f.path,        // Relative path for display
@@ -3540,7 +3540,7 @@ export function findDocuments(
   pattern: string,
   options: { includeBody?: boolean; maxBytes?: number } = {}
 ): { docs: MultiGetResult[]; errors: string[] } {
-  const isCommaSeparated = pattern.includes(',') && !pattern.includes('*') && !pattern.includes('?');
+  const isCommaSeparated = pattern.includes(',') && !pattern.includes('*') && !pattern.includes('?') && !pattern.includes('{');
   const errors: string[] = [];
   const maxBytes = options.maxBytes ?? DEFAULT_MULTI_GET_MAX_BYTES;
 

+ 91 - 0
test/store.test.ts

@@ -1909,6 +1909,55 @@ describe("Document Retrieval", () => {
 
       await cleanupTestDb(store);
     });
+
+    test("findDocuments supports brace expansion patterns", async () => {
+      const store = await createTestStore();
+      const collectionName = await createTestCollection();
+
+      await insertTestDocument(store.db, collectionName, {
+        name: "doc1",
+        filepath: "/path/doc1.md",
+        displayPath: "doc1.md",
+      });
+      await insertTestDocument(store.db, collectionName, {
+        name: "doc2",
+        filepath: "/path/doc2.md",
+        displayPath: "doc2.md",
+      });
+      await insertTestDocument(store.db, collectionName, {
+        name: "doc3",
+        filepath: "/path/doc3.md",
+        displayPath: "doc3.md",
+      });
+
+      const { docs, errors } = store.findDocuments("{doc1,doc2}.md");
+      expect(errors).toHaveLength(0);
+      expect(docs).toHaveLength(2);
+
+      await cleanupTestDb(store);
+    });
+
+    test("findDocuments supports brace expansion with collection prefix", async () => {
+      const store = await createTestStore();
+      const collectionName = await createTestCollection();
+
+      await insertTestDocument(store.db, collectionName, {
+        name: "readme",
+        filepath: "/path/readme.md",
+        displayPath: "readme.md",
+      });
+      await insertTestDocument(store.db, collectionName, {
+        name: "changelog",
+        filepath: "/path/changelog.md",
+        displayPath: "changelog.md",
+      });
+
+      const { docs, errors } = store.findDocuments(`${collectionName}/{readme,changelog}.md`);
+      expect(errors).toHaveLength(0);
+      expect(docs).toHaveLength(2);
+
+      await cleanupTestDb(store);
+    });
   });
 
 });
@@ -2267,6 +2316,48 @@ describe("Fuzzy Matching", () => {
 
     await cleanupTestDb(store);
   });
+
+  test("matchFilesByGlob matches collection/path patterns", async () => {
+    const store = await createTestStore();
+    const collectionName = await createTestCollection();
+
+    await insertTestDocument(store.db, collectionName, {
+      filepath: "/p/readme.md",
+      displayPath: "readme.md",
+    });
+    await insertTestDocument(store.db, collectionName, {
+      filepath: "/p/changelog.md",
+      displayPath: "changelog.md",
+    });
+
+    const matches = store.matchFilesByGlob(`${collectionName}/*.md`);
+    expect(matches).toHaveLength(2);
+
+    await cleanupTestDb(store);
+  });
+
+  test("matchFilesByGlob matches brace expansion", async () => {
+    const store = await createTestStore();
+    const collectionName = await createTestCollection();
+
+    await insertTestDocument(store.db, collectionName, {
+      filepath: "/p/readme.md",
+      displayPath: "readme.md",
+    });
+    await insertTestDocument(store.db, collectionName, {
+      filepath: "/p/changelog.md",
+      displayPath: "changelog.md",
+    });
+    await insertTestDocument(store.db, collectionName, {
+      filepath: "/p/license.md",
+      displayPath: "license.md",
+    });
+
+    const matches = store.matchFilesByGlob(`${collectionName}/{readme,changelog}.md`);
+    expect(matches).toHaveLength(2);
+
+    await cleanupTestDb(store);
+  });
 });
 
 // =============================================================================