Contributing¶
All contributions are welcome! Besides code contributions, this includes things like documentation improvements, bug reports, and feature requests.
You should first check if there is a GitHub issue already open or related to what you would like to contribute. If there is, please comment on that issue to let others know you are working on it. If there is not, please open a new issue to discuss your contribution.
Not all contributions need to start with an issue, such as typo fixes in documentation or version bumps to Python or Django that require no internal code changes, but generally, it is a good idea to open an issue first.
We adhere to a version of Django's Code of Conduct in all interactions and expect all contributors to do the same. Please read the Code of Conduct before contributing.
AI Policy¶
Someone is going to read your PR. Be considerate of that — make sure what you're submitting is something you'd want to review yourself.
AI tools are fine to use. How the code got written matters less than whether it's good. But you're the one submitting it, so you're the one responsible for it. If you can't explain a change, don't submit it. If you haven't tested it, don't submit it. If it doesn't fit the codebase, it's going to need rework.
Mentioning that you used AI is appreciated but not required. We'll assume good faith. That said, a pattern of sloppy submissions speaks for itself regardless of how the code was produced.
- If you submit it, you own it. "The AI wrote it" is not an explanation.
- Read the diff. Understand what it does and why.
- Test your work. Don't submit code you haven't verified.
- Make sure it fits — existing patterns, naming conventions, architecture.
The project includes an AGENTS.md file with guidelines for AI coding agents. If you're using an AI tool that supports it, point it there.
Before opening a PR, make sure the tests, clippy, formatting, and linting all pass.
Getting oriented¶
Django Language Server is exactly what the name says: a standalone program that editors start in the background and query over the Language Server Protocol (github.com/joshuadavidthomas/django-language-server/tree/main/LSP). If you have never worked with a language server before, start with this section; the documents linked at the end go deeper.
The editor/server split¶
The editor owns presentation: completion menus, squiggly underlines, hover popups, jumping between files. The server owns analysis: parsing templates, validating tags and filters, resolving {% extends %} chains, knowing which template tag libraries the project can load. The two communicate only through JSON-RPC messages whose shapes the Language Server Protocol defines, and neither knows the other's internals. That separation lets one server support every editor. It also splits responsibility cleanly when debugging: how a result is displayed is editor behavior; what the result contains is decided in this repository.
A typical session, condensed:
- The editor spawns
djls serveas a child process and speaks JSON-RPC over its stdin and stdout. - An
initializeexchange negotiates capabilities: completion, hover, diagnostics, go to definition, and so on. - The server statically reads the project (github.com/joshuadavidthomas/django-language-server/tree/main/settings module,
INSTALLED_APPS, template directories, template tag libraries). It never imports or runs project code. - Opening a template sends
textDocument/didOpenwith the file's full text. The server analyzes it and pushes back diagnostics, which the editor draws as squiggles. - Document edits send
textDocument/didChangenotifications, which the editor may batch. The server re-analyzes from the editor's buffer, not the file on disk, so it sees unsaved changes. - Hover, completion, and go to definition are request/response pairs: the editor sends a position, the server answers from its analysis, and the editor renders the result.
Inside the server¶
The codebase is a Cargo workspace of small crates, layered so that each answers a different kind of question. Two kinds of knowledge feed everything: what tags and filters exist (github.com/joshuadavidthomas/django-language-server/tree/main/read from the Python side of the project) and what the template says (github.com/joshuadavidthomas/django-language-server/tree/main/parsed from the template source). Separate subsystems produce each, and they meet in the middle during semantic analysis.
From the bottom up:
| Crate | Answers |
|---|---|
djls-source |
Files, spans, line indexes, filesystem access. Nearly everything depends on it. |
djls-project |
Project facts: Python environment discovery, settings extraction, template directories, template tag libraries, and the static extraction that derives validation rules (github.com/joshuadavidthomas/django-language-server/tree/main/argument counts, block structure, filter arity) from the Python source of template tag libraries. |
djls-templates |
Template syntax. A hand-written recursive descent parser that knows nothing about Django semantics and never fails: parse errors become error nodes in its output, because the user is always mid-keystroke in something invalid and the rest of the pipeline has to keep working. |
djls-semantic |
Project meaning. Parsed templates meet project facts here: which libraries are loaded at each position, whether a tag is valid where it appears, structural diagnostics. |
djls-ide |
Translation. Turns analysis into LSP-shaped answers: completions, diagnostics, definitions, references. Everything below it is LSP-unaware. |
djls-server |
The protocol. The only crate that speaks LSP: the session, open-document buffers, request handling. The JSON-RPC transport and request dispatch come from tower-lsp-server; this crate implements the handlers on top. |
djls |
The CLI. djls serve starts the server; djls check runs the same validation in a terminal. |
Tying the layers together is Salsa, the incremental computation framework also used by rust-analyzer. You write analysis as queries over inputs, and when a file changes only the affected queries recompute. That keeps re-analysis on every keystroke cheap.
A template flows through the pipeline in stages: lexing, parsing into a flat node list, analysis (github.com/joshuadavidthomas/django-language-server/tree/main/building the template tree and working out which libraries each position can see), validation, diagnostics. No stage blocks on errors from a previous one. A template full of syntax errors still gets structural analysis on its valid portions, and a template with structural problems still gets validation on the tags that parsed correctly.
ARCHITECTURE.md has the full map (github.com/joshuadavidthomas/django-language-server/tree/main/per-crate detail, the database design, and the invariants the layering maintains), and CONTEXT.md is the domain glossary: the canonical name for every concept in the codebase.
New to Rust?¶
The server is written in Rust, but this is a project for Django developers, and Django expertise is just as valuable as Rust expertise. Understanding Django's internals and common development patterns helps shape what features would be most valuable and how they should behave.
If you know Python but not Rust and want to contribute code:
- The Rust Book is the standard introduction, free and worth reading in order.
- Rustlings is a set of small exercises that pairs well with the book.
- The one unusual dependency here is Salsa, the incremental computation framework also used by rust-analyzer and ty. Most changes don't require understanding it; read its book when you start touching query code.
Plenty of valuable contributions require no Rust at all: editor client configurations, documentation, bug reports with a reproducing template, and feedback on how features behave in real Django projects.
So far it's all been built by a simple country CRUD web developer learning Rust along the way — send help!
Development¶
The project uses a Cargo workspace with all crates under crates/. A few conventions to be aware of:
- Dependency versions are centralized in
[workspace.dependencies]in the rootCargo.toml. Individual crates reference them withdep.workspace = trueand never specify versions directly. - Internal crates are listed before third-party crates in each crate's
[dependencies], separated by a blank line. Both groups are kept in alphabetical order. - Lints are configured once in
[workspace.lints]in the rootCargo.toml. Each crate opts in with[lints] workspace = true. - Versioning: Only the
djlsbinary crate carries the release version. All library crates useversion = "0.0.0".
First-time setup¶
Fork the repository on GitHub and clone your fork. Run the setup commands below from the checkout's root.
Install mise and activate it in your shell. From the repository root, install the development tools:
This installs Rust, Just, uv, prek, cargo-insta, Hawk, and zizmor. For noninteractive shells and editors, add mise's shims to PATH; alternatively prefix commands with mise exec --.
Tool versions are defined in mise.toml and the checked-in rust-toolchain.toml files.
Install the locked Python development dependencies without building the local Rust package, fetch the Rust dependencies, install the Git hooks, and prefetch the test corpus:
The first test or lint run may still download a supported Python version, create Nox environments, compile the Rust workspace, and prepare hook environments. Subsequent runs reuse those artifacts.
Make your first change¶
Create a branch and run just test before editing to establish a working baseline. This is the normal test command: it prepares the default Python/Django environment and corpus, then runs the Rust suite. Subsequent runs reuse the environment, so you do not need to manage it yourself.
Use the crate map above to find the code that owns the behavior. The test-layer overview explains where parser, semantic, corpus, and LSP tests live; start with a nearby test and add a case that reproduces the bug or exercises the new behavior.
During development, run the relevant crate or test rather than the whole workspace each time:
just test -p djls-templates
# Replace the crate and test name with the ones you are working on:
just test -p <crate> <test_name>
For changes to analysis or editor behavior, try the development server as well. Automated tests cover regressions; running your build lets you check that it behaves as intended in a Django project.
The routine checks are:
| Command | When |
|---|---|
just test |
During development and before opening a PR; run the full Rust suite after focused tests pass |
just fmt |
After editing Rust; applies formatting using the pinned formatter |
just lint |
Before committing; runs the all-files lint hooks, including Rust formatting checks and Clippy. It does not fix Rust formatting |
When a change touches snapshots, review them with cargo insta review before committing; see Snapshots.
Before opening your PR:
- Run the checks above and any additional tests relevant to the change, using the test-command table.
- Review the diff, including accepted snapshots, and remove accidental changes.
- Add a changelog entry for notable changes.
- Explain the behavior changed, link the related issue, and state how you tested it.
Try your development server¶
Use cargo run to build and run your changes in one command. In the examples below, replace /path/to/django-language-server with the absolute path to your checkout. --manifest-path selects that checkout while leaving the working directory in the Django project you want to analyze, so you do not accidentally test an installed release.
Use a Django project with its dependencies installed for these checks, rather than opening this Rust repository as the project to analyze. Configure its Django settings module through DJANGO_SETTINGS_MODULE or a project configuration file. For example, in that Django project's pyproject.toml:
Replace myproject.settings with the project's module. The server searches standard virtual environment directories such as .venv; set venv_path if the environment lives elsewhere. See Configuration for details. The Django project's environment is separate from this repository's development environment.
In a terminal¶
From the Django project root, check a template:
cargo run --manifest-path /path/to/django-language-server/Cargo.toml -p djls -- check templates/example.html
Replace the template path with a file in your project. For a quick smoke check, add {% block content %} without its closing tag: the command should report an unclosed tag and exit with status 1. Add {% endblock %} and check that the diagnostic disappears. Then try the behavior your change affects. See the CLI guide for other inputs and options.
In an editor¶
Configure your editor's LSP client to use cargo run with serve as the server argument. Override any installed or automatically downloaded server so only your development build is attached. Cargo must be available on the editor's PATH. For example, with Neovim 0.11+, put this in init.lua:
vim.lsp.config('djls', {
cmd = {
'cargo', 'run', '--quiet',
'--manifest-path', '/path/to/django-language-server/Cargo.toml',
'-p', 'djls', '--', 'serve',
},
filetypes = { 'htmldjango', 'html', 'python' },
root_markers = { 'manage.py', 'pyproject.toml', '.git' },
})
vim.lsp.enable(https://github.com/joshuadavidthomas/django-language-server/tree/main/'djls')
Open the Django project and a template within it. In Neovim, :checkhealth vim.lsp helps confirm that the client is attached; check :set filetype? if it is not. The editor guides cover client setup, including Neovim filetype detection. Other editors have their own executable-override settings.
Try the unclosed-block example above and confirm that its diagnostic appears and disappears as you edit, without saving. Then reproduce the behavior you are changing.
After every Rust change, restart the editor's language-server process (github.com/joshuadavidthomas/django-language-server/tree/main/or restart the editor). cargo run rebuilds changed code on startup; the already-running server does not reload your edits. The first launch may take longer while Cargo compiles dependencies.
From the server repository root, just run is shorthand for cargo run -p djls --. Running just run serve in a terminal waits for LSP messages on stdin/stdout: it is not an interactive shell or a web server. Normally, let the editor start serve for you.
Testing¶
Use just test by default, including for focused runs. It forwards crate and test-name filters to Cargo. The other test commands are for additional coverage:
| Command | What it runs | When to choose it |
|---|---|---|
just test |
The Rust suite excluding corpus-wide sweeps, in a Nox-managed Python 3.10/Django 5.2 environment, after synchronizing corpus source | Everyday development and the pre-PR check |
just testall |
The Rust suite in each configured compatible Python/Django combination, including Django main |
Changing version support or investigating a matrix-specific failure; this does not include LSP end-to-end tests |
just corpus |
Prepare source and dependencies, then run all six corpus-wide suites | Checking real projects in their own dependency environments, once outside the version matrix |
just e2e |
Python/pytest LSP end-to-end tests against the checkout, in the default Python/Django environment | Changing editor-visible behavior such as initialization, diagnostics, navigation, or completions |
just test and just testall create or reuse isolated Nox environments and install the selected Django version before running Cargo. This does not mean every Rust test analyzes that installed Django version: source-backed fixtures use pinned corpus files or explicit test data. The version matrix and incompatible combinations are defined in noxfile.py.
You may see cargo test in Rust documentation. It runs the Rust suite directly, without the environment setup or corpus synchronization provided by just test. You do not need to use it separately in the normal contribution workflow.
Corpus¶
The complete suite requires the corpus: pinned project source and dependencies under crates/djls-testing/.corpus. Run just corpus sync for setup. Subsequent syncs reuse unchanged checkouts and ready environments, rebuilding only missing or stale entries. Use just corpus sync --refresh to deliberately refresh dependencies, including unlocked upstream requirements. No additional dependency lockfiles are maintained by the corpus.
just test and just testall sync source for their focused tests. just corpus runs the six corpus-wide sweeps separately, using each project's own environment where import resolution matters. Plain cargo test includes those sweeps and therefore needs complete setup. Isolated tests such as cargo test -p djls-templates can run without the corpus.
Snapshots¶
The test suite uses Insta snapshots extensively. After running the relevant tests, inspect pending changes interactively:
To rerun snapshot tests, accept updates, and delete unreferenced snapshots in one noninteractive pass:
Always review snapshot changes before committing them.
Linting¶
Install the commit-time hooks with prek install. Run just lint for the all-files local gate; it formats the Justfiles and runs every configured hook, including Rustfmt and Clippy. CI runs the portable pre-commit hooks, Rustfmt, Clippy, and Hawk as separate jobs.
Formatting¶
Formatting uses the dated nightly pinned in tools/rustfmt/rust-toolchain.toml because the repository enables unstable rustfmt options. Run just fmt to apply formatting using that toolchain; just lint only checks Rust formatting. Changing toolchain pins is covered under Maintaining.
Visibility Audits¶
Hawk is an experimental Cargo lint from Astral that checks unnecessary public Rust visibility across a closed-world workspace. It is useful here because most crates are internal architecture layers behind the shipped djls binary.
It matters most when you are changing public APIs, moving code across crates, or cleaning up visibility. A change that stays completely inside one crate is less important. Each run performs multiple Cargo passes and is heavy on CPU and disk. If you are new to the project, let CI run it: a hawk job checks pull requests affecting Rust code or its tooling.
Usage¶
Run Hawk through just rather than cargo hawk directly:
The recipe uses the compiler pinned in tools/hawk/rust-toolchain.toml, as required by the cargo-hawk version in mise.toml, and isolates Hawk's instrumented builds to avoid astral-sh/hawk#74. It enforces findings with -D warnings, the same contract as the other lint recipes. Rustup installs that compiler on first use if needed.
The multiple passes come from Hawk checking the configured production binaries and workspace non-production targets. --fix can repeat analysis while visibility changes converge. That cost is expected: Hawk answers a different question than clippy, namely whether crate boundaries expose more API surface than the workspace needs.
Hawk is installed by mise install during setup; just hawk runs it without updating it. Coordinated tool updates belong under Maintaining. After applying Hawk fixes, run the normal lint and test checks; newly private code may expose cleanup work that belongs there.
Debug information¶
Development and test builds use line-table-only debug information to keep Rust build artifacts smaller while retaining file-and-line panic backtraces and source-level stepping. Compiler diagnostics and normal build and test behavior are unaffected, but native debuggers cannot inspect local variables and function arguments.
When full GDB or LLDB inspection is needed, override the relevant Cargo profile for that build:
Profiling¶
You will rarely need this; it is for benchmark investigations, not everyday changes.
Setup¶
You'll need jq, rg, and the codspeed fork of valgrind (github.com/joshuadavidthomas/django-language-server/tree/main/not stock valgrind):
git clone --depth 1 https://github.com/CodSpeedHQ/valgrind-codspeed /tmp/valgrind-codspeed
cd /tmp/valgrind-codspeed
./autogen.sh
./configure --prefix=$HOME/.local
make -j$(https://github.com/joshuadavidthomas/django-language-server/tree/main/nproc)
make install
Make sure $HOME/.local/bin is on your PATH. Verify with:
Usage¶
The just dev profile command runs benchmarks under valgrind-codspeed, the same callgrind fork used in CI. It records per-function instruction counts with call trees, and automatically strips harness overhead.
just dev profile <bench> [filter]
# Examples:
just dev profile diagnostics collect_diagnostics_realistic
just dev profile parser parse_template
Changelog¶
The project maintains a CHANGELOG.md following Keep a Changelog. All notable changes should be documented under the [Unreleased] heading in the appropriate section.
Sections (github.com/joshuadavidthomas/django-language-server/tree/main/use only those that apply):
Added— new featuresChanged— changes in existing functionalityDeprecated— soon-to-be removed featuresRemoved— now removed featuresFixed— bug fixesSecurity— vulnerability fixes
Writing entries:
- Keep entries short and factual — describe what changed, not why
- Use past tense verbs: "Added", "Fixed", "Removed", "Bumped", etc.
- Wrap crate names, types, commands, and config keys in backticks
- Prefix internal changes (github.com/joshuadavidthomas/django-language-server/tree/main/refactors, crate restructuring, CI) with
**Internal**: - List user-facing entries before
**Internal**:entries within each section
Examples:
### Added
- Added `diagnostics.severity` configuration option for configuring diagnostic severity levels.
### Changed
- Bumped Rust toolchain from 1.90 to 1.91.
- **Internal**: Extracted concrete Salsa database into new `djls-db` crate.
### Fixed
- Fixed false positive errors for quoted strings with spaces (https://github.com/joshuadavidthomas/django-language-server/tree/main/e.g., `{% translate "Contact the owner" %}`).
Maintaining¶
These procedures cover maintenance work outside the normal contribution flow: adding or dropping supported Python and Django versions, and updating pinned development tools.
Version updates¶
Python¶
The project uses noxfile.py as the single source of truth for supported Python versions. The PY_VERSIONS list in this file controls:
- Auto-generated documentation: cogapp reads
PY_VERSIONSto generate Python version classifiers inpyproject.tomland the supported versions list inREADME.md - CI/CD test matrix: GitHub Actions workflows call the
gha_matrixnox session to generate the test matrix fromPY_VERSIONS, so all supported Python versions are tested automatically - Local testing: The
testsnox session usesPY_VERSIONSto parametrize test runs across all supported Python versions
Note
When possible, prefer submitting additions and removals in separate pull requests. This makes it easier to review changes and track the impact of each version update independently.
To update the list of supported Python versions:
-
Update
noxfile.py, adding or removing version constants as needed and updating thePY_VERSIONSlist accordingly.For example, to add Python 3.14 and remove Python 3.9:
-
Regenerate auto-generated content:
This updates:
- The
requires-pythonfield inpyproject.toml - Python version trove classifiers in
pyproject.toml - Supported versions list in
README.md
- The
-
Update the lock file:
-
Test the changes:
Use
just testallrather thanjust testto ensure all Python versions are tested. Thejust testcommand only runs against the default versions (github.com/joshuadavidthomas/django-language-server/tree/main/the oldest supported Python and Django LTS) and won't catch issues with newly added versions.Alternatively, you can test only a specific Python version across all Django versions by
noxdirectly: -
Update
CHANGELOG.md, adding entries for any versions added or removed.
Django¶
The project uses noxfile.py as the single source of truth for supported Django versions. The DJ_VERSIONS list in this file controls:
- Auto-generated documentation: cogapp reads
DJ_VERSIONSto generate Django version classifiers inpyproject.tomland the supported versions list inREADME.md - CI/CD test matrix: GitHub Actions workflows call the
gha_matrixnox session to generate the test matrix fromDJ_VERSIONS, so all supported Django versions are tested automatically - Local testing: The
testsnox session usesDJ_VERSIONSto parametrize test runs across all supported Django versions
Note
When possible, prefer submitting additions and removals in separate pull requests. This makes it easier to review changes and track the impact of each version update independently.
To update the list of supported Django versions:
-
Update
noxfile.py, adding or removing version constants as needed and updating theDJ_VERSIONSlist accordingly.For example, to add Django 6.1 and remove Django 4.2:
-
Update any Python version constraints in the
should_skip()function if the new Django version has specific Python requirements. -
Regenerate auto-generated content:
This updates:
- Django version trove classifiers in
pyproject.toml - Supported versions list in
README.md - Supported versions list in
docs/installation.md
- Django version trove classifiers in
-
Update the lock file:
-
Test the changes:
Use
just testallrather thanjust testto ensure all Django versions are tested. Thejust testcommand only runs against the default versions (github.com/joshuadavidthomas/django-language-server/tree/main/the oldest supported Python and Django LTS) and won't catch issues with newly added versions.Alternatively, you can test only a specific Django version across all Python versions by using
noxdirectly: -
Update
CHANGELOG.md, adding entries for any versions added or removed. -
For major Django releases: If adding support for a new major Django version (github.com/joshuadavidthomas/django-language-server/tree/main/e.g., Django 6.0), the language server version should be bumped to match per DjangoVer versioning. For example, when adding Django 6.0 support, bump the server from v5.x.x to v6.0.0.
Updating bundled Django sources¶
Before a DJLS release, update crates/djls-project/vendor/django.json to the latest
point release of each supported Django feature-release line. Record each release's
wheel URL and SHA-256 from PyPI, then run:
uv run tools/bundle_django.py
uv run tools/bundle_django.py --check
cargo test -p djls-project
cargo test -p djls-db --test settings
uv installs the script's Typer, Pydantic, and Nox dependencies from its inline metadata.
Commit the manifest and generated ZIP archives together. The script verifies the
download hashes and checks its release lines against DJ_VERSIONS in
noxfile.py, excluding main. It retains Django's Python sources, template
directories, and license files, with stable entry ordering and timestamps. Normal
builds and server startup never download Django.
When adding or removing a supported line, also update DjangoVersion in
djls-conf, the embedded archive match in djls-project/src/bundled.rs, and the
configuration documentation. Keep the ultimate fallback at the oldest supported
LTS, matching DJ_DEFAULT in noxfile.py.
Updating development tools¶
- Update the primary compiler in
rust-toolchain.toml. - Update the formatter nightly in
tools/rustfmt/rust-toolchain.toml, then runjust fmtand review any formatting changes. - Update Hawk in
mise.tomltogether with its required compiler intools/hawk/rust-toolchain.toml, then runmise installandjust hawk. CI uses the same pins. - Update auxiliary tool versions in
mise.toml, then runmise install. Keep cargo-insta aligned with the Insta version resolved inCargo.lock.
Hawk uses compiler-private APIs, so even a patch-level compiler mismatch can make it fail before analysis. When updating it, use the compiler version named in the Hawk release notes.
Justfile¶
The repository includes a Justfile that provides all common development tasks with a consistent interface. Running just without arguments shows all available commands and their descriptions.
$ just
$ # just --list --list-submodules
Available recipes:
bumpver *ARGS
check *ARGS
clean
clippy *ARGS
corpus *ARGS
e2e *ARGS
fixtures *ARGS
fmt *ARGS
hawk *ARGS
lint *ARGS # run pre-commit on all files
run *ARGS
test *ARGS
testall *ARGS
dev:
debug # TODO: djls-tmux binary was removed in #214, this recipe needs updating
explore FILENAME="djls.db"
inspect
profile bench filter="" # Profile a bench with callgrind
record FILENAME="djls.db"
docs:
build LOCATION="site" # Build documentation
serve PORT="8000" # Serve documentation locally