attributed-string 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 82923e8039feb4f88aed1efcddc7df11bf2a620c2320d602f8d105c57b510a8a
4
+ data.tar.gz: 0aa653c860bd4329d28464fd2513df2ce3c733cda59f77739c4c6f9eb45cc809
5
+ SHA512:
6
+ metadata.gz: 15758243087810b97934ddc122f4bb60e473ece3a338d5a642e272b08adad982701d2791c71acf46e42a20422a7ff7411e31d748875faa243a5e508eec8a136d
7
+ data.tar.gz: d6c2f8655e93f1536724a321e17fd90da6f82cca842eecfdaa82b20630cafd243e7d5a40ec60a890ae8c3f973359252742e90568347746c355b04ff5d9fc53ce
data/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+ Copyright 2024-, Andrew Mackross
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # AttributedString
2
+
3
+ An attributed string implementation for Ruby.
4
+
5
+ An attributed string contains key-value pairs known as attributes that specify additional information related to ranges of characters within the string. Attributed strings support any key-value pair, but are often used for:
6
+
7
+ - Rendering attributes such as font, color, and other details.
8
+ - Attributes for inline-attachments such as images, videos, files, etc.
9
+ - Semantic attributes such as link URLs or tool-tip information
10
+ - Language attributes to support automatic gender agreement or verb agreement.
11
+ - Accessibility attributes that provide information for assistive technologies
12
+ - Custom attributes you define
13
+
14
+ You will typically need to create a presenter for an attributed string, as the default shows no attribute information and inspect shows all attributes.
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ ```ruby
21
+ gem 'attributed-string', github: 'instruct-rb/attributed-string', branch: 'main'
22
+ ```
23
+
24
+
25
+ ## Usage
26
+
27
+ 🚧 This gem is a work in progress and the API may change before 1.0.
28
+
29
+ ```ruby
30
+ using AttributedString::Refinements
31
+ hello_world1 = AttributedString.new('Hello, World!', color: :red, font: 'Helvetica' )
32
+ hello_world2 = 'Hello, World!'.to_attr_s(color: :red, font: 'Helvetica')
33
+ puts hello_world1 == hello_world2 # true
34
+ ```
35
+
36
+ ## Attachments
37
+
38
+ ```ruby
39
+ hello_world = AttributedString.new('Hello, World!')
40
+ hello_world.add_attachment("any ruby object", position: string.length)
41
+ puts hello_world # => "Hello, World! "
42
+ puts hello_world.attachments # => ["any ruby object"]
43
+ hello_world[-1..-1] = ''
44
+ puts hello_world.attachments # => []
45
+ ```
@@ -0,0 +1,9 @@
1
+ require_relative "attributed_string/filter_result"
2
+ require_relative "attributed_string/inspect"
3
+ require_relative "attributed_string/klass"
4
+ require_relative "attributed_string/attachment"
5
+ require_relative "attributed_string/unsupported"
6
+ require_relative "attributed_string/needs_testing"
7
+ require_relative "attributed_string/refine"
8
+ require_relative "attributed_string/string_ops"
9
+ require_relative "attributed_string/version"
@@ -0,0 +1,66 @@
1
+ class AttributedString < String
2
+ # This character Object Replacement Character is used to represent an attachment in the string.
3
+ ATTACHMENT_CHARACTER = "\u{FFFC}".freeze
4
+
5
+ # Adds an attachment to the given position.
6
+ # @param attachment [Object] The attachment to add.
7
+ # @param position [Integer] The position to add the attachment to.
8
+ # @return [AttributedString] self for chaining
9
+ def add_attachment(attachment, position: self.length)
10
+ self.insert(position, ATTACHMENT_CHARACTER)
11
+
12
+ range = normalize_range(position..position)
13
+ raise ArgumentError, "Position out of bounds" if range.nil?
14
+ @store << { range:, attachment: }
15
+ self
16
+ end
17
+
18
+ # Deletes the attachment at a specific position.
19
+ # @param position [Integer] The index in the string.
20
+ # @return [Object] The attachment that was removed.
21
+ def delete_attachment(position)
22
+ attachment = attachment_at(position)
23
+ self[position] = ''
24
+ attachment
25
+ end
26
+
27
+ # Check if the string has an attachment
28
+ # @param range [Range] The range to check for attachments.
29
+ # @return [Boolean] Whether the string has an attachment at the given position.
30
+ def has_attachment?(range: 0...self.length)
31
+ range = normalize_range(range)
32
+ return false if range.nil?
33
+ (self.to_s[range]||'').include?(ATTACHMENT_CHARACTER)
34
+ end
35
+
36
+ # Returns the attachments at a specific position.
37
+ # @param position [Integer] The index in the string.
38
+ # @return <Object> The attachments at the given position.
39
+ def attachment_at(position)
40
+ result = nil
41
+ @store.each do |stored_val|
42
+ if stored_val[:range].begin == position && stored_val[:attachment]
43
+ result = stored_val[:attachment]
44
+ end
45
+ end
46
+ result
47
+ end
48
+
49
+ # Returns an array of attachments in the given range.
50
+ # @param range [Range] The range to check for attachments.
51
+ # @return [Array<Object>] The attachments in the given range.
52
+ def attachments(range: 0...self.length)
53
+ attachments_with_positions(range: range).map { |attachment| attachment[:attachment] }
54
+ end
55
+
56
+ # TODO: needs a test
57
+ def attachments_with_positions(range: 0...self.length)
58
+ range = normalize_range(range)
59
+ return [] if range.nil?
60
+ attachments = []
61
+ self.chars[range].map.with_index do |char, i|
62
+ attachments << { attachment: attachment_at(range.begin + i), position: range.begin + i } if char == ATTACHMENT_CHARACTER
63
+ end
64
+ attachments
65
+ end
66
+ end
@@ -0,0 +1,108 @@
1
+ class AttributedString < String
2
+
3
+ # Returns a filtered string the block will be called with each attribute,
4
+ # value pair. It's an inclusive filter, so if the block returns true, any
5
+ # character with that attribute will be included.
6
+ #
7
+ # This method has been slightly optimized to minimize allocations.
8
+ # @see AttributedString#filter
9
+ # @param attr_string [AttributedString]
10
+ # @param block [Proc] the block to filter the attributes.
11
+ # @return [AttributedString::FilterResult] a filtered string.
12
+ def filter(&block)
13
+ AttributedString::FilterResult.new(self, &block)
14
+ end
15
+
16
+ class FilterResult < String
17
+ # @see AttributedString#filter
18
+ def initialize(attr_string, &block)
19
+ filtered_positions = []
20
+ cached_block_calls = {}
21
+
22
+ # TODO: this can be optimized to use the same method that inspect uses which doesn't go through
23
+ # every character (it goes through each substring span with different ranges)
24
+ # A presenter type architecture that inspect, rainbow print, and filter can share would be ideal
25
+ attr_string.each_char.with_index do |char, index|
26
+ attrs = attr_string.attrs_at(index)
27
+ # Use the attrs object ID as the cache key to handle different attribute hashes
28
+ cache_key = attrs.hash
29
+ cached_result = cached_block_calls.fetch(cache_key) do
30
+ result = block.call(attrs)
31
+ cached_block_calls[cache_key] = result
32
+ result
33
+ end
34
+ if cached_result
35
+ filtered_positions << index
36
+ end
37
+ end
38
+
39
+ # Group adjacent positions into ranges to minimize allocations
40
+ ranges = []
41
+ unless filtered_positions.empty?
42
+ start_pos = filtered_positions.first
43
+ prev_pos = start_pos
44
+ filtered_positions.each_with_index do |pos, idx|
45
+ next if idx == 0
46
+ if pos == prev_pos + 1
47
+ # Continue the current range
48
+ prev_pos = pos
49
+ else
50
+ # End the current range and start a new one
51
+ ranges << (start_pos..prev_pos)
52
+ start_pos = pos
53
+ prev_pos = pos
54
+ end
55
+ end
56
+ # Add the final range
57
+ ranges << (start_pos..prev_pos)
58
+ end
59
+
60
+ # Concatenate substrings from the original string based on the ranges
61
+ result_string = ranges.map { |range| attr_string.send(:original_slice,range) }.join
62
+
63
+ # Build the list of original positions
64
+ original_positions = ranges.flat_map { |range| range.to_a }
65
+
66
+ super(result_string)
67
+ @original_positions = original_positions
68
+ freeze
69
+ end
70
+
71
+ def original_position_at(index)
72
+ @original_positions.fetch(index)
73
+ end
74
+
75
+ def original_ranges_for(filtered_range)
76
+ # TODO: this doesn't work for excluded end range
77
+ raise ArgumentError, "Invalid range" unless filtered_range.is_a?(Range)
78
+ raise ArgumentError, "Range out of bounds" if filtered_range.end >= length
79
+ if filtered_range.begin > filtered_range.end
80
+ raise ArgumentError, "Reverse range is not allowed"
81
+ end
82
+ if filtered_range.begin == filtered_range.end && filtered_range.exclude_end?
83
+ return []
84
+ end
85
+
86
+ original_positions = @original_positions[filtered_range]
87
+ ranges = []
88
+ start_pos = original_positions.first
89
+ prev_pos = start_pos
90
+
91
+ original_positions.each_with_index do |pos, idx|
92
+ next if idx == 0
93
+ if pos == prev_pos + 1
94
+ # Continue the current range
95
+ prev_pos = pos
96
+ else
97
+ # End the current range and start a new one
98
+ ranges << (start_pos..prev_pos)
99
+ start_pos = pos
100
+ prev_pos = pos
101
+ end
102
+ end
103
+ # Add the final range
104
+ ranges << (start_pos..prev_pos)
105
+ ranges
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,95 @@
1
+ class AttributedString
2
+ # Inspect prints the attributed string in an easily readable way.
3
+ # An example inspect output "{ k1: 1 }these have the attributes k1: 1 { k1: 2, k2: true }these have 2 attrs k1: 2, and k2: true { -k2 }and these have k1: 2 { -k1 }and these have none"
4
+ # could be constructed as:
5
+ #
6
+ # ("these have the attributes k1: 1 ".to_attr_s(k1: 1) +
7
+ # "these have 2 attrs k1: 2, and k2: true ".to_attr_s(k1: 2, k2: true) +
8
+ # "and these have k1: 2 ".to_attr_s(k1: 2) +
9
+ # "and these have none").inspect
10
+ #
11
+ def inspect(color: false)
12
+ # Collect all positions where attributes change
13
+ positions = Set.new
14
+
15
+ @store.each do |attr|
16
+ range = attr[:range]
17
+ positions << range.begin
18
+ positions << range.begin + range.size
19
+ end
20
+
21
+ # Include the start and end positions of the string
22
+ positions << 0
23
+ positions << self.length
24
+
25
+ # Sort all positions
26
+ positions = positions.to_a.sort
27
+
28
+ result = ""
29
+ last_attrs = {} # Initialize as empty hash
30
+
31
+ positions.each_cons(2) do |start_pos, end_pos|
32
+ next if start_pos >= end_pos # Skip invalid ranges
33
+
34
+ substring = self.to_s[start_pos...end_pos]
35
+ attrs_before = last_attrs
36
+ attachment = attachment_at(start_pos)
37
+ attrs_after = attrs_at(start_pos)
38
+
39
+ # Determine attribute changes
40
+ ended_attrs = {}
41
+ started_attrs = {}
42
+
43
+ # Attributes that have ended or changed
44
+ attrs_before.each do |key, value|
45
+ if !attrs_after.key?(key)
46
+ # Attribute has ended
47
+ ended_attrs[key] = value
48
+ elsif attrs_after[key] != value
49
+ # Attribute value has changed; treat as ending old and starting new
50
+ ended_attrs[key] = value
51
+ started_attrs[key] = attrs_after[key]
52
+ end
53
+ end
54
+
55
+ # Attributes that have started
56
+ attrs_after.each do |key, value|
57
+ if !attrs_before.key?(key)
58
+ started_attrs[key] = value
59
+ end
60
+ end
61
+
62
+ # Remove attributes that both ended and started (value change)
63
+ ended_attrs.delete_if { |k, _| started_attrs.key?(k) }
64
+
65
+ unless ended_attrs.empty? && started_attrs.empty?
66
+ attrs_str = ended_attrs.keys.sort.map{ |k| "-#{k}" }
67
+ attrs_str += started_attrs.to_a.sort{ |a,b| a[0] <=> b[0] }.map{ |a| "#{a[0]}: #{a[1]}" }
68
+ attrs_str = "{ #{attrs_str.join(', ')} }"
69
+ result += dim(attrs_str, color: color)
70
+ end
71
+
72
+ if attachment
73
+ substring = dim("[#{attachment}]", color: color)
74
+ end
75
+
76
+ # Append the substring
77
+ result += substring
78
+
79
+ last_attrs = attrs_after
80
+ end
81
+
82
+ # Close any remaining attributes
83
+ unless last_attrs.empty?
84
+ result += dim("{ #{last_attrs.keys.sort.map{ |k| "-#{k}" }.join(", ")} }", color: color)
85
+ end
86
+
87
+ result
88
+ end
89
+
90
+
91
+ def dim(string, color: true)
92
+ color ? "\e[2m#{string}\e[22m" : string
93
+ end
94
+
95
+ end
@@ -0,0 +1,113 @@
1
+ class AttributedString < String
2
+
3
+ alias_method :original_slice, :slice
4
+ private :original_slice
5
+
6
+ # Returns a new instance of AttributedString with the kwargs passed in as
7
+ # attributes for the whole string.
8
+ # @param string [String]
9
+ def initialize(string = "", **attrs)
10
+ super(string)
11
+ @store = []
12
+ if string.is_a?(AttributedString)
13
+ string.instance_variable_get(:@store).each do |entry|
14
+ @store << entry.dup
15
+ end
16
+ end
17
+ add_attrs(**attrs)
18
+ end
19
+
20
+ def dup
21
+ super.tap do |copy|
22
+ new_store = @store.map do |entry|
23
+ entry[:range] = entry[:range].dup
24
+ entry.dup
25
+ end
26
+ copy.instance_variable_set(:@store, new_store)
27
+ end
28
+ end
29
+
30
+ # Adds the given attributes in the hash to the range.
31
+ # @param range [Range] The range to apply the attributes to.
32
+ # @param attributes [Hash<Symbol, Object>] The attributes to apply to the range.
33
+ # @return [AttributedString] self for chaining
34
+ def add_attrs(range = 0..self.length - 1, **attributes)
35
+ range = normalize_range(range)
36
+ return self if attributes.empty? || self.empty? || range.nil? || range.size.zero?
37
+ @store << { range: range, attributes: attributes }
38
+ self
39
+ end
40
+
41
+ # Adds the given attributes in the hash to the range.
42
+ #
43
+ # If the attribute is already set, it will be converted to an array and the new value will be appended.
44
+ #
45
+ # @param range [Range] The range to apply the attributes to.
46
+ # @param attributes [Hash<Symbol, Object>] The attributes to apply to the range.
47
+ # @return [AttributedString] self for chaining
48
+ def add_arr_attrs(range = 0..self.length - 1, **attributes)
49
+ range = normalize_range(range)
50
+ return self if attributes.empty? || self.empty? || range.nil? || range.size.zero?
51
+ @store << { range: range, arr_attributes: attributes }
52
+ self
53
+ end
54
+
55
+
56
+
57
+ # Need to think about how this works as the attachment store is an array
58
+ # def remove_attachment(position)
59
+ # raise Todo
60
+ # end
61
+
62
+
63
+ # Removes the given attributes from a range.
64
+ # @param range_or_key [Range] The range to remove the attributes from, if its not a range, it will be treated as a key to remove.
65
+ # @param attribute_keys [Array<Symbol>] The keys of the attributes to remove.
66
+ # @return [AttributedString] self for chaining
67
+ def remove_attrs(range_or_key, *attribute_keys)
68
+ if !range_or_key.is_a?(Range)
69
+ attribute_keys << range_or_key
70
+ range_or_key = 0..self.length - 1
71
+ end
72
+ @store << { range: range_or_key, delete: attribute_keys }
73
+ self
74
+ end
75
+
76
+ # Returns the attributes at a specific position.
77
+ # @param position [Integer] The index in the string.
78
+ # @return [Hash] The attributes at the given position.
79
+ def attrs_at(position)
80
+ result = {}
81
+ @store.each do |stored_val|
82
+ if stored_val[:range].include?(position)
83
+ if stored_val[:delete]
84
+ stored_val[:delete].each do |key|
85
+ result.delete(key)
86
+ end
87
+ elsif stored_val[:arr_attributes]
88
+ stored_val[:arr_attributes].each do |key, value|
89
+ result[key] = [result[key]] if result.key?(key) && !result[key].is_a?(Array)
90
+ if value.is_a?(Array)
91
+ (result[key] ||= []).concat(value)
92
+ else
93
+ (result[key] ||= []).push(value)
94
+ end
95
+ end
96
+ elsif stored_val[:attributes]
97
+ result.merge!(stored_val[:attributes])
98
+ end
99
+ end
100
+ end
101
+ result
102
+ end
103
+
104
+ def ==(other)
105
+ return false unless other.is_a?(AttributedString)
106
+ # not super efficient, but it works for now
107
+ (0...length).all? { |i| attrs_at(i) == other.attrs_at(i) && attachment_at(i) == other.attachment_at(i) } && super
108
+ end
109
+
110
+
111
+
112
+
113
+ end
@@ -0,0 +1,8 @@
1
+ class AttributedString < String
2
+
3
+ def clear
4
+ @store.clear
5
+ super
6
+ end
7
+
8
+ end
@@ -0,0 +1,42 @@
1
+ class AttributedString
2
+ module Refinements
3
+ refine String do
4
+ unless self.class == AttributedString
5
+ # @param attrs [Hash] A hash of attributes to apply to the string.
6
+ # @return [Instruct::AttributedString] A new AttributedString with the given attributes.
7
+ def to_attr_s(**attrs)
8
+ AttributedString.new(self, **attrs)
9
+ end
10
+
11
+ alias_method :original_eq, :==
12
+ private :original_eq
13
+ def ==(other)
14
+ return false if other.is_a?(AttributedString)
15
+ original_eq(other)
16
+ end
17
+
18
+ alias_method :original_plus, :+
19
+ private :original_plus
20
+ def +(other)
21
+ if other.is_a?(AttributedString)
22
+ other.class.new(self) + other
23
+ else
24
+ original_plus(other)
25
+ end
26
+ end
27
+
28
+ alias_method :original_concat, :concat
29
+ private :original_concat
30
+ def concat(*args)
31
+ args.each do |arg|
32
+ if arg.is_a?(AttributedString)
33
+ raise ArgumentError, "Cannot concatenate AttributedString to String"
34
+ end
35
+ end
36
+ original_concat(*args)
37
+ self
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,270 @@
1
+ class AttributedString < String
2
+
3
+ def insert(index, other)
4
+ return super if other.empty?
5
+ other = AttributedString.new(other) unless other.is_a?(AttributedString)
6
+ index = normalize_index(index, len: self.length + 1)
7
+ split_ranges(@store, index, other.length, self.length + other.length)
8
+ store = other.instance_variable_get(:@store).map { |obj| obj.dup }
9
+ translate_ranges(store, distance: index)
10
+ @store.concat(store)
11
+ super
12
+ end
13
+
14
+ def slice(arg, *args)
15
+ result = super
16
+ raise Todo, "Regular expression not implemented for slice, consider raising a pull request" if arg.is_a?(Regexp)
17
+
18
+ range = arg.is_a?(Range) ? normalize_range(arg) : normalize_integer_slice_args(arg, *args)
19
+
20
+ return range if range.nil?
21
+ raise RuntimeError if range.size != result.length
22
+ store = @store.map do |obj|
23
+ clamped = clamp_range(obj[:range], range)
24
+ next nil if clamped.nil?
25
+ obj.dup.tap do |obj|
26
+ obj[:range] = translate_range(clamped, distance: -range.begin)
27
+ end
28
+ end.compact
29
+
30
+ new_string = self.class.new(result)
31
+ new_string.instance_variable_set(:@store, store)
32
+ new_string
33
+ end
34
+ alias_method :[], :slice
35
+
36
+ def slice!(arg, *args)
37
+ result = slice(arg, *args)
38
+ return result if result.nil?
39
+
40
+ range = arg.is_a?(Range) ? normalize_range(arg) : normalize_integer_slice_args(arg, *args)
41
+ return result if range.nil?
42
+
43
+ super(range)
44
+
45
+ split_ranges(@store, range.begin, -range.size, self.length)
46
+
47
+ result
48
+ end
49
+
50
+ # TODO: this should take args not other
51
+ # can be implemented as (other, *args)
52
+ def concat(other)
53
+ if other.is_a?(AttributedString)
54
+ store = other.instance_variable_get(:@store).map { |obj| obj.dup }
55
+ translate_ranges(store, distance: self.length)
56
+ @store.concat(store)
57
+ end
58
+ super
59
+ end
60
+ alias_method :<<, :concat
61
+
62
+ def prepend(other)
63
+ if other.is_a?(AttributedString)
64
+ store = other.instance_variable_get(:@store).map { |obj| obj.dup }
65
+ translate_ranges(@store, distance: other.length)
66
+ @store.concat(store)
67
+ end
68
+ super
69
+ end
70
+
71
+
72
+ # modifies the string or returns a modified string
73
+ def replace(other)
74
+ super
75
+ other_store = other.instance_variable_get(:@store) || []
76
+ @store = other_store.map { |obj| obj.dup }
77
+ end
78
+
79
+ def +(other)
80
+ self.dup.concat(other)
81
+ end
82
+
83
+ def []=(arg, *args, value)
84
+ if arg.is_a?(Regexp)
85
+ raise Todo, "Regular expression assignment not implemented for []=, consider raising a pull request"
86
+ end
87
+ value ||= ""
88
+ value = value.is_a?(AttributedString) ? value : self.class.new(value)
89
+
90
+ range = arg.is_a?(Range) ? normalize_range(arg) : normalize_integer_slice_args(arg, *args)
91
+ return nil if range.nil?
92
+ slice!(range)
93
+ insert(range.begin, value)
94
+ value
95
+ end
96
+
97
+ private
98
+
99
+ def normalize_range(range, len: self.length)
100
+ return nil unless range
101
+
102
+ b = range.begin
103
+ e = range.end
104
+ exclude_end = range.exclude_end?
105
+
106
+ # Handle nil for begin and end
107
+ b = b.nil? ? 0 : b
108
+ e = e.nil? ? len : e
109
+
110
+ # Convert negative indices to positive
111
+ b += len if b < 0
112
+ e += len if e < 0
113
+
114
+ # Return nil if adjusted begin index is out of bounds
115
+ return nil if b < 0 || b > len
116
+
117
+ # Adjust indices to be within bounds
118
+ b = b.clamp(0, len)
119
+ e = e.clamp(-1, len)
120
+
121
+ # Adjust end for exclusive ranges unless both begin and end are nil
122
+ unless range.begin.nil? && range.end.nil?
123
+ e -= 1 if exclude_end
124
+ end
125
+
126
+ # Ensure the end index does not exceed len - 1
127
+ e = [e, len - 1].min
128
+
129
+ # Return nil if begin index is outside the string
130
+ return nil if b >= len
131
+
132
+ # Return empty range if begin > end
133
+ return b...b if b > e
134
+
135
+ # Return the normalized range
136
+ b..e
137
+ end
138
+
139
+ def normalize_index(index, len: self.length)
140
+ # Convert negative index to positive equivalent
141
+ normalized_index = index < 0 ? len + index : index
142
+
143
+ # Clamp the index within string bounds
144
+ [[normalized_index, 0].max, len].min
145
+ end
146
+
147
+ def normalize_integer_slice_args(start, *args, len: self.length)
148
+ length_set = args.length == 1
149
+ length = length_set ? args[0] : 1
150
+ # Return nil if start is nil or length is nil or negative
151
+ return nil if !length_set && start >= len
152
+
153
+ # Adjust negative start index
154
+ start += len if start < 0
155
+
156
+ # Return nil if start is still negative
157
+ return nil if start < 0
158
+
159
+ # Adjust length if it extends beyond the string
160
+ max_length = len - start
161
+ length = [length, max_length].min
162
+
163
+ # Return nil if length is negative
164
+ return nil if length < 0
165
+
166
+ # Return the normalized arguments
167
+ start...(start + length)
168
+ end
169
+
170
+ # @api private
171
+ # splits the ranges in the store at the given index
172
+ # if the index is negative, it will be counted from the end of the string
173
+ # @param index [Integer] the index to split the ranges at
174
+ # @param gap [Integer] the gap to insert at the index, if its negative, it will delete
175
+ # @param new_length [Integer] the new length of the string, used to remove ranges that are out of bounds
176
+ def split_ranges(store, index, gap, new_length)
177
+ new_ranges = []
178
+ store.each_with_index do |obj, idx|
179
+ range = obj[:range]
180
+
181
+ if gap > 0
182
+ if index <= range.begin
183
+ # Shift the range
184
+ obj[:range] = translate_range(range, distance: gap)
185
+ elsif index > range.begin && index <= range.end
186
+ # Attribute spans over the insertion point; split into two
187
+ original_end = range.end
188
+ obj[:range] = range.begin..(index - 1)
189
+ new_range = (index + gap)..(original_end + gap)
190
+ new_obj = obj.dup
191
+ new_obj[:range] = new_range
192
+ new_ranges << new_obj
193
+ end
194
+ else
195
+ # Deletion logic
196
+ deletion_end = index - gap - 1 # Since gap is negative
197
+ if range.end < index
198
+ # Case 1: Attribute entirely before deletion; no change needed
199
+ next
200
+ elsif range.begin > deletion_end
201
+ # Case 2: Attribute entirely after deletion; shift it
202
+ obj[:range] = translate_range(range, distance: gap)
203
+ elsif range.begin >= index && range.end <= deletion_end
204
+ # Case 5: Attribute entirely within deletion; remove it
205
+ store[idx] = nil
206
+ elsif range.begin >= index && range.end > deletion_end
207
+ # Case 3: Attribute starts within deletion, ends after deletion
208
+ obj[:range] = (index)..(range.end + gap)
209
+ elsif range.begin < index && range.end >= index && range.end <= deletion_end
210
+ # Case 4: Attribute starts before deletion, ends within deletion
211
+ obj[:range] = range.begin..(index - 1)
212
+ elsif range.begin < index && range.end > deletion_end
213
+ # Case 6: Attribute spans over deletion
214
+ obj[:range] = range.begin..(range.end + gap)
215
+ end
216
+
217
+ # Remove ranges that are now invalid
218
+ if obj[:range].end < obj[:range].begin || obj[:range].end < 0 || obj[:range].begin >= new_length
219
+ store[idx] = nil
220
+ end
221
+ end
222
+ end
223
+ store.concat(new_ranges)
224
+ store.compact!
225
+ end
226
+
227
+ def translate_ranges(store, distance:)
228
+ store.each do |obj|
229
+ obj[:range] = translate_range(obj[:range], distance:)
230
+ end
231
+ end
232
+
233
+ def translate_range(range, distance:)
234
+ range.exclude_end? ? (range.begin + distance)...(range.end + distance) : (range.begin + distance)..(range.end + distance)
235
+ end
236
+
237
+ def clamp_range(range, clamp_range)
238
+ # Determine the new start index
239
+ new_start = [range.begin, clamp_range.begin].max
240
+
241
+ # Adjust ends based on exclude_end?
242
+ range_end_adj = range.exclude_end? ? range.end - 1 : range.end
243
+ clamp_end_adj = clamp_range.exclude_end? ? clamp_range.end - 1 : clamp_range.end
244
+
245
+ # Determine the new adjusted end index
246
+ new_end_adj = [range_end_adj, clamp_end_adj].min
247
+
248
+ # Check if ranges do not overlap
249
+ if new_start > new_end_adj
250
+ return nil
251
+ end
252
+
253
+ # Check if each range includes new_end_adj
254
+ range_includes_new_end = range.cover?(new_end_adj)
255
+ clamp_includes_new_end = clamp_range.cover?(new_end_adj)
256
+
257
+ # Determine if the new range should exclude the end
258
+ new_exclude_end = !(range_includes_new_end && clamp_includes_new_end)
259
+
260
+ # Construct the new range
261
+ if new_exclude_end
262
+ # For exclusive ranges, the end index is one beyond the last included index
263
+ new_range = new_start... (new_end_adj + 1)
264
+ else
265
+ new_range = new_start..new_end_adj
266
+ end
267
+
268
+ new_range
269
+ end
270
+ end
@@ -0,0 +1,221 @@
1
+ class AttributedString < String
2
+ class Todo < NotImplementedError
3
+ def message = "Not implemented, consider adding a pull request"
4
+ end
5
+
6
+ # Unsupported.
7
+ # Consider opening pull request
8
+ def gsub(*args, **kwargs, &block) = raise Todo
9
+
10
+ # Unsupported.
11
+ # Consider opening pull request
12
+ def gsub!(*args, **kwargs, &block) = raise Todo
13
+
14
+ # Unsupported.
15
+ # Consider opening pull request
16
+ def sub(*args, **kwargs, &block) = raise Todo
17
+
18
+ # Unsupported.
19
+ # Consider opening pull request
20
+ def sub!(*args, **kwargs, &block) = raise Todo
21
+
22
+ # Unsupported.
23
+ # Consider opening pull request
24
+ def succ(*args, **kwargs, &block) = raise Todo
25
+
26
+ # Unsupported.
27
+ # Consider opening pull request
28
+ def succ!(*args, **kwargs, &block) = raise Todo
29
+
30
+ # Unsupported.
31
+ # Consider opening pull request
32
+ def reverse(*args, **kwargs, &block) = raise Todo
33
+
34
+ # Unsupported.
35
+ # Consider opening pull request
36
+ def reverse!(*args, **kwargs, &block) = raise Todo
37
+
38
+ # Unsupported.
39
+ # Consider opening pull request
40
+ def setbyte(*args, **kwargs, &block) = raise Todo
41
+
42
+ # Unsupported.
43
+ # Consider opening pull request
44
+ def tr(*args, **kwargs, &block) = raise Todo
45
+
46
+ # Unsupported.
47
+ # Consider opening pull request
48
+ def tr!(*args, **kwargs, &block) = raise Todo
49
+
50
+ # Unsupported.
51
+ # Consider opening pull request
52
+ def tr_s(*args, **kwargs, &block) = raise Todo
53
+
54
+ # Unsupported.
55
+ # Consider opening pull request
56
+ def tr_s!(*args, **kwargs, &block) = raise Todo
57
+
58
+ # Unsupported.
59
+ # Consider opening pull request
60
+ def squeeze(*args, **kwargs, &block) = raise Todo
61
+
62
+ # Unsupported.
63
+ # Consider opening pull request
64
+ def squeeze!(*args, **kwargs, &block) = raise Todo
65
+
66
+ # Unsupported.
67
+ # Consider opening pull request
68
+ def lstrip(*args, **kwargs, &block) = raise Todo
69
+
70
+ # Unsupported.
71
+ # Consider opening pull request
72
+ def lstrip!(*args, **kwargs, &block) = raise Todo
73
+
74
+ # Unsupported.
75
+ # Consider opening pull request
76
+ def rstrip(*args, **kwargs, &block) = raise Todo
77
+
78
+ # Unsupported.
79
+ # Consider opening pull request
80
+ def rstrip!(*args, **kwargs, &block) = raise Todo
81
+
82
+ # Unsupported.
83
+ # Consider opening pull request
84
+ def strip(*args, **kwargs, &block) = raise Todo
85
+
86
+ # Unsupported.
87
+ # Consider opening pull request
88
+ def strip!(*args, **kwargs, &block) = raise Todo
89
+
90
+ # Unsupported.
91
+ # Consider opening pull request
92
+ def chomp(*args, **kwargs, &block) = raise Todo
93
+
94
+ # Unsupported.
95
+ # Consider opening pull request
96
+ def chomp!(*args, **kwargs, &block) = raise Todo
97
+
98
+ # Unsupported.
99
+ # Consider opening pull request
100
+ def chop(*args, **kwargs, &block) = raise Todo
101
+
102
+ # Unsupported.
103
+ # Consider opening pull request
104
+ def chop!(*args, **kwargs, &block) = raise Todo
105
+
106
+ # converts string
107
+
108
+ # Unsupported.
109
+ # Consider opening pull request
110
+ def center(*args, **kwargs, &block) = raise Todo
111
+
112
+ # Unsupported.
113
+ # Consider opening pull request
114
+ def ljust(*args, **kwargs, &block) = raise Todo
115
+
116
+ # Unsupported.
117
+ # Consider opening pull request
118
+ def rjust(*args, **kwargs, &block) = raise Todo
119
+
120
+ # Unsupported.
121
+ # Consider opening pull request
122
+ def b(*args, **kwargs, &block) = raise Todo
123
+
124
+ # Unsupported.
125
+ # Consider opening pull request
126
+ def scrub(*args, **kwargs, &block) = raise Todo
127
+
128
+ # Unsupported.
129
+ # Consider opening pull request
130
+ def unicode_normalize(*args, **kwargs, &block) = raise Todo
131
+
132
+ # Unsupported.
133
+ # Consider opening pull request
134
+ def encode(*args, **kwargs, &block) = raise Todo
135
+
136
+ # substitutions
137
+
138
+ # Unsupported.
139
+ # Consider opening pull request
140
+ def dump(*args, **kwargs, &block) = raise Todo
141
+
142
+ # Unsupported.
143
+ # Consider opening pull request
144
+ def undump(*args, **kwargs, &block) = raise Todo
145
+
146
+ # Unsupported.
147
+ # Consider opening pull request
148
+ def %(*args, **kwargs, &block) = raise Todo
149
+
150
+ # more deletes
151
+
152
+ # Unsupported.
153
+ # Consider opening pull request
154
+ def delete(*args, **kwargs, &block) = raise Todo
155
+
156
+ # Unsupported.
157
+ # Consider opening pull request
158
+ def delete!(*args, **kwargs, &block) = raise Todo
159
+
160
+ # Unsupported.
161
+ # Consider opening pull request
162
+ def delete_prefix(*args, **kwargs, &block) = raise Todo
163
+
164
+ # Unsupported.
165
+ # Consider opening pull request
166
+ def delete_prefix!(*args, **kwargs, &block) = raise Todo
167
+
168
+ # Unsupported.
169
+ # Consider opening pull request
170
+ def delete_suffix(*args, **kwargs, &block) = raise Todo
171
+
172
+ # Unsupported.
173
+ # Consider opening pull request
174
+ def delete_suffix!(*args, **kwargs, &block) = raise Todo
175
+
176
+ # Unsupported.
177
+ # Consider opening pull request
178
+ def byteslice(*args, **kwargs, &block) = raise Todo
179
+
180
+ # Unsupported.
181
+ # Consider opening pull request
182
+ def chr(*args, **kwargs, &block) = raise Todo
183
+
184
+ # Unsupported.
185
+ # Consider opening pull request
186
+ def *(*args, **kwargs, &block) = raise Todo
187
+
188
+
189
+ # Unsupported.
190
+ # Consider opening pull request
191
+ def lines(*args, **kwargs, &block) = raise Todo
192
+
193
+ # Unsupported.
194
+ # Consider opening pull request
195
+ def partition(*args, **kwargs, &block) = raise Todo
196
+
197
+ # Unsupported.
198
+ # Consider opening pull request
199
+ def rpartition(*args, **kwargs, &block) = raise Todo
200
+
201
+ # Unsupported.
202
+ # Consider opening pull request
203
+ def split(*args, **kwargs, &block) = raise Todo
204
+
205
+ # Unsupported.
206
+ # Consider opening pull request
207
+ def scan(*args, **kwargs, &block) = raise Todo
208
+
209
+ # Unsupported.
210
+ # Consider opening pull request
211
+ def unpack(*args, **kwargs, &block) = raise Todo
212
+
213
+ # Unsupported.
214
+ # Consider opening pull request
215
+ def unpack1(*args, **kwargs, &block) = raise Todo
216
+
217
+ # Unsupported.
218
+ # Consider opening pull request
219
+ def upto(*args, **kwargs, &block) = raise Todo
220
+
221
+ end
@@ -0,0 +1,3 @@
1
+ class AttributedString < String
2
+ VERSION = "0.1.0"
3
+ end
metadata ADDED
@@ -0,0 +1,56 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: attributed-string
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Mackross
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-12-20 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email: andrew@mackross.net
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - README.md
21
+ - lib/attributed-string.rb
22
+ - lib/attributed_string/attachment.rb
23
+ - lib/attributed_string/filter_result.rb
24
+ - lib/attributed_string/inspect.rb
25
+ - lib/attributed_string/klass.rb
26
+ - lib/attributed_string/needs_testing.rb
27
+ - lib/attributed_string/refine.rb
28
+ - lib/attributed_string/string_ops.rb
29
+ - lib/attributed_string/unsupported.rb
30
+ - lib/attributed_string/version.rb
31
+ homepage: https://github.com/instruct-rb/attributed-string
32
+ licenses:
33
+ - Apache-2.0
34
+ metadata:
35
+ source_code_uri: https://github.com/instruct-rb/attributed-string
36
+ homepage_uri: https://github.com/instruct-rb/attributed-string
37
+ post_install_message:
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: 3.2.3
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 3.5.16
53
+ signing_key:
54
+ specification_version: 4
55
+ summary: An attributed string implementation for Ruby.
56
+ test_files: []