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 ruleSchema = [
    {
        anyOf: [
            {
                enum: ['allowComments'],
            },
            {
                type: 'object',
                properties: {
                    allowComments: {type: 'boolean'},
                },
                additionalProperties: false,
            },
        ],
    },
];

const makeRule = (errorName, reporters) => ({
    meta: {schema: ruleSchema},
    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 meta = {
    name: 'eslint-plugin-json',
    version: '3.1.0',
};

const jsonProcessor = {
    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 processors = {
    // Supports old config.
    '.json': jsonProcessor,
    // Supports new config.
    json: jsonProcessor,
};

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

const json = {meta, rules, configs, processors};

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

module.exports = json;