Django template language services - #126
Merged
Merged
Conversation
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
force-pushed
the
django-template-language-services
branch
from
August 8, 2026 15:55
9b62305 to
a184ca0
Compare
KotlinIsland
approved these changes
Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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