anko / eslisp

un-opinionated S-expression syntax and macro system for JavaScript
ISC License
528 stars 31 forks source link

The `object` macro is not ES6-friendly #23

Open anko opened 8 years ago

anko commented 8 years ago

ES6 introduces dynamic property names in object expressions:

var x = 42;
var obj = {
  [ 'prop_' + x ]: 42
};

At the moment, eslisp's object macro can't unambiguously accommodate that. Given that (object a b) compiles to { a : b }, what should compile to { [a] : b }?

Similarly to previously in #13, this can't simply be solved by having (object "a" b) compile to { a : b } instead and (object a b) to { [a] : b }, because it must continue to be possible to express both { a : b } and { "a" : b } for stuff like Google's closure compiler, and for when it's necessary to ensure that part of the code is also valid JSON.

tabatkins commented 8 years ago

Valid JSON can be done separately via a (json) function; that can limit values to valid JSON values, and invoke a "jsonify" contract for values that aren't valid JSON.

lhorie commented 8 years ago

@impinball brought up another issue in the chat:

things like {a, [b + c]: d}. Using (object a (+ b c) d) would be ambiguous.

In total, there are 8 cases in ES6:

dead-claudia commented 8 years ago

Yep. I would like to mention that the shorthand method can be done without, and in many different compile-to-JS languages, and even in Lua (another prototype-based language), that functionality simply doesn't exist. [1]

That still leaves 4 different versions to cover, including all permutations thereof.

[1] Well, I kinda fibbed a little with ClojureScript, mainly with regards to (defprotocol).

lhorie commented 8 years ago

Oh, looks like I missed a bunch other cases:

Note that, unlike shorthand syntax ({a}), method shorthands ({a() {}}) are functionally different from {a: function() {}}. (Notably, they are not constructable). See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Method_definitions

tabatkins commented 8 years ago

There are a lot of functions similar to this in Lisp, where you have a bunch of key/value pairs, some of which can have different syntax. They all work by taking a list of lists, like:

(object ('a 1) ('b 2))
===>
{a: 1, b: 2}

This sort of syntax, while it requires a little more typing than the "just pair them off" plist-inspired syntax that (object) currently uses, is unambiguous and allows for all the object-literal variants:

(object
  ('a)
  ('b 1)
  ((+ c "foo") 2)
  (get 'd (lambda () (return 3))))
===>
{a, b:1, [c+"foo"]:2, get d() {return 3}}
dead-claudia commented 8 years ago

That looks way better.

On Tue, Oct 6, 2015, 14:14 Tab Atkins Jr. notifications@github.com wrote:

There are a lot of functions similar to this in Lisp, where you have a bunch of key/value pairs, some of which can have different syntax. They all work by taking a list of lists, like:

(object ('a 1) ('b 2)) ===> {a: 1, b: 2}

This sort of syntax, while it requires a little more typing than the "just pair them off" plist-inspired syntax that (object) currently uses, is unambiguous and allows for all the object-literal variants:

(object ('a) ('b 1) ((+ c "foo") 2) (get 'd (lambda () (return 3)))) ===> {a, b:1, [c+"foo"]:2, get d() {return 3}}

— Reply to this email directly or view it on GitHub https://github.com/anko/eslisp/issues/23#issuecomment-145951386.

lhorie commented 8 years ago

There are a lot of functions similar to this in Lisp

Yeah, let form comes to mind. Speaking of which, I realized that var has the same issue (i.e. can't express var a, b;), so it would also need to change to (var (a 1) (b)) => var a = 1, b in order to support multi-variable declarations.

tabatkins commented 8 years ago

It doesn't need to, necessarily; leaving out the value is equivalent to setting it to undefined. But it is probably good to be consistent?

dead-claudia commented 8 years ago

@tabatkins For multiple declarations, it probably is (see my initial comment on the object shorthand ambiguity). For a single declaration, I would love to see this as a shorthand for a common case:

(var a 1) ;=> var a = 1
(var a) ;=> var a
;; (let ...), (const ...)
anko commented 8 years ago

@impinball Ambiguity: Does (var a b) compile to var a = b; or var a, b;?

dead-claudia commented 8 years ago

@anko I was initially thinking of that, but I decided to not bring it up in the first place, as I already knew about the ambiguity. I was also specifically talking about single declarations, where var a, b is literally two separate declarations in one statement.

Or to more directly answer your question, that would compile to var a = b. Think of (var a 1) as a shorthand for (var (a 1)), without nested parentheses.

lhorie commented 8 years ago

Ambiguity: Does (var a b) compile to var a = b; or var a, b;?

Yes, that was the ambiguity that I was trying to raise.

Personally, I don't really like the idea of special casing (var a 1), because it becomes an extra edge case to deal w/ if you're writing an AST visitor

Also, it creates inconsistent indentation rules:

;this is weird
(var a 1
    (b)
     c 2)
(var (d)
      e 3)

;this feels more idiomatic
(var (a 1)
     (b)
     (c 2))
(var (d)
     (e 3))

But then again, indentation rules can always be enforced at a styleguide level

In any case, I think it makes sense to standardize on having each object property /variable declarator in separate forms. ES6 classes will similarly require special subforms as well.

(var (a)
     (b 1))
(object
  (a)
  (b 1))
(class x
  (a ())
  (b ()))
dead-claudia commented 8 years ago

I get what you mean. I've just always preferred single declarations instead of multiple in my projects, particularly for initialized variables (less diff noise).

  var a = 1,
-     b = 2,
-     c = 3;
+     b = 2;

Just personal preference. I know the other is more idiomatic for Lisp dialects, though.

(let [a 1
      b 2]
  (+ a b))
dead-claudia commented 8 years ago

Edit: added shorthand. Edit 2: edited member expressions per my suggestion in #13.

I have an idea for solving the object dilemma:

  1. Make 'name be a literal key, and name be a computed key.
  2. Make static keys for object as follows:

    (object
     ('key1 value1)
     ('key2 value2))
    // In JS:
    {key1: value1, key2: value2}

    The preceding quote is idiomatic in Common Lisp, and a similar preceding colon in Clojure.

    This was @tabatkins' idea.

  3. Omitting the value is the property shorthand.

    (object ('prop))
    (object ('prop prop))

    Thanks, @tabatkins for catching this.

  4. For computed keys, omit the preceding quote.

    (object
     ((. Symbol 'toStringTag) "MyObject")
     ((foo) "bar"))
    // In JS:
    {[Symbol.toStringTag]: "MyObject", [foo()]: "bar"}

    These otherwise carry the same semantics as static keys (e.g. (object ((+ "foo" "bar"))) is equivalent to (object ((+ "foo" "bar") undefined)), etc.). As a side effect, (object (foo bar)) qould translate to {[foo]: bar}.

    This was also @tabatkins' idea.

  5. For getters/setters, use (get 'key () ...body) and (set 'key (arg) ...body), respectively. Use the same semantics for the key as with regular properties.

    (object
     (get 'foo () (return (. this _foo)))
     (set 'foo (arg) (= (. this _foo) arg)))
    // In JS
    {
     get foo() { return this._foo },
     set foo(arg) { this._foo = arg },
    }
  6. For methods, use the following syntax:

    (object
     ('foo () (return 1))
     ('bar (arg) (return arg)))
    // In JS:
    {
     foo() { return 1 },
     bar(arg) { return arg },
    }

    This might initially seem ambiguous with normal properties, but those can only have two parts. This has three or more, and the second part can only possibly be a list. As long as these invariants are satisfied, there is no ambiguity.

  7. For generators, precede the method with a star. The syntax is otherwise identical to the method syntax.

    (object
     (* 'foo () (yield 1))
     (* 'bar (arg) (yield arg)))
    // In JS:
    {
     *foo() { yield 1 },
     *bar(arg) { yield arg },
    }

To show an example with all of them:

(const wm (new WeakMap))
(const syms (require "./symbols")

(object
  ('prop)
  ('_foo 1)
  ((. Symbol 'toStringTag) "Foo")

  (get 'foo ()
    (return (. this _foo)))

  (set 'foo (value)
    (= (. this _foo) value))

  (get (. syms 'Sym) ()
    (return ((. wm get) this)))

  (set (. syms 'Sym) (value)
    ((. wm set) this value))

  ('printFoo ()
    ((. console log) (. this foo)))

  ('concatFoo (arg)
    (return (+ (. this foo) arg)))

  (* 'values ()
    (yield (. this foo)))

  (* 'valuesConcat (value)
    (yield (. this foo))
    (yield value)))
// In JS:
const wm = new WeakMap()
const syms = require("./symbols")

{
  prop,
  _foo: 1,
  [Symbol.toStringTag]: "Foo",

  get foo() {
    return this._foo
  },

  set foo(value) {
    this._foo = value
  },

  get [syms.Sym]() {
    return wm.get(this)
  },

  set [syms.Sym](value) {
    return wm.set(this, value)
  },

  printFoo() {
    console.log(this.foo)
  },

  concatFoo(value) {
    return this.foo + value
  },

  *values() {
    yield this.foo
  },

  *valuesConcat(value) {
    yield this.foo
    yield value
  },
}

And let var and friends be like this:

(var (a))         ; var a;
(var (a b))       ; var a = b;
(var (a b) (c d)) ; var a = b, c = d;

(let (a))         ; let a;
(let (a b))       ; let a = b;
(let (a b) (c d)) ; let a = b, c = d;

(const (a))         ; const a;
(const (a b))       ; const a = b;
(const (a b) (c d)) ; const a = b, c = d;

@anko @tabatkins @lhorie What do you all think?

tabatkins commented 8 years ago

Looks good overall, but your 1-value syntax is wrong. We want it to match {a}, which is equivalent to {"a": a}. So the 1-value syntax should only allow actual variables, like (object ('a)). Having a computed key is an error.

So to summarize:

dead-claudia commented 8 years ago

@tabatkins

  1. I overlooked that. I fixed my initial comment to use that instead.
  2. Correct.
  3. Correct.
  4. Correct.
dead-claudia commented 8 years ago

Also, are you all okay with the implicit lambda?

tabatkins commented 8 years ago

Yeah, I got no problems with implicit lambda.

lhorie commented 8 years ago

Looks very similar to what I currently have in that toy compiler I've been working on, except that I use a special form for computed keys instead of non-computed keys.

For comparison, here's what some of my tests look like right now:

//variable declarations
test('(var (a 1))', 'var a = 1;')
test('(var (a 1) (b 2))', 'var a = 1, b = 2;')
test('(var (a))', 'var a;')
test('(var (a) (b))', 'var a, b;')
test('(let (a 1))', 'let a = 1;')
test('(let (a 1) (b 2))', 'let a = 1, b = 2;')
test('(let (a))', 'let a;')
test('(let (a) (b))', 'let a, b;')
test('(const (a 1))', 'const a = 1;')
test('(const (a 1) (b 2))', 'const a = 1, b = 2;')
test('(const (a))', 'const a;')
test('(const (a) (b))', 'const a, b;')

//object
test('(object (a b))', '({ a: b });')
test('(object (a b) (c d))', '({a: b,c: d});')
test('(object)', '({});')
test('(object (get a () 1))', '({ get a() {1;} });')
test('(object (set a () 1))', '({ set a() {1;} });')
test('(object (get a () 1) (set a () 1))', '({get a() {1;},set a() {1;}});')
test('(object (get a () 1) (b 2) (set a () 1))', '({get a() {1;},b: 2,set a() {1;}});')
test('(object (get a))', '({ get: a });')
test('(object (set a))', '({ set: a });')
test('(object (* a () 1))', '({ *a() {1;} });')
test('(object (a))', '({ a });')
test('(object ((_[] a) 1))', '({ [a]: 1 });')

//class
test('(class a (extends b) (c (d) e))', 'class a extends b {c(d) {e;}}')
test('(class a (extends b) (c (d) e) (f (g) h))', 'class a extends b {c(d) {e;}f(g) {h;}}')
test('(class a (extends b) (static c (d) e))', 'class a extends b {static c(d) {e;}}')
test('(class a (extends b) (get c (d) e))', 'class a extends b {get c(d) {e;}}')
test('(class a (extends b) (set c (d) e))', 'class a extends b {set c(d) {e;}}')
test('(class a (extends b) (* c (d) e))', 'class a extends b {*c(d) {e;}}')
test('(class a (extends b) (static * c (d) e))', 'class a extends b {static *c(d) {e;}}')
test('(class a (extends b) (static get c (d) e))', 'class a extends b {static get c(d) {e;}}')
test('(class a (c (d) e))', 'class a {c(d) {e;}}')
test('(class a (c ()))', 'class a {c() {}}')
test('(class a (static (d) e))', 'class a {static(d) {e;}}')
test('(class a (get (d) e))', 'class a {get(d) {e;}}')
test('(class a (set (d) e))', 'class a {set(d) {e;}}')
test('(class a (static get (d) e))', 'class a {static get(d) {e;}}')
test('(class a (static set (d) e))', 'class a {static set(d) {e;}}')
test('(class a (* get (d) e))', 'class a {*get(d) {e;}}')
test('(class a (* set (d) e))', 'class a {*set(d) {e;}}')
test('(class a (static * get (d) e))', 'class a {static *get(d) {e;}}')
test('(class a (static * set (d) e))', 'class a {static *set(d) {e;}}')
test('(class a ((_[] b) ())', 'class a {[b]() {}}')

I'm still toying w/ it and will probably replace the _[] atom w/ something else, but the idea is to eventually sugar the computed key form w/ a reader macro e.g. (class a ([b] ())) => class a {[b] () {}}

dead-claudia commented 8 years ago

Not too sold on that class syntax, though. I'm too busy fixing my computer to type out a detailed reply, but I don't like having to use the extends keyword. It just doesn't feel right to me.

On Thu, Oct 8, 2015, 21:34 Leo Horie notifications@github.com wrote:

Looks very similar to what I currently have in that toy compiler I've been working on, except that I use a special form for computed values instead of the other way around.

For comparison, here's what some of my tests look like right now:

//variable declarationstest('(var (a 1))', 'var a = 1;')test('(var (a 1) (b 2))', 'var a = 1, b = 2;')test('(var (a))', 'var a;')test('(var (a) (b))', 'var a, b;')test('(let (a 1))', 'let a = 1;')test('(let (a 1) (b 2))', 'let a = 1, b = 2;')test('(let (a))', 'let a;')test('(let (a) (b))', 'let a, b;')test('(const (a 1))', 'const a = 1;')test('(const (a 1) (b 2))', 'const a = 1, b = 2;')test('(const (a))', 'const a;')test('(const (a) (b))', 'const a, b;') //objecttest('(object (a b))', '({ a: b });')test('(object (a b) (c d))', '({a: b,c: d});')test('(object)', '({});')test('(object (get a () 1))', '({ get a() {1;} });')test('(object (set a () 1))', '({ set a() {1;} });')test('(object (get a () 1) (set a () 1))', '({get a() {1;},set a() {1;}});')test('(object (get a () 1) (b 2) (set a () 1))', '({get a() {1;},b: 2,set a() {1;}});')test('(object (get a))', '({ get: a });')test('(object (set a))', '({ set: a });')test('(object (* a () 1))', '({ a() {1;} });')test('(object (a))', '({ a });')test('(object (([] a) 1))', '({ [a]: 1 });') //classtest('(class a (extends b) (c (d) e))', 'class a extends b {c(d) {e;}}')test('(class a (extends b) (c (d) e) (f (g) h))', 'class a extends b {c(d) {e;}f(g) {h;}}')test('(class a (extends b) (static c (d) e))', 'class a extends b {static c(d) {e;}}')test('(class a (extends b) (get c (d) e))', 'class a extends b {get c(d) {e;}}')test('(class a (extends b) (set c (d) e))', 'class a extends b {set c(d) {e;}}')test('(class a (extends b) (_ c (d) e))', 'class a extends b {c(d) {e;}}')test('(class a (extends b) (static * c (d) e))', 'class a extends b {static *c(d) {e;}}')test('(class a (extends b) (static get c (d) e))', 'class a extends b {static get c(d) {e;}}')test('(class a (c (d) e))', 'class a {c(d) {e;}}')test('(class a (c ()))', 'class a {c() {}}')test('(class a (static (d) e))', 'class a {static(d) {e;}}')test('(class a (get (d) e))', 'class a {get(d) {e;}}')test('(class a (set (d) e))', 'class a {set(d) {e;}}')test('(class a (static get (d) e))', 'class a {static get(d) {e;}}')test('(class a (static set (d) e))', 'class a {static set(d) {e;}}')test('(class a ( get (d) e))', 'class a {get(d) {e;}}')test('(class a ( set (d) e))', 'class a {set(d) {e;}}')test('(class a (static * get (d) e))', 'class a {static get(d) {e;}}')test('(class a (static * set (d) e))', 'class a {static *set(d) {e;}}')test('(class a ((_[] b) ())', 'class a {[b]() {}}')

I'm still toying w/ it and will probably replace the _[] atom w/ something else, but the idea is to eventually sugar the computed key form w/ a reader macro e.g. (class a ([b]())) => class a {[b]() {}}

— Reply to this email directly or view it on GitHub https://github.com/anko/eslisp/issues/23#issuecomment-146732974.

lhorie commented 8 years ago

@impinball both class id and superClass are optional in class expressions, so (= x (class a)) would be ambiguous if both id and superClass were simply identifiers. (i.e., is it x = class a {} or x = class extends a?)

I'm currently doing it this way to maintain consistency with the keyword verbosity of other constructs, i.e. I have (catch ...), (finally ...), (else ...), (case ...), (default ...) forms because those are js keywords, even though the estree spec does not specify node types for all of those keywords.

I'm aware that some of my choices are different from current eslisp. For example, I have (if a b (else c d)) instead of (if (block a b) (block c d)) to make if more consistent w/ other statement types like try.

Regardless, eslisp forms don't need to be the same as my toy compiler, and my code is still heavily in flux and I'm more than happy to hear feedback and suggestions.

dead-claudia commented 8 years ago

Yeah... They're different languages. And my opinion isn't exactly required to implement. Plus, we kinda need to sort out objects before moving on to classes here, since class methods should have similar syntax to object methods.

On Fri, Oct 9, 2015, 08:58 Leo Horie notifications@github.com wrote:

@impinball https://github.com/impinball both class id and superClass are optional in class expressions, so (= x (class a)) would be ambiguous if both id and superClass were simply identifiers. (i.e., is it x = class a {} or x = class extends a?)

I'm currently doing it this way to maintain consistency with the keyword verbosity of other constructs, i.e. I have (catch ...), (finally ...), (else ...), (case ...), (default ...) forms because those are js keywords, even though the estree spec does not specify node types for all of those keywords.

I'm aware that some of my choices are different from current eslisp. For example, I have (if a b (else c d)) instead of (if (block a b) (block c d)) to make if more consistent w/ other statement types like try.

Regardless, eslisp forms don't need to be the same as my toy compiler, and my code is still heavily in flux and I'm open to suggestions.

— Reply to this email directly or view it on GitHub https://github.com/anko/eslisp/issues/23#issuecomment-146863379.

anko commented 8 years ago

@impinball Many thanks for the summary. That'll make a useful base for tests.

stasm commented 8 years ago

Isn't this inconsistent with the . macro? A quoted atom should evaluate to itself: in (. console log), log evaluates to log in JS. When defining the object, I'd expect the same behavior: (object (log (lambda …)))—because, again, log evalues to log in JS: {log: function() {}}.

tabatkins commented 8 years ago

No, this is consistent with the planned changes to the (.) macro.

In your example, neither atom is quoted, so they shouldn't evaluate to themselves, but rather to the variable they name.

stasm commented 8 years ago

Ah, of course, my bad! In my example, log doesn't end up as a reference to a variable.

tabatkins commented 8 years ago

But it will, per #13. The current design is broken, as it's not compatible with computed property names.

stasm commented 8 years ago

Yes, I see, it makes sense now :)

anko commented 8 years ago

No, this is consistent with the planned changes to the (.) macro.

:point_up: Correct.

Sorry, the confusion is my fault. #13 should have been marked open. We had some confusion about the exact nature of the problem.

dead-claudia commented 8 years ago

Made another edit to the main suggestion, per my comment in #13.