fhirpath 0.2.0.pre1
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 +7 -0
- data/.rubocop.yml +73 -0
- data/CHANGELOG.md +77 -0
- data/CONTRIBUTING.md +67 -0
- data/Gemfile +12 -0
- data/LICENSE +21 -0
- data/README.md +218 -0
- data/Rakefile +19 -0
- data/SECURITY.md +20 -0
- data/conformance/core.jsonl +39 -0
- data/conformance/official-r4-core.json +15 -0
- data/conformance/r4.jsonl +6 -0
- data/docs/api.md +170 -0
- data/docs/architecture.md +507 -0
- data/docs/conformance.md +67 -0
- data/docs/feature-matrix.md +49 -0
- data/docs/first-slice.md +57 -0
- data/docs/release-checklist.md +57 -0
- data/docs/releasing.md +87 -0
- data/docs/support-matrix.md +102 -0
- data/lib/fhirpath/ast.rb +128 -0
- data/lib/fhirpath/capability.rb +57 -0
- data/lib/fhirpath/collection.rb +94 -0
- data/lib/fhirpath/compiled_expression.rb +35 -0
- data/lib/fhirpath/conformance/importer.rb +317 -0
- data/lib/fhirpath/errors.rb +76 -0
- data/lib/fhirpath/evaluation_context.rb +30 -0
- data/lib/fhirpath/evaluator.rb +690 -0
- data/lib/fhirpath/functions.rb +71 -0
- data/lib/fhirpath/host_services.rb +52 -0
- data/lib/fhirpath/model.rb +47 -0
- data/lib/fhirpath/model_registry.rb +17 -0
- data/lib/fhirpath/models_r4.rb +91 -0
- data/lib/fhirpath/parser.rb +487 -0
- data/lib/fhirpath/source_span.rb +25 -0
- data/lib/fhirpath/types.rb +73 -0
- data/lib/fhirpath/vector_runner.rb +130 -0
- data/lib/fhirpath/version.rb +9 -0
- data/lib/fhirpath.rb +77 -0
- data/script/import_vectors.rb +14 -0
- data/script/run_vectors.rb +19 -0
- metadata +120 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'rexml/document'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
require_relative '../capability'
|
|
7
|
+
|
|
8
|
+
module FHIRPath
|
|
9
|
+
module Conformance
|
|
10
|
+
# Imports a selected, pinned subset of the official XML shared suite.
|
|
11
|
+
# The importer is deliberately Ruby-only so it is safe to use in tests and
|
|
12
|
+
# release checks without installing a second language runtime.
|
|
13
|
+
class Importer
|
|
14
|
+
DEFAULT_SUITE = 'FHIR/fhir-test-cases'
|
|
15
|
+
|
|
16
|
+
def self.from_manifest(path, source_root: nil)
|
|
17
|
+
manifest = JSON.parse(File.read(path))
|
|
18
|
+
root = source_root || File.dirname(path)
|
|
19
|
+
new(
|
|
20
|
+
source_root: root,
|
|
21
|
+
suite_path: manifest.fetch('source'),
|
|
22
|
+
fixture_root: manifest['fixture_root'],
|
|
23
|
+
suite: manifest.fetch('suite', DEFAULT_SUITE),
|
|
24
|
+
suite_commit: manifest.fetch('suite_commit'),
|
|
25
|
+
target: manifest.fetch('target'),
|
|
26
|
+
model: manifest.fetch('model', 'plain'),
|
|
27
|
+
host_features: manifest.fetch('host_features', Capability.current.host_features),
|
|
28
|
+
case_ids: manifest['cases']
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(source_root:, suite_path:, suite_commit:, suite: DEFAULT_SUITE, target: '2.0.0',
|
|
33
|
+
fixture_root: nil, model: 'plain', host_features: Capability.current.host_features, case_ids: nil)
|
|
34
|
+
@source_root = File.expand_path(source_root)
|
|
35
|
+
@suite_path = relative_path(suite_path)
|
|
36
|
+
@fixture_root = fixture_root && relative_path(fixture_root)
|
|
37
|
+
@suite = suite.to_s.freeze
|
|
38
|
+
@suite_commit = suite_commit.to_s.freeze
|
|
39
|
+
@target = target.to_s.freeze
|
|
40
|
+
@model = model.to_s.freeze
|
|
41
|
+
@host_features = Array(host_features).map(&:to_s).freeze
|
|
42
|
+
@case_ids = case_ids&.map(&:to_s)&.freeze
|
|
43
|
+
validate_options
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def import
|
|
47
|
+
case File.extname(@suite_path).downcase
|
|
48
|
+
when '.xml' then import_xml
|
|
49
|
+
when '.yaml', '.yml' then import_yaml
|
|
50
|
+
else raise ArgumentError, "unsupported suite format: #{@suite_path}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def validate_options
|
|
57
|
+
raise ArgumentError, 'suite_commit must not be empty' if @suite_commit.empty?
|
|
58
|
+
raise ArgumentError, 'target must not be empty' if @target.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def import_xml
|
|
62
|
+
document = REXML::Document.new(File.read(absolute_path(@suite_path)))
|
|
63
|
+
tests = document.root.elements.to_a('group/test')
|
|
64
|
+
select_tests(tests).map { |test| record_for(test) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def import_yaml
|
|
68
|
+
document = YAML.safe_load_file(absolute_path(@suite_path), permitted_classes: [], aliases: false)
|
|
69
|
+
subject = deep_copy(document['subject']) if document.is_a?(Hash)
|
|
70
|
+
ordinal = 0
|
|
71
|
+
records = []
|
|
72
|
+
flatten_yaml_tests(document.fetch('tests')).each do |test, group, disabled|
|
|
73
|
+
expressions = test['expression'].is_a?(Array) ? test['expression'] : [test['expression']]
|
|
74
|
+
expressions.each do |expression|
|
|
75
|
+
ordinal += 1
|
|
76
|
+
records << yaml_record_for(test, expression, subject, group, disabled, ordinal)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
records
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def flatten_yaml_tests(value, group = nil, disabled: false, records: [])
|
|
83
|
+
Array(value).each { |entry| flatten_yaml_entry(entry, group, disabled, records) }
|
|
84
|
+
records
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def flatten_yaml_entry(entry, group, disabled, records)
|
|
88
|
+
raise ArgumentError, 'YAML suite entries must be mappings' unless entry.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
group_key = entry.keys.find { |key| key.to_s.start_with?('group') }
|
|
91
|
+
if group_key
|
|
92
|
+
group_name = group_key.to_s.delete_prefix('group:').strip
|
|
93
|
+
group_disabled = disabled || entry['disable'] == true
|
|
94
|
+
flatten_yaml_tests(entry.fetch(group_key), group_name, disabled: group_disabled, records: records)
|
|
95
|
+
elsif entry.key?('expression')
|
|
96
|
+
records << [entry, group, disabled || entry['disable'] == true]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def select_tests(tests)
|
|
101
|
+
return tests unless @case_ids
|
|
102
|
+
|
|
103
|
+
by_id = tests.to_h { |test| [test.attributes.fetch('name').to_s, test] }
|
|
104
|
+
@case_ids.map do |case_id|
|
|
105
|
+
by_id.fetch(case_id) { raise ArgumentError, "case not found in suite: #{case_id}" }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def record_for(test)
|
|
110
|
+
case_id = test.attributes.fetch('name').to_s
|
|
111
|
+
input_fixture = test.attributes['inputfile']&.to_s
|
|
112
|
+
resource, fixture_metadata = fixture_for(input_fixture)
|
|
113
|
+
record = build_record(
|
|
114
|
+
id: case_id,
|
|
115
|
+
expression: test.elements['expression']&.text.to_s.strip,
|
|
116
|
+
input_fixture: input_fixture,
|
|
117
|
+
model: @model,
|
|
118
|
+
capability: test.parent.attributes['name'].to_s,
|
|
119
|
+
expected: expected_outputs(test),
|
|
120
|
+
resource: resource,
|
|
121
|
+
variables: {},
|
|
122
|
+
origin: origin_for(case_id)
|
|
123
|
+
).merge(fixture_metadata)
|
|
124
|
+
add_field(record, 'error', parse_error(test))
|
|
125
|
+
finalize_record(record, fixture_metadata, disabled_test?(test))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def yaml_record_for(test, expression, subject, group, disabled, ordinal)
|
|
129
|
+
input_fixture = test['inputfile']&.to_s
|
|
130
|
+
resource, fixture_metadata = fixture_for(input_fixture, subject)
|
|
131
|
+
case_id = "#{@suite_path}:#{ordinal}"
|
|
132
|
+
record = build_record(
|
|
133
|
+
id: case_id,
|
|
134
|
+
expression: expression.to_s,
|
|
135
|
+
input_fixture: input_fixture,
|
|
136
|
+
model: (test['model'] || @model).to_s,
|
|
137
|
+
capability: group || 'unspecified',
|
|
138
|
+
expected: yaml_expected(test),
|
|
139
|
+
resource: resource,
|
|
140
|
+
variables: deep_copy(test['variables'] || {}),
|
|
141
|
+
origin: origin_for(test['desc'] || case_id)
|
|
142
|
+
).merge(fixture_metadata)
|
|
143
|
+
add_yaml_details(record, test)
|
|
144
|
+
finalize_record(record, fixture_metadata, disabled)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def add_yaml_details(record, test)
|
|
148
|
+
add_field(record, 'description', test['desc']&.to_s)
|
|
149
|
+
add_field(record, 'context', test['context']&.to_s)
|
|
150
|
+
add_true_field(record, 'error', test['error'] == true)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_record(id:, expression:, input_fixture:, model:, capability:, expected:, resource:, variables:, origin:)
|
|
154
|
+
{
|
|
155
|
+
'id' => id,
|
|
156
|
+
'suite' => @suite,
|
|
157
|
+
'suite_commit' => @suite_commit,
|
|
158
|
+
'expression' => expression,
|
|
159
|
+
'input_fixture' => normalized_fixture_path(input_fixture),
|
|
160
|
+
'model' => model,
|
|
161
|
+
'host_features' => @host_features,
|
|
162
|
+
'target' => @target,
|
|
163
|
+
'capability' => capability,
|
|
164
|
+
'expected' => expected,
|
|
165
|
+
'resource' => resource,
|
|
166
|
+
'variables' => variables,
|
|
167
|
+
'origin' => origin
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def expected_outputs(test)
|
|
172
|
+
test.elements.to_a('output').map { |output| parse_output(output) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def yaml_expected(test)
|
|
176
|
+
test.key?('result') ? normalize_yaml_result(test['result']) : []
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def origin_for(case_id)
|
|
180
|
+
{
|
|
181
|
+
'suite' => @suite,
|
|
182
|
+
'suite_commit' => @suite_commit,
|
|
183
|
+
'case' => case_id,
|
|
184
|
+
'path' => @suite_path
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def add_field(record, key, value)
|
|
189
|
+
record[key] = value unless value.nil?
|
|
190
|
+
record
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def add_true_field(record, key, condition)
|
|
194
|
+
record[key] = true if condition
|
|
195
|
+
record
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def finalize_record(record, fixture_metadata, disabled)
|
|
199
|
+
mark_not_run(record, fixture_metadata['not_run_reason']) if fixture_metadata['not_run_reason']
|
|
200
|
+
mark_not_run(record, 'disabled by source suite') if disabled
|
|
201
|
+
record
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def normalize_yaml_result(result)
|
|
205
|
+
result.is_a?(Array) ? deep_copy(result) : [deep_copy(result)]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def disabled_test?(test)
|
|
209
|
+
test.attributes['disabled'].to_s == 'true' || test.attributes['disable'].to_s == 'true'
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def mark_not_run(record, reason)
|
|
213
|
+
record['classification'] = 'not-run'
|
|
214
|
+
record['not_run_reason'] = reason
|
|
215
|
+
record['resource'] = nil if reason.include?('matching JSON fixture')
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def parse_error(test)
|
|
219
|
+
error = test.elements['error']
|
|
220
|
+
return unless error
|
|
221
|
+
|
|
222
|
+
data = {}
|
|
223
|
+
%w[class code].each do |key|
|
|
224
|
+
data[key] = error.attributes[key].to_s if error.attributes[key]
|
|
225
|
+
end
|
|
226
|
+
data['class'] ||= error.text.to_s.strip unless error.text.to_s.strip.empty?
|
|
227
|
+
data.empty? ? nil : data
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def parse_output(output)
|
|
231
|
+
value = output.text.to_s
|
|
232
|
+
case output.attributes['type'].to_s
|
|
233
|
+
when 'boolean' then value.strip.casecmp('true').zero?
|
|
234
|
+
when 'integer' then Integer(value.strip, 10)
|
|
235
|
+
when 'decimal' then value.strip
|
|
236
|
+
when 'string', 'code', 'id', 'uri', 'url', 'markdown' then value
|
|
237
|
+
else
|
|
238
|
+
{ '$type' => output.attributes['type'], 'value' => value }
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def fixture_for(input_fixture, fallback = {})
|
|
243
|
+
return [deep_copy(fallback || {}), {}] unless input_fixture
|
|
244
|
+
|
|
245
|
+
read_fixture(input_fixture)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def normalized_fixture_path(input_fixture)
|
|
249
|
+
input_fixture && fixture_path(input_fixture)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def read_fixture(input_fixture)
|
|
253
|
+
path = absolute_path(fixture_path(input_fixture))
|
|
254
|
+
case File.extname(path).downcase
|
|
255
|
+
when '.json'
|
|
256
|
+
[JSON.parse(File.read(path)), { 'fixture_source' => fixture_path(input_fixture) }]
|
|
257
|
+
when '.xml'
|
|
258
|
+
json_path = matching_json_fixture(path)
|
|
259
|
+
unless json_path
|
|
260
|
+
return [nil, {
|
|
261
|
+
'not_run_reason' => "no verified matching JSON fixture for #{fixture_path(input_fixture)}"
|
|
262
|
+
}]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
[JSON.parse(File.read(json_path)), { 'fixture_source' => relative_path(json_path) }]
|
|
266
|
+
else
|
|
267
|
+
raise ArgumentError, "unsupported fixture format: #{input_fixture}"
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def matching_json_fixture(xml_path)
|
|
272
|
+
candidates = [xml_path.sub(/\.xml\z/i, '.json')]
|
|
273
|
+
basename = File.basename(xml_path, File.extname(xml_path))
|
|
274
|
+
search_root = absolute_path(@fixture_root || File.dirname(@suite_path))
|
|
275
|
+
candidates.concat(Dir.glob(File.join(search_root, '**', "#{basename}.json")))
|
|
276
|
+
candidates.uniq.select { |candidate| File.file?(candidate) }.sort.find do |candidate|
|
|
277
|
+
resource = JSON.parse(File.read(candidate))
|
|
278
|
+
resource.is_a?(Hash) && resource['resourceType'].to_s == xml_resource_type(xml_path)
|
|
279
|
+
rescue JSON::ParserError
|
|
280
|
+
false
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def xml_resource_type(path)
|
|
285
|
+
REXML::Document.new(File.read(path)).root.name.to_s.split(':').last
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def deep_copy(value)
|
|
289
|
+
case value
|
|
290
|
+
when Hash
|
|
291
|
+
value.each_with_object({}) { |(key, item), copy| copy[deep_copy(key)] = deep_copy(item) }
|
|
292
|
+
when Array
|
|
293
|
+
value.map { |item| deep_copy(item) }
|
|
294
|
+
else
|
|
295
|
+
value
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def fixture_path(input_fixture)
|
|
300
|
+
relative_path(File.join(@fixture_root || File.dirname(@suite_path), input_fixture))
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def relative_path(path)
|
|
304
|
+
expanded = File.expand_path(path.to_s, @source_root)
|
|
305
|
+
prefix = "#{@source_root}#{File::SEPARATOR}"
|
|
306
|
+
return expanded.delete_prefix(prefix) if expanded.start_with?(prefix)
|
|
307
|
+
return '.' if expanded == @source_root
|
|
308
|
+
|
|
309
|
+
raise ArgumentError, "path escapes source root: #{path}"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def absolute_path(path)
|
|
313
|
+
File.join(@source_root, path)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FHIRPath
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :code, :span, :expression, :original_cause
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, code:, span: nil, expression: nil, cause: nil)
|
|
8
|
+
super(message)
|
|
9
|
+
@code = code.to_sym
|
|
10
|
+
@span = span
|
|
11
|
+
@expression = expression
|
|
12
|
+
@original_cause = cause
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
data = { code: code, message: message }
|
|
17
|
+
data[:span] = span.to_h if span
|
|
18
|
+
data[:expression] = expression if expression
|
|
19
|
+
data
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class ParseError < Error
|
|
24
|
+
def initialize(message = 'invalid expression', code: :parse_error, **kwargs)
|
|
25
|
+
super(message, code: code, **kwargs)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class EvaluationError < Error
|
|
30
|
+
def initialize(message = 'expression could not be evaluated', code: :evaluation_error, **kwargs)
|
|
31
|
+
super(message, code: code, **kwargs)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class SingletonError < EvaluationError
|
|
36
|
+
def initialize(message = 'a singleton value was required', code: :singleton_required, **kwargs)
|
|
37
|
+
super(message, code: code, **kwargs)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class TypeError < EvaluationError
|
|
42
|
+
def initialize(message = 'value has an incompatible type', code: :type_error, **kwargs)
|
|
43
|
+
super(message, code: code, **kwargs)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class UnknownFunctionError < EvaluationError
|
|
48
|
+
def initialize(message = 'unknown function', code: :unknown_function, **kwargs)
|
|
49
|
+
super(message, code: code, **kwargs)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class UnknownConstantError < EvaluationError
|
|
54
|
+
def initialize(message = 'unknown external constant', code: :unknown_constant, **kwargs)
|
|
55
|
+
super(message, code: code, **kwargs)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class ModelError < EvaluationError
|
|
60
|
+
def initialize(message = 'model navigation failed', code: :model_error, **kwargs)
|
|
61
|
+
super(message, code: code, **kwargs)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class HostError < EvaluationError
|
|
66
|
+
def initialize(message = 'host service failed', code: :host_error, **kwargs)
|
|
67
|
+
super(message, code: code, **kwargs)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class UnsupportedFeatureError < EvaluationError
|
|
72
|
+
def initialize(message = 'feature is not supported', code: :unsupported_feature, **kwargs)
|
|
73
|
+
super(message, code: code, **kwargs)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FHIRPath
|
|
4
|
+
# Per-call state. Compiled expressions never retain a context or resource.
|
|
5
|
+
class EvaluationContext
|
|
6
|
+
attr_reader :root, :focus, :variables, :model, :host, :functions, :capability,
|
|
7
|
+
:options, :index, :total
|
|
8
|
+
|
|
9
|
+
def initialize(root:, focus: nil, variables: {}, model: nil, host: nil,
|
|
10
|
+
functions: nil, capability: nil, options: {}, index: nil, total: nil)
|
|
11
|
+
@root = Collection.from(root)
|
|
12
|
+
@focus = Collection.from(focus || root)
|
|
13
|
+
@variables = variables.transform_keys(&:to_s).freeze
|
|
14
|
+
@model = model || PlainModel.new
|
|
15
|
+
@host = host || HostServices.default
|
|
16
|
+
@functions = functions || FunctionRegistry.standard
|
|
17
|
+
@capability = capability || Capability.current
|
|
18
|
+
@options = options.dup.freeze
|
|
19
|
+
@index = index
|
|
20
|
+
@total = total
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def derive(focus:, variables: self.variables, index: nil, total: nil)
|
|
25
|
+
self.class.new(root: root, focus: focus, variables: variables, model: model,
|
|
26
|
+
host: host, functions: functions, capability: capability,
|
|
27
|
+
options: options, index: index, total: total)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|