Building Delphi with Ruby

By: a z

Abstract: Using Ruby to automatically compile Delphi projects, resource scripts, install scripts, ...

    Introduction

Compiling a Delphi project is dead simple. Hit Ctrl-F9 and 0.9 seconds later you're done.

The problems arise when you start looking at all the other stuff that needs to be done:

  • Run unit tests
  • Update version resources
  • Compile help files
  • Build setup applications
  • Zip the setup application
  • Upload the zip to a FTP site
  • Upload the documentation to a website

The list goes on. Each step by itself is easy enough, but in combination it's tedious and prone to error. An automated build system promises to reduce the misery. What follows in this article is a description of how I use Ruby to automate this build process.

In Code Central you'll find a zip file with a Ruby script called "buildTools.rb" that provides the core functionality of the build system. Also included is a Delphi configuration file with all the options documented, a sample Delphi project, and "build.rb" file which is the build script for the sample project.

    Getting Started

    Where To Get Ruby

The official Ruby home page is www.ruby-lang.org . The "One-Click Installer" is all it takes to get started with Ruby. For assistance with Ruby check out the comp.lang.ruby newsgroup. Dave Thomas and Andy Hunt have kindly made available a CHM version of the 1st edition of their book "Programming Ruby" which is located in the ruby\doc directory. This book is one of the better references to Ruby. You can also order the 2nd edition from their website.

    Installing Ruby

The first question is, "Where do you install it?" By default Ruby installs into C:\ruby but I prefer to install it into a subdirectory of my projects folder (P:\builds\ruby). This allows me to easily place it under version control. I'll talk more about the directory structure a couple sections down.

    Configuring Ruby

I keep my Ruby scripts in P:\build rather than in ruby\lib, consequently the path list that Ruby uses to search for files needs to be updated. Appending "-IP:\build" to the RUBYOPT environmental variable (without the quotes) solves the problem.

    Team Development

The Ruby install automatically configured the PATH and the PATHEXT environment variables for us and also set up file associations for .RB and .RBW. Rather than force every programmer on the team to go through the download and setup process, just create a DOS command script that performs these same actions and place it under version control in the P:\build directory. They can then simply check out P:\build and run the script. This should do the trick nicely:

            @echo off

            :: check if the ruby\bin path already exists in the PATH var
            set rubyPath=%CD%\ruby\bin
            for %%x in ("%path:;=" "%") do (
                if /I %%x == "%rubyPath%" (
                    echo ruby\bin already exists in PATH - no change required.
                    goto skipSettingPath
                )
            )
            setx PATH %PATH%;"%rubyPath%"
            echo Added ruby\bin directory to PATH variable.
            :skipSettingPath

            :: check if the Ruby file extension .rb already exists in PATHEXT
            for %%x in ("%pathext:;=" "%") do (
                if /I %%x == ".RB" (
                    echo RB already exists in PATHEXT    - no change required.
                    goto skipSettingRB
                )
            )
            setx PATHEXT %PATHEXT;.rb
            echo Added .RB to PATHEXT variable.
            :skipSettingRB

            :: check if the Ruby file extension .rbw already exists in PATHEXT
            for %%x in ("%pathext:;=" "%") do (
                if /I %%x == ".RBW" (
                    echo RBW already exists in PATHEXT   - no change required.
                    goto skipSettingRBW
                )
            )
            setx PATHEXT %PATHEXT;.rbw
            echo Added .RBW to PATHEXT variable.
            :skipSettingRBW

            :: create file types
            ftype rbFile=%rubyPath%\ruby.exe "%%1" %%*   > nul:
            ftype rbwFile=%rubyPath%\rubyw.exe "%%1" %%* > nul:

            :: setup file associations. Do NOT put spaces before ">"
            assoc .rb=rbFile>nul:
            assoc .rbw=rbwFile>nul:

            echo File associations configured.

            :: append current directory to RUBYOPT
            set option=-I%CD%
            for %%x in ("%rubyopt: =" "%") do (
                if /I %%x == "%option%" (
                    echo %option% already exists in RUBYOPT - no change required.
                    goto skipSettingRubyOpt
                )
            )
            setx RUBYOPT "%RUBYOPT% %option%"
            echo Added %option% to RUBYOPT variable.
            :skipSettingRubyOpt
            
            :: the build scripts rely on PROJECTS pointing to the root of your
            :: projects directory, which is probably the parent directory of the
            :: current directory
            set originalDir=%CD%
            cd ..
            setx PROJECTS %CD%
            cd %originalDir%

            echo Done.
    

    Directory Structure and File Paths

I've dedicated my P: drive to my projects, and I'm using P:\build to store all of the tools required for building my projects. This includes five different versions of Delphi, two different versions of the .Net framework, HelpMaker , InnoSetup and of course, Ruby itself.

Throughout the rest of the article I'm going to assume that you're using the same directory structure as shown below, but you can easily adjust it to your environment by modifying the path constants at the top of buildTools.rb. This script also requires the PROJECTS environment variable to point to P:.

Hide image
Directory Structure

Note that Ruby prefers forward slashes in filenames while Windows prefers backslashes. Both will often - but not always - accept the other. Since most of the functions in buildTools.rb call Windows applications, it's best to use backslashes. Paths can be absolute but it's better to express them relative to the directory that the build script is located in. By convention the build script is called build.rb.

    Compiling Delphi

The buildTools.rb script contains the core functions for compiling Delphi (and other) projects. Among other things it defines a function called "compileDelphi" which allows us to compile a DPR or DPK file. For example, it might be called from our build.rb script like this:

        compileDelphi(Delphi10,"sample.dpr","Standard.cfg",'')
    

The first parameter specifies the Delphi version we wish to use. "Delphi10" is a constant defined in buildTools.rb which defines the path to the directory containing the Delphi command line compiler and libraries.

The second parameter is the project or package to compile. Compiling an entire package group at once is not supported. The path to the project is relative to the current directory (by default this is the directory that contains the build script).

The third parameter is the configuration file. I always create a separate *.cfg file for use solely with the build script. This ensures that the IDE doesn't modify the config file without our knowledge and thus helps create a more stable build system. I use the same config file whether I'm building a debug version or a release version, which leads use to the fourth parameter.

The fourth parameter is optional, but allows you to specify additional compiler options which will override those in the config file. For example, the following two lines will first build a test release, and then build a production release:

        compileDelphi(Delphi10,"sample.dpr","Standard.cfg",
            "-VN -DUSE_JEDI_JCL;FullDebugMode;NoMessageBoxes")
        compileDelphi(Delphi10,"sample.dpr","Standard.cfg")
    

The test release has debugging information included (-VN) and defines the constants USE_JEDI_JCL, FullDebugMode and NoMessageBoxes. These constants are used by DUnit and FastMM. There's a similar "compileDelphiNet" function for compiling Delphi.Net applications.

Setting up the config file for the very first time can be a daunting task. Who can remember what -$W- or -$YD means? To make it a bit easier I use a config file that contains comments about each option. The one shown below works with Delphi 7 and might need some minor adjustments to work with other versions. You'll notice that \build\Delphi7\lib is specified via a -U switch. This allows the command line compiler to find the standard Delphi libraries. You'll also need to add references to any other libraries that you're using.

        ;==============================================
        ;Directory paths
        ;==============================================
        ;-U... unit directories
        ;-O... object directores
        ;-I... include directories
        ;-R... resource directories

        -U\build\Delphi7\lib

        ;.dcu output directory
        ;-N0.\dcu

        ;.bpl (LE) & .dcp (LN) output directory
        ;-LE
        ;-LN

        ;exe/dll output directory
        ;-E.\bin

        ;use package
        ;-LU(package)

        ;==============================================
        ;Conditional symbols    (-Dblip)
        ;==============================================
        ;-DTEST_MODE

        ;==============================================
        ;Likely to change
        ;==============================================
        -cg             ; application type (g=GUI, c=console)
        ;-VN            ; include Debug information in EXE
        ;-VR            ; generate remote debug info (*.RSM)

        ;==============================================
        ;Warnings
        ;==============================================
        -w-UNSAFE_TYPE
        -w-UNSAFE_CODE
        -w-UNSAFE_CAST
        -w+SYMBOL_DEPRECATED
        -w+SYMBOL_LIBRARY
        -w-SYMBOL_PLATFORM
        -w+UNIT_LIBRARY
        -w+UNIT_PLATFORM
        -w+UNIT_DEPRECATED
        -w+HRESULT_COMPAT
        -w+HIDING_MEMBER
        -w+HIDDEN_VIRTUAL
        -w+GARBAGE
        -w+BOUNDS_ERROR
        -w+ZERO_NIL_COMPAT
        -w+STRING_CONST_TRUNCED
        -w+FOR_LOOP_VAR_VARPAR
        -w+TYPED_CONST_VARPAR
        -w+ASG_TO_TYPED_CONST
        -w+CASE_LABEL_RANGE
        -w+FOR_VARIABLE
        -w+CONSTRUCTING_ABSTRACT
        -w+COMPARISON_FALSE
        -w+COMPARISON_TRUE
        -w+COMPARING_SIGNED_UNSIGNED
        -w+COMBINING_SIGNED_UNSIGNED
        -w+UNSUPPORTED_CONSTRUCT
        -w+FILE_OPEN
        -w+FILE_OPEN_UNITSRC
        -w+BAD_GLOBAL_SYMBOL
        -w+DUPLICATE_CTOR_DTOR
        -w+INVALID_DIRECTIVE
        -w+PACKAGE_NO_LINK
        -w+PACKAGED_THREADVAR
        -w+IMPLICIT_IMPORT
        -w+HPPEMIT_IGNORED
        -w+NO_RETVAL
        -w+USE_BEFORE_DEF
        -w+FOR_LOOP_VAR_UNDEF
        -w+UNIT_NAME_MISMATCH
        -w+NO_CFG_FILE_FOUND
        -w+IMPLICIT_VARIANTS
        -w+UNICODE_TO_LOCALE
        -w+LOCALE_TO_UNICODE
        -w+IMAGEBASE_MULTIPLE
        -w+SUSPICIOUS_TYPECAST
        -w+PRIVATE_PROPACCESSOR
        -w+MESSAGE_DIRECTIVE

        ;==============================================
        ;Historical options
        ;==============================================
        -$H+            ; if on 'string' means a long string (AnsiString), if
                        ;   off, it means a short string (ShortString)
        -$P+            ; 'openstring' checking
        -$V+            ; var string checking - if on, then the argument's
                        ;   length must match parameter's length

        ; unit aliases:
        -AWinTypes=Windows;WinProcs=Windows;DbiTypes=BDE;DbiProcs=BDE;DbiErrs=BDE;


        ;==============================================
        ;Unlikely to change
        ;==============================================
        -$A8             ; align record fields on 8 byte boundary
        -$B-             ; off means disable full boolean evaluation
        -$C+             ; evaluate assertions
        -$D+             ; include line number info in DCUs (has no effect on
                         ;   final executable)
        -$G+             ; use imported data references - allows packages to
                         ;   reference variables in other packages
        -$I+             ; i/o checks, if on an exception will be raised on an
                         ;   i/o error, if off, the IOResult variable will be set
        -$J-             ; if on, typed constants can be modified
        -$L+             ; local symbol info - if enabled, allows the debugger
                         ;   to inspect the values of variables declared in the
                         ; implementation section of the unit, as well as the
                         ;   variables local to a method/function/procedure.
                         ; (no effect on executable size)
        -$M-             ; typeInfo - adds RTTI info for published methods and
                         ;   properties
        -$O+             ; optimization
        -$Q+             ; overflow checks
        -$R+             ; range checks
        -$T-             ; type @ operator
        -$U-             ; if on, protects against divide flaw found in early
                         ;   Pentiums
        -$W-             ; stack frames
        -$X+             ; extended syntax (enables functions as procedures,
                         ;   'result' variable, assigning strings to PChar)
        -$YD             ; generates info used for code navigation by the
                         ;   Project Manager, Code Editor, and Code Explorer
                         ;   $Y- = disabled
                         ;   $YD = include info on where identifiers are declared,
                         ;         and where routines are implemented
                         ;   $Y+ = include same info as $YD, but also include info
                         ;         on where the identifiers are referenced
        -$Z1             ; minimum size of an enumeration - either 1, 2, or 4
        -H+              ; hints on
        -W+              ; warnings on
        -$M16384,1048576 ; min & max stack size
        -K$00400000      ; set image base address
        -GD              ; generate map file (D = detailed, P = publics only,
                         ;   S = segments only)
        -B               ; build rather than make
        -Q               ; quiet compile

        ;==============================================
        ;Purpose unknown, probably not significant
        ;==============================================
        -$E-
        -$F-
        -$K-
        -$N+
        -$S-

        ;==============================================
        ;I'm unlikely to use these
        ;==============================================
        ;-M                              ; make
        ;-F(offset)                      ; Find error
        ;-J                              ; Generate .obj file
        ;-JPHNE                          ; Generate C++ .obj file, .hpp file, in
                                         ;   namespace, export all
        ;-NH(path)                       ; unit .hpp output directory
        ;-NO(path)                       ; unit .obj output directory
        ;-NB(path)                       ; unit .bpi output directory
        ;-NS(namespaces)                 ; Namespace search path
        ;-P                              ; look for 8.3 file names also
        ;-Z                              ; Output 'never build' DCPs
        ;--codepage:(cp)                 ; specify source file encoding
        ;--default-namespace:(namespace) ; set namespace
        ;--depends                       ; output unit dependency information
        ;--doc                           ; output XML documentation
        ;--drc                           ; output resource string .drc file
        ;--no-config                     ; do not load default DCC32.CFG file

    

The buildTools.rb script expects to find the Delphi files in P:\builds\Delphi{version}. At a minimum you need a "P:\builds\Delphi{version}\bin" subdirectory that contains:

File D5 D6 D7 D8 D9 D10
brcc32.exe Y Y Y N ? Y
dcc32.exe Y Y Y N ? Y
dccil.exe N N N Y ? Y
lnkdfm50.dll Y N N N ? N
lnkdfm60.dll N Y N N ? N
lnkdfm70.dll N N Y N ? N
lnkdfm100.exe N N N N ? Y
rlink32.dll Y Y Y Y ? Y
rw32core.dll Y Y Y Y ? Y
Borland.Vcl.LnkDfm.dll N N N Y ? N
Borland.Delphi.Compiler.ResCvt.dll N N N N ? Y

I don't own Delphi 2005 (i.e.: D9), but I expect that the files it requires are similar to Delphi 8.

The P:\build\Delphi{version}\lib directory should contain all of the standard Delphi files found in the Delphi lib directory (typically C:\Program Files\Borland\Delphi{version}\lib).

    Other Tools

    InnoSetup

As mentioned earlier, I use JR Software's InnoSetup to build setups. Calling the InnoSetup command line compiler is very straightforward:

            compileInnoSetup("setup.iss","")
        

The second parameter is optional but can be used to pass options to the iscc.exe program. Currently iscc supports just three parameters:

            /Opath      Output files to the specified path
            /Ffilename  Specifies the filename for the setup program
            /Q          quiet compile
        

While these parameters are useful, I need greater flexibility than what these provide. This is where the filterFile function comes into play. This is a simple Ruby function that takes the name of a source and destination file and an array of substitutions to apply to the file. For example:

            substitutions = [
                ['{password}', "secret"       ],
                ['{version}',  "1.2.3.892"    ],
                ['{encrypt}',  "Yes"          ],
                ['{fileDir}',  "P:\\myapp"    ]
            ]
            filterFile("setup\\setup.iss","setup\\tempSetup.iss",substitutions)
            compileInno("setup\\tempSetup.iss")
        

The substitutions variable is an array of arrays with two elements each. The filterFile function will search for all occurrences of the first element and replace it with the second element. The setup script looks like this:

            [Setup]
            LicenseFile         ={fileDir}\license.txt
            Encryption          ={encrypt}
            Password            ={password}
            VersionInfoVersion  ={version}

            [Files]
            Source: {fileDir}\license.txt; DestDir: {app}; Flags: ignoreversion
            ...
        

There's nothing magical about using braces to enclose the search strings - you can use any character or none at all. The "{app}" string in the above setup script is a standard InnoSetup variable.

    HelpMaker

VizAcc's HelpMaker supports compiling help files from the command line so it's very easy to integrate it into our build process:

            compileHelp(   "help\\myapp.sh5")    # standard CHM help file
            compileWebHelp("help\\myapp.sh5")    # generates browser-based help
        

You can also use "compileHtmlHelp" to invoke the standard Microsoft Help compiler.

I like to include the version number of the software in the help file, but HelpMaker doesn't provide any way of doing this automatically. The filterFile function comes to the rescue again:

            compileHelp("help\\gastown.sh5")
            filterFile("help\\_tmphhp\\introduction.htm", '',
                [['{version}', "1.3.2.892"]] )
            compileHtmlHelp("help\\_tmphhp\\gastown.hhp")
        

This script uses compileHelp to launch HelpMaker. HelpMaker converts its *.SH5 file to a set of HTML files, and then it invokes the Microsoft Help Compiler to compile them into a CHM file. HelpMaker normally deletes the HTML files afterwards but HelpMaker has an option to disable this. This allows us to use the filterFile function to insert the current version in a HTML file, and then call the Microsoft Help Compiler ourselves to recreate the *.CHM file. It's a roundabout way of doing it, but it works well. Since we left the name of the destination file blank in the call to filterFile it will modify the source file itself rather than create a new file.

    Resources

I prefer to manage version information in a separate resource file (*.RC) rather than let the Delphi IDE handle it. Resource files can include other information such as images, SQL scripts, etc. The compileResource function invokes the Borland BRCC.EXE application to compile these scripts. You can specify the file and product version as well as any other substitutions.

            compileResourceScript(
              'main.rc',                      # the resource file
              '1.3.2.59',                     # the file version
              '3.1',                          # the product version
              [['{buildTime}',Time.now]]      # any other substitutions
            )
        

    Unit Tests

You'll notice that there are functions in buildTest.rb for unit testing. Since these use a slightly customized version of DUnit and a simple home-grown testing framework for .Net, I doubt that these will be useful to you in their current form, but they should give you a starting point for creating your own functions.

    Utility Functions

There are a variety of utility functions in buildTools.rb, mainly focused on file management. Some are just convenient wrappers around standard Ruby functions. Peruse the file to learn more.

    Real World Example

Here's a real-life example of a build script that I use for compiling one of my standard utility packages. This script compiles a resource script, compiles the test project, runs the unit tests, and then compiles the production package -- all in just five statements.

        require "buildTools"

        # Compile the project used for unit testing
        compileResource('main\utStringsTest.rc')
        compileDelphi(Delphi10,'utilsTest.dpr', 'Standard.cfg',
            '-DUSE_JEDI_JCL;FullDebugMode;NoMessageBoxes;LOG_TEST'
        )

        # run the test project. This raises a Ruby exception if a test fails
        dunitTest('..\bin\utilsTest.exe')

        # compile the utility package
        compileDelphi(Delphi10,'utils.dpk', 'Standard.cfg')
    

    Summary

This provides a very good start on a build system. Although I haven't discussed it here I also have a "autobuild.rb" script which scans the entire projects directory looking for "build.rb" scripts, executes them one by one, and logs results to file and email. It can also invoke VMWare to perform the builds in a clean environment.

Building projects need not be an onerous task. With a bit of effort up front you can develop a system that guarantees a smooth, low-effort, predictable build process.

Server Response from: ETNASC01