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
270 changes: 270 additions & 0 deletions content/docs/analyzers/LinterCop/LC0098.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
+++
title = 'Event subscriber name does not match the configured template'
linkTitle = 'LC0098'

[params]
id = 'LC0098'
severity = 'Info'
category = 'Naming'
codeAction = true
ignoreObsolete = true
+++

Event subscribers are the connective tissue between publishers and the code that reacts to them. When every subscriber in a codebase follows the same naming convention — source object, event, optional field — it becomes immediately clear what a subscriber does without reading its body. A subscriber named `HandleSomething` tells you nothing; one named `"Sales Header_OnAfterDeleteEvent"` tells you everything.

LC0098 validates subscriber names against a single configurable template. The default template is:

```
{Event Source}_{Event Name}[_{Element Name}]
```

This is byte-for-byte the identifier the AL Language extension's **"Find Event"** feature generates for a new subscriber. Subscribers created via the tooling default already satisfy the rule with zero configuration.

### Example

The following subscriber has a name that does not encode the source object or event:

{{< highlight al "hl_lines=4" >}}
codeunit 50100 MySubscriber
{
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterDeleteEvent, '', false, false)]
local procedure HandleSalesHeaderDelete(var Rec: Record "Sales Header"; RunTrigger: Boolean) // Event subscriber 'HandleSalesHeaderDelete' does not match the expected name '"Sales Header_OnAfterDeleteEvent"'. [LC0098]
begin
end;
}
{{< /highlight >}}

Rename it to match the template:

{{< highlight al "hl_lines=4" >}}
codeunit 50100 MySubscriber
{
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterDeleteEvent, '', false, false)]
local procedure "Sales Header_OnAfterDeleteEvent"(var Rec: Record "Sales Header"; RunTrigger: Boolean)
begin
end;
}
{{< /highlight >}}

The double-quotes are added automatically when the derived identifier contains characters that require AL identifier quoting (spaces, `.`, `/`, `&`, `%`, ...).

### Example with element name

For field-level events (e.g. `OnAfterValidateEvent`), the fourth attribute argument names the field. The optional group `[_{Element Name}]` adds an underscore-separated field name only when the element name is present:

{{< highlight al "hl_lines=4" >}}
codeunit 50100 MySubscriber
{
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterValidateEvent, "Posting Date", false, false)]
local procedure "Sales Header_OnAfterValidateEvent_Posting Date"(var Rec: Record "Sales Header"; var xRec: Record "Sales Header")
begin
end;
}
{{< /highlight >}}

The same subscriber without a field filter (`''`) produces the shorter name without the underscore suffix:

{{< highlight al "hl_lines=4" >}}
codeunit 50100 MySubscriber
{
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterValidateEvent, '', false, false)]
local procedure "Sales Header_OnAfterValidateEvent"(var Rec: Record "Sales Header"; var xRec: Record "Sales Header")
begin
end;
}
{{< /highlight >}}

### Template syntax

A template is a string that mixes literal characters and **token placeholders**. Literal characters are emitted as-is. Placeholders are replaced by the corresponding value from the `[EventSubscriber]` attribute, rendered in the style the placeholder's own casing selects.

#### Token placeholders

The placeholder name determines which value to insert; the *style of the name* determines the output style:

| Placeholder | Token | Output style | Example (`"Sales Header"`) |
|---|---|---|---|
| `{Event Source}` | Source object name | **Raw** (verbatim) | `Sales Header` |
| `{EventSource}` | Source object name | PascalCase | `SalesHeader` |
| `{eventSource}` | Source object name | camelCase | `salesHeader` |
| `{event_source}` | Source object name | snake\_case | `sales_header` |
| `{event-source}` | Source object name | kebab-case | `sales-header` |
| `{Event Name}` | Event procedure name | **Raw** (verbatim) | `OnAfterDeleteEvent` |
| `{EventName}` | Event procedure name | PascalCase | `OnAfterDeleteEvent` |
| `{eventName}` | Event procedure name | camelCase | `onAfterDeleteEvent` |
| `{event_name}` | Event procedure name | snake\_case | `on_after_delete_event` |
| `{event-name}` | Event procedure name | kebab-case | `on-after-delete-event` |
| `{Element Name}` | Field/element name | **Raw** (verbatim) | `Posting Date` |
| `{ElementName}` | Field/element name | PascalCase | `PostingDate` |
| `{elementName}` | Field/element name | camelCase | `postingDate` |
| `{element_name}` | Field/element name | snake\_case | `posting_date` |
| `{element-name}` | Field/element name | kebab-case | `posting-date` |

Raw placeholders (`{Event Source}`, `{Event Name}`, `{Element Name}`) emit the input value **verbatim**: no word splitting, no case transform, no acronym-aware rendering. This is what the default template uses so its output aligns with the AL Language extension's tooling.

Any other text in the template is treated as a literal character and emitted unchanged. Unknown `{...}` sequences are also passed through literally.

#### Optional groups

Wrap part of the template in `[...]` to emit it **only when all token placeholders inside it resolve to a non-empty value**. This prevents orphaned separators when the element name is absent.

Without optional group — empty element name leaves a trailing underscore:

| Template | ElementName | Result |
|---|---|---|
| `{Event Source}_{EventName}_{Element Name}` | `` | `Sales Header_OnAfterDeleteEvent_` ❌ |
| `{Event Source}_{EventName}_{Element Name}` | `Posting Date` | `Sales Header_OnAfterDeleteEvent_Posting Date` ✅ |

With optional group — the separator is emitted only when an element name is present:

| Template | ElementName | Result |
|---|---|---|
| `{Event Source}_{Event Name}[_{Element Name}]` | `` | `Sales Header_OnAfterDeleteEvent` ✅ |
| `{Event Source}_{Event Name}[_{Element Name}]` | `Posting Date` | `Sales Header_OnAfterDeleteEvent_Posting Date` ✅ |

Optional groups can contain any combination of literals and token placeholders. A `[` inside an optional group is treated as a literal character (nesting is not supported).

#### Word splitting (non-raw styles only)

For any of the PascalCase / camelCase / snake\_case / kebab-case placeholders, token values are split into words before the case transform is applied:

1. Split on whitespace and the punctuation characters `_ - . / & ( ) + %`
2. Split further on PascalCase and camelCase boundaries (uppercase letter after lowercase, or at the start of an all-caps run followed by lowercase)

The `%` delimiter drops the character entirely, so `"Line Discount %"` renders as `LineDiscount` (not `LineDiscount%` or `LineDiscountPct`).

The words are then rendered in the requested style, so the output is deterministic regardless of how the source name is written:

| Input | Words | PascalCase | snake\_case |
|---|---|---|---|
| `"Sales Header"` | Sales, Header | `SalesHeader` | `sales_header` |
| `"Gen. Journal Template"` | Gen, Journal, Template | `GenJournalTemplate` | `gen_journal_template` |
| `"G/L Account"` | G, L, Account | `GLAccount` | `g_l_account` |
| `"Sales & Receivables Setup"` | Sales, Receivables, Setup | `SalesReceivablesSetup` | `sales_receivables_setup` |
| `OnAfterDeleteEvent` | On, After, Delete, Event | `OnAfterDeleteEvent` | `on_after_delete_event` |

Raw-style placeholders skip word splitting entirely and emit the input verbatim.

#### Acronyms and casing (non-raw styles only)

For PascalCase and camelCase (non-first) positions, each word is rendered to a canonical **preferred** form. The renderer follows an **"original casing wins"** rule: as long as the source word carries any uppercase character, its casing is preserved and the acronym registry does not change the preferred form. Only all-lowercase source words fall back to the registry to recover a canonical casing.

By default the diagnostic accepts only the preferred form. When `KnownAcronyms` pins an alternate casing on the same case-insensitive key as an uppercase source word, that variant becomes an **additionally accepted** spelling — the CodeFix still suggests the preferred (original-wins) form. See [Alternative: project-specific acronyms](#alternative-project-specific-acronyms) below.

| Word type | Preferred rendering |
|---|---|
| `ID` (any casing) | `Id` — `ID` is an abbreviation per C# guideline, not an acronym |
| Two-letter all-uppercase word (`IO`, `DX`, `AG`, `GL`, `IC`, `UI`, ...) | Kept uppercase (`IOLog`, not `IoLog`) |
| Word contains any uppercase character (`VAT`, `Sales`, `Http`, `XYZ`) | First letter uppercased, remainder untouched — original casing wins |
| Word is all-lowercase and matches a registered acronym (`vat`, `json`, `lcy`, `acme`) | Canonical casing from the registry (`Vat`, `Json`, `Lcy`, `Acme`) |
| Word is all-lowercase and not registered (`amount`, `header`) | First letter uppercased, remainder as-is (`Amount`, `Header`) |

`camelCase` first word is unconditionally fully lowercased per C# convention (`onAfterDeleteEvent`, `httpsEndpoint`, `idBadge`). Snake and kebab styles are always fully lowercased.

Example — field `"LCY Amount"` under a fully cased template `On{EventSource}_{EventName}_{ElementName}` accepts:

- `OnMyTable_OnAfterValidateEvent_LCYAmount` ✅ (source `LCY` is uppercase — original casing wins, preferred spelling)
- `OnMyTable_OnAfterValidateEvent_LcyAmount` ❌ *unless* `"KnownAcronyms": ["Lcy"]` is configured — then it is additionally accepted alongside `LCY` (see [below](#alternative-project-specific-acronyms))
- `OnMyTable_OnAfterValidateEvent_LcYAmount` ❌ (never accepted — third variant)

Example — field `"lcy amount"` (all-lowercase source) under the same template accepts:

- `OnMyTable_OnAfterValidateEvent_LcyAmount` ✅ (registry entry `Lcy` supplies the canonical casing)
- `OnMyTable_OnAfterValidateEvent_LCYAmount` ❌ (registry canonical wins over `EnsureUpperFirst` fallback)

#### Recognized acronyms

The following acronyms ship as the built-in default list and always match case-insensitively:

`Aad`, `Abc`, `Acy`, `Adcs`, `Api`, `Arc`, `Ascii`, `Ato`, `Bcc`, `Bic`, `Blob`, `BoM`, `Bom`, `Bop`, `Bwr`, `Cal`, `Cds`, `Cogs`, `Crm`, `Csv`, `Dach`, `Dtd`, `Dvr`, `Ecsl`, `Emu`, `Eori`, `Fefo`, `Gln`, `Gtin`, `Guid`, `Html`, `Iban`, `Id`, `Iso`, `Isv`, `Json`, `Kpi`, `Lcid`, `Lcy`, `Lid`, `Mps`, `Mrp`, `Nav`, `Ocr`, `Oob`, `Pbix`, `Pdf`, `Pfx`, `Qbd`, `Sepa`, `Sic`, `Sid`, `Sift`, `Sku`, `Smtp`, `Sqm`, `Swift`, `Uid`, `UoM`, `Uom`, `Ups`, `Uri`, `Url`, `Urs`, `Utc`, `Utf`, `Vat`, `Wip`, `Wms`, `Xml`, `Xsd`, `Ytd`.

Two-letter uppercase abbreviations are handled by the general 2-letter rule above and are deliberately not part of this list. A few keys ship with more than one built-in variant (for example `BoM`/`Bom` and `UoM`/`Uom`). For uppercase-carrying source words the original casing always wins, so every listed variant is additionally accepted regardless of order; the first-listed spelling is used only when the source word is all-lowercase, in which case the renderer picks it as the canonical fallback.

Project-specific additions are supplied via the [`KnownAcronyms` setting](#alternative-project-specific-acronyms) — the user list is merged into the built-in defaults, and any user entry for a given case-insensitive key fully replaces the built-in variants for that key.

### Configuration

Set `SubscriberNamingPattern` in `alcops.json`:

```json
{
"SubscriberNamingPattern": "{Event Source}_{Event Name}[_{Element Name}]"
}
```

When the property is absent, `null`, or whitespace, the built-in default `{Event Source}_{Event Name}[_{Element Name}]` is used.

#### Alternative: `On` prefix with PascalCase source

Teams that prefer to omit the raw source name and use a compact PascalCase identifier:

```json
{
"SubscriberNamingPattern": "On{EventSource}_{EventName}[_{ElementName}]"
}
```

This produces `OnSalesHeader_OnAfterDeleteEvent_PostingDate` — no quoting required, and `EventName` PascalCase is a no-op because AL event identifiers already are PascalCase.

#### Alternative: camelCase source and event

```json
{
"SubscriberNamingPattern": "on{EventSource}_{EventName}[_{ElementName}]"
}
```

This produces `onSalesHeader_OnAfterDeleteEvent` — only the first token is lowercased; subsequent tokens keep their own placeholder casing.

#### Alternative: snake\_case throughout

```json
{
"SubscriberNamingPattern": "on_{event_source}_{event_name}[_{element_name}]"
}
```

This produces `on_sales_header_on_after_delete_event_posting_date` — all tokens in snake\_case with a leading `on_` prefix.

#### Alternative: project-specific acronyms

Add domain-specific acronyms (partner prefixes, product names, or industry abbreviations not in the built-in list) via the `KnownAcronyms` array:

```json
{
"KnownAcronyms": ["Acme", "MyCo", "GDPR"]
}
```

Entries are matched case-insensitively; the first entry supplied for a given case-insensitive key defines the preferred canonical casing emitted in the diagnostic message. The user list is merged with the built-in defaults; when the user supplies any entries for a given case-insensitive key, those entries fully replace the built-in variants for that key.

`KnownAcronyms` serves **two purposes**:

1. **Preferred casing for all-lowercase sources.** When the source word is all-lowercase, the registry supplies the preferred canonical casing.
- Field `"acme product"` + `"KnownAcronyms": ["Acme"]` → `AcmeProduct` (registry canonicalises the lowercase source).
- Field `"acme product"` **without** `"KnownAcronyms": ["Acme"]` → `AcmeProduct` (`EnsureUpperFirst` fallback: first letter up, remainder as-is).
- If multiple configured entries map to the same case-insensitive key (for example `"Http"` and `"HTTP"`), the **first entry is canonical** and subsequent entries become additionally accepted variants (`"KnownAcronyms": ["Http", "HTTP"]` → `http` renders as `Http`, and `HTTP` is additionally accepted for uppercase-carrying sources; reversed order renders as `HTTP` and additionally accepts `Http`).

2. **Additionally accepted variants for uppercase-carrying sources.** When the source word already carries uppercase and the registry contains one or more differently cased entries on the same case-insensitive key, **all** such entries are *additionally* accepted alongside the original spelling — the CodeFix always continues to suggest the original casing.
- Subscriber to `OnAfterCalcOverdueBalanceLCY` + `"KnownAcronyms": ["Lcy"]` → both `...OnAfterCalcOverdueBalanceLCY` (preferred, original-wins) and `...OnAfterCalcOverdueBalanceLcy` (registered variant) are accepted; any third spelling such as `...LcYAmount` still triggers a diagnostic. The CodeFix rewrites to the `LCY` form.
- Field `"ACME Product"` + `"KnownAcronyms": ["Acme"]` → both `ACMEProduct` (preferred) and `AcmeProduct` (registered variant) are accepted; the CodeFix suggests `ACMEProduct`.
- The built-in default list also ships multi-variant keys (for example `BoM`/`Bom` and `UoM`/`Uom`); every listed variant is additionally accepted whenever the source word carries uppercase, without any project-specific configuration.

The rationale: the preferred form is always aligned with the source object/field casing, so Microsoft- or partner-owned identifiers are never re-cast. The accepted-variant mechanism is an *opt-in* escape hatch that lets teams acknowledge project-specific acronym spellings without giving up that guarantee.

The `KnownAcronyms` setting is shared infrastructure — future analyzers that render or validate identifiers derived from natural-language input use the same list.

### Exceptions

The rule silently skips a subscriber when any of the following holds:

- The source object cannot be resolved (e.g. a numeric object ID with no matching object in the compilation).
- The event name is missing or empty.
- The method is marked `Obsolete`.
- **AL304 length guard**: the canonical name would exceed **120 characters** — the AL identifier length limit. Suggesting such a name would move the violation from LC0098 into [AL304](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al304), so the rule stays quiet instead.
- **Collision guard**: a sibling method in the same containing type already carries the canonical name, or another event subscriber in that type would compute to the same canonical name. Applying the CodeFix would then produce a duplicate-identifier compile error, so LC0098 stays quiet on both. Two subscribers to the same event inside one codeunit is a legal, not uncommon pattern. The guard is deliberately conservative: any sibling name clash suppresses, even when signatures technically differ and would compile as overloads — renaming into an overload set changes semantics and confuses readers.

### See also

- [LC0092](../lc0092/) — Naming pattern: general regex-based naming for procedures and other symbols. LC0092 and LC0098 are orthogonal — structural template (LC0098) vs. character-class regex (LC0092) — and their settings are decoupled. A subscriber that violates both receives two independent diagnostics.
- [LC0028](../lc0028/) — Identifiers in event subscribers: event and element names must use identifier syntax, not string literals.
1 change: 1 addition & 0 deletions content/docs/analyzers/LinterCop/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ The LinterCop is a code linter for AL, comparable to widely used static analysis
| [LC0095](lc0095/) | Parameter is not referenced | Warning | ✓ | ✓ |
| [LC0096](lc0096/) | Unnecessary record parameter in method call | Warning | ✓ | |
| [LC0097](lc0097/) | Avoid mixing exit() and named return variable assignments | Info | — | |
| [LC0098](lc0098/) | Event subscriber name does not match the configured template | Info | ✓ | ✓ |