diff --git a/.vscodeignore b/.vscodeignore index 10751b3fd..26266da62 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -12,3 +12,28 @@ !assets/documentation-webview/** !assets/swift-docc-render/** !node_modules/@vscode/codicons/** + +# Keep tutorial assets +!assets/tutorial/** + +.vscode/** +.vscode-test/** +src/** +test/** +scripts/** +.gitignore +.github/** +.devcontainer/** +.eslintrc.json +.mailmap +.mocha-reporter.js +.nvmrc +.prettierignore +.prettierrc.json +.unacceptablelanguageignore +.vscode-test.js +tsconfig.json +tsconfig-base.json +package-lock.json +CONTRIBUTING.md +CONTRIBUTORS.txt diff --git a/README.md b/README.md index f6731b0f8..24835e9a6 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The Swift extension is supported on macOS, Linux, and Windows. To install, first ### Language features -The extension provides language features such as code completion and jump to definition via [SourceKit-LSP](https://github.com/apple/sourcekit-lsp). To ensure the extension functions correctly, it’s important to first build the project so that SourceKit-LSP has access to all the symbol data. Whenever you add a new dependency to your project, make sure to rebuild it so that SourceKit-LSP can update its information. +The extension provides language features such as code completion and jump to definition via [SourceKit-LSP](https://github.com/apple/sourcekit-lsp). To ensure the extension functions correctly, it's important to first build the project so that SourceKit-LSP has access to all the symbol data. Whenever you add a new dependency to your project, make sure to rebuild it so that SourceKit-LSP can update its information. ### Automatic task creation @@ -107,7 +107,7 @@ Use the **Run > Start Debugging** menu item to run an executable and start debug LLDB DAP is only available starting in Swift 6.0. On older versions of Swift the [CodeLLDB](https://marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb) extension will be used for debugging instead. You will be prompted to install the CodeLLDB extension in this case. -CodeLLDB includes a version of `lldb` that it uses by default for debugging, but this version of `lldb` doesn’t support Swift. The Swift extension will automatically identify the required version and offer to update the CodeLLDB configuration as necessary so that debugging is supported. +CodeLLDB includes a version of `lldb` that it uses by default for debugging, but this version of `lldb` doesn't support Swift. The Swift extension will automatically identify the required version and offer to update the CodeLLDB configuration as necessary so that debugging is supported. ### Test Explorer @@ -123,6 +123,25 @@ Once your project is built, the Test Explorer will list all your tests. These te * [Test Coverage](docs/test-coverage.md) * [Visual Studio Code Dev Containers](docs/remote-dev.md) +## Tutorial Mode + +The Swift extension includes an interactive tutorial to help you get started with Swift development in VS Code. The tutorial covers: + +- Installing and configuring the Swift toolchain +- Creating your first Swift project +- Building and running Swift code +- Debugging Swift applications +- Writing and running tests +- Using Swift Package Manager + +To start the tutorial: + +1. Open the Command Palette (Cmd+Shift+P on macOS, Ctrl+Shift+P on Windows/Linux) +2. Type "Swift: Start Tutorial" and press Enter +3. Follow the interactive steps in the tutorial panel + +The tutorial will guide you through each step with visual aids and hands-on exercises. You can create a sample project at any time using the "Swift: Create Tutorial Project" command. + ## 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/assets/tutorial/build-run.png b/assets/tutorial/build-run.png new file mode 100644 index 000000000..e69de29bb diff --git a/assets/tutorial/create-project.png b/assets/tutorial/create-project.png new file mode 100644 index 000000000..e69de29bb diff --git a/assets/tutorial/debugging.png b/assets/tutorial/debugging.png new file mode 100644 index 000000000..e69de29bb diff --git a/assets/tutorial/package-manager.png b/assets/tutorial/package-manager.png new file mode 100644 index 000000000..e69de29bb diff --git a/assets/tutorial/testing.png b/assets/tutorial/testing.png new file mode 100644 index 000000000..e69de29bb diff --git a/assets/tutorial/toolchain-setup.png b/assets/tutorial/toolchain-setup.png new file mode 100644 index 000000000..e69de29bb diff --git a/package-lock.json b/package-lock.json index 8495d4aab..f4818fa41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,12 +25,12 @@ "@types/lodash.throttle": "^4.1.9", "@types/mocha": "^10.0.10", "@types/mock-fs": "^4.13.4", - "@types/node": "^20.17.27", + "@types/node": "^20.17.28", "@types/plist": "^3.0.5", "@types/semver": "^7.5.8", "@types/sinon": "^17.0.4", "@types/sinon-chai": "^3.2.12", - "@types/vscode": "^1.88.0", + "@types/vscode": "^1.98.0", "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "^8.28.0", "@typescript-eslint/parser": "^8.28.0", @@ -1152,9 +1152,9 @@ } }, "node_modules/@types/node": { - "version": "20.17.27", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", - "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", + "version": "20.17.28", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.28.tgz", + "integrity": "sha512-DHlH/fNL6Mho38jTy7/JT7sn2wnXI+wULR6PV4gy4VHLVvnrV/d3pHAMQHhc4gjdLmK2ZiPoMxzp6B3yRajLSQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1206,10 +1206,11 @@ "license": "MIT" }, "node_modules/@types/vscode": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.89.0.tgz", - "integrity": "sha512-TMfGKLSVxfGfoO8JfIE/neZqv7QLwS4nwPwL/NwMvxtAY2230H2I4Z5xx6836pmJvMAzqooRQ4pmLm7RUicP3A==", - "dev": true + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.98.0.tgz", + "integrity": "sha512-+KuiWhpbKBaG2egF+51KjbGWatTH5BbmWQjSLMDCssb4xF8FJnW4nGH4nuAdOOfMbpD0QlHtI+C3tPq+DoKElg==", + "dev": true, + "license": "MIT" }, "node_modules/@types/xml2js": { "version": "0.4.14", @@ -6934,9 +6935,9 @@ } }, "@types/node": { - "version": "20.17.27", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", - "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", + "version": "20.17.28", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.28.tgz", + "integrity": "sha512-DHlH/fNL6Mho38jTy7/JT7sn2wnXI+wULR6PV4gy4VHLVvnrV/d3pHAMQHhc4gjdLmK2ZiPoMxzp6B3yRajLSQ==", "dev": true, "requires": { "undici-types": "~6.19.2" @@ -6984,9 +6985,9 @@ "dev": true }, "@types/vscode": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.89.0.tgz", - "integrity": "sha512-TMfGKLSVxfGfoO8JfIE/neZqv7QLwS4nwPwL/NwMvxtAY2230H2I4Z5xx6836pmJvMAzqooRQ4pmLm7RUicP3A==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.98.0.tgz", + "integrity": "sha512-+KuiWhpbKBaG2egF+51KjbGWatTH5BbmWQjSLMDCssb4xF8FJnW4nGH4nuAdOOfMbpD0QlHtI+C3tPq+DoKElg==", "dev": true }, "@types/xml2js": { diff --git a/package.json b/package.json index babcfdf35..fe8d6bab2 100644 --- a/package.json +++ b/package.json @@ -299,6 +299,18 @@ "title": "Run Tests with Coverage", "category": "Test", "icon": "$(debug-coverage)" + }, + { + "command": "swift.startTutorial", + "title": "Start Swift Tutorial", + "category": "Swift", + "icon": "$(book)" + }, + { + "command": "swift.createTutorialProject", + "title": "Create Tutorial Project", + "category": "Swift", + "icon": "$(folder-opened)" } ], "configuration": [ @@ -1610,6 +1622,87 @@ } ] } + ], + "walkthroughs": [ + { + "id": "swift-tutorial", + "title": "Getting Started with Swift in VS Code", + "description": "Learn how to use Swift in Visual Studio Code", + "steps": [ + { + "id": "toolchain-setup", + "title": "Install Swift Toolchain", + "description": "First, you need to install the Swift toolchain on your system.", + "media": { + "image": "assets/tutorial/toolchain-setup.png", + "alt": "Swift toolchain installation" + }, + "completionEvents": [ + "onCommand:swift.selectToolchain" + ] + }, + { + "id": "create-project", + "title": "Create Your First Swift Project", + "description": "Create a new Swift package using Swift Package Manager.", + "media": { + "image": "assets/tutorial/create-project.png", + "alt": "Creating a new Swift project" + }, + "completionEvents": [ + "onCommand:swift.createNewProject" + ] + }, + { + "id": "build-run", + "title": "Build and Run", + "description": "Learn how to build and run your Swift project.", + "media": { + "image": "assets/tutorial/build-run.png", + "alt": "Building and running a Swift project" + }, + "completionEvents": [ + "onCommand:swift.run" + ] + }, + { + "id": "debugging", + "title": "Debugging", + "description": "Set breakpoints and debug your Swift code.", + "media": { + "image": "assets/tutorial/debugging.png", + "alt": "Debugging Swift code" + }, + "completionEvents": [ + "onCommand:swift.debug" + ] + }, + { + "id": "testing", + "title": "Testing", + "description": "Write and run tests for your Swift code.", + "media": { + "image": "assets/tutorial/testing.png", + "alt": "Testing Swift code" + }, + "completionEvents": [ + "onCommand:swift.run" + ] + }, + { + "id": "package-manager", + "title": "Swift Package Manager", + "description": "Learn how to manage dependencies with Swift Package Manager.", + "media": { + "image": "assets/tutorial/package-manager.png", + "alt": "Using Swift Package Manager" + }, + "completionEvents": [ + "onCommand:swift.updateDependencies" + ] + } + ] + } ] }, "extensionDependencies": [ @@ -1648,16 +1741,16 @@ "@types/chai-subset": "^1.3.6", "@types/glob": "^7.1.6", "@types/lcov-parse": "^1.0.2", - "@types/lodash.throttle": "^4.1.9", "@types/lodash.debounce": "^4.0.9", + "@types/lodash.throttle": "^4.1.9", "@types/mocha": "^10.0.10", "@types/mock-fs": "^4.13.4", - "@types/node": "^20.17.27", + "@types/node": "^20.17.28", "@types/plist": "^3.0.5", "@types/semver": "^7.5.8", "@types/sinon": "^17.0.4", "@types/sinon-chai": "^3.2.12", - "@types/vscode": "^1.88.0", + "@types/vscode": "^1.98.0", "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "^8.28.0", "@typescript-eslint/parser": "^8.28.0", @@ -1672,8 +1765,8 @@ "esbuild": "^0.25.1", "eslint": "^8.57.0", "eslint-config-prettier": "^10.1.1", - "lodash.throttle": "^4.1.1", "lodash.debounce": "^4.0.8", + "lodash.throttle": "^4.1.1", "mocha": "^10.8.2", "mock-fs": "^5.5.0", "node-pty": "^1.0.0", diff --git a/src/extension.ts b/src/extension.ts index 7361075f1..36cb0b4e1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -39,6 +39,7 @@ import { checkAndWarnAboutWindowsSymlinks } from "./ui/win32"; import { SwiftEnvironmentVariablesManager, SwiftTerminalProfileProvider } from "./terminal"; import { resolveFolderDependencies } from "./commands/dependencies/resolve"; import { SelectedXcodeWatcher } from "./toolchain/SelectedXcodeWatcher"; +import { SwiftTutorial } from "./tutorial/swiftTutorial"; /** * External API as exposed by the extension. Can be queried by other extensions @@ -92,7 +93,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { context.subscriptions.push(...commands.registerToolchainCommands(toolchain)); context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration(event => { + vscode.workspace.onDidChangeConfiguration((event: vscode.ConfigurationChangeEvent) => { // on toolchain config change, reload window if ( event.affectsConfiguration("swift.path") && @@ -265,6 +266,19 @@ export async function activate(context: vscode.ExtensionContext): Promise { workspaceContext ); + // Register tutorial commands + const tutorial = SwiftTutorial.getInstance(context); + context.subscriptions.push( + vscode.commands.registerCommand("swift.startTutorial", () => { + tutorial.showTutorial(); + }) + ); + context.subscriptions.push( + vscode.commands.registerCommand("swift.createTutorialProject", () => { + tutorial.createSampleProject(); + }) + ); + // Mark the extension as activated. contextKeys.isActivated = true; @@ -288,6 +302,6 @@ export async function activate(context: vscode.ExtensionContext): Promise { async function deactivate(context: vscode.ExtensionContext): Promise { contextKeys.isActivated = false; - context.subscriptions.forEach(subscription => subscription.dispose()); + context.subscriptions.forEach((subscription: vscode.Disposable) => subscription.dispose()); context.subscriptions.length = 0; } diff --git a/src/tutorial/swiftTutorial.ts b/src/tutorial/swiftTutorial.ts new file mode 100644 index 000000000..0085aea37 --- /dev/null +++ b/src/tutorial/swiftTutorial.ts @@ -0,0 +1,180 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; + +export class SwiftTutorial { + private static instance: SwiftTutorial; + private context: vscode.ExtensionContext; + private tutorialState: { [key: string]: boolean }; + + private constructor(context: vscode.ExtensionContext) { + this.context = context; + this.tutorialState = this.context.workspaceState.get('swiftTutorialState', {}); + } + + public static getInstance(context: vscode.ExtensionContext): SwiftTutorial { + if (!SwiftTutorial.instance) { + SwiftTutorial.instance = new SwiftTutorial(context); + } + return SwiftTutorial.instance; + } + + public async showTutorial(): Promise { + const tutorial = { + id: 'swift-tutorial', + title: 'Getting Started with Swift in VS Code', + description: 'Learn how to use Swift in Visual Studio Code', + steps: [ + { + id: 'toolchain-setup', + title: 'Install Swift Toolchain', + description: 'First, you need to install the Swift toolchain on your system.', + media: { + image: 'assets/tutorial/toolchain-setup.png', + alt: 'Swift toolchain installation' + }, + completionEvents: ['onCommand:swift.selectToolchain'] + }, + { + id: 'create-project', + title: 'Create Your First Swift Project', + description: 'Create a new Swift package using Swift Package Manager.', + media: { + image: 'assets/tutorial/create-project.png', + alt: 'Creating a new Swift project' + }, + completionEvents: ['onCommand:swift.createNewProject'] + }, + { + id: 'build-run', + title: 'Build and Run', + description: 'Learn how to build and run your Swift project.', + media: { + image: 'assets/tutorial/build-run.png', + alt: 'Building and running a Swift project' + }, + completionEvents: ['onCommand:swift.run'] + }, + { + id: 'debugging', + title: 'Debugging', + description: 'Set breakpoints and debug your Swift code.', + media: { + image: 'assets/tutorial/debugging.png', + alt: 'Debugging Swift code' + }, + completionEvents: ['onCommand:swift.debug'] + }, + { + id: 'testing', + title: 'Testing', + description: 'Write and run tests for your Swift code.', + media: { + image: 'assets/tutorial/testing.png', + alt: 'Testing Swift code' + }, + completionEvents: ['onCommand:swift.run'] + }, + { + id: 'package-manager', + title: 'Swift Package Manager', + description: 'Learn how to manage dependencies with Swift Package Manager.', + media: { + image: 'assets/tutorial/package-manager.png', + alt: 'Using Swift Package Manager' + }, + completionEvents: ['onCommand:swift.updateDependencies'] + } + ] + }; + + await vscode.window.showInformationMessage('Starting Swift Tutorial...'); + await vscode.commands.executeCommand('workbench.action.walkthroughs.open', tutorial.id); + } + + public async checkToolchain(): Promise { + try { + const result = await vscode.commands.executeCommand('swift.selectToolchain'); + return result !== undefined; + } catch (error) { + return false; + } + } + + public async createSampleProject(): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + throw new Error('No workspace folder found'); + } + + const projectPath = path.join(workspaceFolder.uri.fsPath, 'SwiftTutorial'); + if (!fs.existsSync(projectPath)) { + fs.mkdirSync(projectPath); + } + + // Create Package.swift + const packageContent = `// swift-tools-version:5.5 +import PackageDescription + +let package = Package( + name: "SwiftTutorial", + platforms: [ + .macOS(.v12), + .iOS(.v15) + ], + products: [ + .executable( + name: "SwiftTutorial", + targets: ["SwiftTutorial"]), + ], + dependencies: [], + targets: [ + .executableTarget( + name: "SwiftTutorial", + dependencies: []), + .testTarget( + name: "SwiftTutorialTests", + dependencies: ["SwiftTutorial"]), + ] +)`; + + fs.writeFileSync(path.join(projectPath, 'Package.swift'), packageContent); + + // Create Sources directory and main.swift + const sourcesPath = path.join(projectPath, 'Sources', 'SwiftTutorial'); + fs.mkdirSync(sourcesPath, { recursive: true }); + + const mainContent = `print("Hello, Swift Tutorial!")`; + fs.writeFileSync(path.join(sourcesPath, 'main.swift'), mainContent); + + // Create Tests directory and test file + const testsPath = path.join(projectPath, 'Tests', 'SwiftTutorialTests'); + fs.mkdirSync(testsPath, { recursive: true }); + + const testContent = `import XCTest +@testable import SwiftTutorial + +final class SwiftTutorialTests: XCTestCase { + func testExample() throws { + XCTAssertTrue(true) + } +}`; + fs.writeFileSync(path.join(testsPath, 'SwiftTutorialTests.swift'), testContent); + + // Open the project in VS Code + await vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.file(projectPath)); + } + + public async saveTutorialState(): Promise { + await this.context.workspaceState.update('swiftTutorialState', this.tutorialState); + } + + public isStepCompleted(stepId: string): boolean { + return this.tutorialState[stepId] || false; + } + + public markStepCompleted(stepId: string): void { + this.tutorialState[stepId] = true; + this.saveTutorialState(); + } +} \ No newline at end of file diff --git a/test/tutorial/swiftTutorial.test.ts b/test/tutorial/swiftTutorial.test.ts new file mode 100644 index 000000000..fa583def8 --- /dev/null +++ b/test/tutorial/swiftTutorial.test.ts @@ -0,0 +1,86 @@ +import * as vscode from 'vscode'; +import { expect } from 'chai'; +import { SwiftTutorial } from '../../src/tutorial/swiftTutorial'; +import * as sinon from 'sinon'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('SwiftTutorial', () => { + let context: vscode.ExtensionContext; + let tutorial: SwiftTutorial; + + beforeEach(() => { + context = { + subscriptions: [], + workspaceState: { + get: sinon.stub().returns({}), + update: sinon.stub().resolves() + } + } as unknown as vscode.ExtensionContext; + tutorial = SwiftTutorial.getInstance(context); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('showTutorial', () => { + it('should show tutorial walkthrough', async () => { + const showInfoStub = sinon.stub(vscode.window, 'showInformationMessage'); + const executeCommandStub = sinon.stub(vscode.commands, 'executeCommand'); + + await tutorial.showTutorial(); + + expect(showInfoStub.calledOnce).to.be.true; + expect(executeCommandStub.calledWith('workbench.action.walkthroughs.open', 'swift-tutorial')).to.be.true; + }); + }); + + describe('createSampleProject', () => { + it('should create tutorial project with correct structure', async () => { + const workspaceFolder = { uri: vscode.Uri.file('/test'), index: 0 }; + sinon.stub(vscode.workspace, 'workspaceFolders').value([workspaceFolder]); + + const mkdirSpy = sinon.spy(fs, 'mkdirSync'); + const writeFileSpy = sinon.spy(fs, 'writeFileSync'); + const executeCommandStub = sinon.stub(vscode.commands, 'executeCommand'); + + await tutorial.createSampleProject(); + + expect(mkdirSpy.called).to.be.true; + expect(writeFileSpy.called).to.be.true; + expect(executeCommandStub.calledWith('vscode.openFolder')).to.be.true; + }); + + it('should throw error when no workspace folder exists', async () => { + sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined); + + try { + await tutorial.createSampleProject(); + expect.fail('Should have thrown an error'); + } catch (error: any) { + expect(error.message).to.equal('No workspace folder found'); + } + }); + }); + + describe('tutorial state management', () => { + it('should track completed steps', () => { + const stepId = 'test-step'; + + expect(tutorial.isStepCompleted(stepId)).to.be.false; + + tutorial.markStepCompleted(stepId); + expect(tutorial.isStepCompleted(stepId)).to.be.true; + }); + + it('should save state to workspace', async () => { + const stepId = 'test-step'; + tutorial.markStepCompleted(stepId); + await tutorial.saveTutorialState(); + + const updateStub = context.workspaceState.update as sinon.SinonStub; + expect(updateStub.calledOnce).to.be.true; + }); + }); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 29e5cd152..e381bdd1b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,9 @@ { - "files": [], - "references": [ - { "path": "./src" }, - { "path": "./src/documentation/webview" }, - { "path": "./test" } - ] + "extends": "./tsconfig-base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "types": ["node", "vscode"] + }, + "include": ["src/**/*", "test/**/*"] }