diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..a4a72ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,41 @@ +name: Bug report +description: Report a reproducible problem in fishmarks +title: "bug: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug. + - type: input + id: fish-version + attributes: + label: Fish version + placeholder: fish, version 4.0.6 + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Provide exact commands and inputs. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional context + description: Logs, screenshots, and environment details. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..51c918f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability report + url: https://github.com/techwizrd/fishmarks/security/advisories/new + about: Please report security issues privately. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..b42c76b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,32 @@ +name: Feature request +description: Propose an improvement for fishmarks +title: "feat: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Thanks for the idea. + - type: textarea + id: problem + attributes: + label: Problem statement + description: What user problem does this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe expected behavior and UX. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: textarea + id: context + attributes: + label: Additional context diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..dfe95c9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## Summary + +- + +## Why + +- + +## Changes + +- + +## Validation + +- [ ] `fish tests/check.fish` +- [ ] `fish tests/run.fish` + +## Checklist + +- [ ] Tests added or updated for behavior changes +- [ ] Docs updated when setup or usage changed +- [ ] No breaking changes, or migration path documented diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1e2fc69 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + labels: + - dependencies + - ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e31c497 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Show fish version + uses: fish-actions/install-fish@v1 + + - name: Print fish version + run: fish --version + + - name: Validate fish scripts + run: fish tests/check.fish + + - name: Run test suite + run: fish tests/run.fish + + - name: Smoke test installer + if: matrix.os == 'ubuntu-latest' + run: | + TEST_HOME=$(mktemp -d) + HOME="$TEST_HOME" fish install.fish + test -f "$TEST_HOME/.config/fish/conf.d/fishmarks.fish" + + fisher-smoke: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install fish + uses: fish-actions/install-fish@v1 + + - name: Install plugin with fisher + uses: fish-actions/fisher@v1 + with: + plugins: $GITHUB_WORKSPACE + + - name: Verify plugin commands load + run: | + type -q save_bookmark + type -q go_to_bookmark + type -q fishmarks_version + shell: fish {0} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ccb6202 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,27 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create source archive + run: | + git archive --format=tar.gz --output="fishmarks-${GITHUB_REF_NAME}.tar.gz" HEAD + + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: fishmarks-${{ github.ref_name }}.tar.gz diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0bba78b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-merge-conflict + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-added-large-files + + - repo: local + hooks: + - id: fish-check + name: fish syntax and formatting + entry: fish tests/check.fish + language: system + files: \.fish$ + - id: fish-tests + name: fishmarks behavior tests + entry: fish tests/run.fish + language: system + pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2cac49e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Fish plugin packaging with `functions/`, `conf.d/`, and `completions/` layout. +- Automated test suite (`tests/run.fish`) and style/syntax checks (`tests/check.fish`). +- GitHub Actions CI workflow and pre-commit/prek hook configuration. +- Contributor policy docs and GitHub issue/PR templates. +- `fishmarks_version` command to report plugin version. + +### Changed + +- Core bookmark handling refactored to fish-native parsing and safer file processing. +- Installer updated for Fish 3+ and modern startup integration via `conf.d`. +- `save_bookmark` now rejects paths containing newline or carriage-return characters. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..12c4d80 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,29 @@ +# Code of Conduct + +## Our pledge + +We want fishmarks to be a welcoming, inclusive, and respectful community for everyone. + +## Our standards + +Examples of behavior that contributes to a positive environment: + +- Being respectful and considerate in communication +- Giving and accepting constructive feedback +- Focusing on what is best for the community and users + +Examples of unacceptable behavior: + +- Harassment or personal attacks +- Discriminatory language or conduct +- Trolling, insulting, or deliberately disruptive behavior + +## Scope + +This Code of Conduct applies in project spaces, including issues, pull requests, and discussions. + +## Enforcement + +Project maintainers are responsible for clarifying and enforcing this Code of Conduct and may remove, edit, or reject comments, commits, code, issues, and other contributions that are not aligned with it. + +To report concerns, open a private security advisory or contact the maintainers through repository administrators. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3a2208a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing to fishmarks + +Thanks for contributing. + +## Development setup + +1. Fork and clone the repository. +2. Create a feature branch from `master`. +3. Install Fish 3+. +4. Install local hooks: + +```fish +prek install +``` + +## Validate changes locally + +Run checks before committing: + +```fish +fish tests/check.fish +fish tests/run.fish +``` + +Shortcut: + +```sh +make test +``` + +Or run all hooks: + +```fish +prek run --all-files +``` + +## Pull requests + +- Keep PRs focused and small when possible. +- Include tests for behavior changes. +- Update docs when usage or setup changes. +- Use clear commit messages that explain why the change is needed. + +## Compatibility policy + +- The project targets Fish 3+. +- Backward compatibility with existing bookmark storage (`~/.sdirs`) should be preserved unless a migration path is documented. + +## Versioning and changelog + +- Releases follow Semantic Versioning. +- User-visible changes should be added to `CHANGELOG.md` under `Unreleased`. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..057875a --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: check test + +check: + fish tests/check.fish + +test: check + fish tests/run.fish diff --git a/README.md b/README.md index cf67a51..432e96f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # fishmarks +[![CI](https://github.com/techwizrd/fishmarks/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/techwizrd/fishmarks/actions/workflows/ci.yml?query=branch%3Amaster) +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) +[![SemVer](https://img.shields.io/badge/versioning-semver-3f4551)](https://semver.org/) +[![Fish 3+](https://img.shields.io/badge/fish-3%2B-4AAE46)](https://fishshell.com/) + Fishmarks is a clone of [bashmarks](https://github.com/huyng/bashmarks) for the -[Fish shell](http://fishshell.com/). Fishmarks is compatible with your existing +[Fish shell](https://fishshell.com/). Fishmarks is compatible with your existing bashmarks and bookmarks added using fishmarks are also available in bashmarks. ## Installation @@ -10,10 +15,21 @@ bashmarks and bookmarks added using fishmarks are also available in bashmarks. To install fishmarks automatically, paste the following in your terminal. ```fish -curl -L https://github.com/techwizrd/fishmarks/raw/master/install.fish | fish +curl -fsSL https://raw.githubusercontent.com/techwizrd/fishmarks/master/install.fish | fish ``` -Please note, however, that you should _never_ install things by piping untrusted "install" scripts downloaded through ``curl`` directly into your shell (be it ``bash`` or ``fish``). Even if you read through the install script and think you understand it, you could be prone to a [man-in-the-middle](http://en.wikipedia.org/wiki/Man-in-the-middle_attack) attack or any number of security vulnerabilities. While manual installations are tedious, they are recommended for any situations where security is a concern (and it should almost always be a concern). +Please note, however, that you should _never_ install things by piping untrusted "install" scripts downloaded through ``curl`` directly into your shell (be it ``bash`` or ``fish``). Even if you read through the install script and think you understand it, you could be prone to a [man-in-the-middle](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) attack or any number of security vulnerabilities. While manual installations are tedious, they are recommended for any situations where security is a concern (and it should almost always be a concern). + +### Fisher Installation + +If you use [Fisher](https://github.com/jorgebucaran/fisher), install fishmarks with: + +```fish +fisher install techwizrd/fishmarks +``` + +Fish plugin managers load fishmarks from `functions/`, `conf.d/`, and `completions/`. +The top-level `marks.fish` file remains as a compatibility loader for existing manual installs. ### Manual Installation @@ -22,15 +38,14 @@ To install fishmarks manually: 1. Clone fishmarks into `~/.fishmarks` ```fish -$ git clone http://github.com/techwizrd/fishmarks.git +$ git clone https://github.com/techwizrd/fishmarks.git ~/.fishmarks ``` -2. Source `fishmarks/marks.fish` in your `config.fish` by inserting the - following into your `~/.config/fish/config.fish` +2. Source `~/.fishmarks/marks.fish` from a file in `~/.config/fish/conf.d/` ```fish -# Load fishmarks (http://github.com/techwizrd/fishmarks) -source ~/.fishmarks/marks.fish +mkdir -p ~/.config/fish/conf.d +printf '# Load fishmarks (https://github.com/techwizrd/fishmarks)\nsource ~/.fishmarks/marks.fish\n' > ~/.config/fish/conf.d/fishmarks.fish ``` ### Update to the latest version @@ -46,10 +61,10 @@ cd ~/.fishmarks ```fish git fetch --all ``` -3. Remove any changes and force update to the latest version from the Git repository: +3. Pull the latest version: ```fish -git reset --hard origin/master +git pull --ff-only ``` @@ -62,11 +77,12 @@ s - Saves the current directory as "bookmark_name" g - Goes (cd) to the directory associated with "bookmark_name" p - Prints the directory associated with "bookmark_name" d - Deletes the bookmark -l - Lists all available bookmarks' +l - Lists all available bookmarks +fishmarks_version - Prints the installed fishmarks version ``` ### Configuration Variables -All of these must be set before `virtual.fish` is sourced in your `~/.config/fish/config.fish`. +All of these must be set before `marks.fish` is sourced in your fish startup files. * `SDIRS` - (default: `~/.sdirs`) where all your bookmarks are kept. * `NO_FISHMARKS_COMPAT_ALIASES` - set this to turn off the bashmark-compatible aliases (e.g., `p` for `print_bookmark`) @@ -85,26 +101,63 @@ webfolder /var/www [/var/www]$ ``` -### Contributing +## Running tests + +Run the test suite with: + +```fish +fish tests/run.fish +``` + +Run syntax and formatting checks with: + +```fish +fish tests/check.fish +``` + +Or with make: -*Have you noticed any bugs or issues with fishmarks? Do you have any features you would like to see added?* +```sh +make check +``` + +Run all checks and tests with: + +```sh +make test +``` + +## Development workflow + +Install [prek](https://prek.j178.dev/latest/) (or pre-commit) hooks locally: + +```fish +prek install +``` -1. [Check on Github](https://github.com/techwizrd/fishmarks/issues?state=open) to see whether anyone else has encountered the same issue or has the same feature request. If someone someone has encountered the same issue or has the same feature request, comment to let me know that it affects you too. -2. If no one has encountered the same issue, [file an issue](https://github.com/techwizrd/fishmarks/issues?state=open) on Github with the "bug" label, your operating system version, fish shell version, clear steps describing how to reproduce the error. If no one has requested the same feature, [file an issue](https://github.com/techwizrd/fishmarks/issues?state=open) on Github with the "enhancement" label and a brief, clear description of your feature and why it would make a great addition to fishmarks. -3. Once you have filed the issue, if you would like to fix the issue or add the feature yourself, assign the issue to yourself on Github and fork the repository. Clone your fork and make your changes, commit them, and push them to Github. After you have pushed all your changes to Github, send me a merge request and comment on the issue to let me know that your merge request fixes the bug or adds the requested feature. Please make sure to write good commit messages and keep your history clean and understandable. Good commit messages and clean history make it easier for me to merge your changes and keep the history nice and useful. +Run all hooks on demand: + +```fish +prek run --all-files +``` + +GitHub Actions CI runs the same checks (`tests/check.fish`, `tests/run.fish`) and an installer smoke test on every push and pull request. + +## Versioning + +fishmarks follows [Semantic Versioning](https://semver.org/). Notable changes are tracked in `CHANGELOG.md`. + +### Contributing -I recommend the following guides on writing good commit messages: -- [GIT Commit Good Practice](https://wiki.openstack.org/wiki/GitCommitMessages) -- [A Note About Git Commit Messages](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) -- [Proper Git Commit Messages and an Elegant Git History](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) +See `CONTRIBUTING.md` for setup, validation commands, and pull request guidelines. ## License -Copyright 2013 Kunal Sarkhel +Copyright 2013-present Kunal Sarkhel Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the -License at `http://www.apache.org/licenses/LICENSE-2.0`. +License at `https://www.apache.org/licenses/LICENSE-2.0`. Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..625e016 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Supported versions + +Security fixes are provided for the latest release on `master`. + +## Reporting a vulnerability + +Please do not open public issues for security vulnerabilities. + +Instead: + +1. Use GitHub's private vulnerability reporting for this repository, or +2. Contact the maintainers privately through repository administrators. + +Include as much detail as possible: + +- A clear description of the issue +- Reproduction steps or proof of concept +- Potential impact +- Suggested mitigation (if known) + +We will acknowledge reports promptly and coordinate a fix and disclosure timeline. diff --git a/completions/delete_bookmark.fish b/completions/delete_bookmark.fish new file mode 100644 index 0000000..e0c6f60 --- /dev/null +++ b/completions/delete_bookmark.fish @@ -0,0 +1 @@ +complete -f -c delete_bookmark -a '(_fishmarks_complete)' diff --git a/completions/go_to_bookmark.fish b/completions/go_to_bookmark.fish new file mode 100644 index 0000000..b1f345b --- /dev/null +++ b/completions/go_to_bookmark.fish @@ -0,0 +1 @@ +complete -f -c go_to_bookmark -a '(_fishmarks_complete)' diff --git a/completions/print_bookmark.fish b/completions/print_bookmark.fish new file mode 100644 index 0000000..d57d0e6 --- /dev/null +++ b/completions/print_bookmark.fish @@ -0,0 +1 @@ +complete -f -c print_bookmark -a '(_fishmarks_complete)' diff --git a/conf.d/fishmarks.fish b/conf.d/fishmarks.fish new file mode 100644 index 0000000..8c66e01 --- /dev/null +++ b/conf.d/fishmarks.fish @@ -0,0 +1,29 @@ +if set -q __fishmarks_conf_loaded + return +end +set -g __fishmarks_conf_loaded 1 + +_fishmarks_ensure_sdirs + +complete -e -c print_bookmark +complete -e -c delete_bookmark +complete -e -c go_to_bookmark + +complete -c print_bookmark -a '(_fishmarks_complete)' -f +complete -c delete_bookmark -a '(_fishmarks_complete)' -f +complete -c go_to_bookmark -a '(_fishmarks_complete)' -f + +if not set -q NO_FISHMARKS_COMPAT_ALIASES + alias s save_bookmark + alias g go_to_bookmark + alias p print_bookmark + alias d delete_bookmark + alias l list_bookmarks + + complete -e -c p + complete -e -c d + complete -e -c g + complete -c p -w print_bookmark + complete -c d -w delete_bookmark + complete -c g -w go_to_bookmark +end diff --git a/functions/_check_help.fish b/functions/_check_help.fish new file mode 100644 index 0000000..60461ef --- /dev/null +++ b/functions/_check_help.fish @@ -0,0 +1,17 @@ +function _check_help + if test (count $argv) -lt 1 + return 1 + end + + if contains -- "$argv[1]" -h -help --help + echo '' + echo 's - Saves the current directory as "bookmark_name"' + echo 'g - Goes (cd) to the directory associated with "bookmark_name"' + echo 'p - Prints the directory associated with "bookmark_name"' + echo 'd - Deletes the bookmark' + echo 'l - Lists all available bookmarks' + echo '' + return 0 + end + return 1 +end diff --git a/functions/_fishmarks_complete.fish b/functions/_fishmarks_complete.fish new file mode 100644 index 0000000..3813373 --- /dev/null +++ b/functions/_fishmarks_complete.fish @@ -0,0 +1,6 @@ +function _fishmarks_complete + for entry in (_fishmarks_entries) + set -l parts (string split -m 1 '=' -- "$entry") + printf '%s\n' "$parts[1]" + end +end diff --git a/functions/_fishmarks_decode_path.fish b/functions/_fishmarks_decode_path.fish new file mode 100644 index 0000000..edc8522 --- /dev/null +++ b/functions/_fishmarks_decode_path.fish @@ -0,0 +1,19 @@ +function _fishmarks_decode_path --argument-names raw_value + set -l value "$raw_value" + set -l quote_match (string match -r '^"(.*)"$' -- "$value") + if test (count $quote_match) -gt 1 + set value "$quote_match[2]" + else + set quote_match (string match -r "^'(.*)'\$" -- "$value") + if test (count $quote_match) -gt 1 + set value "$quote_match[2]" + end + end + + set value (string replace -a -- '\$HOME' "$HOME" "$value") + set value (string replace -r -- '^\$HOME' "$HOME" "$value") + set value (string replace -a -- '\`' '`' "$value") + set value (string replace -a -- '\$' '$' "$value") + set value (string replace -a -- '\"' '"' "$value") + string replace -a -- "\\\\" "\\" "$value" +end diff --git a/functions/_fishmarks_encode_path.fish b/functions/_fishmarks_encode_path.fish new file mode 100644 index 0000000..baf6939 --- /dev/null +++ b/functions/_fishmarks_encode_path.fish @@ -0,0 +1,9 @@ +function _fishmarks_encode_path --argument-names path_value + set -l escaped_home (string escape --style=regex -- "$HOME") + set -l value (string replace -r -- "^$escaped_home" '__FISHMARKS_HOME_PREFIX__' "$path_value") + set value (string replace -a -- '\\' '\\\\' "$value") + set value (string replace -a -- '"' '\\"' "$value") + set value (string replace -a -- '$' '\\$' "$value") + set value (string replace -a -- '`' '\\`' "$value") + string replace -a -- __FISHMARKS_HOME_PREFIX__ '\$HOME' "$value" +end diff --git a/functions/_fishmarks_ensure_sdirs.fish b/functions/_fishmarks_ensure_sdirs.fish new file mode 100644 index 0000000..9969eca --- /dev/null +++ b/functions/_fishmarks_ensure_sdirs.fish @@ -0,0 +1,10 @@ +function _fishmarks_ensure_sdirs + if not set -q SDIRS + set -gx SDIRS "$HOME/.sdirs" + end + + command mkdir -p -- (path dirname -- "$SDIRS") + if not test -e "$SDIRS" + command touch -- "$SDIRS" + end +end diff --git a/functions/_fishmarks_entries.fish b/functions/_fishmarks_entries.fish new file mode 100644 index 0000000..3a06d9d --- /dev/null +++ b/functions/_fishmarks_entries.fish @@ -0,0 +1,14 @@ +function _fishmarks_entries + _fishmarks_ensure_sdirs + + while read -l line + set -l entry (string match -r '^export[[:space:]]+DIR_([A-Za-z0-9_]+)=(.+)$' -- "$line") + if test (count $entry) -lt 3 + continue + end + + set -l name "$entry[2]" + set -l value (_fishmarks_decode_path "$entry[3]") + printf '%s=%s\n' "$name" "$value" + end <"$SDIRS" +end diff --git a/functions/_fishmarks_find_path.fish b/functions/_fishmarks_find_path.fish new file mode 100644 index 0000000..4b79d07 --- /dev/null +++ b/functions/_fishmarks_find_path.fish @@ -0,0 +1,11 @@ +function _fishmarks_find_path --argument-names bookmark_name + for entry in (_fishmarks_entries) + set -l parts (string split -m 1 '=' -- "$entry") + if test "$parts[1]" = "$bookmark_name" + printf '%s\n' "$parts[2]" + return 0 + end + end + + return 1 +end diff --git a/functions/_fishmarks_path_supported.fish b/functions/_fishmarks_path_supported.fish new file mode 100644 index 0000000..202467d --- /dev/null +++ b/functions/_fishmarks_path_supported.fish @@ -0,0 +1,11 @@ +function _fishmarks_path_supported --argument-names path_value + if string match -q "*\n*" -- "$path_value" + return 1 + end + + if string match -q "*\r*" -- "$path_value" + return 1 + end + + return 0 +end diff --git a/functions/_fishmarks_print_error.fish b/functions/_fishmarks_print_error.fish new file mode 100644 index 0000000..326da34 --- /dev/null +++ b/functions/_fishmarks_print_error.fish @@ -0,0 +1,5 @@ +function _fishmarks_print_error --argument-names message + set_color red >&2 + printf 'ERROR: %s\n' "$message" >&2 + set_color normal >&2 +end diff --git a/functions/_fishmarks_valid_name.fish b/functions/_fishmarks_valid_name.fish new file mode 100644 index 0000000..b029a6b --- /dev/null +++ b/functions/_fishmarks_valid_name.fish @@ -0,0 +1,3 @@ +function _fishmarks_valid_name --argument-names bookmark_name + string match -rq '^[A-Za-z0-9_]+$' -- "$bookmark_name" +end diff --git a/functions/_fishmarks_write_entries.fish b/functions/_fishmarks_write_entries.fish new file mode 100644 index 0000000..56963bd --- /dev/null +++ b/functions/_fishmarks_write_entries.fish @@ -0,0 +1,12 @@ +function _fishmarks_write_entries + _fishmarks_ensure_sdirs + + set -l tmp_file (command mktemp) + for entry in $argv + set -l parts (string split -m 1 '=' -- "$entry") + set -l encoded_path (_fishmarks_encode_path "$parts[2]") + printf 'export DIR_%s="%s"\n' "$parts[1]" "$encoded_path" + end >"$tmp_file" + + command mv -- "$tmp_file" "$SDIRS" +end diff --git a/functions/_valid_bookmark.fish b/functions/_valid_bookmark.fish new file mode 100644 index 0000000..afb887b --- /dev/null +++ b/functions/_valid_bookmark.fish @@ -0,0 +1,7 @@ +function _valid_bookmark + if test (count $argv) -lt 1 + return 1 + end + + _fishmarks_find_path "$argv[1]" >/dev/null +end diff --git a/functions/delete_bookmark.fish b/functions/delete_bookmark.fish new file mode 100644 index 0000000..c84b1f5 --- /dev/null +++ b/functions/delete_bookmark.fish @@ -0,0 +1,24 @@ +function delete_bookmark --description "Delete a bookmark" + if test (count $argv) -lt 1 + _fishmarks_print_error "bookmark name required" + return 1 + end + + set -l removed 0 + set -l updated_entries + for entry in (_fishmarks_entries) + set -l parts (string split -m 1 '=' -- "$entry") + if test "$parts[1]" = "$argv[1]" + set removed 1 + continue + end + set -a updated_entries "$entry" + end + + if test $removed -eq 0 + _fishmarks_print_error "bookmark '$argv[1]' does not exist" + return 1 + end + + _fishmarks_write_entries $updated_entries +end diff --git a/functions/fishmarks_version.fish b/functions/fishmarks_version.fish new file mode 100644 index 0000000..5d19468 --- /dev/null +++ b/functions/fishmarks_version.fish @@ -0,0 +1,3 @@ +function fishmarks_version --description "Print fishmarks version" + printf '%s\n' '0.2.0-dev' +end diff --git a/functions/go_to_bookmark.fish b/functions/go_to_bookmark.fish new file mode 100644 index 0000000..f214065 --- /dev/null +++ b/functions/go_to_bookmark.fish @@ -0,0 +1,22 @@ +function go_to_bookmark --description "Go to (cd) to the directory associated with a bookmark" + if test (count $argv) -lt 1 + _fishmarks_print_error "bookmark name required" + return 1 + end + + if not _check_help "$argv[1]" + set -l target (_fishmarks_find_path "$argv[1]") + if test -z "$target" + _fishmarks_print_error "'$argv[1]' bookmark does not exist" + return 1 + end + + if test -d "$target" + cd -- "$target" + return 0 + end + + _fishmarks_print_error "'$target' does not exist" + return 1 + end +end diff --git a/functions/list_bookmarks.fish b/functions/list_bookmarks.fish new file mode 100644 index 0000000..b5e916a --- /dev/null +++ b/functions/list_bookmarks.fish @@ -0,0 +1,11 @@ +function list_bookmarks --description "List all available bookmarks" + if not _check_help "$argv[1]" + for entry in (_fishmarks_entries) + set -l parts (string split -m 1 '=' -- "$entry") + set_color yellow + printf '%-20s' "$parts[1]" + set_color normal + printf ' %s\n' "$parts[2]" + end + end +end diff --git a/functions/print_bookmark.fish b/functions/print_bookmark.fish new file mode 100644 index 0000000..a3d505c --- /dev/null +++ b/functions/print_bookmark.fish @@ -0,0 +1,16 @@ +function print_bookmark --description "Print the directory associated with a bookmark" + if test (count $argv) -lt 1 + _fishmarks_print_error "bookmark name required" + return 1 + end + + if not _check_help "$argv[1]" + set -l target (_fishmarks_find_path "$argv[1]") + if test -z "$target" + _fishmarks_print_error "'$argv[1]' bookmark does not exist" + return 1 + end + + printf '%s\n' "$target" + end +end diff --git a/functions/save_bookmark.fish b/functions/save_bookmark.fish new file mode 100644 index 0000000..4380267 --- /dev/null +++ b/functions/save_bookmark.fish @@ -0,0 +1,29 @@ +function save_bookmark --description "Save the current directory as a bookmark" + set -l bn "$argv[1]" + if test (count $argv) -lt 1 + set bn (string replace -ra -- '[^A-Za-z0-9]' '_' (path basename -- "$PWD")) + end + + if not _fishmarks_valid_name "$bn" + _fishmarks_print_error 'Bookmark names may only contain alphanumeric characters and underscores.' + return 1 + end + + if not _fishmarks_path_supported "$PWD" + _fishmarks_print_error 'Current directory path contains unsupported newline or carriage-return characters.' + return 1 + end + + set -l updated_entries + for entry in (_fishmarks_entries) + set -l parts (string split -m 1 '=' -- "$entry") + if test "$parts[1]" = "$bn" + continue + end + set -a updated_entries "$entry" + end + + set -a updated_entries "$bn=$PWD" + _fishmarks_write_entries $updated_entries + return 0 +end diff --git a/install.fish b/install.fish index 8faaa97..8b5630b 100644 --- a/install.fish +++ b/install.fish @@ -1,28 +1,33 @@ #!/usr/bin/env fish -set -x required_version 2 -set -x fish_version (fish --version | cut -d ' ' -f 3 | cut -d . -f 1) -if [ $required_version -gt $fish_version ] - echo "Fish shell version $required_version is require for this script. You can obtain the latest version at http://fishshell.com/" +set -l required_major 3 +set -l fish_major (string split . -- (status fish-version))[1] + +if test "$fish_major" -lt "$required_major" + echo "Fish shell version $required_major or newer is required for this script. Get the latest version at https://fishshell.com/." exit 1 end -if [ -f "marks.fish" ] - set -x FISHMARKS (readlink -f 'marks.fish' | sed "s#^$HOME#\$HOME#g") -else if [ -f "fishmarks/marks.fish" ] - set -x FISHMARKS (readlink -f 'fishmarks/marks.fish' | sed "s#^$HOME#\$HOME#g") -else if not [ -f "$HOME/.fishmarks/marks.fish" ] - git clone http://github.com/techwizrd/fishmarks.git $HOME/.fishmarks - set -x FISHMARKS "\$HOME/.fishmarks/marks.fish" + +set -l source_file +if test -f "marks.fish" + set source_file (path resolve "marks.fish") +else if test -f "fishmarks/marks.fish" + set source_file (path resolve "fishmarks/marks.fish") +else if not test -f "$HOME/.fishmarks/marks.fish" + git clone https://github.com/techwizrd/fishmarks.git "$HOME/.fishmarks" + set source_file "$HOME/.fishmarks/marks.fish" else - cd $HOME/.fishmarks; and git pull - set -x FISHMARKS "\$HOME/.fishmarks/marks.fish" -end -mkdir -p ~/.config/fish -touch ~/.config/fish/config.fish -if grep -Fxq ". $FISHMARKS" $HOME/.config/fish/config.fish - echo "Fishmarks has already been installed" -else - echo '' >> $HOME/.config/fish/config.fish - echo "# Load fishmarks (http://github.com/techwizrd/fishmarks)" >> $HOME/.config/fish/config.fish - echo ". $FISHMARKS" >> $HOME/.config/fish/config.fish - echo "Fishmarks has been installed" + git -C "$HOME/.fishmarks" pull --ff-only + set source_file "$HOME/.fishmarks/marks.fish" end + +set -l conf_d_dir "$HOME/.config/fish/conf.d" +set -l conf_file "$conf_d_dir/fishmarks.fish" +set -l escaped_source (string replace -- "$HOME" '$HOME' "$source_file") +set -l source_token (string escape --style=script -- "$escaped_source") + +command mkdir -p -- "$conf_d_dir" + +printf '# Load fishmarks (https://github.com/techwizrd/fishmarks)\n' >"$conf_file" +printf 'source %s\n' "$source_token" >>"$conf_file" + +echo "Fishmarks has been installed to $conf_file" diff --git a/marks.fish b/marks.fish index 0640587..e8377ba 100644 --- a/marks.fish +++ b/marks.fish @@ -1,4 +1,4 @@ -# Copyright 2013 Kunal Sarkhel +# Copyright 2013-present Kunal Sarkhel # # Licensed under the Apache License, Version 2.0 (the "License"); you may not # use this file except in compliance with the License. You may obtain a copy @@ -12,139 +12,38 @@ # License for the specific language governing permissions and limitations under # the License. -# Fishmarks: -# Save and jump to commonly used directories -# -# Fishmarks is a a clone of bashmarks for the Fish shell. Fishmarks is -# compatible with your existing bashmarks and bookmarks added using fishmarks -# are also available in bashmarks. +# Fishmarks compatibility loader. -if not set -q SDIRS - set -gx SDIRS $HOME/.sdirs -end -touch $SDIRS +set -l fishmarks_root (path dirname -- (status filename)) -if not set -q NO_FISHMARKS_COMPAT_ALIASES - alias s save_bookmark - alias g go_to_bookmark - alias p print_bookmark - alias d delete_bookmark - alias l list_bookmarks +if test "$fishmarks_root" = /; and test -f './functions/save_bookmark.fish' + set fishmarks_root '.' end -function save_bookmark --description "Save the current directory as a bookmark" - set -l bn $argv[1] - if [ (count $argv) -lt 1 ] - set bn (string replace -r [^a-zA-Z0-9] _ (basename (pwd))) - end - if not echo $bn | grep -q "^[a-zA-Z0-9_]*\$"; - echo -e "\033[0;31mERROR: Bookmark names may only contain alphanumeric characters and underscores.\033[00m" - return 1 - end - if _valid_bookmark $bn; - sed -i='' "/DIR_$bn=/d" $SDIRS - end - set -l pwd (pwd | sed "s#^$HOME#\$HOME#g") - echo "export DIR_$bn=\"$pwd\"" >> $SDIRS - _update_completions +if not test -f "$fishmarks_root/functions/save_bookmark.fish" return 0 end -function go_to_bookmark --description "Go to (cd) to the directory associated with a bookmark" - if [ (count $argv) -lt 1 ] - echo -e "\033[0;31mERROR: '' bookmark does not exist\033[00m" - return 1 - end - if not _check_help $argv[1]; - cat $SDIRS | grep "^export DIR_" | sed "s/^export /set -x /" | sed "s/=/ /" | . - set -l target (env | grep "^DIR_$argv[1]=" | cut -f2 -d "=") - if [ ! -n "$target" ] - echo -e "\033[0;31mERROR: '$argv[1]' bookmark does not exist\033[00m" - return 1 - end - if [ -d "$target" ] - cd "$target" - return 0 - else - echo -e "\033[0;31mERROR: '$target' does not exist\033[00m" - return 1 - end - end -end - -function print_bookmark --description "Print the directory associated with a bookmark" - if [ (count $argv) -lt 1 ] - echo -e "\033[0;31mERROR: bookmark name required\033[00m" - return 1 - end - if not _check_help $argv[1]; - cat $SDIRS | grep "^export DIR_" | sed "s/^export /set -x /" | sed "s/=/ /" | . - env | grep "^DIR_$argv[1]=" | cut -f2 -d "=" - end -end - -function delete_bookmark --description "Delete a bookmark" - if [ (count $argv) -lt 1 ] - echo -e "\033[0;31mERROR: bookmark name required\033[00m" - return 1 - end - if not _valid_bookmark $argv[1]; - echo -e "\033[0;31mERROR: bookmark '$argv[1]' does not exist\033[00m" - return 1 - else - sed --follow-symlinks -i='' "/DIR_$argv[1]=/d" $SDIRS - _update_completions - end -end - -function list_bookmarks --description "List all available bookmarks" - if not _check_help $argv[1]; - cat $SDIRS | grep "^export DIR_" | sed "s/^export /set -x /" | sed "s/=/ /" | . - env | sort | awk '/DIR_.+/{split(substr($0,5),parts,"="); printf("\033[0;33m%-20s\033[0m %s\n", parts[1], parts[2]);}' - end -end - -function _check_help - if [ (count $argv) -lt 1 ] - return 1 - end - if begin; [ "-h" = $argv[1] ]; or [ "-help" = $argv[1] ]; or [ "--help" = $argv[1] ]; end - echo '' - echo 's - Saves the current directory as "bookmark_name"' - echo 'g - Goes (cd) to the directory associated with "bookmark_name"' - echo 'p - Prints the directory associated with "bookmark_name"' - echo 'd - Deletes the bookmark' - echo 'l - Lists all available bookmarks' - echo '' - return 0 - end - return 1 -end - -function _valid_bookmark - if begin; [ (count $argv) -lt 1 ]; or not [ -n $argv[1] ]; end - return 1 - else - cat $SDIRS | grep "^export DIR_" | sed "s/^export /set -x /" | sed "s/=/ /" | . - set -l bookmark (env | grep "^DIR_$argv[1]=" | cut -f1 -d "=" | cut -f2 -d "_" ) - if begin; not [ -n "$bookmark" ]; or not [ $bookmark=$argv[1] ]; end - return 1 - else - return 0 - end - end -end - -function _update_completions - cat $SDIRS | grep "^export DIR_" | sed "s/^export /set -x /" | sed "s/=/ /" | . - set -x _marks (env | grep "^DIR_" | sed "s/^DIR_//" | cut -f1 -d "=" | tr '\n' ' ') - complete -c print_bookmark -a $_marks -f - complete -c delete_bookmark -a $_marks -f - complete -c go_to_bookmark -a $_marks -f - if not set -q NO_FISHMARKS_COMPAT_ALIASES - complete -c p -a $_marks -f - complete -c d -a $_marks -f - complete -c g -a $_marks -f - end -end -_update_completions +for function_file in \ + _fishmarks_ensure_sdirs \ + _fishmarks_print_error \ + _fishmarks_encode_path \ + _fishmarks_decode_path \ + _fishmarks_entries \ + _fishmarks_find_path \ + _fishmarks_valid_name \ + _fishmarks_path_supported \ + _fishmarks_write_entries \ + _fishmarks_complete \ + fishmarks_version \ + _check_help \ + _valid_bookmark \ + save_bookmark \ + go_to_bookmark \ + print_bookmark \ + delete_bookmark \ + list_bookmarks + source "$fishmarks_root/functions/$function_file.fish" +end + +source "$fishmarks_root/conf.d/fishmarks.fish" diff --git a/tests/check.fish b/tests/check.fish new file mode 100755 index 0000000..9c51a70 --- /dev/null +++ b/tests/check.fish @@ -0,0 +1,28 @@ +#!/usr/bin/env fish + +set -l files + +if test (count $argv) -gt 0 + for file in $argv + if test -f "$file"; and string match -rq '\\.fish$' -- "$file" + set -a files "$file" + end + end +else + set files \ + marks.fish \ + install.fish \ + conf.d/*.fish \ + functions/*.fish \ + completions/*.fish \ + tests/*.fish +end + +if test (count $files) -eq 0 + exit 0 +end + +fish -n $files +or exit $status + +fish_indent --check $files diff --git a/tests/run.fish b/tests/run.fish new file mode 100755 index 0000000..613ef2d --- /dev/null +++ b/tests/run.fish @@ -0,0 +1,181 @@ +#!/usr/bin/env fish + +set -l test_root (command mktemp -d) +set -gx HOME "$test_root/home" +command mkdir -p -- "$HOME" +set -gx SDIRS "$HOME/.sdirs" +set -gx NO_FISHMARKS_COMPAT_ALIASES 1 +set -g repo_root (path resolve -- (path dirname -- (status filename))/..) + +source "$repo_root/marks.fish" + +set -g failures 0 +set -g assertions 0 + +function _assert_eq --argument-names expected actual message + set -g assertions (math $assertions + 1) + if test "$expected" != "$actual" + set_color red + printf 'FAIL: %s\n expected: %s\n actual: %s\n' "$message" "$expected" "$actual" + set_color normal + set -g failures (math $failures + 1) + end +end + +function _assert_status --argument-names expected actual message + _assert_eq "$expected" "$actual" "$message" +end + +function _assert_true --argument-names status_value message + _assert_status 0 "$status_value" "$message" +end + +function _prepare_dir --argument-names dir_path + command mkdir -p -- "$dir_path" + cd -- "$dir_path" +end + +function _test_save_and_print + _prepare_dir "$HOME/work/app" + save_bookmark app + _assert_status 0 $status 'save_bookmark succeeds' + + set -l location (print_bookmark app) + _assert_status 0 $status 'print_bookmark succeeds' + _assert_eq "$HOME/work/app" "$location" 'print_bookmark returns saved path' +end + +function _test_default_name_generation + _prepare_dir "$HOME/work/my-app.v2" + save_bookmark + _assert_status 0 $status 'save_bookmark without name succeeds' + + set -l location (print_bookmark my_app_v2) + _assert_status 0 $status 'default bookmark name is discoverable' + _assert_eq "$HOME/work/my-app.v2" "$location" 'default bookmark name is sanitized basename' +end + +function _test_go_to_and_delete + _prepare_dir "$HOME/projects/alpha" + save_bookmark alpha + _assert_status 0 $status 'save alpha bookmark succeeds' + + _prepare_dir "$HOME/projects/other" + go_to_bookmark alpha + _assert_status 0 $status 'go_to_bookmark succeeds for existing entry' + _assert_eq "$HOME/projects/alpha" "$PWD" 'go_to_bookmark changes current directory' + + delete_bookmark alpha + _assert_status 0 $status 'delete_bookmark succeeds for existing entry' + + print_bookmark alpha >/dev/null 2>/dev/null + _assert_status 1 $status 'deleted bookmark no longer resolves' +end + +function _test_legacy_file_compatibility + command mkdir -p -- "$HOME/legacy/location" + printf 'export DIR_legacy="\\$HOME/legacy/location"\n' >"$SDIRS" + printf 'export DIR_absolute="/tmp"\n' >>"$SDIRS" + printf 'not a bookmark line\n' >>"$SDIRS" + + set -l legacy_value (print_bookmark legacy) + _assert_status 0 $status 'legacy bookmark is parsed' + _assert_eq "$HOME/legacy/location" "$legacy_value" 'legacy value expands $HOME safely' + + set -l absolute_value (print_bookmark absolute) + _assert_status 0 $status 'absolute legacy bookmark is parsed' + _assert_eq /tmp "$absolute_value" 'absolute path is preserved' +end + +function _test_invalid_name_rejected + _prepare_dir "$HOME/work/invalid" + save_bookmark bad-name >/dev/null 2>/dev/null + _assert_status 1 $status 'invalid bookmark names are rejected' +end + +function _test_shell_escaped_paths + set -l special_path "$HOME/work/special \"q\" \$d\\b" + _prepare_dir "$special_path" + save_bookmark specialchars + _assert_status 0 $status 'save_bookmark supports special shell characters' + + set -l location (print_bookmark specialchars) + _assert_status 0 $status 'print_bookmark resolves special shell characters' + _assert_eq "$special_path" "$location" 'special shell characters round-trip correctly' + + set -l stored_line + while read -l line + if string match -rq '^export DIR_specialchars=' -- "$line" + set stored_line "$line" + break + end + end <"$SDIRS" + + string match -q 'export DIR_specialchars="*"' -- "$stored_line" + _assert_true $status 'bookmark line uses export DIR_="..." format' + + string match -q '*\$HOME/work/special*' -- "$stored_line" + _assert_true $status 'bookmark line preserves $HOME prefix' + + string match -q '*\\"q\\"*' -- "$stored_line" + _assert_true $status 'bookmark line escapes double quotes' + + string match -q '*\\$d*' -- "$stored_line" + _assert_true $status 'bookmark line escapes dollar signs' + + string match -q '*\\\\b*' -- "$stored_line" + _assert_true $status 'bookmark line escapes backslashes' +end + +function _test_rejects_newline_paths + set -l unsupported_path "$HOME/work/bad\nname" + _prepare_dir "$unsupported_path" + save_bookmark newlinepath >/dev/null 2>/dev/null + _assert_status 1 $status 'save_bookmark rejects directories containing newlines' + + print_bookmark newlinepath >/dev/null 2>/dev/null + _assert_status 1 $status 'rejected newline path bookmark is not written' +end + +function _test_version_command + set -l version_output (fishmarks_version) + _assert_status 0 $status 'fishmarks_version command succeeds' + string match -rq '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$' -- "$version_output" + _assert_true $status 'fishmarks_version returns semver-like output' +end + +function _test_conf_aliases + set -e NO_FISHMARKS_COMPAT_ALIASES + set -e __fishmarks_conf_loaded + source "$repo_root/conf.d/fishmarks.fish" + + type -q s + _assert_status 0 $status 'alias s is configured by conf.d script' + type -q g + _assert_status 0 $status 'alias g is configured by conf.d script' + type -q p + _assert_status 0 $status 'alias p is configured by conf.d script' + type -q d + _assert_status 0 $status 'alias d is configured by conf.d script' + type -q l + _assert_status 0 $status 'alias l is configured by conf.d script' +end + +_test_save_and_print +_test_default_name_generation +_test_go_to_and_delete +_test_legacy_file_compatibility +_test_invalid_name_rejected +_test_shell_escaped_paths +_test_rejects_newline_paths +_test_version_command +_test_conf_aliases + +if test $failures -gt 0 + printf '\n%d of %d assertions failed\n' "$failures" "$assertions" + exit 1 +end + +set_color green +printf 'All %d assertions passed\n' "$assertions" +set_color normal