From aea71cb334233f0c1dc8df9463719c209bba5209 Mon Sep 17 00:00:00 2001 From: PcGnCLwnCm9EgY56mAmL Date: Sat, 31 Jan 2026 20:27:25 +0100 Subject: [PATCH] feat: add Go to Definition and Hover support for Gherkin steps - Add DefinitionProvider for navigating from Gherkin steps to Robot Framework keywords - Add HoverProvider for displaying keyword information on hover - Support for parsing .resource files in steps directories - Intelligent keyword matching with variable normalization - Extension metadata updates for enhanced Gherkin support --- GOTO_DEFINITION.md | 85 +++++++++ README.md | 15 +- package.json | 8 +- vscode-client/definitionProvider.ts | 221 ++++++++++++++++++++++ vscode-client/extension.ts | 10 + vscode-client/hoverProvider.ts | 280 ++++++++++++++++++++++++++++ 6 files changed, 614 insertions(+), 5 deletions(-) create mode 100644 GOTO_DEFINITION.md create mode 100644 vscode-client/definitionProvider.ts create mode 100644 vscode-client/hoverProvider.ts diff --git a/GOTO_DEFINITION.md b/GOTO_DEFINITION.md new file mode 100644 index 0000000..26694d7 --- /dev/null +++ b/GOTO_DEFINITION.md @@ -0,0 +1,85 @@ +# Go to Definition Support for Gherkin Steps + +This fork adds **Go to Definition** functionality for Gherkin steps in VS Code. When you Ctrl+Click (or F12) on a Gherkin step in a `.feature` file, the extension will now navigate to the corresponding keyword definition in your Robot Framework `.resource` files. + +## Features Added + +### 1. **Go to Definition** +- **Ctrl+Click** or press **F12** on any Gherkin step to jump to its implementation +- Searches for keyword definitions in `.resource` files within `steps/` directories +- Supports both exact matches and pattern matching for steps with variables + +### 2. **Hover Information** +- **Hover** over Gherkin steps to see: + - Keyword signature + - File location with clickable link + - Arguments and their types + - Tags (if any) + - Documentation (if available) + +### 3. **Variable Support** +- Handles Robot Framework variables like `${variable}` in step definitions +- Matches steps with quoted strings and variable placeholders +- Fuzzy matching for similar keywords + +## How It Works + +The extension: +1. **Caches keyword definitions** from `.resource` files in your workspace +2. **Parses Robot Framework syntax** to extract keyword names, arguments, and documentation +3. **Matches Gherkin steps** to keyword definitions using multiple strategies: + - Exact text matching (case-insensitive) + - Pattern matching for variables (e.g., `${name}` becomes a capture group) + - Fuzzy matching for partial word matches + +## Example + +Given this Gherkin step: +```gherkin +Given a blog post named "My First Post" with Markdown body +``` + +And this Robot Framework keyword definition in a `.resource` file: +```robotframework +*** Keywords *** +a blog post named "${name}" with Markdown body + [Tags] gherkin:step + [Arguments] ${body} + + Log ${name} + Log ${body} +``` + +You can now: +- **Ctrl+Click** on the step to navigate to the keyword definition +- **Hover** to see the keyword signature and documentation + +## File Structure Support + +The extension looks for keyword definitions in: +- `**/steps/**/*.resource` (primary location for step definitions) +- `**/*.resource` (fallback for any `.resource` files in the workspace) + +## Installation + +This enhanced version works as a drop-in replacement for the original RobotCode Gherkin extension. Simply: + +1. Install the modified extension +2. Open a workspace with `.feature` files and corresponding `.resource` files +3. Start using Go to Definition and Hover features! + +## Technical Details + +### Architecture +- **Definition Provider**: Implements VS Code's `vscode.DefinitionProvider` interface +- **Hover Provider**: Implements VS Code's `vscode.HoverProvider` interface +- **Caching**: Intelligent caching with 30-second timeout to balance performance and freshness +- **Pattern Matching**: Advanced regex patterns to handle Robot Framework variable substitution + +### Performance +- **Lazy Loading**: Only parses files when needed +- **Cached Results**: Avoids re-parsing files on every request +- **Efficient Search**: Uses Maps for O(1) keyword lookups +- **Background Processing**: Non-blocking file parsing + +This enhancement significantly improves the developer experience when working with Gherkin feature files in Robot Framework projects by providing the navigation features developers expect from modern IDEs. \ No newline at end of file diff --git a/README.md b/README.md index dd57f68..831cae7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,17 @@ -# **Robot Framework Gherkin Parser**: Quick Overview +# **Robot Framework Gherkin Parser (Extended)**: Quick Overview + +The **Robot Framework Gherkin Parser (Extended)** enables seamless integration of Gherkin feature files with the **Robot Framework**, facilitating behavior-driven development (BDD) with enhanced IDE support. This extended version adds **Go to Definition** and **Hover** functionality for Gherkin steps in VS Code, allowing you to navigate directly from Gherkin steps to their Robot Framework keyword implementations. + +## 🆕 New Features in Extended Version + +- **Go to Definition**: Ctrl+Click or F12 on any Gherkin step to jump to its keyword implementation in `.resource` files +- **Hover Information**: Hover over steps to see keyword signatures, documentation, arguments, and file locations +- **Variable Support**: Handles Robot Framework variables (`${variable}`) in step definitions +- **Smart Matching**: Supports exact matches, pattern matching, and fuzzy matching for keyword discovery + +[**📖 Read detailed documentation about Go to Definition features**](./GOTO_DEFINITION.md) + +--- The **Robot Framework Gherkin Parser** enables seamless integration of Gherkin feature files with the **Robot Framework**, facilitating behavior-driven development (BDD) with ease. This integration not only allows for the flexible execution of Gherkin feature files alongside **Robot Framework** test files but also highlights the complementary strengths of both approaches. Gherkin feature files, with their less technical and more scenario-focused syntax, emphasize the behavioral aspects of what is being tested, rather than the how. In contrast, **Robot Framework** test files tend to be more technical, focusing on the step-by-step implementation of test scenarios through keyword sequences. diff --git a/package.json b/package.json index a1ae43e..8f11795 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "robotcode-gherkin", - "displayName": "RobotCode GherkinParser Support", - "description": "GherkinParser Support for RobotCode and Robot Framework", + "name": "robotcode-gherkin-extended", + "displayName": "RobotCode GherkinParser Support (Extended)", + "description": "GherkinParser Support for RobotCode and Robot Framework with Go to Definition", "icon": "icons/icon.png", "publisher": "d-biehl", - "version": "0.3.2", + "version": "0.4.0", "author": { "name": "Daniel Biehl", "url": "https://github.com/d-biehl/" diff --git a/vscode-client/definitionProvider.ts b/vscode-client/definitionProvider.ts new file mode 100644 index 0000000..7139694 --- /dev/null +++ b/vscode-client/definitionProvider.ts @@ -0,0 +1,221 @@ +import * as vscode from "vscode"; + +interface KeywordMatch { + keyword: string; + file: string; + line: number; + pattern?: RegExp; + priority?: number; +} + +export class GherkinDefinitionProvider implements vscode.DefinitionProvider { + private keywordCache: Map = new Map(); + private cacheTimestamp: number = 0; + private readonly CACHE_TIMEOUT = 30000; // 30 seconds + + async provideDefinition( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken + ): Promise { + // Check if we're in a Gherkin step + const line = document.lineAt(position.line); + const stepMatch = line.text.match(/^\s*(Given|When|Then|And|But)\s+(.+)$/i); + + if (!stepMatch) { + return undefined; + } + + const stepText = stepMatch[2].trim(); + console.log(`[GherkinDefinitionProvider] Looking for definition of: "${stepText}"`); + + // Refresh keyword cache if needed + await this.refreshKeywordCache(document.uri); + + // Find matching keywords + const matches = this.findMatchingKeywords(stepText); + console.log(`[GherkinDefinitionProvider] Found ${matches.length} matches:`, matches.map(m => `${m.keyword} (priority: ${m.priority})`)); + + if (matches.length === 0) { + console.log(`[GherkinDefinitionProvider] No matches found for: "${stepText}"`); + return undefined; + } + + // Convert matches to VS Code locations + const definitions: vscode.Location[] = []; + for (const match of matches) { + try { + const uri = vscode.Uri.file(match.file); + const location = new vscode.Location(uri, new vscode.Position(match.line, 0)); + definitions.push(location); + } catch (error) { + console.error(`Error creating location for ${match.file}:${match.line}`, error); + } + } + + return definitions.length > 0 ? definitions : undefined; + } + + private async refreshKeywordCache(documentUri: vscode.Uri): Promise { + const now = Date.now(); + if (now - this.cacheTimestamp < this.CACHE_TIMEOUT && this.keywordCache.size > 0) { + return; + } + + this.keywordCache.clear(); + this.cacheTimestamp = now; + + // Find workspace folder + const workspaceFolder = vscode.workspace.getWorkspaceFolder(documentUri); + if (!workspaceFolder) { + return; + } + + // Look for .resource files in steps directories + const pattern = new vscode.RelativePattern(workspaceFolder, "**/steps/**/*.resource"); + const resourceFiles = await vscode.workspace.findFiles(pattern); + + // Also look for .resource files in the general workspace (as a fallback) + const generalPattern = new vscode.RelativePattern(workspaceFolder, "**/*.resource"); + const allResourceFiles = await vscode.workspace.findFiles(generalPattern); + + // Combine and deduplicate + const uniqueFiles = [...new Set([...resourceFiles, ...allResourceFiles])]; + + // Parse each resource file + for (const fileUri of uniqueFiles) { + await this.parseResourceFile(fileUri); + } + } + + private async parseResourceFile(fileUri: vscode.Uri): Promise { + try { + const document = await vscode.workspace.openTextDocument(fileUri); + const content = document.getText(); + const lines = content.split(/\r?\n/); + + let inKeywordsSection = false; + let currentKeyword: string | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmedLine = line.trim(); + + // Check for *** Keywords *** section + if (trimmedLine.match(/^\*\*\*\s*Keywords\s*\*\*\*$/i)) { + inKeywordsSection = true; + continue; + } + + // Check for other sections (*** Settings ***, *** Variables ***, etc.) + if (trimmedLine.match(/^\*\*\*.*\*\*\*$/)) { + inKeywordsSection = false; + continue; + } + + if (inKeywordsSection && trimmedLine) { + // Check if this is a keyword definition (not indented or minimally indented) + if (!line.startsWith(' ') && !line.startsWith('\t') && trimmedLine !== '') { + // Skip lines that are clearly not keyword definitions + if (trimmedLine.startsWith('[') || trimmedLine.startsWith('#')) { + continue; + } + + currentKeyword = trimmedLine; + + // Store the keyword + const filePath = fileUri.fsPath; + const keywordMatch: KeywordMatch = { + keyword: currentKeyword, + file: filePath, + line: i, + pattern: this.createKeywordPattern(currentKeyword) + }; + + if (!this.keywordCache.has(currentKeyword.toLowerCase())) { + this.keywordCache.set(currentKeyword.toLowerCase(), []); + } + this.keywordCache.get(currentKeyword.toLowerCase())!.push(keywordMatch); + } + } + } + } catch (error) { + console.error(`Error parsing resource file ${fileUri.fsPath}:`, error); + } + } + + private createKeywordPattern(keyword: string): RegExp { + // Handle Robot Framework variable patterns like ${variable} + // Convert them to regex patterns that can match actual values + + // Escape special regex characters except ${} patterns + let pattern = keyword + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars + .replace(/\\\$\\\{[^}]+\\\}/g, '(.+?)'); // Convert ${var} to capture groups + + // Handle quoted strings with variables + pattern = pattern.replace(/["'](.+?)["']/g, (match, content) => { + // If the content has variables, make the quotes optional + if (content.includes('${')) { + return `(?:["']${content}["']|${content})`; + } + return match; + }); + + // Make the pattern case-insensitive and allow for flexible whitespace + pattern = pattern.replace(/\s+/g, '\\s+'); + + return new RegExp(`^${pattern}$`, 'i'); + } + + private findMatchingKeywords(stepText: string): KeywordMatch[] { + const matches: KeywordMatch[] = []; + + // Clean the step text - remove quotes and normalize + const cleanStepText = stepText.replace(/^["']|["']$/g, '').trim(); + + for (const [, keywordMatches] of this.keywordCache) { + for (const keywordMatch of keywordMatches) { + // Normalize both step and keyword for comparison + const normalizedStep = this.normalizeForMatching(cleanStepText); + const normalizedKeyword = this.normalizeForMatching(keywordMatch.keyword); + + console.log(`[GherkinDefinitionProvider] Comparing:`); + console.log(` Step: "${cleanStepText}" -> normalized: "${normalizedStep}"`); + console.log(` Keyword: "${keywordMatch.keyword}" -> normalized: "${normalizedKeyword}"`); + + // Exact match after normalization - highest priority + if (normalizedStep === normalizedKeyword) { + console.log(` -> EXACT MATCH!`); + matches.push({ ...keywordMatch, priority: 1 }); + continue; + } + + // Pattern match with variables - high priority + if (keywordMatch.pattern && keywordMatch.pattern.test(cleanStepText)) { + console.log(` -> PATTERN MATCH!`); + matches.push({ ...keywordMatch, priority: 2 }); + continue; + } + + console.log(` -> No match`); + } + } + + // Sort by priority (lower number = higher priority) + return matches.sort((a, b) => (a.priority || 999) - (b.priority || 999)); + } + + private normalizeForMatching(text: string): string { + return text + .toLowerCase() + // Remove Gherkin keywords at the start + .replace(/^(given|when|then|and|but)\s+/i, '') + // Replace variables with placeholder + .replace(/\$\{[^}]+\}/g, 'VAR') + // Normalize whitespace and punctuation + .replace(/[^\w\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } +} \ No newline at end of file diff --git a/vscode-client/extension.ts b/vscode-client/extension.ts index 83f39b9..3b4752e 100644 --- a/vscode-client/extension.ts +++ b/vscode-client/extension.ts @@ -1,5 +1,8 @@ import * as vscode from "vscode"; import { GherkinFormattingEditProvider } from "./formattingEditProvider"; +import { GherkinDefinitionProvider } from "./definitionProvider"; +import { GherkinHoverProvider } from "./hoverProvider"; + export async function activateAsync(context: vscode.ExtensionContext): Promise { const robotcode = vscode.extensions.getExtension("d-biehl.robotcode"); if (!robotcode) { @@ -11,8 +14,15 @@ export async function activateAsync(context: vscode.ExtensionContext): Promise = new Map(); + private cacheTimestamp: number = 0; + private readonly CACHE_TIMEOUT = 30000; // 30 seconds + + async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken + ): Promise { + // Check if we're in a Gherkin step + const line = document.lineAt(position.line); + const stepMatch = line.text.match(/^\s*(Given|When|Then|And|But)\s+(.+)$/i); + + if (!stepMatch) { + return undefined; + } + + const stepText = stepMatch[2].trim(); + + // Refresh keyword cache if needed + await this.refreshKeywordCache(document.uri); + + // Find matching keywords + const matches = this.findMatchingKeywords(stepText); + + if (matches.length === 0) { + return undefined; + } + + // Create hover content + const markdownContent = new vscode.MarkdownString(); + markdownContent.isTrusted = true; + + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + + if (i > 0) { + markdownContent.appendMarkdown("\n\n---\n\n"); + } + + // Keyword name + markdownContent.appendCodeblock(match.keyword, "robotframework"); + + // File location + const relativePath = vscode.workspace.asRelativePath(match.file); + markdownContent.appendMarkdown(`\n**Location:** [${relativePath}:${match.line + 1}](${vscode.Uri.file(match.file)}#${match.line + 1})`); + + // Arguments + if (match.arguments && match.arguments.length > 0) { + markdownContent.appendMarkdown(`\n\n**Arguments:** ${match.arguments.join(", ")}`); + } + + // Tags + if (match.tags && match.tags.length > 0) { + markdownContent.appendMarkdown(`\n\n**Tags:** ${match.tags.join(", ")}`); + } + + // Documentation + if (match.documentation) { + markdownContent.appendMarkdown(`\n\n**Documentation:**\n${match.documentation}`); + } + } + + return new vscode.Hover(markdownContent); + } + + private async refreshKeywordCache(documentUri: vscode.Uri): Promise { + const now = Date.now(); + if (now - this.cacheTimestamp < this.CACHE_TIMEOUT && this.keywordCache.size > 0) { + return; + } + + this.keywordCache.clear(); + this.cacheTimestamp = now; + + // Find workspace folder + const workspaceFolder = vscode.workspace.getWorkspaceFolder(documentUri); + if (!workspaceFolder) { + return; + } + + // Look for .resource files in steps directories + const pattern = new vscode.RelativePattern(workspaceFolder, "**/steps/**/*.resource"); + const resourceFiles = await vscode.workspace.findFiles(pattern); + + // Also look for .resource files in the general workspace (as a fallback) + const generalPattern = new vscode.RelativePattern(workspaceFolder, "**/*.resource"); + const allResourceFiles = await vscode.workspace.findFiles(generalPattern); + + // Combine and deduplicate + const uniqueFiles = [...new Set([...resourceFiles, ...allResourceFiles])]; + + // Parse each resource file + for (const fileUri of uniqueFiles) { + await this.parseResourceFile(fileUri); + } + } + + private async parseResourceFile(fileUri: vscode.Uri): Promise { + try { + const document = await vscode.workspace.openTextDocument(fileUri); + const content = document.getText(); + const lines = content.split(/\r?\n/); + + let inKeywordsSection = false; + let currentKeywordInfo: KeywordInfo | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmedLine = line.trim(); + + // Check for *** Keywords *** section + if (trimmedLine.match(/^\*\*\*\s*Keywords\s*\*\*\*$/i)) { + inKeywordsSection = true; + continue; + } + + // Check for other sections + if (trimmedLine.match(/^\*\*\*.*\*\*\*$/)) { + inKeywordsSection = false; + if (currentKeywordInfo) { + this.addKeywordToCache(currentKeywordInfo); + currentKeywordInfo = null; + } + continue; + } + + if (inKeywordsSection && trimmedLine) { + // Check if this is a keyword definition + if (!line.startsWith(' ') && !line.startsWith('\t') && trimmedLine !== '') { + // Save previous keyword if exists + if (currentKeywordInfo) { + this.addKeywordToCache(currentKeywordInfo); + } + + // Skip lines that are clearly not keyword definitions + if (trimmedLine.startsWith('[') || trimmedLine.startsWith('#')) { + continue; + } + + // Start new keyword + currentKeywordInfo = { + keyword: trimmedLine, + file: fileUri.fsPath, + line: i, + arguments: [], + tags: [], + documentation: "" + }; + } else if (currentKeywordInfo) { + // Parse keyword settings + if (trimmedLine.startsWith('[Documentation]')) { + const docMatch = trimmedLine.match(/\[Documentation\]\s*(.+)/); + if (docMatch) { + currentKeywordInfo.documentation = docMatch[1]; + } + } else if (trimmedLine.startsWith('[Arguments]')) { + const argsMatch = trimmedLine.match(/\[Arguments\]\s*(.+)/); + if (argsMatch) { + currentKeywordInfo.arguments = argsMatch[1].split(/\s+/); + } + } else if (trimmedLine.startsWith('[Tags]')) { + const tagsMatch = trimmedLine.match(/\[Tags\]\s*(.+)/); + if (tagsMatch) { + currentKeywordInfo.tags = tagsMatch[1].split(/\s+/); + } + } + } + } + } + + // Don't forget the last keyword + if (currentKeywordInfo) { + this.addKeywordToCache(currentKeywordInfo); + } + + } catch (error) { + console.error(`Error parsing resource file ${fileUri.fsPath}:`, error); + } + } + + private addKeywordToCache(keywordInfo: KeywordInfo): void { + const normalizedKey = this.normalizeForMatching(keywordInfo.keyword); + if (!this.keywordCache.has(normalizedKey)) { + this.keywordCache.set(normalizedKey, []); + } + + // Check if this exact keyword already exists (avoid true duplicates) + const existingKeywords = this.keywordCache.get(normalizedKey)!; + const isDuplicate = existingKeywords.some(existing => + existing.keyword === keywordInfo.keyword && + existing.file === keywordInfo.file && + existing.line === keywordInfo.line + ); + + if (!isDuplicate) { + existingKeywords.push(keywordInfo); + } + } + + private createKeywordPattern(keyword: string): RegExp { + // Handle Robot Framework variable patterns like ${variable} + let pattern = keyword + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars + .replace(/\\\$\\\{[^}]+\\\}/g, '(.+?)'); // Convert ${var} to capture groups + + // Handle quoted strings with variables + pattern = pattern.replace(/["'](.+?)["']/g, (match, content) => { + if (content.includes('${')) { + return `(?:["']${content}["']|${content})`; + } + return match; + }); + + // Make the pattern case-insensitive and allow for flexible whitespace + pattern = pattern.replace(/\s+/g, '\\s+'); + + return new RegExp(`^${pattern}$`, 'i'); + } + + private findMatchingKeywords(stepText: string): KeywordInfo[] { + const matches: KeywordInfo[] = []; + + // Clean and normalize the step text + const cleanStepText = stepText.replace(/^["']|["']$/g, '').trim(); + const normalizedStep = this.normalizeForMatching(cleanStepText); + + // First, try direct lookup for exact matches (much faster) + const directMatches = this.keywordCache.get(normalizedStep); + if (directMatches) { + matches.push(...directMatches.map(match => ({ ...match, priority: 1 }))); + } + + // Then try pattern matching for keywords with variables + for (const [normalizedKeyword, keywordInfos] of this.keywordCache) { + // Skip if we already found exact matches for this normalized keyword + if (normalizedKeyword === normalizedStep) { + continue; + } + + for (const keywordInfo of keywordInfos) { + const pattern = this.createKeywordPattern(keywordInfo.keyword); + if (pattern.test(cleanStepText)) { + matches.push({ ...keywordInfo, priority: 2 }); + } + } + } + + // Sort by priority and remove duplicates + return matches + .sort((a, b) => (a.priority || 999) - (b.priority || 999)) + .slice(0, 2); // Limit to top 2 matches for hover + } + + private normalizeForMatching(text: string): string { + return text + .toLowerCase() + // Remove Gherkin keywords at the start + .replace(/^(given|when|then|and|but)\s+/i, '') + // Replace variables with placeholder + .replace(/\$\{[^}]+\}/g, 'VAR') + // Normalize whitespace and punctuation + .replace(/[^\w\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } +} \ No newline at end of file