diff --git a/.gitignore b/.gitignore index 3b33340..f725a37 100755 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ test/demo/reported.json test/multi-config-demo/vendor test/multi-config-demo/cache test/multi-config-demo/reported.json +test/multi-workspace-demo/primary/vendor +test/multi-workspace-demo/primary/cache +test/multi-workspace-demo/secondary/vendor +test/multi-workspace-demo/secondary/cache test/scratchpad **/reported.json user_config.json diff --git a/client/src/extension.ts b/client/src/extension.ts index 2387e31..f0d01f4 100755 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -1,8 +1,3 @@ -import { - getReadonlyEditorConfiguration, - getWritableEditorConfiguration, - registerEditorConfigurationListener, -} from './lib/editorConfig'; import { createOutputChannel, SERVER_PREFIX, @@ -19,6 +14,7 @@ import type { ServerOptions, } from 'vscode-languageclient/node'; import { LanguageClient, TransportKind } from 'vscode-languageclient/node'; +import { registerEditorConfigurationListener } from './lib/editorConfig'; import { DocumentManager } from './notificationSenders/documentManager'; import { ErrorManager } from './notificationReceivers/errorManager'; import { ZombieKiller } from './notificationReceivers/zombieKiller'; @@ -128,27 +124,6 @@ export async function activate(context: ExtensionContext): Promise { }) ); log(CLIENT_PREFIX, 'Initializing done'); - - void (async () => { - if ( - workspace.workspaceFolders && - workspace.workspaceFolders?.length > 1 && - !getReadonlyEditorConfiguration().suppressWorkspaceMessage - ) { - const SUPPRESS_OPTION = "Don't show again"; - const choice = await window.showWarningMessage( - `PHPStan extension only supports single-workspace projects, it'll only use the first workspace folder (${workspace.workspaceFolders[0].name}`, - SUPPRESS_OPTION - ); - if (choice === SUPPRESS_OPTION) { - await getWritableEditorConfiguration().update( - 'phpstan.suppressWorkspaceMessage', - true - ); - } - } - })(); - log(CLIENT_PREFIX, 'Showing one-time messages (if needed)'); const installationConfig = await getInstallationConfig(context); const version = (context.extension.packageJSON as { version: string }) diff --git a/client/src/lib/setup.ts b/client/src/lib/setup.ts index 6b05980..34e4373 100644 --- a/client/src/lib/setup.ts +++ b/client/src/lib/setup.ts @@ -165,10 +165,16 @@ function toKeyedWorkspaceFolders( const uri = workspaceFolders?.[0].uri; if (uri) { const initializedFolders: WorkspaceFolders = { - default: uri, + byName: {}, + getForPath: (filePath: string) => { + return workspace.getWorkspaceFolder(Uri.file(filePath))?.uri; + }, }; + if (workspaceFolders?.length === 1) { + initializedFolders.default = uri; + } for (const folder of workspaceFolders ?? []) { - initializedFolders[folder.name] = folder.uri; + initializedFolders.byName[folder.name] = folder.uri; } return initializedFolders; } @@ -202,7 +208,7 @@ abstract class SetupSteps { ) {} protected _getCwd(): string | undefined { - return this._workspaceFolders?.default.fsPath; + return this._workspaceFolders?.default?.fsPath; } protected async _rootDirStep( @@ -242,9 +248,9 @@ abstract class SetupSteps { title: 'Select root directory', }); if (folder) { - if (this._workspaceFolders?.default.fsPath === folder[0].fsPath) { + if (this._workspaceFolders?.default?.fsPath === folder[0].fsPath) { this._state.rootDir = './'; - } else if (this._workspaceFolders) { + } else if (this._workspaceFolders?.default) { this._state.rootDir = path.relative( this._workspaceFolders.default.fsPath, folder[0].fsPath @@ -300,7 +306,7 @@ abstract class SetupSteps { }); if (file) { this._state.configFiles = [ - this._workspaceFolders + this._workspaceFolders?.default ? path.relative( this._workspaceFolders.default.fsPath, file[0].fsPath @@ -358,7 +364,7 @@ abstract class SetupSteps { title: 'Select PHPStan binary', }); if (file) { - this._state.binPath = this._workspaceFolders + this._state.binPath = this._workspaceFolders?.default ? path.relative( this._workspaceFolders.default.fsPath, file[0].fsPath @@ -455,7 +461,7 @@ abstract class SetupSteps { label: path.relative( makeAbsolute( this._state.rootDir, - this._workspaceFolders?.default.fsPath + this._workspaceFolders?.default?.fsPath ), uri.fsPath ), @@ -682,7 +688,7 @@ class DockerSetupSteps extends SetupSteps { title: 'Select PHPStan binary', }); if (file) { - this._state.binPath = this._workspaceFolders + this._state.binPath = this._workspaceFolders?.default ? path.relative( this._workspaceFolders.default.fsPath, file[0].fsPath diff --git a/server/src/lib/checkConfigManager.ts b/server/src/lib/checkConfigManager.ts index 701d6e6..fc2c8f5 100644 --- a/server/src/lib/checkConfigManager.ts +++ b/server/src/lib/checkConfigManager.ts @@ -12,6 +12,7 @@ import * as os from 'os'; export interface CheckConfig { cwd: string; configFile: string | null; + workspaceRoot: string | undefined; remoteConfigFile: string | null; getBinCommand: (args: string[]) => string[]; args: string[]; @@ -33,11 +34,11 @@ export class ConfigurationManager { public static async applyPathMapping( classConfig: ClassConfig, - filePath: string + filePath: string, + workspaceRoot: string | undefined ): Promise { const paths = (await getEditorConfiguration(classConfig)).paths; - const cwd = (await classConfig.workspaceFolders.get())?.default.fsPath; - return getPathMapper(paths, cwd)(filePath); + return getPathMapper(paths, workspaceRoot)(filePath); } private static async _fileIfExists( @@ -93,7 +94,7 @@ export class ConfigurationManager { private static async _getConfigFile( classConfig: ClassConfig, - cwd: string, + cwd: string | undefined, currentFile: URI | null ): Promise { const extensionConfig = await getEditorConfiguration(classConfig); @@ -167,7 +168,8 @@ export class ConfigurationManager { public static async getBinComand( classConfig: ClassConfig, - cwd: string + cwd: string, + workspaceRoot: string | undefined ): Promise< | { success: true; @@ -187,7 +189,11 @@ export class ConfigurationManager { if (binPath.startsWith('~')) { binPath = `${process.env.HOME ?? '~'}${binPath.slice(1)}`; } - binPath = await this.applyPathMapping(classConfig, binPath); + binPath = await this.applyPathMapping( + classConfig, + binPath, + workspaceRoot + ); const binCommand = extensionConfig.binCommand; if ( @@ -239,11 +245,48 @@ export class ConfigurationManager { // Settings const extensionConfig = await getEditorConfiguration(classConfig); - const cwd = await this.getCwd(classConfig); + const workspaceFolders = await classConfig.workspaceFolders.get(); + let cwd: string | undefined; + if (workspaceFolders?.default) { + cwd = (await this.getCwd(classConfig)) ?? undefined; + if (!cwd) { + return null; + } + } else { + // Multiple workspaces. This means config files need to be absolute and we can + // use that to infer cwd. + } + + const configFile = await this._getConfigFile( + classConfig, + cwd, + currentFile + ); + if (!configFile) { + if (onError) { + onError('Failed to find config file'); + } + return null; + } + const workspaceRoot = + workspaceFolders?.getForPath(configFile)?.fsPath ?? + workspaceFolders?.default?.fsPath; + + if (!cwd) { + cwd = + this._getAbsolutePath(extensionConfig.rootDir, workspaceRoot) || + workspaceRoot; + } + if (!cwd) { + await showErrorOnce( + classConfig.connection, + 'PHPStan: failed to get CWD' + ); return null; } - const result = await this.getBinComand(classConfig, cwd); + + const result = await this.getBinComand(classConfig, cwd, workspaceRoot); if (!result.success) { if (onError) { onError(result.error); @@ -255,27 +298,18 @@ export class ConfigurationManager { } return null; } - const configFile = await this._getConfigFile( - classConfig, - cwd, - currentFile - ); - if (!configFile) { - if (onError) { - onError('Failed to find config file'); - } - return null; - } const tmpDir: string | undefined = extensionConfig.tmpDir; return { cwd, configFile, + workspaceRoot, remoteConfigFile: configFile ? await ConfigurationManager.applyPathMapping( classConfig, - configFile + configFile, + cwd ) : null, args: extensionConfig.options ?? [], diff --git a/server/src/lib/documentManager.ts b/server/src/lib/documentManager.ts index 4284c69..8eb146c 100644 --- a/server/src/lib/documentManager.ts +++ b/server/src/lib/documentManager.ts @@ -280,12 +280,11 @@ export class DocumentManager implements AsyncDisposable { const editorConfig = await getEditorConfiguration(this._classConfig); if (!editorConfig.singleFileMode) { - await checkManager.checkWithDebounce( - undefined, - e ? URI.parse(e.uri) : null, - 'Config change', - null - ); + if ((await this._classConfig.workspaceFolders.get())?.default) { + await this._onScanCurrentProject(checkManager, e); + } else { + await this._onScanAllProjects(checkManager); + } } void this.watcher?.onConfigChange(); } diff --git a/server/src/lib/editorConfig.ts b/server/src/lib/editorConfig.ts index a30d211..bba3a95 100644 --- a/server/src/lib/editorConfig.ts +++ b/server/src/lib/editorConfig.ts @@ -15,11 +15,9 @@ export async function getEditorConfiguration( > ): Promise, 'enableLanguageServer'>> { const workspaceFolders = await classConfig.workspaceFolders.get(); - const scope = workspaceFolders?.default.toString(); const editorConfig = { ...((await classConfig.connection.workspace.getConfiguration({ - scopeUri: scope, section: 'phpstan', })) as ConfigWithoutPrefix & ConfigWithoutPrefix), diff --git a/server/src/lib/phpstan/check.ts b/server/src/lib/phpstan/check.ts index 90eb3f2..7ecc5d4 100644 --- a/server/src/lib/phpstan/check.ts +++ b/server/src/lib/phpstan/check.ts @@ -95,7 +95,8 @@ export class PHPStanCheck implements AsyncDisposable { // Get file const filePath = await ConfigurationManager.applyPathMapping( this._classConfig, - URI.parse(file.uri).fsPath + URI.parse(file.uri).fsPath, + checkConfig.cwd ); const result = await runner.runProcess( @@ -185,7 +186,7 @@ export class PHPStanCheck implements AsyncDisposable { const errorManager = new PHPStanCheckErrorManager(this._classConfig); const pathMapper = getPathMapper( (await getEditorConfiguration(this._classConfig)).paths, - (await this._classConfig.workspaceFolders.get())?.default.fsPath + checkConfig.workspaceRoot ); const runner = new PHPStanRunner(this._classConfig); this.disposables.push(runner); diff --git a/server/src/lib/phpstan/pro/proErrorManager.ts b/server/src/lib/phpstan/pro/proErrorManager.ts index 8f3b012..a86c76d 100644 --- a/server/src/lib/phpstan/pro/proErrorManager.ts +++ b/server/src/lib/phpstan/pro/proErrorManager.ts @@ -209,7 +209,7 @@ export class PHPStanProErrorManager implements Disposable { this._pathMapper ??= getPathMapper( (await getEditorConfiguration(this._classConfig)).paths, - (await this._classConfig.workspaceFolders.get())?.default.fsPath + (await this._classConfig.workspaceFolders.get())?.default?.fsPath ); const fileSpecificErrors: ReportedErrors['fileSpecificErrors'] = {}; for (const fileError of errors.fileSpecificErrors) { diff --git a/server/src/lib/types.ts b/server/src/lib/types.ts index 6204370..d9cd6c0 100644 --- a/server/src/lib/types.ts +++ b/server/src/lib/types.ts @@ -22,8 +22,11 @@ export interface ClassConfig { } export type WorkspaceFolders = { - [name: string]: URI | undefined; - default: URI; + byName: { + [name: string]: URI | undefined; + }; + getForPath: (path: string) => URI | undefined; + default?: URI; }; export class PromisedValue { diff --git a/server/src/providers/providerUtil.ts b/server/src/providers/providerUtil.ts index 270faeb..9e9835d 100644 --- a/server/src/providers/providerUtil.ts +++ b/server/src/providers/providerUtil.ts @@ -58,15 +58,6 @@ export async function getFileReport( return null; } - const workspaceFolder = (await providerArgs.workspaceFolders.get()) - ?.default; - if ( - !workspaceFolder || - (!NO_CANCEL_OPERATIONS && cancelToken.isCancellationRequested) - ) { - return null; - } - // Ensure the file has been checked if (!providerArgs.phpstan) { return ( @@ -145,10 +136,13 @@ export class ProviderCheckHooks { return null; } + const roots = Object.values(workspaceFolder.byName).map((root) => + root?.toString() + ); return path.join( (await this._extensionPath.get()).fsPath, '_config', - basicHash(workspaceFolder.default.fsPath) + basicHash(JSON.stringify(roots)) ); } diff --git a/server/src/server.ts b/server/src/server.ts index ca41412..be55f22 100755 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -34,6 +34,7 @@ import { startPro } from './start/startPro'; import { StatusBar } from './lib/statusBar'; import { listenTest } from './lib/test'; import { URI } from 'vscode-uri'; +import * as path from 'path'; async function main(): Promise { // Creates the LSP connection @@ -57,10 +58,25 @@ async function main(): Promise { const uri = params.workspaceFolders?.[0].uri; if (uri) { const initializedFolders: WorkspaceFolders = { - default: URI.parse(uri), + byName: {}, + getForPath: (filePath: string) => { + if (!path.isAbsolute(filePath)) { + return undefined; + } + for (const folder of params.workspaceFolders ?? []) { + const folderUri = URI.parse(folder.uri); + if (filePath.startsWith(folderUri.fsPath)) { + return folderUri; + } + } + return undefined; + }, }; + if (params.workspaceFolders?.length === 1) { + initializedFolders.default = URI.parse(uri); + } for (const folder of params.workspaceFolders ?? []) { - initializedFolders[folder.name] = URI.parse(folder.uri); + initializedFolders.byName[folder.name] = URI.parse(folder.uri); } workspaceFolders.set(initializedFolders); } diff --git a/server/src/start/getVersion.ts b/server/src/start/getVersion.ts index a05318e..9348d87 100644 --- a/server/src/start/getVersion.ts +++ b/server/src/start/getVersion.ts @@ -24,9 +24,11 @@ export async function getVersion( }; } + const workspaceRoot = (await classConfig.workspaceFolders.get())?.default; const binConfigResult = await ConfigurationManager.getBinComand( classConfig, - cwd + cwd, + workspaceRoot?.fsPath ); if (!binConfigResult.success) { return { diff --git a/shared/util.ts b/shared/util.ts index c184f9e..4d1212a 100644 --- a/shared/util.ts +++ b/shared/util.ts @@ -180,7 +180,7 @@ export function fromEntries( export async function getConfigFile( configFile: string, - cwd: string, + cwd: string | undefined, pathExistsFn: (filePath: string) => Promise = pathExists ): Promise { const absoluteConfigPaths = configFile @@ -263,7 +263,7 @@ export async function execute( export function getPathMapper( pathMapping: Record, - cwd?: string + workspaceRoot?: string ): (filePath: string, inverse?: boolean) => string { return (filePath: string, inverse: boolean = false) => { if (Object.keys(pathMapping).length === 0) { @@ -272,8 +272,8 @@ export function getPathMapper( const expandedFilePath = filePath.replace(/^~/, os.homedir()); // eslint-disable-next-line prefer-const for (let [fromPath, toPath] of Object.entries(pathMapping)) { - if (!path.isAbsolute(fromPath) && cwd) { - fromPath = path.join(cwd, fromPath); + if (!path.isAbsolute(fromPath) && workspaceRoot) { + fromPath = path.join(workspaceRoot, fromPath); } const [from, to] = inverse diff --git a/shared/variables.ts b/shared/variables.ts index 7eebad7..bac8fdd 100644 --- a/shared/variables.ts +++ b/shared/variables.ts @@ -14,7 +14,7 @@ export function replaceVariables( 'workspaceFolder:name is not set but is used in a variable' ); } - const folder = workspaceFolders[workspaceName]; + const folder = workspaceFolders.byName[workspaceName]; if (!folder) { throw new Error( `workspaceFolder:${workspaceName} is not set but is used in a variable` @@ -23,13 +23,12 @@ export function replaceVariables( return folder.fsPath; } - const workspaceFolder = workspaceFolders?.default; - if (!workspaceFolder) { + if (!workspaceFolders?.default) { throw new Error( 'workspaceFolder is not set but is used in a variable' ); } - return workspaceFolder.fsPath; + return workspaceFolders.default.fsPath; } ); } diff --git a/test/multi-workspace-demo/multi-workspace.code-workspace b/test/multi-workspace-demo/multi-workspace.code-workspace new file mode 100644 index 0000000..ffa44d7 --- /dev/null +++ b/test/multi-workspace-demo/multi-workspace.code-workspace @@ -0,0 +1,19 @@ +{ + "folders": [ + { + "path": "primary", + "name": "Primary", + }, + { + "path": "secondary", + "name": "Secondary", + }, + ], + "settings": { + "phpstan.configFiles": [ + "${workspaceFolder:Primary}/phpstan.neon", + "${workspaceFolder:Secondary}/phpstan.neon", + ], + "phpstan.rootDir": "./s", + }, +} diff --git a/test/multi-workspace-demo/primary/composer.json b/test/multi-workspace-demo/primary/composer.json new file mode 100644 index 0000000..5d647a4 --- /dev/null +++ b/test/multi-workspace-demo/primary/composer.json @@ -0,0 +1,17 @@ +{ + "name": "sander/demo", + "require": { + "phpstan/phpstan": "^1.12" + }, + "autoload": { + "psr-4": { + "Sander\\Demo\\": "src/" + } + }, + "authors": [ + { + "name": "\"Sander Ronde\"", + "email": "awsdfgvhbjn@gmail.com" + } + ] +} diff --git a/test/multi-workspace-demo/primary/composer.lock b/test/multi-workspace-demo/primary/composer.lock new file mode 100644 index 0000000..c4ba517 --- /dev/null +++ b/test/multi-workspace-demo/primary/composer.lock @@ -0,0 +1,67 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "b01bbbb91f7e7dd7cbdc91797a5a10fe", + "packages": [ + { + "name": "phpstan/phpstan", + "version": "1.12.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "384af967d35b2162f69526c7276acadce534d0e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/384af967d35b2162f69526c7276acadce534d0e1", + "reference": "384af967d35b2162f69526c7276acadce534d0e1", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": ["phpstan", "phpstan.phar"], + "type": "library", + "autoload": { + "files": ["bootstrap.php"] + }, + "notification-url": "https://packagist.org/downloads/", + "license": ["MIT"], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": ["dev", "static analysis"], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2024-08-27T09:18:05+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/test/multi-workspace-demo/primary/php/DemoClass.php b/test/multi-workspace-demo/primary/php/DemoClass.php new file mode 100644 index 0000000..737b040 --- /dev/null +++ b/test/multi-workspace-demo/primary/php/DemoClass.php @@ -0,0 +1,24 @@ +