Repository: slevomat/coding-standard (php)

Issue:
Sniff request:  Arrays.SortedByKey
Sorted arrays greatly help with maintainability / reducing merge conflicts

    $keyedArray = array(
        'foo' => 'hello world',
        'bar' => 'some value',
    );

should be 

    $keyedArray = array(
        'bar' => 'some value',
        'foo' => 'hello world',
    );

Sniff would not apply to non-keyed arrays

    $thisIsFine = array(
        'some value',
        'hello world',
    );

Other sort related sniffs

* [SlevomatCodingStandard.Attributes.AttributesOrder](https://github.com/slevomat/coding-standard/blob/master/doc/attributes.md#slevomatcodingstandardattributesattributesorder-)
* [SlevomatCodingStandard.Namespaces.AlphabeticallySortedUses](https://github.com/slevomat/coding-standard/blob/master/doc/namespaces.md#slevomatcodingstandardnamespacesalphabeticallysorteduses-)
* [SlevomatCodingStandard.Classes.ClassStructure](https://github.com/slevomat/coding-standard/blob/master/doc/classes.md#slevomatcodingstandardclassesclassstructure-)


Thoughts?

Pull request:
Resolves #1499 - Arrays.SortedSniff
#1499 

Adds  
* SlevomatCodingStandard\Sniffs\Arrays\ ~~SortedSniff~~ AlphabeticallySortedKeysSniff
* SlevomatCodingStandard\Helpers\ArrayHelper
* SlevomatCodingStandard\Helpers\ArrayKeyValue - represents key/value pair and surrounding comments

Patch:
diff --git a/README.md b/README.md
index 7395cbbf..468b0022 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ Slevomat Coding Standard for [PHP_CodeSniffer](https://github.com/squizlabs/PHP_
 
 🚧 = [Sniff check can be suppressed locally](#suppressing-sniffs-locally)
 
+ - [SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys](doc/arrays.md#slevomatcodingstandardarrayalphabeticallysortedbykeys) 🔧
  - [SlevomatCodingStandard.Arrays.DisallowImplicitArrayCreation](doc/arrays.md#slevomatcodingstandardarraysdisallowimplicitarraycreation)
  - [SlevomatCodingStandard.Arrays.MultiLineArrayEndBracketPlacement](doc/arrays.md#slevomatcodingstandardarraysmultilinearrayendbracketplacement-) 🔧
  - [SlevomatCodingStandard.Arrays.SingleLineArrayWhitespace](doc/arrays.md#slevomatcodingstandardarrayssinglelinearraywhitespace-) 🔧
diff --git a/SlevomatCodingStandard/Helpers/ArrayHelper.php b/SlevomatCodingStandard/Helpers/ArrayHelper.php
new file mode 100644
index 00000000..e2def896
--- /dev/null
+++ b/SlevomatCodingStandard/Helpers/ArrayHelper.php
@@ -0,0 +1,247 @@
+<?php declare(strict_types = 1);
+
+namespace SlevomatCodingStandard\Helpers;
+
+use PHP_CodeSniffer\Files\File;
+use function arsort;
+use function end;
+use function in_array;
+use function key;
+use function min;
+use function strlen;
+use function strnatcasecmp;
+use function strpos;
+use const T_COMMA;
+use const T_OPEN_SHORT_ARRAY;
+use const T_WHITESPACE;
+
+/**
+ * @internal
+ */
+class ArrayHelper
+{
+
+	/** @var array<int, array<string, array<int, int|string>|int|string>> */
+	protected static $tokens;
+
+	/**
+	 * @return ArrayKeyValue[]
+	 */
+	public static function parse(File $phpcsFile, int $pointer): array
+	{
+		self::$tokens = $phpcsFile->getTokens();
+		$token = self::$tokens[$pointer];
+		[$pointerOpener, $pointerCloser] = self::openClosePointers($token);
+		$tokenOpener = self::$tokens[$pointerOpener];
+		$lineIndents = [''];
+		$keyValues = [];
+		$skipUntilPointer = null;
+		$tokenEnd = null;
+		$pointer = $pointerOpener + 1;
+		for (; $pointer < $pointerCloser; $pointer++) {
+			$token = self::$tokens[$pointer];
+			if ($token['line'] > $tokenOpener['line'] || in_array($token['code'], TokenHelper::$ineffectiveTokenCodes, true) === false) {
+				break;
+			}
+		}
+		$pointerStart = $pointer;
+
+		for (; $pointer < $pointerCloser; $pointer++) {
+			if ($pointer < $skipUntilPointer) {
+				continue;
+			}
+
+			$token = self::$tokens[$pointer];
+
+			if (in_array($token['code'], TokenHelper::$arrayTokenCodes, true)) {
+				$pointerCloserWalk = self::openClosePointers($token)[1];
+				$skipUntilPointer = $pointerCloserWalk;
+				continue;
+			}
+			if (isset($token['scope_closer']) && $token['scope_closer'] > $pointer) {
+				$skipUntilPointer = $token['scope_closer'];
+				continue;
+			}
+			if (isset($token['parenthesis_closer'])) {
+				$skipUntilPointer = $token['parenthesis_closer'];
+				continue;
+			}
+			$nextEffective = in_array($token['code'], TokenHelper::$ineffectiveTokenCodes, true)
+				? TokenHelper::findNextEffective($phpcsFile, $pointer)
+				: TokenHelper::findNextEffective($phpcsFile, $pointer + 1);
+			if (
+				isset($lineIndents[$token['line']]) === false
+				&& in_array($token['code'], TokenHelper::$ineffectiveTokenCodes, true) === false
+			) {
+				$firstPointerOnLine = TokenHelper::findFirstTokenOnLine($phpcsFile, $pointer);
+				$firstEffective = TokenHelper::findNextEffective($phpcsFile, $firstPointerOnLine);
+				$lineIndents[$token['line']] = TokenHelper::getContent($phpcsFile, $firstPointerOnLine, $firstEffective - 1);
+			}
+
+			$startNewKeyValue = $tokenEnd !== null
+				? self::parseTestStartKeyVal($pointer, $tokenEnd, end($lineIndents))
+				: false;
+			if ($startNewKeyValue) {
+				if ($nextEffective === $pointerCloser && in_array($token['code'], TokenHelper::$ineffectiveTokenCodes, true)) {
+					// there are no more key/values
+					$firstPointerOnLine = TokenHelper::findFirstTokenOnLine($phpcsFile, $pointer);
+					if ($pointer === $firstPointerOnLine) {
+						// end last key/value on the prev token
+						$pointer--;
+					}
+					break;
+				}
+				$startNewKeyValue = false;
+				$tokenEnd = null;
+				$keyValues[] = new ArrayKeyValue($phpcsFile, $pointerStart, $pointer - 1);
+				$pointerStart = $pointer;
+			}
+			if ($token['code'] === T_COMMA || $tokenEnd !== null) {
+				$tokenEnd = $token;
+			}
+		}
+
+		$pointer = min($pointer, $pointerCloser - 1);
+		$keyValues[] = new ArrayKeyValue($phpcsFile, $pointerStart, $pointer);
+
+		self::$tokens = [];
+		return $keyValues;
+	}
+
+	/**
+	 * @param ArrayKeyValue[] $keyValues
+	 */
+	public static function getIndentation(array $keyValues): ?string
+	{
+		$indents = [];
+		foreach ($keyValues as $keyValue) {
+			$indent = $keyValue->getIndent() ?? 'null';
+			$indents[$indent] = isset($indents[$indent])
+				? $indents[$indent] + 1
+				: 1;
+		}
+		arsort($indents);
+		$indent = key($indents);
+		return $indent !== 'null'
+			? (string) $indent
+			: null;
+	}
+
+	/**
+	 * @param ArrayKeyValue[] $keyValues
+	 */
+	public static function isKeyed(array $keyValues): bool
+	{
+		foreach ($keyValues as $keyValue) {
+			if ($keyValue->getKey() !== null) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * @param ArrayKeyValue[] $keyValues
+	 */
+	public static function isKeyedAll(array $keyValues): bool
+	{
+		foreach ($keyValues as $keyValue) {
+			if ($keyValue->getKey() === null) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+	/**
+	 * Test if non-empty array with opening & closing brackets on separate lines
+	 */
+	public static function isMultiLine(File $phpcsFile, int $pointer): bool
+	{
+		$tokens = $phpcsFile->getTokens();
+		$token = $tokens[$pointer];
+		[$pointerOpener, $pointerCloser] = self::openClosePointers($token);
+		$tokenOpener = $tokens[$pointerOpener];
+		$tokenCloser = $tokens[$pointerCloser];
+
+		/** @var int $pointerPreviousToClose */
+		$pointerPreviousToClose = TokenHelper::findPreviousEffective($phpcsFile, $pointerCloser - 1);
+
+		return $tokenOpener['line'] !== $tokenCloser['line']
+			&& $pointerPreviousToClose !== $pointerOpener;
+	}
+
+	/**
+	 * @param ArrayKeyValue[] $keyValues
+	 */
+	public static function isSortedByKey(array $keyValues): bool
+	{
+		$prev = '';
+		foreach ($keyValues as $keyValue) {
+			$cmp = strnatcasecmp((string) $prev, (string) $keyValue->getKey());
+			if ($cmp === 1) {
+				return false;
+			}
+			$prev = $keyValue->getKey();
+		}
+		return true;
+	}
+
+	/**
+	 * @param array<string, array<int, int|string>|int|string> $token
+	 * @return int[]
+	 */
+	public static function openClosePointers(array $token): array
+	{
+		$isShortArray = $token['code'] === T_OPEN_SHORT_ARRAY;
+		$pointerOpener = $isShortArray
+			? $token['bracket_opener']
+			: $token['parenthesis_opener'];
+		$pointerCloser = $isShortArray
+			? $token['bracket_closer']
+			: $token['parenthesis_closer'];
+		return [(int) $pointerOpener, (int) $pointerCloser];
+	}
+
+	/**
+	 * Test whether we should begin collecting the next key/value tokens
+	 *
+	 * @param array<string, array<int, int|string>|int|string> $tokenPrev
+	 */
+	private static function parseTestStartKeyVal(int $pointer, array $tokenPrev, string $lastIndent): bool
+	{
+		$token = self::$tokens[$pointer];
+
+		// token['column'] cannot be relied on if tabs are being used
+		//   this simply checks if indent contains tab and is the same as the prev indent plus additional
+		$testTabIndent = static function ($indent, $indentPrev) {
+			return strpos($indent, "\t") !== false
+				&& strpos($indent, $indentPrev) === 0
+				&& strlen($indent) > strlen($indentPrev);
+		};
+
+		$startNew = true;
+
+		if (
+			in_array($token['code'], TokenHelper::$ineffectiveTokenCodes, true)
+			&& $token['line'] === $tokenPrev['line']
+		) {
+			// we're whitespace or comment after the value...
+			$startNew = false;
+		} elseif (
+			$token['code'] === T_WHITESPACE
+			&& in_array($tokenPrev['code'], TokenHelper::$inlineCommentTokenCodes, true)
+			&& in_array(self::$tokens[$pointer + 1]['code'], TokenHelper::$inlineCommentTokenCodes, true)
+			&& (self::$tokens[$pointer + 1]['column'] >= $tokenPrev['column']
+				|| $testTabIndent($token['content'], $lastIndent)
+			)
+		) {
+			// 'key' => 'value' // tokenPrev is this comment
+			//                  // we're in the preceeding whitespace
+			$startNew = false;
+		}
+
+		return $startNew;
+	}
+
+}
diff --git a/SlevomatCodingStandard/Helpers/ArrayKeyValue.php b/SlevomatCodingStandard/Helpers/ArrayKeyValue.php
new file mode 100644
index 00000000..7a9c4a1a
--- /dev/null
+++ b/SlevomatCodingStandard/Helpers/ArrayKeyValue.php
@@ -0,0 +1,147 @@
+<?php declare(strict_types = 1);
+
+namespace SlevomatCodingStandard\Helpers;
+
+use PHP_CodeSniffer\Files\File;
+use function in_array;
+use function ltrim;
+use function rtrim;
+use function strpos;
+use function trim;
+use const T_COMMA;
+use const T_DOUBLE_ARROW;
+use const T_WHITESPACE;
+
+/**
+ * @internal
+ */
+class ArrayKeyValue
+{
+
+	/** @var int */
+	private $pointerStart;
+
+	/** @var int */
+	private $pointerEnd;
+
+	/** @var ?string */
+	private $indent = null;
+
+	/** @var ?string */
+	private $key = null;
+
+	/** @var ?int */
+	private $pointerArrow = null;
+
+	/** @var ?int */
+	private $pointerComma = null;
+
+	public function __construct(File $phpcsFile, int $pointerStart, int $pointerEnd)
+	{
+		$this->pointerStart = $pointerStart;
+		$this->pointerEnd = $pointerEnd;
+		$this->addValues($phpcsFile);
+	}
+
+	public function getContent(File $phpcsFile, bool $normalize = false, ?string $indent = null): string
+	{
+		if ($normalize === false) {
+			return TokenHelper::getContent($phpcsFile, $this->pointerStart, $this->pointerEnd);
+		}
+		$content = '';
+		$addCommaPtr = $this->pointerComma === null
+			? TokenHelper::findPreviousEffective($phpcsFile, $this->pointerEnd)
+			: null;
+		$tokens = $phpcsFile->getTokens();
+		for ($pointer = $this->pointerStart; $pointer <= $this->pointerEnd; $pointer++) {
+			$token = $tokens[$pointer];
+			$content .= $token['content'];
+			if ($pointer === $addCommaPtr) {
+				$content .= ',';
+			}
+		}
+
+		// Trim, but keep leading empty lines
+		$content = ltrim($content, " \t");
+		$content = rtrim($content);
+
+		if ($indent !== null && strpos($content, $phpcsFile->eolChar) !== 0) {
+			$content = $indent . $content;
+		}
+
+		return $content;
+	}
+
+	public function getIndent(): ?string
+	{
+		return $this->indent;
+	}
+
+	public function getKey(): ?string
+	{
+		return $this->key;
+	}
+
+	public function getPointerArrow(): ?int
+	{
+		return $this->pointerArrow;
+	}
+
+	public function getPointerComma(): ?int
+	{
+		return $this->pointerComma;
+	}
+
+	public function getPointerEnd(): int
+	{
+		return $this->pointerEnd;
+	}
+
+	public function getPointerStart(): int
+	{
+		return $this->pointerStart;
+	}
+
+	private function addValues(File $phpcsFile): void
+	{
+		$key = '';
+		$tokens = $phpcsFile->getTokens();
+		$firstNonWhitespace = null;
+		$pointerCloser = null;
+		for ($pointer = $this->pointerStart; $pointer <= $this->pointerEnd; $pointer++) {
+			if ($pointer < $pointerCloser) {
+				continue;
+			}
+			$token = $tokens[$pointer];
+			if (in_array($token['code'], TokenHelper::$arrayTokenCodes, true)) {
+				$pointerCloser = ArrayHelper::openClosePointers($token)[1];
+			} elseif ($token['code'] === T_DOUBLE_ARROW) {
+				$this->pointerArrow = $pointer;
+			} elseif ($token['code'] === T_COMMA) {
+				$this->pointerComma = $pointer;
+			} elseif ($this->pointerArrow === null) {
+				if ($firstNonWhitespace === null && $token['code'] !== T_WHITESPACE) {
+					$firstNonWhitespace = $pointer;
+				}
+				if (in_array($token['code'], TokenHelper::$inlineCommentTokenCodes, true) === false) {
+					$key .= $token['content'];
+				}
+			}
+		}
+		$haveIndent = $firstNonWhitespace !== null && TokenHelper::findFirstNonWhitespaceOnLine(
+			$phpcsFile,
+			$firstNonWhitespace
+		) === $firstNonWhitespace;
+		$this->indent = $haveIndent
+			? TokenHelper::getContent(
+				$phpcsFile,
+				TokenHelper::findFirstTokenOnLine($phpcsFile, $firstNonWhitespace),
+				$firstNonWhitespace - 1
+			)
+			: null;
+		$this->key = $this->pointerArrow !== null
+			? trim($key)
+			: null;
+	}
+
+}
diff --git a/SlevomatCodingStandard/Helpers/TokenHelper.php b/SlevomatCodingStandard/Helpers/TokenHelper.php
index d412d495..53cdcde4 100644
--- a/SlevomatCodingStandard/Helpers/TokenHelper.php
+++ b/SlevomatCodingStandard/Helpers/TokenHelper.php
@@ -6,6 +6,7 @@ use PHP_CodeSniffer\Files\File;
 use function array_key_exists;
 use function array_merge;
 use function count;
+use const T_ARRAY;
 use const T_ARRAY_HINT;
 use const T_BREAK;
 use const T_CALLABLE;
@@ -31,6 +32,7 @@ use const T_NAME_QUALIFIED;
 use const T_NAME_RELATIVE;
 use const T_NS_SEPARATOR;
 use const T_NULL;
+use const T_OPEN_SHORT_ARRAY;
 use const T_PARENT;
 use const T_PHPCS_DISABLE;
 use const T_PHPCS_ENABLE;
@@ -59,6 +61,12 @@ use const T_WHITESPACE;
 class TokenHelper
 {
 
+	/** @var array<int, (int|string)> */
+	public static $arrayTokenCodes = [
+		T_ARRAY,
+		T_OPEN_SHORT_ARRAY,
+	];
+
 	/** @var array<int, (int|string)> */
 	public static $typeKeywordTokenCodes = [
 		T_CLASS,
diff --git a/SlevomatCodingStandard/Sniffs/Arrays/AlphabeticallySortedByKeysSniff.php b/SlevomatCodingStandard/Sniffs/Arrays/AlphabeticallySortedByKeysSniff.php
new file mode 100644
index 00000000..816d93ac
--- /dev/null
+++ b/SlevomatCodingStandard/Sniffs/Arrays/AlphabeticallySortedByKeysSniff.php
@@ -0,0 +1,86 @@
+<?php declare(strict_types = 1);
+
+namespace SlevomatCodingStandard\Sniffs\Arrays;
+
+use PHP_CodeSniffer\Files\File;
+use PHP_CodeSniffer\Sniffs\Sniff;
+use SlevomatCodingStandard\Helpers\ArrayHelper;
+use SlevomatCodingStandard\Helpers\ArrayKeyValue;
+use SlevomatCodingStandard\Helpers\FixerHelper;
+use SlevomatCodingStandard\Helpers\TokenHelper;
+use function array_map;
+use function count;
+use function implode;
+use function strnatcasecmp;
+use function usort;
+
+class AlphabeticallySortedByKeysSniff implements Sniff
+{
+
+	public const CODE_INCORRECT_KEY_ORDER = 'IncorrectKeyOrder';
+
+	/**
+	 * @return array<int, (int|string)>
+	 */
+	public function register(): array
+	{
+		return TokenHelper::$arrayTokenCodes;
+	}
+
+	/**
+	 * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint
+	 * @param int $stackPointer
+	 */
+	public function process(File $phpcsFile, $stackPointer): void
+	{
+		if (ArrayHelper::isMultiLine($phpcsFile, $stackPointer) === false) {
+			return;
+		}
+
+		// "Parse" the array... get info for each key/value pair
+		$keyValues = ArrayHelper::parse($phpcsFile, $stackPointer);
+
+		if (ArrayHelper::isKeyedAll($keyValues) === false) {
+			return;
+		}
+
+		if (ArrayHelper::isSortedByKey($keyValues)) {
+			return;
+		}
+
+		$fix = $phpcsFile->addFixableError(
+			'Keyed multi-line arrays must be sorted alphabetically.',
+			$stackPointer,
+			self::CODE_INCORRECT_KEY_ORDER
+		);
+		if ($fix) {
+			$this->fix($phpcsFile, $keyValues);
+		}
+	}
+
+	/**
+	 * @param ArrayKeyValue[] $keyValues
+	 */
+	private function fix(File $phpcsFile, array $keyValues): bool
+	{
+		$pointerStart = $keyValues[0]->getPointerStart();
+		$pointerEnd = $keyValues[count($keyValues) - 1]->getPointerEnd();
+
+		// determine indent to use
+		$indent = ArrayHelper::getIndentation($keyValues);
+
+		usort($keyValues, static function ($a1, $a2) {
+			return strnatcasecmp((string) $a1->getKey(), (string) $a2->getKey());
+		});
+
+		$content = implode('', array_map(static function (ArrayKeyValue $keyValue) use ($phpcsFile, $indent) {
+			return $keyValue->getContent($phpcsFile, true, $indent) . $phpcsFile->eolChar;
+		}, $keyValues));
+
+		$phpcsFile->fixer->beginChangeset();
+		FixerHelper::removeBetweenIncluding($phpcsFile, $pointerStart, $pointerEnd);
+		$phpcsFile->fixer->addContent($pointerStart, $content);
+		return $phpcsFile->fixer->endChangeset();
+	}
+
+}
diff --git a/doc/arrays.md b/doc/arrays.md
index 140a9263..bb5fe1e9 100644
--- a/doc/arrays.md
+++ b/doc/arrays.md
@@ -1,5 +1,13 @@
 ## Arrays
 
+#### SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys 🔧
+
+Arrays should be defined with keys in alphabetical order.
+It defines where new entries should be inserted.
+It reduces merge conflicts and duplicate entries.
+
+This sniff enforces natural sorting of array definitions by key in multi-line arrays.
+
 #### SlevomatCodingStandard.Arrays.DisallowImplicitArrayCreation
 
 Disallows implicit array creation.


Test patch:
diff --git a/tests/Helpers/ArrayHelperTest.php b/tests/Helpers/ArrayHelperTest.php
new file mode 100644
index 00000000..ae16f725
--- /dev/null
+++ b/tests/Helpers/ArrayHelperTest.php
@@ -0,0 +1,452 @@
+<?php declare(strict_types = 1);
+
+namespace SlevomatCodingStandard\Helpers;
+
+use Generator;
+use PHP_CodeSniffer\Files\File;
+use function array_key_exists;
+use function basename;
+use function in_array;
+use function sprintf;
+use function strpos;
+use function trim;
+
+class ArrayHelperTest extends TestCase
+{
+
+	/**
+	 * @dataProvider parseProvider
+	 * @param array<string, mixed> $expect flags and various expected values for the given array
+	 */
+	public function testParse(File $phpcsFile, int $pointer, array $expect): void
+	{
+		$parsed = ArrayHelper::parse($phpcsFile, $pointer);
+		$tokens = $phpcsFile->getTokens();
+
+		foreach ($parsed as $keyValue) {
+			/** @phpstan-var mixed $keyValue phpstan making assumption based on phpdoc */
+			self::assertInstanceOf(ArrayKeyValue::class, $keyValue);
+		}
+
+		if (isset($expect['count'])) {
+			self::assertCount((int) $expect['count'], $parsed);
+		}
+		foreach ((array) $expect['keyValues'] as $i => $keyValueInfoExpect) {
+			$keyValueInfoExpect = (array) $keyValueInfoExpect;
+			$keyValue = $parsed[$i];
+			if (array_key_exists('indent', $keyValueInfoExpect)) {
+				self::assertSame(
+					$keyValueInfoExpect['indent'],
+					$keyValue->getIndent(),
+					sprintf('indent not same: keyValue %s', $i)
+				);
+			}
+			if (array_key_exists('key', $keyValueInfoExpect)) {
+				self::assertSame(
+					$keyValueInfoExpect['key'],
+					$keyValue->getKey(),
+					sprintf('key not same: keyValue %s', $i)
+				);
+			}
+			if (array_key_exists('pointerArrow', $keyValueInfoExpect)) {
+				$actual = $keyValue->getPointerArrow();
+				$keyValueInfoExpect['pointerArrow'] === true
+					? self::assertSame(
+						'=>',
+						$tokens[$actual]['content'],
+						sprintf('pointerArrow does not point to "=>": keyValue %s', $i)
+					)
+					: self::assertNull(
+						$actual,
+						sprintf('pointerArrow not null: keyValue %s', $i)
+					);
+			}
+			if (array_key_exists('pointerComma', $keyValueInfoExpect)) {
+				$actual = $keyValue->getPointerComma();
+				$keyValueInfoExpect['pointerComma'] === true
+					? self::assertSame(
+						',',
+						$tokens[$actual]['content'],
+						sprintf('pointerComma does not point to ",": keyValue %s', $i)
+					)
+					: self::assertNull(
+						$actual,
+						sprintf('pointerComma not null: keyValue %s', $i)
+					);
+			}
+			if (isset($keyValueInfoExpect['content']) === false) {
+				continue;
+			}
+			self::assertSame(
+				$keyValueInfoExpect['content'],
+				$keyValue->getContent($phpcsFile),
+				sprintf('raw content not same: keyValue %s', $i)
+			);
+			// build expected normalized content... this works for our example data
+			$contentNormalizedExpect = trim($keyValueInfoExpect['content']);
+			if (strpos($contentNormalizedExpect, ',') === false) {
+				$contentNormalizedExpect .= ',';
+			}
+			self::assertSame(
+				$contentNormalizedExpect,
+				$keyValue->getContent($phpcsFile, true, null),
+				sprintf('normalized content not same: keyValue %s', $i)
+			);
+		}
+	}
+
+	/**
+	 * @dataProvider dataProvider
+	 * @param array<string, mixed> $expect flags and various expected values for the given array
+	 */
+	public function testGetIndentation(File $phpcsFile, int $pointer, array $expect): void
+	{
+		if (array_key_exists('indentation', $expect) === false) {
+			self::expectNotToPerformAssertions();
+			return;
+		}
+		$parsed = ArrayHelper::parse($phpcsFile, $pointer);
+		$indentation = ArrayHelper::getIndentation($parsed);
+		self::assertSame($expect['indentation'], $indentation);
+	}
+
+	/**
+	 * @dataProvider dataProvider
+	 * @param array<string, mixed> $expect flags and various expected values for the given array
+	 */
+	public function testIsKeyed(File $phpcsFile, int $pointer, array $expect): void
+	{
+		if (array_key_exists('flags', $expect) === false) {
+			self::expectNotToPerformAssertions();
+			return;
+		}
+		$parsed = ArrayHelper::parse($phpcsFile, $pointer);
+		$isKeyed = ArrayHelper::isKeyed($parsed);
+		self::assertSame(in_array('keyed', $expect['flags'], true), $isKeyed);
+	}
+
+	/**
+	 * @dataProvider dataProvider
+	 * @param array<string, mixed> $expect flags and various expected values for the given array
+	 */
+	public function testIsKeyedAll(File $phpcsFile, int $pointer, array $expect): void
+	{
+		if (array_key_exists('flags', $expect) === false) {
+			self::expectNotToPerformAssertions();
+			return;
+		}
+		$parsed = ArrayHelper::parse($phpcsFile, $pointer);
+		$isKeyedAll = ArrayHelper::isKeyedAll($parsed);
+		self::assertSame(in_array('keyedAll', $expect['flags'], true), $isKeyedAll);
+	}
+
+	/**
+	 * @dataProvider dataProvider
+	 * @param array<string, mixed> $expect flags and various expected values for the given array
+	 */
+	public function testIsMultiLine(File $phpcsFile, int $pointer, array $expect): void
+	{
+		if (array_key_exists('flags', $expect) === false) {
+			self::expectNotToPerformAssertions();
+			return;
+		}
+		$isKeyed = ArrayHelper::isMultiLine($phpcsFile, $pointer);
+		self::assertSame(in_array('multi', $expect['flags'], true), $isKeyed);
+	}
+
+	/**
+	 * @dataProvider dataProvider
+	 * @param array<string, mixed> $expect flags and various expected values for the given array
+	 */
+	public function testIsSortedByKey(File $phpcsFile, int $pointer, array $expect): void
+	{
+		if (array_key_exists('flags', $expect) === false) {
+			self::expectNotToPerformAssertions();
+			return;
+		}
+		if (in_array('keyed', $expect['flags'], true) === false) {
+			self::expectNotToPerformAssertions();
+			return;
+		}
+		$parsed = ArrayHelper::parse($phpcsFile, $pointer);
+		$isSortedByKey = ArrayHelper::isSortedByKey($parsed);
+		self::assertSame(in_array('sorted', $expect['flags'], true), $isSortedByKey);
+	}
+
+	/**
+	 * @dataProvider dataProvider
+	 * @param array<string, mixed> $expect flags and various expected values for the given array
+	 */
+	public function testOpenClosePointers(File $phpcsFile, int $pointer, array $expect): void
+	{
+		if (array_key_exists('flags', $expect) === false) {
+			self::expectNotToPerformAssertions();
+			return;
+		}
+		$tokens = $phpcsFile->getTokens();
+		$token = $tokens[$pointer];
+		[$pointerOpener, $pointerCloser] = ArrayHelper::openClosePointers($token);
+		$tokenOpener = $tokens[$pointerOpener];
+		$tokenCloser = $tokens[$pointerCloser];
+		$expect = in_array('short', $expect['flags'], true)
+			? ['[', ']']
+			: ['(', ')'];
+		self::assertSame($expect, [$tokenOpener['content'], $tokenCloser['content']]);
+	}
+
+	public function dataProvider(): Generator
+	{
+		$files = $this->files();
+		foreach ($files as $file => $fileArrays) {
+			$phpcsFile = $this->getCodeSnifferFile($file);
+			$arrayPointers = TokenHelper::findNextAll($phpcsFile, TokenHelper::$arrayTokenCodes, 0);
+			foreach ($fileArrays as $k => $arrayInfoExpect) {
+				$pointer = $arrayPointers[$k];
+
+				yield basename($file) . ':' . $k => [
+					$phpcsFile,
+					$pointer,
+					$arrayInfoExpect,
+				];
+			}
+		}
+	}
+
+	/**
+	 * Subset of files()... fileArrays containing keyValueInfo
+	 */
+	public function parseProvider(): Generator
+	{
+		$files = $this->files();
+		foreach ($files as $file => $fileArrays) {
+			$phpcsFile = $this->getCodeSnifferFile($file);
+			$arrayPointers = TokenHelper::findNextAll($phpcsFile, TokenHelper::$arrayTokenCodes, 0);
+			foreach ($fileArrays as $k => $arrayInfoExpect) {
+				if (isset($arrayInfoExpect['keyValues']) === false) {
+					continue;
+				}
+				$pointer = $arrayPointers[$k];
+
+				yield basename($file) . ':' . $k => [
+					$phpcsFile,
+					$pointer,
+					$arrayInfoExpect,
+				];
+			}
+		}
+	}
+
+	/**
+	 * @return array<string, array<int, array<string,
+	 *    array<int, array<string, bool|string|null>|string>|int|string|null>>>
+	 */
+	protected function files(): array
+	{
+		return [
+			__DIR__ . '/data/array/multiline1Keyed1Sorted1.php' => [
+				[
+					'flags' => ['keyed', 'keyedAll', 'multi', 'sorted'],
+					'indentation' => "\t",
+				],
+				[
+					'flags' => ['keyed', 'keyedAll', 'multi', 'sorted', 'short'],
+					'indentation' => "\t\t",
+				],
+				[
+					'flags' => ['keyed', 'keyedAll', 'multi', 'sorted', 'short'],
+					'indentation' => "\t",
+				],
+				[
+					'flags' => ['keyed', 'keyedAll', 'multi', 'sorted'],
+					'indentation' => "\t\t",
+				],
+			],
+			__DIR__ . '/data/array/multiline1Keyed1Sorted0.php' => [
+				[
+					'flags' => ['keyed', 'keyedAll', 'multi'],
+					'indentation' => "\t",
+				],
+				[
+					'flags' => ['keyed', 'keyedAll', 'multi', 'short'],
+					'indentation' => "\t\t",
+				],
+				[
+					'flags' => ['keyed', 'keyedAll', 'multi', 'short'],
+					'indentation' => "\t",
+				],
+				[
+					'flags' => ['keyed', 'keyedAll', 'multi'],
+					'indentation' => "\t\t",
+				],
+			],
+			__DIR__ . '/data/array/multiline0Keyed1Sorted1.php' => [
+				[
+					'flags' => ['keyed', 'keyedAll', 'sorted'],
+					'indentation' => null,
+				],
+				[
+					'flags' => ['keyed', 'keyedAll', 'sorted', 'short'],
+					'indentation' => null,
+				],
+			],
+			__DIR__ . '/data/array/multiline1Keyed0.php' => [
+				[
+					'flags' => ['multi'],
+					'indentation' => "\t",
+				],
+				[
+					'flags' => ['multi', 'short'],
+					'indentation' => "\t\t",
+				],
+				[
+					'flags' => ['multi', 'short'],
+					'indentation' => "\t",
+				],
+				[
+					'flags' => ['multi'],
+					'indentation' => "\t\t",
+				],
+			],
+			__DIR__ . '/data/array/multiline1Keyed1Comments.php' => [
+				[
+					'count' => 8,
+					'flags' => ['multi', 'keyed', 'short'],
+					'indentation' => "\t",
+					'keyValues' => [
+						[
+							'content' => "	'b' => 'b val',  // comment\n"
+								. "					 // more 'b' comment\n",
+							'indent' => "\t",
+							'key' => "'b'",
+							'pointerArrow' => true,
+						],
+						[
+							'content' => "	'a' . strtolower('param') => 'a val',  /* comment */\n",
+							'indent' => "\t",
+							'key' => "'a' . strtolower('param')",
+							'pointerArrow' => true,
+						],
+						[
+							'content' => "	// closure comment\n"
+							. "	'closure' => static function (\$p1, \$p2) {\n"
+							. "		return ['a2', 'b2'];\n"
+							. "	},\n",
+							'indent' => "\t",
+							'key' => "'closure'",
+							'pointerArrow' => true,
+						],
+						[
+							'content' => "	'nested' => array(\n"
+							. "		'b3' => 'b3 val',\n"
+							. "		'a3' => 'a3 val',\n"
+							. "	),\n",
+							'indent' => "\t",
+							'key' => "'nested'",
+							'pointerArrow' => true,
+						],
+						[
+							'content' => "	'arrow' => fn(\$x) => strtolower(\$x),\n",
+							'indent' => "\t",
+							'key' => "'arrow'",
+							'pointerArrow' => true,
+						],
+						[
+							'content' => "	'anonymous' => new class {\n"
+							. "		public function log(\$msg)\n"
+							. "		{\n"
+							. "			return true;\n"
+							. "		}\n"
+							. "	},\n",
+							'indent' => "\t",
+							'key' => "'anonymous'",
+							'pointerArrow' => true,
+						],
+						[
+							'content' => "	'foo', ",
+							'indent' => "\t",
+							'key' => null,
+							'pointerArrow' => false,
+							'pointerComma' => true,
+						],
+						[
+							'content' => "'bar'",
+							'indent' => null,
+							'key' => null,
+							'pointerArrow' => false,
+							'pointerComma' => false,
+						],
+					],
+				],
+				[
+					// the array inside the closure
+					'count' => 2,
+					'flags' => ['short'],
+					'indentation' => null,
+					'keyValues' => [
+						[
+							'content' => "'a2', ",
+							'indent' => null,
+							'key' => null,
+							'pointerArrow' => false,
+							'pointerComma' => true,
+						],
+						[
+							'content' => "'b2'",
+							'indent' => null,
+							'key' => null,
+							'pointerArrow' => false,
+							'pointerComma' => false,
+						],
+					],
+				],
+				[
+					// the nested array
+					'count' => 2,
+					'flags' => ['multi', 'keyed', 'keyedAll'],
+					'indentation' => "\t\t",
+					'keyValues' => [
+						[
+							'content' => "		'b3' => 'b3 val',\n",
+							'indent' => "\t\t",
+							'key' => "'b3'",
+							'pointerArrow' => true,
+							'pointerComma' => true,
+						],
+						[
+							'content' => "		'a3' => 'a3 val',\n",
+							'indent' => "\t\t",
+							'key' => "'a3'",
+							'pointerArrow' => true,
+							'pointerComma' => true,
+						],
+					],
+				],
+			],
+			__DIR__ . '/data/array/commentBeforeCloser.php' => [
+				[
+					'keyValues' => [
+						[
+							'content' => "	'a' => 'a',\n",
+						],
+					],
+				],
+				[
+					'keyValues' => [
+						[
+							'content' => "	'a' => 'a', // eol comment\n",
+						],
+					],
+				],
+				[
+					'keyValues' => [
+						[
+							'content' => "	'a' => 'a', // eol comment\n"
+								. "				// continued\n",
+						],
+					],
+				],
+			],
+		];
+	}
+
+}
diff --git a/tests/Helpers/data/array/commentBeforeCloser.php b/tests/Helpers/data/array/commentBeforeCloser.php
new file mode 100644
index 00000000..44b86f7c
--- /dev/null
+++ b/tests/Helpers/data/array/commentBeforeCloser.php
@@ -0,0 +1,19 @@
+<?php
+
+// comments at end
+
+[
+	'a' => 'a',
+	// end comment
+];
+
+[
+	'a' => 'a', // eol comment
+	// end comment
+];
+
+[
+	'a' => 'a', // eol comment
+				// continued
+	// end comment
+];
diff --git a/tests/Helpers/data/array/multiline0Keyed1Sorted1.php b/tests/Helpers/data/array/multiline0Keyed1Sorted1.php
new file mode 100644
index 00000000..ccab1fd4
--- /dev/null
+++ b/tests/Helpers/data/array/multiline0Keyed1Sorted1.php
@@ -0,0 +1,7 @@
+<?php
+
+// NOT multi-line / keyed / sorted
+
+array('a' => 'a', 'b' => 'b');
+
+['a' => 'a', 'b' => 'b'];
diff --git a/tests/Helpers/data/array/multiline1Keyed0.php b/tests/Helpers/data/array/multiline1Keyed0.php
new file mode 100644
index 00000000..ca7e8967
--- /dev/null
+++ b/tests/Helpers/data/array/multiline1Keyed0.php
@@ -0,0 +1,21 @@
+<?php
+
+// Multi-line / NOT keyed
+
+array(
+	'a',
+	'b',
+	[
+		'a2',
+		'b2',
+	],
+);
+
+[
+	'a',
+	'b',
+	array(
+		'a2',
+		'b2',
+	),
+];
diff --git a/tests/Helpers/data/array/multiline1Keyed1Comments.php b/tests/Helpers/data/array/multiline1Keyed1Comments.php
new file mode 100644
index 00000000..7416da8a
--- /dev/null
+++ b/tests/Helpers/data/array/multiline1Keyed1Comments.php
@@ -0,0 +1,24 @@
+<?php // lint >= 7.4
+
+// Multi-line / NOT keyed
+
+[ /* inline comment */ // opening line comment
+	'b' => 'b val',  // comment
+					 // more 'b' comment
+	'a' . strtolower('param') => 'a val',  /* comment */
+	// closure comment
+	'closure' => static function ($p1, $p2) {
+		return ['a2', 'b2'];
+	},
+	'nested' => array(
+		'b3' => 'b3 val',
+		'a3' => 'a3 val',
+	),
+	'arrow' => fn($x) => strtolower($x),
+	'anonymous' => new class {
+		public function log($msg)
+		{
+			return true;
+		}
+	},
+	'foo', 'bar'];
diff --git a/tests/Helpers/data/array/multiline1Keyed1Sorted0.php b/tests/Helpers/data/array/multiline1Keyed1Sorted0.php
new file mode 100644
index 00000000..d361637e
--- /dev/null
+++ b/tests/Helpers/data/array/multiline1Keyed1Sorted0.php
@@ -0,0 +1,21 @@
+<?php
+
+// Multi-line / keyed / NOT sorted
+
+array(
+	'c' => [
+		'b2' => 'b2',
+		'a2' => 'a2',
+	],
+	'b' => 'b',
+	'a' => 'a'
+);
+
+[
+	'c' => array(
+		'b2' => 'b2',
+		'a2' => 'a2',
+	),
+	'b' => 'b',
+	'a' => 'a'
+];
diff --git a/tests/Helpers/data/array/multiline1Keyed1Sorted1.php b/tests/Helpers/data/array/multiline1Keyed1Sorted1.php
new file mode 100644
index 00000000..dc64e016
--- /dev/null
+++ b/tests/Helpers/data/array/multiline1Keyed1Sorted1.php
@@ -0,0 +1,21 @@
+<?php
+
+// Multi-line / keyed / sorted
+
+array(
+	'a' => 'a',
+	'b' => 'b',
+	'c' => [
+		'a2' => 'a2',
+		'b2' => 'b2',
+	],
+);
+
+[
+	'a' => 'a',
+	'b' => 'b',
+	'c' => array(
+		'a2' => 'a2',
+		'b2' => 'b2',
+	),
+];
diff --git a/tests/Sniffs/Arrays/AlphabeticallySortedByKeysSniffTest.php b/tests/Sniffs/Arrays/AlphabeticallySortedByKeysSniffTest.php
new file mode 100644
index 00000000..e1d7aeed
--- /dev/null
+++ b/tests/Sniffs/Arrays/AlphabeticallySortedByKeysSniffTest.php
@@ -0,0 +1,30 @@
+<?php declare(strict_types = 1);
+
+namespace SlevomatCodingStandard\Sniffs\Arrays;
+
+use SlevomatCodingStandard\Sniffs\TestCase;
+
+class AlphabeticallySortedByKeysSniffTest extends TestCase
+{
+
+	public function testNoErrors(): void
+	{
+		$report = self::checkFile(__DIR__ . '/data/alphabeticallySortedByKeysNoErrors.php');
+		self::assertNoSniffErrorInFile($report);
+	}
+
+	public function testErrors(): void
+	{
+		$report = self::checkFile(__DIR__ . '/data/alphabeticallySortedByKeysErrors.php', []);
+
+		self::assertSame(4, $report->getErrorCount());
+
+		self::assertSniffError($report, 3, AlphabeticallySortedByKeysSniff::CODE_INCORRECT_KEY_ORDER);
+		self::assertSniffError($report, 15, AlphabeticallySortedByKeysSniff::CODE_INCORRECT_KEY_ORDER);
+		self::assertSniffError($report, 21, AlphabeticallySortedByKeysSniff::CODE_INCORRECT_KEY_ORDER);
+		self::assertSniffError($report, 25, AlphabeticallySortedByKeysSniff::CODE_INCORRECT_KEY_ORDER);
+
+		self::assertAllFixedInFile($report);
+	}
+
+}
diff --git a/tests/Sniffs/Arrays/data/alphabeticallySortedByKeysErrors.fixed.php b/tests/Sniffs/Arrays/data/alphabeticallySortedByKeysErrors.fixed.php
new file mode 100644
index 00000000..aa569827
--- /dev/null
+++ b/tests/Sniffs/Arrays/data/alphabeticallySortedByKeysErrors.fixed.php
@@ -0,0 +1,56 @@
+<?php // lint >= 7.4
+
+[ /* inline comment */ // opening line comment
+	'a' => 'a val',  /* comment */
+	'anonymous' => new class {
+	    public function log($msg)
+	    {
+	        return [
+	        	'y' => 'y val',
+	        	'z' => 'z val',
+	        ];
+	    }
+	},
+	'arrow' => fn($x) => array(
+		'x' => 'x val',
+		'y' => 'y val',
+	),
+	'b' => 'b val',  // comment
+					 // more comment
+
+	// closure comment
+	'closure' => static function ($p1, $p2) {
+		return ['a', 'b'];
+	},
+	'nested' => array(
+		'a2' => 'a2 val',
+		'b2' => 'b2 val',
+	),
+];
+
+[
+	1 => 'one',
+	'two',
+	4 => 'four',
+	'five',
+];
+
+['d' => 'd val', 'c' => 'c val'];
+
+// test nested array sniff is disabled if ancestor is disabled
+// @phpcs:disable SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys.IncorrectKeyOrder
+array(
+	'b3' => 'b3 val',
+	'a3' => 'a3 val',
+	// nested array should abide by the parent's @phpcs:disable
+	'c2' => array(
+		'b4' => 'b4 val',
+		'a4' => 'a4 val',
+	),
+);
+
+[
+	'I am',
+	'just a list',
+	'no keys',
+];
diff --git a/tests/Sniffs/Arrays/data/alphabeticallySortedByKeysErrors.php b/tests/Sniffs/Arrays/data/alphabeticallySortedByKeysErrors.php
new file mode 100644
index 00000000..235c9567
--- /dev/null
+++ b/tests/Sniffs/Arrays/data/alphabeticallySortedByKeysErrors.php
@@ -0,0 +1,55 @@
+<?php // lint >= 7.4
+
+[ /* inline comment */ // opening line comment
+	'b' => 'b val',  // comment
+					 // more comment
+	'a' => 'a val',  /* comment */
+
+	// closure comment
+	'closure' => static function ($p1, $p2) {
+		return ['a', 'b'];
+	},
+	'anonymous' => new class {
+	    public function log($msg)
+	    {
+	        return [
+	        	'z' => 'z val',
+	        	'y' => 'y val',
+	        ];
+	    }
+	},
+	'arrow' => fn($x) => array(
+		'y' => 'y val',
+		'x' => 'x val',
+	),
+	'nested' => array(
+		'b2' => 'b2 val',
+		'a2' => 'a2 val',
+	)];
+
+[
+	1 => 'one',
+	'two',
+	4 => 'four',
+	'five',
+];
+
+['d' => 'd val', 'c' => 'c val'];
+
+// test nested array sniff is disabled if ancestor is disabled
+// @phpcs:disable SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys.IncorrectKeyOrder
+array(
+	'b3' => 'b3 val',
+	'a3' => 'a3 val',
+	// nested array should abide by the parent's @phpcs:disable
+	'c2' => array(
+		'b4' => 'b4 val',
+		'a4' => 'a4 val',
+	),
+);
+
+[
+	'I am',
+	'just a list',
+	'no keys',
+];
diff --git a/tests/Sniffs/Arrays/data/alphabeticallySortedByKeysNoErrors.php b/tests/Sniffs/Arrays/data/alphabeticallySortedByKeysNoErrors.php
new file mode 100644
index 00000000..d7846767
--- /dev/null
+++ b/tests/Sniffs/Arrays/data/alphabeticallySortedByKeysNoErrors.php
@@ -0,0 +1,55 @@
+<?php // lint >= 7.4
+
+[ /* inline comment */ // opening line comment
+	'a' => 'a val',  /* comment */
+	'anonymous' => new class {
+	    public function log($msg)
+	    {
+	        return [
+	        	'y' => 'y val',
+	        	'z' => 'z val',
+	        ];
+	    }
+	},
+	'arrow' => fn($x) => array(
+		'w' => 'w val',
+		'x' => 'x val',
+	),
+	'b' => 'b val',  // comment
+					 // more comment
+
+	// closure comment
+	'closure' => static function ($p1, $p2) {
+		return ['a', 'b'];
+	},
+	'nested' => array(
+		'a2' => 'a2 val',
+		'b2' => 'b2 val',
+	)];
+
+[
+	1 => 'one',
+	'two',
+	4 => 'four',
+	'five',
+];
+
+['d' => 'd val', 'c' => 'c val'];
+
+// test nested array sniff is disabled if ancestor is disabled
+// @phpcs:disable SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys.IncorrectKeyOrder
+array(
+	'b3' => 'b3 val',
+	'a3' => 'a3 val',
+	// nested array should abide by the parent's @phpcs:disable
+	'c2' => array(
+		'b4' => 'b4 val',
+		'a4' => 'a4 val',
+	),
+);
+
+[
+	'I am',
+	'just a list',
+	'no keys',
+];