From 4b266f892ad6d31d3766138cde5adb1d212cb55a Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 15 Apr 2024 12:24:16 -0700 Subject: [PATCH] Refactor CLI version selection --- .../src/flow-cli/binary-versions-provider.ts | 141 ++++++++++++++ extension/src/flow-cli/cli-provider.ts | 172 ++++-------------- .../src/flow-cli/cli-selection-provider.ts | 21 ++- extension/src/server/language-server.ts | 2 +- package.json | 4 +- 5 files changed, 186 insertions(+), 154 deletions(-) create mode 100644 extension/src/flow-cli/binary-versions-provider.ts diff --git a/extension/src/flow-cli/binary-versions-provider.ts b/extension/src/flow-cli/binary-versions-provider.ts new file mode 100644 index 00000000..9ca6df8f --- /dev/null +++ b/extension/src/flow-cli/binary-versions-provider.ts @@ -0,0 +1,141 @@ +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` + +const KNOWN_BINS = ['flow', 'flow-c1'] + +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 { + path: string + version: semver.SemVer +} + +interface FlowVersionOutput { + version: string +} + +export class BinaryVersionsProvider { + #rootCache: StateCache + #caches: { [key: string]: StateCache } = {} + + constructor (seedBinaries: string[] = []) { + // Seed the caches with the known binaries + KNOWN_BINS.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 (path: string): void { + if (this.#caches[path] != null) return + this.#caches[path] = new StateCache(async () => await this.#fetchBinaryInformation(path)) + this.#caches[path].subscribe(() => { + this.#rootCache?.invalidate() + }) + this.#rootCache?.invalidate() + } + + remove (path: string): void { + // Known binaries cannot be removed + if (this.#caches[path] == null || KNOWN_BINS.includes(path)) return + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.#caches[path] + 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) + + // Ensure user has a compatible version number installed + const version: semver.SemVer | null = semver.parse(versionInfo.version) + if (version === null) return null + + return { path: 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 { path: bin, version } + } 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 { + return buffer.toString().match(LEGACY_VERSION_REGEXP)?.[1] ?? null +} diff --git a/extension/src/flow-cli/cli-provider.ts b/extension/src/flow-cli/cli-provider.ts index a5b2181a..1a4ce76f 100644 --- a/extension/src/flow-cli/cli-provider.ts +++ b/extension/src/flow-cli/cli-provider.ts @@ -1,66 +1,36 @@ 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, BinaryVersionsProvider } from './binary-versions-provider' export class CliProvider { #selectedBinaryName: BehaviorSubject #currentBinary$: StateCache - #availableBinaries: AvailableBinariesCache = {} - #availableBinaries$: StateCache + #binaryVersions: BinaryVersionsProvider #settings: Settings constructor (settings: Settings) { + const initialBinaryPath = settings.getSettings().flowCommand + this.#settings = settings + this.#binaryVersions = new BinaryVersionsProvider([initialBinaryPath]) + this.#selectedBinaryName = new BehaviorSubject(initialBinaryPath) + this.#currentBinary$ = new StateCache(async () => { + const name: string = this.#selectedBinaryName.getValue() + const versionCache = this.#binaryVersions.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) => { + this.currentBinary$.subscribe((binary) => { if (binary === null && this.#selectedBinaryName.getValue() !== 'flow') { void vscode.window.showErrorMessage(`The configured Flow CLI binary "${this.#selectedBinaryName.getValue()}" does not exist. Please check your settings.`) } @@ -72,119 +42,39 @@ 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.#binaryVersions.remove(prev) + + // Add the current binary to the cache + if (curr != null) this.#binaryVersions.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) - } - } - return bins + async setCurrentBinary (name: string): Promise { + await this.#settings.updateSettings({ flowCommand: name }) } get currentBinary$ (): Observable { return this.#currentBinary$.pipe(distinctUntilChanged(isEqual)) } - async getCurrentBinary (): Promise { - return await this.#currentBinary$.getValue() + async getBinaryVersions (): Promise { + return await this.#binaryVersions.getVersions() } - async setCurrentBinary (name: string): Promise { - await this.#settings.updateSettings({ flowCommand: name }) + get binaryVersions$ (): Observable { + return this.#binaryVersions.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.#binaryVersions.refresh() + } } diff --git a/extension/src/flow-cli/cli-selection-provider.ts b/extension/src/flow-cli/cli-selection-provider.ts index 53637926..16486d50 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 './binary-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' @@ -85,7 +86,7 @@ export class CliSelectionProvider { // Select the current binary if (currentBinary !== null) { - const currentBinaryItem = versionSelector.items.find(item => item instanceof AvailableBinaryItem && item.path === currentBinary.name) + const currentBinaryItem = versionSelector.items.find(item => item instanceof AvailableBinaryItem && item.path === currentBinary.path) if (currentBinaryItem != null) { versionSelector.selectedItems = [currentBinaryItem] } @@ -103,7 +104,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 +135,11 @@ class AvailableBinaryItem implements vscode.QuickPickItem { } get description (): string { - return `(${this.#binary.name})` + return `(${this.#binary.path})` } get path (): string { - return this.#binary.name + return this.#binary.path } } diff --git a/extension/src/server/language-server.ts b/extension/src/server/language-server.ts index 1973cd34..5ec3b245 100644 --- a/extension/src/server/language-server.ts +++ b/extension/src/server/language-server.ts @@ -75,7 +75,7 @@ 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())?.path if (binaryPath == null) { throw new Error('No flow binary found') } 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": {