Status: Draft for team review
Audience: Core team contributors
Related documents
| Document | Contents |
|---|---|
| Overview | Scope, goals, high-level architecture, roadmap, release strategy |
| Architecture & Implementation Reference | Module design, component inventory, server internals, IDE clients, cross-cutting concerns |
| Open Questions & Risk Register | Active open questions, risk register |
- Infrastructure: Linked Files and Project Membership
- Notation
- F1 · Gherkin Syntax Highlighting
- F2 · Binding Discovery
- F3 · Gherkin File Diagnostics
- F4 · Gherkin Parse Error Display
- F5 · Go to Step Definition
- F6 · Define Steps (Scaffolding)
- F7 · Keyword Completion
- F8 · Step Completion
- F9 · Document Outline
- F10 · Code Folding
- F11 · Document Auto-formatting
- F12 · Table Auto-formatting
- F13 · Comment / Uncomment
- F14 · Find Step Definition Usages
- F15 · Find Unused Step Definitions
- F16 · Step Rename Refactoring
- F17 · Hook Navigation
- F18 · Code Lens (Step Usage Counts)
- F19 · New Project / Item Wizards
- F20 · Installation & Upgrade Experience
- F23 · Inlay Hints (Step Binding Info)
- F24 · Hook Match CodeLens (Feature/Scenario/Step)
- F25 · Hook Match Count CodeLens (Hook Bindings)
- F26 · Test Runner Integration (Run/Debug + Failed-Step Highlight)
- F27 · C# Binding Validation Diagnostics
- Appendix B · Deferred / Future Features
This section documents the root cause analysis and design resolution for the linked-file membership problem (Q17). It is placed here because the analysis was driven by feature-level symptoms and the implementation plan cross-cuts several features. The design outcome — the path → {projects} index and the reqnroll/projectFiles notification — is described in the Architecture document §5 Workspace Model; the summary of how it shapes the overall implementation is in Architecture §2.
The Minimal/ExternalReferences corpus links one .feature file and two binding .cs files from the Minimal project into the ExternalReferences project. Running the LSP extension against it surfaced three concrete symptoms in the logs:
- The connector reports the linked file's physical path, identically for every linking project.
ExternalReferences's discovery returns step definitions whosesourceFiles[0]is…\Minimal\Minimal\StepDefinitions\CalculatorStepDefinitions.cs— the file's home inMinimal, not anything underExternalReferences\.Minimal's discovery reports the same path. This is inherent to reflection/PDB sequence points (they record the compile-time source path, which for a linked item is its original location). The connector output therefore gives no signal that one registry obtained the binding via a link. - The workspace-startup glob never sees the linked feature file from its linking project. The full-replacement scan reported "scanning 1 closed feature file under …\Minimal" but "scanning 0 closed feature file(s) under …\ExternalReferences", and "no open feature files to reparse under …\ExternalReferences" — because the feature file is physically under
Minimal\Minimal\Features\. This is the "Known limitation" noted in F14's implementation notes. didOpen/didChangecarry only the on-disk URI, with no project discriminator. This is inherent to LSP — aTextDocumentItemhas no project field, and the IDE will not tell the server which project's view of a shared file is open.
The server has no authoritative file→project map. Membership is inferred from on-disk folder containment in LspWorkspaceScopeManager.GetProjectForUri (filePath.StartsWith(p.ProjectFolder), longest prefix wins, FirstOrDefault). This collapses a many-to-many relation (one physical file ↔ many projects) into a single guess. A linked .cs/.feature physically under Minimal\ therefore always routes to Minimal, never to ExternalReferences; a file linked from outside every project folder routes to no project and falls back to default configuration (every step shows unmatched). In this corpus the defect is partly masked because ExternalReferences's registry is byte-identical to Minimal's; it becomes visible the moment two linking projects have different bindings (cf. Minimalnet481, which already produces a different regex for the same step text).
Both features are inherently many-to-many and cannot be computed correctly on the folder-prefix model:
- Find All Usages (F14) must union, across every project that includes that binding, the feature steps that match it. A binding linked into N projects is "used" if a feature in any of the N references it.
- Find All Unused (F15) must intersect: the binding is unused only if no feature in any including project references it. A folder-scoped search rooted at the binding's physical project will falsely report a linked-and-used binding as unused — an actively harmful result, since it invites deletion of live code.
The same model must handle the inverse of linking: a file physically inside a project folder but excluded from the .csproj (via <Compile Remove> / <None Remove> or a false Condition). An exclusion is simply the absence of a positive membership assertion, so it looks identical to "not yet reported." The analysis confirmed two failure modes the design must prevent: (a) the folder glob re-admitting an excluded closed feature file into a project's scan; and (b) the user opening an excluded file — which must not confer membership, must not push binding-dependent diagnostics for it, and (for an excluded .cs) must not let the Roslyn live path inject phantom bindings that flip a step to "matched" or a binding to "used" until the next build wipes them.
Adopt the path → {projects} membership index, populated by a new optional reqnroll/projectFiles notification. The decisions:
-
Membership is explicit, never inferred. Each IDE glue layer enumerates the project's feature files and binding source files, on-disk paths including links, and sends them via
reqnroll/projectFiles. The server never re-derives membership from the filesystem. (VsProjectEventMonitordoes not yet enumerate item lists andReqnrollProjectLoadedParamscarries onlyProjectFolder— both are extended for this; the VS enumeration moves from EnvDTE to CPS/MSBuild, see Architecture §6.2.) -
A separate notification, not extra fields on
projectLoaded. This is the resolution of the "extend vs. new message" sub-question, and it is driven by the three IDEs' differing capabilities:- VS Code has no MSBuild project system, so it cannot produce the manifest as a cheap byproduct of project load at all — membership requires an async MSBuild/C# Dev Kit evaluation that necessarily lands later than a coarse "project exists" signal.
- Visual Studio can produce it, but only via CPS/MSBuild evaluation — a slower, async path than the EnvDTE property reads that power
projectLoadedtoday. Coupling would make the fast path (output assembly → start discovery, observed in the logs within ~1 s of load) wait on the slow one. - Rider produces it readily from its backend project model, but the reliability of pushing a custom outbound notification through its built-in LSP client is unproven (cf. Q1/Q2).
An optional, snapshot-plus-delta message decouples fast-path discovery from membership, matches each project system's change cadence (membership changes far more often than build properties), and degrades gracefully per client (a client that can't produce it omits it; that project falls back to folder-prefix). The cost — tolerating
projectFiles/projectLoadedarriving in either order, keyed by(projectFile, TFM)— is the same pending-state machinery required by point 4 anyway. -
Two routing invariants (detailed in Architecture §5): membership is conferred only by the index (closed-file enumeration is index-driven, not a folder glob), and open-state never confers membership or accounting (an opened-but-unowned file gets registry-independent features only).
GetProjectForUrireturns a set; folder-prefix survives only as a read-only fallback for files no project claims. -
Absence means pending, then excluded. The server treats a file's absence from the index as unknown (defer binding-dependent features) until the project's first
baselinemanifest arrives, and as deliberately excluded thereafter. The glue layer re-sends membership on project load, rebuild, and.csprojchange, so re-including a file in the editor restores its ownership. -
Feature-aware operations iterate the owning set. Diagnostics/matching for a linked feature run against each owner's registry (a step is unmatched only if unmatched in all owners). F14 unions a binding's usages across all including projects; F15 intersects (unused only if unused in every including project).
-
F2 fan-out is deliberate. A single
didChangeto a linked.csinvalidates every registry that includes it; the per-file Roslyn patch fans out to all owning projects rather than a single target, and is gated on index membership so an excluded-but-open.cspatches nothing. See the F2 planned-change note.
The concrete code changes across the LSP server and VS extension — DTOs, the index in LspWorkspaceScopeManager, consumer re-gating, VS manifest production, phasing, and tests — are documented in Q17 Membership Index Implementation Plan.
IDE Support Matrix
| Column | Meaning |
|---|---|
| ✅ Generic | Works via standard LSP — no IDE-specific code needed |
| Minor IDE-side configuration required (e.g., static vs. dynamic registration override) | |
| 🔧 Plugin | Custom IDE plugin code required |
| ❌ N/A | Feature is not applicable to this IDE |
Current status: for the authoritative, up-to-date per-IDE implementation status of every feature — including whether the server branches on client identity for it — see the Cross-IDE client implementation & server-conditional-logic matrix in the Architecture doc. That table wins wherever the two disagree.
Sequence diagram conventions
- Participant names shown in bold in tables are OmniSharp protocol handler classes (running in the LSP Server process)
- Internal MediatR notifications are shown with
-->>(dashed arrow) and labelled[internal]
Phase 1
Keywords (Feature:, Scenario:, Given, When, Then, And, But), step text, bound step argument text, tags (@tag), doc strings, data table headers, data table cell content, and comments each render in distinct colors using the IDE's token-color theme. Colors update as the user types without requiring a save.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic |
Visual Studio note: VS has unreliable support for dynamic registration of textDocument/semanticTokens. The server declares semantic token capabilities statically in the initialize response when launched with --ide visualstudio. See the OmniSharp implementation note in Architecture §5.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/didOpen |
Send initial document content |
| Client → Server | textDocument/didChange |
Send incremental edits |
| Client → Server | textDocument/semanticTokens/full |
Request full token set |
| Client → Server | textDocument/semanticTokens/delta |
Request incremental update |
| Server → Client | Response to above | SemanticTokens / SemanticTokensDelta |
Gherkin dialect support: The Semantic Token Service reads the active dialect from the project's reqnroll.json (default: en). Non-English keywords (e.g., German Gegeben sei, French Soit, Dutch Stel) are tokenized as keyword type identically to their English equivalents. The dialect must be resolved before the first textDocument/semanticTokens/full request is processed for a project.
Semantic token types used (custom Reqnroll token types):
Rather than emitting the generic LSP standard token types (keyword, string, parameter, …), the server declares a set of custom semantic token types whose names match the custom ClassificationTypeDefinition names already used by the existing Reqnroll.VisualStudio extension (IdeSupportClassifications). This preserves an exact one-to-one correspondence between the LSP server's output and the classification concepts the existing extension's users already see, and lets each IDE map a Reqnroll-specific concept to a Reqnroll-specific color rather than overloading a host theme's generic scopes.
The server advertises these names in the legend.tokenTypes array of its textDocument/semanticTokens server capability (in the initialize response). The token type index emitted in each 5-tuple is an index into this legend. The legend is the contract between the server and every client; all three clients must map these same names.
| Custom token type (legend name) | IdeSupportClassifications constant |
Gherkin element |
|---|---|---|
reqnroll.keyword |
Keyword |
Feature:, Scenario:, Given, When, Then, And, But, Background:, Rule:, Examples:, Scenario Outline: |
reqnroll.tag |
Tag |
Tag (@tag) |
reqnroll.description |
Description |
Free-text description lines under Feature: / Scenario: |
reqnroll.comment |
Comment |
# line comments |
reqnroll.doc_string |
DocString |
Doc string (""" / ```) content |
reqnroll.data_table |
DataTable |
Data table cell content (non-header rows) |
reqnroll.data_table_header |
DataTableHeader |
Data table header row content |
reqnroll.step_parameter |
StepParameter |
Bound step argument values |
reqnroll.scenario_outline_placeholder |
ScenarioOutlinePlaceholder |
Scenario Outline parameter placeholders <param> |
reqnroll.undefined_step |
UndefinedStep |
Step text of a step with no matching binding (emitted once binding discovery — F2 — is available; in Phase 1 all step text is emitted without this type) |
reqnroll.ambiguous_step |
AmbiguousStep |
Step text of a step matching more than one binding (ambiguous match) |
Note:
reqnroll.undefined_stepandreqnroll.ambiguous_stepdepend on binding match results from F2 and therefore only carries meaning from Phase 2 onward. Its name is reserved in the legend from Phase 1 so the legend does not change across phases (a stable legend simplifies client-side mapping and avoids re-registration).
Why custom names instead of standard LSP types: The standard types force a lossy mapping (e.g., both tags and data tables would collapse onto a host theme's generic
string/typescopes, and there is no standard type that expresses "undefined step" or "scenario outline placeholder"). Custom names move the mapping decision to each client, where the existing color story can be reproduced faithfully. The trade-off is that a client that does not map these names gets no coloring at all for unmapped types (rather than a generic fallback); each client therefore ships a complete mapping (see below), and the VS Code client additionally ships a TextMate grammar fallback (see Architecture §6.1) for the activation gap.
The LSP legend only names the token types; it is the responsibility of each IDE client to map each legend name to a concrete editor color / classification. The mapping is intentionally pushed to the client so each IDE can honor its own theming system and the user's customized colors.
Visual Studio — The VSSDK side of the new extension (Reqnroll.IdeSupport.VisualStudio.VSSDKIntegration) re-uses the existing IdeSupportClassifications class verbatim, including its MEF exports (ClassificationTypeDefinition, EditorFormatDefinition/ClassificationFormatDefinition, and the [Name(...)] exports), so the custom classification types and their default formats (italic Description, the #887DBA Undefined Step foreground, etc.) are registered exactly as before.
Important — VS does not map custom LSP token types by name, and does not reliably pull them. Two confirmed VS limitations break the naïve approach:
- Visual Studio's built-in LSP semantic-token colorizer (
Microsoft.VisualStudio.LanguageServer.Client.SemanticTokensTaggerBase.ClassificationTypeNameForTokenType) maps token-type names to classifications through a fixed internalswitchthat only recognizes the standard LSP token types (plus C++/Roslyn/Razor sets); every unrecognized name — including allreqnroll.*names — falls through to plain"text". It never consults the classification registry by the raw legend name, so registering same-named classifications is not sufficient in VS (unlike VS Code / Rider).- VS only pulls
textDocument/semanticTokens/fulllazily and inconsistently (driven by its own tagger lifecycle); in practice it sometimes never requests tokens for an open document at all.Both were confirmed empirically (decompiling the shipped client; LSP trace logs) and are the R1/R1a risks in the Risk Register.
To restore the custom colors reliably, VS uses a server-push + client-classifier path that does not depend on VS's native semantic-token pull or its token-type mapping:
- Server push — when launched with
--ide visualstudio, the server'sSemanticTokensPushHandlerreacts to eachMatchCacheChangedNotificationby encoding the file's tokens and pushing them to the client via a customreqnroll/semanticTokensnotification ({ uri, version, data[] }). Every other client ignores this notification and uses the standard pull flow. (This is the one place the retained--ideflag changes server behaviour.) - Client capture — a
SemanticTokensClassificationInterceptoron the existingLspInterceptingPipecaptures thereqnroll/semanticTokensnotification (and the legend from theinitializeresponse), decodes the 5-int data, and caches absolute tokens per file in a process-wideSemanticTokenClassificationStore. Messages pass through untouched. - Client classifier — a classic MEF
IClassifierProvider([ContentType("Gherkin")]) returns aGherkinSemanticClassifierthat reads those cached tokens and emitsClassificationSpans, resolving each token's legend name to theIdeSupportClassificationsclassification of the same name viaIClassificationTypeRegistryService.
The net effect is still pixel-for-pixel continuity (existing users keep their configured Reqnroll colors under Tools → Options → Fonts and Colors with no migration) and the server's token encoding stays shared with the other IDEs — only the delivery (push vs pull) and the color mapping (our classifier vs VS's native colorizer) are VS-specific. VS's native colorizer, if it does pull, produces harmless "text" tags for the same spans; the derived Reqnroll classifications take precedence. Note: structural coloring (keywords, tags, comments, descriptions, doc strings, tables, placeholders) is fully covered; reqnroll.undefined_step coloring depends on binding match results and therefore only appears once F2 discovery is active.
VS Code — The client maps each custom token type to a color in one of two ways:
- A
semanticTokenScopescontribution inpackage.jsonthat associates eachreqnroll.*token type with one or more TextMate scopes, so existing color themes light them up automatically; and/or - A
configurationDefaultsblock settingeditor.semanticTokenColorCustomizationsto supply default Reqnroll colors (mirroring theIdeSupportClassificationsdefaults) for themes that do not style the mapped scopes.
The token-type names registered here must match the server legend exactly. (Table cell/header per-pipe coloring is additionally refined by the client-side TableHighlightService decorations described in Architecture §6.1; the reqnroll.data_table* token types provide the base coloring.)
Rider / IntelliJ Platform — Rider's built-in LSP client exposes a hook for translating LSP semantic token types into IntelliJ TextAttributesKeys. The Rider plugin registers a set of custom TextAttributesKeys (one per reqnroll.* legend name, with default colors mirroring IdeSupportClassifications) and overrides the LSP server descriptor's semantic-tokens customization (e.g., LspSemanticTokensSupport.getTextAttributesKey(tokenType, modifiers)) to return the matching key for each legend name. Registering these keys against a Reqnroll color-settings page also lets users recolor them under Settings → Editor → Color Scheme. This is additional Kotlin code in the Rider client beyond the thin-wrapper baseline and should be added to the plugin.xml extension-point list in Architecture §6.3.
Legend stability is a cross-client contract: Adding, removing, or reordering legend entries is a breaking change for all three client mappings simultaneously. The legend is therefore versioned with the server/clients as a unit (see Versioning and Compatibility), and new token types are appended (never reordered) so older index assumptions remain valid.
sequenceDiagram
actor User
participant IDE
box LightBlue LSP Server
participant TDS as TextDocumentSyncHandler
participant DTP as IdeSupportTagParser
participant DB as Document Buffer
participant BMS as Binding Match Service
participant STH as SemanticTokensHandler
participant STS as Semantic Token Service
end
User->>IDE: Opens / edits .feature file
IDE->>TDS: textDocument/didOpen (or didChange)
TDS->>DTP: Parse document + match steps against registry (GherkinDocumentTaggerService)
DTP-->>TDS: IdeSupportTag[] (structural + parse error + match tags, one AST walk)
TDS->>DB: Store (URI, version, IdeSupportTag[])
TDS->>BMS: Store FeatureBindingMatchSet (derived from tags)
Note over IDE,STH: Client requests token coloring
IDE->>STH: textDocument/semanticTokens/full
STH->>DB: Retrieve IdeSupportTag[] by URI
DB-->>STH: IdeSupportTag[]
STH->>STS: Compute tokens from IdeSupportTag[]
STS-->>STH: SemanticTokens[]
STH-->>IDE: SemanticTokens response
IDE-->>User: Colored keywords, tags, parameters
IdeSupportTagParser (GherkinDocumentTaggerService) wraps IdeSupportGherkinParser (the Gherkin parse step) and in one AST walk produces a IdeSupportTag[] tree encoding every downstream-needed piece of classification info together: structural spans (keywords, tags, descriptions, comments, doc strings, data tables), parse error spans, and — once a binding registry is available — step match results (DefinedStep, UndefinedStep, StepParameter, ScenarioOutlinePlaceholder, hook references). The Document Buffer stores this tag tree rather than a raw AST, and SemanticTokensService reads it directly. A FeatureBindingMatchSet is derived from the tags in the same pass and stored in the BindingMatchService, where Go to Definition, diagnostics, and find-usages features read it. This combined-pass design avoids joining AST structural info with match results at render time, and mirrors the approach Reqnroll.VisualStudio used.
Although textDocument/didChange may carry only the incremental text delta, the Gherkin parser always re-parses the entire file. Gherkin AST nodes carry absolute line/column locations; inserting or deleting a line shifts the position of every subsequent node, making partial re-parse impractical.
Phase 2 — prerequisite for F3, F5, F6, F8, F14, F15, F16, F17, F18
This feature is infrastructure, not directly visible. The outcome is that the LSP server maintains an up-to-date registry of step binding patterns, their locations in C# files, and their parameter types. This registry drives all step-related features.
Discovery starts automatically when a workspace folder is opened. The registry is updated when a .cs step file is saved (via Roslyn, immediately) or when the project is built (via the Connector, after compilation).
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic | ✅ Generic |
Both discovery paths are managed by the LSP server, so no IDE-specific code is required.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/didOpen / didChange (.cs files) |
Trigger Roslyn re-discovery for changed file |
| Client → Server | workspace/didChangeWatchedFiles |
Detect assembly changes (build complete) |
| Server (internal) | IPC to Connector | Launch reflection discovery, receive BindingDiscoveryResult |
| Server → Client | textDocument/publishDiagnostics |
Push updated diagnostics after registry change |
Resolved (Q9): watching the output assembly path via
workspace/didChangeWatchedFilesis confirmed reliable per-IDE using each client's standard dynamic-registration handling — no IDE-specific client code is needed. See Open Questions & Risk Register (Q9) for the verification details and the one confirmed caveat (files.watcherExcludecoveringbin/).
sequenceDiagram
participant IDE
box LightBlue LSP Server
participant TDS as TextDocumentSyncHandler
participant CDS as ICSharpBindingDiscoveryService
participant WFH as WatchedFilesHandler
participant BR as ConnectorBindingRegistryProvider
participant Connector as Reflection Connector
end
IDE->>TDS: initialized (workspace folders)
TDS->>CDS: Scan .cs files in project (StepDefinitionFileParser)
CDS-->>BR: Initial binding set (source-derived)
Note over IDE,BR: User saves a .cs step file
IDE->>TDS: textDocument/didChange (.cs file)
TDS->>CDS: Parse changed file (StepDefinitionFileParser)
Note over CDS,BR: Look up owning project(s) in the membership index
CDS->>BR: ApplyRoslynFileUpdateAsync — replace bindings for that file in EACH owning project's registry
BR-->>BR: [internal] BindingRegistryChangedNotification (per project)
Note over IDE,BR: Build detected (assembly changed)
IDE->>WFH: workspace/didChangeWatchedFiles (assembly path)
WFH->>Connector: Launch / notify connector process (IPC)
Connector->>Connector: Reflection scan of assembly
Connector-->>WFH: BindingDiscoveryResult
WFH->>BR: TriggerRefresh() — replace full registry
BR-->>BR: [internal] BindingRegistryChangedNotification
BR-->>IDE: textDocument/publishDiagnostics (all open feature files)
The Roslyn (source-level) path is implemented. TextDocumentSyncHandler is the single sync handler for both .feature and .cs documents, routed internally by extension; on a .cs didOpen/didChange it calls ICSharpBindingDiscoveryService directly, which parses the file syntactically via StepDefinitionFileParser (discovering step definitions and hooks, with scopes) and applies the result through ConnectorBindingRegistryProvider.ApplyRoslynFileUpdateAsync — a per-file replace layered on the current registry. That provider raises a BindingRegistryChanged event, relayed via BindingRegistryProviderRouter as the BindingRegistryChangedNotification shown above; BindingRegistryChangedHandler consumes it, re-parsing open feature files and refreshing semantic tokens.
Merge / precedence: the Roslyn patch is layered on top of the connector's current registry and intentionally does not advance the connector's last-good assembly hash. A real build (different assembly hash) therefore fully replaces the registry with the authoritative reflection result; with no rebuild, the connector run is a hash-match no-op and the source-level patch persists. This realizes the merge strategy described in Architecture §7.
Behavioural nuance: a step renders as unbound (a reqnroll.undefined_step token / "step definition not found" diagnostic) only once the owning project has a valid (non-Invalid) registry — i.e. after any discovery has completed, whether the startup reflection run or the first Roslyn .cs open. Against an Invalid registry (no discovery yet) the tag parser skips step matching, leaving steps unclassified rather than unbound.
The reflection (post-build) trigger shown in the lower half of the diagram is also implemented: WatchedFilesHandler registers workspace/didChangeWatchedFiles watchers for **/reqnroll.json, **/.editorconfig, and **/bin/**/*.dll, and calls ConnectorBindingRegistryProvider.TriggerRefresh() for the project whose output path matches. An initial run is likewise triggered on reqnroll/projectLoaded. VS Code was confirmed to reliably deliver those watched-file events on build via its standard LSP client with no IDE-specific glue (Q9, resolved for VS Code); VS doesn't need this signal at all, since VsProjectEventMonitor hooks DTE.Events.BuildEvents.OnBuildDone directly; Rider was not part of that verification.
As-built — index-driven, multi-project routing. CSharpBindingDiscoveryService routes a .cs edit via ILspWorkspaceScopeManager.ResolveOwners, which returns the set of owning projects (per the membership-index design), consulting the index first and falling back to longest folder-prefix match only for a project that hasn't yet sent a reqnroll/projectFiles baseline. The per-file Roslyn patch fans out to each owning project's registry — a linked .cs legitimately belongs to several projects, so one edit can invalidate several registries. The same lookup gates the patch: a .cs that no project's index claims — e.g. one excluded from its .csproj but opened in the editor — contributes bindings to no registry, preventing phantom bindings that would otherwise be wiped on the next build.
Custom-derived binding attributes are not discovered by Roslyn (source-level) discovery. The in-process Roslyn parser (StepDefinitionFileParser) is intentionally syntactic only — it parses a single .cs file into a syntax tree with no Compilation or semantic model — and recognizes bindings by matching the attribute's simple name against the known Reqnroll attribute names (Given/When/Then/StepDefinition and the hook attributes, allowing for namespace qualification and the Attribute suffix). A user-defined attribute that derives from a Reqnroll binding attribute (e.g. class GivenWebAttribute : GivenAttribute) is therefore not detected by the immediate-on-save Roslyn path, because resolving the inheritance chain would require a semantic model with the project's references.
Such bindings are still discovered by the out-of-process reflection Connector after a build, since reflection inspects the actual attribute type hierarchy. The practical effect is that a step bound via a custom-derived attribute will appear unmatched (warning squiggle) until the next build, after which it resolves normally.
We are not addressing this at this time. Closing the gap would mean feeding the Roslyn parser a project Compilation (Reqnroll + project references) and walking INamedTypeSymbol.BaseType, which is a larger change to how CSharpBindingDiscoveryService obtains source — it currently parses each .cs file in isolation. The limitation is captured by a skipped test in StepDefinitionFileParserTests.
Phase 2 — covers both missing step warnings and parse errors
Resolved — abandoned (Q19): diagnostic pull (textDocument/diagnostic request, LSP 3.17+) was investigated in addition to the push model described here, but abandoned — OmniSharp.Extensions.LanguageServer 0.19.9's server-side (write) JSON converters for the pull-diagnostics report types are NotImplementedException stubs. See Open Questions & Risk Register.
Two categories of diagnostic are displayed for .feature files:
- Binding mismatches (
DiagnosticSeverity.Warning, yellow squiggle,source: "reqnroll.binding"): steps that have no matching binding are underlined. Hovering shows "Step definition not found." — or, when the step structurally matches a binding that exists but is invalid (itsErrorset by the connector's import failure, or by F27's validation), that binding's specific error instead, e.g. "Binding method 'Setup' must be static because its containing type 'Hooks' is abstract." - Parse errors (
DiagnosticSeverity.Error, red squiggle,source: "reqnroll.parser"): structurally invalid Gherkin (e.g., missingFeature:header, invalid tag syntax) is underlined with a description.
Both categories are computed after every edit and pushed as a single textDocument/publishDiagnostics message. The LSP specification requires that one message delivers the complete diagnostic set for a URI; separate messages would clear previously delivered diagnostics of the other category. A DiagnosticsAggregator combines both sources before sending.
Diagnostics refresh after every textDocument/didChange and also whenever the Binding Registry changes (C# file save or build). On textDocument/didClose, an empty textDocument/publishDiagnostics is pushed for the closed URI to clear any squiggles the IDE retained.
Design rationale — color and squiggles are complementary, not redundant: F1/F2 already color unbound steps (purple in Visual Studio). F3 diagnostics are still required because: (a) squiggles appear in the IDE Problems panel / Error List, enabling cross-file triage and keyboard navigation ("Next Warning") that color cannot provide; (b) color-only feedback is inaccessible to colorblind users. Using
Warningrather thanErrorfor binding mismatches distinguishes them visually from parse errors and accommodates step-first development workflows where a binding may not yet exist.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic | ✅ Generic |
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/didOpen / didChange / didSave |
Trigger diagnostic pipeline for this file |
| Server → Client | textDocument/publishDiagnostics |
Push combined diagnostic set (one message per URI) |
sequenceDiagram
actor User
participant IDE
box LightBlue LSP Server
participant TDS as TextDocumentSyncHandler
participant DTP as IdeSupportTagParser
participant BR as Binding Registry
participant DB as Document Buffer
participant BMS as Binding Match Service
participant DPH as DiagnosticsPublishHandler
participant DA as DiagnosticsAggregator
end
User->>IDE: Edits .feature file
IDE->>TDS: textDocument/didChange
TDS->>DTP: Parse document + match steps in one AST walk
DTP->>BR: Look up current bindings (per project)
BR-->>DTP: ProjectBindingRegistry
DTP-->>TDS: IdeSupportTag[] (structural + parse error + match tags together)
TDS->>DB: Store updated IdeSupportTag[]
TDS->>BMS: Store FeatureBindingMatchSet (derived from tags)
TDS-->>DPH: [internal] MatchCacheChangedNotification
DPH->>DB: Retrieve ParserError tags for this URI
DPH->>BMS: Retrieve binding mismatches for this URI
DPH->>DA: Merge into combined Diagnostic[]
DA-->>DPH: GherkinDiagnostic[]
DPH-->>IDE: textDocument/publishDiagnostics (parse errors + unmatched steps)
IDE-->>User: Error squiggles (red, parse errors) + warning squiggles (yellow, unmatched steps)
Parsing and binding matching are not separate pipeline stages: IdeSupportTagParser performs both in a single AST walk (see F1), so there is no intermediate hop between a document change and a diagnostics push. Parse errors emerge as IdeSupportTag items of type ParserError — not a separate ParseErrors[] array — so DiagnosticsAggregator (LSP.Core/Diagnostics/, protocol-agnostic) retrieves them from the tag tree alongside UndefinedStep and BindingError tags and converts them into GherkinDiagnostic records. DiagnosticsPublishHandler (LSP.Server/Pipeline/) is the MatchCacheChangedNotification consumer shown above: it calls the aggregator, converts each GherkinDiagnostic.Range to an LSP Position, and pushes via ILanguageServerFacade.SendNotification("textDocument/publishDiagnostics", ...). The textDocument/didClose empty-diagnostics push is handled inline in TextDocumentSyncHandler.Handle(DidCloseTextDocumentParams) rather than via a separate notification, since no fan-out is required there.
Diagnostic ownership note (superseded by F27): this section originally stated that the server pushes
textDocument/publishDiagnosticsonly for.featurefile URIs, leaving.csdiagnostics exclusively to each IDE's native C# language server. F27 · C# Binding Validation Diagnostics revisits that decision: the server now also pushes binding-validation diagnostics for.csfiles, confirmed live to merge cleanly alongside each IDE's native C# diagnostics for the same file with no special handling on either side. C# parse errors, type errors, and anything else genuinely owned by the C# compiler remain exclusively the native language server's domain — F27 only ever reports Reqnroll binding-shape problems (e.g. a step-definition method that isn't static where required), never general C# correctness.
sequenceDiagram
participant IDE
box LightBlue LSP Server
participant BR as Binding Registry
participant BMH as BindingRegistryChangedHandler
participant DTP as IdeSupportTagParser
participant DB as Document Buffer
participant BMS as Binding Match Service
participant DPH as DiagnosticsPublishHandler
participant DA as DiagnosticsAggregator
end
Note over IDE,BR: C# file saved or build detected (see F2)
BR-->>BMH: [internal] BindingRegistryChangedNotification
loop For each open .feature file
BMH->>DTP: Re-parse + re-match (updated registry, from snapshot text)
DTP-->>BMH: IdeSupportTag[] (updated match tags)
BMH->>DB: Store updated IdeSupportTag[]
BMH->>BMS: Store updated FeatureBindingMatchSet
BMH-->>DPH: [internal] MatchCacheChangedNotification (featureURI)
DPH->>DB: Retrieve ParserError tags for featureURI
DPH->>BMS: Retrieve binding mismatches for featureURI
DPH->>DA: Merge into combined Diagnostic[]
DA-->>DPH: GherkinDiagnostic[]
DPH-->>IDE: textDocument/publishDiagnostics (featureURI)
end
IDE-->>IDE: Feature file squiggles updated
When the registry changes, BindingRegistryChangedHandler re-invokes IdeSupportTagParser for each open feature file, taking the snapshot text (not a cached AST) as input — so the Gherkin text is re-parsed on every registry-change re-tag, a minor cost accepted because Gherkin parsing is fast and caching the intermediate IdeSupportGherkinDocument separately from the tag tree would add complexity without a compelling user-visible benefit.
DiagnosticsAggregator (LSP.Core/Diagnostics/) is a protocol-agnostic service with no OmniSharp dependency; DiagnosticsPublishHandler (LSP.Server/Pipeline/) is the INotificationHandler<MatchCacheChangedNotification> that retrieves tags and the match set, calls the aggregator, converts each GherkinDiagnostic.Range to an LSP Position (the same ResolvePosition algorithm SemanticTokensService uses), and pushes via ILanguageServerFacade.SendNotification("textDocument/publishDiagnostics", PublishDiagnosticsParams) — the same pattern SemanticTokensPushHandler uses. DiagnosticsPublishHandler is auto-discovered by the AddMediatR(typeof(Program)) scan; no explicit DI registration is needed.
Phase 2 — implemented as part of the F3 diagnostics pipeline
Structural errors in .feature files (e.g., missing Feature: header, invalid tag syntax) are shown as red error squiggles with a description, distinct from the yellow warning squiggles of missing step bindings.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic | ✅ Generic |
Parse errors are produced by IdeSupportTagParser whenever a .feature file is parsed (textDocument/didOpen or didChange). Rather than a separate ParseErrors[] array, each parse error is stored as a IdeSupportTag of type ParserError in the tag tree alongside structural and match tags. The DiagnosticsAggregator reads these ParserError tags from the Document Buffer and emits them as DiagnosticSeverity.Error items with source: "reqnroll.parser" to distinguish them from binding mismatch warnings. The complete combined textDocument/publishDiagnostics flow is described in F3.
Phase 2
Pressing Go to Definition (F12 / Ctrl+Click) on a step in a .feature file navigates to the matching [Given] / [When] / [Then] method in the C# binding class. If multiple bindings match (ambiguous), a picker is shown.
Resolved (Q20): textDocument/definition, via DefinitionHandler. See Open Questions & Risk Register.
Open question (Q21): Should the server also support
textDocument/documentLink? This would annotate step lines as Ctrl+hover hyperlinks — a complementary navigation path that requires no keystroke. See Open Questions & Risk Register.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic | 🔧 Plugin |
Rider note (as-built): No PSI bridge needed. The pre-implementation Thomas Heijtink PoC assumed a ReqnrollFeatureDefinitionReferenceProvider PSI bridge would be required, but as built this works through Rider's generic LSP client with zero Rider-specific navigation code — lspGoToDefinitionSupport = true on the server descriptor is sufficient, confirmed live. See Architecture §6.3 for implementation details.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/definition |
Request location of step definition |
| Server → Client | Location / Location[] response |
C# file URI + range |
sequenceDiagram
actor User
participant IDE
box LightBlue LSP Server
participant FDH as DefinitionHandler
participant DB as Document Buffer
participant BM as Binding Match Service
end
User->>IDE: F12 on step text
IDE->>FDH: textDocument/definition (uri, position)
FDH->>DB: Retrieve AST by URI
DB-->>FDH: Gherkin AST
FDH->>BM: Lookup match at (URI, position) in match cache
BM-->>FDH: Binding location(s)
FDH-->>IDE: Location[] response
IDE-->>User: Navigate to .cs binding method
Phase 2
When one or more steps have no matching binding, a code action "Define missing steps" appears (lightbulb / quick-fix). Activating it generates stub binding methods in a new or existing step definition file, with method signatures and parameter types inferred from the step text.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic | ✅ Generic |
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/codeAction |
Request available actions at cursor/selection |
| Server → Client | CodeAction[] response |
List including "Define missing steps" |
| Client → Server | codeAction/resolve (optional) |
Resolve edit lazily |
| Server → Client | workspace/applyEdit |
Apply generated step file content |
Note: When the client applies a
WorkspaceEditthat creates or modifies a.csfile, the resultingtextDocument/didChangereachesTextDocumentSyncHandler, which callsICSharpBindingDiscoveryServicedirectly (see F2) and keeps the Binding Registry current.
sequenceDiagram
actor User
participant IDE
box LightBlue LSP Server
participant FCAH as CodeActionHandler
participant BM as Binding Match Service
participant SS as StepScaffoldService
participant TDS as TextDocumentSyncHandler
participant CDS as ICSharpBindingDiscoveryService
end
User->>IDE: Click lightbulb on unmatched step
IDE->>FCAH: textDocument/codeAction (range, diagnostics context)
FCAH->>BM: Get unmatched steps in range from match cache
BM-->>FCAH: Unmatched step list
FCAH->>SS: Generate step stubs
SS-->>FCAH: WorkspaceEdit (new/modified .cs file)
FCAH-->>IDE: CodeAction[] with embedded WorkspaceEdit
User->>IDE: Selects "Define missing steps"
IDE->>FCAH: codeAction/resolve or direct apply
FCAH-->>IDE: workspace/applyEdit
IDE-->>User: Step definition file created/updated
IDE->>TDS: textDocument/didChange (.cs file created/modified)
TDS->>CDS: Parse changed file, patch the Binding Registry
Phase 3 — Implemented (branch f7_and_f8_completions)
- Handler:
CompletionHandler(LSP.Server/Features/Completions/) implementsICompletionHandlerand is registered via OmniSharp dynamic registration (AddHandler<CompletionHandler>(), document selector**/*.feature). - Core logic:
CompletionService.GetKeywordCompletions(TokenType[], GherkinDialect)andGetDefaultKeywordCompletions(GherkinDialect)inLSP.Core/Completions/. - Token dispatch:
IdeSupportGherkinDocument.GetExpectedTokens(line, monitoringService)→ switch onTokenType; fallback is the default keyword set (Feature, Scenario, steps). - Keyword format: Block keywords (FeatureLine, ScenarioLine, etc.) get
": "appended because Gherkin dialect keywords have no trailing colon. Step keywords from the dialect already include a trailing space. - Dialect fallback:
new GherkinDialectProvider(lang).DefaultDialect(public API) rather than theinternalReqnrollGherkinDialectProvider. - Insert text:
TextEditOrInsertReplaceEditwrapping aTextEditspanning the keyword range on the current line. - Tests:
CompletionServiceKeywordTests(19 unit tests) +KeywordCompletion.featurespec (5 scenarios).
Typing at the start of a line in a Gherkin scenario offers completions for keywords valid in the current context (Given, When, Then, And, But, Scenario:, Feature:, etc.). Completions are context-sensitive: Examples: only appears inside a Scenario Outline; Background: only at feature level.
Gherkin dialect note: Completion items are sourced from the active Gherkin dialect configured in the project's
reqnroll.json. If the project specifies"language": "de", completions offerGegeben,Wenn,Dannrather thanGiven,When,Then.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic | ✅ Generic |
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/completion |
Request completions at position |
| Server → Client | CompletionList response |
Keyword completion items |
| Client → Server | completionItem/resolve |
Resolve detail/documentation lazily |
sequenceDiagram
actor User
participant IDE
box LightBlue LSP Server
participant GCH as CompletionHandler
participant DB as Document Buffer
participant CS as Completion Service
end
User->>IDE: Presses trigger character or Ctrl+Space at line start
IDE->>GCH: textDocument/completion (uri, position)
GCH->>DB: Retrieve AST by URI
DB-->>GCH: Gherkin AST
GCH->>CS: Get valid keywords for context (position in AST)
CS-->>GCH: CompletionItem[] (keywords)
GCH-->>IDE: CompletionList
IDE-->>User: Keyword dropdown
Phase 4 — Implemented (branch f7_and_f8_completions)
- Handler: same
CompletionHandleras F7. Step completion is dispatched when the cursor is on a step line (IdeSupportTagTypes.StepBlocktag) andcursorOffset >= stepTextStart(past the keyword). - Step text start:
snapshotLine.Start + (step.Location.Column - 1) + step.Keyword.Length(1-based Gherkin location, keyword includes trailing space). - Core logic:
CompletionService.GetStepCompletions(step, typedAfterKeyword, registry, usageCounter, matcher)inLSP.Core/Completions/.- Filters
ProjectStepDefinitionBindingbyScenarioBlockmatching the step keyword. - Samples each binding via
StepDefinitionSampler.GetStepDefinitionSample()(ported from Reqnroll.VisualStudio). - Deduplicates identical samples.
- Ranks via
ICompletionMatcher.Rank()→ default implementation isReturnAllCompletionMatcher(pass-through,IsIncomplete=false). SortText= zero-padded rank index (6 digits).
- Filters
- Insert format: literal sample text, e.g.
"I have entered [int] into the calculator"— no snippet placeholders. TheTextEditreplaces from the step text start to end of the step line. - Sampler:
StepDefinitionSamplerusesRegexStepDefinitionExpressionAnalyzerto walk regex parts, substituting type placeholders for capture groups. Choice groups like(option1|option2)are kept verbatim. netstandard2.0-compatible (no[^1]index syntax). - Usage count:
IBindingMatchService.FindUsages(sourceLocation, projectFilter).Countpassed asusageCounter→ forwarded toICompletionMatcherfor future ranking algorithms. - Q14 resolution:
ReturnAllCompletionMatcherpasses all candidates to the client; the LSP client does the filtering.ICompletionMatcheris the extension point for FuzzySharp if needed. - Tests:
CompletionServiceStepTests(9 unit tests) +StepDefinitionSamplerTests(8 unit tests) +ReturnAllCompletionMatcherTests(5 unit tests) +StepCompletion.featurespec (4 scenarios).
When typing a step line after a keyword (Given, When, Then, etc.), the IDE offers completions matching existing step binding patterns. Completions include parameter placeholders styled appropriately and insert the full step text on selection.
Q14 resolved: Client-side matching used (see Open Questions).
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic | ✅ Generic |
Same as F7 (textDocument/completion) but triggered after a step keyword; completion items are derived from the Binding Registry rather than the keyword list.
sequenceDiagram
actor User
participant IDE
box LightBlue LSP Server
participant GCH as CompletionHandler
participant DB as Document Buffer
participant CS as Completion Service
participant BR as Binding Registry
end
User->>IDE: Types partial step text after keyword
IDE->>GCH: textDocument/completion (uri, position)
GCH->>DB: Retrieve AST by URI
DB-->>GCH: Gherkin AST (confirms step context)
GCH->>CS: Get step completions for partial text
CS->>BR: Get all binding patterns
BR-->>CS: Patterns[]
CS->>CS: Match patterns against typed text (see Q14 re: matching strategy)
CS-->>GCH: CompletionItem[] (step patterns with placeholders)
GCH-->>IDE: CompletionList
IDE-->>User: Step suggestion dropdown
Phase 3
The IDE's "Outline" or "Structure" panel shows the hierarchy of the feature file: Feature → Background / Rule → Scenario / Scenario Outline → Step. Clicking a node navigates to that location. Used for quick navigation in large feature files.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic |
Visual Studio caveat: The standard LSP textDocument/documentSymbol handler is implemented and functional. However, VS does not route documentSymbol responses to its Document Outline window (View → Other Windows → Document Outline). That window uses legacy COM/IVsHierarchy APIs from the old language service model, not LSP. Confirmed by log analysis: VS registered the documentSymbol capability but never issued a textDocument/documentSymbol request during an active editing session with feature files open.
VS does consume textDocument/documentSymbol for other surfaces (Navigation Bar dropdowns, Go to Member), but these require VS content-type plumbing to hook up for .feature files.
See Q22 for the options and decision.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/documentSymbol |
Request symbol hierarchy |
| Server → Client | DocumentSymbol[] response |
Nested symbol tree |
sequenceDiagram
participant IDE
box LightBlue LSP Server
participant FDSH as DocumentSymbolHandler
participant DB as Document Buffer
participant SS as DocumentSymbolService
end
IDE->>FDSH: textDocument/documentSymbol
FDSH->>DB: Retrieve IdeSupportTag tree by URI
DB-->>FDSH: IdeSupportTag[]
FDSH->>SS: Build symbol hierarchy from tag tree
SS-->>FDSH: GherkinDocumentSymbol[] (nested, protocol-agnostic)
FDSH->>FDSH: Convert to OmniSharp DocumentSymbol[]
FDSH-->>IDE: DocumentSymbol[] response
IDE-->>IDE: Render outline panel
DocumentSymbolService (LSP.Core) walks the IdeSupportTag tree — the same tree F1's semantic tokens read — and returns a GherkinDocumentSymbol hierarchy as a protocol-agnostic model. DocumentSymbolHandler (LSP.Server) converts that into OmniSharp DocumentSymbol[] and registers via AddHandler<>. Symbol kind mapping: Feature→Module, Background→Constructor, Rule→Namespace, Scenario/ScenarioOutline→Method, Step→Field, Examples→Array. DocumentSymbol.Children is init-only, so children are wrapped in Container<DocumentSymbol> and set in the object initializer. VS Code and Rider receive the outline via this generic handler; Visual Studio's separate route (reqnroll/documentSymbolHierarchical + GherkinNavigationBarSymbolService/IVsDropdownBarClient) is covered in Architecture §6.4.
Phase 3
Scenarios, Backgrounds, Rules, doc strings, and data tables can be collapsed in the editor gutter. Folding regions update as the document is edited.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic | 🔧 Plugin |
VS Code and Visual Studio render folding purely through their generic LSP client. Rider does not (issue #162): bytecode inspection of the pinned Rider 2024.3.5 com.intellij.platform.lsp.api.LspServerDescriptor found no lspFoldingRangeSupport opt-in and no LspFoldingRangeSupport customization class at all — Rider's generic client has no rendering-side consumer for textDocument/foldingRange, the same class of gap as CodeLens/inlay hints/on-type formatting. See the Rider section below for how the plugin covers it instead.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/foldingRange |
Request foldable regions |
| Server → Client | FoldingRange[] response |
Start/end line pairs |
sequenceDiagram
participant IDE
box LightBlue LSP Server
participant FFRH as FoldingRangeHandler
participant DB as Document Buffer
end
IDE->>FFRH: textDocument/foldingRange
FFRH->>DB: Retrieve AST by URI
DB-->>FFRH: Gherkin AST
FFRH->>FFRH: Walk AST for Scenario/Background/Rule/DocString/Table nodes
FFRH-->>IDE: FoldingRange[]
IDE-->>IDE: Render fold markers in gutter
F10 is implemented (issue #162), following the same manual-glue pattern as F18/F23: ReqnrollRequestSender.foldingRange(project, uri) calls the standard textDocument/foldingRange request directly (no custom @JsonRequest needed — same as codeLens/inlayHint), and ReqnrollFeatureFoldingController — an EditorFactoryListener, not a PSI-based FoldingBuilder (.feature has no registered ParserDefinition, same reasoning as ReqnrollFeatureInlayHintsController) — renders the result directly against Editor.foldingModel, debounced on document edits. Fold regions are tagged via FoldRegion's UserDataHolder so a debounced rebuild only touches Reqnroll-owned regions and restores each region's expand/collapse state by matching (startOffset, endOffset) — otherwise every keystroke would silently re-expand everything the user had manually collapsed.
Phase 3
Format Document (Shift+Alt+F or equivalent) re-indents the entire feature file: consistent indentation per nesting level, normalized spacing around keywords, blank lines between scenarios. Formatting rules are read from .editorconfig (indent size, line endings).
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic |
Rider note: textDocument/formatting takes priority via an opt-in lspFormattingSupport property override on the LSP server descriptor, which activates Rider's generic LspFormattingService — Rider's own formatter framework does not compete for .feature files.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/formatting |
Format whole document |
| Client → Server | textDocument/rangeFormatting |
Format selection |
| Client → Server | textDocument/onTypeFormatting |
Format as user types (e.g., on \n) |
| Server → Client | TextEdit[] response |
Set of text edits |
sequenceDiagram
actor User
participant IDE
box LightBlue LSP Server
participant GFH as FormattingHandler
participant CL as Config Loader
participant DB as Document Buffer
participant FS as Formatting Service
end
User->>IDE: Format Document (Shift+Alt+F)
IDE->>GFH: textDocument/formatting (options: tabSize, insertSpaces)
GFH->>CL: Load .editorconfig for file path
CL-->>GFH: FormattingOptions
GFH->>DB: Retrieve AST by URI
DB-->>GFH: Gherkin AST
GFH->>FS: Format document with AST + options
FS-->>GFH: TextEdit[]
GFH-->>IDE: TextEdit[] response
IDE-->>User: Document reformatted
| Component | Location |
|---|---|
GherkinDocumentFormatter (behind IGherkinDocumentFormatter, DI-registered) |
LSP.Core/Formatting/GherkinDocumentFormatter.cs |
GherkinFormatSettings |
LSP.Core/Formatting/GherkinFormatSettings.cs |
DocumentLinesEditBuffer |
LSP.Core/Formatting/DocumentLinesEditBuffer.cs |
FormattingHandler |
LSP.Server/Features/Formatting/FormattingHandler.cs |
| Unit tests | LSP.Core.Tests/Formatting/GherkinDocumentFormatterTests.cs |
| Spec tests | LSP.Server.Specs/Features/Editor/DocumentFormatting.feature |
- A single
TextEditreplacing the entire document is returned fortextDocument/formattingandtextDocument/rangeFormatting. For range formatting, extra blank lines at the start/end of the range are trimmed. GherkinDocumentFormatterre-indents keywords by nesting level (Feature → Scenario → Step) using\tcharacters by default;GherkinFormatSettingscontrols indent char, line endings, and numeric right-alignment in tables.DocumentLinesEditBufferholds the raw line array and is mutated in-place by both document and table formatters; the final joined text is returned as thenewTextof the single edit.- The cursor-position
TextEditrange is captured before calling the formatter (which mutates the line array), usingoriginalEditEndLineLengthto reconstruct valid end-column offsets.
Phase 3
When the user types | or presses Enter inside a Gherkin data table (or Examples table), the columns are padded so pipes align. The table can also be aligned via Format Document (F11). If the table row is missing a final trailing pipe character |, one is appended.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic |
Rider note: Same caveat as F11.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/onTypeFormatting |
Align table on | or \n |
| Server → Client | TextEdit[] response |
Column-padding adjustments |
On-type trigger characters: | (first), \n (more).
sequenceDiagram
actor User
participant IDE
box LightBlue LSP Server
participant GOTFH as FormattingHandler
participant FS as Formatting Service
end
User->>IDE: Types | or presses Enter inside table
IDE->>GOTFH: textDocument/onTypeFormatting (trigger: '|' or '\n')
GOTFH->>FS: Format table at cursor position
FS->>FS: Detect table extent, compute column widths
FS-->>GOTFH: TextEdit[] (column padding adjustments)
GOTFH-->>IDE: TextEdit[] response
IDE-->>User: Table columns aligned
Implemented in the same FormattingHandler as F11:
- Trigger characters:
|(first),\n(more).\twas evaluated but VS 2022 routes Tab to the completion handler rather thanonTypeFormatting, so it is omitted. - Table detection:
FindTableLineRangescans up/down from cursor for lines whoseTrimStart()begins with|.FindTableAtLinelocates the Gherkin AST node (DataTable or Examples table) at that position for the formatter. \ntrigger cursor handling: When Enter is pressed, the cursor is on the new empty line below the last table row.editEndis extended tocursorLineand thenewTextincludes a trailing line-ending; the edit range end is(cursorLine, 0). This is thecursorBelowTablepattern.- Range end column:
originalEditEndLineLengthis captured before callingFormatTable(which mutates the line array) so the range end column references the original document position. - VS 2022 interaction: VS has a built-in Gherkin language service that auto-inserts
after|in table rows and routes|,,\r,\tto its completion handler. WhentextDocument/completionfor these triggers returns[], VS reverts the typed character from the document. To prevent this,CompletionHandler.HandleKeywordchecks_clientIde.IsVisualStudio(via the injectedClientIdeContext) and returns a single|(cell separator) completion item with a zero-length insert range when the line starts with|. For VSCode/Rider (non-VS clients), an emptyCompletionListis returned — the correct LSP response. VS-specific behaviour is covered byKeywordCompletionVisualStudio.feature; general behaviour byKeywordCompletion.feature. The on-type formatting edits are sent correctly by the server; VS's internal document-change pipeline may apply them with a delay or override them via its auto-insert mechanism.
Phase 3
A keyboard shortcut (Ctrl+/) toggles # comments on the selected line(s) in a .feature file.
LSP has no native comment/uncomment capability. This requires a custom command round-trip: the IDE client captures the keybinding and delegates to the server via workspace/executeCommand, which returns a WorkspaceEdit.
| VS Code | Visual Studio | Rider |
|---|---|---|
| 🔧 Plugin | 🔧 Plugin | 🔧 Plugin |
All three IDEs require a small amount of custom code to:
- Intercept the comment keybinding and redirect it (preventing the IDE's default comment handler from firing for
.featurefiles) - Send
workspace/executeCommandwith the current selection - Apply the returned
WorkspaceEdit
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | workspace/executeCommand (reqnroll.toggleComment) |
Toggle comment on lines in range |
| Server → Client | workspace/applyEdit |
Text insertions/deletions for # |
sequenceDiagram
actor User
participant IDE
box LightBlue LSP Server
participant CTH as CommentToggleHandler
participant CTS as CommentToggleService
end
User->>IDE: Ctrl+/ on selected lines
IDE->>IDE: [Plugin] Intercept keybinding for .feature files
IDE->>CTH: workspace/executeCommand\n(reqnroll.toggleComment, {uri, range})
CTH->>CTS: Toggle # on each line in range
CTS-->>CTH: WorkspaceEdit
CTH-->>IDE: workspace/applyEdit
IDE-->>User: Lines commented / uncommented
package.json contributes.keybindings binds ctrl+/ (cmd+/ on macOS) to reqnroll.toggleComment, scoped by "when": "editorTextFocus && editorLangId == gherkin" — VS Code's own comment-toggle keybinding does not fire for gherkin-language documents. The command (registered in extension.ts) delegates to doToggleComment in commentToggle.ts, which normalizes the selection via normalizeSelectionLines (selectionUtils.ts) — trimming a trailing selected line when VS Code reports the selection ending at (line, 0), i.e. the user dragged past the end of the previous line without selecting any character on the next one, so that line is not spuriously toggled — then sends workspace/executeCommand (reqnroll.toggleComment, [uri, startLine, endLine]) via client.sendRequest(ExecuteCommandRequest.type, ...) and lets the returned WorkspaceEdit apply through the standard LSP client machinery; failures surface via vscode.window.showErrorMessage. Also available via editor context menu (editor/context, group 1_modification) and the command palette, both gated on editorLangId == gherkin.
Rider has no native rendering-side consumer to piggyback on for this feature the way it can for folding or inlay hints: the built-in CommentByLineComment action is a MultiCaretCodeInsightAction (confirmed by decompiling), not an EditorAction, so it never consults EditorActionManager — decorating its EditorActionHandler via EditorActionManager.setActionHandler has no effect. It hardcodes new CommentByLineCommentHandler() in its own getHandler() and gates isValidFor on LanguageCommenters.forLanguage finding a registered Commenter for the PSI file's language; .feature has neither, so the built-in action is simply disabled for it.
The plugin (issue #159) instead adds a competing action bound to the same keystroke: ReqnrollToggleCommentAction (a plain AnAction) is bound to the platform's own default Ctrl+/ keystroke via a plugin.xml <keyboard-shortcut> on the same action ID space, making it a second candidate action for that keystroke alongside the built-in CommentByLineComment. ReqnrollCommentTogglePromoter (an ActionPromoter) suppresses the built-in action from that keystroke's candidate list specifically when the active file is .feature (matched by ActionManager.getId(action) == IdeActions.ACTION_COMMENT_LINE), so ReqnrollToggleCommentAction fires instead — every other file type is untouched. ReqnrollToggleCommentAction.actionPerformed is enabled/visible only when the caret is in a .feature file editor (update(), mirroring GoToHooksAction/FindStepUsagesAction's gating pattern), and its normalizeSelectionLines mirrors VS Code's implementation exactly: a selection ending at column 0 of a line past the start line is trimmed back one line, and only the primary caret's selection is used, matching VS/VS Code. ReqnrollRequestSender.toggleComment sends the standard workspace/executeCommand (reqnroll.toggleComment, [uri, startLine, endLine]) directly — no custom reqnroll/* method exists for this feature. The resulting workspace/applyEdit is applied natively by Rider's platform Lsp4jClient.applyEdit (a final method on the base class, confirmed by decompiling), so no client-side consumer glue is needed for that half, unlike codeLens/inlayHint/folding.
Phase 4
Find All References invoked on a C# step binding method (i.e., a method decorated with [Given], [When], or [Then]) finds all .feature file steps that match that binding and displays them in the IDE's references panel. This is the inverse of Go to Definition (F5).
| VS Code | Visual Studio | Rider |
|---|---|---|
| 🔧 Plugin | 🔧 Plugin | 🔧 Plugin |
Dispatch ambiguity note (resolved — Q13): dispatch based on caret position within a .cs file that has multiple registered servers is unreliable on all three IDEs, so every client ships the same custom reqnroll/findStepUsages message rather than relying on generic textDocument/references dispatch (see implementation status below and Open Questions & Risk Register).
| Direction | Method | Purpose |
|---|---|---|
| VS Extension → Server | reqnroll/findStepUsages (custom, owned pipe) |
Three-state response: {isBinding:false} / {isBinding:true,locations:[]} / {isBinding:true,locations:[...]} |
| Client → Server | textDocument/references (at attribute position) |
Two-state fallback (VS Code, Rider, spec tests): empty = no match or not a binding |
| Server → Client | Location[] / FindStepUsagesResponse |
Step locations in .feature files |
sequenceDiagram
actor User
participant Cmd as FindStepUsagesCommand (VS.E)
participant Pipe as LspInterceptingPipe
participant FSH as FindStepUsagesHandler (LSP Server)
participant BM as BindingMatchService
participant FAR as IFindAllReferencesService (VS)
User->>Cmd: Invoke (Extensions menu or C# editor context menu)
Cmd->>Cmd: GetActiveTextViewAsync → (fileUri, line0, char0)
Cmd->>Pipe: SendRequestToServerAsync("reqnroll/findStepUsages", params)
Pipe->>FSH: inject JSON-RPC request (id="reqnroll-far-{guid}")
FSH->>BM: FindUsages(SourceLocation)
BM-->>FSH: StepBindingMatch[] from match cache
FSH-->>Pipe: FindStepUsagesResponse {isBinding, locations[]}
Pipe-->>Cmd: JToken result (response consumed, never forwarded to VS)
Cmd->>FAR: StartSearch(label) → AddSource(FeatureReferencesDataSource)
FAR-->>User: Find All References window populated with .feature step locations
F14 is implemented. VS does not dispatch textDocument/references to secondary LSP servers for .cs files — the C# language server intercepts unconditionally regardless of caret position (Q13). The custom VS.Extensibility command FindStepUsagesCommand sidesteps that by injecting reqnroll/findStepUsages directly over the owned LspInterceptingPipe, validated end-to-end on the Experimental Instance (Surfaces 1 and 2, 2026-06-09).
| Component | Detail |
|---|---|
ReferencesHandler — textDocument/references |
Registered via options.OnRequest (same static-registration pattern as semantic tokens) to avoid OmniSharp dynamic-registration ambiguity with the C# language server on .cs files (see Q13). Serves VS Code / Rider / spec-test compatibility. |
FindStepUsagesHandler — reqnroll/findStepUsages |
Custom request handler (FindStepUsagesHandler.cs). Delivers the full three-state contract: {isBinding:false} = not a binding; {isBinding:true, locations:[]} = 0 usages; {isBinding:true, locations:[...]} = usages. Response type: FindStepUsagesResponse (FindStepUsagesResponse.cs). Each location includes stepText (extracted from in-memory snapshot), keyword, scenarioName, projectName. Protocol note: returns {isBinding:false} rather than JSON null — OmniSharp's OnRequest framework sends an error response for null returns from custom-method handlers, so IsBinding=false is the "not a binding" sentinel. |
| Binding location lookup | IBindingMatchService.FindUsages(SourceLocation) — iterates all cached FeatureBindingMatchSet entries and returns every StepBindingMatch whose BindingLocations match the supplied file + line (column is ignored; line is 1-based) |
| Document ID on match results | StepBindingMatch.FeatureDocumentId carries the feature file's document URI, eliminating the need for a tuple return from FindUsages |
LSP position → SourceLocation conversion |
Handlers convert 0-based LSP line/character to 1-based SourceLocation(file, line+1, char+1) |
GherkinRange → LSP Range |
GherkinRangeExtensions.ToLspRange() (LSP.Server-layer extension); pure offset-to-line geometry lives in GherkinRange.ResolveOffset (LSP.Core) |
| Workspace-wide scan on startup | BindingRegistryChangedNotification.IsFullReplacement flag: true when fired by the Connector / reflection path, false for Roslyn incremental. A full replacement triggers BindingRegistryChangedHandler.ScanAllFeatureFilesAsync, which calls IGherkinDocumentTaggerService.ScanClosedFileAsync for every .feature file in the project folder not already held in the open-document buffer. Known limitation, with chosen resolution: this folder glob misses linked feature files outside the project folder and wrongly admits feature files excluded from the .csproj. Per the membership-index design, closed-file enumeration moves from the folder glob to the project's reqnroll/projectFiles baseline — scan exactly the feature files the project actually includes, links and all, and nothing it excludes. See Q17. |
| Incremental update on Roslyn edit | IsFullReplacement = false → only currently open feature files are re-parsed; closed files retain their cached match sets |
| VS command — Surface 1 (Extensions menu) | FindStepUsagesCommand (FindStepUsages/FindStepUsagesCommand.cs) — [VisualStudioContribution] VS.Extensibility command; GetActiveTextViewAsync → (fileUri, line0, char0) → FindStepUsagesService.FindUsagesAsync → FindStepUsagesRenderer.RenderAsync. |
| VS command — Surface 2 (C# editor context menu) | Same command, second placement: CommandPlacement.VsctParent(guidSHLMainMenu, IDG_VS_CODEWIN_NAVIGATETOLOCATION=0x02B1, priority=0x0100). Item appears next to "Find All References" in the code-window context menu. No .vsct, no VSSDK command table — targets the shell's built-in group directly. Requires experimental-instance reset after first deploy. |
| VS command — Surface 3 (Shift+F12 takeover) | Deferred. Would require an IOleCommandTarget editor command filter (MEF) intercepting GUID {1496A755-94DE-11D0-8C3F-00C04FC2AAE2} ID 97. Not implemented. |
| Owned-pipe RPC | LspInterceptingPipe.SendRequestToServerAsync injects a JSON-RPC request with id prefix reqnroll-far-{guid}. TryCompleteCorrelatedResponse consumes the matching response (never forwarded to VS) and completes the awaiting TCS. The response bypasses the LSP inspector log — the FindStepUsagesService file logger is the only diagnostic window. |
| Results rendering | FindStepUsagesRenderer switches to UI thread (JoinableTaskFactory), locates IFindAllReferencesService via SVsFindAllReferences, calls StartSearch(label) → window.Manager.AddSource(FeatureReferencesDataSource). FeatureReferencesDataSource pushes all FeatureReferenceTableEntry items in Subscribe. |
| DI injection | FindStepUsagesState singleton registered in ExtensionEntrypoint.InitializeServices. ReqnrollLanguageClient populates it on server-init / clears on dispose. FindStepUsagesCommand injects (FindStepUsagesState, TraceSource) only — both guaranteed resolvable from the VS.Extensibility DI container. (Injecting ReqnrollLanguageClient directly caused silent construction failure because contribution classes are not resolvable as injection targets.) |
F14 ships as a custom command rather than a textDocument/references binding — VS Code, like VS, has no reliable way to route textDocument/references on a .cs file to the Reqnroll server instead of the built-in C# extension, so the client sidesteps the dispatch-ambiguity question entirely (same conclusion as Q13, reached independently on the VS Code side):
| Component | Detail |
|---|---|
| Invocation surfaces | Command palette / editor context menu (reqnroll.findStepUsages, when: editorLangId == csharp), and CodeLens click (see F18) — no reliance on textDocument/references. |
| Request | doFindStepUsages (stepUsages.ts) sends the custom reqnroll/findStepUsages request directly (not textDocument/references), with {textDocument, position, context: {includeDeclaration: false}}. |
| Three-state response handling | Mirrors the server's FindStepUsagesResponse contract: isBinding: false shows an information message ("cursor is not on a step definition binding"); isBinding: true with zero locations shows "No usages found"; otherwise a vscode.window.showQuickPick lists each usage as keyword stepText, with scenario name as description and project name as detail. |
| Navigation | Selecting a QuickPick entry calls openAndReveal (navigationUtils.ts) to open the target .feature file and reveal the step location — no native "Find All References" panel integration (VS Code's references panel is not addressable from an extension for arbitrary custom data; the QuickPick is the VS Code-idiomatic substitute). |
| CodeLens integration | When invoked from a CodeLens item (F18), extension.ts receives [uri, line, char] as command arguments instead of reading the active editor's cursor position. A separate reqnroll.noStepUsages command (deliberately omitted from package.json's contributes.commands, since it is never invoked from the palette) is the click target for CodeLens items reporting zero usages. |
Phase 4
A command "Find Unused Step Definitions" scans the Binding Registry against the match cache and reports any binding methods in C# that have zero matched steps across all .feature files in the workspace. Results appear in the IDE's output or search panel.
This is a workspace-wide operation; it is implemented as a custom command handled server-side.
Cross-project semantics (membership index). A binding
.cslinked into several projects appears in each of their registries; a feature file may belong to several projects. "Unused" must therefore be evaluated against the membership index, not folder layout: a binding is unused only if it has zero matched steps in every project that includes it (an intersection — symmetrically, F14 unions a binding's usages across all including projects). A folder-scoped analysis would falsely report a binding that is linked into project B and used by a feature in B as "unused" merely because project A — where the file physically lives — has no matching feature. Because false "unused" results invite deletion of live code, the analysis must only consider files the index actually attributes to a project, and must never let a binding contributed by the editor-open Roslyn path (in a project that does not own the file) suppress an "unused" result.
| VS Code | Visual Studio | Rider |
|---|---|---|
| 🔧 Plugin | 🔧 Plugin | 🔧 Plugin |
All IDEs require a small custom command handler to invoke workspace/executeCommand and display the results. The analysis itself is in the server.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | workspace/executeCommand (reqnroll.findUnusedStepDefinitions) |
Trigger analysis |
| Server → Client | window/showDocument or custom notification |
Surface results to user |
sequenceDiagram
actor User
participant IDE
box LightBlue LSP Server
participant FUH as FindUnusedStepDefinitionsHandler
participant BM as Binding Match Service
participant BR as Binding Registry
end
User->>IDE: Invoke "Find Unused Step Definitions"
IDE->>FUH: workspace/executeCommand (reqnroll.findUnusedStepDefinitions)
FUH->>BR: Get all registered bindings
BR-->>FUH: Bindings[]
FUH->>BM: For each binding, get usage count from match cache
BM-->>FUH: UsageCount per binding
FUH->>FUH: Filter to UsageCount == 0
FUH-->>IDE: window/showDocument (or custom notification) with unused list
IDE-->>User: Results displayed
reqnroll.findUnusedStepDefinitions (command palette only — no keybinding or context-menu placement) invokes doFindUnusedStepDefinitions (findUnusedStepDefinitions.ts), which sends workspace/executeCommand (reqnroll.findUnusedStepDefinitions) wrapped in vscode.window.withProgress so a "Scanning for unused step definitions…" notification is shown while the workspace-wide analysis runs. Zero unused bindings shows an information message; otherwise a vscode.window.showQuickPick lists each unused binding as $(warning) ClassName.MethodName, with the binding expression as description and project name as detail — selecting one opens the .cs source file and reveals the method via openAndReveal. A deleted .cs file's step definitions used to linger in the registry and cause the QuickPick's navigation to throw; this is fixed by ICSharpBindingDiscoveryService.RemoveFileAsync, invoked from WatchedFilesHandler on .cs FileChangeType.Deleted events, using the delete events VS Code's synchronize.fileEvents: '**/*.{feature,cs}' watcher (configured in extension.ts) already emits. The client is a thin pass-through to workspace/executeCommand — the membership-index intersection semantics described above are entirely server-side and apply identically regardless of client.
Phase 4 — prerequisite: F2 (binding registry), F3 (match sets), F14 membership index (Q17)
Renaming a step text (from either the .feature file step line or the C# [Given("...")] attribute string) updates all occurrences across the workspace: the attribute string in the binding class and every matching step in every .feature file.
Key behavioural properties:
- Parameter preservation. The expression's parameter slots (
(.*),(\d+), Cucumber expression parameters) are preserved — the user renames the non-parameter text only. The parameter count and expression types must be identical before and after rename. - Scenario Outline blocking. Steps in Scenario Outlines that contain
<placeholder>tokens in the feature file step text are NOT renamed. Renaming would corrupt the data-binding relationship between the outline and its example set. - Derived-attribute support. Custom attributes deriving from
[Given]/[When]/[Then](e.g.,class GivenWebAttribute : GivenAttribute) are supported: the rename updates the attribute string text but preserves the attribute type name. - Unambiguous cursor position. When the cursor is in C# code, it must be positioned on the specific attribute string to rename. On a method with multiple binding attributes (e.g.,
[Given("x")],[When("y")]), the cursor must be inside the target attribute's string literal. A cursor on the method signature or between attributes causesprepareRenameto return null (rename not available); the user must click into the specific attribute. - Scoped duplicate expressions. Multiple methods can share the same binding expression with different
[Scope]attributes (e.g.,[Scope(Tag = "tag1")] [Given("text")]and[Scope(Tag = "tag2")] [Given("text")]). These are not distinct-by-expressioned — each appears as a separate target in the picker, disambiguated by a scope-tag suffix in the label (e.g., "Given text [@tag1]" vs "Given text [@tag2]"). When the selected binding resolves to a C# edit,BuildCSharpEditAsyncfinds the Nth method in the SyntaxTree whose attribute list contains the matching string (where N = the binding's ordinal among same-expression bindings in the session-resolved list). - Ambiguous multi-attribute handling via custom command. For the case where the user invokes rename from a method-level position (method body, method signature, or partially visible attribute), a custom client-side command (
reqnroll/renameStepon VS, equivalent command on other IDEs) shows a picker to select which binding to rename. This is the same picker pattern used by F14 Find Usages.
The RenameHandler applies the following validations in order. Any validation failure causes the rename to return an error with a human-readable message.
| # | Rule | Error message | Scope |
|---|---|---|---|
| 1 | Cursor must resolve to a single binding at the given position | "No step definition found at this position" |
prepareRename + rename |
| 2 | Binding expression must be a valid string literal (not a constant, concatenation, or expression) | "Step definition expression cannot be detected" |
prepareRename |
| 3 | Non-parameter parts of the new expression must not contain regex / Cucumber expression operators (?, *, +, [, ], {, }, (, ), ^, $, |) |
"The non-parameter parts cannot contain expression operators" |
rename |
| 4 | Parameter count in the new expression must match the original | "Parameter count mismatch" |
rename |
| 5 | Explicit parameter expressions (e.g., (\d+) → (/d)) must be compatible — same type, same allowed value range |
"Parameter expression mismatch" |
rename |
| 6 | Step text in matching .feature files must not contain Scenario Outline placeholders (<param>) |
"Could not rename step with placeholders in scenario outline: {step text}" |
rename |
| 7 | The owning project must have a valid (non-Invalid) binding registry with membership index populated |
"The project is not initialized yet" / "No Reqnroll project with feature files found" |
prepareRename |
| 8 | For linked bindings: the rename must be able to reach every including project's feature files | Handled by fallback: un-reachable files logged, user warned via window/showMessage |
rename (post-WorkspaceEdit) |
Design note on operator validation (Rule 3): The non-parameter parts of a Cucumber expression must remain literal text. Operators like
?,*,+,[...],{...},(...),^,$,|change the generated regex semantics. This validation parses the new expression by splitting on parameter slots and scanning each non-parameter segment for these operator characters. Escaped operators (e.g.,\(,\),\\) are excluded from the scan. The same validation is already implemented in the existing VSRenameStepCommand; the LSP server reuses the same parsing logic.
When the rename handler needs the attribute expression's source range and delimiter type in the .cs file, it calls StepDefinitionFileParser.GetAttributeStringInfo(CSharpStepDefinitionFile, methodLine, methodColumn, expressionPattern) — a new public method on the existing F2 discovery parser. The method reuses the same private helpers (GetSourceLocation, EnumerateAttributes, GetStepDefinitionExpression, GetStringConstant) that ParseBindings already uses, so no new file-reading or attribute-walking infrastructure is needed. The resolution proceeds as follows:
- The method receives the
.csfile content viaCSharpStepDefinitionFile(the same wrapperParseBindingsuses). If the file is open, the document buffer provides the latest version; if closed, it reads from disk. - It parses the file into a Roslyn
SyntaxTree(Content.GetRootAsync(), identical to line 79 ofParseBindings). - It walks
DescendantNodes().OfType<MethodDeclarationSyntax>()and finds the one whose source line/column matches the binding registry's recorded method location (viaGetSourceLocation). - From that method, it walks
AttributeLists→EnumerateAttributes→GetStepDefinitionExpressionto find the attribute whose expression matches the binding. - It extracts:
Span— the exact source range of the string literal (including delimiters)SyntaxKind—StringLiteralToken(regular"...") vsSingleLineRawStringLiteralToken(verbatim@"...") to determine escaping rulesText— the raw source text (including escape sequences like\"\",\()
This parse is fast (single file, single attribute list walk — typically <50ms for a step definition class) and consistent because the handler reads the document buffer, which reflects whatever the user currently sees in the editor. If the user has unsaved edits, the rename edits the version they're looking at, which is the correct behaviour.
Rationale — dynamic parse over proactive storage. The expression type (
@"..."vs"...") and the attribute's source span are only needed at rename time, which is an infrequent, user-invoked operation. Storing them proactively in the binding registry would add three fields per binding attribute that go unused between renames, introduce a sync problem on every.csedit (the cached span goes stale the moment the user types), and add complexity to the registry data model. A dynamic parse at rename time is simpler (no data model change), always reflects the current editor state, and costs negligible wall-clock time for a user-triggered operation. The only case where the document buffer does not have the file is when the.csfile was modified externally and not opened — in that case the handler reads from disk, which is also fine because a rename on a file the user isn't looking at is unlikely to race with an external edit.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic (single-binding) + |
✅ Generic (single-binding) + 🔧 Plugin (multi-attribute + custom dialog) | ✅ Generic (single-binding) + 🔧 Plugin (multi-attribute) |
Multi-attribute method resolution is the key divergence:
- VS Code: When the cursor is on a multi-attribute binding method,
prepareRenamereturnsnulland the standard rename gesture (F2) is unavailable. The user must click into the specific attribute string to rename. An additional keyboard shortcut binding (provided viapackage.json) routes to the customreqnroll/renameStepcommand when supported. - Visual Studio: The existing
RenameStepCommand(VSSDK) is retained for the multi-attribute case and for users who prefer the custom dialog with inline validation (RenameStepViewModel). The standard LSP rename handles the single-binding case. The VS command checks whether the cursor is on an unambiguous binding and delegates to the LSP rename flow; otherwise it falls back to the custom dialog with the step-definition picker. - Rider (as-built): Not a PSI-level handler — Rider has no native rename bridge (confirmed by decompiling
LspServerDescriptor: nolspRenameSupport-style customization exists).RenameStepRunnerholds the shared "disambiguate viareqnroll/renameTargets, prompt, then drivetextDocument/rename" logic behindRenameFeatureStepAction/RenameCSharpStepAction, mirroring VS'sRenameStepCommand/VS Code'srenameStep.ts;RenameWorkspaceEditApplierapplies the returnedWorkspaceEditlocally since Rider's server only proactively pushesworkspace/applyEditfor Visual Studio. See Architecture §6.3.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/prepareRename |
Validate cursor position is on a renameable binding; return null if ambiguous or invalid |
| Client → Server | textDocument/rename (with position) |
Execute rename — server uses cursor position to disambiguate which binding to rename |
| Server → Client | WorkspaceEdit response (success) |
Multi-file edit covering .cs attribute string + all matching .feature step lines |
| Server → Client | ResponseError with message (failure) |
Validation error message (e.g., "Parameter count mismatch"), displayed in IDE rename dialog |
| Server → Client | window/showMessage (post-rename warning) |
Non-blocking notification about files that could not be renamed (e.g., read-only, pending membership) |
| Client → Server | reqnroll/renameTargets (custom, optional) |
When the cursor is on a multi-attribute method, returns the list of binding(s) at that position so the client can show a picker |
| Server → Client | RenameTargetsResponse |
{ targets: RenameTargetItem[] } — one entry per binding attribute, each carrying { label, attributeRange } |
Design note — custom request flow: The standard LSP rename flow has no provision for a mid-rename picker. When the cursor is on a method with multiple binding attributes,
prepareRenamecannot return a meaningful range (it would apply the rename to all attributes, which is wrong). The server therefore returnsnullfromprepareRename, disabling the standard F2 gesture. The customreqnroll/renameTargetsrequest gives the client (via its plugin — VSSDK, Rider PSI, VS Code command) the data needed to show a picker. After the user selects one target, the client issues a follow-upreqnroll/selectRenameTargetnotification, and the server then acceptstextDocument/renamefor that specific target within the next 30 seconds (stored as a pending rename session keyed by(uri, version)).
sequenceDiagram
actor User
participant IDE
box LightBlue LSP Server
participant SRenH as RenameHandler
participant BM as Binding Match Service
participant BR as Binding Registry
participant SFP as StepDefinitionFileParser.GetAttributeStringInfo
end
User->>IDE: F2 on unambiguous step text or attribute string
IDE->>SRenH: textDocument/prepareRename (uri, position)
SRenH->>BR: Look up binding at (uri, position)
BR-->>SRenH: Binding (or null / multi-attribute)
alt Cursor on non-binding location or multi-attribute method
SRenH-->>IDE: null (rename not available)
IDE-->>User: F2 gesture unavailable, user navigates to specific attribute
else Single binding resolved
SRenH-->>IDE: Range of renameable text (attribute string / step text)
User->>IDE: Types new expression, confirms
IDE->>SRenH: textDocument/rename (uri, position, newName)
SRenH->>SRenH: Validate newName (rules 3-6)
alt Validation failed
SRenH-->>IDE: ResponseError with message
IDE-->>User: Validation error in rename dialog
else Validation passed
SRenH->>BM: Resolve binding + feature step locations
BM-->>SRenH: Binding pattern + feature step ranges (all .feature matches)
SRenH->>SFP: Parse .cs file to locate attribute string range + delimiter type
SFP-->>SRenH: AttributeStringInfo (span, literalKind, rawText)
SRenH->>SRenH: Build WorkspaceEdit (1 csharp edit + N feature edits)
SRenH-->>IDE: WorkspaceEdit
alt Partial failure (some files read-only / pending)
SRenH-->>IDE: window/showMessage (warning)
end
IDE-->>User: All occurrences renamed
end
end
sequenceDiagram
actor User
participant IDE
box LightBlue LSP Server
participant SRenH as RenameHandler
participant BM as Binding Match Service
participant SFP as StepDefinitionFileParser.GetAttributeStringInfo
end
User->>IDE: Custom "Rename Step" command on multi-attribute method
IDE->>SRenH: reqnroll/renameTargets (uri, position)
SRenH->>SFP: Parse .cs file at method location
SFP-->>SRenH: AttributeArgumentSyntax[] per binding attribute
SRenH->>BM: Build RenameTarget[] (label + attributeRange per attribute)
BM-->>SRenH: RenameTarget[] (one per binding attribute)
SRenH-->>IDE: RenameTarget[]
alt Single target
IDE-->>User: Continue directly to rename dialog
else Multiple targets
IDE->>IDE: Show picker (ContextMenu / NavigationPickerDialog / PSI popup)
User->>IDE: Select one target
end
IDE->>SRenH: reqnroll/selectRenameTarget { uri, version, attributeRange }
SRenH-->>IDE: OK (pending session established)
IDE-->>User: Native rename dialog with attribute text pre-filled
User->>IDE: Types new expression, confirms
IDE->>SRenH: textDocument/rename (uri, position, newName)
Note over SRenH: Server matches position + pending session to identify binding
SRenH->>SFP: Re-parse .cs to confirm attribute span (version may have changed)
SFP-->>SRenH: Current attribute string span + delimiter
SRenH->>SRenH: Validate newName → Build WorkspaceEdit
SRenH-->>IDE: WorkspaceEdit
IDE-->>User: All occurrences renamed
| Scenario | Behaviour |
|---|---|
| C# file is read-only or checked out to another user | The WorkspaceEdit fails for that file. textDocument/rename returns a ResponseError indicating the file that could not be modified. No partial rename is applied. |
Feature file membership is in pending state (no reqnroll/projectFiles baseline) |
The rename proceeds for all files that can be resolved. After the rename completes, the server publishes a window/showMessage: "Step renamed in N file(s). Note: project '{project}' has not reported its file membership — steps in that project may not have been updated." |
| Feature file in a linked project that has not yet sent membership | Same as pending state — logged, reported via window/showMessage. User is advised to trigger a project reload. |
Binding registry becomes Invalid between prepareRename and rename |
ResponseError("The binding registry has been invalidated. Please try again after the project finishes loading.") |
User edits the .cs file between prepareRename and rename |
Since StepDefinitionFileParser.GetAttributeStringInfo reads from the document buffer at rename time (via CSharpStepDefinitionFile), it always targets the current editor state. The prepareRename response warned the user with the old range, but the actual edit applies to the new version — which is correct behaviour (the user edited the file, and the rename should edit what's on screen). |
- LSP handler placement.
RenameHandlerlives insrc/LSP/Reqnroll.IdeSupport.LSP.Server/Features/Rename/. The handler registers fortextDocument/prepareRenameandtextDocument/renamevia the OmniSharpILanguageServerrouter (same pattern asDefinitionHandler). - Validator class. The validation rules (Rules 1-8) are extracted to a shared
StepRenameValidatorinLSP.Core/Rename/to separate concerns from the OmniSharp handler layer and enable unit testing. - Parameter-slot detection.
StepRenameValidator(LSP.Core/Rename/StepRenameValidator.cs) is self-contained: it detects parameter slots via its ownParameterSlotPatternregex ((\([^)]*\)|\{\w+\}), matching both regex capture groups and Cucumber Expression{param}placeholders) and its ownExpressionOperatorscharacter set, rather than delegating to a shared parsing library. - WorkspaceEdit construction. The
WorkspaceEditbuilder (Changes/DocumentChangesdictionary) is populated from two sources: (a)StepDefinitionFileParser.GetAttributeStringInforesult for the C# attribute string edit (span + replacement text with correct escaping), and (b) each matching.featurestep location from the binding-match result (stepSourceLocation→TextEditreplacing the step text). - Phase 4 migration path for VS. The existing
RenameStepCommand(VSSDK) is retained and acts as a façade: for single-binding positions it delegates to the LSPtextDocument/renameflow (via the sameLspInterceptingPipeused by F14's custom command). For multi-attribute positions it shows the existing picker +RenameStepViewModeldialog. This dual-path approach lets the LSP rename ship in Phase 4 without regressing the rich VS validation UX, and the VS-specific code can be retired in a later release once the LSP dialog ecosystem catches up. - Linked files. When the membership index (Q17) reports that a binding
.csfile belongs to multiple projects, the rename handler unions the feature files from all including projects into the WorkspaceEdit. The handler callsILspWorkspaceScopeManager.GetProjectsForUri(bindingCsFile)to get the owning set, then iterates each project's registry to find matching feature steps. This is the same multi-project routing already designed for F14/F15; the rename handler uses the sameGetProjectsForUriAPI.
F16's single-binding case is implemented on master as a thin pass-through to VS Code's native rename gesture: reqnroll.renameStep (bound to F2 for gherkin-language documents, package.json contributes.keybindings) calls vscode.commands.executeCommand('editor.action.rename'), which drives the standard textDocument/prepareRename / textDocument/rename flow already implemented server-side. No VS Code-specific validation or edit-application code exists — vscode-languageclient applies the returned WorkspaceEdit (spanning the .cs attribute and every matching .feature step) through its normal rename UI.
Multi-attribute disambiguation is not yet on master. As designed above, when the cursor resolves to more than one candidate binding, the server returns null from prepareRename, which makes VS Code report the standard "You cannot rename this element" message with no path to disambiguate — there is currently no VS Code-side consumer of the server's reqnroll/renameTargets / reqnroll/selectRenameTarget custom requests. This matches the "
An open PR (#27, branch feat/vscode-rename-disambiguation, unmerged as of this writing) adds a client-side RenameMiddleware.prepareRename override (src/VSCode/src/renameDisambiguation.ts) that queries reqnroll/renameTargets first: 0–1 candidates pass straight through to native prepareRename (no behavior change from what's on master today); 2+ candidates show a vscode.window.showQuickPick and send reqnroll/selectRenameTarget with the chosen index before letting the native rename input box open. The PR requires no server-side changes, since reqnroll/renameTargets and reqnroll/selectRenameTarget already exist for the Visual Studio disambiguation dialog. Until it merges, this parity gap with Visual Studio's picker-based disambiguation remains open.
Status: Implemented (2026-07-11). #70 / #133. RenameHandler.HandleRenameAsync (src/LSP/Reqnroll.IdeSupport.LSP.Server/Features/Rename/RenameHandler.cs) builds its WorkspaceEdit through a WorkspaceEditBuilder, which negotiates the returned shape per request rather than always emitting the legacy Changes map:
- Negotiation. Read once per rename from
ILanguageServerFacade.ClientSettings.Capabilities.Workspace.WorkspaceEdit: the client must advertise bothDocumentChanges == trueand a non-nullChangeAnnotationSupport. Both are true for VS Code ({ "groupsOnLabel": true }); VS advertisesdocumentChangesbut neverchangeAnnotationSupport, so it always negotiates down to the plainChangesshape. WorkspaceEditBuilder(Features/Rename/WorkspaceEditBuilder.cs) accumulates(DocumentUri, TextEdit)pairs and emits either shape fromBuild(). It also exposesGetEditsByUri()so the VS-onlyworkspace/applyEditpush (below) can reuse the same accumulated edits regardless of the negotiated shape.RenameChangeAnnotations(Features/Rename/RenameChangeAnnotations.cs) holds the two annotation-id constants:reqnroll.rename.feature(feature-file step text edits) andreqnroll.rename.binding(the C# attribute literal edit).NeedsConfirmation. Thefeatureannotation'sNeedsConfirmation = featureFileCount > 1unconditionally — any rename touching more than one.featurefile asks a supporting client to confirm before applying, with no separate opt-in setting. Thebindingannotation staysNeedsConfirmation = false.- VS needs a genuine push. VS's Rename Step command routes
textDocument/renamethrough a custom interception pipe that swallows the handler's return value before VS's built-in LSP client ever sees it (the same constraint documented for F14 and the Comment/Uncomment toggle).RenamePostApplyCoordinator.PushEditIfVisualStudioAsync(Features/Rename/RenamePostApplyCoordinator.cs) sends the edit viaworkspace/applyEditfor VS only — an unannotatedDocumentChangesshape, since VS never advertises annotation support — and is a no-op for every other client (which apply the handler's returnedWorkspaceEditnatively; pushing to them too would double-apply the edit). Only when VS'sApplyWorkspaceEditResponse.Appliedcomes backtruedoes the coordinator let server-side cache invalidation proceed; a VS-reported failure (locked file, unsaved conflicting changes) short-circuits the rename before any cache is touched. - Closed-file match-cache invalidation.
RenamePostApplyCoordinator.InvalidateClosedFeatureCachesexplicitly invalidates the match cache for any touched.featurefile that is not open in the Document Buffer — open files get this for free from thedidChangetheir edit triggers, but a closed file never fires one, and invalidating it before the open-file race would drop the rebuild entirely (observed live as inlay hints silently vanishing for the whole file post-rename). - RA-1 (undo granularity) still open. Whether VS applies the pushed multi-file
workspace/applyEditas one undo transaction or several has not been verified in a live VS session; see Q23 in the Open Questions register.
- RA-2 — VS never sees the confirmation preview. VS never advertises
changeAnnotationSupport, so it always negotiates the plainChangesshape and the annotation catalogue (includingNeedsConfirmation) is never sent to it. This only matters for a hypothetical future client that acceptsdocumentChanges+ annotations but doesn't render the confirmation UI; none of the three current IDE clients are in that state. - RA-3 — versionless
OptionalVersionedTextDocumentIdentifieris the only mode shipped.WorkspaceEditBuilderalways emitsVersion = nullregardless of client — there is no code path that resolves and sends a real document version. If a client is ever found to reject versionless edits, this would need a real fix, not a workaround; none has been observed to date. - RA-4 — no separate cross-project "confirm" setting.
NeedsConfirmationis alwaysfeatureFileCount > 1(any multi-.feature-file rename, not just cross-project ones), with no way to opt out.
Phase 3
Go to Hooks shows the list of hook bindings that are in scope at the current cursor position in a .feature file, filtered by the tags and [Scope] expressions that apply there:
- From the
Feature:line: shows[BeforeTestRun]/[AfterTestRun]and[BeforeFeature]/[AfterFeature]hooks - From a
Scenario:orScenario Outline:line: additionally shows[BeforeScenario]/[AfterScenario]hooks - From a step line: additionally shows
[BeforeStep]/[AfterStep]and[BeforeStepBlock]/[AfterStepBlock]hooks
All results are filtered by the tags in scope at the cursor position matched against the tags: and Scope[] expressions of each candidate hook binding. Selecting an entry navigates to the C# hook method.
| VS Code | Visual Studio | Rider |
|---|---|---|
| 🔧 Plugin | 🔧 Plugin | 🔧 Plugin |
"Go to Hooks" does not map onto any standard IDE command (unlike Go to Definition, which has a universal F12 keybinding). Each IDE client requires custom plugin code to expose the feature.
Using textDocument/definition for this feature is not viable: F5 already uses that message to navigate to the step binding on step lines, so the server would have no way to distinguish "find step definition" from "find hooks" when the cursor is on a step line. Step-level hooks ([BeforeStep]/[AfterStep]) would be unreachable. Instead, the plugin sends a dedicated custom request reqnroll/goToHooks, which the server handles independently of the standard definition pipeline.
Rider note (as-built): Unlike F5 (generic LSP Go to Definition, no Rider-specific code needed), hook navigation has no standard IDE gesture to piggyback on, so it uses the separate reqnroll/goToHooks custom request via a request-sender pattern — not a PSI bridge. See the #### Rider subsection below and Architecture §6.3.
The command is placed in the built-in IDG_VS_CODEWIN_NAVIGATETOLOCATION group of the code-editor context menu (the same group that hosts "Go To Definition" and "Find All References"). A VisibleWhen constraint restricts visibility to editors with the Gherkin content type, so the item does not appear in C# or other file editors.
Single result — navigates directly to the hook method (no dialog).
Multiple results — shows a VS-themed modal dialog (NavigationPickerDialog, a DialogWindow subclass) with a vertical ListBox listing all candidates. Each entry is formatted as [HookType] MethodName (filename:line). Selecting an entry and clicking Go (or double-clicking) navigates to that hook; closing or pressing Escape cancels.
The picker logic is encapsulated in a shared NavigationPickerHelper (static helper in the VS extension). The same helper is designed for reuse when F5 "Go to Step Definition" encounters multiple ambiguous bindings and needs to present a choice.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | reqnroll/goToHooks (uri, position) |
Request hook locations for context |
| Server → Client | GoToHooksResponse (hooks[]) |
C# hook method locations + metadata |
sequenceDiagram
actor User
participant IDE
box LightBlue LSP Server
participant HH as GoToHooksHandler
participant DB as Document Buffer
participant BR as Binding Registry
end
User->>IDE: Right-click → "Go to Hooks" in .feature editor
IDE->>HH: reqnroll/goToHooks (uri, position)
HH->>DB: Retrieve AST + tags by URI
DB-->>HH: Gherkin AST + IdeSupportTags
HH->>HH: Determine position context (Feature/Scenario/Step level)\nand collect tags in scope
HH->>BR: Filter Hooks by context level + scope expressions
BR-->>HH: GoToHooksResponse { hooks[] }
HH-->>IDE: GoToHooksResponse
alt single result
IDE-->>User: Navigate directly to hook method
else multiple results
IDE->>User: NavigationPickerDialog (vertical ListBox)
User->>IDE: Select hook
IDE-->>User: Navigate to selected hook method
end
reqnroll.goToHooks is available via editor context menu (editor/context, group navigation@90, when: editorLangId == gherkin) and the command palette, with no default keybinding. doGoToHooks (goToHooks.ts) reads the active editor's cursor position and sends the custom reqnroll/goToHooks request with {textDocument, position}. A single hook navigates directly via openAndReveal; multiple hooks show a vscode.window.showQuickPick with one entry per hook ($(symbol-event) HookType, method name as description, Order: N as detail when hookOrder !== 0) — the VS Code-idiomatic equivalent of the VS NavigationPickerDialog modal described above. navigateToHook opens the target .cs file and reveals the hook method's location via the shared openAndReveal helper (also used by F14 and F15).
F17 (issue #158) mirrors the F15/F16 request-sender pattern rather than a PSI bridge handler: ReqnrollLanguageServer.goToHooks (ReqnrollLanguageServer.kt), a @JsonRequest("reqnroll/goToHooks") taking the standard LSP4J TextDocumentPositionParams, is called via ReqnrollRequestSender.goToHooks(project, uri, line, character), following the findStepUsages/findUnusedStepDefinitions pattern of sendRequestSync from a Task.Backgroundable. Reqnroll.GoToHooks (GoToHooksAction.kt) is enabled only when the caret is in a .feature file editor (mirroring FindStepUsagesAction's .cs-only gating) and is registered in the Reqnroll.ActionGroup Tools-menu group and in EditorPopupMenu. GoToHooksRunner navigates directly via ReqnrollResultPopup.navigateToUri for a single hook; multiple hooks show ReqnrollResultPopup's chooser popup (the same JBPopupFactory list used by Find Step Usages / Find Unused Step Definitions), each entry rendered as [HookType] MethodName (filename:line).
Phase 4
C# step binding methods display an inline annotation above the method's binding attribute showing how many .feature steps currently match (e.g., "3 usages"). Clicking the annotation opens the references panel showing those step locations.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | 🔧 Plugin (VSSDK) |
Visual Studio note: VS.Extensibility shipped ICodeLensProvider in VS 17.x (as a preview API). The VS plugin implements this interface directly rather than bridging via the legacy VSSDK IVsCodeLensDataPointProvider. StepCodeLensProvider is an ExtensionPart + ICodeLensProvider that is called once per C# code element (method) in the active document. It fetches the full textDocument/codeLens response for the file from StepCodeLensService and then maps the attribute-level server lenses to the correct method.
VS attribute-to-method mapping: The LSP server (StepCodeLensHandler) emits one CodeLens item per step-binding attribute, with the item's range at the method-declaration line (0-based). VS.Extensibility, however, fires one GetLabelAsync callback per method, reporting the code-element range as starting at the method's first attribute line (which may be one or more lines above the declaration when non-binding attributes such as [Scope] appear first). Additionally, VS processes visible methods bottom-to-top, calling TryCreateCodeLensAsync+GetLabelAsync as an interleaved pair per method before moving to the next.
To bridge the mismatch, StepCodeLensState maintains a per-file registry of method start lines. As each TryCreateCodeLensAsync fires it registers the reported line. By the time GetLabelAsync runs for method N, all methods below it (higher line numbers, processed earlier) are already registered. GetNextMethodLine returns the smallest registered line above the current method, providing a reliable upper bound. The filter RangeLine >= currentStartLine && RangeLine < nextMethodStartLine then selects exactly the server lenses that belong to this method. For the bottommost visible method (no next entry registered yet) a fixed AttributeLookahead = 5 constant serves as fallback.
Rider note (as-built): Standard textDocument/codeLens called directly via ReqnrollRequestSender, rendered through IntelliJ's native CodeVisionProvider, since Rider's generic client has no rendering-side consumer for it either. Project-wide refresh when the Binding Registry changes uses the standard workspace/codeLens/refresh notification (unlike VS, which needs the custom reqnroll/refreshCodeLens notification because it bypasses VS's built-in LSP code-lens infrastructure entirely). See Architecture §6.4.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/codeLens |
Request code lens items for .cs document |
| Client → Server | codeLens/resolve |
Resolve lens command detail lazily |
| Server → Client | CodeLens[] response |
Count annotations with command link |
sequenceDiagram
participant IDE
participant SCP as StepCodeLensProvider\n(VS only — ICodeLensProvider)
box LightBlue LSP Server
participant SCLH as StepCodeLensHandler
participant BM as Binding Match Service
end
alt VS Code or Rider
IDE->>SCLH: textDocument/codeLens (.cs file)
SCLH->>BM: For each binding in file, get usage count from match cache
BM-->>SCLH: UsageCount per binding
SCLH-->>IDE: CodeLens[] (one per attribute, range = method-decl line)
IDE-->>IDE: Render lens above each binding attribute
else Visual Studio
Note over IDE,SCP: VS.Extensibility ICodeLensProvider — one callback per C# method
IDE->>SCP: TryCreateCodeLensAsync (code element = method,\nrange.Start = first-attribute line)
SCP->>SCP: Register method start line in per-file bag
IDE->>SCP: GetLabelAsync (same method)
SCP->>SCLH: textDocument/codeLens (.cs file)
SCLH->>BM: For each binding in file, get usage count from match cache
BM-->>SCLH: UsageCount per binding
SCLH-->>SCP: CodeLens[] (one per attribute, range = method-decl line)
SCP->>SCP: Filter: RangeLine ∈ [currentStartLine, nextMethodStartLine)\nsum usage counts across all attributes on this method
SCP-->>IDE: CodeLensLabel ("N step usage(s)")
IDE-->>IDE: Render lens above first attribute of method
end
VS Code's CodeLensProvider API maps directly onto textDocument/codeLens without the method-vs-attribute reconciliation VS.Extensibility's ICodeLensProvider requires (see the VS attribute-to-method mapping note above) — VS Code's provider is called once per document, not once per code element, so no per-method line-bucketing logic is needed. registerStepCodeLens (stepCodeLens.ts) calls vscode.languages.registerCodeLensProvider({ language: 'csharp' }, provider) directly via the VS Code API — registered after client.start() resolves, in extension.ts — rather than through vscode-languageclient's built-in CodeLens feature, specifically to avoid clashing with the C# extension's own CodeLens registration on .cs files. provideCodeLenses sends the raw textDocument/codeLens request (CodeLensRequest.type) for the document and maps each returned lens 1:1 to a vscode.CodeLens, preserving the server's range (method-declaration line) and command (title/command/arguments); a request failure logs a console warning and returns an empty array rather than surfacing an error to the user. Because the provider bypasses vscode-languageclient's CodeLens feature, it also loses that feature's built-in listener for the server's workspace/codeLens/refresh push — stepCodeLens.ts compensates with its own vscode.EventEmitter<void> wired to onDidChangeCodeLenses, firing on client.onRequest(CodeLensRefreshRequest.type, ...) so lenses still refresh promptly after a binding registry change instead of only on incidental events like editor focus change. codeLens/resolve is not used — the server's textDocument/codeLens response already includes the fully resolved command, so no separate resolve round-trip is implemented client-side.
Phase 2 (Visual Studio only; other IDEs: snippets / live templates)
"New Project" offers a Reqnroll project template with test framework selection (NUnit, xUnit, MSTest). "Add New Item" offers a blank .feature file template and a step definitions class template.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ❌ N/A (snippets instead) | 🔧 Plugin (VSSDK) | ❌ N/A (live templates instead) |
VS Code ships a snippet for scaffolding a feature file. Rider uses Live Templates. Only Visual Studio requires a VSSDK Wizard implementation.
None — wizards are entirely IDE-side. No LSP involvement.
- Uses
IVsProjectWizard/IWizardVSSDK interfaces - Wizard UI is WPF; shared with the existing Reqnroll.VisualStudio extension where possible
- VS.Extensibility does not expose a wizard API; VSSDK is the only option here
Phase 3
When the extension is installed for the first time, the user is greeted with a welcome experience (e.g., a getting-started page linking to documentation, the marketplace listing, and a quick tour of features). When the extension is upgraded to a new version, the user is shown a "What's New" experience summarizing notable changes since their previous version. These experiences make the Preview extension's value discoverable and signal active maintenance — important during the transition period while the existing Reqnroll.VisualStudio extension is still available.
For Visual Studio, the existing extension's installation and upgrade UX (welcome / what's-new pages and any first-run setup) is ported rather than rebuilt — the existing WPF UI and the version-detection logic that distinguishes a fresh install from an upgrade are reused.
| VS Code | Visual Studio | Rider |
|---|---|---|
| 🔧 Plugin (ported from existing extension) |
- Visual Studio: Port the existing extension's welcome / what's-new experience and first-run/upgrade detection. WPF UI shared with the existing
Reqnroll.VisualStudioextension where possible. - VS Code: Use the native Walkthroughs contribution point for the getting-started experience and the marketplace's built-in release-notes (
CHANGELOG.md) surface for upgrades — no custom UI required. - Rider: Use the IntelliJ Platform plugin "What's New" / change-notes mechanism declared in
plugin.xml.
None directly — installation and upgrade UX is entirely IDE-side. However, the first-activation and version-change moments are the natural producers of the ExtensionInstalled and ExtensionUpgraded telemetry events (see Architecture §9 Telemetry). Because these events fire before or independently of the LSP server, the telemetry-architecture choice (Q11) and the client-reference question (Q8) directly affect how they are captured.
- Detecting install-vs-upgrade requires persisting the last-run version per IDE (e.g., extension storage / settings). The existing VS extension already implements this; reuse its approach.
- Each IDE client is responsible for its own install/upgrade UX; there is no shared cross-IDE implementation, though the telemetry event names are common.
- This feature is the primary in-product driver of the
ExtensionInstalled/ExtensionUpgraded/ExtensionDaysOfUsageevents listed in Architecture §9 Telemetry.
Status: Implemented (shipped #43, follow-up fixes #57, #77), per docs/InlayHints-Implementation-Plan.md.
Each defined step in a .feature file shows a dimmed, non-editable inline annotation at the end of the line naming the step definition it resolves to, e.g. → CalculatorSteps.AddNumbers. An ambiguous step shows → {n} matches instead; a Scenario Outline/Background template step whose example rows resolve to more than one distinct binding shows → {n} bindings. Undefined steps get no hint — the existing diagnostic already covers those. Hovering a hint shows the full method signature (and, for multi-match hints, every candidate) in the tooltip.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic (textDocument/inlayHint supported; requires the user's inline-hints display setting) |
✅ Generic |
textDocument/inlayHint is a standard pull feature with no IDE-specific handler; VS additionally requires the user to have inline hints enabled in the editor (Tools → Options → Text Editor → display inline hints, or the hold-to-show gesture) — the extension does not force this on.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/inlayHint |
Request hints for a document range (viewport) |
| Server → Client | InlayHint[] response |
One hint per defined/ambiguous/templated step whose anchor intersects the requested range |
| Server → Client | workspace/inlayHint/refresh |
Sent after MatchCacheChangedNotification (debounced 500ms) so the client re-pulls hints when bindings change |
InlayHintService (LSP.Core/InlayHints/) projects a FeatureBindingMatchSet directly into GherkinInlayHints — one per step with a Defined/Ambiguous/Templated result. InlayHintHandler (LSP.Server/Features/InlayHints/) resolves the requesting document's primary owner (the primary-owner (uri, project)-keying rule from Archive/Q22-uri-project-keying-scope.md — not to be confused with Q22 in the Open Questions register, which is the unrelated F9 VS-integration question) to key into IBindingMatchService, builds hints, then filters to the requested viewport. There is no per-request options object and no resolve support: the handler computes the full label and tooltip eagerly in one pass, and InlayHintProvider.ResolveProvider = false is declared statically — acceptable because the tooltip text (a method signature string, already resolved in the match set) is cheap to format with no I/O or additional lookups needed at hint-build time.
GherkinInlayHintKind has three values: Binding and Ambiguous cover a single row's step text resolving to one or several matches; Templated covers a Scenario Outline/Background step whose single merged MatchResult (one entry per template line, not per expanded example row) itself resolves to more than one distinct Defined binding across different rows, reported as → {n} bindings and kept distinct from the Ambiguous case. There are no parameter-type hints (annotating captured-argument spans with :type) and no settings surface (reqnroll.inlayHints.showBindingTarget / showParameterTypes) — the feature is unconditionally on.
Like F10 Folding, inlayHintProvider is declared statically in the initialize response (Program.ConfigureServer) rather than negotiated via OmniSharp's dynamic-registration interface — vscode-languageclient's dynamic client/registerCapability round trip for inlayHint/foldingRange races VS Code's restore of previously-open .feature tabs on window load, and a tab that renders first never gets a provider re-check for the rest of the session. Refresh mirrors semantic tokens: InlayHintRefreshHandler (LSP.Server/Pipeline/) debounces MatchCacheChangedNotification bursts (500ms) into a single workspace/inlayHint/refresh, gated on the client having advertised workspace.inlayHint.refreshSupport — the same pattern as SemanticTokensRefreshHandler.
- No parameter-type hints or settings surface. Adding them would need a second projection pass in
InlayHintService.Buildover each step's regex capture groups (see IH-3's parameter-offset-mapping concern in the Open Questions register) andInlayHintOptionsthreaded from config into the handler. - No conflict with a VS built-in hint provider — VS has no built-in inline-hint provider for
.featurefiles, so there is nothing to conflict with the server's hints.
Status: Implemented, all three IDEs (issue #269; server #369, VS Code #370, Rider #371; follow-ups #383 own-level hook counts, #385 live refresh on .cs edits). Visual Studio shipped separately and much later, via issue #372/PR #398, once VS.Extensibility was confirmed to have no viable extension point (see the Visual Studio subsection below) — a classic (non-Roslyn) CodeLens API bridge instead; PR #407 then fixed the step-hooks lens never rendering alongside the own-level lens on a Scenario: line (requirement 6 below).
.feature files show a CodeLens above each Feature:/Scenario:/Scenario Outline: line with at least one hook native to that level ([BeforeTestRun]/[AfterTestRun]/[BeforeFeature]/[AfterFeature] on the Feature: line; [BeforeScenario]/[AfterScenario] on the Scenario: line), plus a second lens on the Scenario: line reporting the step-level hook count ([BeforeStep]/[AfterStep]/[BeforeStepBlock]/[AfterStepBlock]) that applies to every step in that scenario. Individual step lines do not get their own lens — matching only depends on the scenario's own tags/scope, not on which step, so HookMatching resolves the same step-level hook set for every step and a per-line repeat would be redundant. Background: and Rule: blocks never get a lens, since neither carries scenario tags of its own. Clicking a lens opens the same "Go to Hooks" picker as F17, filtered to just that lens's own-level hook set — but, unlike a manual F17 invocation, a lens click always shows the picker, even for a single match, so the click target visibly matches what the lens counted.
| VS Code | Visual Studio | Rider |
|---|---|---|
| Glue | 🔧 Plugin — classic (non-Roslyn) CodeLens API | Glue |
Visual Studio note: VS.Extensibility's ICodeLensProvider (used by F18) fires once per C# code element (method); .feature files have no analogous per-element hook for Gherkin content, and decompiling Microsoft.VisualStudio.Extensibility.Editor.Contracts.dll confirmed the consumer-only contract has no producer-side extension point for a custom language at all — the F18 mechanism cannot be made to carry over. #372 instead bridges through the older, still-supported classic Microsoft.VisualStudio.Language.CodeLens API, which is driven by ordinary [ContentType(...)]-scoped MEF composition rather than a code-element model. See Visual Studio — architecture and execution flow below for the full component inventory and flow diagrams; this turned out to be substantially more involved than the SDK's own documentation suggests, requiring four separate live-debugging fixes found only by decompiling VS 18's actual editor assemblies against real failures.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/codeLens |
Request code lens items for a .feature document |
| Server → Client | CodeLens[] response |
Hook-count annotations, one per own-level tag block plus one step-hooks lens per scenario |
| Client → Server | reqnroll.goToHooks (via workspace/executeCommand, reusing F17's reqnroll/goToHooks) |
Lens click, with ownLevelOnly and (for the step-hooks lens) an extra flag distinguishing it from the own-level lens |
HookCodeLensHandler (src/LSP/Reqnroll.IdeSupport.LSP.Server/Features/CodeLens/HookCodeLensHandler.cs) handles textDocument/codeLens for .feature URIs only (it returns an empty result for .cs files, which StepCodeLensHandler/F25's HookMatchCountCodeLensHandler own). It delegates all applicability/matching to HookMatching — the same helper GoToHooksHandler (F17) uses — via HookMatching.GetOwnLevelHookTypes/ResolveMatchingHooks, so a lens's count can never disagree with what clicking it shows. AddOwnLevelLens emits the per-tag-block lens; AddStepHooksLens emits the scenario-level step-hooks lens. The three CodeLens handlers (StepCodeLensHandler, HookCodeLensHandler, HookMatchCountCodeLensHandler) are combined into a single textDocument/codeLens OnRequest registration and their results concatenated (LanguageServerOptionsExtensions.cs). This server-side piece is unchanged by the Visual Studio work below — it was already shipped for VS Code/Rider.
VS Code: registerHookCodeLens (src/VSCode/src/commands/hookCodeLens.ts) calls vscode.languages.registerCodeLensProvider({ language: 'gherkin' }, provider) directly (same pattern as F18's stepCodeLens.ts), sending the raw textDocument/codeLens request. Clicks are handled by doGoToHooks (src/VSCode/src/commands/goToHooks.ts), which gates auto-navigate on !position?.alwaysShowPicker — set only for CodeLens-sourced clicks, so a lens click always opens the QuickPick while a manual "Go to Hooks" invocation from the cursor still auto-navigates on a single match.
Rider: two separate CodeVisionProviders — HookCodeVisionProvider.kt (own-level lens) and StepHooksCodeVisionProvider.kt (step-hooks lens, ordered after the first via CodeVisionRelativeOrderingAfter) — because a single CodeVisionProvider cannot render two independent entries on the same line. Both share matching/parsing logic in HookLensSupport.kt. Clicks invoke GoToHooksRunner.runAndShow(..., alwaysShowPicker = true), mirroring the VS Code behavior — GoToHooksAction/manual invocation continues to auto-navigate on a single match.
The classic Microsoft.VisualStudio.Language.CodeLens API (ITagger<ICodeLensTag> + IAsyncCodeLensDataPointProvider) is old enough, and thinly enough documented, that its actual runtime behavior in VS 18 (2026) diverges from what the SDK doc comments describe in several load-bearing ways — each found only by decompiling VS's own editor assemblies against a live failure, not from documentation. They are called out inline below and summarized in Platform requirements found only by live debugging.
The central complication: the tagger (HookCodeLensTaggerProvider/HookCodeLensTagger) runs in-process, in devenv.exe — an ordinary content-type-scoped MEF part, like dozens of others in this repo. But the data-point provider (HookCodeLensDataPointProvider/HookCodeLensDataPoint) runs out-of-process, in a separate CodeLens ServiceHub host process (confirmed live: tasklist resolved the invoking PID to ServiceHub.Host.netfx.Any, not devenv.exe). Every other classic VSSDK component in this repo (CommentToggleRedirect, NavigationBarRedirect, the tagger side of this very feature) is genuinely in-process and reaches the LSP server through a static bridge class populated by ReqnrollLanguageClient. That pattern silently does not work for an out-of-process component — each process has its own independent copy of static state — which is why the data-point side needs its own, separate callback mechanism (HookCodeLensCallbackListener, below) rather than reusing the bridge directly.
Component inventory
| Component | Process | Responsibility |
|---|---|---|
HookCodeLensTaggerProvider / HookCodeLensTagger |
devenv.exe (in-process MEF ITaggerProvider) |
Fetches hook-match lens locations via HookCodeLensRedirect.GetLensesAsync; creates exactly one HookCodeLensTag per Feature:/Scenario: line reported by the server — grouping the server's entries by line, since a Scenario: line carries two lens kinds (see requirement 6) — and raises TagsChanged on refresh. Caches tag instances per line across GetTags calls so an unchanged line's tag keeps its identity — the classic CodeLens host tracks tags by identity, not value, so a fresh instance every call looks like "always new" and churns data points on every scroll. |
HookCodeLensDescriptor |
Constructed in-process; read from both processes | Implements ICodeLensDescriptor and ICodeLensDescriptorContextProvider. The latter is load-bearing, not decorative: VS's descriptor-context resolution (CodeLensRpcDataPointProviderWrapper.TryCreateDataPointAsync → ExtensionMethods.TryGetDescriptorContextAsync) has no fallback for a plain ICodeLensDescriptor in this VS build and unconditionally throws InvalidOperationException: "Unsupported CodeLens descriptor" without it. |
HookCodeLensTag |
devenv.exe (in-process) |
Implements ICodeLensTag2 (not just ICodeLensTag) so its DescriptorContextProvider is discoverable — required for the same reason as above. |
HookElementDescription |
Shared (encoded in-process, decoded out-of-process) | Encodes line plus an opaque content-derived revision into ICodeLensDescriptor.ElementDescription, the one field the classic remoting contract forwards from tagger to data-point provider — the data-point side otherwise has no buffer/span access at all. Identifies a line, not a lens (requirement 6): per-lens detail (title, nav target) is resolved by each data point from a live fetch instead. The revision component exists only so the descriptor changes when a line's lens content changes, since the tagger reuses a tag instance while its ElementDescription is unchanged — a bare line number would never change and a refreshed count would never reach the editor. |
HookCodeLensDataPointProvider |
CodeLens ServiceHub host (out-of-process) | Classic IAsyncCodeLensDataPointProvider for the own-level lens (Feature:/Scenario: hooks). CanCreateDataPointAsync only checks the descriptor decodes structurally — a line-scoped descriptor cannot say in advance which lens kinds the line carries, so that resolves later via the callback round-trip below. Discovered by the OOP host only because the manifest also registers it under the Microsoft.VisualStudio.CodeLensComponent asset — the ordinary MefComponent asset alone (which does correctly compose the in-process tagger) is not sufficient for OOP discovery here, despite this provider type's own SDK doc comment implying otherwise. |
StepHooksCodeLensDataPointProvider |
CodeLens ServiceHub host (out-of-process) | Second IAsyncCodeLensDataPointProvider, identical but for the step-hooks lens. Both providers receive a data point for every tag and share it; each renders only its own kind (requirement 6). Directly parallels Rider's two-CodeVisionProvider split for the same reason. |
HookCodeLensDataPoint |
CodeLens ServiceHub host (out-of-process) | Resolves the rendered label (GetDataAsync) and Details-popup content (GetDetailsAsync) by calling back into devenv.exe via ICodeLensCallbackService, since it cannot reach HookCodeLensRedirect directly. Takes its lens kind from the owning provider (not the descriptor) and filters the server's response to it, returning an empty Description when the line has no lens of that kind — that provider then contributes no indicator while the other still renders. Nav target comes from the resolved entry, since the two kinds point at different places (own-level at the Scenario: tag, step-hooks at its first step). GetDataAsync also pre-fetches and caches the Details-popup content, so GetDetailsAsync returns it with no further callback — see requirement 5 below for why. |
HookCodeLensCallbackListener |
devenv.exe (in-process MEF part) |
ICodeLensCallbackListener — the devenv-side JSON-RPC target the OOP data point calls back into (ICodeLensCallbackService.InvokeAsync), over the same duplex stream the ServiceHub connection already uses. Delegates to HookCodeLensRedirect, same as the tagger. Must carry [ContentType("Gherkin")] metadata — VS's devenv-side CodeLensHubClient filters callback listeners by content type before wiring them onto the RPC target list at all; without it, the listener composes as a valid MEF part but is never actually reachable, and every callback fails with RemoteMethodNotFoundException. |
HookCodeLensRedirect |
devenv.exe (in-process, static) |
The actual LSP bridge, mirroring CommentToggleRedirect/NavigationBarRedirect. Populated by ReqnrollLanguageClient once the LSP connection is live. Used directly by the in-process tagger, and indirectly (via the callback listener) on behalf of the out-of-process data point. |
HookFeatureCodeLensService |
devenv.exe (in-process) |
Sends textDocument/codeLens over LspInterceptingPipe and parses the response into HookFeatureLensEntry[]. |
GoToHooksService (ownLevelOnly overload) |
devenv.exe (in-process) |
Sends reqnroll/goToHooks for the Details popup — reused from F17 so a lens's Details popup always matches what a manual "Go to Hooks" invocation with the same ownLevelOnly would return. |
ReqnrollPluginPackage (IOleCommandTarget) / HookCodeLensCommands.vsct / HookCodeLensCommandIds |
devenv.exe (in-process) |
Routes the Details popup's navigate-to-hook command. CodeLensDetailEntryCommand only supports a CommandSet+CommandId pair on Windows, not a VS.Extensibility command, so this mirrors Microsoft's own CodeLensOopSample sample rather than the VS.Extensibility command pattern used elsewhere in this repo. |
HookCodeLensHandler / GoToHooksHandler |
LSP Server | Pre-existing (#269 / F17); unchanged by this work. |
Platform requirements found only by live debugging (none apparent from the classic CodeLens SDK docs, which describe an older/simpler model this VS build has partially superseded):
- The manifest must register the provider's assembly under both
Microsoft.VisualStudio.MefComponentandMicrosoft.VisualStudio.CodeLensComponent— the former alone silently leaves the out-of-process data-point provider undiscovered (no error; Tools → Options → Text Editor → CodeLens simply never lists it as a toggle). - A tag's descriptor must implement
ICodeLensDescriptorContextProviderand the tag itselfICodeLensTag2— the plainICodeLensDescriptor/ICodeLensTagthe interfaces' own doc comments describe has no context-resolution path in this VS build and always throws"Unsupported CodeLens descriptor". - The data-point provider and its data points run out-of-process; a
staticbridge (correct everywhere else in this repo) is invisible there. UseICodeLensCallbackService/ICodeLensCallbackListenerinstead. - The callback listener needs
[ContentType(...)]metadata matching the buffer's content type, or VS composes it as a valid MEF part but never wires it onto the RPC target list, and every callback fails withRemoteMethodNotFoundExceptioneven though the method genuinely exists. GetDetailsAsyncmust not make its own callback round-trip. VS'sCodeLensDataPointPresenter.OnShowDetailsExecutedblocks the UI thread synchronously onGetDetailsAsyncviaJoinableTask.CompleteOnCurrentThread, using aNoMessagePumpSyncContext— i.e. without pumping the WPF message queue while it waits.ICodeLensCallbackServicedispatches back intodevenv.exevia aSynchronizationContext.Postonto that same UI thread, which needs the message queue pumped to run — a real deadlock, confirmed live via a captured process dump (procdump+dotnet-dump analyze ... -c "clrstack"on a hung Experimental Instance; the stack showed the exactNoMessagePumpSyncContext.Waitframe underOnShowDetailsExecuted). Fixed by havingGetDataAsync— which always runs first, on a normal async path, since the lens must render before it's clickable — pre-fetch and cache the Details-popup content, soGetDetailsAsyncmakes no cross-process call at all.- Two indicators on one line = one tag + two providers, never two tags. Classic CodeLens follows the same model Roslyn uses to render "N references | N changes" on a single C# member: an
ICodeLensTagmarks a location, and every registered provider contributes an indicator to that one location. AScenario:line legitimately carries two lens kinds (own-level hooks and step-hooks), and emitting a tag for each does not produce two indicators — the engine renders one adornment row per line, resolves it against a single location, and silently drops the extra tag however it differs (distinct dictionary keys, distinctElementDescriptions, and even distinct spans on the same line were all tried and all failed live). The working shape is: the tagger emits one line-scoped tag; both providers accept it; each data point filters the server's response to its own kind and opts out by returning an emptyDescription. A provider contributing nothing costs one callback round-trip and renders no indicator, which is exactly the desired behavior on aFeature:line (own-level only) or a scenario with no step-level hooks.
Execution flow — rendering a lens
sequenceDiagram
actor User
participant IDE as devenv.exe (in-process)
box LightYellow CodeLens ServiceHub host (out-of-process)
participant Wrap as VS CodeLens platform
participant Prov as Both data point providers
participant DP as HookCodeLensDataPoint
end
box LightBlue LSP Server
participant HCH as HookCodeLensHandler
participant GTH as GoToHooksHandler
end
User->>IDE: Opens / scrolls a .feature file
IDE->>IDE: HookCodeLensTaggerProvider creates HookCodeLensTagger, a MEF ITaggerProvider
IDE->>HCH: textDocument/codeLens (HookCodeLensRedirect to HookFeatureCodeLensService)
HCH-->>IDE: CodeLens list - own-level plus step-hooks lenses
IDE->>IDE: Tagger groups entries by line, builds one HookCodeLensTag per line, raises TagsChanged
Note over IDE,Wrap: VS platform asks EVERY provider for a data point per tag (requirement 6)
Wrap->>IDE: Resolve descriptor context via DescriptorContextProvider
IDE-->>Wrap: CodeLensDescriptorContext (ApplicableSpan)
Wrap->>Prov: CanCreateDataPointAsync / CreateDataPointAsync
Note over Prov: HookCodeLensDataPointProvider (own-level) and StepHooksCodeLensDataPointProvider (step-hooks) each return one, both bound to the same tag
Prov-->>Wrap: HookCodeLensDataPoint (x2, differing only in lens kind)
Wrap->>DP: GetDataAsync (per data point)
DP->>IDE: ICodeLensCallbackService.InvokeAsync GetLenses(fileUri)
Note over DP,IDE: same ServiceHub JsonRpc channel, reverse direction
IDE->>IDE: HookCodeLensCallbackListener (ContentType Gherkin) receives the callback
IDE->>HCH: textDocument/codeLens (HookCodeLensRedirect, same bridge the tagger used)
HCH-->>IDE: CodeLens list
IDE-->>DP: HookFeatureLensEntry list (JSON-RPC result)
DP->>DP: Filter to this data point's own lens kind
alt line has no lens of this kind (e.g. step-hooks on a Feature: line)
DP-->>Wrap: CodeLensDataPointDescriptor with empty Description - contributes no indicator
else
Note over DP,IDE: GetDataAsync also eagerly fetches and caches the Details-popup content here (requirement 5 above)
DP->>IDE: ICodeLensCallbackService.InvokeAsync GetHookDetails(fileUri, navLine, navChar, ownLevelOnly) - nav args from the resolved entry
IDE->>IDE: HookCodeLensCallbackListener routes to HookCodeLensRedirect to GoToHooksService
IDE->>GTH: reqnroll/goToHooks (ownLevelOnly)
GTH-->>IDE: matching hooks
IDE-->>DP: HookDetailEntry list, cached on the data point instance
DP-->>Wrap: CodeLensDataPointDescriptor with Description "N hooks" / "N step hooks"
end
Wrap-->>User: Surviving indicators rendered side by side above the Feature/Scenario line
Execution flow — Details popup and navigation
sequenceDiagram
actor User
participant IDE as devenv.exe (in-process)
box LightYellow CodeLens ServiceHub host (out-of-process)
participant DP as HookCodeLensDataPoint
end
User->>DP: Clicks a lens (opens Details popup) - each indicator has its own data point, so the popup matches the lens clicked
Note over DP: GetDetailsAsync returns the cached hook list from GetDataAsync - no cross-process call at all
DP-->>User: Details popup lists each hook, each entry bound to a NavigateToHook command
User->>IDE: Clicks a hook entry
IDE->>IDE: IOleCommandTarget on ReqnrollPluginPackage routes the HookCodeLensCommands.vsct command
IDE->>IDE: VsShellUtilities.OpenDocument plus IVsTextView.SetCaretPos
IDE-->>User: Editor jumps to the hook method
- Step lines never get an individual lens — by design, since the step-level hook set is scenario-wide, not step-specific (see End-user experience above).
- The Details popup's "Dock Popup" button produces an empty docked window — VS's classic CodeLens popup chrome includes a generic "dock into a tool window" affordance, but the docked frame it creates (titled "CodeLens Unknown") has no data channel wired to
CodeLensDetailsDescriptor/GetDetailsAsync. This appears to be VS-side: docking support in the classic (out-of-process, non-Roslyn)IAsyncCodeLensDataPointAPI is limited to the transient popup, unlike Roslyn's in-proc reference CodeLens which VS wires into its own tool-window infrastructure. No interface or extra descriptor data was found (via decompilation or SDK docs) that a custom data point could implement to populate the dock — confirmed there is noCodeLensDetailsPaneProvider,IVsCodeLensListDetailsSource, or similar extensibility point in the public/decompiled surface. If a persistent, populated results window is wanted for this lens, the intended path is reusing the Find-All-References plumbing (F14'sIFindAllReferencesService/ITableDataSource, as F25 already does) rather than the CodeLens dock button, which should be treated as unsupported for this lens kind. - Visual Studio's data point never raises
InvalidatedAsync— the classic API gives data points no disposal hook to safely unsubscribe from a shared invalidation source. Refresh instead comes from the tagger side:HookCodeLensRedirect.InvalidateAll(driven byCodeLensRefreshInterceptoronreqnroll/refreshCodeLens) asks each liveHookCodeLensTaggerto re-pull, and a line whose lens content changed gets a newElementDescription— hence a new tag, hence fresh data points. This path raises a plainITagger<T>.TagsChangedrather than callingCodeLens.Invalidate(), so unlike the.cs-side VS.Extensibility lenses it never provokes the #156/#318 client reconnect and needs neither the debounce nor the rate guard those lenses run behind (see F25's live-refresh note).
Status: Implemented, all three IDEs (issue #373; server #374, VS Code #375, Rider #376, Visual Studio #377; follow-ups: VS Code and Rider always-show-picker fixes, below).
The reverse direction of F24: each hook-binding C# method ([BeforeScenario]/[AfterScenario]/[BeforeStep]/[AfterStep]/etc.) shows a CodeLens with the count of features/scenarios that hook currently matches, given its scope/tag expression — conceptually the same shape as F18's step-usage lens, but for hooks. [BeforeTestRun]/[AfterTestRun] hooks are excluded (not scenario-countable — they run once per test run, not per feature/scenario). Unlike F18 and F24, a hook with zero matches still renders "0 scenarios matched" rather than being suppressed — deliberate, since a zero-match hook (e.g. a stale tag scope that no longer matches anything) is often exactly what the user needs to notice. Clicking the lens always shows a picker/results list of the matching scenarios, never auto-navigating directly even for a single match.
Unscoped hooks (issue #403). A hook with no [Scope] at all matches every scenario in the project — an actual count here would be unbounded and uninformative (and expensive to compute for no benefit), so the lens renders the static label "all scenarios" instead of "N scenarios matched" and skips the scenario-corpus walk entirely for that hook. The click action is unaffected — reqnroll/goToMatchingScenarios still resolves and returns the full scenario list on demand, same as any other hook. VS's HookMatchCountCodeLensProvider (which aggregates multiple hook lenses in a method's attribute window by parsing the numeric prefix off each lens's title) special-cases the "all scenarios" label so it isn't misread as a zero count when aggregated alongside scoped hooks in the same window.
| VS Code | Visual Studio | Rider |
|---|---|---|
| Glue | 🔧 Plugin (ICodeLensProvider) |
Glue |
Coexistence with F18. A single [Binding] class routinely mixes step-binding and hook-binding methods in one .cs file, so StepCodeLensHandler (F18) and HookMatchCountCodeLensHandler cannot partition .cs files exclusively the way F18 partitions .cs vs. .feature. — a single textDocument/codeLens response for one .cs file legitimately carries lenses from both handlers. Each client mirrors this: VS registers HookMatchCountCodeLensProvider as a second ICodeLensProvider alongside StepCodeLensProvider (rather than folding the new lens kind into the existing provider), reusing StepCodeLensState's per-file method-start-line registry for the same attribute-to-method range reconciliation F18 needed (see F18's implementation notes). Rider dispatches both lens kinds from the single existing StepUsagesCodeVisionProvider, distinguishing by the returned command name (reqnroll.goToMatchingScenarios vs. reqnroll.findStepUsages) rather than adding a second CodeVision provider.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | textDocument/codeLens |
.cs file — combined in the same response as F18's step-usage lenses |
| Client → Server | reqnroll/goToMatchingScenarios (uri, line, character) |
Lens click — request matching feature/scenario locations |
| Server → Client | GoToMatchingScenariosResponse (scenarios[]) |
Matching feature/scenario locations for the picker/results list |
HookMatchCountCodeLensHandler (src/LSP/Reqnroll.IdeSupport.LSP.Server/Features/CodeLens/HookMatchCountCodeLensHandler.cs) filters registry.Hooks by source file and HookScenarioMatching.IsScenarioCountable (excluding [BeforeTestRun]/[AfterTestRun]), then resolves matches via IBindingMatchService.GetAll(projectFilter) + HookScenarioMatching.ResolveMatchingScenarios. Its command is reqnroll.goToMatchingScenarios.
VS Code: click handling in doGoToMatchingScenarios (src/VSCode/src/commands/goToMatchingScenarios.ts); the lens provider shares the same .cs CodeLensProvider registration as F18's stepCodeLens.ts.
Rider: GoToMatchingScenariosRunner (src/Rider/src/main/kotlin/com/reqnroll/ide/rider/actions/GoToMatchingScenariosRunner.kt) drives navigation via ReqnrollRequestSender.goToMatchingScenarios; dispatch from the lens click lives in StepUsagesCodeVisionProvider (see Coexistence above).
Visual Studio: HookMatchCountCodeLensProvider (src/VisualStudio/Reqnroll.IdeSupport.VisualStudio.Extension/HookMatchCountCodeLens/HookMatchCountCodeLensProvider.cs), a second ICodeLensProvider. Its ExecuteAsync reuses the Find-Usages results-window renderer (the same one F14 uses) to present matches, rather than the NavigationPickerDialog modal F17/F24 use.
Always-show-picker fix. Both VS Code and Rider initially auto-navigated directly when a lens click resolved to a single matching scenario; both were changed so a lens click always shows a picker/results list instead — the count itself is the useful signal, and confirming which scenario it refers to before jumping avoids a surprising jump on what looks like a passive annotation. This contrasts with F17's manual "Go to Hooks" command, which still auto-navigates on a single match since there the user explicitly asked to navigate. VS's ExecuteAsync always used the Find-Usages window presentation, so no equivalent fix was needed there.
Lens invalidation in Visual Studio (issue #400). StepCodeLensState also owns the registry that lets CodeLensRefreshInterceptor re-pull lens labels when the server signals a full binding-registry replacement (a rebuild). That registry was originally typed to StepCodeLens alone, so HookMatchCountCodeLens — which registered nowhere and had an empty Dispose() — was invisible to InvalidateAllOnUiThread() and never refreshed after a rebuild; only navigating away from and back to the tab produced a current count, because VS re-queries GetLabelAsync on tab focus regardless of invalidation. The registry is now keyed on a small IInvalidatableLens { void InvalidateLabel(); } interface implemented by both lens types, and HookMatchCountCodeLens registers on construct / unregisters on dispose exactly as StepCodeLens does. This gives it the same refresh behavior F18's step-usage lens already had.
Live refresh on binding edits (issue #343). Both .cs-side lens kinds originally acted only on isFullReplacement refresh signals — i.e. a rebuild — so editing a binding's expression or [Scope] left the count stale until the next build. That gate existed because CodeLens.Invalidate() was root-caused as the trigger for VS.Extensibility reactivating ReqnrollLanguageClient and forcing a second CreateServerConnectionAsync (#156/#318). CodeLensRefreshInterceptor now acts on incremental signals too. The reconnect is not eliminated — #156's link 2 remains unidentified — but it is no longer destructive: #310 hands out a fresh VS-facing pipe per call, #399 stopped a peer session's late shutdown response being misdelivered to the new connection, and #402 drops late responses to cancelled owned requests. The added cost is bounded by the signal being rare — the server publishes it only when a Roslyn patch actually changed a binding's matched expression (method-body and comment edits never reach it), debounced so an edit burst collapses into one notification — and the server process and its registry are untouched by the reconnect; only the local relay pipe is rebuilt. Because an invalidation can itself provoke a reconnect, the interceptor additionally coalesces signals over a short window and rate-limits invalidations, logging a warning and degrading to build-only refresh rather than spinning if a feedback loop ever formed.
Status: Implemented. Shipped per issue #262, with the per-target resolution fix from issue #495 (2026-08-26) below. Full design, including the decompiled ground truth for how Reqnroll's generator names/maps scenarios to test methods, in docs/Test-Runner-Integration-Design.md.
.feature files get a run/debug gutter affordance on each Scenario:/Scenario Outline: line
(invoking the mapped generated C# test method via the IDE's native test runner), a pass/fail indicator
on the same line reflecting the last run, and a gutter mark on the specific step a failed scenario
stopped at, with a hover tooltip carrying the captured error.
| VS Code | Visual Studio | Rider |
|---|---|---|
🔧 Plugin — CodeLens + custom gutter decorations, own dotnet test execution, no native Testing-panel presence (Option 2, see design doc §5) |
🔧 Plugin — classic CodeLens (F24 pattern), reusing VS's own .TestExplorer.Run/DebugTestsFromCodeLens commands |
🔧 Plugin — CodeVisionProvider (as-built; RunLineMarkerContributor wasn't viable — see design doc §5) |
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | reqnroll/resolveTestTargets (uri, range) |
Resolve the generated test method(s) for a scenario or Outline range |
| Server → Client | ScenarioTestTarget[] |
Declaring type, method name, and (for parameterized Outlines) row index/arguments |
- The one shared, server-side piece is the scenario→generated-test-method mapping (§2–3 of the design doc) — everything else (native run/debug invocation, test-result event subscription, gutter rendering) is per-IDE glue with no LSP involvement, same shape as F19/F20.
- VS's extension-point question is resolved: there is no separate editor-margin surface — VS's own
run/debug/pass-fail affordance for ordinary tests is itself a classic CodeLens data-point provider
(decompiled from
Microsoft.VisualStudio.TestWindow.CodeLens.dll), directly reusing F24's bridge pattern and even VS's own internal.TestExplorer.Run/DebugTestsFromCodeLenscommands. The test-result channel is resolved for all three IDEs: a livedotnet testspike found the failed-step signal is Reqnroll's own default step-trace stdout (seven-outcome vocabulary decompiled fromTestTracer), not#line-mapped stack traces as originally hypothesized — those always misattribute to the scenario's last step, a#line hidden-region artifact. Chris then independently confirmed both findings live in Rider via the devcontainer (real ambiguous-binding scenario run through Rider's Test Runner), which also surfaced that Rider identifies a parameterized Outline row by a formatted display name embedding its arguments, not a positional index — consistent with VS'sTestMethodIdentifierfinding. See design doc §5–6. - VS Code decided against a
TestController(confirmed 2026-08-05, Option 2):vscode.testshas no API to read another extension's controller results, so delegating to the existing .NET/C# Dev Kit testing extension the way VS delegates to its own Test Explorer isn't possible — owning a separateTestControllerwould work but duplicates the same generated methods C# Dev Kit already lists. VS Code instead runsdotnet test --filterdirectly and renders its own gutter decorations, with no presence in the native Testing panel. See design doc §5. - The mapping layer must account for Reqnroll's five test-framework providers (xUnit, xUnit.v3, NUnit, MSTest, TUnit) using different row-test attributes — resolved by keeping method/class resolution framework-agnostic and using a small per-framework attribute-name allowlist plus generator-guaranteed row ordering for Outline row correlation, rather than parsing attribute argument values. See design doc §3 (Tiers 1–2) and §7 items 5–6.
- Breakpoint/DAP support is explicitly out of scope for this issue (confirmed 2026-08-05). Its
prerequisite (scenario→test-method mapping) is the same one this issue builds anyway, and Reqnroll's
#linepragmas mean PDB-level.featuredebugging may be a narrower path-mapping problem than a from-scratch DAP implementation — recorded as a lead for the deferred "Debug Support for Feature Files" item below, not a deliverable of #262. See design doc §7 item 4. - Per-target resolution, not whole-file (issue #495, 2026-08-26): the initial implementation's
IDE-side glue resolved
reqnroll/resolveTestTargetsfor every scenario in a.featurefile on every recompute/refresh, not just the scenario(s) actually needed — cheap per #492's syntax-tree cache in isolation, but still O(scenario count) per recompute, which a 2,000+-scenario stress corpus turned into a 30-45s walk exceeding VS's own CodeLens timeout. Fixed per platform, since each one's extensibility contract determines what's possible: VS's async per-line CodeLens data points and VS Code'sresolveCodeLensboth already support resolving only visible lenses lazily (the fix was using that, not adding new plumbing); Rider'sCodeVisionProviderhas no equivalent hook, so it instead gainedRunTestTargetCache, an identity-keyed per-scenario cache invalidated by the samereqnroll/refreshCodeLensessignal the Hook/StepUsages CodeVision providers already act on. See design doc §3's correction for the full per-client breakdown.
Status: Implemented. Tracks issue
#514. A throwaway feasibility spike
(branch spike/514-cs-file-diagnostics, kept for reference only — not merged) proved the
mechanism live in Visual Studio, VS Code, and Rider before the real implementation was built; its
findings are recorded as a comment on the issue.
A .cs binding method that fails one of Reqnroll's structural validation rules (see below) gets a
warning squiggle on its identifier, with a hover message naming the specific rule violated —
e.g. "Binding method 'Setup' must be static because its containing type 'Hooks' is abstract."
The squiggle appears via each IDE's own C# editor surface, alongside whatever that IDE's native C#
language server already reports for the same file — confirmed live that VS, VS Code, and Rider all
merge the two sources with no visible conflict. In Rider the message also surfaces in the Problems
pane, grouped under the .cs file's name, the same as any other diagnostic source.
| VS Code | Visual Studio | Rider |
|---|---|---|
| ✅ Generic | ✅ Generic | ✅ Generic |
Push (textDocument/publishDiagnostics) requires no capability declaration and needed no IDE-
specific code in any of the three clients. A diagnosticProvider (pull) capability was
deliberately not declared: the spike found VS polling textDocument/diagnostic every 7–15s
and VS Code polling it occasionally, both for zero benefit (no handler exists to answer it, so
every poll just fails) — declaring it would have been pure overhead.
Ports the structural checks from Reqnroll's own Reqnroll.Bindings.Discovery.BindingSourceProcessor
— purely structural, no business logic:
| Rule | Applies to |
|---|---|
| Binding type must be a class (not struct/interface/record) | Every binding on the type |
| Binding type must not be a generic type definition | Every binding on the type |
| A method on an abstract binding type must be static | Every binding on the method |
| A method must not be async void | Every binding on the method |
The four non-scenario-scoped hook types (BeforeTestRun/AfterTestRun/BeforeFeature/AfterFeature) must be static |
That hook attribute only |
| The step-definition expression must be a valid Cucumber Expression or regex | That step-definition attribute only |
[Scope(Tag = "...")]'s tag expression must be valid |
Every binding under that scope (type- or method-level, step definition or hook) |
The last two aren't new grammar work — both already ran via the real libraries
(Cucumber.CucumberExpressions for step expressions, Cucumber.TagExpressions for Scope.Tag,
the same library Reqnroll v4 is adding scope tag-expression support on top of) before this
addition; they just discarded the failure instead of setting Error. See
Architecture §7 note below for the parity
this restores with the connector path, which already combined both into ProjectBinding.Error.
Both of these two are also anchored at the specific attribute that failed ([Given/When/Then] for
an expression error, the binding's own attribute for a scope error), via ProjectBinding.ErrorLocation
— unlike a structural error, which is shared by every attribute on the method and stays anchored at
the method identifier so it dedupes to one squiggle rather than one per attribute. ErrorLocation
is only populated on the Roslyn path; the connector's PDB-derived data has no per-attribute
location to offer, so a connector-reported failure still anchors at the method.
[StepArgumentTransformation] validation is out of scope: StepDefinitionFileParser doesn't
discover that attribute at all today (only StepDefinitionAttributes/HookAttributes are
recognized), a separate, larger prerequisite closed independently.
| Direction | Method | Purpose |
|---|---|---|
| Server → Client | textDocument/publishDiagnostics |
Push the complete current diagnostic set for one .cs URI |
sequenceDiagram
participant IDE
box LightBlue LSP Server
participant SDFP as StepDefinitionFileParser
participant BR as Binding Registry
participant CDRCH as CSharpDiagnosticsRegistryChangedHandler
participant CDA as CSharpDiagnosticsAggregator
participant CDP as CSharpDiagnosticsPublisher
end
Note over IDE,BR: .cs file edited (Roslyn re-parse), or the connector<br/>completes a post-build run — either raises BindingRegistryChangedNotification
SDFP->>BR: Sets Error on any binding that fails a structural check
BR-->>CDRCH: [internal] BindingRegistryChangedNotification
loop For each open .cs file owned by the affected project
CDRCH->>CDP: Publish(uri)
CDP->>BR: GetRegistryForUri(uri)
CDP->>CDA: Aggregate(registry, filePath)
CDA-->>CDP: CSharpBindingDiagnostic[] (deduped by method location)
CDP-->>IDE: textDocument/publishDiagnostics (uri)
end
IDE-->>IDE: .cs squiggles updated
- A single trigger, not one per doc-sync event.
CSharpDiagnosticsRegistryChangedHandler(LSP.Server/Pipeline/) subscribes to the sameBindingRegistryChangedNotificationF2's.featurere-match pipeline does, and on any change re-pushes diagnostics for every currently-open.csfile owned by the affected project — mirroringBindingRegistryChangedHandler.ReparseOpenFilesAsync's equivalent handling for.featurefiles. Earlier drafts also pushed directly fromTextDocumentSyncHandler's.csdidOpen/didChangehandlers, to work aroundConnectorBindingRegistryProvider.ApplyRoslynFileUpdateAsync's notify-gate (ProjectBindingRegistry.HasExpressionChanges/HasHookChanges) not considering a binding'sError— a validity-only edit (e.g. removingstatic) touches neither a step's matched expression nor a hook's scope/order, so the gate never fired for it. That gate now includesErrorin its comparison directly, so the single registry-changed trigger is sufficient; see Architecture §5 Internal Event Architecture for the full before/after and the resulting bonus fix to.featurediagnostics. - Dedup by method, not by binding.
ICSharpDiagnosticsAggregator(LSP.Core/Diagnostics/, protocol-agnostic — no OmniSharp dependency, mirroring F3'sDiagnosticsAggregator) groups bindings by their identifier's source location before emitting a diagnostic. A method carrying several step-definition/hook attributes produces oneProjectBindingper attribute, all sharing oneSourceLocation— without this, the same error rendered as duplicate diagnostics stacked on one squiggle, one per attribute (a spike finding). - Real (non-zero-width) diagnostic ranges.
StepDefinitionFileParser.GetSourceLocationused to report a deliberately zero-width location at the identifier's start (fine for Go to Definition'sToLspLocation, which discards end-position data anyway). For a diagnostic that rendered as a barely-visible one-character squiggle. Fixed at the source by capturingGetLocation().GetLineSpan()'sEndLinePositiontoo, rather than re-deriving the span downstream via a live-buffer text search. - Rides the existing binding-replacement mechanism, per the issue's own recommendation:
Erroris a plain property onProjectBinding, and connector (whole-registry replace) vs. Roslyn (per-fileProjectBindingRegistry.ReplaceBindings) already replace bindings — including theirError— using the same mechanism as everything else about a binding. No separate merge/override logic was needed or written. - Known related bug, not caused by this feature:
#515 — when a project's compiled
DLL was built with source paths that don't match what the LSP client reports for the same files
(e.g. built inside a devcontainer, opened natively afterward),
ReplaceBindings's path-based file-identity check never recognizes the two as the same file, so every binding in the affected file is registered twice and every step matching it is reported ambiguous. Confirmed present before F27 existed; unrelated to this feature, but worth knowing about since it can look like an F27 regression. - The issue's "cheap first step" is also implemented:
.feature-file "step not found" diagnostics (F3) now name the real reason when the step structurally matches an invalid binding, instead of a generic "not found." SinceProjectStepDefinitionBinding.Matchreturnsnullon!IsValidbefore ever trying the regex — making an invalid binding invisible to real matching, correctly — a separateWouldMatchIgnoringValiditycheck (regex + scope only, never used for real step-execution matching) runs only when a step has no valid match at all, and its result flows throughMatchResultItem's existingErrors/MatchResult.GetErrorMessage()mechanism — the same one the Ambiguous case already used — soDiagnosticsAggregatorneeded only a one-line change to stop discarding it.
The following features were identified during planning (see discussion #1077) as valuable but out of scope for the initial phases. They are recorded here to inform architectural decisions — implementations should avoid foreclosing these options.
Ambiguity diagnostics, regex validation in step attributes, and scope-expression validation — all originally listed here — have since shipped; see F3 · Gherkin File Diagnostics and F27 · C# Binding Validation Diagnostics's Validated Rules table.
Breakpoints set on .feature file step lines would pause test execution at the corresponding step. Step-into would navigate to the bound C# method. This requires implementation of the Debug Adapter Protocol (DAP), a separate protocol from LSP, likely in coordination with the Reqnroll test runner.
Shares its scenario→generated-test-method mapping prerequisite with F26 · Test Runner Integration, which is otherwise unrelated and does not deliver this item — explicitly out of scope for #262 (confirmed 2026-08-05). Reqnroll's generator emits #line pragmas around every step statement, so the compiled test assembly's PDB already carries .feature-relative sequence points (the same mechanism Razor/T4 templates use for direct-source debugging) — this may narrow the problem to per-IDE breakpoint-source/path-mapping rather than a from-scratch DAP implementation, but that's unconfirmed and left for whoever picks this item up separately. See Test-Runner-Integration-Design.md §7 item 4.