Skip to content

Commit 2a72782

Browse files
t-dubzhisme
andauthored
Add "Full Formatting" Option (#31)
* feat: allow format control of output - Introduce {code} output token - Introduce output_formats alternative to formats as mechanism for specifying complete output format * test: update tests to account for code token * docs: update README to include code * fix: let empty table merge * format * feat: switch from code to copied_text name * docs: add example for precedence: * test: added test for more config validation cases * lint & format * feat: add mapping descriptions Added automatically-derived desc to the keymaps * fix: validate {copied_text} usage in formats vs output_formats - output_formats must contain {copied_text} to include the copied code - formats cannot contain {copied_text} (auto-prepended, would cause duplication) --------- Co-authored-by: Evgeny Zhdanov <evdev34@gmail.com>
1 parent 09243b8 commit 2a72782

File tree

9 files changed

+435
-45
lines changed

9 files changed

+435
-45
lines changed

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ You can use the following variables in format strings:
218218
- `{line}` - Line number or range (e.g., "42" or "10-20")
219219
- `{linenumber}` - Alias for `{line}`
220220
- `{remote_url}` - Repository URL (GitHub, GitLab, Bitbucket)
221+
- `{copied_text}` - The selected text (used with `output_formats`)
221222

222223
### Custom Mappings and Formats
223224

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

244245
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.
245246

247+
### Full Output Control with `output_formats`
248+
249+
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.
250+
251+
```lua
252+
require('copy_with_context').setup({
253+
mappings = {
254+
relative = '<leader>cy',
255+
markdown = '<leader>cm',
256+
},
257+
output_formats = {
258+
default = "{copied_text}\n\n# {filepath}:{line}", -- Code first, then context
259+
markdown = "```lua\n{copied_text}\n```\n\n*{filepath}:{line}*", -- Wrap in markdown code block
260+
},
261+
})
262+
```
263+
264+
**Key differences:**
265+
- `formats`: The copied text is automatically prepended with a newline. Format string only controls the context line.
266+
- `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).
267+
268+
When both `formats` and `output_formats` define the same format name, `output_formats` takes precedence.
269+
270+
Example with mixed configuration:
271+
```lua
272+
require('copy_with_context').setup({
273+
mappings = {
274+
relative = '<leader>cy',
275+
markdown = '<leader>cm',
276+
verbose = '<leader>cl',
277+
},
278+
formats = {
279+
default = '# {filepath}:{line}', -- {copied_text} is auto-prepended
280+
verbose = '(from {filepath}:{line})', -- will be ignored because the format of the same name in output_formats takes precedence
281+
},
282+
output_formats = {
283+
markdown = "```{copied_text}```\n\n{remote_url}", -- {copied_text} token must be specified or it will not be included
284+
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
285+
},
286+
})
287+
```
288+
246289
### Repository URL Support
247290

248291
When you use `{remote_url}` in a format string, the plugin automatically generates permalink URLs for your code snippets. This feature works with:

lua/copy_with_context/config.lua

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ M.options = {
88
relative = "<leader>cy",
99
absolute = "<leader>cY",
1010
},
11+
-- Formats: copied_text is automatically prepended with a newline
1112
formats = {
1213
default = "# {filepath}:{line}",
1314
},
15+
-- Full output formats: use {copied_text} token for complete control over output
16+
-- Example: output_formats = { default = "{copied_text}\n\n# {filepath}:{line}" }
17+
output_formats = {},
1418
trim_lines = false,
1519
}
1620

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

29-
-- Validate each format string
33+
-- Validate each format string (is_output_format = false)
3034
for format_name, format_string in pairs(M.options.formats or {}) do
31-
local format_valid, format_err = user_config_validation.validate_format_string(format_string)
35+
local format_valid, format_err =
36+
user_config_validation.validate_format_string(format_string, false)
3237
if not format_valid then
3338
error(string.format("Invalid format '%s': %s", format_name, format_err))
3439
end
3540
end
41+
42+
-- Validate each output_format string (is_output_format = true, requires
43+
-- {copied_text})
44+
for format_name, format_string in pairs(M.options.output_formats or {}) do
45+
local format_valid, format_err =
46+
user_config_validation.validate_format_string(format_string, true)
47+
if not format_valid then
48+
error(string.format("Invalid output_format '%s': %s", format_name, format_err))
49+
end
50+
end
3651
end
3752

3853
return M

lua/copy_with_context/formatter.lua

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ local M = {}
77
-- @param line_start number Starting line number
88
-- @param line_end number|nil Ending line number (nil for single line)
99
-- @param remote_url string|nil Remote repository URL (nil if not available)
10+
-- @param copied_text string|nil Copied text content (visual selection or current line)
1011
-- @return table Variables table
11-
function M.get_variables(file_path, line_start, line_end, remote_url)
12+
function M.get_variables(file_path, line_start, line_end, remote_url, copied_text)
1213
local line_range
1314
if line_end and line_end ~= line_start then
1415
line_range = string.format("%d-%d", line_start, line_end)
@@ -21,6 +22,7 @@ function M.get_variables(file_path, line_start, line_end, remote_url)
2122
line = line_range,
2223
linenumber = line_range, -- alias for 'line'
2324
remote_url = remote_url or "",
25+
copied_text = copied_text or "",
2426
}
2527
end
2628

lua/copy_with_context/main.lua

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,39 @@ function M.copy_with_context(mapping_name, is_visual)
2222
file_path = utils.get_file_path(false)
2323
end
2424

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

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

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

39-
-- Build variables and format output
40-
local vars = formatter.get_variables(file_path, start_lnum, end_lnum, remote_url)
41-
local context = formatter.format(format_string, vars)
42+
-- Build variables (include code for full output control)
43+
local vars = formatter.get_variables(file_path, start_lnum, end_lnum, remote_url, content)
4244

43-
-- Combine content and context
44-
local output = content .. "\n" .. context
45+
-- Generate output based on format type
46+
local output
47+
if output_format then
48+
-- New full output format - formatter controls entire output
49+
output = formatter.format(output_format, vars)
50+
elseif format then
51+
-- format - auto-prepend code with newline
52+
local context = formatter.format(format, vars)
53+
output = content .. "\n" .. context
54+
else
55+
-- Fallback if no format found
56+
output = content
57+
end
4558

4659
utils.copy_to_clipboard(output)
4760

@@ -52,15 +65,22 @@ function M.copy_with_context(mapping_name, is_visual)
5265
)
5366
end
5467

68+
-- Generate description for a mapping
69+
local function get_mapping_desc(mapping_name)
70+
return string.format("Copy with context (%s)", mapping_name)
71+
end
72+
5573
function M.setup()
5674
local config = require("copy_with_context.config")
5775

5876
-- Set up keymaps for all defined mappings
5977
for mapping_name, keymap in pairs(config.options.mappings) do
78+
local desc = get_mapping_desc(mapping_name)
79+
6080
-- Normal mode mapping
6181
vim.keymap.set("n", keymap, function()
6282
M.copy_with_context(mapping_name, false)
63-
end, { silent = false })
83+
end, { silent = false, desc = desc })
6484

6585
-- Visual mode mapping
6686
vim.keymap.set(
@@ -70,7 +90,7 @@ function M.setup()
7090
':<C-u>lua require("copy_with_context.main").copy_with_context("%s", true)<CR>',
7191
mapping_name
7292
),
73-
{ silent = true }
93+
{ silent = true, desc = desc }
7494
)
7595
end
7696
end

lua/copy_with_context/user_config_validation.lua

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,27 +12,32 @@ function M.validate(config)
1212

1313
local mappings = config.mappings or {}
1414
local formats = config.formats or {}
15+
local output_formats = config.output_formats or {}
1516

1617
-- Special cases that map to "default" format
1718
local default_mappings = {
1819
relative = true,
1920
absolute = true,
2021
}
2122

22-
-- Check: every mapping must have a format
23+
-- Check: every mapping must have a format (in either formats or output_formats)
2324
for mapping_name, _ in pairs(mappings) do
2425
-- relative/absolute use "default" format
2526
if default_mappings[mapping_name] then
26-
if not formats.default then
27+
if not formats.default and not output_formats.default then
2728
return false,
28-
string.format("Mapping '%s' requires 'formats.default' to be defined", mapping_name)
29+
string.format(
30+
"Mapping '%s' requires 'formats.default' or 'output_formats.default' to be defined",
31+
mapping_name
32+
)
2933
end
3034
else
31-
-- All other mappings need matching format
32-
if not formats[mapping_name] then
35+
-- All other mappings need matching format in either formats or output_formats
36+
if not formats[mapping_name] and not output_formats[mapping_name] then
3337
return false,
3438
string.format(
35-
"Mapping '%s' has no matching format. Add 'formats.%s'",
39+
"Mapping '%s' has no matching format. Add 'formats.%s' or 'output_formats.%s'",
40+
mapping_name,
3641
mapping_name,
3742
mapping_name
3843
)
@@ -54,13 +59,28 @@ function M.validate(config)
5459
end
5560
end
5661

62+
-- Check: every output_format (except default) should have a mapping
63+
for format_name, _ in pairs(output_formats) do
64+
if format_name ~= "default" then
65+
if not mappings[format_name] then
66+
return false,
67+
string.format(
68+
"Output format '%s' has no matching mapping. Add 'mappings.%s' or remove the output_format",
69+
format_name,
70+
format_name
71+
)
72+
end
73+
end
74+
end
75+
5776
return true, nil
5877
end
5978

6079
-- Validate format string has valid variables
6180
-- @param format_string string Format string to validate
81+
-- @param is_output_format boolean Whether this is an output_format (requires {copied_text})
6282
-- @return boolean, string|nil Success status and error message
63-
function M.validate_format_string(format_string)
83+
function M.validate_format_string(format_string, is_output_format)
6484
if not format_string then
6585
return false, "Format string cannot be nil"
6686
end
@@ -70,19 +90,32 @@ function M.validate_format_string(format_string)
7090
line = true,
7191
linenumber = true,
7292
remote_url = true,
93+
copied_text = true,
7394
}
7495

7596
-- Extract all variables from format string
7697
for var in format_string:gmatch("{([^}]+)}") do
7798
if not valid_vars[var] then
7899
return false,
79100
string.format(
80-
"Unknown variable '{%s}' in format string. Valid variables: filepath, line, linenumber, remote_url",
101+
"Unknown variable '{%s}'. Valid: filepath, line, linenumber, remote_url, copied_text",
81102
var
82103
)
83104
end
84105
end
85106

107+
local has_copied_text = format_string:match("{copied_text}") ~= nil
108+
109+
-- output_formats MUST contain {copied_text}
110+
if is_output_format and not has_copied_text then
111+
return false, "output_formats must contain '{copied_text}' to include the copied code"
112+
end
113+
114+
-- regular formats must NOT contain {copied_text} (it's auto-prepended)
115+
if not is_output_format and has_copied_text then
116+
return false, "'{copied_text}' can only be used in 'output_formats', not 'formats'"
117+
end
118+
86119
return true, nil
87120
end
88121

tests/copy_with_context/config_spec.lua

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,4 +154,64 @@ describe("Config Module", function()
154154
-- All formats are valid, should succeed
155155
assert.is_true(success)
156156
end)
157+
158+
it("validates output_formats with valid variables", function()
159+
local success = pcall(config.setup, {
160+
mappings = {
161+
relative = "<leader>cy",
162+
},
163+
output_formats = {
164+
default = "{copied_text}\n\n# {filepath}:{line}",
165+
},
166+
})
167+
168+
assert.is_true(success)
169+
end)
170+
171+
it("validates output_formats with invalid variables", function()
172+
local success = pcall(config.setup, {
173+
mappings = {
174+
relative = "<leader>cy",
175+
},
176+
output_formats = {
177+
default = "{copied_text}\n# {invalid_var}",
178+
},
179+
})
180+
181+
assert.is_false(success)
182+
end)
183+
184+
it("validates custom output_format with invalid variables", function()
185+
local success = pcall(config.setup, {
186+
mappings = {
187+
relative = "<leader>cy",
188+
markdown = "<leader>cm",
189+
},
190+
formats = {
191+
default = "# {filepath}:{line}",
192+
},
193+
output_formats = {
194+
markdown = "{copied_text}\n# {bad_variable}",
195+
},
196+
})
197+
198+
assert.is_false(success)
199+
end)
200+
201+
it("validates multiple output_formats", function()
202+
local success = pcall(config.setup, {
203+
mappings = {
204+
relative = "<leader>cy",
205+
markdown = "<leader>cm",
206+
full = "<leader>cf",
207+
},
208+
output_formats = {
209+
default = "{copied_text}\n# {filepath}:{line}",
210+
markdown = "```\n{copied_text}\n```\n\n_{filepath}:{line}_",
211+
full = "{copied_text}\n\n# {filepath}:{line}\n# {remote_url}",
212+
},
213+
})
214+
215+
assert.is_true(success)
216+
end)
157217
end)

0 commit comments

Comments
 (0)