From a321cba89e661dfd6fb43eaeb710694d6d1180e9 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 17 Apr 2024 15:29:14 -0700 Subject: [PATCH] Refactor CLI version selection (#593) --- .../installers/flow-cli-installer.ts | 10 +- extension/src/flow-cli/cli-provider.ts | 176 ++++-------------- .../src/flow-cli/cli-selection-provider.ts | 45 +++-- .../src/flow-cli/cli-versions-provider.ts | 146 +++++++++++++++ extension/src/server/language-server.ts | 5 +- .../integration/1 - language-server.test.ts | 6 +- extension/test/unit/parser.test.ts | 8 +- package.json | 4 +- 8 files changed, 227 insertions(+), 173 deletions(-) create mode 100644 extension/src/flow-cli/cli-versions-provider.ts diff --git a/extension/src/dependency-installer/installers/flow-cli-installer.ts b/extension/src/dependency-installer/installers/flow-cli-installer.ts index 32d4c951..fa8f971b 100644 --- a/extension/src/dependency-installer/installers/flow-cli-installer.ts +++ b/extension/src/dependency-installer/installers/flow-cli-installer.ts @@ -6,6 +6,7 @@ import { Installer, InstallerConstructor, InstallerContext } from '../installer' import * as semver from 'semver' import fetch from 'node-fetch' import { HomebrewInstaller } from './homebrew-installer' +import { KNOWN_FLOW_COMMANDS } from '../../flow-cli/cli-versions-provider' // Command to check flow-cli const COMPATIBLE_FLOW_CLI_VERSIONS = '>=1.6.0' @@ -97,10 +98,9 @@ export class InstallFlowCLI extends Installer { } } - async checkVersion (vsn?: semver.SemVer): Promise { + async checkVersion (version: semver.SemVer): Promise { // Get user's version informaton this.#context.cliProvider.refresh() - const version = vsn ?? await this.#context.cliProvider.getAvailableBinaries().then(x => x.find(y => y.name === 'flow')?.version) if (version == null) return false if (!semver.satisfies(version, COMPATIBLE_FLOW_CLI_VERSIONS, { @@ -128,7 +128,11 @@ export class InstallFlowCLI extends Installer { async verifyInstall (): Promise { // Check if flow version is valid to verify install this.#context.cliProvider.refresh() - const version = await this.#context.cliProvider.getAvailableBinaries().then(x => x.find(y => y.name === 'flow')?.version) + const installedVersions = await this.#context.cliProvider.getBinaryVersions().catch((e) => { + void window.showErrorMessage(`Failed to check CLI version: ${String(e.message)}`) + return [] + }) + const version = installedVersions.find(y => y.command === KNOWN_FLOW_COMMANDS.DEFAULT)?.version if (version == null) return false // Check flow-cli version number diff --git a/extension/src/flow-cli/cli-provider.ts b/extension/src/flow-cli/cli-provider.ts index a5b2181a..dd9bdb39 100644 --- a/extension/src/flow-cli/cli-provider.ts +++ b/extension/src/flow-cli/cli-provider.ts @@ -1,67 +1,37 @@ import { BehaviorSubject, Observable, distinctUntilChanged, pairwise, startWith } from 'rxjs' -import { execDefault } from '../utils/shell/exec' import { StateCache } from '../utils/state-cache' -import * as semver from 'semver' import * as vscode from 'vscode' import { Settings } from '../settings/settings' import { isEqual } from 'lodash' - -const CHECK_FLOW_CLI_CMD = (flowCommand: string): string => `${flowCommand} version --output=json` -const CHECK_FLOW_CLI_CMD_NO_JSON = (flowCommand: string): string => `${flowCommand} version` - -const KNOWN_BINS = ['flow', 'flow-c1'] - -const CADENCE_V1_CLI_REGEX = /-cadence-v1.0.0/g -const LEGACY_VERSION_REGEXP = /Version:\s*(v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(\s|$)/m - -export interface CliBinary { - name: string - version: semver.SemVer -} - -interface FlowVersionOutput { - version: string -} - -interface AvailableBinariesCache { - [key: string]: StateCache -} +import { CliBinary, CliVersionsProvider, KNOWN_FLOW_COMMANDS } from './cli-versions-provider' export class CliProvider { #selectedBinaryName: BehaviorSubject #currentBinary$: StateCache - #availableBinaries: AvailableBinariesCache = {} - #availableBinaries$: StateCache + #cliVersionsProvider: CliVersionsProvider #settings: Settings constructor (settings: Settings) { + const initialBinaryPath = settings.getSettings().flowCommand + this.#settings = settings + this.#cliVersionsProvider = new CliVersionsProvider([initialBinaryPath]) + this.#selectedBinaryName = new BehaviorSubject(initialBinaryPath) + this.#currentBinary$ = new StateCache(async () => { + const name: string = this.#selectedBinaryName.getValue() + const versionCache = this.#cliVersionsProvider.get(name) + if (versionCache == null) return null + return await versionCache.getValue() + }) - this.#selectedBinaryName = new BehaviorSubject(settings.getSettings().flowCommand) + // Bind the selected binary to the settings this.#settings.watch$(config => config.flowCommand).subscribe((flowCommand) => { this.#selectedBinaryName.next(flowCommand) }) - this.#availableBinaries = KNOWN_BINS.reduce((acc, bin) => { - acc[bin] = new StateCache(async () => await this.#fetchBinaryInformation(bin)) - acc[bin].subscribe(() => { - this.#availableBinaries$.invalidate() - }) - return acc - }, {}) - - this.#availableBinaries$ = new StateCache(async () => { - return await this.getAvailableBinaries() - }) - - this.#currentBinary$ = new StateCache(async () => { - const name: string = this.#selectedBinaryName.getValue() - return await this.#availableBinaries[name].getValue() - }) - // Display warning to user if binary doesn't exist (only if not using the default binary) - this.#currentBinary$.subscribe((binary) => { - if (binary === null && this.#selectedBinaryName.getValue() !== 'flow') { + this.currentBinary$.subscribe((binary) => { + if (binary === null && this.#selectedBinaryName.getValue() !== KNOWN_FLOW_COMMANDS.DEFAULT) { void vscode.window.showErrorMessage(`The configured Flow CLI binary "${this.#selectedBinaryName.getValue()}" does not exist. Please check your settings.`) } }) @@ -72,119 +42,43 @@ export class CliProvider { #watchForBinaryChanges (): void { // Subscribe to changes in the selected binary to update the caches this.#selectedBinaryName.pipe(distinctUntilChanged(), startWith(null), pairwise()).subscribe(([prev, curr]) => { - // Swap out the cache for the selected binary - if (prev != null && !KNOWN_BINS.includes(prev)) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete this.#availableBinaries[prev] - } - if (curr != null && !KNOWN_BINS.includes(curr)) { - this.#availableBinaries[curr] = new StateCache(async () => await this.#fetchBinaryInformation(curr)) - this.#availableBinaries[curr].subscribe(() => { - this.#availableBinaries$.invalidate() - }) - } + // Remove the previous binary from the cache + if (prev != null) this.#cliVersionsProvider.remove(prev) + + // Add the current binary to the cache + if (curr != null) this.#cliVersionsProvider.add(curr) // Invalidate the current binary cache this.#currentBinary$.invalidate() - - // Invalidate the available binaries cache - this.#availableBinaries$.invalidate() }) } - // Fetches the binary information for the given binary - async #fetchBinaryInformation (bin: string): Promise { - try { - // Get user's version informaton - const buffer: string = (await execDefault(CHECK_FLOW_CLI_CMD( - bin - ))).stdout - - // Format version string from output - const versionInfo: FlowVersionOutput = JSON.parse(buffer) - - // Ensure user has a compatible version number installed - const version: semver.SemVer | null = semver.parse(versionInfo.version) - if (version === null) return null - - return { name: bin, version } - } catch { - // Fallback to old method if JSON is not supported/fails - return await this.#fetchBinaryInformationOld(bin) - } - } - - // Old version of fetchBinaryInformation (before JSON was supported) - // Used as fallback for old CLI versions - async #fetchBinaryInformationOld (bin: string): Promise { - try { - // Get user's version informaton - const output = (await execDefault(CHECK_FLOW_CLI_CMD_NO_JSON( - bin - ))) - - let versionStr: string | null = parseFlowCliVersion(output.stdout) - if (versionStr === null) { - // Try to fallback to stderr as patch for bugged version - versionStr = parseFlowCliVersion(output.stderr) - } - - versionStr = versionStr != null ? semver.clean(versionStr) : null - if (versionStr === null) return null - - // Ensure user has a compatible version number installed - const version: semver.SemVer | null = semver.parse(versionStr) - if (version === null) return null - - return { name: bin, version } - } catch { - return null - } - } - - refresh (): void { - for (const bin in this.#availableBinaries) { - this.#availableBinaries[bin].invalidate() - } - this.#currentBinary$.invalidate() - } - - get availableBinaries$ (): Observable { - return new Observable((subscriber) => { - this.#availableBinaries$.subscribe((binaries) => { - subscriber.next(binaries) - }) - }).pipe(distinctUntilChanged(isEqual)) + async getCurrentBinary (): Promise { + return await this.#currentBinary$.getValue() } - async getAvailableBinaries (): Promise { - const bins: CliBinary[] = [] - for (const name in this.#availableBinaries) { - const binary = await this.#availableBinaries[name].getValue().catch(() => null) - if (binary !== null) { - bins.push(binary) - } + async setCurrentBinary (name: string): Promise { + if (vscode.workspace.workspaceFolders == null) { + await this.#settings.updateSettings({ flowCommand: name }, vscode.ConfigurationTarget.Global) + } else { + await this.#settings.updateSettings({ flowCommand: name }) } - return bins } get currentBinary$ (): Observable { return this.#currentBinary$.pipe(distinctUntilChanged(isEqual)) } - async getCurrentBinary (): Promise { - return await this.#currentBinary$.getValue() + async getBinaryVersions (): Promise { + return await this.#cliVersionsProvider.getVersions() } - async setCurrentBinary (name: string): Promise { - await this.#settings.updateSettings({ flowCommand: name }) + get binaryVersions$ (): Observable { + return this.#cliVersionsProvider.versions$.pipe(distinctUntilChanged(isEqual)) } -} - -export function isCadenceV1Cli (version: semver.SemVer): boolean { - return CADENCE_V1_CLI_REGEX.test(version.raw) -} -export function parseFlowCliVersion (buffer: Buffer | string): string | null { - return buffer.toString().match(LEGACY_VERSION_REGEXP)?.[1] ?? null + // Refresh all cached binary versions + refresh (): void { + this.#cliVersionsProvider.refresh() + } } diff --git a/extension/src/flow-cli/cli-selection-provider.ts b/extension/src/flow-cli/cli-selection-provider.ts index 53637926..29034bf0 100644 --- a/extension/src/flow-cli/cli-selection-provider.ts +++ b/extension/src/flow-cli/cli-selection-provider.ts @@ -1,9 +1,10 @@ +import * as vscode from 'vscode' import { zip } from 'rxjs' -import { CliBinary, CliProvider } from './cli-provider' +import { CliProvider } from './cli-provider' import { SemVer } from 'semver' -import * as vscode from 'vscode' +import { CliBinary } from './cli-versions-provider' -const CHANGE_CADENCE_VERSION = 'cadence.changeCadenceVersion' +const CHANGE_CLI_BINARY = 'cadence.changeFlowCliBinary' const CADENCE_V1_CLI_REGEX = /-cadence-v1.0.0/g // label with icon const GET_BINARY_LABEL = (version: SemVer): string => `Flow CLI v${version.format()}` @@ -19,13 +20,13 @@ export class CliSelectionProvider { this.#cliProvider = cliProvider // Register the command to toggle the version - this.#disposables.push(vscode.commands.registerCommand(CHANGE_CADENCE_VERSION, async () => { + this.#disposables.push(vscode.commands.registerCommand(CHANGE_CLI_BINARY, async () => { this.#cliProvider.refresh() await this.#toggleSelector(true) })) // Register UI components - zip(this.#cliProvider.currentBinary$, this.#cliProvider.availableBinaries$).subscribe(() => { + zip(this.#cliProvider.currentBinary$, this.#cliProvider.binaryVersions$).subscribe(() => { void this.#refreshSelector() }) this.#cliProvider.currentBinary$.subscribe((binary) => { @@ -37,7 +38,7 @@ export class CliSelectionProvider { #createStatusBarItem (version: SemVer | null): vscode.StatusBarItem { const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 1) - statusBarItem.command = CHANGE_CADENCE_VERSION + statusBarItem.command = CHANGE_CLI_BINARY statusBarItem.color = new vscode.ThemeColor('statusBar.foreground') statusBarItem.tooltip = 'Click to change the Flow CLI version' @@ -74,23 +75,31 @@ export class CliSelectionProvider { } }) } else if (selected instanceof AvailableBinaryItem) { - void this.#cliProvider.setCurrentBinary(selected.path) + void this.#cliProvider.setCurrentBinary(selected.command) } })) + this.#disposables.push(versionSelector.onDidHide(() => { + void this.#toggleSelector(false) + })) + // Update available versions const items: Array = availableBinaries.map(binary => new AvailableBinaryItem(binary)) items.push(new CustomBinaryItem()) - versionSelector.items = items - // Select the current binary - if (currentBinary !== null) { - const currentBinaryItem = versionSelector.items.find(item => item instanceof AvailableBinaryItem && item.path === currentBinary.name) - if (currentBinaryItem != null) { - versionSelector.selectedItems = [currentBinaryItem] - } + // Hoist the current binary to the top of the list + const currentBinaryIndex = items.findIndex(item => + item instanceof AvailableBinaryItem && + currentBinary != null && + item.command === currentBinary.command + ) + if (currentBinaryIndex !== -1) { + const currentBinaryItem = items[currentBinaryIndex] + items.splice(currentBinaryIndex, 1) + items.unshift(currentBinaryItem) } + versionSelector.items = items return versionSelector } @@ -103,7 +112,7 @@ export class CliSelectionProvider { if (this.#showSelector) { this.#versionSelector?.dispose() const currentBinary = await this.#cliProvider.getCurrentBinary() - const availableBinaries = await this.#cliProvider.getAvailableBinaries() + const availableBinaries = await this.#cliProvider.getBinaryVersions() this.#versionSelector = this.#createVersionSelector(currentBinary, availableBinaries) this.#disposables.push(this.#versionSelector) this.#versionSelector.show() @@ -134,11 +143,11 @@ class AvailableBinaryItem implements vscode.QuickPickItem { } get description (): string { - return `(${this.#binary.name})` + return `(${this.#binary.command})` } - get path (): string { - return this.#binary.name + get command (): string { + return this.#binary.command } } diff --git a/extension/src/flow-cli/cli-versions-provider.ts b/extension/src/flow-cli/cli-versions-provider.ts new file mode 100644 index 00000000..035677ac --- /dev/null +++ b/extension/src/flow-cli/cli-versions-provider.ts @@ -0,0 +1,146 @@ +import * as semver from 'semver' +import { StateCache } from '../utils/state-cache' +import { execDefault } from '../utils/shell/exec' +import { Observable, distinctUntilChanged } from 'rxjs' +import { isEqual } from 'lodash' + +const CHECK_FLOW_CLI_CMD = (flowCommand: string): string => `${flowCommand} version --output=json` +const CHECK_FLOW_CLI_CMD_NO_JSON = (flowCommand: string): string => `${flowCommand} version` + +export enum KNOWN_FLOW_COMMANDS { + DEFAULT = 'flow', + CADENCE_V1 = 'flow-c1', +} + +// Matches the version number from the output of the Flow CLI +const LEGACY_VERSION_REGEXP = /Version:\s*v(.*)(?:\s|$)/m + +export interface CliBinary { + command: string + version: semver.SemVer +} + +interface FlowVersionOutput { + version: string +} + +export class CliVersionsProvider { + #rootCache: StateCache + #caches: { [key: string]: StateCache } = {} + + constructor (seedBinaries: string[] = []) { + // Seed the caches with the known binaries + Object.values(KNOWN_FLOW_COMMANDS).forEach((bin) => { + this.add(bin) + }) + + // Seed the caches with any additional binaries + seedBinaries.forEach((bin) => { + this.add(bin) + }) + + // Create the root cache. This cache will hold all the binary information + // and is a combination of all the individual caches for each binary + this.#rootCache = new StateCache(async () => { + const binaries = await Promise.all( + Object.keys(this.#caches).map(async (bin) => { + return await this.#caches[bin].getValue().catch(() => null) + }) + ) + + // Filter out missing binaries + return binaries.filter((bin) => bin != null) as CliBinary[] + }) + } + + add (command: string): void { + if (this.#caches[command] != null) return + this.#caches[command] = new StateCache(async () => await this.#fetchBinaryInformation(command)) + this.#caches[command].subscribe(() => { + this.#rootCache?.invalidate() + }) + this.#rootCache?.invalidate() + } + + remove (command: string): void { + // Known binaries cannot be removed + if (this.#caches[command] == null || (Object.values(KNOWN_FLOW_COMMANDS) as string[]).includes(command)) return + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.#caches[command] + this.#rootCache?.invalidate() + } + + get (name: string): StateCache | null { + return this.#caches[name] ?? null + } + + // Fetches the binary information for the given binary + async #fetchBinaryInformation (bin: string): Promise { + try { + // Get user's version informaton + const buffer: string = (await execDefault(CHECK_FLOW_CLI_CMD( + bin + ))).stdout + + // Format version string from output + const versionInfo: FlowVersionOutput = JSON.parse(buffer) + + return cliBinaryFromVersion(bin, versionInfo.version) + } catch { + // Fallback to old method if JSON is not supported/fails + return await this.#fetchBinaryInformationOld(bin) + } + } + + // Old version of fetchBinaryInformation (before JSON was supported) + // Used as fallback for old CLI versions + async #fetchBinaryInformationOld (bin: string): Promise { + try { + // Get user's version informaton + const output = (await execDefault(CHECK_FLOW_CLI_CMD_NO_JSON( + bin + ))) + + let versionStr: string | null = parseFlowCliVersion(output.stdout) + if (versionStr === null) { + // Try to fallback to stderr as patch for bugged version + versionStr = parseFlowCliVersion(output.stderr) + } + + if (versionStr == null) return null + + return cliBinaryFromVersion(bin, versionStr) + } catch { + return null + } + } + + refresh (): void { + Object.keys(this.#caches).forEach((bin) => { + this.#caches[bin].invalidate() + }) + this.#rootCache.invalidate() + } + + async getVersions (): Promise { + return await this.#rootCache.getValue() + } + + get versions$ (): Observable { + return this.#rootCache.pipe(distinctUntilChanged(isEqual)) + } +} + +export function parseFlowCliVersion (buffer: Buffer | string): string | null { + const rawMatch = buffer.toString().match(LEGACY_VERSION_REGEXP)?.[1] ?? null + if (rawMatch == null) return null + return semver.clean(rawMatch) +} + +function cliBinaryFromVersion (bin: string, versionStr: string): CliBinary | null { + // Ensure user has a compatible version number installed + const version: semver.SemVer | null = semver.parse(versionStr) + if (version === null) return null + + return { command: bin, version } +} diff --git a/extension/src/server/language-server.ts b/extension/src/server/language-server.ts index 1973cd34..ad74ca80 100644 --- a/extension/src/server/language-server.ts +++ b/extension/src/server/language-server.ts @@ -7,6 +7,7 @@ import { BehaviorSubject, Subscription, filter, firstValueFrom, skip } from 'rxj import { envVars } from '../utils/shell/env-vars' import { FlowConfig } from './flow-config' import { CliProvider } from '../flow-cli/cli-provider' +import { KNOWN_FLOW_COMMANDS } from '../flow-cli/cli-versions-provider' // Identities for commands handled by the Language server const RELOAD_CONFIGURATION = 'cadence.server.flow.reloadConfiguration' @@ -75,12 +76,12 @@ export class LanguageServerAPI { const accessCheckMode: string = this.#settings.getSettings().accessCheckMode const configPath: string | null = this.#config.configPath - const binaryPath = (await this.#cliProvider.getCurrentBinary())?.name + const binaryPath = (await this.#cliProvider.getCurrentBinary())?.command if (binaryPath == null) { throw new Error('No flow binary found') } - if (binaryPath !== 'flow') { + if (binaryPath !== KNOWN_FLOW_COMMANDS.DEFAULT) { try { exec('killall dlv') // Required when running language server locally on mac } catch (err) { void err } diff --git a/extension/test/integration/1 - language-server.test.ts b/extension/test/integration/1 - language-server.test.ts index 3fc9f30b..47c9ab36 100644 --- a/extension/test/integration/1 - language-server.test.ts +++ b/extension/test/integration/1 - language-server.test.ts @@ -8,8 +8,8 @@ import { MaxTimeout } from '../globals' import { BehaviorSubject, Subject } from 'rxjs' import { State } from 'vscode-languageclient' import * as sinon from 'sinon' -import { CliBinary } from '../../src/flow-cli/cli-provider' import { SemVer } from 'semver' +import { CliBinary } from '../../src/flow-cli/cli-versions-provider' suite('Language Server & Emulator Integration', () => { let LS: LanguageServerAPI @@ -33,7 +33,7 @@ suite('Language Server & Emulator Integration', () => { // create a mock cli provider without invokign the constructor cliBinary$ = new BehaviorSubject({ - name: 'flow', + command: 'flow', version: new SemVer('1.0.0') }) const mockCliProvider = { @@ -63,7 +63,7 @@ suite('Language Server & Emulator Integration', () => { fileModified$.next() pathChanged$.next('foo') cliBinary$.next({ - name: 'flow', + command: 'flow', version: new SemVer('1.0.1') }) diff --git a/extension/test/unit/parser.test.ts b/extension/test/unit/parser.test.ts index 6aba70a6..41f48829 100644 --- a/extension/test/unit/parser.test.ts +++ b/extension/test/unit/parser.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert' -import { parseFlowCliVersion } from '../../src/flow-cli/cli-provider' +import { parseFlowCliVersion } from '../../src/flow-cli/cli-versions-provider' import { execDefault } from '../../src/utils/shell/exec' import { ASSERT_EQUAL } from '../globals' import * as semver from 'semver' @@ -8,17 +8,17 @@ suite('Parsing Unit Tests', () => { test('Flow CLI Version Parsing (buffer input)', async () => { let versionTest: Buffer = Buffer.from('Version: v0.1.0\nCommit: 0a1b2c3d') let formatted = parseFlowCliVersion(versionTest) - ASSERT_EQUAL(formatted, 'v0.1.0') + ASSERT_EQUAL(formatted, '0.1.0') versionTest = Buffer.from('Version: v0.1.0') formatted = parseFlowCliVersion(versionTest) - ASSERT_EQUAL(formatted, 'v0.1.0') + ASSERT_EQUAL(formatted, '0.1.0') }) test('Flow CLI Version Parsing (string input)', async () => { const versionTest: string = 'Version: v0.1.0\nCommit: 0a1b2c3d' const formatted = parseFlowCliVersion(versionTest) - ASSERT_EQUAL(formatted, 'v0.1.0') + ASSERT_EQUAL(formatted, '0.1.0') }) test('Flow CLI Version Parsing produces valid semver from Flow CLI output', async () => { diff --git a/package.json b/package.json index be0e51c5..714ef41c 100644 --- a/package.json +++ b/package.json @@ -100,9 +100,9 @@ "title": "Check Dependencies" }, { - "command": "cadence.changeCadenceVersion", + "command": "cadence.changeFlowCliBinary", "category": "Cadence", - "title": "Change Cadence Version" + "title": "Change Flow CLI Binary" } ], "configuration": {