iriq 0.2.0 → 0.33.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.
@@ -1,24 +1,202 @@
1
+ require "set"
2
+
1
3
  module Iriq
2
4
  # Heuristic classifier for individual path segments and query values.
3
5
  #
4
6
  # Returns a symbol from the known TYPES set. Order matters: the first
5
7
  # matching rule wins.
6
8
  class SegmentClassifier
7
- TYPES = %i[literal integer_id uuid date timestamp hash slug opaque_id].freeze
9
+ # `:number` is a corpus-only umbrella surfaced by Cluster#param_type
10
+ # when both `:integer` and `:float` are observed at the same position
11
+ # without either hitting a clear majority. The classifier never returns
12
+ # `:number` for an individual value — every value is unambiguously one
13
+ # or the other.
14
+ #
15
+ # `:enum` is similarly corpus-only — it surfaces when a position has a
16
+ # bounded set of distinct values observed across enough samples (see
17
+ # Cluster::ENUM_* thresholds). `:string` is the rung below `:enum`: a
18
+ # param that varies across free-form literal values but isn't a confident
19
+ # bounded set yet. Neither is ever returned for a single value.
20
+ TYPES = %i[literal string integer float number uuid date year timestamp hash slug
21
+ ipv4 ipv6 url email boolean version locale currency phone jwt mime
22
+ file color coordinate country base64 http_status enum opaque_id].freeze
8
23
 
9
- UUID_RE = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/.freeze
10
- INTEGER_RE = /\A\d+\z/.freeze
11
- DATE_RE = /\A\d{4}-\d{2}-\d{2}\z/.freeze
24
+ # A float requires a decimal point and digits on both sides. Sign is
25
+ # optional. Bare integers and 4+ char hex/UUID-shaped tokens fall through
26
+ # to their own rules.
27
+ FLOAT_RE = /\A-?\d+\.\d+\z/.freeze
28
+ # ISO 8601 timestamp shapes (RFC 3339-ish). Date-only forms live on
29
+ # Recognizers::Date / Recognizers::Integer.
12
30
  ISO_TIME_RE = /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+\-]\d{2}:?\d{2})?\z/.freeze
13
31
  HASH_RE = /\A\h{32,}\z/.freeze
14
32
  SLUG_RE = /\A[a-z0-9]+(?:[-_][a-z0-9]+)+\z/.freeze
15
33
  LITERAL_RE = /\A[\p{L}][\p{L}\p{M}_]*\z/u.freeze
16
34
  OPAQUE_RE = /\A[A-Za-z0-9_\-.~]{4,}\z/.freeze
17
35
 
18
- # Plausible UNIX timestamps (10 digit seconds or 13 digit ms) from
19
- # roughly 2001 onward.
20
- TS_SECONDS_RANGE = 1_000_000_000..9_999_999_999
21
- TS_MILLIS_RANGE = 1_000_000_000_000..9_999_999_999_999
36
+ # Dotted-quad shape; per-octet bounds are validated in classify_ipv4.
37
+ IPV4_RE = /\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/.freeze
38
+ # IPv6: matches either the full eight-group form (`a:b:c:d:e:f:g:h`)
39
+ # or any compressed form containing `::`. Rejects bare hex / integers
40
+ # / single-colon strings so we don't shadow :integer, :hash, etc.
41
+ # Doesn't accept IPv4-mapped variants (`::ffff:192.0.2.1`) — common
42
+ # IPv6 traffic in URLs doesn't use them.
43
+ IPV6_RE = /\A(?:[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){7}|(?=[0-9a-fA-F:]*::)[0-9a-fA-F:]{2,})\z/.freeze
44
+ # URL-as-value: a scheme prefix followed by something non-empty.
45
+ # Used for query params like ?redirect=https://foo.com/bar.
46
+ URL_RE = %r{\A[a-zA-Z][a-zA-Z0-9+.\-]*://\S+\z}.freeze
47
+ # Scheme-less URL — `foo.com/path`, `sub.foo.com/`, etc. Requires a
48
+ # dotted host with a TLD-like suffix (≥2 letters) followed by a slash
49
+ # to disambiguate from filenames like `image.png` or version strings
50
+ # like `1.2.3`.
51
+ SCHEMELESS_URL_RE = %r{\A[a-zA-Z0-9\-]+(?:\.[a-zA-Z0-9\-]+)*\.[a-zA-Z]{2,}/\S*\z}.freeze
52
+ # Simplified email — local@host.tld, no leading/trailing dots in either
53
+ # part. Not RFC 5322 compliant; covers the common shape.
54
+ EMAIL_RE = /\A[A-Za-z0-9._%+\-]+@[A-Za-z0-9](?:[A-Za-z0-9\-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9\-]*[A-Za-z0-9])?)+\z/.freeze
55
+
56
+ # Boolean literal — case-insensitive. `0`/`1` look like integers from a
57
+ # single value alone; the corpus's :enum detection picks them up when
58
+ # they appear as a bounded value set on a param.
59
+ BOOLEAN_RE = /\A(?:true|false)\z/i.freeze
60
+ # SemVer-ish version tag with explicit `v` prefix. Without the prefix
61
+ # `1.2.3` looks like a float / opaque blob; the `v` keeps it
62
+ # unambiguous from a single value.
63
+ VERSION_RE = /\Av\d+(?:\.\d+)*(?:[-+][A-Za-z0-9.\-]+)?\z/.freeze
64
+ # BCP 47-ish locale: 2-3 letter language + separator + 2-4 char region
65
+ # or script. Real-world subtags: ISO 3166-1 region (`US`, `CA`, 2 letters
66
+ # / 3 digits), ISO 15924 script (`Hans`, 4 letters). The bare 2/3-letter
67
+ # case is handled via LOCALE_LANGUAGE_CODES below so we don't
68
+ # over-classify random short words. A trailing helper (classify_locale_pair)
69
+ # also confirms the language portion is in the allowlist — otherwise
70
+ # things like `by-locale` would wrongly promote to :locale.
71
+ LOCALE_RE = /\A([a-z]{2,3})[-_]([A-Za-z0-9]{2,4})\z/.freeze
72
+ # Inline ISO 639-1 (subset) — the language codes we'll accept as a
73
+ # standalone locale segment. Bare `en` / `fr` / `ja` etc. classify as
74
+ # :locale; tokens not in the list (like the 2-letter literal `to` or
75
+ # `if`) stay as :literal. Curated for the languages that show up in
76
+ # real `?lang=` traffic; expandable as needed.
77
+ LOCALE_LANGUAGE_CODES = %w[
78
+ ar bg bn ca cs da de el en es et fa fi fr gu he hi hr hu id it
79
+ ja ka kk km kn ko lt lv mk ml mr ms my nb nl no pa pl pt ro ru
80
+ sk sl sr sv sw ta te th tl tr uk ur vi zh
81
+ ].to_set.freeze
82
+ # 2 letters only — 3-letter slot is handled by CURRENCY_RE (ISO 4217
83
+ # codes are 3 chars; ISO 639-2 language codes are too, but we don't
84
+ # ship that list and would shadow currencies for ambiguous strings).
85
+ LOCALE_BARE_RE = /\A[a-z]{2}\z/.freeze
86
+ # ISO 4217 currency codes — inline allowlist of the ~30 most-used
87
+ # codes covers the long tail of real traffic. Three-letter all-caps
88
+ # strings (`FAQ`, `FOO`) would otherwise leak into the literal type
89
+ # if we relied on shape alone.
90
+ CURRENCY_CODES = %w[
91
+ USD EUR GBP JPY CNY CHF CAD AUD NZD HKD SGD
92
+ INR KRW MXN BRL ZAR SEK NOK DKK PLN CZK HUF
93
+ RUB TRY ILS AED SAR THB IDR PHP VND TWD MYR
94
+ NGN EGP
95
+ ].to_set.freeze
96
+ CURRENCY_RE = /\A[A-Za-z]{3}\z/.freeze
97
+ # E.164 phone number — leading `+` then 1-3 digit country code, then up
98
+ # to 14 more digits. Allows separators (space, dash, dot, parens) but
99
+ # they don't count toward digit length. A standalone `+15551234567` and
100
+ # `+1 (555) 123-4567` both classify; bare digit blobs without `+`
101
+ # stay as :integer / :opaque_id (too ambiguous from a single value).
102
+ PHONE_RE = %r{\A\+(?:[ \-.()\d]){7,20}\z}.freeze
103
+ # NANP phone without `+` — `555-666-7777`, `555.666.7777`, `(555) 666-7777`.
104
+ # The area-code + exchange leading-digit constraint (first digit 2-9 in
105
+ # both) is what makes this safe to add without shadowing :integer —
106
+ # bare digit blobs / dotted numerics fall through. Only matches the
107
+ # 10-digit NANP shape; international formats need the explicit `+`.
108
+ PHONE_NANP_RE = /\A\(?([2-9]\d{2})\)?[ \-.]?([2-9]\d{2})[ \-.]?(\d{4})\z/.freeze
109
+ # JWT: three base64url-encoded segments separated by dots, header
110
+ # starts with `eyJ` (the `{` JSON prefix base64url-encoded).
111
+ JWT_RE = /\Aey[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\z/.freeze
112
+ # MIME / media type — RFC 2046 top-level types plus a subtype. The
113
+ # subtype side is permissive (letters/digits/+-.) so `application/vnd.api+json`
114
+ # and `image/svg+xml` both match.
115
+ MIME_RE = %r{\A(?:text|image|video|audio|application|multipart|message|font|model)/[A-Za-z0-9!#$&^_+\-.]+\z}.freeze
116
+ # File — `name.ext` shape where ext is in FILE_EXTENSIONS. The stem
117
+ # can be a slug, opaque-id, or literal; the meaningful signal is the
118
+ # extension. Per-extension grouping (image / document / data / etc.)
119
+ # surfaces via SegmentClassifier.file_kind for verbose displays.
120
+ FILE_RE = /\A([A-Za-z0-9][A-Za-z0-9_\-.~]*)\.([A-Za-z0-9]{1,8})\z/.freeze
121
+ # Allowlist of common file extensions, keyed by kind. The kind is
122
+ # surfaced via file_kind for verbose output; the type itself is just
123
+ # `:file`. Keep this list curated — random 1-8 char endings can shadow
124
+ # legitimate semantic types (`fr_CA.us`, `1.2.3`).
125
+ FILE_EXTENSIONS = {
126
+ image: %w[png jpg jpeg gif webp svg bmp tiff tif ico avif heic heif],
127
+ document: %w[pdf doc docx xls xlsx ppt pptx odt ods odp rtf epub],
128
+ data: %w[csv tsv json xml yaml yml parquet sqlite db ndjson jsonl],
129
+ text: %w[txt md log markdown rst],
130
+ web: %w[html htm css js mjs cjs ts jsx tsx map],
131
+ font: %w[woff woff2 ttf otf eot],
132
+ audio: %w[mp3 wav ogg flac aac m4a opus],
133
+ video: %w[mp4 mov avi mkv webm flv wmv m4v m3u8],
134
+ archive: %w[zip tar gz bz2 7z rar xz tgz],
135
+ code: %w[rb py go java c cc cpp h hpp sh swift kt rs],
136
+ }.freeze
137
+ # Reverse map ext → kind for O(1) lookup. Lowercase keys; classify
138
+ # downcases before consulting.
139
+ FILE_EXTENSION_KIND = FILE_EXTENSIONS.each_with_object({}) { |(kind, exts), h|
140
+ exts.each { |e| h[e] = kind }
141
+ }.freeze
142
+
143
+ # Hex color — `#fff`, `#ffffff`, `#ffffff80` (with alpha). 3/4/6/8
144
+ # hex chars after the leading `#`. Other color formats (named, rgb(),
145
+ # hsl()) aren't recognized yet; this is the only one common in URL
146
+ # path/query positions.
147
+ COLOR_HEX_RE = /\A#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\z/.freeze
148
+ # Coordinate pair — `lat,lng`, both signed decimals. The extractor's
149
+ # comma boundary means this only survives when present at classify
150
+ # time (e.g. query values fed in already-parsed). Each component
151
+ # validated for plausible lat/lng range in classify_coordinate.
152
+ COORDINATE_RE = /\A(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\z/.freeze
153
+ # ISO 3166-1 alpha-2 — 2 letters, validated against the inline
154
+ # allowlist below (so random 2-letter uppercase tokens like `OK` or
155
+ # `NO` don't unconditionally promote). Lowercase tokens are routed
156
+ # through :locale by LOCALE_BARE_RE.
157
+ COUNTRY_RE = /\A[A-Z]{2}\z/.freeze
158
+ COUNTRY_CODES = %w[
159
+ AD AE AF AG AL AM AO AR AT AU AZ
160
+ BA BB BD BE BG BH BJ BM BN BO BR BS BT BW BY BZ
161
+ CA CD CG CH CI CL CM CN CO CR CU CY CZ
162
+ DE DJ DK DM DO DZ
163
+ EC EE EG ER ES ET
164
+ FI FJ FK FM FO FR
165
+ GA GB GE GH GI GL GM GN GR GT GU GW GY
166
+ HK HN HR HT HU
167
+ ID IE IL IM IN IQ IR IS IT
168
+ JM JO JP
169
+ KE KG KH KM KN KP KR KW KY KZ
170
+ LA LB LC LI LK LR LS LT LU LV LY
171
+ MA MC MD ME MG MK ML MM MN MO MR MT MU MV MW MX MY MZ
172
+ NA NE NG NI NL NO NP NR NU NZ
173
+ OM
174
+ PA PE PF PG PH PK PL PR PT PW PY
175
+ QA
176
+ RE RO RS RU RW
177
+ SA SB SC SD SE SG SI SK SL SM SN SO SR SS ST SV SY SZ
178
+ TD TG TH TJ TM TN TO TR TT TV TW TZ
179
+ UA UG US UY UZ
180
+ VA VC VE VG VI VN VU
181
+ WS
182
+ YE
183
+ ZA ZM ZW
184
+ ].to_set.freeze
185
+ # Standard base64 — at least 16 chars, made up of base64 alphabet,
186
+ # AND contains one of the disambiguating chars (`+`, `/`, trailing
187
+ # `=` padding) so we don't shadow plain alphanumeric :opaque_id
188
+ # blobs. URL-safe base64 (which uses `-`/`_`) overlaps too heavily
189
+ # with :slug to discriminate from shape alone.
190
+ BASE64_RE = %r{\A[A-Za-z0-9+/]{16,}={0,2}\z}.freeze
191
+
192
+ # HTTP status — bare 3-digit integer in the 100..599 window. Same
193
+ # corpus-promotion pattern as :year: a single 3-digit int is ambiguous,
194
+ # but a position whose values cluster inside the HTTP status window is
195
+ # almost certainly statuses. See Cluster#param_type for the promotion.
196
+ HTTP_STATUS_RANGE = 100..599
197
+ # Plausible year — 4-digit integer in the 1900..2100 window. Checked
198
+ # inside classify_integer so we don't shadow shorter / longer ints.
199
+ YEAR_RANGE = 1900..2100
22
200
 
23
201
  # Bounded memoization: classification of a given string is pure, so
24
202
  # repeat segments (e.g. /users in countless paths) can be cached. Cap
@@ -28,6 +206,11 @@ module Iriq
28
206
 
29
207
  def initialize
30
208
  @cache = {}
209
+ # The recognizer ensemble consulted at classify time. Starts with
210
+ # the built-in three (uuid, date, integer); Corpus#activate_proposal
211
+ # appends SynthesizedRecognizer instances at runtime so a corpus
212
+ # picks up its learned patterns without classifier surgery.
213
+ @recognizers = [Recognizers::UUID, Recognizers::DATE, Recognizers::INTEGER]
31
214
  end
32
215
 
33
216
  def classify(segment)
@@ -40,6 +223,22 @@ module Iriq
40
223
  @cache[segment] = compute_classification(segment)
41
224
  end
42
225
 
226
+ # Append a Recognizer to the ensemble. Called by Corpus#activate_proposal
227
+ # to promote a learned RecognizerProposal into a live Recognizer.
228
+ # Busts the classify cache so subsequent classify() calls see the
229
+ # new Recognizer.
230
+ def register_recognizer(recognizer)
231
+ @recognizers << recognizer
232
+ @cache.clear
233
+ recognizer
234
+ end
235
+
236
+ # Snapshot of the live ensemble. Useful for tests and tooling that
237
+ # want to inspect which Recognizers a corpus is consulting.
238
+ def recognizers
239
+ @recognizers.dup
240
+ end
241
+
43
242
  # Anything except :literal is considered variable for shape/explain.
44
243
  def variable?(type)
45
244
  type != :literal
@@ -48,25 +247,153 @@ module Iriq
48
247
  private
49
248
 
50
249
  def compute_classification(segment)
51
- case segment
52
- when UUID_RE then :uuid
53
- when DATE_RE then :date
54
- when ISO_TIME_RE then :timestamp
55
- when INTEGER_RE then classify_integer(segment)
56
- when HASH_RE then :hash
57
- when SLUG_RE then :slug
58
- when LITERAL_RE then :literal
59
- when OPAQUE_RE then :opaque_id
60
- else :literal
250
+ # Cheap composition checks short-circuit regex matches that can't
251
+ # possibly fire. Each `_RE` test below is preceded by a `String#include?`
252
+ # / `start_with?` / `size` guard so a literal like "users" walks
253
+ # past 20-odd `_RE`s in O(len) instead of O(len * n_regexes).
254
+ size = segment.size
255
+ first = segment.getbyte(0)
256
+ digit0 = first && first >= 0x30 && first <= 0x39
257
+ has_dash = segment.include?("-")
258
+ has_dot = segment.include?(".")
259
+ has_colon = segment.include?(":")
260
+ has_slash = segment.include?("/")
261
+ has_at = segment.include?("@")
262
+ has_sep = has_dash || segment.include?("_")
263
+ has_comma = segment.include?(",")
264
+
265
+ # Scored ensemble over the live Recognizer list — built-ins +
266
+ # anything Corpus#activate_proposal has registered for this
267
+ # classifier instance.
268
+ if (v = Recognizer.ensemble(segment, @recognizers))
269
+ return v[:type]
270
+ end
271
+
272
+ # Network / structured-value types take precedence over the generic
273
+ # OPAQUE_RE catch-all (which would otherwise grab IPv4) and the
274
+ # LITERAL fallback (which today swallows email + URL + IPv6).
275
+ return :jwt if segment.start_with?("ey") && segment.count(".") == 2 && JWT_RE.match?(segment)
276
+ return classify_color(segment) if first == 0x23 && COLOR_HEX_RE.match?(segment) # '#'
277
+ return :url if has_colon && segment.include?("://") && URL_RE.match?(segment)
278
+ return :email if has_at && EMAIL_RE.match?(segment)
279
+ return :mime if has_slash && MIME_RE.match?(segment)
280
+ return :url if has_dot && has_slash && SCHEMELESS_URL_RE.match?(segment)
281
+ return classify_ipv4(segment) if digit0 && has_dot && IPV4_RE.match?(segment)
282
+ return :ipv6 if has_colon && IPV6_RE.match?(segment)
283
+ return classify_coordinate(segment) if has_comma && COORDINATE_RE.match?(segment)
284
+ return :hash if size >= 32 && HASH_RE.match?(segment)
285
+ return :version if first == 0x76 && VERSION_RE.match?(segment) # 'v'
286
+ return :boolean if (size >= 4 && size <= 5) && BOOLEAN_RE.match?(segment)
287
+ return classify_locale_pair(segment) if has_sep && LOCALE_RE.match?(segment)
288
+ return classify_locale(segment) if size == 2 && LOCALE_BARE_RE.match?(segment)
289
+ return :timestamp if has_colon && ISO_TIME_RE.match?(segment)
290
+ return classify_phone(segment) if first == 0x2B && PHONE_RE.match?(segment) # '+'
291
+ return :phone if (has_dash || has_dot || segment.include?("(")) && PHONE_NANP_RE.match?(segment)
292
+ return :float if has_dot && FLOAT_RE.match?(segment)
293
+ return classify_currency(segment) if size == 3 && CURRENCY_RE.match?(segment)
294
+ return classify_country(segment) if size == 2 && COUNTRY_RE.match?(segment)
295
+ return :base64 if size >= 16 && (segment.include?("=") || segment.include?("+") || segment.include?("/")) && BASE64_RE.match?(segment)
296
+ return classify_file(segment) if has_dot && FILE_RE.match?(segment)
297
+ return :slug if has_sep && SLUG_RE.match?(segment)
298
+ return :literal if LITERAL_RE.match?(segment)
299
+ return :opaque_id if OPAQUE_RE.match?(segment)
300
+
301
+ :literal
302
+ end
303
+
304
+ # IPV4_RE only checks shape (1-3 digits between dots). Validate each
305
+ # octet ≤ 255; on failure fall back to :opaque_id so we don't promote
306
+ # garbage like `999.999.999.999` to :ipv4.
307
+ def classify_ipv4(segment)
308
+ return :opaque_id unless segment.split(".").all? { |o| (0..255).cover?(o.to_i) }
309
+
310
+ :ipv4
311
+ end
312
+
313
+ # Validate E.164-shaped phone: count digits (ignoring separators) and
314
+ # ensure between 7 and 15 inclusive. The shape regex permits a wide
315
+ # range — the digit count is the meaningful guardrail.
316
+ def classify_phone(segment)
317
+ digits = segment.count("0-9")
318
+ return :phone if digits.between?(7, 15)
319
+
320
+ :opaque_id
321
+ end
322
+
323
+ # Color — only the hex form is recognized for now. Returns :color
324
+ # when the value matches COLOR_HEX_RE. Future extensions (named
325
+ # colors, rgb(), hsl()) can plug in via classify_color without
326
+ # rearranging compute_classification.
327
+ def classify_color(segment)
328
+ return :color if COLOR_HEX_RE.match?(segment)
329
+
330
+ :opaque_id
331
+ end
332
+
333
+ # Coordinate pair — both numbers must land in plausible lat/lng
334
+ # bounds: latitude ±90, longitude ±180. We accept either ordering
335
+ # (lat,lng OR lng,lat) by checking both. Pairs outside the range
336
+ # fall back to :opaque_id so generic CSV-shaped values aren't
337
+ # promoted.
338
+ def classify_coordinate(segment)
339
+ m = segment.match(COORDINATE_RE) or return :opaque_id
340
+ a = m[1].to_f
341
+ b = m[2].to_f
342
+ if (a.between?(-90, 90) && b.between?(-180, 180)) ||
343
+ (a.between?(-180, 180) && b.between?(-90, 90))
344
+ return :coordinate
61
345
  end
346
+
347
+ :opaque_id
348
+ end
349
+
350
+ # Country — promote to :country only when the 2-letter token is in
351
+ # the ISO 3166-1 alpha-2 allowlist. Otherwise fall through to
352
+ # :literal (matches LITERAL_RE).
353
+ def classify_country(segment)
354
+ return :country if COUNTRY_CODES.include?(segment)
355
+
356
+ :literal
357
+ end
358
+
359
+ # File classification — only promote when the trailing extension is
360
+ # in the allowlist. Otherwise fall through to the slug/literal/opaque
361
+ # rules so `1.2.3` (version) and `fr_CA.us` (locale-shaped opaque) don't
362
+ # get pulled in by the FILE_RE shape.
363
+ def classify_file(segment)
364
+ ext = segment[/\.([A-Za-z0-9]{1,8})\z/, 1]&.downcase
365
+ return :file if ext && FILE_EXTENSION_KIND.key?(ext)
366
+ return :slug if segment.match?(SLUG_RE)
367
+
368
+ :opaque_id
369
+ end
370
+
371
+ # Three-letter shape — only call it :currency if it's actually in the
372
+ # ISO 4217 allowlist (case-insensitive). Otherwise fall through to the
373
+ # literal/opaque rules.
374
+ def classify_currency(segment)
375
+ return :currency if CURRENCY_CODES.include?(segment.upcase)
376
+ return :literal if segment.match?(LITERAL_RE)
377
+
378
+ :opaque_id
379
+ end
380
+
381
+ # Bare 2- or 3-letter lowercase token — only :locale when it's a known
382
+ # ISO 639-1 code. Otherwise it's a regular literal (`if`, `to`, `of`).
383
+ def classify_locale(segment)
384
+ return :locale if LOCALE_LANGUAGE_CODES.include?(segment)
385
+
386
+ :literal
62
387
  end
63
388
 
64
- def classify_integer(segment)
65
- n = segment.to_i
66
- return :timestamp if TS_MILLIS_RANGE.cover?(n)
67
- return :timestamp if TS_SECONDS_RANGE.cover?(n)
389
+ # Dashed/underscored locale form (`en-US`, `zh-Hans`). Only promote to
390
+ # :locale when the language portion is in the ISO 639-1 allowlist —
391
+ # otherwise tokens like `by-locale` would slip through.
392
+ def classify_locale_pair(segment)
393
+ lang = segment[/\A[a-z]{2,3}/]
394
+ return :locale if LOCALE_LANGUAGE_CODES.include?(lang)
68
395
 
69
- :integer_id
396
+ segment.match?(SLUG_RE) ? :slug : :literal
70
397
  end
71
398
 
72
399
  public
@@ -74,5 +401,133 @@ module Iriq
74
401
  # Shared singleton — preferred default for callers that don't bring
75
402
  # their own classifier (saves a per-call allocation).
76
403
  DEFAULT = new
404
+
405
+ # Display name for a type in `--normalize` placeholders. Collapses
406
+ # `:ipv4` and `:ipv6` to `:ip` (callers that want the specific family
407
+ # read it off the classifier directly or via cluster stats).
408
+ def self.display_type(type)
409
+ return :ip if type == :ipv4 || type == :ipv6
410
+
411
+ type
412
+ end
413
+
414
+ # Return the kind (`:image`/`:document`/`:data`/...) for a file-shaped
415
+ # value, or nil if the value isn't a recognized file. Used by verbose
416
+ # displays to subdivide `:file` without polluting the top-level type
417
+ # taxonomy.
418
+ def self.file_kind(value)
419
+ return nil if value.nil?
420
+ ext = value[/\.([A-Za-z0-9]{1,8})\z/, 1]&.downcase
421
+ ext && FILE_EXTENSION_KIND[ext]
422
+ end
423
+
424
+ # Return the kind (`:hex` for now — placeholder for future named /
425
+ # rgb / hsl support) of a color-shaped value, or nil if the value
426
+ # isn't a recognized color. Used by verbose displays alongside the
427
+ # `:color` type itself.
428
+ def self.color_kind(value)
429
+ return nil if value.nil?
430
+ return :hex if COLOR_HEX_RE.match?(value)
431
+
432
+ nil
433
+ end
434
+
435
+ # Param-name hints — when a value's classifier output is too generic
436
+ # (`:literal`, `:opaque_id`, `:slug`) to be informative, the param name
437
+ # can supply the type. `?phone=unknown` becomes `:phone` even though
438
+ # `unknown` is a literal. Only "safe" string-shaped types are in the
439
+ # map; numeric types (`:integer`, `:year`, `:http_status`) are handled
440
+ # by range analysis instead.
441
+ PARAM_NAME_HINTS = {
442
+ "phone" => :phone,
443
+ "tel" => :phone,
444
+ "telephone" => :phone,
445
+ "mobile" => :phone,
446
+ "cell" => :phone,
447
+ "email" => :email,
448
+ "e_mail" => :email,
449
+ "mail" => :email,
450
+ "locale" => :locale,
451
+ "lang" => :locale,
452
+ "language" => :locale,
453
+ "currency" => :currency,
454
+ "cur" => :currency,
455
+ "curr" => :currency,
456
+ "url" => :url,
457
+ "uri" => :url,
458
+ "redirect" => :url,
459
+ "redirect_url" => :url,
460
+ "return_to" => :url,
461
+ "return_url" => :url,
462
+ "callback" => :url,
463
+ "callback_url" => :url,
464
+ "next_url" => :url,
465
+ "jwt" => :jwt,
466
+ "bearer" => :jwt,
467
+ "auth_token" => :jwt,
468
+ "mime" => :mime,
469
+ "content_type" => :mime,
470
+ "media_type" => :mime,
471
+ "color" => :color,
472
+ "colour" => :color,
473
+ "bg" => :color,
474
+ "background" => :color,
475
+ "fg" => :color,
476
+ "foreground" => :color,
477
+ "coords" => :coordinate,
478
+ "coordinates" => :coordinate,
479
+ "geo" => :coordinate,
480
+ "location" => :coordinate,
481
+ "position" => :coordinate,
482
+ "latlng" => :coordinate,
483
+ "latlon" => :coordinate,
484
+ "country" => :country,
485
+ "country_code" => :country,
486
+ "nation" => :country,
487
+ }.freeze
488
+ # Types the param-name hint is allowed to override. Anything more
489
+ # specific (`:integer`, `:uuid`, etc.) already carries useful info —
490
+ # the classifier wins.
491
+ PARAM_HINT_OVERRIDABLE = %i[literal opaque_id slug].to_set.freeze
492
+
493
+ # Return a hinted type for a param name when the resolved value type
494
+ # is generic. Nil when no hint applies. Both Cluster#param_type (for
495
+ # the corpus path) and Normalizer.shape_query (for one-shot rendering)
496
+ # consult this so corpus + one-shot agree on the override.
497
+ def self.param_name_hint(name, current_type)
498
+ return nil if name.nil? || !PARAM_HINT_OVERRIDABLE.include?(current_type)
499
+
500
+ PARAM_NAME_HINTS[name.to_s.downcase]
501
+ end
502
+
503
+ # Canonicalize a currency code to uppercase ISO 4217. Returns nil if
504
+ # the value isn't a known code. Used by --normalize so /pricing/usd and
505
+ # /pricing/USD both render as /pricing/USD.
506
+ def self.canonical_currency(value)
507
+ return nil if value.nil?
508
+ up = value.upcase
509
+ CURRENCY_CODES.include?(up) ? up : nil
510
+ end
511
+
512
+ # Canonicalize a recognized date string to ISO 8601 (YYYY-MM-DD). Returns
513
+ # nil if the value isn't one of our accepted date forms. Used by --normalize
514
+ # so /events/2024/01/15 and /events/20240115 both render as
515
+ # /events/2024-01-15 in the output.
516
+ def self.canonical_date(value)
517
+ return nil if value.nil?
518
+ return nil unless value.is_a?(String)
519
+
520
+ canon = Recognizers::Date.canonical(value)
521
+ return canon if canon
522
+
523
+ # Compact YYYYMMDD lives on the Integer recognizer for classification,
524
+ # but the canonical form is part of the same date family.
525
+ if Recognizers::Integer::COMPACT_DATE_PATTERN.match?(value)
526
+ y, m, d = value[0, 4], value[4, 2], value[6, 2]
527
+ return "#{y}-#{m}-#{d}" if Recognizers::Date.plausible?(y, m, d)
528
+ end
529
+
530
+ nil
531
+ end
77
532
  end
78
533
  end
@@ -1,8 +1,16 @@
1
+ require "set"
2
+
1
3
  module Iriq
2
4
  # Walks a segment list and annotates each entry with the type, whether it's
3
5
  # variable, and a RESTful "hint" (e.g. `user_id`) when a variable segment
4
6
  # follows a literal one — `/users/123` ⇒ hint `user_id`.
5
7
  module SegmentHints
8
+ # Only ID-shaped types get the noun-singularize hint. Semantic types
9
+ # (version, locale, currency, date, etc.) are more informative as
10
+ # `{type}` than as `{noun}_id` — `/api/v1/...` should render
11
+ # `{version}`, not `{api_id}`.
12
+ HINT_ELIGIBLE_TYPES = %i[integer uuid hash opaque_id slug].to_set.freeze
13
+
6
14
  module_function
7
15
 
8
16
  def derive(segments, classifier)
@@ -20,6 +28,7 @@ module Iriq
20
28
 
21
29
  def hint_for(segments, i, type, variable, classifier)
22
30
  return nil unless variable && i > 0
31
+ return nil unless HINT_ELIGIBLE_TYPES.include?(type)
23
32
 
24
33
  prev = segments[i - 1]
25
34
  return nil unless classifier.classify(prev) == :literal
data/lib/iriq/shape.rb ADDED
@@ -0,0 +1,106 @@
1
+ module Iriq
2
+ # Structured route shape: an ordered list of typed segment entries plus
3
+ # rendering methods that produce the various string forms (placeholder,
4
+ # canonical-dates, raw-types, etc.).
5
+ #
6
+ # Replaces the string-as-data convention where PathShape's String output
7
+ # was the only carrier of shape information. Structured Shape makes:
8
+ # - downstream consumers cheap (they iterate entries instead of
9
+ # re-deriving from segments + classifier)
10
+ # - shape identity explicit (structural #== / #hash, not string match)
11
+ # - multiple renderings free (canonical dates, hints on/off, raw types
12
+ # vs hinted) without re-walking segments
13
+ #
14
+ # The cluster identity layer still uses string keys for storage; a
15
+ # follow-up step migrates Cluster equality to be Shape-driven.
16
+ class Shape
17
+ attr_reader :entries
18
+
19
+ # Build a Shape from raw path segments using the given classifier.
20
+ def self.from_segments(segments, classifier: SegmentClassifier::DEFAULT)
21
+ new(entries: SegmentHints.derive(segments || [], classifier))
22
+ end
23
+
24
+ # Build a Shape from already-derived SegmentHints entries — same input
25
+ # PathShape.from_entries used to take. Useful when the caller already
26
+ # walked segments once and wants to avoid a second pass.
27
+ def self.from_entries(entries)
28
+ new(entries: entries || [])
29
+ end
30
+
31
+ def initialize(entries:)
32
+ @entries = entries
33
+ end
34
+
35
+ def empty?
36
+ @entries.empty?
37
+ end
38
+
39
+ # Render to the placeholder form — "/users/{user_id}" etc. This is the
40
+ # default string representation.
41
+ def render(hints: true, canonical_dates: false, canonical_currencies: false)
42
+ return "/" if empty?
43
+
44
+ "/" + @entries.map { |e|
45
+ render_entry(e, hints: hints, canonical_dates: canonical_dates,
46
+ canonical_currencies: canonical_currencies)
47
+ }.join("/")
48
+ end
49
+
50
+ def to_s
51
+ render
52
+ end
53
+ alias inspect to_s
54
+
55
+ # Structural equality: two Shapes are equal when they render the same
56
+ # placeholder form. /users/1 and /users/999 are the same shape even
57
+ # though raw values differ, but /users/1 and /posts/1 are not.
58
+ def ==(other)
59
+ other.is_a?(Shape) && other.render == render
60
+ end
61
+ alias eql? ==
62
+
63
+ def hash
64
+ render.hash
65
+ end
66
+
67
+ def to_dump
68
+ { "entries" => @entries.map { |e| e.transform_keys(&:to_s) } }
69
+ end
70
+
71
+ def self.from_dump(h)
72
+ entries = (h["entries"] || []).map do |e|
73
+ e.each_with_object({}) do |(k, v), acc|
74
+ key = k.to_sym
75
+ # Only :type is symbolized — :value and :hint stay as strings,
76
+ # matching what SegmentHints.derive produces.
77
+ acc[key] = key == :type ? v.to_sym : v
78
+ end
79
+ end
80
+ new(entries: entries)
81
+ end
82
+
83
+ private
84
+
85
+ def render_entry(entry, hints:, canonical_dates:, canonical_currencies:)
86
+ return entry[:value] unless entry[:variable]
87
+
88
+ if canonical_dates && entry[:type] == :date &&
89
+ (canon = SegmentClassifier.canonical_date(entry[:value]))
90
+ return canon
91
+ end
92
+
93
+ if canonical_currencies && entry[:type] == :currency &&
94
+ (canon = SegmentClassifier.canonical_currency(entry[:value]))
95
+ return canon
96
+ end
97
+
98
+ placeholder = if hints
99
+ entry[:hint] || SegmentClassifier.display_type(entry[:type])
100
+ else
101
+ SegmentClassifier.display_type(entry[:type])
102
+ end
103
+ "{#{placeholder}}"
104
+ end
105
+ end
106
+ end