The Graphitron LSP surfaces three layers the schema author normally cannot see: inference (the canonical-argument value Graphitron fills in for @table / @field / @reference when the author omits it), classification (what Graphitron takes a declaration to be, which is what decides the code generated for it) and delivery (whether reading a field costs a fetch of its own). All three are off by default and light up through editor-side config.
Four config keys
All four live under the graphitron.inlayHints (and graphitron.hover) namespace and default to false. The LSP pulls them via workspace/configuration on initialisation and refreshes them on workspace/didChangeConfiguration. A client that does not implement workspace/configuration leaves the defaults in effect; no error is surfaced.
| Key | Default | Effect |
|---|---|---|
|
|
Renders a resolution overlay at |
|
|
Renders the classifier claiming a field or type declaration, at the declarations something claims. The field vocabulary is |
|
|
Renders |
|
|
Enables rich hover content on field-declaration and type-declaration name tokens. Where the inlay hint shows the classifier alone, the hover popup adds the facts behind it (the column and the name it matched on, the service method, the join path, the DML verb, the error handlers) and whether the field costs a round-trip of its own. |
The toggles are independent: a user who wants the round-trip markers but no classifier labels beside them gets exactly that.
Inferred-directive hints
These are resolution overlays: text the editor draws beside your schema that is not in the file. Nothing is inserted, and saving does not write it. What an overlay shows is what Graphitron resolved at a site where you wrote nothing.
Renders only when the canonical argument is absent from the SDL source. The LSP asks the live tree-sitter parse tree whether the argument node exists in the buffer; when it does not, it looks up the value Graphitron resolved. Three sites today:
-
@tablewithoutname:showsname: "<table>", the table the type is bound to. -
@fieldwithoutname:showsname: "<column>", the column name the classifier resolved. -
@referencewithoutpath:showspath: [{key: "…"}], the foreign key Graphitron discovers between the field’s own two ends: the table its parent type is bound to, and the table the type it names is bound to. One entry, because discovery is one hop; a path of several elements is one you write. Where the two tables are connected by several foreign keys, or by none, nothing renders, which is also what Graphitron does with such a field: it joins on no discovered key and asks you to name one. A field whose type is its parent’s own type renders nothing for the same reason, a key between one table and itself not saying which way the field navigates.
Adding the canonical argument to the directive removes the hint immediately on next paint, without waiting for a regenerate cycle.
An extend type site additionally gets the whole directive as an overlay. A type’s binding belongs to the type rather than to the declaration in front of you, so extend type Film { … } in a file that never mentions @table is still table-bound, and the hint reads @table(name: "film") on the extension’s type name. Where the base declaration is what you are looking at, the directive is already there and only its argument can be filled in.
Not the undirected bindings, though, and a type can have one without writing any directive at all. A directiveless object returned by a field of a @table type is a nesting type: its fields resolve against the parent’s own row, so type Inner { title: String } under Film.inner reads columns off film without ever naming it. A type produced by a class-returning field, an @service return or an @externalField lift, is backed by that POJO, Java record or jOOQ record, and the class threads down to its child types (see @record, the directive that used to declare this and is now ignored). Both kinds are bound and neither gets an overlay today, so read an overlay that appears and do not read its absence as "this type is unbound".
Where two schemas declare a table of the same name, nothing renders: the binding is ambiguous, so there is no value Graphitron filled in, and showing either candidate would claim a resolution that did not happen. Root operation types never get a @table overlay.
Classification hints
Renders the classifier a declaration is claimed by: the directive that named it (@service shows SERVICE, @table shows TABLE) or, on a field the author bound nothing to, the structural reading Graphitron fell back on (TABLE_COLUMN, when the field’s name resolves to a column of the parent’s bound table).
A declaration nothing claims gets no hint. That is the point of the surface: a hint marks where Graphitron has an opinion about a declaration, and a plain SDL object or a field with no binding gets silence rather than a word for "nothing in particular".
One type label is not a classifier. A payload type reached through a @service return carries no directive of its own, so nothing claims it, and Graphitron still knows exactly what it is: the Java class the producer hands back. Such a type is labelled with that class’s simple name, so type FilmCard under a service returning FilmCardRecord reads FilmCardRecord. The CamelCase is the tell that you are looking at a class rather than a category, and it answers the question worth asking at a payload type. There is no directive to infer here and therefore no overlay: @record is deprecated and ignored, so nothing an author could write expresses the binding, and the label takes its place.
Two producers naming different classes for one type get no label. Graphitron binds neither, and a label showing one would name the class it does not use; hover the type to see which classes disagree.
An authored claim hides the structural one, so a @service field of a @table type shows SERVICE even where its name would have matched a column. Where two directives claim the same declaration, both classifiers render, comma-separated: that is a schema error, and naming the two claims says more than a single word for "conflicted" would.
The label is the classifier and only the classifier. Which table, which column, which foreign-key path, whether the field launches a query of its own: those are facts of their own and the hover is where they are unpacked.
Classification hover
Renders as markdown when the cursor is on a field-declaration or type-declaration name token (outside any directive). The block is:
-
The classifiers claiming the declaration, in the same vocabulary the inlay hint shows. A conflicted coordinate names all of its claims, comma-separated.
-
The qualified coordinate (
ParentType.fieldNamefor fields; the type name for types). -
One line per fact behind each claim, each read from the relation that owns it: the column and table a
TABLE_COLUMNmatch resolved to plus which naming tier matched, the class and method behind aSERVICEorEXTERNAL_FIELD, the target type of aNODE_ID, the routines of a chainedROUTINEin application order, the verb and table of aMUTATION, the bound table of aTABLE, the handlers of anERROR. -
Matched name, on a field whose@field(name:)binding matched under a name other than the field’s own. This is what tells "graphitron read my binding" from "graphitron ignored it". -
The join path, where an authored
@referenceresolves to one: each element as the foreign key it joins on and the table it lands in. -
Fetched separately, described below. -
Backed by, on a type no directive claims that a Java class stands for: the qualified class a producer’s return grounds it on, or that a member of another backed class delivers. -
Backing contested, on a type Graphitron answers more than one way. Two producers naming different classes is a schema Graphitron refuses to bind, and this line is the only place that says so; every surface that needs one class is silent there by design.
Three of those lines hold a hover open on their own. A child field returning a @table type is claimed by nothing (no directive names what it is, and the structural classifier only reaches leaf fields), so gating the block on a claim would silence it on exactly the fields whose round-trip cost an author wants to see; a payload type is claimed by nothing for the same kind of reason, and its backing is the whole answer to what it is.
A claimed type shows no backing line. Its classifier already answers what it is, and the class follows from that answer: a @table type’s class is its table’s generated record, and the table is named in the block above.
Hover stays distinct from the directive-argument-keyed dispatch (@table(name:), ExternalCodeReference.method, …): the existing directive-arg hovers keep firing when the cursor sits inside a directive, and the classification hover fires only as a fallback on declaration coordinates.
Which fields are fetched separately
A field’s rows either come out of the enclosing SELECT or cost a fetch of their own. This is the round-trip question, and it is the one thing on these surfaces that is about cost rather than meaning.
The separateFetch inlay toggle marks the fields, one word each, so a type can be scanned at a glance. The hover names the reason, for every rule that reaches the field:
| Rule | What the hover says | Marked inline |
|---|---|---|
the fetch is deferred to a batched DataLoader call |
yes |
|
|
the fetch runs once per tenant, off the parent’s statement |
yes |
|
the service fetches independently of the parent’s |
yes |
A field of a root operation type |
a root operation field is its own entry point |
no |
A |
the parent hands back a Java object, so this field’s table is a fetch of its own |
yes |
The last row is the one rule an author writes nothing for. A type reached through a @service return, or through a member of a class one returns, is delivered as a Java object rather than as a row of a running SELECT; a field of such a type that names a @table type has no enclosing statement to be projected out of, so its rows cost a fetch. Graphitron does this without being asked, which is exactly why the marker is worth showing: the cost is real and nothing in the schema text hints at it.
A root field is never marked inline. Every field of a root type is its own entry point, so the marker would repeat down the whole type without distinguishing anything; the hover still states it, because a reader who asked about one declaration should get the whole answer. A @splitQuery on a root field is likewise unmarked: the generator ignores the directive there, so marking the field would advertise a split that never happens.
A field several rules reach lists all of them in the hover and still gets one marker inline: the marker is a signal, and which rules produced it is what the hover answers.
A field no rule reaches gets neither, and that silence is not yet the opposite claim. Two splits are still unreported: a child reached through an @asConnection wrapper, and a list-valued interface or union child whose members are @table types, which batches through a DataLoader. So read a marker that appears, and do not read the absence of one as "this inlines".
Freshness
The surfaces read different things and go stale differently.
Every surface on this page comes from the fact store, which the build rewrites on each capture. They render whenever a capture has happened, whether or not the generator pass after it succeeded, and they show what the last capture recorded. That covers all three inferred-directive halves, the classification hints, the separate-fetch markers, and the classification hover with its description overlay.
So a workspace that has captured a schema and never completed a generator pass gets these surfaces in full, and one that has completed many gets the same answers from the same relations. The staleness to reason about is a capture’s: the hints describe the schema as the last capture read it, and a save that reruns capture brings them level.
Each surface is silent when what it reads is missing, rather than holding the others back. Silence is not the opposite claim: where an inferred-directive hint has nothing to fill in, the store either has no answer at the coordinate or has more than one, and the sections above say which cases those are.
Editor configuration
The keys live under the workspace’s settings root. In a VS Code-style settings.json:
{
"graphitron.inlayHints.inferredDirectives": true,
"graphitron.inlayHints.classification": true,
"graphitron.inlayHints.separateFetch": true,
"graphitron.hover.classification": true
}
The editor’s LSP client forwards these to the Graphitron LSP through the workspace/configuration request on initialisation and the workspace/didChangeConfiguration notification on subsequent flips.