Skip to content

Add #examples and #corrections macros to DRY up rule description#6794

Merged
SimplyDanny merged 20 commits into
realm:mainfrom
ZevEisenberg:examples-macro
Jul 9, 2026
Merged

Add #examples and #corrections macros to DRY up rule description#6794
SimplyDanny merged 20 commits into
realm:mainfrom
ZevEisenberg:examples-macro

Conversation

@ZevEisenberg

@ZevEisenberg ZevEisenberg commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

When I wrote the Example type in #3040, it improved the experience of writing and debugging a rule (because test failures would point at precisely the example case that failed the test). It did this by bundling up the file and line number for each example, which can be passed along to the test assertion functions. But it came at the expense of a lot of repetitive Example("...") call sites.

Now that macros exist, I propose using them to keep the benefits of Example while cleaning up the call sites. The new macros transform [String] and [String: String] to [Example] and [Example: Example], respectively.

If it's helpful for review, here's a Claude-generated script that confirms that the changes to the rule files are just adding the macro and removing the Example wrapper:
review_mechanical_changes.py

Alternatives/Questions

  1. If you like this format, would you want to add an internal regex lint rule to encourage using the macros in new rules? It's encouraged in the doc comment, but not required.
  2. Instead of individual macros for array and dictionary, I considered a macro that would replace the entire init of RuleDescription. But this seemed like an overreach, and would prevent falling back to passing variables or non-literals, which limits flexibility and experimentation.
  3. How do we feel about naming? I think we could consolidate both macros into a single overloaded one called #examples, but that seemed too cute and not clear enough at the point of use.

@SwiftLintBot

SwiftLintBot commented Jun 28, 2026

Copy link
Copy Markdown
1 Warning
⚠️ Big PR
19 Messages
📖 Building this branch resulted in a binary size of 28357.8 KiB vs 28240.51 KiB when built on main (0% larger).
📖 Linting Aerial with this PR took 0.67 s vs 0.67 s on main (0% slower).
📖 Linting Alamofire with this PR took 0.93 s vs 0.93 s on main (0% slower).
📖 Linting Brave with this PR took 6.02 s vs 5.99 s on main (0% slower).
📖 Linting DuckDuckGo with this PR took 25.98 s vs 25.79 s on main (0% slower).
📖 Linting Firefox with this PR took 10.53 s vs 10.47 s on main (0% slower).
📖 Linting Kickstarter with this PR took 7.46 s vs 7.38 s on main (1% slower).
📖 Linting Moya with this PR took 0.37 s vs 0.36 s on main (2% slower).
📖 Linting NetNewsWire with this PR took 2.38 s vs 2.38 s on main (0% slower).
📖 Linting Nimble with this PR took 0.56 s vs 0.54 s on main (3% slower).
📖 Linting PocketCasts with this PR took 6.97 s vs 6.91 s on main (0% slower).
📖 Linting Quick with this PR took 0.37 s vs 0.36 s on main (2% slower).
📖 Linting Realm with this PR took 2.43 s vs 2.44 s on main (0% faster).
📖 Linting Sourcery with this PR took 1.49 s vs 1.53 s on main (2% faster).
📖 Linting Swift with this PR took 4.31 s vs 4.29 s on main (0% slower).
📖 Linting SwiftLintPerformanceTests with this PR took 0.16 s vs 0.16 s on main (0% slower).
📖 Linting VLC with this PR took 1.09 s vs 1.09 s on main (0% slower).
📖 Linting Wire with this PR took 15.32 s vs 15.3 s on main (0% slower).
📖 Linting WordPress with this PR took 9.81 s vs 9.78 s on main (0% slower).

Generated by 🚫 Danger

@ZevEisenberg ZevEisenberg changed the title Add #examples and #examplesDictionary macros to DRY up rule description Add #examples and #examplesDictionary macros to DRY up rule description Jun 29, 2026

@SimplyDanny SimplyDanny left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, @ZevEisenberg, for keeping an eye on test example setup over the years! 👍

A few thoughts, remarks and ideas from my side:

  • Ideally, protocol conformances support default parameters at some point. Then we can make Example ExpressibleByStringLiteral and so avoid any manual conversion.
  • I'd prefer to only have a single macro #examples that accepts either an array or a dictionary. Usage should be clear from the context of usage alone.
  • It might be worth supporting some of Examples parameters as macro arguments as well. There are a few cases where a parameter must be applied to all examples or at least a subset of them.
  • To further reduce boilerplate, we could try to get rid of the brackets wrapping the examples by using variadic parameters. At least for arrays, that should work.
  • The dictionary case is a little more tricky. Instead of a dictionary, we could have a list of CorrectionExample objects that wrap two Examples. They could be created with a custom operator =>, e.g.
    corrections: #examples(
      "let foo = 1" => "let bar = 1",
      ...
    )

Comment thread Source/SwiftLintCore/Helpers/Macros.swift Outdated
Comment thread Source/SwiftLintCore/Helpers/Macros.swift Outdated
Comment thread Source/SwiftLintCoreMacros/Examples.swift Outdated
Comment thread Source/SwiftLintCoreMacros/Examples.swift Outdated
@ZevEisenberg

ZevEisenberg commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

It might be worth supporting some of Examples parameters as macro arguments as well. There are a few cases where a parameter must be applied to all examples or at least a subset of them.

@SimplyDanny can you say more? Does this affect any code in the project today, or is it hypothetical?

@ZevEisenberg ZevEisenberg marked this pull request as draft June 30, 2026 18:27
@ZevEisenberg

Copy link
Copy Markdown
Contributor Author

@SimplyDanny can you say more? Does this affect any code in the project today, or is it hypothetical?

Oh, actually, I see what you mean. I had missed a bunch of places where we call Example directly, and some of them use extra utility functions like wrapExample in DiscouragedOptionalBooleanRuleExamples.swft. I'll see what I can do about those.

@ZevEisenberg

Copy link
Copy Markdown
Contributor Author

Another one I'm seeing in DuplicateImportsRuleExamples.swift:

            Example("""
            ↓import A.B.C
            ↓import A.B
            import A

            """, excludeFromDocumentation: true): Example("""
                import A

                """),

The approach I'm currently trying: making #examples and #examplesDictionary take more permissive types, allowing us to mix and match strings with Example instances, and then adding an overloaded init to Example that takes an Example and then replaces its file and line.

I'll also revisit the macro names as you suggest. What do you think about changing the dictionary one to #corrections([Example: Example])?

@SimplyDanny

Copy link
Copy Markdown
Collaborator

I'll also revisit the macro names as you suggest. What do you think about changing the dictionary one to #corrections([Example: Example])?

Also fine. However, just using #examples as well would not require people to remember two names (I know, it's a weak argument. 😅). By the type expected by corrections, the compiler shouldn't be confused about which variant to pick.

@ZevEisenberg

Copy link
Copy Markdown
Contributor Author

One other option I'm thinking about, and I'd love to get your take on: it's going to be annoying to have two different styles for examples:

#examples([
    "Some example",
    Example("exclude this one", excludeFromDocumentation: true),
])

I'm wondering what you might think about using a fluent API like this:

#examples([
    "Some example",
    "exclude this one".excludeFromDocumentation(),
])

There could also be a function for overriding configuration. They would fit nicely with the existing skip* functions, which I would also augment to be callable on strings(after first converting them to Examples). It would make the Example type more complex than you were proposing, but I think it would make the call sites nicer and more discoverable.

@ZevEisenberg

Copy link
Copy Markdown
Contributor Author
  • I opted to rename #examplesDictionary to #corrections. Have a look, and if you still prefer, I'll see if I can get the overloaded #examples version working.
  • Added a fluent API for turning strings into examples, and used that to convert a bunch more examples in the app.
  • As a result of the fluent API, I had to relax the argument of #examples to [Any] and the argument of #corrections to [AnyHashable: Any]. If you pass something bogus, the macro will expand it but things like Example(1234) will then fail to compile. Does this seem acceptable?
  • I added import SwiftLintCore to all files that use the macro, since it fixes an issue I was seeing where syntax highlighting for macros wasn't working, and "Expand Macro" was missing from the context menu.
  • I can try the variadic and custom operator suggestions if you'd like, but my sense is that a) an extra character at the top and bottom of a big stack of examples doesn't really matter that much, and b) the custom operator is a pretty heavy solution, and may increase cognitive load for people reading it, whereas everyone already knows how a dictionary looks and works.

This is ready for another review!

@ZevEisenberg ZevEisenberg marked this pull request as ready for review July 1, 2026 04:27
@ZevEisenberg ZevEisenberg force-pushed the examples-macro branch 2 times, most recently from 78a2147 to 71690c3 Compare July 2, 2026 17:45
@ZevEisenberg

Copy link
Copy Markdown
Contributor Author

Fixed a build failure

@ZevEisenberg ZevEisenberg changed the title Add #examples and #examplesDictionary macros to DRY up rule description Add #examples and #corrections macros to DRY up rule description Jul 3, 2026

@SimplyDanny SimplyDanny left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two more remarks. I'm okay with the other decisions about naming, variadics and the custom operator.

Comment thread Source/SwiftLintCore/Models/Example.swift
Comment thread Source/SwiftLintCore/Helpers/Macros.swift Outdated
@ZevEisenberg

ZevEisenberg commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

I'm applying the feedback. By the way, the changes in this PR will obfuscate a lot of history on affected lines. Do you have any interest in using a blame.ignoreRevsFile to keep history on most affected lines? See: https://akrabat.com/ignoring-revisions-with-git-blame/, https://michaelheap.com/git-ignore-rev/. The only annoying thing is that you have to run a local command to turn it on, so fresh clones wouldn't get it by default.

We use it at work, but all our projects have a shared setup script you must run first, so it's not a hassle. But I'm not sure if there's something equivalent here, so it would just be a convenience for those who know about it (and for those browsing GitHub, where it's picked up automatically). Possibly more trouble than it's worth, but I wanted to mention it.

@ZevEisenberg

Copy link
Copy Markdown
Contributor Author

I pushed the asExample fix and a conflict resolution because those were straightforward. I'm still wrestling with the ExpressibleBy... thing; it may not be so straightforward, but we will see.

@ZevEisenberg

ZevEisenberg commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Note

This comment was written by Claude (via Claude Code), acting on @ZevEisenberg's behalf.

Following up on the suggestion to make Example conform to ExpressibleByStringLiteral so the macros can declare [Example] and produce type errors instead of failed macro expansions.

I ended up using a different mechanism that gives the same diagnostic win without a runtime footgun: a marker protocol ExampleConvertible that only String and Example conform to.

public protocol ExampleConvertible {}
extension String: ExampleConvertible {}
extension Example: ExampleConvertible {}

@freestanding(expression)
public macro examples(_ examples: [any ExampleConvertible]) -> [Example] = 

@freestanding(expression)
public macro corrections(_ examples: [AnyHashable: any ExampleConvertible]) -> [Example: Example] = 

An invalid element is now rejected at the call site. Given:

#examples([
    "let x = 1",
    42,          // ← not a valid example
])
Before — [Any] After — [any ExampleConvertible]
before after
Error is attached to the macro expansion ("In expansion of macro 'examples' here") and phrased in terms of generated code: Cannot convert value of type 'Int' to expected argument type 'Example'. You have to read the expansion to find the bad element. A single error on the literal you actually wrote (the 42 line): Cannot convert value of type 'Int' to expected element type '…' (aka 'any ExampleConvertible').

(In swift build / CI logs the [Any] version additionally repeats that diagnostic once per expansion attempt against @__swiftmacro_… paths; the protocol version emits a single call-site error.)

One caveat worth flagging: #corrections keys stay AnyHashable, because a Dictionary key must be Hashable and an any ExampleConvertible existential is not. So a wrong-typed key still surfaces as a macro-expansion error, but values are fully type-checked.

Why not ExpressibleByStringLiteral? (what we tried first, and why it doesn't work)

Making Example: ExpressibleByStringLiteral satisfies the macro's compile-time argument check, but it breaks every bare Example("…") call at runtime.

With both init(_ code: String) and the conformance's init(stringLiteral:) visible, Swift prefers the zero-default init(stringLiteral:) over the 11-default init(_ code:) when only a single string argument is present:

Example("a")                     // → init(stringLiteral:)   💥 traps
Example("a", file: "x", line: 1) // → init(_ code:)          ✅ (extra args disambiguate)

The conformance's init(stringLiteral:) has to fatalError — it must never actually run, since the macro rewrites each element into a real Example(_:) call — so every Example("…") in the rules and tests would trap. Confirmed the hard way via the test suite.

Things that did not rescue it:

  • @_disfavoredOverload on init(stringLiteral:) — does not override Swift's "fewer defaults wins" preference.
  • Renaming/removing init(_ code:) to kill the ambiguity — works, but requires touching ~230 call sites.

The marker protocol sidesteps all of it: no literal conformance, no trapping initializer, no @_disfavoredOverload, and the same call-site diagnostic.

@ZevEisenberg

Copy link
Copy Markdown
Contributor Author

ready for another look

@SimplyDanny SimplyDanny left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still in favor of the ExpressibleByStringLiteral/ExpressibleByStringInterpolation variant. It really gets the types of #examples and #corrections correct and throwing an error in its required initializer will rarely be a problem (if at all, only in test code).

To help overload resolution, the original Example initializer can use a named argument for the code. We won't use it often after this refactoring, so that would be acceptable.

Comment thread Source/SwiftLintCore/Models/Example.swift Outdated
@SimplyDanny

Copy link
Copy Markdown
Collaborator

Do you have any interest in using a blame.ignoreRevsFile to keep history on most affected lines? See: https://akrabat.com/ignoring-revisions-with-git-blame/, https://michaelheap.com/git-ignore-rev/. The only annoying thing is that you have to run a local command to turn it on, so fresh clones wouldn't get it by default.

Just adding it doesn't hurt. If applied, it helps to get blame clearer. If not ... 🤷‍♂️

So let's add it after merging when we have the final commit hash.

@ZevEisenberg

Copy link
Copy Markdown
Contributor Author

To help overload resolution, the original Example initializer can use a named argument for the code. We won't use it often after this refactoring, so that would be acceptable.

How do you feel about Example(code: "foo") vs "foo".asExample() as the canonical way to construct an Example in code where you have to do that, such as in utility functions that return Example?

@ZevEisenberg

Copy link
Copy Markdown
Contributor Author

Ok, changes pushed with ExpressibleBy* conformance as you suggested. I was able to remove some Example(code:) calls and replace them with string literals, and there are also some .asExample() uses. It almost seems like we'd want to unify them, but I'm not sure it's worth it.

I also cleaned up a few concatenated strings that were clearer as multiline strings. I'd love to convert a gazillion strings to raw strings to make them more readable, but I will resist for this PR.

Ready for another look!

@SimplyDanny SimplyDanny left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more remarks. Otherwise, looks pretty good now!

Comment thread Source/SwiftLintCore/Helpers/Macros.swift Outdated
Comment thread Source/SwiftLintCore/Helpers/Macros.swift Outdated
Comment thread Source/SwiftLintCore/Models/Example.swift
Comment thread Source/SwiftLintCore/Models/Example.swift Outdated
Comment thread Source/SwiftLintCore/Models/Example.swift Outdated
@SimplyDanny

Copy link
Copy Markdown
Collaborator

To help overload resolution, the original Example initializer can use a named argument for the code. We won't use it often after this refactoring, so that would be acceptable.

How do you feel about Example(code: "foo") vs "foo".asExample() as the canonical way to construct an Example in code where you have to do that, such as in utility functions that return Example?

Both are fine for now. Unification can happen in a follow-up PR. 😉

…ons.

# Conflicts:
#	Source/SwiftLintBuiltInRules/Rules/Style/ClosureSpacingRule.swift
ZevEisenberg and others added 19 commits July 9, 2026 08:38
Co-authored-by: Danny Mösch <danny.moesch@icloud.com>
Co-authored-by: Danny Mösch <danny.moesch@icloud.com>
…rt as many remaining Example call sites into bare strings.
… Xcode syntax highlighting and makes the Expand Macro command show up and work more reliably.
Give the #examples and #corrections macros precise parameter types
([any ExampleConvertible] and [AnyHashable: any ExampleConvertible])
instead of [Any]/[AnyHashable: Any]. Passing an unsupported element
type now fails as a type error on the offending literal rather than as
a macro-expansion failure inside generated code. Only String and
Example conform to the marker protocol.

Also drop the vestigial `+ ""` in LineLengthRule, since a bare String
expression now type-checks directly as an example element.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y typed Examples as input. This requires changing Example("...") to Example(code: "...") or "...".asExample(), but the tradeoff is that it enables more precise and actionable errors if you pass the wrong type of thing to the #examples and #corrections macros. Also, did some cleanup: converted a few concatenated strings to multi-line strings.
@ZevEisenberg

Copy link
Copy Markdown
Contributor Author

Rebased and pushed fixes in response to comments.

@SimplyDanny

Copy link
Copy Markdown
Collaborator

Ready to merge from your point of view, @ZevEisenberg?

@ZevEisenberg

Copy link
Copy Markdown
Contributor Author

Ready to merge from your point of view, @ZevEisenberg?

Yes!

@SimplyDanny SimplyDanny merged commit 9c8b1d3 into realm:main Jul 9, 2026
28 checks passed
@ZevEisenberg ZevEisenberg deleted the examples-macro branch July 9, 2026 21:11
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.

3 participants