Meh I'll figure out submodules later
This commit is contained in:
parent
4ca9d44a90
commit
8cb281f436
352 changed files with 66107 additions and 0 deletions
|
|
@ -0,0 +1,326 @@
|
|||
local typecheck = require("neo-tree.health.typecheck")
|
||||
local M = {}
|
||||
local health = vim.health
|
||||
|
||||
local function check_dependencies()
|
||||
local devicons_ok = pcall(require, "nvim-web-devicons")
|
||||
if devicons_ok then
|
||||
health.ok("nvim-web-devicons is installed")
|
||||
else
|
||||
health.info("nvim-web-devicons not installed")
|
||||
end
|
||||
|
||||
local plenary_ok = pcall(require, "plenary")
|
||||
if plenary_ok then
|
||||
health.ok("plenary.nvim is installed")
|
||||
else
|
||||
health.error("plenary.nvim is not installed")
|
||||
end
|
||||
|
||||
local nui_ok = pcall(require, "nui.tree")
|
||||
if nui_ok then
|
||||
health.ok("nui.nvim is installed")
|
||||
else
|
||||
health.error("nui.nvim not installed")
|
||||
end
|
||||
|
||||
health.info("Optional dependencies for preview image support (only need one):")
|
||||
-- optional
|
||||
local snacks_ok = pcall(require, "snacks.image")
|
||||
if snacks_ok then
|
||||
health.ok("snacks.image is installed")
|
||||
else
|
||||
health.info("nui.nvim not installed")
|
||||
end
|
||||
|
||||
local image_ok = pcall(require, "image")
|
||||
if image_ok then
|
||||
health.ok("image.nvim is installed")
|
||||
else
|
||||
health.info("nui.nvim not installed")
|
||||
end
|
||||
end
|
||||
|
||||
local validate = typecheck.validate
|
||||
|
||||
---@module "neo-tree.types.config"
|
||||
---@param config neotree.Config.Base
|
||||
function M.check_config(config)
|
||||
---@type [string, string?][]
|
||||
local errors = {}
|
||||
local start = vim.uv.hrtime()
|
||||
local verbose = vim.o.verbose > 0
|
||||
local matched, missed = validate(
|
||||
"config",
|
||||
config,
|
||||
function(cfg)
|
||||
---@class neotree.health.Validator.Generators
|
||||
local v = {
|
||||
array = function(validator)
|
||||
---@generic T
|
||||
---@param arr T[]
|
||||
return function(arr)
|
||||
for i, val in ipairs(arr) do
|
||||
validate(("[%d]"):format(i), val, validator)
|
||||
end
|
||||
end
|
||||
end,
|
||||
literal = function(literals)
|
||||
return function(value)
|
||||
return vim.tbl_contains(literals, value),
|
||||
("value %s did not match literals %s"):format(value, table.concat(literals, "|"))
|
||||
end
|
||||
end,
|
||||
}
|
||||
local schema = {
|
||||
Filesystem = {
|
||||
---@param follow_current_file neotree.Config.Filesystem.FollowCurrentFile
|
||||
FollowCurrentFile = function(follow_current_file)
|
||||
validate("enabled", follow_current_file.enabled, "boolean", true)
|
||||
validate("leave_dirs_open", follow_current_file.leave_dirs_open, "boolean", true)
|
||||
end,
|
||||
},
|
||||
|
||||
---@param window neotree.Config.Window
|
||||
Window = function(window)
|
||||
validate("mappings", window.mappings, "table") -- TODO: More specific validation for mappings table
|
||||
end,
|
||||
SourceSelector = {
|
||||
---@param item neotree.Config.SourceSelector.Item
|
||||
Item = function(item)
|
||||
validate("source", item.source, "string")
|
||||
validate("padding", item.padding, { "number", "table" }, true) -- TODO: More specific validation for padding table
|
||||
validate("separator", item.separator, { "string", "table" }, true) -- TODO: More specific validation for separator table
|
||||
end,
|
||||
---@param sep neotree.Config.SourceSelector.Separator
|
||||
Separator = function(sep)
|
||||
validate("left", sep.left, "string")
|
||||
validate("right", sep.right, "string")
|
||||
validate("override", sep.override, v.literal({ "right", "left", "active" }), true)
|
||||
end,
|
||||
},
|
||||
Renderers = v.array("table"),
|
||||
}
|
||||
|
||||
if not validate("config", cfg, "table") then
|
||||
health.error("Config does not exist")
|
||||
return
|
||||
end
|
||||
|
||||
validate("sources", cfg.sources, v.array("string"), false)
|
||||
validate("add_blank_line_at_top", cfg.add_blank_line_at_top, "boolean")
|
||||
validate("auto_clean_after_session_restore", cfg.auto_clean_after_session_restore, "boolean")
|
||||
validate("close_if_last_window", cfg.close_if_last_window, "boolean")
|
||||
validate("default_source", cfg.default_source, "string")
|
||||
validate("enable_diagnostics", cfg.enable_diagnostics, "boolean")
|
||||
validate("enable_git_status", cfg.enable_git_status, "boolean")
|
||||
validate("enable_modified_markers", cfg.enable_modified_markers, "boolean")
|
||||
validate("enable_opened_markers", cfg.enable_opened_markers, "boolean")
|
||||
validate("enable_refresh_on_write", cfg.enable_refresh_on_write, "boolean")
|
||||
validate("enable_cursor_hijack", cfg.enable_cursor_hijack, "boolean")
|
||||
validate("git_status_async", cfg.git_status_async, "boolean")
|
||||
validate("git_status_async_options", cfg.git_status_async_options, function(options)
|
||||
validate("batch_size", options.batch_size, "number")
|
||||
validate("batch_delay", options.batch_delay, "number")
|
||||
validate("max_lines", options.max_lines, "number")
|
||||
end)
|
||||
validate("hide_root_node", cfg.hide_root_node, "boolean")
|
||||
validate("retain_hidden_root_indent", cfg.retain_hidden_root_indent, "boolean")
|
||||
validate(
|
||||
"log_level",
|
||||
cfg.log_level,
|
||||
v.literal({ "trace", "debug", "info", "warn", "error", "fatal" }),
|
||||
true
|
||||
)
|
||||
validate("log_to_file", cfg.log_to_file, { "boolean", "string" })
|
||||
validate("open_files_in_last_window", cfg.open_files_in_last_window, "boolean")
|
||||
validate(
|
||||
"open_files_do_not_replace_types",
|
||||
cfg.open_files_do_not_replace_types,
|
||||
v.array("string")
|
||||
)
|
||||
validate("open_files_using_relative_paths", cfg.open_files_using_relative_paths, "boolean")
|
||||
validate(
|
||||
"popup_border_style",
|
||||
cfg.popup_border_style,
|
||||
v.literal({ "NC", "rounded", "single", "solid", "double", "" })
|
||||
)
|
||||
validate("resize_timer_interval", cfg.resize_timer_interval, "number")
|
||||
validate("sort_case_insensitive", cfg.sort_case_insensitive, "boolean")
|
||||
validate("sort_function", cfg.sort_function, "function", true)
|
||||
validate("use_popups_for_input", cfg.use_popups_for_input, "boolean")
|
||||
validate("use_default_mappings", cfg.use_default_mappings, "boolean")
|
||||
validate("source_selector", cfg.source_selector, function(ss)
|
||||
validate("winbar", ss.winbar, "boolean")
|
||||
validate("statusline", ss.statusline, "boolean")
|
||||
validate("show_scrolled_off_parent_node", ss.show_scrolled_off_parent_node, "boolean")
|
||||
validate("sources", ss.sources, v.array(schema.SourceSelector.Item))
|
||||
validate("content_layout", ss.content_layout, v.literal({ "start", "end", "center" }))
|
||||
validate(
|
||||
"tabs_layout",
|
||||
ss.tabs_layout,
|
||||
v.literal({ "equal", "start", "end", "center", "focus" })
|
||||
)
|
||||
validate("truncation_character", ss.truncation_character, "string", false)
|
||||
validate("tabs_min_width", ss.tabs_min_width, "number", true)
|
||||
validate("tabs_max_width", ss.tabs_max_width, "number", true)
|
||||
validate("padding", ss.padding, { "number", "table" }) -- TODO: More specific validation for padding table
|
||||
validate("separator", ss.separator, schema.SourceSelector.Separator)
|
||||
validate("separator_active", ss.separator_active, schema.SourceSelector.Separator, true)
|
||||
validate("show_separator_on_edge", ss.show_separator_on_edge, "boolean")
|
||||
validate("highlight_tab", ss.highlight_tab, "string")
|
||||
validate("highlight_tab_active", ss.highlight_tab_active, "string")
|
||||
validate("highlight_background", ss.highlight_background, "string")
|
||||
validate("highlight_separator", ss.highlight_separator, "string")
|
||||
validate("highlight_separator_active", ss.highlight_separator_active, "string")
|
||||
end)
|
||||
validate("event_handlers", cfg.event_handlers, v.array("table"), true) -- TODO: More specific validation for event handlers
|
||||
validate("default_component_configs", cfg.default_component_configs, function(defaults)
|
||||
validate("container", defaults.container, "table") -- TODO: More specific validation
|
||||
validate("indent", defaults.indent, "table") -- TODO: More specific validation
|
||||
validate("icon", defaults.icon, "table") -- TODO: More specific validation
|
||||
validate("modified", defaults.modified, "table") -- TODO: More specific validation
|
||||
validate("name", defaults.name, "table") -- TODO: More specific validation
|
||||
validate("git_status", defaults.git_status, "table") -- TODO: More specific validation
|
||||
validate("file_size", defaults.file_size, "table") -- TODO: More specific validation
|
||||
validate("type", defaults.type, "table") -- TODO: More specific validation
|
||||
validate("last_modified", defaults.last_modified, "table") -- TODO: More specific validation
|
||||
validate("created", defaults.created, "table") -- TODO: More specific validation
|
||||
validate("symlink_target", defaults.symlink_target, "table") -- TODO: More specific validation
|
||||
end)
|
||||
validate("renderers", cfg.renderers, schema.Renderers)
|
||||
validate("nesting_rules", cfg.nesting_rules, v.array("table"), true) -- TODO: More specific validation for nesting rules
|
||||
validate("commands", cfg.commands, "table", true) -- TODO: More specific validation for commands
|
||||
validate("window", cfg.window, function(window)
|
||||
validate("position", window.position, "string") -- TODO: More specific validation
|
||||
validate("width", window.width, "number")
|
||||
validate("height", window.height, "number")
|
||||
validate("auto_expand_width", window.auto_expand_width, "boolean")
|
||||
validate("popup", window.popup, function(popup)
|
||||
validate("title", popup.title, "function")
|
||||
validate("size", popup.size, function(size)
|
||||
validate("height", size.height, { "string", "number" })
|
||||
validate("width", size.width, { "string", "number" })
|
||||
end)
|
||||
validate(
|
||||
"border",
|
||||
popup.border,
|
||||
v.literal({ "NC", "rounded", "single", "solid", "double", "" }),
|
||||
true
|
||||
)
|
||||
end)
|
||||
validate("insert_as", window.insert_as, v.literal({ "child", "sibling" }), true)
|
||||
validate("mapping_options", window.mapping_options, "table") -- TODO: More specific validation
|
||||
validate("mappings", window.mappings, v.array("table")) -- TODO: More specific validation for mapping items
|
||||
end)
|
||||
|
||||
validate("filesystem", cfg.filesystem, function(fs)
|
||||
validate(
|
||||
"async_directory_scan",
|
||||
fs.async_directory_scan,
|
||||
v.literal({ "auto", "always", "never" })
|
||||
)
|
||||
validate("scan_mode", fs.scan_mode, v.literal({ "shallow", "deep" }))
|
||||
validate("bind_to_cwd", fs.bind_to_cwd, "boolean")
|
||||
validate("cwd_target", fs.cwd_target, function(cwd_target)
|
||||
validate("sidebar", cwd_target.sidebar, v.literal({ "tab", "window", "global" }))
|
||||
validate("current", cwd_target.current, v.literal({ "tab", "window", "global" }))
|
||||
end)
|
||||
validate("check_gitignore_in_search", fs.check_gitignore_in_search, "boolean")
|
||||
validate("filtered_items", fs.filtered_items, function(f)
|
||||
validate("visible", f.visible, "boolean")
|
||||
validate("force_visible_in_empty_folder", f.force_visible_in_empty_folder, "boolean")
|
||||
validate("children_inherit_highlights", f.children_inherit_highlights, "boolean")
|
||||
validate("show_hidden_count", f.show_hidden_count, "boolean")
|
||||
validate("hide_dotfiles", f.hide_dotfiles, "boolean")
|
||||
validate("hide_gitignored", f.hide_gitignored, "boolean")
|
||||
validate("hide_hidden", f.hide_hidden, "boolean")
|
||||
validate("hide_by_name", f.hide_by_name, v.array("string"))
|
||||
validate("hide_by_pattern", f.hide_by_pattern, v.array("string"))
|
||||
validate("always_show", f.always_show, v.array("string"))
|
||||
validate("always_show_by_pattern", f.always_show_by_pattern, v.array("string"))
|
||||
validate("never_show", f.never_show, v.array("string"))
|
||||
validate("never_show_by_pattern", f.never_show_by_pattern, v.array("string"))
|
||||
end)
|
||||
validate("find_by_full_path_words", fs.find_by_full_path_words, "boolean")
|
||||
validate("find_command", fs.find_command, "string", true)
|
||||
validate("find_args", fs.find_args, { "table", "function" }, true)
|
||||
validate("group_empty_dirs", fs.group_empty_dirs, "boolean")
|
||||
validate("search_limit", fs.search_limit, "number")
|
||||
validate("follow_current_file", fs.follow_current_file, schema.Filesystem.FollowCurrentFile)
|
||||
validate(
|
||||
"hijack_netrw_behavior",
|
||||
fs.hijack_netrw_behavior,
|
||||
v.literal({ "open_default", "open_current", "disabled" }),
|
||||
true
|
||||
)
|
||||
validate("use_libuv_file_watcher", fs.use_libuv_file_watcher, "boolean")
|
||||
validate("renderers", fs.renderers, schema.Renderers)
|
||||
validate("window", fs.window, function(window)
|
||||
validate("mappings", window.mappings, "table") -- TODO: More specific validation for mappings table
|
||||
validate("fuzzy_finder_mappings", window.fuzzy_finder_mappings, "table") -- TODO: More specific validation
|
||||
end)
|
||||
end)
|
||||
validate("buffers", cfg.buffers, function(buffers)
|
||||
validate("bind_to_cwd", buffers.bind_to_cwd, "boolean")
|
||||
validate(
|
||||
"follow_current_file",
|
||||
buffers.follow_current_file,
|
||||
schema.Filesystem.FollowCurrentFile
|
||||
)
|
||||
validate("group_empty_dirs", buffers.group_empty_dirs, "boolean")
|
||||
validate("show_unloaded", buffers.show_unloaded, "boolean")
|
||||
validate("terminals_first", buffers.terminals_first, "boolean")
|
||||
validate("renderers", buffers.renderers, schema.Renderers)
|
||||
validate("window", buffers.window, schema.Window)
|
||||
end)
|
||||
validate("git_status", cfg.git_status, function(git_status)
|
||||
validate("renderers", git_status.renderers, schema.Renderers)
|
||||
validate("window", git_status.window, schema.Window)
|
||||
end)
|
||||
validate("document_symbols", cfg.document_symbols, function(ds)
|
||||
validate("follow_cursor", ds.follow_cursor, "boolean")
|
||||
validate("client_filters", ds.client_filters, { "string", "table" }) -- TODO: More specific validation
|
||||
validate("custom_kinds", ds.custom_kinds, "table") -- TODO: More specific validation
|
||||
validate("kinds", ds.kinds, "table")
|
||||
validate("renderers", ds.renderers, schema.Renderers)
|
||||
validate("window", ds.window, schema.Window)
|
||||
end)
|
||||
end,
|
||||
false,
|
||||
nil,
|
||||
function(err)
|
||||
errors[#errors + 1] = { err }
|
||||
end,
|
||||
true
|
||||
)
|
||||
local _end = vim.uv.hrtime()
|
||||
|
||||
if #errors == 0 then
|
||||
health.ok("Configuration conforms to the neotree.Config.Base schema")
|
||||
else
|
||||
for _, err in ipairs(errors) do
|
||||
health.error(unpack(err))
|
||||
end
|
||||
end
|
||||
if verbose then
|
||||
health.info(
|
||||
"[verbose] Config schema checking is not comprehensive yet, unchecked keys listed below:"
|
||||
)
|
||||
if missed then
|
||||
for _, miss in ipairs(missed) do
|
||||
health.info(miss)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function M.check()
|
||||
health.start("Dependencies")
|
||||
check_dependencies()
|
||||
health.start("Configuration")
|
||||
local config = require("neo-tree").ensure_config()
|
||||
M.check_config(config)
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
local M = {}
|
||||
|
||||
---Like type() but also supports "callable" like neovim does.
|
||||
---@see _G.type
|
||||
---@param obj any
|
||||
---@param expected neotree.LuaType
|
||||
function M.match(obj, expected)
|
||||
if type(obj) == expected then
|
||||
return true
|
||||
end
|
||||
if expected == "callable" and vim.is_callable(obj) then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
---@alias neotree.LuaType type|"callable"
|
||||
---@alias neotree.health.ValidatorFunction<T> fun(value: T):boolean?,string?
|
||||
---@alias neotree.health.Validator<T> elem_or_list<neotree.LuaType>|neotree.health.ValidatorFunction<T>
|
||||
|
||||
---@type (fun(err:string))[]
|
||||
M.errfuncs = {}
|
||||
---@type string[]
|
||||
M.namestack = {}
|
||||
|
||||
---@generic T : table
|
||||
---@param path string
|
||||
---@param tbl T
|
||||
---@param accesses string[]
|
||||
---@param missed_paths table<string, true?>
|
||||
---@return T mocked_tbl
|
||||
local function mock_recursive(path, tbl, accesses, missed_paths, track_missed)
|
||||
local mock_table = {}
|
||||
|
||||
---@class neotree.health.Mock.Metatable<T> : metatable
|
||||
---@field accesses string[]
|
||||
local mt = {
|
||||
__original_table = tbl,
|
||||
accesses = accesses,
|
||||
}
|
||||
|
||||
---@return string[] missed_paths
|
||||
mt.get_missed_paths = function()
|
||||
---@type string[]
|
||||
local missed_list = {}
|
||||
if track_missed then
|
||||
for p, _ in pairs(missed_paths) do
|
||||
table.insert(missed_list, p)
|
||||
end
|
||||
end
|
||||
table.sort(missed_list)
|
||||
return missed_list
|
||||
end
|
||||
|
||||
mt.__index = function(_, key)
|
||||
local path_segment
|
||||
if type(key) == "number" then
|
||||
path_segment = ("[%02d]"):format(key)
|
||||
else
|
||||
path_segment = tostring(key)
|
||||
end
|
||||
|
||||
local full_path
|
||||
if path == "" then
|
||||
full_path = path_segment
|
||||
elseif type(key) == "number" then
|
||||
full_path = path .. path_segment
|
||||
else
|
||||
full_path = path .. "." .. path_segment
|
||||
end
|
||||
|
||||
-- Track accesses and missed accesses
|
||||
mt.accesses[#mt.accesses + 1] = full_path
|
||||
if track_missed then
|
||||
missed_paths[full_path] = nil
|
||||
end
|
||||
|
||||
local value = mt.__original_table[key]
|
||||
|
||||
if type(value) == "table" then
|
||||
return mock_recursive(full_path, value, mt.accesses, missed_paths, track_missed)
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
setmetatable(mock_table, mt)
|
||||
return mock_table
|
||||
end
|
||||
|
||||
--- Wraps a given table in a special mock table that tracks all accesses
|
||||
--- (reads) to its fields and sub-fields. Optionally tracks unaccessed fields.
|
||||
---
|
||||
---@generic T : table
|
||||
---@param name string The base name for the table, this forms the root of the access paths.
|
||||
---@param tbl T The table to be mocked.
|
||||
---@param track_missed boolean? Track which fields were NOT accessed.
|
||||
---@return T mocked
|
||||
function M.mock(name, tbl, track_missed)
|
||||
local accesses = {}
|
||||
local path_set = {}
|
||||
track_missed = track_missed or false
|
||||
|
||||
if track_missed then
|
||||
-- Generate another mock table and fully traverse that one first
|
||||
local root_mock = M.mock(name, tbl, false)
|
||||
|
||||
---@param current_table table
|
||||
local function deep_traverse_mock(current_table)
|
||||
---@type neotree.health.Mock.Metatable
|
||||
local mt = getmetatable(current_table)
|
||||
for k, v in pairs(mt.__original_table) do
|
||||
if type(v) == "table" then
|
||||
deep_traverse_mock(current_table[k])
|
||||
else
|
||||
mt.__index(nil, k)
|
||||
end
|
||||
end
|
||||
end
|
||||
deep_traverse_mock(root_mock)
|
||||
accesses = getmetatable(root_mock).accesses
|
||||
for _, path in ipairs(accesses) do
|
||||
path_set[path] = true
|
||||
end
|
||||
end
|
||||
|
||||
-- Start the recursive mocking process, passing all necessary shared tracking data.
|
||||
return mock_recursive(name, tbl, accesses, path_set, track_missed)
|
||||
end
|
||||
|
||||
---A comprehensive version of vim.validate that makes it easy to validate nested tables of various types
|
||||
---@generic T
|
||||
---@param name string
|
||||
---@param value T
|
||||
---@param validator neotree.health.Validator<T>
|
||||
---@param optional? boolean Whether value can be nil
|
||||
---@param message? string message when validation fails
|
||||
---@param on_invalid? fun(err: string, value: T):boolean? What to do when a (nested) validation fails, return true to throw error
|
||||
---@param track_missed? boolean Whether to return a second table that contains every non-checked field
|
||||
---@return boolean valid
|
||||
---@return string[]? missed
|
||||
function M.validate(name, value, validator, optional, message, on_invalid, track_missed)
|
||||
local matched, errmsg, errinfo
|
||||
M.namestack[#M.namestack + 1] = name
|
||||
if type(validator) == "string" then
|
||||
matched = M.match(value, validator)
|
||||
elseif type(validator) == "table" then
|
||||
for _, v in ipairs(validator) do
|
||||
matched = M.match(value, v)
|
||||
if matched then
|
||||
break
|
||||
end
|
||||
end
|
||||
elseif type(validator) == "function" and value ~= nil then
|
||||
local ok = false
|
||||
if on_invalid then
|
||||
M.errfuncs[#M.errfuncs + 1] = on_invalid
|
||||
end
|
||||
if track_missed and type(value) == "table" then
|
||||
value = M.mock(name, value, true)
|
||||
end
|
||||
ok, matched, errinfo = pcall(validator, value)
|
||||
if on_invalid then
|
||||
M.errfuncs[#M.errfuncs] = nil
|
||||
end
|
||||
if not ok then
|
||||
errinfo = matched
|
||||
matched = false
|
||||
elseif matched == nil then
|
||||
matched = true
|
||||
end
|
||||
end
|
||||
matched = matched or (optional and value == nil) or false
|
||||
|
||||
if not matched then
|
||||
---@type string
|
||||
local expected
|
||||
if vim.is_callable(validator) then
|
||||
expected = "?"
|
||||
else
|
||||
---@cast validator -function
|
||||
local expected_types = type(validator) == "string" and { validator } or validator
|
||||
---@cast expected_types -string
|
||||
if optional then
|
||||
expected_types[#expected_types + 1] = "nil"
|
||||
end
|
||||
expected = table.concat(expected_types, "|")
|
||||
end
|
||||
|
||||
errmsg = ("%s: %s, got %s"):format(
|
||||
table.concat(M.namestack, "."),
|
||||
message or ("expected " .. expected),
|
||||
message and value or type(value)
|
||||
)
|
||||
if errinfo then
|
||||
errmsg = errmsg .. ", Info: " .. errinfo
|
||||
end
|
||||
local errfunc = M.errfuncs[#M.errfuncs]
|
||||
local should_error = not errfunc or errfunc(errmsg)
|
||||
if should_error then
|
||||
M.namestack[#M.namestack] = nil
|
||||
error(errmsg, 2)
|
||||
end
|
||||
end
|
||||
M.namestack[#M.namestack] = nil
|
||||
|
||||
if track_missed then
|
||||
local missed = getmetatable(value).get_missed_paths()
|
||||
return matched, missed
|
||||
end
|
||||
return matched
|
||||
end
|
||||
|
||||
return M
|
||||
Loading…
Add table
Add a link
Reference in a new issue