Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ Detects `Permissions` property entries that are not sorted alphabetically by (ty
```
src/ALCops.Common/
└── Permissions/
└── PermissionSyntaxHelper.cs # Shared sort logic, multi-line builder
├── NaturalNameComparer.cs # Natural/alphanumeric name comparison (AZ-compatible)
└── PermissionSyntaxHelper.cs # Shared sort logic, multi-line builder

src/ALCops.FormattingCop/
├── Analyzers/
Expand All @@ -33,11 +34,21 @@ src/ALCops.FormattingCop/

### Sort order

Entries are sorted by two keys:
1. **Type keyword** (alphabetical, case-insensitive): `codeunit` < `page` < `query` < `record` < `report` < `table` < `tabledata` < `xmlport`
2. **Object name** (alphabetical, case-insensitive) within the same type
Entries are sorted using AZ Dev Tools-compatible ordering (matches `PermissionComparer` from al-code-outline):

Uses `StringComparison.OrdinalIgnoreCase` for both comparisons. Note that ordinal comparison means `(` < `+` < `0` < `A`, which may differ from culture-specific ordering.
1. **Table types first**: `table` and `tabledata` entries are grouped before all other types
- Within table types, entries are sorted by **object name** first (natural/alphanumeric comparison)
- For the same object name, `table` sorts before `tabledata`
2. **Remaining types alphabetically**: `codeunit` < `page` < `query` < `report` < `xmlport`
- Within the same type, sorted by **object name** (natural/alphanumeric comparison)

**Name comparison** uses `NaturalNameComparer` (natural/alphanumeric sort):
- Splits names into text and numeric chunks
- Text chunks: spaces stripped, compared with `StringComparison.InvariantCultureIgnoreCase`
- Numeric chunks: compared as integers (`"Item 2"` < `"Item 10"`)
- Tiebreaker: shorter string first

Uses `StringComparison.OrdinalIgnoreCase` for type keyword comparison only (fixed ASCII vocabulary).

### Analysis flow

Expand All @@ -59,7 +70,11 @@ Uses `StringComparison.OrdinalIgnoreCase` for both comparisons. Note that ordina
| `RegisterCompilationAction` | Same pattern as AC0032; iterates all objects in one pass |
| One diagnostic per object, not per entry | The fix reorders the entire list; per-entry diagnostics would be noise |
| Diagnostic on `PropertySyntax` node | Ensures CodeFix can find the node via `FindNode` + ancestor/descendant traversal |
| Type+name sorting applied globally (affects AC0031/AC0032) | `ArePermissionsSorted` was refactored from name-only to type+name; consistent behavior across all permission rules |
| AZ Dev Tools-compatible sort order | Prevents false positives on code formatted by AZ Dev Tools' "Sort Permissions" |
| table/tabledata first, rest alphabetical | Matches AZ's fixed priority map; more future-proof for new permission types |
| Natural/alphanumeric name comparison | Matches AZ's `AlphanumComparatorFast`; handles numeric suffixes correctly |
| `InvariantCultureIgnoreCase` for names | Handles accented chars and punctuation weight; matches AZ behavior |
| Space stripping in text chunks | Matches AZ behavior; prevents spaces from affecting sort order |
| CodeFix always outputs multi-line for 2+ entries | Consistent formatting; single-line permissions are hard to scan |
| Single entry stays single-line | No formatting benefit from multi-line with one entry |
| Permission chars casing preserved | Each entry keeps its original `r`/`R`/`rimd`/`RIMD`/`X` casing |
Expand Down Expand Up @@ -93,13 +108,18 @@ var propertySyntax = node as PropertySyntax

## Test coverage

**HasDiagnostic (4 cases):** UnsortedTabledata, UnsortedMixedTypes, UnsortedSingleType, UnsortedCodeunit.
**NoDiagnostic (4 cases):** AlreadySorted, SingleEntry, NoPermissionsProperty, SortedTabledata.
**HasDiagnostic (6 cases):** UnsortedTabledata, UnsortedMixedTypes, UnsortedSingleType, UnsortedCodeunit, UnsortedNumeric, UnsortedTableNotFirst.
**NoDiagnostic (7 cases):** AlreadySorted, SingleEntry, NoPermissionsProperty, SortedTabledata, DotsBeforeLetters, NaturalNumericSort, TableTypesFirst.
**HasFix (4 cases):** ReorderTabledata, ReorderMixedTypes, SingleLineToMultiLine, PreserveCasing.

## Refactoring impact

The `PermissionSyntaxHelper.ArePermissionsSorted` method was changed from name-only to type+name comparison. This affects AC0031's `FindInsertionIndex` (used by its CodeFix to decide alphabetical vs append insertion). The behavioral change is:
- A list sorted by name but not by type is now detected as "unsorted"
- AC0031's CodeFix will append (instead of inserting alphabetically) for such lists
- This is the correct behavior since type+name is the canonical sort order
The `PermissionSyntaxHelper` sort logic was changed from simple `OrdinalIgnoreCase` to AZ Dev Tools-compatible ordering. This affects:
- **FC0004**: Uses the new sort order for detection and code fix
- **AC0031's `FindInsertionIndex`**: Uses `ComparePermissionEntries` for insertion point calculation
- **AC0032**: Uses `ArePermissionsSorted` to determine if a list is sorted

The behavioral changes:
- table/tabledata types now sort before other types (previously alphabetical: `codeunit` came first)
- Names use natural/alphanumeric comparison (previously ordinal: "Item 10" came before "Item 2")
- Spaces in names are ignored during comparison (previously significant)
108 changes: 108 additions & 0 deletions src/ALCops.Common/Permissions/NaturalNameComparer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
namespace ALCops.Common.Permissions;

/// <summary>
/// Natural/alphanumeric string comparer for AL object names.
/// Splits strings into text and numeric chunks:
/// - Text chunks are compared with spaces stripped and InvariantCultureIgnoreCase.
/// - Numeric chunks are compared as integers.
/// - Shorter string wins as tiebreaker when all chunks are equal.
/// Functionally equivalent to AZ AL Dev Tools' AlphanumComparatorFast.
/// </summary>
public static class NaturalNameComparer
{
/// <summary>
/// Compares two object names using natural/alphanumeric sorting.
/// Null or empty strings sort after non-empty strings.
/// </summary>
public static int Compare(string? x, string? y)
{
bool xEmpty = string.IsNullOrWhiteSpace(x);
bool yEmpty = string.IsNullOrWhiteSpace(y);

if (xEmpty && yEmpty)
return 0;
if (xEmpty)
return 1;
if (yEmpty)
return -1;

int len1 = x!.Length;
int len2 = y!.Length;
int marker1 = 0;
int marker2 = 0;

while (marker1 < len1 && marker2 < len2)
{
char ch1 = x[marker1];
char ch2 = y[marker2];

// Collect chunk from x
int chunkStart1 = marker1;
bool isDigit1 = char.IsDigit(ch1);
while (marker1 < len1 && char.IsDigit(x[marker1]) == isDigit1)
marker1++;

// Collect chunk from y
int chunkStart2 = marker2;
bool isDigit2 = char.IsDigit(ch2);
while (marker2 < len2 && char.IsDigit(y[marker2]) == isDigit2)
marker2++;

int result;
if (isDigit1 && isDigit2)
{
// Both numeric: compare as integers
long num1 = ParseLong(x, chunkStart1, marker1);
long num2 = ParseLong(y, chunkStart2, marker2);
result = num1.CompareTo(num2);
}
else
{
// At least one is text: compare with spaces stripped, InvariantCultureIgnoreCase
var str1 = StripSpaces(x, chunkStart1, marker1);
var str2 = StripSpaces(y, chunkStart2, marker2);
result = string.Compare(str1, str2, StringComparison.InvariantCultureIgnoreCase);
}

if (result != 0)
return result;
}

return len1 - len2;
}

private static long ParseLong(string s, int start, int end)
{
long result = 0;
for (int i = start; i < end; i++)
result = result * 10 + (s[i] - '0');
return result;
}

private static string StripSpaces(string s, int start, int end)
{
// Fast path: if no spaces, return substring directly
bool hasSpace = false;
for (int i = start; i < end; i++)
{
if (s[i] == ' ')
{
hasSpace = true;
break;
}
}

if (!hasSpace)
return s.Substring(start, end - start);

var chars = new char[end - start];
int count = 0;
for (int i = start; i < end; i++)
{
if (s[i] != ' ')
chars[count++] = s[i];
}

return new string(chars, 0, count);
}
}
67 changes: 48 additions & 19 deletions src/ALCops.Common/Permissions/PermissionSyntaxHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ public static class PermissionSyntaxHelper
private const string CanonicalOrder = MethodOperationMap.CanonicalOrder;

/// <summary>
/// Checks whether the permission entries are sorted alphabetically by
/// (type keyword, object name), both case-insensitive.
/// Returns true if 0 or 1 entries (trivially sorted).
/// Checks whether the permission entries are sorted using AZ Dev Tools-compatible ordering:
/// table/tabledata types first (grouped by name), remaining types alphabetically by type, then by name.
/// Names use natural/alphanumeric comparison. Returns true if 0 or 1 entries (trivially sorted).
/// </summary>
public static bool ArePermissionsSorted(SeparatedSyntaxList<PermissionSyntax> permissions)
{
Expand All @@ -33,11 +33,8 @@ public static bool ArePermissionsSorted(SeparatedSyntaxList<PermissionSyntax> pe

if (previousType is not null && previousName is not null)
{
int typeCompare = string.Compare(previousType, type, StringComparison.OrdinalIgnoreCase);
if (typeCompare > 0)
return false;
if (typeCompare == 0 &&
string.Compare(previousName, name, StringComparison.OrdinalIgnoreCase) > 0)
int cmp = ComparePermissionEntries(previousType, previousName, type, name);
if (cmp > 0)
return false;
}

Expand Down Expand Up @@ -127,7 +124,7 @@ public static PermissionSyntax CreatePermissionSyntax(string tableName, string p

/// <summary>
/// Finds the insertion index for a new entry in a sorted permission list,
/// comparing by (type keyword, object name). The new entry type defaults to "tabledata"
/// using AZ Dev Tools-compatible ordering. The new entry type defaults to "tabledata"
/// since AC0031 only inserts tabledata entries.
/// If not sorted, returns the count (append).
/// </summary>
Expand All @@ -144,10 +141,8 @@ public static int FindInsertionIndex(SeparatedSyntaxList<PermissionSyntax> permi
if (entryType is null || entryName is null)
continue;

int typeCompare = string.Compare(newType, entryType, StringComparison.OrdinalIgnoreCase);
if (typeCompare < 0)
return i;
if (typeCompare == 0 && string.Compare(tableName, entryName, StringComparison.OrdinalIgnoreCase) < 0)
int cmp = ComparePermissionEntries(newType, tableName, entryType, entryName);
if (cmp < 0)
return i;
}

Expand Down Expand Up @@ -317,7 +312,8 @@ private static bool HasNewlineTrivia(SyntaxTriviaList triviaList)
}

/// <summary>
/// Sorts the permission entries by (type keyword, object name), both case-insensitive.
/// Sorts the permission entries using AZ Dev Tools-compatible ordering:
/// table/tabledata first (grouped by name), remaining types alphabetically.
/// Returns a new list with the entries in sorted order, stripping leading trivia from
/// all entries (callers are responsible for applying formatting).
/// </summary>
Expand All @@ -331,18 +327,51 @@ public static List<PermissionSyntax> GetSortedPermissions(SeparatedSyntaxList<Pe
{
var typeA = GetPermissionTypeText(a) ?? string.Empty;
var typeB = GetPermissionTypeText(b) ?? string.Empty;
int typeCompare = string.Compare(typeA, typeB, StringComparison.OrdinalIgnoreCase);
if (typeCompare != 0)
return typeCompare;

var nameA = GetObjectNameFromPermission(a) ?? string.Empty;
var nameB = GetObjectNameFromPermission(b) ?? string.Empty;
return string.Compare(nameA, nameB, StringComparison.OrdinalIgnoreCase);
return ComparePermissionEntries(typeA, nameA, typeB, nameB);
});

return entries;
}

/// <summary>
/// Compares two permission entries using AZ Dev Tools-compatible ordering:
/// - table/tabledata types sort first, grouped by name (table before tabledata for same name)
/// - remaining types sort alphabetically by type keyword (OrdinalIgnoreCase)
/// - within the same non-table type, sort by name using natural/alphanumeric comparison
/// </summary>
public static int ComparePermissionEntries(string typeA, string nameA, string typeB, string nameB)
{
bool aIsTable = IsTableType(typeA);
bool bIsTable = IsTableType(typeB);

if (aIsTable && bIsTable)
{
// Both table types: sort by name first, then type (table before tabledata)
int nameCompare = NaturalNameComparer.Compare(nameA, nameB);
if (nameCompare != 0)
return nameCompare;
return string.Compare(typeA, typeB, StringComparison.OrdinalIgnoreCase);
}

if (aIsTable != bIsTable)
{
// Table types come first
return aIsTable ? -1 : 1;
}

// Both non-table: sort by type alphabetically, then by name
int typeCompare = string.Compare(typeA, typeB, StringComparison.OrdinalIgnoreCase);
if (typeCompare != 0)
return typeCompare;
return NaturalNameComparer.Compare(nameA, nameB);
}

private static bool IsTableType(string type) =>
string.Equals(type, "table", StringComparison.OrdinalIgnoreCase) ||
string.Equals(type, "tabledata", StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Builds a multi-line PermissionPropertyValueSyntax from an ordered list of entries.
/// The first entry has no leading trivia (it shares the line with "Permissions = ").
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ permissionset 50100 "My Permission Set"
Assignable = false;
Access = Public;

[|Permissions = tabledata "My Table" = R,
codeunit "My Codeunit" = X,
[|Permissions = codeunit "My Codeunit" = X,
report "My Report" = X,
page "My Page" = X,
report "My Report" = X|];
tabledata "My Table" = R|];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
codeunit 50100 "Item 10"
{
}

codeunit 50101 "Item 2"
{
}

codeunit 50102 "Item 1"
{
}

permissionset 50100 "My Permission Set"
{
Assignable = true;
[|Permissions =
codeunit "Item 1" = X,
codeunit "Item 10" = X,
codeunit "Item 2" = X|];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
table 50100 "My Table"
{
Caption = '', Locked = true;
fields
{
field(1; MyField; Integer) { }
}
}

codeunit 50100 "My Codeunit"
{
}

permissionset 50100 "My Permission Set"
{
Assignable = true;
[|Permissions =
codeunit "My Codeunit" = X,
tabledata "My Table" = R|];
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ permissionset 50100 "My Permission Set"
Assignable = false;
Access = Public;

[|Permissions = tabledata "My Table" = R,
codeunit "My Codeunit" = X,
[|Permissions = codeunit "My Codeunit" = X,
report "My Report" = X,
page "My Page" = X,
report "My Report" = X|];
tabledata "My Table" = R|];
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ permissionset 50100 "My Permission Set"
Assignable = false;
Access = Public;

Permissions = codeunit "My Codeunit" = X,
Permissions = tabledata "My Table" = R,
codeunit "My Codeunit" = X,
page "My Page" = X,
report "My Report" = X,
tabledata "My Table" = R;
report "My Report" = X;
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ permissionset 50100 "My Permission Set"
Assignable = false;
Access = Public;

Permissions = codeunit "My Codeunit" = X,
Permissions = tabledata Alpha = R,
codeunit "My Codeunit" = X,
page "My Page" = X,
report "My Report" = X,
tabledata Alpha = R;
report "My Report" = X;
}
Loading
Loading