azeemba/eslint-plugin-json

View on GitHub
src/index.js

Summary

Maintainability
A
1 hr
Test Coverage
const _ = require('lodash/fp');
const jsonService = require('vscode-json-languageservice');

const jsonServiceHandle = jsonService.getLanguageService({});

const ErrorCodes = {
    Undefined: 0,
    EnumValueMismatch: 1,
    UnexpectedEndOfComment: 0x101,
    UnexpectedEndOfString: 0x102,
    UnexpectedEndOfNumber: 0x103,
    InvalidUnicode: 0x104,
    InvalidEscapeCharacter: 0x105,
    InvalidCharacter: 0x106,
    PropertyExpected: 0x201,
    CommaExpected: 0x202,
    ColonExpected: 0x203,
    ValueExpected: 0x204,
    CommaOrCloseBacketExpected: 0x205,
    CommaOrCloseBraceExpected: 0x206,
    TrailingComma: 0x207,
    DuplicateKey: 0x208,
    CommentNotPermitted: 0x209,
    SchemaResolveError: 0x300,
};

const AllErrorCodes = _.values(ErrorCodes);
const AllowComments = 'allowComments';

const fileLintResults = {};
const fileComments = {};
const fileDocuments = {};

const getSignature = (problem) =>
    `${problem.range.start.line} ${problem.range.start.character} ${problem.message}`;

function getDiagnostics(jsonDocument) {
    return _.pipe(
        _.map((problem) => [getSignature(problem), problem]),
        _.reverse, // reverse ensure fromPairs keep first signature occurence of problem
        _.fromPairs
    )(jsonDocument.syntaxErrors);
}
const reportError = (filter) => (errorName, context) => {
    _.filter(filter, fileLintResults[context.getFilename()]).forEach((error) => {
        context.report({
            ruleId: `json/${errorName}`,
            message: error.message,
            loc: {
                start: {line: error.range.start.line + 1, column: error.range.start.character},
                end: {line: error.range.end.line + 1, column: error.range.end.character},
            },
            // later: see how to add fix
        });
    });
};
const reportComment = (errorName, context) => {
    const ruleOption = _.head(context.options);
    if (ruleOption === AllowComments || _.get(AllowComments, ruleOption)) return;

    _.forEach((comment) => {
        context.report({
            ruleId: errorName,
            message: 'Comment not allowed',
            loc: {
                start: {line: comment.start.line + 1, column: comment.start.character},
                end: {line: comment.end.line + 1, column: comment.end.character},
            },
        });
    }, fileComments[context.getFilename()]);
};

const makeRule = (errorName, reporters) => ({
    create(context) {
        return {
            Program() {
                _.flatten([reporters]).map((reporter) => reporter(errorName, context));
            },
        };
    },
});

const rules = _.pipe(
    _.mapKeys(_.kebabCase),
    _.toPairs,
    _.map(([errorName, errorCode]) => [
        errorName,
        makeRule(
            errorName,
            reportError((err) => err.code === errorCode)
        ),
    ]),
    _.fromPairs,
    _.assign({
        '*': makeRule('*', [reportError(_.constant(true)), reportComment]),
        json: makeRule('json', [reportError(_.constant(true)), reportComment]),
        unknown: makeRule('unknown', reportError(_.negate(AllErrorCodes.includes))),
        'comment-not-permitted': makeRule('comment-not-permitted', reportComment),
    })
)(ErrorCodes);

const errorSignature = (err) =>
    ['message', 'line', 'column', 'endLine', 'endColumn'].map((field) => err[field]).join('::');

const getErrorCode = _.pipe(_.get('ruleId'), _.split('/'), _.last);

const processors = {
    '.json': {
        preprocess: function (text, fileName) {
            const textDocument = jsonService.TextDocument.create(fileName, 'json', 1, text);
            fileDocuments[fileName] = textDocument;
            const parsed = jsonServiceHandle.parseJSONDocument(textDocument);
            fileLintResults[fileName] = getDiagnostics(parsed);
            fileComments[fileName] = parsed.comments;
            return ['']; // sorry nothing ;)
        },
        postprocess: function (messages, fileName) {
            const textDocument = fileDocuments[fileName];
            delete fileLintResults[fileName];
            delete fileComments[fileName];
            return _.pipe(
                _.first,
                _.groupBy(errorSignature),
                _.mapValues((errors) => {
                    if (errors.length === 1) return _.first(errors);
                    // Otherwise there is two errors: the generic and specific one
                    // json/* or json/json and json/some-code
                    const firstErrorCode = getErrorCode(errors[0]);
                    const isFirstGeneric = ['*', 'json'].includes(firstErrorCode);
                    const genericError = errors[isFirstGeneric ? 0 : 1];
                    const specificError = errors[isFirstGeneric ? 1 : 0];
                    return genericError.severity > specificError.severity
                        ? genericError
                        : specificError;
                }),
                _.mapValues((error) => {
                    const source = textDocument.getText({
                        start: {line: error.line - 1, character: error.column},
                        end: {line: error.endLine - 1, character: error.endColumn},
                    });
                    return _.assign(error, {
                        source,
                        column: error.column + 1,
                        endColumn: error.endColumn + 1,
                    });
                }),
                _.values
            )(messages);
        },
    },
};

const configs = {
    recommended: {
        plugins: ['json'],
        rules: {
            'json/*': 'error',
        },
    },
    'recommended-with-comments': {
        plugins: ['json'],
        rules: {
            'json/*': ['error', {allowComments: true}],
        },
    },
};

module.exports = {rules, configs, processors};