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.
featureis a top-level parent for nestedbackgroundand nestedscenarioblocks.scenariooutsidefeatureremains a top-level sibling offeature.backgroundcontains direct step children (given/when/then/and/but).- A step can contain nested directives such as
call,include,webhook, andassert. callcan 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, andexamples). - 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_LITERALPSI nodes. - Interpolation inside strings uses
{{...}}syntax and is segmented in source order. - Segment model for string content:
BerryCrushStringTextSegmentfor normal text contentBerryCrushStringIndentSegmentfor indentation-only runs in multiline contentBerryCrushStringVariableSegmentfor 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: ...) <->includefragment references - Extract declaration (
extract ... => name) <->{{name}}references - Parameter declaration (
parameters: key: ...) <->{{param.key}}references - Example header declaration (
examplesheader 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
)