6774a0bb22b25d98c17fc65d904a04498593539e
[yaffs-website] / vendor / twbs / bootstrap-sass / tasks / converter / less_conversion.rb
1 require_relative 'char_string_scanner'
2 require 'bootstrap-sass/version'
3
4 # This is the script used to automatically convert all of twbs/bootstrap LESS to Sass.
5 #
6 # Most differences are fixed by regexps and other forms of string substitution.
7 # There are Bootstrap-specific workarounds for the lack of parent selectors, recursion, mixin namespaces, extend within @media, etc in Sass 3.2.
8 class Converter
9   module LessConversion
10     # Some regexps for matching bits of SCSS:
11     SELECTOR_CHAR               = '\[\]$\w\-{}#,.:&>@'
12     # 1 selector (the part before the {)
13     SELECTOR_RE                 = /[#{SELECTOR_CHAR}]+[#{SELECTOR_CHAR}\s]*/
14     # 1 // comment
15     COMMENT_RE                  = %r((?:^[ \t]*//[^\n]*\n))
16     # 1 {, except when part of @{ and #{
17     RULE_OPEN_BRACE_RE          = /(?<![@#\$])\{/
18     # same as the one above, but in reverse (on a reversed string)
19     RULE_OPEN_BRACE_RE_REVERSE  = /\{(?![@#\$])/
20     # match closed brace, except when \w precedes }, or when }[.'"]. a heurestic to exclude } that are not selector body close }
21     RULE_CLOSE_BRACE_RE         = /(?<!\w)\}(?![.'"])/
22     RULE_CLOSE_BRACE_RE_REVERSE = /(?<![.'"])\}(?!\w)/
23     # match any brace that opens or closes a properties body
24     BRACE_RE                    = /#{RULE_OPEN_BRACE_RE}|#{RULE_CLOSE_BRACE_RE}/m
25     BRACE_RE_REVERSE            = /#{RULE_OPEN_BRACE_RE_REVERSE}|#{RULE_CLOSE_BRACE_RE_REVERSE}/m
26     # valid characters in mixin definitions
27     SCSS_MIXIN_DEF_ARGS_RE      = /[\w\-,\s$:#%()]*/
28     LESS_MIXIN_DEF_ARGS_RE      = /[\w\-,;.\s@:#%()]*/
29
30     # These mixins will get vararg definitions in SCSS (not supported by LESS):
31     NESTED_MIXINS               = {'#gradient' => 'gradient'}
32
33     # These mixins will get vararg definitions in SCSS (not supported by LESS):
34     VARARG_MIXINS               = %w(
35       scale transition transition-duration transition-property transition-transform box-shadow
36     )
37
38     # A list of classes that will be extracted into mixins
39     # Only the top-level selectors of form .CLASS { ... } are extracted. CLASS must not be used in any other rule definition.
40     # This is a work-around for libsass @extend issues
41     CLASSES_TO_MIXINS = %w(
42       list-unstyled form-inline
43     )
44
45     # Convert a snippet of bootstrap LESS to Scss
46     def convert_less(less)
47       less = convert_to_scss(less)
48       less = yield(less) if block_given?
49       less
50     end
51
52     def shared_mixins
53       @shared_mixins ||= begin
54         log_status '  Reading shared mixins from mixins.less'
55         CLASSES_TO_MIXINS + read_mixins(read_files('less', bootstrap_less_files.grep(/mixins\//)).values.join("\n"),
56                                         nested: NESTED_MIXINS)
57       end
58     end
59
60     def process_stylesheet_assets
61       log_status 'Processing stylesheets...'
62       files   = read_files('less', bootstrap_less_files)
63       save_to = @save_to[:scss]
64
65       log_status '  Converting LESS files to Scss:'
66       files.each do |name, file|
67         log_processing name
68         # apply common conversions
69         file = convert_less(file)
70         if name.start_with?('mixins/')
71           file = varargify_mixin_definitions(file, *VARARG_MIXINS)
72           %w(responsive-(in)?visibility input-size text-emphasis-variant bg-variant).each do |mixin|
73             file = parameterize_mixin_parent_selector file, mixin if file =~ /#{mixin}/
74           end
75           NESTED_MIXINS.each do |sel, name|
76             file = flatten_mixins(file, sel, name) if /#{Regexp.escape(sel)}/ =~ file
77           end
78           file = replace_all file, /(?<=[.-])\$state/, '#{$state}' if file =~ /[.-]\$state/
79         end
80         case name
81           when 'mixins/buttons.less'
82             file = replace_all file, /(\.dropdown-toggle)&/, '&\1'
83           when 'mixins/list-group.less'
84             file = replace_rules(file, '  .list-group-item-') { |rule| extract_nested_rule rule, 'a&' }
85           when 'mixins/gradients.less'
86             file = replace_ms_filters(file)
87             file = deinterpolate_vararg_mixins(file)
88           when 'mixins/vendor-prefixes.less'
89             # remove second scale mixins as this is handled via vararg in the first one
90             file = replace_rules(file, Regexp.escape('@mixin scale($ratioX, $ratioY...)')) { '' }
91           when 'mixins/grid-framework.less'
92             file = convert_grid_mixins file
93           when 'component-animations.less'
94             file = extract_nested_rule file, "#{SELECTOR_RE}&\\.in"
95           when 'responsive-utilities.less'
96             file = apply_mixin_parent_selector file, '\.(?:visible|hidden)'
97           when 'variables.less'
98             file = insert_default_vars(file)
99             file = ['$bootstrap-sass-asset-helper: false !default;', file].join("\n")
100             file = replace_all file, %r{(\$icon-font-path): \s*"(.*)" (!default);}, "\n" + unindent(<<-SCSS, 14)
101               // [converter] If $bootstrap-sass-asset-helper if used, provide path relative to the assets load path.
102               // [converter] This is because some asset helpers, such as Sprockets, do not work with file-relative paths.
103               \\1: if($bootstrap-sass-asset-helper, "bootstrap/", "\\2bootstrap/") \\3;
104             SCSS
105           when 'breadcrumbs.less'
106             file = replace_all file, /(.*)(\\00a0)/, unindent(<<-SCSS, 8) + "\\1\#{$nbsp}"
107               // [converter] Workaround for https://github.com/sass/libsass/issues/1115
108               $nbsp: "\\2";
109             SCSS
110           when 'close.less'
111             # extract .close { button& {...} } rule
112             file = extract_nested_rule file, 'button&'
113           when 'dropdowns.less'
114             file = replace_all file, /@extend \.dropdown-menu-right;/, 'right: 0; left: auto;'
115             file = replace_all file, /@extend \.dropdown-menu-left;/, 'left: 0; right: auto;'
116           when 'forms.less'
117             file = extract_nested_rule file, 'textarea&'
118             file = apply_mixin_parent_selector(file, '\.input-(?:sm|lg)')
119             file = replace_rules file, /\.form-group-(?:sm|lg)/ do |rule|
120               apply_mixin_parent_selector rule, '.form-control'
121             end
122           when 'navbar.less'
123             file = replace_all file, /(\s*)\.navbar-(right|left)\s*\{\s*@extend\s*\.pull-(right|left);\s*/, "\\1.navbar-\\2 {\\1  float: \\2 !important;\\1"
124           when 'tables.less'
125             file = replace_all file, /(@include\s*table-row-variant\()(\w+)/, "\\1'\\2'"
126           when 'thumbnails.less', 'labels.less', 'badges.less', 'buttons.less'
127             file = extract_nested_rule file, 'a&'
128           when 'glyphicons.less'
129             file = replace_rules(file, /\s*@font-face/) { |rule|
130               rule = replace_all rule, /(\$icon-font(?:-\w+)+)/, '#{\1}'
131               replace_asset_url rule, :font
132             }
133           when 'type.less'
134             file = apply_mixin_parent_selector(file, '\.(text|bg)-(success|primary|info|warning|danger)')
135             # .bg-primary will not get patched automatically as it includes an additional rule. fudge for now
136             file = replace_all(file, "  @include bg-variant($brand-primary);\n}", "}\n@include bg-variant('.bg-primary', $brand-primary);")
137         end
138
139         path = File.join save_to, name.sub(/\.less$/, '.scss')
140         path = File.join File.dirname(path), '_' + File.basename(path)
141         save_file(path, file)
142         log_processed File.basename(path)
143       end
144
145       # move bootstrap/_bootstrap.scss to _bootstrap.scss adjusting import paths
146       main_from = "#{save_to}/_bootstrap.scss"
147       main_to   = File.expand_path("#{save_to}/../_bootstrap.scss")
148       save_file main_to, File.read(main_from).gsub(/ "/, ' "bootstrap/')
149       File.delete(main_from)
150
151       # generate variables template
152       save_file 'templates/project/_bootstrap-variables.sass',
153                 "// Override Bootstrap variables here (defaults from bootstrap-sass v#{Bootstrap::VERSION}):\n\n" +
154                     File.read("#{save_to}/_variables.scss").lines[1..-1].join.gsub(/^(?=\$)/, '// ').gsub(/ !default;/, '')
155     end
156
157     def bootstrap_less_files
158       @bootstrap_less_files ||= get_paths_by_type('less', /\.less$/)
159     end
160
161     # apply general less to scss conversion
162     def convert_to_scss(file)
163       # get local mixin names before converting the definitions
164       mixins = shared_mixins + read_mixins(file)
165       file   = replace_vars(file)
166       file   = replace_mixin_definitions(file)
167       file   = replace_mixins(file, mixins)
168       file   = extract_mixins_from_selectors(file, CLASSES_TO_MIXINS.inject({}) { |h, cl| h.update(".#{cl}" => cl) })
169       file   = replace_spin(file)
170       file   = replace_fadein(file)
171       file   = replace_image_urls(file)
172       file   = replace_escaping(file)
173       file   = convert_less_ampersand(file)
174       file   = deinterpolate_vararg_mixins(file)
175       file   = replace_calculation_semantics(file)
176       file   = replace_file_imports(file)
177       file   = wrap_at_groups_with_at_root(file)
178       file
179     end
180
181     def wrap_at_groups_with_at_root(file)
182       replace_rules(file, /@(?:font-face|-ms-viewport)/) { |rule, _pos|
183         %Q(@at-root {\n#{indent rule, 2}\n})
184       }
185     end
186
187     def sass_fn_exists(fn)
188       %Q{(#{fn}("") != unquote('#{fn}("")'))}
189     end
190
191     def replace_asset_url(rule, type)
192       replace_all rule, /url\((.*?)\)/, "url(if($bootstrap-sass-asset-helper, twbs-#{type}-path(\\1), \\1))"
193     end
194
195     # convert recursively evaluated selector $list to @for loop
196     def mixin_all_grid_columns(css, selector: raise('pass class'), from: 1, to: raise('pass to'))
197       mxn_def = css.each_line.first.strip
198       # inject local variables as default arguments
199       # this is to avoid overwriting outer variables with the same name with Sass <= 3.3
200       # see also: https://github.com/twbs/bootstrap-sass/issues/636
201       locals = <<-SASS.strip
202         $i: #{from}, $list: "#{selector}"
203       SASS
204       mxn_def.sub!(/(\(?)(\)\s*\{)/) {  "#{$1}#{', ' if $1.empty?}#{locals}#{$2}" }
205       step_body = (css =~ /\$list \{\n(.*?)\n[ ]*\}/m) && $1
206 <<-SASS
207 // [converter] This is defined recursively in LESS, but Sass supports real loops
208 #{mxn_def}
209   @for $i from (#{from} + 1) through #{to} {
210     $list: "\#{$list}, #{selector}";
211   }
212   \#{$list} {
213 #{unindent step_body, 2}
214   }
215 }
216 SASS
217     end
218
219     # convert grid mixins LESS when => Sass @if
220     def convert_grid_mixins(file)
221       file = replace_rules file, /@mixin make-grid-columns/, comments: false do |css, pos|
222         mixin_all_grid_columns css, selector: '.col-xs-#{$i}, .col-sm-#{$i}, .col-md-#{$i}, .col-lg-#{$i}', to: '$grid-columns'
223       end
224       file = replace_rules file, /@mixin float-grid-columns/, comments: false do |css, pos|
225         mixin_all_grid_columns css, selector: '.col-#{$class}-#{$i}', to: '$grid-columns'
226       end
227       file = replace_rules file, /@mixin calc-grid-column/ do |css|
228         css = indent css.gsub(/.*when (.*?) {/, '@if \1 {').gsub(/(\$[\w-]+)\s+=\s+(\w+)/, '\1 == \2').gsub(/(?<=-)(\$[a-z]+)/, '#{\1}')
229         if css =~ /== width/
230           css = "@mixin calc-grid-column($index, $class, $type) {\n#{css}"
231         elsif css =~ /== offset/
232           css += "\n}"
233         end
234         css
235       end
236       file = replace_rules file, /@mixin loop-grid-columns/ do |css|
237         unindent <<-SASS, 8
238         // [converter] This is defined recursively in LESS, but Sass supports real loops
239         @mixin loop-grid-columns($columns, $class, $type) {
240           @for $i from 0 through $columns {
241             @include calc-grid-column($i, $class, $type);
242           }
243         }
244         SASS
245       end
246       file
247     end
248
249
250     # We need to keep a list of shared mixin names in order to convert the includes correctly
251     # Before doing any processing we read shared mixins from a file
252     # If a mixin is nested, it gets prefixed in the list (e.g. #gradient > .horizontal to 'gradient-horizontal')
253     def read_mixins(mixins_file, nested: {})
254       mixins = get_mixin_names(mixins_file, silent: true)
255       nested.each do |selector, prefix|
256         # we use replace_rules without replacing anything just to use the parsing algorithm
257         replace_rules(mixins_file, selector) { |rule|
258           mixins += get_mixin_names(unindent(unwrap_rule_block(rule)), silent: true).map { |name| "#{prefix}-#{name}" }
259           rule
260         }
261       end
262       mixins.uniq!
263       mixins.sort!
264       log_file_info "mixins: #{mixins * ', '}" unless mixins.empty?
265       mixins
266     end
267
268     def get_mixin_names(file, opts = {})
269       names = get_css_selectors(file).join("\n" * 2).scan(/^\.([\w-]+)\(#{LESS_MIXIN_DEF_ARGS_RE}\)(?: when.*?)?[ ]*\{/).map(&:first).uniq.sort
270       log_file_info "mixin defs: #{names * ', '}" unless opts[:silent] || names.empty?
271       names
272     end
273
274     # margin: a -b
275     # LESS: sets 2 values
276     # Sass: sets 1 value (a-b)
277     # This wraps a and -b so they evaluates to 2 values in Sass
278     def replace_calculation_semantics(file)
279       # split_prop_val.call('(@navbar-padding-vertical / 2) -@navbar-padding-horizontal')
280       # #=> ["(navbar-padding-vertical / 2)", "-navbar-padding-horizontal"]
281       split_prop_val = proc { |val|
282         s         = CharStringScanner.new(val)
283         r         = []
284         buff      = ''
285         d         = 0
286         prop_char = %r([\$\w\-/\*\+%!])
287         while (token = s.scan_next(/([\)\(]|\s+|#{prop_char}+)/))
288           buff << token
289           case token
290             when '('
291               d += 1
292             when ')'
293               d -= 1
294               if d == 0
295                 r << buff
296                 buff = ''
297               end
298             when /\s/
299               if d == 0 && !buff.strip.empty?
300                 r << buff
301                 buff = ''
302               end
303           end
304         end
305         r << buff unless buff.empty?
306         r.map(&:strip)
307       }
308
309       replace_rules file do |rule|
310         replace_properties rule do |props|
311           props.gsub /(?<!\w)([\w-]+):(.*?);/ do |m|
312             prop, vals = $1, split_prop_val.call($2)
313             next m unless vals.length >= 2 && vals.any? { |v| v =~ /^[\+\-]\$/ }
314             transformed = vals.map { |v| v.strip =~ %r(^\(.*\)$) ? v : "(#{v})" }
315             log_transform "property #{prop}: #{transformed * ' '}", from: 'wrap_calculation'
316             "#{prop}: #{transformed * ' '};"
317           end
318         end
319       end
320     end
321
322     # @import "file.less" to "#{target_path}file;"
323     def replace_file_imports(less, target_path = '')
324       less.gsub %r([@\$]import ["|']([\w\-/]+).less["|'];),
325                 %Q(@import "#{target_path}\\1";)
326     end
327
328     def replace_all(file, regex, replacement = nil, &block)
329       log_transform regex, replacement
330       new_file = file.gsub(regex, replacement, &block)
331       raise "replace_all #{regex}, #{replacement} NO MATCH" if file == new_file
332       new_file
333     end
334
335     # @mixin a() { tr& { color:white } }
336     # to:
337     # @mixin a($parent) { tr#{$parent} { color: white } }
338     def parameterize_mixin_parent_selector(file, rule_sel)
339       log_transform rule_sel
340       param = '$parent'
341       replace_rules(file, '^\s*@mixin\s*' + rule_sel) do |mxn_css|
342         mxn_css.sub! /(?=@mixin)/, "// [converter] $parent hack\n"
343         # insert param into mixin def
344         mxn_css.sub!(/(@mixin [\w-]+)\(([\$\w\-,\s]*)\)/) { "#{$1}(#{param}#{', ' if $2 && !$2.empty?}#{$2})" }
345         # wrap properties in #{$parent} { ... }
346         replace_properties(mxn_css) { |props|
347           next props if props.strip.empty?
348           spacer = ' ' * indent_width(props)
349           "#{spacer}\#{#{param}} {\n#{indent(props.sub(/\s+\z/, ''), 2)}\n#{spacer}}"
350         }
351         # change nested& rules to nested#{$parent}
352         replace_rules(mxn_css, /.*&[ ,:]/) { |rule| replace_in_selector rule, /&/, "\#{#{param}}" }
353       end
354     end
355
356     # extracts rule immediately after it's parent, and adjust the selector
357     # .x { textarea& { ... }}
358     # to:
359     # .x { ... }
360     # textarea.x { ... }
361     def extract_nested_rule(file, selector, new_selector = nil)
362       matches = []
363       # first find the rules, and remove them
364       file    = replace_rules(file, "\s*#{selector}", comments: true) { |rule, pos, css|
365         new_sel = new_selector || "#{get_selector(rule).gsub(/&/, selector_for_pos(css, pos.begin))}"
366         matches << [rule, pos, new_sel]
367         indent "// [converter] extracted #{get_selector(rule)} to #{new_sel}".tr("\n", ' ').squeeze(' '), indent_width(rule)
368       }
369       raise "extract_nested_rule: no such selector: #{selector}" if matches.empty?
370       # replace rule selector with new_selector
371       matches.each do |m|
372         m[0].sub! /(#{COMMENT_RE}*)^(\s*).*?(\s*){/m, "\\1\\2#{m[2]}\\3{"
373         log_transform selector, m[2]
374       end
375       replace_substrings_at file,
376                             matches.map { |_, pos| close_brace_pos(file, pos.begin, 1) + 1 },
377                             matches.map { |rule, _| "\n\n" + unindent(rule) }
378     end
379
380     # .visible-sm { @include responsive-visibility() }
381     # to:
382     # @include responsive-visibility('.visible-sm')
383     def apply_mixin_parent_selector(file, rule_sel)
384       log_transform rule_sel
385       replace_rules file, '\s*' + rule_sel, comments: false do |rule, rule_pos, css|
386         body = unwrap_rule_block(rule.dup).strip
387         next rule unless body =~ /^@include \w+/m || body =~ /^@media/ && body =~ /\{\s*@include/
388         rule =~ /(#{COMMENT_RE}*)([#{SELECTOR_CHAR}\s*]+?)#{RULE_OPEN_BRACE_RE}/
389         cmt, sel = $1, $2.strip
390         # take one up selector chain if this is an &. selector
391         if sel.start_with?('&')
392           parent_sel = selector_for_pos(css, rule_pos.begin)
393           sel        = parent_sel + sel[1..-1]
394         end
395         # unwrap, and replace @include
396         unindent unwrap_rule_block(rule).gsub(/(@include [\w-]+)\(?([\$\w\-,\s]*)\)?/) {
397           name, args = $1, $2
398           sel.gsub(/\s+/, ' ').split(/,\s*/ ).map { |sel_part|
399             "#{cmt}#{name}('#{sel_part}'#{', ' if args && !args.empty?}#{args})"
400           }.join(";\n")
401         }
402       end
403     end
404
405     # #gradient > { @mixin horizontal ... }
406     # to:
407     # @mixin gradient-horizontal
408     def flatten_mixins(file, container, prefix)
409       log_transform container, prefix
410       replace_rules file, Regexp.escape(container) do |mixins_css|
411         unindent unwrap_rule_block(mixins_css).gsub(/@mixin\s*([\w-]+)/, "@mixin #{prefix}-\\1")
412       end
413     end
414
415     # .btn { ... } -> @mixin btn { ... }; .btn { @include btn }
416     def extract_mixins_from_selectors(file, selectors_to_mixins)
417       selectors_to_mixins.each do |selector, mixin|
418         file = replace_rules file, Regexp.escape(selector), prefix: false do |selector_css|
419           log_transform "#{selector} { ... } -> @mixin #{mixin} { ... }; #{selector} { @include #{mixin} } ", from: 'extract_mixins_from_selectors'
420           <<-SCSS
421 // [converter] extracted from `#{selector}` for libsass compatibility
422 @mixin #{mixin} {#{unwrap_rule_block(selector_css)}
423 }
424 // [converter] extracted as `@mixin #{mixin}` for libsass compatibility
425 #{selector} {
426   @include #{mixin};
427 }
428           SCSS
429         end
430       end
431       file
432     end
433
434     # @include and @extend from LESS:
435     #  .mixin()             -> @include mixin()
436     #  #scope > .mixin()    -> @include scope-mixin()
437     #  &:extend(.mixin all) -> @include mixin()
438     def replace_mixins(less, mixin_names)
439       mixin_pattern = /(?<=^|\s)((?:[#|\.][\w-]+\s*>\s*)*)\.([\w-]+)\((.*)\)(?!\s\{)/
440
441       less = less.gsub(mixin_pattern) do |_|
442         scope, name, args = $1, $2, $3
443         scope = scope.scan(/[\w-]+/).join('-') + '-' unless scope.empty?
444         args = "(#{args.tr(';', ',')})" unless args.empty?
445         if name && mixin_names.include?("#{scope}#{name}")
446           "@include #{scope}#{name}#{args}"
447         else
448           "@extend .#{scope}#{name}"
449         end
450       end
451
452       less.gsub /&:extend\((#{SELECTOR_RE})(?: all)?\)/ do
453         selector = $1
454         selector =~ /\.([\w-]+)/
455         mixin    = $1
456         if mixin && mixin_names.include?(mixin)
457           "@include #{mixin}"
458         else
459           "@extend #{selector}"
460         end
461       end
462     end
463
464     # change Microsoft filters to Sass calling convention
465     def replace_ms_filters(file)
466       log_transform
467       file.gsub(
468           /filter: e\(%\("progid:DXImageTransform.Microsoft.gradient\(startColorstr='%d', endColorstr='%d', GradientType=(\d)\)",argb\(([\-$\w]+)\),argb\(([\-$\w]+)\)\)\);/,
469           %Q(filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='\#{ie-hex-str(\\2)}', endColorstr='\#{ie-hex-str(\\3)}', GradientType=\\1);)
470       )
471     end
472
473     # unwraps topmost rule block
474     # #sel { a: b; }
475     # to:
476     # a: b;
477     def unwrap_rule_block(css)
478       css[(css =~ RULE_OPEN_BRACE_RE) + 1..-1].sub(/\n?}\s*\z/m, '')
479     end
480
481     def replace_mixin_definitions(less)
482       less.gsub(/^(\s*)\.([\w-]+\(.*\))(\s*\{)/) { |match|
483         "#{$1}@mixin #{$2.tr(';', ',')}#{$3}".sub(/,\)/, ')')
484       }
485     end
486
487     def replace_vars(less)
488       less = less.dup
489       # skip header comment
490       less =~ %r(\A/\*(.*?)\*/)m
491       from           = $~ ? $~.to_s.length : 0
492       less[from..-1] = less[from..-1].
493           gsub(/(?!@mixin|@media|@page|@keyframes|@font-face|@-\w)@/, '$').
494           # variables that would be ignored by gsub above: e.g. @page-header-border-color
495           gsub(/@(page[\w-]+)/, '$\1')
496       less
497     end
498
499     def replace_spin(less)
500       less.gsub(/(?![\-$@.])spin(?!-)/, 'adjust-hue')
501     end
502
503     def replace_fadein(less)
504       less.gsub(/(?![\-$@.])fadein\((.*?),\s*(.*?)%\)/) { "fade_in(#{$1}, #{$2.to_i / 100.0})" }
505     end
506
507     def replace_image_urls(less)
508       less.gsub(/background-image: url\("?(.*?)"?\);/) { |s| replace_asset_url s, :image }
509     end
510
511     def replace_escaping(less)
512       less = less.gsub(/~"([^"]+)"/, '\1').gsub(/~'([^']+)'/, '\1') # Get rid of ~"" escape
513       less.gsub!(/\$\{([^}]+)\}/, '$\1') # Get rid of @{} escape
514       less.gsub!(/"([^"\n]*)(\$[\w\-]+)([^"\n]*)"/, '"\1#{\2}\3"') # interpolate variable in string, e.g. url("$file-1x") => url("#{$file-1x}")
515       less.gsub(/(\W)e\(%\("?([^"]*)"?\)\)/, '\1\2') # Get rid of e(%("")) escape
516     end
517
518     def insert_default_vars(scss)
519       log_transform
520       scss.gsub(/^(\$.+);/, '\1 !default;')
521     end
522
523     # Converts &-
524     def convert_less_ampersand(less)
525       regx = /^\.badge\s*\{[\s\/\w\(\)]+(&{1}-{1})\w.*?^}$/m
526
527       tmp = ''
528       less.scan(/^(\s*&)(-[\w\[\]]+\s*\{.+})$/) do |ampersand, css|
529         tmp << ".badge#{css}\n"
530       end
531
532       less.gsub(regx, tmp)
533     end
534
535     # unindent by n spaces
536     def unindent(txt, n = 2)
537       txt.gsub /^[ ]{#{n}}/, ''
538     end
539
540     # indent by n spaces
541     def indent(txt, n = 2)
542       spaces = ' ' * n
543       txt.gsub /^/, spaces
544     end
545
546     # get indent length from the first line of txt
547     def indent_width(txt)
548       txt.match(/\A\s*/).to_s.length
549     end
550
551     # @mixin transition($transition) {
552     # to:
553     # @mixin transition($transition...) {
554     def varargify_mixin_definitions(scss, *mixins)
555       scss = scss.dup
556       replaced = []
557       mixins.each do |mixin|
558         if scss.gsub! /(@mixin\s*#{Regexp.quote(mixin)})\((#{SCSS_MIXIN_DEF_ARGS_RE})\)/, '\1(\2...)'
559           replaced << mixin
560         end
561       end
562       log_transform *replaced unless replaced.empty?
563       scss
564     end
565
566     # @include transition(#{border-color ease-in-out .15s, box-shadow ease-in-out .15s})
567     # to
568     # @include transition(border-color ease-in-out .15s, box-shadow ease-in-out .15s)
569     def deinterpolate_vararg_mixins(scss)
570       scss = scss.dup
571       VARARG_MIXINS.each do |mixin|
572         if scss.gsub! /(@include\s*#{Regexp.quote(mixin)})\(\s*\#\{([^}]+)\}\s*\)/, '\1(\2)'
573           log_transform mixin
574         end
575       end
576       scss
577     end
578
579     # get full selector for rule_block
580     def get_selector(rule_block)
581       sel = /^\s*(#{SELECTOR_RE}?)\s*\{/.match(rule_block) && $1 && $1.strip
582       sel.sub /\s*\{\n\s.*/m, ''
583     end
584
585     # replace CSS rule blocks matching rule_prefix with yield(rule_block, rule_pos)
586     # will also include immediately preceding comments in rule_block
587     #
588     # option :comments -- include immediately preceding comments in rule_block
589     #
590     # replace_rules(".a{ \n .b{} }", '.b') { |rule, pos| ">#{rule}<"  } #=> ".a{ \n >.b{}< }"
591     def replace_rules(less, selector = SELECTOR_RE, options = {}, &block)
592       options       = {prefix: true, comments: true}.merge(options || {})
593       less          = less.dup
594       s             = CharStringScanner.new(less)
595       rule_re       = if options[:prefix]
596                         /(?:#{selector}[#{SELECTOR_CHAR})=(\s]*?#{RULE_OPEN_BRACE_RE})/
597                       else
598                         /#{selector}[\s]*#{RULE_OPEN_BRACE_RE}/
599                       end
600       rule_start_re = if options[:comments]
601                         /(?:#{COMMENT_RE}*)^#{rule_re}/
602                       else
603                         /^#{rule_re}/
604                       end
605
606       positions = []
607       while (rule_start = s.scan_next(rule_start_re))
608         pos = s.pos
609         positions << (pos - rule_start.length..close_brace_pos(less, pos - 1))
610       end
611       replace_substrings_at(less, positions, &block)
612       less
613     end
614
615     # Get a all top-level selectors (with {)
616     def get_css_selectors(css, opts = {})
617       s         = CharStringScanner.new(css)
618       selectors = []
619       while s.scan_next(RULE_OPEN_BRACE_RE)
620         brace_pos = s.pos
621         def_pos   = css_def_pos(css, brace_pos+1, -1)
622         sel       = css[def_pos.begin..brace_pos - 1].dup
623         sel.strip! if opts[:strip]
624         selectors << sel
625         sel.dup.strip
626         s.pos = close_brace_pos(css, brace_pos, 1) + 1
627       end
628       selectors
629     end
630
631     # replace in the top-level selector
632     # replace_in_selector('a {a: {a: a} } a {}', /a/, 'b') => 'b {a: {a: a} } b {}'
633     def replace_in_selector(css, pattern, sub)
634       # scan for selector positions in css
635       s        = CharStringScanner.new(css)
636       prev_pos = 0
637       sel_pos  = []
638       while (brace = s.scan_next(RULE_OPEN_BRACE_RE))
639         pos = s.pos
640         sel_pos << (prev_pos .. pos - 1)
641         s.pos    = close_brace_pos(css, s.pos - 1) + 1
642         prev_pos = pos
643       end
644       replace_substrings_at(css, sel_pos) { |s| s.gsub(pattern, sub) }
645     end
646
647
648     # replace first level properties in the css with yields
649     # replace_properties("a { color: white }") { |props| props.gsub 'white', 'red' }
650     def replace_properties(css, &block)
651       s = CharStringScanner.new(css)
652       s.skip_until /#{RULE_OPEN_BRACE_RE}\n?/
653       from = s.pos
654       m = s.scan_next(/\s*#{SELECTOR_RE}#{RULE_OPEN_BRACE_RE}/) || s.scan_next(/\s*#{RULE_CLOSE_BRACE_RE}/)
655       to = s.pos - m.length - 1
656       replace_substrings_at css, [(from .. to)], &block
657     end
658
659
660     # immediate selector of css at pos
661     def selector_for_pos(css, pos, depth = -1)
662       css[css_def_pos(css, pos, depth)].dup.strip
663     end
664
665     # get the pos of css def at pos (search backwards)
666     def css_def_pos(css, pos, depth = -1)
667       to       = open_brace_pos(css, pos, depth)
668       prev_def = to - (css[0..to].reverse.index(RULE_CLOSE_BRACE_RE_REVERSE) || to) + 1
669       from     = prev_def + 1 + (css[prev_def + 1..-1] =~ %r(^\s*[^\s/]))
670       (from..to - 1)
671     end
672
673     # next matching brace for brace at from
674     def close_brace_pos(css, from, depth = 0)
675       s = CharStringScanner.new(css[from..-1])
676       while (b = s.scan_next(BRACE_RE))
677         depth += (b == '}' ? -1 : +1)
678         break if depth.zero?
679       end
680       raise "match not found for {" unless depth.zero?
681       from + s.pos - 1
682     end
683
684     # opening brace position from +from+ (search backwards)
685     def open_brace_pos(css, from, depth = 0)
686       s = CharStringScanner.new(css[0..from].reverse)
687       while (b = s.scan_next(BRACE_RE_REVERSE))
688         depth += (b == '{' ? +1 : -1)
689         break if depth.zero?
690       end
691       raise "matching { brace not found" unless depth.zero?
692       from - s.pos + 1
693     end
694
695     # insert substitutions into text at positions (Range or Fixnum)
696     # substitutions can be passed as array or as yields from the &block called with |substring, position, text|
697     # position is a range (begin..end)
698     def replace_substrings_at(text, positions, replacements = nil, &block)
699       offset = 0
700       positions.each_with_index do |p, i|
701         p       = (p...p) if p.is_a?(Fixnum)
702         from    = p.begin + offset
703         to      = p.end + offset
704         p       = p.exclude_end? ? (from...to) : (from..to)
705         # block returns the substitution, e.g.: { |text, pos| text[pos].upcase }
706         r       = replacements ? replacements[i] : block.call(text[p], p, text)
707         text[p] = r
708         # add the change in length to offset
709         offset  += r.size - (p.end - p.begin + (p.exclude_end? ? 0 : 1))
710       end
711       text
712     end
713   end
714 end