IntelliJ-style JPA language injection for @Query strings in Kotlin Spring Data repositories: JPQL/native SQL highlighting, entity/field completion, and a real sqls LSP for native queries.
Lua
2
11 commits
updated Aug 4, 2026
IntelliJ-style JPA "language injection" for @Query annotations in Kotlin
and Java Spring Data repositories:
@Query("""SELECT e FROM Errand e WHERE e.status = 'posted' ...""")
fun findAvailableErrands(...): List<Errand>
@Query(value = """SELECT e.* FROM errands e WHERE ...""", nativeQuery = true)
fun findNearbyErrands(...): List<Errand>
@Query("SELECT b FROM BonusPeriod b WHERE b.status = :status")
Page<BonusPeriod> findByStatus(@Param("status") BonusStatus status, Pageable pageable);
@Query(nativeQuery = true, value = "SELECT * FROM bonus_periods WHERE ...")
List<BonusPeriod> findRaw();
nativeQuery = true strings are injected as real sql and get a live
sqls LSP client (completion/hover against a real DB connection, read from
the project's own .env — see Configuration), via
otter.nvim.jpql "language" that reuses SQL's grammar/highlights but stays
fully separate from real sql — see How it works) and
completion for entity names, field names (parsed from your @Entity
classes via treesitter — JPA property names, not DB column names), aliases
declared in the query, and JPQL keywords, via a
blink.cmp source.:name named parameters (used in both native and JPQL queries) get
completion for the enclosing repository method's @Param("...") values (or
raw Kotlin parameter names where @Param isn't used).
nvim-treesitter with the kotlin and/or java parser installed, plus sqlsqls on your $PATH — only needed for native query completion/hover.
Depend on it via mason-org/mason.nvim's ensure_installed (see below)
rather than installing it yourself.blink.cmp — only needed for JPQL/native-param completion{
"kratosgado/springboot-jpql.nvim", -- or `dir = "/path/to/springboot-jpql.nvim"` for local dev
ft = "kotlin",
dependencies = {
"nvim-treesitter/nvim-treesitter",
"jmbuhr/otter.nvim",
},
opts = {
entities = {
globs = { "src/main/kotlin/**/*.kt", "src/main/java/**/*.java" },
},
},
},
-- sqls' own dependency: request it via mason's ensure_installed instead of
-- installing it yourself.
{
"mason-org/mason.nvim",
opts = function(_, opts)
opts.ensure_installed = opts.ensure_installed or {}
table.insert(opts.ensure_installed, "sqls")
end,
},
DB connection details are read per project from that project's own
.env file — nothing to hardcode in your Neovim config (see
Configuration to point it at different env var names, or to
override with an explicit connection).
Then register the JPQL completion source in your blink.cmp config:
{
"saghen/blink.cmp",
opts = {
sources = {
default = { "springboot_jpql" },
providers = {
springboot_jpql = {
name = "SpringbootJpql",
module = "springboot-jpql.blink_source",
},
},
},
},
},
(sources.default is additive here if your existing blink.cmp spec declares
opts_extend = { "sources.default" }, as LazyVim's does — otherwise merge the
list yourself.)
require("springboot-jpql").setup({
sqls = {
-- DB connection is read PER PROJECT from that project's `.env` file --
-- this is the normal case and usually needs no configuration at all.
-- `env` names the *env vars* to read (not values). Each can be a single
-- name or a list of candidates tried in order; the defaults already
-- cover both DATABASE_URL-style and DB_URL-style projects, so this only
-- needs overriding for something else entirely.
env = {
url = { "DATABASE_URL", "DB_URL", "SPRING_DATASOURCE_URL" }, -- a JDBC URL: jdbc:postgresql://host:port/db
user = { "DATABASE_USERNAME", "DB_USER", "DB_USERNAME", "SPRING_DATASOURCE_USERNAME" },
password = { "DATABASE_PASSWORD", "DB_PASS", "DB_PASSWORD", "SPRING_DATASOURCE_PASSWORD" },
},
-- Filename to look for at the project root. Default: ".env"
env_file = ".env",
-- Map a JDBC driver id (e.g. "postgresql") to sqls' driver name, for the
-- rare case they differ.
driver_map = nil,
-- Files/dirs used to find the project root (also where `.env` is looked
-- up). Order matters: see the comment in lua/springboot-jpql/sqls.lua --
-- build-file markers should come before `.git` so a monorepo's top-level
-- .git doesn't win over the actual Gradle/Maven project root.
root_markers = { "build.gradle.kts", "build.gradle", "settings.gradle.kts", "pom.xml", ".git" },
-- Explicit sqls connections -- if given, used for *every* project
-- instead of reading `.env` at all:
-- connections = {
-- { driver = "postgresql", dataSourceName = "host=127.0.0.1 port=5432 user=postgres password=postgres dbname=mydb sslmode=disable" },
-- -- or with individual fields instead of dataSourceName:
-- -- { driver = "postgresql", proto = "tcp", host = "127.0.0.1", port = 5432, user = "postgres", passwd = "postgres", dbName = "mydb" },
-- },
-- Override the full LSP command (fun(dispatchers, config) or a string[]).
cmd = nil,
-- Override root_dir resolution (fun(bufnr, on_dir)).
root_dir = nil,
},
otter = {
enabled = true,
-- Languages passed to otter.activate(). Kept narrow to "sql" so kotlin's
-- own builtin injections (regex/printf/comment) don't spin up otter
-- buffers we have no use for here.
languages = { "sql" },
},
entities = {
-- Project root to resolve globs against.
root = nil, -- default: vim.fn.getcwd()
-- Glob patterns (relative to root) for files that may contain @Entity classes.
globs = { "src/main/kotlin/**/*.kt", "src/main/java/**/*.java" },
},
})
sqls is generated a fresh sqls.yml per project root (under
stdpath("data")/springboot-jpql/<hash of root>/sqls.yml) the first time its
client starts for that root, so multiple projects with different databases
open in the same Neovim session don't collide.
Run :SpringbootJpqlRefreshEntities to force a full rescan (entities are also
automatically rescanned per-file on save).
@Query(...) (positional or value = ..., single
string or text block) for their respective grammars, and use a custom
#has-native-query?/#has-native-query-java? treesitter predicate
(registered in lua/springboot-jpql/treesitter.lua)
to check sibling arguments for nativeQuery = true. Native queries are
tagged injection.language "sql"; everything else is tagged "jpql". Java
string concatenation (@Query("a" + "b")) isn't matched -- see
Limitations."jpql" is registered as its own treesitter language, backed by the same
compiled SQL parser binary (vim.treesitter.language.add("jpql", { path = <sql.so>, symbol_name = "sql" })), with
queries/jpql/highlights.scm just inheriting
SQL's highlights (;inherits: sql). This gets JPQL real SQL-flavored
syntax highlighting despite there being no dedicated JPQL grammar, while
keeping it a genuinely distinct language from "sql" for grouping
purposes — otter.nvim's own region scan groups injected children by
resolved language name, so "jpql" and "sql" regions never get merged,
and otter/sqls never sees (or attaches the real DB LSP to) a JPQL region.sqls via vim.lsp.config/vim.lsp.enable with a dynamic cmd: at
client-start time (per project root), it reads that project's .env
(lua/springboot-jpql/dotenv.lua), parses
the JDBC URL (lua/springboot-jpql/jdbc.lua),
renders sqls' connection YAML, and starts sqls -config <that file>.otter.activate({"sql"}, ...) on FileType kotlin,java. otter.nvim
finds the sql-tagged regions via the same core treesitter injection
mechanism, creates a hidden buffer per region, and proxies LSP requests
from the main buffer to it — so blink.cmp's existing "lsp" source (or
nvim-cmp's cmp-nvim-lsp) picks up native-query completions automatically;
no otter-specific completion source is needed.@Entity-annotated classes and
their field names via treesitter (no type resolution — just the property
names, which is what JPQL references): Kotlin primary-constructor
properties, or Java field_declarations.jpql-tagged region (in either language's tree,
matching the buffer's own filetype), and does a best-effort scan of that
one query's text for FROM Entity [AS] alias / JOIN Entity [AS] alias
bindings (only recorded when Entity matches a known @Entity class —
collection-navigation joins like JOIN e.ratings r are skipped since
resolving those needs real type resolution, out of scope here).@Query method's parameters, reading
@Param("...") values (falling back to the raw parameter name) — Kotlin's
function_value_parameters/parameter_modifiers pairing, or Java's
simpler formal_parameters/formal_parameter (which carries its own
annotations and name as direct children, no cross-sibling pairing needed).kotlin/java buffers when the cursor
is in a JPQL or native region. After : it offers the enclosing method's
parameter names (in either region type); in JPQL it also offers field
names after alias., and JPQL keywords + entity names + declared aliases
otherwise.FROM Entity alias / JOIN Entity alias shapes and does not resolve collection-navigation joins
(JOIN e.someCollection x) to their target entity.field_declarations only; Kotlin class-body property declarations aren't
scanned.e.ratings is a
List<Rating>).@Query("a" + "b" + "c")) gets no
injection/highlighting/completion at all — tree-sitter query patterns
can't express "capture every fragment at any depth of a left-recursive
binary_expression" without per-depth duplication. Increasingly rare now
that text blocks exist; use those instead..env parsing is intentionally minimal (KEY=value, # comments,
optional quotes) — no export, $VAR interpolation, or multiline values.IntelliJ-style JPA language injection for @Query strings in Kotlin Spring Data repositories: JPQL/native SQL highlighting, entity/field completion, and a real sqls LSP for native queries.
Lua
2
11 commits
updated Aug 4, 2026
IntelliJ-style JPA "language injection" for @Query annotations in Kotlin
and Java Spring Data repositories:
@Query("""SELECT e FROM Errand e WHERE e.status = 'posted' ...""")
fun findAvailableErrands(...): List<Errand>
@Query(value = """SELECT e.* FROM errands e WHERE ...""", nativeQuery = true)
fun findNearbyErrands(...): List<Errand>
@Query("SELECT b FROM BonusPeriod b WHERE b.status = :status")
Page<BonusPeriod> findByStatus(@Param("status") BonusStatus status, Pageable pageable);
@Query(nativeQuery = true, value = "SELECT * FROM bonus_periods WHERE ...")
List<BonusPeriod> findRaw();
nativeQuery = true strings are injected as real sql and get a live
sqls LSP client (completion/hover against a real DB connection, read from
the project's own .env — see Configuration), via
otter.nvim.jpql "language" that reuses SQL's grammar/highlights but stays
fully separate from real sql — see How it works) and
completion for entity names, field names (parsed from your @Entity
classes via treesitter — JPA property names, not DB column names), aliases
declared in the query, and JPQL keywords, via a
blink.cmp source.:name named parameters (used in both native and JPQL queries) get
completion for the enclosing repository method's @Param("...") values (or
raw Kotlin parameter names where @Param isn't used).
nvim-treesitter with the kotlin and/or java parser installed, plus sqlsqls on your $PATH — only needed for native query completion/hover.
Depend on it via mason-org/mason.nvim's ensure_installed (see below)
rather than installing it yourself.blink.cmp — only needed for JPQL/native-param completion{
"kratosgado/springboot-jpql.nvim", -- or `dir = "/path/to/springboot-jpql.nvim"` for local dev
ft = "kotlin",
dependencies = {
"nvim-treesitter/nvim-treesitter",
"jmbuhr/otter.nvim",
},
opts = {
entities = {
globs = { "src/main/kotlin/**/*.kt", "src/main/java/**/*.java" },
},
},
},
-- sqls' own dependency: request it via mason's ensure_installed instead of
-- installing it yourself.
{
"mason-org/mason.nvim",
opts = function(_, opts)
opts.ensure_installed = opts.ensure_installed or {}
table.insert(opts.ensure_installed, "sqls")
end,
},
DB connection details are read per project from that project's own
.env file — nothing to hardcode in your Neovim config (see
Configuration to point it at different env var names, or to
override with an explicit connection).
Then register the JPQL completion source in your blink.cmp config:
{
"saghen/blink.cmp",
opts = {
sources = {
default = { "springboot_jpql" },
providers = {
springboot_jpql = {
name = "SpringbootJpql",
module = "springboot-jpql.blink_source",
},
},
},
},
},
(sources.default is additive here if your existing blink.cmp spec declares
opts_extend = { "sources.default" }, as LazyVim's does — otherwise merge the
list yourself.)
require("springboot-jpql").setup({
sqls = {
-- DB connection is read PER PROJECT from that project's `.env` file --
-- this is the normal case and usually needs no configuration at all.
-- `env` names the *env vars* to read (not values). Each can be a single
-- name or a list of candidates tried in order; the defaults already
-- cover both DATABASE_URL-style and DB_URL-style projects, so this only
-- needs overriding for something else entirely.
env = {
url = { "DATABASE_URL", "DB_URL", "SPRING_DATASOURCE_URL" }, -- a JDBC URL: jdbc:postgresql://host:port/db
user = { "DATABASE_USERNAME", "DB_USER", "DB_USERNAME", "SPRING_DATASOURCE_USERNAME" },
password = { "DATABASE_PASSWORD", "DB_PASS", "DB_PASSWORD", "SPRING_DATASOURCE_PASSWORD" },
},
-- Filename to look for at the project root. Default: ".env"
env_file = ".env",
-- Map a JDBC driver id (e.g. "postgresql") to sqls' driver name, for the
-- rare case they differ.
driver_map = nil,
-- Files/dirs used to find the project root (also where `.env` is looked
-- up). Order matters: see the comment in lua/springboot-jpql/sqls.lua --
-- build-file markers should come before `.git` so a monorepo's top-level
-- .git doesn't win over the actual Gradle/Maven project root.
root_markers = { "build.gradle.kts", "build.gradle", "settings.gradle.kts", "pom.xml", ".git" },
-- Explicit sqls connections -- if given, used for *every* project
-- instead of reading `.env` at all:
-- connections = {
-- { driver = "postgresql", dataSourceName = "host=127.0.0.1 port=5432 user=postgres password=postgres dbname=mydb sslmode=disable" },
-- -- or with individual fields instead of dataSourceName:
-- -- { driver = "postgresql", proto = "tcp", host = "127.0.0.1", port = 5432, user = "postgres", passwd = "postgres", dbName = "mydb" },
-- },
-- Override the full LSP command (fun(dispatchers, config) or a string[]).
cmd = nil,
-- Override root_dir resolution (fun(bufnr, on_dir)).
root_dir = nil,
},
otter = {
enabled = true,
-- Languages passed to otter.activate(). Kept narrow to "sql" so kotlin's
-- own builtin injections (regex/printf/comment) don't spin up otter
-- buffers we have no use for here.
languages = { "sql" },
},
entities = {
-- Project root to resolve globs against.
root = nil, -- default: vim.fn.getcwd()
-- Glob patterns (relative to root) for files that may contain @Entity classes.
globs = { "src/main/kotlin/**/*.kt", "src/main/java/**/*.java" },
},
})
sqls is generated a fresh sqls.yml per project root (under
stdpath("data")/springboot-jpql/<hash of root>/sqls.yml) the first time its
client starts for that root, so multiple projects with different databases
open in the same Neovim session don't collide.
Run :SpringbootJpqlRefreshEntities to force a full rescan (entities are also
automatically rescanned per-file on save).
@Query(...) (positional or value = ..., single
string or text block) for their respective grammars, and use a custom
#has-native-query?/#has-native-query-java? treesitter predicate
(registered in lua/springboot-jpql/treesitter.lua)
to check sibling arguments for nativeQuery = true. Native queries are
tagged injection.language "sql"; everything else is tagged "jpql". Java
string concatenation (@Query("a" + "b")) isn't matched -- see
Limitations."jpql" is registered as its own treesitter language, backed by the same
compiled SQL parser binary (vim.treesitter.language.add("jpql", { path = <sql.so>, symbol_name = "sql" })), with
queries/jpql/highlights.scm just inheriting
SQL's highlights (;inherits: sql). This gets JPQL real SQL-flavored
syntax highlighting despite there being no dedicated JPQL grammar, while
keeping it a genuinely distinct language from "sql" for grouping
purposes — otter.nvim's own region scan groups injected children by
resolved language name, so "jpql" and "sql" regions never get merged,
and otter/sqls never sees (or attaches the real DB LSP to) a JPQL region.sqls via vim.lsp.config/vim.lsp.enable with a dynamic cmd: at
client-start time (per project root), it reads that project's .env
(lua/springboot-jpql/dotenv.lua), parses
the JDBC URL (lua/springboot-jpql/jdbc.lua),
renders sqls' connection YAML, and starts sqls -config <that file>.otter.activate({"sql"}, ...) on FileType kotlin,java. otter.nvim
finds the sql-tagged regions via the same core treesitter injection
mechanism, creates a hidden buffer per region, and proxies LSP requests
from the main buffer to it — so blink.cmp's existing "lsp" source (or
nvim-cmp's cmp-nvim-lsp) picks up native-query completions automatically;
no otter-specific completion source is needed.@Entity-annotated classes and
their field names via treesitter (no type resolution — just the property
names, which is what JPQL references): Kotlin primary-constructor
properties, or Java field_declarations.jpql-tagged region (in either language's tree,
matching the buffer's own filetype), and does a best-effort scan of that
one query's text for FROM Entity [AS] alias / JOIN Entity [AS] alias
bindings (only recorded when Entity matches a known @Entity class —
collection-navigation joins like JOIN e.ratings r are skipped since
resolving those needs real type resolution, out of scope here).@Query method's parameters, reading
@Param("...") values (falling back to the raw parameter name) — Kotlin's
function_value_parameters/parameter_modifiers pairing, or Java's
simpler formal_parameters/formal_parameter (which carries its own
annotations and name as direct children, no cross-sibling pairing needed).kotlin/java buffers when the cursor
is in a JPQL or native region. After : it offers the enclosing method's
parameter names (in either region type); in JPQL it also offers field
names after alias., and JPQL keywords + entity names + declared aliases
otherwise.FROM Entity alias / JOIN Entity alias shapes and does not resolve collection-navigation joins
(JOIN e.someCollection x) to their target entity.field_declarations only; Kotlin class-body property declarations aren't
scanned.e.ratings is a
List<Rating>).@Query("a" + "b" + "c")) gets no
injection/highlighting/completion at all — tree-sitter query patterns
can't express "capture every fragment at any depth of a left-recursive
binary_expression" without per-depth duplication. Increasingly rare now
that text blocks exist; use those instead..env parsing is intentionally minimal (KEY=value, # comments,
optional quotes) — no export, $VAR interpolation, or multiline values.