feat(image): added support for a bunch of aditional languages

This commit is contained in:
Folke Lemaitre 2025-02-16 14:16:13 +01:00
parent 332f4278b0
commit a596f8a9ea
No known key found for this signature in database
GPG key ID: 41F8B1FBACAE2040
21 changed files with 236 additions and 179 deletions

View file

@ -6,160 +6,22 @@ local M = {}
---@field win snacks.win
---@field buf number
---@class snacks.image.Query
---@field setup fun():vim.treesitter.Query
---@field query? vim.treesitter.Query|false
---@field transform? fun(buf:number, src:string, anchor: TSNode, image: TSNode): string
---@alias snacks.image.transform fun(buf:number, src:string, anchor: TSNode, image: TSNode): string
---@type table<string, {setup:(fun():vim.treesitter.Query), query?:vim.treesitter.Query|false}>
M._queries = {
markdown = {
setup = function()
return vim.treesitter.query.parse(
"markdown_inline",
[[
(image
[
(link_destination) @image
(image_description (shortcut_link (link_text) @image))
]) @anchor
]]
)
end,
transform = function(_, src)
return src:gsub("|.*", ""):gsub("^<", ""):gsub(">$", "")
end,
},
html = {
setup = function()
return vim.treesitter.query.parse(
"html",
[[
(element
(start_tag
(tag_name) @tag (#eq? @tag "img")
(attribute
(attribute_name) @attr_name (#eq? @attr_name "src")
(quoted_attribute_value (attribute_value) @image)
)
)
) @anchor
(self_closing_tag
(tag_name) @tag (#eq? @tag "img")
(attribute
(attribute_name) @attr_name (#eq? @attr_name "src")
(quoted_attribute_value (attribute_value) @image)
)
) @anchor
]]
)
end,
},
tsx = {
setup = function()
return vim.treesitter.query.parse(
"tsx",
[[
(jsx_element
(jsx_opening_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
)
) @anchor
(jsx_self_closing_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
) @anchor
]]
)
end,
},
javascript = {
setup = function()
return vim.treesitter.query.parse(
"javascript",
[[
(jsx_element
(jsx_opening_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
)
) @anchor
(jsx_self_closing_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
) @anchor
]]
)
end,
},
css = {
setup = function()
return vim.treesitter.query.parse(
"css",
[[
(declaration
(call_expression
(function_name) @fn (#eq? @fn "url")
(arguments [(plain_value) @image (string_value (string_content) @image)]))
) @anchor
]]
)
end,
},
norg = {
setup = function()
return vim.treesitter.query.parse(
"norg",
[[
(infirm_tag
(tag_name) @tag (#eq? @tag "image")
(tag_parameters (tag_param) @image)
) @anchor
]]
)
end,
---@param anchor TSNode
---@param img TSNode
transform = function(buf, _, anchor, img)
local row, col = img:start()
local line = vim.api.nvim_buf_get_lines(buf, row, row + 1, false)[1]
return line:sub(col + 1)
end,
},
---@type table<string, snacks.image.transform>
M.transforms = {
---@param anchor TSNode
---@param img TSNode
norg = function(buf, _, anchor, img)
local row, col = img:start()
local line = vim.api.nvim_buf_get_lines(buf, row, row + 1, false)[1]
return line:sub(col + 1)
end,
}
local hover ---@type snacks.image.Hover?
local uv = vim.uv or vim.loop
function M.queries()
local ret = {} ---@type snacks.image.Query[]
for _, query in pairs(M._queries) do
if query.query == nil then
local ok, q = pcall(query.setup)
query.query = ok and q or false
end
if query.query then
table.insert(ret, query)
end
end
return ret
end
---@param str string
function M.url_decode(str)
return str:gsub("+", " "):gsub("%%(%x%x)", function(hex)
@ -201,36 +63,39 @@ function M.find(buf, from, to)
end
parser:parse(from and to and { from, to } or true)
local ret = {} ---@type {id:string, pos:snacks.image.Pos, src:string}[]
parser:for_each_tree(function(tstree)
parser:for_each_tree(function(tstree, tree)
if not tstree then
return
end
for _, query in ipairs(M.queries()) do
for _, match in query.query:iter_matches(tstree:root(), buf, from and from - 1 or nil, to and to - 1 or nil) do
local src, pos, nid ---@type string, snacks.image.Pos, string
local anchor, image ---@type TSNode, TSNode
for id, nodes in pairs(match) do
nodes = type(nodes) == "userdata" and { nodes } or nodes
local name = query.query.captures[id]
for _, node in ipairs(nodes) do
if name == "image" then
image = node
src = vim.treesitter.get_node_text(node, buf)
elseif name == "anchor" then
anchor = node
local range = { node:range() }
pos = { range[1] + 1, range[2] }
nid = node:id()
end
local query = vim.treesitter.query.get(tree:lang(), "images")
if not query then
return
end
for _, match, meta in query:iter_matches(tstree:root(), buf, from and from - 1 or nil, to and to - 1 or nil) do
local src, pos, nid ---@type string, snacks.image.Pos, string
local anchor, image ---@type TSNode, TSNode
for id, nodes in pairs(match) do
nodes = type(nodes) == "userdata" and { nodes } or nodes
local name = query.captures[id]
for _, node in ipairs(nodes) do
if name == "image" then
image = node
src = vim.treesitter.get_node_text(node, buf, { metadata = meta[id] })
elseif name == "anchor" then
anchor = node
local range = { node:range() }
pos = { range[1] + 1, range[2] }
nid = node:id()
end
end
if src and pos and nid then
if query.transform then
src = query.transform(buf, src, anchor, image)
end
src = M.resolve(buf, src)
ret[#ret + 1] = { id = nid, pos = pos, src = src }
end
if src and pos and nid then
local transform = M.transforms[tree:lang()]
if transform then
src = transform(buf, src, anchor, image)
end
src = M.resolve(buf, src)
ret[#ret + 1] = { id = nid, pos = pos, src = src }
end
end
end)

View file

@ -51,7 +51,6 @@ local defaults = {
-- a treesitter parser must be available for the enabled languages.
-- supported language injections: markdown, html
enabled = true,
lang = { "markdown", "html", "norg", "tsx", "javascript", "css", "vue", "angular" },
-- render the image inline in the buffer
-- if your env doesn't support unicode placeholders, this will be disabled
-- takes precedence over `opts.float` on supported terminals
@ -127,6 +126,14 @@ function M.hover()
M.doc.hover()
end
---@return string[]
function M.langs()
local queries = vim.api.nvim_get_runtime_file("queries/*/images.scm", true)
return vim.tbl_map(function(q)
return q:match("queries/(.-)/images%.scm")
end, queries)
end
---@private
---@param ev? vim.api.keyset.create_autocmd.callback_args
function M.setup(ev)
@ -156,12 +163,13 @@ function M.setup(ev)
})
end
if M.config.enabled and M.config.doc.enabled then
local langs = M.langs()
vim.api.nvim_create_autocmd("FileType", {
group = group,
callback = function(e)
local ft = vim.bo[e.buf].filetype
local lang = vim.treesitter.language.get_lang(ft)
if vim.tbl_contains(M.config.doc.lang, lang) then
if vim.tbl_contains(langs, lang) then
vim.schedule(function()
M.doc.attach(e.buf)
end)
@ -208,14 +216,15 @@ function M.health()
)
)
M.doc.queries()
for lang, q in pairs(M.doc._queries) do
if q.query then
Snacks.health.ok("Images rendering for `" .. lang .. "` is available")
for _, lang in ipairs(M.langs()) do
local ok, parser = pcall(vim.treesitter.get_string_parser, "", lang)
if ok and parser then
Snacks.health.ok("Image rendering for `" .. lang .. "` is available")
else
Snacks.health.warn("Images rendering for `" .. lang .. "` is not available.\nMissing treesitter parser.")
Snacks.health.error("Image rendering for `" .. lang .. "` is not available")
end
end
if env.supported then
Snacks.health.ok("your terminal supports the kitty graphics protocol")
elseif M.config.force then

6
queries/css/images.scm Normal file
View file

@ -0,0 +1,6 @@
(declaration
(call_expression
(function_name) @fn (#eq? @fn "url")
(arguments [(plain_value) @image (string_value (string_content) @image)]))
) @anchor

17
queries/html/images.scm Normal file
View file

@ -0,0 +1,17 @@
(element
(start_tag
(tag_name) @tag (#eq? @tag "img")
(attribute
(attribute_name) @attr_name (#eq? @attr_name "src")
(quoted_attribute_value (attribute_value) @image)
)
)
) @anchor
(self_closing_tag
(tag_name) @tag (#eq? @tag "img")
(attribute
(attribute_name) @attr_name (#eq? @attr_name "src")
(quoted_attribute_value (attribute_value) @image)
)
) @anchor

View file

@ -0,0 +1,18 @@
(jsx_element
(jsx_opening_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
)
) @anchor
(jsx_self_closing_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
) @anchor

View file

@ -0,0 +1 @@
; extends

View file

@ -0,0 +1,9 @@
(image
[
(link_destination) @image
(image_description (shortcut_link (link_text) @image))
(#gsub! @image "|.*" "") ; remove wikilink image options
(#gsub! @image "^<" "") ; remove bracket link
(#gsub! @image "^>" "")
]) @anchor

5
queries/norg/images.scm Normal file
View file

@ -0,0 +1,5 @@
(infirm_tag
(tag_name) @tag (#eq? @tag "image")
(tag_parameters (tag_param) @image)
) @anchor

11
queries/scss/images.scm Normal file
View file

@ -0,0 +1,11 @@
(declaration
(call_expression
(function_name) @fn (#eq? @fn "url")
(arguments [
(plain_value) @image
(string_value) @image
; Remove quotes from the image URL
(#gsub! @image "^['\"]" "")
(#gsub! @image "['\"]$" "")
]))
) @anchor

18
queries/tsx/images.scm Normal file
View file

@ -0,0 +1,18 @@
(jsx_element
(jsx_opening_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
)
) @anchor
(jsx_self_closing_element
(identifier) @tag (#eq? @tag "img")
(jsx_attribute
(property_identifier) @attr_name (#eq? @attr_name "src")
(string (string_fragment) @image)
)
) @anchor

2
queries/vue/images.scm Normal file
View file

@ -0,0 +1,2 @@
; inherits: html
; extends

4
tests/image/test.css Normal file
View file

@ -0,0 +1,4 @@
.foo {
background: lightblue url("./test.png") no-repeat fixed center;
content: url(test.png);
}

19
tests/image/test.html Normal file
View file

@ -0,0 +1,19 @@
<body>
<a href="https://github.com/folke/lazy.nvim/releases/latest">
<img
alt="Latest release"
src="https://img.shields.io/github/v/release/folke/lazy.nvim?style=for-the-badge&logo=starship&color=C9CBFF&logoColor=D9E0EE&labelColor=302D41&include_prerelease&sort=semver"
/>
</a>
<a href="https://github.com/folke/lazy.nvim/pulse">
<img
alt="Last commit"
src="https://img.shields.io/github/last-commit/folke/lazy.nvim?style=for-the-badge&logo=starship&color=8bd5ca&logoColor=D9E0EE&labelColor=302D41"
/>
</a>
</body>
<style>
background: lightblue url("./test.png") no-repeat fixed center;
content: url(test.png);
</style>

BIN
tests/image/test.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

10
tests/image/test.jsx Normal file
View file

@ -0,0 +1,10 @@
export const Modal = (props) => {
return (
<Show>
<img src="test.png" />
<img src="https://picsum.photos/200/300"></img>
</Show>
)
}

25
tests/image/test.md Normal file
View file

@ -0,0 +1,25 @@
# test
## Wikilinks
!![[test.png]]
!![[test.png|options]]
## Injected HTML
<img src="test.png" alt="png" width="200" height="30" />
<a href="https://github.com/folke/lazy.nvim/releases/latest">
<img alt="Latest release" src="https://img.shields.io/github/v/release/folke/lazy.nvim?style=for-the-badge&logo=starship&color=C9CBFF&logoColor=D9E0EE&labelColor=302D41&include_prerelease&sort=semver" />
</a>
## Markdown Links
![small](https://picsum.photos/200/30)
![relative png](./test.png)
![png](test.png)
![jpg](test.jpg)

7
tests/image/test.norg Normal file
View file

@ -0,0 +1,7 @@
# Test
- foo
.image ./test.png
.image test.png

BIN
tests/image/test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

5
tests/image/test.scss Normal file
View file

@ -0,0 +1,5 @@
.foo {
background: lightblue url("./test.png") no-repeat fixed center;
background: lightblue url("./test.png") no-repeat fixed center;
content: url(test.png);
}

9
tests/image/test.tsx Normal file
View file

@ -0,0 +1,9 @@
export const Modal = (props) => {
return (
<Show>
<img src="test.png" />
<img src="https://picsum.photos/200/300"></img>
</Show>
)
}

17
tests/image/test.vue Normal file
View file

@ -0,0 +1,17 @@
<script setup>
import { ref } from "vue";
const greeting = ref("Hello World!");
</script>
<template>
<p class="greeting">{{ greeting }}</p>
<img src="test.png" alt="test" />
</template>
<style>
.greeting {
color: red;
font-weight: bold;
background: url("test.png");
}
</style>