sass icon indicating copy to clipboard operation
sass copied to clipboard

Dynamic mixin invocation

Open Snugug opened this issue 11 years ago • 169 comments

One of the largest stumbling blocks that contributors face right now is being able to cleanly build extendable systems using Sass. While the zip/index method currently proposed as a best practice works OK for variables, it simply does not work for mixins or functions. While we could build a lookup function/mixin for dealing with this, if we are looking to have 3rd party add-ons to a system we've made, the writing of that lookup function/mixin needs to be passed off to the end user, creating a terrible end user experience especially for inexperienced users. As system contributors, we need a way to develop APIs for our systems in a way that is contained from within our extensions. The only way I can see this being accomplished is through mixin and function interpolation.

We are currently running into this problem with attempting to create an Output API for the next generation of Susy, one of the most widely used Compass extensions available (between the two versions, it's something like the 2nd most installed Sass/Compass gem that's not Sass or Compass itself; right behind Bootstrap). While we can create a lookup function/mixin for people to contribute output styles, it leaves the burden on the end user to do it if there are output styles created in contrib. We are thus left with the following user experience, which IMO is terrible:

Inside Extension

$output-styles: isolation, float;

@mixin output-list($input, $output) {
  @if $output == 'isolation' {
    @include isolation($input);
  }
  @else if $output == 'float' {
    @include float($input);
  }
}

@mixin isolation($input) {…}
@mixin float($input) {…}

Inside non-core Output Style

$output-styles: append($output-styles, 'table');

@mixin table($input) {…}

Inside User's file

Would need to write to use non-core Output style, a bit too technical for most users

@debug $output-styles;

Read output styles from debug. Only really works from Command Line, not from GUI apps which many many people use.

DEBUG: "isolation", "float", "table"
@mixin output-list($input, $output) {
  @if $output == 'isolation' {
    @include isolation($input);
  }
  @else if $output == 'float' {
    @include float($input);
  }
@else if $output == 'table' {
    @include table($input);
  }
}

What we'd much much rather prefer is to do the following.

Inside Extension

$output-styles: 'isolation', 'float';

@mixin output-styles($input, $output) {
  @each $style in $output-styles {
    @if $output == $style {
      @mixin #{unquote($style)}($input);
    }
  }
}

@mixin isolation($input) {…}
@mixin float($input) {…}

Inside non-core Output Style

$output-styles: append($output-styles, 'table');

@mixin table($input) {…}

Inside User's file

NOTHING! They'd just need to use the system like they'd expect to be able to without any setup! Everyone's happy!

Snugug avatar Jan 13 '13 14:01 Snugug

I do think it's incongruous from a user's perspective why they can interpolate a placeholder selector for extend but not for mixin & function definitions and calls. It seems like we can add interpolation for @include and @mixin fairly easily. However, function calls are trickier and I think it may be better to have a generic call($function-name, $arglist...) function that can be used to call a function with the provided arguments.

chriseppstein avatar Jan 13 '13 21:01 chriseppstein

My primary use case is for mixins, so I'm happy with that solution. The generic call for function works for me too. Both would solve the issues I need solved.

Snugug avatar Jan 13 '13 21:01 Snugug

For another real world example I'm writing a mixin right now that has a keyword list, and needs to call a related function if an argument passed in contains a keyword. With function interpolation I could do (pseudocode) if $keyword_list contains $var { #{var}() }. So much shorter, more maintainable, more extensible. The call()function would work great too, just a general +1 to the idea.

robwierzbowski avatar Jan 16 '13 20:01 robwierzbowski

Any movement/further thoughts on this? We're running into quite a few places where this functionality would be immensely useful

Snugug avatar Feb 11 '13 16:02 Snugug

I'm reasonably open to this, although I doubt I'll have time to implement it in the near future.

nex3 avatar Feb 23 '13 00:02 nex3

I'll take a swing at it. @nex3 do you prefer call or invoke (or something else) for a generic function calling function?

chriseppstein avatar Feb 23 '13 00:02 chriseppstein

call matches JavaScript, which is a good enough criterion for me.

nex3 avatar Feb 26 '13 00:02 nex3

+1. Was looking for exactly this yesterday, in the same form as with #673. I was surprised to find that it didn't work.

If I understand it correctly, one can do

@media #{$breakpoint} {...}

but not

@include #{$mixin_name};

?

lunelson avatar Mar 09 '13 13:03 lunelson

What about a mixin-including function? Something like:

include(mixin_name, args...);

?

lunelson avatar Mar 11 '13 12:03 lunelson

@lunelson Interpolating a @media string (which gets printed straight out to CSS) and interpolating a mixin call (which then needs to dynamically call another piece of code) are very different processes, so it shouldn't be too surprising that it didn't work (especially considering you can't interpolate variables or functions either).

As stated above by @chriseppstein, he thinks that they'll be able in do interpolation for mixins with the current @include syntax and he and @nex3 have agreed they like the call syntax for functions, I don't think we need to confuse the issue w/more proposed syntaxes.

Snugug avatar Mar 11 '13 12:03 Snugug

Thanks @Snugug, I see your point and I missed @chriseppstein's mention concerning @include above. All good then, glad to hear it.

lunelson avatar Mar 11 '13 13:03 lunelson

@chriseppstein how goes the work on the call/invoke methods? I ran into the need today and spent an hour trying to homebrew a solution in pure SASS before I realized it was impossible. I'd love to help, but unfortunately I have no ruby experience, and glancing over the apparently relevant files (interpolation.rb and functions.rb?), I realized this might not be the best project to jump in on headfirst.

If there's anything I can do to help, please let me know. In the interim, is their a recommended solution around this? Should I just build a lookup table for the functions I plan to use and a behemoth if tree?

Thanks!

joshuafcole avatar Mar 23 '13 07:03 joshuafcole

What is the state of this call/invoke function calling function feature?

ghepting avatar Apr 22 '13 20:04 ghepting

Low-priority.

nex3 avatar May 10 '13 20:05 nex3

After thinking about this more, I don't really like the idea of adding interpolation support to mixin names. I dislike interpolation and only ever want to use it when there is no way of doing a more readable syntax. For example, I find this to be less readable than some of the options I demonstrate below:

@include #{$some-mixin}($arg1, $arg2);

Since, we have full control over what is a valid syntax for @include, I don't see a need to resort to a syntax hack. So I would like to consider some different syntax options. Som options that come to mind:

  1. @include $some-mixin with ($arg1, $arg2) -- Any expression that evaluates to a string could be used before the with keyword. I think a space after the with would be optional. However if the with clause is optional for mixins with no arguments (as opposed to with () -- which I dislike), this has a degenerative ambiguity where the expression is a simple identifier. So that leads me to think we need some sort of token early on in the mixin expression.
  2. @include mixin $some-mixin with ($arg1, $arg2) -- What i don't like about this is that it makes include seem like it could include things that aren't mixins, but it does allow the with clause to be omitted for mixins with no arguments. This syntax can be parsed with only a single token look-ahead to disambiguate it from an include of a mixin named mixin.

Thoughts?

chriseppstein avatar Jun 24 '13 16:06 chriseppstein

I like the former much more than the later, but the more I think about this holistically, the more I'm not entirely thrilled by either especially when compared to interpolation. How, for instance, would either of the options proposed work with #366, assuming that's still on the roadmap? Can the answer be implied interpolation?

What if a variable/expression is included for a mixin name (mixins currently strictly disallow for naming conventions that would look like a variable/expresion, and this would stay) that it would attempt to resolve the variable/expression? If it doesn't resolve to a string, throws an error. Once it's been resolved to a string, it uses that as its mixin name? From there it's just mixin as normal, probably passing through a check to see if the mixin exists (and if not, throwing an error).

Something as simple as the following:

$foo: 'bar';

@mixin baz {
  content: 'baz';
}

.baz {
  @include $foo;
}

This would also work with the syntax in #366:

.baz {
  ++$foo;
}

Snugug avatar Jun 24 '13 17:06 Snugug

Most of the desire to use interpolation on mixins/functions stems from the fact that they are not first-class entities (ie. they can't be passed as arguments to other mixins/functions). If mixins should become a first-class entity, we'll have to have some way of expressing that anyway, even if adding interpolation wasn't on the table.

Snugug's proposed @include $my-mixin syntax seems the simplest, most logical way to go and would be an easy concept for non-programmers to grasp.

cimmanon avatar Jun 24 '13 17:06 cimmanon

@Snugug @cimmanon The issue here is that using a variable is not forced. Any valid SassScript expression would be allowed, including no indirection at all. So any syntax we choose must allow for a bare identifier to be unambiguous. This is the case for @include $my-mixin, but this implies that @include $my-mixin($a, $b) or @include some-fn-that-returns-a-mixin-name($asdf)($a, $b) would be valid, but they make me a bit squeemish.

chriseppstein avatar Jun 24 '13 17:06 chriseppstein

@chriseppstein That's exactly what I'm implying. I would assume the same would be true for either of your proposed syntaxes, and that's how it would work with interpolation. For me, the most common use for this feature would be something like the following (and why I like interpolation):

$mixins: 'foo', 'bar', 'baz';

@mixin api($mixin-type) {
  @each $mixin in $mixins {
    @if $mixin-type == $mixin {
      @include namespace($mixin) {}
    }
  }
}

Or, with interpolation, would look something like the following:

$mixins: 'foo', 'bar', 'baz';

@mixin api($mixin-type) {
  @each $mixin in $mixins {
    @if $mixin-type == $mixin {
      @include #{$mixin}-namespace {}
    }
  }
}

Snugug avatar Jun 24 '13 17:06 Snugug

@Snugug the problem is that we need to know what parts of that expression are the SassScript that means the mixin name and what parts are the argument list so that we can parse them appropriately. It's very hard to suss out the last parenthesis group using either a regexp or recursive descent parsing. As such, I'm pretty sure we need to have a way to demarcate the argument list expressions from the name expression. (correct me if I'm wrong, @nex3)

chriseppstein avatar Jun 24 '13 17:06 chriseppstein

Ahh, I understand your point now. In that case, this brings me back to one of two things that I think are the best options for this; either magical interpolation but only allowing for variables, or interpolation. I understand that you're not a fan of interpolation, but it does make that distinction for us that you're looking to have, it's an established (if not liked) pattern, and it doesn't add additional complexities to the mixin syntax, allowing for the mixin syntax to take on other forms. Additionally, when it comes to #366 for instance, one of the wants is mixins to look like properties using that syntax, and having interpolation brackets would bake it look identical to property interpolation that we can do now.

Snugug avatar Jun 24 '13 17:06 Snugug

Regarding #366, that syntax is simply sugar to support a common use case, but as I envision it, it does not express the full power of the @include directive. As such, it's just a shortcut parsing scheme for generating an include directive and it could support interpolation like properties do, since the goal of that syntax is to be more property-like. So I don't consider this decision to be linked to that one.

Forcing the use of a variable is unnecessary and arbitrary -- the only places we do that are where the variable is an L-Value (being assigned to).

And I don't agree that interpolation is a better option than the one I specified above. It's a jumble of curly braces and hash signs and it's hard to read. I don't see a need to escape our own syntax. CSS is a verbose language and we do not need to be terse here. I think the include syntax for dynamic mixin names can and should more closely resemble the @for directive.

chriseppstein avatar Jun 24 '13 18:06 chriseppstein

Maybe where I'm getting caught up is the additions to the @include syntax proposed to allow for this to happen with, to me, don't feel right. Maybe the answer is a new directive so we have full control over it, something like @call? That way, this would work closer to how #812 works? So something like the following:


.foo {
  @call baz [with (arglist)];
}

.bar {
  @call qux [with (arglist)] {
    content: 'Qux';
  }
}

I'd even be happy with @call mixin baz… instead of just @call baz if that meant that there was a SassScript API of some sorts of creating new literals.

Snugug avatar Jun 24 '13 18:06 Snugug

I don't think a completely different directive is warranted to accomplish this. It's still just an include of a mixin. If I'm reading a file and I see @call and I don't know what it is and I have to go study it. If I see a slightly different way of using @include I can correctly infer what is going on without having been taught it.

chriseppstein avatar Jun 24 '13 19:06 chriseppstein

I'm not entirely sure I agree with the later part of your statement. If I saw @include mixin $foo as a green user, I'd be confused why I needed to tell the interface that's used for using mixins that I'm using a mixin.

Snugug avatar Jun 24 '13 19:06 Snugug

@Snugug right. As I stated originally "What i don't like about this is that it makes include seem like it could include things that aren't mixins". But I don't want to solve this with interpolation, nor a new directive. Some other keywords are fine though.

chriseppstein avatar Jun 24 '13 19:06 chriseppstein

Is there a way for us to solve both use cases presented with the current @include syntax? A semantic suffix works when there is an arglist, but not when there isn't. A semantic prefix works without an arglist, but makes it appar as if there could be more to @import. What about a flag of some sorts like is being used for @extend?

@include $foo-bar !flag [(arglist) {}]

Possible options for !flag could be:

  • !optional
  • !dynamic
  • !call
  • !eval

Snugug avatar Jun 24 '13 19:06 Snugug

@chriseppstein Unless the user is really confused, I highly doubt anyone is going to believe that @include can be used on anything other than a mixin. Other languages seem to do just fine without adding new language constructs for functions vs variables containing functions.

It's been quite a while since I've used PHP, but this should work (also note that passing functions around as arguments to other function is done with the function name as a string, or at least it used to be).

$my-function = 'is_string';
echo $my-function('foo');

JavaScript that we all know and love...

var foo = function(x) { return x * 2 }
console.log(foo(3));

I've been using Haskell in my day to day programming for the past year or so and there's no differentiation between "variable" and "function" (partially because there's no such thing as variables in Haskell), everything is an expression.

foo :: Bool
foo = True

bar :: Bool -> Bool
bar x = not x

If I try to pass an argument to an expression that takes no arguments (eg. foo False), then the compiler informs me that I'm doing it wrong.

cimmanon avatar Jun 24 '13 20:06 cimmanon

@chriseppstein: Is your argument against interpolation solely readability? Each of the alternate suggestions puts a higher mental burden on me than reading an interpolated string.

If interpolation isn't going away for variables, i.e. #{$foo}-something, I would much rather see an existing language construct reused for an expected result than a new new directive added.

For me, #{$foo}(args) is the most understandable, readable option so far.

robwierzbowski avatar Jun 24 '13 20:06 robwierzbowski

@cimmanon I don't let PHP inspire any of my language design :)

In the javascript case, the variable foo is bound to a function reference. SassScript does not have first class references for mixins and functions. Are you proposing that we create such a construct? To this point, it hasn't seemed necessary and I still don't see a strong argument for it. It implies we have to introduce new definition constructs, etc. which I'm not a big fan of.

@robwierzbowski We've rejected interpolation for variables. Instead, we're adding a map data structure in 3.3. Regarding you claim that you find interpolation easier to read than a keyword, all I can say is that whenever I see a large block of code making heavy use of interpolation, I find it ugly and unreadable. Maybe this isolated use is not, in and of itself unreadable. But it adds to a general unreadability for code en-masse. It's obviously an aesthetic, so we're not going to reach agreement through persuasive arguments, but I appreciate your feedback.

chriseppstein avatar Jun 24 '13 20:06 chriseppstein