grawkit/grawkit

638 lines
20 KiB
Awk
Executable File

#!/usr/bin/awk --exec
#
# Grawkit — The Awksome Git Graph Generator.
# ==========================================
#
# Grawkit is a tool that helps build SVG graphs from Git command-line descriptions.
#
# This documentation is built using Markdown syntax, and can be parsed out by running `make doc` in
# the project root. Please check the project's README file for additional information.
#
# Built-in Functions
# ------------------
#
# This section contains global helper functions, used across different rules, as defined in the next
# section below.
# > Function `add_branch` adds a new, empty branch to the internal list of branches to render.
function add_branch(name) {
branches[len["branches"],"name"] = name
branches[len["branches"],"refs"] = ""
branches[len["branches"],"tags"] = ""
# Branches created before the first commit is established extend to the beginning of time.
if (state["HEAD"] != "") {
branches[len["branches"],"merges"] = state["branch"] "||" state["HEAD"]
} else {
branches[len["branches"],"merges"] = len["branches"] "||0"
}
len["branches"] += 1
}
# > Function `add_commit` adds a new commit, with a specific type and message, to the internal list
# > of commits to render.
function add_commit(type, msg) {
# Add commit information.
commits[len["commits"],"type"] = type
commits[len["commits"],"message"] = msg
# Update commit references.
if (branches[state["branch"],"refs"] == "") {
branches[state["branch"],"refs"] = len["commits"]
} else {
branches[state["branch"],"refs"] = branches[state["branch"],"refs"] "," len["commits"]
}
state["HEAD"] = len["commits"]
len["commits"] += 1
}
# > Function `render_branch` renders pre-defined branch under a specific name to its SVG representation.
function render_branch(idx, _, buf, tmp, refs, tags, t, i, n, hspc, vspc) {
# Do not render branch with no commits.
if (branches[idx,"refs"] == "") {
return
}
# Get commit refs.
n = split(branches[idx,"refs"], refs, ",")
hspc = idx * style["branch/spacing"]
# Print branch root element.
buf = "\n" sprintf(svg["g"], "branch-" normalize(branches[idx,"name"]))
# Add path for branch.
tmp = "M" hspc "," refs[1] * style["commit/spacing"]
tmp = tmp " L" hspc "," refs[count(refs)] * style["commit/spacing"]
# Print path.
buf = buf "\n\t" sprintf(svg["path"], tmp)
# Add commits on path.
for (i = 1; i <= n; i++) {
vspc = refs[i] * style["commit/spacing"]
tmp = sprintf(svg["circle"], hspc, vspc, style["commit/radius"])
buf = buf "\n\t" tmp
}
# Add branch tags as labels.
n = split(branches[idx,"tags"], tags, ",")
for (i = 1; i <= n; i++) {
split(tags[i], t, "|")
buf = buf render_label(t[1], t[2], t[3])
}
# Add branch name as label on last commit.
buf = buf render_label(refs[count(refs)], "branch", branches[idx,"name"])
return buf "\n" svg["/g"]
}
# > Function `render_label` adds a sidebar label at commit index, with a specific
# > class and label name. Multiple labels for the same index will be placed
# > side-by-side.
function render_label(idx, class, name, _, buf, tw, w, h, hspc, vspc) {
# Set specific length for text. Pitch size is approximately right for most fixed-width fonts,
# which are usually twice as tall as they are wide, but may vary for other fonts.
tw = (style["label/font-size"] * 0.5) * length(name)
# Calculate width and height for label rectangle.
w = tw + style["label/spacing"]
h = style["label/font-size"] + style["label/spacing"]
# Calculate label offsets.
hspc = (len["branches"] * style["branch/spacing"]) + label_offset[idx]
vspc = idx * style["commit/spacing"]
# Store width of labels in relation to their commit index.
label_offset[idx] += w + style["label/spacing"]
# Draw label elements.
buf = buf "\n\t" sprintf(svg["gg"], "label-" class, hspc, vspc)
buf = buf "\n\t\t" sprintf(svg["rect"], 0, style["label/font-size"] * -1, w, h, style["label/round"])
buf = buf "\n\t\t" sprintf(svg["text"], style["label/spacing"] / 2, style["label/spacing"] / 4, tw, name)
buf = buf "\n\t" svg["/g"]
return buf
}
# > Function `render_merge` renders merge paths for a branch pointed to by `idx`.
function render_merge(idx, _, buf, tmp, refs, fields, m, i, hspc, last) {
# Do not render merge paths for branch with no commits.
if (branches[idx,"refs"] == "") {
return
}
# Get commit refs.
split(branches[idx,"refs"], refs, ",")
hspc = idx * style["branch/spacing"]
# Print branch root element.
buf = "\n\t" sprintf(svg["g"], normalize("branch-" branches[idx,"name"]))
# Add merge paths for branch, if any.
n = split(branches[idx,"merges"], fields, ",")
for (i = 1; i <= n; i++) {
split(fields[i], m, "|")
# Add starting point to target branch ending point.
tmp = "M" m[1] * style["branch/spacing"] "," m[3] * style["commit/spacing"]
# Add Bezier curve leading to specific commit from source branch.
tmp = tmp " C" hspc "," m[3] * style["commit/spacing"]
tmp = tmp " " hspc "," m[3] * style["commit/spacing"]
# Set default starting point for merge to beginning of branch, if none is set.
tmp = tmp " " hspc "," ((m[2] == "") ? refs[1] : m[2]) * style["commit/spacing"]
# Draw the path.
buf = buf "\n\t\t" sprintf(svg["path"], tmp)
# Store the commit reference for the source commit of the last merge.
last = m[2]
}
# Extend branch to the end of time, if the last commit reference wasn't a merge source commit.
if (refs[count(refs)] != last) {
tmp = "M" hspc "," refs[count(refs)] * style["commit/spacing"]
tmp = tmp " L" hspc "," (state["HEAD"] + 1) * style["commit/spacing"]
buf = buf "\n\t\t" sprintf(svg["path"], tmp)
}
return buf "\n\t" svg["/g"]
}
# > Function `parse_command` reads the given string, and sets the global `command` array variable to
# > its decomposed parts. For example, given the following command-line:
# >
# > git checkout -b some-branch other-branch
# >
# > The `command` would have the following fields set:
# >
# > 'command': 'git checkout'
# > 'argument,b': 'some-branch'
# > 'target': 'other-branch'
# >
# > It is assumed that downstream callers have knowledge of argument keys of interest, and can fetch
# > their values using direct references.
# > Target values can be placed anywhere in the command string; however, these should generally appear
# > after any command-line arguments, and will be preferred in that position if any ambiguities arise.
# > As with command-line arguments, these target values may not appear in command strings, and callers
# > should check for their existence.
function parse_command(str, _, i, m) {
# Clear existing command array.
split("", command)
# Match base command.
match(str, rule["command"])
if (RLENGTH == -1) {
return
}
command["command"] = trim(substr(str, RSTART, RLENGTH))
str = substr(str, RSTART + RLENGTH)
match(str, rule["argument"])
# Match any command-line arguments, either long or short, with additional data or not.
while (RLENGTH != -1) {
m = substr(str, RSTART, RLENGTH)
match(m, rule["argument"])
i = (index(m, " ") > 0) ? index(m, " ") : index(m, "=");
if (i > 0) {
command["argument",trim(substr(m, 0, i), "- ")] = trim(substr(m, i), "\"' ")
} else {
command["argument",trim(m, "- ")] = ""
}
str = substr(str, 0, RSTART - 1) substr(str, RSTART + RLENGTH)
match(str, rule["argument"])
}
# Match target, if any.
match(str, rule["target"])
if (RLENGTH != -1) {
command["target"] = trim(substr(str, RSTART, + RLENGTH))
}
}
# > Function `parse_arguments` implements basic command-line parsing, based on pre-existing defaults
# in the global `config` array. Processing will cease as soon an unknown command-line argument is
# encountered, in order to prevent incorrect handling of filenames
function parse_arguments(argc, argv, _, arg, i, option) {
for (i = 1; i < argc; i++) {
# Stop reading at first non-command-line option.
if (substr(argv[i], 0, 2) != "--") {
return
}
arg = ltrim(argv[i], "-")
if (arg == "help") {
printf "Usage: grawkit [OPTION]... [FILE]\nOptions:\n"
for (k in config) {
printf " --%s=\"%s\"\n \t%s\n", k, config[k], comment[k]
}
exit exit_code = 0
} else {
# Check if command-line option given corresponds to a configuration option, or stop
# handling any more options.
if (split(arg, option, "=") == 2 && option[1] in config) {
config[option[1]] = option[2]
} else {
return
}
}
delete argv[i]
}
}
# > Function `normalize` removes invalid characters and makes string lower-case.
function normalize(str) {
gsub("[/_. ]", "-", str)
return tolower(str)
}
# > Function `count` returns the number of elements in the given array.
function count(arr, _, i) {
for (_ in arr) {i++}
return i
}
# > Function `trim` removes characters (whitespace by default) off both ends of the string passed,
# > and returns the modified string.
function trim(str, ch) {
return ltrim(rtrim(str, ch), ch)
}
# > Function `ltrim` removes characters (whitespace by default) off the left end of the string
# > passed, and returns the modified string.
function ltrim(str, ch) {
ch = (ch != "") ? ch : " "
gsub("^[" ch "]+", "", str)
return str
}
# > Function `rtrim` removes characters (whitespace by default) off the right end of the string
# > passed, and returns the modified string.
function rtrim(str, ch) {
ch = (ch != "") ? ch : " "
gsub("[" ch "]+$", "", str)
return str
}
# Global Declarations
# -------------------
#
# This block contains logic for initializing global variables used across Grawkit.
BEGIN {
# Default configuration and command-line argument parsing.
config["default-branch"] = "master"
comment["default-branch"] = "The name of the pre-defined base branch."
config["branch-spacing"] = "50"
comment["branch-spacing"] = "The amount of space, in pixels, between branch tracks."
config["branch-fill"] = "none"
comment["branch-fill"] = "The color to fill branch tracks with."
config["branch-stroke-width"] = "10"
comment["branch-stroke-width"] = "The stroke width for branch track outlines."
config["commit-spacing"] = "50"
comment["commit-spacing"] = "The amount of space, in pixels, between commit stops."
config["commit-fill"] = "#fff"
comment["commit-fill"] = "The color to fill commit stops with."
config["label-spacing"] = "10"
comment["label-spacing"] = "The amount of space, in pixels, between each label."
config["label-round"] = "3"
comment["label-round"] = "The corner radius, in pixels, for labels."
config["label-fill"] = "#333"
comment["label-fill"] = "The color to fill labels with."
config["label-text"] = "#fff"
comment["label-text"] = "The color to use for label text."
config["label-font"] = "Inconsolata, Consolas, monospace"
comment["label-font"] = "The font to use for label text."
config["label-font-size"] = "14"
comment["label-font-size"] = "The font size to use for label text, in points."
config["palette"] = "#002b36,#268bd2,#859900,#cb4b16,#2aa198,#dc322f,#d33682,#6c71c4,#b58900"
comment["palette"] = "The colors to use for each branch track, in order."
parse_arguments(ARGC, ARGV)
# Errors.
message["branch/no-name"] = "Empty name for `git branch`, line %d"
message["branch/no-branch"] = "No branch with name '%s', line %d"
message["branch/duplicate"] = "Unable to create duplicate branch '%s', line %d"
message["checkout/no-name"] = "Empty name for `git checkout`, line %d"
message["merge/no-name"] = "Empty name for `git merge`, line %d"
message["label/no-name"] = "Empty name for `git tag`, line %d"
# Rule matching.
rule["command"] = "^git[ ]+[a-z]+[ ]+"
rule["target"] = "(^[^ ]+?[ ]*|([ ]*--)?[ ]+)[^ ]+[ ]*$"
rule["argument/value"] = "['|\"]([^'\"]+?)['|\"]|[^-]{1,2}[^ ]+?"
rule["argument"] = "^(--[a-z-](=" rule["argument/value"] ")?|-[a-z]([ ]+" rule["argument/value"] ")?)"
rule["commit"] = "^git commit"
rule["branch"] = "^git branch"
rule["checkout"] = "^git checkout"
rule["merge"] = "^git merge"
rule["tag"] = "^git tag"
# Style definitions.
style["branch/spacing"] = config["branch-spacing"]
style["branch/fill"] = config["branch-fill"]
style["branch/stroke-width"] = config["branch-stroke-width"]
style["commit/spacing"] = config["commit-spacing"]
style["commit/fill"] = config["commit-fill"]
style["commit/stroke-width"] = style["branch/stroke-width"] / 2
style["commit/radius"] = style["commit/stroke-width"] * 1.5
style["label/spacing"] = config["label-spacing"]
style["label/round"] = config["label-round"]
style["label/fill"] = config["label-fill"]
style["label/text"] = config["label-text"]
style["label/font"] = config["label-font"]
style["label/font-size"] = config["label-font-size"]
# Color scheme, based on `base16-solarized-dark`
style["palette"] = config["palette"]
# Static SVG templates.
svg["svg"] = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"%d %d %d %d\">"
svg["/svg"] = "</svg>"
svg["g"] = "<g class=\"%s\">"
svg["gg"] = "<g class=\"%s\" transform=\"translate(%d,%d)\">"
svg["/g"] = "</g>"
svg["path"] = "<path class=\"branch\" d=\"%s\" />"
svg["circle"] = "<circle class=\"commit\" cx=\"%d\" cy=\"%d\" r=\"%s\" />"
svg["rect"] = "<rect x=\"%d\" y=\"%d\" width=\"%d\" height=\"%d\" rx=\"%d\" class=\"label-rect\" />"
svg["text"] = "<text x=\"%d\" y=\"%d\" textLength=\"%d\" class=\"label-text\">%s</text>"
# Branch definitions.
branches[0,"name"] = config["default-branch"]
branches[0,"refs"] = ""
branches[0,"merges"] = "0||0"
branches[0,"tags"] = ""
len["branches"] = 1
# Commit definitions.
commits[0,"type"] = ""
commits[0,"message"] = ""
len["commits"] = 1
# Tracks the state across calls.
state["branch"] = 0
state["HEAD"] = ""
# Other global variables.
command[""] = ""
exit_code = -1
error = ""
}
# Rule Definitions
# ----------------
#
# This block contains definitions for line manipulation rules used across Grawkit. Rules may or may
# not be exclusive, i.e. the effects of one rule may cascade to subsequent rules for the same line.
# > Match `git commit` declarations.
$0 ~ rule["commit"] {
# Parse command into normalized representation.
parse_command($0)
# Add new commit.
msg = command["argument","message"] ? command["argument","message"] : command["argument","m"]
if (msg != "") {
add_commit("commit", msg)
} else {
add_commit("commit", "Empty message")
}
next
}
# > Match `git branch` declarations.
$0 ~ rule["branch"] {
# Parse command into normalized representation.
parse_command($0)
# Get branch name and throw error if one is not set.
if (command["target"] == "") {
error = sprintf(message["branch/no-name"], FNR)
exit
}
# Throw error if branch already exists.
for (i = 0; i < len["branches"]; i++) {
if (branches[i,"name"] == command["target"]) {
error = sprintf(message["branch/duplicate"], command["target"], FNR)
exit
}
}
# Add empty branch as a placeholder.
add_branch(command["target"])
next
}
# > Match `git checkout` declarations.
$0 ~ rule["checkout"] {
# Parse command into normalized representation.
parse_command($0)
# Get branch name and throw error if one is not set.
if (command["target"] == "") {
error = sprintf(message["checkout/no-name"], FNR)
exit
}
# Throw error if branch does not exist.
found = 0
for (i = 0; i < len["branches"]; i++) {
if (branches[i,"name"] == command["target"]) {
found = 1
break
}
}
if (found == 0) {
error = sprintf(message["branch/no-branch"], command["target"], FNR)
exit
}
# Set internal state.
state["branch"] = i
split(branches[i,"refs"], refs, ",")
state["HEAD"] = refs[count(refs)]
next
}
# > Match `git merge` declarations.
$0 ~ rule["merge"] {
# Parse command into normalized representation.
parse_command($0)
# Get branch name and throw error if one is not set.
if (command["target"] == "") {
error = sprintf(message["merge/no-name"], FNR)
exit
}
# Throw error if branch does not exist.
found = 0
for (i = 0; i < len["branches"]; i++) {
if (branches[i,"name"] == command["target"]) {
found = 1
break
}
}
if (found == 0) {
error = sprintf(message["branch/no-branch"], command["target"], FNR)
exit
}
# Add a merge commit to current branch.
add_commit("merge", "Merge commit")
# Add merge reference from last commit in source branch to target branch.
# Format: to-branch|from-commit|to-commit
split(branches[i,"refs"], refs, ",")
merge = state["branch"] "|" refs[count(refs)] "|" state["HEAD"]
if (branches[i,"merges"] == "") {
branches[i,"merges"] = merge
} else {
branches[i,"merges"] = branches[i,"merges"] "," merge
}
next
}
# > Match `git tag` declarations.
$0 ~ rule["tag"] {
# Parse command into normalized representation.
parse_command($0)
# Get tag name and throw error if one is not set.
if (command["target"] == "") {
error = sprintf(message["label/no-name"], FNR)
exit
}
# Add tag reference to target branch.
if (branches[state["branch"],"tags"] == "") {
branches[state["branch"],"tags"] = state["HEAD"] "|tag|" command["target"]
} else {
branches[state["branch"],"tags"] = branches[state["branch"],"tags"] "," state["HEAD"] "|tag|" command["target"]
}
next
}
# SVG Graph Generation
# --------------------
#
# This block contains logic for building the final SVG output from Grawkit's internal state, as
# defined in the command-line provided.
END {
# Handle any early exit or error that might've occurred during parsing.
if (exit_code > -1) {
exit exit_code
} else if (error != "") {
print error | "cat >&2"
exit (exit_code > -1) ? exit_code : 1
}
w = 0
body = ""
# Print merge paths for branches.
for (i = len["branches"] - 1; i >= 0; i--) {
body = body render_merge(i)
}
if (body != "") {
body = sprintf(svg["g"], "merge") body
body = body "\n" svg["/g"]
}
# Print each branch and corresponding commits in turn.
for (i = len["branches"] - 1; i >= 0; i--) {
body = body render_branch(i)
}
# Calculate SVG canvas size, removing `master` branch from X offset if it contains no commits.
x = style["branch/stroke-width"] * -1
x += (branches[0,"refs"] == "") ? style["branch/spacing"] : 0
y = style["branch/stroke-width"] * -1
# Calculate canvas width from largest combined label offset.
for (i in label_offset) {
w = (label_offset[i] > w) ? label_offset[i] : w;
}
w += style["branch/spacing"] * len["branches"]
w -= (branches[0,"refs"] == "") ? style["branch/spacing"] : 0
h = (style["commit/spacing"] * (len["commits"])) + (style["commit/stroke-width"] * 4)
# Print SVG header.
printf svg["svg"], x, y, w, h
printf "\n"
# Print inline style definitions.
print "<style type=\"text/css\"><![CDATA["
print ".branch {"
print "\tfill: " style["branch/fill"] ";"
print "\tstroke-width: " style["branch/stroke-width"] ";"
print "\tstroke-linecap: round;"
print "}"
print ".commit {"
print "\tfill: " style["commit/fill"] ";"
print "\tstroke-width: " style["commit/stroke-width"] ";"
print "}"
print ".label-tag {"
print "\tfill: " style["label/fill"] ";"
print "}"
print ".label-rect {"
print "\tstroke: none;"
print "}"
print ".label-text {"
print "\tfont-family: " style["label/font"] ";"
print "\tfont-size: " style["label/font-size"] "px;"
print "\tfill: " style["label/text"] ";"
print "\tstroke: none;"
print "}"
split(style["palette"], palette, ",")
# Print color scheme definitions for branches.
for (i = 0; i < len["branches"]; i++) {
printf ".branch-" normalize(branches[i,"name"]) " {"
# Reuse palette (except primary colour which is reserved for master).
p = (i - 1) % (count(palette) - 1) + 2
printf "stroke: " palette[p] "; fill: " palette[p] "}\n"
}
print "]]></style>"
# Print SVG body.
print body
# Print SVG footer.
print svg["/svg"]
}