linter-manager.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. /* global $ $create */// dom.js
  2. /* global chromeSync */// storage-util.js
  3. /* global clipString */// util.js
  4. /* global createWorker */// worker-util.js
  5. /* global editor */
  6. /* global prefs */
  7. 'use strict';
  8. //#region linterMan
  9. const linterMan = (() => {
  10. const cms = new Map();
  11. const linters = [];
  12. const lintingUpdatedListeners = [];
  13. const unhookListeners = [];
  14. return {
  15. /** @type {EditorWorker} */
  16. worker: createWorker({url: '/edit/editor-worker'}),
  17. disableForEditor(cm) {
  18. cm.setOption('lint', false);
  19. cms.delete(cm);
  20. for (const cb of unhookListeners) {
  21. cb(cm);
  22. }
  23. },
  24. /**
  25. * @param {Object} cm
  26. * @param {string} [code] - to be used to avoid slowdowns when creating a lot of cms.
  27. * Enables lint option only if there are problems, thus avoiding a _very_ costly layout
  28. * update when lint gutter is added to a lot of editors simultaneously.
  29. */
  30. enableForEditor(cm, code) {
  31. if (cms.has(cm)) return;
  32. cms.set(cm, null);
  33. if (code) {
  34. enableOnProblems(cm, code);
  35. } else {
  36. cm.setOption('lint', {getAnnotations, onUpdateLinting});
  37. }
  38. },
  39. onLintingUpdated(fn) {
  40. lintingUpdatedListeners.push(fn);
  41. },
  42. onUnhook(fn) {
  43. unhookListeners.push(fn);
  44. },
  45. register(fn) {
  46. linters.push(fn);
  47. },
  48. run() {
  49. for (const cm of cms.keys()) {
  50. cm.performLint();
  51. }
  52. },
  53. };
  54. async function enableOnProblems(cm, code) {
  55. const results = await getAnnotations(code, {}, cm);
  56. if (results.length || cm.display.renderedView) {
  57. cms.set(cm, results);
  58. cm.setOption('lint', {getAnnotations: getCachedAnnotations, onUpdateLinting});
  59. } else {
  60. cms.delete(cm);
  61. }
  62. }
  63. async function getAnnotations(...args) {
  64. const results = await Promise.all(linters.map(fn => fn(...args)));
  65. return [].concat(...results.filter(Boolean));
  66. }
  67. function getCachedAnnotations(code, opt, cm) {
  68. const results = cms.get(cm);
  69. cms.set(cm, null);
  70. cm.state.lint.options.getAnnotations = getAnnotations;
  71. return results;
  72. }
  73. function onUpdateLinting(...args) {
  74. for (const fn of lintingUpdatedListeners) {
  75. fn(...args);
  76. }
  77. }
  78. })();
  79. //#endregion
  80. //#region DEFAULTS
  81. linterMan.DEFAULTS = {
  82. stylelint: {
  83. rules: {
  84. 'at-rule-no-unknown': [true, {
  85. 'ignoreAtRules': ['extend', 'extends', 'css', 'block'],
  86. 'severity': 'warning',
  87. }],
  88. 'block-no-empty': [true, {severity: 'warning'}],
  89. 'color-no-invalid-hex': [true, {severity: 'warning'}],
  90. 'declaration-block-no-duplicate-properties': [true, {
  91. 'ignore': ['consecutive-duplicates-with-different-values'],
  92. 'severity': 'warning',
  93. }],
  94. 'declaration-block-no-shorthand-property-overrides': [true, {severity: 'warning'}],
  95. 'font-family-no-duplicate-names': [true, {severity: 'warning'}],
  96. 'function-calc-no-unspaced-operator': [true, {severity: 'warning'}],
  97. 'function-linear-gradient-no-nonstandard-direction': [true, {severity: 'warning'}],
  98. 'keyframe-declaration-no-important': [true, {severity: 'warning'}],
  99. 'media-feature-name-no-unknown': [true, {severity: 'warning'}],
  100. 'no-empty-source': false,
  101. 'no-extra-semicolons': [true, {severity: 'warning'}],
  102. 'no-invalid-double-slash-comments': [true, {severity: 'warning'}],
  103. 'property-no-unknown': [true, {severity: 'warning'}],
  104. 'selector-pseudo-class-no-unknown': [true, {severity: 'warning'}],
  105. 'selector-pseudo-element-no-unknown': [true, {severity: 'warning'}],
  106. 'selector-type-no-unknown': false, // for scss/less/stylus-lang
  107. 'string-no-newline': [true, {severity: 'warning'}],
  108. 'unit-no-unknown': [true, {severity: 'warning'}],
  109. 'comment-no-empty': false,
  110. 'declaration-block-no-redundant-longhand-properties': false,
  111. 'shorthand-property-no-redundant-values': false,
  112. },
  113. },
  114. csslint: {
  115. 'display-property-grouping': 1,
  116. 'duplicate-properties': 1,
  117. 'empty-rules': 1,
  118. 'errors': 1,
  119. 'globals-in-document': 1,
  120. 'known-properties': 1,
  121. 'known-pseudos': 1,
  122. 'selector-newline': 1,
  123. 'shorthand-overrides': 1,
  124. 'simple-not': 1,
  125. 'warnings': 1,
  126. // disabled
  127. 'adjoining-classes': 0,
  128. 'box-model': 0,
  129. 'box-sizing': 0,
  130. 'bulletproof-font-face': 0,
  131. 'compatible-vendor-prefixes': 0,
  132. 'duplicate-background-images': 0,
  133. 'fallback-colors': 0,
  134. 'floats': 0,
  135. 'font-faces': 0,
  136. 'font-sizes': 0,
  137. 'gradients': 0,
  138. 'ids': 0,
  139. 'import': 0,
  140. 'import-ie-limit': 0,
  141. 'important': 0,
  142. 'order-alphabetical': 0,
  143. 'outline-none': 0,
  144. 'overqualified-elements': 0,
  145. 'qualified-headings': 0,
  146. 'regex-selectors': 0,
  147. 'rules-count': 0,
  148. 'selector-max': 0,
  149. 'selector-max-approaching': 0,
  150. 'shorthand': 0,
  151. 'star-property-hack': 0,
  152. 'text-indent': 0,
  153. 'underscore-property-hack': 0,
  154. 'unique-headings': 0,
  155. 'universal-selector': 0,
  156. 'unqualified-attributes': 0,
  157. 'vendor-prefix': 0,
  158. 'zero-units': 0,
  159. },
  160. };
  161. //#endregion
  162. //#region ENGINES
  163. (() => {
  164. const configs = new Map();
  165. const {DEFAULTS, worker} = linterMan;
  166. const ENGINES = {
  167. csslint: {
  168. validMode: mode => mode === 'css',
  169. getConfig: config => Object.assign({}, DEFAULTS.csslint, config),
  170. async lint(text, config) {
  171. const results = await worker.csslint(text, config);
  172. return results
  173. .map(({line, col: ch, message, rule, type: severity}) => line && {
  174. message,
  175. from: {line: line - 1, ch: ch - 1},
  176. to: {line: line - 1, ch},
  177. rule: rule.id,
  178. severity,
  179. })
  180. .filter(Boolean);
  181. },
  182. },
  183. stylelint: {
  184. validMode: () => true,
  185. getConfig: config => ({
  186. rules: Object.assign({}, DEFAULTS.stylelint.rules, config && config.rules),
  187. }),
  188. async lint(code, config, mode) {
  189. const isLess = mode === 'text/x-less';
  190. const isStylus = mode === 'stylus';
  191. const syntax = isLess ? 'less' : isStylus ? 'sugarss' : 'css';
  192. const raw = await worker.stylelint({code, config, syntax});
  193. if (!raw) {
  194. return [];
  195. }
  196. // Hiding the errors about "//" comments as we're preprocessing only when saving/applying
  197. // and we can't just pre-remove the comments since "//" may be inside a string token
  198. const slashCommentAllowed = isLess || isStylus;
  199. const res = [];
  200. for (const w of raw.warnings) {
  201. const msg = w.text.match(/^(?:Unexpected\s+)?(.*?)\s*\([^()]+\)$|$/)[1] || w.text;
  202. if (!slashCommentAllowed || !(
  203. w.rule === 'no-invalid-double-slash-comments' ||
  204. w.rule === 'property-no-unknown' && msg.includes('"//"')
  205. )) {
  206. res.push({
  207. from: {line: w.line - 1, ch: w.column - 1},
  208. to: {line: w.line - 1, ch: w.column},
  209. message: msg.slice(0, 1).toUpperCase() + msg.slice(1),
  210. severity: w.severity,
  211. rule: w.rule,
  212. });
  213. }
  214. }
  215. return res;
  216. },
  217. },
  218. };
  219. linterMan.register(async (text, _options, cm) => {
  220. const linter = prefs.get('editor.linter');
  221. if (linter) {
  222. const {mode} = cm.options;
  223. const currentFirst = Object.entries(ENGINES).sort(([a]) => a === linter ? -1 : 1);
  224. for (const [name, engine] of currentFirst) {
  225. if (engine.validMode(mode)) {
  226. const cfg = configs.get(name) || await getConfig(name);
  227. return ENGINES[name].lint(text, cfg, mode);
  228. }
  229. }
  230. }
  231. });
  232. chrome.storage.onChanged.addListener(changes => {
  233. for (const name of Object.keys(ENGINES)) {
  234. if (chromeSync.LZ_KEY[name] in changes) {
  235. getConfig(name).then(linterMan.run);
  236. }
  237. }
  238. });
  239. async function getConfig(name) {
  240. const rawCfg = await chromeSync.getLZValue(chromeSync.LZ_KEY[name]);
  241. const cfg = ENGINES[name].getConfig(rawCfg);
  242. configs.set(name, cfg);
  243. return cfg;
  244. }
  245. })();
  246. //#endregion
  247. //#region Reports
  248. (() => {
  249. const tables = new Map();
  250. linterMan.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
  251. let table = tables.get(cm);
  252. if (!table) {
  253. table = createTable(cm);
  254. tables.set(cm, table);
  255. const container = $('.lint-report-container');
  256. const nextSibling = findNextSibling(tables, cm);
  257. container.insertBefore(table.element, nextSibling && tables.get(nextSibling).element);
  258. }
  259. table.updateCaption();
  260. table.updateAnnotations(annotations);
  261. updateCount();
  262. });
  263. linterMan.onUnhook(cm => {
  264. const table = tables.get(cm);
  265. if (table) {
  266. table.element.remove();
  267. tables.delete(cm);
  268. }
  269. updateCount();
  270. });
  271. Object.assign(linterMan, {
  272. getIssues() {
  273. const issues = new Set();
  274. for (const table of tables.values()) {
  275. for (const tr of table.trs) {
  276. issues.add(tr.getAnnotation());
  277. }
  278. }
  279. return issues;
  280. },
  281. refreshReport() {
  282. for (const table of tables.values()) {
  283. table.updateCaption();
  284. }
  285. },
  286. });
  287. function updateCount() {
  288. const issueCount = Array.from(tables.values())
  289. .reduce((sum, table) => sum + table.trs.length, 0);
  290. $('#lint').classList.toggle('hidden', issueCount === 0);
  291. $('#issue-count').textContent = issueCount;
  292. }
  293. function findNextSibling(tables, cm) {
  294. const editors = editor.getEditors();
  295. let i = editors.indexOf(cm) + 1;
  296. while (i < editors.length) {
  297. if (tables.has(editors[i])) {
  298. return editors[i];
  299. }
  300. i++;
  301. }
  302. }
  303. function createTable(cm) {
  304. const caption = $create('caption');
  305. const tbody = $create('tbody');
  306. const table = $create('table', [caption, tbody]);
  307. const trs = [];
  308. return {
  309. element: table,
  310. trs,
  311. updateAnnotations,
  312. updateCaption,
  313. };
  314. function updateCaption() {
  315. caption.textContent = editor.getEditorTitle(cm);
  316. }
  317. function updateAnnotations(lines) {
  318. let i = 0;
  319. for (const anno of getAnnotations()) {
  320. let tr;
  321. if (i < trs.length) {
  322. tr = trs[i];
  323. } else {
  324. tr = createTr();
  325. trs.push(tr);
  326. tbody.append(tr.element);
  327. }
  328. tr.update(anno);
  329. i++;
  330. }
  331. if (i === 0) {
  332. trs.length = 0;
  333. tbody.textContent = '';
  334. } else {
  335. while (trs.length > i) {
  336. trs.pop().element.remove();
  337. }
  338. }
  339. table.classList.toggle('empty', trs.length === 0);
  340. function *getAnnotations() {
  341. for (const line of lines.filter(Boolean)) {
  342. yield *line;
  343. }
  344. }
  345. }
  346. function createTr() {
  347. let anno;
  348. const severityIcon = $create('div');
  349. const severity = $create('td', {attributes: {role: 'severity'}}, severityIcon);
  350. const line = $create('td', {attributes: {role: 'line'}});
  351. const col = $create('td', {attributes: {role: 'col'}});
  352. const message = $create('td', {attributes: {role: 'message'}});
  353. const trElement = $create('tr', {
  354. onclick: () => gotoLintIssue(cm, anno),
  355. }, [
  356. severity,
  357. line,
  358. $create('td', {attributes: {role: 'sep'}}, ':'),
  359. col,
  360. message,
  361. ]);
  362. return {
  363. element: trElement,
  364. update,
  365. getAnnotation: () => anno,
  366. };
  367. function update(_anno) {
  368. anno = _anno;
  369. trElement.className = anno.severity;
  370. severity.dataset.rule = anno.rule;
  371. severityIcon.className = `CodeMirror-lint-marker CodeMirror-lint-marker-${anno.severity}`;
  372. severityIcon.textContent = anno.severity;
  373. line.textContent = anno.from.line + 1;
  374. col.textContent = anno.from.ch + 1;
  375. message.title = clipString(anno.message, 1000) +
  376. (anno.rule ? `\n(${anno.rule})` : '');
  377. message.textContent = clipString(anno.message, 100).replace(/ at line.*/, '');
  378. }
  379. }
  380. }
  381. function gotoLintIssue(cm, anno) {
  382. editor.scrollToEditor(cm);
  383. cm.focus();
  384. cm.jumpToPos(anno.from);
  385. }
  386. })();
  387. //#endregion