Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions doc/changes/changes_0.11.0.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
# OpenFastTrace IntelliJ Plugin 0.11.0, released 2026-09-22
# OpenFastTrace IntelliJ Plugin 0.11.0, released 2026-10-01

This maintenance release updates the bundled OpenFastTrace library and refreshes the Gradle build dependencies and plugins to current stable releases.
Version 0.11.0 introduces IntelliJ rename refactoring support for OpenFastTrace specification item IDs, updates the bundled OpenFastTrace library to 4.10.0, and refreshes the Gradle build dependencies and plugins to current stable versions.

Users can trigger standard IntelliJ Rename refactoring (Shift+F6) directly on specification item ID declarations in supported specification documents. The rename dialog validates canonical OpenFastTrace identifier syntax (including tildes, hyphens, and dots), and matching references in `Covers:` sections and source coverage tags across the project update automatically.

## Bundled OpenFastTrace

OpenFastTrace 4.10.0

## Features

* #60: Rename OpenFastTrace specification items with IntelliJ refactoring

## Build Maintenance

* Updated the Gradle wrapper, build plugins, JaCoCo, and test dependencies to current stable versions.
32 changes: 16 additions & 16 deletions doc/changesets/60-intellij-rename-refactoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,28 +46,28 @@ This keeps the implementation close to ordinary IntelliJ rename behavior:

### Requirements And Design

- [ ] Update `doc/system_requirements.md` with a feature requirement for OFT rename refactoring
- [ ] Add scenarios for renaming a declared OFT specification item and updating all resolved OFT references
- [x] Update `doc/system_requirements.md` with a feature requirement for OFT rename refactoring
- [x] Add scenarios for renaming a declared OFT specification item and updating all resolved OFT references
- [ ] Stop and ask user for a review of the system requirements
- [ ] Update `doc/design/solution_strategy.md` to describe the IntelliJ-native rename approach
- [ ] Update `doc/design/building_block_view.md` and `doc/design/runtime_view.md` with the rename/refactoring flow
- [x] Update `doc/design/solution_strategy.md` to describe the IntelliJ-native rename approach
- [x] Update `doc/design/building_block_view.md` and `doc/design/runtime_view.md` with the rename/refactoring flow
- [ ] Stop and ask user for a review of the design

### Implementation

- [ ] Make OFT specification item declarations participate in IntelliJ rename refactoring as the rename source
- [ ] Ensure OFT `Covers:` references and coverage tags resolve as rename usages for the renamed declaration
- [ ] Keep rename behavior limited to declarations that the plugin can map back to a canonical OFT item ID
- [x] Make OFT specification item declarations participate in IntelliJ rename refactoring as the rename source
- [x] Ensure OFT `Covers:` references and coverage tags resolve as rename usages for the renamed declaration
- [x] Keep rename behavior limited to declarations that the plugin can map back to a canonical OFT item ID

### Verification

- [ ] Add rename-refactoring tests that rename a specification declaration and verify the updated declaration text
- [ ] Add rename-refactoring tests that verify resolved `Covers:` references and coverage-tag usages are updated
- [ ] Add an index-and-navigation regression test that renames a specification item ID, confirms the new ID is searchable and navigable, and confirms the old entry no longer appears in index-driven navigation
- [ ] Add negative tests showing that non-declaration text does not participate in the rename flow
- [ ] Keep existing navigation, completion, and indexing tests green
- [ ] Keep the OpenFastTrace trace clean for the requirement and design artifact types in scope
- [ ] Keep required Gradle test, trace, packaging, and plugin verification tasks green
- [x] Add rename-refactoring tests that rename a specification declaration and verify the updated declaration text
- [x] Add rename-refactoring tests that verify resolved `Covers:` references and coverage-tag usages are updated
- [x] Add an index-and-navigation regression test that renames a specification item ID, confirms the new ID is searchable and navigable, and confirms the old entry no longer appears in index-driven navigation
- [x] Add negative tests showing that non-declaration text does not participate in the rename flow
- [x] Keep existing navigation, completion, and indexing tests green
- [x] Keep the OpenFastTrace trace clean for the requirement and design artifact types in scope
- [x] Keep required Gradle test, trace, packaging, and plugin verification tasks green
- [ ] Keep SonarQube Cloud quality-gate checks green
- [ ] Keep OSS Index audit results clean

Expand All @@ -77,5 +77,5 @@ This keeps the implementation close to ordinary IntelliJ rename behavior:

## Version and Changelog Update

- [ ] Check whether this change should be part of a release version update or remain in the current unreleased line
- [ ] Write the changelog entry for the chosen release version if this issue is included in a release
- [x] Check whether this change should be part of a release version update or remain in the current unreleased line
- [x] Write the changelog entry for the chosen release version if this issue is included in a release
2 changes: 1 addition & 1 deletion doc/design/building_block_view.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ Covers:
- `scn~update-oft-references-after-rename~1`
- `scn~show-renamed-oft-item-in-navigation~1`

Needs: impl
Needs: impl, itest

### User Guide Integration
`dsn~user-guide-integration~1`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.intellij.openapi.fileEditor.OpenFileDescriptor;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.PsiElement;
Expand All @@ -28,6 +29,18 @@ final class OftDeclarationNavigationElement extends FakePsiElement {
this.specification = specification;
}

OftIndexedSpecification getSpecification() {
return specification;
}

@Override
public TextRange getTextRange() {
if (specification != null) {
return new TextRange(specification.offset(), specification.offset() + specification.id().length());
}
return super.getTextRange();
}

@Override
public PsiElement getParent() {
return delegate == null ? null : delegate.getParent();
Expand Down Expand Up @@ -82,6 +95,16 @@ public boolean isValid() {
return delegate != null && delegate.isValid();
}

@Override
public boolean isWritable() {
return delegate != null && delegate.isWritable();
}

@Override
public boolean isPhysical() {
return delegate != null && delegate.isPhysical();
}

@Override
public PsiManager getManager() {
return requireDelegate().getManager();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiElementResolveResult;
import com.intellij.psi.PsiFile;
import com.intellij.psi.PsiFileSystemItem;
import com.intellij.psi.PsiManager;
import com.intellij.psi.ResolveResult;
import com.intellij.psi.search.SearchScope;
Expand Down Expand Up @@ -42,26 +43,41 @@ static Optional<OftSpecificationItem> findReferenceAt(final CharSequence text, f
}

static Optional<OftSpecificationItem> findDeclaredItem(final PsiElement element) {
if (element == null || element.getContainingFile() == null) {
if (element == null || element instanceof PsiFileSystemItem) {
return Optional.empty();
}
if (element instanceof OftDeclarationNavigationElement navElement) {
final OftIndexedSpecification spec = navElement.getSpecification();
if (spec != null) {
return Optional.of(new OftSpecificationItem(spec.artifactType(), spec.name(), spec.revision()));
}
return Optional.empty();
}
if (element.getContainingFile() == null) {
return Optional.empty();
}
final VirtualFile virtualFile = element.getContainingFile().getVirtualFile();
if (virtualFile == null || !OftSupportedFiles.isSpecificationFile(virtualFile)) {
if (!OftSupportedFiles.isSpecificationFile(virtualFile)) {
return Optional.empty();
}
final TextRange textRange = element.getTextRange();
if (textRange == null) {
return Optional.empty();
}
final int offset = textRange.getStartOffset();
return OftSyntaxCore.findDefinitionSpecificationItems(
element.getContainingFile().getViewProvider().getContents()
).stream()
.filter(match -> contains(match.span(), offset))
final CharSequence fileText = element.getContainingFile().getViewProvider().getContents();
return OftSyntaxCore.findDefinitionSpecificationItems(fileText).stream()
.filter(match -> isElementMatchingDeclarationSpan(textRange, match.span()))
.map(OftSpecificationItemMatch::item)
.findFirst();
}

private static boolean isElementMatchingDeclarationSpan(final TextRange textRange, final OftTextSpan span) {
final int start = textRange.getStartOffset();
final int end = textRange.getEndOffset();
return (contains(span, start) || (start >= span.startOffset() - 2 && end <= span.endOffset() + 2))
&& start >= span.startOffset() - 10;
}

private static Optional<OftSpecificationItem> findCoverageTagReferenceAt(
final CharSequence text,
final int offset
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package org.itsallcode.openfasttrace.intellijplugin.navigation;

import com.intellij.openapi.actionSystem.CommonDataKeys;
import com.intellij.openapi.actionSystem.DataContext;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.PsiDocumentManager;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiFile;
import com.intellij.refactoring.rename.PsiElementRenameHandler;
import com.intellij.refactoring.rename.RenameHandler;
import com.intellij.refactoring.util.CommonRefactoringUtil;
import org.itsallcode.openfasttrace.intellijplugin.OftSupportedFiles;
import org.itsallcode.openfasttrace.intellijplugin.indexing.OftIndexedSpecification;
import org.itsallcode.openfasttrace.intellijplugin.syntax.OftSpecificationItem;
import org.itsallcode.openfasttrace.intellijplugin.syntax.OftSpecificationItemMatch;
import org.itsallcode.openfasttrace.intellijplugin.syntax.OftSyntaxCore;
import org.itsallcode.openfasttrace.intellijplugin.syntax.OftTextSpan;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;

import java.util.Optional;

// [impl->dsn~specification-item-rename~1]
public final class OftRenameHandler implements RenameHandler {
@Override
public boolean isAvailableOnDataContext(final @NonNull DataContext dataContext) {
final Editor editor = CommonDataKeys.EDITOR.getData(dataContext);
if (editor != null && isAvailableInEditor(editor, dataContext)) {
return true;
}
final PsiElement element = CommonDataKeys.PSI_ELEMENT.getData(dataContext);
if (hasDeclaredItem(element)) {
return true;
}
final PsiElement[] elements = CommonRefactoringUtil.getPsiElementArray(dataContext);
return elements.length == 1 && hasDeclaredItem(elements[0]);
}

private static boolean isAvailableInEditor(final Editor editor, final DataContext dataContext) {
PsiFile file = CommonDataKeys.PSI_FILE.getData(dataContext);
if (file == null) {
final Project project = CommonDataKeys.PROJECT.getData(dataContext);
if (project != null) {
file = PsiDocumentManager.getInstance(project).getPsiFile(editor.getDocument());
}
}
if (file == null) {
return false;
}
final VirtualFile virtualFile = file.getVirtualFile();
if (!OftSupportedFiles.isSpecificationFile(virtualFile)) {
return false;
}
final int offset = editor.getCaretModel().getOffset();
return findDeclarationAt(file, offset).isPresent();
}

private static boolean hasDeclaredItem(final @Nullable PsiElement element) {
return element != null && OftDeclarationResolver.findDeclaredItem(element).isPresent();
}

@Override
public void invoke(
final @NonNull Project project,
final @Nullable Editor editor,
@Nullable PsiFile file,
final @NonNull DataContext dataContext
) {
if (file == null && editor != null) {
file = PsiDocumentManager.getInstance(project).getPsiFile(editor.getDocument());
}
if (editor != null && file != null) {
final int offset = editor.getCaretModel().getOffset();
final Optional<OftDeclarationNavigationElement> declaration = findDeclarationAt(file, offset);
if (declaration.isPresent()) {
performRename(project, editor, declaration.get(), dataContext);
return;
}
}
final PsiElement element = CommonDataKeys.PSI_ELEMENT.getData(dataContext);
if (element != null) {
resolveDeclarationTarget(element).ifPresent(target ->
performRename(project, editor, target, dataContext)
);
return;
}
final PsiElement[] elements = CommonRefactoringUtil.getPsiElementArray(dataContext);
if (elements.length == 1 && elements[0] != null) {
resolveDeclarationTarget(elements[0]).ifPresent(target ->
performRename(project, editor, target, dataContext)
);
}
}

@Override
public void invoke(
final @NonNull Project project,
final PsiElement @NonNull [] elements,
final @NonNull DataContext dataContext
) {
final Editor editor = CommonDataKeys.EDITOR.getData(dataContext);
if (elements.length == 1 && elements[0] != null) {
resolveDeclarationTarget(elements[0]).ifPresent(target ->
performRename(project, editor, target, dataContext)
);
return;
}
final PsiElement element = CommonDataKeys.PSI_ELEMENT.getData(dataContext);
if (element != null) {
resolveDeclarationTarget(element).ifPresent(target ->
performRename(project, editor, target, dataContext)
);
}
}

private static void performRename(
final Project project,
final @Nullable Editor editor,
final PsiElement target,
final DataContext dataContext
) {
final String defaultName = PsiElementRenameHandler.DEFAULT_NAME.getData(dataContext);
if (defaultName != null) {
PsiElementRenameHandler.rename(target, project, target, editor, defaultName);
} else {
PsiElementRenameHandler.rename(target, project, target, editor);
}
}

static Optional<OftDeclarationNavigationElement> findDeclarationAt(
final @Nullable PsiFile file,
final int offset
) {
if (file == null || file.getVirtualFile() == null
|| !OftSupportedFiles.isSpecificationFile(file.getVirtualFile())) {
return Optional.empty();
}
final CharSequence text = file.getViewProvider().getContents();
for (final OftSpecificationItemMatch match : OftSyntaxCore.findDefinitionSpecificationItems(text)) {
if (isOffsetWithinBounds(text, match.span(), offset)) {
return Optional.of(createDeclarationElement(file, match));
}
}
return Optional.empty();
}

private static boolean isOffsetWithinBounds(
final CharSequence text,
final OftTextSpan span,
final int offset
) {
final int start = span.startOffset();
final int end = span.endOffset();
final int startBound = ((start > 0) && (text.charAt(start - 1) == '`')) ? (start - 1) : start;
final int endBound = ((end < text.length()) && (text.charAt(end) == '`')) ? (end + 1) : end;
return (offset >= startBound) && (offset <= endBound);
}

private static OftDeclarationNavigationElement createDeclarationElement(
final PsiFile file,
final OftSpecificationItemMatch match
) {
final int start = match.span().startOffset();
final PsiElement psiElement = file.findElementAt(start);
final OftIndexedSpecification spec = new OftIndexedSpecification(
match.item().artifactType(),
match.item().name(),
match.item().revision(),
start
);
return new OftDeclarationNavigationElement(
psiElement != null ? psiElement : file,
spec
);
}

private static Optional<PsiElement> resolveDeclarationTarget(final PsiElement element) {
if (element instanceof OftDeclarationNavigationElement) {
return Optional.of(element);
}
return OftDeclarationResolver.findDeclaredItem(element)
.map(declaredItem -> createDeclarationNavigationElement(element, declaredItem));
}

private static OftDeclarationNavigationElement createDeclarationNavigationElement(
final PsiElement element,
final OftSpecificationItem declaredItem
) {
final TextRange range = element.getTextRange();
final int offset = range != null ? range.getStartOffset() : element.getTextOffset();
final OftIndexedSpecification spec = new OftIndexedSpecification(
declaredItem.artifactType(),
declaredItem.name(),
declaredItem.revision(),
offset
);
return new OftDeclarationNavigationElement(element, spec);
}
}
Loading
Loading