426 lines
12 KiB
JavaScript
426 lines
12 KiB
JavaScript
/* global $ $create */// dom.js
|
|
/* global chromeSync */// storage-util.js
|
|
/* global clipString */// util.js
|
|
/* global createWorker */// worker-util.js
|
|
/* global editor */
|
|
/* global prefs */
|
|
'use strict';
|
|
|
|
//#region linterMan
|
|
|
|
const linterMan = (() => {
|
|
const cms = new Map();
|
|
const linters = [];
|
|
const lintingUpdatedListeners = [];
|
|
const unhookListeners = [];
|
|
return {
|
|
|
|
/** @type {EditorWorker} */
|
|
worker: createWorker({url: '/edit/editor-worker'}),
|
|
|
|
disableForEditor(cm) {
|
|
cm.setOption('lint', false);
|
|
cms.delete(cm);
|
|
for (const cb of unhookListeners) {
|
|
cb(cm);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @param {Object} cm
|
|
* @param {string} [code] - to be used to avoid slowdowns when creating a lot of cms.
|
|
* Enables lint option only if there are problems, thus avoiding a _very_ costly layout
|
|
* update when lint gutter is added to a lot of editors simultaneously.
|
|
*/
|
|
enableForEditor(cm, code) {
|
|
if (cms.has(cm)) return;
|
|
cms.set(cm, null);
|
|
if (code) {
|
|
enableOnProblems(cm, code);
|
|
} else {
|
|
cm.setOption('lint', {getAnnotations, onUpdateLinting});
|
|
}
|
|
},
|
|
|
|
onLintingUpdated(fn) {
|
|
lintingUpdatedListeners.push(fn);
|
|
},
|
|
|
|
onUnhook(fn) {
|
|
unhookListeners.push(fn);
|
|
},
|
|
|
|
register(fn) {
|
|
linters.push(fn);
|
|
},
|
|
|
|
run() {
|
|
for (const cm of cms.keys()) {
|
|
cm.performLint();
|
|
}
|
|
},
|
|
};
|
|
|
|
async function enableOnProblems(cm, code) {
|
|
const results = await getAnnotations(code, {}, cm);
|
|
if (results.length || cm.display.renderedView) {
|
|
cms.set(cm, results);
|
|
cm.setOption('lint', {getAnnotations: getCachedAnnotations, onUpdateLinting});
|
|
} else {
|
|
cms.delete(cm);
|
|
}
|
|
}
|
|
|
|
async function getAnnotations(...args) {
|
|
const results = await Promise.all(linters.map(fn => fn(...args)));
|
|
return [].concat(...results.filter(Boolean));
|
|
}
|
|
|
|
function getCachedAnnotations(code, opt, cm) {
|
|
const results = cms.get(cm);
|
|
cms.set(cm, null);
|
|
cm.state.lint.options.getAnnotations = getAnnotations;
|
|
return results;
|
|
}
|
|
|
|
function onUpdateLinting(...args) {
|
|
for (const fn of lintingUpdatedListeners) {
|
|
fn(...args);
|
|
}
|
|
}
|
|
})();
|
|
|
|
//#endregion
|
|
//#region DEFAULTS
|
|
|
|
linterMan.DEFAULTS = {
|
|
stylelint: {
|
|
rules: {
|
|
'at-rule-no-unknown': [true, {
|
|
'ignoreAtRules': ['extend', 'extends', 'css', 'block'],
|
|
'severity': 'warning',
|
|
}],
|
|
'block-no-empty': [true, {severity: 'warning'}],
|
|
'color-no-invalid-hex': [true, {severity: 'warning'}],
|
|
'declaration-block-no-duplicate-properties': [true, {
|
|
'ignore': ['consecutive-duplicates-with-different-values'],
|
|
'severity': 'warning',
|
|
}],
|
|
'declaration-block-no-shorthand-property-overrides': [true, {severity: 'warning'}],
|
|
'font-family-no-duplicate-names': [true, {severity: 'warning'}],
|
|
'function-calc-no-unspaced-operator': [true, {severity: 'warning'}],
|
|
'function-linear-gradient-no-nonstandard-direction': [true, {severity: 'warning'}],
|
|
'keyframe-declaration-no-important': [true, {severity: 'warning'}],
|
|
'media-feature-name-no-unknown': [true, {severity: 'warning'}],
|
|
'no-empty-source': false,
|
|
'no-extra-semicolons': [true, {severity: 'warning'}],
|
|
'no-invalid-double-slash-comments': [true, {severity: 'warning'}],
|
|
'property-no-unknown': [true, {severity: 'warning'}],
|
|
'selector-pseudo-class-no-unknown': [true, {severity: 'warning'}],
|
|
'selector-pseudo-element-no-unknown': [true, {severity: 'warning'}],
|
|
'selector-type-no-unknown': false, // for scss/less/stylus-lang
|
|
'string-no-newline': [true, {severity: 'warning'}],
|
|
'unit-no-unknown': [true, {severity: 'warning'}],
|
|
'comment-no-empty': false,
|
|
'declaration-block-no-redundant-longhand-properties': false,
|
|
'shorthand-property-no-redundant-values': false,
|
|
},
|
|
},
|
|
csslint: {
|
|
'display-property-grouping': 1,
|
|
'duplicate-properties': 1,
|
|
'empty-rules': 1,
|
|
'errors': 1,
|
|
'globals-in-document': 1,
|
|
'known-properties': 1,
|
|
'known-pseudos': 1,
|
|
'selector-newline': 1,
|
|
'shorthand-overrides': 1,
|
|
'simple-not': 1,
|
|
'warnings': 1,
|
|
// disabled
|
|
'adjoining-classes': 0,
|
|
'box-model': 0,
|
|
'box-sizing': 0,
|
|
'bulletproof-font-face': 0,
|
|
'compatible-vendor-prefixes': 0,
|
|
'duplicate-background-images': 0,
|
|
'fallback-colors': 0,
|
|
'floats': 0,
|
|
'font-faces': 0,
|
|
'font-sizes': 0,
|
|
'gradients': 0,
|
|
'ids': 0,
|
|
'import': 0,
|
|
'import-ie-limit': 0,
|
|
'important': 0,
|
|
'order-alphabetical': 0,
|
|
'outline-none': 0,
|
|
'overqualified-elements': 0,
|
|
'qualified-headings': 0,
|
|
'regex-selectors': 0,
|
|
'rules-count': 0,
|
|
'selector-max': 0,
|
|
'selector-max-approaching': 0,
|
|
'shorthand': 0,
|
|
'star-property-hack': 0,
|
|
'text-indent': 0,
|
|
'underscore-property-hack': 0,
|
|
'unique-headings': 0,
|
|
'universal-selector': 0,
|
|
'unqualified-attributes': 0,
|
|
'vendor-prefix': 0,
|
|
'zero-units': 0,
|
|
},
|
|
};
|
|
|
|
//#endregion
|
|
//#region ENGINES
|
|
|
|
(() => {
|
|
const configs = new Map();
|
|
const {DEFAULTS, worker} = linterMan;
|
|
const ENGINES = {
|
|
csslint: {
|
|
validMode: mode => mode === 'css',
|
|
getConfig: config => Object.assign({}, DEFAULTS.csslint, config),
|
|
async lint(text, config) {
|
|
const results = await worker.csslint(text, config);
|
|
return results
|
|
.map(({line, col: ch, message, rule, type: severity}) => line && {
|
|
message,
|
|
from: {line: line - 1, ch: ch - 1},
|
|
to: {line: line - 1, ch},
|
|
rule: rule.id,
|
|
severity,
|
|
})
|
|
.filter(Boolean);
|
|
},
|
|
},
|
|
stylelint: {
|
|
validMode: () => true,
|
|
getConfig: config => ({
|
|
rules: Object.assign({}, DEFAULTS.stylelint.rules, config && config.rules),
|
|
}),
|
|
async lint(code, config, mode) {
|
|
const isLess = mode === 'text/x-less';
|
|
const isStylus = mode === 'stylus';
|
|
const syntax = isLess ? 'less' : isStylus ? 'sugarss' : 'css';
|
|
const raw = await worker.stylelint({code, config, syntax});
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
// Hiding the errors about "//" comments as we're preprocessing only when saving/applying
|
|
// and we can't just pre-remove the comments since "//" may be inside a string token
|
|
const slashCommentAllowed = isLess || isStylus;
|
|
const res = [];
|
|
for (const w of raw.warnings) {
|
|
const msg = w.text.match(/^(?:Unexpected\s+)?(.*?)\s*\([^()]+\)$|$/)[1] || w.text;
|
|
if (!slashCommentAllowed || !(
|
|
w.rule === 'no-invalid-double-slash-comments' ||
|
|
w.rule === 'property-no-unknown' && msg.includes('"//"')
|
|
)) {
|
|
res.push({
|
|
from: {line: w.line - 1, ch: w.column - 1},
|
|
to: {line: w.line - 1, ch: w.column},
|
|
message: msg.slice(0, 1).toUpperCase() + msg.slice(1),
|
|
severity: w.severity,
|
|
rule: w.rule,
|
|
});
|
|
}
|
|
}
|
|
return res;
|
|
},
|
|
},
|
|
};
|
|
|
|
linterMan.register(async (text, _options, cm) => {
|
|
const linter = prefs.get('editor.linter');
|
|
if (linter) {
|
|
const {mode} = cm.options;
|
|
const currentFirst = Object.entries(ENGINES).sort(([a]) => a === linter ? -1 : 1);
|
|
for (const [name, engine] of currentFirst) {
|
|
if (engine.validMode(mode)) {
|
|
const cfg = configs.get(name) || await getConfig(name);
|
|
return ENGINES[name].lint(text, cfg, mode);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
chrome.storage.onChanged.addListener(changes => {
|
|
for (const name of Object.keys(ENGINES)) {
|
|
if (chromeSync.LZ_KEY[name] in changes) {
|
|
getConfig(name).then(linterMan.run);
|
|
}
|
|
}
|
|
});
|
|
|
|
async function getConfig(name) {
|
|
const rawCfg = await chromeSync.getLZValue(chromeSync.LZ_KEY[name]);
|
|
const cfg = ENGINES[name].getConfig(rawCfg);
|
|
configs.set(name, cfg);
|
|
return cfg;
|
|
}
|
|
})();
|
|
|
|
//#endregion
|
|
//#region Reports
|
|
|
|
(() => {
|
|
const tables = new Map();
|
|
|
|
linterMan.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
|
|
let table = tables.get(cm);
|
|
if (!table) {
|
|
table = createTable(cm);
|
|
tables.set(cm, table);
|
|
const container = $('.lint-report-container');
|
|
const nextSibling = findNextSibling(tables, cm);
|
|
container.insertBefore(table.element, nextSibling && tables.get(nextSibling).element);
|
|
}
|
|
table.updateCaption();
|
|
table.updateAnnotations(annotations);
|
|
updateCount();
|
|
});
|
|
|
|
linterMan.onUnhook(cm => {
|
|
const table = tables.get(cm);
|
|
if (table) {
|
|
table.element.remove();
|
|
tables.delete(cm);
|
|
}
|
|
updateCount();
|
|
});
|
|
|
|
Object.assign(linterMan, {
|
|
|
|
getIssues() {
|
|
const issues = new Set();
|
|
for (const table of tables.values()) {
|
|
for (const tr of table.trs) {
|
|
issues.add(tr.getAnnotation());
|
|
}
|
|
}
|
|
return issues;
|
|
},
|
|
|
|
refreshReport() {
|
|
for (const table of tables.values()) {
|
|
table.updateCaption();
|
|
}
|
|
},
|
|
});
|
|
|
|
function updateCount() {
|
|
const issueCount = Array.from(tables.values())
|
|
.reduce((sum, table) => sum + table.trs.length, 0);
|
|
$('#lint').classList.toggle('hidden', issueCount === 0);
|
|
$('#issue-count').textContent = issueCount;
|
|
}
|
|
|
|
function findNextSibling(tables, cm) {
|
|
const editors = editor.getEditors();
|
|
let i = editors.indexOf(cm) + 1;
|
|
while (i < editors.length) {
|
|
if (tables.has(editors[i])) {
|
|
return editors[i];
|
|
}
|
|
i++;
|
|
}
|
|
}
|
|
|
|
function createTable(cm) {
|
|
const caption = $create('caption');
|
|
const tbody = $create('tbody');
|
|
const table = $create('table', [caption, tbody]);
|
|
const trs = [];
|
|
return {
|
|
element: table,
|
|
trs,
|
|
updateAnnotations,
|
|
updateCaption,
|
|
};
|
|
|
|
function updateCaption() {
|
|
caption.textContent = editor.getEditorTitle(cm);
|
|
}
|
|
|
|
function updateAnnotations(lines) {
|
|
let i = 0;
|
|
for (const anno of getAnnotations()) {
|
|
let tr;
|
|
if (i < trs.length) {
|
|
tr = trs[i];
|
|
} else {
|
|
tr = createTr();
|
|
trs.push(tr);
|
|
tbody.append(tr.element);
|
|
}
|
|
tr.update(anno);
|
|
i++;
|
|
}
|
|
if (i === 0) {
|
|
trs.length = 0;
|
|
tbody.textContent = '';
|
|
} else {
|
|
while (trs.length > i) {
|
|
trs.pop().element.remove();
|
|
}
|
|
}
|
|
table.classList.toggle('empty', trs.length === 0);
|
|
|
|
function *getAnnotations() {
|
|
for (const line of lines.filter(Boolean)) {
|
|
yield *line;
|
|
}
|
|
}
|
|
}
|
|
|
|
function createTr() {
|
|
let anno;
|
|
const severityIcon = $create('div');
|
|
const severity = $create('td', {attributes: {role: 'severity'}}, severityIcon);
|
|
const line = $create('td', {attributes: {role: 'line'}});
|
|
const col = $create('td', {attributes: {role: 'col'}});
|
|
const message = $create('td', {attributes: {role: 'message'}});
|
|
|
|
const trElement = $create('tr', {
|
|
onclick: () => gotoLintIssue(cm, anno),
|
|
}, [
|
|
severity,
|
|
line,
|
|
$create('td', {attributes: {role: 'sep'}}, ':'),
|
|
col,
|
|
message,
|
|
]);
|
|
return {
|
|
element: trElement,
|
|
update,
|
|
getAnnotation: () => anno,
|
|
};
|
|
|
|
function update(_anno) {
|
|
anno = _anno;
|
|
trElement.className = anno.severity;
|
|
severity.dataset.rule = anno.rule;
|
|
severityIcon.className = `CodeMirror-lint-marker CodeMirror-lint-marker-${anno.severity}`;
|
|
severityIcon.textContent = anno.severity;
|
|
line.textContent = anno.from.line + 1;
|
|
col.textContent = anno.from.ch + 1;
|
|
message.title = clipString(anno.message, 1000) +
|
|
(anno.rule ? `\n(${anno.rule})` : '');
|
|
message.textContent = clipString(anno.message, 100).replace(/ at line.*/, '');
|
|
}
|
|
}
|
|
}
|
|
|
|
function gotoLintIssue(cm, anno) {
|
|
editor.scrollToEditor(cm);
|
|
cm.focus();
|
|
cm.jumpToPos(anno.from);
|
|
}
|
|
})();
|
|
|
|
//#endregion
|