From 9c77522f979ecea3f487803eb6b92f80f9f74a78 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 17 Apr 2024 15:25:05 -0400 Subject: [PATCH 1/3] WIP --- src/AnsiChar.php | 17 +++ src/AnsiString.php | 200 ++++++++++++++++++++++++++++++---- src/Flag.php | 17 +++ tests/Unit/AnsiStringTest.php | 63 +++++++++++ 4 files changed, 275 insertions(+), 22 deletions(-) diff --git a/src/AnsiChar.php b/src/AnsiChar.php index 0a22c7c..41f3a42 100644 --- a/src/AnsiChar.php +++ b/src/AnsiChar.php @@ -6,6 +6,8 @@ class AnsiChar implements Stringable { + protected ?int $width = null; + public function __construct( public string $value, /** @var \Glhd\AnsiPants\Flag[] */ @@ -13,6 +15,21 @@ public function __construct( ) { } + public function width(): int + { + return $this->width ??= mb_strwidth($this->value); + } + + public function is(AnsiChar $other, bool $ignore_style = false): bool + { + return $this->value === $other->value && ($ignore_style || $this->flags === $other->flags); + } + + public function isNot(AnsiChar $other, bool $ignore_style = false): bool + { + return $this->value !== $other->value || (! $ignore_style && $this->flags !== $other->flags); + } + public function withFlags(Flag ...$flags): static { return new static($this->value, $flags); diff --git a/src/AnsiString.php b/src/AnsiString.php index b6bb1a5..081a86a 100644 --- a/src/AnsiString.php +++ b/src/AnsiString.php @@ -22,10 +22,8 @@ public static function make(AnsiString|Collection|string $input): static public function __construct(AnsiString|AnsiChar|Collection|string $input) { if ($input instanceof AnsiChar) { - $input = new Collection([$input]); - } - - if ($input instanceof Collection) { + $this->chars = new Collection([clone $input]); + } elseif ($input instanceof Collection) { $input->ensure(AnsiChar::class); $this->chars = clone $input; } elseif ($input instanceof AnsiString) { @@ -37,17 +35,47 @@ public function __construct(AnsiString|AnsiChar|Collection|string $input) public function withFlags(Flag ...$flags): static { - return new static($this->chars->map(fn(AnsiChar $char) => $char->withFlags(...$flags))); + return new static($this->chars->map(fn(AnsiChar $char) => (clone $char)->withFlags(...$flags))); } public function prepend(AnsiString|AnsiChar|string $string): static { - return new AnsiString(AnsiString::make($string)->chars->merge($this->chars)); + $prepended = collect(); + + if ($string instanceof AnsiChar) { + $prepended->push(clone $string); + } else { + $string = new AnsiString($string); + foreach ($string->chars as $char) { + $prepended->push(clone $char); + } + } + + foreach ($this->chars as $char) { + $prepended->push(clone $char); + } + + return new AnsiString($prepended); } public function append(AnsiString|AnsiChar|string $string): static { - return new AnsiString($this->chars->merge(AnsiString::make($string)->chars)); + $appended = collect(); + + foreach ($this->chars as $char) { + $appended->push(clone $char); + } + + if ($string instanceof AnsiChar) { + $appended->push(clone $string); + } else { + $string = new AnsiString($string); + foreach ($string->chars as $char) { + $appended->push(clone $char); + } + } + + return new AnsiString($appended); } public function padLeft(int $length, AnsiString|string $pad = ' '): static @@ -112,11 +140,138 @@ public function wordwrap(int $width = 75, AnsiString|string $break = "\e[0m\n", return $wrapped; } + /** @return Collection */ + public function explode(AnsiString|string $break, bool $ignore_style = true): Collection + { + $break = new AnsiString($break); + $results = new Collection(); + $buffer = new AnsiString(''); + $tail_buffer = new AnsiString(''); + + foreach ($this->chars as $char) { + $tail_buffer = $tail_buffer->append($char); + + // If our partial buffer is the break value, we'll push the current + // buffer to the results, and reset for the next chunk. + if ($tail_buffer->is($break, $ignore_style)) { + $results->push($buffer); + $buffer = new AnsiString(''); + $tail_buffer = new AnsiString(''); + continue; + } + + // If our partial buffer looks like it's the beginning of the break value + // then we'll just continue buffering + if ($break->startsWith($tail_buffer)) { + continue; + } + + // Otherwise (if the partial buffer isn't part of the break), just push it + // to the current buffer and reset our partial buffer + $buffer = $buffer->append($tail_buffer); + $tail_buffer = new AnsiString(''); + } + + // If we have anything remaining on the partial buffer when we get to the + // end of the string, we just need to append it to our current buffer + if ($tail_buffer->length() > 0) { + $buffer = $buffer->append($tail_buffer); + } + + // And if there's anything remaining on the buffer once we're done, add + // that to the final results + if ($buffer->length() > 0) { + $results->push($buffer); + } + + return $results; + } + + public function is(AnsiString|string $other, bool $ignore_style = false): bool + { + $other = new AnsiString($other); + $length = $this->length(); + + if ($other->length() !== $length || $other->width() !== $this->width()) { + return false; + } + + for ($i = 0; $i < $length; $i++) { + if ($this->chars[$i]->isNot($other->chars[$i], $ignore_style)) { + return false; + } + } + + return true; + } + + public function startsWith(AnsiString|string $needle, bool $ignore_style = false): bool + { + $needle = new AnsiString($needle); + + $index = 0; + $last_index = $needle->length() - 1; + + while ($index <= $last_index) { + if ( + ! isset($this->chars[$index]) + || $this->chars[$index]->isNot($needle->chars[$index], $ignore_style) + ) { + return false; + } + + $index++; + } + + return true; + } + + public function endsWith(AnsiString|string $needle, bool $ignore_style = false): bool + { + $needle = new AnsiString($needle); + + $this_index = $this->length() - 1; + $needle_index = $needle->length() - 1; + + while ($needle_index >= 0) { + if ( + ! isset($this->chars[$this_index]) + || $this->chars[$this_index]->isNot($needle->chars[$needle_index], $ignore_style) + ) { + return false; + } + + $this_index--; + $needle_index--; + } + + return true; + } + public function length(): int { return count($this->chars); } + public function width(): int + { + return $this->chars->sum(fn(AnsiChar $char) => $char->width()); + } + + public function dump(): static + { + dump($this); + + return $this; + } + + public function dd(): static + { + dd($this); + + return $this; + } + public function __toString(): string { $result = ''; @@ -126,22 +281,23 @@ public function __toString(): string [$remove, $add] = $this->diffFlags($active_flags, $char->flags); // If it's simpler, just reset and then add them all - if (count($remove) >= count($char->flags)) { - [$remove, $add] = [[Flag::Reset], $char->flags]; + if (count($remove)) { + $result .= Flag::Reset->getEscapeSequence(); + // [$remove, $add] = [[], $char->flags]; } - foreach ($remove as $remove_flag) { - // First, check to see if any of the flags we're adding overrides the flag - // we're removing (in which case, we don't need to explicitly remove it). - foreach ($add as $add_flag) { - if ($add_flag->overrides($remove_flag)) { - break; - } - } - - // Otherwise, add the inverse sequence - $result .= $remove_flag->getInverseEscapeSequence(); - } + // foreach ($remove as $remove_flag) { + // // First, check to see if any of the flags we're adding overrides the flag + // // we're removing (in which case, we don't need to explicitly remove it). + // foreach ($add as $add_flag) { + // if ($add_flag->overrides($remove_flag)) { + // break; + // } + // } + // + // // Otherwise, add the inverse sequence + // $result .= $remove_flag->getInverseEscapeSequence(); + // } foreach ($add as $add_flag) { $result .= $add_flag->getEscapeSequence(); @@ -207,7 +363,7 @@ protected function parse(string $input): Collection if ($token instanceof EscapeSequence) { $active_flags = $active_flags ->reject(fn(Flag $flag) => $token->flag->overrides($flag)) - ->push($token->flag); + ->when($token->flag->hasStyle(), fn(Collection $active_flags) => $active_flags->push($token->flag)); } if ($token instanceof Text) { diff --git a/src/Flag.php b/src/Flag.php index d03ce7a..dc6df81 100755 --- a/src/Flag.php +++ b/src/Flag.php @@ -114,6 +114,23 @@ public function inverse(): ?Flag return null; } + /** + * Determine whether the flag conveys style, or is just a reset of some sort + * + * @return bool + */ + public function hasStyle(): bool + { + return ! in_array($this, [ + self::Reset, + self::Normal, + self::NotItalic, + self::NotUnderline, + self::DefaultForeground, + self::DefaultBackground, + ]); + } + protected function pairs(): array { return [ diff --git a/tests/Unit/AnsiStringTest.php b/tests/Unit/AnsiStringTest.php index ef25754..a00ac4c 100644 --- a/tests/Unit/AnsiStringTest.php +++ b/tests/Unit/AnsiStringTest.php @@ -55,4 +55,67 @@ public function test_word_wrap(): void $this->assertEquals($expected, (string) $parsed->wordwrap(10)); } + + public function test_explode(): void + { + $input = "The \e[1mquick\e[0m \e[33mbrown fox \e[3mjumps\e[0m over the lazy dog"; + + $expected = [ + "The", + "\e[1mquick", + "\e[33mbrown", + "\e[33mfox", + "\e[33m\e[3mjumps", + "over", + "the", + "lazy", + "dog", + ]; + + $results = AnsiString::make($input) + ->explode(' ') + ->map(fn(AnsiString $line) => (string) $line) + ->all(); + + $this->assertEquals($expected, $results); + } + + public function test_width(): void + { + $string = new AnsiString("\e[1m😎😎😎\e[0m"); + + $this->assertEquals(3, $string->length()); + $this->assertEquals(6, $string->width()); + } + + public function test_starts_with(): void + { + $string = new AnsiString("\e[1m😎😎😎 hello world\e[0m"); + + $this->assertTrue($string->startsWith("\e[1m😎")); + $this->assertTrue($string->startsWith("\e[1m😎😎😎")); + $this->assertTrue($string->startsWith("\e[1m😎😎😎 hello")); + + $this->assertFalse($string->startsWith("😎")); + $this->assertFalse($string->startsWith("😎😎😎")); + $this->assertFalse($string->startsWith("😎😎😎 hello")); + + $this->assertTrue($string->startsWith("😎", true)); + $this->assertTrue($string->startsWith("😎😎😎", true)); + $this->assertTrue($string->startsWith("😎😎😎 hello", true)); + + $this->assertTrue($string->startsWith("\e[3m😎", true)); + $this->assertTrue($string->startsWith("\e[3m😎😎😎", true)); + $this->assertTrue($string->startsWith("\e[3m😎😎😎 hello", true)); + } + + public function test_ends_with(): void + { + $string = new AnsiString("😎😎😎 hello \e[1mworld"); + + $this->assertTrue($string->endsWith("\e[1mworld")); + $this->assertFalse($string->endsWith("world")); + $this->assertTrue($string->endsWith("world", true)); + $this->assertTrue($string->endsWith("\e[3mworld", true)); + } } From 23bc208e4e9ebd5525d1dcf8fcc5038b36362092 Mon Sep 17 00:00:00 2001 From: inxilpro Date: Wed, 17 Apr 2024 19:26:12 +0000 Subject: [PATCH 2/3] 0.0.1 --- CHANGELOG.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6b5ec..b34b9f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,21 @@ format. This project adheres to [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +## [0.0.1] - 2024-04-17 + ## [0.0.1] # Keep a Changelog Syntax -- `Added` for new features. -- `Changed` for changes in existing functionality. -- `Deprecated` for soon-to-be removed features. -- `Removed` for now removed features. -- `Fixed` for any bug fixes. -- `Security` in case of vulnerabilities. +- `Added` for new features. +- `Changed` for changes in existing functionality. +- `Deprecated` for soon-to-be removed features. +- `Removed` for now removed features. +- `Fixed` for any bug fixes. +- `Security` in case of vulnerabilities. [Unreleased]: https://github.com/glhd/ansipants/compare/0.0.1...HEAD + +[0.0.1]: https://github.com/glhd/ansipants/compare/0.0.1...0.0.1 + [0.0.1]: https://github.com/glhd/ansipants/compare/0.0.1...0.0.1 From 780cfa4915d6fd8718572591e01fdf0b803a08f2 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 17 Apr 2024 15:32:45 -0400 Subject: [PATCH 3/3] Release notes --- CHANGELOG.md | 10 +++++++++- README.md | 16 ++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b34b9f8..442d0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,15 @@ format. This project adheres to [Semantic Versioning](https://semver.org/spec/v2 ## [0.0.1] - 2024-04-17 -## [0.0.1] +### Added + +- Added support for `length` (number of chars) and `width` (multibyte width of chars) +- Added support for `prepend` and `append` +- Added support for `startsWith` and `endsWith` +- Added support for `padLeft`, `padRight`, and `padBoth` +- Added support for `wordwrap` +- Added support for `explode` +- Added support for `is` (for comparing two ansi strings) # Keep a Changelog Syntax diff --git a/README.md b/README.md index cfa3a31..97c2725 100644 --- a/README.md +++ b/README.md @@ -31,17 +31,29 @@ -# AnsiPants πŸ‘–πŸ’« +# ANSI Pants πŸ‘–πŸ’« ## Installation +```shell +composer require glhd/ansipants +``` + ## Usage +You can instantiate a new ANSI string using the `ansi()` helper, with `new AnsiString()`, +or with `AnsiString::make()`. All string manipulation functions can be chained, just like +the Laravel `Stringable` class. Where appropriate, you can pass an additional `ignore_style: true` +argument into a function to make that function ignore the ANSI styles that are applied +(like color or font style). + +An example: + ```php ansi("\e[1mHelloπŸ’₯ \e[3mwo\e[0mrld") ->append(" πŸ₯ΈπŸ₯ΈπŸ₯Έ") ->padLeft(100) - ->wordWrap(); + ->wordwrap(); ``` ## Resources