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.
- checksums.yaml +7 -0
- data/CHANGELOG.adoc +0 -0
- data/LICENSE +21 -0
- data/README.adoc +327 -0
- data/Rakefile +12 -0
- data/data/examples/asciidoc/block_admonition.adoc +27 -0
- data/data/examples/asciidoc/block_audio.adoc +13 -0
- data/data/examples/asciidoc/block_colist.adoc +46 -0
- data/data/examples/asciidoc/block_dlist.adoc +99 -0
- data/data/examples/asciidoc/block_example.adoc +21 -0
- data/data/examples/asciidoc/block_floating_title.adoc +27 -0
- data/data/examples/asciidoc/block_image.adoc +28 -0
- data/data/examples/asciidoc/block_listing.adoc +68 -0
- data/data/examples/asciidoc/block_literal.adoc +30 -0
- data/data/examples/asciidoc/block_olist.adoc +55 -0
- data/data/examples/asciidoc/block_open.adoc +40 -0
- data/data/examples/asciidoc/block_outline.adoc +60 -0
- data/data/examples/asciidoc/block_page_break.adoc +6 -0
- data/data/examples/asciidoc/block_paragraph.adoc +17 -0
- data/data/examples/asciidoc/block_pass.adoc +5 -0
- data/data/examples/asciidoc/block_preamble.adoc +19 -0
- data/data/examples/asciidoc/block_quote.adoc +30 -0
- data/data/examples/asciidoc/block_sidebar.adoc +22 -0
- data/data/examples/asciidoc/block_stem.adoc +28 -0
- data/data/examples/asciidoc/block_table.adoc +168 -0
- data/data/examples/asciidoc/block_thematic_break.adoc +2 -0
- data/data/examples/asciidoc/block_toc.adoc +50 -0
- data/data/examples/asciidoc/block_ulist.adoc +43 -0
- data/data/examples/asciidoc/block_verse.adoc +37 -0
- data/data/examples/asciidoc/block_video.adoc +24 -0
- data/data/examples/asciidoc/document.adoc +51 -0
- data/data/examples/asciidoc/embedded.adoc +10 -0
- data/data/examples/asciidoc/inline_anchor.adoc +27 -0
- data/data/examples/asciidoc/inline_break.adoc +8 -0
- data/data/examples/asciidoc/inline_button.adoc +3 -0
- data/data/examples/asciidoc/inline_callout.adoc +5 -0
- data/data/examples/asciidoc/inline_footnote.adoc +9 -0
- data/data/examples/asciidoc/inline_image.adoc +44 -0
- data/data/examples/asciidoc/inline_kbd.adoc +7 -0
- data/data/examples/asciidoc/inline_menu.adoc +11 -0
- data/data/examples/asciidoc/inline_quoted.adoc +59 -0
- data/data/examples/asciidoc/section.adoc +74 -0
- data/doc/img/doctest-diag.odf +0 -0
- data/doc/img/doctest-diag.svg +56 -0
- data/doc/img/failing-test-term.gif +0 -0
- data/lib/asciidoctor-doctest.rb +1 -0
- data/lib/asciidoctor/doctest.rb +30 -0
- data/lib/asciidoctor/doctest/asciidoc/examples_suite.rb +44 -0
- data/lib/asciidoctor/doctest/asciidoc_renderer.rb +103 -0
- data/lib/asciidoctor/doctest/base_example.rb +161 -0
- data/lib/asciidoctor/doctest/base_examples_suite.rb +188 -0
- data/lib/asciidoctor/doctest/core_ext.rb +49 -0
- data/lib/asciidoctor/doctest/generator.rb +63 -0
- data/lib/asciidoctor/doctest/generator_task.rb +111 -0
- data/lib/asciidoctor/doctest/html/example.rb +21 -0
- data/lib/asciidoctor/doctest/html/examples_suite.rb +111 -0
- data/lib/asciidoctor/doctest/html/html_beautifier.rb +17 -0
- data/lib/asciidoctor/doctest/html/normalizer.rb +118 -0
- data/lib/asciidoctor/doctest/minitest_diffy.rb +74 -0
- data/lib/asciidoctor/doctest/test.rb +120 -0
- data/lib/asciidoctor/doctest/version.rb +5 -0
- data/spec/asciidoc/examples_suite_spec.rb +99 -0
- data/spec/base_example_spec.rb +176 -0
- data/spec/core_ext_spec.rb +67 -0
- data/spec/html/examples_suite_spec.rb +249 -0
- data/spec/html/normalizer_spec.rb +70 -0
- data/spec/shared_examples/base_examples_suite.rb +262 -0
- data/spec/spec_helper.rb +33 -0
- data/spec/support/matchers.rb +7 -0
- data/spec/test_spec.rb +164 -0
- 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
|