fawkss/fawkss

421 lines
13 KiB
Awk
Executable File

#!/usr/bin/awk -f
#
# Fawkss — The [ig]noble CSS preprocessor.
# ========================================
#
# Fawkss is a CSS preprocessor for people who dislike CSS preprocessors. It
# implements a subset of the SASS syntax while remaining relatively simple.
#
# 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 `firstindex` returns the index of the first occurence of a regular
# > expression `r` in `str`, or 0 if the none was found.
function firstindex(str, r) {
match(str, r)
return max(RSTART, 0)
}
# > Function `lastindex` returns the index of the last occurence of a regular
# > expression `r` in `str`, or 0 if the none was found.
function lastindex(str, r) {
match(str, ".*" r)
return max(RSTART + RLENGTH - 1, 0)
}
# > Function `max` finds and returns greatest between two numbers.
function max(x, y) {
return (x > y) ? x : y
}
# > Function `trim` removes characters (whitespace by default) off both ends of
# > the string passed and returns the modified string.
function trim(str, ch) {
ch = (ch != "") ? ch : " "
str = substr(str, firstindex(str, "[^" ch "]"))
return substr(str, 0, lastindex(str, "[^" ch "]"))
}
# > Function `basename` strips the directory name from file paths and returns
# > the resulting file name.
function basename(path) {
match(path, "[^/]+$")
return substr(path, RSTART, RLENGTH)
}
# > Function `dirname` strips the last component from a path and returns the
# > resulting path.
function dirname(path) {
match(path, "^.+/")
return substr(path, RSTART, RLENGTH)
}
# > Function `file_exists` checks if file pointed to by `filename` exists, and
# > returns `1` if true, `0` if false.
function file_exists(filename) {
return (system("[ -e '" filename "' ]") == 0) ? 1 : 0;
}
# > Function 'read_line' gets the next line from import, as specified by `type`,
# > pointed to by `name`. Two types are currently specified, "file" and "cmd".
# >
# > Type "file" will attempt to get a newline-delimited line of a file pointed
# > to by `name`, and type "cmd" will get a newline-delimited line from a shell
# > command string.
function read_line(name, type) {
switch (type) {
case "file":
return (getline < name)
case "cmd":
return (name | getline)
}
return 0
}
# Global declarations
# -------------------
#
# This block contains logic for initializing global variables used across Fawkss.
BEGIN {
# Error messages used across Fawkss.
errors["variable-undefined"] = "ERROR: Use of undefined variable '%s' in file '%s', line %d\n"
errors["import-cyclic"] = "ERROR: Cyclic import of file '%s' in file '%s', line %d\n"
errors["import-not-found"] = "ERROR: Import file '%s' not found, defined in '%s', line %d\n"
errors["mixin-exists"] = "ERROR: Mixin '%s' already defined, line %d\n"
errors["mixin-undefined"] = "ERROR: Use of undefined mixin '%s', line %d\n"
errors["mixin-param-undefined"] = "ERROR: Parameter '%s' undefined for mixin '%s', line %d\n"
# Rule definitions.
rules["comment-define"] = "[ ]*//.*$"
rules["comment-exception"] = "['\"][^//]*//[^'\"]*['\"]"
rules["variable-name"] = "\\$[a-zA-Z0-9_-]+"
rules["variable-define"] = "^[\t ]*" rules["variable-name"] "[ ]*:"
rules["import-path"] = "['\"][^'\".]+(.scss)?[ ]*['\"]"
rules["import-define"] = "^[\t ]*@import[ ]+" rules["import-path"] "[ ]*;"
rules["import-variants"] = "%s%s.scss,%s_%s.scss,%s%s,%s_%s"
rules["mixin-name"] = "[a-zA-Z0-9_-]+"
rules["mixin-param"] = rules["variable-name"] "([ ]*:[ ]*[^,;]+)?"
rules["mixin-arg"] = "(" rules["variable-name"] "[ ]*:[ ]*)?[^,;]+"
rules["mixin-params"] = "\\([ ]*" rules["mixin-param"] "([ ]*,[ ]*" rules["mixin-param"] ")*[ ]*\\)"
rules["mixin-args"] = "\\([ ]*" rules["mixin-arg"] "([ ]*,[ ]*" rules["mixin-arg"] ")*[ ]*\\)"
rules["mixin-define"] = "^[\t ]*@mixin[ ]+" rules["mixin-name"] "(" rules["mixin-params"] ")?"
rules["mixin-include"] = "^[\t ]*@include[ ]+" rules["mixin-name"] "(" rules["mixin-args"] ")?[ ]*;"
}
# Import stack initialisation
# ----------------------------
#
# This block initializes the import stack with the current filename, and reads
# from the top line-by-line until the stack is exhausted. Import declarations
# switch the read context by pushing to the stack, and are popped when the read
# operation reaches EOF or any error.
{
# File import stack.
imports["length"] = 0
imports[imports["length"] ":name"] = FILENAME
imports[imports["length"] ":type"] = "file"
# Read from import file stack line-by-line until stack is exhausted.
while (imports["length"] >= 0) {
while (read_line(imports[imports["length"] ":name"], imports[imports["length"] ":type"]) > 0) {
# Rule definitions
# ----------------
#
# This block contains definitions for line manipulation rules used across Fawkss.
# Rules may or may not be exclusive, i.e. the effects of one rule may cascade to
# subsequent rules for the same line.
#
# > Match import declarations, for example:
# >
# > @import "partials/colors";
# >
# > The above declaration may match file `_colors.scss` or `colors.scss` in the
# > `partials` directory (which should exist on the same level as the calling
# > file). Imports can be nested (i.e. imported files may in turn import other
# > files), and cyclic dependancies will return a fatal error.
if ($0 ~ rules["import-define"]) {
# Extract path part from import declaration.
match($0, rules["import-path"])
path = substr($0, RSTART + 1, RLENGTH - 2)
# Extract file and directory name parts from path, and append root directory
# path for current file to directory part.
dir = dirname(imports[imports["length"] ":name"]) dirname(path)
file = basename(path)
# Check filename against all potential filename variations.
exists = 0
split(rules["import-variants"], variants, ",")
for (v in variants) {
filename = sprintf(variants[v], dir, file)
if (file_exists(filename)) {
exists = 1
break
}
}
# Return error if import file was not found.
if (!exists) {
printf errors["import-not-found"], path, imports[imports["length"] ":name"], FNR | "cat >&2"
exit 1
}
# Check for cyclic imports.
if (filename in processed) {
printf errors["import-cyclic"], filename, imports[imports["length"] ":name"], FNR | "cat >&2"
exit 1
}
# Add filename to list of processed imports.
processed[filename] = 1
# Push filename to stack of imports.
imports["length"] += 1
imports[imports["length"] ":name"] = filename
# Specify import type as file.
imports[imports["length"] ":type"] = "file"
continue
}
# > Match mixin declarations, for example:
# >
# > @mixin big-font {
# > font-size: 200%;
# > }
# >
# > Mixin declarations can then be used using `@include`, defined below.
if ($0 ~ rules["mixin-define"]) {
# Get mixin name.
match($2, rules["mixin-name"])
name = substr($2, RSTART, RLENGTH)
# Check for unique mixin name.
if (name in mixins) {
printf errors["mixin-exists"], name, FNR | "cat >&2"
exit 1
}
# Store mixin parameters, if any.
match($0, rules["mixin-params"])
if (RLENGTH > 0) {
mixins[name ":params"] = substr($0, RSTART + 1, RLENGTH - 2)
}
# Read mixin contents until we encounter a closing bracket. The bracket must
# appear on its own line, otherwise the final member of the mixin will not
# be parsed.
while ((getline line < imports[imports["length"] ":name"]) > 0 && line !~ "}" ) {
mixins[name] = mixins[name] "\n" line
}
# Remove leading newline from mixin.
mixins[name] = substr(mixins[name], 2)
continue
}
# > Match variable declarations, for example:
# >
# > $varname: "value";
# >
# > Only one variable declaration can appear on a single line. Redeclaring a
# > variable overrides the value set for that variable.
if ($0 ~ rules["variable-define"]) {
# Split text in tokens.
split($0, token, ":")
# Get variable name and value.
name = trim(substr(token[1], index(token[1], "$")))
value = trim(substr(token[2], 0, lastindex(token[2], ";") - 1))
# Assign variable to the global variables table.
variables[name] = value
continue
}
# > Match mixin includes, for example:
# >
# > body {
# > @include big-font;
# > }
# >
# > Attempting to use an undefined mixin will throw a fatal error.
if ($0 ~ rules["mixin-include"]) {
# Get mixin name.
match($2, rules["mixin-name"])
name = substr($2, RSTART, RLENGTH)
# Check for invalid mixin name.
if (!name in mixins) {
printf errors["mixin-undefined"], name, FNR | "cat >&2"
exit 1
}
# Copy mixin contents for further processing.
contents = mixins[name]
# Check if mixin defines parameters, and attempt to use arguments passed.
if (name ":params" in mixins) {
# Split parameters as individual tokens.
split(mixins[name ":params"], params, "[ ]*,[ ]*")
# Get arguments passed to include declaration.
match($0, rules["mixin-args"])
split(substr($0, RSTART + 1, RLENGTH - 2), args, "[ ]*,[ ]*")
# Check arguments against parameters, substituting each with the other,
# using default values where available when an argument hasn't been passed.
for (i in params) {
# Attempt to split parameter in name and value parts.
split(params[i], p, "[ ]*:[ ]*")
# Throw error if parameter has no default value and no corresponding
# argument has been passed.
if (length(p) == 1 && args[i] == "") {
printf errors["mixin-param-undefined"], p[1], name, FNR | "cat >&2"
exit 1
}
# Replace parameters with the concrete argument values.
gsub("\\" p[1], (args[i] == "") ? p[2] : args[i], contents)
}
}
# Push mixin to import stack.
imports["length"] += 1
imports[imports["length"] ":name"] = "echo '" contents "'"
# Specify import type as command.
imports[imports["length"] ":type"] = "cmd"
continue
}
# > Match inline comments, for example:
# >
# > // This is an inline comment.
# > :root{background: white;} // Another inline comment.
# >
# > As opposed to regular CSS comments (i.e. `/* */`), inline comments are removed
# > from the processed result. Inline comments inside strings are not removed.
if ($0 ~ rules["comment-define"]) {
# Initialize local variables.
len = 0
# Remove any special cases from the line.
while (match($0, rules["comment-exception"])) {
special[len += 1] = RSTART ":" substr($0, RSTART, RLENGTH)
$0 = substr($0, 0, RSTART - 1) substr($0, RSTART + RLENGTH, length($0))
}
# Remove inline comments from line.
while (match($0, rules["comment-define"])) {
$0 = substr($0, 0, RSTART - 1) substr($0, RSTART + RLENGTH, length($0))
}
# Reinsert special cases in their predefined positions.
for (i = len; i != 0; i--) {
pos = substr(special[i], 0, index(special[i], ":") - 1)
# Do not attempt to reinsert special case string if string has been
# truncated to less the original position of the string.
if (pos > length($0)) {
continue
}
str = substr(special[i], index(special[i], ":") + 1, length(special[i]))
$0 = substr($0, 0, pos - 1) str substr($0, pos, length($0))
}
}
# > Match variable uses, for example:
# >
# > :root{background: $white;}
# >
# > Where `$white` is a previously declared variable. Attempting to use a variable
# > that has not been defined yet will throw a fatal error.
if ($0 ~ rules["variable-name"]) {
# Replace each variable use with its concrete value.
while (match($0, rules["variable-name"])) {
name = substr($0, RSTART, RLENGTH)
# Throw error and exit if variable used has not been declared.
if (variables[name] == "") {
printf errors["variable-undefined"], name, imports[imports["length"] ":name"], FNR | "cat >&2"
exit 1
}
$0 = substr($0, 0, RSTART - 1) variables[name] substr($0, RSTART + RLENGTH, length($0))
}
}
# Line printing
# -------------
#
# This block contains line-printing rules, for results generated in the above
# blocks.
#
# > Match empty line. Consecutive empty lines do not print, and are instead
# > squashed down to a single line.
if (NF == 0) {
if ((newlines += 1) < 2) {
print
}
continue
}
# > Print non-blank line, resetting the newline count, used above.
newlines = 0
print
}
# Import stack termination
# -------------------------
#
# This block contains termination rules for the import stack, as initialized in
# aforementioned blocks.
#
# When a file on the stack reaches EOF or error, the file is closed and the
# reference popped from the top of the stack. If the stack is left empty, the
# program continues to cleanup and exit, as defined in the block below.
close(imports[imports["length"] ":name"])
delete processed[imports[imports["length"] ":name"]]
imports["length"] -= 1
}
# Cleanup
# -------
#
# This block contains cleanup operations on end of execution.
exit 0
}