Skip to content

Commit eccaef3

Browse files
committed
- Added replace and replaceMatches methods to AttributedString for replacing substrings and regex matches within
attributed (sub-)strings. - Added `withoutAttribute` method to `AttributedString` for removing specific attributes from the string. - Added static `fromString` method to `AttributedString` for creating an attributed string from a plain string. - Fixed an issue in `MarkdownEncoder` where trailing whitespace was not properly handled.
1 parent 58f1c56 commit eccaef3

File tree

10 files changed

+373
-15
lines changed

10 files changed

+373
-15
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## v1.3 - 2025-08-23
6+
- Added `replace` and `replaceMatches` methods to `AttributedString` for replacing substrings and regex matches within
7+
attributed (sub-)strings.
8+
- Added `withoutAttribute` method to `AttributedString` for removing specific attributes from the string.
9+
- Added static `fromString` method to `AttributedString` for creating an attributed string from a plain string.
10+
- Fixed an issue in `MarkdownEncoder` where trailing whitespace was not properly handled.
11+
512
## v1.2 - 2025-07-24
613
- Added ability to strip attributes from attributed strings.
714
- `Context` is now provided at more places in the parser.

src/MarkupKit/Core/String/AbstractAttributedElement.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace MarkupKit\Core\String;
44

5+
use UnitEnum;
6+
57
abstract readonly class AbstractAttributedElement implements AttributedElement
68
{
79
public function __construct(
@@ -16,6 +18,16 @@ public function withAttribute(Attribute $attribute): static
1618
);
1719
}
1820

21+
/**
22+
* @param class-string<Attribute>|(Attribute&UnitEnum) $attribute
23+
*/
24+
public function withoutAttribute(string|Attribute $attribute): static
25+
{
26+
return $this->replacingAttributes(
27+
$this->attributes->withoutAttribute($attribute)
28+
);
29+
}
30+
1931
public function withoutAttributes(): static
2032
{
2133
return $this->replacingAttributes(new AttributeContainer());

src/MarkupKit/Core/String/AttributeContainer.php

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ public function withAttribute(Attribute $attribute): self
4141
return new self($attributes);
4242
}
4343

44+
45+
/**
46+
* @param class-string<Attribute>|(Attribute&UnitEnum) $attribute
47+
* @return self
48+
*/
49+
public function withoutAttribute(string|Attribute $attribute): self
50+
{
51+
return $this->filterAttributes(
52+
fn (Attribute $attr) => $attribute instanceof Attribute ? $attr !== $attribute : !is_a($attr, $attribute)
53+
);
54+
}
55+
4456
/**
4557
* Check if the container contains a specific attribute, or one or more attributes of a specific type.
4658
*
@@ -93,15 +105,15 @@ public function getAttribute(string|(Attribute&UnitEnum) $attribute): ?Attribute
93105
/**
94106
* @param class-string<Attribute>|(callable(Attribute $attribute): bool) $filter
95107
*
96-
* @return Attribute[]
108+
* @return self
97109
*/
98-
public function filterAttributes(string|callable $filter): array
110+
public function filterAttributes(string|callable $filter): self
99111
{
100112
if (is_string($filter)) {
101113
$filter = fn (Attribute $attr) => $attr instanceof $filter;
102114
}
103115

104-
return array_filter($this->attributes, $filter);
116+
return new self(array_filter($this->attributes, $filter));
105117
}
106118

107119
public function getIterator(): Traversable
@@ -114,13 +126,31 @@ public function count(): int
114126
return count($this->attributes);
115127
}
116128

129+
public function isEmpty(): bool
130+
{
131+
return empty($this->attributes);
132+
}
133+
117134
public function diff(self $other): AttributeContainer
118135
{
119-
return new self(array_filter($this->attributes, fn ($a) => !in_array($a, $other->attributes, true)));
136+
return $this->filterAttributes(fn (Attribute $attr) => !in_array($attr, $other->attributes, true));
120137
}
121138

122139
public function reversed(): AttributeContainer
123140
{
124141
return new self(array_reverse($this->attributes));
125142
}
143+
144+
public function equals(AttributeContainer $other): bool
145+
{
146+
if ($this === $other) {
147+
return true;
148+
}
149+
150+
if (count($this->attributes) !== count($other->attributes)) {
151+
return false;
152+
}
153+
154+
return $this->diff($other)->isEmpty() && $other->diff($this)->isEmpty();
155+
}
126156
}

src/MarkupKit/Core/String/AttributedString.php

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,33 @@
33
namespace MarkupKit\Core\String;
44

55
use MarkupKit\Core\String\Encoder\String\StringEncoder;
6+
use MarkupKit\Core\String\Internal\Traits\OptimizeElements;
67
use Stringable;
8+
use UnitEnum;
79

810
readonly class AttributedString implements Stringable
911
{
12+
use OptimizeElements;
13+
14+
/**
15+
* @var array<AttributedSubstring|Attachment>
16+
*/
17+
public array $elements;
18+
1019
/**
1120
* @param array<AttributedSubstring|Attachment> $elements
1221
*/
1322
public function __construct(
14-
public array $elements = []
23+
array $elements = []
1524
) {
25+
$this->elements = $this->optimizeElements($elements);
26+
}
27+
28+
public static function fromString(
29+
string $string,
30+
AttributeContainer $attributes = new AttributeContainer()
31+
): AttributedString {
32+
return new AttributedString([new AttributedSubstring($string, $attributes)]);
1633
}
1734

1835
public function isEmpty(): bool
@@ -67,6 +84,63 @@ public function withoutAttributes(): AttributedString
6784
return new self(array_map(fn ($e) => $e->withoutAttributes(), $this->elements));
6885
}
6986

87+
/**
88+
* @param class-string<Attribute>|(Attribute&UnitEnum) $attribute
89+
*/
90+
public function withoutAttribute(string|Attribute $attribute): AttributedString
91+
{
92+
return new self(array_map(fn ($e) => $e->withoutAttribute($attribute), $this->elements));
93+
}
94+
95+
/**
96+
* @param string|(callable(string[] $match, AttributeContainer $attributes): (string|AttributedSubstring|Attachment|AttributedString)) $replace
97+
* @return self
98+
*/
99+
public function replace(string $search, string|callable $replace): AttributedString
100+
{
101+
$elements = [];
102+
103+
foreach ($this->elements as $element) {
104+
if (!($element instanceof AttributedSubstring)) {
105+
$elements[] = $element;
106+
continue;
107+
}
108+
109+
array_push($elements, ...$element->replace($search, $replace));
110+
}
111+
112+
113+
if ($this->elements === $elements) {
114+
return $this;
115+
}
116+
117+
return new self($elements);
118+
}
119+
120+
/**
121+
* @param string|(callable(string[] $match, AttributeContainer $attributes): (string|AttributedSubstring|Attachment|AttributedString)) $replace
122+
* @return self
123+
*/
124+
public function replaceMatches(string $regex, string|callable $replace): AttributedString
125+
{
126+
$elements = [];
127+
128+
foreach ($this->elements as $element) {
129+
if (!($element instanceof AttributedSubstring)) {
130+
$elements[] = $element;
131+
continue;
132+
}
133+
134+
array_push($elements, ...$element->replaceMatches($regex, $replace));
135+
}
136+
137+
if ($this->elements === $elements) {
138+
return $this;
139+
}
140+
141+
return new AttributedString($elements);
142+
}
143+
70144
public function __toString(): string
71145
{
72146
return new StringEncoder()->encode($this);

src/MarkupKit/Core/String/AttributedSubstring.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33
namespace MarkupKit\Core\String;
44

5+
use MarkupKit\Core\String\Internal\Traits\OptimizeElements;
56
use Stringable;
67

78
final readonly class AttributedSubstring extends AbstractAttributedElement implements Stringable
89
{
10+
use OptimizeElements;
11+
912
public function __construct(
1013
public string $string,
1114
AttributeContainer $attributes
@@ -22,4 +25,85 @@ public function replacingAttributes(AttributeContainer $attributes): static
2225
{
2326
return new self($this->string, $attributes);
2427
}
28+
29+
/**
30+
* @param string|(callable(string[] $match, AttributeContainer $attributes): (string|AttributedSubstring|Attachment|AttributedString)) $replace
31+
*
32+
* @return (AttributedSubstring|Attachment)[]
33+
*/
34+
public function replace(string $search, callable|string $replace): array
35+
{
36+
if (is_string($replace)) {
37+
$string = str_replace($search, $replace, $this->string);
38+
if ($string === $this->string) {
39+
return [$this];
40+
}
41+
42+
return [new self($string, $this->attributes)];
43+
}
44+
45+
return $this->replaceMatches('/' . preg_quote($search, '/') . '/', $replace);
46+
}
47+
48+
/**
49+
* @param string|(callable(string[] $match, AttributeContainer $attributes): (string|AttributedSubstring|Attachment|AttributedString)) $replace
50+
*
51+
* @return (AttributedSubstring|Attachment)[]
52+
*/
53+
public function replaceMatches(string $regex, callable|string $replace): array
54+
{
55+
if (is_string($replace)) {
56+
$string = preg_replace($regex, $replace, $this->string);
57+
if ($string === null || $string === $this->string) {
58+
return [$this];
59+
}
60+
61+
return [new self($string, $this->attributes)];
62+
}
63+
64+
$count = preg_match_all(
65+
$regex,
66+
$this->string,
67+
$matches,
68+
flags: PREG_OFFSET_CAPTURE
69+
);
70+
71+
if ($count === false || $count === 0) {
72+
return [$this];
73+
}
74+
75+
$elements = [];
76+
$offset = 0;
77+
foreach ($matches[0] as $i => [$match, $matchOffset]) {
78+
if ($matchOffset > $offset) {
79+
$elements[] = new self(
80+
substr($this->string, $offset, $matchOffset - $offset),
81+
$this->attributes
82+
);
83+
}
84+
85+
$matchData = array_map(fn($sub) => $sub[$i][0], $matches);
86+
$replacement = $replace($matchData, $this->attributes);
87+
88+
if (is_string($replacement) && $replacement !== '') {
89+
$elements[] = new self($replacement, $this->attributes);
90+
} elseif ($replacement instanceof AttributedSubstring || $replacement instanceof Attachment) {
91+
$elements[] = $replacement;
92+
} elseif ($replacement instanceof AttributedString) {
93+
array_push($elements, ...$replacement->elements);
94+
}
95+
// else empty replacement, do nothing
96+
97+
$offset = (int)$matchOffset + strlen($match);
98+
}
99+
100+
if ($offset < strlen($this->string)) {
101+
$elements[] = new self(
102+
substr($this->string, $offset),
103+
$this->attributes
104+
);
105+
}
106+
107+
return $this->optimizeElements($elements);
108+
}
25109
}

src/MarkupKit/Core/String/Encoder/Markdown/MarkdownEncoder.php

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ protected function encodeElement(AbstractAttributedElement $element): string
4949
return '';
5050
}
5151

52+
/**
53+
* @return array{0: string, 1: string}
54+
*/
55+
private function splitTrailingWhitespace(string $text): array
56+
{
57+
if (preg_match('/\s+$/', $text, $matches)) {
58+
$trailingSpaces = $matches[0];
59+
return [substr($text, 0, -strlen($trailingSpaces)), $trailingSpaces];
60+
}
61+
62+
return [$text, ''];
63+
}
64+
5265
public function encode(AttributedString $string): string
5366
{
5467
$result = '';
@@ -67,7 +80,10 @@ public function encode(AttributedString $string): string
6780
}
6881

6982
$attrsToClose = $previous === null ? new AttributeContainer() : $previous->attributes->diff($current->attributes);
70-
$result .= $this->closeAttributes($attrsToClose);
83+
if (!$attrsToClose->isEmpty()) {
84+
[$result, $trailingWhitespace] = $this->splitTrailingWhitespace($result);
85+
$result .= $this->closeAttributes($attrsToClose) . $trailingWhitespace;
86+
}
7187

7288
$attrsToOpen = $previous === null ? $current->attributes : $current->attributes->diff($previous->attributes);
7389
$result .= $this->openAttributes($attrsToOpen);
@@ -77,8 +93,9 @@ public function encode(AttributedString $string): string
7793
$previous = $current;
7894
}
7995

80-
if ($previous !== null) {
81-
$result .= $this->closeAttributes($previous->attributes);
96+
if ($previous !== null && !$previous->attributes->isEmpty()) {
97+
[$result, $trailingWhitespace] = $this->splitTrailingWhitespace($result);
98+
$result .= $this->closeAttributes($previous->attributes) . $trailingWhitespace;
8299
}
83100

84101
return $result;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace MarkupKit\Core\String\Internal\Traits;
4+
5+
use MarkupKit\Core\String\Attachment;
6+
use MarkupKit\Core\String\AttributedSubstring;
7+
8+
/**
9+
* @internal
10+
*/
11+
trait OptimizeElements
12+
{
13+
/**
14+
* @param (AttributedSubstring|Attachment)[] $elements
15+
* @return (AttributedSubstring|Attachment)[]
16+
*/
17+
private function optimizeElements(array $elements): array
18+
{
19+
if (count($elements) === 0) {
20+
return $elements;
21+
}
22+
23+
return array_reduce(
24+
array_slice($elements, 1),
25+
function (array $carry, AttributedSubstring|Attachment $item): array {
26+
$last = $carry[count($carry) - 1];
27+
if (
28+
$last instanceof AttributedSubstring &&
29+
$item instanceof AttributedSubstring &&
30+
$last->attributes->equals($item->attributes)
31+
) {
32+
$carry[count($carry) - 1] = new AttributedSubstring($last->string . $item->string, $last->attributes);
33+
} else {
34+
$carry[] = $item;
35+
}
36+
37+
/** @var (AttributedSubstring|Attachment)[] $carry */
38+
return $carry;
39+
},
40+
[$elements[0]]
41+
);
42+
}
43+
}

0 commit comments

Comments
 (0)