Repository: VKCOM/noverify
Pull request #1079 (2021-08-09 17:41:10)

linter: added PHP 8 related things
- Fixed attributes
- All tests moved to 8.1
- Added `--php7` flag
- Now, by default we analyze and parse as PHP 8.1


Patch:
diff --git a/README.md b/README.md
index 76c246d..e3ed66a 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,8 @@ NoVerify is a PHP linter: it finds possible bugs and style violations in your co
 
 1. Fast: analyze ~100k LOC/s (lines of code per second) on Core i7;
 2. Incremental: can analyze changes in git and show only new reports. Indexing speed is ~1M LOC/s;
-3. Auto fixes for some warnings.
+3. Auto fixes for some warnings;
+4. Support PHP 7 and PHP 8.
 
 ## Default lints
 
diff --git a/docs/configuration.md b/docs/configuration.md
index 4e9c85d..0b8d35d 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -18,6 +18,7 @@ This page is dedicated to some technical details.
   * [How to make a check critical](#how-to-make-a-check-critical)
   * [How to change the cache directory](#how-to-change-the-cache-directory)
   * [How to disable caching](#how-to-disable-caching)
+  * [How to use PHP 7](#how-to-use-php-7)
 - [Hard level options](#hard-level-options)
   * [How to use dynamic rules](#how-to-use-dynamic_rules)
   * [How to use `baseline` mode](#how-to-use--baseline--mode)
@@ -231,6 +232,16 @@ It looks like this:
 nocolor check --disable-cache ./src
 ```
 
+### How to use PHP 7
+
+It looks like this:
+
+```shell
+nocolor check --php7 ./src
+```
+
+By default, all code is parsed as 8.1, however, some projects use names that have become reserved in PHP 8, so they need to be parsed as PHP 7.
+
 <p><br></p>
 
 ## Hard level options
diff --git a/docs/getting_started.md b/docs/getting_started.md
index 8ec6155..dc83584 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -50,14 +50,14 @@ This will lead to an errors:
 <critical> WARNING parentConstructor: Missing parent::__construct() call at swiftmailer/lib/classes/Swift/Attachment.php:27
     public function __construct($data = null, $filename = null, $contentType = null)
                     ^^^^^^^^^^^
-2021/07/08 16:13:19 Found 119 critical and 10 minor reports
+2021/07/08 16:13:19 Found 140 critical and 10 minor reports
 ```
 
 From the errors, you can understand on which lines NoVerify gives errors, and also understand what kind of error it is. Also, you may notice that the errors occurred in different files.
 
 This run will analyze all files from the `./lib` folder, and it will also index the `./vendor` folder and take function and class definitions from it for analyze.
 
-## Step 5 — let's try something
+## Step 4 — let's try something
 
 As you can see NoVerify found quite a few bugs.
 
diff --git a/docs/install.md b/docs/install.md
index 64b5bea..68540ad 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -14,13 +14,13 @@ noverify version
 
 You're done! Proceed to the [Getting started](/docs/getting_started.md) page.
 
-## With `go get`
+## With `go install`
 
 Make sure you have [Go](https://golang.org/dl/) version 1.16 or higher installed.  
 Run the following command:
 
 ```sh
-$ go install -u github.com/VKCOM/noverify
+$ go install github.com/VKCOM/noverify
 ```
 
 NoVerify will be installed to `$GOPATH/bin/noverify`, which usually expands to `$HOME/go/bin/noverify`.
diff --git a/example/custom/main.go b/example/custom/main.go
index 9a2516c..8e9f415 100644
--- a/example/custom/main.go
+++ b/example/custom/main.go
@@ -28,7 +28,7 @@ func addCheckers(config *linter.Config) {
 func main() {
 	log.SetFlags(log.Flags() | log.Ltime)
 
-	config := linter.NewConfig()
+	config := linter.NewConfig("8.1")
 	addCheckers(config)
 
 	// Config argument can be nil to use "all default" behavior.
diff --git a/src/cmd/check.go b/src/cmd/check.go
index 0798256..7266798 100644
--- a/src/cmd/check.go
+++ b/src/cmd/check.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 
 	"github.com/VKCOM/noverify/src/linter"
+	"github.com/VKCOM/php-parser/pkg/version"
 )
 
 func Check(ctx *AppContext) (int, error) {
@@ -11,6 +12,12 @@ func Check(ctx *AppContext) (int, error) {
 
 	bindConfigValuesWithFlags(ctx, config)
 
+	// We need to set a new version here, since it is created
+	// before the arguments are parsed.
+	if ctx.ParsedFlags.PHP7 {
+		config.PhpVersion, _ = version.New("7.4")
+	}
+
 	ruleSets, err := ParseExternalRules(ctx.ParsedFlags.RulesList)
 	if err != nil {
 		return 1, fmt.Errorf("preload external rules: %v", err)
diff --git a/src/cmd/flags.go b/src/cmd/flags.go
index 133749a..60108df 100644
--- a/src/cmd/flags.go
+++ b/src/cmd/flags.go
@@ -12,6 +12,8 @@ const AllNonNoticeChecks = "<all-non-notice>"
 const AllChecks = "<all>"
 
 type ParsedFlags struct {
+	PHP7 bool
+
 	PprofHost string
 
 	CPUProfile string
@@ -163,6 +165,7 @@ func RegisterCheckFlags(ctx *AppContext) (*flag.FlagSet, *FlagsGroups) {
 		"Comma-separated list of misspelling dicts; predefined sets are Eng, Eng/US and Eng/UK")
 	fs.BoolVar(&ctx.ParsedFlags.IgnoreVendor, "ignore-vendor", false,
 		"Do not use the vendor folder to get information about functions and classes (not recommended as type inference will work much worse)")
+	fs.BoolVar(&ctx.ParsedFlags.PHP7, "php7", false, "Analyze as PHP 7")
 
 	groups.Add("Analyze setting", "cores")
 	groups.Add("Analyze setting", "cache-dir")
@@ -171,6 +174,7 @@ func RegisterCheckFlags(ctx *AppContext) (*flag.FlagSet, *FlagsGroups) {
 	groups.Add("Analyze setting", "max-sum-filesize")
 	groups.Add("Analyze setting", "stubs-dir")
 	groups.Add("Analyze setting", "ignore-vendor")
+	groups.Add("Analyze setting", "php7")
 
 	// Additional group.
 	fs.BoolVar(&ctx.ParsedFlags.CheckAutoGenerated, "check-auto-generated", false, "Check auto-generated PHP files")
diff --git a/src/cmd/main.go b/src/cmd/main.go
index 67afa43..85a1a1e 100644
--- a/src/cmd/main.go
+++ b/src/cmd/main.go
@@ -98,7 +98,7 @@ func Run(cfg *MainConfig) (int, error) {
 
 	config := cfg.LinterConfig
 	if config == nil {
-		config = linter.NewConfig()
+		config = linter.NewConfig("8.1")
 		cfg.LinterConfig = config
 	}
 
diff --git a/src/cmd/php-guru/dupcode/utils.go b/src/cmd/php-guru/dupcode/utils.go
index b17c323..dc7ba87 100644
--- a/src/cmd/php-guru/dupcode/utils.go
+++ b/src/cmd/php-guru/dupcode/utils.go
@@ -41,7 +41,7 @@ func hasModifier(list []*ir.Identifier, key string) bool {
 }
 
 func runIndexing(cacheDir string, targets []string, filter *workspace.FilenameFilter) error {
-	config := linter.NewConfig()
+	config := linter.NewConfig("8.1")
 	l := linter.NewLinter(config)
 	config.CacheDir = cacheDir
 
diff --git a/src/ir/get_first_token.go b/src/ir/get_first_token.go
index 7e1706e..670f2ee 100644
--- a/src/ir/get_first_token.go
+++ b/src/ir/get_first_token.go
@@ -304,6 +304,21 @@ func GetFirstToken(n Node) *token.Token {
 		if n.CloseParenthesisTkn != nil {
 			return n.CloseParenthesisTkn
 		}
+	case *AttributeGroup:
+		if n.OpenAttributeTkn != nil {
+			return n.OpenAttributeTkn
+		}
+		if n.Attrs != nil {
+			if n.Attrs[0] != nil {
+				return GetFirstToken(n.Attrs[0])
+			}
+		}
+		if len(n.SeparatorTkns) != 0 {
+			return n.SeparatorTkns[0]
+		}
+		if n.CloseAttributeTkn != nil {
+			return n.CloseAttributeTkn
+		}
 	case *BadString:
 	case *BitwiseAndExpr:
 		if n.Left != nil {
diff --git a/src/ir/get_node_kind.go b/src/ir/get_node_kind.go
index 083fe51..77ed245 100644
--- a/src/ir/get_node_kind.go
+++ b/src/ir/get_node_kind.go
@@ -30,6 +30,7 @@ const (
 	KindAssignShiftLeft
 	KindAssignShiftRight
 	KindAttribute
+	KindAttributeGroup
 	KindBadString
 	KindBitwiseAndExpr
 	KindBitwiseNotExpr
@@ -217,6 +218,8 @@ func GetNodeKind(x Node) NodeKind {
 		return KindAssignShiftRight
 	case *Attribute:
 		return KindAttribute
+	case *AttributeGroup:
+		return KindAttributeGroup
 	case *BadString:
 		return KindBadString
 	case *BitwiseAndExpr:
diff --git a/src/ir/get_position.go b/src/ir/get_position.go
index e8b9803..e7a694c 100644
--- a/src/ir/get_position.go
+++ b/src/ir/get_position.go
@@ -52,6 +52,8 @@ func GetPosition(n Node) *position.Position {
 		return n.Position
 	case *Attribute:
 		return n.Position
+	case *AttributeGroup:
+		return n.Position
 	case *BadString:
 		return n.Position
 	case *BitwiseAndExpr:
diff --git a/src/ir/irconv/irconv.go b/src/ir/irconv/irconv.go
index 82967d8..ad2a3e5 100644
--- a/src/ir/irconv/irconv.go
+++ b/src/ir/irconv/irconv.go
@@ -643,9 +643,9 @@ func (c *Converter) convNode(n ast.Vertex) ir.Node {
 		out.Position = n.Position
 
 		if n.AttrGroups != nil {
-			slice := make([]*ir.Attribute, len(n.AttrGroups))
+			slice := make([]*ir.AttributeGroup, len(n.AttrGroups))
 			for i := range n.AttrGroups {
-				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.Attribute)
+				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			out.AttrGroups = slice
 		}
@@ -728,9 +728,9 @@ func (c *Converter) convNode(n ast.Vertex) ir.Node {
 		out.Position = n.Position
 
 		if n.AttrGroups != nil {
-			slice := make([]*ir.Attribute, len(n.AttrGroups))
+			slice := make([]*ir.AttributeGroup, len(n.AttrGroups))
 			for i := range n.AttrGroups {
-				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.Attribute)
+				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			out.AttrGroups = slice
 		}
@@ -1217,9 +1217,9 @@ func (c *Converter) convNode(n ast.Vertex) ir.Node {
 		out.Position = n.Position
 
 		if n.AttrGroups != nil {
-			slice := make([]*ir.Attribute, len(n.AttrGroups))
+			slice := make([]*ir.AttributeGroup, len(n.AttrGroups))
 			for i := range n.AttrGroups {
-				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.Attribute)
+				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			out.AttrGroups = slice
 		}
@@ -1444,9 +1444,9 @@ func (c *Converter) convNode(n ast.Vertex) ir.Node {
 		out.Position = n.Position
 
 		if n.AttrGroups != nil {
-			slice := make([]*ir.Attribute, len(n.AttrGroups))
+			slice := make([]*ir.AttributeGroup, len(n.AttrGroups))
 			for i := range n.AttrGroups {
-				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.Attribute)
+				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			out.AttrGroups = slice
 		}
@@ -1475,9 +1475,9 @@ func (c *Converter) convNode(n ast.Vertex) ir.Node {
 		out := &ir.ClassMethodStmt{}
 		out.Position = n.Position
 		if n.AttrGroups != nil {
-			slice := make([]*ir.Attribute, len(n.AttrGroups))
+			slice := make([]*ir.AttributeGroup, len(n.AttrGroups))
 			for i := range n.AttrGroups {
-				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.Attribute)
+				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			out.AttrGroups = slice
 		}
@@ -1742,9 +1742,9 @@ func (c *Converter) convNode(n ast.Vertex) ir.Node {
 		out := &ir.FunctionStmt{}
 		out.Position = n.Position
 		if n.AttrGroups != nil {
-			slice := make([]*ir.Attribute, len(n.AttrGroups))
+			slice := make([]*ir.AttributeGroup, len(n.AttrGroups))
 			for i := range n.AttrGroups {
-				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.Attribute)
+				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			out.AttrGroups = slice
 		}
@@ -1893,9 +1893,9 @@ func (c *Converter) convNode(n ast.Vertex) ir.Node {
 		out := &ir.InterfaceStmt{}
 		out.Position = n.Position
 		if n.AttrGroups != nil {
-			slice := make([]*ir.Attribute, len(n.AttrGroups))
+			slice := make([]*ir.AttributeGroup, len(n.AttrGroups))
 			for i := range n.AttrGroups {
-				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.Attribute)
+				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			out.AttrGroups = slice
 		}
@@ -1997,9 +1997,9 @@ func (c *Converter) convNode(n ast.Vertex) ir.Node {
 		out.Doc = c.getPhpDoc(tokenWithDoc)
 
 		if n.AttrGroups != nil {
-			slice := make([]*ir.Attribute, len(n.AttrGroups))
+			slice := make([]*ir.AttributeGroup, len(n.AttrGroups))
 			for i := range n.AttrGroups {
-				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.Attribute)
+				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			out.AttrGroups = slice
 		}
@@ -2103,9 +2103,9 @@ func (c *Converter) convNode(n ast.Vertex) ir.Node {
 		out := &ir.TraitStmt{}
 		out.Position = n.Position
 		if n.AttrGroups != nil {
-			slice := make([]*ir.Attribute, len(n.AttrGroups))
+			slice := make([]*ir.AttributeGroup, len(n.AttrGroups))
 			for i := range n.AttrGroups {
-				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.Attribute)
+				slice[i] = c.convNode(n.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			out.AttrGroups = slice
 		}
@@ -2315,12 +2315,32 @@ func (c *Converter) convNode(n ast.Vertex) ir.Node {
 		out := &ir.Attribute{}
 
 		out.Position = n.Position
-		out.Name = c.convNode(n.Name).(*ir.Identifier)
+		out.Name = c.convNode(n.Name)
 		out.OpenParenthesisTkn = n.OpenParenthesisTkn
 		out.Args = c.convNodeSlice(n.Args)
 		out.SeparatorTkns = n.SeparatorTkns
 		out.CloseParenthesisTkn = n.CloseParenthesisTkn
 		return out
+
+	case *ast.AttributeGroup:
+		if n == nil {
+			return (*ir.AttributeGroup)(nil)
+		}
+		out := &ir.AttributeGroup{}
+
+		out.Position = n.Position
+		out.OpenAttributeTkn = n.OpenAttributeTkn
+
+		if n.Attrs != nil {
+			out.Attrs = make([]*ir.Attribute, 0, len(n.Attrs))
+			for _, attr := range n.Attrs {
+				out.Attrs = append(out.Attrs, c.convNode(attr).(*ir.Attribute))
+			}
+		}
+
+		out.SeparatorTkns = n.SeparatorTkns
+		out.CloseAttributeTkn = n.CloseAttributeTkn
+		return out
 	}
 
 	panic(fmt.Sprintf("unhandled type %T", n))
@@ -2465,9 +2485,9 @@ func (c *Converter) convClass(n *ast.StmtClass) ir.Node {
 		ClassName:            c.convNode(n.Name).(*ir.Identifier),
 	}
 	if n.AttrGroups != nil {
-		slice := make([]*ir.Attribute, len(n.AttrGroups))
+		slice := make([]*ir.AttributeGroup, len(n.AttrGroups))
 		for i := range n.AttrGroups {
-			slice[i] = c.convNode(n.AttrGroups[i]).(*ir.Attribute)
+			slice[i] = c.convNode(n.AttrGroups[i]).(*ir.AttributeGroup)
 		}
 		out.AttrGroups = slice
 	}
diff --git a/src/ir/irutil/clone.go b/src/ir/irutil/clone.go
index 0069609..adad31c 100644
--- a/src/ir/irutil/clone.go
+++ b/src/ir/irutil/clone.go
@@ -56,9 +56,9 @@ func NodeClone(x ir.Node) ir.Node {
 	case *ir.ArrowFunctionExpr:
 		clone := *x
 		{
-			sliceClone := make([]*ir.Attribute, len(x.AttrGroups))
+			sliceClone := make([]*ir.AttributeGroup, len(x.AttrGroups))
 			for i := range x.AttrGroups {
-				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.Attribute)
+				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			clone.AttrGroups = sliceClone
 		}
@@ -208,10 +208,20 @@ func NodeClone(x ir.Node) ir.Node {
 	case *ir.Attribute:
 		clone := *x
 		if x.Name != nil {
-			clone.Name = NodeClone(x.Name).(*ir.Identifier)
+			clone.Name = NodeClone(x.Name)
 		}
 		clone.Args = NodeSliceClone(x.Args)
 		return &clone
+	case *ir.AttributeGroup:
+		clone := *x
+		{
+			sliceClone := make([]*ir.Attribute, len(x.Attrs))
+			for i := range x.Attrs {
+				sliceClone[i] = NodeClone(x.Attrs[i]).(*ir.Attribute)
+			}
+			clone.Attrs = sliceClone
+		}
+		return &clone
 	case *ir.BadString:
 		clone := *x
 		return &clone
@@ -305,9 +315,9 @@ func NodeClone(x ir.Node) ir.Node {
 	case *ir.ClassConstListStmt:
 		clone := *x
 		{
-			sliceClone := make([]*ir.Attribute, len(x.AttrGroups))
+			sliceClone := make([]*ir.AttributeGroup, len(x.AttrGroups))
 			for i := range x.AttrGroups {
-				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.Attribute)
+				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			clone.AttrGroups = sliceClone
 		}
@@ -333,9 +343,9 @@ func NodeClone(x ir.Node) ir.Node {
 	case *ir.ClassMethodStmt:
 		clone := *x
 		{
-			sliceClone := make([]*ir.Attribute, len(x.AttrGroups))
+			sliceClone := make([]*ir.AttributeGroup, len(x.AttrGroups))
 			for i := range x.AttrGroups {
-				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.Attribute)
+				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			clone.AttrGroups = sliceClone
 		}
@@ -360,9 +370,9 @@ func NodeClone(x ir.Node) ir.Node {
 	case *ir.ClassStmt:
 		clone := *x
 		{
-			sliceClone := make([]*ir.Attribute, len(x.AttrGroups))
+			sliceClone := make([]*ir.AttributeGroup, len(x.AttrGroups))
 			for i := range x.AttrGroups {
-				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.Attribute)
+				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			clone.AttrGroups = sliceClone
 		}
@@ -390,9 +400,9 @@ func NodeClone(x ir.Node) ir.Node {
 	case *ir.ClosureExpr:
 		clone := *x
 		{
-			sliceClone := make([]*ir.Attribute, len(x.AttrGroups))
+			sliceClone := make([]*ir.AttributeGroup, len(x.AttrGroups))
 			for i := range x.AttrGroups {
-				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.Attribute)
+				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			clone.AttrGroups = sliceClone
 		}
@@ -587,9 +597,9 @@ func NodeClone(x ir.Node) ir.Node {
 	case *ir.FunctionStmt:
 		clone := *x
 		{
-			sliceClone := make([]*ir.Attribute, len(x.AttrGroups))
+			sliceClone := make([]*ir.AttributeGroup, len(x.AttrGroups))
 			for i := range x.AttrGroups {
-				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.Attribute)
+				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			clone.AttrGroups = sliceClone
 		}
@@ -697,9 +707,9 @@ func NodeClone(x ir.Node) ir.Node {
 	case *ir.InterfaceStmt:
 		clone := *x
 		{
-			sliceClone := make([]*ir.Attribute, len(x.AttrGroups))
+			sliceClone := make([]*ir.AttributeGroup, len(x.AttrGroups))
 			for i := range x.AttrGroups {
-				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.Attribute)
+				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			clone.AttrGroups = sliceClone
 		}
@@ -887,9 +897,9 @@ func NodeClone(x ir.Node) ir.Node {
 	case *ir.Parameter:
 		clone := *x
 		{
-			sliceClone := make([]*ir.Attribute, len(x.AttrGroups))
+			sliceClone := make([]*ir.AttributeGroup, len(x.AttrGroups))
 			for i := range x.AttrGroups {
-				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.Attribute)
+				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			clone.AttrGroups = sliceClone
 		}
@@ -976,9 +986,9 @@ func NodeClone(x ir.Node) ir.Node {
 	case *ir.PropertyListStmt:
 		clone := *x
 		{
-			sliceClone := make([]*ir.Attribute, len(x.AttrGroups))
+			sliceClone := make([]*ir.AttributeGroup, len(x.AttrGroups))
 			for i := range x.AttrGroups {
-				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.Attribute)
+				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			clone.AttrGroups = sliceClone
 		}
@@ -1157,9 +1167,9 @@ func NodeClone(x ir.Node) ir.Node {
 	case *ir.TraitStmt:
 		clone := *x
 		{
-			sliceClone := make([]*ir.Attribute, len(x.AttrGroups))
+			sliceClone := make([]*ir.AttributeGroup, len(x.AttrGroups))
 			for i := range x.AttrGroups {
-				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.Attribute)
+				sliceClone[i] = NodeClone(x.AttrGroups[i]).(*ir.AttributeGroup)
 			}
 			clone.AttrGroups = sliceClone
 		}
diff --git a/src/ir/irutil/equal.go b/src/ir/irutil/equal.go
index 53dd2b4..1f4b4c8 100644
--- a/src/ir/irutil/equal.go
+++ b/src/ir/irutil/equal.go
@@ -312,6 +312,20 @@ func NodeEqual(x, y ir.Node) bool {
 			return false
 		}
 		return true
+	case *ir.AttributeGroup:
+		y, ok := y.(*ir.AttributeGroup)
+		if !ok || x == nil || y == nil {
+			return x == y
+		}
+		if len(x.Attrs) != len(y.Attrs) {
+			return false
+		}
+		for i := range x.Attrs {
+			if !NodeEqual(x.Attrs[i], y.Attrs[i]) {
+				return false
+			}
+		}
+		return true
 	case *ir.BadString:
 		y, ok := y.(*ir.BadString)
 		if !ok || x == nil || y == nil {
diff --git a/src/ir/iterate.go b/src/ir/iterate.go
index b3c1c87..4971f52 100644
--- a/src/ir/iterate.go
+++ b/src/ir/iterate.go
@@ -211,6 +211,20 @@ func (n *Attribute) IterateTokens(cb func(*token.Token) bool) {
 	}
 }
 
+func (n *AttributeGroup) IterateTokens(cb func(*token.Token) bool) {
+	if !traverseToken(n.OpenAttributeTkn, cb) {
+		return
+	}
+	for _, tk := range n.SeparatorTkns {
+		if !traverseToken(tk, cb) {
+			return
+		}
+	}
+	if !traverseToken(n.CloseAttributeTkn, cb) {
+		return
+	}
+}
+
 func (n *BadString) IterateTokens(cb func(*token.Token) bool) {
 }
 
diff --git a/src/ir/node_types.go b/src/ir/node_types.go
index c05b2b6..9e31c15 100644
--- a/src/ir/node_types.go
+++ b/src/ir/node_types.go
@@ -417,7 +417,7 @@ type ArrayItemExpr struct {
 // $ReturnType is optional, without it we have `fn($Params...) => $Expr` syntax.
 type ArrowFunctionExpr struct {
 	Position            *position.Position
-	AttrGroups          []*Attribute
+	AttrGroups          []*AttributeGroup
 	StaticTkn           *token.Token
 	FnTkn               *token.Token
 	AmpersandTkn        *token.Token
@@ -471,7 +471,7 @@ type CloneExpr struct {
 // $ClosureUse is optional, without it we have `function($Params...) : $ReturnType { $Stmts... }` syntax.
 type ClosureExpr struct {
 	Position             *position.Position
-	AttrGroups           []*Attribute
+	AttrGroups           []*AttributeGroup
 	StaticTkn            *token.Token
 	FunctionTkn          *token.Token
 	AmpersandTkn         *token.Token
@@ -804,7 +804,7 @@ type Nullable struct {
 // If $Variadic is true, it's `...$Variable`.
 type Parameter struct {
 	Position     *position.Position
-	AttrGroups   []*Attribute
+	AttrGroups   []*AttributeGroup
 	Modifiers    []*Identifier
 	VariableType Node
 	AmpersandTkn *token.Token
@@ -944,7 +944,7 @@ type CatchStmt struct {
 // $Modifiers consist of identifiers like `final` and `abstract`.
 type ClassStmt struct {
 	Position             *position.Position
-	AttrGroups           []*Attribute
+	AttrGroups           []*AttributeGroup
 	Modifiers            []*Identifier
 	ClassTkn             *token.Token
 	ClassName            *Identifier
@@ -958,7 +958,7 @@ type ClassStmt struct {
 // Every element in $Consts is a *ConstantStmt.
 type ClassConstListStmt struct {
 	Position      *position.Position
-	AttrGroups    []*Attribute
+	AttrGroups    []*AttributeGroup
 	Modifiers     []*Identifier
 	ConstTkn      *token.Token
 	Consts        []Node
@@ -987,7 +987,7 @@ type ClassImplementsStmt struct {
 // ClassMethodStmt is a class method declaration.
 type ClassMethodStmt struct {
 	Position            *position.Position
-	AttrGroups          []*Attribute
+	AttrGroups          []*AttributeGroup
 	Modifiers           []*Identifier
 	FunctionTkn         *token.Token
 	AmpersandTkn        *token.Token
@@ -1169,7 +1169,7 @@ type ForeachStmt struct {
 // FunctionStmt is a named function declaration.
 type FunctionStmt struct {
 	Position             *position.Position
-	AttrGroups           []*Attribute
+	AttrGroups           []*AttributeGroup
 	FunctionTkn          *token.Token
 	AmpersandTkn         *token.Token
 	FunctionName         *Identifier
@@ -1260,7 +1260,7 @@ type InlineHTMLStmt struct {
 // InterfaceStmt is an interface declaration.
 type InterfaceStmt struct {
 	Position             *position.Position
-	AttrGroups           []*Attribute
+	AttrGroups           []*AttributeGroup
 	InterfaceTkn         *token.Token
 	InterfaceName        *Identifier
 	Extends              *InterfaceExtendsStmt
@@ -1325,7 +1325,7 @@ type PropertyStmt struct {
 // Every element in $Properties is a *PropertyStmt.
 type PropertyListStmt struct {
 	Position      *position.Position
-	AttrGroups    []*Attribute
+	AttrGroups    []*AttributeGroup
 	Modifiers     []*Identifier
 	Type          Node
 	Properties    []Node
@@ -1400,7 +1400,7 @@ type ThrowStmt struct {
 // TraitStmt is a trait declaration.
 type TraitStmt struct {
 	Position             *position.Position
-	AttrGroups           []*Attribute
+	AttrGroups           []*AttributeGroup
 	TraitTkn             *token.Token
 	TraitName            *Identifier
 	OpenCurlyBracketTkn  *token.Token
@@ -1516,16 +1516,25 @@ type Union struct {
 	SeparatorTkns []*token.Token
 }
 
-// Attribute node is a `#[$Name($Args)]`
+// Attribute node is a `$Name($Args)` inside `#[...]`
 type Attribute struct {
 	Position            *position.Position
-	Name                *Identifier
+	Name                Node
 	OpenParenthesisTkn  *token.Token
 	Args                []Node
 	SeparatorTkns       []*token.Token
 	CloseParenthesisTkn *token.Token
 }
 
+// AttributeGroup node is #[$Attrs]
+type AttributeGroup struct {
+	Position          *position.Position
+	OpenAttributeTkn  *token.Token
+	Attrs             []*Attribute
+	SeparatorTkns     []*token.Token
+	CloseAttributeTkn *token.Token
+}
+
 // NullsafeMethodCallExpr node is a `$Var?->$Method($Args)`
 type NullsafeMethodCallExpr struct {
 	Position             *position.Position
diff --git a/src/ir/walk.go b/src/ir/walk.go
index 1dea8e4..f65aad3 100644
--- a/src/ir/walk.go
+++ b/src/ir/walk.go
@@ -296,6 +296,16 @@ func (n *Attribute) Walk(v Visitor) {
 	v.LeaveNode(n)
 }
 
+func (n *AttributeGroup) Walk(v Visitor) {
+	if !v.EnterNode(n) {
+		return
+	}
+	for _, nn := range n.Attrs {
+		nn.Walk(v)
+	}
+	v.LeaveNode(n)
+}
+
 func (n *BadString) Walk(v Visitor) {
 	if !v.EnterNode(n) {
 		return
diff --git a/src/linter/conf.go b/src/linter/conf.go
index 11df1da..52aaa45 100644
--- a/src/linter/conf.go
+++ b/src/linter/conf.go
@@ -66,11 +66,13 @@ type Config struct {
 	PhpVersion *version.Version
 }
 
-func NewConfig() *Config {
+func NewConfig(ver string) *Config {
 	reg := &CheckersRegistry{
 		info: map[string]CheckerInfo{},
 	}
-	phpVersion, _ := version.New("7.4")
+
+	phpVersion, _ := version.New(ver)
+
 	addBuiltinCheckers(reg)
 	return &Config{
 		SrcInput:       inputs.NewDefaultSourceInput(),


Test patch:
diff --git a/src/ir/normalize/normalize_test.go b/src/ir/normalize/normalize_test.go
index 1bc9990..9e3cf8d 100644
--- a/src/ir/normalize/normalize_test.go
+++ b/src/ir/normalize/normalize_test.go
@@ -73,7 +73,7 @@ func TestNormalizeStmtList(t *testing.T) {
 	}
 
 	conf := Config{}
-	l := linter.NewLinter(linter.NewConfig())
+	l := linter.NewLinter(linter.NewConfig("8.1"))
 	st := &meta.ClassParseState{Info: l.MetaInfo(), CurrentClass: `\Foo`}
 
 	for _, test := range tests {
@@ -145,7 +145,7 @@ func runNormalizeTests(t *testing.T, l *linter.Linter, tests []normalizationTest
 }
 
 func TestNormalizeExpr(t *testing.T) {
-	l := linter.NewLinter(linter.NewConfig())
+	l := linter.NewLinter(linter.NewConfig("8.1"))
 	runNormalizeTests(t, l, []normalizationTest{
 		{`new T`, `new T()`},
 
@@ -221,7 +221,7 @@ func TestNormalizeExpr(t *testing.T) {
 }
 
 func TestNormalizeExprAfterIndexing(t *testing.T) {
-	l := linter.NewLinter(linter.NewConfig())
+	l := linter.NewLinter(linter.NewConfig("8.1"))
 	linttest.ParseTestFile(t, l, "defs.php", `<?php
 const ZERO = 0;
 const HELLO_WORLD = 'hello, world';
@@ -242,7 +242,7 @@ class Foo {
 }
 
 func TestMagicConstFold(t *testing.T) {
-	l := linter.NewLinter(linter.NewConfig())
+	l := linter.NewLinter(linter.NewConfig("8.1"))
 	linttest.ParseTestFile(t, l, "files/file.php", `<?php
 namespace Boo;
 
diff --git a/src/linter/cache_test.go b/src/linter/cache_test.go
index 3b1fd20..7e01fd5 100644
--- a/src/linter/cache_test.go
+++ b/src/linter/cache_test.go
@@ -118,7 +118,7 @@ class ByNull {
 main();
 `
 
-	l := NewLinter(NewConfig())
+	l := NewLinter(NewConfig("8.1"))
 
 	runTest := func(iteration int) {
 		result, err := parseContents(l, "cachetest.php", []byte(code), nil)
diff --git a/src/linter/phpdoc_test.go b/src/linter/phpdoc_test.go
index 9dad67e..0e33099 100644
--- a/src/linter/phpdoc_test.go
+++ b/src/linter/phpdoc_test.go
@@ -60,7 +60,7 @@ func TestParseClassPHPDoc(t *testing.T) {
 		},
 	}
 
-	l := NewLinter(NewConfig())
+	l := NewLinter(NewConfig("8.1"))
 	st := &meta.ClassParseState{Info: l.MetaInfo()}
 	walker := rootWalker{ctx: newRootContext(l.config, NewWorkerContext(), st)}
 	for _, test := range tests {
@@ -113,7 +113,7 @@ func TestParseClassPHPDoc(t *testing.T) {
 }
 
 func BenchmarkParseTypes(b *testing.B) {
-	l := NewLinter(NewConfig())
+	l := NewLinter(NewConfig("8.1"))
 	st := &meta.ClassParseState{}
 	ctx := newRootContext(l.config, NewWorkerContext(), st)
 	typeString := `?x|array<int>|T[]`
diff --git a/src/linttest/inline.go b/src/linttest/inline.go
index e2eb836..0655a78 100644
--- a/src/linttest/inline.go
+++ b/src/linttest/inline.go
@@ -119,7 +119,7 @@ func (s *inlineTestSuite) compare(expects []string, reports []string) (unmatched
 
 // handleFileContents reads, parses the resulting file, and splits it into lines.
 func (s *inlineTestSuite) handleFileContents(file string) (lines []string, reports []*linter.Report, err error) {
-	lint := linter.NewLinter(linter.NewConfig())
+	lint := linter.NewLinter(linter.NewConfig("8.1"))
 
 	err = cmd.LoadEmbeddedStubs(lint, defaultStubs)
 	if err != nil {
diff --git a/src/linttest/linttest.go b/src/linttest/linttest.go
index e97a508..d62d707 100644
--- a/src/linttest/linttest.go
+++ b/src/linttest/linttest.go
@@ -93,9 +93,19 @@ type Suite struct {
 	linter *linter.Linter
 }
 
-// NewSuite returns a new linter test suite for t.
+// NewSuite returns a new linter test suite for t with PHP 8.1.
 func NewSuite(t testing.TB) *Suite {
-	conf := linter.NewConfig()
+	return newSuite(t, "8.1")
+}
+
+// NewPHP7Suite returns a new linter test suite for t with PHP 7.4.
+func NewPHP7Suite(t testing.TB) *Suite {
+	return newSuite(t, "7.4")
+}
+
+// newSuite returns a new linter test suite for t.
+func newSuite(t testing.TB, ver string) *Suite {
+	conf := linter.NewConfig(ver)
 	return &Suite{
 		t: t,
 		defaultStubs: map[string]struct{}{
diff --git a/src/tests/bench/bench_test.go b/src/tests/bench/bench_test.go
index 75b8178..a8bf89f 100644
--- a/src/tests/bench/bench_test.go
+++ b/src/tests/bench/bench_test.go
@@ -12,7 +12,7 @@ import (
 )
 
 func BenchmarkExprType(b *testing.B) {
-	l := linter.NewLinter(linter.NewConfig())
+	l := linter.NewLinter(linter.NewConfig("8.1"))
 	linttest.ParseTestFile(b, l, "file.php", `<?php
 	/** @var int[] $xs */
 	$xs = [1, 2, 3];
diff --git a/src/tests/checkers/basic_test.go b/src/tests/checkers/basic_test.go
index 6361e28..ec77336 100644
--- a/src/tests/checkers/basic_test.go
+++ b/src/tests/checkers/basic_test.go
@@ -801,7 +801,7 @@ func TestCustomUnusedVarRegex(t *testing.T) {
 		return strings.HasPrefix(s, "_")
 	}
 
-	config := linter.NewConfig()
+	config := linter.NewConfig("8.1")
 	config.IsDiscardVar = isDiscardVar
 
 	test := linttest.NewSuite(t)
@@ -2212,7 +2212,7 @@ function f() {
 }
 
 func TestRealCastingAndIsRealCall(t *testing.T) {
-	test := linttest.NewSuite(t)
+	test := linttest.NewPHP7Suite(t)
 	test.AddFile(`<?php
 function is_real($a): bool { return true; }
 
diff --git a/src/tests/custom/path_test.go b/src/tests/custom/path_test.go
index 584b79e..7f71093 100644
--- a/src/tests/custom/path_test.go
+++ b/src/tests/custom/path_test.go
@@ -11,7 +11,7 @@ import (
 
 func runPathTest(t *testing.T, suite *linttest.Suite) {
 	t.Helper()
-	config := linter.NewConfig()
+	config := linter.NewConfig("8.1")
 	config.Checkers.AddBlockChecker(func(ctx *linter.BlockContext) linter.BlockChecker {
 		return &pathTester{ctx: ctx}
 	})
diff --git a/src/tests/exprtype/exprtype_test.go b/src/tests/exprtype/exprtype_test.go
index d36a44d..8ad7868 100644
--- a/src/tests/exprtype/exprtype_test.go
+++ b/src/tests/exprtype/exprtype_test.go
@@ -1356,7 +1356,7 @@ function test() {
   exprtype(1 > 4, 'precise bool');
 }
 `
-	runExprTypeTest(t, &exprTypeTestParams{code: code})
+	runExprTypeTest(t, &exprTypeTestParams{code: code, php7: true})
 }
 
 func TestExprTypeKeyword(t *testing.T) {
@@ -3302,7 +3302,10 @@ func runExprTypeTest(t *testing.T, params *exprTypeTestParams) {
 }
 
 func exprTypeTestImpl(t *testing.T, params *exprTypeTestParams, kphp bool) {
-	config := linter.NewConfig()
+	config := linter.NewConfig("8.1")
+	if params.php7 {
+		config = linter.NewConfig("7.4")
+	}
 	config.Checkers.AddBlockChecker(func(ctx *linter.BlockContext) linter.BlockChecker {
 		return &exprTypeCollector{ctx: ctx}
 	})
@@ -3353,6 +3356,7 @@ func makeType(typ string) testTypesMap {
 type exprTypeTestParams struct {
 	code  string
 	stubs string
+	php7  bool
 }
 
 type exprTypeWalker struct {
diff --git a/src/tests/golden/golden_test.go b/src/tests/golden/golden_test.go
index c595b11..e2e1ada 100644
--- a/src/tests/golden/golden_test.go
+++ b/src/tests/golden/golden_test.go
@@ -197,7 +197,7 @@ func TestGolden(t *testing.T) {
 
 	e2eSuite := linttest.NewGoldenE2ETestSuite(t)
 
-	var linterConfig = linter.NewConfig()
+	linterConfig := linter.NewConfig("8.1")
 	err := linttest.InitEmbeddedRules(linterConfig)
 	if err != nil {
 		t.Fatal(err)
diff --git a/src/tests/golden/quickfix_test.go b/src/tests/golden/quickfix_test.go
index 56dccc6..aa196ee 100644
--- a/src/tests/golden/quickfix_test.go
+++ b/src/tests/golden/quickfix_test.go
@@ -49,7 +49,7 @@ func openFile(filename string) (f *os.File, found bool, err error) {
 }
 
 func (t *quickFixTest) runQuickFixTest() {
-	var linterConfig = linter.NewConfig()
+	linterConfig := linter.NewConfig("8.1")
 	linterConfig.ApplyQuickFixes = true
 	err := linttest.InitEmbeddedRules(linterConfig)
 	if err != nil {