Skip to content

PSI Structure

This document describes the PSI (Program Structure Interface) implementation for BerryCrush files.

Overview

PSI is the IntelliJ Platform's way of representing code structure. It provides: - A tree structure representing file contents - Navigation between elements - Modification support - Integration with IDE features

Current Hierarchy Invariants

The current parser composes hierarchical PSI for .scenario files using indentation as structure.

  • feature is a top-level parent for nested background and nested scenario blocks.
  • scenario outside feature remains a top-level sibling of feature.
  • background contains direct step children (given/when/then/and/but).
  • A step can contain nested directives such as call, include, webhook, and assert.
  • call can contain nested payload entries as parameters (id, body, etc.).
  • body: is represented as a parameter that can contain nested parameter entries.

Comment-Handling Invariants

  • Comment lines are valid at top-level and inside all structural blocks (feature, background, scenario, outline, fragment, step, directive payloads, and examples).
  • Interleaved comments must not change block boundaries or parent-child containment.
  • Consecutive comments inside indented blocks must not terminate parsing of subsequent siblings.
  • Comment tokens should be emitted as comment PSI nodes where comment PSI is supported, rather than degrading into generic text nodes.
  • Parser regressions should include tree-validation assertions (parent/child checks), not only token presence assertions.

Example hierarchy used for regression tests:

FILE
├─ FEATURE("feature description")
│  ├─ BACKGROUND("background description")
│  │  ├─ STEP_GIVEN("background given description")
│  │  │  └─ CALL("operationId")
│  │  │     ├─ PARAM("id", "{{petId}}")
│  │  │     └─ PARAM("body")
│  │  │        └─ PARAM("name", "foo")
│  │  └─ STEP_THEN("check the value")
│  │     └─ ASSERT("status 2xx")
│  └─ SCENARIO("nested scenario")
└─ SCENARIO("standalone scenario")

Implementation note: - Prefer typed PSI accessors in BerryCrushElements.kt (for example feature blocks, scenario steps, call parameters) over ad-hoc token scans in consumers.

String Interpolation Invariants

  • Quoted string literals are wrapped as STRING_LITERAL PSI nodes.
  • Interpolation inside strings uses {{...}} syntax and is segmented in source order.
  • Segment model for string content:
    • BerryCrushStringTextSegment for normal text content
    • BerryCrushStringIndentSegment for indentation-only runs in multiline content
    • BerryCrushStringVariableSegment for interpolation placeholders
  • Multiline string segmentation must preserve line breaks and indentation boundaries.
  • Interpolation references in strings resolve with the same declaration rules as existing variable references:
    • {{name}} resolves as context/extract/example variable
    • {{param.name}} resolves as parameter reference

Example segment shape:

STRING_LITERAL("\"hello {{name}}\"")
├─ TEXT("hello ")
└─ VARIABLE("{{name}}")

STRING_LITERAL("\"\"\"\n  hello\n    {{param.level}}\n\"\"\"")
├─ TEXT("\n")
├─ INDENT("  ")
├─ TEXT("hello\n")
├─ INDENT("    ")
└─ VARIABLE("{{param.level}}")

Core Elements

BerryCrushFile

The root element representing a .scenario or .fragment file.

class BerryCrushFile(viewProvider: FileViewProvider) : 
    PsiFileBase(viewProvider, BerryCrushLanguage) {

    fun getFeature(): BerryCrushFeatureElement?
    fun getScenarios(): List<BerryCrushScenarioElement>
    fun getFragments(): List<BerryCrushFragmentElement>
}

BerryCrushFeatureElement

Represents a Feature: declaration.

interface BerryCrushFeatureElement : BerryCrushNamedElement {
    fun getKeyword(): PsiElement
    fun getDescription(): String?
    fun getTags(): List<BerryCrushTagElement>
}

BerryCrushScenarioElement

Represents a Scenario: block.

interface BerryCrushScenarioElement : BerryCrushNamedElement {
    fun getKeyword(): PsiElement
    fun getSteps(): List<BerryCrushStepElement>
    fun getTags(): List<BerryCrushTagElement>
}

BerryCrushFragmentElement

Represents a Fragment: definition.

interface BerryCrushFragmentElement : BerryCrushNamedElement, PsiNameIdentifierOwner {
    fun getKeyword(): PsiElement
    fun getSteps(): List<BerryCrushStepElement>

    override fun getName(): String?
    override fun setName(name: String): PsiElement
    override fun getNameIdentifier(): PsiElement?
}

BerryCrushStepElement

Represents a step (Given/When/Then/And/But).

interface BerryCrushStepElement : BerryCrushElement {
    fun getKeyword(): BerryCrushKeyword
    fun getText(): String
    fun getOperationRef(): BerryCrushOperationRefElement?
    fun getDataTable(): BerryCrushDataTableElement?
    fun getDocString(): BerryCrushDocStringElement?
}

BerryCrushIncludeElement

Represents an include directive.

interface BerryCrushIncludeElement : BerryCrushElement {
    fun getFragmentName(): String
    fun getReference(): PsiReference
    fun resolve(): BerryCrushFragmentElement?
}

Named Elements

Elements that can be renamed implement PsiNameIdentifierOwner:

interface BerryCrushNamedElement : PsiNameIdentifierOwner, NavigatablePsiElement {
    override fun getName(): String?
    override fun setName(name: String): PsiElement
    override fun getNameIdentifier(): PsiElement?
}

Implementation Example

class BerryCrushFragmentElementImpl(node: ASTNode) : 
    BerryCrushElementImpl(node), BerryCrushFragmentElement {

    override fun getName(): String? {
        return nameIdentifier?.text
    }

    override fun setName(name: String): PsiElement {
        nameIdentifier?.let { identifier ->
            val newElement = BerryCrushElementFactory.createIdentifier(project, name)
            identifier.replace(newElement)
        }
        return this
    }

    override fun getNameIdentifier(): PsiElement? {
        return findChildByType(BerryCrushTypes.TEXT)
    }
}

Element Types

Token Types

Defined by the lexer:

object BerryCrushTypes {
    // Keywords
    val FEATURE = IElementType("FEATURE", BerryCrushLanguage)
    val SCENARIO = IElementType("SCENARIO", BerryCrushLanguage)
    val FRAGMENT = IElementType("FRAGMENT", BerryCrushLanguage)
    val GIVEN = IElementType("GIVEN", BerryCrushLanguage)
    val WHEN = IElementType("WHEN", BerryCrushLanguage)
    val THEN = IElementType("THEN", BerryCrushLanguage)
    val AND = IElementType("AND", BerryCrushLanguage)
    val BUT = IElementType("BUT", BerryCrushLanguage)
    val INCLUDE = IElementType("INCLUDE", BerryCrushLanguage)

    // Content
    val TEXT = IElementType("TEXT", BerryCrushLanguage)
    val COMMENT = IElementType("COMMENT", BerryCrushLanguage)
    val OP_REF = IElementType("OP_REF", BerryCrushLanguage)
    val VARIABLE = IElementType("VARIABLE", BerryCrushLanguage)

    // Table
    val PIPE = IElementType("PIPE", BerryCrushLanguage)
    val TABLE_CELL = IElementType("TABLE_CELL", BerryCrushLanguage)
}

Composite Types

Built by the parser:

object BerryCrushElementTypes {
    val FEATURE = BerryCrushElementType("FEATURE")
    val SCENARIO = BerryCrushElementType("SCENARIO")
    val FRAGMENT = BerryCrushElementType("FRAGMENT")
    val STEP = BerryCrushElementType("STEP")
    val DATA_TABLE = BerryCrushElementType("DATA_TABLE")
    val INCLUDE = BerryCrushElementType("INCLUDE")
}

References

Rename Contract

Rename is declaration/reference driven. The plugin does not use a custom rename handler for BerryCrush files.

Current renameable declaration/reference pairs:

  • Fragment declaration (fragment: ...) <-> include fragment references
  • Extract declaration (extract ... => name) <-> {{name}} references
  • Parameter declaration (parameters: key: ...) <-> {{param.key}} references
  • Example header declaration (examples header cell) <-> linked variable references

Rename entry points work from either side (definition or reference) through PSI resolution.

Fragment Reference

class BerryCrushFragmentReference(
    element: BerryCrushIncludeElement
) : PsiReferenceBase<BerryCrushIncludeElement>(element) {

    override fun resolve(): PsiElement? {
        val fragmentName = element.fragmentName
        return findFragmentByName(fragmentName)
    }

    override fun getVariants(): Array<Any> {
        return getAllFragmentNames().map { name ->
            LookupElementBuilder.create(name)
                .withIcon(BerryCrushIcons.FRAGMENT)
        }.toTypedArray()
    }

    private fun findFragmentByName(name: String): BerryCrushFragmentElement? {
        return StubIndex.getElements(
            FragmentNameIndex.KEY,
            name,
            element.project,
            GlobalSearchScope.projectScope(element.project),
            BerryCrushFragmentElement::class.java
        ).firstOrNull()
    }
}

Operation Reference

class BerryCrushOperationReference(
    element: BerryCrushOperationRefElement
) : PsiReferenceBase<BerryCrushOperationRefElement>(element) {

    override fun resolve(): PsiElement? {
        val operationId = element.operationId
        val openApiService = element.project.service<OpenApiService>()
        return openApiService.getOperationElement(operationId)
    }
}

Stubs

Stubs enable faster indexing by storing essential data without full PSI parsing.

Fragment Stub

interface BerryCrushFragmentStub : StubElement<BerryCrushFragmentElement> {
    val name: String
}

class BerryCrushFragmentStubImpl(
    parent: StubElement<*>,
    override val name: String
) : StubBase<BerryCrushFragmentElement>(parent, BerryCrushStubTypes.FRAGMENT),
    BerryCrushFragmentStub

Stub Element Type

object BerryCrushFragmentStubType : 
    IStubElementType<BerryCrushFragmentStub, BerryCrushFragmentElement>("FRAGMENT", BerryCrushLanguage) {

    override fun createStub(
        psi: BerryCrushFragmentElement,
        parentStub: StubElement<*>
    ): BerryCrushFragmentStub {
        return BerryCrushFragmentStubImpl(parentStub, psi.name ?: "")
    }

    override fun serialize(stub: BerryCrushFragmentStub, dataStream: StubOutputStream) {
        dataStream.writeName(stub.name)
    }

    override fun deserialize(dataStream: StubInputStream, parentStub: StubElement<*>): BerryCrushFragmentStub {
        val name = dataStream.readName()?.string ?: ""
        return BerryCrushFragmentStubImpl(parentStub, name)
    }

    override fun createPsi(stub: BerryCrushFragmentStub): BerryCrushFragmentElement {
        return BerryCrushFragmentElementImpl(stub, this)
    }
}

Traversal

Finding Elements

// Find first matching element
val feature = PsiTreeUtil.findChildOfType(file, BerryCrushFeatureElement::class.java)

// Find all matching elements
val scenarios = PsiTreeUtil.findChildrenOfType(file, BerryCrushScenarioElement::class.java)

// Find parent
val scenario = PsiTreeUtil.getParentOfType(step, BerryCrushScenarioElement::class.java)

// Find sibling
val nextStep = PsiTreeUtil.getNextSiblingOfType(step, BerryCrushStepElement::class.java)

Visitor Pattern

class BerryCrushVisitor : PsiElementVisitor() {
    fun visitFragment(element: BerryCrushFragmentElement) {
        visitElement(element)
    }

    fun visitScenario(element: BerryCrushScenarioElement) {
        visitElement(element)
    }

    fun visitStep(element: BerryCrushStepElement) {
        visitElement(element)
    }
}

// Usage
file.accept(object : BerryCrushVisitor() {
    override fun visitFragment(element: BerryCrushFragmentElement) {
        // Process fragment
    }
})

Element Factory

Create new PSI elements programmatically:

object BerryCrushElementFactory {
    fun createIdentifier(project: Project, name: String): PsiElement {
        val text = "Fragment: $name\n  Given step"
        val file = createFile(project, text)
        return file.firstChild.findChildByType(BerryCrushTypes.TEXT)!!
    }

    fun createFragment(project: Project, name: String): BerryCrushFragmentElement {
        val text = "Fragment: $name\n  Given placeholder step"
        val file = createFile(project, text)
        return PsiTreeUtil.findChildOfType(file, BerryCrushFragmentElement::class.java)!!
    }

    private fun createFile(project: Project, text: String): BerryCrushFile {
        return PsiFileFactory.getInstance(project)
            .createFileFromText("dummy.fragment", BerryCrushFileType, text) as BerryCrushFile
    }
}

Best Practices

Thread Safety

Always access PSI in read/write actions:

// Read
ApplicationManager.getApplication().runReadAction {
    val name = element.name
}

// Write
WriteCommandAction.runWriteCommandAction(project) {
    element.setName(newName)
}

Smart Pointers

Don't hold PSI references across operations:

// Bad - element may become invalid
val element = findElement()
// ... later ...
element.name  // May throw

// Good - use smart pointer
val pointer = SmartPointerManager.createPointer(element)
// ... later ...
pointer.element?.name  // Safe

Avoid Redundant Parsing

Use stubs when possible:

// Slower - parses full file
val fragments = PsiManager.getInstance(project)
    .findFile(virtualFile)
    ?.children
    ?.filterIsInstance<BerryCrushFragmentElement>()

// Faster - uses stub index
val fragments = StubIndex.getElements(
    FragmentNameIndex.KEY,
    name,
    project,
    scope,
    BerryCrushFragmentElement::class.java
)