mustermann 4.0.0.alpha4 → 4.0.0.beta1

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: c45d32932b0745c20363ab0fb0084eb861754482e3c47ed68684cf245f10100f
4
- data.tar.gz: edb3fbe54217319a9a059b2a57c74b4e8f7d84cc4721fdb9a78bbf5ed6afa113
3
+ metadata.gz: a382e55dcc3919ca87dfa480d405b5fc126435c8081ce37e270a63e5907e7517
4
+ data.tar.gz: 4ec389e09c7a625fc83acf00fa9ff98a08e05cb58c1fb5c43772b14605a92e02
5
5
  SHA512:
6
- metadata.gz: 39c217af2266a0dc8f647dafd1c5772cf10e6f921a9d8ccabdcd29357a1a3dc9991065221f12eacc473af1ca183ac8bbd14dc8eae2d83834b21c81ac55e8ffc4
7
- data.tar.gz: f9a2c84244115819b62737f222b3c181ca5c5192e83243387473b3bf07666be8e019a78e591861e55a93579c28ab9e0f63725feddc0b6a61ff01f6f736c29735
6
+ metadata.gz: 7c8595c1df98b37d768691d0e1b591edd5a7b219aeff7569ee6e7e925b29ef2065125e7845d487e4b3aab68e2b7c06ffc7d696c74433a82b0d6012dd80a1d88e
7
+ data.tar.gz: 8f4bf30d82cd54534a5f4e31c1843868e1a0fe0112be8656167321b6554baac4d8455e947146c3ec5f9340f8af20ca8edd79499630a571b2a39851ddaef55f38
data/README.md CHANGED
@@ -595,6 +595,99 @@ Mustermann.new('/:id.:ext', capture: { id: /\d+/, ext: ['png', 'jpg'] })
595
595
 
596
596
  Available POSIX character classes are: `:alnum`, `:alpha`, `:blank`, `:cntrl`, `:digit`, `:graph`, `:lower`, `:print`, `:punct`, `:space`, `:upper`, `:xdigit`, `:word` and `:ascii`.
597
597
 
598
+ #### Typed Captures
599
+
600
+ Certain Ruby classes and named symbols can be passed as a capture value. They constrain what the capture matches **and** automatically convert the captured string in `params` to the appropriate type.
601
+
602
+ ``` ruby
603
+ require 'mustermann'
604
+ require 'date'
605
+
606
+ # Integer: only matches integers, converts to Integer in params
607
+ pattern = Mustermann.new('/:id', capture: Integer)
608
+ pattern.match('/42') # matches
609
+ pattern.match('/foo') # does not match
610
+ pattern.params('/42') # => { "id" => 42 }
611
+
612
+ # Float: matches integers and decimals, converts to Float in params
613
+ pattern = Mustermann.new('/:price', capture: Float)
614
+ pattern.params('/3.14') # => { "price" => 3.14 }
615
+ pattern.params('/5') # => { "price" => 5.0 }
616
+
617
+ # Symbol: only matches word characters (\w+), converts to Symbol in params
618
+ pattern = Mustermann.new('/:format', capture: Symbol)
619
+ pattern.params('/json') # => { "format" => :json }
620
+ pattern.match('/with-hyphen') # does not match
621
+
622
+ # Date: only matches YYYY-MM-DD dates, converts to Date in params
623
+ pattern = Mustermann.new('/:date', capture: Date)
624
+ pattern.params('/2026-04-23') # => { "date" => #<Date: 2026-04-23> }
625
+ pattern.match('/04-23-2026') # does not match
626
+
627
+ # Gem::Version: matches version strings, converts to Gem::Version in params
628
+ require 'rubygems/version'
629
+ pattern = Mustermann.new('/:version', capture: Gem::Version)
630
+ pattern.params('/1.2.3') # => { "version" => #<Gem::Version "1.2.3"> }
631
+ ```
632
+
633
+ Lowercase symbol aliases are also available: `:integer`, `:float`, `:symbol`, `:date`, `:version`. They behave identically to their class counterparts:
634
+
635
+ ``` ruby
636
+ pattern = Mustermann.new('/:id', capture: :integer)
637
+ pattern.params('/42') # => { "id" => 42 }
638
+ ```
639
+
640
+ These can be mixed with other capture types in a hash:
641
+
642
+ ``` ruby
643
+ pattern = Mustermann.new('/:id(.:format)?', capture: { id: Integer, format: :slug })
644
+ pattern.params('/42') # => { "id" => 42, "format" => nil }
645
+ pattern.params('/42.json') # => { "id" => 42, "format" => "json" }
646
+ ```
647
+
648
+ Like all other capture types, these can also be used in an array:
649
+
650
+ ``` ruby
651
+ pattern = Mustermann.new('/score/:score', capture: [Integer, Float])
652
+ pattern.params('/42') # => { "score" => 42 }
653
+ pattern.params('/3.14') # => { "score" => 3.14 }
654
+ ```
655
+
656
+ #### Other Symbols
657
+
658
+ The following symbols constrain the capture with a regex but do **not** perform any type conversion — `params` still returns a string:
659
+
660
+ | Symbol | Matches |
661
+ |-----------|---------|
662
+ | `:locale` | BCP 47 language tags (`en`, `en-US`, `zh-Hans-CN`) |
663
+ | `:slug` | Lowercase URL slugs (`hello-world`, `foo-bar-baz`) |
664
+ | `:uuid` | UUIDs (`f47ac10b-58cc-4372-a567-0e02b2c3d479`, case-insensitive) |
665
+
666
+ ``` ruby
667
+ Mustermann.new('/:lang', capture: :locale).match('/zh-Hans-CN') # matches
668
+ Mustermann.new('/:slug', capture: :slug).match('/Hello') # does not match
669
+ Mustermann.new('/:id', capture: :uuid).match('/not-a-uuid') # does not match
670
+ ```
671
+
672
+ Again, these can be mixed with other capture types in a hash or array:
673
+
674
+ ```ruby
675
+ set = Mustermann::Set.new(capture: { id: [Integer, :uuid], locale: :locale })
676
+
677
+ set.add("(/:locale)?/:id", :show)
678
+ set.add("/(:locale)?", :index)
679
+
680
+ # without capture constraints, this would match the :show pattern instead
681
+ match = set.match('/en')
682
+ match.value # => :index
683
+
684
+ match = set.match('/f47ac10b-58cc-4372-a567-0e02b2c3d479')
685
+ match.value # => :show
686
+
687
+ match = set.match('/en/12')
688
+ match.value # => :show
689
+ ```
690
+
598
691
  <a name="-available-options--except"></a>
599
692
  ### `except`
600
693
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'mustermann/ast/translator'
4
+ require 'mustermann/ast/converters'
4
5
 
5
6
  module Mustermann
6
7
  # @see Mustermann::AST::Pattern
@@ -99,46 +100,48 @@ module Mustermann
99
100
  when Hash then from_hash(capture, **options)
100
101
  when String then from_string(capture, **options)
101
102
  when nil then from_nil(**options)
102
- else capture
103
+ when Regexp then capture
104
+ when Class then from_class(capture, **options)
105
+ else raise CompileError, "invalid capture constraint %p for %p" % [capture, name]
103
106
  end
104
107
  end
105
108
 
106
109
  private
107
110
 
108
- def qualified(string, greedy: true,
109
- **options) "#{string}#{qualifier || "+#{'?' unless greedy}"}"
111
+ def qualified(string, greedy: true, **options)
112
+ "#{string}#{qualifier || "+#{'?' unless greedy}"}"
110
113
  end
111
114
 
112
- def with_lookahead(string, lookahead: nil,
113
- **options) lookahead ? "(?:(?!#{lookahead})#{string})" : string
115
+ def with_lookahead(string, lookahead: nil, **options)
116
+ lookahead ? "(?:(?!#{lookahead})#{string})" : string
114
117
  end
115
118
 
116
- def from_hash(hash,
117
- **options) pattern(capture: hash[name.to_sym],
118
- **options)
119
+ def from_hash(hash, **options)
120
+ pattern(capture: hash[name.to_sym], **options)
119
121
  end
120
122
 
121
123
  def from_array(array, **options)
122
- Regexp.union(*array.map do |e|
123
- pattern(capture: e, **options)
124
- end)
124
+ Regexp.union(*array.map { |e| pattern(capture: e, **options) })
125
125
  end
126
126
 
127
- def from_symbol(symbol,
128
- **options) qualified(with_lookahead("[[:#{symbol}:]]", **options),
129
- **options)
127
+ def from_symbol(symbol, **options)
128
+ capture, _ = CONVERTERS[symbol]
129
+ return pattern(capture:, **options) if capture
130
+ qualified(with_lookahead("[[:#{symbol}:]]", **options), **options)
130
131
  end
131
132
 
132
133
  def from_string(string, **options)
133
- Regexp.new(string.chars.map do |c|
134
- t.encoded(c, **options)
135
- end.join)
134
+ Regexp.new(string.chars.map { |c| t.encoded(c, **options) }.join)
136
135
  end
137
136
 
138
137
  def from_nil(**options)
139
- qualified(
140
- with_lookahead(default(**options), **options), **options
141
- )
138
+ qualified(with_lookahead(default(**options), **options), **options)
139
+ end
140
+
141
+ def from_class(klass, **options)
142
+ capture, _ = CONVERTERS[klass.name]
143
+ raise CompileError, "no converter for class %p" % klass unless capture
144
+ pattern(capture:, **options)
142
145
  end
143
146
 
144
147
  def default(**options) = constraint || '[^/\\?#]'
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+ require "rubygems/version"
3
+ require "date"
4
+
5
+ module Mustermann
6
+ module AST
7
+ CONVERTERS = {
8
+ "Integer" => [ /-?\d+/, :to_i ],
9
+ "Symbol" => [ /\w+/, :to_sym ],
10
+ "String" => [ nil, :to_s ],
11
+ "Float" => [ /-?\d+(?:\.\d+)?/, :to_f ],
12
+
13
+ "Date" => [
14
+ /\d{4}-\d{2}-\d{2}/,
15
+ ->(string) { Date.parse(string) }
16
+ ],
17
+
18
+ "Gem::Version" => [
19
+ Regexp.new(Gem::Version::VERSION_PATTERN),
20
+ ->(string) { Gem::Version.new(string) }
21
+ ],
22
+
23
+ locale: [ /(?:[A-Za-z]{2,3}|i)(-[A-Za-z0-9]{1,8})*/ ],
24
+ slug: [ /[a-z0-9]+(?:-[a-z0-9]+)*/ ],
25
+ uuid: [ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i ],
26
+ }
27
+
28
+ CONVERTERS.merge!({
29
+ integer: CONVERTERS["Integer"],
30
+ symbol: CONVERTERS["Symbol"],
31
+ string: CONVERTERS["String"],
32
+ float: CONVERTERS["Float"],
33
+ date: CONVERTERS["Date"],
34
+ version: CONVERTERS["Gem::Version"],
35
+ })
36
+
37
+ CONVERTERS.freeze
38
+
39
+ private_constant :CONVERTERS
40
+ end
41
+ end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require 'mustermann/ast/converters'
2
3
  require 'mustermann/ast/translator'
3
4
 
4
5
  module Mustermann
@@ -8,14 +9,45 @@ module Mustermann
8
9
  # @see Mustermann::AST::Pattern#to_templates
9
10
  class ParamScanner < Translator
10
11
  # @!visibility private
11
- def self.scan_params(ast)
12
- new.translate(ast)
12
+ def self.scan_params(ast, options)
13
+ new.translate(ast, options)
13
14
  end
14
15
 
15
- translate(:node) { t(payload) }
16
- translate(Array) { map { |e| t(e) }.inject(:merge) }
17
- translate(Object) { {} }
18
- translate(:capture) { convert ? { name => convert } : {} }
16
+ translate(:node) { |o| t(payload, o) }
17
+ translate(:with_look_ahead) { |o| t(head, o).merge(t(payload, o)) }
18
+ translate(Array) { |o| map { |e| t(e, o) }.inject(:merge) }
19
+ translate(Object) { |o| {} }
20
+
21
+ class Capture < NodeTranslator
22
+ register :capture
23
+
24
+ def translate(options)
25
+ return { name => convert } if convert
26
+ _, converter = converter(options[:capture])
27
+ converter ? { name => converter } : {}
28
+ end
29
+
30
+ def converter(capture)
31
+ case capture
32
+ when Hash then return converter(capture[name.to_sym])
33
+ when Class then regexp, converter = CONVERTERS[capture.name]
34
+ when Symbol then regexp, converter = CONVERTERS[capture]
35
+ when Array
36
+ entries = capture.map { |item| converter(item) }.compact
37
+ regexp = Regexp.union(entries.map(&:first))
38
+
39
+ entries.map! { |r, c| [/\A#{r}\Z/, c] }
40
+
41
+ converter = ->(string) do
42
+ _, c = entries.find { |r, _| r.match?(string) }
43
+ c&.call(string) || string
44
+ end
45
+ end
46
+
47
+ return unless converter
48
+ [regexp, converter.to_proc]
49
+ end
50
+ end
19
51
  end
20
52
  end
21
53
  end
@@ -101,13 +101,13 @@ module Mustermann
101
101
  # Internal AST representation of pattern.
102
102
  # @!visibility private
103
103
  def to_ast
104
- ast = self.class.ast_cache.fetch(@string) do
104
+ ast = self.class.ast_cache.fetch([@string, options]) do
105
105
  ast = parse(@string, pattern: self)
106
106
  ast &&= transform(ast)
107
107
  ast &&= set_boundaries(ast, string: @string)
108
108
  validate(ast)
109
109
  end
110
- @param_converters ||= scan_params(ast) if ast
110
+ @param_converters ||= scan_params(ast, options) if ast
111
111
  ast
112
112
  end
113
113
 
@@ -143,7 +143,7 @@ module Mustermann
143
143
 
144
144
  # @!visibility private
145
145
  def param_converters
146
- @param_converters ||= scan_params(to_ast)
146
+ @param_converters ||= scan_params(to_ast, options)
147
147
  end
148
148
 
149
149
  # @api private
@@ -22,6 +22,13 @@ module Mustermann
22
22
  node
23
23
  end
24
24
 
25
+ # eliminate redundant optional nodes - this helps avoid regexp warnings
26
+ translate(:optional) do
27
+ return t(payload) if payload.is_a? Node[:optional]
28
+ node.payload = t(payload)
29
+ node
30
+ end
31
+
25
32
  # ignore unknown objects on the tree
26
33
  translate(Object) { node }
27
34
 
@@ -74,10 +74,13 @@ module Mustermann
74
74
  end
75
75
  end
76
76
 
77
+ # @return [Array<String>] the names of the named captures
78
+ def names = named_captures.keys
79
+
77
80
  # @overload [](key)
78
- # Access params by key.
81
+ # Access named captures by key.
79
82
  # @param key [String, Symbol] the key to access
80
- # @return the value of the param, or nil if not found
83
+ # @return the value of the named capture, or nil if not found
81
84
  #
82
85
  # @overload [](index)
83
86
  # Access captures by index.
@@ -96,8 +99,8 @@ module Mustermann
96
99
  # @return [Array] the values of the captures
97
100
  def [](key, length = nil)
98
101
  case key
99
- when String then params[key]
100
- when Symbol then params[key.to_s]
102
+ when String then named_captures[key]
103
+ when Symbol then named_captures[key.to_s]
101
104
  when Integer then length ? captures[key, length] : captures[key]
102
105
  when Range then captures[key]
103
106
  else raise ArgumentError, "key must be a String, Symbol, Integer, or Range, not #{key.class}"
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Mustermann
3
- VERSION ||= '4.0.0.alpha4'
3
+ VERSION ||= '4.0.0.beta1'
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mustermann
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0.alpha4
4
+ version: 4.0.0.beta1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Haase
@@ -29,6 +29,7 @@ files:
29
29
  - lib/mustermann.rb
30
30
  - lib/mustermann/ast/boundaries.rb
31
31
  - lib/mustermann/ast/compiler.rb
32
+ - lib/mustermann/ast/converters.rb
32
33
  - lib/mustermann/ast/expander.rb
33
34
  - lib/mustermann/ast/fast_pattern.rb
34
35
  - lib/mustermann/ast/node.rb