Skip to content

Commit 8096666

Browse files
authored
Merge pull request rails#41845 from jhawthorn/template_suggestions
Add DidYouMean suggestions for missing templates
2 parents 603eab7 + 6fa9cd8 commit 8096666

File tree

4 files changed

+124
-10
lines changed

4 files changed

+124
-10
lines changed

actionview/lib/action_view/template/error.rb

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,16 @@ def message
2727
end
2828

2929
class MissingTemplate < ActionViewError #:nodoc:
30-
attr_reader :path
30+
attr_reader :path, :paths, :prefixes
3131

3232
def initialize(paths, path, prefixes, partial, details, *)
33+
if partial && path.present?
34+
path = path.sub(%r{([^/]+)$}, "_\\1")
35+
end
36+
3337
@path = path
34-
prefixes = Array(prefixes)
38+
@paths = paths
39+
@prefixes = Array(prefixes)
3540
template_type = if partial
3641
"partial"
3742
elsif /layouts/i.match?(path)
@@ -40,15 +45,103 @@ def initialize(paths, path, prefixes, partial, details, *)
4045
"template"
4146
end
4247

43-
if partial && path.present?
44-
path = path.sub(%r{([^/]+)$}, "_\\1")
45-
end
46-
searched_paths = prefixes.map { |prefix| [prefix, path].join("/") }
48+
searched_paths = @prefixes.map { |prefix| [prefix, path].join("/") }
4749

48-
out = "Missing #{template_type} #{searched_paths.join(", ")} with #{details.inspect}. Searched in:\n"
50+
out = "Missing #{template_type} #{searched_paths.join(", ")} with #{details.inspect}.\n\nSearched in:\n"
4951
out += paths.compact.map { |p| " * #{p.to_s.inspect}\n" }.join
5052
super out
5153
end
54+
55+
class Correction
56+
Result = Struct.new(:path, :score)
57+
58+
class Results
59+
def initialize(size)
60+
@size = size
61+
@results = []
62+
end
63+
64+
def to_a
65+
@results.map(&:path)
66+
end
67+
68+
def should_record?(score)
69+
if @results.size < @size
70+
true
71+
else
72+
score < @results.last.score
73+
end
74+
end
75+
76+
def add(path, score)
77+
if should_record?(score)
78+
@results << Result.new(path, score)
79+
@results.sort_by!(&:score)
80+
@results.pop if @results.size > @size
81+
end
82+
end
83+
end
84+
85+
def initialize(error)
86+
@error = error
87+
end
88+
89+
# Apps may have thousands of candidate templates so we attempt to
90+
# generate the suggestions as efficiently as possible.
91+
# First we split templates into prefixes and basenames, so that those can
92+
# be matched separately.
93+
def corrections
94+
path = @error.path
95+
prefixes = @error.prefixes
96+
candidates = @error.paths.flat_map(&:template_paths_for_suggestions).uniq
97+
98+
# Group by possible prefixes
99+
files_by_dir = candidates.group_by do |x|
100+
File.dirname(x)
101+
end.transform_values do |files|
102+
files.map do |file|
103+
# Remove template extensions
104+
File.basename(file).split(".", 2)[0]
105+
end.uniq
106+
end
107+
108+
# No suggestions if there's an exact match, but wrong details
109+
if prefixes.any? { |prefix| files_by_dir[prefix]&.include?(path) }
110+
return []
111+
end
112+
113+
cached_distance = Hash.new do |h, args|
114+
h[args] = -DidYouMean::Jaro.distance(*args)
115+
end
116+
117+
results = Results.new(6)
118+
119+
files_by_dir.keys.index_with do |dirname|
120+
prefixes.map do |prefix|
121+
cached_distance[[prefix, dirname]]
122+
end.min
123+
end.sort_by(&:last).each do |dirname, dirweight|
124+
# If our directory's score makes it impossible to find a better match
125+
# we can prune this search branch.
126+
next unless results.should_record?(dirweight - 1.0)
127+
128+
files = files_by_dir[dirname]
129+
130+
files.each do |file|
131+
fileweight = cached_distance[[path, file]]
132+
score = dirweight + fileweight
133+
134+
results.add(File.join(dirname, file), score)
135+
end
136+
end
137+
138+
results.to_a
139+
end
140+
end
141+
142+
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error)
143+
DidYouMean.correct_error(self, Correction)
144+
end
52145
end
53146

54147
class Template

actionview/lib/action_view/template/resolver.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ def find_all_with_query(query) # :nodoc:
160160
@cache.cache_query(query) { find_template_paths(File.join(@path, query)) }
161161
end
162162

163+
def template_paths_for_suggestions # :nodoc:
164+
# Not implemented by default
165+
[]
166+
end
167+
163168
private
164169
def _find_all(name, prefix, partial, details, key, locals)
165170
find_templates(name, prefix, partial, details, locals)
@@ -337,6 +342,10 @@ def eql?(resolver)
337342
self.class.equal?(resolver.class) && to_path == resolver.to_path
338343
end
339344
alias :== :eql?
345+
346+
def template_paths_for_suggestions # :nodoc:
347+
Dir.glob("**/*", base: path)
348+
end
340349
end
341350

342351
# An Optimized resolver for Rails' most common case.

actionview/test/template/lookup_context_test.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,21 +208,21 @@ def setup
208208
e = assert_raise ActionView::MissingTemplate do
209209
@lookup_context.find("foo", %w(parent child))
210210
end
211-
assert_match %r{Missing template parent/foo, child/foo with .* Searched in:\n \* "/Path/to/views"\n}, e.message
211+
assert_match %r{Missing template parent/foo, child/foo with .*\n\nSearched in:\n \* "/Path/to/views"\n}, e.message
212212
end
213213

214214
test "if no partial was found we get a helpful error message including the inheritance chain" do
215215
e = assert_raise ActionView::MissingTemplate do
216216
@lookup_context.find("foo", %w(parent child), true)
217217
end
218-
assert_match %r{Missing partial parent/_foo, child/_foo with .* Searched in:\n \* "/Path/to/views"\n}, e.message
218+
assert_match %r{Missing partial parent/_foo, child/_foo with .*\n\nSearched in:\n \* "/Path/to/views"\n}, e.message
219219
end
220220

221221
test "if a single prefix is passed as a string and the lookup fails, MissingTemplate accepts it" do
222222
e = assert_raise ActionView::MissingTemplate do
223223
details = { handlers: [], formats: [], variants: [], locale: [] }
224224
@lookup_context.view_paths.find("foo", "parent", true, details)
225225
end
226-
assert_match %r{Missing partial parent/_foo with .* Searched in:\n \* "/Path/to/views"\n}, e.message
226+
assert_match %r{Missing partial parent/_foo with .*\n\nSearched in:\n \* "/Path/to/views"\n}, e.message
227227
end
228228
end

actionview/test/template/render_test.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,18 @@ def test_render_partial_with_layout_raises_descriptive_error
630630
assert_match "Missing partial /_true with", e.message
631631
end
632632

633+
if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error)
634+
def test_render_partial_provides_spellcheck
635+
e = assert_raises(ActionView::MissingTemplate) { @view.render(partial: "test/partail") }
636+
assert_match %r{Did you mean\? test/_partial\n *test/_partialhtml}, e.message
637+
end
638+
end
639+
640+
def test_render_partial_wrong_details_no_spellcheck
641+
e = assert_raises(ActionView::MissingTemplate) { @view.render(partial: "test/partial_with_only_html_version", formats: [:xml]) }
642+
assert_no_match %r{Did you mean\?}, e.message
643+
end
644+
633645
def test_render_with_nested_layout
634646
assert_equal %(<title>title</title>\n\n<div id="column">column</div>\n<div id="content">content</div>\n),
635647
@view.render(template: "test/nested_layout", layout: "layouts/yield")

0 commit comments

Comments
 (0)