purl 0.1.0 ā 1.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -2
- data/CODE_OF_CONDUCT.md +1 -1
- data/LICENSE +21 -0
- data/README.md +273 -13
- data/Rakefile +385 -0
- data/lib/purl/errors.rb +64 -0
- data/lib/purl/package_url.rb +512 -0
- data/lib/purl/registry_url.rb +309 -0
- data/lib/purl/version.rb +1 -1
- data/lib/purl.rb +111 -1
- data/purl-types.json +358 -0
- data/test-suite-data.json +710 -0
- metadata +7 -1
data/Rakefile
CHANGED
|
@@ -6,3 +6,388 @@ require "minitest/test_task"
|
|
|
6
6
|
Minitest::TestTask.create
|
|
7
7
|
|
|
8
8
|
task default: :test
|
|
9
|
+
|
|
10
|
+
namespace :spec do
|
|
11
|
+
desc "Show available PURL specification tasks"
|
|
12
|
+
task :help do
|
|
13
|
+
puts "š§ PURL Specification Tasks"
|
|
14
|
+
puts "=" * 30
|
|
15
|
+
puts "rake spec:update - Fetch latest test cases from official PURL spec repository"
|
|
16
|
+
puts "rake spec:stats - Show statistics about current test suite data"
|
|
17
|
+
puts "rake spec:compliance - Run all compliance tests against the official test suite"
|
|
18
|
+
puts "rake spec:debug - Show detailed info about failing test cases"
|
|
19
|
+
puts "rake spec:types - Show information about all PURL types and their support"
|
|
20
|
+
puts "rake spec:verify_types - Verify our types list against the official specification"
|
|
21
|
+
puts "rake spec:help - Show this help message"
|
|
22
|
+
puts
|
|
23
|
+
puts "Example workflow:"
|
|
24
|
+
puts " 1. rake spec:update # Get latest test cases"
|
|
25
|
+
puts " 2. rake spec:stats # Review test suite composition"
|
|
26
|
+
puts " 3. rake spec:compliance # Run compliance tests"
|
|
27
|
+
puts " 4. rake spec:debug # Debug any failures"
|
|
28
|
+
puts
|
|
29
|
+
puts "The test suite data is stored in test-suite-data.json at the project root."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
desc "Import/update official PURL specification test cases"
|
|
33
|
+
task :update do
|
|
34
|
+
require "net/http"
|
|
35
|
+
require "uri"
|
|
36
|
+
require "json"
|
|
37
|
+
require "fileutils"
|
|
38
|
+
|
|
39
|
+
puts "Fetching official PURL specification test cases..."
|
|
40
|
+
|
|
41
|
+
# URL for the official test suite data
|
|
42
|
+
url = "https://raw.githubusercontent.com/package-url/purl-spec/master/test-suite-data.json"
|
|
43
|
+
test_file_path = File.join(__dir__, "test-suite-data.json")
|
|
44
|
+
backup_path = "#{test_file_path}.backup"
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
# Create backup of existing file if it exists
|
|
48
|
+
if File.exist?(test_file_path)
|
|
49
|
+
puts "Creating backup of existing test file..."
|
|
50
|
+
FileUtils.cp(test_file_path, backup_path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Fetch the latest test data
|
|
54
|
+
uri = URI(url)
|
|
55
|
+
response = Net::HTTP.get_response(uri)
|
|
56
|
+
|
|
57
|
+
if response.code == "200"
|
|
58
|
+
# Validate that we got valid JSON
|
|
59
|
+
test_data = JSON.parse(response.body)
|
|
60
|
+
|
|
61
|
+
# Write the new test data
|
|
62
|
+
File.write(test_file_path, response.body)
|
|
63
|
+
|
|
64
|
+
puts "ā
Successfully updated test suite data!"
|
|
65
|
+
puts " - Test cases: #{test_data.length}"
|
|
66
|
+
puts " - File: #{test_file_path}"
|
|
67
|
+
|
|
68
|
+
# Remove backup if update was successful
|
|
69
|
+
File.delete(backup_path) if File.exist?(backup_path)
|
|
70
|
+
|
|
71
|
+
# Show summary of test case types
|
|
72
|
+
types = test_data.group_by { |tc| tc["type"] || "unknown" }.transform_values(&:count)
|
|
73
|
+
puts "\nš Test case distribution by package type:"
|
|
74
|
+
types.sort_by { |type, _| type.to_s }.each do |type, count|
|
|
75
|
+
puts " #{type}: #{count} cases"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Show invalid vs valid cases
|
|
79
|
+
invalid_count = test_data.count { |tc| tc["is_invalid"] }
|
|
80
|
+
valid_count = test_data.count { |tc| !tc["is_invalid"] }
|
|
81
|
+
puts "\nš Test case categories:"
|
|
82
|
+
puts " Valid cases: #{valid_count}"
|
|
83
|
+
puts " Invalid cases: #{invalid_count}"
|
|
84
|
+
|
|
85
|
+
else
|
|
86
|
+
raise "HTTP request failed with status #{response.code}: #{response.message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
rescue => e
|
|
90
|
+
puts "ā Failed to update test suite data: #{e.message}"
|
|
91
|
+
|
|
92
|
+
# Restore backup if update failed
|
|
93
|
+
if File.exist?(backup_path)
|
|
94
|
+
puts "Restoring backup..."
|
|
95
|
+
FileUtils.mv(backup_path, test_file_path)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
exit 1
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
desc "Show current test suite statistics"
|
|
103
|
+
task :stats do
|
|
104
|
+
require "json"
|
|
105
|
+
|
|
106
|
+
test_file_path = File.join(__dir__, "test-suite-data.json")
|
|
107
|
+
|
|
108
|
+
unless File.exist?(test_file_path)
|
|
109
|
+
puts "ā Test suite data file not found. Run 'rake spec:update' first."
|
|
110
|
+
exit 1
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
begin
|
|
114
|
+
test_data = JSON.parse(File.read(test_file_path))
|
|
115
|
+
|
|
116
|
+
puts "š PURL Test Suite Statistics"
|
|
117
|
+
puts "=" * 40
|
|
118
|
+
puts "Total test cases: #{test_data.length}"
|
|
119
|
+
puts "File location: #{test_file_path}"
|
|
120
|
+
puts "File size: #{File.size(test_file_path)} bytes"
|
|
121
|
+
puts "Last modified: #{File.mtime(test_file_path)}"
|
|
122
|
+
|
|
123
|
+
# Distribution by package type
|
|
124
|
+
puts "\nš¦ Distribution by package type:"
|
|
125
|
+
types = test_data.group_by { |tc| tc["type"] || "unknown" }.transform_values(&:count)
|
|
126
|
+
types.sort_by { |_, count| -count }.each do |type, count|
|
|
127
|
+
percentage = (count.to_f / test_data.length * 100).round(1)
|
|
128
|
+
puts " #{type.to_s.ljust(12)} #{count.to_s.rjust(3)} cases (#{percentage}%)"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Valid vs invalid cases
|
|
132
|
+
invalid_count = test_data.count { |tc| tc["is_invalid"] }
|
|
133
|
+
valid_count = test_data.count { |tc| !tc["is_invalid"] }
|
|
134
|
+
puts "\nā
Test case validity:"
|
|
135
|
+
puts " Valid cases: #{valid_count} (#{(valid_count.to_f / test_data.length * 100).round(1)}%)"
|
|
136
|
+
puts " Invalid cases: #{invalid_count} (#{(invalid_count.to_f / test_data.length * 100).round(1)}%)"
|
|
137
|
+
|
|
138
|
+
# Cases with different components
|
|
139
|
+
has_namespace = test_data.count { |tc| tc["namespace"] }
|
|
140
|
+
has_version = test_data.count { |tc| tc["version"] }
|
|
141
|
+
has_qualifiers = test_data.count { |tc| tc["qualifiers"] && !tc["qualifiers"].empty? }
|
|
142
|
+
has_subpath = test_data.count { |tc| tc["subpath"] }
|
|
143
|
+
|
|
144
|
+
puts "\nš§ Component usage:"
|
|
145
|
+
puts " With namespace: #{has_namespace} cases"
|
|
146
|
+
puts " With version: #{has_version} cases"
|
|
147
|
+
puts " With qualifiers: #{has_qualifiers} cases"
|
|
148
|
+
puts " With subpath: #{has_subpath} cases"
|
|
149
|
+
|
|
150
|
+
rescue JSON::ParserError => e
|
|
151
|
+
puts "ā Failed to parse test suite data: #{e.message}"
|
|
152
|
+
exit 1
|
|
153
|
+
rescue => e
|
|
154
|
+
puts "ā Error reading test suite data: #{e.message}"
|
|
155
|
+
exit 1
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
desc "Run compliance tests against the official test suite"
|
|
160
|
+
task :compliance do
|
|
161
|
+
puts "Running PURL specification compliance tests..."
|
|
162
|
+
system("ruby test/test_purl_spec_compliance.rb")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
desc "Verify our PURL types against the official specification"
|
|
166
|
+
task :verify_types do
|
|
167
|
+
require "net/http"
|
|
168
|
+
require "uri"
|
|
169
|
+
require_relative "lib/purl"
|
|
170
|
+
|
|
171
|
+
puts "š Verifying PURL Types Against Official Specification"
|
|
172
|
+
puts "=" * 60
|
|
173
|
+
|
|
174
|
+
begin
|
|
175
|
+
# Fetch the official PURL-TYPES.rst file
|
|
176
|
+
url = "https://raw.githubusercontent.com/package-url/purl-spec/main/PURL-TYPES.rst"
|
|
177
|
+
uri = URI(url)
|
|
178
|
+
response = Net::HTTP.get_response(uri)
|
|
179
|
+
|
|
180
|
+
if response.code != "200"
|
|
181
|
+
puts "ā Failed to fetch official specification: HTTP #{response.code}"
|
|
182
|
+
exit 1
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
content = response.body
|
|
186
|
+
|
|
187
|
+
# Extract type names from the specification
|
|
188
|
+
# Look for lines like "**alpm**" or lines that start type definitions
|
|
189
|
+
official_types = []
|
|
190
|
+
content.scan(/^\*\*(\w+)\*\*/) do |match|
|
|
191
|
+
official_types << match[0].downcase
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Also look for types in different format patterns (but be more restrictive)
|
|
195
|
+
content.scan(/^(\w+)\s*$/) do |match|
|
|
196
|
+
type = match[0].downcase
|
|
197
|
+
# Filter out common words that aren't types and document sections
|
|
198
|
+
unless %w[types purl package url specification license abstract].include?(type)
|
|
199
|
+
official_types << type if type.length > 2 && type != "license"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
official_types = official_types.uniq.sort
|
|
204
|
+
our_types = Purl.known_types.sort
|
|
205
|
+
|
|
206
|
+
puts "š Comparison Results:"
|
|
207
|
+
puts " Official specification: #{official_types.length} types"
|
|
208
|
+
puts " Our implementation: #{our_types.length} types"
|
|
209
|
+
|
|
210
|
+
# Find missing types
|
|
211
|
+
missing_from_ours = official_types - our_types
|
|
212
|
+
extra_in_ours = our_types - official_types
|
|
213
|
+
|
|
214
|
+
if missing_from_ours.empty? && extra_in_ours.empty?
|
|
215
|
+
puts "\nā
Perfect match! All types are synchronized."
|
|
216
|
+
else
|
|
217
|
+
if missing_from_ours.any?
|
|
218
|
+
puts "\nā Types in specification but missing from our list:"
|
|
219
|
+
missing_from_ours.each { |type| puts " - #{type}" }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
if extra_in_ours.any?
|
|
223
|
+
puts "\nā ļø Types in our list but not found in specification:"
|
|
224
|
+
extra_in_ours.each { |type| puts " + #{type}" }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
puts "\nš All Official Types Found:"
|
|
229
|
+
official_types.each_with_index do |type, index|
|
|
230
|
+
status = our_types.include?(type) ? "ā" : "ā"
|
|
231
|
+
puts " #{status} #{(index + 1).to_s.rjust(2)}. #{type}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
rescue => e
|
|
235
|
+
puts "ā Error verifying types: #{e.message}"
|
|
236
|
+
exit 1
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
desc "Show information about PURL types"
|
|
241
|
+
task :types do
|
|
242
|
+
require_relative "lib/purl"
|
|
243
|
+
|
|
244
|
+
puts "š PURL Type Information"
|
|
245
|
+
puts "=" * 40
|
|
246
|
+
|
|
247
|
+
puts "\nš All Known PURL Types (#{Purl.known_types.length}):"
|
|
248
|
+
Purl.known_types.each_slice(4) do |slice|
|
|
249
|
+
puts " #{slice.map { |t| t.ljust(12) }.join(" ")}"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
puts "\nš Registry URL Generation Support (#{Purl.registry_supported_types.length}):"
|
|
253
|
+
Purl.registry_supported_types.each_slice(4) do |slice|
|
|
254
|
+
puts " #{slice.map { |t| t.ljust(12) }.join(" ")}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
puts "\nš Reverse Parsing Support (#{Purl.reverse_parsing_supported_types.length}):"
|
|
258
|
+
Purl.reverse_parsing_supported_types.each_slice(4) do |slice|
|
|
259
|
+
puts " #{slice.map { |t| t.ljust(12) }.join(" ")}"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
puts "\nš Type Support Matrix:"
|
|
263
|
+
puts " Type Known Registry Reverse"
|
|
264
|
+
puts " " + "-" * 35
|
|
265
|
+
|
|
266
|
+
all_types = (Purl.known_types + Purl.registry_supported_types).uniq.sort
|
|
267
|
+
all_types.each do |type|
|
|
268
|
+
info = Purl.type_info(type)
|
|
269
|
+
known_mark = info[:known] ? "ā" : "ā"
|
|
270
|
+
registry_mark = info[:registry_url_generation] ? "ā" : "ā"
|
|
271
|
+
reverse_mark = info[:reverse_parsing] ? "ā" : "ā"
|
|
272
|
+
|
|
273
|
+
puts " #{type.ljust(12)} #{known_mark.center(5)} #{registry_mark.center(9)} #{reverse_mark.center(7)}"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
puts "\nš¤ Route Patterns Examples:"
|
|
277
|
+
["gem", "npm", "maven"].each do |type|
|
|
278
|
+
patterns = Purl::RegistryURL.route_patterns_for(type)
|
|
279
|
+
if patterns.any?
|
|
280
|
+
puts "\n #{type.upcase}:"
|
|
281
|
+
patterns.each { |pattern| puts " #{pattern}" }
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
desc "Show failing test cases for debugging"
|
|
287
|
+
task :debug do
|
|
288
|
+
require "json"
|
|
289
|
+
require_relative "lib/purl"
|
|
290
|
+
|
|
291
|
+
test_file_path = File.join(__dir__, "test-suite-data.json")
|
|
292
|
+
|
|
293
|
+
unless File.exist?(test_file_path)
|
|
294
|
+
puts "ā Test suite data file not found. Run 'rake spec:update' first."
|
|
295
|
+
exit 1
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
test_data = JSON.parse(File.read(test_file_path))
|
|
299
|
+
|
|
300
|
+
puts "š Debugging failing test cases..."
|
|
301
|
+
puts "=" * 50
|
|
302
|
+
|
|
303
|
+
failed_cases = []
|
|
304
|
+
|
|
305
|
+
test_data.each_with_index do |test_case, index|
|
|
306
|
+
description = test_case["description"]
|
|
307
|
+
purl_string = test_case["purl"]
|
|
308
|
+
is_invalid = test_case["is_invalid"]
|
|
309
|
+
|
|
310
|
+
begin
|
|
311
|
+
if is_invalid
|
|
312
|
+
begin
|
|
313
|
+
Purl::PackageURL.parse(purl_string)
|
|
314
|
+
failed_cases << {
|
|
315
|
+
index: index + 1,
|
|
316
|
+
description: description,
|
|
317
|
+
purl: purl_string,
|
|
318
|
+
error: "Expected parsing to fail but it succeeded",
|
|
319
|
+
type: "validation"
|
|
320
|
+
}
|
|
321
|
+
rescue Purl::Error
|
|
322
|
+
# Correctly failed - this is expected
|
|
323
|
+
end
|
|
324
|
+
else
|
|
325
|
+
purl = Purl::PackageURL.parse(purl_string)
|
|
326
|
+
|
|
327
|
+
# Check if canonical form matches expected
|
|
328
|
+
expected_canonical = test_case["canonical_purl"]
|
|
329
|
+
if expected_canonical && purl.to_s != expected_canonical
|
|
330
|
+
failed_cases << {
|
|
331
|
+
index: index + 1,
|
|
332
|
+
description: description,
|
|
333
|
+
purl: purl_string,
|
|
334
|
+
error: "Canonical mismatch: expected '#{expected_canonical}', got '#{purl.to_s}'",
|
|
335
|
+
type: "canonical"
|
|
336
|
+
}
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Check component mismatches
|
|
340
|
+
%w[type namespace name version qualifiers subpath].each do |component|
|
|
341
|
+
expected = test_case[component]
|
|
342
|
+
actual = purl.send(component)
|
|
343
|
+
|
|
344
|
+
if expected != actual
|
|
345
|
+
failed_cases << {
|
|
346
|
+
index: index + 1,
|
|
347
|
+
description: description,
|
|
348
|
+
purl: purl_string,
|
|
349
|
+
error: "#{component.capitalize} mismatch: expected #{expected.inspect}, got #{actual.inspect}",
|
|
350
|
+
type: "component"
|
|
351
|
+
}
|
|
352
|
+
break # Only report first mismatch per test case
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
rescue => e
|
|
357
|
+
failed_cases << {
|
|
358
|
+
index: index + 1,
|
|
359
|
+
description: description,
|
|
360
|
+
purl: purl_string,
|
|
361
|
+
error: "#{e.class}: #{e.message}",
|
|
362
|
+
type: "exception"
|
|
363
|
+
}
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
if failed_cases.empty?
|
|
368
|
+
puts "š All test cases are passing!"
|
|
369
|
+
else
|
|
370
|
+
puts "ā Found #{failed_cases.length} failing test cases:\n"
|
|
371
|
+
|
|
372
|
+
# Group by failure type
|
|
373
|
+
failed_cases.group_by { |fc| fc[:type] }.each do |failure_type, cases|
|
|
374
|
+
puts "#{failure_type.upcase} FAILURES (#{cases.length}):"
|
|
375
|
+
puts "-" * 30
|
|
376
|
+
|
|
377
|
+
cases.first(5).each do |failed_case| # Show first 5 of each type
|
|
378
|
+
puts "Case #{failed_case[:index]}: #{failed_case[:description]}"
|
|
379
|
+
puts " PURL: #{failed_case[:purl]}"
|
|
380
|
+
puts " Error: #{failed_case[:error]}"
|
|
381
|
+
puts
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
if cases.length > 5
|
|
385
|
+
puts " ... and #{cases.length - 5} more #{failure_type} failures\n"
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
success_rate = ((test_data.length - failed_cases.length).to_f / test_data.length * 100).round(1)
|
|
390
|
+
puts "Overall success rate: #{success_rate}% (#{test_data.length - failed_cases.length}/#{test_data.length})"
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
data/lib/purl/errors.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Purl
|
|
4
|
+
# Base error class for all Purl-related errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Validation errors for PURL components
|
|
8
|
+
class ValidationError < Error
|
|
9
|
+
attr_reader :component, :value, :rule
|
|
10
|
+
|
|
11
|
+
def initialize(message, component: nil, value: nil, rule: nil)
|
|
12
|
+
super(message)
|
|
13
|
+
@component = component
|
|
14
|
+
@value = value
|
|
15
|
+
@rule = rule
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Parsing errors for malformed PURL strings
|
|
20
|
+
class ParseError < Error; end
|
|
21
|
+
|
|
22
|
+
# Specific validation errors
|
|
23
|
+
class InvalidTypeError < ValidationError; end
|
|
24
|
+
class InvalidNameError < ValidationError; end
|
|
25
|
+
class InvalidNamespaceError < ValidationError; end
|
|
26
|
+
class InvalidQualifierError < ValidationError; end
|
|
27
|
+
class InvalidVersionError < ValidationError; end
|
|
28
|
+
class InvalidSubpathError < ValidationError; end
|
|
29
|
+
|
|
30
|
+
# Parsing-specific errors
|
|
31
|
+
class InvalidSchemeError < ParseError; end
|
|
32
|
+
class MalformedUrlError < ParseError; end
|
|
33
|
+
|
|
34
|
+
# Registry URL generation errors
|
|
35
|
+
class RegistryError < Error
|
|
36
|
+
attr_reader :type
|
|
37
|
+
|
|
38
|
+
def initialize(message, type: nil)
|
|
39
|
+
super(message)
|
|
40
|
+
@type = type
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class UnsupportedTypeError < RegistryError
|
|
45
|
+
attr_reader :supported_types
|
|
46
|
+
|
|
47
|
+
def initialize(message, type: nil, supported_types: [])
|
|
48
|
+
super(message, type: type)
|
|
49
|
+
@supported_types = supported_types
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class MissingRegistryInfoError < RegistryError
|
|
54
|
+
attr_reader :missing
|
|
55
|
+
|
|
56
|
+
def initialize(message, type: nil, missing: nil)
|
|
57
|
+
super(message, type: type)
|
|
58
|
+
@missing = missing
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Legacy compatibility - matches packageurl-ruby's exception name
|
|
63
|
+
InvalidPackageURL = ParseError
|
|
64
|
+
end
|