require 'tap/support/comment' module Tap module Support # Lazydoc scans source files to pull out documentation. Lazydoc can find two # types of documentation, constant attributes and code comments. # # === Constant Attributes # # Constant attributes are designated the same as constants in Ruby, but with # an extra 'key' constant that must consist of only lowercase letters and/or # underscores. Attributes are only parsed from comment lines. # # When Lazydoc finds an attribute it parses a Comment value where the subject # is the remainder of the line, and comment lines are parsed down until a # non-comment line, an end key, or a new attribute is reached. # # str = %Q{ # # Const::Name::key subject for key # # comment for key # # parsed until a non-comment line # # # Const::Name::another subject for another # # comment for another # # parsed to an end key # # Const::Name::another- # # # # ignored comment # } # # lazydoc = Lazydoc.new # lazydoc.resolve(str) # # lazydoc.to_hash {|comment| [comment.subject, comment.to_s] } # # => {'Const::Name' => { # # 'key' => ['subject for key', 'comment for key parsed until a non-comment line'], # # 'another' => ['subject for another', 'comment for another parsed to an end key'] # # }} # # A constant name does not need to be specified; when no constant name is # specified, Lazydoc will store the key as a default for the document. To # turn off attribute parsing for a section of documentation, use start/stop # keys: # # str = %Q{ # # :::- # # Const::Name::not_parsed # # :::+ # # Const::Name::not_parsed # # # Const::Name::parsed subject # } # # lazydoc = Lazydoc.new # lazydoc.resolve(str) # lazydoc.to_hash {|comment| comment.subject } # => {'Const::Name' => {'parsed' => 'subject'}} # # ==== startdoc # # Lazydoc is completely separate from RDoc, but the syntax of Lazydoc was developed # with RDoc in mind. To hide attributes in one line, make use of the RDoc # :startdoc: document modifier like this (spaces added to keep them in the # example): # # # :start doc::Const::Name::one hidden in RDoc # # * This line is visible in RDoc. # # :start doc::Const::Name::one- # # # #-- # # Const::Name::two # # You can hide attribute comments like this. # # Const::Name::two- # #++ # # # # * This line is also visible in RDoc. # # Here is the same text, actually in RDoc: # # :startdoc::Const::Name::one hidden in RDoc # * This line is visible in RDoc. # :startdoc::Const::Name::one- # #-- # Const::Name::two # You can hide attribute comments like this. # Const::Name::two- #++ # # * This line is also visible in RDoc. # # === Code Comments # # Code comments are lines marked for parsing if and when a Lazydoc gets resolved. # Unlike constant attributes, the line is the subject of a code comment and # comment lines are parsed up from it (effectively mimicking the behavior of # RDoc). # # str = %Q{ # # comment lines for # # the method # def method # end # # # as in RDoc, the comment can be # # separated from the method # # def another_method # end # } # # lazydoc = Lazydoc.new # lazydoc.register(3) # lazydoc.register(9) # lazydoc.resolve(str) # # lazydoc.code_comments.collect {|comment| [comment.subject, comment.to_s] } # # => [ # # ['def method', 'comment lines for the method'], # # ['def another_method', 'as in RDoc, the comment can be separated from the method']] # class Lazydoc # A regexp matching an attribute start or end. After a match: # # $1:: const_name # $3:: key # $4:: end flag # ATTRIBUTE_REGEXP = /([A-Z][A-z]*(::[A-Z][A-z]*)*)?::([a-z_]+)(-?)/ # A regexp matching constants from the ATTRIBUTE_REGEXP leader CONSTANT_REGEXP = /#.*?([A-Z][A-z]*(::[A-Z][A-z]*)*)?$/ # A regexp matching a caller line, to extract the calling file # and line number. After a match: # # $1:: file # $3:: line number (as a string, obviously) # # Note that line numbers in caller start at 1, not 0. CALLER_REGEXP = /^(([A-z]:)?[^:]+):(\d+)/ class << self # A hash of (source_file, lazydoc) pairs tracking the # Lazydoc instance for the given source file. def registry @registry ||= [] end # Returns the lazydoc in registry for the specified source file. # If no such lazydoc exists, one will be created for it. def [](source_file) source_file = File.expand_path(source_file.to_s) lazydoc = registry.find {|doc| doc.source_file == source_file } if lazydoc == nil lazydoc = new(source_file) registry << lazydoc end lazydoc end # Register the specified line numbers to the lazydoc for source_file. # Returns a CodeComment corresponding to the line. def register(source_file, line_number) Lazydoc[source_file].register(line_number) end # Resolves all lazydocs which include the specified code comments. def resolve_comments(code_comments) registry.each do |doc| next if (code_comments & doc.code_comments).empty? doc.resolve end end # Scans the specified file for attributes keyed by key and stores # the resulting comments in the corresponding lazydoc. # Returns the lazydoc. def scan_doc(source_file, key) lazydoc = nil scan(File.read(source_file), key) do |const_name, attr_key, comment| lazydoc = self[source_file] unless lazydoc lazydoc.attributes(const_name)[attr_key] = comment end lazydoc end # Scans the string or StringScanner for attributes matching the key; # keys may be patterns, they are incorporated into a regexp. Yields # each (const_name, key, value) triplet to the mandatory block and # skips regions delimited by the stop and start keys :- # and :+. # # str = %Q{ # # Const::Name::key value # # ::alt alt_value # # # # Ignored::Attribute::not_matched value # # :::- # # Also::Ignored::key value # # :::+ # # Another::key another value # # Ignored::key value # } # # results = [] # Lazydoc.scan(str, 'key|alt') do |const_name, key, value| # results << [const_name, key, value] # end # # results # # => [ # # ['Const::Name', 'key', 'value'], # # ['', 'alt', 'alt_value'], # # ['Another', 'key', 'another value']] # # Returns the StringScanner used during scanning. def scan(str, key) # :yields: const_name, key, value scanner = case str when StringScanner then str when String then StringScanner.new(str) else raise TypeError, "can't convert #{str.class} into StringScanner or String" end regexp = /^(.*?)::(:-|#{key})/ while !scanner.eos? break if scanner.skip_until(regexp) == nil if scanner[2] == ":-" scanner.skip_until(/:::\+/) else next unless scanner[1] =~ CONSTANT_REGEXP key = scanner[2] yield($1.to_s, key, scanner.matched.strip) if scanner.scan(/[ \r\t].*$|$/) end end scanner end # Parses constant attributes from the string or StringScanner. Yields # each (const_name, key, comment) triplet to the mandatory block # and skips regions delimited by the stop and start keys :- # and :+. # # str = %Q{ # # Const::Name::key subject for key # # comment for key # # # :::- # # Ignored::key value # # :::+ # # # Ignored text before attribute ::another subject for another # # comment for another # } # # results = [] # Lazydoc.parse(str) do |const_name, key, comment| # results << [const_name, key, comment.subject, comment.to_s] # end # # results # # => [ # # ['Const::Name', 'key', 'subject for key', 'comment for key'], # # ['', 'another', 'subject for another', 'comment for another']] # # Returns the StringScanner used during scanning. def parse(str) # :yields: const_name, key, comment scanner = case str when StringScanner then str when String then StringScanner.new(str) else raise TypeError, "can't convert #{str.class} into StringScanner or String" end scan(scanner, '[a-z_]+') do |const_name, key, value| comment = Comment.parse(scanner, false) do |line| if line =~ ATTRIBUTE_REGEXP # rewind to capture the next attribute unless an end is specified. scanner.unscan unless $4 == '-' && $3 == key && $1.to_s == const_name true else false end end comment.subject = value yield(const_name, key, comment) end end end include Enumerable # The source file for self, used in resolving comments and # attributes. attr_reader :source_file # An array of Comment objects identifying lines resolved or # to-be-resolved for self. attr_reader :code_comments # A hash of (const_name, attributes) pairs tracking the constant # attributes resolved or to-be-resolved for self. Attributes # are hashes of (key, comment) pairs. attr_reader :const_attrs attr_reader :patterns def initialize(source_file=nil) self.source_file = source_file @code_comments = [] @patterns = {} @const_attrs = {} @resolved = false end # Sets the source file for self. Expands the source file path if necessary. def source_file=(source_file) @source_file = source_file == nil ? nil : File.expand_path(source_file) end # Returns the attributes for the specified const_name. def attributes(const_name) const_attrs[const_name] ||= {} end # Returns default document attributes (ie attributes('')) def default_attributes attributes('') end # Returns the attributes for const_name merged to default_attributes. # Set merge_defaults to false to get just the attributes for const_name. def [](const_name, merge_defaults=true) merge_defaults ? default_attributes.merge(attributes(const_name)) : attributes(const_name) end # Yields each (const_name, attributes) pair to the block; const_names where # the attributes are empty are skipped. def each const_attrs.each_pair do |const_name, attrs| yield(const_name, attrs) unless attrs.empty? end end # Returns true if the attributes for const_name are not empty. def has_const?(const_name) const_attrs.each_pair do |constname, attrs| next unless constname == const_name return !attrs.empty? end false end # Returns an array of the constant names in self, for which # the constant attributes are not empty. def const_names names = [] const_attrs.each_pair do |const_name, attrs| names << const_name unless attrs.empty? end names end # Register the specified line number to self. Returns a # Comment object corresponding to the line. def register(line_number) comment = code_comments.find {|c| c.line_number == line_number } if comment == nil comment = Comment.new(line_number) code_comments << comment end comment end def register_pattern(key, regexp, &block) # :yields: comment, match patterns[key] = [regexp, block] end def register_method_pattern(key, method, range=0..-1) register_pattern(key, /^\s*def\s+#{method}(\((.*?)\))?/) do |comment, match| args = match[2].to_s.split(',').collect do |arg| arg = arg.strip.upcase case arg when /^&/ then nil when /^\*/ then arg[1..-1] + "..." else arg end end comment.subject = args[range].join(', ') end end # Returns true if the code_comments for source_file are frozen. def resolved? @resolved end attr_writer :resolved def resolve(str=nil) return(false) if resolved? if str == nil raise ArgumentError, "no source file specified" unless source_file && File.exists?(source_file) str = File.read(source_file) end Lazydoc.parse(str) do |const_name, key, comment| attributes(const_name)[key] = comment end lines = str.split(/\r?\n/) patterns.each_pair do |key, (regexp, block)| next if default_attributes.has_key?(key) lines.each_with_index do |line, line_number| next unless line =~ regexp comment = register(line_number) default_attributes[key] = comment break if block.call(comment, $~) end end unless patterns.empty? code_comments.collect! do |comment| line_number = comment.line_number comment.subject = lines[line_number] if comment.subject == nil # remove whitespace lines line_number -= 1 while lines[line_number].strip.empty? line_number -= 1 end # put together the comment while line_number >= 0 break unless comment.prepend(lines[line_number]) line_number -= 1 end comment end @resolved = true end def to_hash const_hash = {} const_names.sort.each do |const_name| attr_hash = {} self[const_name, false].each_pair do |key, comment| attr_hash[key] = (block_given? ? yield(comment) : comment) end const_hash[const_name] = attr_hash end const_hash end end end end