muffin.coffee | |
|---|---|
muffin.js: handy helpers for building cakefilesLicensed under the MIT License, excluding cloc.pl Includes cloc.pl from http://cloc.sourceforge.net/, licensed under GPL V2 | CoffeeScript = require 'coffee-script' |
| Require Q for all the handy promise based business | q = require 'q'
fs = require 'q-fs'
ofs = require 'fs'
path = require 'path'
glob = require 'glob'
temp = require 'temp' |
| Require | {spawn, exec} = require 'child_process'
orgExec = exec |
| Simple variadic extend | extend = (onto, others...) ->
result = onto
for o in others
for k,v of o
result[k] = v
result |
| Promise based version of | exec = (command, options = {}) ->
deferred = q.defer() |
| Wrap the execution of the original | child = orgExec(command, options, (error, stdout, stderr) ->
if error?
deferred.reject(error)
else
deferred.resolve([stdout, stderr])
) |
| Return both the child process (for writing to stdin) and the promise. | [child, deferred.promise] |
| Internal helper function for deciding if the repo is in the midst of a rebase. | inRebase = ->
path.existsSync('.git/rebase-apply')
ask = (question, format = /.+/) ->
stdin = process.stdin
stdout = process.stdout
deferred = q.defer()
stdin.resume()
stdout.write(question + ": ")
stdin.once 'data', (data) ->
stdin.pause()
data = data.toString().trim()
if format.test(data)
deferred.resolve data
else
stdout.write "It should match: #{format}\n"
deferred.resolve ask(question, format)
deferred.promise |
| Notifies the user of a success or error during compilation | notify = (source, origMessage, error = false) ->
if error |
| If notifying about an error, make sure any errors actually reference the file being compiled. | basename = source.replace(/^.*[\/\\]/, '')
if m = origMessage.match /Parse error on line (\d+)/
message = "Parse error in #{basename}\non line #{m[1]}."
else
message = "Error in #{basename}."
command = growlCommand '-n', 'Cake', '-p', '2', '-t', "\"Action failed\"", '-m', "\"#{message}\"" |
| Always log any autogenerated messages as well as the error message to the console | console.error message
console.error origMessage
else |
| Growl the successful action (usually a compilation) to the user, and always log it to the console as well. | command = growlCommand '-n', 'Cake', '-p', '-1', '-t', "\"Action Succeeded\"", '-m', "\"#{source}\""
console.log origMessage |
| Growl if we can, or | if growlAvailble
[child, promise] = exec command
else |
| return an already fufilled promise if not | promise = q.ref true
promise
readFile = (file, options = {}) ->
deferred = q.defer() |
| Read the file from the git index if we're using the stage as the files being caked,
or otherwise use | if runOptions.commit
[child, promise] = exec "git show :#{file}"
child.stdin.setEncoding('utf8')
child.stdin.end()
q.when promise
, (stdout, stderr) ->
lines = stdout.toString().split('\n')
lines.pop()
str = lines.join('\n')
deferred.resolve(str)
, (reason) ->
handleFileError(file, reason, options)
else
fs.read(file).then((contents) ->
deferred.resolve(contents)
, (reason) ->
handleFileError(file, reason, options)
)
deferred.promise
writeFile = (file, data, options = {}) ->
mode = options.mode || 644 |
| Write the file to the git index if we're using the stage as the files being caked,
or otherwise use | if runOptions.commit |
| With git, it takes two stages to stage a file. We write the object to | [child, promise] = exec "git hash-object --stdin -w"
child.stdin.write(data)
child.stdin.end()
promise.then ([stdout, stderr]) ->
sha = stdout.substr(0,40)
[subchild, subpromise] = exec "git update-index --add --cacheinfo 100#{mode.toString(8)} #{sha} #{file}"
return subpromise
return promise
else |
| Write the file, and then chmod the file using | fs.write(file, data.toString(), "w", "UTF-8").then (data) ->
return fs.chmod file, mode
, (reason) -> |
| Make the common permissions error look a bit nicer. | if reason.toString().match(/not writable/g)
q.reject "#{file} isn't writable, please check permissions!"
else
q.reject(reason)
copyFile = (source, target, options = {}) -> |
| Read the file at the source and then write the file at the target | readFile(source, options).then (contents) ->
writeFile(target, contents, options).then ->
notify source, "Moved #{source} to #{target} successfully" |
| Handy promise based file error handler, which is a simple wrapper for | handleFileError = (file, err, options = {}) ->
notify file, err.message, true unless options.notify == false |
| Following 2 functions are stolen from Jitter, https://github.com/TrevorBurnham/Jitter/blob/master/src/jitter.coffee Compiles a script to a destination | compileScript = (source, target, options = {}) ->
readFile(source, options).then (data) ->
try
js = CoffeeScript.compile data, {source, bare: options?.bare}
writeFile(target, js, options).then ->
notify source, "Compiled #{source} to #{target} successfully" unless options.notify == false
catch err
handleFileError target, err, options |
| Generate a command to growl | growlCommand = (args...) ->
args.unshift('growlnotify')
args.join(' ') |
| Check if growl is available so we can growl notifications only if growl is present | growlAvailble = false
orgExec growlCommand('--version'), (err, stdout, stderr) ->
growlAvailble = err?
doccoFile = (source, options = {}) -> |
| Just tell docco to generate the one file by using it's command line helper | [child, promise] = exec("docco #{source}")
return promise.then ([stdout, stderr]) -> |
| Notify the user if any errors occured. | notify source, stdout.toString() if stdout.toString().length > 0
notify source, stderr.toString(), true if stderr.toString().length > 0
minifyScript = (source, options = {}) -> |
| Grab a reference to uglify. This isn't always globablly required since not everyone will minimize. | {parser, uglify} = require("uglify-js") |
| Read the file and then step through the transformations of the AST. | readFile(source, options).then (original) ->
ast = parser.parse(original) # Parse original JS code and get the initial AST.
ast = uglify.ast_mangle(ast) # Get a new AST with mangled names.
ast = uglify.ast_squeeze(ast) # Get an AST with compression optimizations.
final = uglify.gen_code(ast)
finalPath = source.split('.')
finalPath.pop()
finalPath.push('min.js') |
| Write out the final code to the same file with a | return writeFile(finalPath.join('.'), final, options) |
| Internal function for finding the git root | getGitRoot = () ->
[child, promise] = exec 'git rev-parse --show-toplevel'
child.stdin.end()
promise.then ([stdout, stderr]) ->
stdout.toString().trim() |
| Internal tracking variable and function used for asserting that Perl exists on the system muffin is being run on. | perlPresent = undefined
perlError = () -> throw 'You need a perl v5.3 or higher installed to do this with muffin.'
ensurePerl = () ->
if perlPresent?
perlError() unless perlPresent
else
orgExec 'perl --version', (error, stdout, stderr) ->
if error?
perlPresent = false
perlError() |
| Grab absolute references to the cloc file and language definition (an extension of the default one but including Coffeescript) | clocPath = path.normalize( __dirname + "/../deps/cloc.pl" )
langDefPath = path.normalize( __dirname + "/../deps/cloc_lang_def.txt") |
| Use | clocFile = (filename) ->
ensurePerl() |
| Grab the output of | [child, promise] = exec "#{clocPath} --csv --read-lang-def=#{langDefPath} #{filename}"
q.when promise, ([csv, stderr]) ->
throw stderr.toString() if stderr.toString().length > 0 |
| Split up the output into headers and CSV by splitting by double newline, and then into rows by splitting by newline. | [discard, csv] = csv.split("\n\n")
rows = csv.split("\n") |
| Discard the row of column names, discard the empty newline at the end, and then split each row into comma delimited columns. | names = rows.shift()
rows.pop()
rows = rows.map (row) -> row.split(',') |
| Use the first row since we've only passed one file to | row = rows[0]
return {
filename: filename
filetype: row[1]
blank: row[2]
comment: row[3]
sloc: row[4]
} |
| Use | statFile = (filename) ->
q.when fs.stat(filename), (stats) -> |
| Convert the filesize (which comes in in bytes) into a more human readable form by dividing by 1024 for each unit. | size = stats.size
units = ["bytes", "KB", "MB", "GB"]
for unit in units
break if size < 1024
size = size / 1024 |
| Round off the final value to two digits and set the size to a human readable string. | size = "#{(Math.round(size*100)/100).toFixed(2)} #{unit}"
return {
size: size
modified: stats.mtime
filename: filename
}
_statFiles = (files, options = {}) -> |
| For every file to be statted, | promises = files.map (file) ->
q.join clocFile(file), statFile(file), (clocstats, filestats) ->
extend clocstats, filestats |
| Ensure any errors thrown during the join of the statting aren't swallowed by marking the promises as no longer chainable. | promise.end() for promise in promises |
| For every file's stats (or promise thereof), print out the row in the table. Do this by first figuring out what the widest value in each column is, and then printing while padding all the shorter values out until they reach the same length. | q.all promises
printTable = (fields, results) -> |
| Add the headers to the top of the table. | headers = {}
for field in fields
headers[field] = (field.charAt(0).toUpperCase() + field.slice(1))
results.unshift headers |
| Figure out how wide each column must be, keyed by integer column index. Note that the header row is included in the fields array here because a header may be the widest cell in the column. | maxLengths = for field in fields
max = Math.max.apply Math, results.map (result) ->
unless result[field]?
console.error "Couldn't get value from #{field} on", result
result[field].toString().length
max + 2 |
| Print out each row of results. Use a mutable array buffer which is then joined so new strings aren't created with a bunch of += ops. | for result in results
out = []
for field, i in fields
data = result[field].toString()
out.push ' ' for j in [data.length..maxLengths[i]]
out.push data
console.log out.join('')
return results |
| Logs a stats about files to the console for inspection. | statFiles = (files, options = {}) -> |
| If given a string, glob it to expand any wildcards. | if typeof files is 'string'
files = glob.globSync files |
| If we're comparing two shas, we need to check the two shas out somewhere else, and run the stats on all those files | if options.compare
fields = options.fields || ['filename', 'filetype']
compareFields = options.compareFields || ['sloc', 'size'] |
| Get the two git refs we are comparing | ask('git ref A').then((refA) ->
ask('git ref B').then (refB) -> |
| Get the root of the git repository | getGitRoot().then((root) ->
clones = for ref in [refA, refB]
do (ref) -> |
| Clone each root to a temporary directory and check out the given ref | tmpdir = temp.mkdirSync()
cloneCmd = "git clone #{root} #{tmpdir}"
console.error cloneCmd
[child, clone] = exec cloneCmd
child.stdin.end()
clone.then(([stdout, stderr]) ->
[child, checkingOut] = exec "cd #{tmpdir} && git checkout #{ref}"
checkingOut
).then () -> |
| Get an array of file paths relative to this temporary checkout | clonedFiles = files.map (file) -> path.resolve(file).replace(root, tmpdir) |
| Stat the temporary files, and once the details come in, augment them with the original file's details | _statFiles(clonedFiles, options).then (results) ->
for result, i in results
result['originalFilename'] = files[i]
result['ref'] = ref
results |
| Wait for all the clones and statting to finish. | q.all(clones)
).then((results) ->
|
| Build a table key'd by the original filename | table = {}
for resultSet in results
for result in resultSet
tableEntry = (table[result.originalFilename] ||= {})
for k in fields
tableEntry[k] = result[k]
for k in compareFields
tableEntry["#{k} at #{result.ref}"] = result[k]
|
| Revert to the original filename | results = for k, v of table
v['filename'] = k
v |
| Grab a list of the table fields | tableFields = Array.prototype.slice.call fields
for field in compareFields
for ref in [refA, refB]
tableFields.push "#{field} at #{ref}"
printTable(tableFields, results)
)
).end()
else
fields = options.fields || ['filename', 'filetype', 'sloc', 'size']
_statFiles(files, options).then((results) ->
printTable(fields, results)
).end() |
|
| compileMap = (map) ->
for pattern, action of map
{pattern: new RegExp(pattern), action: action} |
| Store a reference to the options that muffin was run with so we can ensure that none get lost if they don't get passed in by the user. | runOptions = {}
run = (args) -> |
| Grab the glob if not given by globbing a default string, globbing a given string, or globbing the array of given strings. | if !args.files?
args.files = glob.globSync './**/*'
else if typeof args.files is 'string'
args.files = glob.globSync args.files
else
args.files = args.files.reduce ((a, b) -> a.concat(glob.globSync(b))), []
compiledMap = compileMap args.map
|
| Save the reference to this run's options. Unfortuantly this renders muffin not many run safe. Bummer, for now. | runOptions = args.options |
| Run the before callback, and wait till it finishes by wrapping it in a | before = -> q.ref if args.before then args.before() else true
q.when start = before(), |
| Once the before callback has been successfully run, loop over all the pattern -> action pairs, and see if they match any of the files in the array. If so, delete the file, and run the action. | done = compiledMap.reduce (done, map) ->
for i, file of args.files
if matches = map.pattern.exec(file)
delete args.files[i] |
| Do the job and wrap it in a promise if it didn't already return one using | work = q.ref map.action(matches) |
| Watch the file if the option was given. | if args.options.watch
if args.options.commit
console.error "Can't watch committed versions of files, sorry!"
process.exit 1
do (map, matches) ->
ofs.watchFile file, persistent: true, interval: 250, (curr, prev) ->
return if curr.mtime.getTime() is prev.mtime.getTime()
return if inRebase()
q.when start = before(), ->
work = q.ref map.action(matches)
q.when work, (result) ->
args.after() if args.after
work.end()
start.end() |
| Return another promise which will resolve to the work promise | done = q.when(done, -> work)
done
, undefined
q.when done, () ->
args.after() if args.after
done.end()
start.end()
for k, v of {run, copyFile, doccoFile, notify, minifyScript, readFile, writeFile, compileScript, exec, extend, statFiles}
exports[k] = v
|