Skip to content

feat(FC0007): Statement blocks should be separated by a blank line (opt-in) - #424

Open
MODUSCarstenScholling wants to merge 4 commits into
ALCops:mainfrom
MODUSCarstenScholling:dev-cs-statement-blocks-separated
Open

feat(FC0007): Statement blocks should be separated by a blank line (opt-in)#424
MODUSCarstenScholling wants to merge 4 commits into
ALCops:mainfrom
MODUSCarstenScholling:dev-cs-statement-blocks-separated

Conversation

@MODUSCarstenScholling

@MODUSCarstenScholling MODUSCarstenScholling commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

feat(FC0007): Statement blocks should be separated by a blank line (opt-in)

Adds FC0007 — Statement blocks should be separated by a blank line to
ALCops.FormattingCop. Opt-in (disabled by default) and fully configurable via
alcops.json. Closes #.

What it flags

  • Multi-line control-flow blocks (if, case, repeat, while, for,
    foreach) without a blank line before them and/or after their terminating
    end;.
  • Scope-leaving statements: exit, exit(<expr>), and built-in Error(...)
    calls that are not preceded by a blank line.
  • Optionally the end else transition in if … end else … end chains.

Single-line (one-liner) control-flow statements are ignored unless
OneLinerMode is set to All.

Configuration surface

New object in alcops.json:

Property Type Default
ControlFlowBefore bool true
ControlFlowAfter bool true
ScopeLeavingMode Off / ExitOnly / ErrorOnly / ExitAndError ExitAndError
ElseChainBeforeMode Off / RequireBlank Off
OneLinerMode Off / All Off

Enums are real C# types (ALCops.Common.Settings.{Scope,ElseChainBefore,OneLiner}Mode),
serialized case-insensitively via JsonStringEnumConverter on net8+ and
StringEnumConverter on netstandard2.1. Both converter registrations were
added to ALCopsSettingsProvider.

Provider hardening (small, but notable)

ALCopsSettingsProvider.DeserializeSettings now swallows JsonException and
returns defaults. Users with a broken alcops.json (e.g. an unknown enum
value) previously would have crashed the analyzer callback and produced an
AD0001; now the rule simply behaves as if no config file existed. A dedicated
test guards this behavior with a fixture containing "ScopeLeavingMode":"NotAnEnumValue".

Schema parity guard

Settings/alcops.schema.json mirrors the three FC0007 enums. Any drift is
caught by StatementBlockSpacingSchema.cs, a NUnit test template that reflects
over the C# enum values and compares them against the enum arrays in the
schema JSON. Reusable pattern for future enum-typed settings.

Test coverage

  • 18 rule tests (positive/negative × control-flow / scope-leaving / else-chain /
    one-liner / disabled-by-default / malformed-config regression).
  • 3 schema-parity tests (one per enum).
  • 21/21 passing; full solution 1407 tests remain green.

Tests follow a single [Test] + [TestCase] pattern across two parametrized
methods (HasDiagnostic / NoDiagnostic) driven by a named settings
dictionary. The fixture folder (HasDiagnostic/ vs NoDiagnostic/) is
resolved implicitly by LoadFixtureAsync — test signatures stay free of
storage-layout details.

Docs

  • New rule instruction file
    .github/instructions/fc0007-statement-blocks-separated-by-blank-line.instructions.md
    with design decisions, test summary counts, known issues, and a Phase-2
    roadmap (CaseBranchMode, GuardClauseMode, LoopControlBeforeMode,
    TreatCommentOnlyLinesAsSeparator, SkipDirectiveBoundaries).
  • common-library.instructions.md updated for the new StatementBlockSpacing
    setting and the "add a new setting" enum-converter walkthrough.
  • Site page will be added in a follow-up to
    alcops.dev
    at content/docs/analyzers/formattingcop/fc0007.md.

Not in this PR (roadmap)

  • CodeFix (comment-attachment edge cases need care).
  • Additional spacing axes listed above.

Implements #423

- Replace WellKnownFixAllProviders.BatchFixer with a custom
  FixAllProvider.Create(FixAllAsync) that removes all matching
  ParameterSyntax nodes in one root.RemoveNodes pass. BatchFixer's
  ReplaceNode(parameterList, …) merge dropped all but one edit when
  multiple parameters in the same signature were flagged.
- Split registration into three CodeActions with distinct EquivalenceKeys
  (.All, .RegularProcedure, .EventSubscriber). FixAllAsync reads
  CodeActionEquivalenceKey to scope the batch; single-fix path uses
  RemoveNode(KeepNoTrivia) so trivia handling matches FixAll.
- Guard fixAllSpans with !IsDefaultOrEmpty and fall back to
  GetDocumentDiagnosticsAsync — the AL SDK reports HasValue=true with
  an empty array under document-scope FixAll.
- Add multiline HasFix fixture and three HasFixAll fixtures
  (single-method dual removal, multi-method, event subscriber). All
  25 ParameterNotReferenced tests pass.
- Update codefix-development, testing, and LC0095 instruction files:
  document the custom FixAllProvider exception, the HasFixAll test
  pattern, new design decisions, and known-issues entry.
…ne" (opt-in)

Introduce FC0007, a disabled-by-default formatting rule that enforces
blank-line separation around multi-line control-flow blocks (if, case,
repeat, while, for, foreach), before scope-leaving statements (exit,
built-in Error), and optionally before the else keyword of if/end-else
constructs.

Configurable via alcops.json under a new StatementBlockSpacing object
with five properties: ControlFlowBefore, ControlFlowAfter,
ScopeLeavingMode, ElseChainBeforeMode, and OneLinerMode. Three are real
C# enums serialized as case-insensitive strings via
JsonStringEnumConverter (net8+) and StringEnumConverter (netstandard2.1).

Also:
- Colocate the new settings type in Settings/StatementBlockSpacingSettings.cs
- Harden ALCopsSettingsProvider: malformed alcops.json now silently
  falls back to defaults (JsonException swallowed in DeserializeSettings)
- Add reusable schema-parity guard test template
  (StatementBlockSpacingSchema) against alcops.schema.json
- 21 tests (18 rule + 3 schema-parity), all green

No CodeFix. Additional spacing axes are captured on the FC0007 Roadmap.
- Detach StatementBlocksSeparatedByBlankLine from the exception-harness
  bridge back to plain DiagnosticAnalyzer. Matches the current policy in
  analyzer-exception-harness.instructions.md — no production analyzer
  adopts the harness until issue ALCops#389 (AL1003 loader bug) is resolved.
- Unify test class on a single [Test]+[TestCase] pattern: two
  parametrized methods (HasDiagnostic / NoDiagnostic) driven by a named
  settings dictionary. Drop the fixtureKind parameter — LoadFixtureAsync
  resolves the folder from the (globally unique) fixture name, removing
  the "HasDiagnostic" string leaking into NoDiagnostic test cases.
- Make StatementBlockSpacingSchema inherit NavCodeAnalysisBase per
  testing convention (mirrors NamingPatternSettings).
- Sync common-library.instructions.md with reality: remove documentation
  for the removed GetSettings(string?) legacy overload and non-existent
  ClearCache() method.
- Drop a stray blank line in FormattingCop DiagnosticDescriptors.
Collection expressions on ImmutableArray<T> require the CollectionBuilder
attribute, which is only present in System.Collections.Immutable v6+ (net8+).
The netstandard2.1 target pulls v5.0.0 and therefore fails with CS9210.

Replace `[]` and `[.. src]` in GetSiblingStatements with
ImmutableArray<T>.Empty and .ToImmutableArray() respectively.

Local build (net8 only) missed this; CI covers all three TFMs.
@Arthurvdv

Copy link
Copy Markdown
Member

Code Review: FC0007 + bundled LC0095 rewrite

Reviewed against origin/main...origin/pr-424 (merge-base 7932e20). All three TFMs (netstandard2.1/net8.0/net10.0) build clean and the new test suites pass (18 FC0007 + 25 LC0095). Findings ordered by severity:

🔴 High — exit/Error() in an else branch produces false positives

src/ALCops.FormattingCop/Analyzers/StatementBlocksSeparatedByBlankLine.cs (AnalyzeExitStatement ~line 163, AnalyzeInvocationExpression ~line 204)

AnalyzeControlFlowStatement deliberately skips statements whose Parent is IfStatementSyntax (line 73), but the exit/Error() paths have no such guard. GetSiblingStatements on an IfStatementSyntax returns [thenStatement, elseStatement], so an exit in the else branch gets index 1 and is checked for a blank line against the then-branch:

if Flag then begin
    DoSomething();
end else
    exit;                                  // FP: "before scope-leaving statement 'exit'"

if Flag then DoSomething() else exit;      // FP: same-line else branch, diff = 0

No fixtures cover an else-branch exit/Error(). Fix: add the same Parent is IfStatementSyntax early-return to both methods, plus fixtures for else exit; / end else Error(...).

🟡 Medium — Comment-only lines count as blank lines, contradicting the rule''s own spec

HasBlankLineBetween (~line 357) only compares token line numbers (diff >= 2). Comments and #region/#pragma directives are trivia, not tokens, so:

Message(''Start'');
// ---- section divider ----
exit;    // NOT flagged: line diff = 2, but no actual blank line

The bundled instruction file states the opposite ("only actual blank lines count"). Either the doc or the implementation is wrong. Fix: decide intended semantics; if comments must not count, inspect trivia between tokens for whitespace-only lines, and add a comment fixture.

🟡 Medium — "StatementBlockSpacing": null in alcops.json crashes the analyzer

ALCopsSettings.StatementBlockSpacing is non-nullable with a new() default, but neither STJ nor Newtonsoft enforces NRT annotations. An explicit null deserializes without JsonException (so the new try/catch never triggers), gets cached per directory, and every subsequent config.ControlFlowBefore access throws NRE — violating the "malformed alcops.json → defaults" contract this PR adds (#328). Fix: null-coalesce at consumption (settings.StatementBlockSpacing ?? new()) or normalize after deserialization.

🟢 Low — LC0095 FixAll span-scope branch is a latent no-op

ParameterNotReferencedCodeFixProvider.cs (FixAllAsync): when fixAllSpans is present, each span covers a whole member; root.FindNode(span) + AncestorsAndSelf().OfType<ParameterSyntax>() resolves the member declaration, never a parameter, so nothing is removed. Currently unreachable (parameterless FixAllProvider.Create only exposes Document/Project/Solution scopes), but the code claims support. Fix: intersect GetDocumentDiagnosticsAsync results with the spans and use diagnostic locations for FindNode.

Minor

  • StatementBlocksSeparatedByBlankLineDescription (resx) promises "case branches should be separated from each other", but CaseBranchMode is roadmap-only and DisabledByDefault.al asserts case branches are not flagged. User-facing metadata should match shipped behavior.
  • EnumProvider.SyntaxKind.CaseElse is added but unused; better deferred to the CaseBranchMode PR.

Scope

The LC0095 changes (codefix rewrite ~350 lines, HasFix/HasFixAll fixtures, resx entries, instruction files) are entirely unrelated to FC0007 and should be split into their own PR — for reviewability and independent revertability.

Verdict

The core FC0007 implementation is well-tested for the paths it covers, but shouldn''t merge as-is: fix the else-branch FP (High) and the null-settings crash, reconcile blank-line semantics with the spec, and split out the LC0095 work.

@Arthurvdv

Copy link
Copy Markdown
Member

Back from the holidays! If I can somehow can assist on the PR, please let me know 🙋

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants