Class: RubyLsp::PackageURL

Inherits:
Object
  • Object
show all
Defined in:
lib/ruby_lsp/requests/support/package_url.rb

Defined Under Namespace

Classes: InvalidPackageURL

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(type:, name:, namespace: nil, version: nil, qualifiers: nil, subpath: nil) ⇒ PackageURL

Constructs a package URL from its components

Raises:

  • (ArgumentError)


84
85
86
87
88
89
90
91
92
93
94
# File 'lib/ruby_lsp/requests/support/package_url.rb', line 84

def initialize(type:, name:, namespace: nil, version: nil, qualifiers: nil, subpath: nil)
  raise ArgumentError, "type is required" if type.nil? || type.empty?
  raise ArgumentError, "name is required" if name.nil? || name.empty?

  @type = type.downcase
  @namespace = namespace
  @name = name
  @version = version
  @qualifiers = qualifiers
  @subpath = subpath
end

Instance Attribute Details

#nameObject (readonly)

The name of the package.



65
66
67
# File 'lib/ruby_lsp/requests/support/package_url.rb', line 65

def name
  @name
end

#namespaceObject (readonly)

A name prefix, specific to the type of package. For example, an npm scope, a Docker image owner, or a GitHub user.



62
63
64
# File 'lib/ruby_lsp/requests/support/package_url.rb', line 62

def namespace
  @namespace
end

#qualifiersObject (readonly)

Extra qualifying data for a package, specific to the type of package. For example, the operating system or architecture.



72
73
74
# File 'lib/ruby_lsp/requests/support/package_url.rb', line 72

def qualifiers
  @qualifiers
end

#subpathObject (readonly)

An extra subpath within a package, relative to the package root.



75
76
77
# File 'lib/ruby_lsp/requests/support/package_url.rb', line 75

def subpath
  @subpath
end

#typeObject (readonly)

The package type or protocol, such as ‘“gem”`, `“npm”`, and `“github”`.



58
59
60
# File 'lib/ruby_lsp/requests/support/package_url.rb', line 58

def type
  @type
end

#versionObject (readonly)

The version of the package.



68
69
70
# File 'lib/ruby_lsp/requests/support/package_url.rb', line 68

def version
  @version
end

Class Method Details

.parse(string) ⇒ PackageURL

Creates a new PackageURL from a string.

Raises:



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/ruby_lsp/requests/support/package_url.rb', line 100

def self.parse(string)
  components = {
    type: nil,
    namespace: nil,
    name: nil,
    version: nil,
    qualifiers: nil,
    subpath: nil,
  }

  # Split the purl string once from right on '#'
  # - The left side is the remainder
  # - Strip the right side from leading and trailing '/'
  # - Split this on '/'
  # - Discard any empty string segment from that split
  # - Discard any '.' or '..' segment from that split
  # - Percent-decode each segment
  # - UTF-8-decode each segment if needed in your programming language
  # - Join segments back with a '/'
  # - This is the subpath
  case string.rpartition("#")
  in String => remainder, separator, String => subpath unless separator.empty?
    subpath_components = []
    subpath.split("/").each do |segment|
      next if segment.empty? || segment == "." || segment == ".."

      subpath_components << URI.decode_www_form_component(segment)
    end

    components[:subpath] = subpath_components.compact.join("/")

    string = remainder
  else
    components[:subpath] = nil
  end

  # Split the remainder once from right on '?'
  # - The left side is the remainder
  # - The right side is the qualifiers string
  # - Split the qualifiers on '&'. Each part is a key=value pair
  # - For each pair, split the key=value once from left on '=':
  # - The key is the lowercase left side
  # - The value is the percent-decoded right side
  # - UTF-8-decode the value if needed in your programming language
  # - Discard any key/value pairs where the value is empty
  # - If the key is checksums,
  #   split the value on ',' to create a list of checksums
  # - This list of key/value is the qualifiers object
  case string.rpartition("?")
  in String => remainder, separator, String => qualifiers unless separator.empty?
    components[:qualifiers] = {}

    qualifiers.split("&").each do |pair|
      case pair.partition("=")
      in String => key, separator, String => value unless separator.empty?
        key = key.downcase
        value = URI.decode_www_form_component(value)
        next if value.empty?

        components[:qualifiers][key] = case key
        when "checksums"
          value.split(",")
        else
          value
        end
      else
        next
      end
    end

    string = remainder
  else
    components[:qualifiers] = nil
  end

  # Split the remainder once from left on ':'
  # - The left side lowercased is the scheme
  # - The right side is the remainder
  case string.partition(":")
  in "pkg", separator, String => remainder unless separator.empty?
    string = remainder
  else
    raise InvalidPackageURL, 'invalid or missing "pkg:" URL scheme'
  end

  # Strip the remainder from leading and trailing '/'
  # Use gsub to remove ALL leading slashes instead of just one
  string = string.gsub(%r{^/+}, "").delete_suffix("/")
  # - Split this once from left on '/'
  # - The left side lowercased is the type
  # - The right side is the remainder
  case string.partition("/")
  in String => type, separator, remainder unless separator.empty?
    components[:type] = type

    string = remainder
  else
    raise InvalidPackageURL, "invalid or missing package type"
  end

  # Split the remainder once from right on '@'
  # - The left side is the remainder
  # - Percent-decode the right side. This is the version.
  # - UTF-8-decode the version if needed in your programming language
  # - This is the version
  case string.rpartition("@")
  in String => remainder, separator, String => version unless separator.empty?
    components[:version] = URI.decode_www_form_component(version)

    string = remainder
  else
    components[:version] = nil
  end

  # Split the remainder once from right on '/'
  # - The left side is the remainder
  # - Percent-decode the right side. This is the name
  # - UTF-8-decode this name if needed in your programming language
  # - Apply type-specific normalization to the name if needed
  # - This is the name
  case string.rpartition("/")
  in String => remainder, separator, String => name unless separator.empty?
    components[:name] = URI.decode_www_form_component(name)

    # Split the remainder on '/'
    # - Discard any empty segment from that split
    # - Percent-decode each segment
    # - UTF-8-decode the each segment if needed in your programming language
    # - Apply type-specific normalization to each segment if needed
    # - Join segments back with a '/'
    # - This is the namespace
    components[:namespace] = remainder.split("/").map { |s| URI.decode_www_form_component(s) }.compact.join("/")
  in _, _, String => name
    components[:name] = URI.decode_www_form_component(name)
    components[:namespace] = nil
  end

  # Ensure type and name are not nil before creating the PackageURL instance
  raise InvalidPackageURL, "missing package type" if components[:type].nil?
  raise InvalidPackageURL, "missing package name" if components[:name].nil?

  # Create a new PackageURL with validated components
  type = components[:type] || ""  # This ensures type is never nil
  name = components[:name] || ""  # This ensures name is never nil

  new(
    type: type,
    name: name,
    namespace: components[:namespace],
    version: components[:version],
    qualifiers: components[:qualifiers],
    subpath: components[:subpath],
  )
end

Instance Method Details

#deconstructObject

Returns an array containing the scheme, type, namespace, name, version, qualifiers, and subpath components of the package URL.



403
404
405
# File 'lib/ruby_lsp/requests/support/package_url.rb', line 403

def deconstruct
  [scheme, @type, @namespace, @name, @version, @qualifiers, @subpath]
end

#deconstruct_keys(_keys) ⇒ Object

Returns a hash containing the scheme, type, namespace, name, version, qualifiers, and subpath components of the package URL.



410
411
412
# File 'lib/ruby_lsp/requests/support/package_url.rb', line 410

def deconstruct_keys(_keys)
  to_h
end

#schemeObject

The URL scheme, which has a constant value of ‘“pkg”`.



53
54
55
# File 'lib/ruby_lsp/requests/support/package_url.rb', line 53

def scheme
  "pkg"
end

#to_hObject

Returns a hash containing the scheme, type, namespace, name, version, qualifiers, and subpath components of the package URL.



258
259
260
261
262
263
264
265
266
267
268
# File 'lib/ruby_lsp/requests/support/package_url.rb', line 258

def to_h
  {
    scheme: scheme,
    type: @type,
    namespace: @namespace,
    name: @name,
    version: @version,
    qualifiers: @qualifiers,
    subpath: @subpath,
  }
end

#to_sObject

Returns a string representation of the package URL. Package URL representations are created according to the instructions from github.com/package-url/purl-spec/blob/0b1559f76b79829e789c4f20e6d832c7314762c5/PURL-SPECIFICATION.rst#how-to-build-purl-string-from-its-components.



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
# File 'lib/ruby_lsp/requests/support/package_url.rb', line 273

def to_s
  # Start a purl string with the "pkg:" scheme as a lowercase ASCII string
  purl = "pkg:"

  # Append the type string to the purl as a lowercase ASCII string
  # Append '/' to the purl

  purl += @type
  purl += "/"

  # If the namespace is not empty:
  # - Strip the namespace from leading and trailing '/'
  # - Split on '/' as segments
  # - Apply type-specific normalization to each segment if needed
  # - UTF-8-encode each segment if needed in your programming language
  # - Percent-encode each segment
  # - Join the segments with '/'
  # - Append this to the purl
  # - Append '/' to the purl
  # - Strip the name from leading and trailing '/'
  # - Apply type-specific normalization to the name if needed
  # - UTF-8-encode the name if needed in your programming language
  # - Append the percent-encoded name to the purl
  #
  # If the namespace is empty:
  # - Apply type-specific normalization to the name if needed
  # - UTF-8-encode the name if needed in your programming language
  # - Append the percent-encoded name to the purl
  case @namespace
  in String => namespace unless namespace.empty?
    segments = []
    @namespace.delete_prefix("/").delete_suffix("/").split("/").each do |segment|
      next if segment.empty?

      segments << URI.encode_www_form_component(segment)
    end
    purl += segments.join("/")

    purl += "/"
    purl += URI.encode_www_form_component(@name.delete_prefix("/").delete_suffix("/"))
  else
    purl += URI.encode_www_form_component(@name)
  end

  # If the version is not empty:
  # - Append '@' to the purl
  # - UTF-8-encode the version if needed in your programming language
  # - Append the percent-encoded version to the purl
  case @version
  in String => version unless version.empty?
    purl += "@"
    purl += URI.encode_www_form_component(@version)
  else
    nil
  end

  # If the qualifiers are not empty and not composed only of key/value pairs
  # where the value is empty:
  # - Append '?' to the purl
  # - Build a list from all key/value pair:
  # - discard any pair where the value is empty.
  # - UTF-8-encode each value if needed in your programming language
  # - If the key is checksums and this is a list of checksums
  #   join this list with a ',' to create this qualifier value
  # - create a string by joining the lowercased key,
  #   the equal '=' sign and the percent-encoded value to create a qualifier
  # - sort this list of qualifier strings lexicographically
  # - join this list of qualifier strings with a '&' ampersand
  # - Append this string to the purl
  case @qualifiers
  in Hash => qualifiers unless qualifiers.empty?
    list = []
    qualifiers.each do |key, value|
      next if value.empty?

      list << case [key, value]
      in "checksums", Array => checksums
        "#{key.downcase}=#{checksums.join(",")}"
      else
        "#{key.downcase}=#{URI.encode_www_form_component(value)}"
      end
    end

    unless list.empty?
      purl += "?"
      purl += list.sort.join("&")
    end
  else
    nil
  end

  # If the subpath is not empty and not composed only of
  # empty, '.' and '..' segments:
  # - Append '#' to the purl
  # - Strip the subpath from leading and trailing '/'
  # - Split this on '/' as segments
  # - Discard empty, '.' and '..' segments
  # - Percent-encode each segment
  # - UTF-8-encode each segment if needed in your programming language
  # - Join the segments with '/'
  # - Append this to the purl
  case @subpath
  in String => subpath unless subpath.empty?
    segments = []
    subpath.delete_prefix("/").delete_suffix("/").split("/").each do |segment|
      next if segment.empty? || segment == "." || segment == ".."

      # Custom encoding for URL fragment segments:
      # 1. Explicitly encode % as %25 to prevent double-encoding issues
      # 2. Percent-encode special characters according to URL fragment rules
      # 3. This ensures proper round-trip encoding/decoding with the parse method
      segments << segment.gsub(/%|[^A-Za-z0-9\-\._~]/) do |m|
        m == "%" ? "%25" : format("%%%02X", m.ord)
      end
    end

    unless segments.empty?
      purl += "#"
      purl += segments.join("/")
    end
  else
    nil
  end

  purl
end