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
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:
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
initfor 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
Alexander Panasenko (2026-10-10). Every place a struct is built. https://prod.codes/blog/every-place-a-struct-is-built/