Files
plainleaf/libraries/Library/Std/Editor/Lua.md
T
Zef Hemel 4d54d05f88 Schema-based attribute completion for frontmatter and LIQ queries
Experimental and rudamentary, will not cover all cases.
2026-01-27 16:38:48 +01:00

6.7 KiB

#meta

Basic editor support for Space Lua, implemented in Space Lua.

Features:

  • Basic code completion for globals, e.g. string.gm will be completed
  • Navigation to global symbols implemented in Lua by Cmd-clicking (Mac) or Ctrl-clicking (other OSes)

Code complete support

-- priority: 10
local LUA_KEYWORDS = {"do", "if", "then", "for", "else", "end", "function", "local", "return", "in"}

-- Are we in a comment?
local function inComment(line)
  return string.find(line, "%-%-")
end

-- Are we in a string?
local function inString(line)
  local singleQuotes = 0
  local doubleQuotes = 0
  local brackets = 0
  for i = 1, string.len(line) do
    local c = line[i]
    if c == "'" then
      singleQuotes = singleQuotes + 1
    elseif c == '"' then
      doubleQuotes = doubleQuotes + 1
    elseif c == "[" and line[i+1] == "[" and line:sub(i-5, i) != "query[" then
      brackets = brackets + 1
    elseif c == "]" and line[i-1] == "]" then
      brackets = brackets - 1
    end
  end
  return singleQuotes % 2 == 1 or doubleQuotes % 2 == 1 or brackets > 0
end

-- Converts (some) JSON schema to dummy Lua values for code complete to use
local function schemaToDummyValue(schema)
  if schema.type == "string" then
    return ""
  elseif schema.type == "number" then
    return 0
  elseif schema.type == "boolean" then
    return true
  elseif schema.type == "array" then
    return {}
  elseif schema.type == "object" then
    local t = {}
    for k, v in pairs(schema.properties) do
      t[k] = schemaToDummyValue(v)
    end
    return t
  else
    return nil
  end
end

-- Implements extremely simplistic global variable augmentation by scanning for local variables (declared via `local` and `from`)
local function augmentGlobal(localCode)
  local vars = {}
  -- Copy over all globals
  for k, v in pairs(_G) do
    vars[k] = v
  end
  -- Then approximate specific local tag-based object declarations in queries and add them
  -- matches `tags.something`, `index.tag "something"` and `index.tag("something")` patterns only
  for m in string.matchRegexAll(localCode, "(?:from|local)\\s+(\\w+)\\s+=\\s*(?:index\\.tag\\s*\"(\\w+)\"|index\\.tag\\s*\\(\"(\\w+)\"\\)|tags\\.(\\w+))") do
    local tag
    for i, t in ipairs(m) do
      if i > 2 and t then
        tag = t
      end
    end
    -- Do we have a schema for this tag?
    local schema = config.get({"tags", tag, "schema"})
    if schema then
      -- Schema exists, add to vars
      vars[m[2]] = schemaToDummyValue(schema)
    end
  end
  return vars
end

-- API code completion for Lua
-- Completes a.b-style property accesses 
event.listen {
  name = "editor:complete",
  run = function(e)
    local parents = e.data.parentNodes
    local luaCode = nil
    for _, parent in ipairs(parents) do
      if string.startsWith(parent, "FencedCode:space-lua") then
        luaCode = string.sub(parent, #"FencedCode:space-lua"+1)
      elseif string.startsWith(parent, "LuaDirective:") then
        luaCode = string.sub(parent, #"LuaDirective:"+1)
      end
    end
    -- Not in a Lua context
    if not luaCode then
      return
    end
    local linePrefix = e.data.linePrefix
    if inComment(linePrefix) or inString(linePrefix) then
      return
    end
    local pos = e.data.pos
    local propaccessPrefix = string.matchRegex(linePrefix, "([a-zA-Z_0-9]+\\.)*([a-zA-Z_0-9]*)$")
    if not propaccessPrefix or propaccessPrefix[1] == "" then
      -- No propaccess prefix, so we can't complete
      return
    end
    -- Split propaccess and traverse
    local propParts = string.split(propaccessPrefix[1], ".")
    local currentValue = augmentGlobal(luaCode)
    local failed = false
    for i = 1, #propParts-1 do
      local prop = propParts[i]
      if currentValue then
        currentValue = currentValue[prop]
      else
        failed = true
      end
    end
    if failed then
      return
    end
    local lastProp = propParts[#propParts]
    if table.includes(LUA_KEYWORDS, lastProp) then
      return
    end
    local options = {}
    if not currentValue then
      return
    end
    for key, val in pairs(currentValue) do
      if string.startsWith(key, lastProp) and val then
        if type(val) == "function" then
          -- We got a function
          if val.body then
            -- Function defined in Lua
            table.insert(options, {
              label = key .. "(" .. table.concat(val.body.parameters, ", ") ..")",
              apply = key,
              detail = "function"
            })
          else
            -- Builtin
            table.insert(options, {
              label = key .. "()",
              apply = key,
              detail = "built-in"
            })
          end
        else
          table.insert(options, {
            label = key,
            detail = type(val),
          })
        end
      end
    end
    if #options > 0 then
      return {
        from = pos - string.len(lastProp),
        options = options
      }
    end
  end
}

Navigation

Ctrl/Cmd-click navigation to Lua function definition.

-- priority: 10
local function inLuaContext(parentNodes)
  for _, node in ipairs(parentNodes) do
    if node == "LuaDirective"
      or node:startsWith("FencedCode:space-lua") then
      return true
    end
  end
  return false
end

local function notDefinedinLua()
  return editor.flashNotification(
    "Cannot navigate to definition; not defined in Lua."
  )
end

event.listen {
  name = "page:click",
  run = function(e)
    -- Accept either Cmd-click (Mac) or Ctrl-click (Linux)
    if not (e.data.metaKey or e.data.ctrlKey) then
      return
    end
    if not inLuaContext(e.data.parentNodes) then
      return notDefinedinLua()
    end

    local pos = e.data.pos
    local text = editor.getText()
    -- Find start pos
    local startPos = pos
    while string.match(text[startPos], "[a-zA-Z0-9._]") do
      startPos = startPos - 1
      if startPos <= 0 then
        return
      end
    end
    -- Find end pos
    local endPos = pos
    while string.match(text[endPos], "[a-zA-Z0-9_]") do
      endPos = endPos + 1
      if startPos >= #text then
        return
      end
    end
    local callText = text:sub(startPos+1, endPos-1)
    local propParts = callText:split(".")
    local currentValue = _G
    for i = 1, #propParts do
      local prop = propParts[i]
      if currentValue then
        currentValue = currentValue[prop]
      else
        return
      end
    end
    -- Check if this is a Lua-defined API
    if currentValue and currentValue.body and currentValue.body.ctx then
      local ctx = currentValue.body.ctx
      -- Parse out the position in the doc
      local refBits = ctx.ref:split("@")
      -- Navigate there
      editor.navigate(refBits[1] .. "@" ..
        (tonumber(refBits[2]) + ctx.from + #"```space-lua\n")
      )
    else
      return notDefinedinLua()
    end
  end
}