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 b42059c712..417bbbb31e 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 00e406b1a6..c76fcb48f1 100644 --- a/lib/rdoc/code_object/class_module.rb +++ b/lib/rdoc/code_object/class_module.rb @@ -30,22 +30,19 @@ class RDoc::ClassModule < RDoc::Context attr_accessor :constant_aliases ## - # An array of `[comment, location]` pairs documenting this class/module. + # A hash of { location => comment } documenting this class/module. # Use #add_comment to add comments. # + # Ruby hashes maintain insertion order, so comments render in the order + # they were first added. Re-assigning a key preserves its position. + # # Before marshalling: - # - +comment+ is a String # - +location+ is an RDoc::TopLevel + # - +comment+ is a String # # After unmarshalling: - # - +comment+ is an RDoc::Markup::Document # - +location+ is a filename String - # - # These type changes are acceptable (for now) because: - # - +comment+: Both String and Document respond to #empty?, and #parse - # returns Document as-is (see RDoc::Text#parse) - # - +location+: Only used by #parse to set Document#file, which accepts - # both TopLevel (extracts relative_name) and String + # - +comment+ is an RDoc::Markup::Document attr_accessor :comment_location @@ -63,7 +60,7 @@ class RDoc::ClassModule < RDoc::Context def self.from_module(class_type, mod) klass = class_type.new mod.name - mod.comment_location.each do |comment, location| + mod.comment_location.each do |location, comment| klass.add_comment comment, location end @@ -125,7 +122,7 @@ def initialize(name, superclass = nil) @is_alias_for = nil @name = name @superclass = superclass - @comment_location = [] # Array of [comment, location] pairs + @comment_location = {} # Hash of { location => comment } super() end @@ -147,11 +144,7 @@ def add_comment(comment, location) normalize_comment comment end - if location.parser == RDoc::Parser::C - @comment_location.delete_if { |(_, l)| l == location } - end - - @comment_location << [comment, location] + @comment_location[location] = comment self.comment = original end @@ -270,7 +263,7 @@ def document_self_or_methods def documented? return true if @received_nodoc return false if @comment_location.empty? - @comment_location.any? { |comment, _| not comment.empty? } + @comment_location.each_value.any? { |comment| not comment.empty? } end ## @@ -408,13 +401,7 @@ def marshal_load(array) # :nodoc: @superclass = array[3] document = array[4] - @comment = RDoc::Comment.from_document document - - @comment_location = if document.parts.first.is_a?(RDoc::Markup::Document) - document.parts.map { |doc| [doc, doc.file] } - else - [[document, document.file]] - end + load_comment_from_document(document) array[5].each do |name, rw, visibility, singleton, file| singleton ||= false @@ -492,13 +479,7 @@ def merge(class_module) document = document.merge other_document - @comment = RDoc::Comment.from_document(document) - - @comment_location = if document.parts.first.is_a?(RDoc::Markup::Document) - document.parts.map { |doc| [doc, doc.file] } - else - [[document, document.file]] - end + load_comment_from_document(document) end cm = class_module @@ -643,8 +624,8 @@ def parse(comment_location) case comment_location when String then super - when Array then - docs = comment_location.map do |comment, location| + when Hash then + docs = comment_location.map do |location, comment| doc = super comment doc.file = location doc @@ -745,12 +726,22 @@ def search_record # Returns an HTML snippet of the first comment for search results. def search_snippet - first_comment = @comment_location.first&.first + first_comment = @comment_location.each_value.first return '' unless first_comment && !first_comment.empty? 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.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. @@ -926,6 +917,15 @@ def embed_mixins private + def load_comment_from_document(document) # :nodoc: + @comment = RDoc::Comment.from_document(document) + @comment_location = if document.parts.first.is_a?(RDoc::Markup::Document) + document.parts.to_h { |doc| [doc.file, doc] } + else + { document.file => document } + end + end + def prepare_to_embed(code_object, singleton=false) code_object = code_object.dup code_object.mixin_from = code_object.parent diff --git a/lib/rdoc/generator/darkfish.rb b/lib/rdoc/generator/darkfish.rb index 9a81b74688..f6cf3a3e14 100644 --- a/lib/rdoc/generator/darkfish.rb +++ b/lib/rdoc/generator/darkfish.rb @@ -580,6 +580,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/i18n/text.rb b/lib/rdoc/i18n/text.rb index 7ea6664442..ecbc808955 100644 --- a/lib/rdoc/i18n/text.rb +++ b/lib/rdoc/i18n/text.rb @@ -7,7 +7,7 @@ # * Extracts translation messages from wrapped raw text. # * Translates wrapped raw text in specified locale. # -# Wrapped raw text is one of String, RDoc::Comment or Array of them. +# Wrapped raw text is one of String, RDoc::Comment, Hash, or Array of them. class RDoc::I18n::Text @@ -89,8 +89,8 @@ def each_line(raw, &block) case raw when RDoc::Comment raw.text.each_line(&block) - when Array - raw.each do |comment, location| + when Hash + raw.each_value do |comment| each_line(comment, &block) end else 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 8beeac52f5..b681b707ea 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 @@ -516,6 +538,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..f19178426f --- /dev/null +++ b/lib/rdoc/server.rb @@ -0,0 +1,376 @@ +# 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 + 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..43159bc325 100644 --- a/lib/rdoc/store.rb +++ b/lib/rdoc/store.rb @@ -193,6 +193,93 @@ 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 + # Set to empty comment; hash preserves key position for re-parse + cm.comment_location[top_level] = RDoc::Comment.new('') 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 + ## # Make sure any references to C variable names are resolved to the corresponding class. # @@ -978,6 +1065,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/code_object/class_module_test.rb b/test/rdoc/code_object/class_module_test.rb index b2ba4ebcf1..874bdcb38a 100644 --- a/test/rdoc/code_object/class_module_test.rb +++ b/test/rdoc/code_object/class_module_test.rb @@ -12,21 +12,20 @@ def test_add_comment comment_tl1 = RDoc::Comment.new('# comment 1', @top_level, :ruby) cm.add_comment comment_tl1, tl1 - assert_equal [[comment_tl1, tl1]], cm.comment_location + assert_equal({ tl1 => comment_tl1 }, cm.comment_location) assert_equal 'comment 1', cm.comment.text comment_tl2 = RDoc::Comment.new('# comment 2', @top_level, :ruby) cm.add_comment comment_tl2, tl2 - assert_equal [[comment_tl1, tl1], [comment_tl2, tl2]], cm.comment_location + assert_equal({ tl1 => comment_tl1, tl2 => comment_tl2 }, cm.comment_location) assert_equal "comment 1\n---\ncomment 2", cm.comment comment_tl3 = RDoc::Comment.new('# * comment 3', @top_level, :ruby) cm.add_comment comment_tl3, tl3 - assert_equal [[comment_tl1, tl1], - [comment_tl2, tl2], - [comment_tl3, tl3]], cm.comment_location + assert_equal({ tl1 => comment_tl1, tl2 => comment_tl2, + tl3 => comment_tl3 }, cm.comment_location) assert_equal "comment 1\n---\ncomment 2\n---\n* comment 3", cm.comment end @@ -47,8 +46,27 @@ def test_add_comment_duplicate cm.add_comment comment1, tl1 cm.add_comment comment2, tl1 - assert_equal [[comment1, tl1], - [comment2, tl1]], cm.comment_location + # Hash replaces in-place for the same location + assert_equal({ tl1 => comment2 }, cm.comment_location) + end + + def test_add_comment_preserves_order_on_replace + tl1 = @store.add_file 'one.rb' + tl2 = @store.add_file 'two.rb' + + cm = RDoc::ClassModule.new 'Klass' + cm.add_comment 'comment from one', tl1 + cm.add_comment 'comment from two', tl2 + + # Simulate keep_position clearing: set tl1's comment to empty + cm.comment_location[tl1] = RDoc::Comment.new('') + + # Re-adding a comment for tl1 replaces in-place (hash preserves key order) + cm.add_comment 'updated comment from one', tl1 + + assert_equal 2, cm.comment_location.size + assert_equal [tl1, tl2], cm.comment_location.keys + assert_equal 'updated comment from one', cm.comment_location[tl1].to_s end def test_add_comment_stopdoc @@ -156,7 +174,7 @@ def test_from_module_comment klass = RDoc::ClassModule.from_module RDoc::NormalClass, klass - assert_equal [['really a class', tl]], klass.comment_location + assert_equal({ tl => 'really a class' }, klass.comment_location) end def test_marshal_dump @@ -631,7 +649,7 @@ def test_search_snippet_after_marshal assert_match(/class comment/, snippet) end - def test_comment_location_is_array_after_marshal + def test_comment_location_is_hash_after_marshal @store.path = Dir.tmpdir tl = @store.add_file 'file.rb' @@ -642,10 +660,10 @@ def test_comment_location_is_array_after_marshal loaded = Marshal.load Marshal.dump cm loaded.store = @store - assert_kind_of Array, loaded.comment_location - assert_equal 1, loaded.comment_location.length + assert_kind_of Hash, loaded.comment_location + assert_equal 1, loaded.comment_location.size - comment, location = loaded.comment_location.first + location, comment = loaded.comment_location.first assert_kind_of RDoc::Markup::Document, comment # After marshal, location is the filename string (from doc.file) assert_equal tl.relative_name, location @@ -668,7 +686,7 @@ def test_merge assert c1.current_section, 'original current_section' assert c2.current_section, 'merged current_section' - comment, location = c2.comment_location.first + location, comment = c2.comment_location.first assert_kind_of RDoc::Markup::Document, comment assert_equal tl.relative_name, location end @@ -793,7 +811,10 @@ def test_merge_comment inner2 = @RM::Document.new @RM::Paragraph.new 'klass 2' inner2.file = 'two.rb' - expected = @RM::Document.new inner2, inner1 + # With hash-based comment_location, one.rb comes first (its key was + # inserted first), then two.rb. Document.merge preserves this order + # since both self and other have the same files. + expected = @RM::Document.new inner1, inner2 assert_equal expected, cm1.comment.parse end @@ -1217,17 +1238,15 @@ def test_parse_comment_location cm.add_comment 'comment 1', tl1 cm.add_comment 'comment 2', tl2 - assert_kind_of Array, cm.comment_location - assert_equal 2, cm.comment_location.length - assert_equal 'comment 1', cm.comment_location[0][0] - assert_equal tl1, cm.comment_location[0][1] - assert_equal 'comment 2', cm.comment_location[1][0] - assert_equal tl2, cm.comment_location[1][1] + assert_kind_of Hash, cm.comment_location + assert_equal 2, cm.comment_location.size + assert_equal 'comment 1', cm.comment_location[tl1] + assert_equal 'comment 2', cm.comment_location[tl2] cm = Marshal.load Marshal.dump cm - # After marshal, comment_location should still be an array - assert_kind_of Array, cm.comment_location + # After marshal, comment_location should still be a hash + assert_kind_of Hash, cm.comment_location # parse() produces a Document with parts for each comment parsed = cm.parse(cm.comment_location) diff --git a/test/rdoc/parser/c_test.rb b/test/rdoc/parser/c_test.rb index 03157119c9..a8879bf08a 100644 --- a/test/rdoc/parser/c_test.rb +++ b/test/rdoc/parser/c_test.rb @@ -318,8 +318,7 @@ def test_do_classes_duplicate_class klass = util_get_class content, 'cFoo' assert_equal 1, klass.comment_location.size - first = klass.comment_location.first - first_comment = first[0] + first_comment = klass.comment_location.each_value.first assert_equal 'first', first_comment.text end diff --git a/test/rdoc/parser/prism_ruby_test.rb b/test/rdoc/parser/prism_ruby_test.rb index 21306f5c51..e9ad68d2e0 100644 --- a/test/rdoc/parser/prism_ruby_test.rb +++ b/test/rdoc/parser/prism_ruby_test.rb @@ -1890,12 +1890,9 @@ module Foo mod = @top_level.modules.first - expected = [ - RDoc::Comment.new('comment a', @top_level), - RDoc::Comment.new('comment b', @top_level) - ] - - assert_equal expected, mod.comment_location.map { |c, _l| c } + # Hash replaces duplicate entries for the same location; latest comment wins + expected = RDoc::Comment.new('comment b', @top_level) + assert_equal expected, mod.comment_location[@top_level] end def test_enddoc diff --git a/test/rdoc/parser/ruby_test.rb b/test/rdoc/parser/ruby_test.rb index 413de602f8..26d165d27e 100644 --- a/test/rdoc/parser/ruby_test.rb +++ b/test/rdoc/parser/ruby_test.rb @@ -801,9 +801,8 @@ def test_parse_class_in_a_file_repeatedly foo = @top_level.classes.first assert_equal 'Foo', foo.full_name - assert_equal [[comment_a, @top_level], - [comment_b, @top_level], - [comment_c, @top_level]], foo.comment_location + # Hash replaces duplicate entries for the same location; latest comment wins + assert_equal({ @top_level => comment_c }, foo.comment_location) assert_equal [@top_level], foo.in_files assert_equal 1, foo.line end @@ -3717,12 +3716,9 @@ module Foo foo = @top_level.modules.first - expected = [ - RDoc::Comment.new('comment a', @top_level), - RDoc::Comment.new('comment b', @top_level) - ] - - assert_equal expected, foo.comment_location.map { |c, l| c } + # Hash replaces duplicate entries for the same location; latest comment wins + expected = RDoc::Comment.new('comment b', @top_level) + assert_equal expected, foo.comment_location[@top_level] end def test_scan_meta_method_block 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 51708c8e9b..f26b730228 100644 --- a/test/rdoc/rdoc_store_test.rb +++ b/test/rdoc/rdoc_store_test.rb @@ -924,12 +924,12 @@ def test_save_class_merge loaded = s.load_class('Object') - # After loading, comment_location is an array (not a Document) - assert_kind_of Array, loaded.comment_location - assert_equal 1, loaded.comment_location.length + # After loading, comment_location is a hash (not a Document) + assert_kind_of Hash, loaded.comment_location + assert_equal 1, loaded.comment_location.size # Verify content is preserved - comment, location = loaded.comment_location.first + location, comment = loaded.comment_location.first assert_kind_of @RM::Document, comment assert_equal 'new comment', comment.parts[0].text assert_equal @top_level.relative_name, location @@ -1059,4 +1059,199 @@ 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 comment is empty + assert_equal '', klass.comment_location[file_a].to_s + + # in_files is not modified + assert_includes klass.in_files, file_a + assert_includes klass.in_files, file_b + + # Simulate re-parse: add_comment replaces in-place (hash preserves key order) + 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].to_s + end + end