Chapter 8
Metaprogramming
8.1 Literate CoffeeScript
hello.coffee
###
Log 'Hello world!' to the console
###
console.log 'Hello World!'
|
hello.litcoffee
Log 'Hello world!' to the console
console.log 'Hello World!'
|
W B Yeats
The Wild Swans at Coole
The trees are in their autumn beauty,
trees = [{}, {}]
for tree in trees
tree.inAutumnBeauty = yes
The woodland paths are dry,
paths = [{}, {}, {}]
for path in paths
path.dry = yes
Under the October twilight the water
Mirrors a still sky;
octoberTwilight = {}
stillSky = {}
water =
placeUnder: ->
water.placeUnder octoberTwilight
water.mirrors = stillSky
Upon the brimming water among the stones
Are nine-and-fifty swans.
water.brimming = true
water.stones = [{}, {}, {}, {}]
class Swan
x: 3
for n in [1..59]
water.stones.push new Swan
Listing 8.1 Literate CoffeeScript Rot13
## Rot13
A simple letter-substitution cipher that replaces a letter
with the letter 13 letters after it in the alphabet.
charRot13 = (char) ->
The built-in string utility for getting character codes can be used
charCode = char.charCodeAt(0)
If the character is in the alphabet up to 'm', then
add 13 to the character code
charCodeRot13 = if charInRange char, 'a', 'm'
charCode + 13
If the character is after 'm' in the alphabet, then
subtract 13 from the character code
else if charInRange char, 'n', 'z'
charCode - 13
else
charCode
Characters can be converted back using the built-in string method
String.fromCharCode charCodeRot13
A character is in a specific range regardless of whether
it's uppercase or lowercase
charInRange = (char, first, last) ->
lowerCharCode = char.toLowerCase().charCodeAt(0)
first.charCodeAt(0) <= lowerCharCode <= last.charCodeAt(0)
Converting a string is done by converting all the characters
and joining the results
stringRot13 = (string) ->
(charRot13 char for char in string).join ''
8.2 Domain-specific languages
<html>
<p>
It is <strong>very</strong> important that you understand this...
</p>
strongElements = document.getElementsByTagName 'strong'
for strongElement in StrongElements
strongElement.fontWeight = 'bold'
strongElement.color = 'red'
strongStyle:
fontWeight: 'bold'
color: 'red'
strongElements = document.getElementsByTagName 'strong'
for strongElement in StrongElements
for styleName, styleValue of strongStyle
strongElement[styleName] = styleValue
assert = require 'assert'
haystack = [1..900]
needle = 6
assert needle in haystack
assert 'fundamental'.indexOf('fun') >= 0
expect('fundamental').to.contain 'fun'
HELO coffeescriptinaction.com
250 OK
MAIL FROM: scruffy@coffeescriptinaction.com
250 OK - mail from <scruffy@coffeescriptinaction.com>
RCPT TO: agtron@coffeescriptinaction.com
250 OK - Recipient <agtron@coffeescriptinaction.com>
DATA
354 Send data. End with CRLF.CRLF
Hi Agtron. Just Scruffy testing SMTP.
250 OK QUIT
class Smtp
constructor: ->
connect: (host, port=25) ->
send: (message, callback) ->
smtp = new Smtp
smtp.connect 'coffeescriptinaction.com'
smtp.send 'MAIL FROM: scruffy@coffeescriptinaction.com', (response) ->
if response.contains 'OK'
smtp.send 'RCPT TO: agtron@coffeescriptinaction.com', (response) ->
scruffysEmail = new Email
to: ''
from: ''
body: '''
'''
scruffysEmail.send()
> npm install simplesmtp
Listing 8.2 An object literal?based DSL for email (email.coffee)
simplesmtp = require 'simplesmtp'
class Email
SMTP_PORT = 25
SMTP_SERVER = 'coffeescriptinaction.com'
constructor: ({@to, @from, @subject, @body}) ->
send: ->
@client = simplesmtp.connect SMTP_PORT, SMTP_SERVER
@client.once 'idle', ->
@client.useEnvelope
from: @from
to: @to
@client.on 'message', ->
client.write """
From: #{@from}
To: #{@to}
Subject: #{@subject}
#{@body}
"""
client.end()
scruffysEmail = new Email
to: 'agtron@coffeescriptinaction.com'
from: 'scruffy@coffeescriptinaction.com'
subject: 'Hi Agtron!'
body: '''
This is a test email.
'''
scruffysEmail.send()
# { to: 'agtron@coffeescriptinaction.com',
# from: 'scruffy@coffeescriptinaction.com',
# subject: 'Hi Agtron!',
# body: '\nThis is a test email. \n ' }
# Error: connect ETIMEDOUT
scruffysEmail = new Email
scruffysEmail
.to('agtron@coffeescriptinaction.com')
.from('scruffy@coffeescriptinaction.com')
.body '''
Hi Agtron!
'''
scruffysEmail.send (response) ->
console.log response
Listing 8.3 A fluent interface?based DSL for email
simplesmtp = require 'simplesmtp'
class Email
SMTP_PORT = 25
SMTP_SERVER = 'coffeescriptinaction.com'
constructor: (options) ->
['from', 'to', 'subject', 'body'].forEach (key) =>
@["_{key}"] = options?[key]
@[key] = (newValue) ->
@["_#{key}"] = newValue
@
send: ->
client = simplesmtp.connect SMTP_PORT, SMTP_SERVER
client.once 'idle', ->
client.useEnvelope
from: @_from
to: @_to
client.on 'message', ->
client.write """
From: "#{@_from}"
To: #{@_to}
Subject: #{@_subject}
#{@_body}
"""
client.end()
@
scruffysEmail = new Email()
scruffysEmail
.to('agtron@coffeescriptinaction.com')
.from('scruffy@coffeescriptinaction.com')
.subject('Hi Agtron!')
.body '''
This is a test email.
'''
scruffysEmail.send()
send = (next) ->
http.send next()
email = ->
send email (body 'Hi Agtron') to 'agtron@coffeescriptinaction.com'
send email \
(body 'Hi Agtron!')\
(to 'agtron@coffeescriptinaction.com')
loggedIn = -> true
doctype 5
html ->
body ->
ul class: 'info', ->
li -> 'Logged in' if loggedIn()
<!DOCTYPE html>
<html>
<body>
<ul class='info'>
<li>Logged in</li>
Listing 8.4 A basic DSL for HTML
doctype = (variant) ->
switch variant
when 5
"<!DOCTYPE html>"
markup = (wrapper) ->
(attributes..., descendents) ->
attributesMarkup = if attributes.length is 1
' ' + ("#{name}='#{value}'" for name, value of attributes[0]).join ' '
else
''
"<#{wrapper}#{attributesMarkup}>#{descendents() || ''}</#{wrapper}>"
html = markup 'html'
body = markup 'body'
ul = markup 'ul'
li = markup 'li'
emphasis = ->
fontWeight: 'bold'
css
'ul':
emphasis()
'.x':
fontSize: '2em'
ul {
font-weight: bold;
}
.x {
font-size: 2em;
}
Listing 8.5 A basic DSL for CSS
css = (raw) ->
hyphenate = (property) ->
dashThenUpperAsLower = (match, pre, upper) ->
"#{pre}-#{upper.toLowerCase()}"
property.replace /([a-z])([A-Z])/g, dashThenUpperAsLower
output = (for selector, rules of raw #B
rules = (for ruleName, ruleValue of rules
"#{hyphenate ruleName}: #{ruleValue};"
).join '\n'
"""
#{selector} {
#{rules}
}
"""
).join '\n'
SELECT '*' FROM 'users' WHERE 'name LIKE "%scruffy%"'
query
SELECT: '*'
FROM: 'users'
WHERE: 'name LIKE "%scruffy%"'
query
SELECT: '*'
FROM: 'users'
WHERE: "name LIKE '%#{session.user.name}%'"
8.3 How the compiler works
I = (x) -> x
I = λx.x
I = (x) -> x
coffee = require 'coffee-script'
expression = 'I = (x) -> x'
coffee.tokens expression
# [[ 'IDENTIFIER', 'I'],
# [ '=', '='],
# [ 'PARAM_START', '('],
# [ 'IDENTIFIER', 'x'],
# [ 'PARAM_END', ')'],
# [ '->', '->'],
# [ 'INDENT', 2],
# [ 'IDENTIFIER', 'x'],
# [ 'OUTDENT', 2],
# [ 'TERMINATOR', '\n'] ]
I = ( x ) -> x
I = (x) -> x
I = (x) ->[INDENT]x
a = 1
b = 2
a = 1 \n a = 2 \n
I = (x) -> x
I 2
I ( 2 ) \n
f ->
a
.g b, ->
c
.h a
play ( 'football' ) unless injured \n
coffee = require 'coffee-script'
expression = 'I = (x) -> x'
tokens = coffee.tokens expression
coffee.nodes tokens
# { expressions:
# [ { variable: [Object],
# value: [Object],
# context: undefined,
# param: undefined,
# subpattern: undefined } ] }
console.log JSON.stringify coffee.nodes, null, 2
{
"expressions": [
{
"variable": {
"base": {
"value": "I"
},
"properties": []
},
"value": {
"params": [
{
"name": {
"value": "x"
}
}
],
"body": {
"expressions": [
{
"base": {
"value": "x"
},
"properties": []
}
]
},
"bound": false
}
}
]
}
8.4 Bending code to your ideas
node> eval('var x = 2');
node> x
node> # 2
coffee = require 'coffee-script'
coffee.eval '2'
# 2
evaluation = coffee.eval '2 + 4'
# 6
evaluation
# 6
coffee.eval '''
x = 1
y = 2
x + y'''
# 3
x
# Reference Error: x is not defined
y
# Reference Error: y is not defined
coffee = require 'coffee-script'
x = 42
y = coffee.eval "#{x} + 3"
y
# 45
coffee = require 'coffee-script'
scruffyCode = '''
I = λx.x
'''
coffeeCode = scruffyCode.replace /λ([a-zA-Z]+)[.]([a-zA-Z]+)/g, '($1) -> $2'
identity = coffee.eval coffeeCode
identity 2
#2
hello = identity (name) -> "Hello #{name}"
# [Function]
hello 'Scruffy'
# 'Hello Scruffy'
Listing 8.6 ScruffyCoffee with eval
and regular expressions
fs = require 'fs'
coffee = require 'coffee-script'
evalScruffyCoffeeFile = (fileName) ->
fs.readFile fileName, 'utf-8',(err, source) ->
coffeeCode = source.replace /λ([a-zA-Z]+)[.]([a-zA-Z]+)/g,'($1) -> $2'
coffee.eval coffeeCode
fileName = process.argv[2]
unless fileName
console.log 'No file specified'
process.exit()
evalScruffyCoffeeFile fileName
I = λx.x
I = λx . x \n
coffee = require 'coffee-script'
scruffyCoffee = '''
I = λx.x
'''
tokens = coffee.compile scruffyCoffee
i = 0
while token = tokens[i]
# handle token
i++
Listing 8.7 Custom rewriter
fs = require 'fs'
coffee = require 'coffee-script'
evalScruffyCoffeeFile = (fileName) ->
fs.readFile fileName, 'utf-8', (error, scruffyCode) ->
return if error
tokens = coffee.tokens scruffyCode
i = 0
while token = tokens[i]
isLambda = token[0] is 'IDENTIFIER' and /^λ[a-zA-Z]+$/.test token[1]
if isLambda and tokens[i + 1][0] is '.'
paramStart = ['PARAM_START', '(', {}]
param = ['IDENTIFIER', token[1].replace(/λ/, ''), {}]
paramEnd = ['PARAM_END', ')', {}]
arrow = ['->', '->', {}]
indent = ['INDENT', 2, generated: true]
tokens.splice i, 2, paramStart, param, paramEnd, arrow, indent
j = i
while tokens[j][0] isnt 'TERMINATOR'
j++
outdent = ['OUTDENT', 2, generated: true]
tokens.splice j, 0, outdent
i = i + 3
continue
i++
nodes = coffee.nodes tokens
javaScript = nodes.compile()
eval javaScript
fileName = process.argv[2]
process.exit 'No file specified' unless fileName
evalScruffyCoffeeFile fileName
coffee = require 'coffee-script'
nodes = coffee.nodes '2 + 1'
addition = nodes.expressions[0]
addition.operator
# '+'
addition.first.base.value
# '2'
addition.second.base.value
# '1'
nodes.compile bare: true
# 'return 2 + 1;'
addition.operator = '-'
nodes.compile bare: true
# 'return 2 - 1'
Listing 8.8 Generating method tests via the AST
fs = require 'fs'
coffee = require 'coffee-script'
capitalizeFirstLetter = (string) ->
string.replace /^(.)/, (character) -> character.toUpperCase()
generateTestMethod = (name) ->
"test#{capitalizeFirstLetter name}: -> assert false"
walkAst = (node) ->
generated = "assert = require 'assert'"
if node.body?.classBody
className = node.variable.base.value
methodTests = for expression in node.body.expressions
if expression.base?.properties
methodTestBodies = for objectProperties in expression.base.properties
if objectProperties.value.body?
generateTestMethod objectProperties.variable.base.value
methodTestBodies.join '\n\n '
methodTestsAsText = methodTests.join('').replace /^\n/, ''
generated += """
\n
class Test#{className}
#{methodTestsAsText}
test = new Test#{className}
for methodName of Test#{className}::
test[methodName]()
"""
expressions = node.expressions || []
if expressions.length isnt 0
for expression in node.expressions
generated = walkAst expression
generated
generateTestStubs = (source) ->
nodes = coffee.nodes source
walkAst nodes
generateTestFile = (fileName, callback) ->
fs.readFile fileName, 'utf-8', (err, source) ->
if err then callback 'No such file'
testFileName = fileName.replace '.coffee', '_test.coffee'
generatedTests = generateTestStubs source
fs.writeFile "#{testFileName}", generatedTests, callback 'Done'
fileName = process.argv[2]
unless fileName
console.log 'No file specified'
process.exit()
generateTestFile fileName, (report) ->
console.log report
class Elephant
walk: ->
'Walking now'
forget: ->
'I never forget'
> coffee 8.8.coffee elephant.coffee
> # Generated elephant_test.coffee
assert = require 'assert'
class TestElephant
testWalk: -> assert false
testForget: -> assert false
test = new TestElephant
for methodName of TestElephant::
test[methodName]()
let x = 3
console.log x
# 3
console.log x
# ReferenceError: x is not defined
if true
let x = 2, y = 2
console.log 'let expression'
console.log 'wraps block in closure'
var ok;
ok = require('assert').ok;
if (true) {
(function(x, y) {
console.log('let expression');
return console.log('wraps block in closure');
})(2, 2);
}
ok(typeof x === "undefined" || x === null);
ok(typeof y === "undefined" || y === null);
do (x = 2, y = 2) ->
console.log 'let expression'
console.log 'wraps block in closure'
Listing 8.9 Scruffy?s let
implementation using a custom rewriter
fs = require 'fs'
coffee = require 'coffee-script'
evalScruffyCoffeeFile = (fileName) ->
fs.readFile fileName, 'utf-8', (error, scruffyCode) ->
letReplacedScruffyCode = scruffyCode.replace /\slet\s/, ' $LET '
return if error
tokens = coffee.tokens letReplacedScruffyCode
i = 0
consumingLet = false
waitingForOutdent = false
while token = tokens[i]
if token[0] is 'IDENTIFIER' and token[1] is '$LET'
consumingLet = true
doToken = ['UNARY', 'do', spaced: true]
tokens.splice i, 1, doToken
else if consumingLet
if token[0] is 'CALL_START'
paramStartToken = ['PARAM_START', '(', spaced: true]
tokens[i + 1][2] = 0
tokens.splice i, 1, paramStartToken
if token[0] is 'CALL_END'
paramEndToken = ['PARAM_END', ')', spaced: true]
functionArrowToken = ['->', '->', spaced: true]
indentToken = ['INDENT', 2, generated: true]
tokens.splice i, 2, paramEndToken, functionArrowToken, indentToken
consumingLet = false
waitingForOutdent = true
else if waitingForOutdent
if token[0] is 'OUTDENT' or token[0] is 'TERMINATOR'
outdentToken = ['OUTDENT', 2, generated: true]
tokens.splice i, 0, outdentToken
waitingForOutdent = false
i++
nodes = coffee.nodes tokens
javaScript = nodes.compile()
eval javaScript
fileName = process.argv[2]
process.exit 'No file specified' unless fileName
evalScruffyCoffeeFile fileName
8.5 Summary