Skip to content

Instantly share code, notes, and snippets.

@dead-claudia
Last active November 28, 2017 00:48
Show Gist options
  • Save dead-claudia/b9feb99051fdc271e84aeb0d691889da to your computer and use it in GitHub Desktop.
Save dead-claudia/b9feb99051fdc271e84aeb0d691889da to your computer and use it in GitHub Desktop.
An alternative DSL proposal for JavaScript

Please direct all comments to this issue. I won't receive notifications if you comment here, but I will receive them if you comment there.

A DSL proposal for JavaScript

This is an attempt to create a more cohesive, easy-to-implement DSL proposal for JavaScript, attempting to work around most of the issues described in this original proposal. Of course, this is pretty poorly formatted and it's missing a few edge cases, but it's just a strawman.

Syntax

  • Blocks close over environment, but have phantom @@this context (syntax error outside DSL)
  • @foo(...) do { ... } - Invoke @@this.foo(..., block)
  • @['foo'](...) do { ... } - Invoke @@this['foo'](..., block)
  • foo(...) do { ... } - Invoke foo(..., block)
  • foo(...) do (...args) { ... } - Accept arguments for the block
  • @foo do { ... } is equivalent to @foo() do { ... }, etc.
  • @foo(...) async do { ... } makes the block async, like an async function
  • @foo/@['foo'] - Equivalent to @@this.foo/@@this['foo'] outside calls
  • @foo = bar/@['foo'] = bar - Perform @@this.foo = bar/@@this['foo'] = bar
  • @foo as a statement is equivalent to @@this.foo, not @@this.foo()
  • DSLs return their completion value like do blocks
  • Normal blocks always return or throw their value
  • DSL functions are passed a wrapper callback that can be called with @@this and arguments
  • DSLs are invoked via this: foo.call(@@this, ...args, block?)
  • Class decorators have precedence over DSL markers (but reinterpretation between the two is trivial)

Examples

Notes for all the examples, including in other sections:

  • $vars are unique variables not actually exposed to code
  • foo.$props refer to internal slots, not actual properties
  • $func()s refer to internal operations, not actual builtin functions
GraphQL builders
import {query} from "graphql-builders" // hypothetical

let notes = query do {
    @notes do {
        @id
        @createdDate
        @content
        @author do {
            @name
            @avatarUrl({size: 100})
        }
    }
}

Desugared:

query(function () {
    return this.notes(function () {
        this.id
        this.createdDate
        this.content
        return this.author(function () {
            this.name
            return this.avatarURL({size: 100})
        })
    })
})
Framework concept
import * as m from "my-framework/block" // hypothetical
import * as ref from "my-framework/ref" // hypothetical
import marked from "marked"

const Sizes = {
    Small: "0.8em",
    Medium: "1em",
    Large: "1.2em",
}

const Toggle = m.component do ({change, name}) {
    @h("label[style.padding=20px]", {onclick() { change.send(name) }}) do {
        @h("input[type=radio][name=font-size]")
        @t(name)
    }
}

const MyApp = m.element do ({update}) {
    const change = ref.cell("Medium")

    @h("fieldset") do {
        @c(Toggle, {change, name: "Small"})
        @c(Toggle, {change, name: "Medium"})
        @c(Toggle, {change, name: "Large"})
    }

    @h("div", {
        style: {fontSize: change.pipe(
            ref.map(name => Sizes[name])
        )},
        props: {innerHTML: update.pipe(
            ref.map(update => marked(update.content))
        )},
    })
}

m.render(document.getElementById("app")) do {
    const Intro = `
# Anna Karenina

## Chapter 1

Happy families are all alike; every unhappy family is unhappy in its own way.

Everything was in confusion in the Oblonskys’ house. The wife had discovered
that the husband was carrying on an intrigue with a French girl, who had been a
governess in their family, and she had announced to her husband that she could
not go on living in the same house with him...
`

    @c(MyApp, {update: ref.of({content: Intro})})
}

Desugared:

import * as m from "my-framework/block" // hypothetical
import * as ref from "my-framework/ref" // hypothetical
import marked from "marked"

const Sizes = {
    Small: "0.8em",
    Medium: "1em",
    Large: "1.2em",
}

const Toggle = m.component(function ({change, name}) {
    this.h("label[style.padding=20px]", {onclick() { change.send(name) }}, function () {
        this.h("input[type=radio][name=font-size]")
        this.t(name)
    })
})

const MyApp = m.element(function ({update}) {
    const change = ref.cell("Medium")

    this.h("fieldset", function () {
        this.c(Toggle, {change, name: "Small"})
        this.c(Toggle, {change, name: "Medium"})
        this.c(Toggle, {change, name: "Large"})
    })

    this.h("div", {
        style: {fontSize: change.pipe(
            ref.map(name => Sizes[name])
        )},
        props: {innerHTML: update.pipe(
            ref.map(update => marked(update.content))
        )},
    })
})

m.render(document.getElementById("app"), function () {
    const Intro = `
# Anna Karenina

## Chapter 1

Happy families are all alike; every unhappy family is unhappy in its own way.

Everything was in confusion in the Oblonskys’ house. The wife had discovered
that the husband was carrying on an intrigue with a French girl, who had been a
governess in their family, and she had announced to her husband that she could
not go on living in the same house with him...
`

    c(MyApp, {update: ref.of({content: Intro})})
})
Testing
describe("a calculator") do {
    const calculator = new Calculator()

    @on("calling sum with two numbers") do {
        @it("should return the sum of the two numbers") do {
            const sum = calculator.sum(2, 3)
            shouldEqual(5, sum)
        }
    }
}

Desugared:

describe("a calculator", function () {
    const calculator = new Calculator()

    this.on("calling sum with two numbers", function () {
        this.it("should return the sum of the two numbers", function () {
            const sum = calculator.sum(2, 3)
            shouldEqual(5, sum)
        })
    })
})

This is a potential variant of DSLs to enable working with and integrating into control flow. They're called inline DSLs, and have special syntax to make them distinct from normal DSLs.

Syntax

  • :foo(...) do { ... }/::foo(...) do { ... }, etc. - Invoke @@this.foo(...)/foo(...) inline (block required)
    • All arguments are passed as bound thunks
    • Control flow statements (e.g. break, continue, return) are supported
    • Last completion value is returned
    • :['foo'](...) is invoked as @@this['foo'](...)
    • Discerned by : or :: prefix sigil
  • Accept arguments via :foo(...) do (...args) { ... }
  • Inline DSL calls may be labeled
  • Inline blocks and args are coroutines that return an InlineCompletion instance
    • DSL args could have nested DSL arguments and/or do expressions, and so they can't return the raw value
    • Calls throw ReferenceErrors if the DSL coroutine has already returned
    • They are exposed as anonymous generator instances
    • They yield exotic opaque suspension objects, and accept their completion values via throw or return
  • Inline DSL callees are functions returning a coroutine that returns InlineCompletions
    • Completions other than "normal" or "return" are tagged with a reference to the closure and outer call context
    • yielded objects that are not generated internally from language-level await or yield calls result in a TypeError
    • This enables DSLs to specify both, so they can provide an optimized non-control-flow-oriented method
  • After returning, all passed thunks throw errors if called
  • There is a new InlineCompletion object, with the following properties/attributes:
    • new InlineCompletion(value) - Construct a "normal" completion with a value
    • InlineCompletion.prototype.type - Return the type of the completion
    • InlineCompletion.prototype.value - Return the value of the completion, or undefined if type === "break" or type === "abort"
    • InlineCompletion.prototype.return() - Construct a "return" completion to return this completion from the callee
    • These are the following types of inline completions: "normal", "break", "abort", "return"
    • Instances are always frozen, and InlineCompletion.prototype is also frozen
    • "normal" is for normal completion values resulting from executing the block
    • "break" is for simple breaks and labelled breaks and continues where the target is the expression itself
    • "return" is for non-local returns from a child DSL (useful for custom control flow)
    • "abort" is overloaded, since it is used for early returns and outer labelled break/continue
  • Child inline completions are returned identically unless they are either a non-tail "normal" or a "return" from anywhere
  • An engine may wish to consider type info and using it to optimize control flow when inlining
    • This is part of why inline completions and InlineCompletion.prototype are frozen, to broaden the assumptions they can make
    • This is why you can't yield from DSLs directly
    • This allows engines to avoid allocating completions and continuations for simpler methods
    • This can make some utilities like ::select below near zero-cost when fully optimized
  • label::foo do { ... } is read as label: :foo do { ... }, not label; ::foo do { ... }
  • label:foo do { ... } is read as label: foo do { ... }, not label; :foo do { ... }

Examples

Common helpers for below

Yield types:

function $call(...args) {
    return {$type: "call", $value: $invoke(...args)}
}

function $yield(value) {
    return {$type: "yield", $value: value}
}

function $await(value) {
    return {$type: "await", $value: value}
}

function $check(ref) {
    if (!("$type" in ref && "$value" in ref)) {
        throw new TypeError("Only internal values may be yielded")
    }
    return ref
}

Child DSL invocation:

function $iterResult(result, method) {
    if (result == null || typeof result !== "object" && typeof result !== "function") {
        throw new TypeError(`Expected iter.${method} to return an object`)
    }
    return result
}

// Modeled somewhat after Babel's `for ... of` transform output
// I'd use a `for ... of` loop if I could get the return value out of it
function *$runBlock(iter) {
    let thrown = false
    let result
    try {
        block: {
            let next = $iterResult(iter.next(void 0))
            while (!next.done) {
                const ref = $check(next.value)
                let value = ref
                if (ref.$type === "call") {
                    const ret = yield* ref.$value
                    if (ret.type === "return") {
                        result = ret.value
                        break block
                    }
                    value = ret
                }
                next = $iterResult(iter.next(value))
            }
            result = next.value
        }
    } catch (e) {
        result = e
        thrown = true
    } finally {
        const return_ = iter.return
        try {
            if (typeof return_ === "function") $iterResult(return.call(iter))
        } finally {
            if (thrown) throw result
        }
        return result
    }
}

// Invoke a control flow DSL
function *$invoke(func, inst, ...blocks) {
    const args = new Array(blocks.length)
    for (let i = 0; i < blocks.length; i++) {
        const index = i
        args[i] = function () {
            if (blocks == null) throw new ReferenceError("This block is now locked!")
            return $runBlock(blocks[index].apply(this, arguments))
        }
    }
    try {
        return yield* func.apply(inst, args)
    } finally {
        blocks = undefined
    }
}

Parent DSL invocation:

function $cast(ret) {
    while (ret.type === "return") ret = ret.$value
    return ret
}

// For values
function $execValue(iter) {
    let next = iter.next()
    if (!next.done) throw new TypeError("Unexpected `yield`")
    return $cast(next.value)
}

// For generators
function *$execIter(iter) {
    let next = iter.next()
    while (!next.done) next = iter.next(yield $check(next.value).$value)
    return $cast(next.value)
}

// For async functions
async function $execAwait(iter) {
    let next = iter.next()
    while (!next.done) next = iter.next(await $check(next.value).$value)
    return $cast(next.value)
}

// For async generators
async function *$execAwaitIter(iter) {
    let next = iter.next()
    while (!next.done) {
        const ref = $check(next.value)
        next = iter.next(ref.$type === "yield" ? yield ref.$value : await ref.$value)
    }
    return $cast(next.value)
}
C#'s `foreach`
function *foreach(iter, block) {
    const value = iter()
    if (value.type !== "normal") return value
    for (const item of value.value) {
        const ret = yield* block(item)
        if (ret.type === "break") break
        if (ret.type !== "normal") return ret
    }
    return new InlineCompletion()
}

::foreach(array) do (item) {
    console.log(item)
}

Desugared:

function *foreach(iter, block) {
    const value = iter()
    if (value.type !== "normal") return value
    for (const item of value.value) {
        const ret = yield* block(item)
        if (ret.type === "break") break
        if (ret.type !== "normal") return ret
    }
    return new InlineCompletion()
}

$execValue($invoke(foreach, void 0, function *(item) {
    return new InlineCompletion(console.log(item))
}))
VB's `Select`
function *select(cond, block) {
    const value = cond()
    if (value.type !== "normal") return value
    return yield* block.call({
        *when(cond, next) {
            let ret = yield* cond()
            if (ret.type === "normal") {
                if (ret.value !== value.value) return new InlineCompletion()
                return (yield* next()).return()
            } else {
                return ret
            }
        },
        *otherwise(next) {
            return (yield* next()).return()
        },
    })
}

let a = ::select (foo) do {
    :when (bar) do { 1 }
    :when (hello) do { 2 }
    :otherwise do { 3 }
}

Desugared:

function *select(cond, block) {
    const value = cond()
    if (value.type !== "normal") return value
    return yield* block.call({
        *when(cond, next) {
            let ret = yield* cond()
            if (ret.type === "normal") {
                if (ret.value !== value.value) return new InlineCompletion()
                return (yield* next()).return()
            } else {
                return ret
            }
        },
        *otherwise(next) {
            return (yield* next()).return()
        },
    })
}

const $ref = $execValue($invoke(select, void 0, function *() { return new InlineCompletion(foo) }, function *() {
    yield $call(this.when, this, function *() { return new InlineCompletion(bar) }, function *() { return new InlineCompletion(1) })
    yield $call(this.when, this, function *() { return new InlineCompletion(hello) }, function *() { return new InlineCompletion(2) })
    return yield $call(this.otherwise, this, function *() { return new InlineCompletion(3) })
}))
if ($ref.$type === "break") break
if ($ref.$type === "abort") return $ref.$value
let a = $ref.$value
CoffeeScript's `until`
function *until(cond, block) {
    while (true) {
        const value = iter()
        if (value.type !== "normal") return value
        if (value.value) break
        const ret = yield* block(item)
        if (ret.type === "break") break
        if (ret.type !== "normal") return ret
    }
    return new InlineCompletion()
}

let i = 0
::until (i === 10) do {
    // ...
    i++
}

Desugared:

function *until(cond, block) {
    while (true) {
        const value = iter()
        if (value.type !== "normal") return value
        if (value.value) break
        const ret = yield* block(item)
        if (ret.type === "break") break
        if (ret.type !== "normal") return ret
    }
    return new InlineCompletion()
}

let i = 0
$execValue($invoke(until, void 0, function *() { return i === 10 } function *(item) {
    // ...
    return new InlineCompletion(i++)
}))

This is a potential variant for control flow DSL definitions, that use syntax rather than generators with special types.

Syntax

Define a control flow DSL

  • Parameters are call-by-name
  • Final parameter is the block
  • You can return from the calling block via inline return ...
    • From parent DSLs, this has no effect other than returning from the current DSL
    • From child DSLs, this returns from the calling block
  • You can use other control flow DSLs within the DSL itself, and even use inline do ... and friends within them.
inline name(one, two, block) {
  // ...
}

Catch early breaks and returns via catch break and catch return

  • catch break only catches breaks targeted at the current DSL
  • catch return catches those as well as other scenarios like early returns
  • Propagated catch breaks continue into catch return
  • Thrown errors propagate into normal subsequent catch/finally blocks
  • You can propagate breaks via inline break
  • catch return always propagates at the end of the block before leaving
    • Thrown errors are replaced if a new one is thrown in it
try {
    // ...
} catch break {
    // Handle `break`s instead of propagating them upward or aborting
} catch return {
    // Handle aborts from `break`s, `return`s, etc.
}

Invoke blocks via inline do block or with arguments via inline do block(...args)

  • It is an expression that returns the completion value of the block
  • You can invoke it with child DSLs using inline do block(...args) with object
  • The with statement takes precedence, so a line terminator cannot exist between with and its preceding token
  • inline can exist as a modifier, much like async now
// Invoke the block, optionally with arguments, and return the result
inline do block
inline do block(...args)

// Invoke the block with child members
inline do block(...args) with {
    inline foo(body) { ... },
    inline bar(body) { ... },
}

This opens the door to making it an entirely different side channel, and allowing it to be made an exotic type of function. This would allow it to be used without a sigil, and discerned at runtime. In this case, we should probably make the block throw a runtime error on invocation if it involves any control flow when the DSL in question is not an inline DSL.

One open question is how to handle break/continue in a non-linear setting, like in say, subscription callbacks to unsubscribe.

Examples

These are desugared to the coroutine format above, but could be defined as something completely different

C#'s `foreach`
inline foreach(iter, block) {
    for (const item of iter) {
        inline do block(item)
    }
}

Desugared:

function *foreach(iter, block) {
    const value = iter()
    if (value.type !== "normal") return value
    for (const item of value.value) {
        const ret = yield* block(item)
        if (ret.type === "break") break
        if (ret.type !== "normal") return ret
    }
    return new InlineCompletion()
}
VB's `Select`
inline select(cond, block) {
    const value = cond
    inline do block with {
        inline when(cond, next) {
            if (cond === value) inline return inline do next()
        },
        inline otherwise(next) {
            inline return inline do next()
        },
    }
}

Desugared:

function *select(cond, block) {
    const value = cond()
    if (value.type !== "normal") return value
    return yield* block.call({
        *when(cond, next) {
            let ret = yield* cond()
            if (ret.type === "normal") {
                if (ret.value !== value.value) return new InlineCompletion()
                return (yield* next()).return()
            } else {
                return ret
            }
        },
        *otherwise(next) {
            return (yield* next()).return()
        },
    })
}
CoffeeScript's `until`
inline until(cond, block) {
    while (!cond) inline do block()
}

Desugared:

function *until(cond, block) {
    while (true) {
        const value = iter()
        if (value.type !== "normal") return value
        if (value.value) break
        const ret = yield* block(item)
        if (ret.type === "break") break
        if (ret.type !== "normal") return ret
    }
    return new InlineCompletion()
}
// This is an example implementing most of my non-linear control flow proposal, using the
// syntactic variant for the control flow functions:
// https://github.com/isiahmeadows/non-linear-proposal
export inline forAll(iterator, func) {
const promises = []
for (const item of iterator) {
try {
promises.push(inline do func(item))
} catch (e) {
promises.push(Promise.reject(e))
} catch return {
promises.push(Promise.reject(throw new SyntaxError("Invalid return")))
}
}
return Promise.all(promises)
}
export inline forAwaitAll(iterator, func) {
const promises = []
for await (const item of iterator) {
try {
promises.push(inline do func(item))
} catch (e) {
promises.push(Promise.reject(e))
} catch return {
promises.push(Promise.reject(throw new SyntaxError("Invalid return")))
}
}
return Promise.all(promises)
}
export function awaitAll(...items) {
return Promise.all(items)
}
export function awaitRace(...items) {
return Promise.race(items)
}
// Example usage, based on:
// https://github.com/isiahmeadows/non-linear-proposal/blob/master/examples/comparison/async-click.js
function randInt(range) {
return Math.random() * range | 0
}
async function *getSuggestions(selector) {
yield undefined
const response = await fetch(`https://api.github.com/users?since=${randInt(500)}`)
const listUsers = await response.json()
let current = listUsers[randInt(listUsers.length)]
yield current
awaitAll(
::forAwaitAll(fromEvent(document.querySelector(".refresh"), "click")) do {
yield current = undefined
const response = await fetch(`https://api.github.com/users?since=${randInt(500)}`)
const listUsers = await response.json()
yield current = listUsers[randInt(listUsers.length)]
},
::forAwaitAll(fromEvent(document.querySelector(selector), 'click')) do {
yield current
},
)
}
::forAll([".close1", ".close2", ".close3"]) do (selector) {
::forAwaitAll(getSuggestions(selector)) do (suggestion) {
if (suggestion == null) {
// hide the first suggestion DOM element
} else {
// show the first suggestion DOM element and render the data
}
}
}
// Helper
async function *fromEvent(elem, name) {
const resolves = []
const values = []
function listener(e) {
if (resolves.length) resolves.pop()(e)
else values.push(e)
}
elem.addEventListener(name, listener, false)
try {
while (true) {
if (values.length) yield values.pop()
else yield new Promise(r => { resolves.push(r) })
}
} finally {
elem.removeEventListener(name, listener, false)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment