jbenden/vscode-c-cpp-flylint

View on GitHub
server/src/linters/linter.ts

Summary

Maintainability
A
2 hrs
Test Coverage
C
77%
// Copyright (c) 2017-2022 The VSCode C/C++ Flylint Authors
//
// SPDX-License-Identifier: MIT

/* eslint-disable no-console */
import * as which from 'which';
import * as fs from 'fs';
import * as path from 'path';
import * as _ from 'lodash';
import { InternalDiagnostic } from '../server';
import { Settings } from '../settings';
import { path as sysPath } from '../utils';
import * as cross_spawn from 'cross-spawn';
import * as child_process from 'child_process';

const substituteVariables = require('var-expansion').substituteVariables; // no types available
const slash = require('slash'); // no types available

export interface IExpansionResult {
    error?: any;
    result?: string;
}

export const headerExts = ['.h', '.H', '.hh', '.hpp', '.h++', '.hxx'];

export enum Lint {
    ON_SAVE = 1,
    ON_TYPE = 2,
    ON_BUILD = 3,
}

/* istanbul ignore next */
export function toLint(s: string): Lint {
    switch (s) {
        case 'onSave': return Lint.ON_SAVE;
        case 'onType': return Lint.ON_TYPE;
        case 'onBuild': return Lint.ON_BUILD;
        default:
            throw Error('Unknown onLint value of ' + s);
    }
}

/* istanbul ignore next */
export function fromLint(lint: Lint): string {
    switch (lint) {
        case Lint.ON_SAVE: return 'ON_SAVE';
        case Lint.ON_TYPE: return 'ON_TYPE';
        case Lint.ON_BUILD: return 'ON_BUILD';
        default:
            throw Error('Unknown enum Lint value of ' + lint);
    }
}

/* istanbul ignore next */
export class PathEnv {
    private paths: Array<string> = [];

    constructor() {
        if (process.env.PATH) { this.paths = process.env.PATH.split(path.delimiter); }
    }

    append(p: string | Array<string>) {
        // assert(p.includes(path.delimiter) !== true);

        this.paths = this.deduplicate(this.paths.concat(...p));
    }

    prepend(p: string | Array<string>) {
        // assert(p.includes(path.delimiter) !== true);

        if (typeof p === 'string') {
            p = [p];
        }

        this.paths = this.deduplicate(p.concat(...this.paths));
    }

    protected deduplicate(array: ReadonlyArray<string>) {
        return Array.from(new Set(array));
    }

    toString() {
        return this.paths.join(path.delimiter);
    }
}

export class Linter {
    protected name: string;
    protected settings: Settings;
    protected workspaceRoot: string;
    protected enabled: boolean;
    protected active: boolean;
    protected executable: string = '';
    protected configFile: string = '';
    protected requireConfig: boolean;
    protected language: string;
    protected standard: string[];
    protected defines: string[];
    protected undefines: string[];
    protected includePaths: string[];

    protected constructor(name: string, settings: Settings, workspaceRoot: string, requireConfig: boolean) {
        this.name = name;
        this.settings = settings;
        this.workspaceRoot = workspaceRoot;
        this.requireConfig = requireConfig;
        this.enabled = true;
        this.active = true;
        this.language = settings.language;
        this.standard = settings.standard;
        this.defines = settings.defines;
        this.undefines = settings.undefines;
        this.includePaths = settings.includePaths;
    }

    /* istanbul ignore next */
    protected cascadeCommonSettings(key: string) {
        let checkKey = (item: string): boolean => {
            return this.settings[key as keyof Settings].hasOwnProperty(item) &&
                this.settings[key as keyof Settings].hasOwnProperty(item) !== null &&
                (this.settings[key as keyof Settings] as any)[item] !== null;
        };

        let maybe = (orig: string[] | string, maybeKey: string) => {
            if (checkKey(maybeKey)) {
                if (_.isArray(orig) || _.isString(orig)) {
                    return (this.settings[key as keyof Settings] as any)[maybeKey];
                }
            }

            return orig;
        };

        this.language = maybe(this.language, 'language');
        this.standard = maybe(this.standard, 'standard');
        this.defines = maybe(this.defines, 'defines');
        this.undefines = maybe(this.undefines, 'undefines');
        this.includePaths = maybe(this.includePaths, 'includePaths');
    }

    /* istanbul ignore next */
    protected setExecutable(fileName: string) {
        this.executable = fileName;
    }

    /* istanbul ignore next */
    protected setConfigFile(fileName: string) {
        this.configFile = fileName;
    }

    /* istanbul ignore next */
    public Name(): string {
        return this.name;
    }

    /* istanbul ignore next */
    public isEnabled(): boolean {
        return this.enabled;
    }

    /* istanbul ignore next */
    public isActive(): boolean {
        return this.active;
    }

    /* istanbul ignore next */
    public enable() {
        this.enabled = true;
    }

    /* istanbul ignore next */
    public disable() {
        this.enabled = false;
    }

    /* istanbul ignore next */
    public async initialize() {
        await this.maybeEnable().catch(() => {
            // empty
        });
        return this;
    }

    /* istanbul ignore next */
    private async maybeEnable() {
        if (!this.isEnabled()) {
            return Promise.resolve('');
        }

        return this.maybeExecutablePresent()
            .then((val) => {
                this.executable = val;

                return this.maybeConfigFilePresent();
            });
    }

    /* istanbul ignore next */
    private maybeExecutablePresent(): Promise<string> {
        return new Promise((resolve, reject) => {
            let paths = new PathEnv();

            paths.prepend(path.resolve(__dirname, '../../..'));

            which(this.executable, { path: paths.toString() }, (err: any, result: any) => {
                if (err) {
                    this.disable();

                    if (this.settings.debug) {
                        console.log(`The executable was not found for ${this.name}; looked for ${this.executable}`);
                    }

                    reject(Error(`The executable was not found for ${this.name}, disabling linter`));
                }
                else {
                    resolve(result);
                }
            });
        });
    }

    /* istanbul ignore next */
    private async maybeConfigFilePresent(): Promise<string> {
        if (!this.requireConfig) {
            return Promise.resolve('');
        }

        return this.locateFile(this.workspaceRoot, this.configFile)
            .then((val) => {
                this.configFile = val;

                this.enable();

                return val;
            })
            .catch(() => {
                this.disable();

                console.log(`The configuration file was not found for ${this.name}; looked for ${this.configFile}`);

                throw Error(`could not locate configuration file for ${this.name}, disabling linter`);
            });
    }

    /* istanbul ignore next */
    protected locateFile(directory: string, fileName: string): Promise<string> {
        return new Promise((resolve, reject) => {
            let parent = directory;

            do {
                directory = parent;

                const location: string = (() => {
                    if (path.isAbsolute(fileName)) {
                        return fileName;
                    } else {
                        return path.join(directory, fileName);
                    }
                })();

                try {
                    fs.accessSync(location, fs.constants.R_OK);
                    resolve(location);
                } catch (e) {
                    // do nothing
                }

                parent = path.dirname(directory);
            } while (parent !== directory);

            reject('could not locate file within project workspace');
        });
    }

    /* istanbul ignore next */
    protected expandVariables(str: string): IExpansionResult {
        process.env.workspaceRoot = this.workspaceRoot;
        process.env.workspaceFolder = this.workspaceRoot;
        let { value, error } = substituteVariables(str, { env: process.env });

        if (error) {
            return { error: error };
        } else if (value === '') {
            return { error: `Expanding '${str}' resulted in an empty string.` };
        } else {
            return { result: slash(value) };
        }
    }

    /* istanbul ignore next */
    protected buildCommandLine(fileName: string, tmpFileName: string): string[] {
        return [this.executable, fileName, tmpFileName];
    }

    protected runLinter(params: string[], workspaceDir: string): child_process.SpawnSyncReturns<string> {
        let cmd = params.shift() || this.executable;

        /* istanbul ignore if */
        if (this.settings.debug) {
            console.log('executing: ', cmd, params.join(' '));
        }

        return cross_spawn.sync(cmd, params, { 'cwd': workspaceDir, encoding: 'utf8' });
    }

    public lint(fileName: string, directory: null | string, tmpFileName: string): InternalDiagnostic[] {
        if (!this.enabled) { return []; }

        let result = this.runLinter(this.buildCommandLine(fileName, tmpFileName), directory || this.workspaceRoot);
        let stdout = result.stdout !== null ? result.stdout.replace(/\r/g, '').split('\n') : [];
        let stderr = result.stderr !== null ? result.stderr.replace(/\r/g, '').split('\n') : [];

        /* istanbul ignore if */
        if (this.settings.debug) {
            console.log(stdout);
            console.log(stderr);
        }

        /* istanbul ignore if */
        if (result.status !== 0) {
            console.log(`${this.name} exited with status code ${result.status}`);
        }

        return this.parseLines(stdout.concat(stderr));
    }

    /* istanbul ignore next */
    protected isQuote(ch: string): boolean {
        return ch === '\'' || ch === '\"';
    }

    protected parseLines(lines: string[]): InternalDiagnostic[] {
        let results: InternalDiagnostic[] = [];
        let currentParsed: InternalDiagnostic | null = null;

        lines.forEach(line => {
            if (this.isQuote(line.charAt(0))) {
                line = line.substring(1);

                if (this.isQuote(line.charAt(line.length - 1))) {
                    line = line.substring(0, line.length - 1);
                }
            }

            let parsed = this.parseLine(line);
            if (parsed) {
                // check for parse error
                if (parsed.parseError) {
                    if (this.settings.ignoreParseErrors) {
                        console.log(parsed.parseError);
                        return;
                    } else {
                        throw Error(parsed.parseError);
                    }
                }

                ({ currentParsed, parsed } = this.transformParse(currentParsed, parsed));

                if (currentParsed !== null && !currentParsed.parseError) {
                    // output an entry
                    results.push(currentParsed);
                }

                currentParsed = parsed;
            }
        });

        if (currentParsed !== null) {
            // output an entry
            results.push(currentParsed);
        }

        return results;
    }

    /* istanbul ignore next */
    protected transformParse(currentParsed: InternalDiagnostic | null, parsed: InternalDiagnostic | null) {
        return { currentParsed: currentParsed, parsed: parsed };
    }

    /* istanbul ignore next */
    protected parseLine(_line: string): InternalDiagnostic | null {
        return null;
    }

    /* istanbul ignore next */
    protected isValidLanguage(language: string): boolean {
        const allowLanguages = ['c', 'c++'];
        return _.includes(allowLanguages, language);
    }

    /* istanbul ignore next */
    protected getIncludePathParams(): string[] {
        let paths = this.includePaths;
        let params: string[] = [];

        if (paths) {
            _.each(paths, (element: string) => {
                let value = this.expandVariables(element);
                if (value.error) {
                    console.log(`Error expanding include path '${element}': ${value.error.message}`);
                } else {
                    params.push(`-I`);
                    params.push(`${sysPath(value.result!)}`);
                }
            });
        }

        return params;
    }

    /* istanbul ignore next */
    protected expandedArgsFor(key: string, joined: boolean, values: string[] | null, defaults: string[] | null): string[] {
        let params: string[] = [];
        let elaborateArguments = (element: string) => {
            if (element === '') {
                return;
            }
            let value = this.expandVariables(element);
            if (value.error) {
                console.log(`Error expanding '${element}': ${value.error.message}`);
            } else {
                if (joined) {
                    params.push(`${key}${value.result}`);
                } else {
                    params.push(key);
                    params.push(`${value.result}`);
                }
            }
        };

        _.each(values ? values : defaults, elaborateArguments);

        return params;
    }
}