diff --git a/src/main/kotlin/com/vk/kphpstorm/KphpStormParserDefinition.kt b/src/main/kotlin/com/vk/kphpstorm/KphpStormParserDefinition.kt index eb688433..18b1340b 100644 --- a/src/main/kotlin/com/vk/kphpstorm/KphpStormParserDefinition.kt +++ b/src/main/kotlin/com/vk/kphpstorm/KphpStormParserDefinition.kt @@ -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) diff --git a/src/main/kotlin/com/vk/kphpstorm/completion/ShapeKeyInvocationCompletionProvider.kt b/src/main/kotlin/com/vk/kphpstorm/completion/ShapeKeyInvocationCompletionProvider.kt index 45501ca6..c3127e9d 100644 --- a/src/main/kotlin/com/vk/kphpstorm/completion/ShapeKeyInvocationCompletionProvider.kt +++ b/src/main/kotlin/com/vk/kphpstorm/completion/ShapeKeyInvocationCompletionProvider.kt @@ -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 /** @@ -51,15 +53,18 @@ class ShapeKeyInvocationCompletionProvider : CompletionProvider? { - val parsed = type.toExPhpType() - val shapeInType = when (parsed) { - is ExPhpTypePipe -> parsed.items.firstOrNull { it is ExPhpTypeShape } + fun detectPossibleKeysOfShape(type: PhpType): List? { + 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 + } } } } diff --git a/src/main/kotlin/com/vk/kphpstorm/completion/ShapeKeyUsageCompletionProvider.kt b/src/main/kotlin/com/vk/kphpstorm/completion/ShapeKeyUsageCompletionProvider.kt index f1346f54..c84cad04 100644 --- a/src/main/kotlin/com/vk/kphpstorm/completion/ShapeKeyUsageCompletionProvider.kt +++ b/src/main/kotlin/com/vk/kphpstorm/completion/ShapeKeyUsageCompletionProvider.kt @@ -16,6 +16,7 @@ class ShapeKeyUsageCompletionProvider : CompletionProvider 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) diff --git a/src/main/kotlin/com/vk/kphpstorm/exphptype/ExPhpTypeArrayShape.kt b/src/main/kotlin/com/vk/kphpstorm/exphptype/ExPhpTypeArrayShape.kt new file mode 100644 index 00000000..ac689ac1 --- /dev/null +++ b/src/main/kotlin/com/vk/kphpstorm/exphptype/ExPhpTypeArrayShape.kt @@ -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) : 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): 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 + } +} diff --git a/src/main/kotlin/com/vk/kphpstorm/exphptype/ExPhpTypeShape.kt b/src/main/kotlin/com/vk/kphpstorm/exphptype/ExPhpTypeShape.kt index 2b6efcf5..0f923588 100644 --- a/src/main/kotlin/com/vk/kphpstorm/exphptype/ExPhpTypeShape.kt +++ b/src/main/kotlin/com/vk/kphpstorm/exphptype/ExPhpTypeShape.kt @@ -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) : 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)}" } @@ -19,7 +21,8 @@ class ExPhpTypeShape(val items: List) : 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)") } @@ -34,9 +37,9 @@ class ExPhpTypeShape(val items: List) : 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 } } diff --git a/src/main/kotlin/com/vk/kphpstorm/exphptype/PhpTypeToExPhpTypeParsing.kt b/src/main/kotlin/com/vk/kphpstorm/exphptype/PhpTypeToExPhpTypeParsing.kt index 62615650..1d321afa 100644 --- a/src/main/kotlin/com/vk/kphpstorm/exphptype/PhpTypeToExPhpTypeParsing.kt +++ b/src/main/kotlin/com/vk/kphpstorm/exphptype/PhpTypeToExPhpTypeParsing.kt @@ -109,6 +109,18 @@ object PhpTypeToExPhpTypeParsing { offset++ } + inline fun 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 @@ -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++ + } + } } @@ -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) @@ -296,6 +344,44 @@ object PhpTypeToExPhpTypeParsing { return createPipeOrSimplified(pipeItems) } + private fun parseArrayShapeContents(builder: ExPhpTypeBuilder): List? { + if (!builder.compareAndEat('{')) + return null + if (builder.compareAndEat('}')) + return listOf() + + val items = mutableListOf() + + 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. */ diff --git a/src/main/kotlin/com/vk/kphpstorm/exphptype/psi/ArrayShapeItem.kt b/src/main/kotlin/com/vk/kphpstorm/exphptype/psi/ArrayShapeItem.kt new file mode 100644 index 00000000..eb697c21 --- /dev/null +++ b/src/main/kotlin/com/vk/kphpstorm/exphptype/psi/ArrayShapeItem.kt @@ -0,0 +1,8 @@ +package com.vk.kphpstorm.exphptype.psi + +import com.vk.kphpstorm.exphptype.ExPhpType + +interface ArrayShapeItem { + val keyName: String + val type: ExPhpType +} \ No newline at end of file diff --git a/src/main/kotlin/com/vk/kphpstorm/exphptype/psi/ExPhpTypeArrayShapePsiImpl.kt b/src/main/kotlin/com/vk/kphpstorm/exphptype/psi/ExPhpTypeArrayShapePsiImpl.kt new file mode 100644 index 00000000..1811f8c4 --- /dev/null +++ b/src/main/kotlin/com/vk/kphpstorm/exphptype/psi/ExPhpTypeArrayShapePsiImpl.kt @@ -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}") + } +} diff --git a/src/main/kotlin/com/vk/kphpstorm/exphptype/psi/TokensToExPhpTypePsiParsing.kt b/src/main/kotlin/com/vk/kphpstorm/exphptype/psi/TokensToExPhpTypePsiParsing.kt index e27387e9..6c834578 100644 --- a/src/main/kotlin/com/vk/kphpstorm/exphptype/psi/TokensToExPhpTypePsiParsing.kt +++ b/src/main/kotlin/com/vk/kphpstorm/exphptype/psi/TokensToExPhpTypePsiParsing.kt @@ -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("<") @@ -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() diff --git a/src/main/kotlin/com/vk/kphpstorm/typeProviders/TupleShapeTypeProvider.kt b/src/main/kotlin/com/vk/kphpstorm/typeProviders/TupleShapeTypeProvider.kt index 1ceca4f4..40b276e9 100644 --- a/src/main/kotlin/com/vk/kphpstorm/typeProviders/TupleShapeTypeProvider.kt +++ b/src/main/kotlin/com/vk/kphpstorm/typeProviders/TupleShapeTypeProvider.kt @@ -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