asciidoctor-doctest 1.5.2.0 → 2.0.0.beta.1
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/LICENSE +1 -1
- data/README.adoc +48 -68
- data/features/fixtures/html-slim/Rakefile +5 -11
- data/features/generator_html.feature +6 -6
- data/features/test_html.feature +70 -28
- data/lib/asciidoctor/doctest.rb +11 -14
- data/lib/asciidoctor/doctest/asciidoc_converter.rb +85 -0
- data/lib/asciidoctor/doctest/{base_example.rb → example.rb} +6 -27
- data/lib/asciidoctor/doctest/factory.rb +36 -0
- data/lib/asciidoctor/doctest/generator.rb +30 -23
- data/lib/asciidoctor/doctest/html/converter.rb +64 -0
- data/lib/asciidoctor/doctest/{html/normalizer.rb → html_normalizer.rb} +4 -4
- data/lib/asciidoctor/doctest/io.rb +14 -0
- data/lib/asciidoctor/doctest/{asciidoc/examples_suite.rb → io/asciidoc.rb} +4 -8
- data/lib/asciidoctor/doctest/{base_examples_suite.rb → io/base.rb} +28 -46
- data/lib/asciidoctor/doctest/io/xml.rb +69 -0
- data/lib/asciidoctor/doctest/no_fallback_template_converter.rb +42 -0
- data/lib/asciidoctor/doctest/rake_tasks.rb +229 -0
- data/lib/asciidoctor/doctest/test_reporter.rb +110 -0
- data/lib/asciidoctor/doctest/tester.rb +134 -0
- data/lib/asciidoctor/doctest/version.rb +1 -1
- data/spec/asciidoc_converter_spec.rb +64 -0
- data/spec/{base_example_spec.rb → example_spec.rb} +4 -5
- data/spec/factory_spec.rb +46 -0
- data/spec/html/converter_spec.rb +95 -0
- data/spec/{html/normalizer_spec.rb → html_normalizer_spec.rb} +1 -1
- data/spec/{asciidoc/examples_suite_spec.rb → io/asciidoc_spec.rb} +3 -8
- data/spec/{html/examples_suite_spec.rb → io/xml_spec.rb} +3 -106
- data/spec/no_fallback_template_converter_spec.rb +38 -0
- data/spec/shared_examples/{base_examples_suite.rb → base_examples.rb} +25 -28
- data/spec/spec_helper.rb +4 -0
- data/spec/tester_spec.rb +153 -0
- metadata +52 -59
- data/features/fixtures/html-slim/test/html_test.rb +0 -6
- data/features/fixtures/html-slim/test/test_helper.rb +0 -5
- data/lib/asciidoctor/doctest/asciidoc_renderer.rb +0 -111
- data/lib/asciidoctor/doctest/generator_task.rb +0 -115
- data/lib/asciidoctor/doctest/html/example.rb +0 -21
- data/lib/asciidoctor/doctest/html/examples_suite.rb +0 -118
- data/lib/asciidoctor/doctest/test.rb +0 -125
- data/spec/asciidoc_renderer_spec.rb +0 -103
- data/spec/test_spec.rb +0 -164
@@ -6,7 +6,7 @@ module Asciidoctor
|
|
6
6
|
module DocTest
|
7
7
|
##
|
8
8
|
# This class represents a single test example.
|
9
|
-
class
|
9
|
+
class Example
|
10
10
|
|
11
11
|
NAME_SEPARATOR = ':'
|
12
12
|
|
@@ -101,39 +101,18 @@ module Asciidoctor
|
|
101
101
|
end
|
102
102
|
|
103
103
|
##
|
104
|
-
# @
|
105
|
-
# should override this method.
|
106
|
-
#
|
107
|
-
# @return [String] copy of the content in a form that is suitable for
|
108
|
-
# semantic comparison with another content.
|
109
|
-
#
|
110
|
-
def content_normalized
|
111
|
-
content.dup
|
112
|
-
end
|
113
|
-
|
114
|
-
##
|
115
|
-
# @note (see #content_normalized)
|
116
|
-
#
|
117
|
-
# @return [String] copy of the content in a human-readable (formatted)
|
118
|
-
# shape for pretty print.
|
119
|
-
#
|
120
|
-
def content_pretty
|
121
|
-
content.dup
|
122
|
-
end
|
123
|
-
|
124
|
-
##
|
125
|
-
# @return (see #content_pretty)
|
104
|
+
# @return [String] a copy of the content.
|
126
105
|
def to_s
|
127
|
-
|
106
|
+
content.dup
|
128
107
|
end
|
129
108
|
|
130
109
|
##
|
131
110
|
# @param other the object to compare with.
|
132
111
|
# @return [Boolean] +true+ if +self+ and +other+ equals in attributes
|
133
|
-
# +group_name+, +local_name+ and +
|
134
|
-
#
|
112
|
+
# +group_name+, +local_name+ and +content+ (compared using +==+),
|
113
|
+
# otherwise +false+.
|
135
114
|
def ==(other)
|
136
|
-
[:group_name, :local_name, :
|
115
|
+
[:group_name, :local_name, :content].all? do |name|
|
137
116
|
other.respond_to?(name) &&
|
138
117
|
public_send(name) == other.public_send(name)
|
139
118
|
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Asciidoctor::DocTest
|
2
|
+
module Factory
|
3
|
+
|
4
|
+
##
|
5
|
+
# Registers the given class in the factory under the specified name.
|
6
|
+
#
|
7
|
+
# @param name [#to_sym] the name under which to register the class.
|
8
|
+
# @param klass [Class] the class to register.
|
9
|
+
# @param default_opts [Hash] default options to be passed into the class'
|
10
|
+
# initializer. May be overriden by +opts+ passed to {.create}.
|
11
|
+
# @return [self]
|
12
|
+
#
|
13
|
+
def register(name, klass, default_opts = {})
|
14
|
+
@factory_registry ||= {}
|
15
|
+
@factory_registry[name.to_sym] = ->(opts) { klass.new(default_opts.merge(opts)) }
|
16
|
+
self
|
17
|
+
end
|
18
|
+
|
19
|
+
##
|
20
|
+
# @param name [#to_sym] name of the class to create.
|
21
|
+
# @param opts [Hash] options to be passed into the class' initializer.
|
22
|
+
# @return [Object] a new instance of the class registered under the
|
23
|
+
# specified name.
|
24
|
+
# @raise ArgumentError if no class was found for the given name.
|
25
|
+
#
|
26
|
+
def create(name, opts = {})
|
27
|
+
@factory_registry ||= {}
|
28
|
+
|
29
|
+
if (obj = @factory_registry[name.to_sym])
|
30
|
+
obj.call(opts)
|
31
|
+
else
|
32
|
+
fail ArgumentError, "No class registered with name: #{name}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -5,62 +5,69 @@ using Corefines::String::color
|
|
5
5
|
|
6
6
|
module Asciidoctor
|
7
7
|
module DocTest
|
8
|
-
|
8
|
+
class Generator
|
9
9
|
|
10
10
|
##
|
11
|
-
#
|
12
|
-
# input examples
|
11
|
+
# @param input_suite [IO::Base] an instance of {IO::Base} subclass to
|
12
|
+
# read the reference input examples.
|
13
13
|
#
|
14
|
-
# @param output_suite [
|
15
|
-
#
|
16
|
-
# examples.
|
14
|
+
# @param output_suite [IO::Base] an instance of {IO::Base} subclass to
|
15
|
+
# read and generate the output examples.
|
17
16
|
#
|
18
|
-
# @param
|
19
|
-
#
|
20
|
-
#
|
17
|
+
# @param converter [#call] a callable that accepts a string content of
|
18
|
+
# an input example and a hash with options for the converter, and
|
19
|
+
# returns the converted content.
|
21
20
|
#
|
22
|
-
# @param
|
21
|
+
# @param io [#<<] output stream where to write log messages.
|
22
|
+
#
|
23
|
+
def initialize(input_suite, output_suite, converter, io = $stdout)
|
24
|
+
@input_suite = input_suite
|
25
|
+
@output_suite = output_suite
|
26
|
+
@converter = converter
|
27
|
+
@io = io
|
28
|
+
end
|
29
|
+
|
30
|
+
##
|
31
|
+
# Generates missing, or rewrite existing output examples from the
|
32
|
+
# input examples converted using the +converter+.
|
23
33
|
#
|
24
34
|
# @param pattern [String] glob-like pattern to select examples to
|
25
|
-
# (re)generate (see {
|
35
|
+
# (re)generate (see {Example#name_match?}).
|
26
36
|
#
|
27
37
|
# @param rewrite [Boolean] whether to rewrite an already existing
|
28
38
|
# example.
|
29
39
|
#
|
30
|
-
|
31
|
-
#
|
32
|
-
def self.generate!(output_suite, input_suite, renderer, pattern: '*:*',
|
33
|
-
rewrite: false, log_os: $stdout)
|
40
|
+
def generate!(pattern: '*:*', rewrite: false)
|
34
41
|
updated = []
|
35
42
|
|
36
|
-
input_suite.pair_with(output_suite).each do |input, output|
|
43
|
+
@input_suite.pair_with(@output_suite).each do |input, output|
|
37
44
|
next unless input.name_match? pattern
|
38
45
|
|
39
46
|
log = ->(msg, color = :default) do
|
40
|
-
|
47
|
+
@io << " --> #{(msg % input.name).color(color)}\n" if @io
|
41
48
|
end
|
42
49
|
|
43
50
|
if input.empty?
|
44
51
|
log["Unknown %s, doesn't exist in input examples!"]
|
45
52
|
else
|
46
|
-
|
47
|
-
|
53
|
+
actual, expected = @converter.convert_examples(input, output)
|
54
|
+
generated = output.dup.tap { |ex| ex.content = actual }
|
48
55
|
|
49
56
|
if output.empty?
|
50
57
|
log['Generating %s', :magenta]
|
51
|
-
updated <<
|
52
|
-
elsif
|
58
|
+
updated << generated
|
59
|
+
elsif actual == expected
|
53
60
|
log['Unchanged %s', :green]
|
54
61
|
elsif rewrite
|
55
62
|
log['Rewriting %s', :red]
|
56
|
-
updated <<
|
63
|
+
updated << generated
|
57
64
|
else
|
58
65
|
log['Skipping %s', :yellow]
|
59
66
|
end
|
60
67
|
end
|
61
68
|
end
|
62
69
|
|
63
|
-
output_suite.update_examples updated unless updated.empty?
|
70
|
+
@output_suite.update_examples updated unless updated.empty?
|
64
71
|
end
|
65
72
|
end
|
66
73
|
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'asciidoctor/doctest/html_normalizer'
|
2
|
+
require 'corefines'
|
3
|
+
require 'htmlbeautifier'
|
4
|
+
require 'nokogiri'
|
5
|
+
|
6
|
+
using Corefines::Object::then
|
7
|
+
|
8
|
+
module Asciidoctor::DocTest
|
9
|
+
module HTML
|
10
|
+
class Converter < AsciidocConverter
|
11
|
+
|
12
|
+
def initialize(paragraph_xpath: './p/node()', **opts)
|
13
|
+
@paragraph_xpath = paragraph_xpath
|
14
|
+
super opts
|
15
|
+
end
|
16
|
+
|
17
|
+
def convert_examples(input_exmpl, output_exmpl)
|
18
|
+
opts = output_exmpl.opts.dup
|
19
|
+
|
20
|
+
# The header & footer are excluded by default; always enable for document examples.
|
21
|
+
opts[:header_footer] ||= input_exmpl.name.start_with?('document')
|
22
|
+
|
23
|
+
# When asserting inline examples, defaults to ignore paragraph "wrapper".
|
24
|
+
opts[:include] ||= (@paragraph_xpath if input_exmpl.name.start_with? 'inline_')
|
25
|
+
|
26
|
+
actual = convert(input_exmpl.content, header_footer: opts[:header_footer])
|
27
|
+
.then { |s| parse_html s, !opts[:header_footer] }
|
28
|
+
.then { |h| find_nodes h, opts[:include] }
|
29
|
+
.then { |h| remove_nodes h, opts[:exclude] }
|
30
|
+
.then { |h| normalize(h) }
|
31
|
+
|
32
|
+
expected = normalize(output_exmpl.content)
|
33
|
+
|
34
|
+
[actual, expected]
|
35
|
+
end
|
36
|
+
|
37
|
+
protected
|
38
|
+
|
39
|
+
def normalize(content)
|
40
|
+
content = parse_html(content) if content.is_a? String
|
41
|
+
HtmlBeautifier.beautify(content.normalize!)
|
42
|
+
end
|
43
|
+
|
44
|
+
def find_nodes(html, xpaths)
|
45
|
+
Array(xpaths).reduce(html) do |htm, xpath|
|
46
|
+
# XPath returns NodeSet, but we need DocumentFragment, so convert it again.
|
47
|
+
parse_html htm.xpath(xpath).to_html
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def remove_nodes(html, xpaths)
|
52
|
+
return html unless xpaths
|
53
|
+
|
54
|
+
Array(xpaths).each_with_object(html.clone) do |xpath, htm|
|
55
|
+
htm.xpath(xpath).remove
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def parse_html(str, fragment = true)
|
60
|
+
fragment ? ::Nokogiri::HTML.fragment(str) : ::Nokogiri::HTML.parse(str)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -3,8 +3,8 @@ require 'nokogiri'
|
|
3
3
|
|
4
4
|
using Corefines::Object::try
|
5
5
|
|
6
|
-
module Asciidoctor
|
7
|
-
module
|
6
|
+
module Asciidoctor
|
7
|
+
module DocTest
|
8
8
|
##
|
9
9
|
# Module to be included into +Nokogiri::HTML::Document+
|
10
10
|
# or +DocumentFragment+ to add {#normalize!} feature.
|
@@ -12,7 +12,7 @@ module Asciidoctor::DocTest
|
|
12
12
|
# @example
|
13
13
|
# Nokogiri::HTML.parse(str).normalize!
|
14
14
|
# Nokogiri::HTML.fragment(str).normalize!
|
15
|
-
module
|
15
|
+
module HtmlNormalizer
|
16
16
|
|
17
17
|
##
|
18
18
|
# Normalizes the HTML document or fragment so it can be easily compared
|
@@ -116,5 +116,5 @@ module Asciidoctor::DocTest
|
|
116
116
|
end
|
117
117
|
|
118
118
|
[Nokogiri::HTML::Document, Nokogiri::HTML::DocumentFragment].each do |klass|
|
119
|
-
klass.send :include, Asciidoctor::DocTest::
|
119
|
+
klass.send :include, Asciidoctor::DocTest::HtmlNormalizer
|
120
120
|
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'asciidoctor/doctest/io/base'
|
2
|
+
require 'asciidoctor/doctest/io/asciidoc'
|
3
|
+
require 'asciidoctor/doctest/io/xml'
|
4
|
+
require 'asciidoctor/doctest/factory'
|
5
|
+
|
6
|
+
module Asciidoctor::DocTest
|
7
|
+
module IO
|
8
|
+
extend Factory
|
9
|
+
|
10
|
+
register :asciidoc, Asciidoc, file_ext: '.adoc'
|
11
|
+
register :xml, XML, file_ext: '.xml'
|
12
|
+
register :html, XML, file_ext: '.html'
|
13
|
+
end
|
14
|
+
end
|
@@ -1,4 +1,4 @@
|
|
1
|
-
require 'asciidoctor/doctest/
|
1
|
+
require 'asciidoctor/doctest/io/base'
|
2
2
|
require 'corefines'
|
3
3
|
|
4
4
|
using Corefines::Enumerable::map_send
|
@@ -6,9 +6,9 @@ using Corefines::Object[:blank?, :presence]
|
|
6
6
|
using Corefines::String::concat!
|
7
7
|
|
8
8
|
module Asciidoctor::DocTest
|
9
|
-
module
|
9
|
+
module IO
|
10
10
|
##
|
11
|
-
# Subclass of {
|
11
|
+
# Subclass of {IO::Base} for reference input examples.
|
12
12
|
#
|
13
13
|
# @example Format of the example's header
|
14
14
|
# // .example-name
|
@@ -22,11 +22,7 @@ module Asciidoctor::DocTest
|
|
22
22
|
#
|
23
23
|
# NOTE: The trailing new line (below this) will be removed.
|
24
24
|
#
|
25
|
-
class
|
26
|
-
|
27
|
-
def initialize(file_ext: '.adoc', **kwargs)
|
28
|
-
super
|
29
|
-
end
|
25
|
+
class Asciidoc < Base
|
30
26
|
|
31
27
|
def parse(input, group_name)
|
32
28
|
examples = []
|
@@ -4,28 +4,28 @@ require 'pathname'
|
|
4
4
|
using Corefines::Object::blank?
|
5
5
|
using Corefines::Enumerable::index_by
|
6
6
|
|
7
|
-
module Asciidoctor
|
8
|
-
module
|
7
|
+
module Asciidoctor::DocTest
|
8
|
+
module IO
|
9
9
|
##
|
10
10
|
# @abstract
|
11
11
|
# This is a base class that should be extended for specific example
|
12
12
|
# formats.
|
13
|
-
class
|
13
|
+
class Base
|
14
14
|
|
15
|
-
attr_reader :
|
15
|
+
attr_reader :path, :file_ext
|
16
16
|
|
17
17
|
##
|
18
|
+
# @param path [String, Array<String>] path of the directory (or multiple
|
19
|
+
# directories) where to look for the examples.
|
20
|
+
#
|
18
21
|
# @param file_ext [String] the filename extension (e.g. +.adoc+) of the
|
19
22
|
# examples group files. Must not be +nil+ or blank. (required)
|
20
23
|
#
|
21
|
-
|
22
|
-
# multiple directories) where to look for the examples.
|
23
|
-
#
|
24
|
-
def initialize(file_ext: nil, examples_path: DocTest.examples_path)
|
24
|
+
def initialize(path: DocTest.examples_path, file_ext: nil)
|
25
25
|
fail ArgumentError, 'file_ext must not be blank or nil' if file_ext.blank?
|
26
26
|
|
27
|
+
@path = Array(path).freeze
|
27
28
|
@file_ext = file_ext.strip.freeze
|
28
|
-
@examples_path = Array(examples_path).freeze
|
29
29
|
@examples_cache = {}
|
30
30
|
end
|
31
31
|
|
@@ -36,7 +36,7 @@ module Asciidoctor
|
|
36
36
|
# @abstract
|
37
37
|
# @param input [#each_line] the file content to parse.
|
38
38
|
# @param group_name [String] the examples group name.
|
39
|
-
# @return [Array<
|
39
|
+
# @return [Array<Example>] parsed examples.
|
40
40
|
# :nocov:
|
41
41
|
def parse(input, group_name)
|
42
42
|
fail NotImplementedError
|
@@ -47,7 +47,7 @@ module Asciidoctor
|
|
47
47
|
# Serializes the given examples into string.
|
48
48
|
#
|
49
49
|
# @abstract
|
50
|
-
# @param examples [Array<
|
50
|
+
# @param examples [Array<Example>]
|
51
51
|
# @return [String]
|
52
52
|
# :nocov:
|
53
53
|
def serialize(examples)
|
@@ -55,30 +55,6 @@ module Asciidoctor
|
|
55
55
|
end
|
56
56
|
# :nocov:
|
57
57
|
|
58
|
-
##
|
59
|
-
# Returns a new example based on the given input example.
|
60
|
-
# This method should render (AsciiDoc) content of the given example using
|
61
|
-
# the preconfigured +renderer+ and eventually apply some transformations.
|
62
|
-
#
|
63
|
-
# XXX describe it better...
|
64
|
-
#
|
65
|
-
# @abstract
|
66
|
-
# @param example [BaseExample] the input example to convert.
|
67
|
-
# @param opts [Hash] the options to pass to a new example.
|
68
|
-
# @param renderer [#convert]
|
69
|
-
# @return [BaseExample]
|
70
|
-
# :nocov:
|
71
|
-
def convert_example(example, opts, renderer)
|
72
|
-
fail NotImplementedError
|
73
|
-
end
|
74
|
-
# :nocov:
|
75
|
-
|
76
|
-
##
|
77
|
-
# (see BaseExample#initialize)
|
78
|
-
def create_example(*args)
|
79
|
-
BaseExample.new(*args)
|
80
|
-
end
|
81
|
-
|
82
58
|
##
|
83
59
|
# Returns enumerator that yields pairs of the examples from this suite
|
84
60
|
# and the +other_suite+ (examples with the same name) in order of this
|
@@ -89,7 +65,7 @@ module Asciidoctor
|
|
89
65
|
# In the case of missing example from this suite, the pair is placed at
|
90
66
|
# the end of the examples group.
|
91
67
|
#
|
92
|
-
# @param other_suite [
|
68
|
+
# @param other_suite [Base]
|
93
69
|
# @return [Enumerator]
|
94
70
|
#
|
95
71
|
def pair_with(other_suite)
|
@@ -112,9 +88,9 @@ module Asciidoctor
|
|
112
88
|
|
113
89
|
##
|
114
90
|
# Reads the named examples group from file(s). When multiple matching
|
115
|
-
# files are found on the {#
|
91
|
+
# files are found on the {#path}, it merges them together. If
|
116
92
|
# two files defines example with the same name, then the first wins (i.e.
|
117
|
-
# first on the {#
|
93
|
+
# first on the {#path}).
|
118
94
|
#
|
119
95
|
# @param group_name [String] the examples group name.
|
120
96
|
# @return [Array<Example>] an array of parsed examples, or an empty array
|
@@ -129,14 +105,14 @@ module Asciidoctor
|
|
129
105
|
|
130
106
|
##
|
131
107
|
# Writes the given examples into file(s)
|
132
|
-
# +{
|
133
|
-
#
|
108
|
+
# +{path.first}/{group_name}{file_ext}+. Already existing files will
|
109
|
+
# be overwritten!
|
134
110
|
#
|
135
|
-
# @param examples [Array<
|
111
|
+
# @param examples [Array<Example>]
|
136
112
|
#
|
137
113
|
def write_examples(examples)
|
138
114
|
examples.group_by(&:group_name).each do |group_name, exmpls|
|
139
|
-
path = file_path(@
|
115
|
+
path = file_path(@path.first, group_name)
|
140
116
|
File.write(path, serialize(exmpls))
|
141
117
|
end
|
142
118
|
end
|
@@ -144,7 +120,7 @@ module Asciidoctor
|
|
144
120
|
##
|
145
121
|
# Replaces existing examples with the given ones.
|
146
122
|
#
|
147
|
-
# @param examples [Array<
|
123
|
+
# @param examples [Array<Example] the updated examples.
|
148
124
|
# @see #write_examples
|
149
125
|
#
|
150
126
|
def update_examples(examples)
|
@@ -162,12 +138,12 @@ module Asciidoctor
|
|
162
138
|
|
163
139
|
##
|
164
140
|
# Returns names of all the example groups (files with {#file_ext})
|
165
|
-
# found on the {#
|
141
|
+
# found on the {#path}.
|
166
142
|
#
|
167
143
|
# @return [Array<String>]
|
168
144
|
#
|
169
145
|
def group_names
|
170
|
-
@
|
146
|
+
@path.reduce(Set.new) { |acc, path|
|
171
147
|
acc | Pathname.new(path).each_child
|
172
148
|
.select { |p| p.file? && p.extname == @file_ext }
|
173
149
|
.map { |p| p.sub_ext('').basename.to_s }
|
@@ -176,6 +152,12 @@ module Asciidoctor
|
|
176
152
|
|
177
153
|
protected
|
178
154
|
|
155
|
+
##
|
156
|
+
# (see Example#initialize)
|
157
|
+
def create_example(*args)
|
158
|
+
DocTest::Example.new(*args)
|
159
|
+
end
|
160
|
+
|
179
161
|
##
|
180
162
|
# Converts the given options into the format used in examples file.
|
181
163
|
#
|
@@ -207,7 +189,7 @@ module Asciidoctor
|
|
207
189
|
private
|
208
190
|
|
209
191
|
def read_files(file_name)
|
210
|
-
@
|
192
|
+
@path
|
211
193
|
.map { |dir| file_path(dir, file_name) }
|
212
194
|
.select(&:readable?)
|
213
195
|
.map(&:read)
|