@ 261105.2
current
-- Run with: nvim -l scripts/gen_options.lua
-- Generates OPTIONS.md from EmmyLua annotations in the codebase

--- types from https://github.com/lewis6991/gitsigns.nvim/blob/42d6aed4e94e0f0bbced16bbdcc42f57673bd75e/gen_help.lua#L41
--- @alias EmmyDocLoc { file: string, line: integer }
--- @alias EmmyDocParam { name: string, typ: string, desc: string? }
--- @alias EmmyDocReturn { name: string?, typ: string, desc: string? }
--- @alias EmmyDocModule { name: string, members: EmmyDocFn[] }

--- @class EmmyDocFn: EmmyDocTypeField
--- @field type 'fn'
--- @field name string
--- @field description string?
--- @field deprecated boolean
--- @field deprecation_reason string?
--- @field loc EmmyDocLoc
--- @field params EmmyDocParam[]
--- @field returns EmmyDocReturn[]

--- @class EmmyDocTypeField
--- @field type 'field'
--- @field name string
--- @field description string?
--- @field typ string
--- @field is_async boolean?

--- @alias EmmyDocTypeMember EmmyDocTypeField | EmmyDocFn

--- @class EmmyDocTypeClass: EmmyDocTypeField
--- @field type 'class'
--- @field name string
--- @field bases string[]?
--- @field members EmmyDocTypeMember[]

--- @class EmmyDocTypeAlias: EmmyDocTypeField
--- @field type 'alias'
--- @field name string
--- @field members EmmyDocTypeMember[]

--- @alias EmmyDocType EmmyDocTypeClass | EmmyDocTypeAlias | EmmyDocTypeField

--- @class EmmyDocJson
--- @field modules EmmyDocModule[]
--- @field types EmmyDocType[]?

local obj = vim.system({ "sh", "-c", [[
  emmylua_doc_cli lua/fzf-lua/ --output-format json --output stdout
]] }):wait()

vim.opt.rtp:append(vim.fn.fnamemodify(assert(debug.getinfo(1, "S")).source:gsub("^@", ""), ":h:h:h:p"))
local defaults = require("fzf-lua.defaults").defaults

local res = vim.json.decode(obj.stdout or "") ---@type EmmyDocJson

local tymap = {} ---@type table<string, EmmyDocType?>
---@diagnostic disable-next-line: redundant-parameter, call-non-callable, access-invisible
vim.iter(assert(res.types) --[[@as table<integer, EmmyDocType>]]):each(function(ty) tymap[ty.name] = ty end)

---@param typ string
---@param default? string
---@return string, string?
local function fix_typ(typ, default)
  if typ == "fzf-lua.profile" then
    if tymap[typ] and tymap[typ].name then
      return fix_typ(tymap[typ].typ, default)
    end
  end
  -- First, remove trailing ? (optional marker)
  typ = typ:gsub("%?$", "")
  -- Clean up fzf-lua config prefix
  typ = typ:gsub("fzf%-lua%.config%.", "")
  -- Remove outer parentheses from union types for cleaner display
  if typ:match("^%(.*%)$") then
    local ty = typ:sub(2, -2)
    ---@diagnostic disable-next-line: redundant-parameter, call-non-callable
    local is = vim.iter(vim.split(ty, ",")):all(function(t)
    ---@diagnostic disable-next-line: return-type-mismatch
      return t:match([[".-"]])
    end)
    if is then
      return "string[]", typ
    end
    typ = ty
  end
  -- Clean up complex types for readability (remove function signatures)
  if typ:match("|fun") then
    typ = typ:gsub("|%(fun%([^)]*%)[^)]*%)", "")
  end
  return typ, default
end

local normalize_classname = function(classname)
  return classname:gsub("^fzf%-lua%.config%.", ""):gsub("(%l)(%u)", "%1_%2"):lower()
end

local literal = function(v)
  if v == nil then
    return "nil"
  end
  if type(v) ~= "function" then
    local str = vim.inspect(v, { newline = " ", indent = "" })
    -- Truncate very long defaults
    if #str > 60 then
      str = str:sub(1, 57) .. "..."
    end
    return str
  end
  return nil -- Don't show function defaults
end

local alias = {
  __HLS = "hls",
}

-- Fields to exclude from Global Options (setup-only)
local EXCLUDE_FROM_GLOBALS = {
  nbsp = true, -- documented in Setup Options
}

-- Options that are global (not picker-specific) and should be processed
-- from Defaults type with recursion (skipped from Base type processing)
local globals_include = {
  winopts = true,
  keymap = true,
  actions = true,
  hls = true,
}

-- Static header content for OPTIONS.md
local STATIC_HEADER = [[
> [!NOTE]
> **This document should not be modified directly as it's automatically generated from emmylua
> comments and annotations and updated as part of the vimdoc CI, for manual regeneration run
> `nvim -l scripts/gen_options.lua`**

---

# Fzf-Lua Commands and Options

- [General Usage](#general-usage)
- [Setup Options](#setup-options)
- [Global Options](#global-options)
- [Pickers](#pickers)

---

## General Usage

Options in fzf-lua can be specified in a few different ways:
- Global setup options
- Provider-defaults setup options
- Provider-specific setup options
- Command call options

Most of fzf-lua's options are applicable in all of the above, a few examples below:

Global setup, applies to all fzf-lua interfaces:
```lua
-- Places the floating window at the bottom left corner
require("fzf-lua").setup({ winopts = { row = 1, col = 0 } })
```

Disable `file_icons` globally (files, grep, etc) via provider defaults setup options:
```lua
require("fzf-lua").setup({ defaults = { file_icons = false } })
```

Disable `file_icons` in `files` only via provider specific setup options:
```lua
require("fzf-lua").setup({ files = { file_icons = false } })
```

Disable `file_icons` in `files`, applies to this call only:
```lua
:lua require("fzf-lua").files({ file_icons = false  })
-- Or
:FzfLua files file_icons=false
```

Fzf-lua conveniently enables setting lua tables recursively using dotted keys, for example, if we
wanted to call `files` in "split" mode (instead of the default floating window), we would normally call:

```lua
:lua require("fzf-lua").files({ winopts = { split = "belowright new" } })
```

But we can also use the dotted key format (unique to fzf-lua):
```lua
:lua require("fzf-lua").files({ ["winopts.split"] = "belowright new" })
```

This makes it possible to send nested lua values via the `:FzfLua` user command:
```lua
-- Escape spaces with \
:FzfLua files winopts.split=belowright\ new
```

Lua string serialization is also possible:
```lua
-- Places the floating window at the top left corner
:FzfLua files winopts={row=0,col=0}
```

---

## Setup Options

Most of fzf-lua's options are global, meaning they can be specified in any of the different ways
explained in [General Usage](#general-usage) and are described in detail in the [Global Options](#global-options) section below.

There are however a few options that can be specified only during the call to `setup`, these are
described below.

#### setup.nbsp

Type: `string`, Default: `nil`

Fzf-lua uses a special invisible unicode character `EN SPACE` (U+2002) as text delimiter.

It is not recommended to modify this value as this can have unintended consequences when entries contain the character designated as `nbsp`, but if your terminal/font does not support `EN_SPACE` you can use `NBSP` (U+00A0) instead:
```lua
require("fzf-lua").setup({ nbsp = "\xc2\xa0" })
```

#### setup.winopts.preview.default

Type: `string|function|object`, Default: `builtin`

Default previewer for file pickers, possible values `builtin|bat|cat|head`, for example:

```lua
require("fzf-lua").setup({ winopts = { preview = { default = "bat" } } })
```

If set to a `function` the return value will be used (`string|object`).

If set to an `object`, fzf-lua expects a previewer class that will be initialized with `object:new(...)`, see the advanced Wiki "Neovim builtin previewer" section for more info.

#### setup.help_open_win

Type: `fun(number, boolean, table)`,  Default: `vim.api.nvim_open_win`

Function override for opening the help window (default bound to `<F1>`), will be called with the same arguments as `nvim_open_win(bufnr, enter, winopts)`. By default opens a floating window at the bottom of current screen.

Override this function if you want to customize window configs of the help window (location, width, border, etc.).

Example, opening a floating help window at the top of screen with single border:
```lua
    require("fzf-lua").setup({
      help_open_win = function(buf, enter, opts)
        opts.border = 'single'
        opts.row = 0
        opts.col = 0
        return vim.api.nvim_open_win(buf, enter, opts)
      end,
    })
```

#### setup.ui_select

Type: `boolean|table|function`, Default: `false`

Register fzf-lua as the UI interface for `vim.ui.select` during `setup`.

When set to a table or function, the value is passed to `register_ui_select`.

---

## Global Options

Globals are options that aren't picker-specific and can be used with all fzf-lua commands, for
example, positioning the floating window at the bottom line using `globals.winopts.row`:

> The `globals` prefix denotates the scope of the option and is therefore omitted

Using `FzfLua` user command:
```lua
:FzfLua files winopts.row=1
```

Using Lua:
```lua
:lua require("fzf-lua").files({ winopts = { row = 1 } })
-- Using the recursive option format
:lua require("fzf-lua").files({ ["winopts.row"] = 1 })
```

]]

-- Track which options we've already output
local done = {}

local function make_desc(member)
  if type(member.description) == "string" then
    return member.description
  end
  return nil
end

local function concat(x, sep)
  local function flatten(y)
    local ret = {}
    for _, v in ipairs(y) do
      if type(v) == "table" then
        vim.list_extend(ret, flatten(v))
      elseif type(v) == "string" then
        ret[#ret + 1] = v
      end
    end
    return ret
  end
  if not x then return "" end
  return table.concat(flatten(x), sep or "\n") .. "\n"
end

-- Sort members alphabetically by name
local function sort_members(members)
  local sorted = vim.deepcopy(members)
  table.sort(sorted, function(a, b)
    return (a.name or "") < (b.name or "")
  end)
  return sorted
end

local function _member_to_markdown(classname, member, rec, header_level, require_desc)
  header_level = header_level or "####"
  local name = alias[member.name] or member.name or "Unknown"
  local original_typ = member.typ
      or (member.returns and member.returns[1] and member.returns[1].typ)
      or "Unknown"
  local typ = original_typ
  local norm = ("%s.%s"):format(normalize_classname(classname), name)
  -- Build path for default lookup, handling aliases
  local path_parts = vim.split(norm, "%.")
  -- Skip "globals" prefix for default lookup
  local lookup_parts = vim.list_slice(path_parts, 2)
  -- Handle __HLS alias
  if lookup_parts[1] == "hls" then lookup_parts[1] = "__HLS" end
  local default_val = vim.tbl_get(defaults, unpack(lookup_parts))
  local default = type(member.literal) == "string" and member.literal or literal(default_val)
  typ, default = fix_typ(typ, default)
  if done[norm] or name:match("^_") or name == "[string]" then return end
  done[norm] = true
  local subclass = tymap[original_typ] or (original_typ and tymap[original_typ:match("(.-)%?")])
  if rec and subclass and subclass.members and #subclass.members > 0 then
    local ordered_members = sort_members(subclass.members)
    local results = {}
    for _, sub_member in ipairs(ordered_members) do
      local result = _member_to_markdown(norm, sub_member, rec + 1, header_level, require_desc)
      if result then
        table.insert(results, result)
      end
    end
    return results
  end
  local desc = make_desc(member)
  -- Skip options without descriptions if require_desc is set
  if require_desc and not desc then
    return nil
  end
  local out = {}
  out[#out + 1] = ("%s %s"):format(header_level, norm)
  out[#out + 1] = ""
  out[#out + 1] = ("Type: `%s`, Default: `%s`"):format(typ, default or "nil")
  out[#out + 1] = ""
  if type(desc) == "string" then
    out[#out + 1] = desc
    out[#out + 1] = ""
  end
  return out
end

local function member_to_markdown(...)
  return concat(_member_to_markdown(...))
end

-- Generate global options section - starting with Base type options
-- Only include options that have descriptions (curated documentation)
local function generate_globals()
  local output = {}

  -- First, add Base type globals (cwd, query, prompt, header, etc.)
  -- Skip fields that are in globals_include (they'll be processed from Defaults with recursion)
  local base_ty = tymap["fzf-lua.config.Base"]
  if base_ty and base_ty.members then
    local sorted_members = sort_members(base_ty.members)
    for _, member in ipairs(sorted_members) do
      if not globals_include[member.name] then
        table.insert(output, member_to_markdown("globals", member, nil, "####", true))
      end
    end
  end

  -- Then add Defaults type options
  local ty = assert(tymap["fzf-lua.config.Defaults"], "fzf-lua.config.Defaults type not found")
  ---@diagnostic disable-next-line: redundant-parameter, call-non-callable, access-invisible
  vim.iter(ty.members --[[@as table<integer, EmmyDocTypeMember>]])
      ---@diagnostic disable-next-line: access-invisible
      :each(function(member)
        -- Skip excluded fields
        if EXCLUDE_FROM_GLOBALS[member.name] then
          return
        end
        if (member.name:match("^_")
              and not member.name:match("__HLS"))
            or ((defaults[normalize_classname(member.typ:match("fzf%-lua%.config%.(.*)") or "")]
                or defaults[normalize_classname(member.typ:match("fzf%-lua%.config%.(.-)Base") or "")])
              and not globals_include[normalize_classname(member.typ:match("fzf%-lua%.config%.(.*)") or "")])
        then
          return
        end
        table.insert(output, member_to_markdown("globals", member, 1, "####", true))
      end)
  return table.concat(output, "")
end

-- Fields to exclude from picker documentation (internal implementation details)
local PICKER_EXCLUDE_FIELDS = {
  -- Inherited base fields documented elsewhere
  previewer = true,
  file_icons = true,
  color_icons = true,
  git_icons = true,
  multiprocess = true,
  fn_transform = true,
  fn_preprocess = true,
  -- fzf configuration
  fzf_opts = true,
  fzf_colors = true,
  -- Internal fields
  winopts = true,
  actions = true,
  cmd = true, -- Usually internal command strings
  preview = true,
  preview_pager = true,
  -- Internal index fields
  line_field_index = true,
  field_index_expr = true,
  ctags_file = true,
}

-- Keys to skip when discovering pickers (not actual pickers, or internal)
local SKIP_KEYS = {
  nbsp = true,
  fzf_bin = true,
  fzf_opts = true,
  fzf_tmux_opts = true,
  previewers = true,
  formatters = true,
  file_icon_padding = true,
  dir_icon = true,
  __HLS = true,
  keymap = true,
  actions = true,
  winopts = true,
}

-- Classes that are containers, not actual pickers
-- These have sub-pickers but shouldn't appear in docs themselves
local CONTAINER_CLASSES = {
  ["fzf-lua.config.Jj"] = true,
  ["fzf-lua.config.Git"] = true,
  ["fzf-lua.config.Dap"] = true,
  ["fzf-lua.config.Tmux"] = true,
  ["fzf-lua.config.Global"] = true, -- Multi-picker, not a standalone picker
}

-- Convert picker key to EmmyLua class name
-- e.g., "files" -> "fzf-lua.config.Files"
--       "git.files" -> "fzf-lua.config.GitFiles"
--       "lsp.code_actions" -> "fzf-lua.config.LspCodeActions"
local function key_to_classname(key)
  local parts = vim.split(key, "%.")
  local class_parts = {}
  for _, part in ipairs(parts) do
    -- Convert snake_case to PascalCase
    local pascal = part:gsub("_(.)", function(c) return c:upper() end)
    pascal = pascal:sub(1, 1):upper() .. pascal:sub(2)
    table.insert(class_parts, pascal)
  end
  return "fzf-lua.config." .. table.concat(class_parts, "")
end

-- Recursively collect picker keys from defaults table
local function collect_picker_keys(tbl, prefix)
  prefix = prefix or ""
  local keys = {}
  for k, v in pairs(tbl) do
    -- Skip numeric keys (array elements)
    if type(k) ~= "string" then
      goto continue
    end
    local full_key = prefix ~= "" and (prefix .. "." .. k) or k
    if SKIP_KEYS[k] or k:match("^_") then
      -- Skip internal keys
    elseif type(v) == "table" then
      -- Check if this table has a corresponding class (i.e., it's a picker)
      local class_name = key_to_classname(full_key)
      if tymap[class_name] and not CONTAINER_CLASSES[class_name] then
        -- It's a picker with its own class (not a container)
        table.insert(keys, full_key)
      end
      -- Always check for sub-pickers (containers have sub-pickers)
      local sub_keys = collect_picker_keys(v, full_key)
      if #sub_keys > 0 then
        vim.list_extend(keys, sub_keys)
      end
    end
    ::continue::
  end
  return keys
end

-- Generate picker documentation
local function generate_pickers()
  local output = {}
  table.insert(output, "---\n\n## Pickers\n\n")

  -- Collect all picker keys from defaults
  local picker_keys = collect_picker_keys(defaults)
  table.sort(picker_keys)

  for _, key in ipairs(picker_keys) do
    -- Get defaults value for this picker
    local path_parts = vim.split(key, "%.")
    local default_val = vim.tbl_get(defaults, unpack(path_parts))

    -- Picker header with underscore-to-hyphen conversion for display
    local display_name = key:gsub("%.", "_")
    table.insert(output, ("#### %s\n\n"):format(display_name))

    -- Get class type and description from EmmyLua annotations
    local class_name = key_to_classname(key)
    local ty = tymap[class_name]

    -- Use EmmyLua description if available and non-empty
    local desc = ty and ty.description
    -- Filter out empty or inheritance-only descriptions (e.g. ": fzf-lua.config.Base")
    if desc and desc ~= "" and not desc:match("^:%s*fzf%-lua%.config%.") then
      table.insert(output, desc .. "\n\n")
    end

    -- Generate field documentation for this picker's specific fields
    -- Only include fields that have explicit descriptions (curated documentation)
    if ty and ty.members then
      for _, member in ipairs(ty.members) do
        local field_desc = make_desc(member)
        -- Skip fields without descriptions - these are implementation details
        -- Also skip excluded fields, private fields, and picker config types
        -- Note: We don't filter inherited fields if they have a description,
        -- as this indicates intentional documentation override
        local member_typ = member.typ or ""
        local is_picker_config = member_typ:match("^fzf%-lua%.config%.%u")
        local is_excluded = PICKER_EXCLUDE_FIELDS[member.name]
        if field_desc
            and not member.name:match("^_")
            and not is_picker_config
            and not is_excluded
        then
          local field_typ, field_default = fix_typ(member.typ)
          -- Get default value from actual defaults table
          local actual_default = default_val and default_val[member.name]
          local default_str = literal(actual_default) or field_default or "nil"

          table.insert(output, ("##### %s.%s\n\n"):format(display_name, member.name))
          table.insert(output, ("Type: `%s`, Default: `%s`\n\n"):format(field_typ, default_str))
          table.insert(output, field_desc .. "\n\n")
        end
      end
    end
  end

  table.insert(output, "---\n\n<!--- vim: set nospell: -->\n")
  return table.concat(output, "")
end

-- Main function to generate OPTIONS.md
local function generate_options_md()
  local output = {}
  table.insert(output, STATIC_HEADER)
  table.insert(output, generate_globals())
  table.insert(output, generate_pickers())
  return table.concat(output, "")
end

-- Generate and write to OPTIONS.md
local content = generate_options_md()
local script_dir = vim.fn.fnamemodify(assert(debug.getinfo(1, "S")).source:gsub("^@", ""), ":h")
local options_path = script_dir .. "/../OPTIONS.md"
local f = io.open(options_path, "w")
if f then
  f:write(content)
  f:close()
  print("Generated OPTIONS.md successfully at: " .. options_path)
else
  print("Error: Could not write to OPTIONS.md")
  -- Fall back to printing to stdout
  print(content)
end