notes · · 5 min

Every place a struct is built

Turning a value computed inside a method into a field is three small edits. The hard part is the third: finding every place the struct is built, among everything the analyzer calls a reference to it. Imports, return types and `Self` all qualify, and a pattern that lists every field breaks.

On this page · 7 sections
  1. What a reference is
  2. The reference that is spelled differently
  3. Patterns
  4. The value itself
  5. A note that was not true
  6. What it costs
  7. What it does not do

Every query this series’ client sends waits a fixed time for its answer, and the time is written inside the method that sends it:

let budget = if method == "prodCode/structuralReplace" {
    std::time::Duration::from_secs(900)
} else {
    std::time::Duration::from_secs(60)
};

Sixty seconds belongs to the session, not to the call - a session that knows it is talking to a slow node should be able to say so once. Making it a field is three edits: declare the field, read self.query_budget in the method, and initialise it wherever an LspSession is built. Asked what it can do at that expression, the analyzer offers one thing:

$ prod-code assists crates/prod-code-mcp/src/session.rs 149 13
replace_qualified_name_with_use  [RefactorRewrite]  Replace qualified path with use

The first two edits are mechanical. The third is the whole problem.

What a reference is

“Wherever it is built” sounds like a question the analyzer answers: ask for every reference to the type and edit the ones that construct it. Here is what the references to a type actually are:

Seven things the analyzer reports as references to a type, and what each one is. An import, a return type and an impl header name the type without building it. Self with braces and the type's name with braces build a value and get the new field. A pattern ending in two dots still matches. A pattern that lists every field stops matching and is reported. Only the two that build a value are rewritten
Seven references to one type. Two of them build a value.

Most of them name the type without building anything, and two of them look exactly like a construction when read carelessly: fn make() -> Store { and impl Store { both put the name right before a brace. So a construction site is a name followed by braces, unless the line is an impl or struct header, the name is a return type, or the braces belong to a pattern (below). Remove that one exception for -> and the tool’s own test sees it immediately - four construction sites where there are two, because it tried to initialise a field inside a function body.

The reference that is spelled differently

The first real run, on LspSession, rewrote everything correctly and then listed six references it could not read:

not rewritten (6 reference(s) this could not read):
  crates/prod-code-mcp/src/session.rs:30:87 (the analyzer places `LspSession` here, but the file says otherwise)
  crates/prod-code-mcp/src/session.rs:31:9 (the analyzer places `LspSession` here, but the file says otherwise)
  …

That message comes from the check three posts back put in front of every edit at an analyzer position: the name has to be where the analyzer says it is. Here it was not, and the analyzer was right anyway. All six were Self - -> Result<Self>, Self::open_with_purpose, and the Self { … } that builds the session. Inside its own impl, a type’s references are mostly spelled Self, and the constructor almost always is. The check was doing its job; what it needed was a second spelling to accept. Now a reference reads as the type when it says either name, and every impl of the type is also searched for Self { … } directly, so a constructor is found whichever way it was reached.

Patterns

Braces after the name do not always build something. match s { Store { entries, .. } => … } takes a value apart, and so does let Store { entries } = s;. Adding a field changes nothing for the first - .. matches whatever else there is - and breaks the second, which now fails to mention a field. There is no correct text to write into it on the tool’s behalf, so it is reported with its line, and nothing is written while it remains.

Telling a pattern from a literal is again done by reading around the braces. They are a pattern when =>, a single =, |, a : type ascription, in or a match guard’s if follows them - or when they sit inside brackets that are a pattern, as in Some(Store { a }) =>, so a ), ], } or , after them sends the question outward. Braces that end in a bare .. are a pattern whatever surrounds them: a literal’s update always names what it copies from, ..base.

The first version of that rule looked only at what came directly after the braces, and the review of this post found the gap: for Store { a } in all, a match guard and Some(Store { a }) => were all read as literals (#90). The failure is silent, which is why each shape is now written out in the tool’s tests - a pattern read as a literal gets a field written into it, and Store { cap: 65536, a } in a match arm is a pattern that can compile and quietly match less.

The value itself

By default every construction site is initialised with the expression that was selected. That is right for Duration::from_secs(60) and wrong for anything that reads the method’s own state: a construction site may have no self. An expression that mentions self is refused unless an init is given, and one that names a local of the method is caught by the analyzer’s check of the result, like an extracted parameter’s.

The result on LspSession:

`LspSession.query_budget` (crates/prod-code-mcp/src/session.rs)

- new field: `query_budget: std::time::Duration`
- `request` now reads `self.query_budget` in 1 place(s)
- 1 construction site(s) initialise it with `std::time::Duration::from_secs(60)`

+    query_budget: std::time::Duration,
…
         let mut session = Self {
+            query_budget: std::time::Duration::from_secs(60),
             framed,
…
-            std::time::Duration::from_secs(60)
+            self.query_budget

the analyzer accepts the result: 0 errors

the compiler accepts the result too: `cargo check` in a shadow of the workspace, 3866 ms

A note that was not true

The report used to end with this, for any field type that is not a primitive:

`self.query_budget` is a `std::time::Duration`, not a `Copy` value: …

Duration is Copy. The tool does not know which types are - it knows the primitives, and the note turned “not one I recognise” into “not Copy”. The warning behind it is real: reading a non-Copy field by value moves it out of self, and the analyzer does not check borrows. But a warning that states something false about the reader’s own type teaches them to skip warnings. It now says only what the tool knows - the type is not one of the primitive Copy types, if it is not Copy at all a move can hide here, and verify: "compile" is the check that would see it.

What it costs

Once the proposal has been checked, asking again costs a third of a second (0.34, 0.29 and 0.33 s), because validation now runs on its own engine and that engine still holds it; a query right after costs 0.10 s. The first run, with the compiler asked too, took 15.3 seconds, 3.9 of them cargo check.

What it does not do

  • Named fields only. A tuple struct is refused: there is no name to give the new field a place among positions.
  • One init for every construction site. A constructor that should start the field differently from the others is edited by hand afterwards; the tool will not guess which.
  • The field is private. Making it public is a separate decision, and encapsulation goes the other way.
  • Rust only.
Cite this article
Citation
Alexander Panasenko (2026-10-10). Every place a struct is built. https://prod.codes/blog/every-place-a-struct-is-built/