diff --git a/package-lock.json b/package-lock.json index fd56b32..f204114 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { - "name": "vscode-clangd", - "version": "0.2.0", + "name": "vscode-clangd-fancy", + "version": "0.2.1", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "vscode-clangd", - "version": "0.2.0", + "name": "vscode-clangd-fancy", + "version": "0.2.1", "license": "MIT", "dependencies": { "@clangd/install": "0.1.20", - "vscode-languageclient": "^9.0.1" + "vscode-languageclient": "^9.0.1", + "vscode-nls": "^5.2.0" }, "devDependencies": { "@types/glob": "^8.1.0", @@ -6306,6 +6307,12 @@ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", "license": "MIT" }, + "node_modules/vscode-nls": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", + "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -10728,6 +10735,11 @@ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" }, + "vscode-nls": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", + "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==" + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index e91f771..cc4f730 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "vscode:prepublish": "npm run check-ts && npm run esbuild -- --minify --keep-names", "compile": "npm run esbuild -- --sourcemap", "check-ts": "tsc -noEmit -p ./", - "format": "clang-format -i --glob=\"{src,test}/*.ts\"", + "format": "clang-format -i --glob=\"{src,test}/**/*.ts\"", "test-compile": "tsc -p ./ && npm run compile", "test": "npm run test-compile && node ./out/test/index.js", "package": "vsce package --baseImagesUrl https://raw.githubusercontent.com/clangd/vscode-clangd/master/", @@ -48,7 +48,8 @@ }, "dependencies": { "@clangd/install": "0.1.20", - "vscode-languageclient": "^9.0.1" + "vscode-languageclient": "^9.0.1", + "vscode-nls": "^5.2.0" }, "devDependencies": { "@types/glob": "^8.1.0", @@ -194,6 +195,60 @@ "type": "boolean", "default": true, "description": "Enable clangd language server features" + }, + "clangd.createPair.rules": { + "type": "array", + "default": [], + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Unique identifier for this rule" + }, + "label": { + "type": "string", + "description": "Human-readable name shown in UI" + }, + "description": { + "type": "string", + "description": "Detailed description of what this rule creates" + }, + "language": { + "type": "string", + "enum": [ + "c", + "cpp" + ], + "description": "Target programming language" + }, + "headerExt": { + "type": "string", + "description": "File extension for header file (e.g., '.h', '.hh')" + }, + "sourceExt": { + "type": "string", + "description": "File extension for source file (e.g., '.cpp', '.cc', '.c')" + }, + "isClass": { + "type": "boolean", + "description": "Whether this rule creates a class template" + }, + "isStruct": { + "type": "boolean", + "description": "Whether this rule creates a struct template" + } + }, + "required": [ + "key", + "label", + "description", + "language", + "headerExt", + "sourceExt" + ] + }, + "description": "Custom pairing rules for creating source/header file pairs with different extensions" } } }, @@ -212,7 +267,18 @@ { "command": "clangd.switchheadersource", "category": "clangd", - "title": "Switch Between Source/Header" + "title": "Switch Between Source/Header Pair" + }, + { + "command": "clangd.newSourcePair", + "category": "clangd", + "title": "New Source/Header Pair", + "icon": "$(new-file)" + }, + { + "command": "clangd.createPair.configureRules", + "category": "clangd", + "title": "Configure Source/Header Pairing Rules" }, { "command": "clangd.install", @@ -368,6 +434,13 @@ "group": "navigation" } ], + "explorer/context": [ + { + "command": "clangd.newSourcePair", + "when": "explorerResourceIsFolder", + "group": "2_workspace@1" + } + ], "commandPalette": [ { "command": "clangd.typeHierarchy.viewParents", @@ -388,19 +461,22 @@ { "id": "clangd.typeHierarchyView", "name": "Type Hierarchy", - "when": "clangd.typeHierarchyVisible" + "when": "clangd.typeHierarchyVisible", + "icon": "$(type-hierarchy)" }, { "id": "clangd.memoryUsage", "name": "clangd Memory Usage", - "when": "clangd.memoryUsage.hasData" + "when": "clangd.memoryUsage.hasData", + "icon": "$(dashboard)" }, { "id": "clangd.ast", "name": "AST", - "when": "clangd.ast.hasData" + "when": "clangd.ast.hasData", + "icon": "$(list-tree)" } ] } } -} +} \ No newline at end of file diff --git a/src/ast.ts b/src/ast.ts index c136f98..69e0639 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -4,6 +4,7 @@ import * as vscode from 'vscode'; import * as vscodelc from 'vscode-languageclient/node'; import {ClangdContext} from './clangd-context'; + import type {ASTParams, ASTNode} from '../api/vscode-clangd'; const ASTRequestMethod = 'textDocument/ast'; diff --git a/src/create-source-header-pair/coordinator.ts b/src/create-source-header-pair/coordinator.ts new file mode 100644 index 0000000..8ead1f1 --- /dev/null +++ b/src/create-source-header-pair/coordinator.ts @@ -0,0 +1,107 @@ +// +// PAIR COORDINATOR +// ================ +// +// Lean coordinator that orchestrates the source/header pair creation workflow. +// Uses dependency injection and delegates all implementation details to +// service and UI layers. + +import * as vscode from 'vscode'; + +import {showConfigurationWizard} from '../pairing-rule-manager'; + +import {PairCreatorService} from './service'; +import {PairCreatorUI} from './ui'; + +// PairCoordinator orchestrates the workflow between UI and Service layers. +// It follows the single responsibility principle and uses dependency injection. +export class PairCoordinator implements vscode.Disposable { + private static readonly ERROR_MESSAGES = { + NO_TARGET_DIRECTORY: + 'Cannot determine target directory. Please open a folder or a file first.', + FILE_EXISTS: (filePath: string) => `File already exists: ${filePath}`, + UNEXPECTED_ERROR: 'An unexpected error occurred.' + } as const; + + private newPairCommand: vscode.Disposable; + private configureRulesCommand: vscode.Disposable; + + // Constructor with dependency injection - receives pre-configured instances + constructor(private readonly service: PairCreatorService, + private readonly ui: PairCreatorUI) { + // Register commands + this.newPairCommand = vscode.commands.registerCommand( + 'clangd.newSourcePair', this.create, this); + this.configureRulesCommand = vscode.commands.registerCommand( + 'clangd.newSourcePair.configureRules', this.configureRules, this); + } + + // Dispose method for cleanup when extension is deactivated + dispose() { + this.newPairCommand.dispose(); + this.configureRulesCommand.dispose(); + } + + // Main workflow orchestration + public async create(): Promise { + try { + // 1. Determine where to create files + const targetDirectory = await this.getTargetDirectory(); + if (!targetDirectory) { + vscode.window.showErrorMessage( + PairCoordinator.ERROR_MESSAGES.NO_TARGET_DIRECTORY); + return; + } + + // 2. Get user preferences for what to create + const {language, uncertain} = + await this.service.detectLanguageFromEditor(); + const rule = await this.ui.promptForPairingRule(language, uncertain); + if (!rule) + return; + + const fileName = await this.ui.promptForFileName(rule); + if (!fileName) + return; + + // 3. Prepare file paths and check for conflicts + const {headerPath, sourcePath} = + this.service.createFilePaths(targetDirectory, fileName, rule); + const existingFilePath = + await this.service.checkFileExistence(headerPath, sourcePath); + if (existingFilePath) { + vscode.window.showErrorMessage( + PairCoordinator.ERROR_MESSAGES.FILE_EXISTS(existingFilePath)); + return; + } + + // 4. Create the files + await this.service.generateAndWriteFiles(fileName, rule, headerPath, + sourcePath); + + // 5. Show success and handle post-creation tasks + await this.ui.showSuccessAndOpenFile(headerPath, sourcePath); + await this.service.handleOfferToSaveAsDefault(rule, language); + + } catch (error: unknown) { + const errorMessage = + error instanceof Error + ? error.message + : PairCoordinator.ERROR_MESSAGES.UNEXPECTED_ERROR; + vscode.window.showErrorMessage(errorMessage); + } + } + + // Determine target directory with fallback to workspace picker + private async getTargetDirectory(): Promise { + return await this.service.getTargetDirectory( + vscode.window.activeTextEditor?.document?.uri.fsPath, + vscode.workspace.workspaceFolders) ?? + await this.ui.showWorkspaceFolderPicker(); + } + + // Opens the configuration wizard for pairing rules + public async configureRules(): Promise { + await showConfigurationWizard(); + } +} diff --git a/src/create-source-header-pair/index.ts b/src/create-source-header-pair/index.ts new file mode 100644 index 0000000..94322f9 --- /dev/null +++ b/src/create-source-header-pair/index.ts @@ -0,0 +1,56 @@ +// +// CREATE SOURCE HEADER PAIR - MODULE INDEX +// ======================================== +// +// This module provides functionality to create matching header/source file +// pairs for C/C++ development. It intelligently detects language context, +// offers appropriate templates, and handles custom file extensions. +// +// ARCHITECTURE: +// - templates.ts: Template rules and file content templates +// - service.ts: Business logic layer (language detection, file operations) +// - ui.ts: User interface layer (dialogs, input validation) +// - coordinator.ts: Main coordinator (orchestrates workflow, registers +// commands) +// +// WORKFLOW: +// Command triggered → Detect target directory → Analyze language context → +// Check for custom rules → Present template choices → Get file name → +// Validate uniqueness → Generate content → Write files → Open in editor +// +// FEATURES: +// - Smart language detection (C vs C++) +// - Multiple template types (class, struct, empty) +// - Custom file extension support +// - Header guard generation +// - Cross-language template options +// - Workspace-aware directory selection +// - Input validation for C/C++ identifiers +// +// INTEGRATION: +// Uses PairingRuleManager for custom extension configurations +// Integrates with VS Code file system and editor APIs +// + +import {ClangdContext} from '../clangd-context'; + +import {PairCoordinator} from './coordinator'; +import {PairCreatorService} from './service'; +import {PairCreatorUI} from './ui'; + +// Registers the create source/header pair command with the VS Code extension +// context Uses dependency injection to create properly configured instances +export function registerCreateSourceHeaderPairCommand(context: ClangdContext) { + // Create instances with proper dependencies + const service = new PairCreatorService(); + const ui = new PairCreatorUI(service); + const coordinator = new PairCoordinator(service, ui); + + context.subscriptions.push(coordinator); +} + +// Re-export main types and classes for external usage +export {PairCoordinator} from './coordinator'; +export {PairCreatorService} from './service'; +export {PairCreatorUI} from './ui'; +export {Language, TemplateKey} from './templates'; diff --git a/src/create-source-header-pair/service.ts b/src/create-source-header-pair/service.ts new file mode 100644 index 0000000..89a4f54 --- /dev/null +++ b/src/create-source-header-pair/service.ts @@ -0,0 +1,459 @@ +// +// PAIR CREATOR SERVICE +// =================== +// +// Business logic layer for file pair creation functionality. +// Handles language detection, file operations, template processing, +// and configuration management. +// + +import * as path from 'path'; +import * as vscode from 'vscode'; + +import {PairingRule, PairingRuleService} from '../pairing-rule-manager'; + +import { + DEFAULT_PLACEHOLDERS, + FILE_TEMPLATES, + Language, + TemplateKey +} from './templates'; + +// Service Layer - Core business logic +export class PairCreatorService { + // Cache for expensive file system operations with TTL + private static readonly fileStatCache = new Map>(); + private static readonly CACHE_TTL = 5000; // 5 seconds + + // Definitive file extensions for fast lookup + private static readonly DEFINITIVE_EXTENSIONS = { + c: new Set(['.c']), + cpp: new Set(['.cpp', '.cc', '.cxx', '.hh', '.hpp', '.hxx']) + } as const; + + /** + * Creates file paths for header and source files + * @param targetDirectory Target directory URI + * @param fileName Base file name without extension + * @param rule Pairing rule with extensions + * @returns Object with headerPath and sourcePath URIs + */ + public createFilePaths(targetDirectory: vscode.Uri, fileName: string, + rule: PairingRule): + {headerPath: vscode.Uri; sourcePath: vscode.Uri;} { + return { + headerPath: vscode.Uri.file( + path.join(targetDirectory.fsPath, `${fileName}${rule.headerExt}`)), + sourcePath: vscode.Uri.file( + path.join(targetDirectory.fsPath, `${fileName}${rule.sourceExt}`)) + }; + } + + // Optimized file existence check with caching to improve performance + private static async fileExists(filePath: string): Promise { + if (this.fileStatCache.has(filePath)) { + return this.fileStatCache.get(filePath)!; + } + + const promise = + Promise.resolve(vscode.workspace.fs.stat(vscode.Uri.file(filePath)) + .then(() => true, () => false)); + + this.fileStatCache.set(filePath, promise); + + // Auto-clear cache entry after TTL to prevent memory leaks + setTimeout(() => this.fileStatCache.delete(filePath), this.CACHE_TTL); + + return promise; + } + + // Detects programming language from VS Code editor context + // ARCHITECTURE NOTE: This method accesses VS Code APIs but belongs in service + // layer because it contains the business logic for determining language + // context. UI layer should only handle user interactions, not business + // decisions. + public async detectLanguageFromEditor(): + Promise<{language: Language, uncertain: boolean}> { + const activeEditor = vscode.window.activeTextEditor; + const languageId = activeEditor?.document?.languageId; + const filePath = activeEditor?.document && !activeEditor.document.isUntitled + ? activeEditor.document.uri.fsPath + : undefined; + + return this.detectLanguage(languageId, filePath); + } + + // Detects programming language from file info (pure business logic) + // DETECTION STRATEGY: + // 1. Fast path: Check file extension against definitive extension sets + // - .c files are definitely C + // - .cpp/.cc/.cxx/.hh/.hpp/.hxx are definitely C++ + // 2. Special case: .h files are ambiguous (could be C or C++) + // - Look for companion files in same directory to determine context + // - If MyClass.h exists with MyClass.cpp -> C++ + // - If utils.h exists with utils.c -> C + // 3. Fallback: Use VS Code's language ID detection + // - Based on file content analysis or user settings + // 4. Default: When all else fails, assume C++ (more common in modern + // development) + public async detectLanguage(languageId?: string, filePath?: string): + Promise<{language: Language, uncertain: boolean}> { + if (!languageId || !filePath) { + return {language: 'cpp', uncertain: true}; + } + + const ext = path.extname(filePath); + + // Fast path for definitive extensions + if (PairCreatorService.DEFINITIVE_EXTENSIONS.c.has(ext)) { + return {language: 'c', uncertain: false}; + } + if (PairCreatorService.DEFINITIVE_EXTENSIONS.cpp.has(ext)) { + return {language: 'cpp', uncertain: false}; + } + + // Special handling for .h files with companion file detection + if (ext === '.h') { + const result = await this.detectLanguageForHeaderFile(filePath); + if (result) + return result; + } + + // Fallback to language ID + return {language: languageId === 'c' ? 'c' : 'cpp', uncertain: true}; + } + + // Optimized header file language detection by checking companion files + // HEADER FILE DETECTION STRATEGY: + // Problem: .h files are used by both C and C++, making language detection + // ambiguous Solution: Look for companion source files in the same directory + // + // Algorithm: + // 1. Extract base name from header file (e.g., "utils" from "utils.h") + // 2. Check for C companion first (utils.c) - early exit optimization + // - C projects are less common, so checking first allows quick + // determination + // 3. Check for C++ companions in parallel (utils.cpp, utils.cc, utils.cxx) + // - Use Promise.all for concurrent file existence checks + // 4. Return definitive result if companion found, otherwise default to C++ + // + // Examples: + // - math.h + math.c exists → Detected as C language + // - Vector.h + Vector.cpp exists → Detected as C++ language + // - standalone.h (no companions) → Default to C++ (uncertain=true) + private async detectLanguageForHeaderFile(filePath: string): + Promise<{language: Language, uncertain: boolean}|null> { + const baseName = path.basename(filePath, '.h'); + const dirPath = path.dirname(filePath); + + // Check for C companion file first (less common, check first for early + // exit) + const cFile = path.join(dirPath, `${baseName}.c`); + if (await PairCreatorService.fileExists(cFile)) { + return {language: 'c', uncertain: false}; + } + + // Check for C++ companion files in parallel + const cppExtensions = ['.cpp', '.cc', '.cxx']; + const cppChecks = + cppExtensions.map(ext => PairCreatorService.fileExists( + path.join(dirPath, `${baseName}${ext}`))); + + const results = await Promise.all(cppChecks); + if (results.some((exists: boolean) => exists)) { + return {language: 'cpp', uncertain: false}; + } + + return {language: 'cpp', uncertain: true}; + } + + // Gets all available pairing rules (custom + workspace + user) + public getAllPairingRules(): PairingRule[] { + return [ + ...(PairingRuleService.getRules('workspace') ?? []), + ...(PairingRuleService.getRules('user') ?? []) + ]; + } + + // Gets custom C++ extensions if available from configuration + public getCustomCppExtensions(): {headerExt: string, sourceExt: string}|null { + const allRules = this.getAllPairingRules(); + const cppCustomRule = + allRules.find((rule: PairingRule) => rule.language === 'cpp'); + return cppCustomRule ? { + headerExt: cppCustomRule.headerExt, + sourceExt: cppCustomRule.sourceExt + } + : null; + } + + // Converts string to PascalCase efficiently (pure function) + public toPascalCase(input: string): string { + return input.split(/[-_]+/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); + } + + // Generates header guard macro based on filename and extension + private generateHeaderGuard(fileName: string, headerExt: string): string { + // Remove leading dot from extension and convert to uppercase + const extPart = headerExt.replace(/^\./, '').toUpperCase(); + return `${fileName.toUpperCase()}_${extPart}_`; + } + // Generates file content with improved template selection + public generateFileContent(fileName: string, eol: string, rule: PairingRule): + {headerContent: string; sourceContent: string;} { + const templateKey: TemplateKey = + rule.isClass ? 'CPP_CLASS' + : rule.isStruct ? (rule.language === 'cpp' ? 'CPP_STRUCT' : 'C_STRUCT') + : rule.language === 'c' ? 'C_EMPTY' + : 'CPP_EMPTY'; + + const templates = FILE_TEMPLATES[templateKey]; + const context = { + fileName, + headerGuard: this.generateHeaderGuard(fileName, rule.headerExt), + includeLine: `#include "${fileName}${rule.headerExt}"` + }; + + const headerContent = this.applyTemplate(templates.header, context); + const sourceContent = this.applyTemplate(templates.source, context); + + return { + headerContent: headerContent.replace(/\n/g, eol), + sourceContent: sourceContent.replace(/\n/g, eol) + }; + } + // Gets default placeholder based on rule type (pure function) + public getDefaultPlaceholder(rule: PairingRule): string { + if (rule.isClass) { + return DEFAULT_PLACEHOLDERS.CPP_CLASS; + } + + if (rule.isStruct) { + return rule.language === 'cpp' ? DEFAULT_PLACEHOLDERS.CPP_STRUCT + : DEFAULT_PLACEHOLDERS.C_STRUCT; + } + + return rule.language === 'c' ? DEFAULT_PLACEHOLDERS.C_EMPTY + : DEFAULT_PLACEHOLDERS.CPP_EMPTY; + } + + // Optimized line ending detection based on VS Code settings and platform + public getLineEnding(): string { + const eolSetting = + vscode.workspace.getConfiguration('files').get('eol'); + + return eolSetting === '\n' || eolSetting === '\r\n' ? eolSetting + : process.platform === 'win32' ? '\r\n' + : '\n'; + } + + // Optimized template variable substitution using regex replacement + private applyTemplate(template: string, + context: Record): string { + // Pre-compile regex for better performance if used frequently + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => context[key] ?? ''); + } + + // File existence check with parallel processing for multiple files + public async checkFileExistence(headerPath: vscode.Uri, + sourcePath: vscode.Uri): + Promise { + const checks = [headerPath, sourcePath].map(async (uri) => { + try { + await vscode.workspace.fs.stat(uri); + return uri.fsPath; + } catch { + return null; + } + }); + + const results = await Promise.all(checks); + return results.find(path => path !== null) ?? null; + } + + // Optimized file writing with error handling and parallel writes + public async writeFiles(headerPath: vscode.Uri, sourcePath: vscode.Uri, + headerContent: string, + sourceContent: string): Promise { + try { + await Promise.all([ + vscode.workspace.fs.writeFile(headerPath, + Buffer.from(headerContent, 'utf8')), + vscode.workspace.fs.writeFile(sourcePath, + Buffer.from(sourceContent, 'utf8')) + ]); + } catch (error) { + throw new Error(`Failed to create files: ${ + error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // Smart target directory detection (pure business logic) + public async getTargetDirectory(activeDocumentPath?: string, + workspaceFolders + ?: readonly vscode.WorkspaceFolder[]): + Promise { + // Prefer current file's directory + if (activeDocumentPath) { + return vscode.Uri.file(path.dirname(activeDocumentPath)); + } + + // Return single workspace folder directly + if (workspaceFolders?.length === 1) { + return workspaceFolders[0].uri; + } + + // Multiple workspace folders require UI selection + return undefined; + } + + // Language mismatch warning logic (pure business logic) + public async shouldShowLanguageMismatchWarning(language: Language, + result: PairingRule, + currentDir?: string, + activeFilePath + ?: string): Promise { + if (!currentDir || !activeFilePath) { + return true; + } + + if (language === 'c' && result.language === 'cpp') { + return this.checkForCppFilesInDirectory(currentDir); + } + + return this.checkForCorrespondingSourceFiles(currentDir, activeFilePath, + language); + } + + // Check for C++ files in directory to inform language mismatch warnings + private async checkForCppFilesInDirectory(dirPath: string): Promise { + try { + const entries = + await vscode.workspace.fs.readDirectory(vscode.Uri.file(dirPath)); + const hasCppFiles = + entries.some(([fileName, fileType]) => + fileType === vscode.FileType.File && + PairCreatorService.DEFINITIVE_EXTENSIONS.cpp.has( + path.extname(fileName))); + return !hasCppFiles; // Show warning if NO C++ files found + } catch { + return true; // Show warning if can't check + } + } + + // Check for corresponding source files to inform language mismatch warnings + private async checkForCorrespondingSourceFiles( + dirPath: string, filePath: string, language: Language): Promise { + const baseName = path.basename(filePath, path.extname(filePath)); + const extensions = language === 'c' ? ['.c'] : ['.cpp', '.cc', '.cxx']; + + const checks = extensions.map(ext => PairCreatorService.fileExists( + path.join(dirPath, `${baseName}${ext}`))); + + try { + const results = await Promise.all(checks); + return !results.some( + (exists: boolean) => + exists); // Show warning if NO corresponding files found + } catch { + return true; // Show warning if can't check + } + } + + /** + * Checks if should offer to save rule as default and handles the save process + */ + public async handleOfferToSaveAsDefault(rule: PairingRule, + language: 'c'|'cpp'): Promise { + // Only offer for C++ rules + if (language !== 'cpp') { + return; + } + + // Check if user already has custom C++ rules configured + const customRules = this.getAllPairingRules(); + const hasCppCustomRules = customRules.some(r => r.language === 'cpp'); + if (hasCppCustomRules) { + return; // Don't prompt if they already have C++ configuration + } + + // Check if this is a custom rule (has _custom suffix means user went + // through extension selection) + const isCustomRule = rule.key.includes('custom'); + if (!isCustomRule) { + return; // Don't prompt for built-in rules + } + + const choice = await vscode.window.showInformationMessage( + `Files created successfully! Would you like to save "${ + rule.headerExt}/${ + rule.sourceExt}" as your default C++ extensions for this workspace?`, + 'Save as Default', 'Not Now'); + + if (choice === 'Save as Default') { + await this.saveRuleAsDefault(rule); + } + } + + // Saves a rule as the default configuration with user choice of scope + public async saveRuleAsDefault(rule: PairingRule): Promise { + const {PairingRuleService} = await import('../pairing-rule-manager'); + + const scopeChoice = await vscode.window.showQuickPick( + [ + { + label: 'Save for this Workspace', + description: 'Recommended. Creates a .vscode/settings.json file.', + scope: 'workspace' + }, + { + label: 'Save for all my Projects (Global)', + description: 'Modifies your global user settings.', + scope: 'user' + } + ], + { + placeHolder: 'Where would you like to save this configuration?', + title: 'Save Configuration Scope' + }); + + if (!scopeChoice) { + return; + } + + try { + // Create a clean rule for saving (remove the 'custom' key suffix) + const cleanRule: PairingRule = { + ...rule, + key: rule.key.replace('_custom', ''), + label: `${rule.language.toUpperCase()} Pair (${rule.headerExt}/${ + rule.sourceExt})`, + description: `Creates a ${rule.headerExt}/${ + rule.sourceExt} file pair with header guards.` + }; + + await PairingRuleService.writeRules( + [cleanRule], scopeChoice.scope as 'workspace' | 'user'); + + vscode.window.showInformationMessage(`Successfully saved '${ + rule.headerExt}/${rule.sourceExt}' as the default extension for ${ + scopeChoice.scope === 'workspace' ? 'this workspace' + : 'all projects'}.`); + } catch (error) { + vscode.window.showErrorMessage(`Failed to save configuration: ${ + error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // Generates file content and writes both header and source files + public async generateAndWriteFiles(fileName: string, rule: PairingRule, + headerPath: vscode.Uri, + sourcePath: vscode.Uri): Promise { + const eol = this.getLineEnding(); + const {headerContent, sourceContent} = + this.generateFileContent(fileName, eol, rule); + await this.writeFiles(headerPath, sourcePath, headerContent, sourceContent); + } +} diff --git a/src/create-source-header-pair/templates.ts b/src/create-source-header-pair/templates.ts new file mode 100644 index 0000000..826d4e0 --- /dev/null +++ b/src/create-source-header-pair/templates.ts @@ -0,0 +1,157 @@ +// +// TEMPLATES AND CONSTANTS +// ======================= +// +// This module contains all the hardcoded data for file pair creation: +// - Template rules for different file types +// - File content templates with placeholders +// - Default placeholder names +// - Validation patterns +// + +import {PairingRule} from '../pairing-rule-manager'; + +// Types for better type safety +export type Language = 'c'|'cpp'; +export type TemplateKey = + 'CPP_CLASS'|'CPP_STRUCT'|'C_STRUCT'|'C_EMPTY'|'CPP_EMPTY'; + +// Regular expression patterns to validate C/C++ identifiers +export const VALIDATION_PATTERNS = { + IDENTIFIER: /^[a-zA-Z_][a-zA-Z0-9_]*$/ +}; + +// Default placeholder names for different file types +export const DEFAULT_PLACEHOLDERS = { + C_EMPTY: 'my_c_functions', + C_STRUCT: 'MyStruct', + CPP_EMPTY: 'utils', + CPP_CLASS: 'MyClass', + CPP_STRUCT: 'MyStruct' +}; + +// Template rules for available file pair types +export const TEMPLATE_RULES: PairingRule[] = [ + { + key: 'cpp_empty', + label: '$(new-file) C++ Pair', + description: 'Creates a basic Header/Source file pair with header guards.', + language: 'cpp' as const, + headerExt: '.h', + sourceExt: '.cpp' + }, + { + key: 'cpp_class', + label: '$(symbol-class) C++ Class', + description: + 'Creates a Header/Source file pair with a boilerplate class definition.', + language: 'cpp' as const, + headerExt: '.h', + sourceExt: '.cpp', + isClass: true + }, + { + key: 'cpp_struct', + label: '$(symbol-struct) C++ Struct', + description: + 'Creates a Header/Source file pair with a boilerplate struct definition.', + language: 'cpp' as const, + headerExt: '.h', + sourceExt: '.cpp', + isStruct: true + }, + { + key: 'c_empty', + label: '$(file-code) C Pair', + description: 'Creates a basic .h/.c file pair for function declarations.', + language: 'c' as const, + headerExt: '.h', + sourceExt: '.c' + }, + { + key: 'c_struct', + label: '$(symbol-struct) C Struct', + description: 'Creates a .h/.c file pair with a boilerplate typedef struct.', + language: 'c' as const, + headerExt: '.h', + sourceExt: '.c', + isStruct: true + } +]; + +// File templates with immutable structure +export const FILE_TEMPLATES = { + CPP_CLASS: { + header: `#ifndef {{headerGuard}} +#define {{headerGuard}} + +class {{fileName}} { +public: + {{fileName}}(); + ~{{fileName}}(); + +private: + // Add private members here +}; + +#endif // {{headerGuard}} +`, + source: `{{includeLine}} + +{{fileName}}::{{fileName}}() { + // Constructor implementation +} + +{{fileName}}::~{{fileName}}() { + // Destructor implementation +} +` + }, + CPP_STRUCT: { + header: `#ifndef {{headerGuard}} +#define {{headerGuard}} + +struct {{fileName}} { + // Struct members +}; + +#endif // {{headerGuard}} +`, + source: '{{includeLine}}' + }, + C_STRUCT: { + header: `#ifndef {{headerGuard}} +#define {{headerGuard}} + +typedef struct { + // Struct members +} {{fileName}}; + +#endif // {{headerGuard}} +`, + source: '{{includeLine}}' + }, + C_EMPTY: { + header: `#ifndef {{headerGuard}} +#define {{headerGuard}} + +// Declarations for {{fileName}}.c + +#endif // {{headerGuard}} +`, + source: `{{includeLine}} + +// Implementations for {{fileName}}.c +` + }, + CPP_EMPTY: { + header: `#ifndef {{headerGuard}} +#define {{headerGuard}} + +// Declarations for {{fileName}}.cpp + +#endif // {{headerGuard}} +`, + source: '{{includeLine}}' + } +}; diff --git a/src/create-source-header-pair/ui.ts b/src/create-source-header-pair/ui.ts new file mode 100644 index 0000000..86dacfb --- /dev/null +++ b/src/create-source-header-pair/ui.ts @@ -0,0 +1,597 @@ +// +// PAIR CREATOR UI +// =============== +// +// User interface layer for file pair creation functionality. +// Handles all user interactions, input validation, dialogs, +// and template selection. +// + +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { + PairingRule, + PairingRuleService, + PairingRuleUI +} from '../pairing-rule-manager'; + +import {PairCreatorService} from './service'; +import {Language, TEMPLATE_RULES, VALIDATION_PATTERNS} from './templates'; + +// Type to clearly express user intent when selecting custom rules +type CustomRuleSelection = + |{type: 'rule', rule: PairingRule} // User selected a specific rule +|{type: 'use_default'} // User wants to use default templates +|{type: 'cancelled'}; // User cancelled the operation + +// This type clearly expresses three distinct user intents: +// - 'rule': User selected a specific pairing rule and it should be used +// - 'use_default': User explicitly chose to use default templates and should +// proceed to the default template selection flow +// - 'cancelled': User pressed ESC to cancel and the entire flow should be +// terminated + +// PairCreatorUI handles all user interface interactions for file pair creation. +// It manages dialogs, input validation, and user choices. +export class PairCreatorUI { + private service: PairCreatorService; + + constructor(service: PairCreatorService) { this.service = service; } + + private adaptRuleForTemplateDisplay(rule: PairingRule): vscode.QuickPickItem + &PairingRule { + const categoryDesc = + rule.isClass ? 'Includes constructor, destructor, and basic structure' + : rule.isStruct + ? 'Simple data structure with member variables' + : `Basic ${ + rule.language.toUpperCase()} file pair with header guards`; + + return {...rule, description: categoryDesc, detail: rule.description}; + } + + // Converts a PairingRule to a QuickPickItem for custom rules selection + private adaptRuleForCustomRulesDisplay(rule: PairingRule, category: 'custom'| + 'builtin'|'alternative'): + vscode.QuickPickItem&PairingRule { + const categoryMap = { + custom: 'Custom configuration for this workspace', + builtin: 'Built-in template with custom extensions', + alternative: `Alternative ${rule.language.toUpperCase()} template option` + }; + + return { + ...rule, + description: categoryMap[category], + detail: rule.description + }; + } + + // Creates the special "Use Default Templates" option + private createUseDefaultOption(): vscode.QuickPickItem& + {key: string, isSpecial: boolean} { + return { + key: 'use_default', + label: '$(list-unordered) Use Default Templates', + description: 'Ignore custom settings and use built-in defaults', + detail: 'Standard .h/.cpp extensions', + isSpecial: true + }; + } + + // Gets custom rules for the specified language + private getCustomRulesForLanguage(allRules: PairingRule[], + language: 'c'|'cpp'): PairingRule[] { + return allRules.filter((rule: PairingRule) => rule.language === language); + } + + // Gets adapted built-in templates with custom extensions + private getAdaptedBuiltinTemplates(customRules: PairingRule[], + language: 'c'|'cpp'): PairingRule[] { + const customExt = customRules.length > 0 ? customRules[0] : null; + + if (customExt && language === 'cpp') { + return TEMPLATE_RULES + .filter(template => + template.language === 'cpp' && + !customRules.some( + customRule => + customRule.isClass === template.isClass && + customRule.isStruct === template.isStruct && + (customRule.isClass || customRule.isStruct || + (!customRule.isClass && !customRule.isStruct && + !template.isClass && !template.isStruct)))) + .map(template => ({ + ...template, + key: `${template.key}_adapted`, + headerExt: customExt.headerExt, + sourceExt: customExt.sourceExt, + description: + template.description + .replace(/Header\/Source/g, `${customExt.headerExt}/${ + customExt.sourceExt}`) + .replace(/\.h\/\.cpp/g, `${customExt.headerExt}/${ + customExt.sourceExt}`) + .replace(/basic \.h\/\.cpp/g, + `basic ${customExt.headerExt}/${ + customExt.sourceExt}`) + .replace(/Creates a \.h\/\.cpp/g, + `Creates a ${customExt.headerExt}/${ + customExt.sourceExt}`) + })); + } else { + return TEMPLATE_RULES + .filter(template => + template.language === language && + !customRules.some( + customRule => + customRule.headerExt === template.headerExt && + customRule.sourceExt === template.sourceExt && + customRule.isClass === template.isClass && + customRule.isStruct === template.isStruct)) + .map(template => this.adaptRuleForDisplay(template)); + } + } + + // Gets cross-language template options + private getCrossLanguageTemplates(language: 'c'|'cpp'): PairingRule[] { + return TEMPLATE_RULES.filter(template => template.language !== language) + .map(template => this.adaptRuleForDisplay(template)); + } + + // Cleans up custom rules with proper labels and descriptions + private cleanCustomRules(rules: PairingRule[]): PairingRule[] { + return rules.map( + rule => ({ + ...rule, + label: rule.label.includes('$(') + ? rule.label + : `$(new-file) ${ + rule.language === 'cpp' ? 'C++' : 'C'} Pair (${ + rule.headerExt}/${rule.sourceExt})`, + description: rule.description.startsWith('Creates a') + ? rule.description + : `Creates a ${rule.headerExt}/${ + rule.sourceExt} file pair with header guards.` + })); + } + + // Gets placeholder name for input dialog, considering active file context + private getPlaceholder(rule: PairingRule): string { + const activeEditor = vscode.window.activeTextEditor; + + if (activeEditor?.document && !activeEditor.document.isUntitled) { + const fileName = + path.basename(activeEditor.document.fileName, + path.extname(activeEditor.document.fileName)); + return rule.language === 'c' ? fileName + : this.service.toPascalCase(fileName); + } + + return this.service.getDefaultPlaceholder(rule); + } + + // Checks if language mismatch warning should be shown with UI context + private async shouldShowLanguageMismatchWarning(language: Language, + result: PairingRule): + Promise { + const activeEditor = vscode.window.activeTextEditor; + const currentDir = + activeEditor?.document && !activeEditor.document.isUntitled + ? path.dirname(activeEditor.document.uri.fsPath) + : undefined; + const activeFilePath = + activeEditor?.document && !activeEditor.document.isUntitled + ? activeEditor.document.uri.fsPath + : undefined; + + return this.service.shouldShowLanguageMismatchWarning( + language, result, currentDir, activeFilePath); + } + + // Adapts template rules for display in UI based on custom extensions + private adaptRuleForDisplay(rule: PairingRule): PairingRule { + if (rule.language !== 'cpp') { + return rule; + } + + const customExtensions = this.service.getCustomCppExtensions(); + if (!customExtensions) { + return rule; + } + + const {headerExt, sourceExt} = customExtensions; + + // Adapt description for display + const replacementPattern = + /Header\/Source|\.h(?:h|pp|xx)?\/\.c(?:pp|c|xx)?/g; + const newDescription = rule.description.replace( + replacementPattern, `${headerExt}/${sourceExt}`); + + return {...rule, description: newDescription, headerExt, sourceExt}; + } + + // Prepares template choices for UI display with proper ordering and + // adaptation + private prepareTemplateChoices(language: 'c'|'cpp', + uncertain: boolean): PairingRule[] { + const desiredOrder = + uncertain + ? ['cpp_empty', 'c_empty', 'cpp_class', 'cpp_struct', 'c_struct'] + : language === 'c' + ? ['c_empty', 'c_struct', 'cpp_empty', 'cpp_class', 'cpp_struct'] + : ['cpp_empty', 'cpp_class', 'cpp_struct', 'c_empty', 'c_struct']; + + return [...TEMPLATE_RULES] + .sort((a, b) => + desiredOrder.indexOf(a.key) - desiredOrder.indexOf(b.key)) + .map(rule => this.adaptRuleForDisplay(rule)); + } + + // Simplified function that delegates to smaller, focused functions + private prepareCustomRulesChoices(allRules: PairingRule[], + language: 'c'|'cpp'): { + languageRules: PairingRule[], + adaptedDefaultTemplates: PairingRule[], + otherLanguageTemplates: PairingRule[], + cleanedCustomRules: PairingRule[] + } { + const languageRules = this.getCustomRulesForLanguage(allRules, language); + const adaptedDefaultTemplates = + this.getAdaptedBuiltinTemplates(languageRules, language); + const otherLanguageTemplates = this.getCrossLanguageTemplates(language); + const cleanedCustomRules = this.cleanCustomRules(allRules); + + return { + languageRules, + adaptedDefaultTemplates, + otherLanguageTemplates, + cleanedCustomRules + }; + } + + // - Returns 'cancelled' if the user presses ESC to cancel, and the operation + // should be terminated + // - Returns 'use_default' if the user selects "Use Default Templates", and + // should proceed to the default template flow + // - Returns 'rule' if the user selects a specific rule, and that rule should + // be used directly + public async selectFromCustomRules(allRules: PairingRule[], language: 'c'| + 'cpp'): Promise { + const { + cleanedCustomRules, + adaptedDefaultTemplates, + otherLanguageTemplates + } = this.prepareCustomRulesChoices(allRules, language); + + // Use adapters to create consistent UI items + const choices = [ + ...cleanedCustomRules.map( + rule => this.adaptRuleForCustomRulesDisplay(rule, 'custom')), + ...adaptedDefaultTemplates.map( + rule => this.adaptRuleForCustomRulesDisplay(rule, 'builtin')), + ...otherLanguageTemplates.map( + rule => this.adaptRuleForCustomRulesDisplay(rule, 'alternative')), + this.createUseDefaultOption() + ]; + + const result = await vscode.window.showQuickPick(choices, { + placeHolder: `Select a ${language.toUpperCase()} pairing rule`, + title: 'Custom Pairing Rules Available', + matchOnDescription: true, + matchOnDetail: true + }); + + if (!result) + return {type: 'cancelled'}; // User pressed ESC or cancelled + if ('isSpecial' in result && result.isSpecial) + return {type: 'use_default'}; // User chose "Use Default Templates" + return { + type: 'rule', + rule: result as PairingRule + }; // User selected a specific rule + } + + // Shows a dialog offering to create custom pairing rules for C++. + // Only applicable for C++ since C uses standard .c/.h extensions. + // Returns true to create rules, false to dismiss, or null if cancelled. + public async offerToCreateCustomRules(language: 'c'| + 'cpp'): Promise { + if (language === 'c') + return false; + + const result = await vscode.window.showInformationMessage( + `No custom pairing rules found for C++. Would you like to create custom rules to use different file extensions (e.g., .cc/.hh instead of .cpp/.h)?`, + {modal: false}, 'Create Custom Rules', 'Dismiss'); + + return result === 'Create Custom Rules' ? true + : result === 'Dismiss' ? false + : null; + } + + // Guides the user through creating custom pairing rules for C++ + // Offers common extension combinations or allows custom input + // Saves the rule to workspace or global settings + // Returns the created custom rule or undefined if cancelled + public async createCustomRules(language: 'c'| + 'cpp'): Promise { + if (language === 'c') + return undefined; + + const commonExtensions = [ + {label: '.h / .cpp (Default)', headerExt: '.h', sourceExt: '.cpp'}, + {label: '.hh / .cc (Alternative)', headerExt: '.hh', sourceExt: '.cc'}, { + label: '.hpp / .cpp (Header Plus Plus)', + headerExt: '.hpp', + sourceExt: '.cpp' + }, + {label: '.hxx / .cxx (Extended)', headerExt: '.hxx', sourceExt: '.cxx'}, + {label: 'Custom Extensions', headerExt: '', sourceExt: ''} + ]; + + const selectedExtension = + await vscode.window.showQuickPick(commonExtensions, { + placeHolder: `Select file extensions for C++ files`, + title: 'Choose File Extensions' + }); + + if (!selectedExtension) + return undefined; + + let {headerExt, sourceExt} = selectedExtension; + + if (!headerExt || !sourceExt) { + const validateExt = (text: string) => + (!text || !text.startsWith('.') || text.length < 2) + ? 'Please enter a valid file extension starting with a dot (e.g., .h)' + : null; + + headerExt = await vscode.window.showInputBox({ + prompt: 'Enter header file extension (e.g., .h, .hh, .hpp)', + placeHolder: '.h', + validateInput: validateExt + }) || ''; + + if (!headerExt) + return undefined; + + sourceExt = await vscode.window.showInputBox({ + prompt: `Enter source file extension for C++ (e.g., .cpp, .cc, .cxx)`, + placeHolder: '.cpp', + validateInput: validateExt + }) || ''; + + if (!sourceExt) + return undefined; + } + + const customRule: PairingRule = { + key: `custom_cpp_${Date.now()}`, + label: `$(new-file) C++ Pair (${headerExt}/${sourceExt})`, + description: + `Creates a ${headerExt}/${sourceExt} file pair with header guards.`, + language: 'cpp', + headerExt, + sourceExt + }; + + const saveLocation = await vscode.window.showQuickPick( + [ + { + label: 'Workspace Settings', + description: 'Save to current workspace only', + value: 'workspace' + }, + { + label: 'Global Settings', + description: 'Save to user settings (available in all workspaces)', + value: 'user' + } + ], + { + placeHolder: 'Where would you like to save this custom rule?', + title: 'Save Location' + }); + + if (!saveLocation) + return undefined; + + try { + const existingRules = PairingRuleService.getRules( + saveLocation.value as 'workspace' | 'user') || + []; + await PairingRuleService.writeRules([...existingRules, customRule], + saveLocation.value as 'workspace' | + 'user'); + + const locationText = + saveLocation.value === 'workspace' ? 'workspace' : 'global'; + vscode.window.showInformationMessage( + `Custom pairing rule saved to ${locationText} settings.`); + + return customRule; + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error occurred'; + vscode.window.showErrorMessage( + `Failed to save custom rule: ${errorMessage}`); + return undefined; + } + } + + // Prompts the user to select a pairing rule from available options + // IMPROVED FLOW: + // 1. Check for existing custom rules first + // 2. If no custom rules, show template choice (C/C++ types) + // 3. If C++ selected, then choose extensions + // 4. After successful creation, offer to save as default + // Returns selected pairing rule or undefined if cancelled + public async promptForPairingRule(language: 'c'|'cpp', uncertain: boolean): + Promise { + + // First check if there are existing custom rules + if (language === 'cpp') { + const allRules = this.service.getAllPairingRules(); + const languageRules = + allRules.filter((rule: PairingRule) => rule.language === language); + + if (languageRules.length > 0) { + // Use existing flow for custom rules with clear intent handling + const result = await this.selectFromCustomRules(allRules, language); + if (result.type === 'cancelled') + return undefined; // 用户明确取消操作 + if (result.type === 'use_default') { + // 用户选择使用默认模板,继续到常规流程(不return,让代码继续执行) + } else if (result.type === 'rule') { + return result.rule; // 用户选择了具体的自定义规则 + } + } + } + + // New improved flow: Choose template type first (C/C++ language and type) + const templateChoice = + await this.promptForTemplateTypeFirst(language, uncertain); + if (!templateChoice) + return undefined; + + // If C++ template was selected and no custom rules exist, ask for + // extensions + if (templateChoice.language === 'cpp') { + const allRules = this.service.getAllPairingRules(); + const cppRules = + allRules.filter((rule: PairingRule) => rule.language === 'cpp'); + + if (cppRules.length === 0) { + // No custom C++ rules, let user choose extensions + const extensionChoice = await PairingRuleUI.promptForFileExtensions(); + if (!extensionChoice) + return undefined; + + // Apply the chosen extensions to the template + return { + ...templateChoice, + key: `${templateChoice.key}_custom`, + headerExt: extensionChoice.headerExt, + sourceExt: extensionChoice.sourceExt, + description: templateChoice.description.replace( + /\.h\/\.cpp/g, + `${extensionChoice.headerExt}/${extensionChoice.sourceExt}`) + }; + } + } + + return templateChoice; + } + + // Simplified template selection with adapter pattern + private async promptForTemplateTypeFirst(language: 'c'|'cpp', + uncertain: boolean): + Promise { + const choices = this.prepareTemplateChoices(language, uncertain); + const enhancedChoices = + choices.map(rule => this.adaptRuleForTemplateDisplay(rule)); + + const result = await vscode.window.showQuickPick(enhancedChoices, { + placeHolder: 'Please select the type of file pair to create.', + title: 'Create Source/Header Pair', + matchOnDescription: true, + matchOnDetail: true + }); + + if (!result) + return null; // User cancelled + + if (result && !uncertain && language !== result.language) { + const shouldShowWarning = + await this.shouldShowLanguageMismatchWarning(language, result); + + if (shouldShowWarning) { + const detectedLangName = language === 'c' ? 'C' : 'C++'; + const selectedLangName = result.language === 'c' ? 'C' : 'C++'; + + const shouldContinue = await vscode.window.showWarningMessage( + `You're working in a ${detectedLangName} file but selected a ${ + selectedLangName} template. This may create files with incompatible extensions or content.`, + 'Continue', 'Cancel'); + + if (shouldContinue !== 'Continue') + return null; + } + } + + return result; + } + + // Shows workspace folder picker when multiple folders are available + public async showWorkspaceFolderPicker(): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length <= 1) { + return undefined; + } + + const selected = await vscode.window.showQuickPick( + workspaceFolders.map(folder => ({ + label: folder.name, + description: folder.uri.fsPath, + folder: folder + })), + {placeHolder: 'Select workspace folder for new files'}); + + return selected?.folder.uri; + } + + // Prompts the user to enter a name for the new file pair + // Validates input as a valid C/C++ identifier and provides + // context-appropriate prompts Returns the entered file name or undefined if + // cancelled + public async promptForFileName(rule: PairingRule): Promise { + const prompt = rule.isClass ? 'Please enter the name for the new C++ class.' + : rule.isStruct + ? `Please enter the name for the new ${ + rule.language.toUpperCase()} struct.` + : `Please enter the base name for the new ${ + rule.language.toUpperCase()} file pair.`; + + return vscode.window.showInputBox({ + prompt, + placeHolder: this.getPlaceholder(rule), + validateInput: (text) => + VALIDATION_PATTERNS.IDENTIFIER.test(text?.trim() || '') + ? null + : 'Invalid C/C++ identifier.', + title: 'Create Source/Header Pair' + }); + } + + // Shows success message and opens the newly created header file + public async showSuccessAndOpenFile(headerPath: vscode.Uri, + sourcePath: vscode.Uri): Promise { + try { + const document = await vscode.workspace.openTextDocument(headerPath); + + // Use setTimeout to make this non-blocking and avoid hanging + setTimeout(async () => { + try { + await vscode.window.showTextDocument(document); + } catch (error) { + // Silently handle file opening errors + } + }, 100); + + // Show success message with slight delay to allow other dialogs to appear + setTimeout(() => { + vscode.window.showInformationMessage( + `Successfully created ${path.basename(headerPath.fsPath)} and ${ + path.basename(sourcePath.fsPath)}.`); + }, 50); + } catch (error) { + // Still show success message even if file opening fails + setTimeout(() => { + vscode.window.showInformationMessage( + `Successfully created ${path.basename(headerPath.fsPath)} and ${ + path.basename(sourcePath.fsPath)}.`); + }, 50); + } + } +} diff --git a/src/extension.ts b/src/extension.ts index 815866f..e982659 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,6 +5,10 @@ import {ClangdExtension} from '../api/vscode-clangd'; import {ClangdExtensionImpl} from './api'; import {ClangdContext} from './clangd-context'; import {get, update} from './config'; +import { + registerCreateSourceHeaderPairCommand +} from './create-source-header-pair/index'; +import {showConfigurationWizard} from './pairing-rule-manager'; let apiInstance: ClangdExtensionImpl|undefined; @@ -52,8 +56,11 @@ export async function activate(context: vscode.ExtensionContext): clangdContext.dispose(); clangdContext = await ClangdContext.create(context.globalStoragePath, outputChannel); - if (clangdContext) + if (clangdContext) { context.subscriptions.push(clangdContext); + + registerCreateSourceHeaderPairCommand(clangdContext); + } if (apiInstance) { apiInstance.client = clangdContext?.client; } @@ -64,9 +71,12 @@ export async function activate(context: vscode.ExtensionContext): if (vscode.workspace.getConfiguration('clangd').get('enable')) { clangdContext = await ClangdContext.create(context.globalStoragePath, outputChannel); - if (clangdContext) + if (clangdContext) { context.subscriptions.push(clangdContext); + registerCreateSourceHeaderPairCommand(clangdContext); + } + shouldCheck = vscode.workspace.getConfiguration('clangd').get( 'detectExtensionConflicts') ?? false; @@ -103,7 +113,8 @@ export async function activate(context: vscode.ExtensionContext): } }, 5000); } - + context.subscriptions.push(vscode.commands.registerCommand( + 'clangd.createPair.configureRules', showConfigurationWizard)); apiInstance = new ClangdExtensionImpl(clangdContext?.client); return apiInstance; -} +} \ No newline at end of file diff --git a/src/pairing-rule-manager.ts b/src/pairing-rule-manager.ts new file mode 100644 index 0000000..d775894 --- /dev/null +++ b/src/pairing-rule-manager.ts @@ -0,0 +1,329 @@ +import * as vscode from 'vscode'; + +// Public interface for pairing rules +export interface PairingRule { + key: string; + label: string; + description: string; + language: 'c'|'cpp'; + headerExt: string; + sourceExt: string; + isClass?: boolean; + isStruct?: boolean; +} + +// Type aliases for QuickPick items +type RuleQuickPickItem = vscode.QuickPickItem&{rule: PairingRule}; +type ActionQuickPickItem = vscode.QuickPickItem&{key: string}; + +// Configuration management service class +export class PairingRuleService { + private static readonly CONFIG_KEY = 'createPair.rules'; + + // Validate a single pairing rule to ensure it has all required fields + private static validateRule(rule: PairingRule): void { + if (!rule.key || !rule.language || !rule.headerExt || !rule.sourceExt) { + throw new Error(`Invalid rule: ${JSON.stringify(rule)}`); + } + } + + // Show error message and re-throw the error for proper error handling + private static handleError(error: unknown, operation: string, + scope: string): never { + const message = `Failed to ${operation} pairing rules for ${scope}: ${ + error instanceof Error ? error.message : 'Unknown error'}`; + vscode.window.showErrorMessage(message); + throw error; + } + + // Get the currently active pairing rules from configuration + public static getActiveRules(): ReadonlyArray { + return vscode.workspace.getConfiguration('clangd').get( + PairingRuleService.CONFIG_KEY, []); + } + + // Check if custom rules exist for the specified scope (workspace or user) + public static hasCustomRules(scope: 'workspace'|'user'): boolean { + const inspection = + vscode.workspace.getConfiguration('clangd').inspect( + PairingRuleService.CONFIG_KEY); + const value = scope === 'workspace' ? inspection?.workspaceValue + : inspection?.globalValue; + return Array.isArray(value); + } + + // Get pairing rules for a specific scope (workspace or user) + public static getRules(scope: 'workspace'|'user'): PairingRule[]|undefined { + const inspection = + vscode.workspace.getConfiguration('clangd').inspect( + PairingRuleService.CONFIG_KEY); + return scope === 'workspace' ? inspection?.workspaceValue + : inspection?.globalValue; + } + + // Write pairing rules to the specified scope (workspace or user) + public static async writeRules(rules: PairingRule[], + scope: 'workspace'|'user'): Promise { + try { + if (!Array.isArray(rules)) + throw new Error('Rules must be an array'); + rules.forEach(PairingRuleService.validateRule); + + const target = scope === 'workspace' + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global; + await vscode.workspace.getConfiguration('clangd').update( + PairingRuleService.CONFIG_KEY, rules, target); + } catch (error) { + PairingRuleService.handleError(error, 'save', scope); + } + } + + // Reset pairing rules for the specified scope (remove custom rules) + public static async resetRules(scope: 'workspace'|'user'): Promise { + try { + const target = scope === 'workspace' + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global; + await vscode.workspace.getConfiguration('clangd').update( + PairingRuleService.CONFIG_KEY, undefined, target); + } catch (error) { + PairingRuleService.handleError(error, 'reset', scope); + } + } +} + +// User interface management class +export class PairingRuleUI { + // Predefined extension combinations for C++ file pairs + private static readonly EXTENSION_OPTIONS = [ + { + label: '$(file-code) .h / .cpp', + description: 'Standard C++ extensions', + detail: 'Widely used, compatible with most tools and IDEs.', + headerExt: '.h', + sourceExt: '.cpp', + language: 'cpp' as const, + }, + { + label: '$(file-code) .hh / .cc', + description: 'Alternative C++ extensions', + detail: 'Used by Google style guide and some projects.', + headerExt: '.hh', + sourceExt: '.cc', + language: 'cpp' as const, + }, + { + label: '$(file-code) .hpp / .cpp', + description: 'Header Plus Plus style', + detail: 'Explicitly indicates C++ headers.', + headerExt: '.hpp', + sourceExt: '.cpp', + language: 'cpp' as const, + }, + { + label: '$(file-code) .hxx / .cxx', + description: 'Extended C++ extensions', + detail: 'Less common but explicit C++ indicator.', + headerExt: '.hxx', + sourceExt: '.cxx', + language: 'cpp' as const, + }, + ]; + + // Create rule choices from extension options for QuickPick display + private static createRuleChoices(): RuleQuickPickItem[] { + return PairingRuleUI.EXTENSION_OPTIONS.map( + (option, index) => ({ + label: option.label, + description: option.description, + detail: option.detail, + rule: { + key: `custom_${option.language}_${index}`, + label: `${option.language.toUpperCase()} Pair (${ + option.headerExt}/${option.sourceExt})`, + description: `Creates a ${option.headerExt}/${ + option.sourceExt} file pair with header guards.`, + language: option.language, + headerExt: option.headerExt, + sourceExt: option.sourceExt, + }, + })); + } + + // Create advanced options separator and menu item for advanced management + private static createAdvancedOptions(): ActionQuickPickItem[] { + return [ + { + label: 'Advanced Management', + kind: vscode.QuickPickItemKind.Separator, + key: 'separator', + }, + { + label: '$(settings-gear) Advanced Options...', + description: 'Edit or reset rules manually', + key: 'advanced_options', + }, + ]; + } + + // Create advanced menu items based on current settings and available actions + private static createAdvancedMenuItems(): ActionQuickPickItem[] { + const items: ActionQuickPickItem[] = [ + { + label: '$(edit) Edit Workspace Rules...', + description: 'Opens .vscode/settings.json', + key: 'edit_workspace', + }, + ]; + + if (PairingRuleService.hasCustomRules('workspace')) { + items.push({ + label: '$(clear-all) Reset Workspace Rules', + description: 'Use global or default rules instead', + key: 'reset_workspace', + }); + } + + items.push({ + label: 'Global (User) Settings', + kind: vscode.QuickPickItemKind.Separator, + key: 'separator_global', + }, + { + label: '$(edit) Edit Global Rules...', + description: 'Opens your global settings.json', + key: 'edit_global', + }); + + if (PairingRuleService.hasCustomRules('user')) { + items.push({ + label: '$(clear-all) Reset Global Rules', + description: 'Use the extension default rules instead', + key: 'reset_global', + }); + } + + return items; + } + + // Handle rule selection and ask for save scope (workspace or global) + private static async handleRuleSelection(rule: PairingRule): Promise { + const selection = await vscode.window.showQuickPick( + [ + { + label: 'Save for this Workspace', + description: 'Recommended. Creates a .vscode/settings.json file.', + scope: 'workspace', + }, + { + label: 'Save for all my Projects (Global)', + description: 'Modifies your global user settings.', + scope: 'user', + }, + ], + { + placeHolder: 'Where would you like to save this rule?', + title: 'Save Configuration Scope', + }); + + if (!selection) + return; + + await PairingRuleService.writeRules([rule], selection.scope as 'workspace' | + 'user'); + vscode.window.showInformationMessage(`Successfully set '${ + rule.label}' as the default extension for the ${selection.scope}.`); + } + + // Handle advanced menu selection and execute the corresponding action + private static async handleAdvancedMenuSelection(key: string): Promise { + const actions = { + edit_workspace: () => vscode.commands.executeCommand( + 'workbench.action.openWorkspaceSettingsFile'), + edit_global: () => + vscode.commands.executeCommand('workbench.action.openSettingsJson'), + reset_workspace: async () => { + await PairingRuleService.resetRules('workspace'); + vscode.window.showInformationMessage( + 'Workspace pairing rules have been reset.'); + }, + reset_global: async () => { + await PairingRuleService.resetRules('user'); + vscode.window.showInformationMessage( + 'Global pairing rules have been reset.'); + }, + }; + + const action = actions[key as keyof typeof actions]; + if (action) + await action(); + } + + // Public method to prompt for file extensions only + public static async promptForFileExtensions(): + Promise<{headerExt: string, sourceExt: string}|undefined> { + const selected = await vscode.window.showQuickPick( + PairingRuleUI.EXTENSION_OPTIONS.map(option => ({ + label: option.label, + description: option.description, + detail: option.detail, + headerExt: option.headerExt, + sourceExt: option.sourceExt + })), + { + placeHolder: 'Choose file extensions for your C++ files', + title: 'Step 1 of 2: Select File Extensions', + matchOnDescription: true, + matchOnDetail: true + }); + + return selected + ? {headerExt: selected.headerExt, sourceExt: selected.sourceExt} + : undefined; + } + + // Main configuration wizard - entry point for setting up pairing rules + public static async showConfigurationWizard(): Promise { + const quickPick = + vscode.window.createQuickPick(); + quickPick.title = 'Quick Setup: Choose File Extensions'; + quickPick.placeholder = + 'Choose file extension combination for this workspace, or go to advanced options.'; + quickPick.items = [ + ...PairingRuleUI.createRuleChoices(), + ...PairingRuleUI.createAdvancedOptions() + ]; + + quickPick.onDidChangeSelection(async (selection) => { + if (!selection[0]) + return; + quickPick.hide(); + + const item = selection[0]; + if ('rule' in item) { + await PairingRuleUI.handleRuleSelection(item.rule); + } else if (item.key === 'advanced_options') { + await PairingRuleUI.showAdvancedManagementMenu(); + } + }); + + quickPick.onDidHide(() => quickPick.dispose()); + quickPick.show(); + } + + // Advanced management menu for editing and resetting pairing rules + public static async showAdvancedManagementMenu(): Promise { + const selection = await vscode.window.showQuickPick( + PairingRuleUI.createAdvancedMenuItems(), { + title: 'Advanced Rule Management', + }); + + if (selection?.key) { + await PairingRuleUI.handleAdvancedMenuSelection(selection.key); + } + } +} + +// Backward compatibility export for the main configuration wizard +export const showConfigurationWizard = PairingRuleUI.showConfigurationWizard; \ No newline at end of file diff --git a/vscode-clangd-fancy-0.2.1.vsix b/vscode-clangd-fancy-0.2.1.vsix new file mode 100644 index 0000000..f2aab52 Binary files /dev/null and b/vscode-clangd-fancy-0.2.1.vsix differ