Add a Codec for the chunked transfer coding. (#8)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9802eb1..c12d2cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 3.1.0
+
+* Add `chunkedCoding`, a `Codec` that supports encoding and decoding the
+ [chunked transfer coding][].
+
+[chunked transfer coding]: https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6.1
+
## 3.0.2
* Support `string_scanner` 1.0.0.
diff --git a/lib/http_parser.dart b/lib/http_parser.dart
index 684c0b5..77b20c7 100644
--- a/lib/http_parser.dart
+++ b/lib/http_parser.dart
@@ -4,5 +4,6 @@
export 'src/authentication_challenge.dart';
export 'src/case_insensitive_map.dart';
+export 'src/chunked_coding.dart';
export 'src/http_date.dart';
export 'src/media_type.dart';
diff --git a/lib/src/chunked_coding.dart b/lib/src/chunked_coding.dart
new file mode 100644
index 0000000..8d2326c
--- /dev/null
+++ b/lib/src/chunked_coding.dart
@@ -0,0 +1,39 @@
+// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:convert';
+
+import 'chunked_coding/encoder.dart';
+import 'chunked_coding/decoder.dart';
+
+export 'chunked_coding/encoder.dart' hide chunkedCodingEncoder;
+export 'chunked_coding/decoder.dart' hide chunkedCodingDecoder;
+
+/// The canonical instance of [ChunkedCodec].
+const chunkedCoding = const ChunkedCodingCodec._();
+
+/// A codec that encodes and decodes the [chunked transfer coding][].
+///
+/// [chunked transfer coding]: https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6.1
+///
+/// The [encoder] creates a *single* chunked message for each call to
+/// [ChunkedEncoder.convert] or [ChunkedEncoder.startChunkedConversion]. This
+/// means that it will always add an end-of-message footer once conversion has
+/// finished. It doesn't support generating chunk extensions or trailing
+/// headers.
+///
+/// Similarly, the [decoder] decodes a *single* chunked message into a stream of
+/// byte arrays that must be concatenated to get the full list (like most Dart
+/// byte streams). It doesn't support decoding a stream that contains multiple
+/// chunked messages, nor does it support a stream that contains chunked data
+/// mixed with other types of data.
+///
+/// Currently, [decoder] will fail to parse chunk extensions and trailing
+/// headers. It may be updated to silently ignore them in the future.
+class ChunkedCodingCodec extends Codec<List<int>, List<int>> {
+ ChunkedCodingEncoder get encoder => chunkedCodingEncoder;
+ ChunkedCodingDecoder get decoder => chunkedCodingDecoder;
+
+ const ChunkedCodingCodec._();
+}
diff --git a/lib/src/chunked_coding/decoder.dart b/lib/src/chunked_coding/decoder.dart
new file mode 100644
index 0000000..e2a27fc
--- /dev/null
+++ b/lib/src/chunked_coding/decoder.dart
@@ -0,0 +1,212 @@
+// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:convert';
+import 'dart:math' as math;
+import 'dart:typed_data';
+
+import 'package:charcode/ascii.dart';
+import 'package:typed_data/typed_data.dart';
+
+/// The canonical instance of [ChunkedCodingDecoder].
+const chunkedCodingDecoder = const ChunkedCodingDecoder._();
+
+/// A converter that decodes byte arrays into chunks with size tags.
+class ChunkedCodingDecoder extends Converter<List<int>, List<int>> {
+ const ChunkedCodingDecoder._();
+
+ List<int> convert(List<int> bytes) {
+ var sink = new _Sink(null);
+ var output = sink._decode(bytes, 0, bytes.length);
+ if (sink._state == _State.end) return output;
+
+ throw new FormatException(
+ "Input ended unexpectedly.", bytes, bytes.length);
+ }
+
+ ByteConversionSink startChunkedConversion(Sink<List<int>> sink) =>
+ new _Sink(sink);
+}
+
+/// A conversion sink for the chunked transfer encoding.
+class _Sink extends ByteConversionSinkBase {
+ /// The underlying sink to which decoded byte arrays will be passed.
+ final Sink<List<int>> _sink;
+
+ /// The current state of the sink's parsing.
+ var _state = _State.boundary;
+
+ /// The size of the chunk being parsed, or `null` if the size hasn't been
+ /// parsed yet.
+ int _size;
+
+ _Sink(this._sink);
+
+ void add(List<int> chunk) => addSlice(chunk, 0, chunk.length, false);
+
+ void addSlice(List<int> chunk, int start, int end, bool isLast) {
+ RangeError.checkValidRange(start, end, chunk.length);
+ var output = _decode(chunk, start, end);
+ if (output.isNotEmpty) _sink.add(output);
+ if (isLast) _close(chunk, end);
+ }
+
+ void close() => _close();
+
+ /// Like [close], but includes [chunk] and [index] in the [FormatException] if
+ /// one is thrown.
+ void _close([List<int> chunk, int index]) {
+ if (_state != _State.end) {
+ throw new FormatException("Input ended unexpectedly.", chunk, index);
+ }
+
+ _sink.close();
+ }
+
+ /// Decodes the data in [bytes] from [start] to [end].
+ Uint8List _decode(List<int> bytes, int start, int end) {
+ /// Throws a [FormatException] if `bytes[start] != $char`. Uses [name] to
+ /// describe the character in the exception text.
+ assertCurrentChar(int char, String name) {
+ if (bytes[start] != char) {
+ throw new FormatException("Expected LF.", bytes, start);
+ }
+ }
+
+ var buffer = new Uint8Buffer();
+ while (start != end) {
+ switch (_state) {
+ case _State.boundary:
+ _size = _digitForByte(bytes, start);
+ _state = _State.size;
+ start++;
+ break;
+
+ case _State.size:
+ if (bytes[start] == $cr) {
+ _state = _State.beforeLF;
+ } else {
+ // Shift four bits left since a single hex digit contains four bits
+ // of information.
+ _size = (_size << 4) + _digitForByte(bytes, start);
+ }
+ start++;
+ break;
+
+ case _State.beforeLF:
+ assertCurrentChar($lf, "LF");
+ _state = _size == 0 ? _State.endBeforeCR : _State.body;
+ start++;
+ break;
+
+ case _State.body:
+ var chunkEnd = math.min(end, start + _size);
+ buffer.addAll(bytes, start, chunkEnd);
+ _size -= chunkEnd - start;
+ start = chunkEnd;
+ if (_size == 0) _state = _State.boundary;
+ break;
+
+ case _State.endBeforeCR:
+ assertCurrentChar($cr, "CR");
+ _state = _State.endBeforeLF;
+ start++;
+ break;
+
+ case _State.endBeforeLF:
+ assertCurrentChar($lf, "CR");
+ _state = _State.end;
+ start++;
+ break;
+
+ case _State.end:
+ throw new FormatException("Expected no more data.", bytes, start);
+ }
+ }
+ return buffer.buffer.asUint8List(0, buffer.length);
+ }
+
+ /// Returns the hex digit (0 through 15) corresponding to the byte at index
+ /// [i] in [bytes].
+ ///
+ /// If the given byte isn't a hexadecimal ASCII character, throws a
+ /// [FormatException].
+ int _digitForByte(List<int> bytes, int index) {
+ // If the byte is a numeral, get its value. XOR works because 0 in ASCII is
+ // `0b110000` and the other numerals come after it in ascending order and
+ // take up at most four bits.
+ //
+ // We check for digits first because it ensures there's only a single branch
+ // for 10 out of 16 of the expected cases. We don't count the `digit >= 0`
+ // check because branch prediction will always work on it for valid data.
+ var byte = bytes[index];
+ var digit = $0 ^ byte;
+ if (digit <= 9) {
+ if (digit >= 0) return digit;
+ } else {
+ // If the byte is an uppercase letter, convert it to lowercase. This works
+ // because uppercase letters in ASCII are exactly `0b100000 = 0x20` less
+ // than lowercase letters, so if we ensure that that bit is 1 we ensure that
+ // the letter is lowercase.
+ var letter = 0x20 | byte;
+ if ($a <= letter && letter <= $f) return letter - $a + 10;
+ }
+
+ throw new FormatException(
+ "Invalid hexadecimal byte 0x${byte.toRadixString(16).toUpperCase()}.",
+ bytes, index);
+ }
+}
+
+/// An enumeration of states that [_Sink] can exist in when decoded a chunked
+/// message.
+///
+/// [_SizeState], [_CRState], and [_ChunkState] have additional data attached.
+class _State {
+ /// The parser has fully parsed one chunk and is expecting the header for the
+ /// next chunk.
+ ///
+ /// Transitions to [size].
+ static const boundary = const _State._("boundary");
+
+ /// The parser has parsed at least one digit of the chunk size header, but has
+ /// not yet parsed the `CR LF` sequence that indicates the end of that header.
+ ///
+ /// Transitions to [beforeLF].
+ static const size = const _State._("size");
+
+ /// The parser has parsed the chunk size header and the CR character after it,
+ /// but not the LF.
+ ///
+ /// Transitions to [body] or [endBeforeCR].
+ static const beforeLF = const _State._("before LF");
+
+ /// The parser has parsed a chunk header and possibly some of the body, but
+ /// still needs to consume more bytes.
+ ///
+ /// Transitions to [boundary].
+ static const body = const _State._("CR");
+
+ /// The parser has parsed the final empty chunk but not the CR LF sequence
+ /// that follows it.
+ ///
+ /// Transitions to [endBeforeLF].
+ static const endBeforeCR = const _State._("end before CR");
+
+ /// The parser has parsed the final empty chunk and the CR that follows it,
+ /// but not the LF after that.
+ ///
+ /// Transitions to [end].
+ static const endBeforeLF = const _State._("end before LF");
+
+ /// The parser has parsed the final empty chunk as well as the CR LF that
+ /// follows, and expects no more data.
+ static const end = const _State._("end");
+
+ final String _name;
+
+ const _State._(this._name);
+
+ String toString() => _name;
+}
diff --git a/lib/src/chunked_coding/encoder.dart b/lib/src/chunked_coding/encoder.dart
new file mode 100644
index 0000000..c724700
--- /dev/null
+++ b/lib/src/chunked_coding/encoder.dart
@@ -0,0 +1,72 @@
+// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:convert';
+import 'dart:typed_data';
+
+import 'package:charcode/ascii.dart';
+
+/// The canonical instance of [ChunkedCodingEncoder].
+const chunkedCodingEncoder = const ChunkedCodingEncoder._();
+
+/// The chunk indicating that the chunked message has finished.
+final _doneChunk = new Uint8List.fromList([$0, $cr, $lf, $cr, $lf]);
+
+/// A converter that encodes byte arrays into chunks with size tags.
+class ChunkedCodingEncoder extends Converter<List<int>, List<int>> {
+ const ChunkedCodingEncoder._();
+
+ List<int> convert(List<int> bytes) =>
+ _convert(bytes, 0, bytes.length, isLast: true);
+
+ ByteConversionSink startChunkedConversion(Sink<List<int>> sink) =>
+ new _Sink(sink);
+}
+
+/// A conversion sink for the chunked transfer encoding.
+class _Sink extends ByteConversionSinkBase {
+ /// The underlying sink to which encoded byte arrays will be passed.
+ final Sink<List<int>> _sink;
+
+ _Sink(this._sink);
+
+ void add(List<int> chunk) {
+ _sink.add(_convert(chunk, 0, chunk.length));
+ }
+
+ void addSlice(List<int> chunk, int start, int end, bool isLast) {
+ RangeError.checkValidRange(start, end, chunk.length);
+ _sink.add(_convert(chunk, start, end, isLast: isLast));
+ if (isLast) _sink.close();
+ }
+
+ void close() {
+ _sink.add(_doneChunk);
+ _sink.close();
+ }
+}
+
+/// Returns a new list a chunked transfer encoding header followed by the slice
+/// of [bytes] from [start] to [end].
+///
+/// If [isLast] is `true`, this adds the footer that indicates that the chunked
+/// message is complete.
+List<int> _convert(List<int> bytes, int start, int end, {bool isLast: false}) {
+ if (end == start) return isLast ? _doneChunk : const [];
+
+ var size = end - start;
+ var sizeInHex = size.toRadixString(16);
+ var footerSize = isLast ? _doneChunk.length : 0;
+
+ // Add 2 for the CRLF sequence that follows the size header.
+ var list = new Uint8List(sizeInHex.length + 2 + size + footerSize);
+ list.setRange(0, sizeInHex.length, sizeInHex.codeUnits);
+ list[sizeInHex.length] = $cr;
+ list[sizeInHex.length + 1] = $lf;
+ list.setRange(sizeInHex.length + 2, list.length - footerSize, bytes, start);
+ if (isLast) {
+ list.setRange(list.length - footerSize, list.length, _doneChunk);
+ }
+ return list;
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 69ffe2c..d88dec9 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,13 +1,15 @@
name: http_parser
-version: 3.0.3
+version: 3.1.0
author: "Dart Team <misc@dartlang.org>"
homepage: https://github.com/dart-lang/http_parser
description: >
A platform-independent package for parsing and serializing HTTP formats.
dependencies:
+ charcode: "^1.1.0"
collection: ">=0.9.1 <2.0.0"
source_span: "^1.0.0"
string_scanner: ">=0.0.0 <2.0.0"
+ typed_data: "^1.1.0"
dev_dependencies:
test: "^0.12.0"
environment:
diff --git a/test/chunked_coding_test.dart b/test/chunked_coding_test.dart
new file mode 100644
index 0000000..29fce25
--- /dev/null
+++ b/test/chunked_coding_test.dart
@@ -0,0 +1,364 @@
+// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:http_parser/http_parser.dart';
+
+import 'package:charcode/charcode.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group("encoder", () {
+ test("adds a header to the chunk of bytes", () {
+ expect(chunkedCoding.encode([1, 2, 3]),
+ equals([$3, $cr, $lf, 1, 2, 3, $0, $cr, $lf, $cr, $lf]));
+ });
+
+ test("uses hex for chunk size", () {
+ var data = new Iterable.generate(0xA7).toList();
+ expect(chunkedCoding.encode(data),
+ equals([$a, $7, $cr, $lf]
+ ..addAll(data)
+ ..addAll([$0, $cr, $lf, $cr, $lf])));
+ });
+
+ test("just generates a footer for an empty input", () {
+ expect(chunkedCoding.encode([]), equals([$0, $cr, $lf, $cr, $lf]));
+ });
+
+ group("with chunked conversion", () {
+ List<List<int>> results;
+ ByteConversionSink<List<int>> sink;
+ setUp(() {
+ results = [];
+ var controller = new StreamController<List<int>>(sync: true);
+ controller.stream.listen(results.add);
+ sink = chunkedCoding.encoder.startChunkedConversion(controller.sink);
+ });
+
+ test("adds headers to each chunk of bytes", () {
+ sink.add([1, 2, 3, 4]);
+ expect(results, equals([[$4, $cr, $lf, 1, 2, 3, 4]]));
+
+ sink.add([5, 6, 7]);
+ expect(results, equals([
+ [$4, $cr, $lf, 1, 2, 3, 4],
+ [$3, $cr, $lf, 5, 6, 7],
+ ]));
+
+ sink.close();
+ expect(results, equals([
+ [$4, $cr, $lf, 1, 2, 3, 4],
+ [$3, $cr, $lf, 5, 6, 7],
+ [$0, $cr, $lf, $cr, $lf],
+ ]));
+ });
+
+ test("handles empty chunks", () {
+ sink.add([]);
+ expect(results, equals([[]]));
+
+ sink.add([1, 2, 3]);
+ expect(results, equals([[], [$3, $cr, $lf, 1, 2, 3]]));
+
+ sink.add([]);
+ expect(results, equals([[], [$3, $cr, $lf, 1, 2, 3], []]));
+
+ sink.close();
+ expect(results, equals([
+ [],
+ [$3, $cr, $lf, 1, 2, 3],
+ [],
+ [$0, $cr, $lf, $cr, $lf],
+ ]));
+ });
+
+ group("addSlice()", () {
+ test("adds bytes from the specified slice", () {
+ sink.addSlice([1, 2, 3, 4, 5], 1, 4, false);
+ expect(results, equals([[$3, $cr, $lf, 2, 3, 4]]));
+ });
+
+ test("doesn't add a header if the slice is empty", () {
+ sink.addSlice([1, 2, 3, 4, 5], 1, 1, false);
+ expect(results, equals([[]]));
+ });
+
+ test("adds a footer if isLast is true", () {
+ sink.addSlice([1, 2, 3, 4, 5], 1, 4, true);
+ expect(results,
+ equals([[$3, $cr, $lf, 2, 3, 4, $0, $cr, $lf, $cr, $lf]]));
+
+ // Setting isLast shuld close the sink.
+ expect(() => sink.add([]), throwsStateError);
+ });
+
+ group("disallows", () {
+ test("start < 0", () {
+ expect(() => sink.addSlice([1, 2, 3, 4, 5], -1, 4, false),
+ throwsRangeError);
+ });
+
+ test("start > end", () {
+ expect(() => sink.addSlice([1, 2, 3, 4, 5], 3, 2, false),
+ throwsRangeError);
+ });
+
+ test("end > length", () {
+ expect(() => sink.addSlice([1, 2, 3, 4, 5], 1, 10, false),
+ throwsRangeError);
+ });
+ });
+ });
+ });
+ });
+
+ group("decoder", () {
+ test("parses chunked data", () {
+ expect(chunkedCoding.decode([
+ $3, $cr, $lf, 1, 2, 3,
+ $4, $cr, $lf, 4, 5, 6, 7,
+ $0, $cr, $lf, $cr, $lf,
+ ]), equals([1, 2, 3, 4, 5, 6, 7]));
+ });
+
+ test("parses hex size", () {
+ var data = new Iterable.generate(0xA7).toList();
+ expect(
+ chunkedCoding.decode([$a, $7, $cr, $lf]
+ ..addAll(data)
+ ..addAll([$0, $cr, $lf, $cr, $lf])),
+ equals(data));
+ });
+
+ test("parses capital hex size", () {
+ var data = new Iterable.generate(0xA7).toList();
+ expect(
+ chunkedCoding.decode([$A, $7, $cr, $lf]
+ ..addAll(data)
+ ..addAll([$0, $cr, $lf, $cr, $lf])),
+ equals(data));
+ });
+
+ test("parses an empty message", () {
+ expect(chunkedCoding.decode([$0, $cr, $lf, $cr, $lf]), isEmpty);
+ });
+
+ group("disallows a message", () {
+ test("that ends without any input", () {
+ expect(() => chunkedCoding.decode([]), throwsFormatException);
+ });
+
+ test("that ends after the size", () {
+ expect(() => chunkedCoding.decode([$a]), throwsFormatException);
+ });
+
+ test("that ends after CR", () {
+ expect(() => chunkedCoding.decode([$a, $cr]), throwsFormatException);
+ });
+
+ test("that ends after LF", () {
+ expect(() => chunkedCoding.decode([$a, $cr, $lf]),
+ throwsFormatException);
+ });
+
+ test("that ends after insufficient bytes", () {
+ expect(() => chunkedCoding.decode([$a, $cr, $lf, 1, 2, 3]),
+ throwsFormatException);
+ });
+
+ test("that ends at a chunk boundary", () {
+ expect(() => chunkedCoding.decode([$1, $cr, $lf, 1]),
+ throwsFormatException);
+ });
+
+ test("that ends after the empty chunk", () {
+ expect(() => chunkedCoding.decode([$0, $cr, $lf]),
+ throwsFormatException);
+ });
+
+ test("that ends after the closing CR", () {
+ expect(() => chunkedCoding.decode([$0, $cr, $lf, $cr]),
+ throwsFormatException);
+ });
+
+ test("with a chunk without a size", () {
+ expect(() => chunkedCoding.decode([$cr, $lf, $0, $cr, $lf, $cr, $lf]),
+ throwsFormatException);
+ });
+
+ test("with a chunk with a non-hex size", () {
+ expect(
+ () => chunkedCoding.decode([$q, $cr, $lf, $0, $cr, $lf, $cr, $lf]),
+ throwsFormatException);
+ });
+ });
+
+ group("with chunked conversion", () {
+ List<List<int>> results;
+ ByteConversionSink<List<int>> sink;
+ setUp(() {
+ results = [];
+ var controller = new StreamController<List<int>>(sync: true);
+ controller.stream.listen(results.add);
+ sink = chunkedCoding.decoder.startChunkedConversion(controller.sink);
+ });
+
+ test("decodes each chunk of bytes", () {
+ sink.add([$4, $cr, $lf, 1, 2, 3, 4]);
+ expect(results, equals([[1, 2, 3, 4]]));
+
+ sink.add([$3, $cr, $lf, 5, 6, 7]);
+ expect(results, equals([[1, 2, 3, 4], [5, 6, 7]]));
+
+ sink.add([$0, $cr, $lf, $cr, $lf]);
+ sink.close();
+ expect(results, equals([[1, 2, 3, 4], [5, 6, 7]]));
+ });
+
+ test("handles empty chunks", () {
+ sink.add([]);
+ expect(results, isEmpty);
+
+ sink.add([$3, $cr, $lf, 1, 2, 3]);
+ expect(results, equals([[1, 2, 3]]));
+
+ sink.add([]);
+ expect(results, equals([[1, 2, 3]]));
+
+ sink.add([$0, $cr, $lf, $cr, $lf]);
+ sink.close();
+ expect(results, equals([[1, 2, 3]]));
+ });
+
+ test("throws if the sink is closed before the message is done", () {
+ sink.add([$3, $cr, $lf, 1, 2, 3]);
+ expect(() => sink.close(), throwsFormatException);
+ });
+
+ group("preserves state when a byte array ends", () {
+ test("within chunk size", () {
+ sink.add([$a]);
+ expect(results, isEmpty);
+
+ var data = new Iterable.generate(0xA7).toList();
+ sink.add([$7, $cr, $lf]..addAll(data));
+ expect(results, equals([data]));
+ });
+
+ test("after chunk size", () {
+ sink.add([$3]);
+ expect(results, isEmpty);
+
+ sink.add([$cr, $lf, 1, 2, 3]);
+ expect(results, equals([[1, 2, 3]]));
+ });
+
+ test("after CR", () {
+ sink.add([$3, $cr]);
+ expect(results, isEmpty);
+
+ sink.add([$lf, 1, 2, 3]);
+ expect(results, equals([[1, 2, 3]]));
+ });
+
+ test("after LF", () {
+ sink.add([$3, $cr, $lf]);
+ expect(results, isEmpty);
+
+ sink.add([1, 2, 3]);
+ expect(results, equals([[1, 2, 3]]));
+ });
+
+ test("after some bytes", () {
+ sink.add([$3, $cr, $lf, 1, 2]);
+ expect(results, equals([[1, 2]]));
+
+ sink.add([3]);
+ expect(results, equals([[1, 2], [3]]));
+ });
+
+ test("after empty chunk size", () {
+ sink.add([$0]);
+ expect(results, isEmpty);
+
+ sink.add([$cr, $lf, $cr, $lf]);
+ expect(results, isEmpty);
+
+ sink.close();
+ expect(results, isEmpty);
+ });
+
+ test("after first empty chunk CR", () {
+ sink.add([$0, $cr]);
+ expect(results, isEmpty);
+
+ sink.add([$lf, $cr, $lf]);
+ expect(results, isEmpty);
+
+ sink.close();
+ expect(results, isEmpty);
+ });
+
+ test("after first empty chunk LF", () {
+ sink.add([$0, $cr, $lf]);
+ expect(results, isEmpty);
+
+ sink.add([$cr, $lf]);
+ expect(results, isEmpty);
+
+ sink.close();
+ expect(results, isEmpty);
+ });
+
+ test("after second empty chunk CR", () {
+ sink.add([$0, $cr, $lf, $cr]);
+ expect(results, isEmpty);
+
+ sink.add([$lf]);
+ expect(results, isEmpty);
+
+ sink.close();
+ expect(results, isEmpty);
+ });
+ });
+
+ group("addSlice()", () {
+ test("adds bytes from the specified slice", () {
+ sink.addSlice([1, $3, $cr, $lf, 2, 3, 4, 5], 1, 7, false);
+ expect(results, equals([[2, 3, 4]]));
+ });
+
+ test("doesn't decode if the slice is empty", () {
+ sink.addSlice([1, 2, 3, 4, 5], 1, 1, false);
+ expect(results, isEmpty);
+ });
+
+ test("closes the sink if isLast is true", () {
+ sink.addSlice([1, $0, $cr, $lf, $cr, $lf, 7], 1, 6, true);
+ expect(results, isEmpty);
+ });
+
+ group("disallows", () {
+ test("start < 0", () {
+ expect(() => sink.addSlice([1, 2, 3, 4, 5], -1, 4, false),
+ throwsRangeError);
+ });
+
+ test("start > end", () {
+ expect(() => sink.addSlice([1, 2, 3, 4, 5], 3, 2, false),
+ throwsRangeError);
+ });
+
+ test("end > length", () {
+ expect(() => sink.addSlice([1, 2, 3, 4, 5], 1, 10, false),
+ throwsRangeError);
+ });
+ });
+ });
+ });
+ });
+}