PageRenderTime 62ms CodeModel.GetById 28ms RepoModel.GetById 1ms app.codeStats 0ms

/tasks/converter/less_conversion.rb

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