exhale 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.
data/lib/exhale/git.rb ADDED
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "set"
6
+ require_relative "errors"
7
+
8
+ module Exhale
9
+ # Thin, deterministic wrapper over the git CLI. Every call uses argument
10
+ # arrays and ignores the user's quoting config.
11
+ class Git
12
+ # The user's git config must not change what exhale reads, or the same
13
+ # commit could get different verdicts on different machines. These pin
14
+ # every setting that changes diff or blame output.
15
+ BASE_ARGS = ["-c", "core.quotepath=off", "-c", "diff.renames=true", "-c", "diff.noprefix=false",
16
+ "-c", "diff.mnemonicPrefix=false", "-c", "diff.relative=false",
17
+ "-c", "diff.indentHeuristic=false", "-c", "diff.interHunkContext=0", "-c", "blame.ignoreRevsFile=",
18
+ "-c", "blame.markIgnoredLines=false", "-c", "blame.markUnblamableLines=false"].freeze
19
+ DEFAULT_CANDIDATES = %w[origin/main origin/master main master].freeze
20
+ # A blame header line: the commit, SHA-1 or SHA-256, then the original
21
+ # and final line numbers.
22
+ BLAME_HEADER = /\A(\h{40}(?:\h{24})?) \d+ (\d+)/
23
+ HUNK = /\A@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
24
+
25
+ attr_reader :root
26
+
27
+ def initialize(root)
28
+ @root = File.expand_path(root)
29
+ end
30
+
31
+ def repo?
32
+ out, _err, status = run("rev-parse", "--is-inside-work-tree")
33
+ status.success? && out.strip == "true"
34
+ end
35
+
36
+ def head_sha
37
+ out, _err, status = run("rev-parse", "--verify", "--quiet", "HEAD^{commit}")
38
+ status.success? ? out.strip : nil
39
+ end
40
+
41
+ def default_branch_ref
42
+ out, _err, status = run("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
43
+ if status.success? && ref_exists?(out.strip)
44
+ return out.strip
45
+ end
46
+
47
+ DEFAULT_CANDIDATES.find { |ref| ref_exists?(ref) }
48
+ end
49
+
50
+ def merge_base(ref)
51
+ return nil if ref.nil? || ref.empty?
52
+
53
+ out, _err, status = run("merge-base", ref, "HEAD")
54
+ status.success? && !out.strip.empty? ? out.strip : nil
55
+ end
56
+
57
+ # Where root sits inside the repository, like "agora/" for an app in a
58
+ # monorepo. Every path exhale reports is relative to root, so exports and
59
+ # diffs are scoped to it too.
60
+ def prefix
61
+ @prefix ||= run!("rev-parse", "--show-prefix").strip
62
+ end
63
+
64
+ def toplevel
65
+ @toplevel ||= run!("rev-parse", "--show-toplevel").strip
66
+ end
67
+
68
+ # A sparse checkout leaves tracked files out of the work tree, so a sweep of
69
+ # the head would read less than the commit.
70
+ def sparse?
71
+ out, _err, status = run("config", "--bool", "core.sparseCheckout")
72
+ return true if status.success? && out.strip == "true"
73
+
74
+ run!("ls-files", "-t", "-z").split("\0").any? { |entry| entry.start_with?("S ") }
75
+ end
76
+
77
+ def files
78
+ raise GitError, "exhale needs the full tree; this checkout is sparse" if sparse?
79
+
80
+ out = run!("ls-files", "--cached", "--others", "--exclude-standard", "-z")
81
+ out.split("\0").reject(&:empty?).uniq.select do |path|
82
+ full = File.join(root, path)
83
+ File.file?(full) && !File.symlink?(full)
84
+ end.sort
85
+ end
86
+
87
+ # Symlinks (mode 120000) and submodules (type commit) aren't source.
88
+ def files_at(sha)
89
+ blob_oids(sha).keys.sort
90
+ end
91
+
92
+ # Writes the named files at sha into dir as raw blobs. git archive would
93
+ # apply export-ignore and export-subst, and the base must be the commit
94
+ # exactly as it is.
95
+ def export_files(sha, paths, dir)
96
+ FileUtils.mkdir_p(dir)
97
+ return dir if paths.empty?
98
+
99
+ # Blobs are requested by object id: a path with a newline would split a
100
+ # "sha:path" request in two and shift every response after it.
101
+ oids = blob_oids(sha)
102
+ paths = paths.select { |path| oids.key?(path) }
103
+ return dir if paths.empty?
104
+
105
+ out, err, status = Open3.capture3("git", *BASE_ARGS, "-C", root, "cat-file", "--batch",
106
+ stdin_data: paths.map { |path| "#{oids[path]}\n" }.join, binmode: true)
107
+ raise GitError, "git cat-file failed: #{utf8(err).strip}" unless status.success?
108
+
109
+ write_blobs(out, paths, dir)
110
+ dir
111
+ end
112
+
113
+ def changed_lines(base_sha)
114
+ diff = run!("diff", "--relative", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv",
115
+ "--find-renames", "--diff-algorithm=myers", "--src-prefix=a/", "--dst-prefix=b/",
116
+ base_sha, "--")
117
+ result = parse_diff(diff)
118
+ untracked_files.each { |path| result[path] = all_lines(path) }
119
+ result.reject { |_path, lines| lines.empty? }
120
+ end
121
+
122
+ # Committer time for every line of a file, index 0 being line 1, from one
123
+ # blame call. Uncommitted lines, and every line of an untracked file, are
124
+ # Float::INFINITY: newer than anything committed.
125
+ def blame_times(path)
126
+ full = File.join(root, path)
127
+ return Array.new(File.foreach(full).count, Float::INFINITY) unless tracked?(path)
128
+
129
+ # The flag, unlike an empty -c value, really does reset a repository's
130
+ # own blame.ignoreRevsFile.
131
+ out, err, status = run("blame", "--porcelain", "--no-ignore-revs-file", "--", path)
132
+ raise GitError, "git blame failed: #{err.strip}" unless status.success?
133
+
134
+ line_times(out)
135
+ end
136
+
137
+ def blame_time(path, start_line, end_line)
138
+ return Float::INFINITY unless tracked?(path)
139
+
140
+ out, err, status = run("blame", "--porcelain", "--no-ignore-revs-file", "-L", "#{start_line},#{end_line}",
141
+ "--", path)
142
+ raise GitError, "git blame failed: #{err.strip}" unless status.success?
143
+
144
+ newest_time(out)
145
+ end
146
+
147
+ private
148
+
149
+ # Output is UTF-8 whatever the locale, so no verdict depends on it.
150
+ def run(*args)
151
+ out, err, status = Open3.capture3("git", *BASE_ARGS, "-C", root, *args)
152
+ [utf8(out), utf8(err), status]
153
+ end
154
+
155
+ def utf8(text)
156
+ text.dup.force_encoding(Encoding::UTF_8).scrub
157
+ end
158
+
159
+ # path => object id for each blob under root at sha. Symlinks (mode
160
+ # 120000) and submodules (type commit) aren't source.
161
+ def blob_oids(sha)
162
+ run!("ls-tree", "-r", "-z", sha).split("\0").each_with_object({}) do |entry, oids|
163
+ meta, path = entry.split("\t", 2)
164
+ mode, type, oid = meta.split(" ")
165
+ oids[path] = oid if type == "blob" && mode != "120000"
166
+ end
167
+ end
168
+
169
+ def run!(*args)
170
+ out, err, status = run(*args)
171
+ raise GitError, "git #{args.first} failed: #{err.strip}" unless status.success?
172
+
173
+ out
174
+ end
175
+
176
+ def ref_exists?(ref)
177
+ _out, _err, status = run("rev-parse", "--verify", "--quiet", "#{ref}^{commit}")
178
+ status.success?
179
+ end
180
+
181
+ def tracked?(path)
182
+ _out, _err, status = run("ls-files", "--error-unmatch", "--", path)
183
+ status.success?
184
+ end
185
+
186
+ def untracked_files
187
+ run!("ls-files", "--others", "--exclude-standard", "-z").split("\0").reject(&:empty?).sort
188
+ end
189
+
190
+ def all_lines(path)
191
+ full = File.join(root, path)
192
+ return Set.new unless File.file?(full)
193
+
194
+ count = File.foreach(full).count
195
+ Set.new(1..count)
196
+ end
197
+
198
+ # File headers only count between a "diff --git" line and the first hunk,
199
+ # so an added line that happens to start with "++ " can't pass for one.
200
+ def parse_diff(diff)
201
+ result = Hash.new { |h, k| h[k] = Set.new }
202
+ current = nil
203
+ in_header = false
204
+ diff.each_line(chomp: true) do |line|
205
+ if line.start_with?("diff --git ")
206
+ in_header = true
207
+ current = nil
208
+ elsif in_header && line.start_with?("+++ ")
209
+ current = new_path(line)
210
+ elsif (m = HUNK.match(line))
211
+ in_header = false
212
+ add_hunk(result[current], m[1].to_i, (m[2] || "1").to_i) if current
213
+ end
214
+ end
215
+ result.to_h
216
+ end
217
+
218
+ # git appends a tab to a header path that contains a space, and quotes a
219
+ # path with characters it won't print raw.
220
+ def new_path(line)
221
+ target = line.delete_prefix("+++ ").delete_suffix("\t")
222
+ return nil if target == "/dev/null"
223
+
224
+ target = target.undump if target.start_with?('"')
225
+ target.delete_prefix("b/")
226
+ end
227
+
228
+ def write_blobs(out, paths, dir)
229
+ pos = 0
230
+ paths.each do |path|
231
+ header_end = out.index("\n", pos)
232
+ header = out[pos...header_end]
233
+ pos = header_end + 1
234
+ next if header.end_with?(" missing")
235
+
236
+ size = header.split(" ").last.to_i
237
+ full = File.join(dir, path)
238
+ FileUtils.mkdir_p(File.dirname(full))
239
+ File.binwrite(full, out.byteslice(pos, size))
240
+ pos += size + 1
241
+ end
242
+ end
243
+
244
+ def add_hunk(set, start, count)
245
+ count.times { |i| set << start + i }
246
+ end
247
+
248
+ # Porcelain names a commit's metadata only the first time the commit
249
+ # appears, so times are remembered per SHA and handed to each line.
250
+ def line_times(porcelain)
251
+ times = {}
252
+ result = []
253
+ sha = nil
254
+ final = nil
255
+ porcelain.each_line(chomp: true) do |line|
256
+ if line.start_with?("\t")
257
+ result[final - 1] = uncommitted?(sha) ? Float::INFINITY : times.fetch(sha, 0)
258
+ elsif (m = BLAME_HEADER.match(line))
259
+ sha = m[1]
260
+ final = m[2].to_i
261
+ elsif line.start_with?("committer-time ")
262
+ times[sha] = line.split(" ", 2).last.to_i
263
+ end
264
+ end
265
+ result
266
+ end
267
+
268
+ def uncommitted?(sha)
269
+ sha.match?(/\A0+\z/)
270
+ end
271
+
272
+ def newest_time(porcelain)
273
+ return Float::INFINITY if porcelain.each_line.any? { |l| (m = BLAME_HEADER.match(l)) && uncommitted?(m[1]) }
274
+
275
+ times = porcelain.each_line.filter_map { |l| l.split(" ", 2).last.to_i if l.start_with?("committer-time ") }
276
+ times.max or raise GitError, "no blame data"
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "version"
5
+ require_relative "dry/normalizer"
6
+
7
+ module Exhale
8
+ # Renders a duplication check's result as text for the agent doing the
9
+ # exhale, JSON for tools, or EDN in the shape dryer writes to
10
+ # .metrics/dry.edn.
11
+ module Report
12
+ FORMATS = %w[text json edn].freeze
13
+ LABELS = { introduced: "INTRODUCED", shifted: "SHIFTED", already_there: "ALREADY THERE", found: "FOUND" }.freeze
14
+
15
+ module_function
16
+
17
+ def render(result, format)
18
+ case format
19
+ when "text" then Text.new(result).render
20
+ when "json" then JSON.pretty_generate(Json.new(result).to_h)
21
+ when "edn" then Edn.new(result).render
22
+ else raise ArgumentError, "unknown format #{format.inspect}"
23
+ end
24
+ end
25
+
26
+ def counts(result)
27
+ counts = Hash.new(0)
28
+ result.findings.each { |f| counts[f.klass] += 1 }
29
+ counts[:kept] = result.kept.size
30
+ counts[:contracted] = result.contracted.size
31
+ counts
32
+ end
33
+
34
+ def kind_name(kind, location)
35
+ kind == :fragment ? "fragment, #{location.lines} lines" : kind.to_s
36
+ end
37
+
38
+ def score(value)
39
+ value.to_f.round(2)
40
+ end
41
+
42
+ class Text
43
+ def initialize(result)
44
+ @result = result
45
+ @out = []
46
+ end
47
+
48
+ def render
49
+ @out << header
50
+ @result.notes.each { |note| @out << "note: #{note}" }
51
+ @result.findings.each { |f| finding(f) }
52
+ @result.kept.each { |k| kept(k) }
53
+ contracted
54
+ errors
55
+ "#{@out.join("\n")}\n"
56
+ end
57
+
58
+ private
59
+
60
+ def header
61
+ counts = Report.counts(@result)
62
+ parts = %i[introduced shifted already_there found kept contracted].filter_map do |key|
63
+ "#{counts[key]} #{key.to_s.tr('_', ' ')}" if counts[key].positive?
64
+ end
65
+ parts << plural(@result.clause_errors.size, "contract error") unless @result.clause_errors.empty?
66
+ parts << plural(@result.parse_errors.size, "parse error") unless @result.parse_errors.empty?
67
+ parts << (@result.exit_code.zero? ? "clean" : "failing") if parts.empty?
68
+ base = @result.base_sha ? " base #{@result.base_sha[0, 7]}" : ""
69
+ "exhale dry: #{parts.join(', ')}#{base}"
70
+ end
71
+
72
+ def plural(count, noun)
73
+ "#{count} #{noun}#{'s' unless count == 1}"
74
+ end
75
+
76
+ def finding(f)
77
+ @out << ""
78
+ @out << "#{LABELS.fetch(f.klass)} #{Report.score(f.score)} #{Report.kind_name(f.kind, f.copy)}"
79
+ rows = [[f.touched.include?(f.copy) ? "new" : "copy", f.copy, nil]]
80
+ f.others.each { |l, s| rows << ["existing", l, f.others.size > 1 ? Report.score(s) : nil] }
81
+ table(rows)
82
+ @out << " hint #{f.hint}"
83
+ end
84
+
85
+ def kept(k)
86
+ @out << ""
87
+ flag = k.new_clause ? " NEW CLAUSE, review its reason" : ""
88
+ @out << "KEPT #{Report.score(k.score)} #{k.clause.path}:#{k.clause.line} \"#{k.clause.heading}\"#{flag}"
89
+ table([["side", k.a, nil], ["side", k.b, nil]])
90
+ end
91
+
92
+ def table(rows)
93
+ width = rows.map { |_, l, _| place(l).size }.max
94
+ ident = rows.map { |_, l, _| l.unit.identity.size }.max
95
+ rows.each do |label, l, s|
96
+ line = " #{label.ljust(9)} #{place(l).ljust(width)} #{l.unit.identity.ljust(ident)}"
97
+ line += " #{s}" if s
98
+ @out << line.rstrip
99
+ end
100
+ end
101
+
102
+ def place(location)
103
+ "#{location.path}:#{location.start_line}-#{location.end_line}"
104
+ end
105
+
106
+ def contracted
107
+ return if @result.contracted.empty?
108
+
109
+ @out << ""
110
+ @out << "CONTRACTED"
111
+ @result.contracted.first(20).each do |c|
112
+ @out << " #{c.a} no longer matches #{c.b} (#{Report.score(c.score)} at base)"
113
+ end
114
+ more = @result.contracted.size - 20
115
+ @out << " and #{more} more" if more.positive?
116
+ end
117
+
118
+ def errors
119
+ return if @result.clause_errors.empty? && @result.parse_errors.empty?
120
+
121
+ @out << ""
122
+ @result.clause_errors.each { |e| @out << "CONTRACT #{e.message}" }
123
+ @result.parse_errors.each { |e| @out << "PARSE #{e.message}" }
124
+ end
125
+ end
126
+
127
+ class Json
128
+ def initialize(result)
129
+ @result = result
130
+ end
131
+
132
+ def to_h
133
+ {
134
+ "exhale" => VERSION,
135
+ "check" => "dry",
136
+ "normalizer" => Dry::Normalizer::VERSION,
137
+ "base" => @result.base_sha,
138
+ "exit_code" => @result.exit_code,
139
+ "counts" => Report.counts(@result).transform_keys(&:to_s),
140
+ "notes" => @result.notes,
141
+ "findings" => @result.findings.map { |f| finding(f) },
142
+ "kept" => @result.kept.map { |k| kept(k) },
143
+ "contracted" => @result.contracted.map { |c| { "a" => c.a, "b" => c.b, "score" => Report.score(c.score) } },
144
+ "contract_errors" => @result.clause_errors.map { |e| error(e) },
145
+ "parse_errors" => @result.parse_errors.map { |e| error(e) }
146
+ }
147
+ end
148
+
149
+ private
150
+
151
+ def finding(f)
152
+ {
153
+ "class" => f.klass.to_s, "score" => Report.score(f.score), "kind" => f.kind.to_s,
154
+ "copy" => location(f.copy, f.touched),
155
+ "existing" => f.others.map { |l, s| location(l, f.touched).merge("score" => Report.score(s)) },
156
+ "hint" => f.hint, "payoff" => f.payoff, "primitive" => f.primitive
157
+ }
158
+ end
159
+
160
+ def kept(k)
161
+ {
162
+ "score" => Report.score(k.score), "a" => location(k.a, []), "b" => location(k.b, []),
163
+ "clause" => { "path" => k.clause.path, "line" => k.clause.line, "heading" => k.clause.heading },
164
+ "new_clause" => k.new_clause
165
+ }
166
+ end
167
+
168
+ def location(l, touched)
169
+ { "path" => l.path, "start_line" => l.start_line, "end_line" => l.end_line, "identity" => l.unit.identity,
170
+ "nodes" => l.size, "touched" => touched.include?(l) }
171
+ end
172
+
173
+ def error(e)
174
+ { "path" => e.path, "line" => e.line, "message" => e.message }
175
+ end
176
+ end
177
+
178
+ # The candidates list dryer writes, one entry per copy and existing pair.
179
+ class Edn
180
+ def initialize(result)
181
+ @result = result
182
+ end
183
+
184
+ def render
185
+ rows = @result.findings.flat_map do |f|
186
+ f.others.map { |l, s| candidate(f.copy, l, s) }
187
+ end
188
+ "{:candidates\n [#{rows.join("\n ")}]}\n"
189
+ end
190
+
191
+ private
192
+
193
+ def candidate(left, right, score)
194
+ "{:score #{score.to_f.round(12)}\n :language #{left.unit.language.to_s.inspect}\n " \
195
+ ":left #{side(left)}\n :right #{side(right)}\n :left-nodes #{left.size}\n :right-nodes #{right.size}}"
196
+ end
197
+
198
+ def side(l)
199
+ "{:file #{l.path.inspect}, :start-line #{l.start_line}, :end-line #{l.end_line}}"
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exhale
4
+ # One node of a normalized tree. Normalization keeps the names of
5
+ # operations and the shape of the tree, and replaces the names of things
6
+ # with markers, so two units that do the same thing to different locals
7
+ # produce equal trees.
8
+ #
9
+ # kind - String. A Prism or Herb node type after normalization
10
+ # ("call_node", "if_node", "html_element_node"), or a marker
11
+ # for a dropped name: ":local", ":ivar", ":cvar", ":gvar",
12
+ # ":const", ":literal".
13
+ # label - String or nil. A name normalization keeps: the method name at
14
+ # a call site (operators are calls too), an HTML tag name, an
15
+ # attribute name, or a kept attribute value (data-controller,
16
+ # data-action).
17
+ # children - Array of Shape, in source order.
18
+ # start_line - 1-based first line in the unit's file.
19
+ # end_line - 1-based last line.
20
+ # sequence - true when children are a run of statements or sibling
21
+ # markup (a method body, a block body, an element's children).
22
+ # Statement-run fragments come only from sequences.
23
+ Shape = Struct.new(:kind, :label, :children, :start_line, :end_line, :sequence, keyword_init: true) do
24
+ def leaf?
25
+ children.empty?
26
+ end
27
+
28
+ def lines
29
+ end_line - start_line + 1
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Exhale
6
+ # Lists the Ruby and ERB files exhale analyses.
7
+ module SourceFiles
8
+ EXCLUDED_ROOTS = %w[vendor node_modules tmp log coverage public storage bin].freeze
9
+ EXCLUDED_PREFIXES = ["app/assets/builds/"].freeze
10
+ # Schema, migrations and settings, at the root and in each engine or pack
11
+ # of a monorepo.
12
+ EXCLUDED_DIRS = %r{\A(?:(?:engines|packs|components|gems)/[^/]+/)?(?:db|config)/}
13
+ # A generator's header: a comment line that opens with the marker. A
14
+ # comment that only mentions generated code somewhere doesn't count.
15
+ GENERATED = %r{
16
+ \A\s*(?:<%\#|\#|//)\s*
17
+ (?:this\ file\ is\ auto-?generated|this\ file\ is\ automatically\ generated|auto-?generated\ by
18
+ |generated\ by\b.*\bdo\ not\ edit|do\ not\ edit|code\ generated\b.*\bdo\ not\ edit)
19
+ }xi
20
+ # Enough of a file to hold its first five lines.
21
+ HEADER_BYTES = 64 * 1024
22
+
23
+ module_function
24
+
25
+ def list(root, files: nil, include_tests: false)
26
+ candidates = files ? files.map(&:to_s) : walk(root)
27
+ candidates
28
+ .select { |path| language(path) && path_allowed?(path, include_tests) }
29
+ .sort
30
+ .uniq
31
+ .reject { |path| linked?(root, path) }
32
+ .select { |path| File.exist?(File.join(root, path)) }
33
+ .reject { |path| generated?(File.join(root, path)) }
34
+ .map { |path| [path, language(path)] }
35
+ end
36
+
37
+ def language(path)
38
+ case path
39
+ when /\.rb\z/ then :ruby
40
+ # Herb reads HTML with ERB in it. Text, JSON and prompt templates that
41
+ # happen to use ERB aren't HTML, so they aren't compared.
42
+ when /\.html(\+[\w-]+)?\.erb\z/, /\.turbo_stream\.erb\z/ then :erb
43
+ end
44
+ end
45
+
46
+ def walk(root)
47
+ Dir.glob("**/*", base: root).select do |path|
48
+ full = File.join(root, path)
49
+ File.file?(full) && !File.symlink?(full)
50
+ end
51
+ end
52
+
53
+ # A symlink, or a path under a symlinked directory, points outside the
54
+ # tree exhale was asked to read, or at a file it already reads.
55
+ def linked?(root, path)
56
+ segments = path.split("/")
57
+ segments.each_index.any? { |i| File.symlink?(File.join(root, *segments[0..i])) }
58
+ end
59
+
60
+ def path_allowed?(path, include_tests)
61
+ segments = path.split("/")
62
+ return false if segments.any? { |s| s.start_with?(".") }
63
+ return false if EXCLUDED_ROOTS.include?(segments.first) || path.match?(EXCLUDED_DIRS)
64
+ return false if EXCLUDED_PREFIXES.any? { |p| path.start_with?(p) }
65
+
66
+ include_tests || !test_path?(path, segments)
67
+ end
68
+
69
+ def test_path?(path, segments)
70
+ segments[0..-2].any? { |s| %w[spec test].include?(s) } || path.end_with?("_spec.rb", "_test.rb")
71
+ end
72
+
73
+ # Only a generator's header takes a file off the list. A file exhale
74
+ # can't read, or that isn't valid UTF-8, stays on it so the sweep reports
75
+ # it as a parse error; dropping it here would let it through the gate
76
+ # unread.
77
+ def generated?(full)
78
+ head = File.open(full, "rb") { |file| file.read(HEADER_BYTES) }.to_s
79
+ head.force_encoding(Encoding::UTF_8).scrub.each_line.first(5).any? { |line| line.match?(GENERATED) }
80
+ rescue SystemCallError
81
+ false
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exhale
4
+ # Anything that can appear in a finding: a method, the body of a Rails DSL
5
+ # call, or an ERB template. Fragments are found inside units later; they
6
+ # aren't units of their own.
7
+ #
8
+ # kind - :method, :dsl or :template.
9
+ # identity - Survives a file move. "Billing::Invoice#total" for an instance
10
+ # method, "Billing::Invoice.build" for a singleton method,
11
+ # "Order.scope(:settled)" or "Order.before_save[2]" for a DSL
12
+ # body, "views/orders/_form.html.erb" for a template (its path
13
+ # under app/).
14
+ # namespace - "Billing::Invoice" for a Ruby unit ("Object" at top level),
15
+ # nil for a template.
16
+ # name - "total", "build", "scope(:settled)"; nil for a template.
17
+ # path - Path relative to the repository root.
18
+ # language - :ruby or :erb.
19
+ # node - The parser node the unit covers (a Prism::Node, or a Herb
20
+ # node). Held in memory only; never cached.
21
+ Unit = Struct.new(:kind, :identity, :namespace, :name, :path, :start_line, :end_line, :language, :node,
22
+ keyword_init: true) do
23
+ def lines
24
+ end_line - start_line + 1
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "herb"
4
+ require_relative "../errors"
5
+ require_relative "../unit"
6
+
7
+ module Exhale
8
+ module Units
9
+ # A template is one unit. Duplication inside it is found later, as
10
+ # fragments of its tree.
11
+ module Erb
12
+ module_function
13
+
14
+ def extract(source, path)
15
+ # strict: false lets valid HTML through (an omitted `</li>`); broken
16
+ # markup and broken Ruby are still errors.
17
+ result = Herb.parse(source, strict: false)
18
+ raise_first_error(result.errors, path) unless result.errors.empty?
19
+
20
+ [Unit.new(kind: :template, identity: path.delete_prefix("app/"), namespace: nil, name: nil, path: path,
21
+ start_line: 1, end_line: [source.lines.size, 1].max, language: :erb, node: result.value)]
22
+ end
23
+
24
+ def raise_first_error(errors, path)
25
+ error = errors.min_by { |e| [e.location.start.line, e.location.start.column] }
26
+ raise ParseError.new(path, error.location.start.line, error.message)
27
+ end
28
+ end
29
+ end
30
+ end