asciidoctor-doctest 1.5.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|