import { LanguageClient , StreamInfo , ErrorHandler , } from 'vscode-languageclient/node' ; import { StatusBar , LanguageServerStatus } from './StatusBar' ; import { spawn , ChildProcess } from 'child_process' ; import { workspace , Uri , Disposable , ExtensionContext } from 'vscode' ; import { format , URL } from 'url' ; import { DocumentSelector } from 'vscode-languageserver-protocol' ; import { join , isAbsolute } from 'path' ; import { execFile } from 'promisify-child-process' ; import { ConfigurationService } from './ConfigurationService' ; import LanguageServerErrorHandler from './LanguageServerErrorHandler' ; import { statSync , constants } from 'fs' ; import { access } from 'fs/promises' ; import * as semver from 'semver' ; import { LoggingService } from './LoggingService' ; import { Writable } from 'stream' ; import { createServer } from 'net' ; import { showOpenSettingsPrompt , showErrorMessage } from './utils' ; export class LanguageServer { private languageClient : LanguageClient ; private workspacePath : string ; private statusBar : StatusBar ; private configurationService : ConfigurationService ; private psalmConfigPath : string ; private debug : boolean ; private loggingService : LoggingService ; private ready = false ; private initalizing = false ; private disposable : Disposable ; private serverProcess : ChildProcess | null = null ; private context : ExtensionContext ; constructor ( context , workspacePath , psalmConfigPath , statusBar , configurationService , loggingService ) { this . context = context ; this . workspacePath = workspacePath ; this . statusBar = statusBar ; this . configurationService = configurationService ; this . psalmConfigPath = psalmConfigPath ; this . loggingService = loggingService ; this . languageClient = new LanguageClient ( 'psalm' , 'Psalm Language Server' , this . serverOptions . bind ( this ) , { outputChannel : this . loggingService , traceOutputChannel : this . loggingService , documentSelector : this . configurationService . get < string [ ] | DocumentSelector > ( 'analyzedFileExtensions' ) , uriConverters : { code2Protocol : ( uri ) : string => format ( new URL ( uri . toString ( true ) ) ) , protocol2Code : ( str ) : Uri => Uri . parse ( str ) , } , synchronize : { configurationSection : 'psalm' , fileEvents : [ workspace . createFileSystemWatcher ( '**/*.php' ) , ] , } , progressOnInitialization : true , errorHandler : this . createDefaultErrorHandler ( this . configurationService . get < number > ( 'maxRestartCount' ) - 1 ) , } , this . debug ) ; this . languageClient . onReady ( ) . then ( ( ) => { this . initalizing = false ; this . ready = true ; this . loggingService . logInfo ( 'The Language Server is ready' ) ; } ) ; this . languageClient . onTelemetry ( this . onTelemetry . bind ( this ) ) ; } public createDefaultErrorHandler ( maxRestartCount ? ) { if ( maxRestartCount !== undefined && maxRestartCount < 0 ) { throw new Error ( `Invalid maxRestartCount: ${ maxRestartCount }` ) ; } return new LanguageServerErrorHandler ( 'Psalm Language Server' , maxRestartCount ?? 4 ) ; } private onTelemetry ( params ) { if ( typeof params === 'object' && 'message' in params && typeof params . message === 'string' ) { const hideStatusMessageWhenRunning = this . configurationService . get < boolean > ( 'hideStatusMessageWhenRunning' ) ; let status = params . message ; if ( params . message . indexOf ( ':' ) >= 0 ) { status = params . message . split ( ':' ) [ 0 ] ; } switch ( status ) { case 'initializing' : this . statusBar . update ( LanguageServerStatus . Initializing , params . message ) ; break ; case 'initialized' : this . statusBar . update ( LanguageServerStatus . Initialized , params . message ) ; break ; case 'running' : this . statusBar . update ( LanguageServerStatus . Running , params . message ) ; break ; case 'analyzing' : this . statusBar . update ( LanguageServerStatus . Analyzing , params . message ) ; break ; case 'closing' : this . statusBar . update ( LanguageServerStatus . Closing , params . message ) ; break ; case 'closed' : this . statusBar . update ( LanguageServerStatus . Closed , params . message ) ; break ; } if ( hideStatusMessageWhenRunning && status === 'running' ) { this . statusBar . hide ( ) ; } else { this . statusBar . show ( ) ; } } } public getServerProcess ( ) { return this . serverProcess ; } public isReady ( ) { return this . ready ; } public isInitalizing ( ) { return this . initalizing ; } public getDisposable ( ) { return this . disposable ; } public getClient ( ) { return this . languageClient ; } public async stop ( ) { if ( this . initalizing ) { this . loggingService . logWarning ( 'Server is in the process of intializing will stop when ready' ) ; await this . languageClient . onReady ( ) ; } this . loggingService . logInfo ( 'Stopping language server' ) ; await this . languageClient . stop ( ) ; } public async start ( ) { const isValidPsalmVersion = await this . checkPsalmHasLanguageServer ( ) ; if ( ! isValidPsalmVersion ) { showOpenSettingsPrompt ( 'Psalm is not installed' ) ; return ; } this . initalizing = true ; this . statusBar . update ( LanguageServerStatus . Initializing , 'starting' ) ; this . loggingService . logInfo ( 'Starting language server' ) ; this . disposable = this . languageClient . start ( ) ; this . context . subscriptions . push ( this . disposable ) ; } public async restart ( ) { this . loggingService . logInfo ( 'Restarting language server' ) ; await this . stop ( ) ; await this . start ( ) ; } public getLanguageClient ( ) { return this . languageClient ; } private serverOptions ( ) { return new Promise < ChildProcess | StreamInfo > ( ( resolve , reject ) => { const connectToServerWithTcp = this . configurationService . get < boolean > ( 'connectToServerWithTcp' ) ; if ( connectToServerWithTcp || process . platform === 'win32' ) { const server = createServer ( ( socket ) => { this . loggingService . logDebug ( 'PHP process connected' ) ; socket . on ( 'end' , ( ) => { this . loggingService . logDebug ( 'PHP process disconnected' ) ; } ) ; if ( this . loggingService . getOutputLevel ( ) === 'TRACE' ) { socket . on ( 'data' , ( chunk ) => { this . loggingService . logDebug ( `SERVER ==> ${ chunk }\n` ) ; } ) ; } const writeable = new Writable ( ) ; writeable . write = ( chunk , encoding , callback ) => { if ( this . loggingService . getOutputLevel ( ) === 'TRACE' ) { this . loggingService . logDebug ( chunk . toString ? `SERVER <== ${ chunk . toString ( ) }\n` : chunk ) ; } return socket . write ( chunk , encoding , callback ) ; } ; server . close ( ) ; resolve ( { reader : socket , writer : writeable } ) ; } ) ; server . listen ( 0 , '127.0.0.1' , ( ) => { this . spawnServer ( [ '--tcp=127.0.0.1:' + server . address ( ) . port , ] ) ; } ) ; } else { resolve ( this . spawnServer ( ) ) ; } } ) ; } param args return private async spawnServer ( args = [ ] ) { const languageServerVersion = await this . getPsalmLanguageServerVersion ( ) ; const extraServerArgs = this . configurationService . get < string [ ] > ( 'psalmScriptArgs' ) ; if ( extraServerArgs ) { if ( Array . isArray ( extraServerArgs ) ) { args . unshift ( ... extraServerArgs ) ; } } const unusedVariableDetection = this . configurationService . get < boolean > ( 'unusedVariableDetection' ) ; if ( unusedVariableDetection ) { args . unshift ( '--find-dead-code' ) ; } const enableVerbose = this . configurationService . get < boolean > ( 'enableVerbose' ) ; if ( enableVerbose ) { args . unshift ( '--verbose' ) ; } const disableAutoComplete = this . configurationService . get < boolean > ( 'disableAutoComplete' ) ; if ( disableAutoComplete ) { args . unshift ( '--enable-autocomplete=false' ) ; } const psalmHasLanguageServerOption = await this . checkPsalmLanguageServerHasOption ( [ ] , '--language-server' ) ; const psalmScriptArgs = psalmHasLanguageServerOption ? [ '--language-server' ] : [ ] ; if ( languageServerVersion === null || semver . lt ( languageServerVersion , '4.9.0' ) ) { if ( await this . checkPsalmLanguageServerHasOption ( psalmScriptArgs , '--use-extended-diagnostic-codes' ) ) { psalmScriptArgs . unshift ( '--use-extended-diagnostic-codes' ) ; } if ( enableVerbose && ( await this . checkPsalmLanguageServerHasOption ( psalmScriptArgs , '--verbose' ) ) ) { psalmScriptArgs . unshift ( '--verbose' ) ; } } else if ( semver . gte ( languageServerVersion , '4.9.0' ) ) { this . loggingService . logDebug ( `Psalm Language Server Version: ${ languageServerVersion }` ) ; psalmScriptArgs . unshift ( '--use-extended-diagnostic-codes' ) ; if ( enableVerbose ) { psalmScriptArgs . unshift ( '--verbose' ) ; } const enableUseIniDefaults = this . configurationService . get < boolean > ( 'enableUseIniDefaults' ) ; if ( enableUseIniDefaults ) { psalmScriptArgs . unshift ( '--use-ini-defaults' ) ; } } args . unshift ( '-r' , this . workspacePath ) ; args . unshift ( '-c' , this . psalmConfigPath ) ; args . unshift ( ... psalmScriptArgs ) ; args . unshift ( '--' ) ; const psalmScriptPath = await this . resolvePsalmScriptPath ( ) ; args . unshift ( '-f' , psalmScriptPath ) ; this . loggingService . logInfo ( 'Starting Psalm Language Server' ) ; const { file , args : fileArgs } = await this . getPhpArgs ( args ) ; const childProcess = spawn ( file , fileArgs , { cwd : this . workspacePath , } ) ; this . serverProcess = childProcess ; childProcess . stderr . on ( 'data' , ( chunk ) => { this . loggingService . logError ( chunk + '' ) ; } ) ; if ( this . loggingService . getOutputLevel ( ) === 'TRACE' ) { const orig = childProcess . stdin ; childProcess . stdin = new Writable ( ) ; childProcess . stdin . write = ( chunk , encoding , callback ) => { this . loggingService . logDebug ( chunk . toString ? `SERVER <== ${ chunk . toString ( ) }\n` : chunk ) ; return orig . write ( chunk , encoding , callback ) ; } ; childProcess . stdout . on ( 'data' , ( chunk ) => { this . loggingService . logDebug ( `SERVER ==> ${ chunk }\n` ) ; } ) ; } childProcess . on ( 'exit' , ( code , signal ) => { this . statusBar . update ( LanguageServerStatus . Exited , 'Exited (Should Restart)' ) ; } ) ; return childProcess ; } param psalmScriptArgs param option return private async checkPsalmLanguageServerHasOption ( psalmScriptArgs , option ) { const psalmScriptPath = await this . resolvePsalmScriptPath ( ) ; try { const args = [ '-f' , psalmScriptPath , '--' , '--help' , ... psalmScriptArgs , ] ; const out = await this . executePhp ( args ) ; const escaped = option . replace ( /[.*+\-?^${}()|[\]\\]/g , '\\$&' ) ; return new RegExp ( '(\\b|\\s)' + escaped + '(?![-_])(\\b|\\s)' , 'm' ) . test ( out ) ; } catch ( err ) { return false ; } } return public async getPHPVersion ( ) { const out = await this . executePhp ( [ '--version' ] ) ; return out ; } return public async getPsalmLanguageServerVersion ( ) { const psalmScriptPath = await this . resolvePsalmScriptPath ( ) ; try { await access ( psalmScriptPath , constants . F_OK ) ; } catch { const msg = `${ psalmScriptPath } does not exist. Please set a valid path to psalm.psalmScriptPath` ; await showErrorMessage ( `Psalm can not start: ${ msg }` ) ; throw new Error ( msg ) ; } try { const args = [ '-f' , psalmScriptPath , '--' , '--version' ] ; const out = await this . executePhp ( args ) ; const ret = out . match ( /^Psalm\s*((?:[0-9]+\.?)+)@([0-9a-f]{40})/ ) ; if ( ret === null || ret . length !== 3 ) { return null ; } const [ , version ] = ret ; return version ; } catch ( err ) { if ( /Use of undefined constant PSALM_VERSION - assumed 'PSALM_VERSION'/g . test ( String ( err . message ) ) ) { return null ; } return null ; } } param args return private async executePhp ( args ) { let stdout ; const { file , args : fileArgs } = await this . getPhpArgs ( args ) ; ( { stdout } = await execFile ( file , fileArgs , { cwd : this . workspacePath , } ) ) ; return String ( stdout ) ; } param args private async getPhpArgs ( args ) { const phpExecutablePath = this . configurationService . get < string > ( 'phpExecutablePath' ) ; if ( ! phpExecutablePath . length ) { const msg = 'Unable to find any php executable please set one in psalm.phpExecutablePath' ; await showOpenSettingsPrompt ( `Psalm can not start: ${ msg }` ) ; throw new Error ( msg ) ; } try { await access ( phpExecutablePath , constants . X_OK ) ; } catch { const msg = `${ phpExecutablePath } is not executable` ; await showErrorMessage ( `Psalm can not start: ${ msg }` ) ; throw new Error ( msg ) ; } const phpExecutableArgs = this . configurationService . get < string [ ] > ( 'phpExecutableArgs' ) ; if ( phpExecutableArgs ) { if ( Array . isArray ( phpExecutableArgs ) && phpExecutableArgs . length > 0 ) { args . unshift ( ... phpExecutableArgs ) ; } } return { file : phpExecutablePath , args } ; } return private async checkPsalmHasLanguageServer ( ) { const psalmScriptPath = await this . resolvePsalmScriptPath ( ) ; const exists = this . isFile ( psalmScriptPath ) ; if ( ! exists ) { this . loggingService . logError ( `The setting psalm.psalmScriptPath refers to a path that does not exist. path: ${ psalmScriptPath }` ) ; return false ; } return true ; } /**
     * Returns true if the file exists.
     */ private isFile ( filePath ) { try { const stat = statSync ( filePath ) ; return stat . isFile ( ) ; } catch ( e ) { return false ; } } /**
     * Resolve Pslam Script Path if absolute or relative
     */ private async resolvePsalmScriptPath ( ) { const psalmScriptPath = this . configurationService . get < string > ( 'psalmScriptPath' ) ; if ( ! psalmScriptPath ) { await showErrorMessage ( `Unable to find Psalm Language Server. Please set psalm.psalmScriptPath` ) ; throw new Error ( 'psalmScriptPath is not set' ) ; } if ( isAbsolute ( psalmScriptPath ) ) { return psalmScriptPath ; } return join ( this . workspacePath , psalmScriptPath ) ; } }