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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Omoteura
6
+ # Decides which diagram is attached to a code file. In order of priority:
7
+ # the magic comment, the attach setting, then the path convention
8
+ # (<diagrams_dir>/<code path>.excalidraw.md or .excalidraw).
9
+ class Resolver
10
+ class InvalidPath < Error; end
11
+
12
+ # Paths are relative to the project root. An explicitly attached diagram
13
+ # that does not exist is still returned, marked missing, so callers can
14
+ # report it instead of silently falling back to the convention.
15
+ Attachment = Data.define(:code_path, :diagram_path, :source, :missing) do
16
+ def missing?
17
+ missing
18
+ end
19
+ end
20
+
21
+ CONVENTION_EXTENSIONS = %w[.excalidraw.md .excalidraw].freeze
22
+
23
+ attr_reader :config
24
+
25
+ def initialize(config)
26
+ @config = config
27
+ end
28
+
29
+ # Returns an Attachment, or nil when the file has no diagram or lies
30
+ # outside the project root. The code file must exist.
31
+ def resolve(code_path)
32
+ code = config.expand(code_path) or return nil
33
+ relative = relative_to_root(code)
34
+
35
+ if (written = MagicComment.find(File.read(code, encoding: Encoding::UTF_8), path: relative))
36
+ explicit(relative, written, :magic_comment)
37
+ elsif (written = config.attach[relative])
38
+ explicit(relative, written, :config)
39
+ else
40
+ by_convention(relative)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # Obsidian URIs and wikilinks leave out the .md extension, so a path
47
+ # copied from Obsidian is accepted when only "<path>.md" exists.
48
+ def explicit(code_path, written, source)
49
+ path = config.expand(written) or
50
+ raise InvalidPath, "#{code_path} attaches #{written}, which is outside the project"
51
+
52
+ unless File.file?(path)
53
+ with_md = config.expand("#{written}.md")
54
+ path = with_md if with_md && File.file?(with_md)
55
+ end
56
+
57
+ Attachment.new(code_path: code_path, diagram_path: relative_to_root(path), source: source, missing: !File.file?(path))
58
+ end
59
+
60
+ def by_convention(code_path)
61
+ base = File.join(config.diagrams_dir, code_path.delete_suffix(File.extname(code_path)))
62
+ CONVENTION_EXTENSIONS.each do |extension|
63
+ path = config.expand(base + extension)
64
+ next unless path && File.file?(path)
65
+
66
+ return Attachment.new(code_path: code_path, diagram_path: relative_to_root(path), source: :convention, missing: false)
67
+ end
68
+ nil
69
+ end
70
+
71
+ def relative_to_root(path)
72
+ Pathname(path).relative_path_from(Pathname(config.root)).to_s
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Omoteura
6
+ # The places in a Ruby file that an omoteura: link can point at, found by
7
+ # parsing the file with Prism without loading or running it.
8
+ class SourceIndex
9
+ # kind is :spec (an example group or example), :method or :statement.
10
+ # For :spec, name is the full description as RSpec builds it, and group
11
+ # tells example groups from examples; for :statement, name is the first
12
+ # line of the statement.
13
+ # Every entry is built with all five members: a default would need an
14
+ # initialize override that allocates on each of thousands of entries.
15
+ Entry = Data.define(:kind, :name, :start_line, :end_line, :group)
16
+
17
+ attr_reader :entries, :line_count, :spec_entries, :method_entries, :statement_entries
18
+
19
+ def self.parse_file(path)
20
+ new(File.read(path, encoding: Encoding::UTF_8))
21
+ end
22
+
23
+ def initialize(source)
24
+ visitor = Visitor.new(source)
25
+ Prism.parse(source).value.accept(visitor)
26
+ @entries = visitor.entries.freeze
27
+ # Grouped once here; link resolution reads these for every link.
28
+ by_kind = @entries.group_by(&:kind)
29
+ @spec_entries = by_kind.fetch(:spec, []).freeze
30
+ @method_entries = by_kind.fetch(:method, []).freeze
31
+ @statement_entries = by_kind.fetch(:statement, []).freeze
32
+ @line_count = source.count("\n") + ((source.empty? || source.end_with?("\n")) ? 0 : 1)
33
+ end
34
+
35
+ class Visitor < Prism::Visitor
36
+ EXAMPLE_GROUPS = %i[describe context feature example_group xdescribe xcontext xfeature fdescribe fcontext ffeature].freeze
37
+ EXAMPLES = %i[it specify example scenario xit xspecify xexample xscenario fit fspecify fexample fscenario].freeze
38
+
39
+ Group = Data.define(:description, :module)
40
+
41
+ attr_reader :entries
42
+
43
+ def initialize(source)
44
+ super()
45
+ @source = source
46
+ @entries = []
47
+ # Enclosing example groups. A nil description means a parent could
48
+ # not be read statically, so nothing below it has a known one.
49
+ @groups = []
50
+ end
51
+
52
+ def visit_statements_node(node)
53
+ node.body.each { |statement| add(:statement, first_line(statement), statement) }
54
+ super
55
+ end
56
+
57
+ def visit_def_node(node)
58
+ name = node.receiver.is_a?(Prism::SelfNode) ? "self.#{node.name}" : node.name.name
59
+ add(:method, name, node)
60
+ super
61
+ end
62
+
63
+ def visit_call_node(node)
64
+ return super unless spec_call?(node)
65
+
66
+ part, is_module = description_of(node)
67
+ description = full_description(part)
68
+ add(:spec, description, node, group: EXAMPLE_GROUPS.include?(node.name)) if description
69
+
70
+ if EXAMPLE_GROUPS.include?(node.name)
71
+ @groups.push(Group.new(description: description, module: is_module))
72
+ super
73
+ @groups.pop
74
+ else
75
+ super
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ # Cuts the statement's first line out of the source by byte offsets.
82
+ # Slicing the whole statement would copy most of the file for every
83
+ # class or describe block, and again for each level of nesting.
84
+ def first_line(node)
85
+ start = node.location.start_offset
86
+ stop = @source.byteindex("\n", start) || @source.bytesize
87
+ line = @source.byteslice(start, stop - start)
88
+ line.rstrip!
89
+ line
90
+ end
91
+
92
+ def add(kind, name, node, group: false)
93
+ location = node.location
94
+ @entries << Entry.new(kind: kind, name: name, start_line: location.start_line, end_line: location.end_line, group: group)
95
+ end
96
+
97
+ def spec_call?(node)
98
+ return false unless EXAMPLE_GROUPS.include?(node.name) || EXAMPLES.include?(node.name)
99
+
100
+ receiver = node.receiver
101
+ receiver.nil? || (receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :RSpec)
102
+ end
103
+
104
+ # RSpec takes the first argument as the description, plus a second
105
+ # one when it is a string; later symbols and hashes are metadata.
106
+ def description_of(node)
107
+ first, second = node.arguments&.arguments
108
+ part, is_module = literal(first)
109
+ part = "#{part} #{second.unescaped}" if part && second.is_a?(Prism::StringNode)
110
+ [part, is_module]
111
+ end
112
+
113
+ def literal(node)
114
+ case node
115
+ when Prism::StringNode, Prism::SymbolNode then [node.unescaped, false]
116
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then [node.slice, true]
117
+ else [nil, false]
118
+ end
119
+ end
120
+
121
+ # Mirrors RSpec's description building: parts are joined with a space,
122
+ # except that "#method", ".method" and "::Const" attach directly to a
123
+ # parent described by a module, as in "User#display_name".
124
+ def full_description(part)
125
+ return nil if part.nil?
126
+
127
+ parent = @groups.last
128
+ return part if parent.nil?
129
+ return nil if parent.description.nil?
130
+
131
+ separator = (parent.module && part.match?(/\A(?:#|::|\.)/)) ? "" : " "
132
+ "#{parent.description}#{separator}#{part}"
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Omoteura
6
+ # Test results written by Omoteura::RSpec::Formatter.
7
+ class TestResults
8
+ class Invalid < Error; end
9
+
10
+ DEFAULT_PATH = "tmp/omoteura/results.json"
11
+ SUPPORTED_VERSION = 1
12
+
13
+ Example = Data.define(:file_path, :line_number, :full_description, :status, :message)
14
+
15
+ # Combined status of the examples behind one shape: any failure fails
16
+ # the shape, then any pending example makes it pending.
17
+ Summary = Data.define(:status, :examples) do
18
+ def self.of(examples)
19
+ statuses = examples.map(&:status)
20
+ status =
21
+ if statuses.include?("failed") then "failed"
22
+ elsif statuses.include?("pending") then "pending"
23
+ else "passed"
24
+ end
25
+ new(status: status, examples: examples)
26
+ end
27
+ end
28
+
29
+ attr_reader :examples
30
+
31
+ # Returns empty results when the file does not exist yet, so diagrams
32
+ # still work before the first test run.
33
+ def self.load(path)
34
+ return new([]) unless File.file?(path)
35
+
36
+ data = JSON.parse(File.read(path, encoding: Encoding::UTF_8))
37
+ unless data.is_a?(Hash) && data["version"] == SUPPORTED_VERSION && data["examples"].is_a?(Array)
38
+ raise Invalid, "#{path} is not an Omoteura results file of version #{SUPPORTED_VERSION}"
39
+ end
40
+
41
+ examples = data["examples"].map do |e|
42
+ Example.new(file_path: e["file_path"], line_number: e["line_number"], full_description: e["full_description"],
43
+ status: e["status"], message: e["message"])
44
+ end
45
+ executed = data["executed_lines"]
46
+ new(examples, executed_lines: executed.is_a?(Hash) ? executed : nil)
47
+ rescue JSON::ParserError => e
48
+ raise Invalid, "#{path} is not valid JSON: #{e.message}"
49
+ end
50
+
51
+ # executed_lines maps files to the line numbers that ran during the
52
+ # tests, when the run measured coverage; nil when it did not.
53
+ def initialize(examples, executed_lines: nil)
54
+ @examples = examples
55
+ @executed_lines = executed_lines&.transform_values { |lines| lines.to_h { |line| [line, true] } }
56
+ end
57
+
58
+ # Whether code between the lines ran, or nil when nothing says: the run
59
+ # did not measure coverage, or did not load the file.
60
+ def executed?(path, lines)
61
+ executed = @executed_lines&.[](path) or return nil
62
+ lines.any? { |line| executed[line] }
63
+ end
64
+
65
+ # Summarizes the examples defined within the lines of a link target,
66
+ # or returns nil when no recorded example is there.
67
+ def summary_for(target)
68
+ matching = examples.select do |e|
69
+ e.file_path == target.path && (target.start_line..target.end_line).cover?(e.line_number)
70
+ end
71
+ matching.empty? ? nil : Summary.of(matching)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module Omoteura
8
+ # Saves a drawing an editor changed, in the format of the file it goes
9
+ # to: a .excalidraw file as JSON, an Obsidian note through
10
+ # Obsidian.rewrite so the rest of the note stays as it was.
11
+ #
12
+ # The file is replaced in one step, so a failed save leaves it whole.
13
+ # Given the digest of the file as the editor read it, a save that would
14
+ # overwrite someone else's change is refused instead.
15
+ class Writer
16
+ class Conflict < Error; end
17
+
18
+ class Invalid < Error; end
19
+
20
+ Result = Data.define(:diagram_path, :digest)
21
+
22
+ NEW_FILE_EXTENSIONS = [".excalidraw", ".excalidraw.md"].freeze
23
+
24
+ def self.digest(content)
25
+ Digest::SHA256.hexdigest(content)
26
+ end
27
+
28
+ def initialize(config)
29
+ @config = config
30
+ end
31
+
32
+ # data is the drawing as Excalidraw saves it: a Hash with "elements",
33
+ # and usually "type", "version", "source", "appState" and "files".
34
+ def save(diagram_path, data, if_unchanged: nil)
35
+ file = @config.expand(diagram_path) or raise Invalid, "#{diagram_path} is outside #{@config.root}"
36
+ raise Invalid, "the drawing has no elements list" unless data.is_a?(Hash) && data["elements"].is_a?(Array)
37
+
38
+ file = File.realpath(file) if File.symlink?(file)
39
+ existing = File.exist?(file) ? File.binread(file) : nil
40
+ if if_unchanged && (existing.nil? || self.class.digest(existing) != if_unchanged)
41
+ raise Conflict, "#{@config.relative(file)} changed since it was read"
42
+ end
43
+
44
+ content = render(file, data, existing)
45
+ replace(file, content)
46
+ Result.new(diagram_path: @config.relative(file), digest: self.class.digest(content))
47
+ end
48
+
49
+ private
50
+
51
+ def render(file, data, existing)
52
+ if existing
53
+ raise Invalid, "#{@config.relative(file)} is not an Excalidraw drawing" unless Diagram.diagram_file?(file)
54
+ elsif NEW_FILE_EXTENSIONS.none? { |ext| file.end_with?(ext) }
55
+ raise Invalid, "a new drawing must end in #{NEW_FILE_EXTENSIONS.join(" or ")}"
56
+ end
57
+
58
+ return JSON.pretty_generate(data) + "\n" if file.end_with?(".excalidraw")
59
+
60
+ existing ? Obsidian.rewrite(existing.force_encoding(Encoding::UTF_8), keep_raw_text(file, data)) : Obsidian.render(data)
61
+ end
62
+
63
+ # The plugin keeps the markdown behind a text element, such as a
64
+ # [[wiki link]], in rawText, and lists it in the note. Excalidraw does
65
+ # not know it, so it is carried over from the saved drawing while the
66
+ # text is unchanged, and dropped once the text was edited.
67
+ def keep_raw_text(file, data)
68
+ before = Diagram.load(file).scene["elements"].to_h { |e| [e["id"], e] }
69
+ elements = data["elements"].map do |element|
70
+ next element unless element.is_a?(Hash) && element["type"] == "text"
71
+
72
+ old = before[element["id"]]
73
+ raw = old["rawText"] if old && old["originalText"] == element["originalText"]
74
+ raw ? element.merge("rawText" => raw) : element.except("rawText")
75
+ end
76
+ data.merge("elements" => elements)
77
+ end
78
+
79
+ def replace(file, content)
80
+ FileUtils.mkdir_p(File.dirname(file))
81
+ temp = File.join(File.dirname(file), ".#{File.basename(file)}.#{Process.pid}.omoteura")
82
+ File.binwrite(temp, content)
83
+ File.chmod(File.stat(file).mode, temp) if File.exist?(file)
84
+ File.rename(temp, file)
85
+ ensure
86
+ File.delete(temp) if temp && File.exist?(temp)
87
+ end
88
+ end
89
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omoteura-core
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - KAIBYAKU K.K
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ executables:
13
+ - omoteura
14
+ extensions: []
15
+ extra_rdoc_files: []
16
+ files:
17
+ - LICENSE
18
+ - exe/omoteura
19
+ - lib/omoteura/checker.rb
20
+ - lib/omoteura/cli.rb
21
+ - lib/omoteura/config.rb
22
+ - lib/omoteura/core.rb
23
+ - lib/omoteura/core/version.rb
24
+ - lib/omoteura/coverage_report.rb
25
+ - lib/omoteura/diagram.rb
26
+ - lib/omoteura/draft.rb
27
+ - lib/omoteura/draft/scene_builder.rb
28
+ - lib/omoteura/inspector.rb
29
+ - lib/omoteura/link_candidates.rb
30
+ - lib/omoteura/link_resolver.rb
31
+ - lib/omoteura/lz_string.rb
32
+ - lib/omoteura/magic_comment.rb
33
+ - lib/omoteura/minimum_ruby.rb
34
+ - lib/omoteura/obsidian.rb
35
+ - lib/omoteura/outline.rb
36
+ - lib/omoteura/project.rb
37
+ - lib/omoteura/resolver.rb
38
+ - lib/omoteura/source_index.rb
39
+ - lib/omoteura/test_results.rb
40
+ - lib/omoteura/writer.rb
41
+ homepage: https://github.com/kaibyaku-jp/omoteura
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ source_code_uri: https://github.com/kaibyaku-jp/omoteura
46
+ changelog_uri: https://github.com/kaibyaku-jp/omoteura/blob/main/CHANGELOG.md
47
+ rubygems_mfa_required: 'true'
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.4'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.6.9
63
+ specification_version: 4
64
+ summary: 'Diagram file I/O, omoteura: link resolution and CLI for Omoteura'
65
+ test_files: []