dialecto-rails 0.1.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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +196 -0
  4. data/action.yml +83 -0
  5. data/exe/dialecto-rails +6 -0
  6. data/lib/dialecto-rails.rb +1 -0
  7. data/lib/dialecto_rails/config.rb +206 -0
  8. data/lib/dialecto_rails/errors.rb +21 -0
  9. data/lib/dialecto_rails/in_context/args.rb +37 -0
  10. data/lib/dialecto_rails/in_context/assets/overlay.js +2784 -0
  11. data/lib/dialecto_rails/in_context/backend_hook.rb +39 -0
  12. data/lib/dialecto_rails/in_context/csp.rb +72 -0
  13. data/lib/dialecto_rails/in_context/git_context.rb +103 -0
  14. data/lib/dialecto_rails/in_context/key_index.rb +128 -0
  15. data/lib/dialecto_rails/in_context/launch_token.rb +78 -0
  16. data/lib/dialecto_rails/in_context/live_middleware.rb +307 -0
  17. data/lib/dialecto_rails/in_context/live_runtime.rb +53 -0
  18. data/lib/dialecto_rails/in_context/live_store.rb +14 -0
  19. data/lib/dialecto_rails/in_context/marker.rb +106 -0
  20. data/lib/dialecto_rails/in_context/marking.rb +122 -0
  21. data/lib/dialecto_rails/in_context/middleware.rb +202 -0
  22. data/lib/dialecto_rails/in_context/overrides.rb +61 -0
  23. data/lib/dialecto_rails/in_context/railtie_stub.rb +76 -0
  24. data/lib/dialecto_rails/in_context/runtime.rb +52 -0
  25. data/lib/dialecto_rails/in_context/sanitizer.rb +19 -0
  26. data/lib/dialecto_rails/in_context/store.rb +41 -0
  27. data/lib/dialecto_rails/scan/catalogs.rb +46 -0
  28. data/lib/dialecto_rails/scan/cli.rb +339 -0
  29. data/lib/dialecto_rails/scan/client.rb +117 -0
  30. data/lib/dialecto_rails/scan/envelope.rb +100 -0
  31. data/lib/dialecto_rails/scan/git.rb +68 -0
  32. data/lib/dialecto_rails/scan/worktree.rb +41 -0
  33. data/lib/dialecto_rails/unmarked.rb +42 -0
  34. data/lib/dialecto_rails/usages/call_args.rb +78 -0
  35. data/lib/dialecto_rails/usages/catalog_keys.rb +67 -0
  36. data/lib/dialecto_rails/usages/check.rb +70 -0
  37. data/lib/dialecto_rails/usages/haml_extractor.rb +260 -0
  38. data/lib/dialecto_rails/usages/keys.rb +206 -0
  39. data/lib/dialecto_rails/usages/known_wrappers.rb +31 -0
  40. data/lib/dialecto_rails/usages/lazy_scope.rb +52 -0
  41. data/lib/dialecto_rails/usages/parse_check.rb +75 -0
  42. data/lib/dialecto_rails/usages/read_by_name.json +28 -0
  43. data/lib/dialecto_rails/usages/record.rb +91 -0
  44. data/lib/dialecto_rails/usages/ruby_visitor.rb +628 -0
  45. data/lib/dialecto_rails/usages/scanner.rb +241 -0
  46. data/lib/dialecto_rails/usages/summary.rb +191 -0
  47. data/lib/dialecto_rails/usages/templates.rb +45 -0
  48. data/lib/dialecto_rails/usages/wrappers.rb +336 -0
  49. data/lib/dialecto_rails/version.rb +3 -0
  50. data/lib/dialecto_rails.rb +14 -0
  51. metadata +119 -0
@@ -0,0 +1,202 @@
1
+ require "json"
2
+ require "ipaddr"
3
+ require "cgi"
4
+ require_relative "overrides"
5
+ require_relative "../version"
6
+
7
+ module DialectoRails
8
+ module InContext
9
+ # The dev server's side of the in-context editor (spec-0023 §7.6; spec-0017 §1.4 for the endpoints). Pure
10
+ # Rack, inserted at position 0, so it sees every request and every response.
11
+ #
12
+ # Loopback only, by two independent checks: the socket's peer (`REMOTE_ADDR`, never `X-Forwarded-For`)
13
+ # must be a loopback address, and the `Host` must be `localhost`, `127.0.0.1` or `[::1]`, which also
14
+ # defeats DNS rebinding. A POST must carry this site's own `Origin`; a foreign `Origin` or
15
+ # `Sec-Fetch-Site: cross-site` is refused.
16
+ class Middleware
17
+ PREFIX = "/__dialecto".freeze
18
+ MAX_BODY = 256 * 1024
19
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 [::1] ::1].freeze
20
+ LOOPBACK_NETS = [IPAddr.new("127.0.0.0/8"), IPAddr.new("::1"), IPAddr.new("::ffff:127.0.0.0/104")].freeze
21
+ JSON_HEADERS = {"content-type" => "application/json", "cache-control" => "no-store"}.freeze
22
+
23
+ # `settings`: url, url_valid?, project, paths (array or nil), catalogs. `store`, `git_context`: the
24
+ # runtime's. `logger` may be nil.
25
+ def initialize(app, settings:, store:, git_context:, logger: nil)
26
+ @app = app
27
+ @settings = settings
28
+ @store = store
29
+ @git_context = git_context
30
+ @logger = logger
31
+ end
32
+
33
+ def call(env)
34
+ path = env["PATH_INFO"].to_s
35
+ return endpoint(env, path.delete_prefix(PREFIX)) if path == PREFIX || path.start_with?(PREFIX + "/")
36
+
37
+ response = @app.call(env)
38
+ document_request?(env) && path_allowed?(path) ? inject(env, response) : response
39
+ end
40
+
41
+ private
42
+
43
+ ## Endpoints
44
+
45
+ def endpoint(env, route)
46
+ case route
47
+ when "/context" then context(env)
48
+ when "/overrides" then post(env) { |payload| overrides(payload) }
49
+ when "/marking" then post(env) { |payload| marking(payload) }
50
+ else json(404, error: "not_found")
51
+ end
52
+ rescue => error
53
+ @logger&.debug("dialecto-rails: endpoint failed (#{error.class})")
54
+ json(500, error: "internal")
55
+ end
56
+
57
+ def context(env)
58
+ return json(405, error: "method_not_allowed") unless %w[GET HEAD].include?(env["REQUEST_METHOD"])
59
+ return json(403, error: "forbidden_origin") unless read_allowed?(env)
60
+
61
+ json(200, @git_context.read)
62
+ end
63
+
64
+ def post(env)
65
+ return json(405, error: "method_not_allowed") unless env["REQUEST_METHOD"] == "POST"
66
+ return json(403, error: "forbidden_origin") unless write_allowed?(env)
67
+
68
+ body = env["rack.input"]&.read(MAX_BODY + 1).to_s
69
+ return json(413, error: "too_large") if body.bytesize > MAX_BODY
70
+
71
+ payload = begin
72
+ JSON.parse(body)
73
+ rescue JSON::ParserError
74
+ return json(400, error: "invalid_json")
75
+ end
76
+ status, result = yield payload
77
+ json(status, result)
78
+ end
79
+
80
+ def overrides(payload)
81
+ parsed = Overrides.parse(payload)
82
+ return [400, {error: "invalid_edits"}] unless parsed
83
+
84
+ @store.replace_overrides(parsed.applied)
85
+ [200, {ok: true, applied: parsed.applied.length, ignored: parsed.ignored}]
86
+ end
87
+
88
+ def marking(payload)
89
+ on = payload.is_a?(Hash) ? payload["on"] : nil
90
+ return [400, {error: "invalid_marking"}] unless on == true || on == false
91
+
92
+ [200, {ok: true, on: @store.set_marking(on), ttl_ms: @store.marking_ttl_ms}]
93
+ end
94
+
95
+ def json(status, body)
96
+ text = JSON.generate(body)
97
+ [status, JSON_HEADERS.merge("content-length" => text.bytesize.to_s), [text]]
98
+ end
99
+
100
+ ## Who may call
101
+
102
+ def read_allowed?(env)
103
+ loopback?(env) && origin_ok?(env, required: false) && [nil, "same-origin"].include?(env["HTTP_SEC_FETCH_SITE"])
104
+ end
105
+
106
+ def write_allowed?(env)
107
+ loopback?(env) && origin_ok?(env, required: true) && env["HTTP_SEC_FETCH_SITE"] != "cross-site"
108
+ end
109
+
110
+ def loopback?(env) = LOOPBACK_HOSTS.include?(host(env)) && loopback_peer?(env)
111
+
112
+ # `Host` is the client's to choose, so the peer address must be loopback too: a LAN peer of a server
113
+ # bound to 0.0.0.0 can send `Host: localhost`.
114
+ def loopback_peer?(env)
115
+ address = IPAddr.new(env["REMOTE_ADDR"].to_s)
116
+ LOOPBACK_NETS.any? { |net| net.family == address.family && net.include?(address) }
117
+ rescue IPAddr::Error
118
+ false
119
+ end
120
+
121
+ def host(env)
122
+ value = (env["HTTP_HOST"] || env["SERVER_NAME"]).to_s.downcase
123
+ value.start_with?("[") ? value[/\A\[[^\]]*\]/].to_s : value.sub(/:\d+\z/, "")
124
+ end
125
+
126
+ def origin_ok?(env, required:)
127
+ origin = env["HTTP_ORIGIN"]
128
+ return !required if origin.nil?
129
+
130
+ origin == "#{env["rack.url_scheme"] || "http"}://#{env["HTTP_HOST"]}"
131
+ end
132
+
133
+ ## The loader
134
+
135
+ def document_request?(env)
136
+ env["REQUEST_METHOD"] == "GET" && @settings.url_valid? && loopback?(env) &&
137
+ [nil, "document"].include?(env["HTTP_SEC_FETCH_DEST"]) &&
138
+ env["HTTP_X_REQUESTED_WITH"].to_s.empty? && env["HTTP_TURBO_FRAME"].to_s.empty?
139
+ end
140
+
141
+ def path_allowed?(path)
142
+ prefixes = @settings.paths
143
+ return true if prefixes.nil?
144
+
145
+ prefixes.any? do |prefix|
146
+ base = prefix.chomp("/")
147
+ path == base || path.start_with?(base + "/")
148
+ end
149
+ end
150
+
151
+ def inject(env, response)
152
+ status, headers, body = response
153
+ return response unless html?(headers) && header(headers, "content-encoding").nil?
154
+
155
+ html = +""
156
+ body.each { |part| html << part.to_s }
157
+ body.close if body.respond_to?(:close)
158
+ html.force_encoding(Encoding::UTF_8)
159
+ add_loader(env, status, headers, html)
160
+ end
161
+
162
+ # Never breaks the page: any failure here returns the buffered document as it was.
163
+ def add_loader(env, status, headers, html)
164
+ at = html.index("</head>")
165
+ return [status, headers, [html]] unless at
166
+
167
+ out = html.dup.insert(at, loader_tag(env, headers))
168
+ headers = headers.dup
169
+ key = headers.keys.find { |name| name.casecmp?("content-length") }
170
+ headers[key] = out.bytesize.to_s if key
171
+ [status, headers, [out]]
172
+ rescue => error
173
+ @logger&.debug("dialecto-rails: loader skipped (#{error.class})")
174
+ [status, headers, [html]]
175
+ end
176
+
177
+ def html?(headers) = header(headers, "content-type").to_s.start_with?("text/html")
178
+
179
+ def header(headers, name)
180
+ key = headers.keys.find { |candidate| candidate.casecmp?(name) }
181
+ key && headers[key]
182
+ end
183
+
184
+ def loader_tag(env, headers)
185
+ base = "#{env["SCRIPT_NAME"]}#{PREFIX}"
186
+ csp = header(headers, "content-security-policy").to_s + header(headers, "content-security-policy-report-only").to_s
187
+ nonce = csp[/'nonce-([A-Za-z0-9+\/=_-]+)'/, 1]
188
+ attributes = {
189
+ "src" => @settings.url + "/assets/in-context/overlay.js",
190
+ "data-url" => @settings.url,
191
+ "data-context" => base + "/context",
192
+ "data-overrides" => base + "/overrides",
193
+ "data-marking" => base + "/marking",
194
+ "data-project" => @settings.project,
195
+ "nonce" => nonce
196
+ }.compact.map { |name, value| %( #{name}="#{CGI.escapeHTML(value.to_s)}") }.join
197
+ # Deferred: the page renders even when Dialecto is slow or unreachable.
198
+ "<script defer#{attributes}></script>"
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,61 @@
1
+ module DialectoRails
2
+ module InContext
3
+ # The saved-drafts payload the overlay posts to `/__dialecto/overrides` (spec-0017 §1.2):
4
+ # `{edits: [{domain, key, locale, to}]}`, where a plural entry carries `forms: [text, …]` (in the entry's
5
+ # categories in canonical order, matching a marker's `__form`) instead of, or beside, `to`.
6
+ module Overrides
7
+ MAX_EDITS = 5_000
8
+ MAX_TEXT = 20_000
9
+ MAX_KEY = 20_000
10
+ MAX_FORMS = 10
11
+ LOCALE = /\A[a-z]{2,3}([-_][A-Za-z0-9]{2,8})*\z/
12
+
13
+ Result = Struct.new(:applied, :ignored, keyword_init: true)
14
+
15
+ module_function
16
+
17
+ # A Result, or nil when the payload isn't a well-formed edit list.
18
+ def parse(payload)
19
+ edits = payload.is_a?(Hash) ? payload["edits"] : nil
20
+ return nil unless edits.is_a?(Array) && edits.length <= MAX_EDITS
21
+
22
+ applied = []
23
+ ignored = 0
24
+ edits.each do |edit|
25
+ case (entry = edit_entry(edit))
26
+ when :error then return nil
27
+ when :ignored then ignored += 1
28
+ else applied << entry
29
+ end
30
+ end
31
+ Result.new(applied: applied, ignored: ignored)
32
+ end
33
+
34
+ def edit_entry(edit)
35
+ return :error unless edit.is_a?(Hash)
36
+
37
+ domain, key, locale = edit.values_at("domain", "key", "locale")
38
+ return :error unless [domain, key, locale].all?(String)
39
+ return :error unless domain.length <= 512 && !key.empty? && key.length <= MAX_KEY && locale.length <= 64
40
+
41
+ override = override_for(edit["to"], edit["forms"])
42
+ return :error unless override
43
+
44
+ locale.match?(LOCALE) ? {domain: domain, key: key, locale: locale, override: override} : :ignored
45
+ end
46
+
47
+ def override_for(to, forms)
48
+ return nil unless to.nil? || (to.is_a?(String) && to.length <= MAX_TEXT)
49
+ return nil unless forms.nil? || forms?(forms)
50
+ return nil if to.nil? && forms.nil?
51
+
52
+ {to: to, forms: forms}.compact
53
+ end
54
+
55
+ def forms?(forms)
56
+ forms.is_a?(Array) && !forms.empty? && forms.length <= MAX_FORMS &&
57
+ forms.all? { |form| form.is_a?(String) && form.length <= MAX_TEXT }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,76 @@
1
+ module DialectoRails
2
+ # The Rails integration (spec-0023 §7.1). This file is all a production process loads: every initializer
3
+ # begins with a guard, and the in-context code is required only after one passes.
4
+ class Railtie < ::Rails::Railtie
5
+ config.dialecto = ActiveSupport::OrderedOptions.new
6
+
7
+ # The development guard: development only, so test, staging and any custom environment get nothing, and
8
+ # the environment or `config.dialecto.enabled = false` can switch it off. The mode may also say `:live` or
9
+ # `:off`; unset it is `:dev` here.
10
+ def self.active?(app)
11
+ return false unless ::Rails.env.development?
12
+
13
+ require "dialecto_rails/config"
14
+ config = DialectoRails::Config.new(app.config.dialecto.to_h)
15
+ config.enabled? && config.mode(:dev) == :dev
16
+ end
17
+
18
+ # The live guard (spec-0025 §4): any environment, but only when `mode` is `:live` and the repo, origin and
19
+ # public key are set. Unset, the mode is `:off` outside development, so a production app is inert until its
20
+ # config says otherwise.
21
+ def self.live?(app)
22
+ require "dialecto_rails/config"
23
+ DialectoRails::Config.new(app.config.dialecto.to_h).live?(:off)
24
+ end
25
+
26
+ initializer "dialecto_rails.middleware" do |app|
27
+ next unless DialectoRails::Railtie.active?(app)
28
+
29
+ require "dialecto_rails/in_context/runtime"
30
+ runtime = DialectoRails::InContext::Runtime.new(
31
+ root: app.root, config: DialectoRails::Config.new(app.config.dialecto.to_h), logger: ::Rails.logger
32
+ )
33
+ app.config.dialecto.runtime = runtime
34
+ app.middleware.insert_before 0, DialectoRails::InContext::Middleware, **runtime.middleware_options
35
+ end
36
+
37
+ initializer "dialecto_rails.live_middleware" do |app|
38
+ unless DialectoRails::Railtie.live?(app)
39
+ problems = DialectoRails::Config.new(app.config.dialecto.to_h).live_problems(:off)
40
+ unless problems.empty?
41
+ ::Rails.logger&.warn("dialecto-rails: mode :live stays off: #{problems.join("; ")}")
42
+ end
43
+ next
44
+ end
45
+
46
+ # The launch token travels in the URL of the first request; keep it out of the request log.
47
+ app.config.filter_parameters += [:dialecto_edit]
48
+
49
+ require "dialecto_rails/in_context/live_runtime"
50
+ runtime = DialectoRails::InContext::LiveRuntime.new(
51
+ root: app.root, config: DialectoRails::Config.new(app.config.dialecto.to_h), logger: ::Rails.logger
52
+ )
53
+ app.config.dialecto.live_runtime = runtime
54
+ # After the session middleware, which the middleware reads and writes; an API-only app has none.
55
+ if defined?(::ActionDispatch::Flash) && !app.config.api_only
56
+ app.middleware.insert_before ::ActionDispatch::Flash, DialectoRails::InContext::LiveMiddleware, **runtime.middleware_options
57
+ else
58
+ app.middleware.use DialectoRails::InContext::LiveMiddleware, **runtime.middleware_options
59
+ end
60
+ end
61
+
62
+ config.after_initialize do |app|
63
+ if DialectoRails::Railtie.active?(app) && (runtime = app.config.dialecto.runtime)
64
+ runtime.install_hook
65
+ runtime.allow_overlay_in([app.config.content_security_policy, app.config.content_security_policy_report_only]
66
+ .select { |policy| policy.respond_to?(:directives) })
67
+ if app.config.action_controller.perform_caching
68
+ ::Rails.logger.warn("dialecto-rails: fragment caching is on, so cached fragments carry no markers. " \
69
+ "Run `bin/rails dev:cache` to turn it off while editing.")
70
+ end
71
+ elsif (runtime = app.config.dialecto.live_runtime)
72
+ runtime.install_hook
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,52 @@
1
+ require_relative "../config"
2
+ require_relative "store"
3
+ require_relative "key_index"
4
+ require_relative "marking"
5
+ require_relative "git_context"
6
+ require_relative "backend_hook"
7
+ require_relative "sanitizer"
8
+ require_relative "middleware"
9
+ require_relative "csp"
10
+
11
+ module DialectoRails
12
+ module InContext
13
+ # The development-only machinery, built once the Railtie's guard has passed (spec-0023 §7.1).
14
+ # Nothing in this folder is required before that, so a production process holds none of it.
15
+ class Runtime
16
+ attr_reader :config, :store, :index, :marking, :git_context
17
+
18
+ def self.html_sanitizer = Sanitizer.build
19
+
20
+ def initialize(root:, config:, logger: nil, sanitizer: self.class.html_sanitizer)
21
+ @config = config
22
+ @logger = logger
23
+ @store = Store.new
24
+ @index = KeyIndex.new(root: root, catalog_paths: config.catalog_paths)
25
+ @marking = Marking.new(store: @store, index: @index, skip_keys: -> { config.skip_keys }, sanitizer: sanitizer)
26
+ @git_context = GitContext.new(root: root, project: config.project, catalogs: config.catalogs)
27
+ end
28
+
29
+ def middleware_options = {settings: config, store: store, git_context: git_context, logger: @logger}
30
+
31
+ # Prepends the hook on the class of each file-based backend. Returns the classes hooked.
32
+ def install_hook(backend = ::I18n.backend)
33
+ BackendHook.marking = marking
34
+ BackendHook.logger = @logger
35
+ backends = backend.is_a?(::I18n::Backend::Chain) ? backend.backends : [backend]
36
+ backends.filter_map do |member|
37
+ unless member.is_a?(::I18n::Backend::Simple)
38
+ @logger&.info("dialecto-rails: in-context editing skipped #{member.class}: it isn't a file backend")
39
+ next
40
+ end
41
+ klass = member.class
42
+ klass.prepend(BackendHook) unless klass.ancestors.include?(BackendHook)
43
+ klass
44
+ end
45
+ end
46
+
47
+ def allow_overlay_in(policies)
48
+ policies.each { |policy| Csp.allow(policy, config.url) if policy }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,19 @@
1
+ module DialectoRails
2
+ module InContext
3
+ # The safe-list sanitizer for `_html` drafts. ActionView depends on rails-html-sanitizer, but loads it
4
+ # lazily, so it is required here rather than found by `defined?`; if it can't load, the marking escapes
5
+ # drafts instead (it fails closed).
6
+ module Sanitizer
7
+ module_function
8
+
9
+ def build
10
+ require "rails-html-sanitizer"
11
+ klass = defined?(::Rails::HTML5::SafeListSanitizer) ? ::Rails::HTML5::SafeListSanitizer : ::Rails::Html::SafeListSanitizer
12
+ sanitizer = klass.new
13
+ ->(text) { sanitizer.sanitize(text) }
14
+ rescue LoadError, NameError
15
+ nil
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,41 @@
1
+ module DialectoRails
2
+ module InContext
3
+ # The dev server's in-context state: whether marking is on (spec-0017 §1.3) and the saved drafts the
4
+ # overlay sent (§1.2). Marking lasts `MARKING_TTL_MS` after the last `set_marking(true)`: the overlay
5
+ # heartbeats while the editor is open, so a closed tab or a crashed overlay can't leave markers on.
6
+ class Store
7
+ MARKING_TTL_MS = 90_000
8
+
9
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) })
10
+ @clock = clock
11
+ @mutex = Mutex.new
12
+ @marking_until = nil
13
+ @overrides = {}.freeze
14
+ end
15
+
16
+ def marking_ttl_ms = MARKING_TTL_MS
17
+
18
+ def marking?
19
+ until_ms = @marking_until
20
+ !until_ms.nil? && until_ms > @clock.call
21
+ end
22
+
23
+ def set_marking(on, ttl_ms = MARKING_TTL_MS)
24
+ @mutex.synchronize { @marking_until = on ? @clock.call + ttl_ms : nil }
25
+ on
26
+ end
27
+
28
+ # The draft for `{domain, key, locale}`, or nil: `{to: String}` and/or `{forms: [String]}`.
29
+ def override(domain, key, locale) = @overrides[[domain, key, locale]]
30
+
31
+ def overrides? = !@overrides.empty?
32
+
33
+ # Replaces every draft: the overlay always sends the full set, so an empty list restores the catalogs.
34
+ def replace_overrides(entries)
35
+ table = entries.to_h { |entry| [[entry[:domain], entry[:key], entry[:locale]], entry[:override]] }.freeze
36
+ @mutex.synchronize { @overrides = table }
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,46 @@
1
+ module DialectoRails
2
+ module Scan
3
+ # Which files are catalogs: the project's catalog paths, matched the way Dialecto matches them
4
+ # (ADR-0020): `**` spans folders, `*` stays in one folder or file name, `?` is one character, and no
5
+ # wildcard reaches a folder or file whose name starts with a dot.
6
+ class Catalogs
7
+ def initialize(patterns)
8
+ @patterns = patterns.map { |pattern| pattern.split("/") }
9
+ end
10
+
11
+ def match?(path)
12
+ segments = path.split("/")
13
+ @patterns.any? { |pattern| match_segments?(pattern, segments) }
14
+ end
15
+
16
+ private
17
+
18
+ def match_segments?(pattern, path)
19
+ return path.empty? if pattern.empty?
20
+
21
+ head, *rest = pattern
22
+ if head == "**"
23
+ (0..path.length).any? do |taken|
24
+ path.first(taken).none? { |segment| segment.start_with?(".") } &&
25
+ match_segments?(rest, path.drop(taken))
26
+ end
27
+ else
28
+ !path.empty? && match_segment?(head, path.first) && match_segments?(rest, path.drop(1))
29
+ end
30
+ end
31
+
32
+ def match_segment?(pattern, segment)
33
+ return false if segment.start_with?(".") && pattern.start_with?("*", "?")
34
+
35
+ regex = pattern.each_char.map do |char|
36
+ case char
37
+ when "*" then "[^/]*"
38
+ when "?" then "[^/]"
39
+ else Regexp.escape(char)
40
+ end
41
+ end.join
42
+ Regexp.new("\\A#{regex}\\z").match?(segment)
43
+ end
44
+ end
45
+ end
46
+ end