Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Add support for phpstan's array shapes #53

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class KphpStormParserDefinition() : PhpParserDefinition() {
ExPhpTypeArrayPsiImpl.elementType -> ExPhpTypeArrayPsiImpl(node)
ExPhpTypeTuplePsiImpl.elementType -> ExPhpTypeTuplePsiImpl(node)
ExPhpTypeShapePsiImpl.elementType -> ExPhpTypeShapePsiImpl(node)
ExPhpTypeArrayShapePsiImpl.elementType -> ExPhpTypeArrayShapePsiImpl(node)
ExPhpTypeNullablePsiImpl.elementType -> ExPhpTypeNullablePsiImpl(node)
ExPhpTypeTplInstantiationPsiImpl.elementType -> ExPhpTypeTplInstantiationPsiImpl(node)
ExPhpTypeCallablePsiImpl.elementType -> ExPhpTypeCallablePsiImpl(node)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import com.jetbrains.php.lang.psi.resolve.types.PhpType
import com.vk.kphpstorm.exphptype.ExPhpTypeNullable
import com.vk.kphpstorm.exphptype.ExPhpTypePipe
import com.vk.kphpstorm.exphptype.ExPhpTypeShape
import com.vk.kphpstorm.exphptype.ExPhpTypeArrayShape
import com.vk.kphpstorm.exphptype.psi.ArrayShapeItem
import com.vk.kphpstorm.helpers.toExPhpType

/**
Expand Down Expand Up @@ -51,15 +53,18 @@ class ShapeKeyInvocationCompletionProvider : CompletionProvider<CompletionParame
/**
* Having PhpType e.g. "shape(...)|null" — get items of that shape
*/
fun detectPossibleKeysOfShape(type: PhpType): List<ExPhpTypeShape.ShapeItem>? {
val parsed = type.toExPhpType()
val shapeInType = when (parsed) {
is ExPhpTypePipe -> parsed.items.firstOrNull { it is ExPhpTypeShape }
fun detectPossibleKeysOfShape(type: PhpType): List<ArrayShapeItem>? {
val shapeInType = when (val parsed = type.toExPhpType()) {
is ExPhpTypePipe -> parsed.items.firstOrNull { it is ExPhpTypeShape }
is ExPhpTypeNullable -> parsed.inner
else -> parsed
} as? ExPhpTypeShape ?: return null
else -> parsed
}

return shapeInType.items
return when (shapeInType) {
is ExPhpTypeShape -> shapeInType.items
is ExPhpTypeArrayShape -> shapeInType.items
else -> null
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class ShapeKeyUsageCompletionProvider : CompletionProvider<CompletionParameters>
val shapeItems = ShapeKeyInvocationCompletionProvider.detectPossibleKeysOfShape(lhs.type) ?: return

for (item in shapeItems)
// TODO: if completion has escape sequences, we need to change single quotes to double quotes
result.addElement(LookupElementBuilder.create(item.keyName).withTypeText(item.type.toString()).withInsertHandler(ArrayKeyInsertHandler))

// PhpStorm also tries to suggest keys based on usage (not on type, of course)
Expand Down
59 changes: 59 additions & 0 deletions src/main/kotlin/com/vk/kphpstorm/exphptype/ExPhpTypeArrayShape.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.vk.kphpstorm.exphptype

import com.intellij.openapi.project.Project
import com.jetbrains.php.lang.psi.elements.PhpPsiElement
import com.jetbrains.php.lang.psi.resolve.types.PhpType
import com.vk.kphpstorm.exphptype.psi.ArrayShapeItem

/**
* shape(x:int, y:?A, ...) — shape is a list of shape items
* vararg flag is not stored here: does not influence any behavior
*/
class ExPhpTypeArrayShape(val items: List<ShapeItem>) : ExPhpType {
class ShapeItem(
override val keyName: String,
val isString: Boolean,
val nullable: Boolean,
override val type: ExPhpType
) :
ArrayShapeItem {
override fun toString() = "$keyName${if (nullable) "?" else ""}:$type"
fun toHumanReadable(file: PhpPsiElement) =
"${if (isString) "\"$keyName\"" else keyName}${if (nullable) "?" else ""}:${type.toHumanReadable(file)}"
}

override fun toString() = "array{${items.joinToString(",")}}"

override fun toHumanReadable(expr: PhpPsiElement) = "array{${items.joinToString { it.toHumanReadable(expr) }}}"

override fun toPhpType(): PhpType {
val itemsStrJoined =
items.joinToString(",") { "${it.keyName}${if (it.nullable) "?" else ""}:${it.type.toPhpType()}" }
return PhpType().add("array{$itemsStrJoined}")
}

override fun getSubkeyByIndex(indexKey: String): ExPhpType? {
if (indexKey.isEmpty())
return ExPhpType.ANY

return items.find { it.keyName == indexKey }?.type
}

override fun instantiateTemplate(nameMap: Map<String, ExPhpType>): ExPhpType {
return ExPhpTypeArrayShape(items.map {
ShapeItem(
it.keyName,
it.isString,
it.nullable,
it.type.instantiateTemplate(nameMap)
)
})
}

override fun isAssignableFrom(rhs: ExPhpType, project: Project): Boolean = when (rhs) {
is ExPhpTypeAny -> true
is ExPhpTypePipe -> rhs.isAssignableTo(this, project)
is ExPhpTypeArrayShape -> true // any array shape is compatible with any other, for simplification (tuples are not)
else -> false
}
}
13 changes: 8 additions & 5 deletions src/main/kotlin/com/vk/kphpstorm/exphptype/ExPhpTypeShape.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ package com.vk.kphpstorm.exphptype
import com.intellij.openapi.project.Project
import com.jetbrains.php.lang.psi.elements.PhpPsiElement
import com.jetbrains.php.lang.psi.resolve.types.PhpType
import com.vk.kphpstorm.exphptype.psi.ArrayShapeItem

/**
* shape(x:int, y:?A, ...) — shape is a list of shape items
* vararg flag is not stored here: does not influence any behavior
*/
class ExPhpTypeShape(val items: List<ShapeItem>) : ExPhpType {
class ShapeItem(val keyName: String, val nullable: Boolean, val type: ExPhpType) {
class ShapeItem(override val keyName: String, val nullable: Boolean, override val type: ExPhpType) :
ArrayShapeItem {
override fun toString() = "$keyName${if (nullable) "?" else ""}:$type"
fun toHumanReadable(file: PhpPsiElement) = "$keyName${if (nullable) "?" else ""}:${type.toHumanReadable(file)}"
}
Expand All @@ -19,7 +21,8 @@ class ExPhpTypeShape(val items: List<ShapeItem>) : ExPhpType {
override fun toHumanReadable(expr: PhpPsiElement) = "shape(${items.joinToString { it.toHumanReadable(expr) }})"

override fun toPhpType(): PhpType {
val itemsStrJoined = items.joinToString(",") { "${it.keyName}${if (it.nullable) "?" else ""}:${it.type.toPhpType()}" }
val itemsStrJoined =
items.joinToString(",") { "${it.keyName}${if (it.nullable) "?" else ""}:${it.type.toPhpType()}" }
return PhpType().add("shape($itemsStrJoined)")
}

Expand All @@ -34,9 +37,9 @@ class ExPhpTypeShape(val items: List<ShapeItem>) : ExPhpType {
}

override fun isAssignableFrom(rhs: ExPhpType, project: Project): Boolean = when (rhs) {
is ExPhpTypeAny -> true
is ExPhpTypePipe -> rhs.isAssignableTo(this, project)
is ExPhpTypeAny -> true
is ExPhpTypePipe -> rhs.isAssignableTo(this, project)
is ExPhpTypeShape -> true // any shape is compatible with any other, for simplification (tuples are not)
else -> false
else -> false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ object PhpTypeToExPhpTypeParsing {
offset++
}

inline fun <T> rollbackOnNull(operation: () -> T?): T? {
val curr = offset
val result = operation()

if (result == null) {
offset = curr
return null
} else {
return result
}
}

fun compare(c: Char): Boolean {
skipWhitespace()
return offset < type.length && type[offset] == c
Expand All @@ -130,6 +142,37 @@ object PhpTypeToExPhpTypeParsing {
offset = match.range.last + 1
return match.value
}

fun parseStringLiteral(): String? {
skipWhitespace()

return buildString {
if (type.length - offset < 2) return null

// "..."
// ^
when (type[offset]) {
'\'' -> offset++
'\"' -> offset++
else -> return null
}


// "..."
// ^
while (type[offset] != '\'' && type[offset] != '"') {
append(type[offset++])
}

// "..."
// ^
if (type[offset] != '"' && type[offset] != '\'') {
return null
}

offset++
}
}
}


Expand Down Expand Up @@ -265,6 +308,11 @@ object PhpTypeToExPhpTypeParsing {
return ExPhpTypeForcing(inner)
}

if (fqn == "array" && builder.compare('{')) {
val items = parseArrayShapeContents(builder) ?: return null
return ExPhpTypeArrayShape(items)
}

if (builder.compare('<')) {
val specialization = parseTemplateSpecialization(builder) ?: return null
return ExPhpTypeTplInstantiation(fqn, specialization)
Expand Down Expand Up @@ -296,6 +344,44 @@ object PhpTypeToExPhpTypeParsing {
return createPipeOrSimplified(pipeItems)
}

private fun parseArrayShapeContents(builder: ExPhpTypeBuilder): List<ExPhpTypeArrayShape.ShapeItem>? {
if (!builder.compareAndEat('{'))
return null
if (builder.compareAndEat('}'))
return listOf()

val items = mutableListOf<ExPhpTypeArrayShape.ShapeItem>()

while (true) {
var isString = false

val keyName = builder.rollbackOnNull {
builder.parseFQN()
} ?: builder.rollbackOnNull {
isString = true
builder.parseStringLiteral()
} ?: return null

val nullable = builder.compareAndEat('?')
builder.compareAndEat(':')
val type = parseTypeExpression(builder) ?: return null

items.add(ExPhpTypeArrayShape.ShapeItem(keyName, isString, nullable, type))
if (builder.compareAndEat('}'))
return items

if (builder.compareAndEat(',')) {
if (builder.compareAndEat('.') && builder.compareAndEat('.') && builder.compareAndEat('.')) {
if (!builder.compareAndEat('}'))
return null
return items
}
continue
}
return null
}
}

/**
* Having T1|T2|... create ExPhpType representation; not always pipe: int|null will be ?int for example.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.vk.kphpstorm.exphptype.psi

import com.vk.kphpstorm.exphptype.ExPhpType

interface ArrayShapeItem {
val keyName: String
val type: ExPhpType
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.vk.kphpstorm.exphptype.psi

import com.intellij.lang.ASTNode
import com.intellij.psi.util.elementType
import com.jetbrains.php.lang.documentation.phpdoc.lexer.PhpDocTokenTypes
import com.jetbrains.php.lang.documentation.phpdoc.psi.PhpDocElementType
import com.jetbrains.php.lang.documentation.phpdoc.psi.PhpDocType
import com.jetbrains.php.lang.documentation.phpdoc.psi.impl.PhpDocTypeImpl
import com.jetbrains.php.lang.psi.resolve.types.PhpType
import com.vk.kphpstorm.helpers.toStringAsNested

class ExPhpTypeArrayShapePsiImpl(node: ASTNode) : PhpDocTypeImpl(node) {
companion object {
val elementType = PhpDocElementType("exPhpTypeArrayShape")
}

override fun getNameNode(): ASTNode? = null

override fun getType(): PhpType {
var itemsStr = ""
var child = firstChild?.nextSibling?.nextSibling // after '('
while (child != null) {
// key name
if (child.elementType == PhpDocTokenTypes.DOC_IDENTIFIER || child.elementType == PhpDocTokenTypes.DOC_STRING) {
if (itemsStr.length > 1)
itemsStr += ','
itemsStr += child.text
if (child.nextSibling?.text?.let { it.isNotEmpty() && it[0] == '?' } == true) // nullable
itemsStr += '?'
itemsStr += ':'
}
// key type
if (child is PhpDocType)
itemsStr += child.type.toStringAsNested()

child = child.nextSibling
}
// vararg shapes with "..." in the end are not reflected in PhpType/ExPhpType

return PhpType().add("array{$itemsStr}")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,45 @@ internal object TokensToExPhpTypePsiParsing {
}
}


// example: array{x:int, y?:\A}
private fun parseArrayShapeContents(builder: PhpPsiBuilder): Boolean {
if (!builder.compareAndEat(PhpDocTokenTypes.DOC_LBRACE) && !builder.compareAndEat(PhpDocTokenTypes.DOC_LAB))
return !builder.expected("{")

while (true) {
if (!builder.compare(PhpDocTokenTypes.DOC_IDENTIFIER) && !builder.compare(PhpDocTokenTypes.DOC_STRING))
return builder.expected("key name")
// val keyName = builder.tokenText
builder.advanceLexer()

val sepOk = builder.compare(PhpDocTokenTypes.DOC_TEXT) && builder.tokenText.let {
it == ":" || it == "?:"
}

if (sepOk)
builder.advanceLexer()
else
return builder.expected(":")

if (!parseTypeExpression(builder))
return builder.expected("expression")
if (builder.compareAndEat(PhpDocTokenTypes.DOC_RBRACE) || builder.compareAndEat(PhpDocTokenTypes.DOC_RAB))
return true

if (builder.compareAndEat(PhpDocTokenTypes.DOC_COMMA)) {
if (builder.compare(PhpDocTokenTypes.DOC_TEXT) && builder.tokenText == "...") {
builder.advanceLexer()
if (!builder.compareAndEat(PhpDocTokenTypes.DOC_RBRACE) && !builder.compareAndEat(PhpDocTokenTypes.DOC_RAB))
return builder.expected("}")
return true
}
continue
}
return builder.expected(", or }")
}
}

private fun parseTemplateSpecialization(builder: PhpPsiBuilder): Boolean {
if (!builder.compareAndEat(PhpDocTokenTypes.DOC_LAB))
return !builder.expected("<")
Expand Down Expand Up @@ -234,6 +273,17 @@ internal object TokensToExPhpTypePsiParsing {
return true
}

if (builder.compare(PhpDocTokenTypes.DOC_IDENTIFIER) && builder.tokenText == "array" && builder.rawLookup(1) == PhpDocTokenTypes.DOC_LBRACE) {
val marker = builder.mark()
builder.advanceLexer()
if (!parseArrayShapeContents(builder)) {
marker.drop()
return false
}
marker.done(ExPhpTypeArrayShapePsiImpl.elementType)
return true
}

if (builder.compare(PhpDocTokenTypes.DOC_IDENTIFIER) && KphpPrimitiveTypes.mapPrimitiveToPhpType.containsKey(builder.tokenText!!)) {
val marker = builder.mark()
builder.advanceLexer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,11 @@ class TupleShapeTypeProvider : PhpTypeProvider4 {
private fun inferTypeOfTupleShapeByIndex(wholeType: PhpType, indexKey: String): PhpType? {
// optimization: parse wholeType from string only if tuple/shape exist in it
val needsCustomIndexing = wholeType.types.any {
it.length > 7 && it[5] == '(' // tuple(, shape(, force(
(it.length > 7 && it[5] == '(' // tuple(, shape(, force(
|| it == "\\kmixed" // kmixed[*] is kmixed, not PhpStorm 'mixed' meaning uninferred
|| it == "\\any" // any[*] is any, not undefined
|| it == "\\array" // array[*] is any (untyped arrays)
|| it == "\\array") // array[*] is any (untyped arrays)
|| it.slice(0..5) == "array{" // array{ - phpstan-like array shape, similar to shape(
}
if (!needsCustomIndexing)
return null
Expand Down