diff --git a/Makefile b/Makefile index 22b6cd1..cdd04e2 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ LUA_VERSION = 5.1 DEPS_DIR = deps TEST_DIR = tests -ROCKSPEC = copy_with_context-2.1.0-1.rockspec +ROCKSPEC = copy_with_context-3.0.0-1.rockspec BUSTED = $(DEPS_DIR)/bin/busted LUACHECK = $(DEPS_DIR)/bin/luacheck diff --git a/README.md b/README.md index 27fe99f..4c73f1f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![GitHub Tag](https://img.shields.io/github/v/tag/zhisme/copy_with_context.nvim) ![GitHub License](https://img.shields.io/github/license/zhisme/copy_with_context.nvim) -Copy lines with file path and line number metadata. Perfect for sharing code snippets with context. +Copy lines with file path, line number, and repository URL metadata. Perfect for sharing code snippets with context. ## Why? @@ -42,11 +42,12 @@ Here's my login function: validate_credentials(user) end # app/controllers/auth_controller.rb:45-47 + # https://github.com/user/repo/blob/abc123/app/controllers/auth_controller.rb#L45-L47 How do I add OAuth? ``` -**Result**: The second prompt gives AI file location, line numbers, and project structure insight. AI provides OAuth integration that fits your exact architecture instead of generic advice. +**Result**: The second prompt gives AI file location, line numbers, project structure insight, and a direct link to the code. AI provides OAuth integration that fits your exact architecture instead of generic advice. ## Installation @@ -70,11 +71,15 @@ use { -- Customize mappings mappings = { relative = 'cy', - absolute = 'cY' + absolute = 'cY', + remote = 'cr', + }, + formats = { + default = '# {filepath}:{line}', -- Used by relative and absolute mappings + remote = '# {remote_url}', -- Custom format for remote mapping }, -- whether to trim lines or not trim_lines = false, - context_format = '# %s:%s', -- Default format for context: "# Source file: filepath:line" }) end } @@ -89,11 +94,15 @@ use { -- Customize mappings mappings = { relative = 'cy', - absolute = 'cY' + absolute = 'cY', + remote = 'cr', + }, + formats = { + default = '# {filepath}:{line}', -- Used by relative and absolute mappings + remote = '# {remote_url}', }, -- whether to trim lines or not trim_lines = false, - context_format = '# %s:%s', -- Default format for context: "# Source file: filepath:line" }) end }, @@ -101,6 +110,8 @@ use { ## Usage +### Default context + 1. Copy current line with relative path: - Press `cy` in normal mode. - Plugin copies line under cursor with relative path into your unnamed register. @@ -151,6 +162,33 @@ Output example: # /Users/zh/dev/project_name/app/views/widgets/show.html.erb:4-6 ``` +### Remote URL Support + +5. Copy current line with remote URL: + - Press `cr` in normal mode. + - Plugin copies line under cursor with repository URL into your unnamed register. + - Paste somewhere +Output example: +``` + <% posts.each do |post| %> + # https://github.com/user/repo/blob/abc123def/app/views/widgets/show.html.erb#L4 +``` + +6. Copy visual selection with remote URL: + - Select lines in visual mode. + - Press `cr`. + - Plugin copies the selected lines with repository URL into your unnamed register. + - Paste somewhere +Output example: +``` + <% posts.each do |post| %> + <%= post.title %> + <% end %> + # https://github.com/user/repo/blob/abc123def/app/views/widgets/show.html.erb#L4-L6 +``` + + + ## Configuration There is no need to call setup if you are ok with the defaults. @@ -161,16 +199,60 @@ require('copy_with_context').setup({ -- Customize mappings mappings = { relative = 'cy', - absolute = 'cY' + absolute = 'cY', + }, + -- Define format strings for each mapping + formats = { + default = '# {filepath}:{line}', -- Used by relative and absolute mappings }, -- whether to trim lines or not trim_lines = false, - context_format = '# %s:%s', -- Default format for context: "# Source file: filepath:line" - -- context_format = '# Source file: %s:%s', - -- Other format for context: "# Source file: /path/to/file:123" }) ``` +### Format Variables + +You can use the following variables in format strings: + +- `{filepath}` - The file path (relative or absolute depending on mapping) +- `{line}` - Line number or range (e.g., "42" or "10-20") +- `{linenumber}` - Alias for `{line}` +- `{remote_url}` - Repository URL (GitHub, GitLab, Bitbucket) + +### Custom Mappings and Formats + +You can define unlimited custom mappings with their own format strings: + +```lua +require('copy_with_context').setup({ + mappings = { + relative = 'cy', + absolute = 'cY', + remote = 'cr', + full = 'cx', -- Custom mapping with everything + }, + formats = { + default = '# {filepath}:{line}', + remote = '# {remote_url}', + full = '# {filepath}:{line}\n# {remote_url}', + }, +}) +``` + +**Important**: Every mapping name must have a matching format name. The special mappings `relative` and `absolute` use the `default` format. + +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. + +### 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: + +- **GitHub** (github.com and GitHub Enterprise) +- **GitLab** (gitlab.com and self-hosted instances containing "gitlab" in the domain) +- **Bitbucket** (bitbucket.org and *.bitbucket.org) + +The URLs always use the current commit SHA for stable permalinks. If you're not in a git repository or the repository provider is not recognized, the URL will simply be omitted (graceful degradation) + ## Development Want to contribute to `copy_with_context.nvim`? Here's how to set up your local development environment: @@ -230,13 +312,15 @@ use { -- Customize mappings mappings = { relative = 'cy', - absolute = 'cY' + absolute = 'cY', + remote = 'cr', + }, + formats = { + default = '# {filepath}:{line}', + remote = '# {remote_url}', }, -- whether to trim lines or not trim_lines = false, - context_format = '# %s:%s', -- Default format for context: "# filepath:line" - -- context_format = '# Source file: %s:%s', - -- Other format for context: "# Source file: /path/to/file:123" }) end } @@ -251,18 +335,30 @@ With lazy.nvim: opts = { mappings = { relative = 'cy', - absolute = 'cY' + absolute = 'cY', + remote = 'cr', + }, + formats = { + default = '# {filepath}:{line}', + remote = '# {remote_url}', }, -- whether to trim lines or not trim_lines = false, - context_format = '# %s:%s', -- Default format for context: "# filepath:line" - -- context_format = '# Source file: %s:%s', - -- Other format for context: "# Source file: /path/to/file:123" } } ``` Then restart Neovim or run `:Lazy` sync to load the local version +### Releasing + +For maintainers: see [RELEASING.md](./RELEASING.md) for the complete release process. + +The guide covers: +- Version numbering (Semantic Versioning) +- Generating release notes from git history +- Creating and publishing releases +- Publishing to LuaRocks + ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/zhisme/copy_with_context.nvim. Ensure to test your solution and provide a clear description of the problem you are solving. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..1c6f7f0 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,362 @@ +# Release Guide + +This guide covers the process for releasing a new version of `copy_with_context.nvim`. + +## Prerequisites + +- Write access to the repository +- [LuaRocks](https://luarocks.org/) account (optional, for publishing to LuaRocks) +- Familiarity with [Semantic Versioning](https://semver.org/) +- All CI checks passing on main branch + +## Release Checklist + +### 1. Pre-Release Verification + +Before starting the release process, ensure: + +- [ ] All tests pass: `make test` +- [ ] No linting errors: `make lint` +- [ ] Code is formatted: `make fmt-check` +- [ ] All CI/CD checks are passing on the main branch +- [ ] Documentation is up to date (README.md) +- [ ] All planned features/fixes for the release are merged + +### 2. Determine Version Number + +Follow [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PATCH): + +- **Major version (X.0.0)**: Breaking changes + - API changes + - Removed features + - Configuration structure changes + +- **Minor version (0.X.0)**: New features (backward compatible) + - New functionality + - New configuration options + - Performance improvements + +- **Patch version (0.0.X)**: Bug fixes (backward compatible) + - Bug fixes + - Documentation updates + - Internal refactoring + +**Examples:** +- `2.1.0` → `3.0.0` (breaking change: API redesign) +- `2.1.0` → `2.2.0` (new feature: added format variables) +- `2.1.0` → `2.1.1` (bug fix: fixed URL parsing) + +### 3. Generate Release Notes + +Use git commit history to generate release notes instead of maintaining a CHANGELOG.md file. + +**Quick method:** +```bash +# Get commits since last release +git log $(git describe --tags --abbrev=0)..HEAD --oneline +``` + +**Categorized method (recommended):** +```bash +# Use the provided script +./scripts/generate-release-notes.sh > release-notes.md + +# Or specify a tag to compare against +./scripts/generate-release-notes.sh v2.0.0 > release-notes.md +``` + +**GitHub auto-generate:** +When creating a release on GitHub, click **"Generate release notes"** button. GitHub will automatically create notes from PRs and commits. + +The `scripts/generate-release-notes.sh` script categorizes commits by type: +- ⚠️ Breaking Changes (commits with "BREAKING") +- ✨ Features (commits starting with "feat") +- 🐛 Bug Fixes (commits starting with "fix") +- ♻️ Refactoring (commits starting with "refactor") +- 📚 Documentation (commits starting with "docs") +- ✅ Tests (commits starting with "test") +- 🔧 Maintenance (commits starting with "chore") + +### 4. Update Rockspec + +Create a new rockspec file for the version: + +```bash +# Determine new version (e.g., 3.0.0) +NEW_VERSION="3.0.0" +OLD_VERSION=$(ls copy_with_context-*.rockspec | head -1 | sed 's/copy_with_context-\(.*\)\.rockspec/\1/') + +# Copy the current rockspec +cp copy_with_context-${OLD_VERSION}.rockspec copy_with_context-${NEW_VERSION}.rockspec +``` + +Edit `copy_with_context-${NEW_VERSION}.rockspec`: + +```lua +package = "copy_with_context" +version = "X.Y.Z-1" -- Update this +source = { + url = "git://github.com/zhisme/copy_with_context.nvim.git", + tag = "vX.Y.Z", -- Update this +} +-- ... rest of the file +``` + +**Important:** +- Update `version` field to match new version +- Update `tag` field to match new version (with `v` prefix) +- Verify all modules are listed in `build.modules` if you added new files +- Dependencies should only include runtime dependencies (not luacheck, busted, etc.) + +### 5. Update Makefile + +Update the `ROCKSPEC` variable in `Makefile`: + +```makefile +ROCKSPEC = copy_with_context-X.Y.Z-1.rockspec # Update this +``` + +### 6. Commit Version Bump + +```bash +# Stage the changes +git add copy_with_context-*.rockspec Makefile + +# Commit with conventional commit message +git commit -m "chore: bump version to X.Y.Z" + +# Push to main +git push origin main +``` + +### 7. Create Git Tag + +```bash +# Create an annotated tag +git tag -a vX.Y.Z -m "Release vX.Y.Z + +Brief description of major changes in this release. + +Breaking changes (if any): +- List breaking changes here + +New features: +- List new features here + +Bug fixes: +- List bug fixes here +" + +# Verify the tag +git tag -n9 vX.Y.Z + +# Push the tag to GitHub +git push origin vX.Y.Z +``` + +**Tag naming convention:** +- Format: `vMAJOR.MINOR.PATCH` +- Examples: `v3.0.0`, `v2.1.5`, `v1.0.0-rc.1` + +### 8. Create GitHub Release + +1. Go to https://github.com/zhisme/copy_with_context.nvim/releases +2. Click **"Draft a new release"** +3. **Choose tag:** Select the tag you just pushed (e.g., `v3.0.0`) +4. **Release title:** Format: `vX.Y.Z - Brief Description` + - Examples: + - `v3.0.0 - Flexible Mapping System` + - `v2.1.0 - Repository URL Support` + - `v2.0.1 - Bug Fixes` +5. **Description:** + - Click **"Generate release notes"** button (recommended) + - Or paste from `release-notes.md` generated in step 3 + - Or write manually using this template: + +```markdown +# 🎉 vX.Y.Z - Release Title + +Brief summary of what this release is about. + +## ⚠️ Breaking Changes + +**If this is a major version (X.0.0), list breaking changes:** +- Configuration change: explain what changed +- API change: explain what changed + +**Migration guide:** +- Step-by-step instructions for users to upgrade + +## ✨ New Features + +- Feature 1: description +- Feature 2: description + +## 🐛 Bug Fixes + +- Fix 1: description +- Fix 2: description + +## 📚 Documentation + +See commit history for full details: +```bash +git log vPREV..vX.Y.Z --oneline +``` + +Full documentation: [README.md](./README.md) +``` + +6. Check **"Set as the latest release"** (unless it's a pre-release) +7. Click **"Publish release"** + +### 9. Publish to LuaRocks (Optional) + +If you want to publish to [LuaRocks](https://luarocks.org/): + +```bash +# Install luarocks CLI if not already installed +# See: https://github.com/luarocks/luarocks/wiki/Download + +# Login to LuaRocks (first time only) +luarocks login + +# Upload the rockspec +luarocks upload copy_with_context-X.Y.Z-1.rockspec +``` + +**Note:** You need a LuaRocks account and to be a maintainer of the package. + +### 10. Post-Release Tasks + +- [ ] Verify the release appears on GitHub Releases page +- [ ] Verify the tag is visible: `git tag -l` +- [ ] Test installation from the new tag: + ```bash + # In a test environment + cd /tmp + git clone https://github.com/zhisme/copy_with_context.nvim.git + cd copy_with_context.nvim + git checkout vX.Y.Z + make test + ``` +- [ ] (Optional) Announce the release: + - Reddit: r/neovim + - Twitter/X + - Discord communities +- [ ] Close the milestone (if using GitHub milestones) + +## Quick Reference + +### Version Bumping Rules + +| Change Type | Example | Current | New Version | +|-------------|---------|---------|-------------| +| Breaking change | API redesign, config structure change | 2.1.0 | 3.0.0 | +| New feature | Add format variables | 2.1.0 | 2.2.0 | +| Bug fix | Fix URL parsing | 2.1.0 | 2.1.1 | +| Multiple bug fixes | Several small fixes | 2.1.0 | 2.1.1 | +| Feature + bug fix | Both in one release | 2.1.0 | 2.2.0 | + +### Rockspec Naming Convention + +Format: `--.rockspec` + +- **Package:** `copy_with_context` +- **Version:** Semantic version (e.g., `3.0.0`) +- **Revision:** Usually `1` (increment if republishing same version with rockspec-only changes) + +Examples: +- `copy_with_context-3.0.0-1.rockspec` (first release of 3.0.0) +- `copy_with_context-3.0.0-2.rockspec` (rockspec fix for 3.0.0) + +### Tag Naming Convention + +Format: `v` + +Examples: +- `v3.0.0` (stable release) +- `v3.0.0-rc.1` (release candidate) +- `v3.0.0-beta.1` (beta release) +- `v3.0.0-alpha.1` (alpha release) + +### Conventional Commit Prefixes + +Used for categorizing commits in release notes: + +- `feat:` - New feature +- `fix:` - Bug fix +- `docs:` - Documentation changes +- `chore:` - Maintenance tasks +- `refactor:` - Code refactoring +- `test:` - Test updates +- `perf:` - Performance improvements + +## Troubleshooting + +### Tag Already Exists + +```bash +# Delete local tag +git tag -d vX.Y.Z + +# Delete remote tag +git push origin :refs/tags/vX.Y.Z + +# Recreate tag +git tag -a vX.Y.Z -m "Release vX.Y.Z" +git push origin vX.Y.Z +``` + +### Rockspec Validation Fails + +```bash +# Validate rockspec locally +luarocks lint copy_with_context-X.Y.Z-1.rockspec + +# Test local installation +luarocks make copy_with_context-X.Y.Z-1.rockspec +``` + +### Wrong Rockspec in Makefile + +Make sure `Makefile` references the correct version: + +```makefile +ROCKSPEC = copy_with_context-X.Y.Z-1.rockspec +``` + +### Release Notes Script Not Working + +```bash +# Make sure script is executable +chmod +x scripts/generate-release-notes.sh + +# Run with bash explicitly +bash scripts/generate-release-notes.sh + +# Check if git tags exist +git tag -l +``` + +### GitHub Release Not Showing Up + +- Ensure the tag was pushed: `git ls-remote --tags origin` +- Check if CI is passing for the tag +- Verify you have write access to the repository + +## Automation (Future) + +The release process can be automated with GitHub Actions. A workflow will be added in the future to: + +- Automatically create GitHub releases when tags are pushed +- Run tests before releasing +- Auto-generate release notes from commits +- Optionally publish to LuaRocks + +## Additional Resources + +- [Semantic Versioning](https://semver.org/) +- [GitHub Releases Documentation](https://docs.github.com/en/repositories/releasing-projects-on-github) +- [LuaRocks Documentation](https://github.com/luarocks/luarocks/wiki) +- [Conventional Commits](https://www.conventionalcommits.org/) diff --git a/claude.md b/claude.md index c7e9c2a..e6f8414 100644 --- a/claude.md +++ b/claude.md @@ -210,6 +210,7 @@ Contributions are welcome! Please: 3. Run linters (`make lint`) 4. Format code (`make fmt`) 5. Provide clear descriptions in PRs +6. Use conventional commit messages for consistency. Each commit message should contain the following (feat:, fix:, docs:, style:, refactor:, test:, chore:) ## License diff --git a/copy_with_context-3.0.0-1.rockspec b/copy_with_context-3.0.0-1.rockspec new file mode 100644 index 0000000..d0599d7 --- /dev/null +++ b/copy_with_context-3.0.0-1.rockspec @@ -0,0 +1,42 @@ +package = "copy_with_context" +version = "3.0.0-1" +source = { + url = "git://github.com/zhisme/copy_with_context.nvim.git", + tag = "v3.0.0", +} +description = { + summary = "A Neovim plugin for copying code with context", + detailed = [[ + Copy lines with file path and line number metadata. + Supports flexible format strings with custom variables and + repository URL generation for GitHub, GitLab, and Bitbucket. + + Features: + - Unlimited custom mappings with unique formats + - Template variables: {filepath}, {line}, {remote_url} + - Repository URL support with commit SHAs + - Works with nested groups (GitLab, GitHub, Bitbucket) + ]], + homepage = "https://github.com/zhisme/copy_with_context.nvim", + license = "MIT", +} +dependencies = { + "lua >= 5.1", +} +build = { + type = "builtin", + modules = { + ["copy_with_context"] = "lua/copy_with_context/init.lua", + ["copy_with_context.config"] = "lua/copy_with_context/config.lua", + ["copy_with_context.formatter"] = "lua/copy_with_context/formatter.lua", + ["copy_with_context.git"] = "lua/copy_with_context/git.lua", + ["copy_with_context.main"] = "lua/copy_with_context/main.lua", + ["copy_with_context.url_builder"] = "lua/copy_with_context/url_builder.lua", + ["copy_with_context.user_config_validation"] = "lua/copy_with_context/user_config_validation.lua", + ["copy_with_context.utils"] = "lua/copy_with_context/utils.lua", + ["copy_with_context.providers.init"] = "lua/copy_with_context/providers/init.lua", + ["copy_with_context.providers.github"] = "lua/copy_with_context/providers/github.lua", + ["copy_with_context.providers.gitlab"] = "lua/copy_with_context/providers/gitlab.lua", + ["copy_with_context.providers.bitbucket"] = "lua/copy_with_context/providers/bitbucket.lua", + }, +} diff --git a/lua/copy_with_context/config.lua b/lua/copy_with_context/config.lua index cfa9dba..6ba3f41 100644 --- a/lua/copy_with_context/config.lua +++ b/lua/copy_with_context/config.lua @@ -1,3 +1,5 @@ +local user_config_validation = require("copy_with_context.user_config_validation") + local M = {} -- Default configuration @@ -6,7 +8,9 @@ M.options = { relative = "cy", absolute = "cY", }, - context_format = "# %s:%s", -- format for context: "# filepath:line", example: "# /path/to/file:123" + formats = { + default = "# {filepath}:{line}", + }, trim_lines = false, } @@ -15,6 +19,20 @@ function M.setup(opts) if opts then M.options = vim.tbl_deep_extend("force", M.options, opts) end + + -- Validate configuration + local valid, err = user_config_validation.validate(M.options) + if not valid then + error(string.format("Invalid configuration: %s", err)) + end + + -- Validate each format string + 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) + if not format_valid then + error(string.format("Invalid format '%s': %s", format_name, format_err)) + end + end end return M diff --git a/lua/copy_with_context/formatter.lua b/lua/copy_with_context/formatter.lua new file mode 100644 index 0000000..bc90bc5 --- /dev/null +++ b/lua/copy_with_context/formatter.lua @@ -0,0 +1,49 @@ +-- Formatter module for variable replacement in format strings + +local M = {} + +-- Build variables table from file and line information +-- @param file_path string File path to use +-- @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) +-- @return table Variables table +function M.get_variables(file_path, line_start, line_end, remote_url) + local line_range + if line_end and line_end ~= line_start then + line_range = string.format("%d-%d", line_start, line_end) + else + line_range = tostring(line_start) + end + + return { + filepath = file_path, + line = line_range, + linenumber = line_range, -- alias for 'line' + remote_url = remote_url or "", + } +end + +-- Replace {variable} placeholders in format string with actual values +-- @param format_string string Format string with {variable} placeholders +-- @param vars table Variables table (from get_variables) +-- @return string Formatted string with variables replaced +function M.format(format_string, vars) + if not format_string then + return "" + end + + -- Replace each {variable} with its value + local result = format_string:gsub("{([^}]+)}", function(var_name) + local value = vars[var_name] + if value == nil then + -- Return placeholder unchanged if variable not found + return "{" .. var_name .. "}" + end + return tostring(value) + end) + + return result +end + +return M diff --git a/lua/copy_with_context/git.lua b/lua/copy_with_context/git.lua new file mode 100644 index 0000000..11eceea --- /dev/null +++ b/lua/copy_with_context/git.lua @@ -0,0 +1,167 @@ +-- Git utilities for repository information and URL generation + +local M = {} + +-- Check if current file is in a git repository +function M.is_git_repo() + local result = vim.fn.system("git rev-parse --is-inside-work-tree 2>/dev/null") + return vim.v.shell_error == 0 and vim.fn.trim(result) == "true" +end + +-- Get the remote URL (prefer 'origin', fallback to first available) +function M.get_remote_url() + local result = vim.fn.system("git remote -v 2>/dev/null") + if vim.v.shell_error ~= 0 or result == "" then + return nil + end + + -- Parse remote output + -- Format: "origin https://github.com/user/repo.git (fetch)" + -- Prefer 'origin' remote + local origin_url = result:match("origin%s+([^%s]+)%s+%(fetch%)") + if origin_url then + return origin_url + end + + -- Fallback to first available remote + local first_url = result:match("^[^%s]+%s+([^%s]+)%s+%(fetch%)") + return first_url +end + +-- Parse remote URL to extract provider, owner, and repo +-- Supports HTTPS, SSH, and git:// formats +-- Handles nested groups (e.g., gitlab.com/group/subgroup/repo) +function M.parse_remote_url(url) + if not url then + return nil + end + + local provider, path + + -- HTTPS: https://github.com/user/repo.git or https://gitlab.com/group/subgroup/repo.git + provider, path = url:match("https?://([^/]+)/(.+)%.git$") + if provider and path then + local owner, repo = path:match("(.+)/([^/]+)$") + if owner and repo then + return { provider = provider, owner = owner, repo = repo } + end + end + + -- HTTPS without .git: https://github.com/user/repo + provider, path = url:match("https?://([^/]+)/(.+)$") + if provider and path then + local owner, repo = path:match("(.+)/([^/]+)$") + if owner and repo then + return { provider = provider, owner = owner, repo = repo } + end + end + + -- SSH: git@github.com:user/repo.git or git@gitlab.com:group/subgroup/repo.git + provider, path = url:match("git@([^:]+):(.+)%.git$") + if provider and path then + local owner, repo = path:match("(.+)/([^/]+)$") + if owner and repo then + return { provider = provider, owner = owner, repo = repo } + end + end + + -- SSH without .git: git@github.com:user/repo + provider, path = url:match("git@([^:]+):(.+)$") + if provider and path then + local owner, repo = path:match("(.+)/([^/]+)$") + if owner and repo then + return { provider = provider, owner = owner, repo = repo } + end + end + + -- git protocol: git://github.com/user/repo.git + provider, path = url:match("git://([^/]+)/(.+)%.git$") + if provider and path then + local owner, repo = path:match("(.+)/([^/]+)$") + if owner and repo then + return { provider = provider, owner = owner, repo = repo } + end + end + + -- git protocol without .git: git://github.com/user/repo + provider, path = url:match("git://([^/]+)/(.+)$") + if provider and path then + local owner, repo = path:match("(.+)/([^/]+)$") + if owner and repo then + return { provider = provider, owner = owner, repo = repo } + end + end + + return nil +end + +-- Get current commit SHA (full 40 characters) +function M.get_current_commit() + local result = vim.fn.system("git rev-parse HEAD 2>/dev/null") + if vim.v.shell_error ~= 0 then + return nil + end + return vim.fn.trim(result) +end + +-- Convert absolute path to repo-relative path +function M.get_file_git_path(file_path) + -- Get the absolute path if not already absolute + local abs_path = file_path + if not file_path:match("^/") and not file_path:match("^%a:") then + abs_path = vim.fn.fnamemodify(file_path, ":p") + end + + local result = vim.fn.system( + string.format("git ls-files --full-name %s 2>/dev/null", vim.fn.shellescape(abs_path)) + ) + if vim.v.shell_error ~= 0 or result == "" then + return nil + end + + local git_path = vim.fn.trim(result) + + -- Convert Windows backslashes to forward slashes + git_path = git_path:gsub("\\", "/") + + return git_path +end + +-- Aggregate function to get all git info for a file +-- Returns: {provider="github.com", owner="user", repo="repo", commit="abc123", file_path="path/to/file"} +-- Returns nil if not in git repo or any step fails +function M.get_git_info(file_path) + if not M.is_git_repo() then + return nil + end + + local remote_url = M.get_remote_url() + if not remote_url then + return nil + end + + local parsed = M.parse_remote_url(remote_url) + if not parsed then + return nil + end + + local commit = M.get_current_commit() + if not commit then + return nil + end + + local git_path = M.get_file_git_path(file_path) + if not git_path then + return nil + end + + return { + provider = parsed.provider, + owner = parsed.owner, + repo = parsed.repo, + commit = commit, + file_path = git_path, + } +end + +return M diff --git a/lua/copy_with_context/main.lua b/lua/copy_with_context/main.lua index 5db5178..ab33ad5 100644 --- a/lua/copy_with_context/main.lua +++ b/lua/copy_with_context/main.lua @@ -1,15 +1,47 @@ local M = {} -function M.copy_with_context(absolute_path, is_visual) +-- Generic copy function that works with any mapping +function M.copy_with_context(mapping_name, is_visual) + local config = require("copy_with_context.config") local utils = require("copy_with_context.utils") + local formatter = require("copy_with_context.formatter") + local url_builder = require("copy_with_context.url_builder") + + -- Get lines and line numbers local lines, start_lnum, end_lnum = utils.get_lines(is_visual) local content = table.concat(utils.process_lines(lines), "\n") - local output = utils.format_output( - content, - utils.get_file_path(absolute_path), - utils.format_line_range(start_lnum, end_lnum) - ) + -- Determine file path based on mapping type + local file_path + if mapping_name == "relative" then + file_path = utils.get_file_path(false) + elseif mapping_name == "absolute" then + file_path = utils.get_file_path(true) + else + -- For custom mappings, default to relative path + file_path = utils.get_file_path(false) + end + + -- Get remote URL if needed (check if format uses {remote_url}) + 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 + + -- Only fetch remote URL if format string uses it + 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) + + -- Combine content and context + local output = content .. "\n" .. context utils.copy_to_clipboard(output) @@ -23,25 +55,24 @@ end function M.setup() local config = require("copy_with_context.config") - -- Apply mappings - vim.keymap.set("n", config.options.mappings.relative, function() - M.copy_with_context(false, false) - end, { silent = false }) - vim.keymap.set("n", config.options.mappings.absolute, function() - M.copy_with_context(true, false) - end, { silent = false }) - vim.keymap.set( - "x", - config.options.mappings.relative, - ':lua require("copy_with_context.main").copy_with_context(false, true)', - { silent = true } - ) - vim.keymap.set( - "x", - config.options.mappings.absolute, - ':lua require("copy_with_context.main").copy_with_context(true, true)', - { silent = true } - ) + -- Set up keymaps for all defined mappings + for mapping_name, keymap in pairs(config.options.mappings) do + -- Normal mode mapping + vim.keymap.set("n", keymap, function() + M.copy_with_context(mapping_name, false) + end, { silent = false }) + + -- Visual mode mapping + vim.keymap.set( + "x", + keymap, + string.format( + ':lua require("copy_with_context.main").copy_with_context("%s", true)', + mapping_name + ), + { silent = true } + ) + end end return M diff --git a/lua/copy_with_context/providers/bitbucket.lua b/lua/copy_with_context/providers/bitbucket.lua new file mode 100644 index 0000000..cd4ade2 --- /dev/null +++ b/lua/copy_with_context/providers/bitbucket.lua @@ -0,0 +1,32 @@ +-- Bitbucket provider for URL generation + +local M = {} + +M.name = "bitbucket" + +-- Check if this provider handles the given domain +function M.matches(domain) + return domain == "bitbucket.org" or domain:match("%.bitbucket%.org$") ~= nil +end + +-- Build URL for Bitbucket +-- Format: https://bitbucket.org/{owner}/{repo}/src/{commit_sha}/{file_path}#lines-{start}[:{end}] +function M.build_url(git_info, line_start, line_end) + local base_url = string.format( + "https://%s/%s/%s/src/%s/%s", + git_info.provider, + git_info.owner, + git_info.repo, + git_info.commit, + git_info.file_path + ) + + -- Add line fragment + if line_start == line_end then + return base_url .. "#lines-" .. line_start + else + return base_url .. "#lines-" .. line_start .. ":" .. line_end + end +end + +return M diff --git a/lua/copy_with_context/providers/github.lua b/lua/copy_with_context/providers/github.lua new file mode 100644 index 0000000..f47f353 --- /dev/null +++ b/lua/copy_with_context/providers/github.lua @@ -0,0 +1,32 @@ +-- GitHub provider for URL generation + +local M = {} + +M.name = "github" + +-- Check if this provider handles the given domain +function M.matches(domain) + return domain == "github.com" or domain:match("github") ~= nil +end + +-- Build URL for GitHub +-- Format: https://github.com/{owner}/{repo}/blob/{commit_sha}/{file_path}#L{start}[-L{end}] +function M.build_url(git_info, line_start, line_end) + local base_url = string.format( + "https://%s/%s/%s/blob/%s/%s", + git_info.provider, + git_info.owner, + git_info.repo, + git_info.commit, + git_info.file_path + ) + + -- Add line fragment + if line_start == line_end then + return base_url .. "#L" .. line_start + else + return base_url .. "#L" .. line_start .. "-L" .. line_end + end +end + +return M diff --git a/lua/copy_with_context/providers/gitlab.lua b/lua/copy_with_context/providers/gitlab.lua new file mode 100644 index 0000000..9809322 --- /dev/null +++ b/lua/copy_with_context/providers/gitlab.lua @@ -0,0 +1,33 @@ +-- GitLab provider for URL generation + +local M = {} + +M.name = "gitlab" + +-- Check if this provider handles the given domain +-- GitLab.com or assume self-hosted GitLab as fallback +function M.matches(domain) + return domain == "gitlab.com" or domain:match("gitlab") ~= nil +end + +-- Build URL for GitLab +-- Format: https://gitlab.com/{owner}/{repo}/-/blob/{commit_sha}/{file_path}#L{start}[-{end}] +function M.build_url(git_info, line_start, line_end) + local base_url = string.format( + "https://%s/%s/%s/-/blob/%s/%s", + git_info.provider, + git_info.owner, + git_info.repo, + git_info.commit, + git_info.file_path + ) + + -- Add line fragment + if line_start == line_end then + return base_url .. "#L" .. line_start + else + return base_url .. "#L" .. line_start .. "-" .. line_end + end +end + +return M diff --git a/lua/copy_with_context/providers/init.lua b/lua/copy_with_context/providers/init.lua new file mode 100644 index 0000000..1c69233 --- /dev/null +++ b/lua/copy_with_context/providers/init.lua @@ -0,0 +1,39 @@ +-- Provider detection and factory + +local M = {} + +-- Lazy-loaded provider modules +local providers = { + "copy_with_context.providers.github", + "copy_with_context.providers.gitlab", + "copy_with_context.providers.bitbucket", +} + +-- Detect which provider handles the given domain +function M.detect_provider(domain) + if not domain then + return nil + end + + -- Try each provider in order + for _, provider_path in ipairs(providers) do + local ok, provider = pcall(require, provider_path) + if ok and provider.matches and provider.matches(domain) then + return provider + end + end + + -- Return nil for unknown providers (graceful degradation) + return nil +end + +-- Factory method to get provider from git info +function M.get_provider(git_info) + if not git_info or not git_info.provider then + return nil + end + + return M.detect_provider(git_info.provider) +end + +return M diff --git a/lua/copy_with_context/url_builder.lua b/lua/copy_with_context/url_builder.lua new file mode 100644 index 0000000..32690e0 --- /dev/null +++ b/lua/copy_with_context/url_builder.lua @@ -0,0 +1,30 @@ +-- URL builder module for generating repository URLs + +local M = {} + +-- Build a repository URL for the given file and line range +-- @param file_path string File path (relative or absolute) +-- @param line_start number Starting line number +-- @param line_end number|nil Ending line number (nil for single line) +-- @return string|nil Repository URL or nil if not available +function M.build_url(file_path, line_start, line_end) + -- Get git info + local git = require("copy_with_context.git") + local git_info = git.get_git_info(file_path) + if not git_info then + return nil + end + + -- Get provider + local providers = require("copy_with_context.providers") + local provider = providers.get_provider(git_info) + if not provider then + return nil + end + + -- Build URL + local url = provider.build_url(git_info, line_start, line_end) + return url +end + +return M diff --git a/lua/copy_with_context/user_config_validation.lua b/lua/copy_with_context/user_config_validation.lua new file mode 100644 index 0000000..b1e14ea --- /dev/null +++ b/lua/copy_with_context/user_config_validation.lua @@ -0,0 +1,89 @@ +-- User configuration validation module + +local M = {} + +-- Validate that mappings and formats match +-- @param config table User configuration +-- @return boolean, string|nil Success status and error message +function M.validate(config) + if not config then + return true, nil + end + + local mappings = config.mappings or {} + local formats = config.formats or {} + + -- Special cases that map to "default" format + local default_mappings = { + relative = true, + absolute = true, + } + + -- Check: every mapping must have a format + for mapping_name, _ in pairs(mappings) do + -- relative/absolute use "default" format + if default_mappings[mapping_name] then + if not formats.default then + return false, + string.format("Mapping '%s' requires 'formats.default' to be defined", mapping_name) + end + else + -- All other mappings need matching format + if not formats[mapping_name] then + return false, + string.format( + "Mapping '%s' has no matching format. Add 'formats.%s'", + mapping_name, + mapping_name + ) + end + end + end + + -- Check: every format (except default) should have a mapping + for format_name, _ in pairs(formats) do + if format_name ~= "default" then + if not mappings[format_name] then + return false, + string.format( + "Format '%s' has no matching mapping. Add 'mappings.%s' or remove the 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 +-- @return boolean, string|nil Success status and error message +function M.validate_format_string(format_string) + if not format_string then + return false, "Format string cannot be nil" + end + + local valid_vars = { + filepath = true, + line = true, + linenumber = true, + remote_url = 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", + var + ) + end + end + + return true, nil +end + +return M diff --git a/lua/copy_with_context/utils.lua b/lua/copy_with_context/utils.lua index df5150a..13fc969 100644 --- a/lua/copy_with_context/utils.lua +++ b/lua/copy_with_context/utils.lua @@ -22,11 +22,6 @@ function M.get_file_path(absolute) return absolute and vim.fn.expand("%:p") or vim.fn.expand("%") end -function M.format_line_range(start_line, end_line) - return start_line == end_line and tostring(start_line) - or string.format("%d-%d", start_line, end_line) -end - function M.process_lines(lines) local config = require("copy_with_context.config") local processed = {} @@ -47,11 +42,4 @@ function M.copy_to_clipboard(output) vim.fn.setreg("+", output) end -function M.format_output(content, file_path, line_range) - local config = require("copy_with_context.config") - local comment_line = string.format(config.options.context_format, file_path, line_range) - - return string.format("%s\n%s", content, comment_line) -end - return M diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh new file mode 100755 index 0000000..3d3405c --- /dev/null +++ b/scripts/generate-release-notes.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# Generate release notes from git commit history +# Usage: ./scripts/generate-release-notes.sh [TAG] +# If TAG is not provided, generates notes since last tag + +set -e + +# Get the tag to compare against +if [ -n "$1" ]; then + LAST_TAG="$1" +else + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") +fi + +if [ -z "$LAST_TAG" ]; then + echo "## All Commits" + echo "" + git log --pretty=format:"- %s (%h)" --reverse + exit 0 +fi + +echo "## Changes since ${LAST_TAG}" +echo "" + +# Function to print commits matching a pattern +print_commits() { + local pattern="$1" + local commits=$(git log ${LAST_TAG}..HEAD --grep="$pattern" --pretty=format:"- %s (%h)" --reverse 2>/dev/null) + if [ -n "$commits" ]; then + echo "$commits" + fi +} + +# Breaking changes (highest priority) +echo "### ⚠️ Breaking Changes" +breaking=$(print_commits "BREAKING") +if [ -z "$breaking" ]; then + echo "_None_" +else + echo "$breaking" +fi +echo "" + +# Features +echo "### ✨ Features" +features=$(print_commits "^feat") +if [ -z "$features" ]; then + echo "_None_" +else + echo "$features" +fi +echo "" + +# Bug fixes +echo "### 🐛 Bug Fixes" +fixes=$(print_commits "^fix") +if [ -z "$fixes" ]; then + echo "_None_" +else + echo "$fixes" +fi +echo "" + +# Refactoring +echo "### ♻️ Refactoring" +refactor=$(print_commits "^refactor") +if [ -z "$refactor" ]; then + echo "_None_" +else + echo "$refactor" +fi +echo "" + +# Documentation +echo "### 📚 Documentation" +docs=$(print_commits "^docs") +if [ -z "$docs" ]; then + echo "_None_" +else + echo "$docs" +fi +echo "" + +# Tests +echo "### ✅ Tests" +tests=$(print_commits "^test") +if [ -z "$tests" ]; then + echo "_None_" +else + echo "$tests" +fi +echo "" + +# Chores +echo "### 🔧 Maintenance" +chores=$(print_commits "^chore") +if [ -z "$chores" ]; then + echo "_None_" +else + echo "$chores" +fi +echo "" + +# All other commits not matching patterns above +echo "### 📝 Other Changes" +other=$(git log ${LAST_TAG}..HEAD \ + --invert-grep \ + --grep="BREAKING" \ + --grep="^feat" \ + --grep="^fix" \ + --grep="^refactor" \ + --grep="^docs" \ + --grep="^test" \ + --grep="^chore" \ + --pretty=format:"- %s (%h)" \ + --reverse 2>/dev/null) +if [ -z "$other" ]; then + echo "_None_" +else + echo "$other" +fi +echo "" + +# Summary statistics +echo "---" +echo "" +total=$(git rev-list ${LAST_TAG}..HEAD --count) +contributors=$(git log ${LAST_TAG}..HEAD --format='%an' | sort -u | wc -l) +echo "**Total commits:** $total" +echo "**Contributors:** $contributors" diff --git a/tests/copy_with_context/config_spec.lua b/tests/copy_with_context/config_spec.lua index dab4391..840108c 100644 --- a/tests/copy_with_context/config_spec.lua +++ b/tests/copy_with_context/config_spec.lua @@ -17,21 +17,45 @@ _G.vim = { -- Ensure fresh module loading package.loaded["copy_with_context.config"] = nil +package.loaded["copy_with_context.user_config_validation"] = nil local config = require("copy_with_context.config") describe("Config Module", function() + before_each(function() + -- Reset config.options to defaults before each test + config.options = { + mappings = { + relative = "cy", + absolute = "cY", + }, + formats = { + default = "# {filepath}:{line}", + }, + trim_lines = false, + } + end) + it("has default options", function() assert.same({ mappings = { relative = "cy", absolute = "cY", }, - context_format = "# %s:%s", + formats = { + default = "# {filepath}:{line}", + }, trim_lines = false, }, config.options) end) + it("can be called without arguments", function() + config.setup() + -- Should not error and keep default options + assert.is_not_nil(config.options.mappings) + assert.is_not_nil(config.options.formats) + end) + it("merges user options with defaults", function() config.setup({ mappings = { relative = "new" }, @@ -43,8 +67,91 @@ describe("Config Module", function() relative = "new", absolute = "cY", }, - context_format = "# %s:%s", + formats = { + default = "# {filepath}:{line}", + }, trim_lines = true, }, config.options) end) + + it("validates configuration on setup", function() + local success = pcall(config.setup, { + mappings = { + custom = "cc", + }, + formats = { + default = "# {filepath}:{line}", + -- missing 'custom' format + }, + }) + + assert.is_false(success) + end) + + it("validates format strings on setup", function() + local success = pcall(config.setup, { + mappings = { + relative = "cy", + }, + formats = { + default = "# {invalid_variable}", + }, + }) + + assert.is_false(success) + end) + + it("validates custom format strings with invalid variables", function() + -- This test covers the error on line 33 of config.lua + local success = pcall(config.setup, { + mappings = { + relative = "cy", + custom = "cc", + }, + formats = { + default = "# {filepath}:{line}", + custom = "# {invalid_custom_var}", -- Invalid variable in custom format + }, + }) + + assert.is_false(success) + end) + + it("handles missing formats gracefully", function() + -- Manually reset config to have no formats + config.options = { + mappings = { + relative = "cy", + }, + trim_lines = false, + } + + -- Setup without providing formats - should fail because no default format + local success = pcall(config.setup, { + mappings = { + relative = "cy", + }, + }) + + -- Should fail validation because no default format + assert.is_false(success) + end) + + it("validates multiple format strings", function() + local success = pcall(config.setup, { + mappings = { + relative = "cy", + custom1 = "c1", + custom2 = "c2", + }, + formats = { + default = "# {filepath}:{line}", + custom1 = "# {remote_url}", + custom2 = "# {filepath}", + }, + }) + + -- All formats are valid, should succeed + assert.is_true(success) + end) end) diff --git a/tests/copy_with_context/formatter_spec.lua b/tests/copy_with_context/formatter_spec.lua new file mode 100644 index 0000000..3902bc7 --- /dev/null +++ b/tests/copy_with_context/formatter_spec.lua @@ -0,0 +1,134 @@ +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) + + assert.same({ + filepath = "/path/to/file.lua", + line = "42", + linenumber = "42", + remote_url = "", + }, vars) + end) + + it("creates variables table with line range", function() + local vars = formatter.get_variables("/path/to/file.lua", 10, 20, nil) + + assert.same({ + filepath = "/path/to/file.lua", + line = "10-20", + linenumber = "10-20", + remote_url = "", + }, vars) + end) + + it("creates variables table with remote URL", function() + local vars = formatter.get_variables( + "/path/to/file.lua", + 5, + 5, + "https://github.com/user/repo/blob/abc123/file.lua#L5" + ) + + assert.same({ + filepath = "/path/to/file.lua", + line = "5", + linenumber = "5", + remote_url = "https://github.com/user/repo/blob/abc123/file.lua#L5", + }, vars) + end) + + it("handles line_end same as line_start", function() + local vars = formatter.get_variables("/path/to/file.lua", 7, 7, nil) + + assert.same({ + filepath = "/path/to/file.lua", + line = "7", + linenumber = "7", + remote_url = "", + }, vars) + end) + end) + + describe("format", function() + it("replaces variables in format string", function() + local vars = { + filepath = "src/main.lua", + line = "42", + linenumber = "42", + remote_url = "https://github.com/user/repo/blob/abc/main.lua#L42", + } + + local result = formatter.format("# {filepath}:{line}", vars) + assert.equals("# src/main.lua:42", result) + end) + + it("replaces multiple instances of same variable", function() + local vars = { + filepath = "test.lua", + line = "1", + linenumber = "1", + remote_url = "", + } + + local result = formatter.format("{filepath} - {filepath}", vars) + assert.equals("test.lua - test.lua", result) + end) + + it("replaces all available variables", function() + local vars = { + filepath = "file.lua", + line = "10-20", + linenumber = "10-20", + remote_url = "https://example.com", + } + + local result = formatter.format("# {filepath}:{line} - {remote_url}", vars) + assert.equals("# file.lua:10-20 - https://example.com", result) + end) + + it("handles empty remote_url", function() + local vars = { + filepath = "file.lua", + line = "5", + linenumber = "5", + remote_url = "", + } + + local result = formatter.format("# {filepath}:{line} {remote_url}", vars) + assert.equals("# file.lua:5 ", result) + end) + + it("returns empty string for nil format string", function() + local vars = { filepath = "test.lua", line = "1", linenumber = "1", remote_url = "" } + local result = formatter.format(nil, vars) + assert.equals("", result) + end) + + it("leaves unknown variables unchanged", function() + local vars = { + filepath = "test.lua", + line = "1", + linenumber = "1", + remote_url = "", + } + + local result = formatter.format("# {filepath} {unknown}", vars) + assert.equals("# test.lua {unknown}", result) + end) + + it("uses linenumber as alias for line", function() + local vars = { + filepath = "test.lua", + line = "42", + linenumber = "42", + remote_url = "", + } + + local result = formatter.format("# {filepath}:{linenumber}", vars) + assert.equals("# test.lua:42", result) + end) + end) +end) diff --git a/tests/copy_with_context/git_spec.lua b/tests/copy_with_context/git_spec.lua new file mode 100644 index 0000000..dea6f69 --- /dev/null +++ b/tests/copy_with_context/git_spec.lua @@ -0,0 +1,424 @@ +-- Git utilities tests +-- luacheck: globals vim + +-- Set up vim mock before requiring the module +_G.vim = { + fn = {}, + v = { + shell_error = 0, + }, +} + +-- Clear cached modules +package.loaded["copy_with_context.git"] = nil + +local git = require("copy_with_context.git") + +describe("Git utilities", function() + local original_system, original_trim, original_shellescape, original_fnamemodify + + before_each(function() + -- Save originals + original_system = vim.fn.system + original_trim = vim.fn.trim + original_shellescape = vim.fn.shellescape + original_fnamemodify = vim.fn.fnamemodify + + -- Set defaults + vim.v.shell_error = 0 + vim.fn.system = function(_cmd) + return "" + end + vim.fn.trim = function(s) + if not s then + return "" + end + return s:match("^%s*(.-)%s*$") or s + end + vim.fn.shellescape = function(s) + return "'" .. s:gsub("'", "'\\''") .. "'" + end + vim.fn.fnamemodify = function(path, _mod) + return path + end + end) + + after_each(function() + -- Restore originals + vim.fn.system = original_system + vim.fn.trim = original_trim + vim.fn.shellescape = original_shellescape + vim.fn.fnamemodify = original_fnamemodify + end) + + describe("is_git_repo", function() + it("returns true when in a git repository", function() + vim.fn.system = function(_cmd) + return "true\n" + end + vim.v.shell_error = 0 + + local result = git.is_git_repo() + assert.is_true(result) + end) + + it("returns false when not in a git repository", function() + vim.fn.system = function(_cmd) + return "fatal: not a git repository\n" + end + vim.v.shell_error = 128 + + local result = git.is_git_repo() + assert.is_false(result) + end) + end) + + describe("get_remote_url", function() + it("returns origin remote URL", function() + vim.fn.system = function(_cmd) + return "origin\thttps://github.com/user/repo.git (fetch)\norigin\thttps://github.com/user/repo.git (push)\n" + end + vim.v.shell_error = 0 + + local result = git.get_remote_url() + assert.equals("https://github.com/user/repo.git", result) + end) + + it("returns first remote if origin not available", function() + vim.fn.system = function(_cmd) + return "upstream\thttps://github.com/other/repo.git (fetch)\n" + .. "upstream\thttps://github.com/other/repo.git (push)\n" + end + vim.v.shell_error = 0 + + local result = git.get_remote_url() + assert.equals("https://github.com/other/repo.git", result) + end) + + it("returns nil when no remotes available", function() + vim.fn.system = function(_cmd) + return "" + end + vim.v.shell_error = 0 + + local result = git.get_remote_url() + assert.is_nil(result) + end) + + it("returns nil on git error", function() + vim.fn.system = function(_cmd) + return "fatal: not a git repository\n" + end + vim.v.shell_error = 128 + + local result = git.get_remote_url() + assert.is_nil(result) + end) + end) + + describe("parse_remote_url", function() + it("parses HTTPS URL with .git", function() + local result = git.parse_remote_url("https://github.com/user/repo.git") + assert.same({ provider = "github.com", owner = "user", repo = "repo" }, result) + end) + + it("parses HTTP URL with .git", function() + local result = git.parse_remote_url("http://github.com/user/repo.git") + assert.same({ provider = "github.com", owner = "user", repo = "repo" }, result) + end) + + it("parses HTTPS URL without .git", function() + local result = git.parse_remote_url("https://github.com/user/repo") + assert.same({ provider = "github.com", owner = "user", repo = "repo" }, result) + end) + + it("parses HTTP URL without .git", function() + local result = git.parse_remote_url("http://github.com/user/repo") + assert.same({ provider = "github.com", owner = "user", repo = "repo" }, result) + end) + + it("parses SSH URL with .git", function() + local result = git.parse_remote_url("git@github.com:user/repo.git") + assert.same({ provider = "github.com", owner = "user", repo = "repo" }, result) + end) + + it("parses SSH URL without .git", function() + local result = git.parse_remote_url("git@github.com:user/repo") + assert.same({ provider = "github.com", owner = "user", repo = "repo" }, result) + end) + + it("parses git protocol URL with .git", function() + local result = git.parse_remote_url("git://github.com/user/repo.git") + assert.same({ provider = "github.com", owner = "user", repo = "repo" }, result) + end) + + it("parses git protocol URL without .git", function() + local result = git.parse_remote_url("git://github.com/user/repo") + assert.same({ provider = "github.com", owner = "user", repo = "repo" }, result) + end) + + it("parses GitLab URL with nested groups (HTTPS with .git)", function() + local result = git.parse_remote_url("https://gitlab.example.com/frontend/web/dashboard.git") + assert.same({ + provider = "gitlab.example.com", + owner = "frontend/web", + repo = "dashboard", + }, result) + end) + + it("parses GitLab URL with nested groups (SSH with .git)", function() + local result = git.parse_remote_url("git@gitlab.example.com:backend/api/service.git") + assert.same({ + provider = "gitlab.example.com", + owner = "backend/api", + repo = "service", + }, result) + end) + + it("parses GitLab URL with nested groups (HTTPS without .git)", function() + local result = git.parse_remote_url("https://gitlab.company.com/team/subteam/project") + assert.same({ + provider = "gitlab.company.com", + owner = "team/subteam", + repo = "project", + }, result) + end) + + it("parses GitLab URL with nested groups (SSH without .git)", function() + local result = git.parse_remote_url("git@gitlab.company.com:team/subteam/project") + assert.same({ + provider = "gitlab.company.com", + owner = "team/subteam", + repo = "project", + }, result) + end) + + it("parses GitHub Enterprise URL with nested paths", function() + local result = git.parse_remote_url("https://github.enterprise.com/org/team/repo.git") + assert.same({ + provider = "github.enterprise.com", + owner = "org/team", + repo = "repo", + }, result) + end) + + it("parses deeply nested group paths", function() + local result = git.parse_remote_url("git@gitlab.com:group/subgroup/subsubgroup/project.git") + assert.same({ + provider = "gitlab.com", + owner = "group/subgroup/subsubgroup", + repo = "project", + }, result) + end) + + it("returns nil for invalid URL", function() + local result = git.parse_remote_url("invalid-url") + assert.is_nil(result) + end) + + it("returns nil for nil input", function() + local result = git.parse_remote_url(nil) + assert.is_nil(result) + end) + end) + + describe("get_current_commit", function() + it("returns commit SHA", function() + vim.fn.system = function(_cmd) + return "abc123def456\n" + end + vim.v.shell_error = 0 + + local result = git.get_current_commit() + assert.equals("abc123def456", result) + end) + + it("returns nil on git error", function() + vim.fn.system = function(_cmd) + return "fatal: not a git repository\n" + end + vim.v.shell_error = 128 + + local result = git.get_current_commit() + assert.is_nil(result) + end) + end) + + describe("get_file_git_path", function() + it("returns repo-relative path", function() + vim.fn.system = function(_cmd) + return "lua/copy_with_context/git.lua\n" + end + vim.v.shell_error = 0 + + local result = git.get_file_git_path("/home/user/project/lua/copy_with_context/git.lua") + assert.equals("lua/copy_with_context/git.lua", result) + end) + + it("converts relative path to absolute before querying git", function() + vim.fn.system = function(_cmd) + return "lua/test.lua\n" + end + vim.fn.fnamemodify = function(path, _mod) + return "/home/user/project/" .. path + end + vim.v.shell_error = 0 + + local result = git.get_file_git_path("lua/test.lua") + assert.equals("lua/test.lua", result) + end) + + it("converts Windows backslashes to forward slashes", function() + vim.fn.system = function(_cmd) + return "lua\\copy_with_context\\git.lua\n" + end + vim.v.shell_error = 0 + + local result = git.get_file_git_path("C:\\project\\lua\\copy_with_context\\git.lua") + assert.equals("lua/copy_with_context/git.lua", result) + end) + + it("returns nil for untracked file", function() + vim.fn.system = function(_cmd) + return "" + end + vim.v.shell_error = 128 + + local result = git.get_file_git_path("/home/user/project/untracked.lua") + assert.is_nil(result) + end) + end) + + describe("get_git_info", function() + local orig_is_git_repo, orig_get_remote_url, orig_get_current_commit, orig_get_file_git_path + + before_each(function() + -- Save originals + orig_is_git_repo = git.is_git_repo + orig_get_remote_url = git.get_remote_url + orig_get_current_commit = git.get_current_commit + orig_get_file_git_path = git.get_file_git_path + end) + + after_each(function() + -- Restore originals + git.is_git_repo = orig_is_git_repo + git.get_remote_url = orig_get_remote_url + git.get_current_commit = orig_get_current_commit + git.get_file_git_path = orig_get_file_git_path + end) + + it("returns complete git info", function() + -- Mock functions + git.is_git_repo = function() + return true + end + git.get_remote_url = function() + return "https://github.com/user/repo.git" + end + git.get_current_commit = function() + return "abc123def456" + end + git.get_file_git_path = function(_path) + return "lua/file.lua" + end + + local result = git.get_git_info("/home/user/project/lua/file.lua") + + assert.same({ + provider = "github.com", + owner = "user", + repo = "repo", + commit = "abc123def456", + file_path = "lua/file.lua", + }, result) + end) + + it("returns nil when not in git repo", function() + git.is_git_repo = function() + return false + end + + local result = git.get_git_info("/home/user/project/file.lua") + assert.is_nil(result) + end) + + it("returns nil when remote URL not available", function() + git.is_git_repo = function() + return true + end + git.get_remote_url = function() + return nil + end + + local result = git.get_git_info("/home/user/project/file.lua") + assert.is_nil(result) + end) + + it("returns nil when remote URL cannot be parsed", function() + git.is_git_repo = function() + return true + end + git.get_remote_url = function() + return "invalid-url-format" + end + + local result = git.get_git_info("/home/user/project/file.lua") + assert.is_nil(result) + end) + + it("returns nil when commit is not available", function() + git.is_git_repo = function() + return true + end + git.get_remote_url = function() + return "https://github.com/user/repo.git" + end + git.get_current_commit = function() + return nil + end + + local result = git.get_git_info("/home/user/project/file.lua") + assert.is_nil(result) + end) + + it("returns nil when file git path is not available", function() + git.is_git_repo = function() + return true + end + git.get_remote_url = function() + return "https://github.com/user/repo.git" + end + git.get_current_commit = function() + return "abc123" + end + git.get_file_git_path = function(_path) + return nil + end + + local result = git.get_git_info("/home/user/project/file.lua") + assert.is_nil(result) + end) + end) + + describe("get_file_git_path with relative paths", function() + it("converts relative path to absolute before calling git", function() + local fnamemodify_called = false + local mod_value = nil + vim.fn.fnamemodify = function(path, mod) + fnamemodify_called = true + mod_value = mod + return "/home/user/project/" .. path + end + vim.fn.system = function(_cmd) + return "lua/file.lua\n" + end + vim.v.shell_error = 0 + + local result = git.get_file_git_path("lua/file.lua") + assert.is_true(fnamemodify_called) + assert.equals(":p", mod_value) + assert.equals("lua/file.lua", result) + end) + end) +end) diff --git a/tests/copy_with_context/main_spec.lua b/tests/copy_with_context/main_spec.lua index a24349d..8037deb 100644 --- a/tests/copy_with_context/main_spec.lua +++ b/tests/copy_with_context/main_spec.lua @@ -13,10 +13,14 @@ local stub = require("luassert.stub") package.loaded["copy_with_context.main"] = nil package.loaded["copy_with_context.utils"] = nil package.loaded["copy_with_context.config"] = nil +package.loaded["copy_with_context.formatter"] = nil +package.loaded["copy_with_context.url_builder"] = nil local main = require("copy_with_context.main") local utils = require("copy_with_context.utils") local config = require("copy_with_context.config") +local formatter = require("copy_with_context.formatter") +local url_builder = require("copy_with_context.url_builder") describe("Main Module", function() before_each(function() @@ -24,9 +28,15 @@ describe("Main Module", function() stub(utils, "get_lines").returns({ "line 1", "line 2" }, 1, 2) stub(utils, "process_lines").returns({ "line 1", "line 2" }) stub(utils, "get_file_path").returns("/fake/path.lua") - stub(utils, "format_line_range").returns("1-2") - stub(utils, "format_output").returns("Processed output") stub(utils, "copy_to_clipboard") + stub(formatter, "get_variables").returns({ + filepath = "/fake/path.lua", + line = "1-2", + linenumber = "1-2", + remote_url = "", + }) + stub(formatter, "format").returns("# /fake/path.lua:1-2") + stub(url_builder, "build_url").returns(nil) stub(vim.api, "nvim_echo") stub(vim.keymap, "set") end) @@ -36,43 +46,110 @@ describe("Main Module", function() utils.get_lines:revert() utils.process_lines:revert() utils.get_file_path:revert() - utils.format_line_range:revert() - utils.format_output:revert() utils.copy_to_clipboard:revert() + formatter.get_variables:revert() + formatter.format:revert() + url_builder.build_url:revert() vim.api.nvim_echo:revert() vim.keymap.set:revert() end) - it("copies content with context (relative path, non-visual mode)", function() - main.copy_with_context(false, false) + it("copies content with context (relative mapping, non-visual mode)", function() + main.copy_with_context("relative", false) assert.stub(utils.get_lines).was_called_with(false) assert.stub(utils.process_lines).was_called_with({ "line 1", "line 2" }) assert.stub(utils.get_file_path).was_called_with(false) - assert.stub(utils.format_line_range).was_called_with(1, 2) - assert.stub(utils.format_output).was_called_with("line 1\nline 2", "/fake/path.lua", "1-2") + assert.stub(formatter.get_variables).was_called() + assert.stub(formatter.format).was_called() + assert.stub(utils.copy_to_clipboard).was_called() assert.stub(vim.api.nvim_echo).was_called() end) - it("copies content with context (absolute path, visual mode)", function() - main.copy_with_context(true, true) + it("copies content with context (absolute mapping, visual mode)", function() + main.copy_with_context("absolute", true) assert.stub(utils.get_lines).was_called_with(true) assert.stub(utils.get_file_path).was_called_with(true) - assert.stub(utils.format_output).was_called() + assert.stub(formatter.get_variables).was_called() + assert.stub(formatter.format).was_called() assert.stub(utils.copy_to_clipboard).was_called() assert.stub(vim.api.nvim_echo).was_called() end) - it("sets up key mappings", function() + it("copies content with custom mapping", function() + -- Add custom mapping to config + config.options.mappings.custom = "cc" + config.options.formats.custom = "# {filepath}" + + main.copy_with_context("custom", false) + + assert.stub(utils.get_lines).was_called_with(false) + assert.stub(utils.process_lines).was_called_with({ "line 1", "line 2" }) + assert.stub(utils.get_file_path).was_called_with(false) + assert.stub(formatter.get_variables).was_called() + assert.stub(formatter.format).was_called_with("# {filepath}", match._) + assert.stub(utils.copy_to_clipboard).was_called() + + -- Cleanup + config.options.mappings.custom = nil + config.options.formats.custom = nil + end) + + it("fetches remote URL only when format uses it", function() + -- Add remote mapping that uses {remote_url} + config.options.mappings.remote = "cr" + config.options.formats.remote = "# {remote_url}" + + url_builder.build_url:revert() + stub(url_builder, "build_url").returns( + "https://github.com/user/repo/blob/abc123/path.lua#L1-L2" + ) + + main.copy_with_context("remote", false) + + -- Should call build_url because format uses {remote_url} + assert.stub(url_builder.build_url).was_called() + + -- Cleanup + config.options.mappings.remote = nil + config.options.formats.remote = nil + end) + + it("does not fetch remote URL when format doesn't use it", function() + main.copy_with_context("relative", false) + + -- Should not call build_url because default format doesn't use {remote_url} + assert.stub(url_builder.build_url).was_not_called() + end) + + it("handles missing format gracefully", function() + -- Add mapping without corresponding format to simulate edge case + config.options.mappings.missing = "cm" + -- Don't add format for it (this would normally be caught by validation) + + -- This should not error, just use nil format_string + main.copy_with_context("missing", false) + + -- Should not call build_url because format_string is nil + assert.stub(url_builder.build_url).was_not_called() + + -- Cleanup + config.options.mappings.missing = nil + end) + + it("sets up key mappings for all defined mappings", function() main.setup() + -- Should set up normal mode mappings assert .stub(vim.keymap.set) .was_called_with("n", config.options.mappings.relative, match._, { silent = false }) assert .stub(vim.keymap.set) .was_called_with("n", config.options.mappings.absolute, match._, { silent = false }) + + -- Should set up visual mode mappings assert .stub(vim.keymap.set) .was_called_with("x", config.options.mappings.relative, match._, { silent = true }) @@ -80,4 +157,20 @@ describe("Main Module", function() .stub(vim.keymap.set) .was_called_with("x", config.options.mappings.absolute, match._, { silent = true }) end) + + it("sets up key mappings for custom mappings", function() + -- Add custom mapping + config.options.mappings.custom = "cc" + config.options.formats.custom = "# {filepath}" + + main.setup() + + -- Should set up mappings for custom mapping too + assert.stub(vim.keymap.set).was_called_with("n", "cc", match._, { silent = false }) + assert.stub(vim.keymap.set).was_called_with("x", "cc", match._, { silent = true }) + + -- Cleanup + config.options.mappings.custom = nil + config.options.formats.custom = nil + end) end) diff --git a/tests/copy_with_context/providers/bitbucket_spec.lua b/tests/copy_with_context/providers/bitbucket_spec.lua new file mode 100644 index 0000000..8ea3405 --- /dev/null +++ b/tests/copy_with_context/providers/bitbucket_spec.lua @@ -0,0 +1,78 @@ +-- Bitbucket provider tests + +-- Clear cached modules +package.loaded["copy_with_context.providers.bitbucket"] = nil + +local bitbucket = require("copy_with_context.providers.bitbucket") + +describe("Bitbucket provider", function() + describe("matches", function() + it("matches bitbucket.org", function() + assert.is_true(bitbucket.matches("bitbucket.org")) + end) + + it("matches Bitbucket Server domains", function() + assert.is_true(bitbucket.matches("code.bitbucket.org")) + assert.is_true(bitbucket.matches("enterprise.bitbucket.org")) + end) + + it("does not match non-Bitbucket domains", function() + assert.is_false(bitbucket.matches("github.com")) + assert.is_false(bitbucket.matches("gitlab.com")) + assert.is_false(bitbucket.matches("example.com")) + end) + end) + + describe("build_url", function() + local git_info = { + provider = "bitbucket.org", + owner = "user", + repo = "repo", + commit = "abc123def456", + file_path = "lua/file.lua", + } + + it("builds URL for single line", function() + local url = bitbucket.build_url(git_info, 42, 42) + assert.equals("https://bitbucket.org/user/repo/src/abc123def456/lua/file.lua#lines-42", url) + end) + + it("builds URL for multiple lines", function() + local url = bitbucket.build_url(git_info, 10, 20) + assert.equals( + "https://bitbucket.org/user/repo/src/abc123def456/lua/file.lua#lines-10:20", + url + ) + end) + + it("builds URL for Bitbucket Enterprise", function() + local enterprise_info = { + provider = "bitbucket.example.com", + owner = "team", + repo = "project", + commit = "xyz789", + file_path = "src/main.rb", + } + local url = bitbucket.build_url(enterprise_info, 5, 5) + assert.equals( + "https://bitbucket.example.com/team/project/src/xyz789/src/main.rb#lines-5", + url + ) + end) + + it("builds URL for Bitbucket with nested project keys", function() + local nested_info = { + provider = "bitbucket.org", + owner = "company/engineering", + repo = "api", + commit = "abc123", + file_path = "handlers/auth.go", + } + local url = bitbucket.build_url(nested_info, 100, 120) + assert.equals( + "https://bitbucket.org/company/engineering/api/src/abc123/handlers/auth.go#lines-100:120", + url + ) + end) + end) +end) diff --git a/tests/copy_with_context/providers/github_spec.lua b/tests/copy_with_context/providers/github_spec.lua new file mode 100644 index 0000000..c4c37a4 --- /dev/null +++ b/tests/copy_with_context/providers/github_spec.lua @@ -0,0 +1,72 @@ +-- GitHub provider tests + +-- Clear cached modules +package.loaded["copy_with_context.providers.github"] = nil + +local github = require("copy_with_context.providers.github") + +describe("GitHub provider", function() + describe("matches", function() + it("matches github.com", function() + assert.is_true(github.matches("github.com")) + end) + + it("matches GitHub Enterprise domains", function() + assert.is_true(github.matches("github.example.com")) + assert.is_true(github.matches("code.github.com")) + end) + + it("does not match non-GitHub domains", function() + assert.is_false(github.matches("gitlab.com")) + assert.is_false(github.matches("bitbucket.org")) + assert.is_false(github.matches("example.com")) + end) + end) + + describe("build_url", function() + local git_info = { + provider = "github.com", + owner = "user", + repo = "repo", + commit = "abc123def456", + file_path = "lua/file.lua", + } + + it("builds URL for single line", function() + local url = github.build_url(git_info, 42, 42) + assert.equals("https://github.com/user/repo/blob/abc123def456/lua/file.lua#L42", url) + end) + + it("builds URL for multiple lines", function() + local url = github.build_url(git_info, 10, 20) + assert.equals("https://github.com/user/repo/blob/abc123def456/lua/file.lua#L10-L20", url) + end) + + it("builds URL for GitHub Enterprise", function() + local enterprise_info = { + provider = "github.example.com", + owner = "user", + repo = "repo", + commit = "abc123", + file_path = "src/main.js", + } + local url = github.build_url(enterprise_info, 5, 5) + assert.equals("https://github.example.com/user/repo/blob/abc123/src/main.js#L5", url) + end) + + it("builds URL for GitHub with nested paths (org structure)", function() + local nested_info = { + provider = "github.com", + owner = "myorg/team", + repo = "project", + commit = "def456abc", + file_path = "packages/core/index.ts", + } + local url = github.build_url(nested_info, 15, 25) + assert.equals( + "https://github.com/myorg/team/project/blob/def456abc/packages/core/index.ts#L15-L25", + url + ) + end) + end) +end) diff --git a/tests/copy_with_context/providers/gitlab_spec.lua b/tests/copy_with_context/providers/gitlab_spec.lua new file mode 100644 index 0000000..71d5b76 --- /dev/null +++ b/tests/copy_with_context/providers/gitlab_spec.lua @@ -0,0 +1,81 @@ +-- GitLab provider tests + +-- Clear cached modules +package.loaded["copy_with_context.providers.gitlab"] = nil + +local gitlab = require("copy_with_context.providers.gitlab") + +describe("GitLab provider", function() + describe("matches", function() + it("matches gitlab.com", function() + assert.is_true(gitlab.matches("gitlab.com")) + end) + + it("matches self-hosted GitLab domains", function() + assert.is_true(gitlab.matches("gitlab.example.com")) + assert.is_true(gitlab.matches("mygitlab.company.com")) + end) + end) + + describe("build_url", function() + local git_info = { + provider = "gitlab.com", + owner = "user", + repo = "repo", + commit = "abc123def456", + file_path = "lua/file.lua", + } + + it("builds URL for single line", function() + local url = gitlab.build_url(git_info, 42, 42) + assert.equals("https://gitlab.com/user/repo/-/blob/abc123def456/lua/file.lua#L42", url) + end) + + it("builds URL for multiple lines", function() + local url = gitlab.build_url(git_info, 10, 20) + assert.equals("https://gitlab.com/user/repo/-/blob/abc123def456/lua/file.lua#L10-20", url) + end) + + it("builds URL for self-hosted GitLab", function() + local selfhosted_info = { + provider = "gitlab.example.com", + owner = "team", + repo = "project", + commit = "xyz789", + file_path = "src/main.py", + } + local url = gitlab.build_url(selfhosted_info, 5, 5) + assert.equals("https://gitlab.example.com/team/project/-/blob/xyz789/src/main.py#L5", url) + end) + + it("builds URL for nested groups (GitLab)", function() + local nested_info = { + provider = "gitlab.example.com", + owner = "frontend/web", + repo = "dashboard", + commit = "abc123def456", + file_path = "src/components/App.tsx", + } + local url = gitlab.build_url(nested_info, 42, 42) + assert.equals( + "https://gitlab.example.com/frontend/web/dashboard/-/blob/abc123def456/src/components/App.tsx#L42", + url + ) + end) + + it("builds URL for deeply nested groups", function() + local deeply_nested_info = { + provider = "gitlab.company.com", + owner = "org/team/subteam", + repo = "service", + commit = "xyz789abc", + file_path = "lib/utils.js", + } + local url = gitlab.build_url(deeply_nested_info, 10, 20) + assert.equals( + "https://gitlab.company.com/org/team/subteam/service/-/blob/xyz789abc/lib/utils.js#L10-20", + url + ) + end) + end) +end) diff --git a/tests/copy_with_context/providers/init_spec.lua b/tests/copy_with_context/providers/init_spec.lua new file mode 100644 index 0000000..278d839 --- /dev/null +++ b/tests/copy_with_context/providers/init_spec.lua @@ -0,0 +1,80 @@ +-- Provider detection and factory tests + +-- Clear cached modules +package.loaded["copy_with_context.providers.init"] = nil +package.loaded["copy_with_context.providers.github"] = nil +package.loaded["copy_with_context.providers.gitlab"] = nil +package.loaded["copy_with_context.providers.bitbucket"] = nil + +local providers = require("copy_with_context.providers") + +describe("Provider detection and factory", function() + describe("detect_provider", function() + it("detects GitHub provider", function() + local provider = providers.detect_provider("github.com") + assert.is_not_nil(provider) + assert.equals("github", provider.name) + end) + + it("detects GitHub Enterprise provider", function() + local provider = providers.detect_provider("github.example.com") + assert.is_not_nil(provider) + assert.equals("github", provider.name) + end) + + it("detects GitLab provider", function() + local provider = providers.detect_provider("gitlab.com") + assert.is_not_nil(provider) + assert.equals("gitlab", provider.name) + end) + + it("detects Bitbucket provider", function() + local provider = providers.detect_provider("bitbucket.org") + assert.is_not_nil(provider) + assert.equals("bitbucket", provider.name) + end) + + it("returns nil for unknown domains", function() + local provider = providers.detect_provider("unknown.example.com") + assert.is_nil(provider) + end) + + it("returns nil for nil domain", function() + local provider = providers.detect_provider(nil) + assert.is_nil(provider) + end) + end) + + describe("get_provider", function() + it("returns provider from git info", function() + local git_info = { + provider = "github.com", + owner = "user", + repo = "repo", + commit = "abc123", + file_path = "lua/file.lua", + } + + local provider = providers.get_provider(git_info) + assert.is_not_nil(provider) + assert.equals("github", provider.name) + end) + + it("returns nil for nil git info", function() + local provider = providers.get_provider(nil) + assert.is_nil(provider) + end) + + it("returns nil for git info without provider", function() + local git_info = { + owner = "user", + repo = "repo", + commit = "abc123", + file_path = "lua/file.lua", + } + + local provider = providers.get_provider(git_info) + assert.is_nil(provider) + end) + end) +end) diff --git a/tests/copy_with_context/url_builder_spec.lua b/tests/copy_with_context/url_builder_spec.lua new file mode 100644 index 0000000..0ad4bcb --- /dev/null +++ b/tests/copy_with_context/url_builder_spec.lua @@ -0,0 +1,184 @@ +local url_builder = require("copy_with_context.url_builder") + +describe("URL Builder", function() + before_each(function() + -- Clear module cache + package.loaded["copy_with_context.git"] = nil + package.loaded["copy_with_context.providers"] = nil + end) + + describe("build_url", function() + it("returns URL when git info and provider are available", function() + local git_mock = { + get_git_info = function(_file_path) + return { + provider = "github.com", + owner = "user", + repo = "repo", + commit = "abc123", + file_path = "lua/test.lua", + } + end, + } + package.loaded["copy_with_context.git"] = git_mock + + local provider_mock = { + build_url = function(_git_info, _start, _end) + return "https://github.com/user/repo/blob/abc123/lua/test.lua#L10-L20" + end, + } + local providers_mock = { + get_provider = function(_git_info) + return provider_mock + end, + } + package.loaded["copy_with_context.providers"] = providers_mock + + local url = url_builder.build_url("lua/test.lua", 10, 20) + assert.equals("https://github.com/user/repo/blob/abc123/lua/test.lua#L10-L20", url) + end) + + it("returns nil when git info is not available", function() + local git_mock = { + get_git_info = function(_file_path) + return nil + end, + } + package.loaded["copy_with_context.git"] = git_mock + + local url = url_builder.build_url("lua/test.lua", 10, 20) + assert.is_nil(url) + end) + + it("returns nil when provider is not available", function() + local git_mock = { + get_git_info = function(_file_path) + return { + provider = "unknown.com", + owner = "user", + repo = "repo", + commit = "abc123", + file_path = "lua/test.lua", + } + end, + } + package.loaded["copy_with_context.git"] = git_mock + + local providers_mock = { + get_provider = function(_git_info) + return nil -- Unknown provider + end, + } + package.loaded["copy_with_context.providers"] = providers_mock + + local url = url_builder.build_url("lua/test.lua", 10, 20) + assert.is_nil(url) + end) + + it("returns nil when provider build_url returns nil", function() + local git_mock = { + get_git_info = function(_file_path) + return { + provider = "github.com", + owner = "user", + repo = "repo", + commit = "abc123", + file_path = "lua/test.lua", + } + end, + } + package.loaded["copy_with_context.git"] = git_mock + + local provider_mock = { + build_url = function(_git_info, _start, _end) + return nil -- Provider failed to build URL + end, + } + local providers_mock = { + get_provider = function(_git_info) + return provider_mock + end, + } + package.loaded["copy_with_context.providers"] = providers_mock + + local url = url_builder.build_url("lua/test.lua", 10, 20) + assert.is_nil(url) + end) + + it("handles single line numbers", function() + local git_mock = { + get_git_info = function(_file_path) + return { + provider = "github.com", + owner = "user", + repo = "repo", + commit = "abc123", + file_path = "lua/test.lua", + } + end, + } + package.loaded["copy_with_context.git"] = git_mock + + local captured_start, captured_end + local provider_mock = { + build_url = function(_git_info, start, end_line) + captured_start = start + captured_end = end_line + return "https://github.com/user/repo/blob/abc123/lua/test.lua#L42" + end, + } + local providers_mock = { + get_provider = function(_git_info) + return provider_mock + end, + } + package.loaded["copy_with_context.providers"] = providers_mock + + local url = url_builder.build_url("lua/test.lua", 42, 42) + assert.equals(42, captured_start) + assert.equals(42, captured_end) + assert.equals("https://github.com/user/repo/blob/abc123/lua/test.lua#L42", url) + end) + + it("passes correct parameters to provider", function() + local git_mock = { + get_git_info = function(_file_path) + return { + provider = "gitlab.com", + owner = "user", + repo = "repo", + commit = "def456", + file_path = "src/main.lua", + } + end, + } + package.loaded["copy_with_context.git"] = git_mock + + local captured_git_info, captured_start, captured_end + local provider_mock = { + build_url = function(git_info, start, end_line) + captured_git_info = git_info + captured_start = start + captured_end = end_line + return "https://gitlab.com/user/repo/-/blob/def456/src/main.lua#L5-10" + end, + } + local providers_mock = { + get_provider = function(_git_info) + return provider_mock + end, + } + package.loaded["copy_with_context.providers"] = providers_mock + + url_builder.build_url("src/main.lua", 5, 10) + + assert.equals("gitlab.com", captured_git_info.provider) + assert.equals("user", captured_git_info.owner) + assert.equals("repo", captured_git_info.repo) + assert.equals("def456", captured_git_info.commit) + assert.equals("src/main.lua", captured_git_info.file_path) + assert.equals(5, captured_start) + assert.equals(10, captured_end) + end) + end) +end) diff --git a/tests/copy_with_context/user_config_validation_spec.lua b/tests/copy_with_context/user_config_validation_spec.lua new file mode 100644 index 0000000..bede0c8 --- /dev/null +++ b/tests/copy_with_context/user_config_validation_spec.lua @@ -0,0 +1,198 @@ +local validation = require("copy_with_context.user_config_validation") + +describe("User Config Validation", function() + describe("validate", function() + it("accepts nil config", function() + local valid, err = validation.validate(nil) + assert.is_true(valid) + assert.is_nil(err) + end) + + it("accepts empty config", function() + local valid, err = validation.validate({}) + assert.is_true(valid) + assert.is_nil(err) + end) + + it("accepts valid config with default mapping", function() + local config = { + mappings = { + relative = "cy", + absolute = "cY", + }, + formats = { + default = "# {filepath}:{line}", + }, + } + + local valid, err = validation.validate(config) + assert.is_true(valid) + assert.is_nil(err) + end) + + it("accepts valid config with custom mappings", function() + local config = { + mappings = { + relative = "cy", + custom = "cc", + }, + formats = { + default = "# {filepath}:{line}", + custom = "# {remote_url}", + }, + } + + local valid, err = validation.validate(config) + assert.is_true(valid) + assert.is_nil(err) + end) + + it("rejects mapping without matching format", function() + local config = { + mappings = { + custom = "cc", + }, + formats = { + default = "# {filepath}:{line}", + -- missing 'custom' format + }, + } + + local valid, err = validation.validate(config) + assert.is_false(valid) + assert.is_not_nil(err) + assert.matches("custom", err) + end) + + it("rejects format without matching mapping", function() + local config = { + mappings = { + relative = "cy", + }, + formats = { + default = "# {filepath}:{line}", + orphan = "# {filepath}", + }, + } + + local valid, err = validation.validate(config) + assert.is_false(valid) + assert.is_not_nil(err) + assert.matches("orphan", err) + end) + + it("requires default format for relative mapping", function() + local config = { + mappings = { + relative = "cy", + }, + formats = { + -- missing default format + }, + } + + local valid, err = validation.validate(config) + assert.is_false(valid) + assert.is_not_nil(err) + assert.matches("relative", err) + assert.matches("default", err) + end) + + it("requires default format for absolute mapping", function() + local config = { + mappings = { + absolute = "cY", + }, + formats = { + -- missing default format + }, + } + + local valid, err = validation.validate(config) + assert.is_false(valid) + assert.is_not_nil(err) + assert.matches("absolute", err) + assert.matches("default", err) + end) + + it("allows default format without explicit mapping", function() + local config = { + mappings = { + relative = "cy", + }, + formats = { + default = "# {filepath}:{line}", + }, + } + + local valid, err = validation.validate(config) + assert.is_true(valid) + assert.is_nil(err) + end) + end) + + describe("validate_format_string", function() + it("accepts valid format with filepath", function() + local valid, err = validation.validate_format_string("# {filepath}") + assert.is_true(valid) + assert.is_nil(err) + end) + + it("accepts valid format with line", function() + local valid, err = validation.validate_format_string("# {line}") + assert.is_true(valid) + assert.is_nil(err) + end) + + it("accepts valid format with linenumber", function() + local valid, err = validation.validate_format_string("# {linenumber}") + assert.is_true(valid) + assert.is_nil(err) + end) + + it("accepts valid format with remote_url", function() + local valid, err = validation.validate_format_string("# {remote_url}") + assert.is_true(valid) + assert.is_nil(err) + end) + + it("accepts valid format with multiple variables", function() + local valid, err = validation.validate_format_string("# {filepath}:{line} - {remote_url}") + assert.is_true(valid) + assert.is_nil(err) + end) + + it("accepts format with no variables", function() + local valid, err = validation.validate_format_string("# No variables here") + assert.is_true(valid) + assert.is_nil(err) + end) + + it("rejects nil format string", function() + local valid, err = validation.validate_format_string(nil) + assert.is_false(valid) + assert.is_not_nil(err) + end) + + it("rejects unknown variable", function() + local valid, err = validation.validate_format_string("# {invalid_var}") + assert.is_false(valid) + assert.is_not_nil(err) + assert.matches("invalid_var", err) + end) + + it("rejects format with multiple unknown variables", function() + local valid, err = validation.validate_format_string("# {filepath} {unknown1} {unknown2}") + assert.is_false(valid) + assert.is_not_nil(err) + -- Should error on first unknown variable + assert.matches("unknown", err) + end) + + it("accepts repeated valid variables", function() + local valid, err = validation.validate_format_string("# {filepath} - {filepath}") + assert.is_true(valid) + assert.is_nil(err) + end) + end) +end) diff --git a/tests/copy_with_context/utils_spec.lua b/tests/copy_with_context/utils_spec.lua index 06b089b..384cbfe 100644 --- a/tests/copy_with_context/utils_spec.lua +++ b/tests/copy_with_context/utils_spec.lua @@ -91,18 +91,6 @@ describe("Utility Functions", function() end) end) - describe("format_line_range", function() - it("returns a single line number when start and end are the same", function() - local result = utils.format_line_range(5, 5) - assert.equals("5", result) - end) - - it("returns a range when start and end are different", function() - local result = utils.format_line_range(2, 6) - assert.equals("2-6", result) - end) - end) - describe("process_lines", function() local config_mock = { options = { trim_lines = false }, @@ -136,21 +124,4 @@ describe("Utility Functions", function() assert.equals("copied text", setreg_calls["+"]) end) end) - - describe("format_output", function() - local config_mock = { - options = { - context_format = "-- %s (lines: %s)", - }, - } - - before_each(function() - package.loaded["copy_with_context.config"] = config_mock - end) - - it("formats output correctly", function() - local result = utils.format_output("content here", "file.lua", "5-10") - assert.equals("content here\n-- file.lua (lines: 5-10)", result) - end) - end) end)