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,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "psych"
|
|
6
|
+
require "omoteura/lz_string"
|
|
7
|
+
|
|
8
|
+
module Omoteura
|
|
9
|
+
# An Excalidraw drawing read from disk, either a plain .excalidraw file or
|
|
10
|
+
# an Obsidian Excalidraw plugin markdown file.
|
|
11
|
+
class Diagram
|
|
12
|
+
class NotADiagram < Error; end
|
|
13
|
+
|
|
14
|
+
class ParseError < Error; end
|
|
15
|
+
|
|
16
|
+
LINK_PREFIX = "omoteura:"
|
|
17
|
+
|
|
18
|
+
Element = Data.define(:id, :type, :link, :label) do
|
|
19
|
+
def omoteura_link?
|
|
20
|
+
link&.start_with?(LINK_PREFIX) || false
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
FRONTMATTER = /\A---[ \t]*\r?\n(.*?)^---[ \t]*$/m
|
|
25
|
+
DRAWING_BLOCK = /^\#\#[ \t]+Drawing[ \t]*\r?\n+```(json|compressed-json)[ \t]*\r?\n(.*?)^```/m
|
|
26
|
+
ELEMENT_LINKS = /^\#\# Element Links\r?\n(.*?)(?=^\#\# Embedded Files|^\#\#? Drawing|\z)/m
|
|
27
|
+
# The plugin's ids are eight characters.
|
|
28
|
+
ELEMENT_LINK = /^(.{8}):[ \t]*(.*?)\r?$/
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def load(path)
|
|
32
|
+
path = path.to_s
|
|
33
|
+
source = File.read(path, encoding: Encoding::UTF_8)
|
|
34
|
+
format = detect_format(path, source)
|
|
35
|
+
raise NotADiagram, "#{path} is not an Excalidraw drawing" unless format
|
|
36
|
+
|
|
37
|
+
new(path: path, format: format, data: parse_data(format, source, path), digest: Digest::SHA256.hexdigest(source))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def diagram_file?(path)
|
|
41
|
+
path = path.to_s
|
|
42
|
+
return false unless File.file?(path)
|
|
43
|
+
|
|
44
|
+
!detect_format(path, File.read(path, encoding: Encoding::UTF_8)).nil?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Mirrors the Obsidian plugin: a .md file is a drawing when its
|
|
50
|
+
# frontmatter has the excalidraw-plugin key, whatever its name.
|
|
51
|
+
def detect_format(path, source)
|
|
52
|
+
case File.extname(path)
|
|
53
|
+
when ".excalidraw" then :excalidraw
|
|
54
|
+
when ".md" then :obsidian if obsidian_drawing?(source)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def obsidian_drawing?(source)
|
|
59
|
+
match = FRONTMATTER.match(source) or return false
|
|
60
|
+
frontmatter = Psych.safe_load(match[1])
|
|
61
|
+
frontmatter.is_a?(Hash) && frontmatter.key?("excalidraw-plugin")
|
|
62
|
+
rescue Psych::Exception
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_data(format, source, path)
|
|
67
|
+
json =
|
|
68
|
+
if format == :excalidraw
|
|
69
|
+
source
|
|
70
|
+
else
|
|
71
|
+
drawing_json(source, path)
|
|
72
|
+
end
|
|
73
|
+
data = JSON.parse(json)
|
|
74
|
+
raise ParseError, "#{path} has no elements list" unless data.is_a?(Hash) && data["elements"].is_a?(Array)
|
|
75
|
+
|
|
76
|
+
apply_element_links(source, data["elements"]) if format == :obsidian
|
|
77
|
+
data
|
|
78
|
+
rescue JSON::ParserError => e
|
|
79
|
+
raise ParseError, "#{path} has invalid drawing JSON: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def drawing_json(source, path)
|
|
83
|
+
match = DRAWING_BLOCK.match(source) or raise ParseError, "#{path} has no ## Drawing block"
|
|
84
|
+
return match[2] if match[1] == "json"
|
|
85
|
+
|
|
86
|
+
LZString.decompress_from_base64(match[2]) or
|
|
87
|
+
raise ParseError, "#{path} has a corrupt compressed-json block"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# As in the plugin, a link listed under Element Links wins over the
|
|
91
|
+
# one in the drawing, so a link edited in the markdown counts before
|
|
92
|
+
# Obsidian has saved the drawing again.
|
|
93
|
+
def apply_element_links(source, elements)
|
|
94
|
+
section = ELEMENT_LINKS.match(source) or return
|
|
95
|
+
links = section[1].scan(ELEMENT_LINK).to_h
|
|
96
|
+
elements.each { |e| e["link"] = links[e["id"]] if e.is_a?(Hash) && links.key?(e["id"]) }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# data is the whole drawing as read, for writing it back; digest is the
|
|
101
|
+
# SHA-256 of the file as read, for noticing later changes.
|
|
102
|
+
attr_reader :path, :format, :elements, :digest, :data
|
|
103
|
+
|
|
104
|
+
def initialize(path:, format:, data:, digest: nil)
|
|
105
|
+
@path = path
|
|
106
|
+
@format = format
|
|
107
|
+
@data = data
|
|
108
|
+
@digest = digest
|
|
109
|
+
@elements = build_elements(data["elements"])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The drawing as Excalidraw itself reads it, for showing it in an editor.
|
|
113
|
+
def scene
|
|
114
|
+
{"elements" => @data["elements"], "appState" => @data["appState"] || {}, "files" => @data["files"] || {}}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def linked_elements
|
|
118
|
+
elements.select(&:omoteura_link?)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def build_elements(raw_elements)
|
|
124
|
+
live = raw_elements.select { |e| e.is_a?(Hash) && !e["isDeleted"] }
|
|
125
|
+
labels = live.each_with_object({}) do |e, acc|
|
|
126
|
+
acc[e["containerId"]] = text_of(e) if e["type"] == "text" && e["containerId"]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
live.map do |e|
|
|
130
|
+
label = (e["type"] == "text") ? text_of(e) : labels[e["id"]]
|
|
131
|
+
Element.new(id: e["id"], type: e["type"], link: e["link"], label: label)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def text_of(element)
|
|
136
|
+
element["originalText"] || element["text"]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Omoteura
|
|
6
|
+
class Draft
|
|
7
|
+
# Builds Excalidraw elements laid out in columns: a title, then for each
|
|
8
|
+
# column a heading and a stack of boxes, each box labelled and linked.
|
|
9
|
+
# Element ids and seeds derive from the file and the box, so drafting
|
|
10
|
+
# the same code twice gives the same drawing.
|
|
11
|
+
class SceneBuilder
|
|
12
|
+
Box = Data.define(:label, :link)
|
|
13
|
+
Heading = Data.define(:text, :link)
|
|
14
|
+
Column = Data.define(:heading, :items)
|
|
15
|
+
|
|
16
|
+
INK = "#1e1e1e"
|
|
17
|
+
FONT_EXCALIFONT = 5
|
|
18
|
+
BOX_FONT_SIZE = 16
|
|
19
|
+
HEADING_FONT_SIZE = 20
|
|
20
|
+
TITLE_FONT_SIZE = 28
|
|
21
|
+
LINE_HEIGHT = 1.25
|
|
22
|
+
COLUMN_WIDTH = 280
|
|
23
|
+
COLUMN_GAP = 60
|
|
24
|
+
BOX_GAP = 16
|
|
25
|
+
BOX_PADDING = 14
|
|
26
|
+
MAX_LABEL_WIDTH = COLUMN_WIDTH - 2 * BOX_PADDING
|
|
27
|
+
# A word with its trailing space, or one Japanese or Chinese character.
|
|
28
|
+
WRAP_TOKENS = /[\p{Han}\p{Hiragana}\p{Katakana}ー、。]|[^\p{Han}\p{Hiragana}\p{Katakana}ー、。\s]+\s*|\s+/
|
|
29
|
+
|
|
30
|
+
# salt varies the ids, for adding to a drawing that has these already.
|
|
31
|
+
def initialize(code_path, updated: (Time.now.to_f * 1000).to_i, salt: nil)
|
|
32
|
+
@code_path = code_path
|
|
33
|
+
@updated = updated
|
|
34
|
+
@salt = salt
|
|
35
|
+
@elements = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# columns are Columns whose items are Boxes, or Headings for a nested
|
|
39
|
+
# group, in reading order.
|
|
40
|
+
# origin is where the title's top left corner goes.
|
|
41
|
+
def build(title, columns, origin: [0, 0])
|
|
42
|
+
left, top_edge = origin
|
|
43
|
+
add_text("title", title, x: left, y: top_edge, size: TITLE_FONT_SIZE)
|
|
44
|
+
top = top_edge + TITLE_FONT_SIZE * LINE_HEIGHT + 32
|
|
45
|
+
columns.each_with_index do |column, index|
|
|
46
|
+
x = left + index * (COLUMN_WIDTH + COLUMN_GAP)
|
|
47
|
+
y = top
|
|
48
|
+
if column.heading
|
|
49
|
+
add_text("column #{index}", column.heading.text, x: x, y: y, size: HEADING_FONT_SIZE, link: column.heading.link)
|
|
50
|
+
y += HEADING_FONT_SIZE * LINE_HEIGHT + BOX_GAP
|
|
51
|
+
end
|
|
52
|
+
column.items.each_with_index do |item, row|
|
|
53
|
+
y += item.is_a?(Heading) ? place_heading(item, index, row, x, y) : place_box(item, index, row, x, y)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
@elements
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def place_heading(heading, column, row, x, y)
|
|
62
|
+
add_text("heading #{column}/#{row}", heading.text, x: x, y: y + 8, size: BOX_FONT_SIZE, link: heading.link)
|
|
63
|
+
8 + BOX_FONT_SIZE * LINE_HEIGHT + BOX_GAP / 2
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def place_box(box, column, row, x, y)
|
|
67
|
+
key = "box #{column}/#{row}"
|
|
68
|
+
lines = wrap(box.label, MAX_LABEL_WIDTH, BOX_FONT_SIZE)
|
|
69
|
+
text_height = lines.size * BOX_FONT_SIZE * LINE_HEIGHT
|
|
70
|
+
height = (text_height + 2 * BOX_PADDING).round
|
|
71
|
+
box_id = id_for(key)
|
|
72
|
+
label_id = id_for("#{key} label")
|
|
73
|
+
|
|
74
|
+
@elements << base(key, type: "rectangle", x: x, y: y, width: COLUMN_WIDTH, height: height).merge(
|
|
75
|
+
"roundness" => {"type" => 3},
|
|
76
|
+
"boundElements" => [{"type" => "text", "id" => label_id}],
|
|
77
|
+
"link" => box.link
|
|
78
|
+
)
|
|
79
|
+
text = lines.join("\n")
|
|
80
|
+
width = lines.map { |line| text_width(line, BOX_FONT_SIZE) }.max
|
|
81
|
+
@elements << base("#{key} label", type: "text", x: x + (COLUMN_WIDTH - width) / 2.0, y: y + BOX_PADDING,
|
|
82
|
+
width: width, height: text_height).merge(text_fields(text, BOX_FONT_SIZE, container: box_id))
|
|
83
|
+
height + BOX_GAP
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def add_text(key, text, x:, y:, size:, link: nil)
|
|
87
|
+
width = text.lines.map { |line| text_width(line.chomp, size) }.max
|
|
88
|
+
@elements << base(key, type: "text", x: x, y: y, width: width, height: size * LINE_HEIGHT)
|
|
89
|
+
.merge(text_fields(text, size, container: nil))
|
|
90
|
+
.merge("link" => link)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def text_fields(text, size, container:)
|
|
94
|
+
{
|
|
95
|
+
"text" => text,
|
|
96
|
+
"originalText" => text,
|
|
97
|
+
"fontSize" => size,
|
|
98
|
+
"fontFamily" => FONT_EXCALIFONT,
|
|
99
|
+
"textAlign" => container ? "center" : "left",
|
|
100
|
+
"verticalAlign" => container ? "middle" : "top",
|
|
101
|
+
"containerId" => container,
|
|
102
|
+
"autoResize" => true,
|
|
103
|
+
"lineHeight" => LINE_HEIGHT
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def base(key, type:, x:, y:, width:, height:)
|
|
108
|
+
{
|
|
109
|
+
"id" => id_for(key),
|
|
110
|
+
"type" => type,
|
|
111
|
+
"x" => x,
|
|
112
|
+
"y" => y,
|
|
113
|
+
"width" => width,
|
|
114
|
+
"height" => height,
|
|
115
|
+
"angle" => 0,
|
|
116
|
+
"strokeColor" => INK,
|
|
117
|
+
"backgroundColor" => "transparent",
|
|
118
|
+
"fillStyle" => "solid",
|
|
119
|
+
"strokeWidth" => 2,
|
|
120
|
+
"strokeStyle" => "solid",
|
|
121
|
+
"roughness" => 1,
|
|
122
|
+
"opacity" => 100,
|
|
123
|
+
"groupIds" => [],
|
|
124
|
+
"frameId" => nil,
|
|
125
|
+
"index" => nil,
|
|
126
|
+
"roundness" => nil,
|
|
127
|
+
"seed" => seed_for(key),
|
|
128
|
+
"version" => 1,
|
|
129
|
+
"versionNonce" => seed_for("#{key} nonce"),
|
|
130
|
+
"isDeleted" => false,
|
|
131
|
+
"boundElements" => nil,
|
|
132
|
+
"updated" => @updated,
|
|
133
|
+
"link" => nil,
|
|
134
|
+
"locked" => false
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Eight characters, as the Obsidian Excalidraw plugin expects.
|
|
139
|
+
def id_for(key)
|
|
140
|
+
digest(key)[0, 8]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def seed_for(key)
|
|
144
|
+
digest(key)[0, 8].to_i(16) % 2**31
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def digest(key)
|
|
148
|
+
Digest::SHA256.hexdigest(@salt ? "#{@code_path}\0#{@salt}\0#{key}" : "#{@code_path}\0#{key}")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# An estimate, since the real width depends on the font: Excalidraw
|
|
152
|
+
# measures text again when the drawing opens.
|
|
153
|
+
def text_width(text, size)
|
|
154
|
+
text.each_char.sum { |char| char.ascii_only? ? size * 0.55 : size * 1.0 }.ceil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Breaks between words, or between characters in Japanese and Chinese
|
|
158
|
+
# text, which has no spaces; a word wider than a line is split.
|
|
159
|
+
def wrap(text, max_width, size)
|
|
160
|
+
lines = [+""]
|
|
161
|
+
text.scan(WRAP_TOKENS).each do |token|
|
|
162
|
+
if lines.last != "" && text_width(lines.last + token.rstrip, size) > max_width
|
|
163
|
+
lines.last.rstrip!
|
|
164
|
+
lines << +""
|
|
165
|
+
token = token.lstrip
|
|
166
|
+
end
|
|
167
|
+
token.each_char do |char|
|
|
168
|
+
lines << +"" if lines.last != "" && text_width(lines.last + char, size) > max_width
|
|
169
|
+
lines.last << char
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
lines.each(&:rstrip!)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "omoteura/draft/scene_builder"
|
|
6
|
+
require "omoteura/outline"
|
|
7
|
+
|
|
8
|
+
module Omoteura
|
|
9
|
+
# Drafts a diagram from a code file: for a spec, a column per example
|
|
10
|
+
# group with a box per example; for other code, a column per kind of
|
|
11
|
+
# declaration and one for public methods. Every box links to its code,
|
|
12
|
+
# and a link is added only when it resolves to exactly that code.
|
|
13
|
+
#
|
|
14
|
+
# A draft is a starting point for a hand-drawn diagram. It never
|
|
15
|
+
# replaces one: when the file already has a diagram, the draft goes
|
|
16
|
+
# beside it as <name>.draft.excalidraw, or .draft.excalidraw.md when
|
|
17
|
+
# drafting for Obsidian.
|
|
18
|
+
class Draft
|
|
19
|
+
CATEGORIES = Outline::KINDS
|
|
20
|
+
FORMAT_SOURCE = "https://github.com/kaibyaku-jp/omoteura"
|
|
21
|
+
EXTENSIONS = {excalidraw: ".excalidraw", obsidian: ".excalidraw.md"}.freeze
|
|
22
|
+
|
|
23
|
+
# status is :written, :draft (written beside an existing diagram),
|
|
24
|
+
# :exists (a previous draft is in the way) or :empty (nothing to draw);
|
|
25
|
+
# for #merge, :merged, :up_to_date (nothing missing) or :no_diagram.
|
|
26
|
+
Result = Data.define(:code_path, :diagram_path, :shape_count, :status)
|
|
27
|
+
|
|
28
|
+
# format is :excalidraw for a .excalidraw file or :obsidian for an
|
|
29
|
+
# Obsidian Excalidraw plugin note.
|
|
30
|
+
def initialize(config, only: CATEGORIES, out: nil, format: :excalidraw)
|
|
31
|
+
@config = config
|
|
32
|
+
@only = only
|
|
33
|
+
@out = out
|
|
34
|
+
@format = format
|
|
35
|
+
@extension = EXTENSIONS.fetch(format)
|
|
36
|
+
@resolver = Resolver.new(config)
|
|
37
|
+
@link_resolver = LinkResolver.new(config)
|
|
38
|
+
@drawn = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# code_path is relative to the project root. Nothing is written when
|
|
42
|
+
# dry_run is true.
|
|
43
|
+
def draft(code_path, dry_run: false)
|
|
44
|
+
index = SourceIndex.parse_file(File.join(@config.root, code_path))
|
|
45
|
+
columns = index.spec_entries.empty? ? code_columns(index, code_path) : spec_columns(index, code_path)
|
|
46
|
+
boxes = columns.sum { |column| column.items.count { |item| item.is_a?(SceneBuilder::Box) } }
|
|
47
|
+
return Result.new(code_path: code_path, diagram_path: nil, shape_count: 0, status: :empty) if boxes.zero?
|
|
48
|
+
|
|
49
|
+
diagram_path, status = destination(code_path)
|
|
50
|
+
return Result.new(code_path: code_path, diagram_path: diagram_path, shape_count: boxes, status: :exists) if status == :exists
|
|
51
|
+
|
|
52
|
+
write(diagram_path, SceneBuilder.new(code_path).build(code_path, columns)) unless dry_run
|
|
53
|
+
Result.new(code_path: code_path, diagram_path: diagram_path, shape_count: boxes, status: status)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Adds to the file's diagram what it has no shape for yet, in columns to
|
|
57
|
+
# the right of everything drawn. Nothing drawn is moved or removed. A
|
|
58
|
+
# shape counts as drawn when its link points at the same code, or its
|
|
59
|
+
# label is the same.
|
|
60
|
+
def merge(code_path, dry_run: false)
|
|
61
|
+
attachment = @resolver.resolve(code_path)
|
|
62
|
+
if attachment.nil? || attachment.missing?
|
|
63
|
+
return Result.new(code_path: code_path, diagram_path: attachment&.diagram_path, shape_count: 0, status: :no_diagram)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
file = @config.expand(attachment.diagram_path)
|
|
67
|
+
diagram = Diagram.load(file)
|
|
68
|
+
existing = diagram.data["elements"].select { |e| e.is_a?(Hash) && !e["isDeleted"] }
|
|
69
|
+
@drawn = drawn_in(diagram, code_path)
|
|
70
|
+
index = SourceIndex.parse_file(File.join(@config.root, code_path))
|
|
71
|
+
columns = prune(index.spec_entries.empty? ? code_columns(index, code_path) : spec_columns(index, code_path))
|
|
72
|
+
boxes = columns.sum { |column| column.items.count { |item| item.is_a?(SceneBuilder::Box) } }
|
|
73
|
+
return Result.new(code_path: code_path, diagram_path: attachment.diagram_path, shape_count: 0, status: :up_to_date) if boxes.zero?
|
|
74
|
+
|
|
75
|
+
unless dry_run
|
|
76
|
+
added = merged_elements(code_path, columns, existing, diagram.data["elements"])
|
|
77
|
+
data = diagram.data.merge("elements" => diagram.data["elements"] + added)
|
|
78
|
+
Writer.new(@config).save(file, data, if_unchanged: diagram.digest)
|
|
79
|
+
end
|
|
80
|
+
Result.new(code_path: code_path, diagram_path: attachment.diagram_path, shape_count: boxes, status: :merged)
|
|
81
|
+
ensure
|
|
82
|
+
@drawn = nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
MERGE_TITLE = "Not drawn yet"
|
|
88
|
+
|
|
89
|
+
# The lines of this file the diagram's shapes link to, and their labels.
|
|
90
|
+
def drawn_in(diagram, code_path)
|
|
91
|
+
lines = diagram.linked_elements.filter_map do |element|
|
|
92
|
+
target = @link_resolver.resolve(element.link, context_path: code_path).target
|
|
93
|
+
target.start_line if target && target.path == code_path
|
|
94
|
+
end
|
|
95
|
+
labels = diagram.elements.filter_map { |element| element.label&.tr("\n", " ")&.squeeze(" ") }
|
|
96
|
+
{lines: lines.to_h { |line| [line, true] }, labels: labels.to_h { |label| [label, true] }}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def drawn?(entry, label)
|
|
100
|
+
@drawn && (@drawn[:lines][entry.start_line] || @drawn[:labels][label]) || false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Without the boxes taken out for being drawn, headings left with no
|
|
104
|
+
# box under them go, and so do columns left with none.
|
|
105
|
+
def prune(columns)
|
|
106
|
+
columns.filter_map do |column|
|
|
107
|
+
items = column.items.each_with_index.select do |item, i|
|
|
108
|
+
item.is_a?(SceneBuilder::Box) || column.items[(i + 1)..].take_while { |next_item| !next_item.is_a?(SceneBuilder::Heading) }.any?
|
|
109
|
+
end.map(&:first)
|
|
110
|
+
column.with(items: items) if items.any? { |item| item.is_a?(SceneBuilder::Box) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The new elements, under a title to the right of everything drawn, with
|
|
115
|
+
# ids that no existing element has.
|
|
116
|
+
def merged_elements(code_path, columns, existing, all)
|
|
117
|
+
right = existing.map { |e| e["x"].to_f + e["width"].to_f }.max || 0
|
|
118
|
+
top = existing.map { |e| e["y"].to_f }.min || 0
|
|
119
|
+
taken = all.filter_map { |e| e["id"] if e.is_a?(Hash) }.to_h { |id| [id, true] }
|
|
120
|
+
(1..).each do |attempt|
|
|
121
|
+
elements = SceneBuilder.new(code_path, salt: "merge #{attempt}")
|
|
122
|
+
.build(MERGE_TITLE, columns, origin: [(right + SceneBuilder::COLUMN_GAP * 2).round, top.round])
|
|
123
|
+
return elements if elements.none? { |e| taken[e["id"]] }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def spec_columns(index, code_path)
|
|
128
|
+
entries = index.spec_entries.sort_by(&:start_line)
|
|
129
|
+
parents = parents_of(entries)
|
|
130
|
+
roots = entries.reject { |entry| parents[entry] }
|
|
131
|
+
# A single top-level group, the usual RSpec.describe, gives its
|
|
132
|
+
# children a column each; otherwise each top-level group is a column.
|
|
133
|
+
tops = (roots.size == 1 && roots.first.group) ? entries.select { |e| parents[e] == roots.first } : roots
|
|
134
|
+
loose = tops.reject(&:group)
|
|
135
|
+
loose_boxes = loose.filter_map { |e| spec_box(e, parents, code_path) }
|
|
136
|
+
columns = loose_boxes.empty? ? [] : [SceneBuilder::Column.new(heading: nil, items: loose_boxes)]
|
|
137
|
+
columns + tops.select(&:group).map do |group|
|
|
138
|
+
items = entries.select { |e| descendant?(e, group, parents) }.filter_map do |entry|
|
|
139
|
+
entry.group ? SceneBuilder::Heading.new(text: own_label(entry, parents), link: spec_link(entry, code_path)) : spec_box(entry, parents, code_path)
|
|
140
|
+
end
|
|
141
|
+
SceneBuilder::Column.new(heading: SceneBuilder::Heading.new(text: own_label(group, parents), link: spec_link(group, code_path)), items: items)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# nil when the diagram being merged into already has it.
|
|
146
|
+
def spec_box(entry, parents, code_path)
|
|
147
|
+
label = own_label(entry, parents)
|
|
148
|
+
return nil if drawn?(entry, label)
|
|
149
|
+
|
|
150
|
+
SceneBuilder::Box.new(label: label, link: spec_link(entry, code_path))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def spec_link(entry, code_path)
|
|
154
|
+
verified_link(["omoteura:##{entry.name}"], entry, code_path)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Entries are sorted by start line and RSpec blocks nest, so each
|
|
158
|
+
# entry's parent is the nearest open entry that still contains it.
|
|
159
|
+
def parents_of(entries)
|
|
160
|
+
open = []
|
|
161
|
+
entries.to_h do |entry|
|
|
162
|
+
open.pop while open.any? && open.last.end_line < entry.start_line
|
|
163
|
+
parent = open.last
|
|
164
|
+
open.push(entry)
|
|
165
|
+
[entry, parent]
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def descendant?(entry, ancestor, parents)
|
|
170
|
+
parent = parents[entry]
|
|
171
|
+
parent = parents[parent] while parent && parent != ancestor
|
|
172
|
+
!parent.nil?
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The description the entry adds to its parent's: "keeps emails
|
|
176
|
+
# unique" for "User validations keeps emails unique".
|
|
177
|
+
def own_label(entry, parents)
|
|
178
|
+
parent = parents[entry]
|
|
179
|
+
return entry.name unless parent && entry.name.start_with?(parent.name)
|
|
180
|
+
|
|
181
|
+
entry.name.delete_prefix(parent.name).delete_prefix(" ")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def code_columns(index, code_path)
|
|
185
|
+
Outline.new(index).items(only: @only).chunk_while { |a, b| a.kind == b.kind }.map do |items|
|
|
186
|
+
kind = items.first.kind
|
|
187
|
+
boxes = items.filter_map do |item|
|
|
188
|
+
entry = item.entry
|
|
189
|
+
label = (kind == "methods") ? entry.name : entry.name.delete_suffix(",")
|
|
190
|
+
next if drawn?(entry, label)
|
|
191
|
+
|
|
192
|
+
if kind == "methods"
|
|
193
|
+
SceneBuilder::Box.new(label: entry.name, link: verified_link(["omoteura:##{entry.name}"], entry, code_path))
|
|
194
|
+
else
|
|
195
|
+
prefix = entry.name[/\A\S+\s+[^,(\s]+/]
|
|
196
|
+
SceneBuilder::Box.new(label: entry.name.delete_suffix(","), link: verified_link(["omoteura:##{prefix}", "omoteura:##{entry.name}"].uniq, entry, code_path))
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
SceneBuilder::Column.new(heading: SceneBuilder::Heading.new(text: kind.capitalize, link: nil), items: boxes) unless boxes.empty?
|
|
200
|
+
end.compact
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The first candidate that resolves to exactly this entry, or nil.
|
|
204
|
+
def verified_link(candidates, entry, code_path)
|
|
205
|
+
@link_resolver.exact_link(candidates, path: code_path, line: entry.start_line)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The draft takes the conventional place, unless the file already has
|
|
209
|
+
# a diagram, by any means, or a file is there. --out, relative to the
|
|
210
|
+
# root, names a file or a directory instead. Nothing is ever replaced.
|
|
211
|
+
def destination(code_path)
|
|
212
|
+
if @out
|
|
213
|
+
path = @out.end_with?(@extension) ? @out : File.join(@out, File.basename(base_name(code_path)) + @extension)
|
|
214
|
+
return [path, File.exist?(File.expand_path(path, @config.root)) ? :exists : :written]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
base = File.join(@config.diagrams_dir, base_name(code_path))
|
|
218
|
+
attachment = @resolver.resolve(code_path)
|
|
219
|
+
taken = attachment || EXTENSIONS.each_value.any? { |ext| File.exist?(File.join(@config.root, base + ext)) }
|
|
220
|
+
return [base + @extension, :written] unless taken
|
|
221
|
+
|
|
222
|
+
path = base + ".draft" + @extension
|
|
223
|
+
[path, File.exist?(File.join(@config.root, path)) ? :exists : :draft]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def base_name(code_path)
|
|
227
|
+
code_path.delete_suffix(File.extname(code_path))
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def write(diagram_path, elements)
|
|
231
|
+
full = File.expand_path(diagram_path, @config.root)
|
|
232
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
233
|
+
scene = {
|
|
234
|
+
"type" => "excalidraw",
|
|
235
|
+
"version" => 2,
|
|
236
|
+
"source" => FORMAT_SOURCE,
|
|
237
|
+
"elements" => elements,
|
|
238
|
+
"appState" => {"viewBackgroundColor" => "#ffffff", "gridSize" => nil},
|
|
239
|
+
"files" => {}
|
|
240
|
+
}
|
|
241
|
+
File.write(full, (@format == :obsidian) ? Obsidian.render(scene) : JSON.pretty_generate(scene) + "\n")
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omoteura
|
|
4
|
+
# Everything an editor needs about one code file or one diagram, as plain
|
|
5
|
+
# data: the attached diagram or the code it is attached to, where each
|
|
6
|
+
# linked shape points and how the tests behind it did. Broken links and
|
|
7
|
+
# unreadable diagrams are reported in the data rather than raised, so the
|
|
8
|
+
# editor can show them.
|
|
9
|
+
class Inspector
|
|
10
|
+
FORMAT_VERSION = 1
|
|
11
|
+
|
|
12
|
+
def initialize(config, results: TestResults.new([]))
|
|
13
|
+
@config = config
|
|
14
|
+
@results = results
|
|
15
|
+
@resolver = Resolver.new(config)
|
|
16
|
+
@link_resolver = LinkResolver.new(config)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# code_path must be an existing file inside the project root.
|
|
20
|
+
def report(code_path)
|
|
21
|
+
file = @config.expand(code_path) or raise ArgumentError, "#{code_path} is outside the project"
|
|
22
|
+
relative = @config.relative(file)
|
|
23
|
+
data = {version: FORMAT_VERSION, code_path: relative, new_diagram_path: new_diagram_path(relative),
|
|
24
|
+
attachment: nil, shapes: [], error: nil}
|
|
25
|
+
|
|
26
|
+
attachment = @resolver.resolve(relative) or return data
|
|
27
|
+
data[:attachment] = {diagram_path: attachment.diagram_path, source: attachment.source.to_s, missing: attachment.missing?}
|
|
28
|
+
return data if attachment.missing?
|
|
29
|
+
|
|
30
|
+
diagram = Diagram.load(@config.expand(attachment.diagram_path))
|
|
31
|
+
data[:shapes] = diagram.linked_elements.map { |element| shape(element, relative) }
|
|
32
|
+
data
|
|
33
|
+
rescue MagicComment::Duplicate, Resolver::InvalidPath, Diagram::NotADiagram, Diagram::ParseError => e
|
|
34
|
+
data.merge(error: e.message.delete_prefix("#{@config.root}/"))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# diagram_path must be an existing file inside the project root. A shared
|
|
38
|
+
# diagram can be attached to several files; links without a path are
|
|
39
|
+
# resolved against the first of them.
|
|
40
|
+
def diagram_report(diagram_path)
|
|
41
|
+
file = @config.expand(diagram_path) or raise ArgumentError, "#{diagram_path} is outside the project"
|
|
42
|
+
relative = @config.relative(file)
|
|
43
|
+
attached_to = code_attached_to(relative)
|
|
44
|
+
data = {version: FORMAT_VERSION, diagram_path: relative, attached_to: attached_to, digest: nil, scene: nil, shapes: [], error: nil}
|
|
45
|
+
|
|
46
|
+
diagram = Diagram.load(file)
|
|
47
|
+
data[:digest] = diagram.digest
|
|
48
|
+
data[:scene] = diagram.scene
|
|
49
|
+
data[:shapes] = diagram.linked_elements.map { |element| shape(element, attached_to.first) }
|
|
50
|
+
data
|
|
51
|
+
rescue Diagram::NotADiagram, Diagram::ParseError => e
|
|
52
|
+
data.merge(error: e.message.delete_prefix("#{@config.root}/"))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def code_attached_to(diagram_path)
|
|
58
|
+
Project.new(@config).code_paths.select do |code_path|
|
|
59
|
+
attachment = @resolver.resolve(code_path)
|
|
60
|
+
attachment && !attachment.missing? && attachment.diagram_path == diagram_path
|
|
61
|
+
rescue MagicComment::Duplicate, Resolver::InvalidPath
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Where a new plain Excalidraw diagram for the file belongs by the path
|
|
67
|
+
# convention, so an editor can offer to create one.
|
|
68
|
+
def new_diagram_path(code_path)
|
|
69
|
+
File.join(@config.diagrams_dir, code_path.delete_suffix(File.extname(code_path)) + ".excalidraw")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def shape(element, context_path)
|
|
73
|
+
result = @link_resolver.resolve(element.link, context_path: context_path)
|
|
74
|
+
target = result.target
|
|
75
|
+
summary = target && @results.summary_for(target)
|
|
76
|
+
{
|
|
77
|
+
id: element.id,
|
|
78
|
+
label: element.label,
|
|
79
|
+
link: element.link,
|
|
80
|
+
target: target&.to_h,
|
|
81
|
+
error: result.error,
|
|
82
|
+
test: summary && {
|
|
83
|
+
status: summary.status,
|
|
84
|
+
examples: summary.examples.map { |e| e.to_h.slice(:line_number, :full_description, :status, :message) }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|