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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0c9ec9195c34a9aa30eb4cb708dbf72cbaca5ee7b5f6f04a5e78df33c52ae428
4
+ data.tar.gz: 27841d7cd4cffc527a4270467d33d12e3629b0c85a260a5621d09ec630629811
5
+ SHA512:
6
+ metadata.gz: 9565e3f5fdf46d9ceb06ff93b9de14c7bba26f40e385c401776725ecaadfdb67c1b7d7e5338096c684a36fb9f2165c06ace31b545bfef33139d6e24134d242bd
7
+ data.tar.gz: ea41624e44792a929c4429f6a8b4f640b5b6027bd7f28d203c8a2035419ca8c257321697e6eb3750d267e480360227b5681ee4ece9a6dc09197b2f71f14e642d
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KAIBYAKU K.K
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/exe/omoteura ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Checked before loading anything else, so an editor that finds the system
5
+ # Ruby gets a message it can act on. Not warn, which prints nothing when
6
+ # warnings are off.
7
+ require_relative "../lib/omoteura/minimum_ruby"
8
+ if (message = Omoteura.ruby_too_old_message)
9
+ $stderr.puts message # standard:disable Style/StderrPuts
10
+ exit 2
11
+ end
12
+
13
+ require "omoteura/cli"
14
+
15
+ exit Omoteura::CLI.new.run(ARGV)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omoteura
4
+ # Checks a whole project: every attached diagram must exist and parse,
5
+ # every omoteura: link in it must resolve, every magic comment must be
6
+ # valid, and every diagram under diagrams_dir must be attached to code.
7
+ class Checker
8
+ # file is relative to the project root. shape names the diagram element
9
+ # a link problem comes from, as its label or id.
10
+ Problem = Data.define(:file, :message, :shape)
11
+
12
+ Report = Data.define(:problems, :diagram_count, :link_count) do
13
+ def ok?
14
+ problems.empty?
15
+ end
16
+ end
17
+
18
+ attr_reader :config
19
+
20
+ def initialize(config)
21
+ @config = config
22
+ @project = Project.new(config)
23
+ @resolver = Resolver.new(config)
24
+ @link_resolver = LinkResolver.new(config)
25
+ end
26
+
27
+ def run
28
+ problems = []
29
+ attachments = @project.code_paths.filter_map do |code_path|
30
+ @resolver.resolve(code_path)
31
+ rescue MagicComment::Duplicate, Resolver::InvalidPath => e
32
+ problems << Problem.new(file: code_path, message: e.message.delete_prefix("#{code_path} "), shape: nil)
33
+ nil
34
+ end
35
+
36
+ link_count = 0
37
+ attachments.each do |attachment|
38
+ if attachment.missing?
39
+ problems << Problem.new(file: attachment.code_path, message: "attaches #{attachment.diagram_path}, which does not exist", shape: nil)
40
+ next
41
+ end
42
+
43
+ diagram = load_diagram(attachment.diagram_path, problems) or next
44
+ diagram.linked_elements.each do |element|
45
+ link_count += 1
46
+ result = @link_resolver.resolve(element.link, context_path: attachment.code_path)
47
+ next unless result.broken?
48
+
49
+ problems << Problem.new(file: attachment.diagram_path, message: result.error, shape: element.label || element.id)
50
+ end
51
+ end
52
+
53
+ problems.concat(unattached_diagrams(attachments))
54
+ diagram_paths = attachments.reject(&:missing?).map(&:diagram_path).uniq
55
+ Report.new(problems: problems, diagram_count: diagram_paths.size, link_count: link_count)
56
+ end
57
+
58
+ private
59
+
60
+ def load_diagram(diagram_path, problems)
61
+ Diagram.load(File.join(config.root, diagram_path))
62
+ rescue Diagram::NotADiagram, Diagram::ParseError => e
63
+ problems << Problem.new(file: diagram_path, message: e.message.delete_prefix("#{File.join(config.root, diagram_path)} "), shape: nil)
64
+ nil
65
+ end
66
+
67
+ def unattached_diagrams(attachments)
68
+ attached = attachments.map(&:diagram_path).to_set
69
+ diagram_dir = File.join(config.root, config.diagrams_dir)
70
+ return [] unless Dir.exist?(diagram_dir)
71
+
72
+ Dir.glob("**/*.{excalidraw,md}", base: diagram_dir).sort.filter_map do |relative|
73
+ path = File.join(config.diagrams_dir, relative)
74
+ next if attached.include?(path) || !Diagram.diagram_file?(File.join(config.root, path))
75
+
76
+ message = path.match?(/\.draft\.excalidraw(?:\.md)?\z/) ? "is a draft; copy what you need into the diagram, then delete it" : "is not attached to any code file"
77
+ Problem.new(file: path, message: message, shape: nil)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require "omoteura/core"
6
+
7
+ module Omoteura
8
+ # The omoteura command. #run returns the exit status: 0 when everything
9
+ # is fine, 1 when check finds problems or save refuses to write, 2 for
10
+ # usage or settings errors.
11
+ class CLI
12
+ USAGE = <<~TEXT
13
+ Usage: omoteura <command> [options]
14
+
15
+ Commands:
16
+ check Report broken omoteura: links and diagrams not attached to code
17
+ inspect Print what an editor needs about one code file, as JSON
18
+ diagram Print what an editor needs to show one diagram, as JSON
19
+ coverage Print which declarations and methods of one file its specs cover, as JSON
20
+ links Print the places in one code file a shape can link to, as JSON
21
+ draft Draft a diagram from code, as a starting point to draw on
22
+ save Save a drawing an editor changed, read as JSON from stdin
23
+ version Print the version
24
+
25
+ Run "omoteura <command> --help" for a command's options.
26
+ TEXT
27
+
28
+ def initialize(out: $stdout, err: $stderr, input: $stdin)
29
+ @out = out
30
+ @err = err
31
+ @input = input
32
+ end
33
+
34
+ def run(argv)
35
+ command, *args = argv
36
+ case command
37
+ when "check" then check(args)
38
+ when "inspect" then editor_report(args, "inspect FILE") { |config, results, file| Inspector.new(config, results: results).report(file) }
39
+ when "diagram" then editor_report(args, "diagram FILE") { |config, results, file| Inspector.new(config, results: results).diagram_report(file) }
40
+ when "coverage" then editor_report(args, "coverage FILE") { |config, results, file| CoverageReport.new(config, results: results).report(file) }
41
+ when "links"
42
+ editor_report(args, "links FILE") do |config, _results, file|
43
+ code_path = config.relative(file)
44
+ {version: Inspector::FORMAT_VERSION, code_path: code_path, candidates: LinkCandidates.new(config).for(code_path).map(&:to_h)}
45
+ end
46
+ when "draft" then draft(args)
47
+ when "save" then save(args)
48
+ when "version", "--version", "-v"
49
+ @out.puts VERSION
50
+ 0
51
+ when "help", "--help", "-h"
52
+ @out.puts USAGE
53
+ 0
54
+ when nil
55
+ @err.puts USAGE
56
+ 2
57
+ else
58
+ @err.puts "omoteura: unknown command '#{command}'", "", USAGE
59
+ 2
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def check(args)
66
+ options = {root: Dir.pwd, format: "text"}
67
+ parser = OptionParser.new do |o|
68
+ o.banner = "Usage: omoteura check [options]"
69
+ o.on("--root DIR", "Project root (default: the current directory)") { |dir| options[:root] = dir }
70
+ o.on("--format FORMAT", %w[text github], "text, or github for GitHub Actions annotations (default: text)") { |f| options[:format] = f }
71
+ o.on("-h", "--help", "Show this help") do
72
+ @out.puts o
73
+ return 0
74
+ end
75
+ end
76
+ parser.parse!(args)
77
+
78
+ report = Checker.new(Config.load(options[:root])).run
79
+ report.problems.each { |problem| @out.puts format_problem(problem, options[:format]) }
80
+ @out.puts summary(report)
81
+ report.ok? ? 0 : 1
82
+ rescue OptionParser::ParseError, Config::Invalid => e
83
+ @err.puts "omoteura: #{e.message}"
84
+ 2
85
+ end
86
+
87
+ # The commands editors run: one file in, one line of JSON out.
88
+ def editor_report(args, usage)
89
+ options = {root: Dir.pwd, results: TestResults::DEFAULT_PATH}
90
+ parser = OptionParser.new do |o|
91
+ o.banner = "Usage: omoteura #{usage} [options]"
92
+ o.on("--root DIR", "Project root (default: the current directory)") { |dir| options[:root] = dir }
93
+ o.on("--results PATH", "Test results, relative to the root (default: #{TestResults::DEFAULT_PATH})") { |path| options[:results] = path }
94
+ o.on("-h", "--help", "Show this help") do
95
+ @out.puts o
96
+ return 0
97
+ end
98
+ end
99
+ parser.parse!(args)
100
+ raise OptionParser::InvalidArgument, "expected exactly one FILE" unless args.size == 1
101
+
102
+ config = Config.load(options[:root])
103
+ file = File.expand_path(args.first)
104
+ raise OptionParser::InvalidArgument, "#{args.first} is not a file" unless File.file?(file)
105
+ raise OptionParser::InvalidArgument, "#{args.first} is outside #{config.root}" unless config.expand(file)
106
+
107
+ results = TestResults.load(File.expand_path(options[:results], config.root))
108
+ @out.puts JSON.generate(yield(config, results, file))
109
+ 0
110
+ rescue OptionParser::ParseError, Config::Invalid, TestResults::Invalid => e
111
+ @err.puts "omoteura: #{e.message}"
112
+ 2
113
+ end
114
+
115
+ def draft(args)
116
+ options = {root: Dir.pwd, only: Draft::CATEGORIES, out: nil, format: "json", dry_run: false, merge: false, json: false}
117
+ parser = OptionParser.new do |o|
118
+ o.banner = "Usage: omoteura draft FILE_OR_GLOB... [options]"
119
+ o.on("--root DIR", "Project root (default: the current directory)") { |dir| options[:root] = dir }
120
+ o.on("--out PATH", "Write here instead, relative to the root: a file or a directory") { |path| options[:out] = path }
121
+ o.on("--format FORMAT", %w[json md], "json for .excalidraw (default), or md for an Obsidian .excalidraw.md") { |f| options[:format] = f }
122
+ o.on("--only LIST", Array, "For code other than specs, draw only: #{Draft::CATEGORIES.join(", ")}") { |list| options[:only] = list }
123
+ o.on("--merge", "Add what the file's diagram has no shape for yet, to the right of it") { options[:merge] = true }
124
+ o.on("--dry-run", "Show what would be written without writing") { options[:dry_run] = true }
125
+ o.on("--json", "Print the results as one line of JSON, for editors") { options[:json] = true }
126
+ o.on("-h", "--help", "Show this help") do
127
+ @out.puts o
128
+ return 0
129
+ end
130
+ end
131
+ parser.parse!(args)
132
+ raise OptionParser::InvalidArgument, "expected at least one FILE" if args.empty?
133
+ unknown = options[:only] - Draft::CATEGORIES
134
+ raise OptionParser::InvalidArgument, "--only does not know #{unknown.join(", ")}" unless unknown.empty?
135
+
136
+ raise OptionParser::InvalidArgument, "--merge adds to the diagram in place, so it takes no --out" if options[:merge] && options[:out]
137
+
138
+ config = Config.load(options[:root])
139
+ paths = draft_paths(args, config)
140
+ format = (options[:format] == "md") ? :obsidian : :excalidraw
141
+ out_file = options[:out]&.match?(/\.excalidraw(?:\.md)?\z/)
142
+ if out_file && !options[:out].end_with?(Draft::EXTENSIONS.fetch(format))
143
+ raise OptionParser::InvalidArgument, "--out #{options[:out]} does not end in #{Draft::EXTENSIONS.fetch(format)}, as --format #{options[:format]} writes"
144
+ end
145
+ if out_file && paths.size > 1
146
+ raise OptionParser::InvalidArgument, "--out names one file, but #{paths.size} files match"
147
+ end
148
+
149
+ drafter = Draft.new(config, only: options[:only], out: options[:out], format: format)
150
+ results = paths.map do |path|
151
+ result = options[:merge] ? drafter.merge(path, dry_run: options[:dry_run]) : drafter.draft(path, dry_run: options[:dry_run])
152
+ @out.puts draft_line(result, options[:dry_run]) unless options[:json]
153
+ result
154
+ end
155
+ @out.puts JSON.generate(version: Inspector::FORMAT_VERSION, results: results.map { |r| r.to_h.merge(status: r.status.to_s) }) if options[:json]
156
+ 0
157
+ rescue OptionParser::ParseError, Config::Invalid, Writer::Conflict, Diagram::ParseError, Diagram::NotADiagram, Resolver::InvalidPath, MagicComment::Duplicate => e
158
+ @err.puts "omoteura: #{e.message}"
159
+ 2
160
+ end
161
+
162
+ # Prints one line of JSON: where the drawing went and the digest of
163
+ # what was written, or why nothing was.
164
+ def save(args)
165
+ options = {root: Dir.pwd, if_unchanged: nil}
166
+ parser = OptionParser.new do |o|
167
+ o.banner = "Usage: omoteura save FILE [options] < drawing.json"
168
+ o.on("--root DIR", "Project root (default: the current directory)") { |dir| options[:root] = dir }
169
+ o.on("--if-unchanged DIGEST", "Refuse to save unless the file's SHA-256 is still DIGEST") { |digest| options[:if_unchanged] = digest }
170
+ o.on("-h", "--help", "Show this help") do
171
+ @out.puts o
172
+ return 0
173
+ end
174
+ end
175
+ parser.parse!(args)
176
+ raise OptionParser::InvalidArgument, "expected exactly one FILE" unless args.size == 1
177
+
178
+ config = Config.load(options[:root])
179
+ begin
180
+ result = Writer.new(config).save(File.expand_path(args.first), JSON.parse(@input.read), if_unchanged: options[:if_unchanged])
181
+ @out.puts JSON.generate(version: Inspector::FORMAT_VERSION, diagram_path: result.diagram_path, digest: result.digest, error: nil, conflict: false)
182
+ 0
183
+ rescue JSON::ParserError, Writer::Invalid, Writer::Conflict, Diagram::ParseError => e
184
+ error = e.is_a?(JSON::ParserError) ? "the drawing is not valid JSON" : e.message
185
+ @out.puts JSON.generate(version: Inspector::FORMAT_VERSION, diagram_path: nil, digest: nil, error: error, conflict: e.is_a?(Writer::Conflict))
186
+ 1
187
+ end
188
+ rescue OptionParser::ParseError, Config::Invalid => e
189
+ @err.puts "omoteura: #{e.message}"
190
+ 2
191
+ end
192
+
193
+ # Arguments are files or globs, relative to the root.
194
+ def draft_paths(args, config)
195
+ paths = args.flat_map do |arg|
196
+ matches = arg.match?(/[*?\[{]/) ? Dir.glob(arg, base: config.root).sort : [arg]
197
+ raise OptionParser::InvalidArgument, "#{arg} matches no files" if matches.empty?
198
+
199
+ matches.map do |path|
200
+ file = config.expand(path) or raise OptionParser::InvalidArgument, "#{path} is outside #{config.root}"
201
+ raise OptionParser::InvalidArgument, "#{path} is not a file" unless File.file?(file)
202
+
203
+ config.relative(file)
204
+ end
205
+ end
206
+ paths.uniq
207
+ end
208
+
209
+ def draft_line(result, dry_run)
210
+ shapes = "#{result.shape_count} #{(result.shape_count == 1) ? "shape" : "shapes"}"
211
+ verb = dry_run ? "would write" : "wrote"
212
+ case result.status
213
+ when :written then "#{verb} #{result.diagram_path} (#{shapes})"
214
+ when :draft
215
+ "#{verb} #{result.diagram_path} (#{shapes}) beside the diagram #{result.code_path} already has; copy what you need, then delete it"
216
+ when :exists then "skipped #{result.code_path}: #{result.diagram_path} already exists; delete it to draft again"
217
+ when :empty then "skipped #{result.code_path}: nothing to draw"
218
+ when :merged then "#{dry_run ? "would add" : "added"} #{shapes} to #{result.diagram_path}"
219
+ when :up_to_date then "skipped #{result.code_path}: #{result.diagram_path} already has a shape for everything"
220
+ when :no_diagram then "skipped #{result.code_path}: no diagram to merge into; draft without --merge"
221
+ end
222
+ end
223
+
224
+ def format_problem(problem, format)
225
+ message = problem.shape ? "#{problem.message} (shape \"#{problem.shape}\")" : problem.message
226
+ if format == "github"
227
+ "::error file=#{escape_property(problem.file)},title=omoteura check::#{escape_data(message)}"
228
+ else
229
+ "#{problem.file}: #{message}"
230
+ end
231
+ end
232
+
233
+ def summary(report)
234
+ checked = "Checked #{count(report.diagram_count, "diagram")} and #{count(report.link_count, "link")}"
235
+ report.ok? ? "#{checked}: no problems." : "#{checked}: #{count(report.problems.size, "problem")}."
236
+ end
237
+
238
+ def count(number, noun)
239
+ "#{number} #{noun}#{"s" unless number == 1}"
240
+ end
241
+
242
+ # Escaping rules for GitHub Actions workflow commands.
243
+ def escape_data(text)
244
+ text.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
245
+ end
246
+
247
+ def escape_property(text)
248
+ escape_data(text).gsub(":", "%3A").gsub(",", "%2C")
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "psych"
5
+
6
+ module Omoteura
7
+ # Project settings from .omoteura.yml, overridden by .omoteura.local.yml.
8
+ class Config
9
+ class Invalid < Error; end
10
+
11
+ FILE_NAMES = %w[.omoteura.yml .omoteura.local.yml].freeze
12
+ DEFAULT_DIAGRAMS_DIR = "ura"
13
+
14
+ # coverage_panel is false to turn the editor's coverage panel off.
15
+ attr_reader :root, :diagrams_dir, :attach, :coverage_panel
16
+
17
+ def self.load(root)
18
+ root = File.expand_path(root)
19
+ settings = FILE_NAMES.each_with_object({}) do |name, merged|
20
+ path = File.join(root, name)
21
+ merge_settings(merged, read_settings(path)) if File.file?(path)
22
+ end
23
+ new(root: root, **settings)
24
+ end
25
+
26
+ def self.read_settings(path)
27
+ settings = Psych.safe_load_file(path) || {}
28
+ raise Invalid, "#{path} must be a mapping" unless settings.is_a?(Hash)
29
+
30
+ settings
31
+ rescue Psych::Exception => e
32
+ raise Invalid, "#{path} is not valid YAML: #{e.message}"
33
+ end
34
+
35
+ # The local file adds to and overrides individual attach entries
36
+ # rather than replacing the whole attach mapping.
37
+ def self.merge_settings(merged, settings)
38
+ merged[:diagrams_dir] = settings["diagrams_dir"] if settings.key?("diagrams_dir")
39
+ merged[:coverage_panel] = settings["coverage_panel"] if settings.key?("coverage_panel")
40
+ return unless settings.key?("attach")
41
+
42
+ attach = settings["attach"]
43
+ # A non-mapping value is passed through so #initialize reports it.
44
+ merged[:attach] = attach.is_a?(Hash) ? (merged[:attach] || {}).merge(attach) : attach
45
+ end
46
+ private_class_method :read_settings, :merge_settings
47
+
48
+ def initialize(root:, diagrams_dir: DEFAULT_DIAGRAMS_DIR, attach: {}, coverage_panel: true)
49
+ @root = File.expand_path(root)
50
+ @root_prefix = (@root + File::SEPARATOR).freeze
51
+ @diagrams_dir = validate_relative_path(diagrams_dir, "diagrams_dir")
52
+ @attach = validate_attach(attach).freeze
53
+ raise Invalid, "coverage_panel must be true or false, got #{coverage_panel.inspect}" unless [true, false].include?(coverage_panel)
54
+
55
+ @coverage_panel = coverage_panel
56
+ end
57
+
58
+ # Expands a path written relative to the project root. Returns nil when
59
+ # it points outside the root, so a repository cannot make Omoteura read
60
+ # arbitrary files on the machine.
61
+ def expand(relative_path)
62
+ path = File.expand_path(relative_path, root)
63
+ inside_root?(path) ? path : nil
64
+ end
65
+
66
+ # The path relative to the root, for paths that #expand accepted.
67
+ def relative(path)
68
+ Pathname(path).relative_path_from(Pathname(root)).to_s
69
+ end
70
+
71
+ private
72
+
73
+ def inside_root?(path)
74
+ return false unless path.start_with?(@root_prefix)
75
+ return true unless File.exist?(path)
76
+
77
+ File.realpath(path).start_with?(@real_root_prefix ||= (File.realpath(root) + File::SEPARATOR).freeze)
78
+ end
79
+
80
+ def validate_relative_path(value, key)
81
+ unless value.is_a?(String) && !value.empty? && !Pathname(value).absolute?
82
+ raise Invalid, "#{key} must be a relative path, got #{value.inspect}"
83
+ end
84
+
85
+ value
86
+ end
87
+
88
+ def validate_attach(value)
89
+ raise Invalid, "attach must be a mapping of code paths to diagram paths" unless value.is_a?(Hash)
90
+
91
+ value.to_h do |code_path, diagram_path|
92
+ [validate_relative_path(code_path, "attach key"), validate_relative_path(diagram_path, "attach[#{code_path}]")]
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omoteura
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "omoteura/core/version"
4
+
5
+ module Omoteura
6
+ class Error < StandardError; end
7
+ end
8
+
9
+ require "omoteura/checker"
10
+ require "omoteura/config"
11
+ require "omoteura/coverage_report"
12
+ require "omoteura/diagram"
13
+ require "omoteura/draft"
14
+ require "omoteura/inspector"
15
+ require "omoteura/link_candidates"
16
+ require "omoteura/link_resolver"
17
+ require "omoteura/magic_comment"
18
+ require "omoteura/minimum_ruby"
19
+ require "omoteura/obsidian"
20
+ require "omoteura/outline"
21
+ require "omoteura/project"
22
+ require "omoteura/resolver"
23
+ require "omoteura/source_index"
24
+ require "omoteura/test_results"
25
+ require "omoteura/writer"
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omoteura
4
+ # Which of a code file's declarations and public methods its specs cover,
5
+ # for the editor's coverage panel. Separate from diagrams: it works on any
6
+ # file and needs none.
7
+ #
8
+ # An item counts as covered by an example that names it, in the example's
9
+ # description or its code: "validates :email" by an example mentioning
10
+ # email, "def display_name" by one inside describe "#display_name". The
11
+ # descriptions RSpec generated in the last run, such as those of
12
+ # shoulda-matchers one-liners, count as well. A method also counts as
13
+ # covered when its body ran in the last run, if that run measured
14
+ # coverage.
15
+ class CoverageReport
16
+ FORMAT_VERSION = 1
17
+
18
+ def initialize(config, results: TestResults.new([]))
19
+ @config = config
20
+ @results = results
21
+ end
22
+
23
+ # code_path must be an existing file inside the project root.
24
+ def report(code_path)
25
+ file = @config.expand(code_path) or raise ArgumentError, "#{code_path} is outside the project"
26
+ relative = @config.relative(file)
27
+ data = {version: FORMAT_VERSION, code_path: relative, enabled: @config.coverage_panel, spec_paths: [], items: []}
28
+ return data unless @config.coverage_panel
29
+
30
+ specs = spec_paths(relative)
31
+ examples = specs.flat_map { |spec| examples_in(spec) }
32
+ items = Outline.new(SourceIndex.parse_file(file)).items
33
+ data.merge(spec_paths: specs, items: items.map { |item| item_data(item, examples, relative) })
34
+ end
35
+
36
+ private
37
+
38
+ ONE_LINER = /\A(?:it|specify|example|its)\b(?!\s*["'(])/
39
+ # label is what the panel shows; description is what names are looked for in.
40
+ Example = Data.define(:path, :line, :label, :description, :source, :status)
41
+ private_constant :ONE_LINER, :Example
42
+
43
+ # The specs for a file by RSpec's conventions: app/models/user.rb is
44
+ # tested in spec/models/user_spec.rb, lib/a/b.rb in spec/a/b_spec.rb or
45
+ # spec/lib/a/b_spec.rb.
46
+ def spec_paths(code_path)
47
+ base = code_path.delete_suffix(File.extname(code_path))
48
+ candidates =
49
+ if base.start_with?("app/") then ["spec/#{base.delete_prefix("app/")}_spec.rb"]
50
+ elsif base.start_with?("lib/") then ["spec/#{base.delete_prefix("lib/")}_spec.rb", "spec/#{base}_spec.rb"]
51
+ else ["spec/#{base}_spec.rb"]
52
+ end
53
+ candidates.select { |path| (full = @config.expand(path)) && File.file?(full) }
54
+ end
55
+
56
+ def examples_in(spec_path)
57
+ source = File.read(File.join(@config.root, spec_path), encoding: Encoding::UTF_8)
58
+ lines = source.lines
59
+ recorded = @results.examples.select { |e| e.file_path.delete_prefix("./") == spec_path }.to_h { |e| [e.line_number, e] }
60
+ index = SourceIndex.new(source)
61
+ described = index.spec_entries.reject(&:group)
62
+ # Examples without a description, such as it { is_expected.to ... },
63
+ # are statements only; their description comes from the last run.
64
+ lines_taken = described.to_h { |entry| [entry.start_line, true] }
65
+ undescribed = index.statement_entries.select { |e| e.name.match?(ONE_LINER) && !lines_taken[e.start_line] }
66
+ (described + undescribed).sort_by(&:start_line).map do |entry|
67
+ result = recorded[entry.start_line]
68
+ static = (entry.kind == :spec) ? entry.name : nil
69
+ description = [static, result&.full_description].compact.uniq.join("\n")
70
+ Example.new(path: spec_path, line: entry.start_line, label: static || result&.full_description || entry.name, description: description,
71
+ source: lines[(entry.start_line - 1)...entry.end_line].join, status: result&.status)
72
+ end
73
+ end
74
+
75
+ def item_data(item, examples, code_path)
76
+ entry = item.entry
77
+ patterns = names_of(item).map { |name| patterns_for(name) }
78
+ covering = examples.select { |example| patterns.any? { |words, code| example.description.match?(words) || example.source.match?(code) } }
79
+ executed = executed?(item, code_path)
80
+ {
81
+ kind: item.kind,
82
+ name: entry.name,
83
+ start_line: entry.start_line,
84
+ end_line: entry.end_line,
85
+ covered: !covering.empty? || executed == true,
86
+ executed: executed,
87
+ examples: covering.map { |e| {path: e.path, line: e.line, description: e.label, status: e.status} }
88
+ }
89
+ end
90
+
91
+ # Whether a method's body ran: its def line runs when the class loads,
92
+ # so only the lines after it tell. nil for declarations, which run when
93
+ # the class loads, and for methods on one line.
94
+ def executed?(item, code_path)
95
+ entry = item.entry
96
+ return nil unless item.kind == "methods" && entry.end_line - entry.start_line >= 2
97
+
98
+ @results.executed?(code_path, (entry.start_line + 1)...entry.end_line)
99
+ end
100
+
101
+ # What an example would call the item: the symbols a declaration names,
102
+ # such as email and name in "validates :email, :name, presence: true",
103
+ # or a method's name.
104
+ def names_of(item)
105
+ return [item.entry.name.delete_prefix("self.")] if item.kind == "methods"
106
+
107
+ call = item.entry.name.sub(/\A\S+\s*/, "")
108
+ symbols = call.scan(/(?<![\w:]):(\w+[?!]?)/).flatten
109
+ # enum role: { ... }, the older syntax, names its attribute as a key.
110
+ symbols = call.scan(/\A(\w+):/).flatten if symbols.empty?
111
+ symbols.reject { |name| name.length < 2 }
112
+ end
113
+
114
+ # The name as a word in an example's description, and in its code.
115
+ # Underscores may be spaces in a description: "display name" for
116
+ # display_name. Built once per name, not per example.
117
+ def patterns_for(name)
118
+ code = Regexp.escape(name)
119
+ [/(?<!\w)#{code.gsub("_", "[_ ]")}(?![\w?!])/i, /(?<!\w)#{code}(?![\w?!])/]
120
+ end
121
+ end
122
+ end