Skip to content

feature request: detect more manual sections in man.lua #493

@eastarpen

Description

@eastarpen

Did you check existing requests?

  • I have searched the existing issues

Describe the feature

Thanks for the amazing work.

I use nvim as my man pager, and aerial.nvim helps me a lot in navigation and having an overview of strange manuals. But the current implementation (simple pattern match) just ignores many useful sections. Hence, I made such code below to include more sections.

Unlike the original implementation, I use indent as a checkpoint to judge whether to create a new section (or item in the original code), and handle example blocks which, in some manuals (e.g. tmux), may have useful information.

CODE
M.fetch_symbols_sync = function(bufnr)
  bufnr = bufnr or 0
  local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)

  local root_sections = {}
  local current_section = nil

  local prev_line = {} -- Use to create sections
  local prepre_line = {} -- Use to finish sections
  local example_line = {} -- Use to mark example sections

  -- Helper: Get indentation level
  local function get_indent(line)
    local padding = line:match("^(%s*)")
    return padding:len()
  end

  -- Helper: Truncate to maximum 5 words
  local function truncate_name(text)
    local words = {}
    for word in text:gmatch("%S+") do
      table.insert(words, word)
      if #words >= 5 then
        break
      end
    end

    if words == {} then
      return "NO_NAME_FOUND"
    end
    return table.concat(words, " ")
  end

  -- Helper: Close sections until reaching target indent
  local function try_finish_sections(target_indent, end_lnum, end_col)
    while current_section and current_section.col >= target_indent do
      -- Only finish example sections when target indent is smaller than example_line.indent
      local is_example_section = example_line.is_section
        and current_section.lnum == example_line.lnum
      if is_example_section and target_indent == example_line.indent then
        return
      end
      current_section.end_lnum = end_lnum
      current_section.end_col = end_col
      current_section = current_section.parent
    end
    if example_line.lnum and target_indent <= example_line.indent then
      example_line = {}
    end
  end

  -- Core: Create a new section
  local function create_section(line, lnum, indent)
    local name = truncate_name(line:gsub("^%s+", ""))

    local new_section = {
      kind = "Interface",
      name = name,
      level = current_section and current_section.level + 1 or 0,
      lnum = lnum,
      col = indent,
    }

    -- DO NOT know what this statement does
    -- Just keep same with original code
    if
      not config.post_parse_symbol
      or config.post_parse_symbol(bufnr, new_section, {
          backend_name = "man",
          lang = "man",
        })
        ~= false
    then
      if current_section then
        current_section.children = current_section.children or {}
        new_section.parent = current_section
        table.insert(current_section.children, new_section)
      else
        table.insert(root_sections, new_section)
      end
    end

    current_section = new_section
  end

  local function should_create_section(curr_indent)
    if not prev_line.lnum then
      return false
    end

    if example_line.lnum then
      return example_line.is_section and curr_indent == example_line.indent
    end

    return curr_indent > prev_line.indent
  end

  local function should_finish_sections(curr_indent)
    -- current_section.col can not be used to judge whether to finish simple example sections which
    -- is not created as a valid section based on rule
    if example_line.lnum and not example_line.is_section and curr_indent == example_line.indent then
      return true
    end
    return current_section and curr_indent <= current_section.col and prepre_line.lnum
  end

  local function shift_lines(curr_line, curr_lnum, curr_indent)
    prepre_line = prev_line
    prev_line = {
      line = curr_line,
      lnum = curr_lnum,
      indent = curr_indent,
      length = curr_line:len(),
    }
  end

  local function handle_example_line(curr_indent)
    local is_example_line = example_line.lnum and prev_line.lnum == example_line.lnum
    if is_example_line then
      -- In example sections, children have same indent with example_line
      local same_indent = (curr_indent == example_line.indent)
      local has_keyword = false

      local lowerStr = string.lower(example_line.line)
      -- Define the words to search for
      local requiredWords = { "available", "follow", "all" }
      -- Check if each word is present in the string
      for _, word in ipairs(requiredWords) do
        if string.find(lowerStr, word, 1, true) then
          has_keyword = true
          break
        end
      end

      if same_indent and has_keyword then
        -- The order in this if statement is important
        -- create_section() uses example_line.is_section
        create_section(example_line.line, example_line.lnum, example_line.indent)
        example_line.is_section = true
      end
    end
  end

  -- Main loop
  for lnum, line in ipairs(lines) do
    -- Skip first line (title)
    if lnum > 1 then
      local is_empty = line:match("^%s*$")
      if not is_empty then
        local curr_indent = get_indent(line)

        DEBUG = 196 <= lnum and lnum <= 300

        handle_example_line(curr_indent)
        if should_finish_sections(curr_indent) then
          try_finish_sections(curr_indent, prepre_line.lnum, prepre_line.length)
        end

        if should_create_section(curr_indent) then
          if example_line.is_section then
            create_section(line, lnum, curr_indent)
          else
            create_section(prev_line.line, prev_line.lnum, prev_line.indent)
          end
        end

        shift_lines(line, lnum, curr_indent)
        if line:sub(-1) == ":" and not example_line.lnum then
          example_line = {
            line = line,
            lnum = lnum,
            indent = curr_indent,
            is_section = false,
          }
        end
      end
    end
  end

  backends.set_symbols(bufnr, root_sections, { backend_name = "man", lang = "man" })
end

I tested it in some manuals and I'm happy to make a PR but I'm not sure what else needs to be done

Provide background

No response

What is the significance of this feature?

strongly desired

Additional details

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions