From e2e563b0e72497328e52f0faed4276bd80cf649b Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Sat, 14 Mar 2026 19:05:11 +0000 Subject: [PATCH 1/2] Remove unused memoized caches from Context (#1644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Remove `class_attributes` and `instance_attributes` methods (zero callers) - Remove obsolete `instance_method_list` method (deprecated wrapper) - Remove memoization from `class_method_list` and `instance_methods` — these are only called once per class/module during generation, so caching adds no benefit --- lib/rdoc/code_object/context.rb | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/lib/rdoc/code_object/context.rb b/lib/rdoc/code_object/context.rb index 03d7f96142..6bdb475f00 100644 --- a/lib/rdoc/code_object/context.rb +++ b/lib/rdoc/code_object/context.rb @@ -640,18 +640,11 @@ def child_name(name) end end - ## - # Class attributes - - def class_attributes - @class_attributes ||= attributes.select { |a| a.singleton } - end - ## # Class methods def class_method_list - @class_method_list ||= method_list.select { |a| a.singleton } + method_list.select { |a| a.singleton } end ## @@ -951,28 +944,11 @@ def http_url File.join(*path.compact) + '.html' end - ## - # Instance attributes - - def instance_attributes - @instance_attributes ||= attributes.reject { |a| a.singleton } - end - ## # Instance methods def instance_methods - @instance_methods ||= method_list.reject { |a| a.singleton } - end - - ## - # Instance methods - #-- - # TODO remove this later - - def instance_method_list - warn '#instance_method_list is obsoleted, please use #instance_methods' - @instance_methods ||= method_list.reject { |a| a.singleton } + method_list.reject { |a| a.singleton } end ## From 3c6f5f6fd253dfac7ac6af61e05692b05912089b Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Sat, 14 Mar 2026 22:46:33 +0000 Subject: [PATCH 2/2] Add server mode with live reload (`rdoc --server`) (#1620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A better attempt of #1151 Implement `rdoc --server[=PORT]` for previewing documentation with automatic browser refresh on source file changes. The server parses all sources on startup, serves pages from memory via the Aliki generator, and watches for file modifications, additions, and deletions — re-parsing only what changed. https://github.com/user-attachments/assets/06d5ae7b-cc54-4cc5-a8b0-1cc30177cadb ## Changes - **New `RDoc::Server`** (`lib/rdoc/server.rb`) — minimal HTTP server using Ruby's built-in `TCPServer` (no WEBrick or external dependencies) - Thread-per-connection with `Connection: close` - Persistent Aliki generator instance rendering pages to strings - In-memory page cache with full invalidation on changes - Live reload via inline JS polling `/__status` endpoint every 1 second - Background file watcher polling source file mtimes every 1 second - Incremental re-parse: only changed files are re-parsed, old data removed first - Detection of new and deleted source files - **`--server[=PORT]`** CLI option (default port 4000) and `rdoc:server` Rake task - **`RDoc::Store#remove_file`** — removes a file's entries from the store hashes - **`RDoc::Store#clear_file_contributions`** — surgically removes a file's methods, constants, comments, includes, extends, and aliases from its classes/modules, preserving classes that span multiple files. Supports `keep_position:` for server re-parse to preserve comment ordering. - **`RDoc::RDoc#relative_path_for`** — extracted path normalization (against `options.root` and `options.page_dir`) shared by `parse_file` and the server - **`Darkfish#refresh_store_data`** — extracted for reuse by the server after re-parsing - **`RDoc::Servlet` → `RDoc::RI::Servlet`** — moved to clarify RI-specific usage ## Security & robustness - Binds to `127.0.0.1` only (localhost) - Path traversal protection in asset serving (`File.expand_path` containment check) - Proper HTTP error responses: 400, 404, 405, 500 - 5-second `IO.select` read timeout on client sockets - Mutex protects all store mutations, generator refresh, and cache invalidation atomically - Individual `parse_file` errors rescued so one failure doesn't block remaining files - Watcher thread uses `@running` flag with clean shutdown via `Thread#join` ## Known limitations - **Full cache invalidation:** any file change clears all cached pages. Rendering is fast (~ms per page); parsing is the expensive part and is done incrementally. - **Template/CSS changes:** require server restart (only source files are watched). --------- Co-authored-by: Sutou Kouhei --- .claude/skills/test-server/SKILL.md | 120 +++++++++ AGENTS.md | 130 ++++----- README.md | 35 +++ lib/rdoc.rb | 2 +- lib/rdoc/code_object/class_module.rb | 12 + lib/rdoc/generator/darkfish.rb | 9 + lib/rdoc/options.rb | 18 ++ lib/rdoc/rdoc.rb | 63 ++++- lib/rdoc/ri.rb | 7 +- lib/rdoc/ri/driver.rb | 2 +- lib/rdoc/{ => ri}/servlet.rb | 8 +- lib/rdoc/server.rb | 378 +++++++++++++++++++++++++++ lib/rdoc/store.rb | 128 +++++++++ lib/rdoc/task.rb | 16 ++ test/rdoc/rdoc_servlet_test.rb | 6 +- test/rdoc/rdoc_store_test.rb | 244 +++++++++++++++++ 16 files changed, 1080 insertions(+), 98 deletions(-) create mode 100644 .claude/skills/test-server/SKILL.md rename lib/rdoc/{ => ri}/servlet.rb (98%) create mode 100644 lib/rdoc/server.rb diff --git a/.claude/skills/test-server/SKILL.md b/.claude/skills/test-server/SKILL.md new file mode 100644 index 0000000000..182b188ee0 --- /dev/null +++ b/.claude/skills/test-server/SKILL.md @@ -0,0 +1,120 @@ +--- +name: test-server +description: E2E testing workflow for the RDoc live-reload server (rdoc --server) +--- + +# Test Server + +End-to-end testing workflow for the RDoc live-reload server. Use after modifying server code, templates, generators, or routing. + +## Steps + +### 1. Start the server + +```bash +bundle exec rdoc --server & +SERVER_PID=$! +sleep 2 # wait for TCP server to bind +``` + +Or on a custom port: + +```bash +bundle exec rdoc --server=8080 & +``` + +Default port is 4000. + +### 2. Verify core endpoints + +Run these curl checks against the running server: + +```bash +# Root → 200, HTML index page +curl -s -o /dev/null -w '%{http_code}' http://localhost:4000/ +# Expected: 200 + +# Status endpoint → 200, JSON with last_change float +curl -s http://localhost:4000/__status +# Expected: {"last_change":1234567890.123} + +# Class page → 200, HTML with live-reload script +curl -s http://localhost:4000/RDoc.html | head -5 +# Expected: HTML containing class documentation + +# CSS asset → 200, stylesheet +curl -s -o /dev/null -w '%{http_code}' http://localhost:4000/css/rdoc.css +# Expected: 200 + +# JS search index → 200, search data +curl -s -o /dev/null -w '%{http_code}' http://localhost:4000/js/search_data.js +# Expected: 200 + +# Missing page → 404, still has live-reload script +curl -s -w '\n%{http_code}' http://localhost:4000/Missing.html | tail -1 +# Expected: 404 + +# Path traversal via asset route → 404 (blocked by expand_path check) +curl -s -o /dev/null -w '%{http_code}' 'http://localhost:4000/css/../../etc/passwd' +# Expected: 404 +``` + +### 3. Verify live-reload + +HTML pages should contain the live-reload polling script: + +```bash +# Check for live-reload script in a class page +curl -s http://localhost:4000/RDoc.html | grep 'var lastChange' +# Expected: var lastChange = ; + +# Check that 404 pages also get live-reload +curl -s http://localhost:4000/Missing.html | grep 'var lastChange' +# Expected: var lastChange = ; +``` + +The script polls `/__status` and reloads when `data.last_change > lastChange`. + +### 4. Verify file change detection + +Confirm the server detects source file changes and invalidates its cache: + +```bash +# Record the current last_change timestamp +BEFORE=$(curl -s http://localhost:4000/__status | grep -o '"last_change":[0-9.]*' | cut -d: -f2) + +# Touch a source file to trigger the file watcher +touch lib/rdoc.rb +sleep 2 # watcher polls every 1 second + +# Check that last_change has advanced +AFTER=$(curl -s http://localhost:4000/__status | grep -o '"last_change":[0-9.]*' | cut -d: -f2) +echo "before=$BEFORE after=$AFTER" +# Expected: AFTER > BEFORE +``` + +### 5. (Optional) Visual testing with Playwright CLI + +For visual inspection of rendered pages, use Playwright CLI commands directly: + +```bash +# Install browsers (one-time) +npx playwright install chromium + +# Take a screenshot of the index page +npx playwright screenshot http://localhost:4000/ /tmp/rdoc-index.png + +# Take a screenshot of a specific class page +npx playwright screenshot http://localhost:4000/RDoc.html /tmp/rdoc-class.png + +# Full-page screenshot +npx playwright screenshot --full-page http://localhost:4000/RDoc.html /tmp/rdoc-full.png +``` + +Review the screenshots to verify layout, styling, and content rendering. + +### 6. Stop the server + +```bash +kill $SERVER_PID 2>/dev/null +``` diff --git a/AGENTS.md b/AGENTS.md index a51f9ddce7..81acf64ae5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,12 @@ bundle exec rake rerdoc # Show documentation coverage bundle exec rake rdoc:coverage bundle exec rake coverage + +# Start live-reloading preview server (port 4000) +bundle exec rake rdoc:server + +# Or via CLI with custom port +bundle exec rdoc --server=8080 ``` **Output Directory:** `_site/` (GitHub Pages compatible) @@ -176,6 +182,7 @@ lib/rdoc/ │ ├── c.rb # C extension parser │ ├── prism_ruby.rb # Prism-based Ruby parser │ └── ... +├── server.rb # Live-reloading preview server (rdoc --server) ├── generator/ # Documentation generators │ ├── aliki.rb # HTML generator (default theme) │ ├── darkfish.rb # HTML generator (deprecated, will be removed in v8.0) @@ -232,6 +239,30 @@ exe/ - **Parsers:** Ruby, C, Markdown, RD, Prism-based Ruby (experimental) - **Generators:** HTML/Aliki (default), HTML/Darkfish (deprecated), RI, POT (gettext), JSON, Markup +### Live Preview Server (`RDoc::Server`) + +The server (`lib/rdoc/server.rb`) provides `rdoc --server` for live documentation preview. + +**Architecture:** +- Uses Ruby's built-in `TCPServer` (`socket` stdlib) — no WEBrick or external dependencies +- Creates a persistent `RDoc::Generator::Aliki` instance with `file_output = false` (renders to strings) +- Thread-per-connection HTTP handling with `Connection: close` (no keep-alive) +- Background watcher thread polls file mtimes every 1 second +- Live reload via inline JS polling `/__status` endpoint + +**Key files:** +- `lib/rdoc/server.rb` — HTTP server, routing, caching, file watcher +- `lib/rdoc/rdoc.rb` — `start_server` method, server branch in `document` +- `lib/rdoc/options.rb` — `--server[=PORT]` option +- `lib/rdoc/generator/darkfish.rb` — `refresh_store_data` (extracted for server reuse) +- `lib/rdoc/store.rb` — `remove_file` (for deleted file handling) +- `lib/rdoc/task.rb` — `rdoc:server` Rake task + +**Known limitations:** +- Reopened classes: deleting a file that partially defines a class removes the entire class from the store (save the other file to restore) +- Template/CSS changes require server restart (only source files are watched) +- Full page cache invalidation on any change (rendering is fast, so this is acceptable) + ## Common Workflows Do NOT commit anything. Ask the developer to review the changes after tasks are finished. @@ -319,20 +350,36 @@ When editing markup reference documentation, such as `doc/markup_reference/markd When making changes to theme CSS or templates (e.g., Darkfish or Aliki themes): -1. **Generate documentation**: Run `bundle exec rake rerdoc` to create baseline -2. **Start HTTP server**: Run `cd _site && python3 -m http.server 8000` (use different port if 8000 is in use) -3. **Investigate with Playwright**: Ask the AI assistant to take screenshots and inspect the documentation visually - - Example: "Navigate to the docs at localhost:8000 and screenshot the RDoc module page" - - See "Playwright MCP for Testing Generated Documentation" section below for details -4. **Make changes**: Edit files in `lib/rdoc/generator/template//` as needed -5. **Regenerate**: Run `bundle exec rake rerdoc` to rebuild documentation with changes -6. **Verify with Playwright**: Take new screenshots and compare to original issues -7. **Lint changes** (if modified): +1. **Start the live-reloading server**: Run `bundle exec rdoc --server` (or `bundle exec rake rdoc:server`) +2. **Make changes**: Edit files in `lib/rdoc/generator/template//` or source code +3. **Browser auto-refreshes**: The server detects file changes and refreshes the browser automatically +4. **Verify with `/test-server`**: Use the test-server skill for endpoint checks, live-reload verification, and optional Playwright screenshots +5. **Lint changes** (if modified): - ERB templates: `npx @herb-tools/linter "lib/rdoc/generator/template/**/*.rhtml"` - CSS files: `npm run lint:css -- --fix` -8. **Stop server**: Kill the HTTP server process when done -**Tip:** Keep HTTP server running during iteration. Just regenerate with `bundle exec rake rerdoc` between changes. +**Note:** The server watches source files, not template files. If you modify `.rhtml` templates or CSS in the template directory, restart the server to pick up those changes. + +## Visual Testing with Playwright CLI + +Use `npx playwright` to take screenshots of generated documentation — works with both the live-reload server and static `_site/` output. + +```bash +# Install browsers (one-time) +npx playwright install chromium + +# Screenshot a live server page +npx playwright screenshot http://localhost:4000/RDoc.html /tmp/rdoc-class.png + +# Screenshot static output (start a file server first) +cd _site && python3 -m http.server 8000 & +npx playwright screenshot http://localhost:8000/index.html /tmp/rdoc-index.png + +# Full-page screenshot +npx playwright screenshot --full-page http://localhost:4000/RDoc.html /tmp/rdoc-full.png +``` + +For server-specific E2E testing (endpoint checks, live-reload verification, file change detection), use the `/test-server` skill. ## Notes for AI Agents @@ -345,64 +392,3 @@ When making changes to theme CSS or templates (e.g., Darkfish or Aliki themes): 4. **Use `rake rerdoc`** to regenerate documentation (not just `rdoc`) 5. **Verify generated files** with `rake verify_generated` 6. **Don't edit generated files** directly (in `lib/rdoc/markdown/` and `lib/rdoc/rd/`) - -## Playwright MCP for Testing Generated Documentation - -The Playwright MCP server enables visual inspection and interaction with generated HTML documentation. This is useful for verifying CSS styling, layout issues, and overall appearance. - -**MCP Server:** `@playwright/mcp` (Microsoft's official browser automation server) - -### Setup - -The Playwright MCP server can be used with any MCP-compatible AI tool (Claude Code, Cursor, GitHub Copilot, OpenAI Agents, etc.). - -**Claude Code:** - -```bash -/plugin playwright -``` - -**Other MCP-compatible tools:** - -```bash -npx @playwright/mcp@latest -``` - -Configure your tool to connect to this MCP server. Playwright launches its own browser instance automatically - no manual browser setup or extensions required. - -### Troubleshooting: Chrome Remote Debugging Blocked - -If you encounter `DevTools remote debugging is disallowed by the system admin`, Chrome's debugging is blocked by the machine's policy. Use Firefox instead: - -```bash -# Install Firefox for Playwright -npx playwright install firefox - -# Add Playwright MCP with Firefox to your project (creates/updates .mcp.json) -claude mcp add playwright --scope project -- npx -y @playwright/mcp@latest --browser firefox -``` - -Restart Claude Code after running these commands. - -### Testing Generated Documentation - -To test the generated documentation: - -```bash -# Generate documentation -bundle exec rake rerdoc - -# Start a simple HTTP server in the _site directory (use an available port) -cd _site && python3 -m http.server 8000 -``` - -If port 8000 is already in use, try another port (e.g., `python3 -m http.server 9000`). - -Then ask the AI assistant to inspect the documentation. It will use the appropriate Playwright tools (`browser_navigate`, `browser_snapshot`, `browser_take_screenshot`, etc.) based on your request. - -**Example requests:** - -- "Navigate to `http://localhost:8000` and take a screenshot" -- "Take a screenshot of the RDoc module page" -- "Check if code blocks are rendering properly on the Markup page" -- "Compare the index page before and after my CSS changes" diff --git a/README.md b/README.md index 510f12b5b2..3f3e4519a1 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,41 @@ There are also a few community-maintained themes for RDoc: Please follow the theme's README for usage instructions. +## Live Preview Server + +RDoc includes a built-in server for previewing documentation while you edit source files. It parses your code once on startup, then watches for changes and auto-refreshes the browser. + +```shell +rdoc --server +``` + +This starts a server at `http://localhost:4000`. You can specify a different port: + +```shell +rdoc --server=8080 +``` + +Or use the Rake task: + +```shell +rake rdoc:server +``` + +### How It Works + +- Parses all source files on startup and serves pages from memory using the Aliki theme +- A background thread polls file mtimes every second +- When a file changes, only that file is re-parsed — the browser refreshes automatically +- New files are detected and added; deleted files are removed + +**No external dependencies.** The server uses Ruby's built-in `TCPServer` (`socket` stdlib) — no WEBrick or other gems required. + +### Limitations + +- **Reopened classes and file deletion.** If a class is defined across multiple files (e.g. `Foo` in both `a.rb` and `b.rb`), deleting one file removes the entire class from the store, including parts from the other file. Saving the remaining file triggers a re-parse that restores it. +- **Full cache invalidation.** Any file change clears all cached pages. This is simple and correct — rendering is fast (~ms per page), parsing is the expensive part and is done incrementally. +- **No HTTPS or HTTP/2.** The server is intended for local development preview only. + ## Bugs See [CONTRIBUTING.md](CONTRIBUTING.md) for information on filing a bug report. It's OK to file a bug report for anything you're having a problem with. If you can't figure out how to make RDoc produce the output you like that is probably a documentation bug. diff --git a/lib/rdoc.rb b/lib/rdoc.rb index d93d79e4d1..3629f018a5 100644 --- a/lib/rdoc.rb +++ b/lib/rdoc.rb @@ -160,7 +160,7 @@ def self.home autoload :Generator, "#{__dir__}/rdoc/generator" autoload :Options, "#{__dir__}/rdoc/options" autoload :Parser, "#{__dir__}/rdoc/parser" - autoload :Servlet, "#{__dir__}/rdoc/servlet" + autoload :Server, "#{__dir__}/rdoc/server" autoload :RI, "#{__dir__}/rdoc/ri" autoload :Stats, "#{__dir__}/rdoc/stats" autoload :Store, "#{__dir__}/rdoc/store" diff --git a/lib/rdoc/code_object/class_module.rb b/lib/rdoc/code_object/class_module.rb index b8c95157fe..b564059ae7 100644 --- a/lib/rdoc/code_object/class_module.rb +++ b/lib/rdoc/code_object/class_module.rb @@ -747,6 +747,18 @@ def search_snippet snippet(first_comment) end + ## + # Rebuilds +@comment+ from the current +@comment_location+ entries, + # skipping any empty placeholders. + + def rebuild_comment_from_location + texts = @comment_location.each_value.flat_map { |comments| + comments.filter_map { |c| c.to_s unless c.empty? } + } + merged = texts.join("\n---\n") + @comment = merged.empty? ? '' : RDoc::Comment.new(merged) + end + ## # Sets the store for this class or module and its contained code objects. diff --git a/lib/rdoc/generator/darkfish.rb b/lib/rdoc/generator/darkfish.rb index 574918500f..8e63f5bd23 100644 --- a/lib/rdoc/generator/darkfish.rb +++ b/lib/rdoc/generator/darkfish.rb @@ -577,6 +577,15 @@ def setup return unless @store + refresh_store_data + end + + ## + # Refreshes the generator's data from the store. Called by #setup and + # can be called again after the store has been updated (e.g. in server + # mode after re-parsing changed files). + + def refresh_store_data @classes = @store.all_classes_and_modules.sort @files = @store.all_files.sort @methods = @classes.flat_map { |m| m.method_list }.sort diff --git a/lib/rdoc/options.rb b/lib/rdoc/options.rb index a74db7a79e..5bc23f832e 100644 --- a/lib/rdoc/options.rb +++ b/lib/rdoc/options.rb @@ -95,6 +95,7 @@ class RDoc::Options pipe rdoc_include root + server_port static_path template template_dir @@ -328,6 +329,13 @@ class RDoc::Options attr_reader :visibility + ## + # When set to a port number, starts a live-reloading server instead of + # writing files. Defaults to +false+ (no server). Set via + # --server[=PORT]. + + attr_reader :server_port + ## # Indicates if files of test suites should be skipped attr_accessor :skip_tests @@ -410,6 +418,7 @@ def init_ivars # :nodoc: @output_decoration = true @rdoc_include = [] @root = Pathname(Dir.pwd) + @server_port = false @show_hash = false @static_path = [] @tab_width = 8 @@ -1123,6 +1132,15 @@ def parse(argv) opt.separator "Generic options:" opt.separator nil + opt.on("--server[=PORT]", Integer, + "Start a web server to preview", + "documentation with live reload.", + "Defaults to port 4000.") do |port| + @server_port = port || 4000 + end + + opt.separator nil + opt.on("--write-options", "Write .rdoc_options to the current", "directory with the given options. Not all", diff --git a/lib/rdoc/rdoc.rb b/lib/rdoc/rdoc.rb index f14db7c2c7..65fab1a833 100644 --- a/lib/rdoc/rdoc.rb +++ b/lib/rdoc/rdoc.rb @@ -332,20 +332,7 @@ def parse_file(filename) return unless content - filename_path = Pathname(filename).expand_path - begin - relative_path = filename_path.relative_path_from @options.root - rescue ArgumentError - relative_path = filename_path - end - - if @options.page_dir and - relative_path.to_s.start_with? @options.page_dir.to_s then - relative_path = - relative_path.relative_path_from @options.page_dir - end - - top_level = @store.add_file filename, relative_name: relative_path.to_s + top_level = @store.add_file filename, relative_name: relative_path_for(filename) parser = RDoc::Parser.for top_level, content, @options, @stats @@ -388,6 +375,28 @@ def parse_file(filename) raise e end + ## + # Returns the relative path for +filename+ against +options.root+ (and + # +options.page_dir+ when set). This is the key used by RDoc::Store to + # identify files. + + def relative_path_for(filename) + filename_path = Pathname(filename).expand_path + begin + relative_path = filename_path.relative_path_from @options.root + rescue ArgumentError + relative_path = filename_path + end + + if @options.page_dir && + relative_path.to_s.start_with?(@options.page_dir.to_s) + relative_path = + relative_path.relative_path_from @options.page_dir + end + + relative_path.to_s + end + ## # Parse each file on the command line, recursively entering directories. @@ -456,6 +465,19 @@ def document(options) exit end + if @options.server_port + @store.load_cache + + parse_files @options.files + + @options.default_title = "RDoc Documentation" + + @store.complete @options.visibility + + start_server + exit + end + unless @options.coverage_report then @last_modified = setup_output_dir @options.op_dir, @options.force_update end @@ -518,6 +540,19 @@ def generate end end + ## + # Starts a live-reloading HTTP server for previewing documentation. + # Called from #document when --server is given. + + def start_server + server = RDoc::Server.new(self, @options.server_port) + + trap('INT') { server.shutdown } + trap('TERM') { server.shutdown } + + server.start + end + ## # Removes a siginfo handler and replaces the previous diff --git a/lib/rdoc/ri.rb b/lib/rdoc/ri.rb index 0af05f729f..ccf11c4636 100644 --- a/lib/rdoc/ri.rb +++ b/lib/rdoc/ri.rb @@ -13,8 +13,9 @@ module RDoc::RI class Error < RDoc::Error; end - autoload :Driver, "#{__dir__}/ri/driver" - autoload :Paths, "#{__dir__}/ri/paths" - autoload :Store, "#{__dir__}/ri/store" + autoload :Driver, "#{__dir__}/ri/driver" + autoload :Paths, "#{__dir__}/ri/paths" + autoload :Servlet, "#{__dir__}/ri/servlet" + autoload :Store, "#{__dir__}/ri/store" end diff --git a/lib/rdoc/ri/driver.rb b/lib/rdoc/ri/driver.rb index 13ad9366ec..014c5be4fb 100644 --- a/lib/rdoc/ri/driver.rb +++ b/lib/rdoc/ri/driver.rb @@ -1521,7 +1521,7 @@ def start_server extra_doc_dirs = @stores.map {|s| s.type == :extra ? s.path : nil}.compact - server.mount '/', RDoc::Servlet, nil, extra_doc_dirs + server.mount '/', RDoc::RI::Servlet, nil, extra_doc_dirs trap 'INT' do server.shutdown end trap 'TERM' do server.shutdown end diff --git a/lib/rdoc/servlet.rb b/lib/rdoc/ri/servlet.rb similarity index 98% rename from lib/rdoc/servlet.rb rename to lib/rdoc/ri/servlet.rb index 257e32cead..78160ff1ea 100644 --- a/lib/rdoc/servlet.rb +++ b/lib/rdoc/ri/servlet.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -require_relative '../rdoc' +require_relative '../../rdoc' require 'erb' require 'time' require 'json' @@ -24,14 +24,14 @@ # # server = WEBrick::HTTPServer.new Port: 8000 # -# server.mount '/', RDoc::Servlet +# server.mount '/', RDoc::RI::Servlet # # If you want to mount the servlet some other place than the root, provide the # base path when mounting: # -# server.mount '/rdoc', RDoc::Servlet, '/rdoc' +# server.mount '/rdoc', RDoc::RI::Servlet, '/rdoc' -class RDoc::Servlet < WEBrick::HTTPServlet::AbstractServlet +class RDoc::RI::Servlet < WEBrick::HTTPServlet::AbstractServlet @server_stores = Hash.new { |hash, server| hash[server] = {} } @cache = Hash.new { |hash, store| hash[store] = {} } diff --git a/lib/rdoc/server.rb b/lib/rdoc/server.rb new file mode 100644 index 0000000000..724d492c63 --- /dev/null +++ b/lib/rdoc/server.rb @@ -0,0 +1,378 @@ +# frozen_string_literal: true + +require 'socket' +require 'json' +require 'erb' +require 'uri' + +## +# A minimal HTTP server for live-reloading RDoc documentation. +# +# Uses Ruby's built-in +TCPServer+ (no external dependencies). +# +# Used by rdoc --server to let developers preview documentation +# while editing source files. Parses sources once on startup, watches for +# file changes, re-parses only the changed files, and auto-refreshes the +# browser via a simple polling script. + +class RDoc::Server + + ## + # Returns a live-reload polling script with the given +last_change_time+ + # embedded so the browser knows the exact timestamp of the content it + # received. This avoids a race where a change that occurs between page + # generation and the first poll would be silently skipped. + + def self.live_reload_script(last_change_time) + <<~JS + + JS + end + + CONTENT_TYPES = { + '.html' => 'text/html', + '.css' => 'text/css', + '.js' => 'application/javascript', + '.json' => 'application/json', + }.freeze + + STATUS_TEXTS = { + 200 => 'OK', + 400 => 'Bad Request', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 500 => 'Internal Server Error', + }.freeze + + ## + # Creates a new server. + # + # +rdoc+ is the RDoc::RDoc instance that has already parsed the source + # files. + # +port+ is the TCP port to listen on. + + def initialize(rdoc, port) + @rdoc = rdoc + @options = rdoc.options + @store = rdoc.store + @port = port + + @generator = create_generator + @template_dir = File.expand_path(@generator.template_dir) + @page_cache = {} + @last_change_time = Time.now.to_f + @mutex = Mutex.new + @running = false + end + + ## + # Starts the server. Blocks until interrupted. + + def start + @tcp_server = TCPServer.new('127.0.0.1', @port) + @running = true + + @watcher_thread = start_watcher(@rdoc.last_modified.keys) + + url = "http://localhost:#{@port}" + $stderr.puts "\nServing documentation at: \e]8;;#{url}\e\\#{url}\e]8;;\e\\" + $stderr.puts "Press Ctrl+C to stop.\n\n" + + loop do + client = @tcp_server.accept + Thread.new(client) { |c| handle_client(c) } + rescue IOError + break + end + end + + ## + # Shuts down the server. + + def shutdown + @running = false + @tcp_server&.close + @watcher_thread&.join(2) + end + + private + + def create_generator + gen = RDoc::Generator::Aliki.new(@store, @options) + gen.file_output = false + gen.asset_rel_path = '' + gen.setup + gen + end + + ## + # Reads an HTTP request from +client+ and dispatches to the router. + + def handle_client(client) + client.binmode + + return unless IO.select([client], nil, nil, 5) + + request_line = client.gets("\n") + return unless request_line + + method, request_uri, = request_line.split(' ', 3) + return write_response(client, 400, 'text/plain', 'Bad Request') unless request_uri + + begin + path = URI.parse(request_uri).path + rescue URI::InvalidURIError + return write_response(client, 400, 'text/plain', 'Bad Request') + end + + while (line = client.gets("\n")) + break if line.strip.empty? + end + + unless method == 'GET' + return write_response(client, 405, 'text/plain', 'Method Not Allowed') + end + + status, content_type, body = route(path) + write_response(client, status, content_type, body) + rescue => e + write_response(client, 500, 'text/html', <<~HTML) + + +

Internal Server Error

+
#{ERB::Util.html_escape e.message}\n#{ERB::Util.html_escape e.backtrace.join("\n")}
+ + HTML + ensure + client.close rescue nil + end + + ## + # Routes a request path and returns [status, content_type, body]. + + def route(path) + case path + when '/__status' + t = @mutex.synchronize { @last_change_time } + [200, 'application/json', JSON.generate(last_change: t)] + when '/js/search_data.js' + # Search data is dynamically generated, not a static asset + serve_page(path) + when %r{\A/(?:css|js)/} + serve_asset(path) + else + serve_page(path) + end + end + + ## + # Writes an HTTP/1.1 response to +client+. + + def write_response(client, status, content_type, body) + body_bytes = body.b + + header = +"HTTP/1.1 #{status} #{STATUS_TEXTS[status] || 'Unknown'}\r\n" + header << "Content-Type: #{content_type}\r\n" + header << "Content-Length: #{body_bytes.bytesize}\r\n" + header << "Connection: close\r\n" + header << "\r\n" + + client.write(header) + client.write(body_bytes) + client.flush + end + + ## + # Serves a static asset (CSS, JS) from the Aliki template directory. + + def serve_asset(path) + rel_path = path.delete_prefix("/") + asset_path = File.join(@generator.template_dir, rel_path) + real_asset = File.expand_path(asset_path) + + unless real_asset.start_with?("#{@template_dir}/") && File.file?(real_asset) + return [404, 'text/plain', "Asset not found: #{rel_path}"] + end + + ext = File.extname(rel_path) + content_type = CONTENT_TYPES[ext] || 'application/octet-stream' + [200, content_type, File.read(real_asset)] + end + + ## + # Serves an HTML page, rendering from the generator or returning a cached + # version. + + def serve_page(path) + name = path.delete_prefix("/") + name = 'index.html' if name.empty? + + html = render_page(name) + + unless html + not_found = @generator.generate_servlet_not_found( + "The page #{ERB::Util.html_escape path} was not found" + ) + t = @mutex.synchronize { @last_change_time } + return [404, 'text/html', inject_live_reload(not_found || '', t)] + end + + ext = File.extname(name) + content_type = CONTENT_TYPES[ext] || 'text/html' + [200, content_type, html] + end + + ## + # Renders a page through the Aliki generator and caches the result. + + def render_page(name) + @mutex.synchronize do + return @page_cache[name] if @page_cache[name] + + result = generate_page(name) + return nil unless result + + result = inject_live_reload(result, @last_change_time) if name.end_with?('.html') + @page_cache[name] = result + end + end + + ## + # Dispatches to the appropriate generator method based on the page name. + + def generate_page(name) + case name + when 'index.html' + @generator.generate_index + when 'table_of_contents.html' + @generator.generate_table_of_contents + when 'js/search_data.js' + "var search_data = #{JSON.generate(index: @generator.build_search_index)};" + else + text_name = name.chomp('.html') + class_name = text_name.gsub('/', '::') + + if klass = @store.find_class_or_module(class_name) + @generator.generate_class(klass) + elsif page = @store.find_text_page(text_name.sub(/_([^_]*)\z/, '.\1')) + @generator.generate_page(page) + end + end + end + + ## + # Injects the live-reload polling script before ++. + + def inject_live_reload(html, last_change_time) + html.sub('', "#{self.class.live_reload_script(last_change_time)}") + end + + ## + # Starts a background thread that polls source file mtimes and triggers + # re-parsing when changes are detected. + + def start_watcher(source_files) + @file_mtimes = source_files.each_with_object({}) do |f, h| + h[f] = File.mtime(f) rescue nil + end + + Thread.new do + while @running + begin + sleep 1 + check_for_changes + rescue => e + $stderr.puts "RDoc server watcher error: #{e.message}" + end + end + end + end + + ## + # Checks for modified, new, and deleted files. Returns true if any + # changes were found and processed. + + def check_for_changes + changed = [] + removed = [] + + @file_mtimes.each do |file, old_mtime| + unless File.exist?(file) + removed << file + next + end + + current_mtime = File.mtime(file) rescue nil + next unless current_mtime + changed << file if old_mtime.nil? || current_mtime > old_mtime + end + + file_list = @rdoc.normalized_file_list( + @options.files.empty? ? [@options.root.to_s] : @options.files, + true, @options.exclude + ) + file_list = @rdoc.remove_unparseable(file_list) + file_list.each_key do |file| + unless @file_mtimes.key?(file) + @file_mtimes[file] = nil # will be updated after parse + changed << file + end + end + + return false if changed.empty? && removed.empty? + + reparse_and_refresh(changed, removed) + true + end + + ## + # Re-parses changed files, removes deleted files from the store, + # refreshes the generator, and invalidates caches. + + def reparse_and_refresh(changed_files, removed_files) + @mutex.synchronize do + unless removed_files.empty? + $stderr.puts "Removed: #{removed_files.join(', ')}" + removed_files.each do |f| + @file_mtimes.delete(f) + relative = @rdoc.relative_path_for(f) + @store.clear_file_contributions(relative) + @store.remove_file(relative) + end + end + + unless changed_files.empty? + $stderr.puts "Re-parsing: #{changed_files.join(', ')}" + changed_files.each do |f| + begin + relative = @rdoc.relative_path_for(f) + @store.clear_file_contributions(relative, keep_position: true) + @rdoc.parse_file(f) + @file_mtimes[f] = File.mtime(f) rescue nil + rescue => e + $stderr.puts "Error parsing #{f}: #{e.message}" + end + end + + @store.cleanup_stale_contributions + end + + @store.complete(@options.visibility) + + @generator.refresh_store_data + @page_cache.clear + @last_change_time = Time.now.to_f + end + end + +end diff --git a/lib/rdoc/store.rb b/lib/rdoc/store.rb index 57429e6aad..bcba7ddabb 100644 --- a/lib/rdoc/store.rb +++ b/lib/rdoc/store.rb @@ -193,6 +193,121 @@ def add_file(absolute_name, relative_name: absolute_name, parser: nil) top_level end + ## + # Removes a file and its classes/modules from the store. Used by the + # live-reloading server when a source file is deleted. + # + # Note: this does not handle reopened classes correctly. If a class is + # defined across multiple files (e.g. +Foo+ in both +a.rb+ and +b.rb+), + # deleting one file removes the entire class from the store — including + # the parts contributed by the other file. Saving the remaining file + # triggers a re-parse that restores it. + + def remove_file(relative_name) + top_level = @files_hash.delete(relative_name) + @text_files_hash.delete(relative_name) + @c_class_variables.delete(relative_name) + @c_singleton_class_variables.delete(relative_name) + return unless top_level + + remove_classes_and_modules(top_level.classes_or_modules) + end + + ## + # Removes a file's contributions (methods, constants, comments, etc.) + # from its classes and modules. If no other files contribute to a + # class or module, it is removed from the store entirely. This + # prevents duplication when the file is re-parsed while preserving + # shared namespaces like +RDoc+ that span many files. + + def clear_file_contributions(relative_name, keep_position: false) + top_level = @files_hash[relative_name] + return unless top_level + + top_level.classes_or_modules.each do |cm| + # Remove methods and attributes contributed by this file + cm.method_list.reject! { |m| m.file == top_level } + cm.attributes.reject! { |a| a.file == top_level } + + # Rebuild methods_hash from remaining methods and attributes + cm.methods_hash.clear + cm.method_list.each { |m| cm.methods_hash[m.pretty_name] = m } + cm.attributes.each { |a| cm.methods_hash[a.pretty_name] = a } + + # Remove constants contributed by this file + cm.constants.reject! { |c| c.file == top_level } + cm.constants_hash.clear + cm.constants.each { |c| cm.constants_hash[c.name] = c } + + # Remove includes, extends, and aliases from this file + cm.includes.reject! { |i| i.file == top_level } + cm.extends.reject! { |e| e.file == top_level } + cm.aliases.reject! { |a| a.file == top_level } + cm.external_aliases.reject! { |a| a.file == top_level } + + # Clear or remove comment entries from this file + if cm.is_a?(RDoc::ClassModule) + if keep_position + cm.comment_location[top_level] = [] if cm.comment_location.key?(top_level) + else + cm.comment_location.delete(top_level) + end + cm.rebuild_comment_from_location + end + + unless keep_position + # Remove this file from the class/module's file list + cm.in_files.delete(top_level) + + # If no files contribute to this class/module anymore, remove it + # from the store entirely. This handles file deletion correctly + # for classes that are only defined in the deleted file, while + # preserving classes that span multiple files. + if cm.in_files.empty? + if cm.is_a?(RDoc::NormalModule) + @modules_hash.delete(cm.full_name) + else + @classes_hash.delete(cm.full_name) + end + cm.parent&.classes_hash&.delete(cm.name) + cm.parent&.modules_hash&.delete(cm.name) + end + end + end + + # Clear the TopLevel's class/module list to prevent duplicates + top_level.classes_or_modules.clear + end + + ## + # Removes stale empty placeholders left by +clear_file_contributions+ with + # keep_position: true. After re-parsing, a file may no longer + # define a class it previously contributed to, leaving an empty entry in + # +comment_location+ and a stale +in_files+ reference. Call this after + # all re-parsing is complete. + + def cleanup_stale_contributions + all_classes_and_modules.each do |cm| + cm.comment_location.delete_if { |_, comments| comments.empty? } + cm.rebuild_comment_from_location + + cm.in_files.select! { |tl| cm.comment_location.key?(tl) || + cm.method_list.any? { |m| m.file == tl } || + cm.attributes.any? { |a| a.file == tl } || + cm.constants.any? { |c| c.file == tl } } + + if cm.in_files.empty? + if cm.is_a?(RDoc::NormalModule) + @modules_hash.delete(cm.full_name) + else + @classes_hash.delete(cm.full_name) + end + cm.parent&.classes_hash&.delete(cm.name) + cm.parent&.modules_hash&.delete(cm.name) + end + end + end + ## # Make sure any references to C variable names are resolved to the corresponding class. # @@ -978,6 +1093,19 @@ def unique_modules end private + + def remove_classes_and_modules(cms) + cms.each do |cm| + remove_classes_and_modules(cm.classes_and_modules) + + if cm.is_a?(RDoc::NormalModule) + @modules_hash.delete(cm.full_name) + else + @classes_hash.delete(cm.full_name) + end + end + end + def marshal_load(file) File.open(file, 'rb') {|io| Marshal.load(io, MarshalFilter)} end diff --git a/lib/rdoc/task.rb b/lib/rdoc/task.rb index 5e0881803e..979c36254d 100644 --- a/lib/rdoc/task.rb +++ b/lib/rdoc/task.rb @@ -245,6 +245,15 @@ def define $stderr.puts "rdoc #{args.join ' '}" if Rake.application.options.trace RDoc::RDoc.new.document args end + + desc server_task_description + task "server" do + @before_running_rdoc.call if @before_running_rdoc + args = option_list + ["--server"] + @rdoc_files + + $stderr.puts "rdoc #{args.join ' '}" if Rake.application.options.trace + RDoc::RDoc.new.document args + end end self @@ -294,6 +303,13 @@ def coverage_task_description "Print RDoc coverage report" end + ## + # Task description for the server task + + def server_task_description + "Start a live-reloading documentation server" + end + private def rdoc_target diff --git a/test/rdoc/rdoc_servlet_test.rb b/test/rdoc/rdoc_servlet_test.rb index 0d9da1b727..0632d28595 100644 --- a/test/rdoc/rdoc_servlet_test.rb +++ b/test/rdoc/rdoc_servlet_test.rb @@ -5,7 +5,7 @@ rescue LoadError end -class RDocServletTest < RDoc::TestCase +class RDocRIServletTest < RDoc::TestCase def setup super @@ -30,7 +30,7 @@ def @server.mount(*) end @extra_dirs = [File.join(@tempdir, 'extra1'), File.join(@tempdir, 'extra2')] - @s = RDoc::Servlet.new @server, @stores, @cache, nil, @extra_dirs + @s = RDoc::RI::Servlet.new @server, @stores, @cache, nil, @extra_dirs @req = WEBrick::HTTPRequest.new :Logger => nil @res = WEBrick::HTTPResponse.new :HTTPVersion => '1.0' @@ -142,7 +142,7 @@ def @req.path() raise 'no' end end def test_do_GET_mount_path - @s = RDoc::Servlet.new @server, @stores, @cache, '/mount/path' + @s = RDoc::RI::Servlet.new @server, @stores, @cache, '/mount/path' temp_dir do FileUtils.mkdir 'css' diff --git a/test/rdoc/rdoc_store_test.rb b/test/rdoc/rdoc_store_test.rb index 576fca405f..15492b1fb9 100644 --- a/test/rdoc/rdoc_store_test.rb +++ b/test/rdoc/rdoc_store_test.rb @@ -1060,4 +1060,248 @@ def test_title assert_equal 'rdoc', @s.title end + def test_clear_file_contributions_single_file_class + file = @s.add_file 'single.rb' + + klass = file.add_class RDoc::NormalClass, 'SingleFileClass' + klass.record_location file + file.add_to_classes_or_modules klass + + meth = RDoc::AnyMethod.new nil, 'solo_method' + meth.record_location file + klass.add_method meth + + assert_includes @s.classes_hash, 'SingleFileClass' + + @s.clear_file_contributions 'single.rb' + + assert_not_include @s.classes_hash, 'SingleFileClass' + end + + def test_clear_file_contributions_single_file_module + file = @s.add_file 'single_mod.rb' + + mod = file.add_module RDoc::NormalModule, 'SingleFileMod' + mod.record_location file + file.add_to_classes_or_modules mod + + meth = RDoc::AnyMethod.new nil, 'solo_method' + meth.record_location file + mod.add_method meth + + assert_includes @s.modules_hash, 'SingleFileMod' + + @s.clear_file_contributions 'single_mod.rb' + + assert_not_include @s.modules_hash, 'SingleFileMod' + end + + def test_clear_file_contributions_multi_file_class + file_a = @s.add_file 'a.rb' + file_b = @s.add_file 'b.rb' + + klass = file_a.add_class RDoc::NormalClass, 'MultiFileClass' + klass.record_location file_a + klass.record_location file_b + file_a.add_to_classes_or_modules klass + file_b.add_to_classes_or_modules klass + + meth_a = RDoc::AnyMethod.new nil, 'from_a' + meth_a.record_location file_a + klass.add_method meth_a + + meth_b = RDoc::AnyMethod.new nil, 'from_b' + meth_b.record_location file_b + klass.add_method meth_b + + klass.add_comment 'comment from a', file_a + klass.add_comment 'comment from b', file_b + + @s.clear_file_contributions 'a.rb' + + # Class is preserved because file_b still contributes + assert_includes @s.classes_hash, 'MultiFileClass' + + # Method from a.rb is removed, method from b.rb remains + method_names = klass.method_list.map { |m| m.name } + assert_not_include method_names, 'from_a' + assert_includes method_names, 'from_b' + + # Comment from a.rb is removed, comment from b.rb remains + assert_not_include klass.comment_location.keys, file_a + assert_includes klass.comment_location.keys, file_b + end + + def test_clear_file_contributions_cleans_methods_and_constants + file_a = @s.add_file 'ca.rb' + file_b = @s.add_file 'cb.rb' + + klass = file_a.add_class RDoc::NormalClass, 'CleanupTestClass' + klass.record_location file_a + klass.record_location file_b + file_a.add_to_classes_or_modules klass + file_b.add_to_classes_or_modules klass + + # Methods from different files + meth_a = RDoc::AnyMethod.new nil, 'meth_a' + meth_a.record_location file_a + klass.add_method meth_a + + meth_b = RDoc::AnyMethod.new nil, 'meth_b' + meth_b.record_location file_b + klass.add_method meth_b + + # Attributes from different files + attr_a = RDoc::Attr.new nil, 'attr_a', 'R', '' + attr_a.record_location file_a + klass.add_attribute attr_a + + attr_b = RDoc::Attr.new nil, 'attr_b', 'R', '' + attr_b.record_location file_b + klass.add_attribute attr_b + + # Constants from different files + const_a = RDoc::Constant.new 'CONST_A', 'val_a', '' + const_a.record_location file_a + klass.add_constant const_a + + const_b = RDoc::Constant.new 'CONST_B', 'val_b', '' + const_b.record_location file_b + klass.add_constant const_b + + # Includes from different files + incl_a = RDoc::Include.new 'ModA', '' + incl_a.record_location file_a + klass.add_include incl_a + + incl_b = RDoc::Include.new 'ModB', '' + incl_b.record_location file_b + klass.add_include incl_b + + # Extends from different files + ext_a = RDoc::Extend.new 'ExtA', '' + ext_a.record_location file_a + klass.add_extend ext_a + + ext_b = RDoc::Extend.new 'ExtB', '' + ext_b.record_location file_b + klass.add_extend ext_b + + @s.clear_file_contributions 'ca.rb' + + # Methods: only file_b's remain + assert_equal ['meth_b'], klass.method_list.map(&:name) + assert_includes klass.methods_hash, '#meth_b' + assert_not_include klass.methods_hash, '#meth_a' + + # Attributes: only file_b's remain + assert_equal ['attr_b'], klass.attributes.map(&:name) + + # Constants: only file_b's remain + assert_equal ['CONST_B'], klass.constants.map(&:name) + assert_includes klass.constants_hash, 'CONST_B' + assert_not_include klass.constants_hash, 'CONST_A' + + # Includes: only file_b's remain + assert_equal ['ModB'], klass.includes.map(&:name) + + # Extends: only file_b's remain + assert_equal ['ExtB'], klass.extends.map(&:name) + + # in_files no longer includes file_a + assert_not_include klass.in_files, file_a + assert_includes klass.in_files, file_b + end + + def test_clear_file_contributions_nonexistent_file + # Should be a no-op and not raise + @s.clear_file_contributions 'nonexistent.rb' + end + + def test_clear_file_contributions_keep_position + file_a = @s.add_file 'a.rb' + file_b = @s.add_file 'b.rb' + + klass = file_a.add_class RDoc::NormalClass, 'KeepPosClass' + klass.record_location file_a + klass.record_location file_b + file_a.add_to_classes_or_modules klass + file_b.add_to_classes_or_modules klass + + klass.add_comment 'comment from a', file_a + klass.add_comment 'comment from b', file_b + + @s.clear_file_contributions 'a.rb', keep_position: true + + # Class is preserved + assert_includes @s.classes_hash, 'KeepPosClass' + + # comment_location still has two entries (empty placeholder for a.rb) + assert_equal 2, klass.comment_location.size + assert_equal [file_a, file_b], klass.comment_location.keys + + # The placeholder is an empty array + assert_equal [], klass.comment_location[file_a] + + # in_files is not modified + assert_includes klass.in_files, file_a + assert_includes klass.in_files, file_b + + # Simulate re-parse: add_comment appends to array at existing key position + klass.add_comment 'updated comment from a', file_a + + # Order is preserved: a.rb first, b.rb second + assert_equal [file_a, file_b], klass.comment_location.keys + assert_equal 'updated comment from a', klass.comment_location[file_a].first.to_s + end + + def test_cleanup_stale_comment_locations + file_a = @s.add_file 'a.rb' + file_b = @s.add_file 'b.rb' + + klass = file_a.add_class RDoc::NormalClass, 'StaleClass' + klass.record_location file_a + klass.record_location file_b + file_a.add_to_classes_or_modules klass + file_b.add_to_classes_or_modules klass + + klass.add_comment 'comment from a', file_a + klass.add_comment 'comment from b', file_b + + # Simulate keep_position clearing followed by re-parse that no longer + # defines StaleClass in a.rb (no add_comment called for file_a) + @s.clear_file_contributions 'a.rb', keep_position: true + + # Stale placeholder exists + assert_equal [], klass.comment_location[file_a] + + # Cleanup should remove the stale entry + @s.cleanup_stale_contributions + + assert_not_include klass.comment_location.keys, file_a + assert_not_include klass.in_files, file_a + assert_includes klass.comment_location.keys, file_b + assert_includes klass.in_files, file_b + end + + def test_cleanup_stale_contributions_removes_empty_class + file_a = @s.add_file 'a.rb' + + klass = file_a.add_class RDoc::NormalClass, 'GoneClass' + klass.record_location file_a + file_a.add_to_classes_or_modules klass + + klass.add_comment 'comment from a', file_a + + @s.clear_file_contributions 'a.rb', keep_position: true + + # Stale placeholder exists, class still in store + assert_includes @s.classes_hash, 'GoneClass' + + @s.cleanup_stale_contributions + + # Class should be removed from store + assert_not_include @s.classes_hash, 'GoneClass' + end + end