string_magic 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29052aa4271f574a936a326f9e2460655962a9242020d4180d3bb170d043374b
4
- data.tar.gz: 1f728af7f58397870658ce3edb3cb1522b6dc036c068b8c092475fcd7185779f
3
+ metadata.gz: c860708ae5aa73e721fc45b4aa173a79c75a2645bcf244a416e7f758e089d2cd
4
+ data.tar.gz: ded9547fb9a55ab72c7f4a15cfee03a707811d4c4a7bbf46f66d4c1dc99f8b3c
5
5
  SHA512:
6
- metadata.gz: c135c02a42aecdd923c75bff56354b4c12e158e8a359e2c9f3114bb4aa319111907770bf6ee012abaf431f48b26776b5b5d9ecfdbd81ce47927c3e4438aab14b
7
- data.tar.gz: fcd862e9ed702db639d76af3ce6767ca8c4f2fe21493a991f9a4362db18125b72a6e26a7fc160715b2528cf22fc28c7c80bc60418a061b3c0e8a0883c54437b2
6
+ metadata.gz: 4efe0e495f4dcbeb8ffffe2f8f049c79b79c81ab991049698a659a9d795d4c11988e0b060017734d60f1cd201732c9bcdb9f23f85c3d4cf8a050757ea1287626
7
+ data.tar.gz: 3ef437f7a57cfab7e031aadbbf64cbf76851ab684fcf5a4e0059fb0c74e312e34c53c44da42ef0acd5984c2748c3ba0c97bb0752a42816c1bd441c39503a4c02
data/CHANGELOG.md CHANGED
@@ -1,3 +1,31 @@
1
+ ## [0.5.0] - 2025-08-12
2
+
3
+ ### Added
4
+
5
+ - **StringMagic::Core::Validation** - Email, URL, phone, credit card validation and more
6
+ - **StringMagic::Utilities::Slug** - Convert text to URL-safe slugs and filename-safe strings
7
+ - **StringMagic::Utilities::Inflection** - Pluralize, singularize, ordinalize, and humanize text
8
+
9
+ ### Enhanced
10
+
11
+ - **StringMagic::Core::Transformation** - New case conversions and text manipulation methods
12
+ - **StringMagic::Core::Analysis** - Improved entity extraction and sentiment analysis
13
+ - **StringMagic::Formatting::Truncation** - Smart word, sentence, and character truncation
14
+ - **StringMagic::Formatting::Highlighting** - HTML highlighting and URL auto-linking
15
+ - **StringMagic::Advanced::Security** - Mask sensitive data like credit cards and emails
16
+
17
+ ### Changed
18
+
19
+ - Modular code organization
20
+ - All methods now available as both String instance methods and module methods
21
+
22
+ ## [0.4.0] - 2025-01-01
23
+
24
+ ### Added
25
+
26
+ - Core string utility methods (palindrome, case conversions, word operations)
27
+ - Test coverage for new methods
28
+
1
29
  ## [0.3.0] - 2025-01-01
2
30
 
3
31
  - Text analysis features
data/README.md CHANGED
@@ -1,75 +1,81 @@
1
- # StringMagic
1
+ # StringMagic - v0.5.0
2
+ [![Gem Version](https://badge.fury.io/rb/string_magic.svg)](https://badge.fury.io/rb/string_magic)
3
+ [![Build Status](https://dl.circleci.com/status-badge/img/circleci/8MamMcAVAVNWTcUqkjQk7R/Sh2DQkMWqqCv4MFvAmYWDL/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/circleci/8MamMcAVAVNWTcUqkjQk7R/Sh2DQkMWqqCv4MFvAmYWDL/tree/main)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-lightgreen.svg)](LICENSE)
2
5
 
3
- [![Gem Version](https://d25lcipzij17d.cloudfront.net/badge.svg?id=rb&r=r&ts=1683906897&type=6e&v=0.3.0&x2=0)](https://badge.fury.io/rb/string_magic)
4
- [![CircleCI](https://dl.circleci.com/status-badge/img/circleci/8MamMcAVAVNWTcUqkjQk7R/Sh2DQkMWqqCv4MFvAmYWDL/tree/main.svg?style=svg&circle-token=CCIPRJ_PF8xu3Svcj2Ro4D8jhjCi7_71b7c0a7c781e09fc7194cd58cca67aecdc111b5)](https://dl.circleci.com/status-badge/redirect/circleci/8MamMcAVAVNWTcUqkjQk7R/Sh2DQkMWqqCv4MFvAmYWDL/tree/main)
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ **StringMagic** super-charges Ruby's `String` with batteries-included helpers for formatting, validation, analysis, transformation, and security all in one tidy gem.
6
7
 
7
- ## Summary
8
+ ## Why StringMagic?
9
+ - Zero dependencies – pure Ruby
10
+ - Non-destructive: original strings are never mutated
11
+ - Thread-safe & fully tested (160+ RSpec examples, ~98% coverage)
12
+ - Drop-in: optionally auto-mixes into String, or use module methods (`StringMagic.to_slug(...)`)
8
13
 
9
- A gem that enhances Ruby's string class with an array of versatile methods designed for enhanced text formatting, manipulation, and analysis.
10
-
11
- ## Description
12
-
13
- The StringMagic gem enriches Ruby's string class by adding a suite of versatile methods. These methods offer extended capabilities for formatting, manipulating, and querying text, making string operations more efficient and expressive.
14
-
15
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/string_magic`. To experiment with that code, run `bin/console` for an interactive prompt.
16
-
17
- ## Installation
18
-
19
- Add this line to your Gemfile:
14
+ ## Quickstart Cheatsheet
20
15
 
21
16
  ```ruby
22
- gem 'string_magic'
17
+ # Formatting
18
+ "Hello world".truncate_words(1) # => "Hello..."
19
+ "user@domain.com".mask_emails # => "u********@domain.com"
20
+ "Ruby on Rails".highlight('Ruby') # => "<mark>Ruby</mark> on Rails"
21
+
22
+ # Validation
23
+ "test@example.com".email? # => true
24
+ "4111111111111111".credit_card? # => true
25
+ "racecar".palindrome? # => true
26
+
27
+ # Transformation
28
+ "CamelCase".to_snake_case # => "camel_case"
29
+ "user input".to_filename_safe # => "user_input"
30
+ "second".ordinalize # => "2nd"
31
+
32
+ # Analysis
33
+ "Visit https://example.com".extract_urls # => ["https://example.com"]
34
+ "Great product!".sentiment_indicators # => {:positive=>1.0, :negative=>0.0, :neutral=>0}
23
35
  ```
24
36
 
25
- And then execute:
26
-
27
- ```ruby
28
- bundle install
29
- ```
30
-
31
- Or install it globally:
32
-
33
- ```ruby
37
+ ## Installation
38
+ ```bash
34
39
  gem install string_magic
35
40
  ```
36
-
37
- ## Usage
38
-
41
+ Or add to your Gemfile:
39
42
  ```ruby
40
- require 'string_magic'
41
-
42
- # Example usage
43
- string = "hello world"
44
- puts StringMagic.palindrome?(string)
43
+ gem 'string_magic'
45
44
  ```
46
45
 
47
- ## Development
46
+ ## Full Documentation
48
47
 
49
- After checking out the repo, run `bin/setup` to install dependencies. Then, run rake spec to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
48
+ ### Core Operations
49
+ - **Case conversion**: `to_snake_case`, `to_kebab_case`, `to_camel_case`, `to_pascal_case`, `to_title_case`
50
+ - **Text manipulation**: `reverse_words`, `alternating_case`, `remove_duplicate_chars`, `remove_duplicate_words`
51
+ - **HTML handling**: `remove_html_tags`, `escape_html`
50
52
 
51
- To install this gem onto your local machine, run:
53
+ ### Validation
54
+ - Format checks: `email?`, `url?`, `phone?`, `credit_card?`
55
+ - Text relations: `palindrome?`, `anagram_of?`
56
+ - Password strength: `strong_password?`
52
57
 
53
- ```ruby
54
- bundle exec rake install
55
- ```
58
+ ### Analysis
59
+ - **Entity extraction**: `extract_emails`, `extract_urls`, `extract_phones`, `extract_dates`
60
+ - **Text metrics**: `readability_score`, `word_frequency`, `sentiment_indicators`
56
61
 
57
- To release a new version, update the version number in version.rb, and then run:
62
+ ### Formatting
63
+ - **Truncation**: `truncate_words`, `truncate_sentences`, `truncate_characters`, `smart_truncate`
64
+ - **Highlighting**: `highlight`, `remove_highlights`, `highlight_urls`
58
65
 
59
- ```ruby
60
- bundle exec rake release
61
- ```
66
+ ### Security
67
+ - **Data masking**: `mask_sensitive_data`, `mask_credit_cards`, `mask_emails`, `mask_phones`
68
+ - **Detection**: `contains_sensitive_data?`
62
69
 
63
- This will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
70
+ ### Utilities
71
+ - **Inflection**: `to_plural`, `to_singular`, `ordinalize`, `humanize`
72
+ - **Slug generation**: `to_slug`, `to_url_slug`, `to_filename_safe`
64
73
 
65
74
  ## Contributing
66
-
67
- Bug reports and pull requests are welcome on GitHub at https://github.com/erscript/string-magic. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/erscript/string-magic/blob/main/CODE_OF_CONDUCT.md).
75
+ 1. Fork → `git checkout -b feature/awesome`
76
+ 2. Add specs for your change
77
+ 3. `bundle exec rspec`
78
+ 4. PR ✉️ – we love improvements!
68
79
 
69
80
  ## License
70
-
71
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
72
-
73
- ## Code of Conduct
74
-
75
- Everyone interacting in the StringMagic project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the [code of conduct](https://github.com/erscript/string-magic/blob/main/CODE_OF_CONDUCT.md).
81
+ MIT - See [LICENSE](LICENSE) for details.
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StringMagic
4
+ module Advanced
5
+ module Security
6
+ # ------------------------------------------------------------
7
+ # Public API
8
+ # ------------------------------------------------------------
9
+
10
+ # Detects and masks sensitive data in the string.
11
+ #
12
+ # options:
13
+ # :mask_char → character to use for masking (default '*')
14
+ # :preserve_count → trailing digits to leave clear (default 4)
15
+ # :types → array of types to mask (default [:credit_card, :ssn, :email, :phone])
16
+ #
17
+ def mask_sensitive_data(options = {})
18
+ return '' if empty?
19
+
20
+ mask_char = options.fetch(:mask_char, '*')
21
+ preserve_count = options.fetch(:preserve_count, 4)
22
+ types = options.fetch(:types, %i[credit_card ssn email phone])
23
+
24
+ patterns = {
25
+ credit_card: /\b(?:\d{4}[-\s]?){3}\d{4}\b/,
26
+ ssn: /\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b/,
27
+ email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/,
28
+ phone: /(?:\+\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}(?!\d)/
29
+ }
30
+
31
+ result = dup
32
+ types.each do |type|
33
+ next unless patterns[type]
34
+
35
+ result.gsub!(patterns[type]) do |match|
36
+ case type
37
+ when :credit_card then mask_credit_card(match, mask_char, preserve_count)
38
+ when :ssn then mask_ssn(match, mask_char, preserve_count)
39
+ when :email then mask_email(match, mask_char)
40
+ when :phone then mask_phone(match, mask_char, preserve_count)
41
+ end
42
+ end
43
+ end
44
+ result
45
+ end
46
+
47
+ # Convenience wrappers
48
+ def mask_credit_cards(mask_char: '*', preserve_count: 4)
49
+ mask_sensitive_data(types: [:credit_card], mask_char: mask_char, preserve_count: preserve_count)
50
+ end
51
+
52
+ def mask_emails(mask_char: '*')
53
+ mask_sensitive_data(types: [:email], mask_char: mask_char)
54
+ end
55
+
56
+ def mask_phones(mask_char: '*', preserve_count: 4)
57
+ mask_sensitive_data(types: [:phone], mask_char: mask_char, preserve_count: preserve_count)
58
+ end
59
+
60
+ def contains_sensitive_data?
61
+ return false if empty?
62
+
63
+ [
64
+ /\b(?:\d{4}[-\s]?){3}\d{4}\b/, # Credit card
65
+ /\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b/, # SSN
66
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/ # Email
67
+ ].any? { |re| match?(re) }
68
+ end
69
+
70
+ # ------------------------------------------------------------
71
+ # Private helpers
72
+ # ------------------------------------------------------------
73
+ private
74
+
75
+ # Mask a phone number while preserving formatting characters and last n digits.
76
+ def mask_phone(original, mask_char, preserve_count)
77
+ digits_only = original.gsub(/\D/, '')
78
+ return original if digits_only.length <= preserve_count
79
+
80
+ masked_part = mask_char * (digits_only.length - preserve_count)
81
+ preserved_part = digits_only[-preserve_count..]
82
+ replacement_pool = (masked_part + preserved_part).chars
83
+ index = 0
84
+
85
+ original.gsub(/\d/) { replacement_pool[index].tap { index += 1 } }
86
+ end
87
+
88
+ def mask_credit_card(original, mask_char, preserve_count)
89
+ digits = original.gsub(/\D/, '')
90
+ masked_len = [digits.length - preserve_count, 0].max
91
+ mask_char * masked_len + digits[-preserve_count..]
92
+ end
93
+
94
+ def mask_ssn(original, mask_char, preserve_count)
95
+ mask_credit_card(original, mask_char, preserve_count)
96
+ end
97
+
98
+ # Email masking policy:
99
+ # - If local part length <= 4 → keep first char, then 8 mask chars (e.g. "mail" => "m********")
100
+ # - Else → keep first char, mask the remainder (no last-char preservation)
101
+ def mask_email(original, mask_char)
102
+ local, domain = original.split('@', 2)
103
+ if local.length <= 4
104
+ masked_local = local[0] + (mask_char * 8)
105
+ else
106
+ masked_local = local[0] + (mask_char * (local.length - 1))
107
+ end
108
+ "#{masked_local}@#{domain}"
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ # Optional automatic mix-in
115
+ class String
116
+ include StringMagic::Advanced::Security
117
+ end
@@ -1,49 +1,140 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module StringMagic
2
4
  module Core
3
5
  module Analysis
4
- def extract_entities(text)
5
- raise MalformedInputError, "Input must be a string" unless text.is_a?(String)
6
+ # ------------------------------------------------------------------
7
+ # Entity extraction
8
+ # ------------------------------------------------------------------
9
+
10
+ def extract_entities
11
+ return default_entities_hash if empty?
6
12
 
7
13
  {
8
- emails: text.scan(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/).uniq,
9
- urls: text.scan(%r{(?:https?:)?//[^\s/$.?#][^\s,]*}).uniq,
10
- phone_numbers: text.scan(/(?:\+\d{1,3}\s?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/).uniq,
11
- dates: text.scan(%r{\b(?:\d{1,2}[-/]\d{1,2}[-/]\d{2,4}|(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*|(?:January|February|March|April|May|June|July|August|September|October|November|December)) \d{1,2},? \d{4}|\d{4}[-/]\d{1,2}[-/]\d{1,2})\b}i).uniq,
12
- hashtags: text.scan(/#[[:word:]]+/).map { |tag| tag[1..] }.uniq,
13
- mentions: text.scan(/@[[:word:]]+/).map { |mention| mention[1..] }.uniq
14
+ emails: extract_emails,
15
+ urls: extract_urls,
16
+ phone_numbers: extract_phones,
17
+ dates: extract_dates,
18
+ hashtags: extract_hashtags,
19
+ mentions: extract_mentions
14
20
  }
15
21
  end
16
22
 
17
- def readability_score(text)
18
- return 0 if text.empty?
23
+ def extract_emails
24
+ return [] if empty?
25
+ scan(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/).uniq
26
+ end
27
+
28
+ def extract_urls
29
+ return [] if empty?
30
+
31
+ # initial capture
32
+ urls = scan(%r{https?://[^\s<>"']+|www\.[^\s<>"']+})
33
+
34
+ # strip trailing punctuation like . , ; : ! ? )
35
+ urls.map { |u| u.gsub(/[\.,;:!?)+]+\z/, '') }.uniq
36
+ end
37
+
38
+ def extract_phones
39
+ return [] if empty?
40
+ phone_re = /(?:\+\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}(?!\d)/
41
+ scan(phone_re).uniq
42
+ end
43
+
44
+ def extract_dates
45
+ return [] if empty?
46
+
47
+ patterns = [
48
+ %r{\b\d{1,2}[-/]\d{1,2}[-/]\d{2,4}\b}, # 01/31/2025 or 31-01-25
49
+ %r{\b\d{4}[-/]\d{1,2}[-/]\d{1,2}\b}, # 2025-01-31
50
+ /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}\b/i,
51
+ /\b\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{4}\b/i
52
+ ]
53
+
54
+ patterns.flat_map { |re| scan(re) }.uniq
55
+ end
56
+
57
+ def extract_hashtags
58
+ return [] if empty?
59
+ scan(/#(\w+)/).flatten.uniq
60
+ end
61
+
62
+ def extract_mentions
63
+ return [] if empty?
64
+ scan(/(?:^|\s)@([A-Za-z0-9_]+)/).flatten.uniq
65
+ end
66
+
67
+ # ------------------------------------------------------------------
68
+ # Text statistics
69
+ # ------------------------------------------------------------------
19
70
 
20
- sentences = text.split(/[.!?]+/)
21
- .map(&:strip)
22
- .reject(&:empty?)
71
+ def readability_score
72
+ return 0.0 if empty?
23
73
 
24
- return 0 if sentences.empty? || !text.match?(/[.!?]/)
74
+ sentences = split(/[.!?]+/).map(&:strip).reject(&:empty?)
75
+ return 0.0 if sentences.empty?
25
76
 
26
- words = text.split(/\s+/)
27
- return 0 if words.empty?
77
+ words = scan(/\b[\p{L}\p{N}'-]+\b/)
78
+ return 0.0 if words.empty?
28
79
 
29
- syllables = words.sum { |word| calculate_syllables(word) }
80
+ syllables = words.sum { |w| calculate_syllables(w) }
81
+ return 0.0 if syllables.zero?
30
82
 
31
- score = (0.39 * (words.length.to_f / sentences.length) +
32
- 11.8 * (syllables.to_f / words.length) - 15.59)
83
+ score = 0.39 * (words.size.to_f / sentences.size) +
84
+ 11.8 * (syllables.to_f / words.size) - 15.59
33
85
 
34
- [score.round(1), 0].max
86
+ score.round(1).clamp(0, Float::INFINITY)
35
87
  end
36
88
 
89
+ def word_frequency
90
+ return {} if empty?
91
+ downcase.scan(/\b[\p{L}\p{N}'-]+\b/).tally
92
+ end
93
+
94
+ def sentiment_indicators
95
+ return { positive: 0, negative: 0, neutral: 1 } if empty?
96
+
97
+ positive_words = %w[good great excellent amazing wonderful fantastic happy joy love like best awesome]
98
+ negative_words = %w[bad terrible awful horrible sad hate dislike worst annoying frustrating]
99
+
100
+ words = downcase.scan(/\b[\p{L}\p{N}'-]+\b/)
101
+ pos = words.count { |w| positive_words.include?(w) }
102
+ neg = words.count { |w| negative_words.include?(w) }
103
+ total = pos + neg
104
+
105
+ if total.zero?
106
+ { positive: 0, negative: 0, neutral: 1 }
107
+ else
108
+ { positive: (pos.to_f / total).round(2),
109
+ negative: (neg.to_f / total).round(2),
110
+ neutral: 0 }
111
+ end
112
+ end
113
+
114
+ # ------------------------------------------------------------------
115
+ # Private helpers
116
+ # ------------------------------------------------------------------
37
117
  private
38
118
 
119
+ def default_entities_hash
120
+ { emails: [], urls: [], phone_numbers: [], dates: [], hashtags: [], mentions: [] }
121
+ end
122
+
123
+ # Rough syllable estimator (en-US)
39
124
  def calculate_syllables(word)
40
- word = word.downcase.gsub(/[^a-z]/, "")
41
- return 0 if word.empty?
125
+ w = word.downcase.gsub(/[^a-z]/, '')
126
+ return 1 if w.length <= 2
42
127
 
43
- count = word.scan(/[aeiou]+/i).size
44
- count -= 1 if word.end_with?("e")
128
+ count = w.scan(/[aeiouy]+/).size
129
+ count -= 1 if w.end_with?('e') && count > 1
130
+ count += 1 if w.end_with?('le') && w[-3] !~ /[aeiouy]/i
45
131
  [count, 1].max
46
132
  end
47
133
  end
48
134
  end
49
135
  end
136
+
137
+ # Optional auto-mix-in
138
+ class String
139
+ include StringMagic::Core::Analysis
140
+ end
@@ -1,25 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi' # for HTML escaping
4
+
1
5
  module StringMagic
2
6
  module Core
3
7
  module Transformation
4
- def to_snake_case(string)
5
- return "" if string.nil?
8
+ # ------------------------------------------------------------
9
+ # Case conversions
10
+ # ------------------------------------------------------------
6
11
 
7
- string.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
8
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
9
- .tr("-", "_")
10
- .downcase
12
+ def to_snake_case
13
+ return '' if empty?
14
+
15
+ str = dup
16
+ str.gsub!(/::/, '/')
17
+ str.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
18
+ str.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
19
+ str.tr!('-', '_')
20
+ str.gsub!(/\s+/, '_')
21
+ str.downcase!
22
+ str.squeeze('_').gsub(/^_+|_+$/, '')
11
23
  end
12
24
 
13
- def to_kebab_case(string)
14
- to_snake_case(string).tr("_", "-")
25
+ def to_kebab_case
26
+ to_snake_case.tr('_', '-')
15
27
  end
16
28
 
17
- def to_pascal_case(string)
18
- return "" if string.nil?
19
- return string if string.match?(/^[A-Z][a-z]*([A-Z][a-z]*)*$/)
29
+ # first_letter: :lower (default) or :upper
30
+ def to_camel_case(first_letter: :lower)
31
+ return '' if empty?
32
+
33
+ words = to_snake_case.split('_').map(&:capitalize)
34
+ words[0].downcase! if first_letter == :lower && words.any?
35
+ words.join
36
+ end
37
+
38
+ def to_pascal_case
39
+ to_camel_case(first_letter: :upper)
40
+ end
41
+
42
+ def to_title_case
43
+ return '' if empty?
44
+
45
+ small = %w[a an and as at but by for if in nor of on or so the to up yet]
46
+ words = downcase.split(/\s+/)
47
+ words.map!.with_index do |w, i|
48
+ (i.zero? || i == words.size - 1 || !small.include?(w)) ? w.capitalize : w
49
+ end
50
+ words.join(' ')
51
+ end
52
+
53
+ # ------------------------------------------------------------
54
+ # Simple word / char utilities
55
+ # ------------------------------------------------------------
56
+
57
+ def reverse_words
58
+ return '' if empty?
59
+ split(/\s+/).reverse.join(' ')
60
+ end
20
61
 
21
- string.split(/[-_\s]+/).map(&:capitalize).join
62
+ def alternating_case
63
+ return '' if empty?
64
+ each_char.with_index.map { |c, i| i.even? ? c.upcase : c.downcase }.join
65
+ end
66
+
67
+ def remove_duplicate_chars
68
+ return '' if empty?
69
+ each_char.to_a.uniq.join
70
+ end
71
+
72
+ def remove_duplicate_words
73
+ return '' if empty?
74
+ split(/\s+/).uniq.join(' ')
75
+ end
76
+
77
+ # ------------------------------------------------------------
78
+ # Misc. text transformations
79
+ # ------------------------------------------------------------
80
+
81
+ def squeeze_whitespace
82
+ return '' if empty?
83
+ gsub(/\s+/, ' ').strip
84
+ end
85
+
86
+ def remove_html_tags
87
+ return '' if empty?
88
+ gsub(/<[^>]*>/, '')
89
+ end
90
+
91
+ def escape_html
92
+ return '' if empty?
93
+ CGI.escapeHTML(self)
22
94
  end
23
95
  end
24
96
  end
25
97
  end
98
+
99
+ # Optional automatic mix-in
100
+ class String
101
+ include StringMagic::Core::Transformation
102
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module StringMagic
6
+ module Core
7
+ module Validation
8
+ # ------------------------------------------------------------
9
+ # Basic format checks
10
+ # ------------------------------------------------------------
11
+
12
+ def email?
13
+ !!(self =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
14
+ end
15
+
16
+ def url?
17
+ !!(self =~ /\A#{URI::DEFAULT_PARSER.make_regexp(%w[http https])}\z/)
18
+ end
19
+
20
+ def phone?
21
+ digits = gsub(/\D/, '')
22
+ (10..15).cover?(digits.length)
23
+ end
24
+
25
+ # ------------------------------------------------------------
26
+ # Credit-card number (Luhn)
27
+ # ------------------------------------------------------------
28
+
29
+ def credit_card?
30
+ digits = gsub(/\D/, '')
31
+ return false unless (13..19).cover?(digits.length)
32
+
33
+ sum = digits.reverse.chars.each_with_index.sum do |ch, idx|
34
+ n = ch.to_i
35
+ n *= 2 if idx.odd?
36
+ n > 9 ? n - 9 : n
37
+ end
38
+ (sum % 10).zero?
39
+ end
40
+
41
+ # ------------------------------------------------------------
42
+ # Text relationships
43
+ # ------------------------------------------------------------
44
+
45
+ def palindrome?
46
+ cleaned = downcase.gsub(/[^a-z0-9]/, '')
47
+ cleaned == cleaned.reverse && !cleaned.empty?
48
+ end
49
+
50
+ def anagram_of?(other)
51
+ return false if other.nil? || other.empty?
52
+
53
+ norm = ->(s) { s.downcase.gsub(/[^a-z0-9]/, '').chars.sort.join }
54
+ !empty? && norm.call(self) == norm.call(other)
55
+ end
56
+
57
+ # ------------------------------------------------------------
58
+ # Number checks
59
+ # ------------------------------------------------------------
60
+
61
+ def numeric?
62
+ !!(self =~ /\A-?(?:\d+\.?\d*|\.\d+)\z/)
63
+ end
64
+
65
+ def integer?
66
+ !!(self =~ /\A-?\d+\z/)
67
+ end
68
+
69
+ # ------------------------------------------------------------
70
+ # Password strength
71
+ # ------------------------------------------------------------
72
+
73
+ def strong_password?(min_length: 8)
74
+ return false if length < min_length
75
+
76
+ /[A-Z]/.match?(self) &&
77
+ /[a-z]/.match?(self) &&
78
+ /\d/.match?(self) &&
79
+ /[^A-Za-z0-9]/.match?(self)
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ # Optional auto-mix-in
86
+ class String
87
+ include StringMagic::Core::Validation
88
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StringMagic
4
+ module Formatting
5
+ module Highlighting
6
+ # ------------------------------------------------------------
7
+ # Generic phrase highlighting
8
+ # ------------------------------------------------------------
9
+ #
10
+ # highlight(%w[ruby rails], tag: 'span',
11
+ # css_class: 'hit', case_sensitive: false)
12
+ #
13
+ def highlight(phrases, tag: 'mark', css_class: nil, case_sensitive: true)
14
+ return '' if empty?
15
+
16
+ phrases = Array(phrases).compact.reject(&:empty?)
17
+ return self if phrases.empty?
18
+
19
+ klass = css_class ? %( class="#{css_class}") : ''
20
+ esc = phrases.sort_by(&:length).reverse.map { |p| Regexp.escape(p) }
21
+ regex = Regexp.union(esc)
22
+ regex = Regexp.new(regex.source, Regexp::IGNORECASE) unless case_sensitive
23
+
24
+ gsub(regex) { |m| "<#{tag}#{klass}>#{m}</#{tag}>" }
25
+ end
26
+
27
+ # ------------------------------------------------------------
28
+ # Remove previously added highlighting
29
+ # ------------------------------------------------------------
30
+ #
31
+ # remove_highlights # strips ALL html tags
32
+ # remove_highlights('mark') # strips only <mark>…</mark>
33
+ #
34
+ def remove_highlights(tag = nil)
35
+ return '' if empty?
36
+
37
+ if tag
38
+ gsub(/<#{Regexp.escape(tag)}[^>]*>(.*?)<\/#{Regexp.escape(tag)}>/im, '\1')
39
+ else
40
+ gsub(/<[^>]+>/, '')
41
+ end
42
+ end
43
+
44
+ # ------------------------------------------------------------
45
+ # Auto-link / highlight URLs
46
+ # ------------------------------------------------------------
47
+ #
48
+ # highlight_urls(css_class: 'link', target: '_blank')
49
+ #
50
+ def highlight_urls(tag: 'a', css_class: nil, target: nil)
51
+ return '' if empty?
52
+
53
+ klass = css_class ? %( class="#{css_class}") : ''
54
+ tgt = target ? %( target="#{target}") : ''
55
+ url_re = %r{https?://[^\s<>"']+}i
56
+
57
+ gsub(url_re) { |url| %(<#{tag} href="#{url}"#{klass}#{tgt}>#{url}</#{tag}>) }
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ # Optional automatic mix-in
64
+ class String
65
+ include StringMagic::Formatting::Highlighting
66
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StringMagic
4
+ module Formatting
5
+ module Truncation
6
+ # ------------------------------------------------------------
7
+ # Word-based
8
+ # ------------------------------------------------------------
9
+ def truncate_words(limit, suffix: '...', separator: ' ')
10
+ return '' if empty?
11
+ words = split(separator)
12
+ return self if words.size <= limit
13
+ words.first(limit).join(separator) + suffix
14
+ end
15
+
16
+ # ------------------------------------------------------------
17
+ # Sentence-based
18
+ # ------------------------------------------------------------
19
+ def truncate_sentences(limit, suffix: '...')
20
+ return '' if empty?
21
+ sentences = split(/(?<=[.!?])\s+/)
22
+ return self if sentences.size <= limit
23
+ result = sentences.first(limit).join(' ')
24
+ result.chomp!('.')
25
+ result.chomp!('!')
26
+ result.chomp!('?')
27
+ result += suffix unless result == self
28
+ end
29
+
30
+ # ------------------------------------------------------------
31
+ # Character-based
32
+ # ------------------------------------------------------------
33
+ #
34
+ # break_on_word: true → keep whole words
35
+ # break_on_word: false → may cut mid-word (default)
36
+ #
37
+ def truncate_characters(limit, suffix: '...', break_on_word: false)
38
+ return '' if empty? || length <= limit
39
+
40
+ # When break_on_word is true, we want the result to be exactly `limit` chars total
41
+ target_content_len = limit - suffix.length
42
+ return suffix[0, limit] if target_content_len <= 0
43
+
44
+ if break_on_word
45
+ cut = rindex(' ', target_content_len) || target_content_len
46
+ self[0, cut].rstrip + suffix
47
+ else
48
+ self[0, target_content_len] + suffix
49
+ end
50
+ end
51
+
52
+ # ------------------------------------------------------------
53
+ # "Smart" truncate: sentence ▸ word ▸ hard cut
54
+ # ------------------------------------------------------------
55
+ def smart_truncate(limit, suffix: '...')
56
+ return '' if empty? || length <= limit
57
+ hard_len = limit - suffix.length
58
+ return suffix[0, limit] if hard_len.negative?
59
+
60
+ # Try sentence boundary
61
+ sent_break = rindex(/[.!?]\s/, hard_len)
62
+ return self[0..sent_break].rstrip + suffix if sent_break && sent_break > limit * 0.5
63
+
64
+ # Try word boundary
65
+ word_break = rindex(' ', hard_len)
66
+ return self[0, word_break].rstrip + suffix if word_break && word_break > limit * 0.3
67
+
68
+ # Fallback
69
+ self[0, hard_len] + suffix
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ # Optional auto-mix-in
76
+ class String
77
+ include StringMagic::Formatting::Truncation
78
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StringMagic
4
+ module Utilities
5
+ module Inflection
6
+ # ------------------------------------------------------------
7
+ # Plural ↔ singular
8
+ # ------------------------------------------------------------
9
+
10
+ def to_plural
11
+ return '' if empty?
12
+
13
+ case downcase
14
+ when /(s|sh|ch|x|z)\z/ then self + 'es'
15
+ when /[^aeiou]y\z/i then chop + 'ies'
16
+ when /(f|fe)\z/ then sub(/(f|fe)\z/, 'ves')
17
+ else self + 's'
18
+ end
19
+ end
20
+
21
+ def to_singular
22
+ return '' if empty?
23
+
24
+ down = downcase
25
+ if down.end_with?('ies') then sub(/ies\z/i, 'y')
26
+ elsif down.end_with?('ves') && length > 3
27
+ sub(/ves\z/i, down[-4] == 'i' ? 'fe' : 'f') # knives → knife, leaves → leaf
28
+ elsif down =~ /(ses|shes|ches|xes|zes)\z/i then sub(/es\z/i, '')
29
+ elsif down.end_with?('s') && length > 1 then chop
30
+ else self
31
+ end
32
+ end
33
+
34
+ # ------------------------------------------------------------
35
+ # Ordinalisation
36
+ # ------------------------------------------------------------
37
+ #
38
+ # '1'.ordinalize #=> '1st'
39
+ #
40
+ def ordinalize
41
+ return self unless /\A-?\d+\z/.match?(self)
42
+
43
+ num = to_i
44
+ suffix = if (11..13).cover?(num % 100)
45
+ 'th'
46
+ else
47
+ { 1 => 'st', 2 => 'nd', 3 => 'rd' }.fetch(num % 10, 'th')
48
+ end
49
+ self + suffix
50
+ end
51
+
52
+ # ------------------------------------------------------------
53
+ # Human-readable
54
+ # ------------------------------------------------------------
55
+
56
+ def humanize
57
+ return '' if empty?
58
+ gsub('_', ' ')
59
+ .gsub(/([a-z])([A-Z])/, '\1 \2')
60
+ .downcase
61
+ .strip
62
+ .capitalize
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ # Optional auto-mix-in
69
+ class String
70
+ include StringMagic::Utilities::Inflection
71
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StringMagic
4
+ module Utilities
5
+ module Slug
6
+ # ------------------------------------------------------------------
7
+ # Public helpers
8
+ # ------------------------------------------------------------------
9
+
10
+ def to_slug(separator: '-', preserve_case: false)
11
+ return '' if empty?
12
+
13
+ result = dup
14
+ result.downcase! unless preserve_case
15
+ result = transliterate_accents(result)
16
+ result.gsub!(/<[^>]*>/, '') # strip HTML
17
+ result.gsub!(/[^A-Za-z0-9]+/, separator) # non-alnum → sep
18
+ result.gsub!(/^#{Regexp.escape(separator)}+|#{Regexp.escape(separator)}+$/, '')
19
+ result
20
+ end
21
+
22
+ def to_url_slug
23
+ to_slug(separator: '-', preserve_case: false)
24
+ end
25
+
26
+ def to_filename_safe(replacement: '_', preserve_case: false)
27
+ return '' if empty?
28
+ result = transliterate_accents(dup)
29
+ result.downcase! unless preserve_case
30
+ result.gsub!(/[^0-9A-Za-z\.\-]+/, replacement) # keep only safe chars
31
+ # convert file extension: ".txt" → "_txt"
32
+ result.gsub!(/\.([a-z0-9]{1,5})\z/i, "#{replacement}\\1")
33
+ result.gsub!(/\.+$/, '') # drop any remaining trailing dots
34
+ result.gsub!(/#{Regexp.escape(replacement)}+/, replacement) # collapse runs
35
+ result.gsub!(/^#{Regexp.escape(replacement)}+|#{Regexp.escape(replacement)}+$/, '') # trim
36
+ result = 'untitled' if result.empty?
37
+ result = "#{result}_file" if reserved_filename?(result)
38
+ result
39
+ end
40
+
41
+ def slugify_path(separator: '/')
42
+ return '' if empty?
43
+ split('/').map { |seg| seg.to_slug }.reject(&:empty?).join(separator)
44
+ end
45
+
46
+ def extract_slug_from_url
47
+ return '' if empty?
48
+ clean = split('/').last.to_s.split(/[?#]/).first
49
+ clean.gsub!(/\.\w+$/, '') if clean.match?(/\.\w+$/)
50
+ clean
51
+ end
52
+
53
+ # ------------------------------------------------------------------
54
+ # Private helpers
55
+ # ------------------------------------------------------------------
56
+ private
57
+
58
+ def transliterate_accents(text)
59
+ accents = {
60
+ 'À'=>'A','Á'=>'A','Â'=>'A','Ã'=>'A','Ä'=>'A','Å'=>'A',
61
+ 'à'=>'a','á'=>'a','â'=>'a','ã'=>'a','ä'=>'a','å'=>'a',
62
+ 'È'=>'E','É'=>'E','Ê'=>'E','Ë'=>'E',
63
+ 'è'=>'e','é'=>'e','ê'=>'e','ë'=>'e',
64
+ 'Ì'=>'I','Í'=>'I','Î'=>'I','Ï'=>'I',
65
+ 'ì'=>'i','í'=>'i','î'=>'i','ï'=>'i',
66
+ 'Ò'=>'O','Ó'=>'O','Ô'=>'O','Õ'=>'O','Ö'=>'O',
67
+ 'ò'=>'o','ó'=>'o','ô'=>'o','õ'=>'o','ö'=>'o',
68
+ 'Ù'=>'U','Ú'=>'U','Û'=>'U','Ü'=>'U',
69
+ 'ù'=>'u','ú'=>'u','û'=>'u','ü'=>'u',
70
+ 'Ñ'=>'N','ñ'=>'n',
71
+ 'Ç'=>'C','ç'=>'c',
72
+ 'ß'=>'ss'
73
+ }
74
+ accents.each { |from, to| text.gsub!(from, to) }
75
+ text
76
+ end
77
+
78
+ def reserved_filename?(name)
79
+ %w[
80
+ CON PRN AUX NUL COM1 COM2 COM3 COM4 COM5 COM6 COM7 COM8 COM9
81
+ LPT1 LPT2 LPT3 LPT4 LPT5 LPT6 LPT7 LPT8 LPT9
82
+ ].include?(name.upcase)
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ # Optional auto-mix-in
89
+ class String
90
+ include StringMagic::Utilities::Slug
91
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StringMagic
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/string_magic.rb CHANGED
@@ -2,74 +2,35 @@
2
2
 
3
3
  require_relative "string_magic/version"
4
4
  require_relative "string_magic/core/analysis"
5
- require_relative "string_magic/core/transformation"
5
+ require_relative "string_magic/core/transformation"
6
+ require_relative "string_magic/core/validation"
7
+ require_relative "string_magic/formatting/highlighting"
8
+ require_relative "string_magic/formatting/truncation"
9
+ require_relative "string_magic/advanced/security"
10
+ require_relative "string_magic/utilities/slug"
11
+ require_relative "string_magic/utilities/inflection"
12
+
13
+ class String
14
+ include StringMagic::Core::Analysis
15
+ include StringMagic::Core::Transformation
16
+ include StringMagic::Core::Validation
17
+ include StringMagic::Formatting::Highlighting
18
+ include StringMagic::Formatting::Truncation
19
+ include StringMagic::Advanced::Security
20
+ include StringMagic::Utilities::Slug
21
+ include StringMagic::Utilities::Inflection
22
+ end
6
23
 
7
24
  module StringMagic
8
25
  class Error < StandardError; end
9
26
  class MalformedInputError < Error; end
10
27
 
11
- # Now extend the modules
12
28
  extend Core::Analysis
13
29
  extend Core::Transformation
14
-
15
- def self.hello_world
16
- "hello world!"
17
- end
18
-
19
- def self.word_count(string)
20
- string.split.size
21
- end
22
-
23
- def self.palindrome?(string)
24
- cleaned = string.downcase.gsub(/[^a-z0-9]/, "")
25
- cleaned == cleaned.reverse
26
- end
27
-
28
- def self.capitalize_words(string)
29
- string.split.map(&:capitalize).join(" ")
30
- end
31
-
32
- def self.reverse_words(string)
33
- string.split.reverse.join(" ")
34
- end
35
-
36
- def self.remove_duplicates(string)
37
- string.chars.uniq.join
38
- end
39
-
40
- def self.count_vowels(string)
41
- string.downcase.count("aeiou")
42
- end
43
-
44
- def self.to_pig_latin(string)
45
- string.split.map do |word|
46
- if word[0] =~ /[aeiou]/i
47
- word + "ay"
48
- else
49
- word[1..-1] + word[0] + "ay"
50
- end
51
- end.join(" ")
52
- end
53
-
54
- def self.alternating_case(string)
55
- string.chars.map.with_index { |char, i| i.even? ? char.upcase : char.downcase }.join
56
- end
57
-
58
- def self.camel_case(string)
59
- string.split.map(&:capitalize).join
60
- end
61
-
62
- def self.snake_case(string)
63
- string.downcase.gsub(/\s+/, "_")
64
- end
65
-
66
- def self.title_case(string)
67
- string.split.map(&:capitalize).join(" ")
68
- end
69
-
70
- def self.anagram?(string1, string2)
71
- processed_string1 = string1.downcase.gsub(/[^a-z0-9]/, "").chars.sort.join
72
- processed_string2 = string2.downcase.gsub(/[^a-z0-9]/, "").chars.sort.join
73
- processed_string1 == processed_string2
74
- end
75
- end
30
+ extend Core::Validation
31
+ extend Formatting::Highlighting
32
+ extend Formatting::Truncation
33
+ extend Advanced::Security
34
+ extend Utilities::Slug
35
+ extend Utilities::Inflection
36
+ end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: string_magic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emad Rahimi
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-01-02 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rspec
@@ -69,8 +68,14 @@ files:
69
68
  - README.md
70
69
  - Rakefile
71
70
  - lib/string_magic.rb
71
+ - lib/string_magic/advanced/security.rb
72
72
  - lib/string_magic/core/analysis.rb
73
73
  - lib/string_magic/core/transformation.rb
74
+ - lib/string_magic/core/validation.rb
75
+ - lib/string_magic/formatting/highlighting.rb
76
+ - lib/string_magic/formatting/truncation.rb
77
+ - lib/string_magic/utilities/inflection.rb
78
+ - lib/string_magic/utilities/slug.rb
74
79
  - lib/string_magic/version.rb
75
80
  - sig/string_magic.rbs
76
81
  homepage: https://github.com/erscript/string-magic
@@ -80,7 +85,6 @@ metadata:
80
85
  homepage_uri: https://github.com/erscript/string-magic
81
86
  source_code_uri: https://github.com/erscript/string-magic
82
87
  changelog_uri: https://github.com/erscript/string-magic/blob/main/CHANGELOG.md
83
- post_install_message:
84
88
  rdoc_options: []
85
89
  require_paths:
86
90
  - lib
@@ -95,8 +99,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
95
99
  - !ruby/object:Gem::Version
96
100
  version: '0'
97
101
  requirements: []
98
- rubygems_version: 3.5.11
99
- signing_key:
102
+ rubygems_version: 3.6.9
100
103
  specification_version: 4
101
104
  summary: The StringMagic gem enriches Ruby's string class with an array of versatile
102
105
  methods designed for enhanced text formatting, manipulation, and analysis.