coradoc 2.0.20 → 2.0.22
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/lib/coradoc/cli.rb +26 -1
- data/lib/coradoc/coradoc.rb +66 -8
- data/lib/coradoc/core_model/children_content.rb +5 -0
- data/lib/coradoc/core_model/frontmatter/frontmatter_value.rb +61 -0
- data/lib/coradoc/core_model/has_children.rb +23 -0
- data/lib/coradoc/core_model/include.rb +43 -0
- data/lib/coradoc/core_model/include_level_offset.rb +71 -0
- data/lib/coradoc/core_model/include_options.rb +100 -0
- data/lib/coradoc/core_model/raw_inline_element.rb +16 -0
- data/lib/coradoc/core_model/structural_element.rb +5 -0
- data/lib/coradoc/core_model.rb +5 -0
- data/lib/coradoc/errors.rb +56 -0
- data/lib/coradoc/include_resolver/filesystem.rb +84 -0
- data/lib/coradoc/include_resolver.rb +67 -0
- data/lib/coradoc/include_selectors/indent.rb +54 -0
- data/lib/coradoc/include_selectors/level_offset.rb +86 -0
- data/lib/coradoc/include_selectors/lines.rb +60 -0
- data/lib/coradoc/include_selectors/tags.rb +138 -0
- data/lib/coradoc/include_selectors.rb +26 -0
- data/lib/coradoc/performance_regression.rb +19 -7
- data/lib/coradoc/resolve_includes.rb +202 -0
- data/lib/coradoc/version.rb +1 -1
- metadata +15 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Coradoc
|
|
6
|
+
class IncludeResolver
|
|
7
|
+
# Default include resolver: reads files from the local filesystem,
|
|
8
|
+
# rooted at +base_dir+. Path-traversal protection is ON by default
|
|
9
|
+
# to match asciidoctor's +:safe+ mode.
|
|
10
|
+
#
|
|
11
|
+
# Pass +allow_unsafe: true+ to opt out (matches +:unsafe+ mode).
|
|
12
|
+
class Filesystem < IncludeResolver
|
|
13
|
+
attr_reader :base_dir, :allow_unsafe, :max_bytes
|
|
14
|
+
|
|
15
|
+
# @param base_dir [String] absolute path to the directory includes
|
|
16
|
+
# are resolved against. Usually the directory of the including
|
|
17
|
+
# document. Relative paths inside the resolver are expanded
|
|
18
|
+
# against this.
|
|
19
|
+
# @param allow_unsafe [Boolean] when false (default), refuses any
|
|
20
|
+
# resolved path that escapes +base_dir+ via .. or that is an
|
|
21
|
+
# absolute path outside +base_dir+.
|
|
22
|
+
# @param max_bytes [Integer, nil] if set, refuses files larger
|
|
23
|
+
# than this. Defense against accidental megabyte-include loops.
|
|
24
|
+
def initialize(base_dir:, allow_unsafe: false, max_bytes: nil)
|
|
25
|
+
@base_dir = File.expand_path(base_dir)
|
|
26
|
+
@allow_unsafe = allow_unsafe
|
|
27
|
+
@max_bytes = max_bytes
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(target:, base_dir:, options:, context:)
|
|
31
|
+
full = File.expand_path(target, base_dir)
|
|
32
|
+
enforce_safety!(full, base_dir) unless allow_unsafe
|
|
33
|
+
raise Coradoc::IncludeNotFoundError, target unless File.file?(full)
|
|
34
|
+
|
|
35
|
+
enforce_size!(full, target)
|
|
36
|
+
|
|
37
|
+
encoding = options&.file_encoding || 'utf-8'
|
|
38
|
+
read_with_encoding(full, encoding)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def enforce_safety!(full_path, base_dir)
|
|
44
|
+
base_expanded = File.expand_path(base_dir)
|
|
45
|
+
base_with_sep = "#{base_expanded}#{File::SEPARATOR}"
|
|
46
|
+
|
|
47
|
+
return if full_path == base_expanded || full_path.start_with?(base_with_sep)
|
|
48
|
+
|
|
49
|
+
raise Coradoc::UnsafeIncludeError, full_path
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def enforce_size!(full_path, target)
|
|
53
|
+
return unless max_bytes
|
|
54
|
+
return unless File.exist?(full_path)
|
|
55
|
+
|
|
56
|
+
size = File.size(full_path)
|
|
57
|
+
return if size <= max_bytes
|
|
58
|
+
|
|
59
|
+
raise Coradoc::IncludeTooLargeError, target
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def read_with_encoding(full_path, encoding_name)
|
|
63
|
+
content = File.binread(full_path)
|
|
64
|
+
return content if encoding_name.to_s.downcase == 'binary'
|
|
65
|
+
|
|
66
|
+
content.force_encoding(clean_encoding_name(encoding_name))
|
|
67
|
+
encoded = content.encode('utf-8', invalid: :replace, undef: :replace)
|
|
68
|
+
normalize_line_endings(encoded)
|
|
69
|
+
rescue ArgumentError => e
|
|
70
|
+
raise Coradoc::Error, "Unsupported encoding #{encoding_name.inspect}: #{e.message}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# asciidoctor parity: normalize CRLF and lone CR to LF so the parser
|
|
74
|
+
# sees consistent line endings regardless of the source platform.
|
|
75
|
+
def normalize_line_endings(text)
|
|
76
|
+
text.gsub(/\r\n?/, "\n")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def clean_encoding_name(name)
|
|
80
|
+
name.to_s.downcase.sub(/^utf-8$/, 'utf-8')
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
# Include resolver protocol.
|
|
5
|
+
#
|
|
6
|
+
# A resolver is anything that responds to +#call(target:, base_dir:,
|
|
7
|
+
# options:, context:)+ and returns the raw bytes of the included
|
|
8
|
+
# target, BEFORE tag/line/indent selectors are applied. Selectors
|
|
9
|
+
# live in the processor; the resolver only fetches bytes.
|
|
10
|
+
#
|
|
11
|
+
# The default resolver is {IncludeResolver::Filesystem}. Custom
|
|
12
|
+
# resolvers (HTTP, database, generated) plug in here without changes
|
|
13
|
+
# to the processor (OCP).
|
|
14
|
+
#
|
|
15
|
+
# Contract:
|
|
16
|
+
# call(target:, base_dir:, options:, context:) -> String
|
|
17
|
+
# target String path/URL as authored
|
|
18
|
+
# base_dir String absolute path to the including file's dir
|
|
19
|
+
# options CoreModel::IncludeOptions
|
|
20
|
+
# context Hash recursion state (depth, parent_chain, ...)
|
|
21
|
+
#
|
|
22
|
+
# Raises Coradoc::IncludeNotFoundError if the target cannot be located.
|
|
23
|
+
# The processor's missing-file policy decides what to do with that.
|
|
24
|
+
#
|
|
25
|
+
# This base class is provided for documentation and for is_a? checks.
|
|
26
|
+
# Custom resolvers do NOT need to inherit — duck typing on the call
|
|
27
|
+
# signature is sufficient. ( SPEC 13 uses a bare Object with
|
|
28
|
+
# define_singleton_method, which we support.)
|
|
29
|
+
class IncludeResolver
|
|
30
|
+
autoload :Filesystem, "#{__dir__}/include_resolver/filesystem"
|
|
31
|
+
|
|
32
|
+
def call(target:, base_dir:, options:, context:)
|
|
33
|
+
raise NotImplementedError,
|
|
34
|
+
"#{self.class} must implement #call(target:, base_dir:, options:, context:)"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Coerce +value+ into something that quacks like an IncludeResolver.
|
|
39
|
+
# - Already-callable objects (respond to :call) are returned as-is.
|
|
40
|
+
# - Symbols are interpreted as built-in names: +:filesystem+,
|
|
41
|
+
# +:filesystem_strict+ (path-traversal protection on).
|
|
42
|
+
#
|
|
43
|
+
# @param value [Object, nil] the resolver or built-in name
|
|
44
|
+
# @param base_dir [String] required for built-in filesystem resolvers
|
|
45
|
+
# @param allow_unsafe [Boolean] opt-out of path-traversal protection
|
|
46
|
+
# @return [Object] something callable as a resolver
|
|
47
|
+
def coerce(value, base_dir:, allow_unsafe: false)
|
|
48
|
+
return Filesystem.new(base_dir: base_dir, allow_unsafe: allow_unsafe) if value.nil?
|
|
49
|
+
|
|
50
|
+
case value
|
|
51
|
+
when Symbol then coerce_symbol(value, base_dir: base_dir, allow_unsafe: allow_unsafe)
|
|
52
|
+
else value
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def coerce_symbol(name, base_dir:, allow_unsafe:)
|
|
59
|
+
case name
|
|
60
|
+
when :filesystem then Filesystem.new(base_dir: base_dir, allow_unsafe: allow_unsafe)
|
|
61
|
+
else
|
|
62
|
+
raise ArgumentError, "Unknown include resolver: #{name.inspect}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module IncludeSelectors
|
|
5
|
+
# Indent normalization. Two modes per asciidoctor:
|
|
6
|
+
#
|
|
7
|
+
# indent=0 strip all leading whitespace from every line
|
|
8
|
+
# indent=N normalize leading whitespace to exactly N spaces
|
|
9
|
+
# nil pass through unchanged
|
|
10
|
+
module Indent
|
|
11
|
+
# @param text [String]
|
|
12
|
+
# @param options [Coradoc::CoreModel::IncludeOptions]
|
|
13
|
+
# @return [String]
|
|
14
|
+
def self.call(text, options:)
|
|
15
|
+
return text if options.indent.nil?
|
|
16
|
+
|
|
17
|
+
if options.indent.zero?
|
|
18
|
+
strip_all(text)
|
|
19
|
+
else
|
|
20
|
+
reindent(text, options.indent)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def strip_all(text)
|
|
28
|
+
text.lines.map { |line| line.sub(/\A[[:space:]]+/, '') }.join
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reindent(text, target)
|
|
32
|
+
min_indent = text.lines
|
|
33
|
+
.reject { |l| l.strip.empty? }
|
|
34
|
+
.map { |l| l.length - l.lstrip.length }
|
|
35
|
+
.min || 0
|
|
36
|
+
|
|
37
|
+
pad = ' ' * target
|
|
38
|
+
text.lines.map do |line|
|
|
39
|
+
stripped = strip_common_prefix(line, min_indent)
|
|
40
|
+
if stripped.strip.empty?
|
|
41
|
+
stripped.strip + "\n"
|
|
42
|
+
else
|
|
43
|
+
pad + stripped
|
|
44
|
+
end
|
|
45
|
+
end.join
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def strip_common_prefix(line, count)
|
|
49
|
+
line.sub(/\A[[:space:]]{0,#{count}}/, '')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module IncludeSelectors
|
|
5
|
+
# Shifts section heading levels in a parsed CoreModel subtree.
|
|
6
|
+
#
|
|
7
|
+
# Applied AFTER parsing — works on SectionElement instances. Two modes:
|
|
8
|
+
#
|
|
9
|
+
# relative (+N / -N) every SectionElement#level += delta
|
|
10
|
+
# absolute (N) the FIRST section's level becomes N, and
|
|
11
|
+
# descendants shift by the same delta so their
|
|
12
|
+
# relative structure is preserved (asciidoctor
|
|
13
|
+
# behavior).
|
|
14
|
+
#
|
|
15
|
+
# The processor passes a freshly-parsed subtree to this selector, so
|
|
16
|
+
# there are no external references and in-place mutation is safe.
|
|
17
|
+
module LevelOffset
|
|
18
|
+
# @param core [Coradoc::CoreModel::Base] freshly parsed — mutated
|
|
19
|
+
# @param options [Coradoc::CoreModel::IncludeOptions]
|
|
20
|
+
# @return [Coradoc::CoreModel::Base] the same core (mutated)
|
|
21
|
+
def self.call(core, options:)
|
|
22
|
+
offset = options.leveloffset
|
|
23
|
+
return core if offset.nil?
|
|
24
|
+
|
|
25
|
+
first_level = find_first_level(core)
|
|
26
|
+
actual_delta = compute_actual_delta(offset, first_level)
|
|
27
|
+
return core if actual_delta.zero?
|
|
28
|
+
|
|
29
|
+
walk_and_shift(core, actual_delta)
|
|
30
|
+
core
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def compute_actual_delta(offset, first_level)
|
|
37
|
+
case offset.mode
|
|
38
|
+
when 'relative' then offset.delta
|
|
39
|
+
when 'absolute'
|
|
40
|
+
return 0 if first_level.nil?
|
|
41
|
+
|
|
42
|
+
offset.delta - first_level
|
|
43
|
+
else 0
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def find_first_level(node)
|
|
48
|
+
case node
|
|
49
|
+
when Coradoc::CoreModel::SectionElement
|
|
50
|
+
node.level || 1
|
|
51
|
+
when Coradoc::CoreModel::StructuralElement, Coradoc::CoreModel::Block
|
|
52
|
+
walk_for_first_level(node.children)
|
|
53
|
+
else
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def walk_for_first_level(children)
|
|
59
|
+
return nil if children.nil?
|
|
60
|
+
|
|
61
|
+
children.each do |child|
|
|
62
|
+
lvl = find_first_level(child)
|
|
63
|
+
return lvl unless lvl.nil?
|
|
64
|
+
end
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def walk_and_shift(node, delta)
|
|
69
|
+
case node
|
|
70
|
+
when Coradoc::CoreModel::SectionElement
|
|
71
|
+
node.level = [(node.level || 1) + delta, 0].max
|
|
72
|
+
walk_children(node, delta)
|
|
73
|
+
when Coradoc::CoreModel::StructuralElement, Coradoc::CoreModel::Block
|
|
74
|
+
walk_children(node, delta)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def walk_children(node, delta)
|
|
79
|
+
return if node.children.nil?
|
|
80
|
+
|
|
81
|
+
node.children.each { |c| walk_and_shift(c, delta) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module IncludeSelectors
|
|
5
|
+
# Line-range selection. Parses asciidoctor-style specs:
|
|
6
|
+
#
|
|
7
|
+
# N single line
|
|
8
|
+
# A..B inclusive range
|
|
9
|
+
# A..B;C;D..E multiple, semicolon-separated
|
|
10
|
+
#
|
|
11
|
+
# Out-of-bounds clamps gracefully (SPEC 3.4). One-based indexing
|
|
12
|
+
# (asciidoctor convention).
|
|
13
|
+
module Lines
|
|
14
|
+
SPEC_PART = %r{
|
|
15
|
+
\A
|
|
16
|
+
(?<start>\d+)
|
|
17
|
+
(?:\.\.(?<finish>\d+))?
|
|
18
|
+
\z
|
|
19
|
+
}x.freeze
|
|
20
|
+
|
|
21
|
+
# @param text [String]
|
|
22
|
+
# @param options [Coradoc::CoreModel::IncludeOptions]
|
|
23
|
+
# @return [String]
|
|
24
|
+
def self.call(text, options:)
|
|
25
|
+
return text unless options.lines?
|
|
26
|
+
|
|
27
|
+
ranges = parse_spec(options.lines_spec, max: text.lines.length)
|
|
28
|
+
return '' if ranges.empty?
|
|
29
|
+
|
|
30
|
+
indices = ranges.flat_map { |start, finish| (start..finish).to_a }.uniq.sort
|
|
31
|
+
text.lines.values_at(*indices.map { |i| i - 1 }).join
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def parse_spec(spec, max:)
|
|
38
|
+
spec.split(';').map(&:strip).filter_map do |part|
|
|
39
|
+
parse_part(part, max: max)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_part(part, max:)
|
|
44
|
+
SPEC_PART.match(part) do |m|
|
|
45
|
+
start = m[:start].to_i
|
|
46
|
+
finish = m[:finish] ? m[:finish].to_i : start
|
|
47
|
+
[start, finish].minmax.map { |n| clamp(n, max: max) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def clamp(n, max:)
|
|
52
|
+
return nil if n < 1
|
|
53
|
+
return max if n > max
|
|
54
|
+
|
|
55
|
+
n
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module IncludeSelectors
|
|
5
|
+
# Extracts regions delimited by +// tag::name[]+ / +// end::name[]+
|
|
6
|
+
# markers from included content. Supports single, multiple,
|
|
7
|
+
# wildcard (*), and inverted (**) selection.
|
|
8
|
+
#
|
|
9
|
+
# Tag markers themselves are never emitted as text (SPEC 2.8).
|
|
10
|
+
# Unknown tag names yield empty content (SPEC 2.6). Nested tag
|
|
11
|
+
# regions are included when an outer tag is selected (SPEC 2.7).
|
|
12
|
+
#
|
|
13
|
+
# Marker forms recognized:
|
|
14
|
+
# // tag::name[] (AsciiDoc line comment)
|
|
15
|
+
# ## tag::name[] (Markdown line comment, permissive)
|
|
16
|
+
#
|
|
17
|
+
# Markers may appear on their own line; they must be the first
|
|
18
|
+
# non-whitespace token on that line (asciidoctor convention).
|
|
19
|
+
module Tags
|
|
20
|
+
MARKER_OPEN = /\A[[:space:]]*(?:\/\/+|#+)[[:space:]]*tag::([^\[\]]+)\[[[:space:]]*\]/
|
|
21
|
+
MARKER_CLOSE = /\A[[:space:]]*(?:\/\/+|#+)[[:space:]]*end::([^\[\]]+)\[[[:space:]]*\]/
|
|
22
|
+
|
|
23
|
+
# @param text [String] raw included file content
|
|
24
|
+
# @param options [Coradoc::CoreModel::IncludeOptions]
|
|
25
|
+
# @return [String] filtered content
|
|
26
|
+
def self.call(text, options:)
|
|
27
|
+
return text unless options.tags?
|
|
28
|
+
|
|
29
|
+
if options.tags_inverted
|
|
30
|
+
inverted(text)
|
|
31
|
+
elsif options.tags_wildcard
|
|
32
|
+
wildcard(text)
|
|
33
|
+
else
|
|
34
|
+
named(text, options.tags)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def scan_markers(text)
|
|
42
|
+
markers = []
|
|
43
|
+
text.each_line.with_index do |line, idx|
|
|
44
|
+
if (m = MARKER_OPEN.match(line))
|
|
45
|
+
markers << [:open, m[1].strip, idx]
|
|
46
|
+
elsif (m = MARKER_CLOSE.match(line))
|
|
47
|
+
markers << [:close, m[1].strip, idx]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
markers
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def named(text, names)
|
|
54
|
+
wanted_indices = selected_line_indices(text, names).to_set
|
|
55
|
+
pick_lines(text, wanted_indices)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def selected_line_indices(text, wanted_names)
|
|
59
|
+
markers = scan_markers(text)
|
|
60
|
+
wanted = wanted_names.to_set
|
|
61
|
+
open_stack = []
|
|
62
|
+
emit = {}
|
|
63
|
+
|
|
64
|
+
markers.each do |kind, name, idx|
|
|
65
|
+
case kind
|
|
66
|
+
when :open
|
|
67
|
+
next unless wanted.include?(name)
|
|
68
|
+
|
|
69
|
+
open_stack.push([name, idx])
|
|
70
|
+
when :close
|
|
71
|
+
next unless wanted.include?(name)
|
|
72
|
+
|
|
73
|
+
open_idx = open_stack.rindex { |n, _| n == name }
|
|
74
|
+
next unless open_idx
|
|
75
|
+
|
|
76
|
+
_open_name, open_line = open_stack.delete_at(open_idx)
|
|
77
|
+
(open_line + 1...idx).each { |i| emit[i] = true }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
emit.keys
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def wildcard(text)
|
|
85
|
+
markers = scan_markers(text)
|
|
86
|
+
open_stack = []
|
|
87
|
+
emit = {}
|
|
88
|
+
|
|
89
|
+
markers.each do |kind, _name, idx|
|
|
90
|
+
case kind
|
|
91
|
+
when :open
|
|
92
|
+
open_stack.push(idx)
|
|
93
|
+
when :close
|
|
94
|
+
next if open_stack.empty?
|
|
95
|
+
|
|
96
|
+
open_line = open_stack.pop
|
|
97
|
+
(open_line + 1...idx).each { |i| emit[i] = true }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
pick_lines(text, emit.keys.to_set)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def inverted(text)
|
|
105
|
+
markers = scan_markers(text)
|
|
106
|
+
open_stack = []
|
|
107
|
+
excluded = {}
|
|
108
|
+
|
|
109
|
+
markers.each do |kind, _name, idx|
|
|
110
|
+
case kind
|
|
111
|
+
when :open
|
|
112
|
+
open_stack.push(idx)
|
|
113
|
+
when :close
|
|
114
|
+
next if open_stack.empty?
|
|
115
|
+
|
|
116
|
+
open_line = open_stack.pop
|
|
117
|
+
(open_line..idx).each { |i| excluded[i] = true }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
markers.each { |_kind, _name, idx| excluded[idx] = true }
|
|
122
|
+
|
|
123
|
+
lines = text.lines
|
|
124
|
+
kept = lines.each_with_index.reject { |_line, idx| excluded[idx] }
|
|
125
|
+
kept.map(&:first).join
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def pick_lines(text, wanted_indices)
|
|
129
|
+
lines = text.lines
|
|
130
|
+
lines.each_with_index.select { |_line, idx| wanted_indices.include?(idx) }
|
|
131
|
+
.map(&:first).join
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
require 'set'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
# Pure-function selectors applied to resolved include content.
|
|
5
|
+
#
|
|
6
|
+
# Each selector owns exactly one transformation (MECE):
|
|
7
|
+
# Tags // tag::X[] ... // end::X[] region extraction
|
|
8
|
+
# Lines line-range selection (1..N;2;3..4)
|
|
9
|
+
# Indent leading-whitespace normalization
|
|
10
|
+
# LevelOffset section-level shift (applied AFTER parsing)
|
|
11
|
+
#
|
|
12
|
+
# Tags, Lines, and Indent take a String and return a String.
|
|
13
|
+
# LevelOffset takes a parsed CoreModel and returns a new CoreModel.
|
|
14
|
+
#
|
|
15
|
+
# The processor orchestrates the order:
|
|
16
|
+
# 1. Tags (or Lines; Lines wins if both specified — SPEC 3.5)
|
|
17
|
+
# 2. Indent
|
|
18
|
+
# 3. parse → CoreModel
|
|
19
|
+
# 4. LevelOffset
|
|
20
|
+
module IncludeSelectors
|
|
21
|
+
autoload :Tags, "#{__dir__}/include_selectors/tags"
|
|
22
|
+
autoload :Lines, "#{__dir__}/include_selectors/lines"
|
|
23
|
+
autoload :Indent, "#{__dir__}/include_selectors/indent"
|
|
24
|
+
autoload :LevelOffset, "#{__dir__}/include_selectors/level_offset"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -23,6 +23,11 @@ module Coradoc
|
|
|
23
23
|
def passed?
|
|
24
24
|
duration < threshold
|
|
25
25
|
end
|
|
26
|
+
|
|
27
|
+
def format_line
|
|
28
|
+
status = passed? ? 'PASS' : 'FAIL'
|
|
29
|
+
" #{status} #{name}: #{duration.round(4)}s (threshold: #{threshold}s)"
|
|
30
|
+
end
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
ComparisonResult = Struct.new(:name, :duration, :baseline, keyword_init: true) do
|
|
@@ -31,6 +36,12 @@ module Coradoc
|
|
|
31
36
|
|
|
32
37
|
(duration - baseline).abs / baseline > pct
|
|
33
38
|
end
|
|
39
|
+
|
|
40
|
+
def format_line
|
|
41
|
+
status = regressed? ? 'WARN' : 'OK'
|
|
42
|
+
baseline_str = baseline ? "(baseline: #{baseline.round(4)}s)" : '(no baseline)'
|
|
43
|
+
" #{status} #{name}: #{duration.round(4)}s #{baseline_str}"
|
|
44
|
+
end
|
|
34
45
|
end
|
|
35
46
|
|
|
36
47
|
class << self
|
|
@@ -50,14 +61,15 @@ module Coradoc
|
|
|
50
61
|
{ results:, failed_count: failed, total: results.size }
|
|
51
62
|
end
|
|
52
63
|
|
|
53
|
-
def print_results(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
puts
|
|
64
|
+
def print_results(summary_or_results)
|
|
65
|
+
if summary_or_results.is_a?(Hash)
|
|
66
|
+
summary = summary_or_results
|
|
67
|
+
summary[:results].each { |r| puts r.format_line }
|
|
68
|
+
puts
|
|
69
|
+
puts "#{summary[:total] - summary[:failed_count]}/#{summary[:total]} passed"
|
|
70
|
+
else
|
|
71
|
+
Array(summary_or_results).each { |r| puts r.format_line }
|
|
58
72
|
end
|
|
59
|
-
puts
|
|
60
|
-
puts "#{summary[:total] - summary[:failed_count]}/#{summary[:total]} passed"
|
|
61
73
|
end
|
|
62
74
|
|
|
63
75
|
def compare_with_baseline(baseline_path, iterations: 3)
|