core.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. var gonzales = require('gonzales-pe');
  2. var minimatch = require('minimatch');
  3. var vow = require('vow');
  4. var vfs = require('vow-fs');
  5. /**
  6. * @param {Array} predefinedOptions
  7. * @constructor
  8. * @name Comb
  9. */
  10. var Comb = function(predefinedOptions) {
  11. // For unambiguity, use `that` to access public methods
  12. // and `_this` to access private ones.
  13. var that = this;
  14. /**
  15. * PRIVATE AREA
  16. * Those properties and methods are made available for easy testing.
  17. */
  18. Object.defineProperty(that, '_', {
  19. enumerable: false,
  20. writable: true,
  21. configurable: true
  22. });
  23. var _this = that._ = {};
  24. /**
  25. * PRIVATE PROPERTIES
  26. */
  27. // List of handlers:
  28. _this.handlers = null;
  29. // List of supported syntaxes:
  30. _this.supportedSyntaxes = Array.prototype.slice.call(arguments, 1);
  31. // List of file paths that should be excluded from processing:
  32. _this.exclude = null;
  33. // List of configured options with values:
  34. _this.configuredOptions = null;
  35. // Whether lint mode is on:
  36. _this.lint = null;
  37. // Whether verbose mode is on:
  38. _this.verbose = null;
  39. // Map of used options:
  40. _this.options = {};
  41. // List of option names in exact order they should be processed:
  42. _this.optionsOrder = [];
  43. _this.unmetOptions = {};
  44. /**
  45. * PRIVATE METHODS
  46. * - addHandler;
  47. * - lintTree;
  48. * - processTree;
  49. * - setValue;
  50. * - shouldProcess;
  51. * - shouldProcessFile;
  52. * - updateOptionOrder;
  53. * - usePredefinedOptions;
  54. */
  55. /**
  56. * Adds an option to list of configured options.
  57. *
  58. * @param {Object} option Option's object
  59. * @param {Object} value Value that should be set for the option
  60. * @returns {Object} Object with option's name, value and link to
  61. * `process()` method
  62. */
  63. _this.addHandler = function addHandler(option, value) {
  64. value = option.setValue ?
  65. option.setValue(value) :
  66. _this.setValue(option.accepts, value);
  67. _this.handlers.push(option);
  68. _this.configuredOptions[option.name] = value;
  69. };
  70. /**
  71. * Processes stylesheet tree node.
  72. *
  73. * @param {Object} tree Parsed tree
  74. * @returns {Array} List of found errors
  75. */
  76. _this.lintTree = function lintTree(tree) {
  77. var _handlers;
  78. _handlers = _this.handlers.filter(function(handler) {
  79. var syntax = that.getSyntax();
  80. return handler.syntax.indexOf(syntax) > -1 &&
  81. typeof handler.lint === 'function';
  82. }).map(function(handler) {
  83. return handler.lint.bind(that);
  84. });
  85. // We walk across complete tree for each handler,
  86. // because we need strictly maintain order in which handlers work,
  87. // despite fact that handlers work on different level of the tree.
  88. var errors = [];
  89. _handlers.forEach(function(handler) {
  90. tree.map(function(node) {
  91. var error = handler(node);
  92. if (!error) return;
  93. if (Array.isArray(error)) {
  94. errors = errors.concat(error);
  95. } else {
  96. errors.push(error);
  97. }
  98. });
  99. });
  100. return errors;
  101. };
  102. /**
  103. * Processes stylesheet tree node.
  104. *
  105. * @param {Array} tree Parsed tree
  106. * @returns {Array} Modified tree
  107. */
  108. _this.processTree = function processTree(tree) {
  109. var _handlers;
  110. _handlers = _this.handlers.filter(function(handler) {
  111. var syntax = that.getSyntax();
  112. return handler.syntax.indexOf(syntax) > -1;
  113. }).map(function(handler) {
  114. return handler.process.bind(that);
  115. });
  116. // We walk across complete tree for each handler,
  117. // because we need strictly maintain order in which handlers work,
  118. // despite fact that handlers work on different level of the tree.
  119. _handlers.forEach(function(handler) {
  120. tree.map(handler);
  121. });
  122. return tree;
  123. };
  124. /**
  125. * Processes value and checks if it is acceptable by the option.
  126. *
  127. * @param {Object} acceptableValues Map of value types that are acceptable
  128. * by option. If `string` property is present, its value is a regular
  129. * expression that is used to validate value passed to the function.
  130. * @param {Object|undefined} value
  131. * @returns {Boolean|String} Valid option's value
  132. */
  133. _this.setValue = function setValue(acceptableValues, value) {
  134. if (!acceptableValues) throw new Error('Option\'s module must either' +
  135. ' implement `setValue()` method or provide `accepts` object' +
  136. ' with acceptable values.');
  137. var valueType = typeof value;
  138. var pattern = acceptableValues[valueType];
  139. if (!pattern) throw new Error('The option does not accept values of type "' +
  140. valueType + '".\nValue\'s type must be one the following: ' +
  141. Object.keys(acceptableValues).join(', ') + '.');
  142. switch (valueType) {
  143. case 'boolean':
  144. if (pattern.indexOf(value) < 0) throw new Error(' Value must be ' +
  145. 'one of the following: ' + pattern.join(', ') + '.');
  146. return value;
  147. case 'number':
  148. if (value !== parseInt(value)) throw new Error('Value must be an integer.');
  149. return new Array(value + 1).join(' ');
  150. case 'string':
  151. if (!value.match(pattern)) throw new Error('Value must match pattern ' +
  152. pattern + '.');
  153. return value;
  154. default:
  155. throw new Error('If you see this message and you are not' +
  156. ' a developer adding a new option, please open an issue here:' +
  157. ' https://github.com/csscomb/csscomb.js/issues/new' +
  158. '\nFor option to accept values of type "' + valueType +
  159. '" you need to implement custom `setValue()` method. See' +
  160. ' `lib/options/sort-order.js` for example.');
  161. }
  162. };
  163. /**
  164. * Checks if path is present in `exclude` list.
  165. *
  166. * @param {String} path
  167. * @returns {Boolean} False if specified path is present in `exclude` list.
  168. * Otherwise returns true.
  169. */
  170. _this.shouldProcess = function shouldProcess(path) {
  171. path = path.replace(/^\.\//, '');
  172. for (var i = _this.exclude.length; i--;) {
  173. if (_this.exclude[i].match(path)) return false;
  174. }
  175. return true;
  176. };
  177. /**
  178. * Checks if specified path is not present in `exclude` list and it has one of
  179. * acceptable extensions.
  180. *
  181. * @param {String} path
  182. * @returns {Boolean} False if the path either has unacceptable extension or
  183. * is present in `exclude` list. True if everything is ok.
  184. */
  185. _this.shouldProcessFile = function shouldProcessFile(path) {
  186. // Get file's extension:
  187. var syntax = path.split('.').pop();
  188. // Check if syntax is supported. If not, ignore the file:
  189. if (_this.supportedSyntaxes.indexOf(syntax) < 0) {
  190. return false;
  191. }
  192. return _this.shouldProcess(path);
  193. };
  194. /**
  195. * @param {Object} option
  196. */
  197. _this.updateOptionOrder = function updateOptionOrder(option) {
  198. var name = option.name;
  199. var runBefore = option.runBefore;
  200. var runBeforeIndex;
  201. _this.options[name] = option;
  202. if (runBefore) {
  203. runBeforeIndex = _this.optionsOrder.indexOf(runBefore);
  204. if (runBeforeIndex > -1) {
  205. _this.optionsOrder.splice(runBeforeIndex, 0, name);
  206. } else {
  207. _this.optionsOrder.push(name);
  208. if (!_this.unmetOptions[runBefore]) _this.unmetOptions[runBefore] = [];
  209. _this.unmetOptions[runBefore].push(name);
  210. }
  211. } else {
  212. _this.optionsOrder.push(name);
  213. }
  214. var unmet = _this.unmetOptions[name];
  215. if (unmet) {
  216. unmet.forEach(function(name) {
  217. var i = _this.optionsOrder.indexOf(name);
  218. _this.optionsOrder.splice(i, 1);
  219. _this.optionsOrder.splice( -1, 0, name);
  220. });
  221. }
  222. };
  223. _this.usePredefinedOptions = function usePredefinedOptions() {
  224. if (!predefinedOptions) return;
  225. predefinedOptions.forEach(function(option) {
  226. that.use(option);
  227. });
  228. };
  229. /**
  230. * PUBLIC INSTANCE METHODS
  231. * Methods that depend on certain instance variables, e.g. configuration:
  232. * - configure;
  233. * - getOptionsOrder;
  234. * - getSyntax;
  235. * - getValue;
  236. * - lintFile;
  237. * - lintDirectory;
  238. * - lintPath;
  239. * - lintString;
  240. * - processFile;
  241. * - processDirectory;
  242. * - processPath;
  243. * - processString;
  244. * - use;
  245. */
  246. /**
  247. * Loads configuration from JSON.
  248. * Activates and configures required options.
  249. *
  250. * @param {Object} config
  251. * @returns {Object} Comb's object (that makes the method chainable).
  252. */
  253. that.configure = function configure(config) {
  254. _this.handlers = [];
  255. _this.configuredOptions = {};
  256. _this.verbose = config.verbose;
  257. _this.lint = config.lint;
  258. _this.exclude = (config.exclude || []).map(function(pattern) {
  259. return new minimatch.Minimatch(pattern);
  260. });
  261. _this.optionsOrder.forEach(function(optionName) {
  262. if (config[optionName] === undefined) return;
  263. try {
  264. _this.addHandler(_this.options[optionName], config[optionName]);
  265. } catch (e) {
  266. // Show warnings about illegal config values only in verbose mode:
  267. if (_this.verbose) {
  268. console.warn('\nFailed to configure "%s" option:\n%s',
  269. optionName, e.message);
  270. }
  271. }
  272. });
  273. return that;
  274. };
  275. that.getOptionsOrder = function getOptionsOrder() {
  276. return _this.optionsOrder.slice();
  277. };
  278. that.getSyntax = function getSyntax() {
  279. return that.syntax;
  280. };
  281. /**
  282. * Gets option's value.
  283. *
  284. * @param {String} optionName
  285. * @returns {String|Boolean|undefined}
  286. */
  287. that.getValue = function getValue(optionName) {
  288. return _this.configuredOptions[optionName];
  289. };
  290. /**
  291. * @param {String} path
  292. * @returns {Promise}
  293. */
  294. that.lintDirectory = function lintDirectory(path) {
  295. return vfs.listDir(path).then(function(filenames) {
  296. return vow.all(filenames.map(function(filename) {
  297. var fullname = path + '/' + filename;
  298. return vfs.stat(fullname).then(function(stat) {
  299. if (stat.isDirectory()) {
  300. return _this.shouldProcess(fullname) && that.lintDirectory(fullname);
  301. } else {
  302. return that.lintFile(fullname);
  303. }
  304. });
  305. })).then(function(results) {
  306. return [].concat.apply([], results);
  307. });
  308. });
  309. };
  310. /**
  311. * @param {String} path
  312. * @returns {Promise}
  313. */
  314. that.lintFile = function lintFile(path) {
  315. if (!_this.shouldProcessFile(path)) return;
  316. return vfs.read(path, 'utf8').then(function(data) {
  317. var syntax = path.split('.').pop();
  318. return that.lintString(data, { syntax: syntax, filename: path });
  319. });
  320. };
  321. /**
  322. * @param {String} path
  323. * @returns {Promise}
  324. */
  325. that.lintPath = function lintPath(path) {
  326. path = path.replace(/\/$/, '');
  327. return vfs.exists(path).then(function(exists) {
  328. if (!exists) {
  329. console.warn('Path ' + path + ' was not found.');
  330. return;
  331. }
  332. return vfs.stat(path).then(function(stat) {
  333. if (stat.isDirectory()) {
  334. return that.lintDirectory(path);
  335. } else {
  336. return that.lintFile(path);
  337. }
  338. });
  339. });
  340. };
  341. /**
  342. * @param {String} text
  343. * @param {{context: String, filename: String, syntax: String}} options
  344. * @returns {Array} List of found errors
  345. */
  346. that.lintString = function lintString(text, options) {
  347. var syntax = options && options.syntax;
  348. var filename = options && options.filename;
  349. var context = options && options.context;
  350. var tree;
  351. if (!text) return [];
  352. if (!syntax) syntax = 'css';
  353. that.syntax = syntax;
  354. try {
  355. tree = gonzales.parse(text, { syntax: syntax, rule: context });
  356. } catch (e) {
  357. var version = require('../package.json').version;
  358. var message = filename ? [filename] : [];
  359. message.push(e.message);
  360. message.push('CSScomb Core version: ' + version);
  361. e.stack = e.message = message.join('\n');
  362. throw e;
  363. }
  364. var errors = _this.lintTree(tree);
  365. if (filename) {
  366. errors.map(function(error) {
  367. return error.filename = filename;
  368. })
  369. }
  370. return errors;
  371. };
  372. /**
  373. * Processes directory recursively.
  374. *
  375. * @param {String} path
  376. * @returns {Promise}
  377. */
  378. that.processDirectory = function processDirectory(path) {
  379. return vfs.listDir(path).then(function(filenames) {
  380. return vow.all(filenames.map(function(filename) {
  381. var fullname = path + '/' + filename;
  382. return vfs.stat(fullname).then(function(stat) {
  383. if (stat.isDirectory()) {
  384. return _this.shouldProcess(fullname) && that.processDirectory(fullname);
  385. } else {
  386. return that.processFile(fullname);
  387. }
  388. });
  389. })).then(function(results) {
  390. return [].concat.apply([], results);
  391. });
  392. });
  393. };
  394. /**
  395. * Processes single file.
  396. *
  397. * @param {String} path
  398. * @returns {Promise}
  399. */
  400. that.processFile = function processFile(path) {
  401. if (!_this.shouldProcessFile(path)) return;
  402. return vfs.read(path, 'utf8').then(function(data) {
  403. var syntax = path.split('.').pop();
  404. var processedData = that.processString(data, { syntax: syntax, filename: path });
  405. var isChanged = data !== processedData;
  406. var tick = isChanged ? (_this.lint ? '!' : '✓') : ' ';
  407. var output = function() {
  408. if (_this.verbose) console.log(tick, path);
  409. return isChanged ? 1 : 0;
  410. };
  411. if (!isChanged || _this.lint) {
  412. return output();
  413. } else {
  414. return vfs.write(path, processedData, 'utf8').then(output);
  415. }
  416. });
  417. };
  418. /**
  419. * Processes directory or file.
  420. *
  421. * @param {String} path
  422. * @returns {Promise}
  423. */
  424. that.processPath = function processPath(path) {
  425. path = path.replace(/\/$/, '');
  426. return vfs.exists(path).then(function(exists) {
  427. if (!exists) {
  428. console.warn('Path ' + path + ' was not found.');
  429. return;
  430. }
  431. return vfs.stat(path).then(function(stat) {
  432. if (stat.isDirectory()) {
  433. return that.processDirectory(path);
  434. } else {
  435. return that.processFile(path);
  436. }
  437. });
  438. });
  439. };
  440. /**
  441. * Processes a string.
  442. *
  443. * @param {String} text
  444. * @param {{context: String, filename: String, syntax: String}} options
  445. * @returns {String} Processed string
  446. */
  447. that.processString = function processString(text, options) {
  448. var syntax = options && options.syntax;
  449. var filename = options && options.filename || '';
  450. var context = options && options.context;
  451. var tree;
  452. if (!text) return text;
  453. // TODO: Parse different syntaxes
  454. syntax = syntax || 'css';
  455. that.syntax = syntax || 'css';
  456. try {
  457. tree = gonzales.parse(text, { syntax: syntax, rule: context });
  458. } catch (e) {
  459. var version = require('../package.json').version;
  460. var message = [filename,
  461. e.message,
  462. 'CSScomb Core version: ' + version];
  463. e.stack = e.message = message.join('\n');
  464. throw e;
  465. }
  466. tree = _this.processTree(tree);
  467. return tree.toCSS(syntax);
  468. };
  469. /**
  470. *
  471. * @param {Object} option
  472. * @returns {Object} Comb's object
  473. */
  474. that.use = function use(option) {
  475. var name;
  476. if (typeof option !== 'object') {
  477. throw new Error('Can\'t use option because it is not an object');
  478. }
  479. name = option.name;
  480. if (typeof name !== 'string' || !name) {
  481. throw new Error('Can\'t use option because it has invalid name: ' +
  482. name);
  483. }
  484. if (typeof option.accepts !== 'object' &&
  485. typeof option.setValue !== 'function') {
  486. throw new Error('Can\'t use option "' + name + '"');
  487. }
  488. if (typeof option.process !== 'function') {
  489. throw new Error('Can\'t use option "' + name + '"');
  490. }
  491. _this.updateOptionOrder(option);
  492. return that;
  493. };
  494. _this.usePredefinedOptions();
  495. };
  496. module.exports = Comb;