reen 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c32624b91f88178dd9a68dc09229916804beffff846e04074e4f76a5eb299739
4
+ data.tar.gz: ef4cde36ed7202532276d761da152004dcb2f322814d7ed8d82c6de78ba4c920
5
+ SHA512:
6
+ metadata.gz: e139aa0f8f927448c3e566a37868b5c29697e91b46f9fa3043d12dce53d015e64b1791474e36178d40b1b937b76032ab1fcce8caebfa5c45081a79251cee1c56
7
+ data.tar.gz: f9e20a1defcf449536eb2e8ca160ef7ee6eb26831527015df85cb2839b88cc97fb3ca2300ab27d00e388ea22af95c978c0d972932a80e2881638390f2c4b3c81
data/.claude/CLAUDE.md ADDED
@@ -0,0 +1,44 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ Reen is a Ruby gem for mass renaming/deleting files through an interactive editor. It provides both a CLI (`reen`) and a programmatic API. Version 1.0.0, requires Ruby >= 3.0.
8
+
9
+ ## Common Commands
10
+
11
+ ```bash
12
+ bundle exec rake # Run tests + rubocop (default task)
13
+ bundle exec rake spec # Run all tests (minitest)
14
+ bundle exec rake rubocop # Lint only
15
+ bundle exec rake respec # Auto-rerun tests on file changes
16
+
17
+ # Run a single test file
18
+ bundle exec ruby -Ispec:lib spec/spec_changes.rb
19
+
20
+ # Test fixtures
21
+ bundle exec rake example:recreate # Extract example.zip for tests
22
+ bundle exec rake example:remove # Clean up extracted fixtures
23
+ ```
24
+
25
+ ## Architecture
26
+
27
+ **Flow:** `ReenCLI (bin/reen)` → `Reen::Reen` → `ChangesFile` (temp file + editor) → `Changes` → `Change` → `Action`
28
+
29
+ - **`Reen::Reen`** (`lib/reen/reen.rb`) — Main orchestrator. Two-phase workflow: `request()` analyzes changes, `execute()` applies them. Constructor takes `editor:` (defaults to $VISUAL/$EDITOR) and `options:`.
30
+ - **`Reen::Changes`** (`lib/reen/changes.rb`) — Collection of `Change` objects with filtering (`rename_requested`, `delete_requested`, `accepted`, `rejected`, `executed`, `failed`) and `execute_all`.
31
+ - **`Reen::Change`** (`lib/reen/change.rb`) — Parses a single original→requested pair. Prefix `-` = delete, `--` = force delete. Maps change type to action handler via `ACTION_HANDLER` constant. Auto-rejects non-empty directory deletion (unless force).
32
+ - **`Reen::ChangesFile`** (`lib/reen/changes_file.rb`) — Manages Tempfile for editor interaction, writes instructions header, parses edited result.
33
+ - **Actions** (`lib/reen/actions/`) — Strategy pattern: `Rename`, `Delete`, `ForceDelete`, `DoNothing`. Each has `new(old, new)` and `call()` returning nil (success) or error string.
34
+
35
+ ## Test Setup
36
+
37
+ - **Framework:** Minitest with describe/it blocks, minitest-rg for formatting, SimpleCov for coverage
38
+ - **Fixtures:** `spec/fixtures/example.zip` extracted to `spec/fixtures/example/` via `FixtureHelper` before tests
39
+ - **Test files:** `spec/spec_*.rb` pattern (spec_reenrb, spec_changes, spec_requests)
40
+
41
+ ## Style
42
+
43
+ - RuboCop enforced: double quotes, 120 char line length, NewCops enabled
44
+ - Target Ruby version: 3.0
@@ -0,0 +1,3 @@
1
+ {
2
+ "MD013": false
3
+ }
data/.rubocop.yml ADDED
@@ -0,0 +1,24 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+ NewCops: enable
4
+
5
+ Metrics/BlockLength:
6
+ Enabled: true
7
+ Exclude:
8
+ - reen.gemspec
9
+
10
+ Style/HashSyntax:
11
+ Enabled: true
12
+ Exclude:
13
+ - Rakefile
14
+
15
+ Style/StringLiterals:
16
+ Enabled: true
17
+ EnforcedStyle: double_quotes
18
+
19
+ Style/StringLiteralsInInterpolation:
20
+ Enabled: true
21
+ EnforcedStyle: double_quotes
22
+
23
+ Layout/LineLength:
24
+ Max: 120
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.1] - 2022-11-05
11
+
12
+ ### Changed
13
+
14
+ - Simplified API for passing block to request/execute
15
+
16
+ ## [0.1.0] - 2022-11-05
17
+
18
+ - Initial release: CLI and API working
@@ -0,0 +1,145 @@
1
+ # Numbered List Prefixes for Editor
2
+
3
+ > **IMPORTANT**: This plan must be kept up-to-date at all times. Assume context can be cleared at any time — this file is the single source of truth for the current state of this work. Update this plan before and after task and subtask implementations.
4
+
5
+ ## Branch
6
+
7
+ `feature-numbered-lists`
8
+
9
+ ## Goal
10
+
11
+ Add numbered prefixes (e.g., `[01]`, `[002]`) to the file list shown in the editor so users can freely reorder entries without losing track of which original file each line corresponds to. Matching changes back to originals uses the number, not line position. Also fix the dash-operator ambiguity by requiring a space after `-`/`--` to distinguish delete operations from literal filenames starting with dashes. Introduce `PathEntry` and `PathEntryList` domain objects to replace raw path strings — `PathEntry` wraps a file path and answers filesystem queries, `PathEntryList` is a collection that owns numbered serialization for the editor.
12
+
13
+ ## Strategy: Vertical Slice
14
+
15
+ 1. **Refactor** — Introduce `PathEntry`/`PathEntryList` domain objects, migrate existing code (all tests pass)
16
+ 2. **Fix** — Require space after `-`/`--` for delete operators (dash ambiguity fix)
17
+ 3. **Feature** — Add numbered prefixes and number-based matching
18
+ 4. **Verify** — Manual test with real editor confirms behavior
19
+
20
+ ## Current State
21
+
22
+ - [x] Plan created
23
+ - [x] Slice 0: PathEntry/PathEntryList refactor
24
+ - [x] Slice 1: Dash-operator ambiguity fix
25
+ - [x] Slice 2: Numbered prefixes
26
+ - [x] Slice 3: Number-based matching
27
+ - [x] Slice 4: Documentation
28
+ - [ ] Slice 5: Verification
29
+
30
+ ## Key Findings
31
+
32
+ - `Change` currently owns filesystem queries (`request_dir?`, `request_file?`, `request_empty_dir?`, `request_full_dir?`) by checking `@original` path directly. These should move to `PathEntry`.
33
+ - `Change` will hold an `PathEntry` instead of a raw `@original` string. `Change#original` delegates to `PathEntry#path`.
34
+ - `PathEntryList` wraps an array of `PathEntry` objects. Constructed from `Dir.glob` results (array of strings). Owns numbered serialization (writing `[NN] path` lines) and parsing (stripping prefixes, returning number-keyed entries).
35
+ - `ChangesFile#initialize` currently writes `requested_list.join("\n")` — will instead take an `PathEntryList` and call its serialization.
36
+ - `ChangesFile#allow_changes` reads back lines, strips comments/blanks — will delegate prefix parsing to `PathEntryList`.
37
+ - `Reen#request` does positional matching via `original_list.zip(changed_list)` — changes to number-based matching via `PathEntryList`.
38
+ - `Reen#request` raises if list sizes differ (line deletion guard) — still valid with numbered approach.
39
+ - `Change#extract_request` parses operator prefixes (`-`, `--`) greedily, which means renaming to `-` or `--` is impossible. Fix: require a space after `-`/`--` to trigger delete.
40
+ - Tests manipulate `file.list` directly (mock editor) — the list exposed to the block will include number prefixes so test blocks simulate what a real user sees/edits.
41
+ - Number width is auto-sized: `format("%0#{width}d")` where `width = list.size.to_s.length`.
42
+
43
+ ## Questions
44
+
45
+ - ~~Should `Change` know about number prefixes?~~ No — `ChangesFile` handles writing and stripping prefixes. `Change` and `Reen` see clean filenames. `Reen#compare_lists` changes from positional zip to number-keyed matching.
46
+ - ~~Should dashes go before or after number prefixes?~~ After. Dashes are operators on the filename, numbers are stable anchors: `[01] - file` = delete, `[01] -file` = rename to `-file`. Require space after `-`/`--` to distinguish.
47
+ - [ ] Should reordering be reflected in the output summary? (Probably not needed for v1.)
48
+
49
+ ## Scope
50
+
51
+ **In scope**:
52
+
53
+ - `PathEntry` (`lib/reenrb/path_entry.rb`): wraps a file path, provides `file?`, `dir?`, `empty_dir?`, `full_dir?`, `path`
54
+ - `PathEntryList` (`lib/reenrb/path_entry_list.rb`): collection of `PathEntry`, constructed from string array, owns numbered serialization/parsing
55
+ - `Change`: refactor to hold `PathEntry` instead of raw string, delegate filesystem queries to `PathEntry`
56
+ - `Change#extract_request`: require space after `-`/`--` for delete operators (fixes dash-name ambiguity)
57
+ - `ChangesFile`: use `PathEntryList` for writing/reading, numbered prefixes, updated instructions
58
+ - `Reen`: accept `PathEntryList` (or build one from string array), number-based matching
59
+ - Tests for: `PathEntry`, `PathEntryList`, dash-space fix, prefix formatting/stripping, reordered matching
60
+
61
+ **Out of scope**:
62
+
63
+ - Reporting reorder information to the user
64
+ - Any CLI flag to disable numbering
65
+ - `Changes` refactor (stays as-is, wraps array of `Change`)
66
+ - File/folder creation via unnumbered lines (touch/mkdir) — future feature, separate branch
67
+
68
+ ## Tasks
69
+
70
+ > **Strict TDD cycle**: For each slice — (1) run full suite to confirm green baseline, (2) write failing tests (RED), (3) run tests to confirm they fail, (4) implement (GREEN), (5) run full suite to confirm all pass. **Check off each step immediately after completing it.**
71
+
72
+ ### Slice 0: Introduce PathEntry and PathEntryList domain objects (refactor)
73
+
74
+ - [x] 0.0 Run full suite — confirm green baseline (11 tests, 0 failures)
75
+ - [x] 0.1 RED — Write failing tests in `spec/spec_path_entry.rb`:
76
+ - [x] 0.1a `PathEntry` wraps a path and delegates `file?`, `dir?`, `empty_dir?`, `full_dir?`
77
+ - [x] 0.1b `PathEntryList` constructs from array of strings, iterates as `PathEntry` objects
78
+ - [x] 0.1c `PathEntryList#paths` returns array of path strings
79
+ - [x] 0.2 Run new tests — confirm RED (6 errors, uninitialized constant)
80
+ - [x] 0.3 GREEN — Implement `PathEntry` (`lib/reenrb/path_entry.rb`)
81
+ - [x] 0.4 GREEN — Implement `PathEntryList` (`lib/reenrb/path_entry_list.rb`)
82
+ - [x] 0.5 Run new tests — confirm GREEN (6 runs, 27 assertions, 0 failures)
83
+ - [x] 0.6 Refactor `Change` to hold `PathEntry`, delegate filesystem queries
84
+ - [x] 0.7 Refactor `Reen` and `ChangesFile` to accept/use `PathEntryList`
85
+ - [x] 0.8 Run full suite — confirm all tests pass (17 runs, 58 assertions, 0 failures)
86
+
87
+ ### Slice 1: Fix dash-operator ambiguity in Change
88
+
89
+ - [x] 1.0 Run full suite — confirm green baseline (17 runs, 0 failures)
90
+ - [x] 1.1 RED — Write failing tests in `spec/spec_change.rb`:
91
+ - [x] 1.1a `- filename` (with space) triggers delete
92
+ - [x] 1.1b `-filename` (no space) triggers rename to `-filename`
93
+ - [x] 1.1c `-- filename` (with space) triggers force delete
94
+ - [x] 1.1d `--filename` (no space) triggers rename to `--filename`
95
+ - [x] 1.2 Run new tests — confirm RED (1.1b and 1.1d fail as expected)
96
+ - [x] 1.3 GREEN — Updated `Change#extract_request` regex: `^(--|-)·(?<name>.*)` with fallback `^()(?<name>.*)`
97
+ - [x] 1.4 Run full suite — confirm GREEN (21 runs, 64 assertions, 0 failures)
98
+
99
+ ### Slice 2: Numbered prefixes in PathEntryList and ChangesFile
100
+
101
+ - [x] 2.0 Run full suite — confirm green baseline (21 runs, 0 failures)
102
+ - [x] 2.1 RED — Write failing tests:
103
+ - [x] 2.1a `PathEntryList#to_numbered` serializes as `[NN] path` lines (auto-sized width)
104
+ - [x] 2.1b `PathEntryList.from_numbered` parses `[NN] path` lines back to number-keyed entries
105
+ - [x] 2.1c (deferred to Slice 4 — documentation)
106
+ - [x] 2.2 Run new tests — confirm RED (3 errors, undefined method `to_numbered`)
107
+ - [x] 2.3 GREEN — Implement `PathEntryList#to_numbered` and `PathEntryList.from_numbered`
108
+ - [x] 2.4 GREEN — Update `ChangesFile` to use numbered serialization
109
+ - [x] 2.5 GREEN — Update `INSTRUCTIONS` constant with reorder/number/dash-space guidance
110
+ - [x] 2.6 Run full suite — deferred to Slice 3 (existing tests broke as expected, fixed there)
111
+
112
+ ### Slice 3: Number-based matching in Reen
113
+
114
+ - [x] 3.0 (combined with Slice 2 — baseline was 21 runs before numbered output broke existing tests)
115
+ - [x] 3.1 RED — Write failing tests in `spec/spec_reorder.rb`:
116
+ - [x] 3.1a `Reen#request` correctly matches reordered entries to originals by number (reverse, rename+move)
117
+ - [x] 3.1b `Reen#request` still raises error when lines are removed
118
+ - [x] 3.2 Run new tests — confirm RED (3.1a failed, 3.1b passed)
119
+ - [x] 3.3 GREEN — Changed `Reen#compare_lists` to use `PathEntryList.from_numbered` for number-keyed matching
120
+ - [x] 3.4 GREEN — Updated existing tests: delete/force-delete use `.sub("] ", "] - ")` pattern
121
+ - [x] 3.5 Run full suite — confirm all pass (27 runs, 110 assertions, 0 failures)
122
+
123
+ ### Slice 4: Documentation
124
+
125
+ - [x] 4.1 Update README.md: documented numbered prefixes, dash-space syntax, reordering, programmatic examples
126
+ - [x] 4.2 CLI help in `bin/reen` — no changes needed (uses banner/options only, editing syntax is in INSTRUCTIONS)
127
+ - [x] 4.3 `ChangesFile::INSTRUCTIONS` — updated in Slice 2.5, verified
128
+
129
+ ### Slice 5: Verification
130
+
131
+ - [x] 5.1 Run full suite (`bundle exec rake`) — 29 tests, 115 assertions, 0 failures. RuboCop: only pre-existing offenses, no new ones.
132
+ - [ ] 5.2 Manual verification with real editor (user to perform)
133
+
134
+ ## Completed
135
+
136
+ - Slice 0: PathEntry/PathEntryList refactor — `PathEntry` wraps paths with filesystem queries, `PathEntryList` is an Enumerable collection. `Change` delegates to `PathEntry`. `Reen` and `ChangesFile` accept `PathEntryList`. All 17 tests pass.
137
+ - Slice 1: Dash-operator ambiguity fix — `Change#extract_request` now requires space after `-`/`--` for delete operators. `-file` is rename to `-file`, `- file` is delete. All 21 tests pass.
138
+ - Slice 2: Numbered prefixes — `PathEntryList#to_numbered` and `.from_numbered` implemented. `ChangesFile` writes `[NN] path` lines and updated `INSTRUCTIONS`.
139
+ - Slice 3: Number-based matching — `Reen#compare_lists` uses `from_numbered` for number-keyed matching instead of positional zip. Reordering works. All 27 tests pass (110 assertions).
140
+ - Slice 4: Documentation — README updated with numbered prefixes, dash-space syntax, reordering docs, programmatic examples. CLI help unchanged (banner only). INSTRUCTIONS verified.
141
+ - Slice 5: Verification — 29 tests, 115 assertions, 0 failures. No new RuboCop offenses. Manual editor test pending.
142
+
143
+ ---
144
+
145
+ Last updated: 2026-03-10
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in reen.gemspec
6
+ gemspec
7
+
8
+ gem "irb"
9
+ gem "minitest", "~> 5.0"
10
+ gem "minitest-rg", "~> 5.0"
11
+ gem "rake", "~> 13.0"
12
+ gem "rerun", "~> 0.13"
13
+ gem "rubocop", "~> 1.21"
14
+ gem "rubyzip", "~> 2.3"
15
+ gem "simplecov"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 Soumya Ray
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # Reen
2
+
3
+ Reen is a utility written in Ruby (requires Ruby installed on your machine) that mass renames/deletes files by allowing the user to modify a list. It includes a command line executable called `reen` that opens the user's default editor to permit interactive changes, or can be used programatically by modifying the list of file names using a code block.
4
+
5
+ [![Reen usage introduction](https://img.youtube.com/vi/yJfDRfJr3os/0.jpg)](https://www.youtube.com/watch?v=yJfDRfJr3os)
6
+
7
+ ## Installation
8
+
9
+ To install the command line utility, use:
10
+
11
+ $ gem install reen
12
+
13
+ Or add this line to your Ruby application's Gemfile for programmatic use:
14
+
15
+ ```ruby
16
+ gem 'reen'
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### Command line
22
+
23
+ From command line, run `reen` with file list:
24
+
25
+ reen files [options]
26
+
27
+ where `files` are a list of files or wildcard pattern (defaults to `*`; see examples)
28
+
29
+ Options include:
30
+
31
+ - `--help` or `-h` to see options help
32
+ - `--editor [EDITOR]` or `-e [EDITOR]` to use a specific editor, or just `-e` to use `$EDITOR`
33
+ - `--visual [EDITOR]` or `-v [EDITOR]` to use a specific editor, or just `-v` to use `$VISUAL`
34
+ - `--review` or `-r` request to review and confirm changes
35
+
36
+ The editor must block until the file is closed — for VS Code use `'code -w'`, for Sublime use `'subl -w'`. Without `-e` or `-v`, Reen defaults to `$VISUAL` then `$EDITOR`.
37
+
38
+ Examples:
39
+
40
+ reen # reen all files (*)
41
+ reen **/* # reen all files in all subfolders
42
+ reen myfolder/**/*.mov # reen all mov files in subfolders
43
+ reen -e # reen all files using $EDITOR
44
+ reen -v # reen all files using $VISUAL
45
+ reen -e vi *.md # reen all markdown files using vi
46
+ reen --editor 'code -w' # reen all files using vscode
47
+
48
+ ### Specifying changes through the editor
49
+
50
+ Upon running Reen on file list, your editor will open with a numbered list of file/folder names. Each line has a number prefix like `[01]` that tracks the original file. For example:
51
+
52
+ ```
53
+ [01] LICENSE.txt
54
+ [02] README.md
55
+ [03] SETUP.txt
56
+ [04] bin
57
+ [05] bin/help
58
+ [06] bin/myexec
59
+ [07] tests
60
+ [08] tests/fixtures
61
+ [09] tests/fixtures/a.json
62
+ [10] tests/fixtures/b.json
63
+ [11] tests/fixtures/c.json
64
+ [12] tests/helper.code
65
+ [13] tests/tests.code
66
+ ```
67
+
68
+ Specify changes to each file you wish changed modifying it in your editor:
69
+
70
+ - Change the file/folder name (after the number prefix) to rename it
71
+ - Put `- ` (dash followed by a space) after the number prefix to delete a file or empty folder
72
+ - Put `-- ` (double dash followed by a space) to force delete a file or non-empty folder (recursively)
73
+ - You may freely reorder lines — the number prefixes track which original file each line refers to
74
+ - Do not change or remove the `[NN]` number prefixes
75
+
76
+ For example, if we wanted to (a) rename `LICENSE.txt` to `LICENSE.md`, (b) delete `SETUP.txt`, and (c) recursively delete the `bin/` folder:
77
+
78
+ ```
79
+ [01] LICENSE.md
80
+ [02] README.md
81
+ [03] - SETUP.txt
82
+ [04] -- bin
83
+ [05] bin/help
84
+ [06] bin/myexec
85
+ [07] tests
86
+ [08] tests/fixtures
87
+ [09] tests/fixtures/a.json
88
+ [10] tests/fixtures/b.json
89
+ [11] tests/fixtures/c.json
90
+ [12] tests/helper.code
91
+ [13] tests/tests.code
92
+ ```
93
+
94
+ Note: filenames starting with dashes (e.g., `-myfile`) are safe — only a dash followed by a space triggers deletion.
95
+
96
+ Upon saving and exiting the editor, Reen will execute all the changes.
97
+
98
+ ### Ruby application
99
+
100
+ Use Reen programmatically using the `reen` gem. In the example below, we specify that we do not want to use an actual editor to modify the list, but rather alter the list file using a block. Note that `file.list` contains numbered lines (e.g., `[1] LICENSE.txt`).
101
+
102
+ ```ruby
103
+ require 'reen'
104
+
105
+ glob = Dir.glob("*")
106
+ reen = Reen::Reen.new(editor: nil)
107
+
108
+ reen.execute(glob) do |file|
109
+ # Rename LICENSE.txt -> LICENSE.md (gsub works on the path portion)
110
+ index = file.list.index { |l| l.include? "LICENSE.txt" }
111
+ file.list[index] = file.list[index].gsub("txt", "md")
112
+
113
+ # Delete a file — insert "- " after the number prefix
114
+ index = file.list.index { |l| l.include? "SETUP.txt" }
115
+ file.list[index] = file.list[index].sub("] ", "] - ")
116
+ end
117
+ ```
118
+
119
+ You may also pass a block with an editor specified, in which case the block is run after the editor has finished.
120
+
121
+ ## Development
122
+
123
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
124
+
125
+ ## Contributing
126
+
127
+ Bug reports and pull requests are welcome on GitHub at https://github.com/soumyaray/reen.
128
+
129
+ ## License
130
+
131
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:spec) do |t|
7
+ t.libs << "spec"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["spec/**/spec_*.rb"]
10
+ end
11
+
12
+ task :respec do
13
+ sh "rerun -c --ignore 'spec/fixtures/*' rake spec"
14
+ end
15
+
16
+ namespace :example do
17
+ task :helper do
18
+ require_relative "spec/fixture_helper"
19
+ end
20
+
21
+ desc "Recreates the example fixture folder"
22
+ task :recreate => :helper do
23
+ FixtureHelper.recreate_example_dir
24
+ puts "Example fixture recreated"
25
+ end
26
+
27
+ desc "Deletes the example fixture folder"
28
+ task :remove => :helper do
29
+ FixtureHelper.remove_example_dirs
30
+ puts "Example fixture removed"
31
+ end
32
+ end
33
+
34
+ require "rubocop/rake_task"
35
+
36
+ RuboCop::RakeTask.new
37
+
38
+ task default: %i[spec rubocop]
data/bin/reen ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
5
+ require "reen"
6
+ require "optparse"
7
+
8
+ # Reen command line application
9
+ class ReenCLI
10
+ EDITOR_MSG = "Editor not set in $VISUAL or $EDITOR -- please set one of those environment variables"
11
+ Options = Struct.new(:editor, :review) do
12
+ def initialize(editor: nil, review: false)
13
+ editor ||= ENV["VISUAL"] || ENV["EDITOR"] # rubocop:disable Style/FetchEnvVar
14
+ super(editor, review)
15
+ end
16
+ end
17
+
18
+ attr_reader :options, :files
19
+
20
+ def initialize(args)
21
+ @options = Options.new
22
+ optparse = OptionParser.new { |parser| setup_options(parser) }
23
+ optparse.parse!(args, into: @options)
24
+
25
+ exit_with_msg(EDITOR_MSG) unless options.editor
26
+
27
+ @files = args.empty? ? Dir.glob("*") : args
28
+ rescue OptionParser::InvalidOption => e
29
+ puts "#{e.message}\n\n"
30
+ exit_with_msg(optparse)
31
+ end
32
+
33
+ def setup_options(parser) # rubocop:disable Metrics/MethodLength
34
+ parser.banner = "Usage: reen files [options]"
35
+ parser.version = Reen::VERSION
36
+
37
+ parser.on("-e", "--editor [EDITOR]", "Use EDITOR (default: $EDITOR)") do |ed|
38
+ @options.editor = ed || ENV["EDITOR"] # rubocop:disable Style/FetchEnvVar
39
+ end
40
+ parser.on("-v", "--visual [EDITOR]", "Use EDITOR (default: $VISUAL)") do |ed|
41
+ @options.editor = ed || ENV["VISUAL"] # rubocop:disable Style/FetchEnvVar
42
+ end
43
+ parser.on("-r", "--review", "Require review and confirmation of changes")
44
+
45
+ parser.on("-h", "--help", "Show help for options") do
46
+ exit_with_msg(parser)
47
+ end
48
+ end
49
+
50
+ def exit_with_msg(message)
51
+ puts message
52
+ exit(false)
53
+ end
54
+
55
+ def review_changes
56
+ puts
57
+ puts @requests.change_requested.summarize
58
+ print "\nContinue? (y/n) "
59
+ confirmation = %w[y yes].include?($stdin.gets.chomp.downcase)
60
+ exit_with_msg("Nothing changed") unless confirmation
61
+ end
62
+
63
+ def check_inputs
64
+ @files = files.empty? ? Dir.glob("*") : files
65
+ exit_with_msg(EDITOR_MSG) unless options.editor
66
+ end
67
+
68
+ def user_review?
69
+ @options.review && @requests.changes_requested?
70
+ end
71
+
72
+ def call
73
+ @requests = Reen::Reen.new(editor: options.editor).request(files)
74
+ review_changes if user_review?
75
+
76
+ changes = @requests
77
+ .execute_all
78
+ .change_requested
79
+
80
+ if user_review? && changes.all_executed?
81
+ puts "Changes made"
82
+ else
83
+ puts changes.summarize
84
+ end
85
+ end
86
+ end
87
+
88
+ begin
89
+ ReenCLI.new(ARGV).call
90
+ rescue Reen::Error => e
91
+ puts "#{e.message}\n"
92
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reen
4
+ module Actions
5
+ # Deletes a file
6
+ class Delete
7
+ def initialize(old_name, new_name)
8
+ @old_name = old_name
9
+ @new_name = new_name
10
+ end
11
+
12
+ def call
13
+ File.delete(@old_name)
14
+ nil
15
+ rescue Errno::ENOENT
16
+ "Could not delete"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reen
4
+ module Actions
5
+ # Deletes a file
6
+ class ForceDelete
7
+ def initialize(old_name, new_name)
8
+ @old_name = old_name
9
+ @new_name = new_name
10
+ end
11
+
12
+ def call
13
+ FileUtils.rm_rf(@old_name)
14
+ nil
15
+ rescue Errno::ENOENT
16
+ "Could not force delete"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reen
4
+ module Actions
5
+ # Does nothing to items
6
+ class DoNothing
7
+ def initialize(old_name, new_name)
8
+ @old_name = old_name
9
+ @new_name = new_name
10
+ end
11
+
12
+ def call
13
+ nil
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reen
4
+ module Actions
5
+ # Renames files
6
+ class Rename
7
+ def initialize(old_name, new_name)
8
+ @old_name = old_name
9
+ @new_name = new_name
10
+ end
11
+
12
+ def call
13
+ File.rename(@old_name, @new_name)
14
+ nil
15
+ rescue Errno::ENOENT
16
+ "No such target file or directory"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "actions/delete"
4
+ require_relative "actions/force_delete"
5
+ require_relative "actions/rename"
6
+ require_relative "actions/nothing"
7
+
8
+ module Reen
9
+ # Change to an orignal file
10
+ class Change
11
+ attr_reader :original, :requested, :change, :status
12
+
13
+ module STATUS
14
+ ACCEPTED = :accepted
15
+ REJECTED = :rejected
16
+ EXECUTED = :executed
17
+ FAILED = :failed
18
+ end
19
+
20
+ module CHANGE
21
+ NONE = :none
22
+ DELETE = :delete
23
+ FORCE_DELETE = :force_delete
24
+ RENAME = :rename
25
+ end
26
+
27
+ module OBJECT
28
+ FILE = :file
29
+ FOLDER = :folder
30
+ end
31
+
32
+ ACTION_HANDLER = {
33
+ CHANGE::NONE => Actions::DoNothing,
34
+ CHANGE::DELETE => Actions::Delete,
35
+ CHANGE::FORCE_DELETE => Actions::ForceDelete,
36
+ CHANGE::RENAME => Actions::Rename
37
+ }.freeze
38
+
39
+ CHANGES_DESC = {
40
+ CHANGE::NONE => "Nothing",
41
+ CHANGE::DELETE => "Deleting",
42
+ CHANGE::FORCE_DELETE => "Force deleting",
43
+ CHANGE::RENAME => "Renaming"
44
+ }.freeze
45
+
46
+ def initialize(original, requested)
47
+ @entry = original
48
+ @original = @entry.path
49
+ extract_request(requested)
50
+ decide_object
51
+ decide_change
52
+ decide_status_reason
53
+ end
54
+
55
+ def extract_request(str)
56
+ requested = str.match(/^\s*(?<op>--|-) +(?<name>.*)/) || str.match(/^(?<op>)(?<name>.*)/)
57
+
58
+ @operator = requested[:op]
59
+ @requested = requested[:name].strip
60
+ end
61
+
62
+ def decide_change
63
+ @change =
64
+ if @operator == "--"
65
+ CHANGE::FORCE_DELETE
66
+ elsif @operator == "-"
67
+ CHANGE::DELETE
68
+ elsif @original != @requested
69
+ CHANGE::RENAME
70
+ else
71
+ CHANGE::NONE
72
+ end
73
+ end
74
+
75
+ def decide_object
76
+ @object = OBJECT::FILE if request_file?
77
+ @object = OBJECT::FOLDER if request_dir?
78
+ end
79
+
80
+ def decide_status_reason
81
+ if request_full_dir? && request_delete?
82
+ @status = STATUS::REJECTED
83
+ @reason = "Directories with files cannot be changed"
84
+ else
85
+ @status = STATUS::ACCEPTED
86
+ @reason = ""
87
+ end
88
+ end
89
+
90
+ def execute
91
+ return self if not_accepted?
92
+
93
+ @status = STATUS::EXECUTED
94
+ error = ACTION_HANDLER[@change].new(@original, @requested).call
95
+
96
+ if error
97
+ @status = STATUS::FAILED
98
+ @reason = error
99
+ end
100
+
101
+ self
102
+ end
103
+
104
+ # Predicates
105
+
106
+ def request_dir? = @entry.dir?
107
+
108
+ def request_file? = @entry.file?
109
+
110
+ def request_nothing? = @change == CHANGE::NONE
111
+
112
+ def request_rename? = @change == CHANGE::RENAME
113
+
114
+ def request_delete? = @change == CHANGE::DELETE
115
+
116
+ def request_empty_dir? = @entry.empty_dir?
117
+
118
+ def accepted? = @status == STATUS::ACCEPTED
119
+
120
+ def not_accepted? = !accepted?
121
+
122
+ def executed? = @status == STATUS::EXECUTED
123
+
124
+ def rejected? = @status == STATUS::REJECTED
125
+
126
+ def failed? = @status == STATUS::FAILED
127
+
128
+ def request_full_dir? = @entry.full_dir?
129
+
130
+ def executed_or_rejected? = %i[executed rejected].include?(@status)
131
+
132
+ # Decoration
133
+
134
+ def to_s
135
+ file_desc =
136
+ case @change
137
+ when CHANGE::RENAME
138
+ "#{@original} -> #{@requested}"
139
+ else
140
+ @original
141
+ end
142
+
143
+ reason_desc = rejected? || failed? ? " (failed: #{@reason})" : ""
144
+
145
+ "#{CHANGES_DESC[@change]}: #{file_desc}#{reason_desc}"
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "change"
4
+
5
+ module Reen
6
+ # Change to an orignal file
7
+ class Changes
8
+ attr_reader :list
9
+
10
+ def initialize(changes_list)
11
+ @list = changes_list
12
+ end
13
+
14
+ def execute_all
15
+ @list.map(&:execute)
16
+ self
17
+ end
18
+
19
+ # Queries
20
+
21
+ def rename_requested
22
+ Changes.new(@list.select(&:request_rename?))
23
+ end
24
+
25
+ def delete_requested
26
+ Changes.new(@list.select(&:request_delete?))
27
+ end
28
+
29
+ def change_requested
30
+ Changes.new(@list.reject(&:request_nothing?))
31
+ end
32
+
33
+ def rejected
34
+ Changes.new(@list.select(&:rejected?))
35
+ end
36
+
37
+ def accepted
38
+ Changes.new(@list.select(&:accepted?))
39
+ end
40
+
41
+ def executed
42
+ Changes.new(@list.select(&:executed?))
43
+ end
44
+
45
+ def failed
46
+ Changes.new(@list.select(&:failed?))
47
+ end
48
+
49
+ def any?
50
+ !@list.empty?
51
+ end
52
+
53
+ def count
54
+ @list.size
55
+ end
56
+
57
+ # Decoration
58
+
59
+ def summarize
60
+ return "Nothing changed" if @list.empty?
61
+
62
+ @list.join("\n")
63
+ end
64
+
65
+ # Predicates
66
+
67
+ def no_changes_requested?
68
+ list.map(&:change).all? Change::CHANGE::NONE
69
+ end
70
+
71
+ def changes_requested?
72
+ !no_changes_requested?
73
+ end
74
+
75
+ def all_executed?
76
+ @list.all?(&:executed?)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module Reen
6
+ # Manages a temporary file with requested changes
7
+ class ChangesFile
8
+ INSTRUCTIONS = <<~COMMENTS
9
+ # Edit the names of any files/folders to rename or move them
10
+ # - Put a dash followed by a space to delete a file or empty folder: - filename
11
+ # - Put double dash followed by a space to force delete: -- filename
12
+ # - Do not change or remove the [NN] number prefixes
13
+ # - You may reorder lines freely; numbers track the original files
14
+
15
+ COMMENTS
16
+
17
+ attr_accessor :list
18
+
19
+ def initialize(entry_list)
20
+ @entry_list = entry_list
21
+ @list_file = Tempfile.new("reen-")
22
+ @list_file.write(INSTRUCTIONS)
23
+ @list_file.write(@entry_list.to_numbered.join("\n"))
24
+ @list_file.close
25
+ end
26
+
27
+ def allow_changes(editor, &block)
28
+ await_editor(editor) if editor
29
+ @list = File.read(path).split("\n").map(&:strip)
30
+ .reject { |line| line.start_with?("#") || line.empty? }
31
+
32
+ block&.call(self)
33
+ @list
34
+ end
35
+
36
+ private
37
+
38
+ def path
39
+ @list_file.path
40
+ end
41
+
42
+ def await_editor(editor)
43
+ system("#{editor} #{@list_file.path}")
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reen
4
+ # Wraps a file path and answers filesystem queries
5
+ class PathEntry
6
+ attr_reader :path
7
+
8
+ def initialize(path)
9
+ @path = path
10
+ end
11
+
12
+ def file? = File.file?(@path)
13
+
14
+ def dir? = Dir.exist?(@path)
15
+
16
+ def empty_dir? = dir? && Dir.empty?(@path)
17
+
18
+ def full_dir? = dir? && !Dir.empty?(@path)
19
+ end
20
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path_entry"
4
+
5
+ module Reen
6
+ # Collection of PathEntry objects, constructed from an array of path strings
7
+ class PathEntryList
8
+ include Enumerable
9
+
10
+ def initialize(path_strings)
11
+ @entries = path_strings.map { |p| PathEntry.new(p) }
12
+ end
13
+
14
+ def each(&block)
15
+ @entries.each(&block)
16
+ end
17
+
18
+ def paths
19
+ @entries.map(&:path)
20
+ end
21
+
22
+ def to_numbered
23
+ width = @entries.size.to_s.length
24
+ @entries.each_with_index.map do |entry, i|
25
+ num = format("%0#{width}d", i + 1)
26
+ "[#{num}] #{entry.path}"
27
+ end
28
+ end
29
+
30
+ def self.from_numbered(lines)
31
+ lines.each_with_object({}) do |line, hash|
32
+ match = line.match(/^\[(\d+)\] (.*)/)
33
+ next unless match
34
+
35
+ hash[match[1].to_i] = match[2]
36
+ end
37
+ end
38
+ end
39
+ end
data/lib/reen/reen.rb ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reen
4
+ # Renames pattern of files with given editor
5
+ # Examples:
6
+ # Reen::Reen.new(editor: "code -w").call("spec/fixtures/example/*")
7
+ # Reen::Reen.new(editor: nil).call("spec/fixtures/example/*") { ... }
8
+ class Reen
9
+ DEL_ERROR = "Do not remove any file/folder names (no changes made)"
10
+
11
+ attr_reader :changes
12
+
13
+ def initialize(editor: "emacs", options: {})
14
+ @editor = editor
15
+ @options = options
16
+ end
17
+
18
+ def request(original_list, &block)
19
+ @entry_list = PathEntryList.new(original_list)
20
+ changed_list = ChangesFile.new(@entry_list).allow_changes(@editor, &block)
21
+
22
+ raise(Error, DEL_ERROR) if changed_list.size != @entry_list.count
23
+
24
+ @changes = compare_lists(@entry_list, changed_list)
25
+ .then { |change_array| Changes.new(change_array) }
26
+ end
27
+
28
+ def execute(original_list, &block)
29
+ @changes ||= request(original_list, &block)
30
+ @changes = @changes.execute_all
31
+ end
32
+
33
+ private
34
+
35
+ def compare_lists(entry_list, changed_list)
36
+ changed_by_number = PathEntryList.from_numbered(changed_list)
37
+ entries = entry_list.to_a
38
+
39
+ entries.each_with_index.map do |entry, i|
40
+ number = i + 1
41
+ revised = changed_by_number[number]
42
+ Change.new(entry, revised)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reen
4
+ VERSION = "1.0.0"
5
+ end
data/lib/reen.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "reen/version"
4
+ require_relative "reen/path_entry"
5
+ require_relative "reen/path_entry_list"
6
+ require_relative "reen/changes_file"
7
+ require_relative "reen/change"
8
+ require_relative "reen/changes"
9
+ require_relative "reen/reen"
10
+
11
+ module Reen
12
+ class Error < StandardError; end
13
+ end
data/sig/reenrb.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Reenrb
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: reen
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Soumya Ray
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Renames or deletes a pattern of files using your favorite editor
13
+ email:
14
+ - soumya.ray@gmail.com
15
+ executables:
16
+ - reen
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".claude/CLAUDE.md"
21
+ - ".markdownlint.json"
22
+ - ".rubocop.yml"
23
+ - CHANGELOG.md
24
+ - CLAUDE.feature-numbered-lists.md
25
+ - Gemfile
26
+ - LICENSE.txt
27
+ - README.md
28
+ - Rakefile
29
+ - bin/reen
30
+ - lib/reen.rb
31
+ - lib/reen/actions/delete.rb
32
+ - lib/reen/actions/force_delete.rb
33
+ - lib/reen/actions/nothing.rb
34
+ - lib/reen/actions/rename.rb
35
+ - lib/reen/change.rb
36
+ - lib/reen/changes.rb
37
+ - lib/reen/changes_file.rb
38
+ - lib/reen/path_entry.rb
39
+ - lib/reen/path_entry_list.rb
40
+ - lib/reen/reen.rb
41
+ - lib/reen/version.rb
42
+ - sig/reenrb.rbs
43
+ homepage: https://github.com/soumyaray/reen
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ homepage_uri: https://github.com/soumyaray/reen
48
+ source_code_uri: https://github.com/soumyaray/reen
49
+ rubygems_mfa_required: 'true'
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '3.0'
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ requirements: []
64
+ rubygems_version: 4.0.7
65
+ specification_version: 4
66
+ summary: Renames or deletes a pattern of files using your favorite editor
67
+ test_files: []