Skip to content

Adopt SwiftLint#166

Merged
odrobnik merged 42 commits into
mainfrom
lint
May 19, 2026
Merged

Adopt SwiftLint#166
odrobnik merged 42 commits into
mainfrom
lint

Conversation

@odrobnik
Copy link
Copy Markdown
Contributor

Summary

Adopt SwiftLint as the lint gate, then clean up violations incrementally — file by file — so CI verifies each step.

This PR replaces #161 (which had a tangled bisect/merge history). The new branch starts from current main and adds:

  1. Commit 1 (this commit) — SwiftLint infrastructure only:
    • .swiftlint.yml: indented switch cases + RFC-named identifier exemptions
    • .editorconfig: 4-space indent, LF, trim trailing whitespace
    • .github/workflows/swiftlint.yml: macOS runner, swiftlint lint --strict
    • scripts/hooks/pre-commit: auto-fix + strict lint on staged files
    • scripts/install-hooks.sh: one-time hook installer

CI will be red at this commit, on purpose — it surfaces the baseline (~2348 violations across 167 files).

Plan for follow-up commits

  1. Tree-wide swiftlint --fix pass — mechanical: trailing whitespace, syntactic sugar, vertical whitespace, trailing commas, trailing newlines, implicit optional init. Drops baseline by ~1450.
  2. File-by-file cleanups for the structural rules that aren't auto-fixable: switch_case_alignment (527), line_length (167), identifier_name (62), function_body_length (31), cyclomatic_complexity (26), and the long tail.

Each follow-up commit pushes so CI can verify the violation count drops.

Test plan

  • CI baseline visible on commit 1
  • CI green after final cleanup commit
  • swift build, swift test remain passing throughout

🤖 Generated with Claude Code

odrobnik and others added 30 commits May 18, 2026 18:05
Introduces the lint gate infrastructure only; existing violations
will be fixed in follow-up commits so CI surfaces them per change.

- .swiftlint.yml: indented switch cases, identifier-name exemptions
  for RFC 5322/2971/3501 protocol names (to, cc, os, on, or)
- .editorconfig: 4-space indent, LF endings, trim trailing whitespace
- .github/workflows/swiftlint.yml: macOS runner, brew install swiftlint,
  swiftlint lint --strict
- scripts/hooks/pre-commit: auto-fix + strict lint on staged Swift files,
  refuses to run when staged file also has unstaged edits
- scripts/install-hooks.sh: one-time per-clone hook installer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical pass: swiftlint lint --fix corrected ~1465 violations across
137 files. All auto-fixable rules — no behavior changes.

Rules fixed by --fix: trailing_whitespace, syntactic_sugar,
vertical_whitespace, trailing_comma, trailing_newline,
implicit_optional_initialization, unneeded_synthesized_initializer.

Remaining: 883 violations from rules that need structural changes
(switch_case_alignment, line_length, identifier_name, function_body_length,
cyclomatic_complexity, file_length, type_body_length, force_try, force_cast,
etc.) — addressed in follow-up commits, file by file.

swift build clean, 284 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical line wraps to bring each file under the 120-char limit.
Each file had exactly one line_length violation; no logic changes.

Files:
- Core/Commands/BaseMailCommandHandler.swift
- Extensions/Email+CustomDebugStringConvertible.swift
- Extensions/EmailAddress+StringConversion.swift
- IMAP/IMAP/Commands/SearchCommand.swift
- IMAP/IMAP/Handler/LoginHandler.swift
- IMAP/IMAPIdleConfiguration.swift
- IMAP/Models/Message+CustomStringConvertible.swift
- IMAP/UndefinedFolderError.swift
- SMTP/Handlers/BaseSMTPHandler.swift
- Tests/SwiftIMAPTests/EMLTests.swift
- Tests/SwiftIMAPTests/ICSCalendarDetectionTests.swift
- Tests/SwiftIMAPTests/IMAPConnectionTLSModeTests.swift
- Tests/SwiftIMAPTests/SendDraftTests.swift

Violations: 883 → 870 (-13). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each file had exactly one violation:

- Data+Utilities.swift: replace `guard let _ = String(data:encoding:)`
  with `guard String(data:encoding:) != nil` (unused_optional_binding)
- CommandHandler.swift: add swiftlint:disable for the Void-cast on
  succeedWithResult — the cast is already guarded by an explicit
  `ResultType.self == Void.self` check on the line above (force_cast)
- QuotaHandler.swift: inline the `q` temporary into `self.quota = ...`
  (identifier_name 'q')
- IMAPCommandQueue.swift: rename `op` parameter to `operation`
  (identifier_name 'op')
- LoginAuthHandler.swift: reformat init parameters one-per-line
  (vertical_parameter_alignment)
- SMTPLogger.swift: add swiftlint:disable for the InboundIn -> String
  cast — MailLogger declares InboundIn=Any but SMTPLogger only ever
  receives String responses (force_cast)

Violations: 870 → 864 (-6). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reformats case/default labels inside switch statements one level
deeper to match the `switch_case_alignment: indented_cases: true`
config — Xcode's default "Indent switch statement case labels in"
setting. Applied via:

    swiftformat --rules indent --indent-case true --swift-version 6.0 \
        Sources Tests Demos

110 files reformatted. Pure indentation change — diff is 3182/3182,
balanced exactly because lines moved but no content changed. No
behavior changes. swift test 284/284.

Violations: 864 → 345 (-519). All switch_case_alignment violations
cleared; ~9 lines now exceed 120 chars due to deeper indent — addressed
in next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Applied via:

    swiftformat --rules wrapArguments \
        --max-width 120 \
        --wrap-arguments before-first \
        --wrap-parameters before-first \
        --swift-version 6.0 \
        Sources Tests Demos

43 files reformatted. Mechanical line wraps — no behavior changes.
swift test 284/284.

Violations: 345 → 262 (-83). Cleared 84 line_length violations;
one additional function_body_length surfaced (a function grew by the
wrap; will address with the other structural rules).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Manual line wraps for files with exactly one remaining line_length
violation. Mix of comment splits, class-conformance wraps, ternary
splits, and argument extractions.

Files: Email+StringInitializers, String+Charset, SearchHandler,
XOAUTH2AuthenticationHandler, BodyStructure+CustomStringConvertible,
Message, MessagePart+BodyStructure, MessagePart, SMTPError,
ExtendedSearchHandlerTests, IMAPNamedConnectionTests, MessageBodyTests,
MessagePartBodyStructureTests, ProblematicMessageTests, String+MIMETests.

Violations: 262 → 248 (-14). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- MIME/EMLParser.swift: wrap String(data:encoding:) fallback and
  filename suffix extraction
- IMAPTestServer.swift: split CAPABILITY and SELECT response strings
  across multiple concatenations
- XOAUTH2AuthenticationHandlerTests.swift: reformat withTimeout and
  setUpChannel signatures one-parameter-per-line

Violations: 248 → 242 (-6). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- IMAPNamedConnection.swift: reformat three function signatures
  one-parameter-per-line (fetchMessageInfosBulk, executeCommand,
  expungeMoveFallback)
- ExtendedSearchHandler.swift: wrap class conformance list and
  convertNIOSet signature
- FetchMessageInfoHandlerTests.swift: split two fetchResponse helpers
  with multi-line signatures and concatenated response strings
- QuotedPrintableTests.swift: split long MIME-encoded test data across
  string concatenations
- String+HostnameTests.swift: split the IPv6 regex pattern and two
  hostname patterns across alternation boundaries
- Demos/SwiftIMAPCLI/main.swift: split three error/info messages

Violations: 242 → 225 (-17). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- IMAPConnection.swift (5 fixes): log messages bound to String let
  before passing to logger (Logger.Message doesn't accept +
  concatenation directly); also wrap executeCommandBody signature
- SMTPServer.swift (4 fixes): same Logger.Message pattern for two
  log statements; wrap sendRawMessage and executeCommand signatures
- Email+Demo.swift (4 fixes): wrap textBody string; surround the
  CSS-heavy multi-line HTML template with
  // swiftlint:disable/enable line_length (CSS rules don't wrap
  cleanly and this is a demo)

Violations: 225 → 212 (-13). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three patterns:
- Doc comments: split long /// parameter and throws descriptions
  across multiple lines (lines 128, 289, 308, 539, 1135-1136, 2236, 2260, 2408)
- Function signatures: reformat generic functions and append/fetch
  variants one-parameter-per-line (idle, fetchMessageInfosBulk,
  fetchMessageInfos, fetchMessages, expungeMoveFallback,
  recursivelyFetchParts, executeCommand, append × 2)
- Log messages: bind to String let then interpolate (Logger.Message
  doesn't accept + concatenation directly); used for IDLE reliability
  task start, reconnect failure logs, DONE checkpoint, error retry
- Gmail special-use detection: extract matchesGmailFolder helper to
  collapse 6 nearly-identical caseInsensitiveCompare chains

Violations: 212 → 185 (-27). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- IMAPLogger.swift: surround compile-time regex literals with
  swiftlint:disable/enable force_try (NSRegularExpression with a
  string-literal pattern cannot fail at runtime)
- SMTPServer.swift (3 sites): replace try! channel.pipeline.syncOperations
  calls with plain try and route failures through makeFailedFuture; the
  SSL branch already has a do/catch, and the plaintext branch gets one
  added so add-handler failures surface as a failed future rather than
  crashing
- String+HostnameTests.swift (4 sites): swiftlint:disable for the four
  NSRegularExpression initializers — all use constant string-literal
  patterns defined immediately above

Violations: 185 → 176 (-9). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace .data(using: .utf8)! with Data(_.utf8) across test files.
Data(stringLiteral.utf8) is non-optional and always succeeds, while
the .data(using:)! pattern returns Data? and crashes on nil — for
literal strings the conversion can't fail, so the explicit cast just
adds noise.

Files: DataUtilitiesTests, MessageBodyTests,
MessagePartBodyStructureTests, PipelinedFetchTests, SMTPTests.

Violations: 176 → 160 (-16). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add swiftlint:disable:next with one-line reasons for each
String(decoding:as:) call where lossy UTF-8 decoding is intentional:

- IMAPServer.swift: raw message data may contain non-UTF-8 sequences
  (8-bit, binary content), and we'd rather preserve the message with
  replacement characters than fail the Sent-folder append
- Mailbox.swift: mailbox names may use modified-UTF-7 (RFC 3501) which
  is not valid UTF-8; replacement characters > failing
- String+QuotedPrintable.swift: best-effort fallback when bytes don't
  match the declared charset
- SendContentCommand.swift: messages may carry 8-bit or non-UTF-8
  bytes that still need to wire to the server

Violations: 160 → 156 (-4). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both signatures are dictated externally or read clearer with explicit
parameters; add swiftlint:disable:next with one-line reasons:

- Demos/Swift{IMAP,SMTP}CLI/OSLogHandler.swift: 7-parameter log()
  signature is the LogHandler protocol contract from swift-log
- Tests/FetchMessageInfoHandlerTests.swift: 6-int makeDate helper
  reads more clearly at test call sites than a wrapper struct

Violations: 156 → 153 (-3). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical renames to satisfy identifier_name (3-40 chars):
- fd -> fileDescriptor (4 sites: handleClient param, sendLine param,
  stop loop var, listen-cancel handler)
- n -> bytesRead / bytesWritten (read/write return values)
- ct -> contentType / mediaType / rawContentType (depending on context)
- s -> value (quote helper param)

No behavior changes; swift test 284/284.

Violations: 153 → 144 (-9).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical renames in two test functions: pendingCount and
failIndividualHandlers. p1/p2 -> promise1/promise2, h1/h2 ->
handler1/handler2. No behavior changes; swift test 284/284.

Violations: 144 → 136 (-8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Test helpers used a/b for equality and hash comparisons; expand to
lhs/rhs which match the typical Swift convention for comparison
operands and satisfy identifier_name. No behavior changes.

Violations: 136 → 130 (-6). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
c/c1/c2 used in `.and` and `.or` switch cases - rename to
child / left / right which match the structural role of each
branch. No behavior changes; swift test 284/284.

Violations: 130 → 125 (-5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical identifier_name renames to satisfy the 3-40 char rule:

Single-letter loop / temp vars:
- IMAPConnection.swift, Section.swift, Email.swift, Data+Decoding.swift:
  i -> index
- Identification.swift: k/v -> key/value
- Flag.swift: s -> raw

Two-char abbreviations expanded:
- IMAPServer.swift: shorten the long testing accessor name
  primaryConnectionCertificateVerificationPolicyForTesting (55 chars
  exceeded 40) -> primaryConnectionVerifyPolicyForTesting
- ExtendedSearchHandler, FetchMessageInfoHandler, NamespaceHandler:
  c, dc, ns -> count, dateComponents, namespace
- Message.swift, EMLSerializer.swift, EMLParser.swift:
  ct -> contentType / lowercasedContentType
- Data+Decoding.swift: hi/lo -> highNibble/lowNibble
- Int+Utilities.swift: kb/mb/gb -> kilobytes/megabytes/gigabytes
- SendContentCommand.swift: cr/lf -> carriageReturn/lineFeed
- MessageID.swift, FetchMessageInfoHandler.swift: s -> trimmed / input
- EMLParser.swift: n1 -> firstHexIndex
- String+HostnameTests.swift: ip -> address
- main.swift (demo): ts -> timestamp, f -> flagList

IMAPTransportSecurityTests.swift updated to call the renamed accessor.
No behavior changes; swift test 284/284.

Violations: 125 → 93 (-32).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mixed bag of tail-end fixes:
- Email+Demo.swift: trim trailing whitespace inside the HTML template
- ExtendedSearchHandler.swift: split a 124-char doc comment
- Mailbox.swift: swiftlint:disable nesting for Mailbox.Info.Attributes —
  the path is public API and flattening would break consumers
- IMAPConnection.swift / XOAUTH2AuthenticationHandlerTests.swift:
  swiftlint:disable large_tuple for two intentional 3-tuples (internal
  pipelined-request bookkeeping; test helper return type)
- IMAPServer.swift / Section.swift / ProblematicMessageTests.swift:
  convert `for ... { if pred { ... } }` to `for ... where pred`
- MessageIdentifierSet.swift / SMTPServer.swift: swiftlint:disable
  force_cast at three sites where the cast is guarded by an explicit
  type check on the preceding line (UID vs SequenceNumber generic
  bridging; LoginAuthCommand result-type bridging)

Violations: 93 → 83 (-10). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move idle(), idle(on:configuration:), idle(on:cycleInterval:), and
done() to a new IMAPServer+Idle.swift extension file. Widen access on
storage and helpers that the extension now references:

  private -> internal: host, port, primaryConnection, idleConnections,
  authentication, IdleConnection struct, Authentication enum,
  makeIdleConnection, endIdleSession
  private extension -> internal extension: resolveMailboxPath helpers

IMAPServer.swift: 2480 -> 2099 lines, actor body 1014 -> 729 lines.
No behavior changes; swift test 284/284. Lint count unchanged because
the big idle(on:configuration:) function (267 lines, cyclomatic 31)
still trips function_body_length / cyclomatic_complexity in its new
home — to be addressed by extracting IMAPResilientIdleRunner in a
follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 267-line idle(on:configuration:) body (cyclomatic complexity 31)
is replaced with delegation to a non-isolated IMAPResilientIdleRunner
enum that holds the cycle/recovery logic. Split across three files
mirroring the original PR design:

- IMAPServer+IdleRunner.swift: state types (IdleCycleState,
  IdleCycleTrigger, IdleCycleResult, IdleCycleContext) and the
  top-level run() / makeCycleLogger / reconnectDelay helpers
- IMAPServer+IdleRunner+Cycle.swift: runOneCycle,
  raceIdleStreamAgainstTimer, applyCycleResult, handleStreamEnded,
  handleTimer
- IMAPServer+IdleRunner+Recovery.swift: PostIdleEvents,
  collectPostIdleEvents, advanceTimersAfterCheckpoint,
  attemptRoutineReconnect, handleCycleError

Also adds the IdleSessionRequest bundle and noop()'s definition
moves into IMAPServer+Idle.swift (removed from the main file to
avoid duplicate declaration).

IMAPServer.swift: 2099 -> 2093 lines, actor body 729 -> 726 lines.
IMAPServer+Idle.swift idle(on:configuration:) drops to ~40 lines.
swift test 284/284.

Violations: 83 -> 81 (-2). The orchestration is now splittable into
per-phase static helpers, so subsequent function-body-length / cyclomatic
work in the IDLE area is contained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move methods from the IMAPServer actor into per-feature extension
files, mirroring the original PR 161 design. Each extension is
isolated to the actor since they live in the same module.

New files:
- IMAPServer+Append.swift: append(rawMessage:), helpers
- IMAPServer+Connection.swift: connect, disconnect, login,
  authenticate*, id, connection(named:), close-all
- IMAPServer+Fetch.swift: fetchStructure, fetchPart,
  fetchPartsPipelined, fetchRawMessage, fetchAllMessageParts,
  fetchMessage, fetchMessageInfo*, fetchAndDecodeMessagePartData,
  recursivelyFetchParts
- IMAPServer+FolderOperations.swift: createMailbox, deleteMailbox,
  renameMailbox, closeMailbox, unselectMailbox
- IMAPServer+Folders.swift: listMailboxes, ensureMailboxesLoaded
- IMAPServer+Helpers.swift: resolveMailboxPath, normalizedMailboxName,
  canonicalizeCRLF, makeInternalDate
- IMAPServer+Mailbox.swift: mailboxStatus, expunge variants
- IMAPServer+Manipulation.swift: move, copy, store, executeMove
- IMAPServer+Namespace.swift: fetchNamespaces
- IMAPServer+Quota.swift: getQuota, getQuotaRoot
- IMAPServer+Search.swift: search, extendedSearch
- IMAPServer+Send.swift: append(email:), createDraft, sendDraft
- IMAPServer+SpecialUse.swift: listSpecialUseMailboxes,
  moveToTrash, archive, markAsJunk, saveAsDraft

IMAPServer.swift: 2093 -> 217 lines (just the actor declaration,
state, init, and updateMailboxes/updateSpecialMailboxes helpers).
Actor body 726 -> ~120 lines.

Renamed testing accessor: primaryConnectionVerifyPolicyForTesting ->
certificatePolicyForTesting (the original PR's name). Updated
IMAPTransportSecurityTests.swift accordingly.

Violations: 81 -> 76 (-5). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move methods from the IMAPConnection class into per-feature
extension files, mirroring the original PR 161 design.

New files:
- IMAPConnection+Authentication.swift: login, authenticate*,
  IDLE-required authenticate paths
- IMAPConnection+CommandExecution.swift: executeCommand,
  executeCommandBody, recycleConnectionIfBufferedTermination
- IMAPConnection+Connection.swift: connectBody, disconnectBody,
  channel-setup helpers, doneBody
- IMAPConnection+Events.swift: drainBufferedEvents and other event
  helpers
- IMAPConnection+Idle.swift: idle, doneBody, IDLE handler glue
- IMAPConnection+PipelinedFetch.swift: pipelined FETCH dispatch
- IMAPConnection+TLS.swift: TLS upgrade logic

IMAPConnection.swift: 1362 -> 230 lines, class body 1074 -> ~85 lines
(just the typealiases, state, init, and three top-level entry
points that wrap commandQueue.run).

Violations: 76 -> 66 (-10). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move methods from the SMTPServer actor into per-feature extension
files mirroring the original PR 161 design.

New files:
- SMTPServer+Authentication.swift: authenticate(username:password:),
  loginAuth, plainAuth, auth helpers
- SMTPServer+Capabilities.swift: capability parsing, supports8BitMIME,
  maximumMessageSizeOctets, EHLO handling
- SMTPServer+Connection.swift: connect, disconnect, STARTTLS upgrade,
  resolveLegacyTransportSecurity, makeCommandHandler
- SMTPServer+Send.swift: sendEmail, sendRawMessage, prepareEmailForSend

SMTPServer.swift: 1021 -> 370 lines, actor body 501 -> ~80 lines.

Violations: 66 -> 63 (-3). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the 1700+ MIME-type dictionaries out of String+MIME.swift into
6 partial-dictionary files (alphabetical buckets A/B/C × the two
directions), mirroring the original PR 161 design.

New files:
- String+MIME+ExtensionToMimeTypeA.swift / ...B.swift / ...C.swift
- String+MIME+MimeTypeToExtensionA.swift / ...B.swift / ...C.swift

Each dictionary table is the second half of a static computed
property that joins three buckets — keeps each file well under
the 1000-line file_length limit.

String+MIME.swift: 1834 -> 62 lines (just the public extension
methods that look up the dictionaries).

Violations: 63 -> 62 (-1, file_length on this file cleared).
swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply the original PR 161 splits for three more medium-sized files.

EMLParser.swift: 608 -> 75 lines, struct body 385 -> ~30 lines.
Split across 5 feature files:
- EMLParser+AddressAndDate.swift: address-list parsing, date parsing
- EMLParser+Headers.swift: header parsing, MessageInfo construction
- EMLParser+Multipart.swift: multipart body splitting
- EMLParser+Parameters.swift: content-type / disposition parameter
  extraction
- EMLParser+RFC2047.swift: =?CHARSET?B?...?= encoded-word decoding

IMAPNamedConnection.swift: 448 -> 94 lines, actor body 334 -> ~50
lines. Split across 5 feature files:
- IMAPNamedConnection+Fetch.swift
- IMAPNamedConnection+Idle.swift
- IMAPNamedConnection+Mailbox.swift
- IMAPNamedConnection+Manipulation.swift
- IMAPNamedConnection+Search.swift

String+QuotedPrintable.swift: 477 -> ~50 lines. Split across 3 files:
- String+QuotedPrintable+Decoding.swift
- String+QuotedPrintable+Encoding.swift
- String+QuotedPrintable+MIMEHeader.swift

FetchMessageInfoHandler.swift was not split — the 105fc09 version
predates the named-time-zone normalization added by 717b981/d8c5bb8.
That file still has type_body_length / file_length / cyclomatic_complexity
violations; will address with a tailored split that preserves the
named-zone parsing in a follow-up commit.

Violations: 62 -> 53 (-9). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply the 105fc09 splits for the test files that exceeded
file_length / type_body_length:

- IMAPTestServer.swift: 540 -> 122 lines. Split into +CommandHandling,
  +MaildirLoading, +ResponseBuilding extensions
- ExtendedSearchHandlerTests.swift: 404 -> 159 lines, body 293 -> ~150.
  Split into +Fallback, +Helpers, +WireFormat extensions (separate
  helper struct ExtendedSearchHandlerTestHelpers)
- XOAUTH2AuthenticationHandlerTests.swift: 332 -> 132 lines, body
  274 -> ~120. Split into +Failure, +Helpers extensions (separate
  fixture struct XOAUTH2TestFixtures)
- EmailMessageConversionTests.swift: 407 -> ~140 lines. Split into
  +Fixtures, +MessageFromEmail extensions (separate fixture struct
  EmailMessageConversionFixtures)
- SMTPTests.swift: 601 -> 59 lines. Split into +DotStuffing,
  +ErrorHandling, +MessageContent, +MessageID, +SendValidation,
  +ServerCapabilities extensions

Violations: 53 -> 39 (-14). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move ParsableCommand subcommands and environment helpers out of the
837-line main.swift into per-command files under Commands/ and a
shared Environment.swift, mirroring the 105fc09 split:

- Commands/DownloadAttachment.swift
- Commands/Fetch.swift
- Commands/Folders.swift
- Commands/Idle.swift
- Commands/List.swift
- Commands/Move.swift
- Commands/Search.swift
- Commands/Search+Criteria.swift
- Environment.swift

main.swift: 837 -> 56 lines (logger, async helpers, root IMAPTool
command, and entry point only).

Violations: 39 -> 31 (-8). swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
odrobnik and others added 3 commits May 18, 2026 20:41
…foHandler split

Add scoped `// swiftlint:disable ... // swiftlint:enable` blocks around
the 19 function/initializer bodies whose complexity or length is inherent
to their purpose (response-payload switches, recursive body-structure
parsers, multi-format date parsers, MIME assembly, Codable Decoders,
LogHandler protocol witnesses). Each disable block carries a one-line
justification in the function's doc comment so the rationale is
discoverable without grep.

Also split FetchMessageInfoHandler.swift one more time:
- FetchMessageInfoHandler.swift: 431 -> 297 lines, class body 293 ->
  ~250. The static parsing helpers (parseEnvelopeDate, the named-zone
  table, parseMessageIDs, shouldCollectThreadingHeaders) move to
- FetchMessageInfoHandler+Parsing.swift (141 lines, new file)

Final couple of fixups:
- Demos/SwiftIMAPCLI/Environment.swift: wrap a 123-char throw line
- Demos/SwiftSMTPCLI/Email+Demo.swift: trim trailing whitespace inside
  the HTML template
- Mailbox.swift: remove a duplicate doc comment that was orphaning

🟢 Violations: 31 -> 0 in 277 files. swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splitting IMAPTestServer.swift into +CommandHandling / +MaildirLoading /
+ResponseBuilding extensions introduced a build-macos hang that exceeded
the 10-minute job timeout on three consecutive runs. iOS and Linux
builds completed normally on the same commits, and the SwiftLint job
finished green in 14s — so this is specifically a macOS test-runner
interaction with the split.

This matches the macOS hang the original PR 161 bisect commits were
investigating; reverting the split is the lower-risk path.

Restore the single-file IMAPTestServer.swift and gate the structural
SwiftLint rules off for this one file via a top-of-file
swiftlint:disable / bottom-of-file swiftlint:enable pair, with a comment
naming the macOS hang as the reason.

Violations: still 0 across 274 files. swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After reverting just IMAPTestServer.swift, build-macos still hit the
10-minute timeout while build-ios (3m41s) and build-linux (5m29s)
passed. The hang isn't unique to IMAPTestServer's POSIX-socket code —
it's somehow tied to having Swift Testing tests split across multiple
files in this repo's macOS test runner.

Revert the remaining 4 test-file splits:
- ExtendedSearchHandlerTests.swift (and remove +Fallback, +Helpers,
  +WireFormat)
- XOAUTH2AuthenticationHandlerTests.swift (and remove +Failure,
  +Helpers)
- EmailMessageConversionTests.swift (and remove +Fixtures,
  +MessageFromEmail)
- SMTPTests.swift (and remove +DotStuffing, +ErrorHandling,
  +MessageContent, +MessageID, +SendValidation, +ServerCapabilities)

Gate the structural rules (file_length, type_body_length, large_tuple
on a test helper) off at the top of each restored file with a
swiftlint:disable / swiftlint:enable pair and a one-line note.

Violations: still 0 across 261 files. swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
odrobnik and others added 9 commits May 18, 2026 21:32
force_cast (3 sites):
- CommandHandler.swift: replace `() as! ResultType` (guarded by an outer
  `ResultType.self == Void.self`) with `() as Any as? ResultType`, which
  performs the same runtime type check without the bang
- SMTPLogger.swift: replace `unwrapInboundIn(data) as! String` with a
  guarded `as? String` that drops non-String inbound (MailLogger's
  declared InboundIn is Any but the SMTP pipeline only emits Strings)
- SMTPServer.swift: add a `makeHandler(commandTag:promise:)` factory
  method to the SMTPCommand protocol with a default implementation that
  forwards to the handler's required init; LoginAuthCommand overrides
  it to inject username/password. The downstream makeCommandHandler in
  SMTPServer no longer needs an `as! EventLoopPromise<AuthResult>`

cyclomatic_complexity (5 sites):
- Attachment.swift: replace the file-extension -> MIME-type if/else
  chain with a `[String: String]` lookup table
- FetchPartHandler.swift: hoist the `!didFinishPart` guard above the
  FetchResponse switch so each case stays single-purpose
- AuthHandler.swift / AuthHandlerStateMachine.swift: split the nested
  switch over auth method × LOGIN-state into a one-shot helper
  (PLAIN/XOAUTH2) and a state-machine helper (LOGIN), with the two
  challenge-then-credential transitions consolidated through
  advanceLogin
- EMLSerializer.swift: extract writeHeaders/writeBody helpers plus
  appendHeaderIfPresent/appendListHeader so serialize() is a
  three-step orchestrator

Violations stay at 0. swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
function_body_length (2 sites):
- Email+Message.swift: extract collectAttachments(from:) and
  nonStandardHeaders(from:) helpers; the init becomes a 20-line
  orchestrator that delegates the attachment/header pulls
- MessageInfo.swift: collapse the 70-line Codable Decoder.init into a
  single self.init(...) call by extracting decodeMessageID and
  decodeReferences for the three legacy-vs-current decode forks

force_try (2 sites):
- IMAPLogger.swift: replace `try!` on compile-time-constant regex
  patterns with a static makeRegex(_:) helper that surfaces a
  preconditionFailure on the impossible programmer error
- String+HostnameTests.swift: same makeRegex(_:) pattern for the
  4 IPv4/IPv6/hostname regex compilations

function_parameter_count (1 site):
- FetchMessageInfoHandlerTests.swift: collapse the 6-int makeDate
  helper into a single DateComponents parameter; callers wrap the
  named ints in `DateComponents(year:month:day:hour:minute:second:)`

Violations stay at 0. swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cyclomatic_complexity / function_body_length (3 sites):
- NoopHandler.swift: split handleUntagged(ResponsePayload) into per-arm
  helpers (handleMailboxData, handleMessageData, handleConditionalState),
  and collapse the 7 "ignore unsolicited X" arms in MailboxData into a
  default-case log so each helper stays under the complexity threshold
- IdleHandler.swift: same split pattern; bye-detection moves to
  handleConditionalState which returns Bool to signal session
  termination
- SelectHandler.swift: collapse the nested
  conditionalState/ok/code-switch + mailboxData-switch into two
  per-payload helpers (applyResponseCode, applyMailboxData); the
  outer override is now a 5-arm switch

line_length (4 sites):
- IMAPServer+IdleRunner.swift: bind the cycle-start info message to
  a String let then interpolate; same pattern as the IMAPServer
  log-message fixes earlier in this PR
- IMAPServer+IdleRunner+Recovery.swift: same pattern for the three
  reconnect-failure log lines

Violations stay at 0. swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cyclomatic_complexity (2 sites):
- Mailbox.Attributes(from:): extract per-attribute mapping into a
  `attribute(for:)` helper and replace the special-use if/else chain
  with a small `[(token, Attributes)]` lookup table
- FetchMessageInfoHandler.updateHeader: split the MessageAttribute
  switch into `applyEnvelope(_:to:)` and a static `date(from:)` helper
  for ServerMessageDate -> Date

cyclomatic_complexity + function_body_length (1 site):
- Email.constructContent: 155-line inline MIME assembler split into a
  short writeHeaders/writeBody dispatcher plus four per-structure
  helpers (writeMultipartMixed, writeHTMLWithInlineAttachments,
  writeAlternativeTextAndHTML, encodedAttachmentBody). Encoding /
  text-body selection and the three boundary tokens move into a new
  private MIMEBuildContext struct that the helpers thread through.

Violations stay at 0. swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cyclomatic_complexity + function_body_length (2 sites):
- ExtendedSearchHandler.processResponse: split the 60-line, nested-
  triple-pattern function into a small dispatcher plus three helpers:
  failOnNegativeStatus (BAD/NO -> failWithError), ingest(MailboxData)
  for ESEARCH / SEARCH / SORT fanout, and ingest(SearchReturnData) for
  the per-ESEARCH-attribute updates (MIN/MAX/ALL/COUNT/PARTIAL)
- SearchCriteria.toNIO: the 83-line / cyclomatic-39 enum-case switch
  was already grouped by intent in the original code; lift that intent
  into the type. The public method now dispatches in order through
  flagSearchKey, textSearchKey, dateSearchKey, sizeSearchKey, and
  compositeSearchKey (AND/OR/NOT/UID/MODSEQ). flagSearchKey further
  fans out into positive/negative/keyword helpers to keep each under
  the per-function complexity bar.

Violations stay at 0. swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
function_body_length (2 sites):
- String+Charset.stringEncoding(for:): a 110-line function whose bulk
  was the two big mapping tables. Lift the tables to file-private
  let constants (charsetAliases, knownCharsetEncodings), pull
  normalization into canonicalCharsetLabel(_:), and isolate the
  CoreFoundation path in coreFoundationEncoding(forIANAName:). The
  public entry point is now a 15-line dispatcher.
- MessageBodyTests.testFetchMessagesSequentialOrder: the 56-line test
  body was driven by an inline FakeServer class declaration. Hoist
  FakeSequentialFetchServer to file scope (a single test references
  it) so the @test body becomes a 10-line assertion.

Violations stay at 0. swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cyclomatic_complexity + function_body_length (1 site, large):
- The public init recursively walked the BodyStructure but folded all
  of singlepart extraction, content-type rendering, disposition/filename
  resolution, the message/rfc822 envelope decode, and the embedded-
  body recursion into one 188-line body. Lift each concern into its
  own helper:
    - appendSinglepart(_:sectionPath:) / appendMultipart(_:sectionPath:)
      mutating helpers for the two outer cases
    - appendEmbeddedRFC822 for the nested message/rfc822 body walk
    - contentType(for:) renders type/subtype[; charset=...] from
      the typed `kind`
    - dispositionAndFilename(for:contentType:) resolves the
      Content-Type / Content-Disposition / text-calendar default /
      Content-ID fallback / MIME-decoded filename chain (with
      filenameFromContentTypeParameters extracted)
    - embeddedMessageInfo(from:) builds the envelope-derived MessageInfo
    - filename(forEmbeddedSubject:) does the subject-sanitising
      `subject.eml` fallback

The public init is now a 10-line dispatcher. swift test 284/284.
Violations stay at 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
optional_data_string_conversion (4 -> 1 site):
- Introduce `Data.lossyUTF8String` — a single, documented entry point
  for the lossy `String(decoding: data, as: UTF8.self)` pattern, with
  the swiftlint:disable scoped to that one property. Use it from the
  four lossy-decoding sites that previously each carried their own
  disable comment: String+QuotedPrintable+Decoding (fallback after
  String(data:encoding:)), IMAPServer+Send (raw message append),
  Mailbox.init(nio:) (mailbox-name modified-UTF-7), and
  SendContentCommand.toCommandString (DATA payload).

large_tuple (1 site):
- XOAUTH2AuthenticationHandlerTests.setUpChannel: replace the
  3-tuple return with a `ChannelSetup` struct (channel/promise/handler).
  Each test now reads `let setup = try await setUpChannel(...)` and
  pulls `setup.channel` / `setup.promise` explicitly.

Violations stay at 0. swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
force_cast (2 -> 0 sites):
- MessageIdentifierSet.toNIOSet<NIOType>: the generic-NIOType bridge
  used to build a same-shape `MessageIdentifierRange<NIOType>` from a
  concrete `<UID>` or `<SequenceNumber>` via `as!`. Route it through
  the existing concrete-typed `toNIOSet()` overloads on
  `MessageIdentifierSet<UID>` and `MessageIdentifierSet<SequenceNumber>`
  using `as?` checks, then `as?` the typed NIO result back to the
  caller's generic. Each bridge is now a runtime-checked conditional
  cast (which doesn't trigger force_cast); the unreachable third
  branch surfaces as preconditionFailure.

function_body_length (1 site):
- Email.demo (SwiftSMTPCLI): the 89-line demo helper folded download,
  validation, HTML template, and attachment construction inline. Lift
  downloadSwiftLogo() (network + 200-OK check) and htmlTemplate(...)
  (the CSS/HTML, retaining a scoped line_length disable since the
  body is intentionally one long CSS rule per line) into separate
  helpers. The public demo() shrinks to ~30 lines.

Violations stay at 0. swift test 284/284.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@odrobnik odrobnik marked this pull request as ready for review May 19, 2026 04:08
@odrobnik odrobnik merged commit 1baa0bc into main May 19, 2026
4 checks passed
@odrobnik odrobnik deleted the lint branch May 19, 2026 04:08
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cc8ee789b6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +143 to +146
writeHTMLWithInlineAttachments(context: context, into: &content, terminating: false)
} else {
// Just text, no HTML
content += "--\(mainBoundary)\r\n"
content += "Content-Type: text/plain; charset=UTF-8\r\n"
content += "Content-Transfer-Encoding: \(textEncoding)\r\n\r\n"
content += "\(textBody)\r\n\r\n"
}

// Add regular attachments
for attachment in self.regularAttachments {
content += "--\(mainBoundary)\r\n"
content += "Content-Type: \(attachment.mimeType)\r\n"
content += "Content-Transfer-Encoding: base64\r\n"
content += "Content-Disposition: attachment; filename=\"\(attachment.filename)\"\r\n\r\n"

// Encode attachment data as base64
let base64Data = attachment.data.base64EncodedString(options: [.lineLength76Characters, .endLineWithCarriageReturn])
content += "\(base64Data)\r\n\r\n"
}

// End main boundary
content += "--\(mainBoundary)--\r\n"
} else if hasHtmlBody && hasInlineAttachments {
// HTML with inline attachments but no regular attachments - use multipart/related
content += "Content-Type: multipart/related; boundary=\"\(relatedBoundary)\"\r\n\r\n"
content += "This is a multi-part message in MIME format.\r\n\r\n"

// First add the multipart/alternative part
content += "--\(relatedBoundary)\r\n"
content += "Content-Type: multipart/alternative; boundary=\"\(altBoundary)\"\r\n\r\n"

// Add text part
content += "--\(altBoundary)\r\n"
content += "Content-Type: text/plain; charset=UTF-8\r\n"
content += "Content-Transfer-Encoding: \(textEncoding)\r\n\r\n"
content += "\(textBody)\r\n\r\n"

// Add HTML part
content += "--\(altBoundary)\r\n"
content += "Content-Type: text/html; charset=UTF-8\r\n"
content += "Content-Transfer-Encoding: \(textEncoding)\r\n\r\n"
content += "\(htmlBody ?? "")\r\n\r\n"

// End alternative boundary
content += "--\(altBoundary)--\r\n\r\n"

// Add inline attachments
for attachment in self.inlineAttachments {
content += "--\(relatedBoundary)\r\n"
content += "Content-Type: \(attachment.mimeType)"
content += "; name=\"\(attachment.filename)\"\r\n"
content += "Content-Transfer-Encoding: base64\r\n"

if let contentID = attachment.contentID {
content += "Content-ID: <\(contentID)>\r\n"
}

content += "Content-Disposition: inline; filename=\"\(attachment.filename)\"\r\n\r\n"

// Encode attachment data as base64
let base64Data = attachment.data.base64EncodedString(options: [.lineLength76Characters, .endLineWithCarriageReturn])
content += "\(base64Data)\r\n\r\n"
content += "Content-Type: multipart/alternative; boundary=\"\(context.altBoundary)\"\r\n\r\n"
writeAlternativeTextAndHTML(context: context, into: &content, terminating: false)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Close nested multipart boundaries before attachments

When an email has both HTML and regular attachments, this now calls the nested multipart/related/multipart/alternative writers with terminating: false, but writeMultipartMixed never emits the corresponding closing --related-- or --alt-- boundary before starting the next --mainBoundary attachment part. That produces malformed MIME for common HTML-with-attachment messages (and for HTML+inline+regular attachments), so clients may parse regular attachments as part of the nested multipart or ignore them; the previous implementation closed the nested boundary before adding regular attachments.

Useful? React with 👍 / 👎.

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.

1 participant