Kratosgado/springboot-jpql.nvim

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

See the code

See what people are saying

SourceMessageScoreDate

[Plugin] springboot-jpql.nvim — IntelliJ-style JPQL/native SQL support for Kotlin Spring Data repositories (r/neovim)

Hi everyone! https://preview.redd.it/gf47sd4ltlth1.png?width=611&format=png&auto=webp&s=ef901708bf443453127e6baf6665321d4f6bdaf7 I’ve built [**springboot-jpql.nvim**](https://github.com/Kratosgado/springboot-jpql.nvim), a Neovim plugin for Kotlin Spring Data repositories that brings…

3

Oct 5, 2026

README

springboot-jpql.nvim

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 strings (everything else) get SQL-flavored syntax highlighting (via a 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).

JPQL and native queries both syntax-highlighted inside @Query strings

Requirements

  • Neovim >= 0.10
  • nvim-treesitter with the kotlin and/or java parser installed, plus sql
  • otter.nvim
  • sqls 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

Install (lazy.nvim)

{
  "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.)

Configuration

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).

How it works

  • queries/kotlin/injections.scm and queries/java/injections.scm each match the string argument to @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.
  • lua/springboot-jpql/sqls.lua registers 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>.
  • lua/springboot-jpql/otter_integration.lua calls 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.
  • lua/springboot-jpql/entities.lua scans files matching your configured globs for @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.
  • lua/springboot-jpql/jpql.lua detects whether the cursor is inside a 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).
  • lua/springboot-jpql/params.lua walks back from the cursor (staying within the host language's own tree, not the injected one) to the enclosing @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).
  • lua/springboot-jpql/blink_source.lua is a blink.cmp source: enabled for 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.

Limitations

  • JPQL alias resolution is a lightweight text scan of the current query, not a real JPQL parser — it handles the common FROM Entity alias / JOIN Entity alias shapes and does not resolve collection-navigation joins (JOIN e.someCollection x) to their target entity.
  • Entity fields come from Kotlin primary-constructor properties or Java field_declarations only; Kotlin class-body property declarations aren't scanned.
  • No cross-file type resolution: field types aren't inspected, so there's no relationship-aware completion (e.g. it won't know e.ratings is a List<Rating>).
  • Java string concatenation (@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.

Kratosgado/springboot-jpql.nvim

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

See the code

See what people are saying

SourceMessageScoreDate

[Plugin] springboot-jpql.nvim — IntelliJ-style JPQL/native SQL support for Kotlin Spring Data repositories (r/neovim)

Hi everyone! https://preview.redd.it/gf47sd4ltlth1.png?width=611&amp;format=png&amp;auto=webp&amp;s=ef901708bf443453127e6baf6665321d4f6bdaf7 I’ve built [**springboot-jpql.nvim**](https://github.com/Kratosgado/springboot-jpql.nvim), a Neovim plugin for Kotlin Spring Data repositories that brings…

3

Oct 5, 2026

README

springboot-jpql.nvim

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 strings (everything else) get SQL-flavored syntax highlighting (via a 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).

JPQL and native queries both syntax-highlighted inside @Query strings

Requirements

  • Neovim >= 0.10
  • nvim-treesitter with the kotlin and/or java parser installed, plus sql
  • otter.nvim
  • sqls 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

Install (lazy.nvim)

{
  "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.)

Configuration

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).

How it works

  • queries/kotlin/injections.scm and queries/java/injections.scm each match the string argument to @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.
  • lua/springboot-jpql/sqls.lua registers 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>.
  • lua/springboot-jpql/otter_integration.lua calls 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.
  • lua/springboot-jpql/entities.lua scans files matching your configured globs for @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.
  • lua/springboot-jpql/jpql.lua detects whether the cursor is inside a 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).
  • lua/springboot-jpql/params.lua walks back from the cursor (staying within the host language's own tree, not the injected one) to the enclosing @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).
  • lua/springboot-jpql/blink_source.lua is a blink.cmp source: enabled for 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.

Limitations

  • JPQL alias resolution is a lightweight text scan of the current query, not a real JPQL parser — it handles the common FROM Entity alias / JOIN Entity alias shapes and does not resolve collection-navigation joins (JOIN e.someCollection x) to their target entity.
  • Entity fields come from Kotlin primary-constructor properties or Java field_declarations only; Kotlin class-body property declarations aren't scanned.
  • No cross-file type resolution: field types aren't inspected, so there's no relationship-aware completion (e.g. it won't know e.ratings is a List<Rating>).
  • Java string concatenation (@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.