diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e4c1427e..d35cf8701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Show progress when describing/listing dependencies on package load ([#2028](https://github.com/swiftlang/vscode-swift/pull/2028)) - Drop support for Swift 5.8 ([#1853](https://github.com/swiftlang/vscode-swift/pull/1853)) +- An official public API for the Swift extension that can be used by other Visual Studio Code extensions ([#2030](https://github.com/swiftlang/vscode-swift/pull/2030)) ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index becc6829f..38bca5983 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,6 +113,35 @@ Tests can also be launched from the terminal with the `--coverage` flag to displ npm run unit-test -- --coverage ``` +### Extension API Versioning + +The Swift extension exposes a public API to other extensions as defined in [`src/SwiftExtensionApi.ts`](src/SwiftExtensionApi.ts). This API follows [semantic versioning](https://semver.org/) and is separate from the extension's version number. + +When making changes to the public API you must update the `"api-version"` property in the `package.json`. The following sections describe when each version number should be updated: + +#### MAJOR version (breaking changes) +Increment when making changes that are incompatible with previous versions: +- Removing/renaming properties, methods, or interfaces +- Changing property types incompatibly +- Making optional properties required +- Removing enum values + +> [!NOTE] +> It is always preferable to deprecate old API and/or provide a compatibility layer before making a breaking change. We want to allow other extensions as much time as possible to update their API usage. In some instances this may not be feasible which will require working with extension authors to facilitate a smooth transition. + +#### MINOR version (new features) +Increment when adding new backward-compatible features: +- Adding new optional properties +- Adding new interfaces, types, or enum values +- Adding new methods +- Making required properties optional +- Marking properties as deprecated + +#### PATCH version (bug fixes) +Increment when making backward-compatible fixes: +- Documentation improvements +- Type annotation fixes + ## sourcekit-lsp The VS Code extension for Swift relies on Apple's [sourcekit-lsp](https://github.com/apple/sourcekit-lsp) for syntax highlighting, enumerating tests, and more. If you want to test the extension with a different version of the sourcekit-lsp you can add a `swift.sourcekit-lsp.serverPath` entry in your local `settings.json` to point to your sourcekit-lsp binary. The setting is no longer visible in the UI because it has been deprecated. diff --git a/README.md b/README.md index a205f5387..21c8e6a36 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,50 @@ This extension uses [SourceKit LSP](https://github.com/apple/sourcekit-lsp) for To propose new features, you can post on the [swift.org forums](https://forums.swift.org) in the [VS Code Swift Extension category](https://forums.swift.org/c/related-projects/vscode-swift-extension/). If you run into something that doesn't work the way you'd expect, you can [file an issue in the GitHub repository](https://github.com/swiftlang/vscode-swift/issues/new). +## Extension API + +The Swift extension exports a comprehensive API that can be used by other Visual Studio Code extensions. The API provides access to: + +- **Swift Toolchains**: The active Swift toolchain both globally and on a per-project basis. +- **Swift Projects**: A list of all known Swift projects in the active workspace. +- **SPM Packages**: Swift Package Manager targets, products, dependencies, and plugins. + +The API includes a version number that follows [semantic versioning](https://semver.org/) and is separate from the extension's version number. See [the contributing guide](CONTRIBUTING.md) for a more detailed explanation on when this version number is incremented. + +### Using the API + +To use the API in your extension you can download the [`src/SwiftExtensionApi.ts`](src/SwiftExtensionApi.ts) file from this repository to get proper TypeScript type definitions. It is recommended that you download the version from a tagged release as main is not guaranteed to remain stable. + +Example usage: + +```typescript +import { getSwiftExtensionApi, SwiftExtensionApi } from './SwiftExtensionApi'; + +// Get the Swift extension API +const api: SwiftExtensionApi = await getSwiftExtensionApi(); + +// Access workspace context +const workspaceContext = api.workspaceContext; +if (workspaceContext) { + // Get the toolchain version + const toolchain = workspaceContext.globalToolchain; + console.log(`Using Swift ${toolchain.swiftVersion}`); + + // Listen for folder changes + workspaceContext.onDidChangeFolders((event) => { + console.log(`Folder operation: ${event.operation}`); + }); + + // Access the in focus folder and its Swift package + const currentFolder = workspaceContext.currentFolder; + if (currentFolder) { + const swiftPackage = currentFolder.swiftPackage; + const targets = await swiftPackage.targets; + console.log(`Found ${targets.length} targets`); + } +} +``` + ## Contributing The Swift for Visual Studio Code extension is based on an extension originally created by the [Swift Server Working Group](https://www.swift.org/sswg/). It is now maintained as part of the [swiftlang organization](https://github.com/swiftlang/), and the original extension is deprecated. Contributions, including code, tests, and documentation, are welcome. For more details, refer to [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/package.json b/package.json index 550313e49..9cdd72d3d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "displayName": "Swift", "description": "Swift Language Support for Visual Studio Code.", "version": "2.16.0-dev", + "api-version": "0.1.0", "publisher": "swiftlang", "icon": "icon.png", "repository": { diff --git a/src/FolderContext.ts b/src/FolderContext.ts index 0881758ed..c687a391b 100644 --- a/src/FolderContext.ts +++ b/src/FolderContext.ts @@ -17,6 +17,7 @@ import * as vscode from "vscode"; import { BackgroundCompilation } from "./BackgroundCompilation"; import { LinuxMain } from "./LinuxMain"; import { PackageWatcher } from "./PackageWatcher"; +import { FolderContext as ExternalFolderContext } from "./SwiftExtensionApi"; import { SwiftPackage, Target, TargetType } from "./SwiftPackage"; import { TestExplorer } from "./TestExplorer/TestExplorer"; import { TestRunManager } from "./TestExplorer/TestRunManager"; @@ -30,7 +31,7 @@ import { SwiftToolchain } from "./toolchain/toolchain"; import { showToolchainError } from "./ui/ToolchainSelection"; import { isPathInsidePath } from "./utilities/filesystem"; -export class FolderContext implements vscode.Disposable { +export class FolderContext implements ExternalFolderContext, vscode.Disposable { public backgroundCompilation: BackgroundCompilation; public hasResolveErrors = false; public taskQueue: TaskQueue; diff --git a/src/SwiftExtensionApi.ts b/src/SwiftExtensionApi.ts new file mode 100644 index 000000000..ef1ef63c5 --- /dev/null +++ b/src/SwiftExtensionApi.ts @@ -0,0 +1,449 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2026 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import * as vscode from "vscode"; + +/** The identifier for the Swift extension as it appears in the VSCode Marketplace and OpenVSX. */ +export const SWIFT_EXTENSION_ID = "swiftlang.swift-vscode"; + +/** + * Retrieves the API for the Swift extension. + * + * The Swift extension will be activated if it isn't already. + */ +export async function getSwiftExtensionApi(): Promise { + const extension = vscode.extensions.getExtension(SWIFT_EXTENSION_ID); + if (extension === undefined) { + throw new Error(`Unable to find the Swift extension "${SWIFT_EXTENSION_ID}".`); + } + if (!extension.isActive) { + return await extension.activate(); + } + return extension.exports; +} + +/** + * External API as exposed by the extension. + * + * Use {@link getSwiftExtensionApi} to activate the Swift extension and retrieve its API. + */ +export interface SwiftExtensionApi { + /** + * The version number of the Swift Extension API. Follows the semantic versioning standard. + * + * This version number is separate from the version number of the Swift Extension itself. It will only be updated + * when changes are made to the API. + * + * This was added to the API in Swift Extension version 2.16.0. Older versions do not provide an API version number. + */ + readonly version?: Version; + + /** The {@link WorkspaceContext} if it is currently available. */ + readonly workspaceContext?: WorkspaceContext; +} + +/** Context containing the state of the Swift extension for the entire workspace. */ +export interface WorkspaceContext { + /** Array of available folders that contain Swift code. */ + readonly folders: FolderContext[]; + + /** + * The folder that currently has focus. + * + * Focus changes as files are opened and closed by the user. + */ + readonly currentFolder: FolderContext | null | undefined; + + /** + * The document URI that currently has focus. + * + * Focus changes as files are opened and closed by the user. + */ + readonly currentDocument: vscode.Uri | null; + + /** The global toolchain used as the default for this workspace. */ + readonly globalToolchain: SwiftToolchain; + + /** An event that fires when the list of folders has changed. */ + readonly onDidChangeFolders: vscode.Event; + + /** An event that fires when a Swift file has changed. */ + readonly onDidChangeSwiftFiles: vscode.Event; +} + +/** Context containing the state of the Swift extension for a specific folder in the workspace. */ +export interface FolderContext { + /** The name of the folder. */ + readonly name: string; + + /** The relative path of the folder within the workspace. */ + readonly relativePath: string; + + /** The URI of the folder. */ + readonly folder: vscode.Uri; + + /** The VS Code workspace folder that contains this folder. */ + readonly workspaceFolder: vscode.WorkspaceFolder; + + /** Whether this folder is the root folder of the workspace. */ + readonly isRootFolder: boolean; + + /** Reference to the workspace context that contains this folder. */ + readonly workspaceContext: WorkspaceContext; + + /** + * The toolchain used by this folder. + * + * Most of the time this will be identical to {@link WorkspaceContext.globalToolchain}. However, it can be + * different depending on extension settings and/or the use of a toolchain manager like swiftly. + */ + readonly toolchain: SwiftToolchain; + + /** The Swift package associated with this folder. */ + readonly swiftPackage: SwiftPackage; +} + +/** Represents a version number of the form "{major}.{minor}.{patch}". */ +export interface VersionInterface { + /** The major version number. */ + readonly major: number; + + /** The minor version number. */ + readonly minor: number; + + /** The patch version number. */ + readonly patch: number; +} + +/** Represents a version with additional methods for comparing with other versions. */ +export interface Version extends VersionInterface { + /** Whether or not this is a development version that has the suffix "-dev". */ + readonly dev: boolean; + + /** Convert this version number to a string of the form "{major}.{minor}.{patch}". */ + toString(): string; + + /** Whether or not this version is less than the provided version. */ + isLessThan(rhs: VersionInterface): boolean; + + /** Whether or not this version is greater than the provided version. */ + isGreaterThan(rhs: VersionInterface): boolean; + + /** Whether or not this version is less than or equal to the provided version. */ + isLessThanOrEqual(rhs: VersionInterface): boolean; + + /** Whether or not this version is greater than or equal to the provided version. */ + isGreaterThanOrEqual(rhs: VersionInterface): boolean; + + /** + * Compare this version with another version. The result is an integer: + * - **negative**: this version is less than the provided version. + * - **zero**: this version is equal to the provided version. + * - **positive**: this version is greater than the provided version. + * + * This function is primarily used by sorting algorithms. + * + * @param rhs The version to compare with. + * @returns An integer representing the result of the comparison. + */ + compare(rhs: VersionInterface): number; +} + +/** + * Different entities which are used to manage toolchain installations. Possible values are: + * - `xcrun`: An Xcode/CommandLineTools toolchain controlled via the `xcrun` and `xcode-select` utilities on macOS. + * - `swiftly`: A toolchain managed by `swiftly`. + * - `swiftenv`: A toolchain managed by `swiftenv`. + * - `unknown`: This toolchain was installed via a method unknown to the Swift extension. + */ +export type ToolchainManager = "xcrun" | "swiftly" | "swiftenv" | "unknown"; + +export interface SwiftToolchain { + /** The manager for this toolchain, if any. See {@link ToolchainManager} for more information. */ + readonly manager: ToolchainManager; + + /** The version number of this Swift toolchain. */ + readonly swiftVersion: Version; + + /** The SDK currently in use by this toolchain. */ + readonly sdk?: string; + + /** The user-specified SDK as configured by the `swift.sdk` setting. */ + readonly customSDK?: string; + + /** The default SDK for this toolchain. */ + readonly defaultSDK?: string; +} + +/** Workspace Folder Operation types. */ +export const enum FolderOperation { + /** Package folder has been added. */ + add = "add", + /** Package folder has been removed. */ + remove = "remove", + /** Workspace folder has gained focus via a file inside the folder becoming the actively edited file. */ + focus = "focus", + /** Workspace folder loses focus because another workspace folder gained it. */ + unfocus = "unfocus", + /** Package.swift has been updated. */ + packageUpdated = "packageUpdated", + /** Package.resolved has been updated. */ + resolvedUpdated = "resolvedUpdated", + /** .build/workspace-state.json has been updated. */ + workspaceStateUpdated = "workspaceStateUpdated", + /** .build/workspace-state.json has been updated. */ + packageViewUpdated = "packageViewUpdated", + /** Package plugins list has been updated. */ + pluginsUpdated = "pluginsUpdated", + /** The folder's swift toolchain version has been updated. */ + swiftVersionUpdated = "swiftVersionUpdated", +} + +/** Workspace Folder Event. */ +export interface FolderEvent { + /** The type of event that occurred. */ + readonly operation: FolderOperation; + + /** The {@link WorkspaceContext} where the event occurred. */ + readonly workspace: WorkspaceContext; + + /** + * The {@link FolderContext} that was affected. + * + * A null folder's significance depends on the operation. For example, a "focus" event with a null folder indicates that + * no folder currently has focus. Other events such as "unfocus" will never have a null folder. + */ + readonly folder: FolderContext | null; +} + +/** File Operation types. */ +export const enum FileOperation { + /** The file has been created. */ + created = "created", + /** The file has been changed. */ + changed = "changed", + /** The file was deleted. */ + deleted = "deleted", +} + +/** Swift File Event */ +export interface SwiftFileEvent { + /** The type of operation that occurred on the file. */ + readonly operation: FileOperation; + + /** The URI of the Swift file that was affected. */ + readonly uri: vscode.Uri; +} + +/** Swift Package Manager product information. */ +export interface Product { + /** The name of the product. */ + readonly name: string; + + /** The list of target names that make up this product. */ + readonly targets: string[]; +} + +/** Swift Package Manager target information. */ +export interface Target { + /** The name of the target. */ + readonly name: string; + + /** The C99-compatible name of the target. */ + readonly c99name: string; + + /** The relative path to the target directory. */ + readonly path: string; + + /** The list of source file paths within the target. */ + readonly sources: string[]; + + /** The type of target. */ + readonly type: + | "executable" + | "test" + | "library" + | "snippet" + | "plugin" + | "binary" + | "system-target"; +} + +/** Types of Swift Package Manager targets that can be filtered for. */ +export const enum TargetType { + executable = "executable", + library = "library", + test = "test", +} + +/** Swift Package Manager dependency */ +export interface Dependency { + /** The unique identifier of the dependency. */ + readonly identity: string; + + /** The type of dependency (optional). */ + readonly type?: string; + + /** The version requirement specification (optional). */ + readonly requirement?: object; + + /** The URL of the dependency repository (optional). */ + readonly url?: string; + + /** The local file system path of the dependency (optional). */ + readonly path?: string; + + /** The nested dependencies of this dependency. */ + readonly dependencies: Dependency[]; +} + +/** A Swift Package Manager dependency with resolved version information. */ +export interface ResolvedDependency extends Dependency { + /** The resolved version of the dependency. */ + readonly version: string; + + /** The type of the dependency. */ + readonly type: string; + + /** The file system path where the dependency is located. */ + readonly path: string; + + /** The location (URL or path) where the dependency was retrieved from. */ + readonly location: string; + + /** The Git revision hash of the dependency (optional). */ + readonly revision?: string; +} + +/** Swift Package.resolved file */ +export interface PackageResolved { + /** Hash of the Package.resolved file contents. */ + readonly fileHash: number; + + /** The list of pinned dependencies. */ + readonly pins: PackageResolvedPin[]; + + /** The version format of the Package.resolved file. */ + readonly version: number; +} + +/** A pinned dependency entry in a Swift Package.resolved file. */ +export interface PackageResolvedPin { + /** The unique identifier of the pinned dependency. */ + readonly identity: string; + + /** The location (URL or path) of the dependency. */ + readonly location: string; + + /** The state information for this pinned dependency. */ + readonly state: PackageResolvedPinState; +} + +/** The state information for a pinned dependency in Package.resolved. */ +export interface PackageResolvedPinState { + /** The Git branch name, if the dependency is pinned to a branch. */ + readonly branch: string | null; + + /** The Git revision hash of the pinned dependency. */ + readonly revision: string; + + /** The semantic version, if the dependency is pinned to a version tag. */ + readonly version: string | null; +} + +/** A Swift Package Manager plugin that can be executed. */ +export interface PackagePlugin { + /** The command used to execute the plugin. */ + readonly command: string; + + /** The display name of the plugin. */ + readonly name: string; + + /** The name of the package that provides this plugin. */ + readonly package: string; +} + +/** + * Represents a Swift Package Manager package. + * + * Provides access to package information, dependencies, targets, and products. + */ +export interface SwiftPackage { + /** A promise that resolves to true if a Package.swift file was found. */ + readonly foundPackage: Promise; + + /** A promise that resolves to the name of this package. */ + readonly name: Promise; + + /** The URI of the folder containing this package. */ + readonly folder: vscode.Uri; + + /** Array of available package plugins. */ + readonly plugins: PackagePlugin[]; + + /** The contents of the Package.resolved if one exists. */ + readonly resolved: PackageResolved | undefined; + + /** A promise that resolves to true if the package is valid. */ + readonly isValid: Promise; + + /** A promise that resolves with the error that occurred during package loading, if any. */ + readonly error: Promise; + + /** A promise that resolves to the list of package dependencies. */ + readonly dependencies: Promise; + + /** A promise that resolves to the array of targets in this Swift package. */ + readonly targets: Promise; + + /** + * Array of targets in this Swift package. + * + * NOTE: The targets may not be loaded yet. It is preferable to use the {@link targets} property + * which returns a promise that resolves to the targets when they're guaranteed to be available. + **/ + readonly currentTargets: Target[]; + + /** A promise that resolves to the list of executable products. */ + readonly executableProducts: Promise; + + /** A promise that resolves to the list of library products. */ + readonly libraryProducts: Promise; + + /** A promise that resolves to the list of resolved root dependencies. */ + readonly rootDependencies: Promise; + + /** + * Gets targets filtered by type. + * + * @param type The type of targets to retrieve (optional). + * @returns A promise that resolves to the filtered list of targets. + */ + getTargets(type?: TargetType): Promise; + + /** + * Gets the target that contains the specified file. + * + * @param file The file path to search for. + * @returns A promise that resolves to the target containing the file, or undefined if not found. + */ + getTarget(file: string): Promise; + + /** + * Gets the dependencies of a specified dependency. + * + * @param dependency The parent dependency to get dependencies of. + * @returns The list of dependencies. + */ + childDependencies(dependency: Dependency): ResolvedDependency[]; +} diff --git a/src/SwiftPackage.ts b/src/SwiftPackage.ts index d8de7f67f..b9b28842b 100644 --- a/src/SwiftPackage.ts +++ b/src/SwiftPackage.ts @@ -16,6 +16,18 @@ import * as path from "path"; import * as vscode from "vscode"; import { FolderContext } from "./FolderContext"; +import { + Dependency, + PackageResolved as ExternalPackageResolved, + Product as ExternalProduct, + SwiftPackage as ExternalSwiftPackage, + PackagePlugin, + PackageResolvedPin, + PackageResolvedPinState, + ResolvedDependency, + Target, + TargetType, +} from "./SwiftExtensionApi"; import { describePackage } from "./commands/dependencies/describe"; import { showPackageDependencies } from "./commands/dependencies/show"; import { SwiftLogger } from "./logging/SwiftLogger"; @@ -25,6 +37,22 @@ import { isPathInsidePath } from "./utilities/filesystem"; import { lineBreakRegex } from "./utilities/tasks"; import { execSwift, getErrorDescription, hashString, unwrapPromise } from "./utilities/utilities"; +// Re-export some types from the external API for convenience. +export { + Dependency, + PackagePlugin, + PackageResolvedPin, + PackageResolvedPinState, + ResolvedDependency, + Target, + TargetType, +}; + +// Need to re-export the Product interface with internal types +export interface Product extends ExternalProduct { + readonly type: { executable?: null; library?: string[] }; +} + /** Swift Package Manager contents */ export interface PackageContents { name: string; @@ -33,46 +61,12 @@ export interface PackageContents { targets: Target[]; } -/** Swift Package Manager product */ -export interface Product { - name: string; - targets: string[]; - type: { executable?: null; library?: string[] }; -} - export function isAutomatic(product: Product): boolean { return (product.type.library || []).includes("automatic"); } -/** Swift Package Manager target */ -export interface Target { - name: string; - c99name: string; - path: string; - sources: string[]; - type: "executable" | "test" | "library" | "snippet" | "plugin" | "binary" | "system-target"; -} - -/** Swift Package Manager dependency */ -export interface Dependency { - identity: string; - type?: string; - requirement?: object; - url?: string; - path?: string; - dependencies: Dependency[]; -} - -export interface ResolvedDependency extends Dependency { - version: string; - type: string; - path: string; - location: string; - revision?: string; -} - /** Swift Package.resolved file */ -export class PackageResolved { +export class PackageResolved implements ExternalPackageResolved { readonly fileHash: number; readonly pins: PackageResolvedPin[]; readonly version: number; @@ -84,19 +78,18 @@ export class PackageResolved { if (this.version === 1) { const v1Json = json as PackageResolvedFileV1; - this.pins = v1Json.object.pins.map( - pin => - new PackageResolvedPin( - this.identity(pin.repositoryURL), - pin.repositoryURL, - pin.state - ) - ); + this.pins = v1Json.object.pins.map(pin => ({ + identity: this.identity(pin.repositoryURL), + location: pin.repositoryURL, + state: pin.state, + })); } else if (this.version === 2 || this.version === 3) { const v2Json = json as PackageResolvedFileV2; - this.pins = v2Json.pins.map( - pin => new PackageResolvedPin(pin.identity, pin.location, pin.state) - ); + this.pins = v2Json.pins.map(pin => ({ + identity: pin.identity, + location: pin.location, + state: pin.state, + })); } else { throw Error("Unsupported Package.resolved version"); } @@ -104,28 +97,12 @@ export class PackageResolved { // Copied from `PackageIdentityParser.computeDefaultName` in // https://github.com/apple/swift-package-manager/blob/main/Sources/PackageModel/PackageIdentity.swift - identity(url: string): string { + private identity(url: string): string { const file = path.basename(url, ".git"); return file.toLowerCase(); } } -/** Swift Package.resolved file */ -export class PackageResolvedPin { - constructor( - readonly identity: string, - readonly location: string, - readonly state: PackageResolvedPinState - ) {} -} - -/** Swift Package.resolved file */ -export interface PackageResolvedPinState { - branch: string | null; - revision: string; - version: string | null; -} - interface PackageResolvedFileV1 { object: { pins: PackageResolvedPinFileV1[] }; version: number; @@ -169,12 +146,6 @@ export interface WorkspaceStateDependency { subpath: string; } -export interface PackagePlugin { - command: string; - name: string; - package: string; -} - /** Swift Package State * * Can be package contents, error found when loading package or undefined meaning @@ -199,7 +170,7 @@ function isError(state: SwiftPackageState): state is Error { /** * Class holding Swift Package Manager Package */ -export class SwiftPackage implements vscode.Disposable { +export class SwiftPackage implements ExternalSwiftPackage, vscode.Disposable { public plugins: PackagePlugin[] = []; private _contents: SwiftPackageState | undefined; private contentsPromise: Promise; @@ -552,20 +523,10 @@ export class SwiftPackage implements vscode.Disposable { ); } - /** - * Array of targets in Swift Package. The targets may not be loaded yet. - * It is preferable to use the `targets` property that returns a promise that - * returns the targets when they're guarenteed to be resolved. - **/ get currentTargets(): Target[] { return (this._contents as unknown as { targets: Target[] })?.targets ?? []; } - /** - * Return array of targets of a certain type - * @param type Type of target - * @returns Array of targets - */ async getTargets(type?: TargetType): Promise { if (type === undefined) { return this.targets; @@ -574,9 +535,6 @@ export class SwiftPackage implements vscode.Disposable { } } - /** - * Get target for file - */ async getTarget(file: string): Promise { const filePath = path.relative(this.folder.fsPath, file); return this.targets.then(targets => @@ -598,9 +556,3 @@ export class SwiftPackage implements vscode.Disposable { this.tokenSource.dispose(); } } - -export enum TargetType { - executable = "executable", - library = "library", - test = "test", -} diff --git a/src/WorkspaceContext.ts b/src/WorkspaceContext.ts index 2bf21f09c..07f452b4c 100644 --- a/src/WorkspaceContext.ts +++ b/src/WorkspaceContext.ts @@ -17,6 +17,13 @@ import * as vscode from "vscode"; import { ContextKeys } from "./ContextKeyManager"; import { DiagnosticsManager } from "./DiagnosticsManager"; import { FolderContext } from "./FolderContext"; +import { + FolderEvent as ExternalFolderEvent, + WorkspaceContext as ExternalWorkspaceContext, + FileOperation, + FolderOperation, + SwiftFileEvent, +} from "./SwiftExtensionApi"; import { TestKind } from "./TestExplorer/TestKind"; import { TestRunManager } from "./TestExplorer/TestRunManager"; import configuration from "./configuration"; @@ -39,11 +46,20 @@ import { isExcluded, isPathInsidePath } from "./utilities/filesystem"; import { swiftLibraryPathKey } from "./utilities/utilities"; import { isValidWorkspaceFolder, searchForPackages } from "./utilities/workspace"; +// Re-export some types from the external API for convenience. +export { FolderOperation, SwiftFileEvent }; + +// Need to re-export FolderEvent with internal typings. +export interface FolderEvent extends ExternalFolderEvent { + workspace: WorkspaceContext; + folder: FolderContext | null; +} + /** * Context for whole workspace. Holds array of contexts for each workspace folder * and the ExtensionContext */ -export class WorkspaceContext implements vscode.Disposable { +export class WorkspaceContext implements ExternalWorkspaceContext, vscode.Disposable { public folders: FolderContext[] = []; public currentFolder: FolderContext | null | undefined; public currentDocument: vscode.Uri | null; @@ -656,50 +672,3 @@ interface BuildEvent { launchConfig: vscode.DebugConfiguration; options: vscode.DebugSessionOptions; } - -/** Workspace Folder Operation types */ -export enum FolderOperation { - // Package folder has been added - add = "add", - // Package folder has been removed - remove = "remove", - // Workspace folder has gained focus via a file inside the folder becoming the actively edited file - focus = "focus", - // Workspace folder loses focus because another workspace folder gained it - unfocus = "unfocus", - // Package.swift has been updated - packageUpdated = "packageUpdated", - // Package.resolved has been updated - resolvedUpdated = "resolvedUpdated", - // .build/workspace-state.json has been updated - workspaceStateUpdated = "workspaceStateUpdated", - // .build/workspace-state.json has been updated - packageViewUpdated = "packageViewUpdated", - // Package plugins list has been updated - pluginsUpdated = "pluginsUpdated", - // The folder's swift toolchain version has been updated - swiftVersionUpdated = "swiftVersionUpdated", -} - -/** Workspace Folder Event */ -export interface FolderEvent { - operation: FolderOperation; - workspace: WorkspaceContext; - folder: FolderContext | null; -} - -/** File Operation types */ -export enum FileOperation { - // File has been created - created = "created", - // File has been changed - changed = "changed", - // File was deleted - deleted = "deleted", -} - -/** Swift File Event */ -export interface SwiftFileEvent { - operation: FileOperation; - uri: vscode.Uri; -} diff --git a/src/extension.ts b/src/extension.ts index faaa7042c..5282fca25 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -14,10 +14,12 @@ // Use source-map-support to get better stack traces import "source-map-support/register"; +import * as fs from "fs/promises"; import * as vscode from "vscode"; import { ContextKeyManager, ContextKeys } from "./ContextKeyManager"; import { FolderContext } from "./FolderContext"; +import { SwiftExtensionApi } from "./SwiftExtensionApi"; import { TestExplorer } from "./TestExplorer/TestExplorer"; import { FolderEvent, FolderOperation, WorkspaceContext } from "./WorkspaceContext"; import * as commands from "./commands"; @@ -44,21 +46,19 @@ import { checkAndWarnAboutWindowsSymlinks } from "./ui/win32"; import { getErrorDescription } from "./utilities/utilities"; import { Version } from "./utilities/version"; -/** - * External API as exposed by the extension. Can be queried by other extensions - * or by the integration test runner for VS Code extensions. - */ -export interface Api { +export interface InternalSwiftExtensionApi extends SwiftExtensionApi { workspaceContext?: WorkspaceContext; logger: SwiftLogger; - activate(): Promise; + activate(): Promise; deactivate(): Promise; } /** * Activate the extension. This is the main entry point. */ -export async function activate(context: vscode.ExtensionContext): Promise { +export async function activate( + context: vscode.ExtensionContext +): Promise { const activationStartTime = Date.now(); try { const logSetupStartTime = Date.now(); @@ -175,6 +175,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { const workspaceFoldersElapsed = Date.now() - workspaceFoldersStartTime; const finalStepsStartTime = Date.now(); + const apiVersion = await getApiVersionNumber(context); // Mark the extension as activated. contextKeys.isActivated = true; const finalStepsElapsed = Date.now() - finalStepsStartTime; @@ -187,6 +188,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { return { workspaceContext, logger, + version: apiVersion, activate: () => activate(context), deactivate: async () => { await workspaceContext.stop(); @@ -233,6 +235,31 @@ function configureLogging(context: vscode.ExtensionContext) { return logger; } +async function getApiVersionNumber(context: vscode.ExtensionContext): Promise { + try { + const packageJsonPath = context.asAbsolutePath("package.json"); + const packageJsonRaw = await fs.readFile(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(packageJsonRaw); + const apiVersionRaw = packageJson["api-version"]; + if (!apiVersionRaw || typeof apiVersionRaw !== "string") { + throw Error( + `The "api-version" property in the package.json is missing or invalid: ${JSON.stringify(apiVersionRaw)}` + ); + } + const apiVersion = Version.fromString(apiVersionRaw); + if (!apiVersion) { + throw Error( + `Unable to parse the "api-version" string from the package.json: "${apiVersionRaw}"` + ); + } + return apiVersion; + } catch (error) { + throw Error("Failed to load the Swift extension API version number from the package.json", { + cause: error, + }); + } +} + function handleFolderEvent(logger: SwiftLogger): (event: FolderEvent) => Promise { // function called when a folder is added. I broke this out so we can trigger it // without having to await for it. @@ -321,7 +348,8 @@ async function createActiveToolchain( } async function deactivate(context: vscode.ExtensionContext): Promise { - const workspaceContext = (context.extension.exports as Api).workspaceContext; + const api: InternalSwiftExtensionApi = context.extension.exports; + const workspaceContext = api.workspaceContext; if (workspaceContext) { workspaceContext.contextKeys.isActivated = false; } diff --git a/src/toolchain/toolchain.ts b/src/toolchain/toolchain.ts index 8806a21b7..36cc05d07 100644 --- a/src/toolchain/toolchain.ts +++ b/src/toolchain/toolchain.ts @@ -17,6 +17,7 @@ import * as path from "path"; import * as plist from "plist"; import * as vscode from "vscode"; +import { SwiftToolchain as ExternalSwiftToolchain, ToolchainManager } from "../SwiftExtensionApi"; import configuration from "../configuration"; import { SwiftLogger } from "../logging/SwiftLogger"; import { expandFilePathTilde, fileExists, pathExists } from "../utilities/filesystem"; @@ -101,18 +102,13 @@ export function getDarwinTargetTriple(target: DarwinCompatibleTarget): string | } } -/** - * Different entities which are used to manage toolchain installations. Possible values are: - * - `xcrun`: An Xcode/CommandLineTools toolchain controlled via the `xcrun` and `xcode-select` utilities on macOS. - * - `swiftly`: A toolchain managed by `swiftly`. - * - `swiftenv`: A toolchain managed by `swiftenv`. - * - `unknown`: This toolchain was installed via a method unknown to the extension. - */ -export type ToolchainManager = "xcrun" | "swiftly" | "swiftenv" | "unknown"; - -export class SwiftToolchain { +export class SwiftToolchain implements ExternalSwiftToolchain { public swiftVersionString: string; + public get sdk(): string | undefined { + return this.customSDK ?? this.defaultSDK; + } + constructor( public manager: ToolchainManager, public swiftFolderPath: string, // folder swift executable in $PATH was found in @@ -454,11 +450,10 @@ export class SwiftToolchain { } private basePlatformDeveloperPath(): string | undefined { - const sdk = this.customSDK ?? this.defaultSDK; - if (!sdk) { + if (!this.sdk) { return undefined; } - return path.resolve(sdk, "../../"); + return path.resolve(this.sdk, "../../"); } /** @@ -499,6 +494,9 @@ export class SwiftToolchain { if (this.targetInfo.target?.triple) { str += `\nDefault Target: ${this.targetInfo.target?.triple}`; } + if (this.sdk) { + str += `\nSelected SDK: ${this.sdk}`; + } if (this.defaultSDK) { str += `\nDefault SDK: ${this.defaultSDK}`; } diff --git a/src/utilities/version.ts b/src/utilities/version.ts index c1ed2de6d..9644e6121 100644 --- a/src/utilities/version.ts +++ b/src/utilities/version.ts @@ -11,14 +11,9 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// +import { Version as ExternalVersion, VersionInterface } from "../SwiftExtensionApi"; -export interface VersionInterface { - major: number; - minor: number; - patch: number; -} - -export class Version implements VersionInterface { +export class Version implements ExternalVersion { constructor( readonly major: number, readonly minor: number, diff --git a/test/integration-tests/extension.test.ts b/test/integration-tests/extension.test.ts index c64e478fd..42f4acc6a 100644 --- a/test/integration-tests/extension.test.ts +++ b/test/integration-tests/extension.test.ts @@ -15,6 +15,7 @@ import * as assert from "assert"; import { expect } from "chai"; import * as vscode from "vscode"; +import { getSwiftExtensionApi } from "@src/SwiftExtensionApi"; import { WorkspaceContext } from "@src/WorkspaceContext"; import { SwiftExecution } from "@src/tasks/SwiftExecution"; import { getBuildAllTask } from "@src/tasks/SwiftTaskProvider"; @@ -30,9 +31,19 @@ suite("Extension Test Suite", function () { }, }); + suite("Extension API", function () { + test("can use getSwiftExtensionApi() to retrieve the Swift extension's API", async () => { + // Chai's expect() tries to introspect the API on failure which causes VS Code to complain and give a + // useless error message about using proposed API. Check it ourselves and output a reasonable error. + const swiftExtensionApi = await getSwiftExtensionApi(); + if (!swiftExtensionApi || typeof swiftExtensionApi !== "object") { + assert.fail("The Swift extension did not return an API."); + } + }); + }); + suite("Workspace", function () { - /** Verify tasks.json is being loaded */ - test("Tasks.json", async () => { + test("tasks.json is loaded correctly", async () => { const folder = findWorkspaceFolder("defaultPackage", workspaceContext); assert.ok(folder); const buildAllTask = await getBuildAllTask(folder); diff --git a/test/integration-tests/utilities/testutilities.ts b/test/integration-tests/utilities/testutilities.ts index 5fcb38595..d46e08c78 100644 --- a/test/integration-tests/utilities/testutilities.ts +++ b/test/integration-tests/utilities/testutilities.ts @@ -21,7 +21,7 @@ import { FolderContext } from "@src/FolderContext"; import { FolderOperation, WorkspaceContext } from "@src/WorkspaceContext"; import configuration from "@src/configuration"; import { getLLDBLibPath } from "@src/debugger/lldb"; -import { Api } from "@src/extension"; +import { InternalSwiftExtensionApi } from "@src/extension"; import { SwiftLogger } from "@src/logging/SwiftLogger"; import { buildAllTaskName, resetBuildAllTaskCache } from "@src/tasks/SwiftTaskProvider"; import { Extension } from "@src/utilities/extensions"; @@ -103,8 +103,8 @@ function configureLogDumpOnTimeout(timeout: number, logger: ExtensionActivationL } const extensionBootstrapper = (() => { - let activator: (() => Promise) | undefined = undefined; - let activatedAPI: Api | undefined = undefined; + let activator: (() => Promise) | undefined = undefined; + let activatedAPI: InternalSwiftExtensionApi | undefined = undefined; let lastTestName: string | undefined = undefined; const testTitle = (currentTest: Mocha.Test) => currentTest.titlePath().join(" → "); let activationLogger: ExtensionActivationLogger; @@ -308,7 +308,7 @@ const extensionBootstrapper = (() => { } const extensionId = "swiftlang.swift-vscode"; - const ext = vscode.extensions.getExtension(extensionId); + const ext = vscode.extensions.getExtension(extensionId); if (!ext) { throw new Error(`Unable to find extension "${extensionId}"`); } @@ -323,7 +323,7 @@ const extensionBootstrapper = (() => { "Performing the one and only extension activation for this test run." ); for (const depId of [Extension.CODELLDB, Extension.LLDBDAP]) { - const dep = vscode.extensions.getExtension(depId); + const dep = vscode.extensions.getExtension(depId); if (!dep) { throw new Error(`Unable to find extension "${depId}"`); }