root/trunk/utils.rb

Revision 99, 18.3 kB (checked in by deveiant, 3 months ago)

Converted to a new build system.

  • Property svn:keywords set to Date Rev Author URL Id
Line 
1#
2#   Install/distribution utility functions
3#   $Id$
4#
5#   Copyright (c) 2001-2005, The FaerieMUD Consortium.
6#
7#   This is free software. You may use, modify, and/or redistribute this
8#   software under the terms of the Perl Artistic License. (See
9#   http://language.perl.com/misc/Artistic.html)
10#
11
12BEGIN {
13    require 'rbconfig'
14    require 'uri'
15    require 'find'
16    require 'pp'
17
18    begin
19        require 'readline'
20        include Readline
21    rescue LoadError => e
22        $stderr.puts "Faking readline..."
23        def readline( prompt )
24            $stderr.print prompt.chomp
25            return $stdin.gets.chomp
26        end
27    end
28
29    begin
30        require 'yaml'
31        $yaml = true
32    rescue LoadError => e
33        $stderr.puts "No YAML; try() will use PrettyPrint instead."
34        $yaml = false
35    end
36}
37
38
39module UtilityFunctions
40    include Config
41
42    # The list of regexen that eliminate files from the MANIFEST
43    ANTIMANIFEST = [
44        /makedist\.rb/,
45        /\bCVS\b/,
46        /~$/,
47        /^#/,
48        %r{docs/html},
49        %r{docs/man},
50        /\bTEMPLATE\.\w+\.tpl\b/,
51        /\.cvsignore/,
52        /\.s?o$/,
53    ]
54
55    # Set some ANSI escape code constants (Shamelessly stolen from Perl's
56    # Term::ANSIColor by Russ Allbery <rra@stanford.edu> and Zenin <zenin@best.com>
57    AnsiAttributes = {
58        'clear'      => 0,
59        'reset'      => 0,
60        'bold'       => 1,
61        'dark'       => 2,
62        'underline'  => 4,
63        'underscore' => 4,
64        'blink'      => 5,
65        'reverse'    => 7,
66        'concealed'  => 8,
67
68        'black'      => 30,   'on_black'   => 40, 
69        'red'        => 31,   'on_red'     => 41, 
70        'green'      => 32,   'on_green'   => 42, 
71        'yellow'     => 33,   'on_yellow'  => 43, 
72        'blue'       => 34,   'on_blue'    => 44, 
73        'magenta'    => 35,   'on_magenta' => 45, 
74        'cyan'       => 36,   'on_cyan'    => 46, 
75        'white'      => 37,   'on_white'   => 47
76    }
77
78    ErasePreviousLine = "\033[A\033[K"
79
80    ManifestHeader = (<<-"EOF").gsub( /^\t+/, '' )
81        #
82        # Distribution Manifest
83        # Created: #{Time::now.to_s}
84        #
85
86    EOF
87
88    ###############
89    module_function
90    ###############
91
92    # Create a string that contains the ANSI codes specified and return it
93    def ansiCode( *attributes )
94        return '' unless /(?:vt10[03]|xterm(?:-color)?|linux|screen)/i =~ ENV['TERM']
95        attr = attributes.collect {|a| AnsiAttributes[a] ? AnsiAttributes[a] : nil}.compact.join(';')
96        if attr.empty?
97            return ''
98        else
99            return "\e[%sm" % attr
100        end
101    end
102
103    # Test for the presence of the specified <tt>library</tt>, and output a
104    # message describing the test using <tt>nicename</tt>. If <tt>nicename</tt>
105    # is <tt>nil</tt>, the value in <tt>library</tt> is used to build a default.
106    def testForLibrary( library, nicename=nil, progress=false )
107        nicename ||= library
108        message( "Testing for the #{nicename} library..." ) if progress
109        if $LOAD_PATH.detect {|dir|
110                File.exists?(File.join(dir,"#{library}.rb")) ||
111                File.exists?(File.join(dir,"#{library}.#{CONFIG['DLEXT']}"))
112            }
113            message( "found.\n" ) if progress
114            return true
115        else
116            message( "not found.\n" ) if progress
117            return false
118        end
119    end
120
121    # Test for the presence of the specified <tt>library</tt>, and output a
122    # message describing the problem using <tt>nicename</tt>. If
123    # <tt>nicename</tt> is <tt>nil</tt>, the value in <tt>library</tt> is used
124    # to build a default. If <tt>raaUrl</tt> and/or <tt>downloadUrl</tt> are
125    # specified, they are also use to build a message describing how to find the
126    # required library. If <tt>fatal</tt> is <tt>true</tt>, a missing library
127    # will cause the program to abort.
128    def testForRequiredLibrary( library, nicename=nil, raaUrl=nil, downloadUrl=nil, fatal=true )
129        nicename ||= library
130        unless testForLibrary( library, nicename )
131            msgs = [ "You are missing the required #{nicename} library.\n" ]
132            msgs << "RAA: #{raaUrl}\n" if raaUrl
133            msgs << "Download: #{downloadUrl}\n" if downloadUrl
134            if fatal
135                abort msgs.join('')
136            else
137                errorMessage msgs.join('')
138            end
139        end
140        return true
141    end
142
143    ### Output <tt>msg</tt> as a ANSI-colored program/section header (white on
144    ### blue).
145    def header( msg )
146        msg.chomp!
147        $stderr.puts ansiCode( 'bold', 'white', 'on_blue' ) + msg + ansiCode( 'reset' )
148        $stderr.flush
149    end
150
151    ### Output <tt>msg</tt> to STDERR and flush it.
152    def message( *msgs )
153        $stderr.print( msgs.join("\n") )
154        $stderr.flush
155    end
156
157    ### Output +msg+ to STDERR and flush it if $VERBOSE is true.
158    def verboseMsg( msg )
159        msg.chomp!
160        message( msg + "\n" ) if $VERBOSE
161    end
162
163    ### Output the specified <tt>msg</tt> as an ANSI-colored error message
164    ### (white on red).
165    def errorMessage( msg )
166        message ansiCode( 'bold', 'white', 'on_red' ) + msg + ansiCode( 'reset' )
167    end
168
169    ### Output the specified <tt>msg</tt> as an ANSI-colored debugging message
170    ### (yellow on blue).
171    def debug_msg( msg )
172        return unless $DEBUG
173        msg.chomp!
174        $stderr.puts ansiCode( 'bold', 'yellow', 'on_blue' ) + ">>> #{msg}" + ansiCode( 'reset' )
175        $stderr.flush
176    end
177
178    ### Erase the previous line (if supported by your terminal) and output the
179    ### specified <tt>msg</tt> instead.
180    def replaceMessage( msg )
181        $stderr.print ErasePreviousLine
182        message( msg )
183    end
184
185    ### Output a divider made up of <tt>length</tt> hyphen characters.
186    def divider( length=75 )
187        $stderr.puts "\r" + ("-" * length )
188    end
189    alias :writeLine :divider
190
191
192    ### Output the specified <tt>msg</tt> colored in ANSI red and exit with a
193    ### status of 1.
194    def abort( msg )
195        print ansiCode( 'bold', 'red' ) + "Aborted: " + msg.chomp + ansiCode( 'reset' ) + "\n\n"
196        Kernel.exit!( 1 )
197    end
198
199
200    ### Output the specified <tt>promptString</tt> as a prompt (in green) and
201    ### return the user's input with leading and trailing spaces removed.  If a
202    ### test is provided, the prompt will repeat until the test returns true.
203    ### An optional failure message can also be passed in.
204    def prompt( promptString, failure_msg="Try again." ) # :yields: response
205        promptString.chomp!
206        response = nil
207
208        begin
209            response = readline( ansiCode('bold', 'green') +
210                "#{promptString}: " + ansiCode('reset') ).strip
211            if block_given? && ! yield( response ) 
212                errorMessage( failure_msg + "\n\n" )
213                response = nil
214            end
215        end until response
216
217        return response
218    end
219
220
221    ### Prompt the user with the given <tt>promptString</tt> via #prompt,
222    ### substituting the given <tt>default</tt> if the user doesn't input
223    ### anything.  If a test is provided, the prompt will repeat until the test
224    ### returns true.  An optional failure message can also be passed in.
225    def promptWithDefault( promptString, default, failure_msg="Try again." )
226        response = nil
227       
228        begin
229            response = prompt( "%s [%s]" % [ promptString, default ] )
230            response = default if response.empty?
231
232            if block_given? && ! yield( response ) 
233                errorMessage( failure_msg + "\n\n" )
234                response = nil
235            end
236        end until response
237
238        return response
239    end
240
241
242    $programs = {}
243
244    ### Search for the program specified by the given <tt>progname</tt> in the
245    ### user's <tt>PATH</tt>, and return the full path to it, or <tt>nil</tt> if
246    ### no such program is in the path.
247    def findProgram( progname )
248        unless $programs.key?( progname )
249            ENV['PATH'].split(File::PATH_SEPARATOR).each {|d|
250                file = File.join( d, progname )
251                if File.executable?( file )
252                    $programs[ progname ] = file
253                    break
254                end
255            }
256        end
257
258        return $programs[ progname ]
259    end
260
261
262    ### Search for the release version for the project in the specified
263    ### +directory+.
264    def extractVersion( directory='.' )
265        release = nil
266
267        Dir::chdir( directory ) do
268            if File::directory?( "CVS" )
269                verboseMsg( "Project is versioned via CVS. Searching for RELEASE_*_* tags..." )
270
271                if (( cvs = findProgram('cvs') ))
272                    revs = []
273                    output = %x{cvs log}
274                    output.scan( /RELEASE_(\d+(?:_\d\w+)*)/ ) {|match|
275                        rev = $1.split(/_/).collect {|s| Integer(s) rescue 0}
276                        verboseMsg( "Found %s...\n" % rev.join('.') )
277                        revs << rev
278                    }
279
280                    release = revs.sort.last
281                end
282
283            elsif File::directory?( '.svn' )
284                verboseMsg( "Project is versioned via Subversion" )
285
286                if (( svn = findProgram('svn') ))
287                    output = %x{svn pg project-version}.chomp
288                    unless output.empty?
289                        verboseMsg( "Using 'project-version' property: %p" % output )
290                        release = output.split( /[._]/ ).collect {|s| Integer(s) rescue 0}
291                    end
292                end
293            end
294        end
295
296        return release
297    end
298
299
300    ### Find the current release version for the project in the specified
301    ### +directory+ and return its successor.
302    def extractNextVersion( directory='.' )
303        version = extractVersion( directory ) || [0,0,0]
304        version.compact!
305        version[-1] += 1
306
307        return version
308    end
309
310
311    # Pattern for extracting the name of the project from a Subversion URL
312    SVNUrlPath = %r{
313        .*/                     # Skip all but the last bit
314        (\w+)                   # $1 = project name
315        /                       # Followed by / +
316        (?:
317            trunk |             # 'trunk'
318            (
319                branches |      # ...or branches/branch-name
320                tags            # ...or tags/tag-name
321            )/\w   
322        )
323        $                       # bound to the end
324    }ix
325
326    ### Extract the project name (CVS Repository name) for the given +directory+.
327    def extractProjectName( directory='.' )
328        name = nil
329
330        Dir::chdir( directory ) do
331
332            # CVS-controlled
333            if File::directory?( "CVS" )
334                verboseMsg( "Project is versioned via CVS. Using repository name." )
335                name = File.open( "CVS/Repository", "r").readline.chomp
336                name.sub!( %r{.*/}, '' )
337
338            # Subversion-controlled
339            elsif File::directory?( '.svn' )
340                verboseMsg( "Project is versioned via Subversion" )
341
342                # If the machine has the svn tool, try to get the project name
343                if (( svn = findProgram( 'svn' ) ))
344
345                    # First try an explicit property
346                    output = shellCommand( svn, 'pg', 'project-name' )
347                    if !output.empty?
348                        verboseMsg( "Using 'project-name' property: %p" % output )
349                        name = output.first.chomp
350
351                    # If that doesn't work, try to figure it out from the URL
352                    elsif (( uri = getSvnUri() ))
353                        name = uri.path.sub( SVNUrlPath ) { $1 }
354                    end
355                end
356            end
357
358            # Fall back to guessing based on the directory name
359            unless name
360                name = File::basename(File::dirname( File::expand_path(__FILE__) ))
361            end
362        end
363
364        return name
365    end
366
367
368    ### Extract the Subversion URL from the specified directory and return it as
369    ### a URI object.
370    def getSvnUri( directory='.' )
371        uri = nil
372
373        Dir::chdir( directory ) do
374            output = %x{svn info}
375            debug_msg( "Using info: %p" % output )
376
377            if /^URL: \s* ( .* )/xi.match( output )
378                uri = URI::parse( $1 )
379            end
380        end
381
382        return uri
383    end
384
385
386    ### (Re)make a manifest file in the specified +path+.
387    def makeManifest( path="MANIFEST" )
388        if File::exists?( path )
389            reply = promptWithDefault( "Replace current '#{path}'? [yN]", "n" )
390            return false unless /^y/i.match( reply )
391
392            verboseMsg "Replacing manifest at '#{path}'"
393        else
394            verboseMsg "Creating new manifest at '#{path}'"
395        end
396
397        files = []
398        verboseMsg( "Finding files...\n" )
399        Find::find( Dir::pwd ) do |f|
400            Find::prune if File::directory?( f ) &&
401                /^\./.match( File::basename(f) )
402            verboseMsg( "  found: #{f}\n" )
403            files << f.sub( %r{^#{Dir::pwd}/?}, '' )
404        end
405        files = vetManifest( files )
406       
407        verboseMsg( "Writing new manifest to #{path}..." )
408        File::open( path, File::WRONLY|File::CREAT|File::TRUNC ) do |ofh|
409            ofh.puts( ManifestHeader )
410            ofh.puts( files )
411        end
412        verboseMsg( "done." )
413    end
414
415
416    ### Read the specified <tt>manifestFile</tt>, which is a text file
417    ### describing which files to package up for a distribution. The manifest
418    ### should consist of one or more lines, each containing one filename or
419    ### shell glob pattern.
420    def readManifest( manifestFile="MANIFEST" )
421        verboseMsg "Building manifest..."
422        raise "Missing #{manifestFile}, please remake it" unless File.exists? manifestFile
423
424        manifest = IO::readlines( manifestFile ).collect {|line|
425            line.chomp
426        }.select {|line|
427            line !~ /^(\s*(#.*)?)?$/
428        }
429
430        filelist = []
431        for pat in manifest
432            verboseMsg "Adding files that match '#{pat}' to the file list"
433            filelist |= Dir.glob( pat ).find_all {|f| FileTest.file?(f)}
434        end
435
436        verboseMsg "found #{filelist.length} files.\n"
437        return filelist
438    end
439
440
441    ### Given a <tt>filelist</tt> like that returned by #readManifest, remove
442    ### the entries therein which match the Regexp objects in the given
443    ### <tt>antimanifest</tt> and return the resultant Array.
444    def vetManifest( filelist, antimanifest=ANTIMANIFEST )
445        origLength = filelist.length
446        verboseMsg "Vetting manifest..."
447
448        for regex in antimanifest
449            verboseMsg "\n\tPattern /#{regex.source}/ removed: " +
450                filelist.find_all {|file| regex.match(file)}.join(', ')
451            filelist.delete_if {|file| regex.match(file)}
452        end
453
454        verboseMsg "removed #{origLength - filelist.length} files from the list.\n"
455        return filelist
456    end
457
458
459    ### Combine a call to #readManifest with one to #vetManifest.
460    def getVettedManifest( manifestFile="MANIFEST", antimanifest=ANTIMANIFEST )
461        vetManifest( readManifest(manifestFile), antimanifest )
462    end
463
464
465    ### Given a documentation <tt>catalogFile</tt>, extract the title, if
466    ### available, and return it. Otherwise generate a title from the name of
467    ### the CVS module.
468    def findRdocTitle( catalogFile="docs/CATALOG" )
469
470        # Try extracting it from the CATALOG file from a line that looks like:
471        # Title: Foo Bar Module
472        title = findCatalogKeyword( 'title', catalogFile )
473
474        # If that doesn't work for some reason, use the name of the project.
475        title = extractProjectName()
476
477        return title
478    end
479
480
481    ### Given a documentation <tt>catalogFile</tt>, extract the name of the file
482    ### to use as the initally displayed page. If extraction fails, the
483    ### +default+ will be used if it exists. Returns +nil+ if there is no main
484    ### file to be found.
485    def findRdocMain( catalogFile="docs/CATALOG", default="README" )
486
487        # Try extracting it from the CATALOG file from a line that looks like:
488        # Main: Foo Bar Module
489        main = findCatalogKeyword( 'main', catalogFile )
490
491        # Try to make some educated guesses if that doesn't work
492        if main.nil?
493            basedir = File::dirname( __FILE__ )
494            basedir = File::dirname( basedir ) if /docs$/ =~ basedir
495           
496            if File::exists?( File::join(basedir, default) )
497                main = default
498            end
499        end
500
501        return main
502    end
503
504
505    ### Given a documentation <tt>catalogFile</tt>, extract an upload URL for
506    ### RDoc.
507    def findRdocUpload( catalogFile="docs/CATALOG" )
508        findCatalogKeyword( 'upload', catalogFile )
509    end
510
511
512    ### Given a documentation <tt>catalogFile</tt>, extract a CVS web frontend
513    ### URL for RDoc.
514    def findRdocCvsURL( catalogFile="docs/CATALOG" )
515        findCatalogKeyword( 'webcvs', catalogFile )
516    end
517
518
519    ### Given a documentation <tt>catalogFile</tt>, try extracting the given
520    ### +keyword+'s value from it. Keywords are lines that look like:
521    ###   # <keyword>: <value>
522    ### Returns +nil+ if the catalog file was unreadable or didn't contain the
523    ### specified +keyword+.
524    def findCatalogKeyword( keyword, catalogFile="docs/CATALOG" )
525        val = nil
526
527        if File::exists? catalogFile
528            verboseMsg "Extracting '#{keyword}' from CATALOG file (%s).\n" % catalogFile
529            File::foreach( catalogFile ) {|line|
530                debug_msg( "Examining line #{line.inspect}..." )
531                val = $1.strip and break if /^#\s*#{keyword}:\s*(.*)$/i =~ line
532            }
533        end
534
535        return val
536    end
537
538
539    ### Given a documentation <tt>catalogFile</tt>, which is in the same format
540    ### as that described by #readManifest, read and expand it, and then return
541    ### a list of those files which appear to have RDoc documentation in
542    ### them. If <tt>catalogFile</tt> is nil or does not exist, the MANIFEST
543    ### file is used instead.
544    def findRdocableFiles( catalogFile="docs/CATALOG" )
545        startlist = []
546        if File.exists? catalogFile
547            verboseMsg "Using CATALOG file (%s).\n" % catalogFile
548            startlist = getVettedManifest( catalogFile )
549        else
550            verboseMsg "Using default MANIFEST\n"
551            startlist = getVettedManifest()
552        end
553
554        verboseMsg "Looking for RDoc comments in:\n"
555        startlist.select {|fn|
556            verboseMsg #{fn}: "
557            found = false
558            File::open( fn, "r" ) {|fh|
559                fh.each {|line|
560                    if line =~ /^(\s*#)?\s*=/ || line =~ /:\w+:/ || line =~ %r{/\*}
561                        found = true
562                        break
563                    end
564                }
565            }
566
567            verboseMsg( (found ? "yes" : "no") + "\n" )
568            found
569        }
570    end
571
572    ### Open a file and filter each of its lines through the given block a
573    ### <tt>line</tt> at a time. The return value of the block is used as the
574    ### new line, or omitted if the block returns <tt>nil</tt> or
575    ### <tt>false</tt>.
576    def editInPlace( file, testMode=false ) # :yields: line
577        raise "No block specified for editing operation" unless block_given?
578
579        tempName = "#{file}.#{$$}"
580        File::open( tempName, File::RDWR|File::CREAT, 0600 ) {|tempfile|
581            File::open( file, File::RDONLY ) {|fh|
582                fh.each {|line|
583                    newline = yield( line ) or next
584                    tempfile.print( newline )
585                    $deferr.puts "%p -> %p" % [ line, newline ] if
586                        line != newline
587                }
588            }
589        }
590
591        if testMode
592            File::unlink( tempName )
593        else
594            File::rename( tempName, file )
595        end
596    end
597
598    ### Execute the specified shell <tt>command</tt>, read the results, and
599    ### return them. Like a %x{} that returns an Array instead of a String.
600    def shellCommand( *command )
601        raise "Empty command" if command.empty?
602
603        cmdpipe = IO::popen( command.join(' '), 'r' )
604        return cmdpipe.readlines
605    end
606
607    ### Execute a block with $VERBOSE set to +false+, restoring it to its
608    ### previous value before returning.
609    def verboseOff
610        raise LocalJumpError, "No block given" unless block_given?
611
612        thrcrit = Thread.critical
613        oldverbose = $VERBOSE
614        begin
615            Thread.critical = true
616            $VERBOSE = false
617            yield
618        ensure
619            $VERBOSE = oldverbose
620            Thread.critical = false
621        end
622    end
623
624
625    ### Try the specified code block, printing the given
626    def try( msg, bind=TOPLEVEL_BINDING )
627        result = ''
628        if msg =~ /^to\s/
629            message "Trying #{msg}...\n"
630        else
631            message msg + "\n"
632        end
633           
634        begin
635            rval = nil
636            if block_given?
637                rval = yield
638            else
639                file, line = caller(1)[0].split(/:/,2)
640                rval = eval( msg, bind, file, line.to_i )
641            end
642
643            if $yaml
644                result = rval.to_yaml
645            else
646                PP.pp( rval, result )
647            end
648
649        rescue Exception => err
650            if err.backtrace
651                nicetrace = err.backtrace.delete_if {|frame|
652                    /in `(try|eval)'/ =~ frame
653                }.join("\n\t")
654            else
655                nicetrace = "Exception had no backtrace"
656            end
657
658            result = err.message + "\n\t" + nicetrace
659        ensure
660            divider
661            message result + "\n"
662            divider
663            $deferr.puts
664        end
665    end
666end
667
668
669if __FILE__ == $0
670    # $DEBUG = true
671    include UtilityFunctions
672
673    projname = extractProjectName()
674    header "Project: #{projname}"
675
676    ver = extractVersion() || [0,0,1]
677    puts "Version: %s\n" % ver.join('.')
678
679    if File::directory?( "docs" )
680        puts "Rdoc:",
681            "  Title: " + findRdocTitle(),
682            "  Main: " + findRdocMain(),
683            "  Upload: " + findRdocUpload(),
684            "  SCCS URL: " + findRdocCvsURL()
685    end
686
687    puts "Manifest:",
688        "  " + getVettedManifest().join("\n  ")
689end
Note: See TracBrowser for help on using the browser.