usernames 0.1.3

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.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +39 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +362 -0
  5. data/SECURITY.md +42 -0
  6. data/config/locales/usernames.en.yml +72 -0
  7. data/config/locales/usernames.es.yml +51 -0
  8. data/config/locales/usernames.pt.yml +51 -0
  9. data/data/LICENSES/allprofanity.txt +24 -0
  10. data/data/LICENSES/big-username-blocklist.txt +25 -0
  11. data/data/LICENSES/corpora.txt +128 -0
  12. data/data/LICENSES/cuss.txt +25 -0
  13. data/data/LICENSES/django-registration.txt +33 -0
  14. data/data/LICENSES/dsojevic-profanity-list.txt +24 -0
  15. data/data/LICENSES/forwardemail.txt +24 -0
  16. data/data/LICENSES/friendly-words.txt +24 -0
  17. data/data/LICENSES/gitea.txt +23 -0
  18. data/data/LICENSES/gitlab.txt +31 -0
  19. data/data/LICENSES/iana-well-known-uris.txt +130 -0
  20. data/data/LICENSES/imsky-wordlists.txt +24 -0
  21. data/data/LICENSES/mottie-github-reserved-names.txt +24 -0
  22. data/data/LICENSES/obscenity.txt +27 -0
  23. data/data/LICENSES/petname.txt +218 -0
  24. data/data/LICENSES/rfc2142.txt +9 -0
  25. data/data/LICENSES/shouldbee.txt +25 -0
  26. data/data/LICENSES/usernames.txt +30 -0
  27. data/data/NOTICE +36 -0
  28. data/data/offensive/README.md +90 -0
  29. data/data/offensive/allowlist/en.txt +727 -0
  30. data/data/offensive/allowlist/es.txt +39 -0
  31. data/data/offensive/en.json +5680 -0
  32. data/data/offensive/es.json +1014 -0
  33. data/data/offensive/hate.yml +51 -0
  34. data/data/offensive/leet.yml +27 -0
  35. data/data/reserved/ALLOWLIST_EXCEPTIONS.txt +6 -0
  36. data/data/reserved/impersonation.txt +69 -0
  37. data/data/reserved/mailboxes.txt +77 -0
  38. data/data/reserved/system.txt +124 -0
  39. data/data/reserved/web.txt +962 -0
  40. data/data/words/adjectives.txt +367 -0
  41. data/data/words/animals.txt +253 -0
  42. data/lib/generators/usernames/availability_generator.rb +67 -0
  43. data/lib/generators/usernames/existing_schema.rb +85 -0
  44. data/lib/generators/usernames/install_generator.rb +241 -0
  45. data/lib/generators/usernames/install_messages.rb +63 -0
  46. data/lib/generators/usernames/locale_generator.rb +56 -0
  47. data/lib/generators/usernames/templates/add_username.rb.erb +53 -0
  48. data/lib/generators/usernames/templates/availability/_field.html.erb.tt +25 -0
  49. data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +107 -0
  50. data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +115 -0
  51. data/lib/generators/usernames/templates/usernames.rb +34 -0
  52. data/lib/tasks/usernames.rake +38 -0
  53. data/lib/usernames/adapters/moderate.rb +37 -0
  54. data/lib/usernames/audit/output.rb +51 -0
  55. data/lib/usernames/audit/values.rb +34 -0
  56. data/lib/usernames/audit.rb +230 -0
  57. data/lib/usernames/candidates.rb +64 -0
  58. data/lib/usernames/comparison/index_contract.rb +241 -0
  59. data/lib/usernames/comparison/index_expression.rb +183 -0
  60. data/lib/usernames/comparison.rb +127 -0
  61. data/lib/usernames/configuration.rb +197 -0
  62. data/lib/usernames/data.rb +205 -0
  63. data/lib/usernames/engine.rb +32 -0
  64. data/lib/usernames/errors.rb +16 -0
  65. data/lib/usernames/generator.rb +206 -0
  66. data/lib/usernames/model.rb +397 -0
  67. data/lib/usernames/normalizer.rb +51 -0
  68. data/lib/usernames/offensive/hate.rb +124 -0
  69. data/lib/usernames/offensive/lexicon.rb +156 -0
  70. data/lib/usernames/offensive/name.rb +254 -0
  71. data/lib/usernames/offensive.rb +136 -0
  72. data/lib/usernames/phrase.rb +68 -0
  73. data/lib/usernames/reserved/listing.rb +79 -0
  74. data/lib/usernames/reserved.rb +219 -0
  75. data/lib/usernames/result.rb +52 -0
  76. data/lib/usernames/rules/options.rb +175 -0
  77. data/lib/usernames/rules.rb +257 -0
  78. data/lib/usernames/unique_violation.rb +176 -0
  79. data/lib/usernames/validator.rb +61 -0
  80. data/lib/usernames/version.rb +5 -0
  81. data/lib/usernames/words.rb +13 -0
  82. data/lib/usernames.rb +138 -0
  83. data/sig/usernames.rbs +75 -0
  84. metadata +217 -0
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ # `Usernames.configure { |config| … }`. The generated initializer shows ten
5
+ # of these options; the README documents the rest.
6
+ #
7
+ # Word lists START FROM the bundled data, so `+=` adds to it and `=`
8
+ # replaces it:
9
+ #
10
+ # config.reserved += %w[recipe recipes] # the bundled words, and these
11
+ # config.reserved = %w[recipe recipes] # only these
12
+ #
13
+ # Lists are handed out frozen. The rules compiled from them are cached, and
14
+ # a list changed behind the cache's back (`config.reserved << "x"`) would
15
+ # be ignored without a word — so that raises FrozenError instead, and the
16
+ # two spellings above are the ones that work.
17
+ #
18
+ # Nothing is checked as you assign: validate! (run at the end of configure,
19
+ # and by the Rails engine after boot) checks every value at once and raises
20
+ # Usernames::ConfigurationError naming the bad one.
21
+ class Configuration
22
+ # Accessors whose writers invalidate the compiled rules.
23
+ module Settings
24
+ def option(*names)
25
+ names.each do |name|
26
+ attr_reader name
27
+
28
+ define_method(:"#{name}=") do |value|
29
+ changed!
30
+ instance_variable_set(:"@#{name}", value.is_a?(Array) ? value.dup.freeze : value)
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ extend Settings
37
+
38
+ # config.offensive.severity / .languages / .allowed / .adapters
39
+ class Offensive
40
+ extend Settings
41
+
42
+ option :severity, :languages, :allowed, :adapters
43
+
44
+ def initialize(configuration)
45
+ @configuration = configuration
46
+ @severity = :strong
47
+ @languages = %i[en es].freeze
48
+ # Your own exemptions, so — unlike reserved and forbidden — this list
49
+ # starts empty: the bundled allowlists are per language and apply only
50
+ # inside their own language, so they are not merged in here.
51
+ @allowed = [].freeze
52
+ # A no-op unless the moderate gem is loaded.
53
+ @adapters = [:moderate].freeze
54
+ end
55
+
56
+ def to_h
57
+ { severity: severity, languages: languages, allowed: allowed, adapters: adapters }
58
+ end
59
+
60
+ private
61
+
62
+ def changed!
63
+ @configuration.changed!
64
+ end
65
+ end
66
+
67
+ # config.generator.adjectives / .nouns, or config.generator = ->(attempt) { … }
68
+ class Generator
69
+ extend Settings
70
+
71
+ option :adjectives, :nouns, :callable
72
+
73
+ def initialize(configuration)
74
+ @configuration = configuration
75
+ @adjectives = Data.adjectives
76
+ @nouns = Data.nouns
77
+ @callable = nil
78
+ end
79
+
80
+ def to_h
81
+ { adjectives: adjectives, nouns: nouns, callable: callable }
82
+ end
83
+
84
+ private
85
+
86
+ def changed!
87
+ @configuration.changed!
88
+ end
89
+ end
90
+
91
+ # Per-call word lists ADD to the configured ones: a model that passes
92
+ # `reserved: %w[recipe]` keeps every bundled reservation. Every other
93
+ # per-call option replaces the configured value. Adding is the choice
94
+ # that can never silently un-reserve a name: a per-model list fails closed.
95
+ LISTS = %i[reserved forbidden allowed].freeze
96
+
97
+ # Distinct per-call option sets kept compiled. They come from model and
98
+ # validator declarations, so a handful in practice; the limit only stops
99
+ # a caller passing fresh options on every request from growing it forever.
100
+ CACHE_LIMIT = 64
101
+
102
+ option :length, :separators, :edge_separators, :case, :first_character, :reserve_routes, :app_name,
103
+ :reserved, :forbidden, :allowed, :on_adapter_failure, :unique_index
104
+
105
+ attr_reader :offensive, :generator
106
+
107
+ def initialize
108
+ @length = 3..30
109
+ @separators = %w[_].freeze
110
+ @edge_separators = false
111
+ @case = :lowercase
112
+ @first_character = :alphanumeric
113
+ @reserve_routes = true
114
+ @app_name = nil # detected from the Rails application; false opts out
115
+ @reserved = Data.reserved
116
+ @forbidden = Data.impersonation
117
+ @allowed = [].freeze
118
+ @on_adapter_failure = :allow
119
+ @unique_index = nil
120
+ @offensive = Offensive.new(self)
121
+ @generator = Generator.new(self)
122
+ end
123
+
124
+ # `config.generator = ->(attempt) { … }` replaces the default shape;
125
+ # the word lists stay at config.generator.adjectives / .nouns.
126
+ def generator=(callable)
127
+ generator.callable = callable
128
+ end
129
+
130
+ # The compiled, frozen Usernames::Rules for these per-call options merged
131
+ # over this configuration, raising ConfigurationError for a bad value.
132
+ def rules_for(**overrides)
133
+ cache = (@rules_cache ||= {})
134
+ cache.clear if cache.size >= CACHE_LIMIT
135
+ cache[overrides] ||= begin
136
+ options = merge(overrides)
137
+ Rules.new(**options)
138
+ end
139
+ end
140
+
141
+ def validate!
142
+ rules_for
143
+ self
144
+ end
145
+
146
+ def to_h
147
+ {
148
+ length: length, separators: separators, edge_separators: edge_separators, case: @case,
149
+ first_character: first_character, reserve_routes: reserve_routes, app_name: app_name,
150
+ reserved: reserved, forbidden: forbidden, allowed: allowed, offensive: offensive.to_h,
151
+ on_adapter_failure: on_adapter_failure, generator: generator.to_h, unique_index: unique_index
152
+ }
153
+ end
154
+
155
+ # Called by every writer, including those of config.offensive and
156
+ # config.generator. :nodoc:
157
+ def changed!
158
+ @rules_cache = nil
159
+ end
160
+
161
+ private
162
+
163
+ def merge(overrides)
164
+ options = to_h
165
+ overrides.each do |name, value|
166
+ options[name] =
167
+ case name
168
+ when *LISTS then added(options[name], value)
169
+ when :offensive then merged_offensive(options[:offensive], value)
170
+ when :generator then merged_generator(options[:generator], value)
171
+ else value
172
+ end
173
+ end
174
+ options
175
+ end
176
+
177
+ # Anything that is not a list is passed on as given, for Rules to refuse by name.
178
+ def added(list, value)
179
+ value.is_a?(Array) ? list + value : value
180
+ end
181
+
182
+ def merged_offensive(settings, value)
183
+ return value unless value.is_a?(Hash)
184
+
185
+ settings.merge(value) do |name, configured, given|
186
+ name == :allowed ? added(configured, given) : given
187
+ end
188
+ end
189
+
190
+ def merged_generator(settings, value)
191
+ return settings.merge(callable: value) if value.nil? || value.respond_to?(:call)
192
+ return value unless value.is_a?(Hash)
193
+
194
+ settings.merge(value)
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Usernames
7
+ # The word lists that ship in data/, read once per process and frozen.
8
+ #
9
+ # Every list is a plain text file, one entry per line; blank lines and
10
+ # `#` comments (which carry each block's source and reason, see
11
+ # data/NOTICE) are skipped. Every file is mandatory: one that is missing,
12
+ # empty or malformed raises Usernames::DataError when the gem loads, since
13
+ # the alternative — rules that silently check nothing — fails open.
14
+ #
15
+ # Note the name: inside `module Usernames`, `Data` is this module. Ruby's
16
+ # own value-object class is `::Data`, and the gem always spells it so.
17
+ module Data
18
+ ROOT = File.expand_path("../../data", __dir__)
19
+
20
+ # The lists every offensive entry carries, besides its term and severity.
21
+ TERM_LISTS = %w[variants tags exceptions].freeze
22
+
23
+ # Matched exactly: system words, mailboxes, web paths.
24
+ EXACT_FILES = %w[reserved/system.txt reserved/mailboxes.txt reserved/web.txt].freeze
25
+
26
+ # Matched the way impersonators type them.
27
+ IMPERSONATION_FILES = %w[reserved/impersonation.txt].freeze
28
+
29
+ # `# source: gitlab (MIT) — GitLab top-level and project wildcard routes`
30
+ SOURCE_LINE = /\A#\s*source:\s*(?<name>\S+)\s+\((?<licence>[^)]+)\)\s*(?:—\s*(?<reason>.*))?\z/
31
+
32
+ # Where one bundled reserved word comes from: its file (:impersonation,
33
+ # :system, :mailboxes, :web) and the `# source:` comment of its block.
34
+ Provenance = ::Data.define(:category, :source, :licence, :reason)
35
+
36
+ class << self
37
+ # What `config.reserved` starts from.
38
+ def reserved
39
+ @reserved ||= EXACT_FILES.flat_map { |file| list(file) }.uniq.freeze
40
+ end
41
+
42
+ # What `config.forbidden` starts from.
43
+ def impersonation
44
+ @impersonation ||= IMPERSONATION_FILES.flat_map { |file| list(file) }.uniq.freeze
45
+ end
46
+
47
+ # { word => Provenance } over every bundled reserved file. The
48
+ # impersonation file is read first: a word in it and in a mailbox list
49
+ # (`postmaster`) is matched the stricter way, so that is its source.
50
+ def reserved_provenance
51
+ @reserved_provenance ||= (IMPERSONATION_FILES + EXACT_FILES).each_with_object({}) do |file, words|
52
+ category = File.basename(file, ".txt").to_sym
53
+ sourced(file).each { |word, source| words[word] ||= Provenance.new(category: category, **source) }
54
+ end.freeze
55
+ end
56
+
57
+ # The generator's vocabulary.
58
+ def adjectives
59
+ @adjectives ||= list("words/adjectives.txt")
60
+ end
61
+
62
+ # data/words/animals.txt, published as Usernames::Words::ANIMALS.
63
+ def nouns
64
+ @nouns ||= list("words/animals.txt")
65
+ end
66
+
67
+ # The languages whose offensive words ship (data/offensive/<locale>.json),
68
+ # as symbols: what config.offensive.languages may name.
69
+ def available_locales
70
+ @available_locales ||= begin
71
+ locales = Dir[File.join(ROOT, "offensive", "*.json")].map { |path| File.basename(path, ".json").to_sym }
72
+ broken!(File.join(ROOT, "offensive"), "holds no <locale>.json word list") if locales.empty?
73
+ locales.sort.freeze
74
+ end
75
+ end
76
+
77
+ # The parsed entries of data/offensive/<locale>.json: each a Hash with
78
+ # a "term", a "severity" from 1 to 4, and lists of "variants", "tags"
79
+ # and "exceptions". An empty list is as broken as a missing file: it
80
+ # would screen nothing in that language.
81
+ def offensive(locale)
82
+ (@offensive ||= {})[locale.to_sym] ||= read("offensive/#{locale}.json") do |path|
83
+ entries = JSON.parse(File.read(path, encoding: "UTF-8"))
84
+ broken!(path, "has no entries") if entries == []
85
+ unless entries.is_a?(Array) && entries.all? { |entry| term?(entry) }
86
+ broken!(path, "is not a list of {term, severity 1–4, variants, tags, exceptions} entries")
87
+ end
88
+
89
+ deep_freeze(entries)
90
+ end
91
+ end
92
+
93
+ # data/offensive/allowlist/<locale>.txt (each line `word # source`).
94
+ def allowlist(locale)
95
+ (@allowlist ||= {})[locale.to_sym] ||= list("offensive/allowlist/#{locale}.txt")
96
+ end
97
+
98
+ # data/offensive/hate.yml: the hate tokens and number rules.
99
+ def hate
100
+ @hate ||= yaml("offensive/hate.yml", "always" => Array, "digit_runs" => Array, "adjacent" => Hash,
101
+ "whole_token" => Hash)
102
+ end
103
+
104
+ # data/offensive/leet.yml: the digit folds both matchers use, and the
105
+ # rewrites only the offensive matcher uses.
106
+ def leet
107
+ @leet ||= yaml("offensive/leet.yml", "digits" => Hash, "offensive_only" => Hash)
108
+ end
109
+
110
+ # The entries of one data file, relative to data/ (or an absolute path).
111
+ # Every bundled file is read as UTF-8 explicitly: the host's locale
112
+ # (Encoding.default_external, US-ASCII where LANG is unset) must never
113
+ # decide how the lists are decoded.
114
+ def list(path)
115
+ read(path) do |file|
116
+ entries = File.foreach(file, chomp: true, encoding: "UTF-8").filter_map do |line|
117
+ entry = line.sub(/#.*/, "").strip
118
+ entry.freeze unless entry.empty?
119
+ end
120
+ broken!(file, "has no entries") if entries.empty?
121
+ entries.freeze
122
+ end
123
+ end
124
+
125
+ # [[entry, { source:, licence:, reason: }], …] for one data file: each
126
+ # entry with the `# source:` comment of the block it sits in (nils for
127
+ # an entry before any).
128
+ def sourced(path)
129
+ read(path) do |file|
130
+ source = { source: nil, licence: nil, reason: nil }
131
+ File.foreach(file, chomp: true, encoding: "UTF-8").filter_map do |line|
132
+ if (match = SOURCE_LINE.match(line.strip))
133
+ source = { source: match[:name], licence: match[:licence], reason: match[:reason] }
134
+ next
135
+ end
136
+ entry = line.sub(/#.*/, "").strip
137
+ [entry, source] unless entry.empty?
138
+ end
139
+ end
140
+ end
141
+
142
+ # Reads every bundled file once, so a broken install fails when the gem
143
+ # loads rather than on the first sign-up that needs the missing list.
144
+ def load!
145
+ reserved
146
+ impersonation
147
+ reserved_provenance
148
+ adjectives
149
+ nouns
150
+ available_locales.each do |locale|
151
+ offensive(locale)
152
+ allowlist(locale)
153
+ end
154
+ hate
155
+ leet
156
+ self
157
+ end
158
+
159
+ private
160
+
161
+ def read(relative)
162
+ path = File.expand_path(relative, ROOT)
163
+ broken!(path, "is missing") unless File.file?(path)
164
+
165
+ yield path
166
+ rescue JSON::ParserError, Psych::Exception => e
167
+ broken!(path, "can't be parsed (#{e.message.lines.first.strip})")
168
+ end
169
+
170
+ # A YAML mapping with at least `shape`'s keys, each of its type.
171
+ def yaml(relative, shape)
172
+ read(relative) do |path|
173
+ data = YAML.safe_load_file(path)
174
+ wrong = shape.find { |key, type| !(data.is_a?(Hash) && data[key].is_a?(type)) }
175
+ broken!(path, "needs #{wrong[0].inspect} (a #{wrong[1].name})") if wrong
176
+
177
+ deep_freeze(data)
178
+ end
179
+ end
180
+
181
+ def term?(entry)
182
+ entry.is_a?(Hash) && entry["term"].is_a?(String) && !entry["term"].empty? &&
183
+ entry["severity"].is_a?(Integer) && (1..4).cover?(entry["severity"]) &&
184
+ term_lists?(entry) && [nil, true, false].include?(entry["segment_only"])
185
+ end
186
+
187
+ def term_lists?(entry)
188
+ TERM_LISTS.all? { |key| entry[key].is_a?(Array) && entry[key].all?(String) }
189
+ end
190
+
191
+ def broken!(path, problem)
192
+ raise DataError, "usernames' bundled data is broken: #{path} #{problem}. " \
193
+ "Reinstall the gem, or rebuild data/ with script/build_*.rb in a checkout."
194
+ end
195
+
196
+ def deep_freeze(value)
197
+ case value
198
+ when Hash then value.each_value { |item| deep_freeze(item) }
199
+ when Array then value.each { |item| deep_freeze(item) }
200
+ end
201
+ value.freeze
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ # Not isolated, and no routes: the gem mounts nothing behind your back
5
+ # (the availability check is a generator that copies files into the app).
6
+ #
7
+ # What it does do: its config/locales load BEFORE the app's — Rails puts
8
+ # engines first and the application last — so any key in the app's own
9
+ # locale files wins; `has_username` reaches every Active Record model; the
10
+ # app's own routes are reserved and re-read whenever they reload; and a bad
11
+ # configuration value fails at boot, not at the first sign-up.
12
+ #
13
+ # The generators (`usernames:install`, `usernames:availability`,
14
+ # `usernames:locale`) need no wiring: Rails finds them in
15
+ # lib/generators/usernames/ on the load path.
16
+ class Engine < ::Rails::Engine
17
+ initializer "usernames.active_record" do
18
+ ActiveSupport.on_load(:active_record) { extend Usernames::Model }
19
+ end
20
+
21
+ # After every initializer, including the app's config/initializers/usernames.rb.
22
+ initializer "usernames.validate_configuration" do |app|
23
+ app.config.after_initialize { Usernames.configuration.validate! }
24
+ end
25
+
26
+ # The app's static top-level paths are reserved. They are
27
+ # collected lazily, on the first check after the routes load; in
28
+ # development the routes reload when config/routes.rb changes, and a
29
+ # page added there is reserved from the next check on.
30
+ config.after_routes_loaded { Usernames::Reserved.reset_routes! }
31
+ end
32
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ class Error < StandardError; end
5
+
6
+ # A configuration value the gem cannot use, named in the message.
7
+ class ConfigurationError < Error; end
8
+
9
+ # Every generated candidate was taken (generate_username draws one batch).
10
+ class NoFreeNameError < Error; end
11
+
12
+ # A bundled data file (data/**) is missing or not what the gem expects.
13
+ # Raised when the gem loads: without its word lists the gem would check
14
+ # nothing at all — `admin` registrable, every slur allowed — and say nothing.
15
+ class DataError < Error; end
16
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "active_support/inflector"
5
+
6
+ module Usernames
7
+ # Default and seeded names: `quiet_otter_42`, or `jose_garcia`
8
+ # from "José García".
9
+ #
10
+ # Pure: it draws a batch of candidates and keeps those the rules allow; it
11
+ # never asks a database whether one is free. The model helpers take the
12
+ # batch to the database in ONE query, which is why it is a batch: twenty
13
+ # names at once instead of a retry loop of queries.
14
+ #
15
+ # The order inside each draw is fixed: assemble the name, shorten it
16
+ # to fit, and only then run the full check. Shortening can make a name
17
+ # the check refuses (an all-digit stem, a word cut into something else), and
18
+ # a check run before it would have passed a name that is not the one
19
+ # handed out. Digits always come from SecureRandom, never a counter, so
20
+ # the next name is not guessable from the last.
21
+ module Generator
22
+ # Draws per batch. Every shape, fallback and retry fits inside it.
23
+ DRAWS = 20
24
+
25
+ # Local parts that say nothing about the person: from
26
+ # info@acme.com the domain label is the better seed.
27
+ GENERIC_LOCAL_PARTS = %w[i me info support admin webmaster hello mail office contact team].freeze
28
+
29
+ # Below this maximum length `adjective_noun_42` cannot fit, so the
30
+ # compact shapes take over (as they do with no separators at all).
31
+ COMPACT_BELOW = 10
32
+
33
+ # How long a random-letters fallback is, within the length rule.
34
+ RANDOM_LETTERS = 8
35
+
36
+ class << self
37
+ # Up to DRAWS distinct names the rules allow, in the order drawn.
38
+ #
39
+ # Generator.candidates(rules: rules) # the default shapes
40
+ # Generator.candidates(from: "José García", rules: rules)
41
+ #
42
+ # With a seed: the cleaned seed, the seed with two random digits (three
43
+ # times) and with three (twice), then default shapes for the rest of
44
+ # the batch.
45
+ # The default shapes after a seed count their own attempts
46
+ # from 1, so they start at two digits as an unseeded batch does.
47
+ def candidates(rules:, from: nil)
48
+ base = from.nil? ? "" : clean(from, rules)
49
+ draws = base.empty? ? [] : seeded(base, rules)
50
+ draws += Array.new(DRAWS - draws.size) { |index| shape(index + 1, rules) }
51
+ allowed(draws, rules)
52
+ end
53
+
54
+ # One draw of the default shape, or of config.generator when the app
55
+ # set one (called with the attempt number, 1 up). nil when the draw
56
+ # cannot fit the length rule; never checked here.
57
+ def shape(attempt, rules)
58
+ callable = rules.generator.callable
59
+ return callable.call(attempt).to_s if callable
60
+
61
+ compact?(rules) ? compact_shape(attempt, rules) : default_shape(attempt, rules)
62
+ end
63
+
64
+ # A seed as a name: the useful half of an email,
65
+ # normalised, transliterated to ASCII, with every run of characters the
66
+ # rules do not allow turned into the first separator, runs of
67
+ # separators collapsed and the edges trimmed. May be "".
68
+ def clean(seed, rules)
69
+ text = prepare(seed, rules)
70
+ separators = rules.separators
71
+ allowed = "#{rules.lowercase? ? 'a-z' : 'A-Za-z'}0-9#{Regexp.escape(separators.join)}"
72
+ text = text.gsub(/[^#{allowed}]+/, separators.first.to_s)
73
+ return text if separators.empty?
74
+
75
+ run = "[#{Regexp.escape(separators.join)}]"
76
+ text.gsub(/#{run}{2,}/) { |separators_run| separators_run[0] }.gsub(/\A#{run}+|#{run}+\z/, "")
77
+ end
78
+
79
+ # The words of a seed, whatever the separators: "Javi R" → javi, r.
80
+ def words(seed, rules)
81
+ prepare(seed, rules).split(/[^A-Za-z0-9]+/).reject(&:empty?)
82
+ end
83
+
84
+ # `base` cut to at most `budget` characters, at a separator that falls
85
+ # inside the budget (`pedro_gonzales_armillon` → `pedro_gonzales`).
86
+ # nil when not even the first word fits: a name is never cut mid-word
87
+ # (`maximilianus` is not `maximil`), so the batch moves on to its next
88
+ # candidate instead.
89
+ def fit(base, budget, rules)
90
+ return if budget < 1 || base.empty?
91
+ return base if base.length <= budget
92
+
93
+ boundary = rules.separators.empty? ? nil : base.rindex(Regexp.union(rules.separators), budget)
94
+ base[0, boundary] if boundary&.positive?
95
+ end
96
+
97
+ # `base` + separator + random digits from `range`, the base shortened
98
+ # first so the whole fits the maximum length.
99
+ def with_digits(base, range, rules)
100
+ separator = rules.separators.first.to_s
101
+ digits = SecureRandom.random_number(range).to_s
102
+ stem = fit(base, rules.length.max - separator.length - digits.length, rules)
103
+ "#{stem}#{separator}#{digits}" if stem
104
+ end
105
+
106
+ private
107
+
108
+ def seeded(base, rules)
109
+ [fit(base, rules.length.max, rules)] +
110
+ Array.new(3) { with_digits(base, 10..99, rules) } +
111
+ Array.new(2) { with_digits(base, 100..999, rules) }
112
+ end
113
+
114
+ # adjective + separator + noun + separator + digits, dropping the
115
+ # adjective when the pair does not fit the budget the digits leave.
116
+ def default_shape(attempt, rules)
117
+ separator = rules.separators.first
118
+ digits = suffix(attempt)
119
+ base = default_base(rules, rules.length.max - separator.length - digits.length)
120
+ "#{base}#{separator}#{digits}" if base
121
+ end
122
+
123
+ def default_base(rules, budget)
124
+ noun = pick(rules.generator.nouns)
125
+ return unless noun
126
+
127
+ pair = [pick(rules.generator.adjectives), noun].compact.join(rules.separators.first)
128
+ [pair, noun].find { |words| words.length <= budget }
129
+ end
130
+
131
+ # For a short maximum or no separators: noun + digits, then the noun
132
+ # cut to the budget (`ott42`), then random letters when not even one
133
+ # letter fits beside the digits (`length: 2..2`).
134
+ def compact_shape(attempt, rules)
135
+ digits = suffix(attempt)
136
+ noun = pick(rules.generator.nouns)
137
+ budget = rules.length.max - digits.length
138
+ name = "#{noun[0, budget]}#{digits}" if noun && budget >= 1
139
+ name && name.length >= rules.length.min ? name : random_letters(rules)
140
+ end
141
+
142
+ def compact?(rules)
143
+ rules.separators.empty? || rules.length.max < COMPACT_BELOW
144
+ end
145
+
146
+ # Two digits for the first three draws, three for the next two, four
147
+ # for the next three, then six hex characters: short names
148
+ # first, and more room as the obvious ones turn out taken.
149
+ def suffix(attempt)
150
+ case attempt
151
+ when 1..3 then SecureRandom.random_number(10..99).to_s
152
+ when 4..5 then SecureRandom.random_number(100..999).to_s
153
+ when 6..8 then SecureRandom.random_number(1000..9999).to_s
154
+ else SecureRandom.hex(3)
155
+ end
156
+ end
157
+
158
+ def random_letters(rules)
159
+ length = RANDOM_LETTERS.clamp(rules.length.min, rules.length.max)
160
+ Array.new(length) { ("a".ord + SecureRandom.random_number(26)).chr }.join
161
+ end
162
+
163
+ def pick(list)
164
+ list[SecureRandom.random_number(list.size)] if list.any?
165
+ end
166
+
167
+ # Normalised, deduplicated and checked — the full check, last.
168
+ def allowed(draws, rules)
169
+ draws.each_with_object([]) do |draw, names|
170
+ next if draw.nil?
171
+
172
+ name = rules.normalize(draw)
173
+ names << name unless name.empty? || names.include?(name) || rules.failure_for(name)
174
+ end
175
+ end
176
+
177
+ # Repaired into UTF-8 first, as the normaliser does: a UTF-16 or
178
+ # binary seed must not reach the String operations below.
179
+ def prepare(seed, rules)
180
+ text = Normalizer.utf8(seed)
181
+ text = email_seed(text) if text.include?("@")
182
+ text = transliterate(rules.normalize(text)).delete("?")
183
+ rules.lowercase? ? text.downcase : text
184
+ end
185
+
186
+ # The local part, without a +tag, unless it is generic or empty
187
+ # (`@javi` is a handle, not an address): then the domain's first label.
188
+ # The first label, so acme.co.uk gives `acme`; the +tag is dropped
189
+ # (`javi+news`).
190
+ def email_seed(text)
191
+ local, _, domain = text.rpartition("@")
192
+ local = local.split("+", 2).first.to_s.strip
193
+ return local unless local.empty? || GENERIC_LOCAL_PARTS.include?(local.downcase)
194
+
195
+ domain.strip.split(".").first.to_s
196
+ end
197
+
198
+ # AnyAscii (gem `any_ascii`) when the app has it: it knows Cyrillic,
199
+ # Greek, CJK… Otherwise Active Support's transliteration, which knows
200
+ # Latin accents and writes `?` for the rest — dropped by the caller.
201
+ def transliterate(text)
202
+ defined?(::AnyAscii) ? ::AnyAscii.transliterate(text) : ActiveSupport::Inflector.transliterate(text)
203
+ end
204
+ end
205
+ end
206
+ end