Skip to content
Merged
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ You can use the following variables in format strings:
- `{line}` - Line number or range (e.g., "42" or "10-20")
- `{linenumber}` - Alias for `{line}`
- `{remote_url}` - Repository URL (GitHub, GitLab, Bitbucket)
- `{copied_text}` - The selected text (used with `output_formats`)READ

### Custom Mappings and Formats

Expand All @@ -243,6 +244,48 @@ require('copy_with_context').setup({

In case it fails to find the format for a mapping, it will fail during config load time with an error message. Check your config if that happens, whether everything specified in mappings is also present in formats.

### Full Output Control with `output_formats`

For complete control over the output structure, use `output_formats` instead of `formats`. The `output_formats` option allows you to place the code content anywhere in your output using the `{copied_text}` variable.

```lua
require('copy_with_context').setup({
mappings = {
relative = '<leader>cy',
markdown = '<leader>cm',
},
output_formats = {
default = "{copied_text}\n\n# {filepath}:{line}", -- Code first, then context
markdown = "```lua\n{copied_text}\n```\n\n*{filepath}:{line}*", -- Wrap in markdown code block
},
})
```

**Key differences:**
- `formats`: The copied text is automatically prepended with a newline. Format string only controls the context line.
- `output_formats`: You control the entire output. Typically includes `{copied_text}` token, but it's optional (omit it if you only want to copy metadata without the copied content).

When both `formats` and `output_formats` define the same format name, `output_formats` takes precedence.

Example with mixed configuration:
```lua
require('copy_with_context').setup({
mappings = {
relative = '<leader>cy',
markdown = '<leader>cm',
verbose = '<leader>cl',
},
formats = {
default = '# {filepath}:{line}', -- {copied_text} is auto-prepended
verbose = '(from {filepath}:{line})', -- will be ignored because the format of the same name in output_formats takes precedence
},
output_formats = {
markdown = "```{copied_text}```\n\n{remote_url}", -- {copied_text} token must be specified or it will not be included
verbose = 'This glorious example:\n\n```{copied_text}```\n\n(from {filepath}:{line})', -- This is the format used because output_formats take precedence over formats of the same name
},
})
```

### Repository URL Support

When you use `{remote_url}` in a format string, the plugin automatically generates permalink URLs for your code snippets. This feature works with:
Expand Down
19 changes: 17 additions & 2 deletions lua/copy_with_context/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ M.options = {
relative = "<leader>cy",
absolute = "<leader>cY",
},
-- Formats: copied_text is automatically prepended with a newline
formats = {
default = "# {filepath}:{line}",
},
-- Full output formats: use {copied_text} token for complete control over output
-- Example: output_formats = { default = "{copied_text}\n\n# {filepath}:{line}" }
output_formats = {},
trim_lines = false,
}

Expand All @@ -26,13 +30,24 @@ function M.setup(opts)
error(string.format("Invalid configuration: %s", err))
end

-- Validate each format string
-- Validate each format string (is_output_format = false)
for format_name, format_string in pairs(M.options.formats or {}) do
local format_valid, format_err = user_config_validation.validate_format_string(format_string)
local format_valid, format_err =
user_config_validation.validate_format_string(format_string, false)
if not format_valid then
error(string.format("Invalid format '%s': %s", format_name, format_err))
end
end

-- Validate each output_format string (is_output_format = true, requires
-- {copied_text})
for format_name, format_string in pairs(M.options.output_formats or {}) do
local format_valid, format_err =
user_config_validation.validate_format_string(format_string, true)
if not format_valid then
error(string.format("Invalid output_format '%s': %s", format_name, format_err))
end
end
end

return M
4 changes: 3 additions & 1 deletion lua/copy_with_context/formatter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ local M = {}
-- @param line_start number Starting line number
-- @param line_end number|nil Ending line number (nil for single line)
-- @param remote_url string|nil Remote repository URL (nil if not available)
-- @param copied_text string|nil Copied text content (visual selection or current line)
-- @return table Variables table
function M.get_variables(file_path, line_start, line_end, remote_url)
function M.get_variables(file_path, line_start, line_end, remote_url, copied_text)
local line_range
if line_end and line_end ~= line_start then
line_range = string.format("%d-%d", line_start, line_end)
Expand All @@ -21,6 +22,7 @@ function M.get_variables(file_path, line_start, line_end, remote_url)
line = line_range,
linenumber = line_range, -- alias for 'line'
remote_url = remote_url or "",
copied_text = copied_text or "",
}
end

Expand Down
42 changes: 31 additions & 11 deletions lua/copy_with_context/main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,39 @@ function M.copy_with_context(mapping_name, is_visual)
file_path = utils.get_file_path(false)
end

-- Get remote URL if needed (check if format uses {remote_url})
-- Determine format name (relative/absolute use "default")
local format_name = mapping_name
if mapping_name == "relative" or mapping_name == "absolute" then
format_name = "default"
end

local format_string = config.options.formats[format_name]
local remote_url = nil
-- Get format string (output_formats takes precedence over formats)
local output_format = config.options.output_formats and config.options.output_formats[format_name]
local format = config.options.formats and config.options.formats[format_name]
local format_string = output_format or format

-- Only fetch remote URL if format string uses it
-- Get remote URL if needed (check if format uses {remote_url})
local remote_url = nil
if format_string and format_string:match("{remote_url}") then
remote_url = url_builder.build_url(file_path, start_lnum, end_lnum)
end

-- Build variables and format output
local vars = formatter.get_variables(file_path, start_lnum, end_lnum, remote_url)
local context = formatter.format(format_string, vars)
-- Build variables (include code for full output control)
local vars = formatter.get_variables(file_path, start_lnum, end_lnum, remote_url, content)

-- Combine content and context
local output = content .. "\n" .. context
-- Generate output based on format type
local output
if output_format then
-- New full output format - formatter controls entire output
output = formatter.format(output_format, vars)
elseif format then
-- format - auto-prepend code with newline
local context = formatter.format(format, vars)
output = content .. "\n" .. context
else
-- Fallback if no format found
output = content
end

utils.copy_to_clipboard(output)

Expand All @@ -52,15 +65,22 @@ function M.copy_with_context(mapping_name, is_visual)
)
end

-- Generate description for a mapping
local function get_mapping_desc(mapping_name)
return string.format("Copy with context (%s)", mapping_name)
end

function M.setup()
local config = require("copy_with_context.config")

-- Set up keymaps for all defined mappings
for mapping_name, keymap in pairs(config.options.mappings) do
local desc = get_mapping_desc(mapping_name)

-- Normal mode mapping
vim.keymap.set("n", keymap, function()
M.copy_with_context(mapping_name, false)
end, { silent = false })
end, { silent = false, desc = desc })

-- Visual mode mapping
vim.keymap.set(
Expand All @@ -70,7 +90,7 @@ function M.setup()
':<C-u>lua require("copy_with_context.main").copy_with_context("%s", true)<CR>',
mapping_name
),
{ silent = true }
{ silent = true, desc = desc }
)
end
end
Expand Down
37 changes: 29 additions & 8 deletions lua/copy_with_context/user_config_validation.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,32 @@ function M.validate(config)

local mappings = config.mappings or {}
local formats = config.formats or {}
local output_formats = config.output_formats or {}

-- Special cases that map to "default" format
local default_mappings = {
relative = true,
absolute = true,
}

-- Check: every mapping must have a format
-- Check: every mapping must have a format (in either formats or output_formats)
for mapping_name, _ in pairs(mappings) do
-- relative/absolute use "default" format
if default_mappings[mapping_name] then
if not formats.default then
if not formats.default and not output_formats.default then
return false,
string.format("Mapping '%s' requires 'formats.default' to be defined", mapping_name)
string.format(
"Mapping '%s' requires 'formats.default' or 'output_formats.default' to be defined",
mapping_name
)
end
else
-- All other mappings need matching format
if not formats[mapping_name] then
-- All other mappings need matching format in either formats or output_formats
if not formats[mapping_name] and not output_formats[mapping_name] then
return false,
string.format(
"Mapping '%s' has no matching format. Add 'formats.%s'",
"Mapping '%s' has no matching format. Add 'formats.%s' or 'output_formats.%s'",
mapping_name,
mapping_name,
mapping_name
)
Expand All @@ -54,13 +59,28 @@ function M.validate(config)
end
end

-- Check: every output_format (except default) should have a mapping
for format_name, _ in pairs(output_formats) do
if format_name ~= "default" then
if not mappings[format_name] then
return false,
string.format(
"Output format '%s' has no matching mapping. Add 'mappings.%s' or remove the output_format",
format_name,
format_name
)
end
end
end

return true, nil
end

-- Validate format string has valid variables
-- @param format_string string Format string to validate
-- @param _is_output_format boolean Whether this is an output_format (reserved for future use)
-- @return boolean, string|nil Success status and error message
function M.validate_format_string(format_string)
function M.validate_format_string(format_string, _is_output_format)
if not format_string then
return false, "Format string cannot be nil"
end
Expand All @@ -70,14 +90,15 @@ function M.validate_format_string(format_string)
line = true,
linenumber = true,
remote_url = true,
copied_text = true,
}

-- Extract all variables from format string
for var in format_string:gmatch("{([^}]+)}") do
if not valid_vars[var] then
return false,
string.format(
"Unknown variable '{%s}' in format string. Valid variables: filepath, line, linenumber, remote_url",
"Unknown variable '{%s}'. Valid: filepath, line, linenumber, remote_url, copied_text",
var
)
end
Expand Down
60 changes: 60 additions & 0 deletions tests/copy_with_context/config_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,64 @@ describe("Config Module", function()
-- All formats are valid, should succeed
assert.is_true(success)
end)

it("validates output_formats with valid variables", function()
local success = pcall(config.setup, {
mappings = {
relative = "<leader>cy",
},
output_formats = {
default = "{copied_text}\n\n# {filepath}:{line}",
},
})

assert.is_true(success)
end)

it("validates output_formats with invalid variables", function()
local success = pcall(config.setup, {
mappings = {
relative = "<leader>cy",
},
output_formats = {
default = "{copied_text}\n# {invalid_var}",
},
})

assert.is_false(success)
end)

it("validates custom output_format with invalid variables", function()
local success = pcall(config.setup, {
mappings = {
relative = "<leader>cy",
markdown = "<leader>cm",
},
formats = {
default = "# {filepath}:{line}",
},
output_formats = {
markdown = "{copied_text}\n# {bad_variable}",
},
})

assert.is_false(success)
end)

it("validates multiple output_formats", function()
local success = pcall(config.setup, {
mappings = {
relative = "<leader>cy",
markdown = "<leader>cm",
full = "<leader>cf",
},
output_formats = {
default = "{copied_text}\n# {filepath}:{line}",
markdown = "```\n{copied_text}\n```\n\n_{filepath}:{line}_",
full = "{copied_text}\n\n# {filepath}:{line}\n# {remote_url}",
},
})

assert.is_true(success)
end)
end)
Loading