asciidoctor-doctest 1.5.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.
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