asciidoctor-doctest 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.adoc +0 -0
  3. data/LICENSE +21 -0
  4. data/README.adoc +327 -0
  5. data/Rakefile +12 -0
  6. data/data/examples/asciidoc/block_admonition.adoc +27 -0
  7. data/data/examples/asciidoc/block_audio.adoc +13 -0
  8. data/data/examples/asciidoc/block_colist.adoc +46 -0
  9. data/data/examples/asciidoc/block_dlist.adoc +99 -0
  10. data/data/examples/asciidoc/block_example.adoc +21 -0
  11. data/data/examples/asciidoc/block_floating_title.adoc +27 -0
  12. data/data/examples/asciidoc/block_image.adoc +28 -0
  13. data/data/examples/asciidoc/block_listing.adoc +68 -0
  14. data/data/examples/asciidoc/block_literal.adoc +30 -0
  15. data/data/examples/asciidoc/block_olist.adoc +55 -0
  16. data/data/examples/asciidoc/block_open.adoc +40 -0
  17. data/data/examples/asciidoc/block_outline.adoc +60 -0
  18. data/data/examples/asciidoc/block_page_break.adoc +6 -0
  19. data/data/examples/asciidoc/block_paragraph.adoc +17 -0
  20. data/data/examples/asciidoc/block_pass.adoc +5 -0
  21. data/data/examples/asciidoc/block_preamble.adoc +19 -0
  22. data/data/examples/asciidoc/block_quote.adoc +30 -0
  23. data/data/examples/asciidoc/block_sidebar.adoc +22 -0
  24. data/data/examples/asciidoc/block_stem.adoc +28 -0
  25. data/data/examples/asciidoc/block_table.adoc +168 -0
  26. data/data/examples/asciidoc/block_thematic_break.adoc +2 -0
  27. data/data/examples/asciidoc/block_toc.adoc +50 -0
  28. data/data/examples/asciidoc/block_ulist.adoc +43 -0
  29. data/data/examples/asciidoc/block_verse.adoc +37 -0
  30. data/data/examples/asciidoc/block_video.adoc +24 -0
  31. data/data/examples/asciidoc/document.adoc +51 -0
  32. data/data/examples/asciidoc/embedded.adoc +10 -0
  33. data/data/examples/asciidoc/inline_anchor.adoc +27 -0
  34. data/data/examples/asciidoc/inline_break.adoc +8 -0
  35. data/data/examples/asciidoc/inline_button.adoc +3 -0
  36. data/data/examples/asciidoc/inline_callout.adoc +5 -0
  37. data/data/examples/asciidoc/inline_footnote.adoc +9 -0
  38. data/data/examples/asciidoc/inline_image.adoc +44 -0
  39. data/data/examples/asciidoc/inline_kbd.adoc +7 -0
  40. data/data/examples/asciidoc/inline_menu.adoc +11 -0
  41. data/data/examples/asciidoc/inline_quoted.adoc +59 -0
  42. data/data/examples/asciidoc/section.adoc +74 -0
  43. data/doc/img/doctest-diag.odf +0 -0
  44. data/doc/img/doctest-diag.svg +56 -0
  45. data/doc/img/failing-test-term.gif +0 -0
  46. data/lib/asciidoctor-doctest.rb +1 -0
  47. data/lib/asciidoctor/doctest.rb +30 -0
  48. data/lib/asciidoctor/doctest/asciidoc/examples_suite.rb +44 -0
  49. data/lib/asciidoctor/doctest/asciidoc_renderer.rb +103 -0
  50. data/lib/asciidoctor/doctest/base_example.rb +161 -0
  51. data/lib/asciidoctor/doctest/base_examples_suite.rb +188 -0
  52. data/lib/asciidoctor/doctest/core_ext.rb +49 -0
  53. data/lib/asciidoctor/doctest/generator.rb +63 -0
  54. data/lib/asciidoctor/doctest/generator_task.rb +111 -0
  55. data/lib/asciidoctor/doctest/html/example.rb +21 -0
  56. data/lib/asciidoctor/doctest/html/examples_suite.rb +111 -0
  57. data/lib/asciidoctor/doctest/html/html_beautifier.rb +17 -0
  58. data/lib/asciidoctor/doctest/html/normalizer.rb +118 -0
  59. data/lib/asciidoctor/doctest/minitest_diffy.rb +74 -0
  60. data/lib/asciidoctor/doctest/test.rb +120 -0
  61. data/lib/asciidoctor/doctest/version.rb +5 -0
  62. data/spec/asciidoc/examples_suite_spec.rb +99 -0
  63. data/spec/base_example_spec.rb +176 -0
  64. data/spec/core_ext_spec.rb +67 -0
  65. data/spec/html/examples_suite_spec.rb +249 -0
  66. data/spec/html/normalizer_spec.rb +70 -0
  67. data/spec/shared_examples/base_examples_suite.rb +262 -0
  68. data/spec/spec_helper.rb +33 -0
  69. data/spec/support/matchers.rb +7 -0
  70. data/spec/test_spec.rb +164 -0
  71. metadata +360 -0
@@ -0,0 +1,103 @@
1
+ require 'active_support/core_ext/array/wrap'
2
+ require 'asciidoctor'
3
+ require 'asciidoctor/converter/template'
4
+
5
+ module Asciidoctor
6
+ module DocTest
7
+ ##
8
+ # This class is basically a wrapper for +Asciidoctor.render+ that allows to
9
+ # preset and validate some common parameters.
10
+ class AsciidocRenderer
11
+
12
+ attr_reader :backend_name, :converter, :template_dirs
13
+
14
+ ##
15
+ # @param backend_name [#to_s] the name of the tested backend.
16
+ #
17
+ # @param converter [Class, Asciidoctor::Converter::Base, nil]
18
+ # the backend's converter class (or its instance). If not
19
+ # specified, the default converter for the specified backend will
20
+ # be used.
21
+ #
22
+ # @param template_dirs [Array<String>, String] path of the directory (or
23
+ # multiple directories) where to look for the backend's templates.
24
+ # Relative paths are referenced from the working directory.
25
+ #
26
+ # @param templates_fallback [Boolean] allow to fall back to using an
27
+ # appropriate built-in converter when there is no required
28
+ # template in the tested backend?
29
+ # This is actually a default behaviour in Asciidoctor, but since
30
+ # it's inappropriate for testing of custom backends, it's disabled
31
+ # by default.
32
+ #
33
+ # @raise [ArgumentError] if some path from the +template_dirs+ doesn't
34
+ # exist or is not a directory.
35
+ #
36
+ def initialize(backend_name: nil, converter: nil, template_dirs: [],
37
+ templates_fallback: false)
38
+
39
+ @backend_name = backend_name.freeze
40
+ @converter = converter
41
+ @converter ||= TemplateConverterAdapter unless template_dirs.empty? || templates_fallback
42
+
43
+ template_dirs = Array.wrap(template_dirs).freeze
44
+
45
+ unless template_dirs.all? { |path| Dir.exist? path }
46
+ fail ArgumentError, "Templates directory '#{path}' doesn't exist!"
47
+ end
48
+ @template_dirs = template_dirs unless template_dirs.empty?
49
+ end
50
+
51
+ ##
52
+ # Renders the given +text+ in AsciiDoc syntax with Asciidoctor using the
53
+ # tested backend.
54
+ #
55
+ # @param text [#to_s] the input text in AsciiDoc syntax.
56
+ # @param opts [Hash] options to pass to Asciidoctor.
57
+ # @return [String] rendered input.
58
+ #
59
+ def render(text, opts = {})
60
+ renderer_opts = {
61
+ safe: :safe,
62
+ backend: backend_name,
63
+ converter: converter,
64
+ template_dirs: template_dirs
65
+ }.merge(opts)
66
+
67
+ Asciidoctor.render(text.to_s, renderer_opts)
68
+ end
69
+ end
70
+
71
+ ##
72
+ # @private
73
+ # Adapter for +Asciidoctor::Converter::TemplateConverter+.
74
+ class TemplateConverterAdapter < SimpleDelegator
75
+
76
+ # Placeholder to be written in a rendered output in place of the node's
77
+ # content that cannot be rendered due to missing template.
78
+ NOT_FOUND_MARKER = '--TEMPLATE NOT FOUND--'
79
+
80
+ def initialize(backend, opts = {})
81
+ super Asciidoctor::Converter::TemplateConverter.new(backend, opts[:template_dirs], opts)
82
+ end
83
+
84
+ ##
85
+ # Delegates to the template converter and returns results, or prints
86
+ # warning and returns {NOT_FOUND_MARKER} if there is no template to
87
+ # handle the specified +template_name+.
88
+ def convert(node, template_name = nil, opts = {})
89
+ template_name ||= node.node_name
90
+
91
+ if handles? template_name
92
+ super
93
+ else
94
+ warn "Could not find a custom template to handle template_name: #{template_name}"
95
+ NOT_FOUND_MARKER
96
+ end
97
+ end
98
+
99
+ # Alias for backward compatibility.
100
+ alias_method :convert_with_options, :convert
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,161 @@
1
+ require 'active_support/core_ext/object/blank'
2
+ require 'active_support/core_ext/object/deep_dup'
3
+ require 'active_support/core_ext/object/instance_variables'
4
+
5
+ module Asciidoctor
6
+ module DocTest
7
+ ##
8
+ # This class represents a single test example.
9
+ class BaseExample
10
+
11
+ NAME_SEPARATOR = ':'
12
+
13
+ # @return [String] the first part of the name.
14
+ attr_accessor :group_name
15
+
16
+ # @return [String] the second part of the name.
17
+ attr_accessor :local_name
18
+
19
+ # @return [String] raw content.
20
+ attr_accessor :content
21
+
22
+ # @return [String] description.
23
+ attr_accessor :desc
24
+
25
+ # @return [Hash] options.
26
+ attr_accessor :opts
27
+
28
+ ##
29
+ # @param name (see #name=)
30
+ # @param content [String]
31
+ # @param desc [String] description
32
+ # @param opts [Hash] options
33
+ #
34
+ def initialize(name, content: '', desc: '', opts: {})
35
+ self.name = name
36
+ @content = content
37
+ @desc = desc
38
+ @opts = opts
39
+ end
40
+
41
+ ##
42
+ # @return [String] the name in format +group_name:local_name+.
43
+ def name
44
+ [@group_name, @local_name].join(NAME_SEPARATOR)
45
+ end
46
+
47
+ ##
48
+ # @param name [String, Array<String>] a String in format
49
+ # +group_name:local_name+, or an Array with the group_name and the
50
+ # local_name.
51
+ def name=(*name)
52
+ name.flatten!
53
+ @group_name, @local_name = name.one? ? name.first.split(NAME_SEPARATOR, 2) : name
54
+ end
55
+
56
+ ##
57
+ # @param pattern [String] the glob pattern (e.g. +block_*:with*+).
58
+ # @return [Boolean] +true+ if the name matches against the +pattern+,
59
+ # +false+ otherwise.
60
+ def name_match?(pattern)
61
+ globs = pattern.split(NAME_SEPARATOR, 2)
62
+ [group_name, local_name].zip(globs).all? do |name, glob|
63
+ File.fnmatch? glob || '*', name.to_s
64
+ end
65
+ end
66
+
67
+ ##
68
+ # Returns value(s) of the named option.
69
+ #
70
+ # @param name [#to_sym] the option name.
71
+ # @return [Array<String>, Boolean] the option value.
72
+ #
73
+ def [](name)
74
+ @opts[name.to_sym]
75
+ end
76
+
77
+ ##
78
+ # Sets or unsets the option.
79
+ #
80
+ # @param name [#to_sym] the option name.
81
+ # @param value [Array<String>, Boolean, String, nil] the option value;
82
+ # +Array+ and +Boolean+ are just assigned to the option, +nil+
83
+ # removes the option and others are added to the option as an
84
+ # array item.
85
+ #
86
+ def []=(name, value)
87
+ case value
88
+ when nil
89
+ @opts.delete(name.to_sym)
90
+ when Array, TrueClass, FalseClass
91
+ @opts[name.to_sym] = value.deep_dup
92
+ else
93
+ (@opts[name.to_sym] ||= []) << value.dup
94
+ end
95
+ end
96
+
97
+ ##
98
+ # @return [Boolean] +true+ when the content is blank, +false+ otherwise.
99
+ def empty?
100
+ content.blank?
101
+ end
102
+
103
+ ##
104
+ # @note The default implementation returns content as-is; subclasses
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 The default implementation returns content as-is; subclasses
116
+ # should override this method.
117
+ #
118
+ # @return [String] a human-readable (formatted) version of the content.
119
+ #
120
+ def to_s
121
+ content.dup
122
+ end
123
+
124
+ ##
125
+ # @param other the object to compare with.
126
+ # @return [Boolean] +true+ if +self+ and +other+ equals in attributes
127
+ # +group_name+, +local_name+ and +content_normalized+ (compared
128
+ # using +==+), otherwise +false+.
129
+ def ==(other)
130
+ [:group_name, :local_name, :content_normalized].all? do |name|
131
+ other.respond_to?(name) &&
132
+ public_send(name) == other.public_send(name)
133
+ end
134
+ end
135
+
136
+ ##
137
+ # @param other [Object] the object to compare with.
138
+ # @return [Boolean] +true+ if +self+ and +other+ are instances of the same
139
+ # class and all their attributes are equal (compared using +==+),
140
+ # otherwise +false+.
141
+ def eql?(other)
142
+ self.class == other.class &&
143
+ instance_variables == other.instance_variables
144
+ end
145
+
146
+ # :nocov:
147
+ def hash
148
+ self.class.hash ^ instance_variables.hash
149
+ end
150
+ # :nocov:
151
+
152
+ private
153
+
154
+ def initialize_copy(source)
155
+ instance_variables.each do |name|
156
+ instance_variable_set name, instance_variable_get(name).deep_dup
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,188 @@
1
+ require 'active_support/core_ext/enumerable'
2
+ require 'active_support/core_ext/array/wrap'
3
+ require 'active_support/core_ext/class/attribute'
4
+ require 'pathname'
5
+
6
+ module Asciidoctor
7
+ module DocTest
8
+ ##
9
+ # @abstract
10
+ # This is a base class that should be extended for specific example
11
+ # formats.
12
+ class BaseExamplesSuite
13
+
14
+ attr_reader :examples_path, :file_ext
15
+
16
+ ##
17
+ # @param file_ext [String] the filename extension (e.g. +.adoc+) of the
18
+ # examples group files.
19
+ #
20
+ # @param examples_path [String, Array<String>] path of the directory (or
21
+ # multiple directories) where to look for the examples.
22
+ #
23
+ def initialize(file_ext:, examples_path: DocTest.examples_path)
24
+ @file_ext = file_ext.freeze
25
+ @examples_path = Array.wrap(examples_path).freeze
26
+ @examples_cache = {}
27
+ end
28
+
29
+ ##
30
+ # Parses group of examples from the +input+ and returns array of the
31
+ # parsed examples.
32
+ #
33
+ # @abstract
34
+ # @param input [#each_line] the file content to parse.
35
+ # @param group_name [String] the examples group name.
36
+ # @return [Array<BaseExample>] parsed examples.
37
+ # :nocov:
38
+ def parse(input, group_name)
39
+ fail NotImplementedError
40
+ end
41
+ # :nocov:
42
+
43
+ ##
44
+ # Serializes the given examples into string.
45
+ #
46
+ # @abstract
47
+ # @param examples [Array<BaseExample>]
48
+ # @return [String]
49
+ # :nocov:
50
+ def serialize(examples)
51
+ fail NotImplementedError
52
+ end
53
+ # :nocov:
54
+
55
+ ##
56
+ # Returns a new example based on the given input example.
57
+ # This method should render (AsciiDoc) content of the given example using
58
+ # the preconfigured +renderer+ and eventually apply some transformations.
59
+ #
60
+ # XXX describe it better...
61
+ #
62
+ # @abstract
63
+ # @param example [BaseExample] the input example to convert.
64
+ # @param opts [Hash] the options to pass to a new example.
65
+ # @param renderer [#render]
66
+ # @return [BaseExample]
67
+ # :nocov:
68
+ def convert_example(example, opts, renderer)
69
+ fail NotImplementedError
70
+ end
71
+ # :nocov:
72
+
73
+ ##
74
+ # (see BaseExample#initialize)
75
+ def create_example(*args)
76
+ BaseExample.new(*args)
77
+ end
78
+
79
+ ##
80
+ # Returns enumerator that yields pairs of the examples from this suite
81
+ # and the +other_suite+ (examples with the same name) in order of this
82
+ # suite.
83
+ #
84
+ # When some example is missing in this or the +other_suite+, it's
85
+ # substituted with an empty example of the corresponding type and name.
86
+ # In the case of missing example from this suite, the pair is placed at
87
+ # the end of the examples group.
88
+ #
89
+ # @param other_suite [BaseExamplesSuite]
90
+ # @return [Enumerator]
91
+ #
92
+ def pair_with(other_suite)
93
+ Enumerator.new do |y|
94
+ group_names.each do |group_name|
95
+ theirs_by_name = other_suite.read_examples(group_name).index_by(&:name)
96
+
97
+ read_examples(group_name).each do |ours|
98
+ theirs = theirs_by_name.delete(ours.name)
99
+ theirs ||= other_suite.create_example(ours.name)
100
+ y.yield ours, theirs
101
+ end
102
+
103
+ theirs_by_name.each_value do |theirs|
104
+ y.yield create_example(theirs.name), theirs
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ ##
111
+ # Reads the named examples group from file(s). When multiple matching
112
+ # files are found on the {#examples_path}, it merges them together. If
113
+ # two files defines example with the same name, then the first wins (i.e.
114
+ # first on the {#examples_path}).
115
+ #
116
+ # @param group_name [String] the examples group name.
117
+ # @return [Array<Example>] an array of parsed examples, or an empty array
118
+ # if no file found.
119
+ #
120
+ def read_examples(group_name)
121
+ @examples_cache[group_name] ||= read_files(group_name)
122
+ .map { |data| parse(data, group_name) }
123
+ .flatten
124
+ .uniq(&:name)
125
+ end
126
+
127
+ ##
128
+ # Writes the given examples into file(s)
129
+ # +{examples_path.first}/{group_name}{file_ext}+. Already existing files
130
+ # will be overwritten!
131
+ #
132
+ # @param examples [Array<BaseExample>]
133
+ #
134
+ def write_examples(examples)
135
+ examples.group_by(&:group_name).each do |group_name, exmpls|
136
+ path = file_path(@examples_path.first, group_name)
137
+ File.write(path, serialize(exmpls))
138
+ end
139
+ end
140
+
141
+ ##
142
+ # Replaces existing examples with the given ones.
143
+ #
144
+ # @param examples [Array<BaseExample] the updated examples.
145
+ # @see #write_examples
146
+ #
147
+ def update_examples(examples)
148
+ examples.group_by(&:group_name).each do |group, exmpls|
149
+ # replace cached examples with the given ones and preserve original order
150
+ updated_group = [ read_examples(group), exmpls ]
151
+ .map_send(:index_by, &:local_name)
152
+ .reduce(:merge)
153
+ .values
154
+
155
+ write_examples updated_group
156
+ @examples_cache.delete(group) # flush cache
157
+ end
158
+ end
159
+
160
+ ##
161
+ # Returns names of all the example groups (files with {#file_ext})
162
+ # found on the {#examples_path}.
163
+ #
164
+ # @return [Array<String>]
165
+ #
166
+ def group_names
167
+ @examples_path.reduce(Set.new) { |acc, path|
168
+ acc | Pathname.new(path).each_child
169
+ .select { |p| p.file? && p.extname == @file_ext }
170
+ .map { |p| p.sub_ext('').basename.to_s }
171
+ }.sort
172
+ end
173
+
174
+ private
175
+
176
+ def read_files(file_name)
177
+ @examples_path
178
+ .map { |dir| file_path(dir, file_name) }
179
+ .select(&:readable?)
180
+ .map(&:read)
181
+ end
182
+
183
+ def file_path(base_dir, file_name)
184
+ Pathname.new(base_dir).join(file_name).sub_ext(@file_ext)
185
+ end
186
+ end
187
+ end
188
+ end