@@ -17,6 +17,8 @@ interface RubyVersion {
17
17
version : string ;
18
18
}
19
19
20
+ class RubyVersionCancellationError extends Error { }
21
+
20
22
// A tool to change the current Ruby version
21
23
// Learn more: https://github.com/postmodern/chruby
22
24
export class Chruby extends VersionManager {
@@ -45,8 +47,26 @@ export class Chruby extends VersionManager {
45
47
}
46
48
47
49
async activate ( ) : Promise < ActivationResult > {
48
- const versionInfo = await this . discoverRubyVersion ( ) ;
49
- const rubyUri = await this . findRubyUri ( versionInfo ) ;
50
+ let versionInfo = await this . discoverRubyVersion ( ) ;
51
+ let rubyUri : vscode . Uri ;
52
+
53
+ if ( versionInfo ) {
54
+ rubyUri = await this . findRubyUri ( versionInfo ) ;
55
+ } else {
56
+ try {
57
+ const fallback = await this . fallbackToLatestRuby ( ) ;
58
+ versionInfo = fallback . rubyVersion ;
59
+ rubyUri = fallback . uri ;
60
+ } catch ( error : any ) {
61
+ if ( error instanceof RubyVersionCancellationError ) {
62
+ // Try to re-activate if the user has configured a fallback during cancellation
63
+ return this . activate ( ) ;
64
+ }
65
+
66
+ throw error ;
67
+ }
68
+ }
69
+
50
70
this . outputChannel . info (
51
71
`Discovered Ruby installation at ${ rubyUri . fsPath } ` ,
52
72
) ;
@@ -118,7 +138,7 @@ export class Chruby extends VersionManager {
118
138
}
119
139
120
140
// Returns the Ruby version information including version and engine. E.g.: ruby-3.3.0, truffleruby-21.3.0
121
- private async discoverRubyVersion ( ) : Promise < RubyVersion > {
141
+ private async discoverRubyVersion ( ) : Promise < RubyVersion | undefined > {
122
142
let uri = this . bundleUri ;
123
143
const root = path . parse ( uri . fsPath ) . root ;
124
144
let version : string ;
@@ -156,7 +176,183 @@ export class Chruby extends VersionManager {
156
176
return { engine : match . groups . engine , version : match . groups . version } ;
157
177
}
158
178
159
- throw new Error ( "No .ruby-version file was found" ) ;
179
+ return undefined ;
180
+ }
181
+
182
+ private async fallbackToLatestRuby ( ) {
183
+ let gemfileContents ;
184
+
185
+ try {
186
+ gemfileContents = await vscode . workspace . fs . readFile (
187
+ vscode . Uri . joinPath ( this . workspaceFolder . uri , "Gemfile" ) ,
188
+ ) ;
189
+ } catch ( error : any ) {
190
+ // The Gemfile doesn't exist
191
+ }
192
+
193
+ // If the Gemfile includes ruby version restrictions, then trying to fall back to latest Ruby may lead to errors
194
+ if (
195
+ gemfileContents &&
196
+ / ^ r u b y ( \s | \( ) ( " | ' ) [ \d . ] + / . test ( gemfileContents . toString ( ) )
197
+ ) {
198
+ throw this . rubyVersionError ( ) ;
199
+ }
200
+
201
+ const fallback = await vscode . window . withProgress (
202
+ {
203
+ title :
204
+ "No .ruby-version found. Trying to fall back to latest installed Ruby in 10 seconds" ,
205
+ location : vscode . ProgressLocation . Notification ,
206
+ cancellable : true ,
207
+ } ,
208
+ async ( progress , token ) => {
209
+ progress . report ( {
210
+ message :
211
+ "You can create a .ruby-version file in a parent directory to configure a fallback" ,
212
+ } ) ;
213
+
214
+ // If they don't cancel, we wait 10 seconds before falling back so that they are aware of what's happening
215
+ await new Promise < void > ( ( resolve ) => {
216
+ setTimeout ( resolve , 10000 ) ;
217
+
218
+ // If the user cancels the fallback, resolve immediately so that they don't have to wait 10 seconds
219
+ token . onCancellationRequested ( ( ) => {
220
+ resolve ( ) ;
221
+ } ) ;
222
+ } ) ;
223
+
224
+ if ( token . isCancellationRequested ) {
225
+ await this . handleCancelledFallback ( ) ;
226
+
227
+ // We throw this error to be able to catch and re-run activation after the user has configured a fallback
228
+ throw new RubyVersionCancellationError ( ) ;
229
+ }
230
+
231
+ const fallback = await this . findFallbackRuby ( ) ;
232
+
233
+ if ( ! fallback ) {
234
+ throw new Error ( "Cannot find any Ruby installations" ) ;
235
+ }
236
+
237
+ return fallback ;
238
+ } ,
239
+ ) ;
240
+
241
+ return fallback ;
242
+ }
243
+
244
+ private async handleCancelledFallback ( ) {
245
+ const answer = await vscode . window . showInformationMessage (
246
+ `The Ruby LSP requires a Ruby version to launch.
247
+ You can define a fallback for the system or for the Ruby LSP only` ,
248
+ "System" ,
249
+ "Ruby LSP only" ,
250
+ ) ;
251
+
252
+ if ( answer === "System" ) {
253
+ await this . createParentRubyVersionFile ( ) ;
254
+ } else if ( answer === "Ruby LSP only" ) {
255
+ await this . manuallySelectRuby ( ) ;
256
+ }
257
+
258
+ throw this . rubyVersionError ( ) ;
259
+ }
260
+
261
+ private async createParentRubyVersionFile ( ) {
262
+ const items : vscode . QuickPickItem [ ] = [ ] ;
263
+
264
+ for ( const uri of this . rubyInstallationUris ) {
265
+ let directories ;
266
+
267
+ try {
268
+ directories = ( await vscode . workspace . fs . readDirectory ( uri ) ) . sort (
269
+ ( left , right ) => right [ 0 ] . localeCompare ( left [ 0 ] ) ,
270
+ ) ;
271
+
272
+ directories . forEach ( ( directory ) => {
273
+ items . push ( {
274
+ label : directory [ 0 ] ,
275
+ } ) ;
276
+ } ) ;
277
+ } catch ( error : any ) {
278
+ continue ;
279
+ }
280
+ }
281
+
282
+ const answer = await vscode . window . showQuickPick ( items , {
283
+ title : "Select a Ruby version to use as fallback" ,
284
+ ignoreFocusOut : true ,
285
+ } ) ;
286
+
287
+ if ( ! answer ) {
288
+ throw this . rubyVersionError ( ) ;
289
+ }
290
+
291
+ const targetDirectory = await vscode . window . showOpenDialog ( {
292
+ defaultUri : vscode . Uri . file ( os . homedir ( ) ) ,
293
+ openLabel : "Add fallback in this directory" ,
294
+ canSelectFiles : false ,
295
+ canSelectFolders : true ,
296
+ canSelectMany : false ,
297
+ title : "Select the directory to create the .ruby-version fallback in" ,
298
+ } ) ;
299
+
300
+ if ( ! targetDirectory ) {
301
+ throw this . rubyVersionError ( ) ;
302
+ }
303
+
304
+ await vscode . workspace . fs . writeFile (
305
+ vscode . Uri . joinPath ( targetDirectory [ 0 ] , ".ruby-version" ) ,
306
+ Buffer . from ( answer . label ) ,
307
+ ) ;
308
+ }
309
+
310
+ private async findFallbackRuby ( ) : Promise <
311
+ { uri : vscode . Uri ; rubyVersion : RubyVersion } | undefined
312
+ > {
313
+ for ( const uri of this . rubyInstallationUris ) {
314
+ let directories ;
315
+
316
+ try {
317
+ directories = ( await vscode . workspace . fs . readDirectory ( uri ) ) . sort (
318
+ ( left , right ) => right [ 0 ] . localeCompare ( left [ 0 ] ) ,
319
+ ) ;
320
+
321
+ let groups ;
322
+ let targetDirectory ;
323
+
324
+ for ( const directory of directories ) {
325
+ const match =
326
+ / ( (?< engine > [ A - Z a - z ] + ) - ) ? (?< version > \d + \. \d + ( \. \d + ) ? ( - [ A - Z a - z 0 - 9 ] + ) ? ) / . exec (
327
+ directory [ 0 ] ,
328
+ ) ;
329
+
330
+ if ( match ?. groups ) {
331
+ groups = match . groups ;
332
+ targetDirectory = directory ;
333
+ break ;
334
+ }
335
+ }
336
+
337
+ if ( targetDirectory ) {
338
+ return {
339
+ uri : vscode . Uri . joinPath ( uri , targetDirectory [ 0 ] , "bin" , "ruby" ) ,
340
+ rubyVersion : {
341
+ engine : groups ! . engine ,
342
+ version : groups ! . version ,
343
+ } ,
344
+ } ;
345
+ }
346
+ } catch ( error : any ) {
347
+ // If the directory doesn't exist, keep searching
348
+ this . outputChannel . debug (
349
+ `Tried searching for Ruby installation in ${ uri . fsPath } but it doesn't exist` ,
350
+ ) ;
351
+ continue ;
352
+ }
353
+ }
354
+
355
+ return undefined ;
160
356
}
161
357
162
358
// Run the activation script using the Ruby installation we found so that we can discover gem paths
@@ -197,4 +393,11 @@ export class Chruby extends VersionManager {
197
393
198
394
return { defaultGems, gemHome, yjit : yjit === "true" , version } ;
199
395
}
396
+
397
+ private rubyVersionError ( ) {
398
+ return new Error (
399
+ `Cannot find .ruby-version file. Please specify the Ruby version in a
400
+ .ruby-version either in ${ this . bundleUri . fsPath } or in a parent directory` ,
401
+ ) ;
402
+ }
200
403
}
0 commit comments