diff --git a/config/.phpcs.xml b/config/.phpcs.xml
index a1f6b340..a1c70fb1 100644
--- a/config/.phpcs.xml
+++ b/config/.phpcs.xml
@@ -7,6 +7,7 @@
../config
../tests
+
*/src/Psl/File/(WriteMode|LockType).php
*/src/Psl/Network/SocketScheme.php
*/src/Psl/Str/Encoding.php
@@ -16,6 +17,8 @@
*/src/Psl/OS/OperatingSystemFamily.php
*/src/Psl/Password/Algorithm.php
*/src/Psl/Shell/ErrorOutputBehavior.php
+
+ */tests/unit/Str/Grapheme/RangeTest.php
diff --git a/docs/component/str-byte.md b/docs/component/str-byte.md
index 18a09ceb..33656271 100644
--- a/docs/component/str-byte.md
+++ b/docs/component/str-byte.md
@@ -35,6 +35,7 @@
- [ord](./../../src/Psl/Str/Byte/ord.php#L12)
- [pad_left](./../../src/Psl/Str/Byte/pad_left.php#L24)
- [pad_right](./../../src/Psl/Str/Byte/pad_right.php#L24)
+- [range](./../../src/Psl/Str/Byte/range.php#L42)
- [replace](./../../src/Psl/Str/Byte/replace.php#L15)
- [replace_ci](./../../src/Psl/Str/Byte/replace_ci.php#L15)
- [replace_every](./../../src/Psl/Str/Byte/replace_every.php#L17)
diff --git a/docs/component/str-grapheme.md b/docs/component/str-grapheme.md
index 881ca15d..3ff113d0 100644
--- a/docs/component/str-grapheme.md
+++ b/docs/component/str-grapheme.md
@@ -25,6 +25,7 @@
- [ends_with](./../../src/Psl/Str/Grapheme/ends_with.php#L16)
- [ends_with_ci](./../../src/Psl/Str/Grapheme/ends_with_ci.php#L16)
- [length](./../../src/Psl/Str/Grapheme/length.php#L18)
+- [range](./../../src/Psl/Str/Grapheme/range.php#L43)
- [reverse](./../../src/Psl/Str/Grapheme/reverse.php#L16)
- [search](./../../src/Psl/Str/Grapheme/search.php#L27)
- [search_ci](./../../src/Psl/Str/Grapheme/search_ci.php#L27)
diff --git a/docs/component/str.md b/docs/component/str.md
index 7fac0256..45b24198 100644
--- a/docs/component/str.md
+++ b/docs/component/str.md
@@ -50,6 +50,7 @@
- [ord](./../../src/Psl/Str/ord.php#L22)
- [pad_left](./../../src/Psl/Str/pad_left.php#L34)
- [pad_right](./../../src/Psl/Str/pad_right.php#L34)
+- [range](./../../src/Psl/Str/range.php#L41)
- [repeat](./../../src/Psl/Str/repeat.php#L26)
- [replace](./../../src/Psl/Str/replace.php#L15)
- [replace_ci](./../../src/Psl/Str/replace_ci.php#L16)
diff --git a/src/Psl/Internal/Loader.php b/src/Psl/Internal/Loader.php
index 26e497dc..7b8a2462 100644
--- a/src/Psl/Internal/Loader.php
+++ b/src/Psl/Internal/Loader.php
@@ -219,6 +219,7 @@ final class Loader
'Psl\\Str\\Byte\\search_last_ci' => 'Psl/Str/Byte/search_last_ci.php',
'Psl\\Str\\Byte\\shuffle' => 'Psl/Str/Byte/shuffle.php',
'Psl\\Str\\Byte\\slice' => 'Psl/Str/Byte/slice.php',
+ 'Psl\\Str\\Byte\\range' => 'Psl/Str/Byte/range.php',
'Psl\\Str\\Byte\\splice' => 'Psl/Str/Byte/splice.php',
'Psl\\Str\\Byte\\split' => 'Psl/Str/Byte/split.php',
'Psl\\Str\\Byte\\starts_with' => 'Psl/Str/Byte/starts_with.php',
@@ -275,6 +276,7 @@ final class Loader
'Psl\\Str\\search_last' => 'Psl/Str/search_last.php',
'Psl\\Str\\search_last_ci' => 'Psl/Str/search_last_ci.php',
'Psl\\Str\\slice' => 'Psl/Str/slice.php',
+ 'Psl\\Str\\range' => 'Psl/Str/range.php',
'Psl\\Str\\splice' => 'Psl/Str/splice.php',
'Psl\\Str\\split' => 'Psl/Str/split.php',
'Psl\\Str\\starts_with' => 'Psl/Str/starts_with.php',
@@ -366,6 +368,7 @@ final class Loader
'Psl\\Str\\Grapheme\\search_last' => 'Psl/Str/Grapheme/search_last.php',
'Psl\\Str\\Grapheme\\search_last_ci' => 'Psl/Str/Grapheme/search_last_ci.php',
'Psl\\Str\\Grapheme\\slice' => 'Psl/Str/Grapheme/slice.php',
+ 'Psl\\Str\\Grapheme\\range' => 'Psl/Str/Grapheme/range.php',
'Psl\\Str\\Grapheme\\starts_with' => 'Psl/Str/Grapheme/starts_with.php',
'Psl\\Str\\Grapheme\\starts_with_ci' => 'Psl/Str/Grapheme/starts_with_ci.php',
'Psl\\Str\\Grapheme\\strip_prefix' => 'Psl/Str/Grapheme/strip_prefix.php',
diff --git a/src/Psl/Str/Byte/range.php b/src/Psl/Str/Byte/range.php
new file mode 100644
index 00000000..b7fedd87
--- /dev/null
+++ b/src/Psl/Str/Byte/range.php
@@ -0,0 +1,64 @@
+ $range
+ *
+ * @throws Exception\OutOfBoundsException If the $offset is out-of-bounds.
+ *
+ * @pure
+ */
+function range(string $string, RangeInterface $range): string
+{
+ $offset = 0;
+ $length = null;
+ if ($range instanceof LowerBoundRangeInterface) {
+ /** @var int<0, max> $offset */
+ $offset = $range->getLowerBound();
+ }
+
+ if ($range instanceof UpperBoundRangeInterface) {
+ /**
+ * @psalm-suppress InvalidOperand
+ *
+ * @var int<0, max> $length
+ */
+ $length = $range->getUpperBound() - $offset;
+ if ($range->isUpperInclusive()) {
+ $length += 1;
+ }
+ }
+
+ return slice($string, $offset, $length);
+}
diff --git a/src/Psl/Str/Grapheme/range.php b/src/Psl/Str/Grapheme/range.php
new file mode 100644
index 00000000..e723eb26
--- /dev/null
+++ b/src/Psl/Str/Grapheme/range.php
@@ -0,0 +1,65 @@
+ $range
+ *
+ * @throws Exception\OutOfBoundsException If the $offset is out-of-bounds.
+ * @throws Exception\InvalidArgumentException If $string is not made of grapheme clusters.
+ *
+ * @pure
+ */
+function range(string $string, RangeInterface $range): string
+{
+ $offset = 0;
+ $length = null;
+ if ($range instanceof LowerBoundRangeInterface) {
+ /** @var int<0, max> $offset */
+ $offset = $range->getLowerBound();
+ }
+
+ if ($range instanceof UpperBoundRangeInterface) {
+ /**
+ * @psalm-suppress InvalidOperand
+ *
+ * @var int<0, max> $length
+ */
+ $length = $range->getUpperBound() - $offset;
+ if ($range->isUpperInclusive()) {
+ $length += 1;
+ }
+ }
+
+ return slice($string, $offset, $length);
+}
diff --git a/src/Psl/Str/Grapheme/slice.php b/src/Psl/Str/Grapheme/slice.php
index be1a4373..dac48252 100644
--- a/src/Psl/Str/Grapheme/slice.php
+++ b/src/Psl/Str/Grapheme/slice.php
@@ -19,7 +19,7 @@
* @pure
*
* @throws Str\Exception\OutOfBoundsException If $offset is out-of-bounds.
- * @throws Str\Exception\InvalidArgumentException If $haystack is not made of grapheme clusters.
+ * @throws Str\Exception\InvalidArgumentException If $string is not made of grapheme clusters.
*/
function slice(string $string, int $offset, ?int $length = null): string
{
diff --git a/src/Psl/Str/range.php b/src/Psl/Str/range.php
new file mode 100644
index 00000000..65c856cc
--- /dev/null
+++ b/src/Psl/Str/range.php
@@ -0,0 +1,63 @@
+ $range
+ *
+ * @throws Exception\OutOfBoundsException If the $offset is out-of-bounds.
+ *
+ * @pure
+ */
+function range(string $string, RangeInterface $range, Encoding $encoding = Encoding::UTF_8): string
+{
+ $offset = 0;
+ $length = null;
+ if ($range instanceof LowerBoundRangeInterface) {
+ /** @var int<0, max> $offset */
+ $offset = $range->getLowerBound();
+ }
+
+ if ($range instanceof UpperBoundRangeInterface) {
+ /**
+ * @psalm-suppress InvalidOperand
+ *
+ * @var int<0, max> $length
+ */
+ $length = $range->getUpperBound() - $offset;
+ if ($range->isUpperInclusive()) {
+ $length += 1;
+ }
+ }
+
+ return slice($string, $offset, $length, $encoding);
+}
diff --git a/tests/unit/Str/Byte/RangeTest.php b/tests/unit/Str/Byte/RangeTest.php
new file mode 100644
index 00000000..f1c3f9b8
--- /dev/null
+++ b/tests/unit/Str/Byte/RangeTest.php
@@ -0,0 +1,59 @@
+ $range
+ *
+ * @dataProvider provideData
+ */
+ public function testRange(string $expected, string $string, Range\RangeInterface $range): void
+ {
+ static::assertSame($expected, Byte\range($string, $range));
+ }
+
+ /**
+ * @return list<{0: string, 1: string, 2: Range\RangeInterface}>
+ */
+ public function provideData(): array
+ {
+ return [
+ ['', '', Range\between(0, 5, upper_inclusive: true)],
+ ['Hello,', 'Hello, World!', Range\between(0, 5, upper_inclusive: true)],
+ ['Hello', 'Hello, World!', Range\between(0, 5, upper_inclusive: false)],
+ ['Hello, World!', 'Hello, World!', Range\from(0)],
+ ['World!', 'Hello, World!', Range\between(7, 12, upper_inclusive: true)],
+ ['World', 'Hello, World!', Range\between(7, 12, upper_inclusive: false)],
+ ['destiny', 'People linked by destiny will always find each other.', Range\between(17, 23, upper_inclusive: true)],
+ ['destiny', 'People linked by destiny will always find each other.', Range\between(17, 24, upper_inclusive: false)],
+ ['hel', 'hello world', Range\to(3, inclusive: false)],
+ ['', 'lo world', Range\between(3, 3)],
+ ['', 'foo', Range\between(3, 3)],
+ ['', 'foo', Range\between(3, 12)],
+ ['foo', 'foo', Range\full()],
+ ];
+ }
+
+ public function testRangeThrowsForOutOfBoundOffset(): void
+ {
+ $this->expectException(Exception\OutOfBoundsException::class);
+
+ Byte\range('Hello', Range\from(10));
+ }
+
+ public function testRangeThrowsForNegativeOutOfBoundOffset(): void
+ {
+ $this->expectException(Exception\OutOfBoundsException::class);
+
+ Byte\range('Hello', Range\from(-6));
+ }
+}
diff --git a/tests/unit/Str/Grapheme/RangeTest.php b/tests/unit/Str/Grapheme/RangeTest.php
new file mode 100644
index 00000000..67cc21c7
--- /dev/null
+++ b/tests/unit/Str/Grapheme/RangeTest.php
@@ -0,0 +1,67 @@
+ $range
+ *
+ * @dataProvider provideData
+ */
+ public function testRange(string $expected, string $string, Range\RangeInterface $range): void
+ {
+ static::assertSame($expected, Grapheme\range($string, $range));
+ }
+
+ /**
+ * @return list<{0: string, 1: string, 2: Range\RangeInterface}>
+ */
+ public function provideData(): array
+ {
+ return [
+ ['', '', Range\between(0, 5, upper_inclusive: true)],
+ ['Hello,', 'Hello, World!', Range\between(0, 5, upper_inclusive: true)],
+ ['Hello', 'Hello, World!', Range\between(0, 5, upper_inclusive: false)],
+ ['Hello, World!', 'Hello, World!', Range\from(0)],
+ ['World!', 'Hello, World!', Range\between(7, 12, upper_inclusive: true)],
+ ['World', 'Hello, World!', Range\between(7, 12, upper_inclusive: false)],
+ ['سيف', 'مرحبا سيف', Range\between(6, 9, upper_inclusive: true)],
+ ['اهلا', 'اهلا بكم', Range\between(0, 3, upper_inclusive: true)],
+ ['اهلا', 'اهلا بكم', Range\between(0, 4, upper_inclusive: false)],
+ ['destiny', 'People linked by destiny will always find each other.', Range\between(17, 23, upper_inclusive: true)],
+ ['destiny', 'People linked by destiny will always find each other.', Range\between(17, 24, upper_inclusive: false)],
+ ['lö ', 'héllö wôrld', Range\between(3, 5, upper_inclusive: true)],
+ ['lö ', 'héllö wôrld', Range\between(3, 6, upper_inclusive: false)],
+ ['lö wôrld', 'héllö wôrld', Range\from(3)],
+ ['héll', 'héllö wôrld', Range\to(3, inclusive: true)],
+ ['hél', 'héllö wôrld', Range\to(3, inclusive: false)],
+ ['', 'lö wôrld', Range\between(3, 3)],
+ ['', 'fôo', Range\between(3, 3)],
+ ['', 'fôo', Range\between(3, 12)],
+ ['fôo', 'fôo', Range\full()],
+ ['he̡̙̬͎̿́̐̅̕͢l͕̮͕͈̜͐̈́̇̕͠ļ͚͉̗̘̽͑̿͑̚o̼̰̼͕̞̍̄̎̿̊,̻̰̻̘́̎͒̋͘͟ ̧̬̝͈̬̿͌̿̑̕ẉ̣̟͉̮͆̊̃͐̈́ờ̢̫͎͖̹͊́͐r̨̮͓͓̣̅̋͐͐͆ḻ̩̦͚̯͑̌̓̅͒d͇̯͔̼͍͛̾͛͡͝', 'he̡̙̬͎̿́̐̅̕͢l͕̮͕͈̜͐̈́̇̕͠ļ͚͉̗̘̽͑̿͑̚o̼̰̼͕̞̍̄̎̿̊,̻̰̻̘́̎͒̋͘͟ ̧̬̝͈̬̿͌̿̑̕ẉ̣̟͉̮͆̊̃͐̈́ờ̢̫͎͖̹͊́͐r̨̮͓͓̣̅̋͐͐͆ḻ̩̦͚̯͑̌̓̅͒d͇̯͔̼͍͛̾͛͡͝', Range\between(0, 11, true)],
+ ];
+ }
+
+ public function testRangeThrowsForOutOfBoundOffset(): void
+ {
+ $this->expectException(Exception\OutOfBoundsException::class);
+
+ Grapheme\range('Hello', Range\from(10));
+ }
+
+ public function testRangeThrowsForNegativeOutOfBoundOffset(): void
+ {
+ $this->expectException(Exception\OutOfBoundsException::class);
+
+ Grapheme\range('Hello', Range\from(-6));
+ }
+}
diff --git a/tests/unit/Str/RangeTest.php b/tests/unit/Str/RangeTest.php
new file mode 100644
index 00000000..409d3ab1
--- /dev/null
+++ b/tests/unit/Str/RangeTest.php
@@ -0,0 +1,67 @@
+ $range
+ *
+ * @dataProvider provideData
+ */
+ public function testRange(string $expected, string $string, Range\RangeInterface $range): void
+ {
+ static::assertSame($expected, Str\range($string, $range));
+ }
+
+ /**
+ * @return list<{0: string, 1: string, 2: Range\RangeInterface}>
+ */
+ public function provideData(): array
+ {
+ return [
+ ['', '', Range\between(0, 5, upper_inclusive: true)],
+ ['Hello,', 'Hello, World!', Range\between(0, 5, upper_inclusive: true)],
+ ['Hello', 'Hello, World!', Range\between(0, 5, upper_inclusive: false)],
+ ['Hello, World!', 'Hello, World!', Range\from(0)],
+ ['World!', 'Hello, World!', Range\between(7, 12, upper_inclusive: true)],
+ ['World', 'Hello, World!', Range\between(7, 12, upper_inclusive: false)],
+ ['سيف', 'مرحبا سيف', Range\between(6, 9, upper_inclusive: true)],
+ ['اهلا', 'اهلا بكم', Range\between(0, 3, upper_inclusive: true)],
+ ['اهلا', 'اهلا بكم', Range\between(0, 4, upper_inclusive: false)],
+ ['destiny', 'People linked by destiny will always find each other.', Range\between(17, 23, upper_inclusive: true)],
+ ['destiny', 'People linked by destiny will always find each other.', Range\between(17, 24, upper_inclusive: false)],
+ ['lö ', 'héllö wôrld', Range\between(3, 5, upper_inclusive: true)],
+ ['lö ', 'héllö wôrld', Range\between(3, 6, upper_inclusive: false)],
+ ['lö wôrld', 'héllö wôrld', Range\from(3)],
+ ['héll', 'héllö wôrld', Range\to(3, inclusive: true)],
+ ['hél', 'héllö wôrld', Range\to(3, inclusive: false)],
+ ['', 'lö wôrld', Range\between(3, 3)],
+ ['', 'fôo', Range\between(3, 3)],
+ ['', 'fôo', Range\between(3, 12)],
+ ['fôo', 'fôo', Range\full()],
+ ['he̡̙̬͎̿́̐̅̕͢', 'he̡̙̬͎̿́̐̅̕͢l͕̮͕͈̜͐̈́̇̕͠ļ͚͉̗̘̽͑̿͑̚o̼̰̼͕̞̍̄̎̿̊,̻̰̻̘́̎͒̋͘͟ ̧̬̝͈̬̿͌̿̑̕ẉ̣̟͉̮͆̊̃͐̈́ờ̢̫͎͖̹͊́͐r̨̮͓͓̣̅̋͐͐͆ḻ̩̦͚̯͑̌̓̅͒d͇̯͔̼͍͛̾͛͡͝', Range\between(0, 11, true)],
+ ];
+ }
+
+ public function testRangeThrowsForOutOfBoundOffset(): void
+ {
+ $this->expectException(Exception\OutOfBoundsException::class);
+
+ Str\range('Hello', Range\from(10));
+ }
+
+ public function testRangeThrowsForNegativeOutOfBoundOffset(): void
+ {
+ $this->expectException(Exception\OutOfBoundsException::class);
+
+ Str\range('Hello', Range\from(-6));
+ }
+}