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.
@@ -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(summary)
54
- results = summary[:results]
55
- results.each do |r|
56
- status = r.passed? ? 'PASS' : 'FAIL'
57
- puts " #{status} #{r.name}: #{r.duration.round(4)}s (threshold: #{r.threshold}s)"
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)