22
33import { exec } from "child_process" ;
44import * as fs from "fs" ;
5+ import * as os from "os" ;
6+ import * as path from "path" ;
57import { promisify } from "util" ;
68import { ExtensionContext , commands , window , workspace } from "vscode" ;
7- import { LanguageClient , ServerOptions } from "vscode-languageclient/node" ;
9+ import { LanguageClient , Executable } from "vscode-languageclient/node" ;
810
9- import * as variables from "./variables" ;
1011import Visualize from "./Visualize" ;
1112
1213const promiseExec = promisify ( exec ) ;
@@ -50,9 +51,96 @@ export async function activate(context: ExtensionContext) {
5051 } )
5152 ) ;
5253
54+ // We're returning a Promise from this function that will start the Ruby
55+ // subprocess.
56+ await startLanguageServer ( ) ;
57+
5358 // If there's an open folder, use it as cwd when spawning commands
5459 // to promote correct package & language versioning.
55- const getCWD = ( ) => workspace . workspaceFolders ?. [ 0 ] ?. uri ?. fsPath || process . cwd ( ) ;
60+ function getCWD ( ) {
61+ return workspace . workspaceFolders ?. [ 0 ] ?. uri ?. fsPath || process . cwd ( ) ;
62+ }
63+
64+ // There's a bit of complexity here. Basically, we try to locate
65+ // an stree executable in three places, in order of preference:
66+ // 1. Explicit path from advanced settings, if provided
67+ // 2. The bundle inside CWD, if syntax_tree is in the bundle
68+ // 3. Somewhere in $PATH that contains "ruby" in the director
69+ // 4. Anywhere in $PATH (i.e. system gem)
70+ //
71+ // None of these approaches is perfect. System gem might be correct if the
72+ // right environment variables are set, but it's a bit of a prayer. Bundled
73+ // gem is better, but we make the gross oversimplification that the
74+ // workspace only has one root and that the bundle is at root of the
75+ // workspace -- which is not true for large projects or monorepos.
76+ // Explicit path varies between machines/users and is also victim to the
77+ // oversimplification problem.
78+ async function getServerOptions ( args : string [ ] ) : Promise < Executable > {
79+ const advancedConfig = workspace . getConfiguration ( "syntaxTree.advanced" ) ;
80+ let value = advancedConfig . get < string > ( "commandPath" ) ;
81+
82+ // If a value is given on the command line, attempt to use it.
83+ if ( value ) {
84+ // First, substitute in any variables that may have been present in the
85+ // given value to the configuration.
86+ const substitution = new RegExp ( "\\$\\{([^}]*)\\}" ) ;
87+
88+ for ( let match = substitution . exec ( value ) ; match ; match = substitution . exec ( value ) ) {
89+ switch ( match [ 1 ] ) {
90+ case "cwd" :
91+ value = value . replace ( match [ 0 ] , process . cwd ( ) ) ;
92+ break ;
93+ case "pathSeparator" :
94+ value = value . replace ( match [ 0 ] , path . sep ) ;
95+ break ;
96+ case "userHome" :
97+ value = value . replace ( match [ 0 ] , os . homedir ( ) ) ;
98+ break ;
99+ }
100+ }
101+
102+ // Next, attempt to stat the executable path. If it's a file, we're good.
103+ try {
104+ if ( fs . statSync ( value ) . isFile ( ) ) {
105+ return { command : value , args } ;
106+ }
107+ } catch {
108+ outputChannel . appendLine ( `Ignoring bogus commandPath (${ value } does not exist).` ) ;
109+ }
110+ }
111+
112+ // Otherwise, we're going to try using bundler to find the executable.
113+ try {
114+ const cwd = getCWD ( ) ;
115+ await promiseExec ( "bundle show syntax_tree" , { cwd } ) ;
116+ return { command : "bundle" , args : [ "exec" , "stree" ] . concat ( args ) , options : { cwd } } ;
117+ } catch {
118+ // Do nothing.
119+ }
120+
121+ // Otherwise, we're going to try parsing the PATH environment variable to
122+ // find the executable.
123+ const executablePaths = await Promise . all ( ( process . env . PATH || "" )
124+ . split ( path . delimiter )
125+ . filter ( ( directory ) => directory . includes ( "ruby" ) )
126+ . map ( ( directory ) => {
127+ const executablePath = path . join ( directory , "stree" ) ;
128+
129+ return fs . promises . stat ( executablePath ) . then (
130+ ( stat ) => stat . isFile ( ) ? executablePath : null ,
131+ ( ) => null
132+ ) ;
133+ } ) ) ;
134+
135+ for ( const executablePath in executablePaths ) {
136+ if ( executablePath ) {
137+ return { command : executablePath , args } ;
138+ }
139+ }
140+
141+ // Otherwise, fall back to the global stree lookup.
142+ return { command : "stree" , args } ;
143+ }
56144
57145 // This function is called when the extension is activated or when the
58146 // language server is restarted.
@@ -64,8 +152,6 @@ export async function activate(context: ExtensionContext) {
64152 // The top-level configuration group is syntaxTree. Broadly useful settings
65153 // are under that group.
66154 const config = workspace . getConfiguration ( "syntaxTree" ) ;
67- // More obscure settings for power users live in a subgroup.
68- const advancedConfig = workspace . getConfiguration ( "syntaxTree.advanced" ) ;
69155
70156 // The args are going to be passed to the stree executable. It's important
71157 // that it lines up with what the CLI expects.
@@ -97,40 +183,7 @@ export async function activate(context: ExtensionContext) {
97183 args . push ( `--print-width=${ printWidth } ` ) ;
98184 }
99185
100- // There's a bit of complexity here. Basically, we try to locate
101- // an stree executable in three places, in order of preference:
102- // 1. Explicit path from advanced settings, if provided
103- // 2. The bundle inside CWD, if syntax_tree is in the bundle
104- // 3. Anywhere in $PATH (i.e. system gem)
105- //
106- // None of these approaches is perfect. System gem might be correct if the
107- // right environment variables are set, but it's a bit of a prayer. Bundled
108- // gem is better, but we make the gross oversimplification that the
109- // workspace only has one root and that the bundle is at root of the
110- // workspace -- which is not true for large projects or monorepos.
111- // Explicit path varies between machines/users and is also victim to the
112- // oversimplification problem.
113- let run : ServerOptions = { command : "stree" , args } ;
114- let commandPath = advancedConfig . get < string > ( "commandPath" ) ;
115- if ( commandPath ) {
116- commandPath = variables . substitute ( commandPath ) ;
117- try {
118- if ( fs . statSync ( commandPath ) . isFile ( ) ) {
119- run = { command : commandPath , args } ;
120- }
121- } catch ( err ) {
122- outputChannel . appendLine ( `Ignoring bogus commandPath (${ commandPath } does not exist); falling back to global.` ) ;
123- }
124- } else {
125- try {
126- const cwd = getCWD ( ) ;
127- await promiseExec ( "bundle show syntax_tree" , { cwd } ) ;
128- run = { command : "bundle" , args : [ "exec" , "stree" ] . concat ( args ) , options : { cwd } } ;
129- } catch {
130- // No-op (just keep using the global stree)
131- }
132- }
133-
186+ const run = await getServerOptions ( args ) ;
134187 outputChannel . appendLine ( `Starting language server: ${ run . command } ${ run . args ?. join ( " " ) } ` ) ;
135188
136189 // Here, we instantiate the language client. This is the object that is
@@ -206,10 +259,6 @@ export async function activate(context: ExtensionContext) {
206259 outputChannel . appendLine ( `Error installing gem: ${ error } ` ) ;
207260 }
208261 }
209-
210- // We're returning a Promise from this function that will start the Ruby
211- // subprocess.
212- await startLanguageServer ( ) ;
213262}
214263
215264// This is the expected top-level export that is called by VSCode when the
0 commit comments