omoteura-core 0.1.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.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omoteura
4
+ # The places in a code file a shape can link to, each with a link that
5
+ # resolves to exactly that place: in a spec, every example group and
6
+ # example; in other code, every declaration and method. For an editor to
7
+ # offer when linking a shape, and for drafts.
8
+ class LinkCandidates
9
+ Candidate = Data.define(:link, :label, :kind, :line)
10
+
11
+ def initialize(config, link_resolver: LinkResolver.new(config))
12
+ @config = config
13
+ @link_resolver = link_resolver
14
+ end
15
+
16
+ # code_path is relative to the project root. Links leave the path out,
17
+ # so they are for a diagram attached to this file. Places no link can
18
+ # single out, such as two examples with one description, are left out.
19
+ def for(code_path)
20
+ index = SourceIndex.parse_file(File.join(@config.root, code_path))
21
+ candidates = index.spec_entries.empty? ? code(index, code_path) : specs(index, code_path)
22
+ candidates.sort_by(&:line)
23
+ end
24
+
25
+ private
26
+
27
+ def specs(index, code_path)
28
+ index.spec_entries.filter_map do |entry|
29
+ candidate(["omoteura:##{entry.name}"], entry, code_path, label: entry.name, kind: entry.group ? "group" : "example")
30
+ end
31
+ end
32
+
33
+ # Declarations link by the macro and its first argument, such as
34
+ # "validates :email", and failing that by their whole first line.
35
+ def code(index, code_path)
36
+ declarations = Outline.new(index).items(only: Outline::KINDS - ["methods"]).filter_map do |item|
37
+ entry = item.entry
38
+ prefix = entry.name[/\A\S+\s+[^,(\s]+/]
39
+ candidate(["omoteura:##{prefix}", "omoteura:##{entry.name}"].compact.uniq, entry, code_path,
40
+ label: entry.name.delete_suffix(","), kind: item.kind)
41
+ end
42
+ methods = index.method_entries.filter_map do |entry|
43
+ candidate(["omoteura:##{entry.name}"], entry, code_path, label: entry.name, kind: "methods")
44
+ end
45
+ declarations + methods
46
+ end
47
+
48
+ def candidate(links, entry, code_path, label:, kind:)
49
+ link = @link_resolver.exact_link(links, path: code_path, line: entry.start_line)
50
+ link && Candidate.new(link: link, label: label, kind: kind, line: entry.start_line)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omoteura
4
+ # Turns an omoteura: link into a file and line range.
5
+ #
6
+ # omoteura:#<fragment> a place in the code file the diagram is attached to
7
+ # omoteura:<path>#<fragment> a place in another file
8
+ # omoteura:<path> a whole file
9
+ #
10
+ # A fragment is tried, in order, as a spec description (in full, or a
11
+ # trailing part of one), a method name, then the start of a statement.
12
+ # The first kind that matches decides: one match resolves the link, more
13
+ # than one makes it ambiguous, and none moves on to the next kind.
14
+ class LinkResolver
15
+ Target = Data.define(:path, :start_line, :end_line)
16
+
17
+ Result = Data.define(:link, :target, :error) do
18
+ def broken?
19
+ !error.nil?
20
+ end
21
+ end
22
+
23
+ IDENTIFIER_CHAR = /[[:alnum:]_?!]/
24
+ WORD_END_BYTES = [" ", "#", ".", ":"].map(&:ord).freeze
25
+
26
+ attr_reader :config
27
+
28
+ def initialize(config)
29
+ @config = config
30
+ @indexes = {}
31
+ end
32
+
33
+ # context_path is the code file the diagram is attached to, relative to
34
+ # the project root. It is needed only for links without a path.
35
+ def resolve(link, context_path: nil)
36
+ body = link.to_s.delete_prefix(Diagram::LINK_PREFIX)
37
+ path, fragment = body.split("#", 2)
38
+ path = path.to_s
39
+ fragment = fragment&.strip
40
+ return failure(link, "has an empty #fragment") if fragment == ""
41
+
42
+ path = context_path if path.empty?
43
+ return failure(link, "has no file and the diagram is not attached to one") if path.nil?
44
+
45
+ file = config.expand(path) or return failure(link, "points outside the project")
46
+ return failure(link, "points to #{path}, which does not exist") unless File.file?(file)
47
+
48
+ index = index_for(file)
49
+ return success(link, Target.new(path: path, start_line: 1, end_line: index.line_count)) if fragment.nil?
50
+
51
+ find(index, fragment, link, path)
52
+ end
53
+
54
+ # The first of links that resolves to exactly the code starting at line
55
+ # of path, or nil. Links without a path are read against path.
56
+ def exact_link(links, path:, line:)
57
+ links.find do |link|
58
+ target = resolve(link, context_path: path).target
59
+ target && target.path == path && target.start_line == line
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ # Each kind is only searched when the ones before it found nothing.
66
+ # filter_map rather than select: select sizes its result for the whole
67
+ # input even when nothing matches, about 9 KB per scan of a large file,
68
+ # while filter_map grows its result only for matches. (.standard.yml
69
+ # turns off the cop that prefers select here.)
70
+ def find(index, fragment, link, path)
71
+ candidates = spec_candidates(index.spec_entries, fragment)
72
+ candidates = index.method_entries.filter_map { |e| e if e.name == fragment } if candidates.empty?
73
+ candidates = index.statement_entries.filter_map { |e| e if statement_match?(e.name, fragment) } if candidates.empty?
74
+
75
+ case candidates.size
76
+ when 0 then failure(link, "matches nothing in #{path}")
77
+ when 1 then success(link, Target.new(path: path, start_line: candidates.first.start_line, end_line: candidates.first.end_line))
78
+ else
79
+ lines = candidates.map(&:start_line).join(", ")
80
+ failure(link, "matches #{candidates.size} places in #{path} (lines #{lines})")
81
+ end
82
+ end
83
+
84
+ # An exact description beats descriptions that only end with it.
85
+ def spec_candidates(entries, fragment)
86
+ exact = entries.filter_map { |e| e if e.name == fragment }
87
+ exact.empty? ? entries.filter_map { |e| e if spec_suffix_match?(e.name, fragment) } : exact
88
+ end
89
+
90
+ # A trailing part must start at a word boundary: "email 一意であること"
91
+ # matches "User validations email 一意であること" but "ail 一意であること"
92
+ # does not. In "User#display_name", both "#display_name" and
93
+ # "display_name" match, as "#", "." and "::" also end a word.
94
+ # Compares bytes so that checking thousands of entries allocates nothing.
95
+ def spec_suffix_match?(name, fragment)
96
+ return false unless name.bytesize > fragment.bytesize && name.end_with?(fragment)
97
+
98
+ fragment.start_with?("#", ".", "::") || WORD_END_BYTES.include?(name.getbyte(name.bytesize - fragment.bytesize - 1))
99
+ end
100
+
101
+ # "validates :email" matches "validates :email, presence: true" but not
102
+ # "validates :email_confirmation".
103
+ def statement_match?(first_line, fragment)
104
+ return false unless first_line.start_with?(fragment)
105
+
106
+ following = first_line.getbyte(fragment.bytesize)
107
+ return true if following.nil?
108
+ # Only a non-ASCII character needs the regexp; ASCII is checked by byte.
109
+ return !first_line[fragment.length].match?(IDENTIFIER_CHAR) if following >= 0x80
110
+
111
+ !(following == 95 || following == 33 || following == 63 || (48..57).cover?(following) ||
112
+ (65..90).cover?(following) || (97..122).cover?(following))
113
+ end
114
+
115
+ def index_for(file)
116
+ @indexes[file] ||= SourceIndex.parse_file(file)
117
+ end
118
+
119
+ def success(link, target)
120
+ Result.new(link: link, target: target, error: nil)
121
+ end
122
+
123
+ def failure(link, reason)
124
+ Result.new(link: link, target: nil, error: "#{link} #{reason}")
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omoteura
4
+ # lz-string's compressToBase64 format, which the Obsidian Excalidraw
5
+ # plugin uses for ```compressed-json blocks.
6
+ # Port of https://github.com/pieroxy/lz-string (MIT).
7
+ module LZString
8
+ BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
9
+ # Value of each byte of the alphabet, indexed by byte; anything else reads as 0.
10
+ BASE64_BYTE_VALUES = Array.new(256, 0).tap { |table| BASE64_ALPHABET.each_byte.with_index { |b, i| table[b] = i } }.freeze
11
+ WHITESPACE = " \t\r\n\f\v"
12
+
13
+ module_function
14
+
15
+ # Compresses a UTF-8 string to the same output as lz-string's
16
+ # compressToBase64.
17
+ def compress_to_base64(input)
18
+ result = compress(input.encode(Encoding::UTF_16BE).unpack("n*"), 6)
19
+ result << "=" * ((4 - result.size % 4) % 4)
20
+ end
21
+
22
+ # Returns the decompressed UTF-8 string, or nil if the input is corrupt.
23
+ def decompress_from_base64(input)
24
+ input = input.delete(WHITESPACE)
25
+ return "" if input.empty?
26
+
27
+ values = input.bytes
28
+ values.map! { |b| BASE64_BYTE_VALUES[b] }
29
+ decompress(values, 32)
30
+ end
31
+
32
+ # Dictionary entries are UTF-16BE byte strings, because lz-string works on
33
+ # JavaScript's UTF-16 code units rather than on characters. They are never
34
+ # modified, so single code units are shared through the units cache.
35
+ def decompress(values, reset_value)
36
+ reader = BitReader.new(values, reset_value)
37
+ units = {}
38
+ dictionary = [nil, nil, nil]
39
+ enlarge_in = 4
40
+ num_bits = 3
41
+
42
+ c =
43
+ case reader.read(2)
44
+ when 0 then unit(units, reader.read(8))
45
+ when 1 then unit(units, reader.read(16))
46
+ else return ""
47
+ end
48
+ dictionary << c
49
+ w = c
50
+ result = c.dup
51
+
52
+ loop do
53
+ return nil if reader.exhausted?
54
+
55
+ code = reader.read(num_bits)
56
+ case code
57
+ when 0, 1
58
+ dictionary << unit(units, reader.read((code == 0) ? 8 : 16))
59
+ code = dictionary.size - 1
60
+ enlarge_in -= 1
61
+ when 2
62
+ return result.force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8)
63
+ end
64
+
65
+ if enlarge_in == 0
66
+ enlarge_in = 2**num_bits
67
+ num_bits += 1
68
+ end
69
+
70
+ entry =
71
+ if dictionary[code]
72
+ dictionary[code]
73
+ elsif code == dictionary.size
74
+ w + first_unit(units, w)
75
+ else
76
+ return nil
77
+ end
78
+ result << entry
79
+ dictionary << (w + first_unit(units, entry))
80
+ enlarge_in -= 1
81
+ w = entry
82
+
83
+ if enlarge_in == 0
84
+ enlarge_in = 2**num_bits
85
+ num_bits += 1
86
+ end
87
+ end
88
+ rescue EncodingError
89
+ nil
90
+ end
91
+
92
+ # Phrases are dictionary codes rather than strings: a phrase extended by a
93
+ # code unit is looked up as (phrase << 16 | unit), so compressing
94
+ # allocates no string per phrase.
95
+ def compress(code_units, bits_per_char)
96
+ writer = BitWriter.new(bits_per_char)
97
+ singles = {}
98
+ phrases = {}
99
+ to_create = {}
100
+ dict_size = 3
101
+ enlarge_in = 2
102
+ num_bits = 2
103
+ w = nil
104
+ w_unit = nil
105
+
106
+ emit = lambda do
107
+ if w_unit && to_create.delete(w_unit)
108
+ if w_unit < 256
109
+ writer.write(0, num_bits)
110
+ writer.write(w_unit, 8)
111
+ else
112
+ writer.write(1, num_bits)
113
+ writer.write(w_unit, 16)
114
+ end
115
+ enlarge_in -= 1
116
+ if enlarge_in == 0
117
+ enlarge_in = 2**num_bits
118
+ num_bits += 1
119
+ end
120
+ else
121
+ writer.write(w, num_bits)
122
+ end
123
+ enlarge_in -= 1
124
+ if enlarge_in == 0
125
+ enlarge_in = 2**num_bits
126
+ num_bits += 1
127
+ end
128
+ end
129
+
130
+ code_units.each do |c|
131
+ unless singles.key?(c)
132
+ singles[c] = dict_size
133
+ dict_size += 1
134
+ to_create[c] = true
135
+ end
136
+
137
+ if w.nil?
138
+ w = singles[c]
139
+ w_unit = c
140
+ elsif (code = phrases[(w << 16) | c])
141
+ w = code
142
+ w_unit = nil
143
+ else
144
+ emit.call
145
+ phrases[(w << 16) | c] = dict_size
146
+ dict_size += 1
147
+ w = singles[c]
148
+ w_unit = c
149
+ end
150
+ end
151
+ emit.call if w
152
+ writer.write(2, num_bits)
153
+ writer.finish
154
+ end
155
+
156
+ def unit(units, code)
157
+ units[code] ||= [code].pack("n").freeze
158
+ end
159
+
160
+ # The first code unit of an entry, from the cache rather than byteslice,
161
+ # so that extending an entry allocates only the result.
162
+ def first_unit(units, entry)
163
+ unit(units, (entry.getbyte(0) << 8) | entry.getbyte(1))
164
+ end
165
+
166
+ class BitWriter
167
+ def initialize(bits_per_char)
168
+ @bits_per_char = bits_per_char
169
+ @val = 0
170
+ @position = 0
171
+ @out = +""
172
+ end
173
+
174
+ # Writes the low count bits of value, lowest first.
175
+ def write(value, count)
176
+ count.times do
177
+ @val = (@val << 1) | (value & 1)
178
+ if @position == @bits_per_char - 1
179
+ @out << BASE64_ALPHABET.getbyte(@val)
180
+ @val = 0
181
+ @position = 0
182
+ else
183
+ @position += 1
184
+ end
185
+ value >>= 1
186
+ end
187
+ end
188
+
189
+ def finish
190
+ loop do
191
+ @val <<= 1
192
+ if @position == @bits_per_char - 1
193
+ @out << BASE64_ALPHABET.getbyte(@val)
194
+ return @out
195
+ end
196
+ @position += 1
197
+ end
198
+ end
199
+ end
200
+
201
+ class BitReader
202
+ def initialize(values, reset_value)
203
+ @values = values
204
+ @reset_value = reset_value
205
+ @val = values.fetch(0, 0)
206
+ @position = reset_value
207
+ @index = 1
208
+ end
209
+
210
+ def exhausted?
211
+ @index > @values.size
212
+ end
213
+
214
+ def read(count)
215
+ bits = 0
216
+ count.times do |i|
217
+ bit = @val & @position
218
+ @position >>= 1
219
+ if @position == 0
220
+ @position = @reset_value
221
+ @val = @values.fetch(@index, 0)
222
+ @index += 1
223
+ end
224
+ bits |= 1 << i if bit > 0
225
+ end
226
+ bits
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Omoteura
6
+ # Finds the "# omoteura: <path>" comment that attaches a diagram to a
7
+ # Ruby file. Only comments on their own line before the first statement
8
+ # or after the last one count; comments inside the code are ignored.
9
+ module MagicComment
10
+ class Duplicate < Error; end
11
+
12
+ PATTERN = /\A#[ \t]*omoteura:[ \t]*(\S(?:.*\S)?)[ \t]*\z/
13
+
14
+ module_function
15
+
16
+ # Returns the diagram path written in the magic comment, or nil.
17
+ def find(source, path: "source")
18
+ # Most files have no magic comment; skip parsing them.
19
+ return nil unless source.include?("omoteura:")
20
+
21
+ result = Prism.parse(source)
22
+ statements = result.value.statements.body
23
+ first_code = statements.first&.location&.start_offset
24
+ last_code = statements.last&.location&.end_offset
25
+
26
+ paths = result.comments.filter_map do |comment|
27
+ next if !comment.is_a?(Prism::InlineComment) || comment.trailing?
28
+
29
+ location = comment.location
30
+ outside_code = first_code.nil? || location.end_offset <= first_code || location.start_offset >= last_code
31
+ PATTERN.match(location.slice)&.[](1) if outside_code
32
+ end
33
+
34
+ raise Duplicate, "#{path} has #{paths.size} omoteura magic comments; keep only one" if paths.size > 1
35
+
36
+ paths.first
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded by the executable before anything else, so it must stay in syntax
4
+ # that old Rubies can parse and must not depend on the rest of the gem.
5
+ module Omoteura
6
+ MINIMUM_RUBY = "3.4"
7
+
8
+ # The message to stop with when version is older than MINIMUM_RUBY, or nil.
9
+ def self.ruby_too_old_message(version = RUBY_VERSION, ruby = RbConfig.ruby)
10
+ return nil unless Gem::Version.new(version) < Gem::Version.new(MINIMUM_RUBY)
11
+
12
+ "omoteura needs Ruby #{MINIMUM_RUBY} or later, but this is Ruby #{version} (#{ruby})."
13
+ end
14
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "omoteura/lz_string"
5
+
6
+ module Omoteura
7
+ # Writes drawings as the Obsidian Excalidraw plugin saves them: a
8
+ # markdown note whose "# Excalidraw Data" part lists the text of each
9
+ # text element and the link of each linked element, followed by the
10
+ # scene itself in a ## Drawing block.
11
+ #
12
+ # The plugin reads ids back from those lists as exactly eight
13
+ # characters, so elements with other ids are left out of them. It still
14
+ # reads them from the scene, and gives text elements new ids.
15
+ module Obsidian
16
+ # What the plugin writes at the top of a new drawing.
17
+ HEADER = <<~MARKDOWN
18
+ ---
19
+
20
+ excalidraw-plugin: parsed
21
+ tags: [excalidraw]
22
+
23
+ ---
24
+ ==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠== You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'
25
+
26
+
27
+ MARKDOWN
28
+ ID_LENGTH = 8
29
+ CHUNK_SIZE = 256
30
+ # Where the plugin's part of the note starts; everything above is the
31
+ # user's. Older files have no "# Excalidraw Data" heading.
32
+ DATA_START = /^(%%\n+)?(?:# Excalidraw Data|##? Text Elements|##? Drawing)(?:\n|$)/
33
+ DRAWING_START = /^##? Drawing\n/
34
+ EMBEDDED_FILES = /^## Embedded Files\n.*/m
35
+
36
+ class << self
37
+ # A new note holding scene, a Hash with "elements", "appState" and
38
+ # "files" as Excalidraw saves them.
39
+ def render(scene, compress: true)
40
+ HEADER + data(scene, compress: compress, commented_out: false, embedded_files: "")
41
+ end
42
+
43
+ # source with its drawing replaced by scene. What the user wrote above
44
+ # the drawing, the Embedded Files list and whether the drawing is
45
+ # compressed stay as they were.
46
+ def rewrite(source, scene)
47
+ match = DATA_START.match(source) or raise Diagram::ParseError, "no Excalidraw data to replace"
48
+ commented_out = !match[1].nil?
49
+ rest = match.post_match
50
+ before_drawing = (drawing = DRAWING_START.match(rest)) ? drawing.pre_match : rest
51
+ before_drawing = before_drawing.delete_suffix("%%\n") unless commented_out
52
+
53
+ match.pre_match + data(
54
+ scene,
55
+ compress: source.include?("```compressed-json\n"),
56
+ commented_out: commented_out,
57
+ embedded_files: before_drawing[EMBEDDED_FILES] || ""
58
+ )
59
+ end
60
+
61
+ private
62
+
63
+ def data(scene, compress:, commented_out:, embedded_files:)
64
+ live = scene["elements"].select { |e| !e["isDeleted"] && e["id"].to_s.length == ID_LENGTH }
65
+ texts, others = live.partition { |e| e["type"] == "text" }
66
+ out = +(commented_out ? "%%\n" : "")
67
+ out << "# Excalidraw Data\n\n## Text Elements\n"
68
+ texts.each { |e| out << "#{e["rawText"] || e["originalText"] || e["text"]} ^#{e["id"]}\n\n" }
69
+ linked = others.select { |e| e["link"] } + texts.select { |e| e["link"] }
70
+ unless linked.empty?
71
+ out << "## Element Links\n"
72
+ linked.each { |e| out << "#{e["id"]}: #{e["link"]}\n\n" }
73
+ end
74
+ out << embedded_files
75
+ out << "%%\n" unless commented_out
76
+ out << drawing(scene, compress)
77
+ end
78
+
79
+ def drawing(scene, compress)
80
+ json = JSON.pretty_generate(scene, indent: "\t")
81
+ return "## Drawing\n```json\n#{json}\n```\n%%" unless compress
82
+
83
+ chunks = LZString.compress_to_base64(json).scan(/.{1,#{CHUNK_SIZE}}/o)
84
+ "## Drawing\n```compressed-json\n#{chunks.join("\n\n")}\n```\n%%"
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omoteura
4
+ # What a code file declares, the way a reader of a Rails model groups it:
5
+ # validations, associations, callbacks, scopes and enums, then the public
6
+ # methods. Read from a SourceIndex, without loading Rails or the code.
7
+ class Outline
8
+ KINDS = %w[validations associations callbacks scopes enums methods].freeze
9
+ DECLARATIONS = {
10
+ "validations" => /\A(?:validates?|validates_\w+)\b/,
11
+ "associations" => /\A(?:has_many|has_one|belongs_to|has_and_belongs_to_many)\b/,
12
+ "callbacks" => /\A(?:before|after|around)_\w+\b/,
13
+ "scopes" => /\Ascope\b/,
14
+ "enums" => /\Aenum\b/
15
+ }.freeze
16
+
17
+ # entry is the SourceIndex entry the item was read from; its name is the
18
+ # statement's first line, or the method's name.
19
+ Item = Data.define(:kind, :entry)
20
+
21
+ def initialize(index)
22
+ @index = index
23
+ end
24
+
25
+ # Items of the given kinds, by kind in KINDS order, then by line.
26
+ def items(only: KINDS)
27
+ KINDS.flat_map do |kind|
28
+ next [] unless only.include?(kind)
29
+
30
+ entries = (kind == "methods") ? public_methods : @index.statement_entries.select { |e| e.name.match?(DECLARATIONS[kind]) }
31
+ entries.map { |entry| Item.new(kind: kind, entry: entry) }
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # Methods after a bare private or protected in the same class, or
38
+ # defined with private def, are left out, and so is initialize, which
39
+ # Ruby makes private and callers reach through new.
40
+ def public_methods
41
+ statements = @index.statement_entries
42
+ classes = statements.select { |e| e.name.match?(/\A(?:class|module)\b/) }
43
+ hidden_from = statements.select { |e| %w[private protected].include?(e.name) }
44
+ hidden_names = statements.filter_map { |e| e.name[/\A(?:private|protected)\s+def\s+(?:self\.)?(\w+[?!=]?)/, 1] }
45
+ @index.method_entries.reject do |method|
46
+ owner = classes.select { |c| c.start_line < method.start_line && c.end_line >= method.end_line }.max_by(&:start_line)
47
+ method.name == "initialize" || hidden_names.include?(method.name.delete_prefix("self.")) ||
48
+ hidden_from.any? { |h| h.start_line < method.start_line && (!owner || (h.start_line > owner.start_line && h.end_line <= owner.end_line)) }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omoteura
4
+ # The files of a project that can have a diagram attached.
5
+ class Project
6
+ # Ruby files under these top-level directories are not project code.
7
+ IGNORED_DIRS = %w[vendor tmp log node_modules coverage pkg].freeze
8
+
9
+ attr_reader :config
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ end
14
+
15
+ # The project's Ruby files, outside IGNORED_DIRS and the diagrams
16
+ # directory, and any other existing file named in the attach setting,
17
+ # relative to the root and sorted.
18
+ def code_paths
19
+ paths = ruby_files.to_set
20
+ paths.merge(config.attach.keys.select { |path| File.file?(File.join(config.root, path)) })
21
+ paths.sort
22
+ end
23
+
24
+ private
25
+
26
+ def ruby_files
27
+ Dir.glob("**/*.rb", base: config.root).reject do |path|
28
+ IGNORED_DIRS.include?(path.split("/", 2).first) || path.start_with?("#{config.diagrams_dir}/")
29
+ end
30
+ end
31
+ end
32
+ end