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 +7 -0
- data/changelog.md +11 -0
- data/lib/punctuated/component.rb +57 -0
- data/lib/punctuated/constants.rb +12 -0
- data/lib/punctuated/options.rb +84 -0
- data/lib/punctuated/parsed/composition.rb +162 -0
- data/lib/punctuated/parsed/operations.rb +242 -0
- data/lib/punctuated/parsed/parsing.rb +180 -0
- data/lib/punctuated/parsed.rb +67 -0
- data/lib/punctuated/version.rb +5 -0
- data/lib/punctuated.rb +54 -0
- data/readme.md +174 -0
- metadata +60 -0
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
|
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: []
|