A Debug Log Is Not Allowed to Run Your Code

Debug logging is supposed to observe a program. That sounds too obvious to deserve a rule.

Then put a cursor on prefs in this line and ask a source-editing tool to “log what is under the cursor”:

await prefs.reload();

What should it insert?

If the tool expands the selection to the widest nearby expression, it may generate this:

print('${await prefs.reload()}');

Congratulations. The debugger has changed the program by calling reload() a second time.

While building Turbo Flutter Log, I found that automatic log insertion is not primarily a string-generation problem. It is a source-semantics problem with three separate boundaries:

  1. Is the cursor on a value?
  2. Which expression can be observed without evaluating more code?
  3. Where can a complete logging statement legally be inserted?

Get any one of those wrong, and a convenience command produces code that does not compile—or worse, code that compiles and behaves differently.

Round 1: The Cursor Is Not an AST Node

Developers place cursors on characters. Compilers understand syntax trees. Between those worlds sits a surprising amount of ambiguity.

Consider these positions:

final resolvedCurrency = await repository.load();

The cursor may rest on final, the type, the variable name, repository, load, or even whitespace. Only some of those positions refer to a value worth logging.

Keywords, type names, method names, and named-argument labels are not log values. Generating interpolation from them can create invalid Dart or a misleading constant-looking string.

Turbo Flutter Log therefore refuses unsupported positions instead of inventing an expression. There is one deliberate usability exception: when the caret rests on a declaration modifier or type, the tool can resolve the variable being declared. In the line above, a caret on final can still mean resolvedCurrency because the assignment establishes that intent.

The useful principle is default-deny expression selection. When the editor cannot establish a value, “nothing to log” is safer than “this looks close enough.”

Round 2: Climb the Expression, but Stop Before Behavior

The Dart analysis server can return nested selection ranges from the cursor outward. For user inside user.profile.name, that chain can grow from the identifier to the complete member access.

The widest value reference is often what the developer intended:

user -> user.profile -> user.profile.name

But the next parent may be a call:

prefs -> prefs.reload -> prefs.reload()

That is where climbing must stop.

Turbo Flutter Log selects prefs, not prefs.reload() and not await prefs.reload(). The generated log observes the receiver without invoking the method again:

await prefs.reload();
print('prefs: $prefs');

The same caution applies to assignments, top-level operators, argument lists, map entries, and statement terminators. They indicate that the selected range has stopped being one stable value reference.

This is not about building a perfect Dart parser inside an extension. The analysis server already owns semantic structure. The extension's job is to define a conservative walk over that structure and to have a text-based fallback when semantic data is unavailable.

Round 3: The Next Line May Still Belong to the Statement

Once the expression is known, the tool still has to place a new statement.

“Insert on the next line” works for this:

final value = repository.load();

It fails for multiline syntax:

final value = repository.load(
  accountId: account.id,
  refresh: true,
);

The physical line after account.id is still inside the argument list. Dropping print() there produces invalid Dart.

The same problem appears inside parameter lists and switch expression arms. A valid logging statement belongs after the complete containing statement, not after the nearest line that looks related.

Turbo Flutter Log therefore resolves the statement boundary and inserts after it while inheriting the surrounding indentation. Multiple cursors are handled as multiple insertion sites rather than as one text blob.

This boundary is easy to miss because editors are line-oriented. Programming languages are not.

Round 4: A Log Has to Survive the Formatter

Even a correctly placed statement can become unmanageable after dart format.

Turbo Flutter Log marks generated statements so later commands can comment, uncomment, delete, or correct only logs created by the extension. Those cleanup commands rely on the marker and callee remaining discoverable together.

If a long log wraps across several lines, the cleanup surface becomes less reliable. So the statement builder reads the project's configured page width and drops optional context in a fixed order until the log fits.

Location goes first. Then the level tag. Then the enclosing class and function. The expression being logged is never dropped because that would leave an impressively formatted statement with no purpose.

The final statement may be detailed:

print('🎯 · [DEBUG] · palette_repository.dart:44 · PaletteRepository.load · resolvedCurrency: $resolvedCurrency');

Or compressed:

print('🎯 · PaletteRepository.load · resolvedCurrency: $resolvedCurrency');

The important part is that formatting behavior participates in the feature contract. “The extension inserted valid text” is not sufficient if the project's formatter immediately transforms that text into a shape the extension can no longer manage.

Round 5: Separate Pure Decisions From Editor Effects

The implementation becomes much easier to test when source decisions are pure functions.

Expression selection accepts strings from a selection-range chain and returns the chosen expression. Statement building accepts a configuration and context and returns Dart source. Indentation and page-width calculations are similarly isolated.

The VS Code command layer owns the effects: asking the analysis server, reading the document, applying edits, and showing warnings.

That split lets tests cover uncomfortable cases without launching an editor for each one:

  • reserved words such as final;
  • declaration types and variable recovery;
  • member chains that must not widen into calls;
  • named arguments and map entries;
  • nested brackets;
  • multiline statements;
  • interpolation and quote escaping;
  • page-width omission order.

The tests are not decoration here. The entire feature is an edge-case distributor with a keyboard shortcut.

Observation Is a Semantic Contract

The most useful rule from this project is simple:

Instrumentation must not change control flow, evaluation order, or the number of times user code runs.

For a manual print(), developers enforce that rule by judgment. For an automatic source transformer, the rule has to become code.

That means refusing ambiguous cursor positions. It means selecting a receiver instead of a method call. It means inserting after a syntactic statement rather than after a line. It means testing the formatter's effect on generated code.

The feature may look like “press a shortcut, get a log.” Underneath, it is a tiny refactoring engine with a very narrow permission slip.

It may observe. It may annotate. It must not participate.

Because the fastest way to make a bug harder to reproduce is to let your debug tool call it twice.