Skip to content

Django template language services - #126

Merged
KotlinIsland merged 16 commits into
mainfrom
django-template-language-services
Aug 9, 2026
Merged

Django template language services#126
KotlinIsland merged 16 commits into
mainfrom
django-template-language-services

Conversation

@charliecloudberry

@charliecloudberry charliecloudberry commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

What was built
A template front end in [crates/ty_ide/src/django_template/]
Project discovery ([project.rs]
Type resolution ([resolve.rs]
Completions ([completion.rs]
Go-to-definition ([goto.rs]
Semantic tokens — leaves markup to the editor's grammar and adds what a TextMate grammar can't know, including a defaultLibrary modifier separating django's tags from the project's.
Server wiring — a LanguageId::DjangoTemplate recognised from the editor's language id

charliecloudberry and others added 16 commits August 8, 2026 23:23
templates get completions, go-to-definition and semantic highlighting,
all of it joined to the project's own django definitions rather than to a
grammar: `{{ book.author. }}` completes model fields because some view
wrote `render(request, "post.html", {"book": book})` and the type checker
knows what that `book` is.

the template front end is new — a lexer for the three constructs, and an
index that recovers block nesting from the tags that impose it, since the
delimiters do not. everything else is a join to the python side: the
`templatetags` modules a `{% load %}` names, the `path(…, name=…)` a
`{% url %}` reverses, the `render()` call a `{{ variable }}` comes from.

the server recognises a template from the editor's language id, or from
the path when the editor just says `html`. a template's `File` is
interned so its source can be read, but it never enters the project's
open-file set, so the type checker is never handed an html file.

the semantic token legend grows two types and a modifier. both are
appended: a token type's position in the enum is the index the wire
format sends, so inserting would silently recolour every python file.
a construct is bounded to its own line, the way django's own tag_re is, so a
stray `{%` no longer swallows the rest of the file. the filesystem walks gained a
salsa revision, so a template added on disk is seen. a member that is callable
with no required arguments contributes its return type, because that is django's
documented resolution and `.all` is in most real templates

and django 6.0 ships the partial tags itself, so marking them third-party was
wrong — it would have written a `{% load partials %}` that raises
a drf router names routes that no `path(..., name=)` call spells, so they are
indexed from `register()` instead. template names and route names in python source
are recognised as the names they are. and a template answers hover, document
symbols and folding
the three indexes that had to agree with django before any diagnostic could ship
honestly: `TEMPLATES`/`APP_DIRS`/`INSTALLED_APPS` from the settings module the
project actually names, the tag libraries it can `{% load %}`, and the url tree
walked for route names

each carries an is-authoritative predicate, so a project whose settings cannot be
read reports nothing rather than guessing
twelve diagnostics, each silent unless the index behind it is authoritative. a
context is recovered from more than a dict literal written in the `render` call.
a name that crosses the language boundary can be renamed, and a template can say
what uses it
django's own registry decides which of its tags exist, replacing a table we wrote.
`{% url %}` offers the arguments a route takes and checks them against its
converters. a filter says what it takes, and a loop says what it binds. the
project's django shape is searchable. and a code lens carries both the rendered-by
lens and the `manage.py` runnables
…s so

the check found seven real bugs in the probe project itself, every one confirmed
by django raising `TypeError`, with `manage.py check` clean throughout. django does
not check this

reporting on a python file made the editor and `by check` disagree, which is what
got an earlier rule reverted. a `ProjectChecker` trait in `ty_project` that
`ty_ide` registers into fixes it for all fourteen rules at once and keeps
`Project::check` the single place that knows how to check a project — and folding
the rules in beside the type checker's own is what finally made a `ty: ignore` on
one count as used
a model's `Meta.ordering`, a `ModelSerializer`'s and `ModelForm`'s `Meta.fields`
and `exclude` against `Meta.model`, and a drf view's `ordering_fields`,
`search_fields` and `filterset_fields` against the model its own queryset is of.
nothing is reported unless the model is certain

`source="author.name"` is deliberately not checked: drf resolves it with `getattr`
at serialization time against whatever object it is handed, so the canonical
`.annotate()` idiom names an attribute the model provably lacks

and `defaults`, with `create_defaults` on `update_or_create`, are django's own
keywords rather than lookups — four false positives on textbook django
django's own set, gated three ways: the receiver is a class django marks, a
`django.*` class in the mro declares the name, and the member is a method rather
than a value. the gate is what makes it more than a table — a model's own
`update()` is not flagged while an *override* of `save` is, and django agrees with
both

212 members compared against django's own three-arm resolution: 40 refused, 40
flagged, no false positives and no misses. completion marks the five and sorts
them last, which is the direct answer to a template author seeing `save` first
`get_queryset`, `get_object`, `get_serializer` and `save` specialize from the
view's own `queryset` and the serializer's `Meta.model`, which the stubs leave as
`Unknown` and their mypy plugin derives nothing from

`data` and `validated_data` stay `Any` deliberately: `validate()` may drop and
invent keys freely, so the key set is *open* rather than merely optional, and a
closed type there would turn correct code into errors
the case for building `.delay` typing was measured without `celery-types`, the
package typed celery projects actually install. with it, `@shared_task` gives
`Task[(book_id: int, force: bool = False), str]` and all four of the wrong calls
that motivated the work already report

so there is nothing to build, and these tests pin that rather than reimplement it.
they also record the one place celery-types refuses: `Celery` is generic over the
task *class*, so `@app.task` widens to `Task[(...), Any]`
the settings module a project names in `DJANGO_SETTINGS_MODULE` is resolved where
the type checker can read it, and `settings.FOO` takes the type that module gives
it, on the command line and in the editor alike

deliberately scoped, on measurement: typing django's 149 `global_settings`
defaults added sixteen diagnostics and none of them true, and the project's
container settings two more. so only non-generic values from the project's own
module are typed — zero new diagnostics over django's own corpus
matching ty's own python semantics rather than inventing any, including reporting
a suppression whose rule is turned off in configuration — measured, that is what
python does

with one template-only gate: a check that bailed because its index could not
answer has decided nothing, so its suppression is never called unused
`filter(author.name == "Ursula", published > date(1970, 1, 1))` lowers to the
keywords it spells, and a json subscript is a key or index transform
(`data["key"]` → `data__key`, `data[0]` → `data__0`). verified by sql
equivalence against real django rather than by reading the output

the classifier is one query shared by ty and the transpiler, so they cannot
disagree about what a path means. `!=` refuses, because django spells it
`.exclude()`/`~Q()` and that changes which method is called; a key colliding with
a lookup name refuses, because `data__gt` compiles to `data > 1` rather than the
key `"gt"`

the gate that gives an otherwise-unresolvable name a meaning now has one home,
shared with enum members and implicit receivers — they observably disagreed about
exactly one name. and a lookup path is not an undefined name to the linter
go-to-definition on a lookup path's leading name, which alone of its segments had
nothing to resolve to

an editor's own language id is accepted, so the document enters the project at
all: `basedpython` fell through to `LanguageId::Other`, which reported zero
diagnostics *and* contributed no in-memory contents, so every service answered
about the file as last saved

a template rename is no longer refused by any unrelated local function named
`render`, and f2 on an `{% include %}` fragment no longer moves the template

plus two ignored regression tests for a pre-pass rewrite dropping the project db —
a file whose source is rewritten before phase 0 loses project resolution, so an
unrelated enum member silently stops a lookup lowering while `by check` stays
clean
erased-union reification, name qualification and enum lowering all rewrite the
source before phase 0 runs, and phase 0 then fell back to a bare single-file db
because the working source no longer matched the project file. every
TypeAwarePass lost cross-module *and* project resolution and silently declined
to lower, so an enum declared anywhere in a file broke a django lookup
elsewhere in it — with a clean `by check` and valid emitted python

callers that own a real db now pass a `RebuildProject`: a way to build a second
db over the same project, into which the rewritten source is served for the one
file through `File::source_text_override`. the project's metadata, search paths
and sibling files stay. a caller with no project still gets the single-file db

the rebuilt db has to be a new one rather than a clone: salsa handles cloned
from one db share storage, so the override would be visible through the
caller's own db
@KotlinIsland
KotlinIsland force-pushed the django-template-language-services branch from 9b62305 to a184ca0 Compare August 8, 2026 15:55
@KotlinIsland
KotlinIsland merged commit 859cdfc into main Aug 9, 2026
58 of 63 checks passed
@KotlinIsland
KotlinIsland deleted the django-template-language-services branch August 9, 2026 02:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants