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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/exe/omoteura +15 -0
- data/lib/omoteura/checker.rb +81 -0
- data/lib/omoteura/cli.rb +251 -0
- data/lib/omoteura/config.rb +96 -0
- data/lib/omoteura/core/version.rb +5 -0
- data/lib/omoteura/core.rb +25 -0
- data/lib/omoteura/coverage_report.rb +122 -0
- data/lib/omoteura/diagram.rb +139 -0
- data/lib/omoteura/draft/scene_builder.rb +176 -0
- data/lib/omoteura/draft.rb +244 -0
- data/lib/omoteura/inspector.rb +89 -0
- data/lib/omoteura/link_candidates.rb +53 -0
- data/lib/omoteura/link_resolver.rb +127 -0
- data/lib/omoteura/lz_string.rb +230 -0
- data/lib/omoteura/magic_comment.rb +39 -0
- data/lib/omoteura/minimum_ruby.rb +14 -0
- data/lib/omoteura/obsidian.rb +88 -0
- data/lib/omoteura/outline.rb +52 -0
- data/lib/omoteura/project.rb +32 -0
- data/lib/omoteura/resolver.rb +75 -0
- data/lib/omoteura/source_index.rb +136 -0
- data/lib/omoteura/test_results.rb +74 -0
- data/lib/omoteura/writer.rb +89 -0
- metadata +65 -0
|
@@ -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
|