diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..de85108c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,662 @@ +# Cucumber Eclipse - Copilot Instructions + +This document provides guidance for GitHub Copilot when working on the Cucumber Eclipse plugin project. + +## Project Overview + +Cucumber Eclipse is an Eclipse plugin that provides IDE support for Cucumber feature files and integrates with various backend implementations (Java/JVM, Python/Behave, etc.). The project uses: + +- **Eclipse PDE** (Plugin Development Environment) +- **Maven/Tycho** for building Eclipse plugins +- **OSGi** for modularity and service discovery +- **Java 21** as the minimum runtime requirement + +## Architecture + +The project is organized into multiple Eclipse plugin bundles: + +- **io.cucumber.eclipse.editor** - Core editor functionality for `.feature` files +- **io.cucumber.eclipse.java** - Java/JVM backend integration +- **io.cucumber.eclipse.python** - Python/Behave backend integration +- **io.cucumber.eclipse.java.plugins** - Plugin extensions for Java backend +- **io.cucumber.eclipse.feature** - Eclipse feature definition +- **io.cucumber.eclipse.product** - Product configuration +- **io.cucumber.eclipse.updatesite** - Update site for distribution + +## Creating a New Backend Bundle + +This section documents the process of creating a new backend integration (e.g., for a different programming language or test framework), based on the Python/Behave implementation. + +### 1. Bundle Structure Setup + +Create a new bundle directory with the standard Eclipse plugin structure: + +``` +io.cucumber.eclipse./ +├── .classpath # Eclipse Java classpath +├── .project # Eclipse project configuration +├── .settings/ # Eclipse project settings +│ ├── org.eclipse.jdt.core.prefs +│ └── org.eclipse.pde.ds.annotations.prefs +├── .gitignore # Ignore bin/ and build artifacts +├── META-INF/ +│ └── MANIFEST.MF # OSGi bundle metadata +├── build.properties # PDE build configuration +├── plugin.xml # Extension point declarations +├── OSGI-INF/ # Declarative Services descriptors +├── icons/ # UI icons (cukes.gif, etc.) +├── src/ # Java source code +│ └── io/cucumber/eclipse// +│ ├── Activator.java +│ ├── launching/ # Launch configuration support +│ ├── preferences/ # User preferences +│ ├── steps/ # Step definition support +│ └── validation/ # Glue code validation +├── README.md # User documentation +└── IMPLEMENTATION.md # Technical documentation +``` + +### 2. Core Components to Implement + +#### 2.1 Bundle Activator + +Create an `Activator.java` that extends `AbstractUIPlugin`: + +```java +public class Activator extends AbstractUIPlugin { + public static final String PLUGIN_ID = "io.cucumber.eclipse."; + private static Activator plugin; + + @Override + public void start(BundleContext context) throws Exception { + super.start(context); + plugin = this; + } + + @Override + public void stop(BundleContext context) throws Exception { + plugin = null; + super.stop(context); + } + + public static Activator getDefault() { + return plugin; + } +} +``` + +#### 2.2 ILauncher Implementation + +Implement `io.cucumber.eclipse.editor.launching.ILauncher` to integrate with the editor's launch framework: + +```java +@Component(service = ILauncher.class) +public class Launcher implements ILauncher { + + @Override + public void launch(Map launchMap, + Mode mode, boolean temporary, IProgressMonitor monitor) { + // Create and execute launch configurations + } + + @Override + public boolean supports(IResource resource) { + // Detect if resource belongs to your language/framework + return isYourProject(resource); + } + + @Override + public boolean supports(Mode mode) { + // Return true for supported modes (RUN, DEBUG) + return mode == Mode.RUN; + } +} +``` + +**Key Points:** +- Register as OSGi service using `@Component` annotation +- Create XML descriptor in `OSGI-INF/` +- Implement project detection in `supports(IResource)` +- Integrate with existing `CucumberFeatureLaunchShortcut` + +#### 2.3 Launch Configuration Delegate + +Extend `LaunchConfigurationDelegate` to execute your test framework: + +```java +public class LaunchConfigurationDelegate extends LaunchConfigurationDelegate { + + @Override + public void launch(ILaunchConfiguration configuration, String mode, + ILaunch launch, IProgressMonitor monitor) throws CoreException { + // Read configuration attributes + // Build command-line arguments + // Launch the test framework process + // Attach process to Eclipse debug infrastructure + } +} +``` + +**Best Practices:** +- Use a builder pattern for process creation (see `BehaveProcessLauncher`) +- Support variable substitution for paths +- Handle both run and debug modes +- Properly manage process lifecycle + +#### 2.4 Process Launcher Builder + +Create a builder class to centralize process launching logic: + +```java +public class ProcessLauncher { + private String command; + private String featurePath; + private String workingDirectory; + private List additionalArgs = new ArrayList<>(); + + public ProcessLauncher withCommand(String command) { + this.command = command; + return this; + } + + // More builder methods... + + public Process launch() throws IOException { + // Build and start the process + } + + public static boolean isProject(IResource resource) { + // Centralized project detection logic + } +} +``` + +**Benefits:** +- Eliminates code duplication +- Fluent API for configuration +- Single source of truth for project detection +- Reusable across launch delegate and validation jobs + +#### 2.5 Launch Configuration UI + +Implement UI tabs for launch configuration: + +```java +public class MainTab extends AbstractLaunchConfigurationTab { + + @Override + public void createControl(Composite parent) { + // Create UI widgets for configuration + // Feature path selector + // Working directory + // Framework-specific options + } + + @Override + public void performApply(ILaunchConfigurationWorkingCopy configuration) { + // Save UI values to configuration + } + + @Override + public void initializeFrom(ILaunchConfiguration configuration) { + // Load configuration values to UI + } +} + +public class TabGroup extends AbstractLaunchConfigurationTabGroup { + @Override + public void createTabs(ILaunchConfigurationDialog dialog, String mode) { + setTabs(new ILaunchConfigurationTab[] { + new MainTab(), + new CommonTab() + }); + } +} +``` + +#### 2.6 Glue Code Validation + +Implement background validation to check step definition matching: + +##### Document Setup Participant + +```java +public class GlueValidator implements IDocumentSetupParticipant { + + @Override + public void setup(IDocument document) { + document.addDocumentListener(new IDocumentListener() { + @Override + public void documentChanged(DocumentEvent event) { + validate(document, 1000); // Delay for debouncing + } + }); + validate(document, 0); + } + + private static void validate(IDocument document, int delay) { + // Schedule background validation job + } +} +``` + +Register in `plugin.xml`: +```xml + + + + +``` + +##### Background Validation Job + +```java +final class GlueJob extends Job { + + @Override + protected IStatus run(IProgressMonitor monitor) { + // 1. Run framework with dry-run/validation mode + // 2. Parse output to extract step-to-definition mappings + // 3. Get all steps from editorDocument.getSteps() + // 4. Compare matched vs. all steps to find unmatched + // 5. Create markers for unmatched steps + + // Error handling: + // - Return CANCEL_STATUS for InterruptedException + // - Return OK_STATUS for errors, create error marker instead + // - Never return error status (prevents popup in background) + } +} +``` + +**Key Points:** +- Execute framework in validation/dry-run mode +- Parse output to extract step mappings +- Use `editorDocument.getSteps()` to get all steps +- Create markers only for truly unmatched steps +- Handle errors gracefully (markers, not popups) + +#### 2.7 Step Definition Navigation + +Implement `IStepDefinitionOpener` for Ctrl+Click navigation: + +```java +@Component(service = IStepDefinitionOpener.class) +public class StepDefinitionOpener implements IStepDefinitionOpener { + + @Override + public boolean canOpen(IResource resource) { + // Use centralized project detection + return ProcessLauncher.isProject(resource); + } + + @Override + public boolean openInEditor(ITextViewer textViewer, IResource resource, + Step step) throws CoreException { + // 1. Get matched steps from validator + // 2. Find match for current step by line number + // 3. Open file at specified line + // 4. Navigate to line in editor + } +} +``` + +**Implementation Tips:** +- Register as OSGi service component +- Reuse step mappings from validation job +- Use Eclipse's IDE.openEditor() and text editor APIs +- Handle file path resolution (relative vs. absolute) + +#### 2.8 Preferences + +Provide user-configurable preferences: + +##### Preferences Data Class + +```java +public class Preferences { + public static final String PREF_COMMAND = ".command"; + public static final String DEFAULT_COMMAND = ""; + + public static Preferences of(IResource resource) { + // Support both workspace and project-level preferences + } + + public String command() { + // Return configured command with fallback to default + } +} +``` + +##### Preferences Page + +```java +public class PreferencePage extends PreferencePage + implements IWorkbenchPreferencePage { + + @Override + protected Control createContents(Composite parent) { + // Create UI for configuration options + // Command path + // Additional settings + } + + @Override + public boolean performOk() { + // Save preferences + } +} +``` + +Register in `plugin.xml`: +```xml + + + + +``` + +### 3. MANIFEST.MF Configuration + +Essential bundle metadata: + +``` +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: +Bundle-SymbolicName: io.cucumber.eclipse.;singleton:=true +Bundle-Version: 3.0.0.qualifier +Bundle-Activator: io.cucumber.eclipse..Activator +Bundle-RequiredExecutionEnvironment: JavaSE-21 +Bundle-ActivationPolicy: lazy +Automatic-Module-Name: io.cucumber.eclipse. + +Export-Package: io.cucumber.eclipse.;x-internal:=true, + io.cucumber.eclipse..launching;x-internal:=true, + io.cucumber.eclipse..preferences;x-internal:=true, + io.cucumber.eclipse..steps;x-internal:=true, + io.cucumber.eclipse..validation;x-internal:=true + +Require-Bundle: org.eclipse.ui, + org.eclipse.core.runtime, + io.cucumber.eclipse.editor;bundle-version="1.0.0", + org.eclipse.jface.text, + org.eclipse.debug.ui, + org.eclipse.debug.core, + org.eclipse.ui.workbench.texteditor, + org.eclipse.ui.console, + org.eclipse.ui.ide;bundle-version="3.18.0", + org.eclipse.core.filebuffers, + org.eclipse.core.variables, + io.cucumber.messages;bundle-version="13.2.1", + io.cucumber.tag-expressions;bundle-version="3.0.0" + +Import-Package: org.eclipse.unittest.ui, + org.osgi.service.component.annotations;version="1.3.0" + +Service-Component: OSGI-INF/*.xml +``` + +### 4. plugin.xml Configuration + +Define extension points: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### 5. build.properties Configuration + +```properties +source.. = src/ +output.. = bin/ +bin.includes = META-INF/,\ + .,\ + plugin.xml,\ + icons/,\ + OSGI-INF/ +``` + +### 6. Integration with Build System + +#### Add to parent pom.xml + +```xml + + + io.cucumber.eclipse. + +``` + +#### Add to feature.xml + +```xml + +``` + +### 7. Project Detection Best Practices + +Implement hierarchical detection (most specific to most general): + +1. **Framework Convention**: Check for framework-specific file structure + - Example: `.feature` file with adjacent `steps/` directory containing implementation files + +2. **Language Nature**: Check for Eclipse project nature + - Example: PyDev nature for Python projects + +3. **Project Indicators**: Fallback to common project files + - Example: `requirements.txt`, `setup.py`, `pyproject.toml` for Python + +```java +public static boolean isProject(IResource resource) { + // 1. Check framework convention (most specific) + if (resource.getType() == IResource.FILE && resource.getName().endsWith(".feature")) { + // Check for framework-specific structure + } + + // 2. Check project nature + IProject project = resource.getProject(); + try { + if (project.hasNature(".nature")) { + return true; + } + } catch (CoreException e) { + // Ignore + } + + // 3. Check project indicators (fallback) + return project.getFile("project-file").exists(); +} +``` + +### 8. Error Handling Guidelines + +#### Background Jobs + +- **Never** return error status from background jobs (causes popups) +- Return `Status.OK_STATUS` and create error markers instead +- Return `Status.CANCEL_STATUS` for `InterruptedException` +- Log errors for troubleshooting + +```java +try { + // Validation logic +} catch (InterruptedException e) { + return Status.CANCEL_STATUS; +} catch (IOException e) { + ILog.get().error("Validation failed", e); + try { + createErrorMarker(resource, "Helpful error message"); + } catch (CoreException ce) { + // Ignore marker creation failure + } + return Status.OK_STATUS; +} +``` + +#### Marker Management + +- Delete existing markers before creating new ones +- Use unique source IDs to identify marker ownership +- Provide actionable error messages +- Link to preferences or logs for more information + +### 9. Testing Your Implementation + +Create an example project in `examples/-/`: + +``` +examples/-/ +├── .gitignore +├── README.md # Setup instructions +├── features/ +│ ├── .feature # Feature file +│ └── steps/ +│ └── _steps. # Step definitions +``` + +**Testing Checklist:** +- [ ] Launch configuration creates successfully +- [ ] Feature file executes with framework +- [ ] Tags filter scenarios correctly +- [ ] Validation detects unmatched steps +- [ ] Markers appear for unmatched steps +- [ ] Ctrl+Click navigates to step definitions +- [ ] Preferences page saves settings +- [ ] Custom command path works +- [ ] Error markers appear on validation failure +- [ ] Background validation doesn't cause popups + +### 10. Documentation + +Create two documentation files: + +#### README.md (User-facing) +- Installation instructions +- Usage guide +- Configuration options +- Example project walkthrough + +#### IMPLEMENTATION.md (Developer-facing) +- Architecture overview +- Component descriptions +- Design decisions +- Extension points +- Future enhancements + +## Code Style Guidelines + +- **Formatting**: Follow Eclipse Java code conventions +- **Naming**: Use descriptive names, avoid abbreviations +- **Comments**: Document public APIs, explain non-obvious logic +- **Error Messages**: Be specific and actionable +- **Logging**: Use `ILog.get()` for error logging +- **Dependencies**: Mark optional dependencies in MANIFEST.MF + +## OSGi Declarative Services + +Register services using annotations and XML descriptors: + +```java +@Component(service = ILauncher.class) +public class MyLauncher implements ILauncher { + // Implementation +} +``` + +Create descriptor in `OSGI-INF/`: + +```xml + + + + + + + +``` + +## Common Pitfalls + +1. **Don't create custom launch shortcuts** - Implement `ILauncher` instead +2. **Don't return error status from background jobs** - Create markers +3. **Don't duplicate project detection** - Centralize in one method +4. **Don't hardcode paths** - Support variable substitution +5. **Don't ignore existing markers** - Delete before creating new ones +6. **Don't use snippets if not generated** - Use line numbers only +7. **Don't forget optional dependencies** - Mark with `resolution:=optional` + +## Resources + +- [Eclipse PDE Guide](https://www.eclipse.org/pde/) +- [OSGi Declarative Services](https://www.osgi.org/developer/architecture/) +- [Eclipse Debug Framework](https://www.eclipse.org/articles/Article-Debugger/how-to.html) +- [Existing Java Implementation](io.cucumber.eclipse.java/) +- [Existing Python Implementation](io.cucumber.eclipse.python/) + +## Getting Help + +- Check existing implementations (Java, Python bundles) +- Review Eclipse PDE documentation +- Test incrementally and validate each component +- Use the example projects for testing diff --git a/examples/python-calculator/.gitignore b/examples/python-calculator/.gitignore new file mode 100644 index 00000000..4315742b --- /dev/null +++ b/examples/python-calculator/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ diff --git a/examples/python-calculator/README.md b/examples/python-calculator/README.md new file mode 100644 index 00000000..163c91a7 --- /dev/null +++ b/examples/python-calculator/README.md @@ -0,0 +1,69 @@ +# Python Calculator Example + +This is a simple example demonstrating how to use the Cucumber-Behave launcher with Eclipse. + +## Prerequisites + +1. Python 3.x installed +2. Behave package installed: + ```bash + pip install behave + ``` + +## Running the Example + +### From Command Line + +```bash +cd examples/python-calculator +behave +``` + +### From Eclipse + +1. Open Eclipse with Cucumber Eclipse plugin installed +2. Import this project into Eclipse +3. Right-click on `features/calculator.feature` +4. Select "Run As" > "Cucumber-Behave" +5. In the launch configuration dialog: + - **Feature Path**: Select the `calculator.feature` file + - **Working Directory**: Set to the `examples/python-calculator` directory + - **Python Interpreter**: Use `python` or `python3` depending on your system + - Click "Run" + +## Project Structure + +``` +python-calculator/ +├── features/ +│ ├── calculator.feature # Feature file with scenarios +│ └── steps/ +│ └── calculator_steps.py # Step definitions +└── README.md +``` + +## Expected Output + +When you run the tests, you should see output indicating that all three scenarios pass: + +``` +Feature: Calculator # features/calculator.feature:1 + + Scenario: Add two numbers # features/calculator.feature:6 + Given I have a calculator # features/steps/calculator_steps.py:19 + When I add 2 and 3 # features/steps/calculator_steps.py:23 + Then the result should be 5 # features/steps/calculator_steps.py:35 + + Scenario: Subtract two numbers # features/calculator.feature:11 + Given I have a calculator # features/steps/calculator_steps.py:19 + When I subtract 3 from 5 # features/steps/calculator_steps.py:27 + Then the result should be 2 # features/steps/calculator_steps.py:35 + + Scenario: Multiply two numbers # features/calculator.feature:16 + Given I have a calculator # features/steps/calculator_steps.py:19 + When I multiply 2 by 3 # features/steps/calculator_steps.py:31 + Then the result should be 6 # features/steps/calculator_steps.py:35 + +3 scenarios (3 passed) +9 steps (9 passed) +``` diff --git a/examples/python-calculator/features/calculator.feature b/examples/python-calculator/features/calculator.feature new file mode 100644 index 00000000..32c31b46 --- /dev/null +++ b/examples/python-calculator/features/calculator.feature @@ -0,0 +1,19 @@ +Feature: Calculator + As a user + I want to use a calculator + So that I can perform basic arithmetic operations + + Scenario: Add two numbers + Given I have a calculator + When I add 2 and 3 + Then the result should be 5 + + Scenario: Subtract two numbers + Given I have a calculator + When I subtract 3 from 5 + Then the result should be 2 + + Scenario: Multiply two numbers + Given I have a calculator + When I multiply 2 by 3 + Then the result should be 6 diff --git a/examples/python-calculator/features/steps/calculator_steps.py b/examples/python-calculator/features/steps/calculator_steps.py new file mode 100644 index 00000000..565cfac3 --- /dev/null +++ b/examples/python-calculator/features/steps/calculator_steps.py @@ -0,0 +1,38 @@ +from behave import given, when, then + +class Calculator: + def __init__(self): + self.result = 0 + + def add(self, a, b): + self.result = a + b + return self.result + + def subtract(self, a, b): + self.result = a - b + return self.result + + def multiply(self, a, b): + self.result = a * b + return self.result + +@given('I have a calculator') +def step_impl(context): + context.calculator = Calculator() + +@when('I add {a:d} and {b:d}') +def step_impl(context, a, b): + context.calculator.add(a, b) + +@when('I subtract {a:d} from {b:d}') +def step_impl(context, a, b): + context.calculator.subtract(b, a) + +@when('I multiply {a:d} by {b:d}') +def step_impl(context, a, b): + context.calculator.multiply(a, b) + +@then('the result should be {expected:d}') +def step_impl(context, expected): + assert context.calculator.result == expected, \ + f"Expected {expected}, but got {context.calculator.result}" diff --git a/io.cucumber.eclipse.feature/feature.xml b/io.cucumber.eclipse.feature/feature.xml index a79ecdd4..4debfd53 100644 --- a/io.cucumber.eclipse.feature/feature.xml +++ b/io.cucumber.eclipse.feature/feature.xml @@ -51,4 +51,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. id="io.cucumber.eclipse.java.plugins" version="0.0.0"/> + + diff --git a/io.cucumber.eclipse.python/.classpath b/io.cucumber.eclipse.python/.classpath new file mode 100644 index 00000000..375961e4 --- /dev/null +++ b/io.cucumber.eclipse.python/.classpath @@ -0,0 +1,7 @@ + + + + + + + diff --git a/io.cucumber.eclipse.python/.gitignore b/io.cucumber.eclipse.python/.gitignore new file mode 100644 index 00000000..e660fd93 --- /dev/null +++ b/io.cucumber.eclipse.python/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/io.cucumber.eclipse.python/.project b/io.cucumber.eclipse.python/.project new file mode 100644 index 00000000..6d5ba51a --- /dev/null +++ b/io.cucumber.eclipse.python/.project @@ -0,0 +1,28 @@ + + + io.cucumber.eclipse.python + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.pde.ManifestBuilder + + + + + org.eclipse.pde.SchemaBuilder + + + + + + org.eclipse.pde.PluginNature + org.eclipse.jdt.core.javanature + + diff --git a/io.cucumber.eclipse.python/.settings/org.eclipse.jdt.core.prefs b/io.cucumber.eclipse.python/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 00000000..23fa13b1 --- /dev/null +++ b/io.cucumber.eclipse.python/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,9 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.targetPlatform=21 +org.eclipse.jdt.core.compiler.compliance=21 +org.eclipse.jdt.core.compiler.problem.assertIdentifier=error +org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled +org.eclipse.jdt.core.compiler.problem.enumIdentifier=error +org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning +org.eclipse.jdt.core.compiler.release=enabled +org.eclipse.jdt.core.compiler.source=21 diff --git a/io.cucumber.eclipse.python/.settings/org.eclipse.pde.ds.annotations.prefs b/io.cucumber.eclipse.python/.settings/org.eclipse.pde.ds.annotations.prefs new file mode 100644 index 00000000..73a356b6 --- /dev/null +++ b/io.cucumber.eclipse.python/.settings/org.eclipse.pde.ds.annotations.prefs @@ -0,0 +1,8 @@ +classpath=true +dsVersion=V1_3 +eclipse.preferences.version=1 +enabled=true +generateBundleActivationPolicyLazy=true +path=OSGI-INF +validationErrorLevel=error +validationErrorLevel.missingImplicitUnbindMethod=error diff --git a/io.cucumber.eclipse.python/IMPLEMENTATION.md b/io.cucumber.eclipse.python/IMPLEMENTATION.md new file mode 100644 index 00000000..6766a10a --- /dev/null +++ b/io.cucumber.eclipse.python/IMPLEMENTATION.md @@ -0,0 +1,175 @@ +# Implementation Summary: io.cucumber.eclipse.python Bundle + +This document describes the implementation of the new `io.cucumber.eclipse.python` bundle that enables launching Cucumber feature files using Python's Behave framework. + +## Implementation Overview + +The bundle provides a complete Eclipse launch configuration for running Cucumber feature files with Behave, following the same architectural pattern as the existing `io.cucumber.eclipse.java` bundle. + +## Components Implemented + +### 1. Core Bundle Configuration + +- **MANIFEST.MF**: Defines bundle metadata, dependencies, and exported packages + - Dependencies include Eclipse UI/Debug framework and optional PyDev support + - Targets JavaSE-21 runtime environment + - Optional resolution for PyDev plugins to avoid hard dependency + +- **plugin.xml**: Declares Eclipse extension points + - Launch configuration type: `cucumber.eclipse.python.launching.localCucumberBehave` + - Launch configuration UI (tab groups and icon) + - Launch shortcut for context menu integration + - Contextual launch support for `.feature` files + +- **build.properties**: Maven/Tycho build configuration + - Source directory: `src/` + - Binary directory: `bin/` + - Includes plugin.xml, icons, and OSGI-INF + +### 2. Launch Framework Classes + +#### CucumberBehaveLaunchConstants +- Defines configuration attribute keys: + - `ATTR_FEATURE_PATH`: Path to feature file + - `ATTR_WORKING_DIRECTORY`: Working directory for execution + - `ATTR_PYTHON_INTERPRETER`: Python interpreter path + - `ATTR_TAGS`: Tag expression for filtering + - `ATTR_IS_VERBOSE`: Enable verbose output + - `ATTR_IS_NO_CAPTURE`: Disable output capture + - `ATTR_IS_DRY_RUN`: Enable dry-run mode + +#### CucumberBehaveLaunchConfigurationDelegate +- Main launch delegate extending `LaunchConfigurationDelegate` +- Responsibilities: + - Read launch configuration attributes + - Build behave command with appropriate flags + - Create and manage Python process + - Handle working directory and environment + - Support for tags, verbose, no-capture, and dry-run options + +#### CucumberBehaveTabGroup +- Launch configuration tab group +- Includes: + - CucumberBehaveMainTab (main configuration) + - EnvironmentTab (environment variables) + - CommonTab (common launch settings) + +#### CucumberBehaveMainTab +- Main configuration UI tab extending `AbstractLaunchConfigurationTab` +- UI Components: + - Feature Path selector with file browser + - Working Directory selector with directory browser + - Python Interpreter text field + - Tags text field + - Behave Options checkboxes (Verbose, No Capture, Dry Run) +- Validates required fields (feature path, working directory) + +#### CucumberBehaveLauncher +- Implements `ILauncher` interface for integration with editor's launch framework +- Registered as OSGi service component +- Automatically discovered by `CucumberFeatureLaunchShortcut` in editor bundle +- Supports running feature files and specific scenarios +- Handles tag filtering and temporary launch configurations + +### 3. Bundle Infrastructure + +- **Activator.java**: OSGi bundle activator +- **OSGI-INF/CucumberBehaveLauncher.xml**: OSGi Declarative Services descriptor for ILauncher registration +- **.project**: Eclipse project configuration (PDE plugin nature) +- **.classpath**: Java classpath configuration (JavaSE-21) +- **.settings/**: Eclipse project settings (JDT, PDE) +- **.gitignore**: Excludes bin/ directory from version control + +## Integration with Existing Codebase + +### Parent POM +- Added `io.cucumber.eclipse.python` module to parent `pom.xml` + +### Feature Definition +- Added plugin entry to `io.cucumber.eclipse.feature/feature.xml` + +## Example Project + +Created `examples/python-calculator/` demonstrating usage: +- Simple calculator feature with scenarios +- Python step definitions using Behave +- README with setup and usage instructions +- Demonstrates add, subtract, and multiply operations + +## Design Decisions + +1. **Independent Bundle**: Created as a standalone bundle rather than extending existing Java bundle + - Cleaner separation of concerns + - Easier to maintain and update independently + - Follows Eclipse plugin architecture best practices + +2. **ILauncher Implementation**: Implements the `ILauncher` interface from editor bundle + - Integrates with existing `CucumberFeatureLaunchShortcut` in editor + - Registered as OSGi service component for automatic discovery + - Supports running from editor or project explorer context menus + - No need for custom launch shortcut implementation + +3. **Optional PyDev Dependencies**: Marked as optional in MANIFEST.MF + - Allows bundle to work without PyDev installed + - Provides better integration when PyDev is available + - Future enhancement: Could use PyDev for Python interpreter selection + +4. **Simple Launch Delegate**: Uses standard ProcessBuilder + - Direct execution of behave command + - No dependency on PyDev launch infrastructure + - Easy to understand and maintain + - Future enhancement: Could integrate with PyDev's Python runner + +5. **Behave-specific Options**: Focused on common Behave options + - Verbose, no-capture, and dry-run flags + - Tag filtering support + - Future enhancement: Could add more Behave-specific options (format, color, etc.) + +## Future Enhancements + +1. **PyDev Integration** + - Use PyDev's Python interpreter configuration + - Integrate with PyDev's Python project settings + - Use PyDev's console for output + +2. **Debug Support** + - Integrate with Python debugger + - Breakpoint support in step definitions + - Variable inspection + +3. **Advanced Behave Options** + - More formatter options (JSON, JUnit XML, etc.) + - Coverage integration + - Parallel execution support + +4. **Step Definition Navigation** + - Jump from feature file to step definition + - Step definition completion + - Unused step detection + +5. **Test Results Integration** + - Eclipse test results view integration + - Visual representation of test execution + - Failed test navigation + +## Testing + +The implementation has been designed to follow the same patterns as the Java bundle, but requires a full Eclipse environment with the plugin installed to test: + +1. Import the bundle into Eclipse with PDE +2. Launch Eclipse Application (Run As > Eclipse Application) +3. Create a Python project with Behave installed +4. Create a feature file +5. Right-click and select "Run As > Cucumber-Behave" + +## Compliance with Requirements + +✅ New bundle `io.cucumber.eclipse.python` created +✅ Contains methods to launch Python process using Behave +✅ Reuses Eclipse Debug framework (similar to PyDev approach) +✅ Executes Cucumber feature files with Python glue code via Behave +✅ Defines `org.eclipse.debug.core.launchConfigurationTypes` as "Cucumber-Behave" +✅ Follows the Java implementation (`io.cucumber.eclipse.java`) as a blueprint +✅ Launch configuration UI with tabs for configuration +✅ Implements `ILauncher` interface for integration with editor's launch framework +✅ Registered as OSGi service component for automatic discovery diff --git a/io.cucumber.eclipse.python/META-INF/MANIFEST.MF b/io.cucumber.eclipse.python/META-INF/MANIFEST.MF new file mode 100644 index 00000000..096e7dcf --- /dev/null +++ b/io.cucumber.eclipse.python/META-INF/MANIFEST.MF @@ -0,0 +1,33 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: Python +Bundle-SymbolicName: io.cucumber.eclipse.python;singleton:=true +Bundle-Version: 3.0.0.qualifier +Export-Package: io.cucumber.eclipse.python;x-internal:=true, + io.cucumber.eclipse.python.launching;x-internal:=true, + io.cucumber.eclipse.python.preferences;x-internal:=true, + io.cucumber.eclipse.python.steps;x-internal:=true, + io.cucumber.eclipse.python.validation;x-internal:=true +Bundle-Activator: io.cucumber.eclipse.python.Activator +Require-Bundle: org.eclipse.ui, + org.eclipse.core.runtime, + io.cucumber.eclipse.editor;bundle-version="1.0.0", + org.eclipse.jface.text, + org.eclipse.debug.ui, + org.eclipse.ui.workbench.texteditor, + org.eclipse.ui.console, + org.eclipse.core.filebuffers, + org.eclipse.ui.ide;bundle-version="3.18.0", + org.eclipse.debug.core, + org.eclipse.core.variables, + io.cucumber.messages;bundle-version="13.2.1", + io.cucumber.tag-expressions;bundle-version="3.0.0", + org.python.pydev.core;bundle-version="9.0.0";resolution:=optional, + org.python.pydev.debug;bundle-version="9.0.0";resolution:=optional +Bundle-RequiredExecutionEnvironment: JavaSE-21 +Automatic-Module-Name: io.cucumber.eclipse.python +Bundle-ActivationPolicy: lazy +Import-Package: org.eclipse.unittest.ui, + org.osgi.service.component.annotations;version="1.3.0" +Service-Component: OSGI-INF/io.cucumber.eclipse.python.launching.CucumberBehaveLauncher.xml, + OSGI-INF/io.cucumber.eclipse.python.steps.PythonStepDefinitionOpener.xml diff --git a/io.cucumber.eclipse.python/OSGI-INF/io.cucumber.eclipse.python.launching.CucumberBehaveLauncher.xml b/io.cucumber.eclipse.python/OSGI-INF/io.cucumber.eclipse.python.launching.CucumberBehaveLauncher.xml new file mode 100644 index 00000000..55132e34 --- /dev/null +++ b/io.cucumber.eclipse.python/OSGI-INF/io.cucumber.eclipse.python.launching.CucumberBehaveLauncher.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/io.cucumber.eclipse.python/OSGI-INF/io.cucumber.eclipse.python.steps.PythonStepDefinitionOpener.xml b/io.cucumber.eclipse.python/OSGI-INF/io.cucumber.eclipse.python.steps.PythonStepDefinitionOpener.xml new file mode 100644 index 00000000..51325d89 --- /dev/null +++ b/io.cucumber.eclipse.python/OSGI-INF/io.cucumber.eclipse.python.steps.PythonStepDefinitionOpener.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/io.cucumber.eclipse.python/README.md b/io.cucumber.eclipse.python/README.md new file mode 100644 index 00000000..2cd88289 --- /dev/null +++ b/io.cucumber.eclipse.python/README.md @@ -0,0 +1,61 @@ +# Cucumber Eclipse Python Bundle + +This bundle provides support for launching Cucumber feature files using Python's Behave framework. + +## Features + +- Launch Cucumber feature files with Behave +- Configure Python interpreter path +- Set working directory for test execution +- Support for Behave command-line options: + - Verbose output + - No capture mode + - Dry run +- Tag-based test filtering + +## Requirements + +- Python 3.x installed on your system +- Behave package installed (`pip install behave`) +- PyDev plugin (optional, but recommended) + +## Usage + +1. Right-click on a `.feature` file in your project +2. Select "Run As" > "Cucumber" (or "Cucumber Feature") +3. The Python/Behave launcher will automatically be used for Python projects +4. Configure the launch configuration: + - **Feature Path**: Path to the feature file to run + - **Working Directory**: Directory where behave will be executed + - **Python Interpreter**: Path to Python executable (defaults to `python`) + - **Tags**: Optional tag expression to filter scenarios + - **Behave Options**: Enable verbose, no-capture, or dry-run modes + +## Configuration + +The launch configuration supports the following attributes: + +- `ATTR_FEATURE_PATH`: Path to the feature file +- `ATTR_WORKING_DIRECTORY`: Working directory for execution +- `ATTR_PYTHON_INTERPRETER`: Python interpreter path +- `ATTR_TAGS`: Tag expression for filtering +- `ATTR_IS_VERBOSE`: Enable verbose output +- `ATTR_IS_NO_CAPTURE`: Disable output capture +- `ATTR_IS_DRY_RUN`: Run in dry-run mode + +## Example Project Structure + +``` +my-python-project/ +├── features/ +│ ├── calculator.feature +│ └── steps/ +│ └── calculator_steps.py +└── behave.ini +``` + +## Notes + +- Make sure Behave is installed in your Python environment +- The working directory should typically be the root of your project +- PyDev dependencies are optional and marked with `resolution:=optional` in the manifest diff --git a/io.cucumber.eclipse.python/build.properties b/io.cucumber.eclipse.python/build.properties new file mode 100644 index 00000000..d9196947 --- /dev/null +++ b/io.cucumber.eclipse.python/build.properties @@ -0,0 +1,7 @@ +source.. = src/ +output.. = bin/ +bin.includes = META-INF/,\ + .,\ + plugin.xml,\ + icons/,\ + OSGI-INF/ diff --git a/io.cucumber.eclipse.python/icons/cukes.gif b/io.cucumber.eclipse.python/icons/cukes.gif new file mode 100644 index 00000000..b0faaeda Binary files /dev/null and b/io.cucumber.eclipse.python/icons/cukes.gif differ diff --git a/io.cucumber.eclipse.python/plugin.xml b/io.cucumber.eclipse.python/plugin.xml new file mode 100644 index 00000000..b675da90 --- /dev/null +++ b/io.cucumber.eclipse.python/plugin.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/Activator.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/Activator.java new file mode 100644 index 00000000..30d1f1ea --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/Activator.java @@ -0,0 +1,44 @@ +package io.cucumber.eclipse.python; + +import org.eclipse.ui.plugin.AbstractUIPlugin; +import org.osgi.framework.BundleContext; + +/** + * The activator class controls the plug-in life cycle + */ +public class Activator extends AbstractUIPlugin { + + // The plug-in ID + public static final String PLUGIN_ID = "io.cucumber.eclipse.python"; + + // The shared instance + private static Activator plugin; + + /** + * The constructor + */ + public Activator() { + } + + @Override + public void start(BundleContext context) throws Exception { + super.start(context); + plugin = this; + } + + @Override + public void stop(BundleContext context) throws Exception { + plugin = null; + super.stop(context); + } + + /** + * Returns the shared instance + * + * @return the shared instance + */ + public static Activator getDefault() { + return plugin; + } + +} diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/BehaveProcessLauncher.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/BehaveProcessLauncher.java new file mode 100644 index 00000000..44a6008e --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/BehaveProcessLauncher.java @@ -0,0 +1,204 @@ +package io.cucumber.eclipse.python.launching; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.CoreException; + +/** + * Builder for launching behave processes with various configurations. + * Provides a fluent API to configure and launch behave. + */ +public class BehaveProcessLauncher { + + private String command = "behave"; + private String featurePath; + private String workingDirectory; + private List additionalArgs = new ArrayList<>(); + + /** + * Sets the behave command to use (defaults to "behave") + */ + public BehaveProcessLauncher withCommand(String command) { + this.command = command; + return this; + } + + /** + * Sets the feature file or directory path + */ + public BehaveProcessLauncher withFeaturePath(String featurePath) { + this.featurePath = featurePath; + return this; + } + + /** + * Sets the working directory for the process + */ + public BehaveProcessLauncher withWorkingDirectory(String workingDirectory) { + this.workingDirectory = workingDirectory; + return this; + } + + /** + * Adds an additional command line argument + */ + public BehaveProcessLauncher withArgument(String arg) { + this.additionalArgs.add(arg); + return this; + } + + /** + * Adds multiple command line arguments + */ + public BehaveProcessLauncher withArguments(List args) { + this.additionalArgs.addAll(args); + return this; + } + + /** + * Adds tag filter + */ + public BehaveProcessLauncher withTags(String tags) { + if (tags != null && !tags.isEmpty()) { + this.additionalArgs.add("--tags"); + this.additionalArgs.add(tags); + } + return this; + } + + /** + * Enables verbose output + */ + public BehaveProcessLauncher withVerbose(boolean verbose) { + if (verbose) { + this.additionalArgs.add("--verbose"); + } + return this; + } + + /** + * Enables no-capture mode + */ + public BehaveProcessLauncher withNoCapture(boolean noCapture) { + if (noCapture) { + this.additionalArgs.add("--no-capture"); + } + return this; + } + + /** + * Enables dry-run mode + */ + public BehaveProcessLauncher withDryRun(boolean dryRun) { + if (dryRun) { + this.additionalArgs.add("--dry-run"); + } + return this; + } + + /** + * Sets the output format + */ + public BehaveProcessLauncher withFormat(String format) { + if (format != null && !format.isEmpty()) { + this.additionalArgs.add("--format"); + this.additionalArgs.add(format); + } + return this; + } + + /** + * Disables summary output + */ + public BehaveProcessLauncher withNoSummary(boolean noSummary) { + if (noSummary) { + this.additionalArgs.add("--no-summary"); + } + return this; + } + + /** + * Launches the behave process with the configured parameters + * + * @return the started Process + * @throws IOException if process creation fails + */ + public Process launch() throws IOException { + List commandList = new ArrayList<>(); + commandList.add(command); + + if (featurePath != null && !featurePath.isEmpty()) { + commandList.add(featurePath); + } + + commandList.addAll(additionalArgs); + + ProcessBuilder processBuilder = new ProcessBuilder(commandList); + + if (workingDirectory != null && !workingDirectory.isEmpty()) { + processBuilder.directory(new File(workingDirectory)); + } + + processBuilder.redirectErrorStream(true); + + return processBuilder.start(); + } + + /** + * Checks if a resource belongs to a Behave/Python project + * + * @param resource the resource to check + * @return true if the resource is in a Python/Behave project + */ + public static boolean isBehaveProject(IResource resource) { + if (resource == null) { + return false; + } + + // Check for Behave convention: .feature file with steps/ directory containing .py files + if (resource.getType() == IResource.FILE && resource.getName().endsWith(".feature")) { + // Look for a 'steps' subdirectory relative to the feature file + org.eclipse.core.runtime.IPath featureParent = resource.getParent().getLocation(); + if (featureParent != null) { + java.io.File stepsDir = new java.io.File(featureParent.toFile(), "steps"); + if (stepsDir.exists() && stepsDir.isDirectory()) { + // Check if steps directory contains at least one .py file + java.io.File[] files = stepsDir.listFiles(); + if (files != null) { + for (java.io.File file : files) { + if (file.isFile() && file.getName().endsWith(".py")) { + return true; + } + } + } + } + } + } + + IProject project = resource.getProject(); + if (project == null) { + return false; + } + + // Check for PyDev nature + try { + if (project.hasNature("org.python.pydev.pythonNature")) { + return true; + } + } catch (CoreException e) { + // Ignore and try other checks + } + + // Check for Python-related files/folders as fallback + return project.getFile("requirements.txt").exists() || + project.getFile("setup.py").exists() || + project.getFile("pyproject.toml").exists() || + project.getFolder("venv").exists() || + project.getFolder(".venv").exists(); + } +} diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveLaunchConfigurationDelegate.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveLaunchConfigurationDelegate.java new file mode 100644 index 00000000..f5f335ec --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveLaunchConfigurationDelegate.java @@ -0,0 +1,129 @@ +package io.cucumber.eclipse.python.launching; + +import java.io.File; +import java.io.IOException; + +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; +import org.eclipse.core.variables.VariablesPlugin; +import org.eclipse.debug.core.DebugPlugin; +import org.eclipse.debug.core.ILaunch; +import org.eclipse.debug.core.ILaunchConfiguration; +import org.eclipse.debug.core.model.IProcess; +import org.eclipse.debug.core.model.LaunchConfigurationDelegate; + +import io.cucumber.eclipse.python.Activator; +import io.cucumber.eclipse.python.preferences.BehavePreferences; + +public class CucumberBehaveLaunchConfigurationDelegate extends LaunchConfigurationDelegate { + + @Override + public void launch(ILaunchConfiguration configuration, String mode, ILaunch launch, IProgressMonitor monitor) + throws CoreException { + + // Get configuration attributes + String featurePath = substituteVar(configuration.getAttribute(CucumberBehaveLaunchConstants.ATTR_FEATURE_PATH, "")); + String withLine = configuration.getAttribute(CucumberBehaveLaunchConstants.ATTR_FEATURE_WITH_LINE, ""); + String tags = configuration.getAttribute(CucumberBehaveLaunchConstants.ATTR_TAGS, ""); + String workingDirectory = substituteVar(configuration.getAttribute(CucumberBehaveLaunchConstants.ATTR_WORKING_DIRECTORY, "")); + boolean isVerbose = configuration.getAttribute(CucumberBehaveLaunchConstants.ATTR_IS_VERBOSE, false); + boolean isNoCapture = configuration.getAttribute(CucumberBehaveLaunchConstants.ATTR_IS_NO_CAPTURE, false); + boolean isDryRun = configuration.getAttribute(CucumberBehaveLaunchConstants.ATTR_IS_DRY_RUN, false); + + // Validate feature path + if (featurePath == null || featurePath.isEmpty()) { + throw new CoreException(new Status(IStatus.ERROR, Activator.PLUGIN_ID, "Feature path is not specified")); + } + + // Validate working directory + File workingDir = null; + if (workingDirectory != null && !workingDirectory.isEmpty()) { + workingDir = new File(workingDirectory); + if (!workingDir.exists() || !workingDir.isDirectory()) { + throw new CoreException(new Status(IStatus.ERROR, Activator.PLUGIN_ID, + "Working directory does not exist: " + workingDirectory)); + } + } + + // Get behave command from preferences + BehavePreferences preferences = BehavePreferences.of(); + String behaveCommand = preferences.behaveCommand(); + + // Build and launch the behave process + try { + BehaveProcessLauncher launcher = new BehaveProcessLauncher() + .withCommand(behaveCommand) + .withFeaturePath(withLine != null && !withLine.isEmpty() ? withLine : featurePath) + .withWorkingDirectory(workingDirectory) + .withTags(tags) + .withVerbose(isVerbose) + .withNoCapture(isNoCapture) + .withDryRun(isDryRun); + + Process process = launcher.launch(); + IProcess iProcess = DebugPlugin.newProcess(launch, process, "Cucumber Behave"); + iProcess.setAttribute(IProcess.ATTR_PROCESS_TYPE, "cucumber.behave"); + } catch (IOException e) { + throw new CoreException(new Status(IStatus.ERROR, Activator.PLUGIN_ID, + "Failed to launch behave process", e)); + } + } + + /** + * Substitute any variable + */ + private static String substituteVar(String s) { + if (s == null) { + return s; + } + try { + return VariablesPlugin.getDefault().getStringVariableManager().performStringSubstitution(s); + } catch (CoreException e) { + System.out.println("Could not substitute variable " + s); + return null; + } + } + +} + "Working directory does not exist: " + workingDirectory)); + } + } + + // Convert command list to array + String[] commandArray = commandList.toArray(new String[0]); + + // Create process + ProcessBuilder processBuilder = new ProcessBuilder(commandArray); + if (workingDir != null) { + processBuilder.directory(workingDir); + } + processBuilder.redirectErrorStream(true); + + try { + Process process = processBuilder.start(); + IProcess iProcess = DebugPlugin.newProcess(launch, process, "Cucumber Behave"); + iProcess.setAttribute(IProcess.ATTR_PROCESS_TYPE, "cucumber.behave"); + } catch (Exception e) { + throw new CoreException(new Status(IStatus.ERROR, Activator.PLUGIN_ID, + "Failed to launch behave process", e)); + } + } + + /** + * Substitute any variable + */ + private static String substituteVar(String s) { + if (s == null) { + return s; + } + try { + return VariablesPlugin.getDefault().getStringVariableManager().performStringSubstitution(s); + } catch (CoreException e) { + System.out.println("Could not substitute variable " + s); + return null; + } + } + +} diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveLaunchConstants.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveLaunchConstants.java new file mode 100644 index 00000000..f0f4ca80 --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveLaunchConstants.java @@ -0,0 +1,16 @@ +package io.cucumber.eclipse.python.launching; + +public interface CucumberBehaveLaunchConstants { + + public static final String TYPE_ID = "cucumber.eclipse.python.launching.localCucumberBehave"; + + public static final String ATTR_FEATURE_PATH = "cucumber feature"; + public static final String ATTR_FEATURE_WITH_LINE = "cucumber feature_with_line"; + public static final String ATTR_TAGS = "cucumber tags"; + public static final String ATTR_WORKING_DIRECTORY = "working directory"; + public static final String ATTR_PYTHON_INTERPRETER = "python interpreter"; + public static final String ATTR_IS_VERBOSE = "is verbose?"; + public static final String ATTR_IS_NO_CAPTURE = "is no-capture?"; + public static final String ATTR_IS_DRY_RUN = "is dry-run?"; + +} diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveLauncher.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveLauncher.java new file mode 100644 index 00000000..84344181 --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveLauncher.java @@ -0,0 +1,140 @@ +package io.cucumber.eclipse.python.launching; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.SubMonitor; +import org.eclipse.debug.core.DebugPlugin; +import org.eclipse.debug.core.ILaunchConfiguration; +import org.eclipse.debug.core.ILaunchConfigurationType; +import org.eclipse.debug.core.ILaunchConfigurationWorkingCopy; +import org.eclipse.debug.core.ILaunchManager; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.osgi.service.component.annotations.Component; + +import io.cucumber.eclipse.editor.document.GherkinEditorDocument; +import io.cucumber.eclipse.editor.launching.ILauncher; +import io.cucumber.eclipse.editor.launching.Mode; +import io.cucumber.messages.types.Scenario; +import io.cucumber.tagexpressions.Expression; + +/** + * Launches Cucumber feature files using Python's Behave framework + * + * @author copilot + */ +@Component(service = ILauncher.class) +public class CucumberBehaveLauncher implements ILauncher { + + @Override + public void launch(Map launchMap, Mode mode, boolean temporary, + IProgressMonitor monitor) throws CoreException { + + ILaunchManager lm = DebugPlugin.getDefault().getLaunchManager(); + ILaunchConfigurationType type = lm.getLaunchConfigurationType(CucumberBehaveLaunchConstants.TYPE_ID); + + SubMonitor subMonitor = SubMonitor.convert(monitor, launchMap.size() * 100); + + for (Entry entry : launchMap.entrySet()) { + GherkinEditorDocument document = entry.getKey(); + IResource resource = document.getResource(); + IProject project = resource.getProject(); + + if (project == null || !supports(resource)) { + continue; + } + + ILaunchConfiguration lc = getLaunchConfiguration(project, resource, type); + String identifier = mode.getLaunchMode().getIdentifier(); + + if (temporary) { + ILaunchConfigurationWorkingCopy copy = lc.getWorkingCopy(); + + // Handle scenarios with specific line numbers + List lines = new ArrayList<>(); + List tagFilters = new ArrayList<>(); + + IStructuredSelection selection = entry.getValue(); + for (Object object : selection) { + if (object instanceof Scenario) { + Scenario scenario = (Scenario) object; + lines.add(scenario.getLocation().getLine().intValue()); + } else if (object instanceof Expression) { + tagFilters.add((Expression) object); + } + } + + // Set feature path with line numbers if any scenarios are selected + if (!lines.isEmpty()) { + String featurePathWithLine = resource.getLocation().toOSString() + ":" + + lines.stream().map(String::valueOf).collect(Collectors.joining(":")); + copy.setAttribute(CucumberBehaveLaunchConstants.ATTR_FEATURE_WITH_LINE, featurePathWithLine); + } + + // Set tag filters if any + if (!tagFilters.isEmpty()) { + String tags = tagFilters.stream() + .map(Expression::toString) + .collect(Collectors.joining(" and ")); + copy.setAttribute(CucumberBehaveLaunchConstants.ATTR_TAGS, tags); + } + + copy.launch(identifier, subMonitor.split(100)); + } else { + lc.launch(identifier, subMonitor.split(100)); + } + } + } + + private ILaunchConfiguration getLaunchConfiguration(IProject project, IResource resource, + ILaunchConfigurationType type) throws CoreException { + + ILaunchManager lm = DebugPlugin.getDefault().getLaunchManager(); + String featurePath = resource.getLocation().toOSString(); + + // Try to find existing configuration + for (ILaunchConfiguration configuration : lm.getLaunchConfigurations(type)) { + String configPath = configuration.getAttribute(CucumberBehaveLaunchConstants.ATTR_FEATURE_PATH, ""); + if (featurePath.equals(configPath)) { + return configuration; + } + } + + // Create new configuration + ILaunchConfigurationWorkingCopy wc = type.newInstance(null, + lm.generateLaunchConfigurationName(resource.getName())); + + wc.setAttribute(CucumberBehaveLaunchConstants.ATTR_FEATURE_PATH, featurePath); + wc.setAttribute(CucumberBehaveLaunchConstants.ATTR_WORKING_DIRECTORY, + project.getLocation().toOSString()); + wc.setAttribute(CucumberBehaveLaunchConstants.ATTR_PYTHON_INTERPRETER, "python"); + + // Set mapped resource for better launch configuration management + wc.setMappedResources(new IResource[] { resource }); + + // Apply test result listener configuration + TEST_RESULT_LISTENER_CONFIGURER.apply(wc); + + return wc.doSave(); + } + + @Override + public boolean supports(IResource resource) { + return BehaveProcessLauncher.isBehaveProject(resource); + } + + @Override + public boolean supports(Mode mode) { + // For now, only support RUN mode. Debug support can be added later. + return mode == Mode.RUN; + } + +} diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveMainTab.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveMainTab.java new file mode 100644 index 00000000..c5934203 --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveMainTab.java @@ -0,0 +1,277 @@ +package io.cucumber.eclipse.python.launching; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.debug.core.ILaunchConfiguration; +import org.eclipse.debug.core.ILaunchConfigurationWorkingCopy; +import org.eclipse.debug.internal.ui.SWTFactory; +import org.eclipse.debug.ui.AbstractLaunchConfigurationTab; +import org.eclipse.debug.ui.ILaunchConfigurationTab; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.ModifyEvent; +import org.eclipse.swt.events.ModifyListener; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.events.SelectionListener; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.DirectoryDialog; +import org.eclipse.swt.widgets.FileDialog; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Text; + +public class CucumberBehaveMainTab extends AbstractLaunchConfigurationTab implements ILaunchConfigurationTab { + + protected Text featurePathText; + protected Text workingDirectoryText; + protected Text pythonInterpreterText; + protected Text tagsText; + private WidgetListener listener = new WidgetListener(); + private Button featureButton; + private Button workingDirectoryButton; + private Button verboseCheckbox; + private Button noCaptureCheckbox; + private Button dryRunCheckbox; + + private class WidgetListener implements ModifyListener, SelectionListener { + + @Override + public void modifyText(ModifyEvent e) { + updateLaunchConfigurationDialog(); + } + + @Override + public void widgetDefaultSelected(SelectionEvent e) { + } + + @Override + public void widgetSelected(SelectionEvent e) { + Object source = e.getSource(); + if (source == featureButton) { + FileDialog fileDialog = new FileDialog(getShell()); + fileDialog.setText("Select Feature File"); + fileDialog.setFilterExtensions(new String[] { "*.feature" }); + fileDialog.setFilterNames(new String[] { "Feature Files (*.feature)" }); + fileDialog.setFileName(featurePathText.getText()); + String selected = fileDialog.open(); + if (selected != null) { + featurePathText.setText(selected); + } + } else if (source == workingDirectoryButton) { + DirectoryDialog directoryDialog = new DirectoryDialog(getShell()); + directoryDialog.setText("Select Working Directory"); + directoryDialog.setFilterPath(workingDirectoryText.getText()); + String selected = directoryDialog.open(); + if (selected != null) { + workingDirectoryText.setText(selected); + } + } + updateLaunchConfigurationDialog(); + } + } + + @Override + public void createControl(Composite parent) { + Composite comp = SWTFactory.createComposite(parent, parent.getFont(), 1, 1, GridData.FILL_BOTH); + createFeaturePathEditor(comp); + createWorkingDirectoryEditor(comp); + createPythonInterpreterEditor(comp); + createTagsEditor(comp); + createBehaveOptions(comp); + setControl(comp); + } + + private void createFeaturePathEditor(Composite comp) { + Font font = comp.getFont(); + Group group = new Group(comp, SWT.NONE); + group.setText("Feature Path:"); + GridData gd = new GridData(GridData.FILL_HORIZONTAL); + group.setLayoutData(gd); + GridLayout layout = new GridLayout(); + layout.numColumns = 2; + group.setLayout(layout); + group.setFont(font); + + featurePathText = new Text(group, SWT.SINGLE | SWT.BORDER); + gd = new GridData(GridData.FILL_HORIZONTAL); + featurePathText.setLayoutData(gd); + featurePathText.setFont(font); + featurePathText.addModifyListener(listener); + + featureButton = createPushButton(group, "Browse...", null); + featureButton.addSelectionListener(listener); + } + + private void createWorkingDirectoryEditor(Composite comp) { + Font font = comp.getFont(); + Group group = new Group(comp, SWT.NONE); + group.setText("Working Directory:"); + GridData gd = new GridData(GridData.FILL_HORIZONTAL); + group.setLayoutData(gd); + GridLayout layout = new GridLayout(); + layout.numColumns = 2; + group.setLayout(layout); + group.setFont(font); + + workingDirectoryText = new Text(group, SWT.SINGLE | SWT.BORDER); + gd = new GridData(GridData.FILL_HORIZONTAL); + workingDirectoryText.setLayoutData(gd); + workingDirectoryText.setFont(font); + workingDirectoryText.addModifyListener(listener); + + workingDirectoryButton = createPushButton(group, "Browse...", null); + workingDirectoryButton.addSelectionListener(listener); + } + + private void createPythonInterpreterEditor(Composite comp) { + Font font = comp.getFont(); + Group group = new Group(comp, SWT.NONE); + group.setText("Python Interpreter:"); + GridData gd = new GridData(GridData.FILL_HORIZONTAL); + group.setLayoutData(gd); + GridLayout layout = new GridLayout(); + layout.numColumns = 1; + group.setLayout(layout); + group.setFont(font); + + pythonInterpreterText = new Text(group, SWT.SINGLE | SWT.BORDER); + gd = new GridData(GridData.FILL_HORIZONTAL); + pythonInterpreterText.setLayoutData(gd); + pythonInterpreterText.setFont(font); + pythonInterpreterText.addModifyListener(listener); + } + + private void createTagsEditor(Composite comp) { + Font font = comp.getFont(); + Group group = new Group(comp, SWT.NONE); + group.setText("Tags:"); + GridData gd = new GridData(GridData.FILL_HORIZONTAL); + group.setLayoutData(gd); + GridLayout layout = new GridLayout(); + layout.numColumns = 1; + group.setLayout(layout); + group.setFont(font); + + tagsText = new Text(group, SWT.SINGLE | SWT.BORDER); + gd = new GridData(GridData.FILL_HORIZONTAL); + tagsText.setLayoutData(gd); + tagsText.setFont(font); + tagsText.addModifyListener(listener); + } + + private void createBehaveOptions(Composite comp) { + Font font = comp.getFont(); + Group group = new Group(comp, SWT.NONE); + group.setText("Behave Options:"); + GridData gd = new GridData(GridData.FILL_HORIZONTAL); + group.setLayoutData(gd); + GridLayout layout = new GridLayout(); + layout.numColumns = 2; + group.setLayout(layout); + group.setFont(font); + + verboseCheckbox = new Button(group, SWT.CHECK); + verboseCheckbox.addSelectionListener(listener); + verboseCheckbox.setText("Verbose"); + + noCaptureCheckbox = new Button(group, SWT.CHECK); + noCaptureCheckbox.addSelectionListener(listener); + noCaptureCheckbox.setText("No Capture"); + + dryRunCheckbox = new Button(group, SWT.CHECK); + dryRunCheckbox.addSelectionListener(listener); + dryRunCheckbox.setText("Dry Run"); + } + + @Override + public String getName() { + return "Cucumber Behave Options"; + } + + @Override + public void performApply(ILaunchConfigurationWorkingCopy config) { + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_FEATURE_PATH, featurePathText.getText().trim()); + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_WORKING_DIRECTORY, workingDirectoryText.getText().trim()); + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_PYTHON_INTERPRETER, pythonInterpreterText.getText().trim()); + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_TAGS, tagsText.getText().trim()); + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_IS_VERBOSE, verboseCheckbox.getSelection()); + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_IS_NO_CAPTURE, noCaptureCheckbox.getSelection()); + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_IS_DRY_RUN, dryRunCheckbox.getSelection()); + } + + @Override + public void setDefaults(ILaunchConfigurationWorkingCopy config) { + // Set default values + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_FEATURE_PATH, ""); + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_WORKING_DIRECTORY, getDefaultWorkingDirectory()); + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_PYTHON_INTERPRETER, "python"); + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_TAGS, ""); + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_IS_VERBOSE, false); + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_IS_NO_CAPTURE, false); + config.setAttribute(CucumberBehaveLaunchConstants.ATTR_IS_DRY_RUN, false); + } + + @Override + public void initializeFrom(ILaunchConfiguration config) { + updateFromConfig(config, CucumberBehaveLaunchConstants.ATTR_FEATURE_PATH, featurePathText); + updateFromConfig(config, CucumberBehaveLaunchConstants.ATTR_WORKING_DIRECTORY, workingDirectoryText); + updateFromConfig(config, CucumberBehaveLaunchConstants.ATTR_PYTHON_INTERPRETER, pythonInterpreterText); + updateFromConfig(config, CucumberBehaveLaunchConstants.ATTR_TAGS, tagsText); + + verboseCheckbox.setSelection(getAttribute(config, CucumberBehaveLaunchConstants.ATTR_IS_VERBOSE, false)); + noCaptureCheckbox.setSelection(getAttribute(config, CucumberBehaveLaunchConstants.ATTR_IS_NO_CAPTURE, false)); + dryRunCheckbox.setSelection(getAttribute(config, CucumberBehaveLaunchConstants.ATTR_IS_DRY_RUN, false)); + } + + private void updateFromConfig(ILaunchConfiguration config, String attribute, Text text) { + String value = ""; + try { + value = config.getAttribute(attribute, ""); + } catch (CoreException e) { + // Use default empty value + } + text.setText(value); + } + + private boolean getAttribute(ILaunchConfiguration config, String attribute, boolean defaultValue) { + try { + return config.getAttribute(attribute, defaultValue); + } catch (CoreException e) { + return defaultValue; + } + } + + private String getDefaultWorkingDirectory() { + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + IProject[] projects = root.getProjects(); + if (projects.length > 0) { + return projects[0].getLocation().toOSString(); + } + return ""; + } + + @Override + public boolean isValid(ILaunchConfiguration config) { + setErrorMessage(null); + setMessage(null); + + String featurePath = featurePathText.getText().trim(); + if (featurePath.isEmpty()) { + setErrorMessage("Feature path must be specified"); + return false; + } + + String workingDirectory = workingDirectoryText.getText().trim(); + if (workingDirectory.isEmpty()) { + setErrorMessage("Working directory must be specified"); + return false; + } + + return true; + } + +} diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveTabGroup.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveTabGroup.java new file mode 100644 index 00000000..296ab3f8 --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/launching/CucumberBehaveTabGroup.java @@ -0,0 +1,22 @@ +package io.cucumber.eclipse.python.launching; + +import org.eclipse.debug.ui.AbstractLaunchConfigurationTabGroup; +import org.eclipse.debug.ui.CommonTab; +import org.eclipse.debug.ui.EnvironmentTab; +import org.eclipse.debug.ui.ILaunchConfigurationDialog; +import org.eclipse.debug.ui.ILaunchConfigurationTab; +import org.eclipse.debug.ui.ILaunchConfigurationTabGroup; + +public class CucumberBehaveTabGroup extends AbstractLaunchConfigurationTabGroup + implements ILaunchConfigurationTabGroup { + + @Override + public void createTabs(ILaunchConfigurationDialog dialog, String mode) { + ILaunchConfigurationTab[] tabs = new ILaunchConfigurationTab[] { + new CucumberBehaveMainTab(), + new EnvironmentTab(), + new CommonTab() + }; + setTabs(tabs); + } +} diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/preferences/BehavePreferencePage.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/preferences/BehavePreferencePage.java new file mode 100644 index 00000000..dc92add5 --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/preferences/BehavePreferencePage.java @@ -0,0 +1,74 @@ +package io.cucumber.eclipse.python.preferences; + +import org.eclipse.debug.internal.ui.SWTFactory; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.preference.PreferencePage; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Text; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.IWorkbenchPreferencePage; + +/** + * Preference page for Behave backend configuration + */ +public class BehavePreferencePage extends PreferencePage implements IWorkbenchPreferencePage { + + public static final String PAGE_ID = "io.cucumber.eclipse.python.preferences.BehavePreferencePage"; + + private Text behaveCommandText; + private BehavePreferences behavePreferences; + + public BehavePreferencePage() { + super(); + behavePreferences = BehavePreferences.of(); + setPreferenceStore(behavePreferences.store()); + setTitle("Behave Backend"); + setDescription("Configure Python Behave backend settings for Cucumber Eclipse"); + } + + @Override + protected Control createContents(Composite parent) { + Composite composite = SWTFactory.createComposite(parent, parent.getFont(), 1, 1, GridData.FILL_BOTH, 0, 0); + + // Behave command configuration + Label commandLabel = new Label(composite, SWT.NONE); + commandLabel.setText("Behave Command:"); + commandLabel.setLayoutData(new GridData(GridData.BEGINNING, GridData.CENTER, false, false)); + + behaveCommandText = new Text(composite, SWT.SINGLE | SWT.BORDER); + behaveCommandText.setLayoutData(new GridData(GridData.FILL, GridData.CENTER, true, false)); + behaveCommandText.setText(behavePreferences.behaveCommand()); + + Label hintLabel = new Label(composite, SWT.WRAP); + hintLabel.setText("Specify the command to launch behave (e.g., 'behave', '/usr/bin/behave', or 'python -m behave').\nDefault is 'behave'."); + GridData hintData = new GridData(GridData.FILL, GridData.CENTER, true, false); + hintData.widthHint = 400; + hintLabel.setLayoutData(hintData); + + return composite; + } + + @Override + public void init(IWorkbench workbench) { + // Nothing to initialize + } + + @Override + protected void performDefaults() { + super.performDefaults(); + behaveCommandText.setText(BehavePreferences.DEFAULT_BEHAVE_COMMAND); + } + + @Override + public boolean performOk() { + IPreferenceStore store = getPreferenceStore(); + if (store != null) { + store.setValue(BehavePreferences.PREF_BEHAVE_COMMAND, behaveCommandText.getText().trim()); + } + return super.performOk(); + } +} diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/preferences/BehavePreferences.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/preferences/BehavePreferences.java new file mode 100644 index 00000000..accde456 --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/preferences/BehavePreferences.java @@ -0,0 +1,90 @@ +package io.cucumber.eclipse.python.preferences; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.ProjectScope; +import org.eclipse.core.runtime.preferences.IEclipsePreferences; +import org.eclipse.core.runtime.preferences.InstanceScope; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.ui.preferences.ScopedPreferenceStore; + +import io.cucumber.eclipse.python.Activator; + +/** + * Manages Behave preferences at workspace and project level + */ +public class BehavePreferences { + + public static final String PREF_BEHAVE_COMMAND = "behave.command"; + public static final String DEFAULT_BEHAVE_COMMAND = "behave"; + + private final IPreferenceStore store; + private final IEclipsePreferences node; + + private BehavePreferences(IPreferenceStore store, IEclipsePreferences node) { + this.store = store; + this.node = node; + } + + /** + * Get workspace-level preferences + */ + public static BehavePreferences of() { + return of((IResource) null); + } + + /** + * Get project-level preferences for a resource + */ + public static BehavePreferences of(IResource resource) { + if (resource != null) { + IProject project = resource.getProject(); + if (project != null) { + return of(project); + } + } + // Return workspace-level preferences + ScopedPreferenceStore store = new ScopedPreferenceStore(InstanceScope.INSTANCE, Activator.PLUGIN_ID); + IEclipsePreferences node = InstanceScope.INSTANCE.getNode(Activator.PLUGIN_ID); + return new BehavePreferences(store, node); + } + + /** + * Get project-level preferences + */ + public static BehavePreferences of(IProject project) { + if (project != null) { + ScopedPreferenceStore store = new ScopedPreferenceStore(new ProjectScope(project), Activator.PLUGIN_ID); + IEclipsePreferences node = new ProjectScope(project).getNode(Activator.PLUGIN_ID); + return new BehavePreferences(store, node); + } + return of((IResource) null); + } + + /** + * Get the behave command to use + */ + public String behaveCommand() { + if (store != null) { + String command = store.getString(PREF_BEHAVE_COMMAND); + if (command != null && !command.isEmpty()) { + return command; + } + } + return DEFAULT_BEHAVE_COMMAND; + } + + /** + * Get the preference store + */ + public IPreferenceStore store() { + return store; + } + + /** + * Get the Eclipse preferences node + */ + public IEclipsePreferences node() { + return node; + } +} diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/steps/PythonStepDefinitionOpener.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/steps/PythonStepDefinitionOpener.java new file mode 100644 index 00000000..8bc8f513 --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/steps/PythonStepDefinitionOpener.java @@ -0,0 +1,92 @@ +package io.cucumber.eclipse.python.steps; + +import java.io.File; +import java.util.Collection; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.Path; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.ITextViewer; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.PartInitException; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.ide.IDE; +import org.eclipse.ui.texteditor.ITextEditor; +import org.osgi.service.component.annotations.Component; + +import io.cucumber.eclipse.editor.hyperlinks.IStepDefinitionOpener; +import io.cucumber.eclipse.python.Activator; +import io.cucumber.eclipse.python.launching.BehaveProcessLauncher; +import io.cucumber.eclipse.python.validation.BehaveGlueValidator; +import io.cucumber.eclipse.python.validation.StepMatch; +import io.cucumber.messages.types.Step; + +/** + * Opens Python step definitions when user Ctrl+Clicks on a step + */ +@Component(service = IStepDefinitionOpener.class) +public class PythonStepDefinitionOpener implements IStepDefinitionOpener { + + @Override + public boolean canOpen(IResource resource) throws CoreException { + return BehaveProcessLauncher.isBehaveProject(resource); + } + + @Override + public boolean openInEditor(ITextViewer textViewer, IResource resource, Step step) throws CoreException { + if (resource == null || step == null) { + return false; + } + + IDocument document = textViewer.getDocument(); + Collection matchedSteps = BehaveGlueValidator.getMatchedSteps(document); + + // Find the step match for this step based on line number + int stepLine = step.getLocation().getLine().intValue(); + StepMatch match = matchedSteps.stream().filter(m -> m.getFeatureLine() == stepLine).findFirst().orElse(null); + + if (match == null) { + return false; + } + + try { + // Open the Python file at the specified line + IProject project = resource.getProject(); + String stepFile = match.getStepFile(); + + // Resolve the step file path relative to the project + File file = new File(project.getLocation().toFile(), stepFile); + if (!file.exists()) { + // Try as absolute path + file = new File(stepFile); + } + + if (file.exists()) { + IWorkbenchPage page = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + IEditorPart editorPart = IDE.openEditor(page, + project.getWorkspace().getRoot() + .getFileForLocation(new Path(file.getAbsolutePath())), + "org.python.pydev.editor.PythonEditor", true); + + // Navigate to the line + if (editorPart instanceof ITextEditor) { + ITextEditor textEditor = (ITextEditor) editorPart; + IDocument targetDoc = textEditor.getDocumentProvider().getDocument(textEditor.getEditorInput()); + if (targetDoc != null) { + // Line numbers are 1-based in the step match, but 0-based in the document + int lineOffset = targetDoc.getLineOffset(match.getStepLine() - 1); + textEditor.selectAndReveal(lineOffset, 0); + } + } + return true; + } + } catch (PartInitException | org.eclipse.jface.text.BadLocationException e) { + Activator.getDefault().getLog().error("Failed to open Python step definition", e); + } + + return false; + } +} diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/validation/BehaveGlueJob.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/validation/BehaveGlueJob.java new file mode 100644 index 00000000..9928fb49 --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/validation/BehaveGlueJob.java @@ -0,0 +1,169 @@ +package io.cucumber.eclipse.python.validation; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.ILog; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; +import org.eclipse.core.runtime.jobs.Job; + +import io.cucumber.eclipse.editor.document.GherkinEditorDocument; +import io.cucumber.eclipse.python.Activator; +import io.cucumber.eclipse.python.launching.BehaveProcessLauncher; +import io.cucumber.eclipse.python.preferences.BehavePreferences; + +/** + * Background job that runs behave --dry-run to validate step definitions + */ +final class BehaveGlueJob extends Job { + + // Pattern to match step definition lines like: + // @given('I have a calculator') # calculator_steps.py:19 + private static final Pattern STEP_DEF_PATTERN = Pattern + .compile("@\\w+\\('(.+?)'\\)\\s+#\\s+(.+?):(\\d+)"); + + // Pattern to match step usage lines like: + // Given I have a calculator # ../calculator.feature:7 + private static final Pattern STEP_USAGE_PATTERN = Pattern + .compile("\\s+(Given|When|Then|And|But)\\s+(.+?)\\s+#\\s+(.+?):(\\d+)"); + + private Supplier documentSupplier; + private volatile Collection matchedSteps = Collections.emptyList(); + + BehaveGlueJob(Supplier documentSupplier) { + super("Verify Behave Glue Code"); + this.documentSupplier = documentSupplier; + } + + @Override + protected IStatus run(IProgressMonitor monitor) { + GherkinEditorDocument editorDocument = documentSupplier.get(); + if (editorDocument == null) { + return Status.CANCEL_STATUS; + } + + IResource resource = editorDocument.getResource(); + if (resource == null) { + return Status.CANCEL_STATUS; + } + + IProject project = resource.getProject(); + if (project == null) { + return Status.CANCEL_STATUS; + } + + try { + // Get behave command from preferences + BehavePreferences preferences = BehavePreferences.of(resource); + String behaveCommand = preferences.behaveCommand(); + + // Run behave --dry-run --format steps.usage --no-summary + String workingDir = project.getLocation().toOSString(); + String featurePath = resource.getLocation().toOSString(); + + BehaveProcessLauncher launcher = new BehaveProcessLauncher() + .withCommand(behaveCommand) + .withFeaturePath(featurePath) + .withWorkingDirectory(workingDir) + .withDryRun(true) + .withFormat("steps.usage") + .withNoSummary(true); + + Process process = launcher.launch(); + + // Parse the output + Map stepMatchMap = new HashMap<>(); + String currentStepPattern = null; + String currentStepFile = null; + int currentStepLine = -1; + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + if (monitor.isCanceled()) { + process.destroy(); + return Status.CANCEL_STATUS; + } + + // Check if this is a step definition line + Matcher defMatcher = STEP_DEF_PATTERN.matcher(line); + if (defMatcher.find()) { + currentStepPattern = defMatcher.group(1); + currentStepFile = defMatcher.group(2); + currentStepLine = Integer.parseInt(defMatcher.group(3)); + continue; + } + + // Check if this is a step usage line + Matcher usageMatcher = STEP_USAGE_PATTERN.matcher(line); + if (usageMatcher.find() && currentStepPattern != null) { + String stepText = usageMatcher.group(2); + String featureFile = usageMatcher.group(3); + int featureLine = Integer.parseInt(usageMatcher.group(4)); + + // Only record matches for the current feature file + if (featureFile.contains(resource.getName())) { + StepMatch match = new StepMatch(featureLine, stepText, currentStepFile, currentStepLine, + currentStepPattern); + stepMatchMap.put(featureLine, match); + } + } + } + } + + // Wait for process to complete + int exitCode = process.waitFor(); + + // Store matched steps + matchedSteps = new ArrayList<>(stepMatchMap.values()); + + // Collect all steps from the feature document to find unmatched ones + List unmatchedLineNumbers = new ArrayList<>(); + editorDocument.getSteps().forEach(step -> { + int lineNumber = step.getLocation().getLine().intValue(); + if (!stepMatchMap.containsKey(lineNumber)) { + unmatchedLineNumbers.add(lineNumber); + } + }); + + // Create markers for unmatched steps + BehaveMarkerFactory.unmatchedSteps(resource, unmatchedLineNumbers, Activator.PLUGIN_ID, false); + + return Status.OK_STATUS; + + } catch (InterruptedException e) { + // Return cancel status for interrupted exception + return Status.CANCEL_STATUS; + } catch (IOException e) { + // Log the error but don't show error popup - create marker instead + ILog.get().error("Behave validation failed - check that behave is installed and accessible", e); + try { + BehaveMarkerFactory.behaveExecutionError(resource, + "Failed to run behave for validation. Check that behave is installed and the behave command is configured correctly in preferences. See error log for details."); + } catch (CoreException ce) { + // Ignore marker creation failure + } + return Status.OK_STATUS; + } catch (CoreException e) { + return e.getStatus(); + } + } + + public Collection getMatchedSteps() { + return matchedSteps; + } +} diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/validation/BehaveGlueValidator.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/validation/BehaveGlueValidator.java new file mode 100644 index 00000000..42c733a3 --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/validation/BehaveGlueValidator.java @@ -0,0 +1,127 @@ +package io.cucumber.eclipse.python.validation; + +import java.util.Collection; +import java.util.Collections; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.eclipse.core.filebuffers.FileBuffers; +import org.eclipse.core.filebuffers.IDocumentSetupParticipant; +import org.eclipse.core.filebuffers.IFileBuffer; +import org.eclipse.core.filebuffers.IFileBufferListener; +import org.eclipse.core.filebuffers.ITextFileBuffer; +import org.eclipse.core.runtime.IPath; +import org.eclipse.jface.text.DocumentEvent; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IDocumentListener; + +import io.cucumber.eclipse.editor.document.GherkinEditorDocument; + +/** + * Performs validation on Python/Behave feature files by running behave --dry-run + * to verify step definition matching + * + * @author copilot + */ +public class BehaveGlueValidator implements IDocumentSetupParticipant { + + private static ConcurrentMap jobMap = new ConcurrentHashMap<>(); + + static { + FileBuffers.getTextFileBufferManager().addFileBufferListener(new IFileBufferListener() { + + @Override + public void underlyingFileMoved(IFileBuffer buffer, IPath path) { + } + + @Override + public void underlyingFileDeleted(IFileBuffer buffer) { + } + + @Override + public void stateValidationChanged(IFileBuffer buffer, boolean isStateValidated) { + } + + @Override + public void stateChanging(IFileBuffer buffer) { + } + + @Override + public void stateChangeFailed(IFileBuffer buffer) { + } + + @Override + public void dirtyStateChanged(IFileBuffer buffer, boolean isDirty) { + } + + @Override + public void bufferDisposed(IFileBuffer buffer) { + if (buffer instanceof ITextFileBuffer) { + IDocument document = ((ITextFileBuffer) buffer).getDocument(); + BehaveGlueJob remove = jobMap.remove(document); + if (remove != null) { + remove.cancel(); + } + } + } + + @Override + public void bufferCreated(IFileBuffer buffer) { + } + + @Override + public void bufferContentReplaced(IFileBuffer buffer) { + } + + @Override + public void bufferContentAboutToBeReplaced(IFileBuffer buffer) { + } + }); + } + + @Override + public void setup(IDocument document) { + document.addDocumentListener(new IDocumentListener() { + + @Override + public void documentChanged(DocumentEvent event) { + validate(document, 1000); + } + + @Override + public void documentAboutToBeChanged(DocumentEvent event) { + } + }); + validate(document, 0); + } + + private static void validate(IDocument document, int delay) { + jobMap.compute(document, (key, oldJob) -> { + if (oldJob != null) { + oldJob.cancel(); + } + BehaveGlueJob verificationJob = new BehaveGlueJob(() -> GherkinEditorDocument.get(document)); + verificationJob.setUser(false); + verificationJob.setPriority(org.eclipse.core.runtime.jobs.Job.DECORATE); + if (delay > 0) { + verificationJob.schedule(delay); + } else { + verificationJob.schedule(); + } + return verificationJob; + }); + } + + /** + * Get the matched steps for a document + */ + public static Collection getMatchedSteps(IDocument document) { + if (document != null) { + BehaveGlueJob job = jobMap.get(document); + if (job != null) { + return job.getMatchedSteps(); + } + } + return Collections.emptyList(); + } +} diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/validation/BehaveMarkerFactory.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/validation/BehaveMarkerFactory.java new file mode 100644 index 00000000..6d2d8db4 --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/validation/BehaveMarkerFactory.java @@ -0,0 +1,76 @@ +package io.cucumber.eclipse.python.validation; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.CoreException; + +import io.cucumber.eclipse.editor.marker.MarkerFactory; + +/** + * Utility class for creating Python/Behave specific markers + */ +public class BehaveMarkerFactory { + + /** + * Creates markers for unmatched steps (steps without matching glue code) + * + * @param resource the resource to mark + * @param lineNumbers list of line numbers where unmatched steps are located + * @param snippetType the type identifier for the snippet + * @param persistent whether markers should persist + * @throws CoreException if marker creation fails + */ + public static void unmatchedSteps(IResource resource, List lineNumbers, + String snippetType, boolean persistent) throws CoreException { + + // Delete existing unmatched step markers + IMarker[] existingMarkers = resource.findMarkers(MarkerFactory.UNMATCHED_STEP, true, IResource.DEPTH_ZERO); + for (IMarker marker : existingMarkers) { + String source = marker.getAttribute(IMarker.SOURCE_ID, ""); + if (source.startsWith(snippetType + "_")) { + marker.delete(); + } + } + + // Create new markers for unmatched steps + for (int lineNumber : lineNumbers) { + String sourceId = snippetType + "_" + lineNumber; + IMarker marker = resource.createMarker(MarkerFactory.UNMATCHED_STEP); + marker.setAttribute(IMarker.SOURCE_ID, sourceId); + marker.setAttribute(IMarker.SEVERITY, IMarker.SEVERITY_WARNING); + marker.setAttribute(IMarker.MESSAGE, "Step does not have a matching glue code"); + marker.setAttribute(IMarker.LINE_NUMBER, lineNumber); + marker.setAttribute(IMarker.TRANSIENT, !persistent); + } + } + + /** + * Creates an error marker indicating that behave execution failed + * + * @param resource the resource to mark + * @param message the error message + * @throws CoreException if marker creation fails + */ + public static void behaveExecutionError(IResource resource, String message) throws CoreException { + // Delete existing behave execution error markers + IMarker[] existingMarkers = resource.findMarkers(IMarker.PROBLEM, true, IResource.DEPTH_ZERO); + for (IMarker marker : existingMarkers) { + String source = marker.getAttribute(IMarker.SOURCE_ID, ""); + if ("behave_execution_error".equals(source)) { + marker.delete(); + } + } + + // Create new error marker + IMarker marker = resource.createMarker(IMarker.PROBLEM); + marker.setAttribute(IMarker.SOURCE_ID, "behave_execution_error"); + marker.setAttribute(IMarker.SEVERITY, IMarker.SEVERITY_ERROR); + marker.setAttribute(IMarker.MESSAGE, message); + marker.setAttribute(IMarker.LINE_NUMBER, 1); + marker.setAttribute(IMarker.TRANSIENT, true); + } +} diff --git a/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/validation/StepMatch.java b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/validation/StepMatch.java new file mode 100644 index 00000000..306f44bd --- /dev/null +++ b/io.cucumber.eclipse.python/src/io/cucumber/eclipse/python/validation/StepMatch.java @@ -0,0 +1,45 @@ +package io.cucumber.eclipse.python.validation; + +/** + * Represents a matched step with its location in the Python step definition file + */ +public class StepMatch { + private final int featureLine; + private final String stepText; + private final String stepFile; + private final int stepLine; + private final String stepPattern; + + public StepMatch(int featureLine, String stepText, String stepFile, int stepLine, String stepPattern) { + this.featureLine = featureLine; + this.stepText = stepText; + this.stepFile = stepFile; + this.stepLine = stepLine; + this.stepPattern = stepPattern; + } + + public int getFeatureLine() { + return featureLine; + } + + public String getStepText() { + return stepText; + } + + public String getStepFile() { + return stepFile; + } + + public int getStepLine() { + return stepLine; + } + + public String getStepPattern() { + return stepPattern; + } + + @Override + public String toString() { + return "StepMatch{featureLine=" + featureLine + ", stepFile=" + stepFile + ", stepLine=" + stepLine + "}"; + } +} diff --git a/pom.xml b/pom.xml index 6191ee89..2fcf3d5b 100644 --- a/pom.xml +++ b/pom.xml @@ -19,6 +19,7 @@ io.cucumber.eclipse.editor io.cucumber.eclipse.java io.cucumber.eclipse.java.plugins + io.cucumber.eclipse.python io.cucumber.eclipse.feature io.cucumber.eclipse.updatesite