Skip to content

Commit c2f42be

Browse files
committed
Improve rust project detection
Rust projects don't just have a single Cargo.toml. They can have a deep folder structure with multiple crates within. Cargo.lock is a better indicator of the root of the project. Bit even then we probably want to find the _furthest_ away Cargo.foo not the nearest, as that's more likely to be the real project root. Implement that somewhat generically so that other completers can use it. Java already has a similar codepath but it's more complicated so not touching it. Finally, even with the above 2 changes, we still have a problem because there might just be gaps. Taking wasmtime project for example there are: ./Cargo.toml ./Cargo.lock ./src/foo.rs ./crates/bar/Cargo.toml ./crates/bar/src/bar.rs ./crates/baz/Cargo.toml ./crates/baz/src/baz.rs So, we allow for the top-level 'project_directory' setting to take precedence _if_ the file opened is in a subdirectory of it.
1 parent 664b151 commit c2f42be

3 files changed

Lines changed: 241 additions & 11 deletions

File tree

ycmd/completers/language_server/language_server_completer.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2386,6 +2386,44 @@ def GetProjectDirectory( self, request_data ):
23862386
return os.path.dirname( filepath )
23872387

23882388

2389+
def FindProjectFromRootFiles( self,
2390+
filepath,
2391+
project_root_files,
2392+
nearest=True ):
2393+
2394+
project_folder = None
2395+
project_root_type = None
2396+
2397+
# First, find the nearest dir that has one of the root file types
2398+
for folder in utils.PathsToAllParentFolders( filepath ):
2399+
f = Path( folder )
2400+
for root_file in project_root_files:
2401+
if next( f.glob( root_file ), [] ):
2402+
# Found one, store the root file and the current nearest folder
2403+
project_root_type = root_file
2404+
project_folder = folder
2405+
break
2406+
if project_folder:
2407+
break
2408+
2409+
if not project_folder:
2410+
return None
2411+
2412+
# If asking for the nearest, return the one found
2413+
if nearest:
2414+
return str( project_folder )
2415+
2416+
# Otherwise keep searching up from the nearest until we don't find any more
2417+
for folder in utils.PathsToAllParentFolders( os.path.join( project_folder,
2418+
'..' ) ):
2419+
f = Path( folder )
2420+
if next( f.glob( project_root_type ), [] ):
2421+
project_folder = folder
2422+
else:
2423+
break
2424+
return project_folder
2425+
2426+
23892427
def GetWorkspaceForFilepath( self, filepath, strict = False ):
23902428
"""Return the workspace of the provided filepath. This could be a subproject
23912429
or a completely unrelated project to the root directory.
@@ -2396,12 +2434,12 @@ def GetWorkspaceForFilepath( self, filepath, strict = False ):
23962434
reuse this implementation.
23972435
"""
23982436
project_root_files = self.GetProjectRootFiles()
2437+
workspace = None
23992438
if project_root_files:
2400-
for folder in utils.PathsToAllParentFolders( filepath ):
2401-
for root_file in project_root_files:
2402-
if next( Path( folder ).glob( root_file ), [] ):
2403-
return folder
2404-
return None if strict else os.path.dirname( filepath )
2439+
workspace = self.FindProjectFromRootFiles( filepath,
2440+
project_root_files,
2441+
nearest = True )
2442+
return workspace or ( None if strict else os.path.dirname( filepath ) )
24052443

24062444

24072445
def _SendInitialize( self, request_data ):

ycmd/completers/rust/rust_completer.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import logging
1919
import os
2020
from subprocess import PIPE
21+
from pathlib import Path
2122

2223
from ycmd import responses, utils
2324
from ycmd.completers.language_server import language_server_completer
@@ -107,12 +108,44 @@ def GetServerEnvironment( self ):
107108
return env
108109

109110

110-
def GetProjectRootFiles( self ):
111-
# Without LSP workspaces support, RA relies on the rootUri to detect a
111+
def GetWorkspaceForFilepath( self, filepath, strict = False ):
112+
# For every unique workspace, rust analyzer launches a nuclear
113+
# weapon^h^h^h^h new server and indexes the internet. Try to minimise the
114+
# number of such launches.
115+
116+
# If filepath is a subdirectory of the manually-specified project root, use
117+
# the project root
118+
if 'project_directory' in self._settings:
119+
project_root = utils.AbsolutePath( self._settings[ 'project_directory' ],
120+
self._extra_conf_dir )
121+
122+
prp = Path( project_root )
123+
for parent in Path( filepath ).absolute().parents:
124+
if parent == prp:
125+
return project_root
126+
127+
# Otherwise, we might not have one configured, or it' a totally different
112128
# project.
113-
# TODO: add support for LSP workspaces to allow users to change project
114-
# without having to restart RA.
115-
return [ 'Cargo.toml' ]
129+
#
130+
# Our main heuristic is:
131+
# - find the nearest Cargo.lock, and assume that's the root
132+
# - otherwise find the _furthest_ Cargo.toml and assume that's the root
133+
# - otherwise use the project root directory that we previously calculated.
134+
#
135+
# We never use the directory of the file as that could just be anything
136+
# random, and we might as well just use the original project in that case
137+
if candidate := self.FindProjectFromRootFiles( filepath,
138+
[ 'Cargo.lock' ],
139+
nearest = True ):
140+
return candidate
141+
142+
if candidate := self.FindProjectFromRootFiles( filepath,
143+
[ 'Cargo.toml' ],
144+
nearest = False ):
145+
return candidate
146+
147+
# Never use the
148+
return None if strict else self._project_directory
116149

117150

118151
def ServerIsReady( self ):

ycmd/tests/rust/server_management_test.py

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from hamcrest import assert_that, contains_exactly, equal_to, has_entry
1919
from unittest.mock import patch
2020
from unittest import TestCase
21+
import contextlib
22+
from pathlib import Path
2123

2224
from ycmd.completers.language_server.language_server_completer import (
2325
LanguageServerConnectionTimeout )
@@ -26,7 +28,10 @@
2628
StartRustCompleterServerInDirectory )
2729
from ycmd.tests.test_utils import ( BuildRequest,
2830
MockProcessTerminationTimingOut,
29-
WaitUntilCompleterServerReady )
31+
WaitUntilCompleterServerReady,
32+
TemporaryTestDir )
33+
34+
from ycmd import handlers
3035

3136

3237
def AssertRustCompleterServerIsRunning( app, is_running ):
@@ -138,3 +143,157 @@ def test_ServerManagement_StartServer_Fails( self, app ):
138143
has_entry( 'is_running', False )
139144
) )
140145
) )
146+
147+
148+
@contextlib.contextmanager
149+
def TemporaryProjectLayout( project_files ):
150+
import os
151+
with TemporaryTestDir() as project_dir:
152+
project_dir_path = Path( project_dir )
153+
for file, contents in project_files.items():
154+
file = project_dir_path / file
155+
os.makedirs( file.parent, exist_ok = True )
156+
file.write_text( contents )
157+
yield project_dir_path
158+
159+
160+
class ProjectDetectionTest( TestCase ):
161+
@IsolatedYcmd()
162+
def test_ProjectDetection_CargoTomlFiles_None( self, app ):
163+
with TemporaryProjectLayout( {
164+
'src/main.rs': '',
165+
'src/foo/main.rs': '',
166+
'foo/main.rs': '',
167+
} ) as project_dir:
168+
StartRustCompleterServerInDirectory( app, project_dir )
169+
completer = handlers._server_state.GetFiletypeCompleter( [ 'rust' ] )
170+
assert_that(
171+
completer.GetWorkspaceForFilepath( project_dir / 'src/main.rs',
172+
strict=True ),
173+
equal_to( None )
174+
)
175+
assert_that(
176+
completer.GetWorkspaceForFilepath( project_dir / 'src/foo/main.rs',
177+
strict=True ),
178+
equal_to( None )
179+
)
180+
assert_that(
181+
completer.GetWorkspaceForFilepath( project_dir / 'foo/main.rs',
182+
strict=True ),
183+
equal_to( None )
184+
)
185+
assert_that(
186+
completer.GetWorkspaceForFilepath( project_dir / 'src/main.rs',
187+
strict=False ),
188+
equal_to( str( project_dir / 'src' ) )
189+
)
190+
assert_that(
191+
completer.GetWorkspaceForFilepath( project_dir / 'src/foo/main.rs',
192+
strict=False ),
193+
equal_to( str( project_dir / 'src' ) )
194+
)
195+
assert_that(
196+
completer.GetWorkspaceForFilepath( project_dir / 'foo/main.rs',
197+
strict=False ),
198+
equal_to( str( project_dir / 'src' ) )
199+
)
200+
201+
@IsolatedYcmd()
202+
def test_ProjectDetection_CargoTomlFiles_Justone( self, app ):
203+
with TemporaryProjectLayout( {
204+
'Cargo.toml': '',
205+
'src/main.rs': '',
206+
'src/foo/main.rs': '',
207+
'foo/main.rs': '',
208+
} ) as project_dir:
209+
StartRustCompleterServerInDirectory( app, project_dir )
210+
completer = handlers._server_state.GetFiletypeCompleter( [ 'rust' ] )
211+
assert_that(
212+
completer.GetWorkspaceForFilepath( project_dir / 'src/main.rs' ),
213+
equal_to( str( project_dir ) )
214+
)
215+
assert_that(
216+
completer.GetWorkspaceForFilepath( project_dir / 'src/foo/main.rs' ),
217+
equal_to( str( project_dir ) )
218+
)
219+
assert_that(
220+
completer.GetWorkspaceForFilepath( project_dir / 'foo/main.rs' ),
221+
equal_to( str( project_dir ) )
222+
)
223+
224+
@IsolatedYcmd()
225+
def test_ProjectDetection_CargoTomlFiles_Nogaps( self, app ):
226+
with TemporaryProjectLayout( {
227+
'Cargo.toml': '',
228+
'src/main.rs': '',
229+
'src/Cargo.toml': '',
230+
'src/foo/main.rs': '',
231+
'src/foo/Cargo.toml': '',
232+
} ) as project_dir:
233+
StartRustCompleterServerInDirectory( app, project_dir )
234+
completer = handlers._server_state.GetFiletypeCompleter( [ 'rust' ] )
235+
assert_that(
236+
completer.GetWorkspaceForFilepath( project_dir / 'src/main.rs' ),
237+
equal_to( str( project_dir ) )
238+
)
239+
assert_that(
240+
completer.GetWorkspaceForFilepath( project_dir / 'src/foo/main.rs' ),
241+
equal_to( str( project_dir ) )
242+
)
243+
244+
@IsolatedYcmd()
245+
def test_ProjectDetection_CargoTomlFiles_Gaps( self, app ):
246+
# This result is not ideal, but better in other tests/cases below
247+
# This is the "historical" behaviour
248+
with TemporaryProjectLayout( {
249+
'Cargo.toml': '',
250+
'src/foo/main.rs': '',
251+
'src/foo/Cargo.toml': '',
252+
'src/bar/main.rs': '',
253+
'src/bar/Cargo.toml': '',
254+
} ) as project_dir:
255+
StartRustCompleterServerInDirectory( app, project_dir )
256+
completer = handlers._server_state.GetFiletypeCompleter( [ 'rust' ] )
257+
assert_that(
258+
completer.GetWorkspaceForFilepath( project_dir / 'src/foo/main.rs' ),
259+
equal_to( str( project_dir / 'src' / 'foo' ) )
260+
)
261+
assert_that(
262+
completer.GetWorkspaceForFilepath( project_dir / 'src/bar/main.rs' ),
263+
equal_to( str( project_dir / 'src' / 'bar' ) )
264+
)
265+
266+
267+
@IsolatedYcmd()
268+
def test_ProjectDetection_CargoLockFiles( self, app ):
269+
with self.subTest( 'justone' ):
270+
with TemporaryProjectLayout( {
271+
'Cargo.lock': '',
272+
'src/main.rs': '',
273+
'src/Cargo.toml': '',
274+
'src/foo/main.rs': '',
275+
'src/foo/Cargo.toml': '',
276+
} ) as project_dir:
277+
StartRustCompleterServerInDirectory( app, project_dir )
278+
completer = handlers._server_state.GetFiletypeCompleter( [ 'rust' ] )
279+
assert_that(
280+
completer.GetWorkspaceForFilepath( project_dir / 'src/main.rs' ),
281+
equal_to( str( project_dir ) )
282+
)
283+
assert_that(
284+
completer.GetWorkspaceForFilepath( project_dir / 'src/foo/main.rs' ),
285+
equal_to( str( project_dir ) )
286+
)
287+
288+
289+
@IsolatedYcmd()
290+
def test_ProjectDetection_LockPrecidenceOverToml( self, app ):
291+
pass
292+
293+
@IsolatedYcmd()
294+
def test_ProjectDetection_ManualProjectOverrideWithinPath( self, app ):
295+
pass
296+
297+
@IsolatedYcmd()
298+
def test_ProjectDetection_ManualProjectIgnoredOutside( self, app ):
299+
pass

0 commit comments

Comments
 (0)