punctuated 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 44f5d30890160857cc82ff20ffe096fb8c063c614a3dd22e6c7f187513431a4f
4
+ data.tar.gz: bf3c0eb0f0ac606d4f14c0996fc92879dec257290ba3afb665002dbc2004de71
5
+ SHA512:
6
+ metadata.gz: 5b54216e598db32e130a5a9c3149eda0be6cb0376236ad3d98cd5ebc8e7ba6fabad2fb036b8f6789949d014a14bdf1249818a9c76dd94025310c513a4a454a1d
7
+ data.tar.gz: 94b72417fe7ebd12f61197a81a714154bcfff7c6bfe0b749c0e8787963e301f90ec70a74a750d6c55f6bc2ea609d803be05d1761d94d2f0f780b7b1b413f5205
data/changelog.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+
4
+ ## 0.9.0
5
+
6
+ * Unicode-aware parsing of sentence terminals and surrounding wrappers at either string boundary.
7
+ * APIs to detect, ensure, strip and replace punctuation, including mutable parsed objects and bang variants.
8
+ * Coordinated inside/outside placement.
9
+ * Strict option validation and string/symbol-indifferent configuration.
10
+ * Support for Ruby 2.3 and later, with a cross-version continuous integration matrix.
11
+ * Executable examples, package validation and release documentation.
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+
5
+ module Punctuated
6
+ # A mutable parsed run. Setters delegate to Parsed so paired placements always remain coordinated.
7
+ class Component
8
+ attr_reader :raw, :placement
9
+
10
+ def initialize(owner, side, raw = "", placement = nil)
11
+ @owner = owner
12
+ @side = side
13
+ commit(raw, placement)
14
+ end
15
+
16
+ def raw=(value)
17
+ @owner.change_component(@side, kind, raw: value)
18
+ end
19
+
20
+ def placement=(value)
21
+ @owner.change_component(@side, kind, placement: value)
22
+ end
23
+
24
+ # Whitespace is formatting owned by the run, not one of its punctuation characters.
25
+ def chars
26
+ @raw.each_char.reject { |character| WHITESPACE.match(character) }
27
+ end
28
+
29
+ # The owner calculates this structurally, avoiding false matches when raw strings repeat.
30
+ def index
31
+ return -1 if @raw.empty?
32
+
33
+ @owner.component_index(self)
34
+ end
35
+
36
+ def kind
37
+ self.class::KIND
38
+ end
39
+
40
+ protected
41
+
42
+ def commit(raw, placement)
43
+ @raw = raw.dup
44
+ @placement = placement
45
+ end
46
+ end
47
+
48
+ # Describes one sentence-terminal run at the start or end of a parsed string.
49
+ class Terminal < Component
50
+ KIND = :terminal
51
+ end
52
+
53
+ # Describes one opening/closing/quotation run at the start or end of a parsed string.
54
+ class Wrapper < Component
55
+ KIND = :wrapper
56
+ end
57
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Punctuated
4
+ # Sentence-terminal characters recognised by Unicode, plus inverted sentence terminals omitted from STerm.
5
+ TERMINAL = /[\p{STerm}¡¿⸘]/u
6
+
7
+ # Opening, closing and quotation characters that may surround a sentence terminal.
8
+ WRAPPER = /(?:[\p{Ps}\p{Pe}\p{Pi}\p{Pf}]|\p{Quotation_Mark})/u
9
+
10
+ # Whitespace deliberately uses Ruby's normal \s semantics, as required by the public contract.
11
+ WHITESPACE = /\s/u
12
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Punctuated
4
+ # Centralises strict option validation and string/symbol indifference for every public method.
5
+ module Options
6
+ module_function
7
+
8
+ def hash(positional, keywords, allowed:, label: "options")
9
+ unless positional.nil? || positional.is_a?(Hash)
10
+ raise ArgumentError, "#{label} must be a Hash"
11
+ end
12
+
13
+ normalised = {}
14
+ [positional || {}, keywords || {}].each do |source|
15
+ source.each do |key, value|
16
+ normalised_key = key(key)
17
+ unless allowed.include?(normalised_key)
18
+ raise ArgumentError, "unknown #{label} key: #{key.inspect}"
19
+ end
20
+ if normalised.key?(normalised_key)
21
+ raise ArgumentError, "duplicate #{label} key: #{normalised_key.inspect}"
22
+ end
23
+
24
+ normalised[normalised_key] = value
25
+ end
26
+ end
27
+
28
+ normalised
29
+ end
30
+
31
+ def key(value)
32
+ return value if value.is_a?(Symbol)
33
+ return value.to_sym if value.is_a?(String)
34
+
35
+ raise ArgumentError, "option keys must be Strings or Symbols"
36
+ end
37
+
38
+ def enum(value, allowed:, label:, allow_nil: false)
39
+ return nil if value.nil? && allow_nil
40
+
41
+ normalised = if value.is_a?(String)
42
+ value.to_sym
43
+ elsif value.is_a?(Symbol)
44
+ value
45
+ end
46
+ unless allowed.include?(normalised)
47
+ raise ArgumentError, "invalid #{label}: #{value.inspect}"
48
+ end
49
+
50
+ normalised
51
+ end
52
+
53
+ def placement(value, allow_nil: true)
54
+ enum(value, allowed: [:inside, :outside], label: "placement", allow_nil: allow_nil)
55
+ end
56
+
57
+ def side(value)
58
+ enum(value, allowed: [:start, :end], label: "side")
59
+ end
60
+
61
+ # Expands shorthand values to start/end pairs, optionally directing shorthand to one implicit side.
62
+ def sides(value, label:, side: nil, &validator)
63
+ implicit_side = side.nil? ? nil : self.side(side)
64
+ values = value.is_a?(Array) ? value.dup : [value]
65
+ case values.length
66
+ when 1
67
+ values = case implicit_side
68
+ when :start
69
+ [values.first, nil]
70
+ when :end
71
+ [nil, values.first]
72
+ else
73
+ [values.first, values.first]
74
+ end
75
+ when 2
76
+ # Already in the required two-sided form.
77
+ else
78
+ raise ArgumentError, "#{label} must contain one or two elements"
79
+ end
80
+
81
+ values.map(&validator)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Punctuated
4
+ class Parsed
5
+ # Recombines parsed state and provides the single commit path for all component changes.
6
+ module Composition
7
+ # Rebuilds from current state on every call, so returned strings can be mutated safely.
8
+ def result
9
+ result_parts.map { |part| part.is_a?(Component) ? part.raw : part }.join
10
+ end
11
+
12
+ def component_index(target)
13
+ offset = 0
14
+ result_parts.each do |part|
15
+ return offset if part.equal?(target)
16
+
17
+ offset += part.is_a?(Component) ? part.raw.length : part.length
18
+ end
19
+ -1
20
+ end
21
+
22
+ # Raw and placement setters enter here so a public mutation commits one coordinated pair.
23
+ def change_component(side, kind, attributes)
24
+ change = {}
25
+ if attributes.key?(:raw)
26
+ change[:raw] = attributes[:raw]
27
+ end
28
+ if attributes.key?(:placement)
29
+ change[:placement] = attributes[:placement]
30
+ change[:placement_specified] = true
31
+ end
32
+
33
+ apply_component_changes(side => { kind => change })
34
+ component(side, kind)
35
+ end
36
+
37
+ private
38
+
39
+ def build_components
40
+ @terminals = [Terminal.new(self, 0), Terminal.new(self, 1)].freeze
41
+ @wrappers = [Wrapper.new(self, 0), Wrapper.new(self, 1)].freeze
42
+ end
43
+
44
+ def result_parts
45
+ ordered_components(0) + [@content] + ordered_components(1)
46
+ end
47
+
48
+ def ordered_components(side)
49
+ present = KINDS.map { |kind| component(side, kind) }.reject { |item| item.raw.empty? }
50
+ return present if present.length < 2
51
+
52
+ if side.zero?
53
+ present.sort_by { |item| item.placement == :outside ? 0 : 1 }
54
+ else
55
+ present.sort_by { |item| item.placement == :inside ? 0 : 1 }
56
+ end
57
+ end
58
+
59
+ # Every operation is fully validated and coordinated here before any component is committed.
60
+ def apply_component_changes(changes)
61
+ candidates = {}
62
+ changes.each do |side, pair_changes|
63
+ candidates[side] = coordinated_pair(side, pair_changes)
64
+ end
65
+
66
+ candidates.each do |side, pair|
67
+ KINDS.each do |kind|
68
+ state = pair[kind]
69
+ component(side, kind).__send__(:commit, state[:raw], state[:placement])
70
+ end
71
+ end
72
+ end
73
+
74
+ # Computes a complete candidate before committing, with explicit placement requests taking priority.
75
+ def coordinated_pair(side, changes)
76
+ current = {}
77
+ candidate = {}
78
+ explicit = {}
79
+ KINDS.each do |kind|
80
+ item = component(side, kind)
81
+ current[kind] = { raw: item.raw, placement: item.placement }
82
+ change = changes.fetch(kind, {})
83
+ raw = change.key?(:raw) ? change[:raw] : item.raw
84
+ unless raw.is_a?(String)
85
+ raise ArgumentError, "raw must be a String"
86
+ end
87
+ candidate[kind] = { raw: raw, placement: item.placement }
88
+ if change[:placement_specified]
89
+ explicit[kind] = Options.placement(change[:placement])
90
+ end
91
+ end
92
+
93
+ explicit.each do |kind, placement|
94
+ if candidate[kind][:raw].empty? && placement
95
+ raise ArgumentError, "an absent #{kind} cannot have a placement"
96
+ end
97
+ end
98
+
99
+ present = KINDS.reject { |kind| candidate[kind][:raw].empty? }
100
+ if present.empty?
101
+ KINDS.each { |kind| candidate[kind][:placement] = nil }
102
+ elsif present.length == 1
103
+ coordinate_lone_component(current, candidate, present.first, explicit)
104
+ else
105
+ coordinate_complete_pair(current, candidate, explicit)
106
+ end
107
+ candidate
108
+ end
109
+
110
+ def coordinate_lone_component(current, candidate, present_kind, explicit)
111
+ absent_kind = counterpart(present_kind)
112
+ candidate[absent_kind][:placement] = nil
113
+ removed_component = KINDS.any? { |kind| !current[kind][:raw].empty? && candidate[kind][:raw].empty? }
114
+ candidate[present_kind][:placement] = if removed_component
115
+ nil
116
+ elsif explicit.key?(present_kind)
117
+ explicit[present_kind]
118
+ else
119
+ current[present_kind][:placement]
120
+ end
121
+ end
122
+
123
+ def coordinate_complete_pair(current, candidate, explicit)
124
+ non_nil_preferences = explicit.reject { |_kind, placement| placement.nil? }
125
+ if non_nil_preferences.length == 2 && non_nil_preferences.values.uniq.length == 1
126
+ raise ArgumentError, "terminal and wrapper cannot have the same placement"
127
+ end
128
+
129
+ if !non_nil_preferences.empty?
130
+ preferred_kind, preferred_placement = non_nil_preferences.first
131
+ set_opposite_placements(candidate, preferred_kind, preferred_placement)
132
+ elsif !explicit.empty?
133
+ set_natural_placements(candidate)
134
+ elsif KINDS.all? { |kind| !current[kind][:raw].empty? }
135
+ candidate[:terminal][:placement] = current[:terminal][:placement]
136
+ candidate[:wrapper][:placement] = current[:wrapper][:placement]
137
+ else
138
+ existing_kind = KINDS.find { |kind| !current[kind][:raw].empty? }
139
+ if existing_kind && current[existing_kind][:placement]
140
+ set_opposite_placements(candidate, existing_kind, current[existing_kind][:placement])
141
+ else
142
+ set_natural_placements(candidate)
143
+ end
144
+ end
145
+ end
146
+
147
+ def set_opposite_placements(candidate, kind, placement)
148
+ candidate[kind][:placement] = placement
149
+ candidate[counterpart(kind)][:placement] = placement == :inside ? :outside : :inside
150
+ end
151
+
152
+ def set_natural_placements(candidate)
153
+ candidate[:terminal][:placement] = :inside
154
+ candidate[:wrapper][:placement] = :outside
155
+ end
156
+
157
+ def counterpart(kind)
158
+ kind == :terminal ? :wrapper : :terminal
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Punctuated
4
+ class Parsed
5
+ # Implements punctuation queries and transformations over already parsed state.
6
+ module Operations
7
+ QUERY_METHODS = [:start_with?, :end_with?].freeze
8
+ TRANSFORMING_METHODS = [:ensure, :ensure_start, :strip, :lstrip, :rstrip, :replace].freeze
9
+ METHODS = (QUERY_METHODS + TRANSFORMING_METHODS).freeze
10
+ METHOD_SIDES = {
11
+ start_with?: :start,
12
+ end_with?: :end,
13
+ lstrip: :start,
14
+ rstrip: :end
15
+ }.freeze
16
+
17
+ # Non-bang transformations are uniformly independent views of their bang implementation.
18
+ TRANSFORMING_METHODS.each do |method_name|
19
+ define_method(method_name) do |*arguments, **keywords, &block|
20
+ copy = dup
21
+ copy.public_send("#{method_name}!", *arguments, **keywords, &block)
22
+ copy.result
23
+ end
24
+ end
25
+
26
+ def start_with?(terminal = nil, options = nil, **keywords)
27
+ terminal, settings = terminal_and_options(terminal, options, keywords, allowed: [:placement])
28
+ matches_terminal?(0, terminal, settings)
29
+ end
30
+
31
+ def end_with?(terminal = nil, options = nil, **keywords)
32
+ terminal, settings = terminal_and_options(terminal, options, keywords, allowed: [:placement])
33
+ matches_terminal?(1, terminal, settings)
34
+ end
35
+
36
+ alias at_start? start_with?
37
+ alias at_end? end_with?
38
+
39
+ def ensure!(terminal = DEFAULT_TERMINALS, options = nil, **keywords)
40
+ terminal, settings = terminal_and_options(terminal, options, keywords, allowed: [:placement], default_terminal: DEFAULT_TERMINALS)
41
+ terminals = Options.sides(terminal, label: "terminal", side: :end) { |value| validate_terminal(value) }
42
+ placements = if settings.key?(:placement)
43
+ Options.sides(settings[:placement], label: "placement", side: :end) { |value| Options.placement(value) }
44
+ else
45
+ [nil, nil]
46
+ end
47
+ changes = {}
48
+
49
+ 2.times do |side|
50
+ fallback = terminals[side]
51
+ next if fallback.nil?
52
+
53
+ terminal_component = component(side, :terminal)
54
+ placement = placements[side]
55
+ if terminal_component.raw.empty?
56
+ next if fallback.empty?
57
+
58
+ terminal_change = { raw: fallback, placement: placement || :inside, placement_specified: true }
59
+ elsif placement
60
+ terminal_change = { placement: placement, placement_specified: true }
61
+ else
62
+ next
63
+ end
64
+
65
+ changes[side] = {
66
+ terminal: terminal_change
67
+ }
68
+ end
69
+ apply_component_changes(changes)
70
+ self
71
+ end
72
+
73
+ def ensure_start!(terminal, options = nil, **keywords)
74
+ validate_single_terminal(terminal)
75
+ settings = Options.hash(options, keywords, allowed: [:placement])
76
+ if settings.key?(:placement)
77
+ if settings[:placement].is_a?(Array)
78
+ raise ArgumentError, "ensure_start placement does not accept Arrays"
79
+ end
80
+ settings[:placement] = [settings[:placement], nil]
81
+ end
82
+ ensure!([terminal, nil], settings, **{})
83
+ end
84
+
85
+ def strip!(options = nil, **keywords)
86
+ settings = Options.hash(options, keywords, allowed: [:terminals, :wrappers], label: "strip options")
87
+ terminal_settings = Options.sides(settings.fetch(:terminals, true), label: "terminals") { |value| validate_strip_value(value) }
88
+ wrapper_settings = Options.sides(settings.fetch(:wrappers, nil), label: "wrappers") { |value| validate_strip_value(value) }
89
+ changes = {}
90
+
91
+ # Select everything against the original placements before coordinating any removals.
92
+ 2.times do |side|
93
+ pair_changes = {}
94
+ if strip_selected?(side, :terminal, terminal_settings[side])
95
+ pair_changes[:terminal] = { raw: "" }
96
+ end
97
+ if strip_selected?(side, :wrapper, wrapper_settings[side])
98
+ pair_changes[:wrapper] = { raw: "" }
99
+ end
100
+ changes[side] = pair_changes unless pair_changes.empty?
101
+ end
102
+ apply_component_changes(changes)
103
+ self
104
+ end
105
+
106
+ def lstrip!(options = nil, **keywords)
107
+ side_strip!(0, options, keywords)
108
+ end
109
+
110
+ def rstrip!(options = nil, **keywords)
111
+ side_strip!(1, options, keywords)
112
+ end
113
+
114
+ def replace!(replacements = nil, **keywords)
115
+ settings = Options.hash(replacements, keywords, allowed: [:terminals, :wrappers], label: "replacement options")
116
+ if settings.empty?
117
+ raise ArgumentError, "replace requires wrappers and/or terminals"
118
+ end
119
+
120
+ normalised = {}
121
+ settings.each do |kind_plural, value|
122
+ normalised[kind_plural] = Options.sides(value, label: kind_plural.to_s) { |entry| entry }
123
+ end
124
+
125
+ # Resolve every Proc and validate every candidate before changing either side.
126
+ changes = {}
127
+ 2.times do |side|
128
+ pair_changes = {}
129
+ { terminals: :terminal, wrappers: :wrapper }.each do |plural, kind|
130
+ next unless normalised.key?(plural)
131
+
132
+ replacement = replacement_change(normalised[plural][side], component(side, kind).raw)
133
+ pair_changes[kind] = replacement if replacement
134
+ end
135
+ changes[side] = pair_changes unless pair_changes.empty?
136
+ end
137
+ apply_component_changes(changes)
138
+ self
139
+ end
140
+
141
+ private
142
+
143
+ def terminal_and_options(terminal, options, keywords, allowed:, default_terminal: nil)
144
+ if terminal.is_a?(Hash) && options.nil?
145
+ settings = Options.hash(terminal, keywords, allowed: allowed)
146
+ terminal = default_terminal
147
+ else
148
+ settings = Options.hash(options, keywords, allowed: allowed)
149
+ end
150
+ [terminal, settings]
151
+ end
152
+
153
+ def validate_terminal(value)
154
+ return value if value.nil? || value.is_a?(String)
155
+
156
+ raise ArgumentError, "terminal must be nil or a String"
157
+ end
158
+
159
+ def validate_single_terminal(value)
160
+ if value.is_a?(Array)
161
+ raise ArgumentError, "terminal must not be an Array"
162
+ end
163
+ validate_terminal(value)
164
+ end
165
+
166
+ def matches_terminal?(side, expected, settings)
167
+ validate_terminal(expected)
168
+ placement = settings.key?(:placement) ? Options.placement(settings[:placement], allow_nil: false) : nil
169
+ terminal = component(side, :terminal)
170
+ return false if terminal.raw.empty?
171
+ if placement && !component(side, :wrapper).raw.empty? && terminal.placement != placement
172
+ return false
173
+ end
174
+ return true if expected.nil?
175
+
176
+ actual_chars = terminal.chars.join
177
+ expected_chars = expected.each_char.reject { |character| whitespace?(character) }.join
178
+ side.zero? ? actual_chars.start_with?(expected_chars) : actual_chars.end_with?(expected_chars)
179
+ end
180
+
181
+ def validate_strip_value(value)
182
+ return value if value.nil? || value == true
183
+
184
+ Options.placement(value, allow_nil: false)
185
+ end
186
+
187
+ def strip_selected?(side, kind, setting)
188
+ item = component(side, kind)
189
+ return false if setting.nil? || item.raw.empty?
190
+ return true if setting == true
191
+ return true if component(side, counterpart(kind)).raw.empty?
192
+
193
+ item.placement == setting
194
+ end
195
+
196
+ def side_strip!(side, options, keywords)
197
+ settings = Options.hash(options, keywords, allowed: [:terminals, :wrappers], label: "strip options")
198
+ terminal_setting = settings.fetch(:terminals, true)
199
+ wrapper_setting = settings.fetch(:wrappers, nil)
200
+ if terminal_setting.is_a?(Array) || wrapper_setting.is_a?(Array)
201
+ raise ArgumentError, "lstrip and rstrip options do not accept Arrays"
202
+ end
203
+ validate_strip_value(terminal_setting)
204
+ validate_strip_value(wrapper_setting)
205
+
206
+ terminal_sides = side.zero? ? [terminal_setting, nil] : [nil, terminal_setting]
207
+ wrapper_sides = side.zero? ? [wrapper_setting, nil] : [nil, wrapper_setting]
208
+ strip!({ terminals: terminal_sides, wrappers: wrapper_sides }, **{})
209
+ end
210
+
211
+ def replacement_change(value, current_raw, allow_proc: true)
212
+ if value.nil?
213
+ return nil
214
+ elsif value.is_a?(Proc)
215
+ unless allow_proc
216
+ raise ArgumentError, "a replacement Proc cannot return another Proc"
217
+ end
218
+ return replacement_change(value.call(current_raw), current_raw, allow_proc: false)
219
+ elsif value.is_a?(String)
220
+ return { raw: value }
221
+ elsif value.is_a?(Hash)
222
+ settings = Options.hash(value, {}, allowed: [:new, :placement], label: "replacement")
223
+ unless settings.key?(:new)
224
+ raise ArgumentError, "replacement Hash requires new"
225
+ end
226
+ unless settings[:new].is_a?(String)
227
+ raise ArgumentError, "replacement new must be a String"
228
+ end
229
+
230
+ change = { raw: settings[:new] }
231
+ if settings.key?(:placement)
232
+ change[:placement] = Options.placement(settings[:placement], allow_nil: false)
233
+ change[:placement_specified] = true
234
+ end
235
+ return change
236
+ end
237
+
238
+ raise ArgumentError, "invalid replacement: #{value.inspect}"
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Punctuated
4
+ class Parsed
5
+ # Discovers the terminal and wrapper runs at the requested string boundaries.
6
+ module Parsing
7
+ private
8
+
9
+ def parse_constraints(settings, key)
10
+ return [nil, nil] unless settings.key?(key)
11
+
12
+ Options.sides(settings[key], label: key.to_s) { |value| Options.placement(value) }
13
+ end
14
+
15
+ def parse_string(string)
16
+ unless string.is_a?(String)
17
+ parse_failure("input is not a String")
18
+ return
19
+ end
20
+
21
+ unless string.valid_encoding?
22
+ parse_failure("input has an invalid encoding")
23
+ return
24
+ end
25
+
26
+ begin
27
+ parse_valid_string(string)
28
+ rescue Encoding::CompatibilityError, ArgumentError => error
29
+ parse_failure(error.message)
30
+ end
31
+ end
32
+
33
+ def parse_failure(reason)
34
+ Kernel.warn("Punctuated could not parse input: #{reason}")
35
+ @original = "".freeze
36
+ @content = ""
37
+ end
38
+
39
+ def parse_valid_string(string)
40
+ @original = string.dup.freeze
41
+ if string.empty?
42
+ @content = ""
43
+ return
44
+ end
45
+
46
+ case @side_mode
47
+ when :start
48
+ start_boundary = constrained_boundary(scan_start(string), 0)
49
+ commit_parsed_boundary(start_boundary, 0)
50
+ @content = string[start_boundary_position(start_boundary), string.length] || ""
51
+ when :end
52
+ end_boundary = constrained_boundary(scan_end(string), 1)
53
+ commit_parsed_boundary(end_boundary, 1)
54
+ @content = string[0, end_boundary_position(end_boundary, string.length)] || ""
55
+ else
56
+ end_boundary = constrained_boundary(scan_end(string), 1)
57
+ end_position = end_boundary_position(end_boundary, string.length)
58
+ start_source = string[0, end_position] || ""
59
+ start_boundary = constrained_boundary(scan_start(start_source), 0)
60
+ start_position = start_boundary_position(start_boundary)
61
+ commit_parsed_boundary(start_boundary, 0)
62
+ commit_parsed_boundary(end_boundary, 1)
63
+ @content = string[start_position, end_position - start_position] || ""
64
+ end
65
+ end
66
+
67
+ # Returns outer-to-inner runs and their exact character offsets at the start boundary.
68
+ def scan_start(string)
69
+ characters = string.each_char.to_a
70
+ cursor = 0
71
+ cursor += 1 while cursor < characters.length && whitespace?(characters[cursor])
72
+ outer_kind = character_kind(characters[cursor])
73
+ return [] unless outer_kind
74
+
75
+ outer_end = scan_run_forward(characters, cursor, outer_kind)
76
+ runs = [{ kind: outer_kind, raw: string[0, outer_end], begin: 0, end: outer_end }]
77
+ inner_kind = character_kind(characters[outer_end])
78
+ if inner_kind && inner_kind != outer_kind
79
+ inner_end = scan_run_forward(characters, outer_end, inner_kind)
80
+ runs << { kind: inner_kind, raw: string[outer_end, inner_end - outer_end], begin: outer_end, end: inner_end }
81
+ end
82
+ runs
83
+ end
84
+
85
+ def scan_run_forward(characters, cursor, kind)
86
+ while cursor < characters.length
87
+ if character_kind(characters[cursor]) == kind
88
+ cursor += 1
89
+ next
90
+ end
91
+ break unless whitespace?(characters[cursor])
92
+
93
+ cursor += 1 while cursor < characters.length && whitespace?(characters[cursor])
94
+ break unless cursor < characters.length && character_kind(characters[cursor]) == kind
95
+ end
96
+ cursor
97
+ end
98
+
99
+ # Scans from the outside edge backwards, retaining offsets in normal string order.
100
+ def scan_end(string)
101
+ characters = string.each_char.to_a
102
+ cursor = characters.length - 1
103
+ cursor -= 1 while cursor >= 0 && whitespace?(characters[cursor])
104
+ outer_kind = character_kind(characters[cursor])
105
+ return [] unless outer_kind
106
+
107
+ outer_begin = scan_run_backward(characters, cursor, outer_kind)
108
+ runs = [{ kind: outer_kind, raw: string[outer_begin, characters.length - outer_begin], begin: outer_begin, end: characters.length }]
109
+ inner_cursor = outer_begin - 1
110
+ inner_kind = character_kind(characters[inner_cursor])
111
+ if inner_kind && inner_kind != outer_kind
112
+ inner_begin = scan_run_backward(characters, inner_cursor, inner_kind)
113
+ runs << { kind: inner_kind, raw: string[inner_begin, outer_begin - inner_begin], begin: inner_begin, end: outer_begin }
114
+ end
115
+ runs
116
+ end
117
+
118
+ def scan_run_backward(characters, cursor, kind)
119
+ while cursor >= 0
120
+ if character_kind(characters[cursor]) == kind
121
+ cursor -= 1
122
+ next
123
+ end
124
+ break unless whitespace?(characters[cursor])
125
+
126
+ cursor -= 1 while cursor >= 0 && whitespace?(characters[cursor])
127
+ break unless cursor >= 0 && character_kind(characters[cursor]) == kind
128
+ end
129
+ cursor + 1
130
+ end
131
+
132
+ def character_kind(character)
133
+ return nil unless character
134
+ return :terminal if TERMINAL.match(character)
135
+ return :wrapper if WRAPPER.match(character)
136
+
137
+ nil
138
+ end
139
+
140
+ def whitespace?(character)
141
+ WHITESPACE.match(character)
142
+ end
143
+
144
+ # A rejected inner run leaves it in content; rejecting an outer run makes the boundary inaccessible.
145
+ def constrained_boundary(runs, side)
146
+ return runs unless runs.length == 2
147
+
148
+ if @wrapper_constraints[side]
149
+ target_kind = :wrapper
150
+ constraint = @wrapper_constraints[side]
151
+ elsif @terminal_constraints[side]
152
+ target_kind = :terminal
153
+ constraint = @terminal_constraints[side]
154
+ else
155
+ return runs
156
+ end
157
+ target_index = runs.index { |run| run[:kind] == target_kind }
158
+ actual = target_index.zero? ? :outside : :inside
159
+ return runs if actual == constraint
160
+
161
+ target_index.zero? ? [] : [runs.first]
162
+ end
163
+
164
+ def start_boundary_position(runs)
165
+ runs.empty? ? 0 : runs.last[:end]
166
+ end
167
+
168
+ def end_boundary_position(runs, string_length)
169
+ runs.empty? ? string_length : runs.last[:begin]
170
+ end
171
+
172
+ def commit_parsed_boundary(runs, side)
173
+ runs.each_with_index do |run, index|
174
+ placement = runs.length == 1 ? nil : (index.zero? ? :outside : :inside)
175
+ component(side, run[:kind]).__send__(:commit, run[:raw], placement)
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "component"
4
+ require_relative "options"
5
+ require_relative "parsed/composition"
6
+ require_relative "parsed/parsing"
7
+ require_relative "parsed/operations"
8
+
9
+ module Punctuated
10
+ # Holds a decomposed string while focused concerns parse, inspect and transform its state.
11
+ class Parsed
12
+ DEFAULT_TERMINALS = ".".freeze
13
+ KINDS = [:terminal, :wrapper].freeze
14
+
15
+ include Composition
16
+ include Parsing
17
+ include Operations
18
+
19
+ attr_reader :original, :terminals, :wrappers
20
+
21
+ def initialize(string, options = nil, **keywords)
22
+ settings = Options.hash(options, keywords, allowed: [:side, :wrappers, :terminals], label: "parse options")
23
+ if settings.key?(:wrappers) && settings.key?(:terminals)
24
+ raise ArgumentError, "wrappers and terminals placement constraints are mutually exclusive"
25
+ end
26
+
27
+ @side_mode = settings.key?(:side) ? Options.side(settings[:side]) : nil
28
+ @wrapper_constraints = parse_constraints(settings, :wrappers)
29
+ @terminal_constraints = parse_constraints(settings, :terminals)
30
+ build_components
31
+ parse_string(string)
32
+ end
33
+
34
+ def initialize_copy(source)
35
+ super
36
+ @original = source.original.dup.freeze
37
+ @content = source.content.dup
38
+ @side_mode = source.instance_variable_get(:@side_mode)
39
+ @wrapper_constraints = source.instance_variable_get(:@wrapper_constraints).dup
40
+ @terminal_constraints = source.instance_variable_get(:@terminal_constraints).dup
41
+ build_components
42
+ 2.times do |side|
43
+ KINDS.each do |kind|
44
+ source_component = source.component(side, kind)
45
+ component(side, kind).__send__(:commit, source_component.raw, source_component.placement)
46
+ end
47
+ end
48
+ end
49
+
50
+ def content
51
+ @content
52
+ end
53
+
54
+ def content=(value)
55
+ unless value.is_a?(String)
56
+ raise ArgumentError, "content must be a String"
57
+ end
58
+
59
+ @content = value
60
+ end
61
+
62
+ # Internal access is public for collaborating Component instances, but is not part of the documented API.
63
+ def component(side, kind)
64
+ kind == :terminal ? @terminals[side] : @wrappers[side]
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Punctuated
4
+ VERSION = "0.9.0"
5
+ end
data/lib/punctuated.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "punctuated/constants"
4
+ require_relative "punctuated/version"
5
+ require_relative "punctuated/parsed"
6
+
7
+ module Punctuated
8
+ class << self
9
+ # Parse is an alias of new so either entry point produces the same mutable representation.
10
+ def new(string, options = nil, **keywords)
11
+ Parsed.new(string, options, **keywords)
12
+ end
13
+ alias parse new
14
+
15
+ # Each static API method delegates to the corresponding Parsed implementation.
16
+ Parsed::Operations::METHODS.each do |method_name|
17
+ side = Parsed::Operations::METHOD_SIDES[method_name]
18
+ define_method(method_name) do |string, *arguments, **keywords, &block|
19
+ parsed = side ? Parsed.new(string, side: side) : Parsed.new(string)
20
+ parsed.public_send(method_name, *arguments, **keywords, &block)
21
+ end
22
+ end
23
+
24
+ # Static bang methods share the same declaration and differ only in their String commit semantics.
25
+ Parsed::Operations::TRANSFORMING_METHODS.each do |method_name|
26
+ side = Parsed::Operations::METHOD_SIDES[method_name]
27
+ bang_method_name = "#{method_name}!"
28
+ define_method(bang_method_name) do |string, *arguments, **keywords, &block|
29
+ mutate_string(string, side: side) do |parsed|
30
+ parsed.public_send(bang_method_name, *arguments, **keywords, &block)
31
+ end
32
+ end
33
+ end
34
+
35
+ alias at_start? start_with?
36
+ alias at_end? end_with?
37
+
38
+ private
39
+
40
+ # Static bang methods preserve normal String mutation behaviour and nil-on-no-change semantics.
41
+ def mutate_string(string, side: nil)
42
+ parsed = side ? Parsed.new(string, side: side) : Parsed.new(string)
43
+ yield(parsed)
44
+ unless string.is_a?(String)
45
+ raise TypeError, "no implicit conversion of #{string.class} into String"
46
+ end
47
+
48
+ value = parsed.result
49
+ return nil if string == value
50
+
51
+ string.replace(value)
52
+ end
53
+ end
54
+ end
data/readme.md ADDED
@@ -0,0 +1,174 @@
1
+ # Punctuated (Ruby Gem)
2
+
3
+ A utility for detecting, adding and removing punctuation at the beginning or end of strings.
4
+
5
+ Punctuation is composed of **terminals** and **wrappers**.
6
+
7
+ * A **terminal** is a sentence-ending (or sometimes sentence-beginning) character like `.`, `?` and `!`. An example of a sentence-beginning terminal would be `¿`.
8
+ * A **wrapper** is a character that can appear next to a terminal, and is considered part of the same sentence, like `"`, `'` or `)`.
9
+
10
+ For instance, `("¿Dónde está?")` can be analysed as:
11
+
12
+ | Start Wrapper | Start Terminal | Content | End Terminal | End Wrapper |
13
+ | ---- | ---- | ---------- | ---- | ---- |
14
+ | `("` | `¿` | Dónde está | `?` | `")` |
15
+
16
+ Wrappers don't always appear outside terminals. Punctuated lets you detect, add, remove and move terminals and wrappers both inside and outside.
17
+
18
+ Punctuated does not detect every occurrence of a terminal or wrapper -- only one consecutive series of them at either end of the string. The series can contain white space.
19
+
20
+ Comprehensive [examples of input and output](/docs/spec/examples.md) are documented.
21
+
22
+
23
+ ## Detect punctuation
24
+
25
+ Check for any terminal, or for a particular terminal sequence:
26
+
27
+ ```ruby
28
+ Punctuated.at_end?("Ready?") #=> true
29
+ Punctuated.at_end?("Ready?!", "!") #=> true
30
+ Punctuated.at_end?("Ready?!", "?") #=> false
31
+ Punctuated.at_start?("\u{BF}Ready?", "\u{BF}") #=> true
32
+ ```
33
+
34
+ `at_start?` and `at_end?` are aliases for `start_with?` and `end_with?`.
35
+
36
+ `start_with?` compares from the start of the captured terminal run; `end_with?` compares from its end. Whitespace within a terminal run is ignored when matching.
37
+
38
+ Use `placement` to check for punctuation that's specifically inside or outside any wrapper:
39
+
40
+ ```ruby
41
+ Punctuated.end_with?(%q{"Ready!"}, "!", placement: :inside) #=> true
42
+ Punctuated.end_with?(%q{"Ready"!}, "!", placement: :outside) #=> true
43
+ ```
44
+
45
+
46
+ ## Ensure a string is punctuated
47
+
48
+ `ensure` adds punctuation only when it is missing; it never replaces an existing terminal. By default it ensures a full stop at the end.
49
+
50
+ ```ruby
51
+ Punctuated.ensure("Ready") #=> "Ready."
52
+ Punctuated.ensure("Ready!") #=> "Ready!"
53
+ Punctuated.ensure(%q{"Ready"}, "!") #=> "\"Ready!\""
54
+ Punctuated.ensure_start("Ready", "\u{BF}") #=> "\u{BF}Ready"
55
+ ```
56
+
57
+ Unlike other two-sided methods, a scalar or one-element array passed to `ensure` implicitly targets the end. Pass a two-element array to control the start and end explicitly:
58
+
59
+ ```ruby
60
+ Punctuated.ensure("Ready", ["\u{BF}", "?"]) #=> "\u{BF}Ready?"
61
+ ```
62
+
63
+ Missing punctuation is placed inside any wrapper by default. Existing punctuation is left in place unless `placement` is supplied. Placement values follow the same rules as terminal values: a scalar or one-element array implicitly targets the end, while a two-element array controls the start and end explicitly.
64
+
65
+
66
+ ## Remove punctuation and wrappers
67
+
68
+ `strip` removes terminals from both ends by default. Wrappers are retained unless requested:
69
+
70
+ ```ruby
71
+ Punctuated.strip(%q{"Ready!!?"}) #=> "\"Ready\""
72
+ Punctuated.strip(%q{"Ready!!?"},
73
+ terminals: true, wrappers: true) #=> "Ready"
74
+ Punctuated.lstrip("\u{BF})Ready?") #=> "Ready?"
75
+ Punctuated.rstrip("\u{BF}Ready?") #=> "\u{BF}Ready"
76
+ ```
77
+
78
+ Use `:inside` or `:outside` instead of `true` to remove only components in that position. `strip` also accepts two-element arrays whose first value controls the start and second controls the end.
79
+
80
+
81
+ ## Replace punctuation and wrappers
82
+
83
+ `replace` changes the captured runs. A single value applies to both ends; an array supplies start and end replacements:
84
+
85
+ ```ruby
86
+ Punctuated.replace(%q{"Ready?!"}, terminals: [nil, "."]) #=> "\"Ready.\""
87
+ Punctuated.replace(%q{"Ready!"}, wrappers: ["(", ")"]) #=> "(Ready!)"
88
+ ```
89
+
90
+ Use an empty string to remove a run. A Proc can derive a replacement from the current raw run:
91
+
92
+ ```ruby
93
+ Punctuated.replace("Ready ? !", terminals: ->(raw) { raw.delete(" ") }) #=> "Ready?!"
94
+ ```
95
+
96
+ A replacement Hash uses `new` for its replacement text and can specify its position relative to a wrapper:
97
+
98
+ ```ruby
99
+ Punctuated.replace(%q{"Ready!"}, terminals: [nil, {
100
+ new: ".",
101
+ placement: :outside,
102
+ }]) #=> "\"Ready\"."
103
+ ```
104
+
105
+
106
+ ## Inspect and edit parsed punctuation
107
+
108
+ `parse` returns a mutable representation when you need to inspect or make several changes:
109
+
110
+ ```ruby
111
+ parsed = Punctuated.parse(%q{"Ready!"})
112
+
113
+ parsed.original #=> "\"Ready!\""
114
+ parsed.content #=> "Ready"
115
+ parsed.terminals[1].raw #=> "!"
116
+ parsed.wrappers[1].raw #=> "\""
117
+
118
+ parsed.terminals[1].raw = "."
119
+ parsed.result #=> "\"Ready.\""
120
+ ```
121
+
122
+ `terminals` and `wrappers` contain the start and end components. Each component exposes `raw`, `chars`, `index` and `placement`. `content`, component `raw` values and component placements can be assigned directly; `result` recomposes the current string.
123
+
124
+ `terminals` and `wrappers` always contain start and end components. Use `parse(str, side: :start)` or `side: :end` to inspect only one boundary; the uninspected boundary has empty terminal and wrapper components.
125
+
126
+
127
+ ## Mutating strings
128
+
129
+ Every modifying method has a bang form, including `ensure!`, `strip!`, `replace!`, `lstrip!` and `rstrip!`:
130
+
131
+ ```ruby
132
+ text = "Ready"
133
+ Punctuated.ensure!(text) #=> "Ready."
134
+ text #=> "Ready."
135
+ Punctuated.ensure!(text) #=> nil
136
+ ```
137
+
138
+ Module-level bang methods mutate the passed string and return `nil` when it was already correct. Bang methods on a parsed object mutate that object and return the object itself.
139
+
140
+ Options and enum values accept equivalent strings or symbols. Unknown, duplicate or malformed options raise `ArgumentError`.
141
+
142
+
143
+ ## Versioning
144
+
145
+ Punctuated follows semantic versioning. Until version 1.0, a minor version may contain breaking API changes; patch versions remain backwards compatible within their minor release.
146
+
147
+
148
+ ## Installation
149
+
150
+ Punctuated supports Ruby 2.3 and later.
151
+
152
+ ```bash
153
+ gem install punctuated
154
+ ```
155
+
156
+ Or add it to your bundle:
157
+
158
+ ```ruby
159
+ gem "punctuated", "~> 0.9.0"
160
+ ```
161
+
162
+ Then load it with `require "punctuated"`.
163
+
164
+
165
+ ## Development
166
+
167
+ Install dependencies and run the test suite:
168
+
169
+ ```bash
170
+ bundle install
171
+ bundle exec rake test
172
+ ```
173
+
174
+ The examples in [`docs/spec/examples.md`](docs/spec/examples.md) are executable specifications and run as part of the suite. See [`docs/releasing.md`](docs/releasing.md) for the release procedure.
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: punctuated
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.0
5
+ platform: ruby
6
+ authors:
7
+ - Convincible Media
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-16 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A utility for detecting, adding and removing punctuation at the beginning
14
+ or end of strings. Punctuated detects a wide range of punctuation, and is aware
15
+ of a wide range of wrappers that may appear around punctuation.
16
+ email:
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - changelog.md
22
+ - lib/punctuated.rb
23
+ - lib/punctuated/component.rb
24
+ - lib/punctuated/constants.rb
25
+ - lib/punctuated/options.rb
26
+ - lib/punctuated/parsed.rb
27
+ - lib/punctuated/parsed/composition.rb
28
+ - lib/punctuated/parsed/operations.rb
29
+ - lib/punctuated/parsed/parsing.rb
30
+ - lib/punctuated/version.rb
31
+ - readme.md
32
+ homepage: https://github.com/ConvincibleMedia/ruby-gem-punctuated
33
+ licenses:
34
+ - "'LGPL-3.0-or-later'"
35
+ metadata:
36
+ bug_tracker_uri: https://github.com/ConvincibleMedia/ruby-gem-punctuated/issues
37
+ changelog_uri: https://github.com/ConvincibleMedia/ruby-gem-punctuated/blob/master/changelog.md
38
+ homepage_uri: https://github.com/ConvincibleMedia/ruby-gem-punctuated
39
+ source_code_uri: https://github.com/ConvincibleMedia/ruby-gem-punctuated/tree/v0.9.0
40
+ rubygems_mfa_required: 'true'
41
+ post_install_message:
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '2.3'
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 3.4.22
57
+ signing_key:
58
+ specification_version: 4
59
+ summary: Detect, add and remove sentence punctuation around string boundaries.
60
+ test_files: []