Skip to content

Commit 9925736

Browse files
Fix bracket/brace filenames breaking LSP features
Files with `[`, `]`, `{`, or `}` in their names (e.g., `[id].rb`, `{slug}.rb`) broke multiple ruby-lsp features because brackets were not percent-encoded in URIs and are glob metacharacters in Dir.glob. - Remove `[]` from URI safe character set so they are percent-encoded, matching VS Code's vscode-uri encoding behavior - Use Dir.glob `base:` parameter in references, rename, addon discovery, test_style, and go_to_relevant_file to treat paths as literal - Escape glob metacharacters in user input for require_relative completion - Fix expectations test runner to use File.file? instead of Dir.glob for expectation lookup and sanitize test method names - Add URI tests for brackets, braces, parentheses, and whitespace
1 parent 3ec2699 commit 9925736

9 files changed

Lines changed: 100 additions & 21 deletions

File tree

lib/ruby_indexer/lib/ruby_indexer/uri.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def from_path(path:, fragment: nil, scheme: "file", load_path_entry: nil)
1818
# This unsafe regex is the same one used in the URI::RFC2396_REGEXP class with the exception of the fact that we
1919
# do not include colon as a safe character. VS Code URIs always escape colons and we need to ensure we do the
2020
# same to avoid inconsistencies in our URIs, which are used to identify resources
21-
unsafe_regex = %r{[^\-_.!~*'()a-zA-Z\d;/?@&=+$,\[\]]}
21+
unsafe_regex = %r{[^\-_.!~*'()a-zA-Z\d;/?@&=+$,]}
2222

2323
# On Windows, if the path begins with the disk name, we need to add a leading slash to make it a valid URI
2424
escaped_path = if /^[A-Z]:/i.match?(path)

lib/ruby_indexer/test/uri_test.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,64 @@ def test_from_path_with_unicode_characters
8181
assert_equal(path, uri.to_standardized_path)
8282
assert_equal("file:///path/with/unicode/%E6%96%87%E4%BB%B6.rb", uri.to_s)
8383
end
84+
85+
def test_from_path_with_brackets
86+
uri = URI::Generic.from_path(path: "/some/path/[id].rb")
87+
assert_match("%5B", uri.path)
88+
assert_match("%5D", uri.path)
89+
assert_equal("file:///some/path/%5Bid%5D.rb", uri.to_s)
90+
end
91+
92+
def test_from_path_with_braces
93+
uri = URI::Generic.from_path(path: "/some/path/{slug}.rb")
94+
assert_match("%7B", uri.path)
95+
assert_match("%7D", uri.path)
96+
assert_equal("file:///some/path/%7Bslug%7D.rb", uri.to_s)
97+
end
98+
99+
def test_round_trip_with_brackets
100+
path = "/some/path/[id].rb"
101+
uri = URI::Generic.from_path(path: path)
102+
assert_equal(path, uri.to_standardized_path)
103+
end
104+
105+
def test_round_trip_with_braces
106+
path = "/some/path/{slug}.rb"
107+
uri = URI::Generic.from_path(path: path)
108+
assert_equal(path, uri.to_standardized_path)
109+
end
110+
111+
def test_from_path_with_parentheses
112+
uri = URI::Generic.from_path(path: "/some/path/(id).rb")
113+
assert_equal("/some/path/(id).rb", uri.path)
114+
assert_equal("file:///some/path/(id).rb", uri.to_s)
115+
end
116+
117+
def test_round_trip_with_parentheses
118+
path = "/some/path/(id).rb"
119+
uri = URI::Generic.from_path(path: path)
120+
assert_equal(path, uri.to_standardized_path)
121+
end
122+
123+
def test_round_trip_with_spaces_inside_brackets
124+
path = "/some/path/[id page].rb"
125+
uri = URI::Generic.from_path(path: path)
126+
assert_equal(path, uri.to_standardized_path)
127+
assert_equal("file:///some/path/%5Bid%20page%5D.rb", uri.to_s)
128+
end
129+
130+
def test_round_trip_with_spaces_inside_braces
131+
path = "/some/path/{slug name}.rb"
132+
uri = URI::Generic.from_path(path: path)
133+
assert_equal(path, uri.to_standardized_path)
134+
assert_equal("file:///some/path/%7Bslug%20name%7D.rb", uri.to_s)
135+
end
136+
137+
def test_round_trip_with_spaces_inside_parentheses
138+
path = "/some/path/file (copy).rb"
139+
uri = URI::Generic.from_path(path: path)
140+
assert_equal(path, uri.to_standardized_path)
141+
assert_equal("file:///some/path/file%20(copy).rb", uri.to_s)
142+
end
84143
end
85144
end

lib/ruby_lsp/addon.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,10 @@ def load_addons(global_state, outgoing_queue, include_project_addons: true)
5656
addon_files = Gem.find_files("ruby_lsp/**/addon.rb")
5757

5858
if include_project_addons
59+
project_addons = Dir.glob("**/ruby_lsp/**/addon.rb", base: global_state.workspace_path).map! do |relative_path|
60+
File.join(global_state.workspace_path, relative_path)
61+
end
5962
bundle_path = Bundler.bundle_path.to_s
60-
project_addons = Dir.glob("#{global_state.workspace_path}/**/ruby_lsp/**/addon.rb")
6163
project_addons.reject! { |path| path.start_with?(bundle_path) || gem_installation_path?(path) }
6264

6365
addon_files.concat(project_addons)

lib/ruby_lsp/listeners/completion.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -457,10 +457,11 @@ def complete_require_relative(node)
457457
# if the path is not a directory, glob all possible next characters
458458
# for example ../somethi| (where | is the cursor position)
459459
# should find files for ../somethi*/
460+
escaped_content = content.gsub(/[\[\]{}*?\\]/) { |c| "\\#{c}" }
460461
path_query = if content.end_with?("/") || content.empty?
461-
"#{content}**/*.rb"
462+
"#{escaped_content}**/*.rb"
462463
else
463-
"{#{content}*/**/*.rb,**/#{content}*.rb}"
464+
"{#{escaped_content}*/**/*.rb,**/#{escaped_content}*.rb}"
464465
end
465466

466467
Dir.glob(path_query, File::FNM_PATHNAME | File::FNM_EXTGLOB, base: origin_dir).sort!.each do |path|

lib/ruby_lsp/listeners/test_style.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@ def resolve_test_commands(items)
3535
if children.empty?
3636
full_files.concat(
3737
Dir.glob(
38-
"#{path}/**/{*_test,test_*,*_spec}.rb",
38+
"**/{*_test,test_*,*_spec}.rb",
3939
File::Constants::FNM_EXTGLOB | File::Constants::FNM_PATHNAME,
40-
).map! { |p| Shellwords.escape(p) },
40+
base: path,
41+
).map! { |p| Shellwords.escape(File.join(path, p)) },
4142
)
4243
end
4344
elsif tags.include?("test_file")

lib/ruby_lsp/requests/go_to_relevant_file.rb

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ def perform
3636
#: -> Array[String]
3737
def find_relevant_paths
3838
patterns = relevant_filename_patterns
39+
root = search_root
3940

4041
candidate_paths = patterns.flat_map do |pattern|
41-
Dir.glob(File.join(search_root, "**", pattern))
42+
Dir.glob(File.join("**", pattern), base: root).map! { |p| File.join(root, p) }
4243
end
4344

4445
return [] if candidate_paths.empty?
@@ -89,26 +90,34 @@ def relevant_filename_patterns
8990
# Test file -> find implementation
9091
base = input_basename.gsub(TEST_PATTERN, "")
9192
parent_dir = File.basename(File.dirname(@path))
93+
escaped_base = escape_glob_metacharacters(base)
94+
escaped_parent_dir = escape_glob_metacharacters(parent_dir)
9295

9396
# If test file is in a directory matching the implementation name
9497
# (e.g., go_to_relevant_file/test_go_to_relevant_file_a.rb)
9598
# return patterns for both the base file name and the parent directory name
9699
if base.include?(parent_dir) && base != parent_dir
97-
["#{base}#{extension}", "#{parent_dir}#{extension}"]
100+
["#{escaped_base}#{extension}", "#{escaped_parent_dir}#{extension}"]
98101
else
99-
["#{base}#{extension}"]
102+
["#{escaped_base}#{extension}"]
100103
end
101104
else
102105
# Implementation file -> find tests (including in matching directory)
106+
escaped_basename = escape_glob_metacharacters(input_basename)
103107
[
104-
"{#{TEST_PREFIX_GLOB}}#{input_basename}#{extension}",
105-
"#{input_basename}{#{TEST_SUFFIX_GLOB}}#{extension}",
106-
"#{input_basename}/{#{TEST_PREFIX_GLOB}}*#{extension}",
107-
"#{input_basename}/*{#{TEST_SUFFIX_GLOB}}#{extension}",
108+
"{#{TEST_PREFIX_GLOB}}#{escaped_basename}#{extension}",
109+
"#{escaped_basename}{#{TEST_SUFFIX_GLOB}}#{extension}",
110+
"#{escaped_basename}/{#{TEST_PREFIX_GLOB}}*#{extension}",
111+
"#{escaped_basename}/*{#{TEST_SUFFIX_GLOB}}#{extension}",
108112
]
109113
end
110114
end
111115

116+
#: (String str) -> String
117+
def escape_glob_metacharacters(str)
118+
str.gsub(/[\[\]{}*?\\]/) { |c| "\\#{c}" }
119+
end
120+
112121
# Using the Jaccard algorithm to determine the similarity between the
113122
# input path and the candidate relevant file paths.
114123
# Ref: https://en.wikipedia.org/wiki/Jaccard_index

lib/ruby_lsp/requests/references.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ def perform
6060
reference_target = create_reference_target(target, node_context)
6161
return @locations unless reference_target
6262

63-
Dir.glob(File.join(@global_state.workspace_path, "**/*.rb")).each do |path|
63+
Dir.glob("**/*.rb", base: @global_state.workspace_path).each do |relative_path|
64+
path = File.join(@global_state.workspace_path, relative_path)
6465
uri = URI::Generic.from_path(path: path)
6566
# If the document is being managed by the client, then we should use whatever is present in the store instead
6667
# of reading from disk

lib/ruby_lsp/requests/rename.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ def collect_file_renames(fully_qualified_name, document_changes)
129129
def collect_text_edits(target, name)
130130
changes = {}
131131

132-
Dir.glob(File.join(@global_state.workspace_path, "**/*.rb")).each do |path|
132+
Dir.glob("**/*.rb", base: @global_state.workspace_path).each do |relative_path|
133+
path = File.join(@global_state.workspace_path, relative_path)
133134
uri = URI::Generic.from_path(path: path)
134135
# If the document is being managed by the client, then we should use whatever is present in the store instead
135136
# of reading from disk

test/requests/support/expectations_test_runner.rb

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,21 @@ def default_args
5050
raise "Expectations directory #{expectations_dir} does not exist"
5151
end
5252

53-
expectation_glob = Dir.glob(File.join(expectations_dir, "#{test_name}.exp.{rb,json}"))
54-
if expectation_glob.size == 1
55-
expectation_path = expectation_glob.first
56-
elsif expectation_glob.size > 1
53+
json_path = File.join(expectations_dir, "#{test_name}.exp.json")
54+
rb_path = File.join(expectations_dir, "#{test_name}.exp.rb")
55+
if File.file?(json_path) && File.file?(rb_path)
5756
raise "multiple expectations for #{test_name}"
57+
elsif File.file?(json_path)
58+
expectation_path = json_path
59+
elsif File.file?(rb_path)
60+
expectation_path = rb_path
5861
end
5962

63+
sanitized_name = test_name.gsub(/[^\w]/, "_")
64+
6065
if expectation_path && File.file?(expectation_path)
6166
class_eval(<<~RB, __FILE__, __LINE__ + 1)
62-
def test_#{expectation_suffix}__#{test_name}
67+
def test_#{expectation_suffix}__#{sanitized_name}
6368
@_path = "#{path}"
6469
@_expectation_path = "#{expectation_path}"
6570
source = File.read(@_path)
@@ -70,7 +75,7 @@ def test_#{expectation_suffix}__#{test_name}
7075
RB
7176
else
7277
class_eval(<<~RB, __FILE__, __LINE__ + 1)
73-
def test_#{expectation_suffix}__#{test_name}__does_not_raise
78+
def test_#{expectation_suffix}__#{sanitized_name}__does_not_raise
7479
@_path = "#{path}"
7580
source = File.read(@_path)
7681
run_expectations(source)

0 commit comments

Comments
 (0)