Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Tutorial

zorbathut edited this page · 17 revisions
Clone this wiki locally

NOTE: This tutorial applies to code that is not yet written. I’m writing the tutorial first to make sure the interface is useful. Afterwards, I’ll be writing the code. If you’re reading this and thinking “hmm, this sounds like fun, I wish to use this software” then the software may actually not exist quite yet.

So, What’s Ursa?

Ursa is a build tool, similar to GNU Make or SCons. Ursa’s design philosophy is to not make assumptions and to do only what is requested. Additionally, Ursa takes a design cue from Lua and is built as a simple underlying framework, with an optional library layered on top of it for high-level features.

Ursa is built as a Lua library and, theoretically, could be embedded in Lua programs. At the moment, this isn’t a design goal.

Who is Ursa for?

Ursa is ideal for people with complicated nasty build processes that do not lend themselves well to classic build systems. The author wrote Ursa to handle a build system that encompassed several sublibraries constructed at build-time, three separate runtime environments, large-scale datafile processing, and several proprietary tools, all intended to build installer packages from sourcecode and sourcedata in a single command.

Most frameworks are not built to handle this gracefully – they make assumptions of a single compiler, a single set of flags, a single output target. Ursa makes no assumptions, and thus never has to be corrected or overridden.

Who isn’t Ursa for?

With great power comes great responsibility. Ursa may, someday, provide utility libraries to make conventional programs as simple as in other build tools. Ursa does not yet provide those libraries. If all you want is to build a standard Unix-style program, Ursa may be overkill for you. Ursa is really intended for people who are already dealing with individual compiler flags – if you’re looking for an alternative to “g++ -o myprogram *.cpp”, you may want to look elsewhere.

If you don’t understand the line “g++ -o myprogram *.cpp” instantly, you almost certainly want to look elsewhere.

Hello World

The best way to start is with an example.


-- Denfile for Hello World 0.1

-- Import the Ursa library
require "ursa"

-- Define a rule to build "hello.o"
ursa.rule{"hello.o", "hello.cpp", "g++ -o hello.o -c hello.cpp"}

-- Define a rule to build "hello"
ursa.rule{"hello", "hello.o", "g++ -o hello hello.o"}

-- Define a pair of commands that can be triggered as build targets
ursa.command{ursa.command.default, "hello"}
ursa.command{"run", "hello", "./hello"}
-- [[
"ursa.command.default" is an opaque token that specifies a default
command when Ursa is run without a parameter. The third parameter
of "command" is run after the build is complete.
]]

-- Start the actual build process
ursa.build{...}

The core functionality of Ursa is defined in terms of rules. A rule describes how to turn some number of input files and tokens into some number of output files. As should be obvious above, Ursa takes the convention of {destination, source, process}.

Some build tools use opaque tokens to represent intermediate files. Ursa does not – all files are described by their filename.

Removing Duplication

The above code is rather nasty. The rule to build the object duplicates the file prefix “hello” four times, and the rule to build the application duplicates the “hello.o” object twice more and the final executable name another two times. This is solvable entirely through Lua, without leaning on any Ursa utilities.


-- Denfile for Hello World 1.4

-- Import the Ursa library
require "ursa"

-- List our source files
local sources = {"main", "ui", "debug", "log", "driver_vga", "driver_console", "driver_x"}

-- Build object files
local objects = {}
for _, file in ipairs(sources) do
  ursa.rule{file .. ".o", file .. ".cpp", ("g++ -o %s.o -c %s.cpp"):format(file, file)}
  table.insert(objects, file .. ".o")
end

ursa.rule{"hello", objects, "g++ -o hello ".. table.concat(objects, " ")}

ursa.command{ursa.command.default, "hello"}
ursa.command{"run", "hello", "./hello"}

ursa.build{...}

Note that the second parameter of ursa.rule is now a table. Tables of filenames can universally be substituted for filenames. In the case of rule dependencies, it means that the rule depends on multiple files. In the case of rule products, it means the rule creates multiple files.

Besides the multi-file support, the above example involves no more Ursa API than the previous example did.

This is incredibly ugly!

It’s true.

Ursa is not intended for the use of small programs like this – it will invariably be more verbose and more complicated than a conventional build system. As I’ve mentioned previously, Ursa really shines with large complicated projects, where the automatic behavior and other “conveniences” of a conventional system would otherwise have to be painstakingly worked around.

This makes it rather difficult to show in a good light, since any situation where Ursa is preferable will, out of necessity, be complicated and rather painful. If you’re trying to demonstrate the power of a hundred-ton earthmover, but you’re doing it in someone’s back yard, they’ll probably suggest you use a shovel instead, and they’d be right. Please imagine applying these examples to the nastiest build system you’ve ever worked on. If you haven’t worked on a nasty build system, you probably don’t want to use Ursa :)

Configuration

A program that is ported to another operating system will often end up with many build steps that have to be changed somewhat. GNU tools has autoconf, SCons has a configuration system.

The simple obvious way to do this in Ursa is to use Lua functions to run external programs. The Lua library is difficult (though not impossible) to use for this, so I will simultaneously introduce an ursa.util function.


-- Denfile for Hello World 1.5

require "ursa"

-- Hello World now depends on libpng
local libpng_cflags = ursa.util.system("pkg-config libpng --cflags")
local libpng_linkflags = ursa.util.system("pkg-config libpng --libs")

local sources = {"main", "ui", "debug", "log", "driver_vga", "driver_console", "driver_x"}

local objects = {}
for _, file in ipairs(sources) do
  ursa.rule{file .. ".o", file .. ".cpp", ("g++ -o %s.o -c %s.cpp %s"):format(file, file, libpng_cflags)}
  table.insert(objects, file .. ".o")
end

ursa.rule{"hello", objects, ("g++ -o hello %s %s"):format(table.concat(objects, " "), libpng_linkflags)}

ursa.command{ursa.command.default, "hello"}
ursa.command{"run", "hello", "./hello"}

ursa.build{...}

ursa.util.system runs the given string via the shell and returns stdout as a string. If the shell returns an error code, it calls error() with the contents of stderr.

Before I continue, I should point out that I have made no attempt at defining an “official style” for Ursa. There are many ways to write an Ursa script. The correct way is the one that builds your project in the best way, where “best” depends entirely on your requirements and abilities. Libraries like zlib and libpng do not claim to define “best practices” for the rest of your program, and neither does Ursa.

This works, but there’s an issue: some configuration steps are slow. Occasionally your configuration system has to call g++ or similarly large programs in order to test capabilities. Doing this every time you try to build your program – sometimes just to prove that your program is up to date – would be horribly slow. ursa.token is a solution.


-- Denfile for Hello World 1.5

require "ursa"

-- Hello World now depends on libpng
ursa.token.rule{"libpng_cflags", nil, "pkg-config libpng --cflags"}
ursa.token.rule{"libpng_linkflags", nil, "pkg-config libpng --libs"}

local sources = {"main", "ui", "debug", "log", "driver_vga", "driver_console", "driver_x"}

local objects = {}
for _, file in ipairs(sources) do
  ursa.rule{file .. ".o", file .. ".cpp", ("g++ -o %s.o -c %s.cpp %s"):format(file, file, ursa.token{"libpng_cflags"})}
  table.insert(objects, file .. ".o")
end

ursa.rule{"hello", objects, ("g++ -o hello %s %s"):format(table.concat(objects, " "), ursa.token{"libpng_linkflags"})}

ursa.command{ursa.command.default, "hello"}
ursa.command{"run", "hello", "./hello"}

ursa.build{...}

Tokens are a “build step” that generate internal values, not files. These values will be cached, and tokens themselves live inside the dependency tree much like any other rule does. Tokens have dependencies will be rebuilt only if their dependencies change.

As of the current build, tokens are limited to numbers, strings, or tables arranged into a tree layout. This might be improved in the future.

Clean Builds

Ursa includes hooks for clearing tokens and iterating over rules. These can be used to write a command that cleans up all intermediate files.

There’s almost never any reason to do so, however, since ursa.util includes a function prewritten for you.


ursa.command{"clean", ursa.util.clean}

I’m not going into any detail on the ursa API for this at the moment.

CPP dependencies

Functions can be embedded in rule dependency lists. They’ll be called if and only if the rule needs to be inspected, and their return values will be added to the dependencies.


-- Denfile for Hello World 2.0
require "luarocks.loader"
require "ursa"

local sources = {"main", "ui", "log"}

-- Parse the result from g++'s built-in dependency scanner
local function make_dependencies(srcfile)
  local deps = ursa.util.system(("g++ -MM %s"):format(srcfile))
  deps = deps:match("^.*: (.*)$")

  local dependencies = {}
  for file in deps:gmatch("([^ \n\t]+)") do
    table.insert(dependencies, file)
  end

  return dependencies
end

local objects = {}
for _, file in ipairs(sources) do
  local cpp = file .. ".cpp"
  local o = file .. ".o"

  ursa.rule{o, make_dependencies(cpp), ("g++ -o %s.o -c %s.cpp"):format(file, file)}
  table.insert(objects, o)
end

ursa.rule{"hello.exe", objects, ("g++ -o hello.exe %s"):format(table.concat(objects, " "))}

ursa.command{ursa.command.default, "hello.exe"}
ursa.command{"run", "hello.exe", "./hello.exe"}

ursa.build{...}

Note that g++ is not called until it’s needed – if a file is never touched, then its dependencies are never calculated. Unfortunately, its dependencies are re-calculated each time, whether or not they’re necessary.

Ursa’s token system can be used to fix this. One potential problem is that the “dependency token” will have to contain, as dependencies, the same dependencies that it is used to generate. Circular references aren’t allowed, so normally it cannot hold “itself” as a dependency. A flag can be passed to ursa.token to fix this issue and provide a default set for the first build.


-- Denfile for Hello World 2.0
require "luarocks.loader"
require "ursa"

local sources = {"main", "ui", "log"}

-- Parse the result from g++'s built-in dependency scanner
local function make_dependencies(srcfile)
  local deps = ursa.util.system(("g++ -MM %s"):format(srcfile))
  deps = deps:match("^.*: (.*)$")

  local dependencies = {}
  for file in deps:gmatch("([^ \n\t]+)") do
    table.insert(dependencies, file)
  end

  return dependencies
end

local objects = {}
for _, file in ipairs(sources) do
  local cpp = file .. ".cpp"
  local o = file .. ".o"
  local depend = file .. " dependencies"

  ursa.token.rule{depend, ursa.util.token_deferred{depend, default = cpp}, function () return make_dependencies(cpp) end}
  ursa.rule{o, ursa.util.token_deferred{depend}, ("g++ -o %s.o -c %s.cpp"):format(file, file)}
  table.insert(objects, o)
end

ursa.rule{"hello.exe", objects, ("g++ -o hello.exe %s"):format(table.concat(objects, " "))}

ursa.command{ursa.command.default, "hello.exe"}
ursa.command{"run", "hello.exe", "./hello.exe"}

ursa.build{...}

Note the use of “ursa.util.token_deferred”, which simply creates a function that can be called to return that particular token. The parameters are otherwise identical to ursa.token but the result is calculated at build time rather than when the call occurs.

Something went wrong with that request. Please try again.