xp-framework/core

Glob support

thekid opened this issue · 5 comments

Should something along the lines of the following be part of io.FolderEntries?

class Glob implements IteratorAggregate {

  public function __construct(private Path $base, private string $pattern) { }

  public function getIterator(): Traversable {
    foreach (glob($this->base.$this->pattern, GLOB_NOSORT | GLOB_BRACE) as $file) {
      yield new Path($file);
    }
  }
}

Usage idea:

$f= new Folder($argv[1]);
foreach ($f->entries()->matching('*.jpg') as $image) {
  // ...
}

See https://www.php.net/glob

Implementation:

diff --git a/src/main/php/io/FolderEntries.class.php b/src/main/php/io/FolderEntries.class.php
index c1718b3a3..3b0d77abd 100755
--- a/src/main/php/io/FolderEntries.class.php
+++ b/src/main/php/io/FolderEntries.class.php
@@ -31,6 +31,17 @@ class FolderEntries implements IteratorAggregate {
     return new Path($this->base, $name);
   }
 
+  /**
+   * Iterates over all entries matching a given pattern
+   *
+   * @see  https://www.php.net/glob
+   */
+  public function matching(string $pattern): iterable {
+    foreach (glob(rtrim($this->base, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$pattern, GLOB_NOSORT | GLOB_BRACE) as $match) {
+      yield basename($match) => new Path($match);
+    }
+  }
+
   /** Iterate over all entries */
   public function getIterator(): Traversable {
     if (null === $this->handle) {
diff --git a/src/test/php/net/xp_framework/unittest/io/FolderEntriesTest.class.php b/src/test/php/net/xp_framework/unittest/io/FolderEntriesTest.class.php
index 3a2dea37d..36c332023 100755
--- a/src/test/php/net/xp_framework/unittest/io/FolderEntriesTest.class.php
+++ b/src/test/php/net/xp_framework/unittest/io/FolderEntriesTest.class.php
@@ -105,4 +105,41 @@ class FolderEntriesTest extends \unittest\TestCase {
   public function named_dot() {
     $this->assertEquals(new Path($this->folder), (new FolderEntries($this->folder))->named('.'));
   }
+
+  #[Test]
+  public function entries_matching() {
+    (new File($this->folder, 'a.txt'))->touch();
+    (new File($this->folder, 'b.txt'))->touch();
+    (new File($this->folder, 'not-found'))->touch();
+
+    $this->assertEquals(
+      ['a.txt' => new Path($this->folder, 'a.txt'), 'b.txt' => new Path($this->folder, 'b.txt')],
+      iterator_to_array((new FolderEntries($this->folder))->matching('*.txt'))
+    );
+  }
+
+  #[Test]
+  public function entries_matching_ignores_hidden_files() {
+    (new File($this->folder, '.hidden.txt'))->touch();
+
+    $this->assertEquals([], iterator_to_array((new FolderEntries($this->folder))->matching('*.txt')));
+  }
+
+  #[Test]
+  public function entries_matching_is_case_sensitive() {
+    (new File($this->folder, 'C.TXT'))->touch();
+
+    $this->assertEquals([], iterator_to_array((new FolderEntries($this->folder))->matching('*.txt')));
+  }
+
+  #[Test]
+  public function entries_matching_supports_braces() {
+    (new File($this->folder, 'a.txt'))->touch();
+    (new File($this->folder, 'C.TXT'))->touch();
+
+    $this->assertEquals(
+      ['a.txt' => new Path($this->folder, 'a.txt'), 'C.TXT' => new Path($this->folder, 'C.TXT')],
+      iterator_to_array((new FolderEntries($this->folder))->matching('*.{txt,TXT}'))
+    );
+  }
 }
\ No newline at end of file

https://gist.github.com/thekid/795c35bec603c0f217cb5445cc901ded could be simplified if this was implemented:

- class Glob implements IteratorAggregate {
-
-   public function __construct(private Path $base, private string $pattern) { }
-
-   public function getIterator(): Traversable {
-     foreach (glob($this->base.$this->pattern, GLOB_NOSORT | GLOB_BRACE) as $file) {
-       yield new Path($file);
-     }
-   }
- }
@@ ... @@
- foreach ($p->isFile() ? [$p] : new Glob($p, 'thumb*.webp') as $entry) { ... }
+ foreach ($p->isFile() ? [$p] : $p->entries()->matching('thumb*.webp') as $entry) { ... }

Should there also be io.Path::match() which uses https://www.php.net/fnmatch? - including a replacement for non-POSIX compliant systems except Windows where this function is not available.

Should there also be io.Path::match() which uses https://www.php.net/fnmatch? - including a replacement for non-POSIX compliant systems except Windows where this function is not available.

Using fnmatch() means an inconsistency with glob(), the first doesn't support braces. Here's a replacement:

class Path {

  // ...

  public function matches($pattern): bool {
    $regex= '';
    $o= 0;
    $l= strlen($pattern);
    do {
      $s= strcspn($pattern, '.*?[]{}\\', $o);
      $regex.= substr($pattern, $o, $s);
      $o+= $s;
      switch ($pattern[$o] ?? null) {
        case null: break;
        case '.': $regex.= '\\.'; break;
        case '*': $regex.= '.*'; break;
        case '\\': $regex.= '\\'.$pattern[++$o]; break;
        case '{':
          $s= strcspn($pattern, '}', $o);
          $regex.= '('.strtr(substr($pattern, $o + 1, $s - 1), ',', '|').')';
          $o+= $s;
          break;
        case '[':
          $s= strcspn($pattern, ']', $o);
          $regex.= '['.('!' === $pattern[$o + 1] ? '^' : $pattern[$o + 1]).substr($pattern, $o + 2, $s - 2).']';
          $o+= $s;
          break;
      }
    } while (++$o < $l);

    return preg_match('~'.$regex.'$~', $this->path);
  }
}

Alternatively, move this to a separate library, which also supersedes https://github.com/xp-framework/io-collections in Sequence-style interface:

Files::in($folder)
  ->matching('thumb*.webp')
  ->toMap(fn($file) => yield $file->name => $file->size())
;