From d042fedc447206cc98b7aef34b1a01bbf326863e Mon Sep 17 00:00:00 2001
From: Richard van Velzen <richard@goreply.com>
Date: Wed, 8 May 2024 13:54:34 +0200
Subject: [PATCH] Add syntax for subtraction type

---
 doc/grammars/type.abnf                  |  6 +++
 src/Ast/Type/SubtractionTypeNode.php    | 30 ++++++++++++
 src/Lexer/Lexer.php                     |  3 ++
 src/Parser/TypeParser.php               | 31 +++++++++++++
 src/Printer/Printer.php                 | 38 ++++++++++++++-
 tests/PHPStan/Parser/TypeParserTest.php | 61 +++++++++++++++++++++++++
 tests/PHPStan/Printer/PrinterTest.php   | 41 +++++++++++++++++
 7 files changed, 208 insertions(+), 2 deletions(-)
 create mode 100644 src/Ast/Type/SubtractionTypeNode.php

diff --git a/doc/grammars/type.abnf b/doc/grammars/type.abnf
index 36118d2b..9a1918fd 100644
--- a/doc/grammars/type.abnf
+++ b/doc/grammars/type.abnf
@@ -16,6 +16,9 @@ Union
 Intersection
 	= 1*(TokenIntersection Atomic)
 
+Subtraction
+    = TokenSubtraction Atomic
+
 Conditional
     = 1*ByteHorizontalWs TokenIs [TokenNot] Atomic TokenNullable Type TokenColon ParenthesizedType
 
@@ -141,6 +144,9 @@ TokenUnion
 TokenIntersection
 	= "&" *ByteHorizontalWs
 
+TokenSubtraction
+    = "~" *ByteHorizontalWs
+
 TokenNullable
 	= "?" *ByteHorizontalWs
 
diff --git a/src/Ast/Type/SubtractionTypeNode.php b/src/Ast/Type/SubtractionTypeNode.php
new file mode 100644
index 00000000..ddbd72e2
--- /dev/null
+++ b/src/Ast/Type/SubtractionTypeNode.php
@@ -0,0 +1,30 @@
+<?php declare(strict_types = 1);
+
+namespace PHPStan\PhpDocParser\Ast\Type;
+
+use PHPStan\PhpDocParser\Ast\NodeAttributes;
+
+class SubtractionTypeNode implements TypeNode
+{
+
+	use NodeAttributes;
+
+	/** @var TypeNode */
+	public $type;
+
+	/** @var TypeNode */
+	public $subtractedType;
+
+	public function __construct(TypeNode $type, TypeNode $subtractedType)
+	{
+		$this->type = $type;
+		$this->subtractedType = $subtractedType;
+	}
+
+
+	public function __toString(): string
+	{
+		return $this->type . '~' . $this->subtractedType;
+	}
+
+}
diff --git a/src/Lexer/Lexer.php b/src/Lexer/Lexer.php
index 32539faf..181c2dbc 100644
--- a/src/Lexer/Lexer.php
+++ b/src/Lexer/Lexer.php
@@ -49,11 +49,13 @@ class Lexer
 	public const TOKEN_CLOSE_CURLY_BRACKET = 34;
 	public const TOKEN_NEGATED = 35;
 	public const TOKEN_ARROW = 36;
+	public const TOKEN_SUBTRACTION = 37;
 
 	public const TOKEN_LABELS = [
 		self::TOKEN_REFERENCE => '\'&\'',
 		self::TOKEN_UNION => '\'|\'',
 		self::TOKEN_INTERSECTION => '\'&\'',
+		self::TOKEN_SUBTRACTION => '\'~\'',
 		self::TOKEN_NULLABLE => '\'?\'',
 		self::TOKEN_NEGATED => '\'!\'',
 		self::TOKEN_OPEN_PARENTHESES => '\'(\'',
@@ -147,6 +149,7 @@ private function generateRegexp(): string
 			self::TOKEN_REFERENCE => '&(?=\\s*+(?:[.,=)]|(?:\\$(?!this(?![0-9a-z_\\x80-\\xFF])))))',
 			self::TOKEN_UNION => '\\|',
 			self::TOKEN_INTERSECTION => '&',
+			self::TOKEN_SUBTRACTION => '\\~',
 			self::TOKEN_NULLABLE => '\\?',
 			self::TOKEN_NEGATED => '!',
 
diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php
index 5669fe45..77a60332 100644
--- a/src/Parser/TypeParser.php
+++ b/src/Parser/TypeParser.php
@@ -59,6 +59,9 @@ public function parse(TokenIterator $tokens): Ast\Type\TypeNode
 
 			} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_INTERSECTION)) {
 				$type = $this->parseIntersection($tokens, $type);
+
+			} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_SUBTRACTION)) {
+				$type = $this->parseSubtraction($tokens, $type);
 			}
 		}
 
@@ -111,6 +114,9 @@ private function subParse(TokenIterator $tokens): Ast\Type\TypeNode
 
 				} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_INTERSECTION)) {
 					$type = $this->subParseIntersection($tokens, $type);
+
+				} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_SUBTRACTION)) {
+					$type = $this->subParseSubtraction($tokens, $type);
 				}
 			}
 		}
@@ -312,6 +318,31 @@ private function subParseIntersection(TokenIterator $tokens, Ast\Type\TypeNode $
 	}
 
 
+	/** @phpstan-impure */
+	private function parseSubtraction(TokenIterator $tokens, Ast\Type\TypeNode $type): Ast\Type\TypeNode
+	{
+		$tokens->consumeTokenType(Lexer::TOKEN_SUBTRACTION);
+
+		$subtractedType = $this->parseAtomic($tokens);
+
+		return new Ast\Type\SubtractionTypeNode($type, $subtractedType);
+	}
+
+
+	/** @phpstan-impure */
+	private function subParseSubtraction(TokenIterator $tokens, Ast\Type\TypeNode $type): Ast\Type\TypeNode
+	{
+		$tokens->consumeTokenType(Lexer::TOKEN_SUBTRACTION);
+		$tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL);
+
+		$subtractedType = $this->parseAtomic($tokens);
+
+		$tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL);
+
+		return new Ast\Type\SubtractionTypeNode($type, $subtractedType);
+	}
+
+
 	/** @phpstan-impure */
 	private function parseConditional(TokenIterator $tokens, Ast\Type\TypeNode $subjectType): Ast\Type\TypeNode
 	{
diff --git a/src/Printer/Printer.php b/src/Printer/Printer.php
index 044d07f8..a59ca80e 100644
--- a/src/Printer/Printer.php
+++ b/src/Printer/Printer.php
@@ -57,6 +57,7 @@
 use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode;
 use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode;
 use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode;
+use PHPStan\PhpDocParser\Ast\Type\SubtractionTypeNode;
 use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode;
 use PHPStan\PhpDocParser\Ast\Type\TypeNode;
 use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
@@ -129,11 +130,13 @@ final class Printer
 			CallableTypeNode::class,
 			UnionTypeNode::class,
 			IntersectionTypeNode::class,
+			SubtractionTypeNode::class,
 		],
 		ArrayTypeNode::class . '->type' => [
 			CallableTypeNode::class,
 			UnionTypeNode::class,
 			IntersectionTypeNode::class,
+			SubtractionTypeNode::class,
 			ConstTypeNode::class,
 			NullableTypeNode::class,
 		],
@@ -141,8 +144,19 @@ final class Printer
 			CallableTypeNode::class,
 			UnionTypeNode::class,
 			IntersectionTypeNode::class,
+			SubtractionTypeNode::class,
 			NullableTypeNode::class,
 		],
+		SubtractionTypeNode::class . '->type' => [
+			UnionTypeNode::class,
+			IntersectionTypeNode::class,
+			SubtractionTypeNode::class,
+		],
+		SubtractionTypeNode::class . '->subtractedType' => [
+			UnionTypeNode::class,
+			IntersectionTypeNode::class,
+			SubtractionTypeNode::class,
+		],
 	];
 
 	/** @var array<string, list<class-string<TypeNode>>> */
@@ -150,11 +164,13 @@ final class Printer
 		IntersectionTypeNode::class . '->types' => [
 			IntersectionTypeNode::class,
 			UnionTypeNode::class,
+			SubtractionTypeNode::class,
 			NullableTypeNode::class,
 		],
 		UnionTypeNode::class . '->types' => [
 			IntersectionTypeNode::class,
 			UnionTypeNode::class,
+			SubtractionTypeNode::class,
 			NullableTypeNode::class,
 		],
 	];
@@ -387,7 +403,12 @@ private function printType(TypeNode $node): string
 			return $this->printOffsetAccessType($node->type) . '[]';
 		}
 		if ($node instanceof CallableTypeNode) {
-			if ($node->returnType instanceof CallableTypeNode || $node->returnType instanceof UnionTypeNode || $node->returnType instanceof IntersectionTypeNode) {
+			if (
+				$node->returnType instanceof CallableTypeNode
+				|| $node->returnType instanceof UnionTypeNode
+				|| $node->returnType instanceof IntersectionTypeNode
+				|| $node->returnType instanceof SubtractionTypeNode
+			) {
 				$returnType = $this->wrapInParentheses($node->returnType);
 			} else {
 				$returnType = $this->printType($node->returnType);
@@ -450,6 +471,7 @@ private function printType(TypeNode $node): string
 				if (
 					$type instanceof IntersectionTypeNode
 					|| $type instanceof UnionTypeNode
+					|| $type instanceof SubtractionTypeNode
 					|| $type instanceof NullableTypeNode
 				) {
 					$items[] = $this->wrapInParentheses($type);
@@ -461,11 +483,14 @@ private function printType(TypeNode $node): string
 
 			return implode($node instanceof IntersectionTypeNode ? '&' : '|', $items);
 		}
+		if ($node instanceof SubtractionTypeNode) {
+			return $this->printSubtractionType($node->type) . '~' . $this->printSubtractionType($node->subtractedType);
+		}
 		if ($node instanceof InvalidTypeNode) {
 			return (string) $node;
 		}
 		if ($node instanceof NullableTypeNode) {
-			if ($node->type instanceof IntersectionTypeNode || $node->type instanceof UnionTypeNode) {
+			if ($node->type instanceof IntersectionTypeNode || $node->type instanceof UnionTypeNode || $node->type instanceof SubtractionTypeNode) {
 				return '?(' . $this->printType($node->type) . ')';
 			}
 
@@ -519,6 +544,15 @@ private function printOffsetAccessType(TypeNode $type): string
 		return $this->printType($type);
 	}
 
+	private function printSubtractionType(TypeNode $type): string
+	{
+		if ($type instanceof UnionTypeNode || $type instanceof IntersectionTypeNode) {
+			return $this->wrapInParentheses($type);
+		}
+
+		return $this->printType($type);
+	}
+
 	private function printConstExpr(ConstExprNode $node): string
 	{
 		// this is fine - ConstExprNode classes do not contain nodes that need smart printer logic
diff --git a/tests/PHPStan/Parser/TypeParserTest.php b/tests/PHPStan/Parser/TypeParserTest.php
index d6c66bb8..15980a0b 100644
--- a/tests/PHPStan/Parser/TypeParserTest.php
+++ b/tests/PHPStan/Parser/TypeParserTest.php
@@ -26,6 +26,7 @@
 use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode;
 use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode;
 use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode;
+use PHPStan\PhpDocParser\Ast\Type\SubtractionTypeNode;
 use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode;
 use PHPStan\PhpDocParser\Ast\Type\TypeNode;
 use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
@@ -276,6 +277,66 @@ public function provideParseData(): array
 				]),
 				Lexer::TOKEN_INTERSECTION,
 			],
+			[
+				'string~int',
+				new SubtractionTypeNode(
+					new IdentifierTypeNode('string'),
+					new IdentifierTypeNode('int')
+				),
+			],
+			[
+				'string ~ int',
+				new SubtractionTypeNode(
+					new IdentifierTypeNode('string'),
+					new IdentifierTypeNode('int')
+				),
+			],
+			[
+				'(string ~ int)',
+				new SubtractionTypeNode(
+					new IdentifierTypeNode('string'),
+					new IdentifierTypeNode('int')
+				),
+			],
+			[
+				'(' . PHP_EOL .
+				'  string' . PHP_EOL .
+				'  ~' . PHP_EOL .
+				'  int' . PHP_EOL .
+				')',
+				new SubtractionTypeNode(
+					new IdentifierTypeNode('string'),
+					new IdentifierTypeNode('int')
+				),
+			],
+			[
+				'string~int~float',
+				new SubtractionTypeNode(
+					new IdentifierTypeNode('string'),
+					new IdentifierTypeNode('int')
+				),
+				Lexer::TOKEN_SUBTRACTION,
+			],
+			[
+				'(string&int)~float',
+				new SubtractionTypeNode(
+					new IntersectionTypeNode([
+						new IdentifierTypeNode('string'),
+						new IdentifierTypeNode('int'),
+					]),
+					new IdentifierTypeNode('float')
+				),
+			],
+			[
+				'float~(string&int)',
+				new SubtractionTypeNode(
+					new IdentifierTypeNode('float'),
+					new IntersectionTypeNode([
+						new IdentifierTypeNode('string'),
+						new IdentifierTypeNode('int'),
+					])
+				),
+			],
 			[
 				'string[]',
 				new ArrayTypeNode(
diff --git a/tests/PHPStan/Printer/PrinterTest.php b/tests/PHPStan/Printer/PrinterTest.php
index d73481e2..fd61a11d 100644
--- a/tests/PHPStan/Printer/PrinterTest.php
+++ b/tests/PHPStan/Printer/PrinterTest.php
@@ -39,6 +39,7 @@
 use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode;
 use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode;
 use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode;
+use PHPStan\PhpDocParser\Ast\Type\SubtractionTypeNode;
 use PHPStan\PhpDocParser\Ast\Type\TypeNode;
 use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
 use PHPStan\PhpDocParser\Lexer\Lexer;
@@ -1427,6 +1428,46 @@ public function enterNode(Node $node)
 			},
 		];
 
+		yield [
+			'/** @param Foo~Bar $a */',
+			'/** @param Foo~(Lorem|Ipsum) $a */',
+			new class extends AbstractNodeVisitor {
+
+				public function enterNode(Node $node)
+				{
+					if ($node instanceof SubtractionTypeNode) {
+						$node->subtractedType = new UnionTypeNode([
+							new IdentifierTypeNode('Lorem'),
+							new IdentifierTypeNode('Ipsum'),
+						]);
+					}
+
+					return $node;
+				}
+
+			},
+		];
+
+		yield [
+			'/** @param Foo~Bar $a */',
+			'/** @param (Lorem|Ipsum)~Bar $a */',
+			new class extends AbstractNodeVisitor {
+
+				public function enterNode(Node $node)
+				{
+					if ($node instanceof SubtractionTypeNode) {
+						$node->type = new UnionTypeNode([
+							new IdentifierTypeNode('Lorem'),
+							new IdentifierTypeNode('Ipsum'),
+						]);
+					}
+
+					return $node;
+				}
+
+			},
+		];
+
 		yield [
 			'/** @var ArrayObject<int[]> */',
 			'/** @var ArrayObject<array<int>> */',