From 1d918d2675ed27d1ee3a4eba903633e029eef0e5 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Tue, 2 Sep 2025 10:25:18 -0400 Subject: [PATCH 1/4] Support "swift.play" CodeLens - Advertise to the LSP we support "swift.play" - Add a "swift.play" command, hiding it from the command palette - Fix order of env variables passed to task so variables like DYLD_LIBRARY_PATH are not overwritten accidentally Issue: #1782 --- package.json | 10 ++++ src/commands.ts | 6 +++ src/commands/runPlayground.ts | 48 +++++++++++++++++++ .../LanguageClientConfiguration.ts | 4 ++ src/tasks/SwiftTaskProvider.ts | 12 +++-- 5 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 src/commands/runPlayground.ts diff --git a/package.json b/package.json index 55e4a6c79..e1c1fce9c 100644 --- a/package.json +++ b/package.json @@ -289,6 +289,12 @@ "category": "Swift", "icon": "$(play)" }, + { + "command": "swift.play", + "title": "Run Swift playground", + "category": "Swift", + "icon": "$(play)" + }, { "command": "swift.debug", "title": "Debug Swift executable", @@ -1372,6 +1378,10 @@ { "command": "swift.openEducationalNote", "when": "false" + }, + { + "command": "swift.play", + "when": "false" } ], "editor/context": [ diff --git a/src/commands.ts b/src/commands.ts index 581d635a0..0f5a5d22f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -42,6 +42,7 @@ import { reindexProject } from "./commands/reindexProject"; import { resetPackage } from "./commands/resetPackage"; import restartLSPServer from "./commands/restartLSPServer"; import { runAllTests } from "./commands/runAllTests"; +import { runPlayground } from "./commands/runPlayground"; import { runPluginTask } from "./commands/runPluginTask"; import { runSwiftScript } from "./commands/runSwiftScript"; import { runTask } from "./commands/runTask"; @@ -88,6 +89,7 @@ export function registerToolchainCommands( export enum Commands { RUN = "swift.run", DEBUG = "swift.debug", + PLAY = "swift.play", CLEAN_BUILD = "swift.cleanBuild", RESOLVE_DEPENDENCIES = "swift.resolveDependencies", SHOW_FLAT_DEPENDENCIES_LIST = "swift.flatDependenciesList", @@ -146,6 +148,10 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] { Commands.DEBUG, async target => await debugBuild(ctx, ...unwrapTreeItem(target)) ), + vscode.commands.registerCommand( + Commands.PLAY, + async target => await runPlayground(ctx, target) + ), vscode.commands.registerCommand(Commands.CLEAN_BUILD, async () => await cleanBuild(ctx)), vscode.commands.registerCommand( Commands.RUN_TESTS_MULTIPLE_TIMES, diff --git a/src/commands/runPlayground.ts b/src/commands/runPlayground.ts new file mode 100644 index 000000000..459ec3372 --- /dev/null +++ b/src/commands/runPlayground.ts @@ -0,0 +1,48 @@ +import * as vscode from "vscode"; +import { Location, Range } from "vscode-languageclient"; + +import { WorkspaceContext } from "../WorkspaceContext"; +import { createSwiftTask } from "../tasks/SwiftTaskProvider"; +import { packageName } from "../utilities/tasks"; + +export interface PlaygroundItem { + id: string; + label?: string; +} + +export interface DocumentPlaygroundItem extends PlaygroundItem { + id: string; + label?: string; + range: Range; +} + +export interface WorkspacePlaygroundItem extends PlaygroundItem { + id: string; + label?: string; + location: Location; +} + +/** + * Executes a {@link vscode.Task task} to run swift playground. + */ +export async function runPlayground(ctx: WorkspaceContext, item?: PlaygroundItem) { + const folderContext = ctx.currentFolder; + if (!folderContext || !item) { + return false; + } + const id = item.label ?? item.id; + const task = createSwiftTask( + ["play", id], + `Play "${id}"`, + { + cwd: folderContext.folder, + scope: folderContext.workspaceFolder, + packageName: packageName(folderContext), + presentationOptions: { reveal: vscode.TaskRevealKind.Always }, + }, + folderContext.toolchain + ); + + await vscode.tasks.executeTask(task); + return true; +} diff --git a/src/sourcekit-lsp/LanguageClientConfiguration.ts b/src/sourcekit-lsp/LanguageClientConfiguration.ts index 7a08b060b..fcc570140 100644 --- a/src/sourcekit-lsp/LanguageClientConfiguration.ts +++ b/src/sourcekit-lsp/LanguageClientConfiguration.ts @@ -36,6 +36,7 @@ function initializationOptions(swiftVersion: Version): any { supportedCommands: { "swift.run": "swift.run", "swift.debug": "swift.debug", + "swift.play": "swift.play", }, }, }; @@ -254,6 +255,9 @@ export function lspClientOptions( case "swift.debug": codelens.command.title = `$(debug)\u00A0${codelens.command.title}`; break; + case "swift.play": + codelens.command.title = `$(play)\u00A0${codelens.command.title}`; + break; } return codelens; }); diff --git a/src/tasks/SwiftTaskProvider.ts b/src/tasks/SwiftTaskProvider.ts index 140692bd4..c0bc32cb0 100644 --- a/src/tasks/SwiftTaskProvider.ts +++ b/src/tasks/SwiftTaskProvider.ts @@ -312,7 +312,11 @@ export function createSwiftTask( } else { cwd = config.cwd.fsPath; }*/ - const env = { ...configuration.swiftEnvironmentVariables, ...swiftRuntimeEnv(), ...cmdEnv }; + const env = { + ...swiftRuntimeEnv(), // From process.env first + ...configuration.swiftEnvironmentVariables, // Then swiftEnvironmentVariables settings + ...cmdEnv, // Task configuration takes highest precedence + }; const presentation = config?.presentationOptions ?? {}; if (config?.packageName) { name += ` (${config?.packageName})`; @@ -469,9 +473,9 @@ export class SwiftTaskProvider implements vscode.TaskProvider { const env = platform?.env ?? task.definition.env; const fullCwd = resolveTaskCwd(task, platform?.cwd ?? task.definition.cwd); const fullEnv = { - ...configuration.swiftEnvironmentVariables, - ...swiftRuntimeEnv(), - ...env, + ...swiftRuntimeEnv(), // From process.env first + ...configuration.swiftEnvironmentVariables, // Then swiftEnvironmentVariables settings + ...env, // Task configuration takes highest precedence }; const presentation = task.definition.presentation ?? task.presentationOptions ?? {}; From f524bb13f322996d27eb178e40373fd8907b33f4 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Thu, 6 Nov 2025 08:04:56 -0500 Subject: [PATCH 2/4] Add copyright header --- src/commands/runPlayground.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/commands/runPlayground.ts b/src/commands/runPlayground.ts index 459ec3372..d26bd7dce 100644 --- a/src/commands/runPlayground.ts +++ b/src/commands/runPlayground.ts @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 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"; import { Location, Range } from "vscode-languageclient"; From 19163332b75df7fe77d09fab373fbfce74be7ccf Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Wed, 12 Nov 2025 12:06:13 -0500 Subject: [PATCH 3/4] Add tests --- src/commands/runPlayground.ts | 4 - .../commands/runPlayground.test.ts | 96 +++++++++++++++++++ .../LanguageClientManager.test.ts | 16 ++++ 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 test/integration-tests/commands/runPlayground.test.ts diff --git a/src/commands/runPlayground.ts b/src/commands/runPlayground.ts index d26bd7dce..140a85bd0 100644 --- a/src/commands/runPlayground.ts +++ b/src/commands/runPlayground.ts @@ -24,14 +24,10 @@ export interface PlaygroundItem { } export interface DocumentPlaygroundItem extends PlaygroundItem { - id: string; - label?: string; range: Range; } export interface WorkspacePlaygroundItem extends PlaygroundItem { - id: string; - label?: string; location: Location; } diff --git a/test/integration-tests/commands/runPlayground.test.ts b/test/integration-tests/commands/runPlayground.test.ts new file mode 100644 index 000000000..7dbba0d74 --- /dev/null +++ b/test/integration-tests/commands/runPlayground.test.ts @@ -0,0 +1,96 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 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 { expect } from "chai"; +import * as vscode from "vscode"; + +import { FolderContext } from "@src/FolderContext"; +import { WorkspaceContext } from "@src/WorkspaceContext"; +import { Commands } from "@src/commands"; +import { SwiftTask } from "@src/tasks/SwiftTaskProvider"; + +import { mockGlobalObject } from "../../MockUtils"; +import { activateExtensionForSuite, folderInRootWorkspace } from "../utilities/testutilities"; + +suite("Run Playground Command", function () { + let folderContext: FolderContext; + let workspaceContext: WorkspaceContext; + + const mockTasks = mockGlobalObject(vscode, "tasks"); + + activateExtensionForSuite({ + async setup(ctx) { + workspaceContext = ctx; + folderContext = await folderInRootWorkspace("defaultPackage", workspaceContext); + }, + }); + + setup(async () => { + await workspaceContext.focusFolder(folderContext); + }); + + test("No playground item provided", async () => { + expect(await vscode.commands.executeCommand(Commands.PLAY), undefined).to.be.false; + expect(mockTasks.executeTask).to.not.have.been.called; + }); + + test("No folder focussed", async () => { + await workspaceContext.focusFolder(null); + expect( + await vscode.commands.executeCommand(Commands.PLAY, { + id: "PackageLib/PackageLib.swift:3", + }) + ).to.be.false; + expect(mockTasks.executeTask).to.not.have.been.called; + }); + + test('Runs "swift play" on "id"', async () => { + expect( + await vscode.commands.executeCommand(Commands.PLAY, { + id: "PackageLib/PackageLib.swift:3", + }) + ).to.be.true; + expect(mockTasks.executeTask).to.have.been.calledOnce; + + const task = mockTasks.executeTask.args[0][0] as SwiftTask; + expect(task.execution.args).to.deep.equal(["play", "PackageLib/PackageLib.swift:3"]); + expect(task.execution.options.cwd).to.equal(folderContext.folder.fsPath); + }); + + test('Runs "swift play" on "id" with space in path', async () => { + expect( + await vscode.commands.executeCommand(Commands.PLAY, { + id: "PackageLib/Package Lib.swift:3", + }) + ).to.be.true; + expect(mockTasks.executeTask).to.have.been.calledOnce; + + const task = mockTasks.executeTask.args[0][0] as SwiftTask; + expect(task.execution.args).to.deep.equal(["play", "PackageLib/Package Lib.swift:3"]); + expect(task.execution.options.cwd).to.equal(folderContext.folder.fsPath); + }); + + test('Runs "swift play" on "label"', async () => { + expect( + await vscode.commands.executeCommand(Commands.PLAY, { + id: "PackageLib/PackageLib.swift:3", + label: "bar", + }) + ).to.be.true; + expect(mockTasks.executeTask).to.have.been.calledOnce; + + const task = mockTasks.executeTask.args[0][0] as SwiftTask; + expect(task.execution.args).to.deep.equal(["play", "bar"]); + expect(task.execution.options.cwd).to.equal(folderContext.folder.fsPath); + }); +}); diff --git a/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts b/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts index 0fe5b4b5d..b3b2b9753 100644 --- a/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts +++ b/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts @@ -547,6 +547,14 @@ suite("LanguageClientManager Suite", () => { }, isResolved: true, }, + { + range: new vscode.Range(0, 0, 0, 0), + command: { + title: 'Play "bar"', + command: "swift.play", + }, + isResolved: true, + }, { range: new vscode.Range(0, 0, 0, 0), command: { @@ -588,6 +596,14 @@ suite("LanguageClientManager Suite", () => { }, isResolved: true, }, + { + range: new vscode.Range(0, 0, 0, 0), + command: { + title: '$(play)\u00A0Play "bar"', + command: "swift.play", + }, + isResolved: true, + }, { range: new vscode.Range(0, 0, 0, 0), command: { From 124e39b29574171cd7fed0cf022336b2dfbe2a6c Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Wed, 12 Nov 2025 14:19:49 -0500 Subject: [PATCH 4/4] Restructure to make more testable --- src/commands.ts | 11 +- src/commands/runPlayground.ts | 14 ++- .../commands/runPlayground.test.ts | 109 ++++++++++-------- 3 files changed, 77 insertions(+), 57 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 0f5a5d22f..3c9dd2898 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -148,10 +148,13 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] { Commands.DEBUG, async target => await debugBuild(ctx, ...unwrapTreeItem(target)) ), - vscode.commands.registerCommand( - Commands.PLAY, - async target => await runPlayground(ctx, target) - ), + vscode.commands.registerCommand(Commands.PLAY, async target => { + const folder = ctx.currentFolder; + if (!folder) { + return false; + } + return await runPlayground(folder, ctx.tasks, target); + }), vscode.commands.registerCommand(Commands.CLEAN_BUILD, async () => await cleanBuild(ctx)), vscode.commands.registerCommand( Commands.RUN_TESTS_MULTIPLE_TIMES, diff --git a/src/commands/runPlayground.ts b/src/commands/runPlayground.ts index 140a85bd0..67cf2af4a 100644 --- a/src/commands/runPlayground.ts +++ b/src/commands/runPlayground.ts @@ -14,8 +14,9 @@ import * as vscode from "vscode"; import { Location, Range } from "vscode-languageclient"; -import { WorkspaceContext } from "../WorkspaceContext"; +import { FolderContext } from "../FolderContext"; import { createSwiftTask } from "../tasks/SwiftTaskProvider"; +import { TaskManager } from "../tasks/TaskManager"; import { packageName } from "../utilities/tasks"; export interface PlaygroundItem { @@ -34,9 +35,12 @@ export interface WorkspacePlaygroundItem extends PlaygroundItem { /** * Executes a {@link vscode.Task task} to run swift playground. */ -export async function runPlayground(ctx: WorkspaceContext, item?: PlaygroundItem) { - const folderContext = ctx.currentFolder; - if (!folderContext || !item) { +export async function runPlayground( + folderContext: FolderContext, + tasks: TaskManager, + item?: PlaygroundItem +) { + if (!item) { return false; } const id = item.label ?? item.id; @@ -52,6 +56,6 @@ export async function runPlayground(ctx: WorkspaceContext, item?: PlaygroundItem folderContext.toolchain ); - await vscode.tasks.executeTask(task); + await tasks.executeTaskAndWait(task); return true; } diff --git a/test/integration-tests/commands/runPlayground.test.ts b/test/integration-tests/commands/runPlayground.test.ts index 7dbba0d74..edfce882b 100644 --- a/test/integration-tests/commands/runPlayground.test.ts +++ b/test/integration-tests/commands/runPlayground.test.ts @@ -12,21 +12,23 @@ // //===----------------------------------------------------------------------===// import { expect } from "chai"; +import { stub } from "sinon"; import * as vscode from "vscode"; import { FolderContext } from "@src/FolderContext"; import { WorkspaceContext } from "@src/WorkspaceContext"; import { Commands } from "@src/commands"; +import { runPlayground } from "@src/commands/runPlayground"; import { SwiftTask } from "@src/tasks/SwiftTaskProvider"; +import { TaskManager } from "@src/tasks/TaskManager"; -import { mockGlobalObject } from "../../MockUtils"; +import { MockedObject, instance, mockObject } from "../../MockUtils"; import { activateExtensionForSuite, folderInRootWorkspace } from "../utilities/testutilities"; suite("Run Playground Command", function () { let folderContext: FolderContext; let workspaceContext: WorkspaceContext; - - const mockTasks = mockGlobalObject(vscode, "tasks"); + let mockTaskManager: MockedObject; activateExtensionForSuite({ async setup(ctx) { @@ -37,60 +39,71 @@ suite("Run Playground Command", function () { setup(async () => { await workspaceContext.focusFolder(folderContext); + mockTaskManager = mockObject({ executeTaskAndWait: stub().resolves() }); }); - test("No playground item provided", async () => { - expect(await vscode.commands.executeCommand(Commands.PLAY), undefined).to.be.false; - expect(mockTasks.executeTask).to.not.have.been.called; - }); + suite("Command", () => { + test("Succeeds", async () => { + expect( + await vscode.commands.executeCommand(Commands.PLAY, { + id: "PackageLib/PackageLib.swift:3", + }) + ).to.be.true; + }); + + test("No playground item provided", async () => { + expect(await vscode.commands.executeCommand(Commands.PLAY), undefined).to.be.false; + }); - test("No folder focussed", async () => { - await workspaceContext.focusFolder(null); - expect( - await vscode.commands.executeCommand(Commands.PLAY, { - id: "PackageLib/PackageLib.swift:3", - }) - ).to.be.false; - expect(mockTasks.executeTask).to.not.have.been.called; + test("No folder focussed", async () => { + await workspaceContext.focusFolder(null); + expect( + await vscode.commands.executeCommand(Commands.PLAY, { + id: "PackageLib/PackageLib.swift:3", + }) + ).to.be.false; + }); }); - test('Runs "swift play" on "id"', async () => { - expect( - await vscode.commands.executeCommand(Commands.PLAY, { - id: "PackageLib/PackageLib.swift:3", - }) - ).to.be.true; - expect(mockTasks.executeTask).to.have.been.calledOnce; + suite("Arguments", () => { + test('Runs "swift play" on "id"', async () => { + expect( + await runPlayground(folderContext, instance(mockTaskManager), { + id: "PackageLib/PackageLib.swift:3", + }) + ).to.be.true; + expect(mockTaskManager.executeTaskAndWait).to.have.been.calledOnce; - const task = mockTasks.executeTask.args[0][0] as SwiftTask; - expect(task.execution.args).to.deep.equal(["play", "PackageLib/PackageLib.swift:3"]); - expect(task.execution.options.cwd).to.equal(folderContext.folder.fsPath); - }); + const task = mockTaskManager.executeTaskAndWait.args[0][0] as SwiftTask; + expect(task.execution.args).to.deep.equal(["play", "PackageLib/PackageLib.swift:3"]); + expect(task.execution.options.cwd).to.equal(folderContext.folder.fsPath); + }); - test('Runs "swift play" on "id" with space in path', async () => { - expect( - await vscode.commands.executeCommand(Commands.PLAY, { - id: "PackageLib/Package Lib.swift:3", - }) - ).to.be.true; - expect(mockTasks.executeTask).to.have.been.calledOnce; + test('Runs "swift play" on "id" with space in path', async () => { + expect( + await runPlayground(folderContext, instance(mockTaskManager), { + id: "PackageLib/Package Lib.swift:3", + }) + ).to.be.true; + expect(mockTaskManager.executeTaskAndWait).to.have.been.calledOnce; - const task = mockTasks.executeTask.args[0][0] as SwiftTask; - expect(task.execution.args).to.deep.equal(["play", "PackageLib/Package Lib.swift:3"]); - expect(task.execution.options.cwd).to.equal(folderContext.folder.fsPath); - }); + const task = mockTaskManager.executeTaskAndWait.args[0][0] as SwiftTask; + expect(task.execution.args).to.deep.equal(["play", "PackageLib/Package Lib.swift:3"]); + expect(task.execution.options.cwd).to.equal(folderContext.folder.fsPath); + }); - test('Runs "swift play" on "label"', async () => { - expect( - await vscode.commands.executeCommand(Commands.PLAY, { - id: "PackageLib/PackageLib.swift:3", - label: "bar", - }) - ).to.be.true; - expect(mockTasks.executeTask).to.have.been.calledOnce; + test('Runs "swift play" on "label"', async () => { + expect( + await runPlayground(folderContext, instance(mockTaskManager), { + id: "PackageLib/PackageLib.swift:3", + label: "bar", + }) + ).to.be.true; + expect(mockTaskManager.executeTaskAndWait).to.have.been.calledOnce; - const task = mockTasks.executeTask.args[0][0] as SwiftTask; - expect(task.execution.args).to.deep.equal(["play", "bar"]); - expect(task.execution.options.cwd).to.equal(folderContext.folder.fsPath); + const task = mockTaskManager.executeTaskAndWait.args[0][0] as SwiftTask; + expect(task.execution.args).to.deep.equal(["play", "bar"]); + expect(task.execution.options.cwd).to.equal(folderContext.folder.fsPath); + }); }); });