diff --git a/README.md b/README.md index 8f4dafb..5d0e982 100755 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Compression streams [![Supports PHP 8.0+](https://raw.githubusercontent.com/xp-framework/web/master/static/php-8_0plus.svg)](http://php.net/) [![Latest Stable Version](https://poser.pugx.org/xp-forge/compression/version.svg)](https://packagist.org/packages/xp-forge/compression) -Compressing output and decompressing input streams including GZip, BZip2, ZStandard and Brotli. +Compressing output and decompressing input streams including GZip, BZip2, Brotli, Snappy and ZStandard. Examples -------- @@ -39,11 +39,12 @@ $out->close(); Dependencies ------------ -Compression algorithms are implemented in C and thus require a specific PHP extension: +Compression algorithms might require a specific PHP extension: * **GZip** - requires PHP's ["zlib" extension](https://www.php.net/zlib) * **Bzip2** - requires PHP's ["bzip2" extension](https://www.php.net/bzip2) * **Brotli** - requires https://github.com/kjdev/php-ext-brotli +* **Snappy** - *no dependencies, implemented in userland* * **ZStandard** - requires https://github.com/kjdev/php-ext-zstd Accessing these algorithms can be done via the `Compression` API: @@ -97,6 +98,7 @@ io.streams.compress.Algorithms@{ io.streams.compress.Gzip(token: gzip, extension: .gz, supported: true, levels: 1..9) io.streams.compress.Bzip2(token: bzip2, extension: .bz2, supported: false, levels: 1..9) io.streams.compress.Brotli(token: br, extension: .br, supported: true, levels: 1..11) + io.streams.compress.Snappy(token: snappy, extension: .sn, supported: true, levels: 0..0) io.streams.compress.ZStandard(token: zstd, extension: .zstd, supported: true, levels: 1..22) } ``` @@ -137,4 +139,5 @@ $in->close(); See also -------- -* The PHP RFC [Modern Compression](https://wiki.php.net/rfc/modern_compression) suggests adding *zstd* and *brotli* into PHP. \ No newline at end of file +* The PHP RFC [Modern Compression](https://wiki.php.net/rfc/modern_compression) suggests adding *zstd* and *brotli* into PHP. +* Snappy *does not aim for maximum compression, or compatibility with any other compression library; instead, it aims for very high speeds and reasonable compression*, quoting [its Wikipedia page](https://en.wikipedia.org/wiki/Snappy_(compression)) \ No newline at end of file diff --git a/src/main/php/io/streams/Compression.class.php b/src/main/php/io/streams/Compression.class.php index 712dd10..9359af3 100755 --- a/src/main/php/io/streams/Compression.class.php +++ b/src/main/php/io/streams/Compression.class.php @@ -1,6 +1,6 @@ add(new Gzip(), new Bzip2(), new Brotli(), new ZStandard()); + self::$algorithms= (new Algorithms())->add( + new Gzip(), + new Bzip2(), + new Brotli(), + new Snappy(), + new ZStandard() + ); } /** diff --git a/src/main/php/io/streams/compress/BufferedInputStream.class.php b/src/main/php/io/streams/compress/BufferedInputStream.class.php new file mode 100755 index 0000000..8684cbc --- /dev/null +++ b/src/main/php/io/streams/compress/BufferedInputStream.class.php @@ -0,0 +1,78 @@ +decompress= [$decompress, 'decompress']; + } else if (is_callable($decompress)) { + $this->decompress= $decompress; + } else { + throw new IllegalArgumentException('Expected an Algorithm or a callable, have '.typeof($decompress)); + } + $this->in= $in; + } + + /** @return string */ + private function buffer() { + if (null === $this->buffer) { + $compressed= ''; + while ($this->in->available()) { + $compressed.= $this->in->read(); + } + + $this->buffer= ($this->decompress)($compressed); + } + return $this->buffer; + } + + /** + * Read a string + * + * @param int limit default 8192 + * @return string + */ + public function read($limit= 8192) { + $chunk= substr($this->buffer(), $this->position, $limit); + $this->position+= strlen($chunk); + return $chunk; + } + + /** + * Returns the number of bytes that can be read from this stream + * without blocking. + * + * @return int + */ + public function available() { + return strlen($this->buffer()) - $this->position; + } + + /** + * Close this buffer. + * + * @return void + */ + public function close() { + $this->buffer= null; + $this->in->close(); + } + + /** Ensures input stream is closed */ + public function __destruct() { + $this->close(); + } +} \ No newline at end of file diff --git a/src/main/php/io/streams/compress/BufferedOutputStream.class.php b/src/main/php/io/streams/compress/BufferedOutputStream.class.php new file mode 100755 index 0000000..22b72ea --- /dev/null +++ b/src/main/php/io/streams/compress/BufferedOutputStream.class.php @@ -0,0 +1,67 @@ +compress= [$compress, 'compress']; + } else if (is_callable($compress)) { + $this->compress= $compress; + } else { + throw new IllegalArgumentException('Expected an Algorithm or a callable, have '.typeof($compress)); + } + $this->out= $out; + } + + /** + * Write a string + * + * @param var $arg + * @return void + */ + public function write($arg) { + $this->buffer.= $arg; + } + + /** + * Flush this buffer + * + * @return void + */ + public function flush() { + // NOOP + } + + /** + * Closes this object. May be called more than once, which may + * not fail - that is, if the object is already closed, this + * method should have no effect. + * + * @return void + */ + public function close() { + if (null !== $this->buffer) { + $this->out->write(($this->compress)($this->buffer)); + $this->buffer= null; + } + $this->out->close(); + } + + /** Ensures output stream is closed */ + public function __destruct() { + $this->close(); + } +} \ No newline at end of file diff --git a/src/main/php/io/streams/compress/Snappy.class.php b/src/main/php/io/streams/compress/Snappy.class.php new file mode 100755 index 0000000..0151118 --- /dev/null +++ b/src/main/php/io/streams/compress/Snappy.class.php @@ -0,0 +1,240 @@ +> 7; + if ($length > 0) { + $out.= chr($l + 0x80); + goto shift; + } + + return $out.chr($l); + } + + /** Encode literal operation */ + public static function literal(int $l): string { + if ($l <= 60) { + return chr(($l - 1) << 2); + } else if ($l < 256) { + return pack('CC', 60 << 2, $l - 1); + } else { + return pack('CCC', 61 << 2, ($l - 1) & 0xff, (($l - 1) & 0xffffffff) >> 8); + } + } + + /** Encode copy operation */ + public static function copy(int $i, int $l): string { + if ($l < 12 && $i < 2048) { + return pack('CC', 1 + (($l - 4) << 2) + ((($i & 0xffffffff) >> 8) << 5), $i & 0xff); + } else if ($i < 65536) { + return pack('CCC', 2 + (($l - 1) << 2), $i & 0xff, ($i & 0xffffffff) >> 8); + } else { + return pack('CV', 3 + (($l - 1) << 2), $i & 0xffffffff); + } + } + + /** Compresses data */ + public function compress(string $data, $options= null): string { + $length= strlen($data); + $out= self::length($length); + + // Inlined comparison of 4-byte offsets in data at offsets a and b + $equals32= fn($a, $b) => ( + $data[$a] === $data[$b] && + $data[$a + 1] === $data[$b + 1] && + $data[$a + 2] === $data[$b + 2] && + $data[$a + 3] === $data[$b + 3] + ); + + for ($pos= 0; $pos < $length; $pos= $end) { + $fragment= min($length - $pos, self::BLOCK_SIZE); + $end= $pos + $fragment; + $emit= $pos; + + if ($fragment >= self::INPUT_MARGIN) { + $bits= 1; + while ((1 << $bits) <= $fragment && $bits <= self::HASH_BITS) { + $bits++; + } + $bits--; + $shift= 32 - $bits; + $hashtable= array_fill(0, 1 << $bits, 0); + + $start= $pos; + $limit= $end - self::INPUT_MARGIN; + $next= ((unpack('V', $data, ++$pos)[1] * self::HASH_KEY) & 0xffffffff) >> $shift; + + // Emit literals + next: $forward= $pos; + $skip= 32; + do { + $pos= $forward; + $hash= $next; + $forward+= ($skip & 0xffffffff) >> 5; + $skip++; + if ($pos > $limit || $forward > $limit) goto emit; + + $next= ((unpack('V', $data, $forward)[1] * self::HASH_KEY) & 0xffffffff) >> $shift; + $candidate= $start + $hashtable[$hash]; + $hashtable[$hash]= ($pos - $start) & 0xffff; + } while (!$equals32($pos, $candidate)); + + $out.= self::literal($pos - $emit).substr($data, $emit, $pos - $emit); + + // Emit copy instructions + do { + $offset= $pos - $candidate; + $matched= 4; + while ($pos + $matched < $end && $data[$pos + $matched] === $data[$candidate + $matched]) { + $matched++; + } + $pos+= $matched; + + while ($matched >= 68) { + $out.= self::copy($offset, 64); + $matched-= 64; + } + if ($matched > 64) { + $out.= self::copy($offset, 60); + $matched-= 60; + } + $out.= self::copy($offset, $matched); + $emit= $pos; + + if ($pos >= $limit) goto emit; + + $hash= ((unpack('V', $data, $pos - 1)[1] * self::HASH_KEY) & 0xffffffff) >> $shift; + $hashtable[$hash]= ($pos - 1 - $start) & 0xffff; + $hash= ((unpack('V', $data, $pos)[1] * self::HASH_KEY) & 0xffffffff) >> $shift; + $candidate= $start + $hashtable[$hash]; + $hashtable[$hash]= ($pos - $start) & 0xffff; + } while ($equals32($pos, $candidate)); + + $pos++; + $next= ((unpack('V', $data, $pos)[1] * self::HASH_KEY) & 0xffffffff) >> $shift; + goto next; + } + + emit: if ($emit < $end) { + $out.= self::literal($end - $emit).substr($data, $emit, $end - $emit); + } + } + + return $out; + } + + /** Decompresses bytes */ + public function decompress(string $bytes): string { + $out= ''; + $pos= 0; + + // Read uncompressed length from varint + for ($length= $shift= 0, $c= 255; $shift < 32, $c >= 128; $pos++, $shift+= 7) { + $c= ord($bytes[$pos]); + $length|= ($c & 0x7f) << $shift; + } + + // Decompress using literal and copy operations + $limit= strlen($bytes); + while ($pos < $limit) { + $c= ord($bytes[$pos++]); + switch ($c & 0x03) { + case 0: + $l= $c >> 2; + if ($l >= 60) { + $n= $l - 59; + if ($pos + $n >= $limit) throw new IOException('Not enough input, expected '.$n); + $l= unpack('P', str_pad(substr($bytes, $pos, $n), 8, "\0"))[1]; + $pos+= $n; + } + + $l++; + if ($pos + $l > $limit) throw new IOException('Not enough input, expected '.$l); + + $out.= substr($bytes, $pos, $l); + $pos+= $l; + break; + + case 1: + $l= 4 + (($c >> 2) & 0x7); + $offset= ord($bytes[$pos]) + (($c >> 5) << 8); + for ($i= 0, $end= strlen($out) - $offset; $i < $l; $i++) { + $out.= $out[$end + $i]; + } + $pos++; + break; + + case 2: + if ($pos + 1 >= $limit) throw new IOException('Not enough input, expected 1'); + + $l= 1 + ($c >> 2); + $offset= unpack('v', $bytes, $pos)[1]; + for ($i= 0, $end= strlen($out) - $offset; $i < $l; $i++) { + $out.= $out[$end + $i]; + } + $pos+= 2; + break; + + case 3: + if ($pos + 3 >= $limit) throw new IOException('Not enough input, expected 3'); + + $l= 1 + ($c >> 2); + $offset= unpack('V', $bytes, $pos)[1]; + for ($i= 0, $end= strlen($out) - $offset; $i < $l; $i++) { + $out.= $out[$end + $i]; + } + $pos+= 4; + break; + } + } + + // Verify uncompressed length + if ($length !== ($l= strlen($out))) { + throw new IOException('Expected length '.$length.', have '.$l); + } + + return $out; + } + + /** Opens an input stream for reading */ + public function open(InputStream $in): InputStream { + return new SnappyInputStream($in); + } + + /** Opens an output stream for writing */ + public function create(OutputStream $out, $options= null): OutputStream { + if (null !== ($length= Options::from($options)->length)) { + return new SnappyOutputStream($out, $length); + } else { + return new BufferedOutputStream($out, [$this, 'compress']); + } + } +} \ No newline at end of file diff --git a/src/main/php/io/streams/compress/SnappyInputStream.class.php b/src/main/php/io/streams/compress/SnappyInputStream.class.php new file mode 100755 index 0000000..bb73fce --- /dev/null +++ b/src/main/php/io/streams/compress/SnappyInputStream.class.php @@ -0,0 +1,130 @@ +buffer) < $n) { + if ($this->in->available()) { + $this->buffer.= $this->in->read(); + } else { + throw new IOException('Not enough input, expected '.$n); + } + } + + $chunk= substr($this->buffer, 0, $n); + $this->buffer= substr($this->buffer, $n); + return $chunk; + } + + /** + * Creates a new decompressing input stream + * + * @param io.streams.InputStream $in The stream to read from + */ + public function __construct(InputStream $in) { + $this->in= $in; + $this->out= ''; + for ($shift= 0, $c= 255; $shift < 32, $c >= 128; $shift+= 7) { + $c= ord($this->bytes(1)); + $this->limit|= ($c & 0x7f) << $shift; + } + } + + /** + * Read a string + * + * @param int limit default 8192 + * @return string + */ + public function read($limit= 8192) { + $pos= $start= strlen($this->out); + $limit= min($limit + $start, $this->limit); + + while ($pos < $limit) { + $c= ord($this->bytes(1)); + switch ($c & 0x03) { + case 0: + $l= $c >> 2; + if ($l >= 60) { + $l= unpack('P', str_pad($this->bytes($l - 59), 8, "\0"))[1]; + } + $this->out.= $this->bytes(++$l); + break; + + case 1: + $l= 4 + (($c >> 2) & 0x7); + $offset= ord($this->bytes(1)) + (($c >> 5) << 8); + for ($i= 0, $end= strlen($this->out) - $offset; $i < $l; $i++) { + $this->out.= $this->out[$end + $i]; + } + break; + + case 2: + $l= 1 + ($c >> 2); + $offset= unpack('v', $this->bytes(2))[1]; + for ($i= 0, $end= strlen($this->out) - $offset; $i < $l; $i++) { + $this->out.= $this->out[$end + $i]; + } + break; + + case 3: + $l= 1 + ($c >> 2); + $offset= unpack('V', $this->bytes(4))[1]; + for ($i= 0, $end= strlen($this->out) - $offset; $i < $l; $i++) { + $this->out.= $this->out[$end + $i]; + } + break; + } + $pos+= $l; + } + + $chunk= substr($this->out, $start); + + // Once block size is reached, offets never reference anything before, + // free memory by removing one block from the front of the output. + while (strlen($this->out) > Snappy::BLOCK_SIZE) { + $this->out= substr($this->out, Snappy::BLOCK_SIZE); + $this->limit-= Snappy::BLOCK_SIZE; + } + + return $chunk; + } + + /** + * Returns the number of bytes that can be read from this stream + * without blocking. + * + * @return int + */ + public function available() { + return $this->limit - strlen($this->out); + } + + /** + * Close this buffer. + * + * @return void + */ + public function close() { + $this->in->close(); + } + + /** Ensures input stream is closed */ + public function __destruct() { + $this->close(); + } +} \ No newline at end of file diff --git a/src/main/php/io/streams/compress/SnappyOutputStream.class.php b/src/main/php/io/streams/compress/SnappyOutputStream.class.php new file mode 100755 index 0000000..7c23880 --- /dev/null +++ b/src/main/php/io/streams/compress/SnappyOutputStream.class.php @@ -0,0 +1,151 @@ +out= $out; + $this->out->write(Snappy::length($length)); + } + + /** Compare 4-byte offsets in data at offsets a and b */ + private function equals32(int $a, int $b): bool { + return ( + $this->buffer[$a] === $this->buffer[$b] && + $this->buffer[$a + 1] === $this->buffer[$b + 1] && + $this->buffer[$a + 2] === $this->buffer[$b + 2] && + $this->buffer[$a + 3] === $this->buffer[$b + 3] + ); + } + + /** Compresses a fragment and returns last emitted position */ + private function fragment() { + $end= min(strlen($this->buffer), Snappy::BLOCK_SIZE); + $pos= $emit= 0; + $out= ''; + + if ($end >= Snappy::INPUT_MARGIN) { + $bits= 1; + while ((1 << $bits) <= $end && $bits <= Snappy::HASH_BITS) { + $bits++; + } + $bits--; + $shift= 32 - $bits; + $hashtable= array_fill(0, 1 << $bits, 0); + + $limit= $end - Snappy::INPUT_MARGIN; + $next= ((unpack('V', $this->buffer, ++$pos)[1] * Snappy::HASH_KEY) & 0xffffffff) >> $shift; + + // Emit literals + next: $forward= $pos; + $skip= 32; + do { + $pos= $forward; + $hash= $next; + $forward+= ($skip & 0xffffffff) >> 5; + $skip++; + if ($pos > $limit || $forward > $limit) goto emit; + + $next= ((unpack('V', $this->buffer, $forward)[1] * Snappy::HASH_KEY) & 0xffffffff) >> $shift; + $candidate= $hashtable[$hash]; + $hashtable[$hash]= $pos & 0xffff; + } while (!$this->equals32($pos, $candidate)); + + $out.= Snappy::literal($pos - $emit).substr($this->buffer, $emit, $pos - $emit); + + // Emit copy instructions + do { + $offset= $pos - $candidate; + $matched= 4; + while ($pos + $matched < $end && $this->buffer[$pos + $matched] === $this->buffer[$candidate + $matched]) { + $matched++; + } + $pos+= $matched; + + while ($matched >= 68) { + $out.= Snappy::copy($offset, 64); + $matched-= 64; + } + if ($matched > 64) { + $out.= Snappy::copy($offset, 60); + $matched-= 60; + } + $out.= Snappy::copy($offset, $matched); + $emit= $pos; + + if ($pos >= $limit) goto emit; + + $hash= ((unpack('V', $this->buffer, $pos - 1)[1] * Snappy::HASH_KEY) & 0xffffffff) >> $shift; + $hashtable[$hash]= ($pos - 1) & 0xffff; + $hash= ((unpack('V', $this->buffer, $pos)[1] * Snappy::HASH_KEY) & 0xffffffff) >> $shift; + $candidate= $hashtable[$hash]; + $hashtable[$hash]= $pos & 0xffff; + } while ($this->equals32($pos, $candidate)); + + $pos++; + $next= ((unpack('V', $this->buffer, $pos)[1] * Snappy::HASH_KEY) & 0xffffffff) >> $shift; + goto next; + } + + emit: if ($emit < $end) { + $out.= Snappy::literal($end - $emit).substr($this->buffer, $emit, $end - $emit); + } + + $this->buffer= substr($this->buffer, $end); + return $out; + } + + /** + * Write a string + * + * @param var $arg + * @return void + */ + public function write($arg) { + $this->buffer.= $arg; + if (strlen($this->buffer) > Snappy::BLOCK_SIZE) { + $this->out->write($this->fragment()); + } + } + + /** + * Flush this buffer + * + * @return void + */ + public function flush() { + if (strlen($this->buffer) > 0) { + $this->out->write($this->fragment()); + } + } + + /** + * Closes this object. May be called more than once, which may + * not fail - that is, if the object is already closed, this + * method should have no effect. + * + * @return void + */ + public function close() { + if (strlen($this->buffer) > 0) { + $this->out->write($this->fragment()); + $this->buffer= ''; + } + $this->out->close(); + } + + /** Ensures output stream is closed */ + public function __destruct() { + $this->close(); + } +} \ No newline at end of file diff --git a/src/test/php/io/streams/compress/unittest/BufferedInputStreamTest.class.php b/src/test/php/io/streams/compress/unittest/BufferedInputStreamTest.class.php new file mode 100755 index 0000000..da9f27d --- /dev/null +++ b/src/test/php/io/streams/compress/unittest/BufferedInputStreamTest.class.php @@ -0,0 +1,36 @@ + $data); + } + + #[Test, Expect(IllegalArgumentException::class)] + public function illegal_compress() { + new BufferedInputStream(new MemoryInputStream(''), null); + } + + #[Test, Values([1, 8192, 8193, 65536])] + public function read_completely($repeat) { + $in= new BufferedInputStream(new MemoryInputStream($repeat), fn($data) => str_repeat('*', (int)$data)); + + $decompressed= ''; + while ($in->available()) { + $decompressed.= $in->read(); + } + + Assert::equals($repeat, strlen($decompressed)); + } +} \ No newline at end of file diff --git a/src/test/php/io/streams/compress/unittest/BufferedOutputStreamTest.class.php b/src/test/php/io/streams/compress/unittest/BufferedOutputStreamTest.class.php new file mode 100755 index 0000000..3b0fb01 --- /dev/null +++ b/src/test/php/io/streams/compress/unittest/BufferedOutputStreamTest.class.php @@ -0,0 +1,52 @@ + $data); + } + + #[Test, Expect(IllegalArgumentException::class)] + public function illegal_compress() { + new BufferedOutputStream(new MemoryOutputStream(), null); + } + + #[Test] + public function writes_on_close() { + $out= new MemoryOutputStream(); + + $compress= new BufferedOutputStream($out, fn($data) => 'Z:'.strlen($data)); + $compress->write('Test'); + $compress->write('ed'); + $compress->close(); + + Assert::equals('Z:6', $out->bytes()); + } + + #[Test] + public function closes_underlying_stream() { + $out= new class() implements OutputStream { + public $closed= false; + public function write($bytes) { } + public function flush() { } + public function close() { $this->closed= true; } + }; + + $compress= new BufferedOutputStream($out, new None()); + $closed= $out->closed; + $compress->close(); + + Assert::equals([false, true], [$closed, $out->closed]); + } +} \ No newline at end of file diff --git a/src/test/php/io/streams/compress/unittest/CompressionTest.class.php b/src/test/php/io/streams/compress/unittest/CompressionTest.class.php index 9445010..ec7a4cd 100755 --- a/src/test/php/io/streams/compress/unittest/CompressionTest.class.php +++ b/src/test/php/io/streams/compress/unittest/CompressionTest.class.php @@ -50,6 +50,11 @@ private function erroneous() { yield [$bzip2, "BZh61AY&SY\331"]; } + $snappy= $algorithms->named('snappy'); + if ($snappy->supported()) { + yield [$snappy, "\002"]; + } + $brotli= $algorithms->named('brotli'); if ($brotli->supported()) { yield [$brotli, ""]; @@ -67,7 +72,7 @@ public function enumerating_included_algorithms() { foreach (Compression::algorithms() as $name => $algorithm) { $names[]= $name; } - Assert::equals(['gzip', 'bzip2', 'brotli', 'zstandard'], $names); + Assert::equals(['gzip', 'bzip2', 'brotli', 'snappy', 'zstandard'], $names); } #[Test] @@ -112,7 +117,7 @@ public function unknown($name) { #[Test, Values(from: 'algorithms')] public function compress_roundtrip($compressed) { - $bytes= $compressed->compress('Test', Compression::DEFAULT); + $bytes= $compressed->compress('Test'); $result= $compressed->decompress($bytes); Assert::equals('Test', $result); @@ -122,7 +127,7 @@ public function compress_roundtrip($compressed) { public function streams_roundtrip($compressed) { $target= new MemoryOutputStream(); - $out= $compressed->create($target, Compression::DEFAULT); + $out= $compressed->create($target); $out->write('Test'); $out->close(); diff --git a/src/test/php/io/streams/compress/unittest/SnappyInputStreamTest.class.php b/src/test/php/io/streams/compress/unittest/SnappyInputStreamTest.class.php new file mode 100755 index 0000000..47ff065 --- /dev/null +++ b/src/test/php/io/streams/compress/unittest/SnappyInputStreamTest.class.php @@ -0,0 +1,53 @@ +fixture("\x00"); + } + + #[Test, Values([[5, "\005\020"], [255, "\377\001\360\376"], [256, "\200\002\364\377\000"]])] + public function literals($length, $encoded) { + $payload= str_repeat('*', $length); + Assert::equals($payload, Streams::readAll($this->fixture($encoded.$payload))); + } + + #[Test] + public function consecutive_literals() { + $ones= str_repeat('1', 255); + $twos= str_repeat('2', 255); + Assert::equals( + $ones.$twos, + Streams::readAll($this->fixture("\376\003\360\376{$ones}\360\376{$twos}")) + ); + } + + #[Test] + public function copy() { + Assert::equals( + "Hello\n=================", + Streams::readAll($this->fixture("\027\030Hello\n=\076\001\000")) + ); + } + + #[Test, Expect(class: IOException::class, message: 'Not enough input, expected 1')] + public function from_empty() { + Streams::readAll($this->fixture('')); + } + + #[Test, Expect(class: IOException::class, message: 'Not enough input, expected 1')] + public function not_enough_input() { + Streams::readAll($this->fixture("\x01")); + } +} \ No newline at end of file diff --git a/src/test/php/io/streams/compress/unittest/SnappyOutputStreamTest.class.php b/src/test/php/io/streams/compress/unittest/SnappyOutputStreamTest.class.php new file mode 100755 index 0000000..b28baf3 --- /dev/null +++ b/src/test/php/io/streams/compress/unittest/SnappyOutputStreamTest.class.php @@ -0,0 +1,61 @@ +fixture(new MemoryOutputStream(), 0); + } + + #[Test, Values([[0, "\000"], [5, "\005"], [255, "\377\001"], [256, "\200\002"], [65536, "\200\200\004"]])] + public function length_as_varint($length, $expected) { + $out= new MemoryOutputStream(); + $this->fixture($out, $length); + + Assert::equals(new Bytes($expected), new Bytes($out->bytes())); + } + + #[Test] + public function literal() { + $out= new MemoryOutputStream(); + $compress= $this->fixture($out, 5); + $compress->write('Hello'); + $compress->close(); + + Assert::equals(new Bytes("\005\020Hello"), new Bytes($out->bytes())); + } + + #[Test] + public function copy() { + $out= new MemoryOutputStream(); + $compress= $this->fixture($out, 23); + $compress->write("Hello\n================="); + $compress->close(); + + Assert::equals(new Bytes("\027\030Hello\n=\076\001\000"), new Bytes($out->bytes())); + } + + #[Test] + public function repeated_input_compressed() { + $out= new MemoryOutputStream(); + $compress= $this->fixture($out, 20); + $compress->write('Hello'); + $compress->write('Hello'); + $compress->write('Hello'); + $compress->write('Hello'); + $compress->write('!'); + $compress->close(); + + Assert::equals(new Bytes("\024\020Hello:\005\000\000!"), new Bytes($out->bytes())); + } +} \ No newline at end of file