diff --git a/AGENTS.md b/AGENTS.md index a51f9ddce7..822276dbf6 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,17 @@ 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" +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. **Investigate with Playwright**: Ask the AI assistant to take screenshots and inspect the documentation visually + - Example: "Navigate to the docs at localhost:4000 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): +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. ## Notes for AI Agents @@ -386,23 +414,27 @@ Restart Claude Code after running these commands. ### Testing Generated Documentation -To test the generated documentation: +The easiest way to test documentation is with the live-reloading server: ```bash -# Generate documentation -bundle exec rake rerdoc +bundle exec rdoc --server +# Or: bundle exec rake rdoc:server +``` + +This starts a server at `http://localhost:4000` that auto-refreshes on file changes. + +Alternatively, for testing static output: -# Start a simple HTTP server in the _site directory (use an available port) +```bash +bundle exec rake rerdoc 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" +- "Navigate to `http://localhost:4000` 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/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/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..77f43825ae 100644 --- a/lib/rdoc/rdoc.rb +++ b/lib/rdoc/rdoc.rb @@ -456,6 +456,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 + return + end + unless @options.coverage_report then @last_modified = setup_output_dir @options.op_dir, @options.force_update end @@ -516,6 +529,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..b973c10b31 --- /dev/null +++ b/lib/rdoc/server.rb @@ -0,0 +1,450 @@ +# 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 + + LIVE_RELOAD_SCRIPT = <<~JS + + JS + + 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 + @page_cache = {} + @search_index_cache = nil + @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.sub(%r{\A/}, '') + asset_path = File.join(@generator.template_dir, rel_path) + real_asset = File.expand_path(asset_path) + real_template = File.expand_path(@generator.template_dir) + + unless real_asset.start_with?("#{real_template}/") && 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.sub(%r{\A/}, '') + 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" + ) + return [404, 'text/html', inject_live_reload(not_found || '')] + 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) 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' + 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 + + ## + # Builds the search index JavaScript. + + def build_search_index + @search_index_cache ||= + "var search_data = #{JSON.generate(index: @generator.build_search_index)};" + end + + ## + # Injects the live-reload polling script before ++. + + def inject_live_reload(html) + html.sub('', "#{LIVE_RELOAD_SCRIPT}") + end + + ## + # Clears all cached HTML pages and the search index. + + def invalidate_all_caches + @page_cache.clear + @search_index_cache = nil + 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 + + ## + # Returns the relative path for +filename+ matching the key used by the + # store, mirroring the logic in RDoc::RDoc#parse_file. + + 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 + + ## + # 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 = relative_path_for(f) + @store.remove_file(relative) + end + end + + unless changed_files.empty? + $stderr.puts "Re-parsing: #{changed_files.join(', ')}" + changed_files.each do |f| + begin + relative = relative_path_for(f) + clear_file_contributions(relative) + @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 + invalidate_all_caches + @last_change_time = Time.now.to_f + end + end + + ## + # Removes a file's contributions (methods, constants, comments, etc.) + # from its classes and modules without removing the classes themselves + # from the store. 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) + top_level = @store.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 } + + # Remove comment entries from this file and rebuild the comment + if cm.is_a?(RDoc::ClassModule) + cm.comment_location.reject! { |(_, loc)| loc == top_level } + texts = cm.comment_location.map { |(c, _)| c.to_s } + merged = texts.join("\n---\n") + cm.instance_variable_set(:@comment, + merged.empty? ? '' : RDoc::Comment.new(merged)) + end + + # Remove this file from the class/module's file list + cm.in_files.delete(top_level) + end + + # Clear the TopLevel's class/module list to prevent duplicates + top_level.classes_or_modules.clear + end +end diff --git a/lib/rdoc/store.rb b/lib/rdoc/store.rb index 57429e6aad..2ea1bb6b76 100644 --- a/lib/rdoc/store.rb +++ b/lib/rdoc/store.rb @@ -193,6 +193,26 @@ 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 + ## # Make sure any references to C variable names are resolved to the corresponding class. # @@ -978,6 +998,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'