1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
|
# frozen_string_literal: true
require 'asciidoctor/interdoc_reftext/version'
require 'asciidoctor'
require 'logger'
module Asciidoctor::InterdocReftext
# Resolver of inter-document cross reference texts.
class Resolver
# @param document [Asciidoctor::Document] the document associated with this resolver.
# @param asciidoc_exts [Array<String>] AsciiDoc file extensions (e.g. `.adoc`).
# @param logger [Logger] the logger to use for logging warning and errors.
# @param raise_exceptions [Boolean] whether to raise exceptions, or just log them.
def initialize(document,
asciidoc_exts: ['.adoc', '.asciidoc', '.ad'],
logger: ::Logger.new(STDERR),
raise_exceptions: true)
@document = document
@asciidoc_exts = asciidoc_exts.dup.freeze
@logger = logger
@raise_exceptions = raise_exceptions
@cache = {}
end
# @param refid [String] the target without a file extension, optionally with
# a fragment (e.g. `intro`, `intro#about`).
# @return [String, nil] reference text, or `nil` if not found.
# @raise ArgumentError if the *refid* is empty or starts with `#` and
# *raise_exceptions* is true.
def resolve_reftext(refid)
if refid.empty? || refid.start_with?('#')
msg = "interdoc-reftext: refid must not be empty or start with '#', but given: '#{refid}'"
raise ArgumentError, msg if @raise_exceptions
@logger.error msg
return nil
end
path, fragment = refid.split('#', 2)
path = resolve_target_path(path) or return nil
@cache["#{path}##{fragment}".freeze] ||= begin
lines = read_file(path) or return nil
parse_reftext(lines, fragment)
rescue => e # rubocop: disable RescueWithoutErrorClass
raise if @raise_exceptions
@logger.error "interdoc-reftext: #{e}"
nil
end
end
alias call resolve_reftext
protected
# @return [Array<String>] AsciiDoc file extensions (e.g. `.adoc`).
attr_reader :asciidoc_exts
# @return [Hash<String, String>] a cache of resolved reftexts.
attr_reader :cache
# @return [Asciidoctor::Document] the document associated with this resolver.
attr_reader :document
# @return [Logger] the logger to use for logging warning and errors.
attr_reader :logger
# @return [Boolean] whether to raise exceptions, or just log them.
attr_reader :raise_exceptions
# @param target_path [String] the target path without a file extension.
# @return [String, nil] file path of the *target_path*, or `nil` if not found.
def resolve_target_path(target_path)
# Include file is resolved relative to dir of the current include,
# or base_dir if within original docfile.
path = @document.normalize_system_path(target_path, @document.reader.dir,
nil, target_name: 'xref target')
return nil unless path
@asciidoc_exts.each do |extname|
filename = path + extname
return filename if ::File.file? filename
end
nil
end
# @param path [String] path of the file to read.
# @return [Enumerable<String>] lines of the file.
def read_file(path)
::IO.foreach(path)
end
# @param input [Enumerable<String>] lines of the AsciiDoc document.
# @param fragment [String, nil] part of the target after `#`.
# @return [String, nil]
def parse_reftext(input, fragment = nil)
unless fragment
# Document title is typically defined at top of the document,
# so we try to parse just the first 10 lines to save resources.
# If document title is not here, we fallback to parsing whole document.
title = asciidoc_load(input.take(10)).doctitle
return title if title
end
doc = asciidoc_load(input)
if fragment
ref = doc.catalog[:refs][fragment]
ref.xreftext if ref
else
doc.doctitle
end
end
# @param input [Enumerable<String>, String] lines of the AsciiDoc document to load.
# @return [Asciidoctor::Document] a parsed document.
def asciidoc_load(input)
# Asciidoctor is dumb. It doesn't know enumerators and when we give it
# an Array, it calls #dup on it. At least it knows #readlines, so we just
# define it as an alias for #to_a.
if input.is_a?(::Enumerable) && !input.respond_to?(:readlines)
input.singleton_class.send(:alias_method, :readlines, :to_a)
end
::Asciidoctor.load(input, @document.options)
end
end
end
|