#!/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 }