Skip to content
Merged
40 changes: 40 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)
- `{code}` - The selected code content (used with `output_formats`)

### Custom Mappings and Formats

Expand All @@ -243,6 +244,45 @@ 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 `{code}` variable.

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

**Key differences:**
- `formats`: The code is automatically prepended with a newline. Format string only controls the context line.
- `output_formats`: You control the entire output. Typically includes `{code}` token, but it's optional (omit it if you only want to copy metadata without the code 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',
},
formats = {
default = '# {filepath}:{line}', -- Code is auto-prepended
},
output_formats = {
markdown = "```{code}```\n\n{remote_url}", -- Code token must be specified
},
})
```

### 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
18 changes: 16 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: code is automatically prepended with a newline
formats = {
default = "# {filepath}:{line}",
},
-- Full output formats: use {code} token for complete control over output
-- Example: output_formats = { default = "{code}\n\n# {filepath}:{line}" }
output_formats = {},
trim_lines = false,
}

Expand All @@ -26,13 +30,23 @@ 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 {code})
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 code string|nil Code 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, code)
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 "",
code = code or "",
}
end

Expand Down
31 changes: 22 additions & 9 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 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
-- @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,
code = 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}' in format string. Valid variables: filepath, line, linenumber, remote_url, code",
var
)
end
Expand Down
65 changes: 61 additions & 4 deletions tests/copy_with_context/formatter_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,26 @@ local formatter = require("copy_with_context.formatter")
describe("Formatter", function()
describe("get_variables", function()
it("creates variables table with single line", function()
local vars = formatter.get_variables("/path/to/file.lua", 42, nil, nil)
local vars = formatter.get_variables("/path/to/file.lua", 42, nil, nil, nil)

assert.same({
filepath = "/path/to/file.lua",
line = "42",
linenumber = "42",
remote_url = "",
code = "",
}, vars)
end)

it("creates variables table with line range", function()
local vars = formatter.get_variables("/path/to/file.lua", 10, 20, nil)
local vars = formatter.get_variables("/path/to/file.lua", 10, 20, nil, nil)

assert.same({
filepath = "/path/to/file.lua",
line = "10-20",
linenumber = "10-20",
remote_url = "",
code = "",
}, vars)
end)

Expand All @@ -29,25 +31,40 @@ describe("Formatter", function()
"/path/to/file.lua",
5,
5,
"https://github.com/user/repo/blob/abc123/file.lua#L5"
"https://github.com/user/repo/blob/abc123/file.lua#L5",
nil
)

assert.same({
filepath = "/path/to/file.lua",
line = "5",
linenumber = "5",
remote_url = "https://github.com/user/repo/blob/abc123/file.lua#L5",
code = "",
}, vars)
end)

it("handles line_end same as line_start", function()
local vars = formatter.get_variables("/path/to/file.lua", 7, 7, nil)
local vars = formatter.get_variables("/path/to/file.lua", 7, 7, nil, nil)

assert.same({
filepath = "/path/to/file.lua",
line = "7",
linenumber = "7",
remote_url = "",
code = "",
}, vars)
end)

it("creates variables table with code content", function()
local vars = formatter.get_variables("/path/to/file.lua", 10, 12, nil, "function hello()\n print('hello')\nend")

assert.same({
filepath = "/path/to/file.lua",
line = "10-12",
linenumber = "10-12",
remote_url = "",
code = "function hello()\n print('hello')\nend",
}, vars)
end)
end)
Expand Down Expand Up @@ -125,10 +142,50 @@ describe("Formatter", function()
line = "42",
linenumber = "42",
remote_url = "",
code = "",
}

local result = formatter.format("# {filepath}:{linenumber}", vars)
assert.equals("# test.lua:42", result)
end)

it("replaces code variable", function()
local vars = {
filepath = "test.lua",
line = "1-3",
linenumber = "1-3",
remote_url = "",
code = "local x = 1\nlocal y = 2\nreturn x + y",
}

local result = formatter.format("{code}\n\n# {filepath}:{line}", vars)
assert.equals("local x = 1\nlocal y = 2\nreturn x + y\n\n# test.lua:1-3", result)
end)

it("handles code with special characters", function()
local vars = {
filepath = "test.lua",
line = "1",
linenumber = "1",
remote_url = "",
code = "print('Hello {world}')",
}

local result = formatter.format("{code}\n# {filepath}", vars)
assert.equals("print('Hello {world}')\n# test.lua", result)
end)

it("allows code variable anywhere in format string", function()
local vars = {
filepath = "test.lua",
line = "5",
linenumber = "5",
remote_url = "",
code = "x = 1",
}

local result = formatter.format("```lua\n{code}\n```\n\n_{filepath}:{line}_", vars)
assert.equals("```lua\nx = 1\n```\n\n_test.lua:5_", result)
end)
end)
end)
Loading
Loading