Skip to content

Latest commit

 

History

History
436 lines (372 loc) · 18.6 KB

File metadata and controls

436 lines (372 loc) · 18.6 KB

Annotations

A comprehensive Dart analyzer plugin that provides powerful annotations and static analysis rules for them.

Annotations

  • @Throws: Declare the exceptions that a function can throw, enabling better documentation and static analysis of error handling.
  • @IgnoreThrows / @ignoreThrows: Suppress this plugin's throws diagnostics for the annotated declaration — handle_throwing_invocations (and its test-directory companion) for invocations inside it, and declare_thrown_exceptions / require_throws_declaration for direct throws inside it. See Quick Fixes & Assists below.

Rules

  • handle_throwing_invocations: Ensures that any function that calls a function annotated with @Throws either catches the declared exceptions or also declares them with @Throws.
  • handle_throwing_invocations_in_tests: The same rule, reported under its own diagnostic code for code under test/, integration_test/, test_driver/, testing/, tool/, and benchmark/ directories, so it can be toggled independently of the main rule. See Configuration.
  • declare_thrown_exceptions (opt-in): A function annotated with @Throws must cover every exception type it directly throws (subtypes of a declared type count; the degenerate @Throws({}) is treated as a blanket declaration and never flagged). Flagged at the throw expression.
  • require_throws_declaration (opt-in, strict): Any function that directly throws a non-excluded exception type must declare it with @Throws. Never double-reports with declare_thrown_exceptions. Enable the two as a pair: this rule checks only that an annotation exists — once any @Throws is present, checking its completeness is declare_thrown_exceptions' job, so with this rule alone a partial annotation silences the remaining throws. See Undeclared-throws configuration.

Quick Fixes & Assists

When handle_throwing_invocations reports an unhandled invocation, your IDE (IntelliJ/Android Studio, VS Code — anything speaking to the Dart Analysis Server) offers these quick fixes:

  • Wrap in 'try' with an 'on' clause per declared exception
  • Wrap in generic 'try-catch'
  • Add missing 'on' clauses to the enclosing 'try' — when the call is already inside a try that doesn't cover the declared types
  • Add '@Throws' to the enclosing function — propagate instead of handle; merges into an existing @Throws set
  • Suppress with '@ignoreThrows' — inserts a bare @ignoreThrows annotation on the enclosing function/method/constructor/top-level variable/field declaration (adding the hyper_lints import if needed), silencing the diagnostic by suppression instead of handling or propagating it

For the undeclared-throws rules (declare_thrown_exceptions / require_throws_declaration), the IDE offers:

  • Declare the thrown type in '@Throws' — creates a @Throws annotation on the enclosing function/method/getter/setter/constructor, or merges the thrown type into an existing set
  • Suppress with '@ignoreThrows' — the same suppress fix listed above for the call-site rule, registered for these diagnostics too

@IgnoreThrows / @ignoreThrows

Annotate a function, method, getter, setter, field, top-level variable, or constructor to suppress handle_throwing_invocations for invocations inside it:

@Throws({FormatException})
void parseData(String input) { /* may throw FormatException */ }

@ignoreThrows // bare form: suppresses every declared exception type
void callerThatAcceptsAnyRisk() {
  parseData('...'); // not flagged
}

@IgnoreThrows({FormatException}) // typed form: only these types
void callerThatAcceptsFormatExceptionOnly() {
  parseData('...'); // not flagged: FormatException is covered
}

The typed set only suppresses invocations whose entire declared @Throws set is covered by it — a call declaring a type outside the set still lints. ignoreThrows is shorthand for the bare IgnoreThrows() constructor.

Coverage notes

  • The rule also flags bare (unqualified) getter reads — e.g. a top-level or local @Throws getter read as plain riskyValue, not just obj.riskyValuecompound-assignment reads (riskyValue += 1), and setter writes: an assignment to a @Throws setter (riskyValue = 1 or obj.riskyValue = 1) invokes the setter and is checked like any other invocation. When a compound assignment hits both an annotated getter and an annotated setter, one diagnostic is reported, not two.
  • Operator invocations are enforced too: a + b, a[0], a[0] = v, -a, x++, and x += b check the resolved operator method's @Throws; compound forms report one diagnostic carrying the union of the getter/setter/operator contracts.
  • .ignore() and unawaited(...) (matched by name, so re-exports work too) on a flagged Future-returning call are treated as handled, including through a .then()/.whenComplete()/.timeout() chain, e.g. unawaited(risky().then((_) {}));. Note unawaited marks a future as deliberately fire-and-forget — it discards errors rather than handling them; treating it as "handled" is a design decision matching the SDK idiom's intent.

Two assists are available on any try statement (no diagnostic needed):

  • Add 'on' clause — inserts a template on Exception catch (e) clause
  • Narrow 'catch' to declared exception types — when a broad catch swallows specific @Throws types thrown inside the try body, inserts specific on clauses above it

Fixes are async- and scope-aware:

  • await insertion — when the flagged call returns a Future and the enclosing function body is async, the try-catch fixes insert await so the handler actually catches. In a sync body, an un-awaited async call throws after the try/catch has already returned, so no wrap fix or Add missing 'on' clauses can ever silence the diagnostic for it — those fixes aren't offered for un-awaited async calls in sync bodies (await it, or see the SDK's unawaited_futures lint). Add '@Throws' is also NOT offered for a fire-and-forget call: an un-awaited, un-returned Future fails out-of-band, so its error never reaches the caller's future and a @Throws on the caller cannot cover it (the rule likewise refuses such an annotation as propagation).
  • Declaration splitting — wrapping final x = risky(); when x is used later splits the declaration out of the try as a nullable variable (int? x;), keeping later code in scope. Later uses may need ! at typed use sites; the fix does not rewrite them.
  • Apply in file — the two wrap fixes (Wrap in 'try' with 'on' clauses and Wrap in generic 'try-catch') offer an "everywhere in file" variant in the IDE. (dart fix on the command line cannot apply plugin fixes yet; see dart-lang/sdk#53402.)
  • Narrowing is nesting-aware — the narrow-catch assist ignores exception types already handled by nested try statements.
  • Type matching uses real subtype checks everywhere. Only a bare catch, on Object, and on dynamic are universal — on Exception and on Error are NOT catch-alls for any rule, since (for example) on Error can never catch a type that implements Exception.

After upgrading the plugin, restart the Dart Analysis Server (IntelliJ: Dart Analysis tool window → restart icon) to pick up the fixes.

Installation

Requires Dart 3.11+ (Flutter with Dart 3.11+).

Add this package as a dependency:

dependencies:
  hyper_lints: ^1.3.0

Configuration

You can configure it in your analysis_options.yaml. Every rule is opt-in: all four rules (handle_throwing_invocations, handle_throwing_invocations_in_tests, declare_thrown_exceptions, and require_throws_declaration) are OFF by default and only take effect once explicitly listed as true in the diagnostics: map below — listing one does not enable any other.

plugins:
  hyper_lints:
    version: ^1.3.0
    diagnostics:
      handle_throwing_invocations: true
      # Set to `false` (or omit this line entirely) to silence `test/`,
      # `tool/`, `benchmark/`, and `integration_test/` code instead of
      # flagging it.
      handle_throwing_invocations_in_tests: true

handle_throwing_invocations_in_tests reports the same problem under its own diagnostic code for code under test/, integration_test/, test_driver/, testing/, tool/, and benchmark/ — it has its own on/off switch, so you can enable the rule in your main code while disabling it for tests (or vice versa):

diagnostics:
  handle_throwing_invocations: true
  handle_throwing_invocations_in_tests: false

Migrating from an earlier version: if your existing config lists only handle_throwing_invocations: true, it will keep flagging lib/ code but will no longer flag test/, tool/, benchmark/, or integration_test/ code after upgrading, since handle_throwing_invocations_in_tests is a separate, independently opt-in rule rather than something the main rule's true also implies. Add handle_throwing_invocations_in_tests: true to your config to keep flagging that code too.

Two 1.3.0 changes can alter existing diagnostics:

  • @Throws/@IgnoreThrows are recognized only when declared by the hyper_lints package (re-exports still work). If you vendored copies of the annotation classes, all diagnostics for them stop — depend on the real annotations instead.
  • Multi-type @Throws({A, B}) invocations now require all declared types to be handled (catching just one no longer silences the rest), so new diagnostics may appear on call sites that were previously under-checked. Handling composes: types caught locally are subtracted, and only the remainder needs declaring or suppressing.

Undeclared-throws configuration

Enable the rules in the diagnostics: map like any other — as a pair: require_throws_declaration only checks that a @Throws annotation exists, and declare_thrown_exceptions only checks a present annotation's completeness, so enabling just one leaves the other half of the contract unenforced. Then (optionally) configure them via a top-level hyper_lints: key — the analyzer's plugin config schema only supports per-rule on/off, so list/flag options live in their own section:

plugins:
  hyper_lints:
    version: ^1.3.0
    diagnostics:
      declare_thrown_exceptions: true
      require_throws_declaration: true

hyper_lints:
  # Class names never required in @Throws (matched by simple name; a
  # listed type's SUBTYPES are excluded too).
  exclude_throws: [TelemetryException]
  # By default anything assignable to dart:core's Error (StateError guards,
  # ArgumentError, custom Error subclasses) is exempt — Effective Dart
  # treats Errors as programmer bugs, not API contract. Set true to check
  # them too.
  include_errors: false

Notes:

  • Throws inside closures and local functions don't count against the enclosing function; a local try that catches the type (and doesn't rethrow — or throw e the caught variable, which is treated the same) silences the rules. An unannotated local function's body is not checked by any rule; a local function that itself carries @Throws is verified by declare_thrown_exceptions (its call sites are enforced by handle_throwing_invocations either way). Closures passed directly as invocation arguments are assumed to run synchronously (forEach, map, sort, ...); this deliberately trades away strictness for known-deferred APIs (Timer(...), Future.delayed(...), event handlers), whose callbacks regain 1.2.0-era false negatives — a future refinement may special-case them.
  • require_throws_declaration also fires on main() and other entrypoints — no caller consults their @Throws, so annotate, catch, or suppress with @ignoreThrows there as you prefer; the rule doesn't special-case entrypoints.
  • Awaiting a wrapper that receives a risky future (await consume(risky())) is assumed to forward the failure to the await; a wrapper that silently drops its argument's future defeats that assumption. This mirrors the argument-closure synchrony assumption and errs permissive by design.
  • Field and top-level-variable initializers, and constructor initializer lists, are not checked by the undeclared-throws rules in this iteration (final x = throw ...; is exempt) — they have no function body to attribute the throw to.
  • Config values are strict: include_errors must be a literal YAML boolean (true/false — not yes/on), and unknown or mistyped keys are silently ignored (the analyzer's own options validation doesn't see this section). A bare string is accepted for a single exclude_throws name.
  • The config walk searches ancestor directories all the way up, exactly like the analyzer's own options lookup — so a monorepo's root analysis_options.yaml governs member packages here precisely when its plugins: section does. (The flip side, also matching the analyzer: an analysis_options.yaml in a directory above your repo would be consulted too.)
  • Only types assignable to Exception or Error are checked: throw 'message' and other non-throwable objects are the SDK's only_throw_errors lint's domain, not a declarable contract. Generator bodies (sync*/async*) are never checked — their throws surface on iteration, where no try around the call can catch them.
  • @Throws is per-declaration, not inherited: an override that throws must re-declare, even when the interface member is annotated. This is deliberate — call sites resolve statically, so the contract has to be present on every static target a caller might resolve to.
  • Catch-and-propagate of an unannotated callee's exception is not reported by any rule: in try { callback(); } on X { rethrow; } (or throw e), the rules know nothing about what an unannotated callback throws — the clause author is asserting knowledge the analysis doesn't have. When the try body's exception origin is reportable (a direct throw, or a @Throws callee), the origin reports and the propagation correctly doesn't double-report.
  • @ignoreThrows / @IgnoreThrows({...}) on the declaration suppresses these rules the same way it does handle_throwing_invocations.
  • The section is looked up in the nearest analysis_options.yaml, including anything it include:s by relative path (nearest section wins: the including file beats its includes, a later include beats an earlier one). The winning file's section is taken wholesale — there is no per-key merging across the chain, so a project-local section fully replaces one from a shared base file. package: includes are not resolved — if your shared config lives in a package-included file, copy the hyper_lints: section into the project's own options file. Edits to any file in the chain take effect on the next analysis (same as toggling a rule in diagnostics:); only creating a brand-new analysis_options.yaml nearer to your sources needs an analysis-server restart. This deviates from the analyzer's own per-key deep-merge of options sections and is intentional v1 behavior — if you split hyper_lints: keys across an include chain, the losing file's keys are silently dropped, so keep the whole section in one file.
  • Both rules skip test/, integration_test/, test_driver/, testing/, tool/, and benchmark/ code, and — unlike handle_throwing_invocations — have no _in_tests companion yet, so that code is never checked by them.
  • exclude_throws / include_errors affect only the two undeclared-throws rules; handle_throwing_invocations never consults them (its contract comes from the @Throws annotations themselves).
  • A finally block that unconditionally throws replaces the in-flight exception at runtime; the rules don't model that (such code is broken by construction — the original exception is silently lost), so the original throw is still reported.

Usage

Basic Usage

@Throws({CustomException})
void riskyFunction() { /* ... */ }

// ✅ Specific exception type
try {
  riskyFunction();
} on CustomException catch (e) {
  // handle
}

// ✅ General Exception catch
try {
  riskyFunction();
} on Exception catch (e) {
  // handle
}

// ✅ Catch-all
try {
  riskyFunction();
} catch (e) {
  // handle
}

// ✅ Rethrowing with @Throws
@Throws({CustomException})
void callerFunction() {
  riskyFunction(); // OK because caller also declares @Throws
}

// ❌ Not declaring @Throws in caller
void anotherCallerFunction() {
    riskyFunction(); // Warning: callerFunction should declare @Throws
}

// ❌ Wrong exception type caught
try {
  riskyFunction();
} on StateError catch (e) {
  // This doesn't catch CustomException!
  // Warning: Unhandled exception from invocation annotated with @Throws
}

// ❌ Rethrowing catch clause
try {
  riskyFunction();
} on CustomException {
  rethrow; // The exception still escapes (even after logging first)!
  // Warning: Unhandled exception from invocation annotated with @Throws
  // Catch it in an outer try, or declare @Throws on the enclosing function.
}

// ❌ Undeclared direct throw (declare_thrown_exceptions)
@Throws({CustomException})
void submit(bool bad) {
  if (bad) throw FormatException('bad'); // Warning: FormatException is
                                         // not declared in @Throws
  throw StateError('disposed'); // OK: Errors are exempt by default
}

Async Functions

@Throws({CustomException})
Future<void> riskyAsyncFunction() async { /* ... */ }

// ✅ Awaited call inside try-catch
try {
  await riskyAsyncFunction();
} catch (e) {
  // handle
}

// ✅ Using .catchError()
riskyAsyncFunction().catchError((e) {
  // handle
});

// ✅ Using .then() with onError
riskyAsyncFunction().then((_) {
  // success
}, onError: (e) {
  // handle
});

// ✅ Chained .then().catchError()
riskyAsyncFunction()
  .then((_) => print('success'))
  .catchError((e) => print('error'));

// ❌ Non-awaited call - try-catch won't catch async exceptions!
try {
  riskyAsyncFunction(); // Warning: async call not awaited
} catch (e) {
  // This won't catch the exception!
}

// ❌ Unhandled async call
riskyAsyncFunction(); // Warning: Unhandled exception

Contributing

Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.

License

This project is licensed under the MIT License - see the LICENSE file for details.