Repository: ember-template-lint/ember-template-lint
Pull request #906 (2019-11-05 07:30:18)

Add utility function to allow reading from `.editorconfig`
refs: https://github.com/editorconfig/editorconfig-core-js/

https://github.com/ember-template-lint/ember-template-lint/issues/901
https://github.com/ember-template-lint/ember-template-lint/issues/284

Things to figure out:

`.editorconfig` pretty flexible, and allow different rules for different file types, paths, names.

We can't read it once and use everywhere (in simple way).

It's overhead - Read editorconfig for each file and rule.

Refering to https://github.com/ember-template-lint/ember-template-lint/pulls and `moduleId` === `source file path` disussuion, we can provide path to exact file to lint and rule authors can use getEditorConfigData method inside if needed.

lifeart: We can:

Count `.editorconfig` as immutable.
Drop support for whole regexps inside it.
Create cache class for it and use extension as caching key.
||
Write our own `.editorconfig` lookup logic and use `parseString` to extract editorconfig data


Regarding external rules usage, I think we can provide reference function into plugin scope, to call this method. 

like

```js
class Rule(options) {
  this.editorConfig = options.configResolver.editorConfigFor('' | options.moduleId)
}

```

rwjblue: > Write our own .editorconfig lookup logic and use parseString to extract editorconfig data

This seems like a good starting point to me.

> Regarding external rules usage, I think we can provide reference function into plugin scope, to call this method.

Ya, this seems fine as long as we address the caching bit (e.g. custom class) mentioned above.

Patch:
diff --git a/lib/get-editor-config.js b/lib/get-editor-config.js
new file mode 100644
index 00000000..ce9210a6
--- /dev/null
+++ b/lib/get-editor-config.js
@@ -0,0 +1,240 @@
+// main code parts copypasted from https://github.com/editorconfig/editorconfig-core-js and and rewritten as Class.
+
+'use strict';
+const micromatch = require('micromatch');
+const path = require('path');
+const fs = require('fs');
+
+class EditorConfigResolver {
+  constructor() {
+    this.EDITOR_CONFIG_CACHE = null;
+    this.CONFIG_RESOLUTIONS_CACHE = new Map();
+    /**
+     * define the possible values:
+     * section: [section]
+     * param: key=value
+     * comment: ;this is a comment
+     */
+    this.regex = {
+      section: /^\s*\[(([^#;]|\\#|\\;)+)\]\s*([#;].*)?$/,
+      param: /^\s*([\w.\-_]+)\s*[=:]\s*(.*?)\s*([#;].*)?$/,
+      comment: /^\s*[#;].*$/,
+    };
+
+    this.knownProps = {
+      end_of_line: true,
+      indent_style: true,
+      indent_size: true,
+      insert_final_newline: true,
+      trim_trailing_whitespace: true,
+      charset: true,
+    };
+  }
+  opts(filepath, options) {
+    const resolvedFilePath = path.resolve(filepath);
+    return [resolvedFilePath, this.processOptions(options, resolvedFilePath)];
+  }
+  processOptions(options = {}, filepath) {
+    return {
+      config: options.config || '.editorconfig',
+      root: path.resolve(options.root || path.parse(filepath).root),
+    };
+  }
+  parseString(data) {
+    let sectionBody = {};
+    let sectionName = null;
+    const value = [[sectionName, sectionBody]];
+    const lines = data.split(/\r\n|\r|\n/);
+    lines.forEach(line => {
+      let match;
+      if (this.regex.comment.test(line)) {
+        return;
+      }
+      if (this.regex.param.test(line)) {
+        match = line.match(this.regex.param);
+        sectionBody[match[1]] = match[2];
+      } else if (this.regex.section.test(line)) {
+        match = line.match(this.regex.section);
+        sectionName = match[1];
+        sectionBody = {};
+        value.push([sectionName, sectionBody]);
+      }
+    });
+    return value;
+  }
+  buildFullGlob(pathPrefix, glob) {
+    switch (glob.indexOf('/')) {
+      case -1:
+        glob = `**/${glob}`;
+        break;
+      case 0:
+        glob = glob.substring(1);
+        break;
+      default:
+        break;
+    }
+    return path.join(pathPrefix, glob);
+  }
+  fnmatch(filepath, glob) {
+    const matchOptions = { matchBase: false, dot: false, noext: true };
+    glob = glob.split(path.sep).join('/');
+    filepath = filepath.split(path.sep).join('/');
+    return micromatch.isMatch(filepath, glob, matchOptions);
+  }
+  extendProps(props = {}, options = {}) {
+    for (const key in options) {
+      if (Object.prototype.hasOwnProperty.call(options, key)) {
+        const value = options[key];
+        const key2 = key.toLowerCase();
+        let value2 = value;
+        if (this.knownProps[key2]) {
+          value2 = value.toLowerCase();
+        }
+        try {
+          value2 = JSON.parse(value);
+        } catch (e) {
+          //
+        }
+        if (typeof value === 'undefined' || value === null) {
+          // null and undefined are values specific to JSON (no special meaning
+          // in editorconfig) & should just be returned as regular strings.
+          value2 = String(value);
+        }
+        props[key2] = value2;
+      }
+    }
+    return props;
+  }
+  readConfigFilesSync(filepaths) {
+    const files = [];
+
+    filepaths.forEach(filepath => {
+      if (!fs.existsSync(filepath)) {
+        return;
+      }
+      let file;
+      try {
+        file = fs.readFileSync(filepath, 'utf8');
+      } catch (e) {
+        file = '';
+      }
+      files.push({
+        name: filepath,
+        contents: file,
+      });
+    });
+    return files;
+  }
+  processMatches(matches) {
+    // Set indent_size to 'tab' if indent_size is unspecified and
+    // indent_style is set to 'tab'.
+    if (
+      'indent_style' in matches &&
+      matches.indent_style === 'tab' &&
+      !('indent_size' in matches)
+    ) {
+      matches.indent_size = 'tab';
+    }
+
+    // Set tab_width to indent_size if indent_size is specified and
+    // tab_width is unspecified
+    if ('indent_size' in matches && !('tab_width' in matches) && matches.indent_size !== 'tab') {
+      matches.tab_width = matches.indent_size;
+    }
+
+    // Set indent_size to tab_width if indent_size is 'tab'
+    if ('indent_size' in matches && 'tab_width' in matches && matches.indent_size === 'tab') {
+      matches.indent_size = matches.tab_width;
+    }
+
+    return matches;
+  }
+  parseFromConfigs(configs, filepath) {
+    return this.processMatches(
+      configs.reverse().reduce((matches, file) => {
+        const pathPrefix = path.dirname(file.name);
+        file.contents.forEach(section => {
+          const [glob, options] = section;
+          if (!glob) {
+            return;
+          }
+          const fullGlob = this.buildFullGlob(pathPrefix, glob);
+          if (!this.fnmatch(filepath, fullGlob)) {
+            return;
+          }
+          matches = this.extendProps(matches, options);
+        });
+        return matches;
+      }, {})
+    );
+  }
+  resetEditorConfigCache() {
+    this.EDITOR_CONFIG_CACHE = null;
+  }
+  resetConfigResolutionsCache() {
+    this.CONFIG_RESOLUTIONS_CACHE.clear();
+  }
+  getConfigFileNames(filepath, options) {
+    const paths = [];
+    do {
+      filepath = path.dirname(filepath);
+      paths.push(path.join(filepath, options.config));
+    } while (filepath !== options.root);
+    return paths;
+  }
+  getConfigsForFiles(files) {
+    const configs = [];
+    for (const i in files) {
+      if (Object.prototype.hasOwnProperty.call(files, i)) {
+        const file = files[i];
+        const contents = this.parseString(file.contents);
+        configs.push({
+          name: file.name,
+          contents,
+        });
+        if ((contents[0][1].root || '').toLowerCase() === 'true') {
+          break;
+        }
+      }
+    }
+    return configs;
+  }
+  /*
+    provcess.cwd() = /root/path
+    dirname(/root/path) => root
+    but, we need to find .editorconfig under /root/path
+    provcess.cwd() + "this.file.does.not.exist" = /root/path/this.file.does.not.exist
+    dirname(/root/path/this.file.does.not.exist) => /root/path
+  */
+  resolveEditorConfigFiles(
+    _filepath = path.join(process.cwd(), 'this.file.does.not.exist'),
+    _options = {}
+  ) {
+    const [resolvedFilePath, processedOptions] = this.opts(_filepath, _options);
+    const filepaths = this.getConfigFileNames(resolvedFilePath, processedOptions);
+    const files = this.readConfigFilesSync(filepaths);
+    const configForFiles = this.getConfigsForFiles(files);
+    return configForFiles;
+  }
+  checkEditorConfigCache(entry, _opts) {
+    if (this.EDITOR_CONFIG_CACHE === null) {
+      this.EDITOR_CONFIG_CACHE = this.resolveEditorConfigFiles(entry, _opts);
+    }
+  }
+  editorConfigForFilePath(resolvedFilePath) {
+    return this.parseFromConfigs(this.EDITOR_CONFIG_CACHE, resolvedFilePath);
+  }
+  cachedEditorConfigForFilePath(resolvedFilePath) {
+    if (!this.CONFIG_RESOLUTIONS_CACHE.has(resolvedFilePath)) {
+      const editorConfig = this.editorConfigForFilePath(resolvedFilePath);
+      this.CONFIG_RESOLUTIONS_CACHE.set(resolvedFilePath, JSON.stringify(editorConfig));
+    }
+    return JSON.parse(this.CONFIG_RESOLUTIONS_CACHE.get(resolvedFilePath));
+  }
+  getEditorConfigData(entry, _opts = {}) {
+    this.checkEditorConfigCache(entry, _opts);
+    const resolvedFilePath = path.resolve(entry);
+    return this.cachedEditorConfigForFilePath(resolvedFilePath);
+  }
+}
+module.exports = EditorConfigResolver;
diff --git a/lib/index.js b/lib/index.js
index a4dbed7a..b33192c4 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -5,6 +5,7 @@ const Minimatch = require('minimatch').Minimatch;
 const getConfig = require('./get-config');
 const stripBom = require('strip-bom');
 const chalk = require('chalk');
+const EditorConfigResolver = require('./get-editor-config');
 let path = require('path');
 
 function buildErrorMessage(moduleId, error) {
@@ -36,6 +37,8 @@ class Linter {
 
     this.loadConfig();
     this.constructor = Linter;
+    this.editorConfigResolver = new EditorConfigResolver();
+    this.editorConfigResolver.resolveEditorConfigFiles();
   }
 
   loadConfig() {
@@ -81,6 +84,11 @@ class Linter {
           log: addToResults,
           defaultSeverity: this._defaultSeverityForRule(ruleName, config.pending),
           ruleNames: Object.keys(loadedRules),
+          configResolver: config.configResolver || {
+            editorConfig: () => {
+              return this.editorConfigResolver.getEditorConfigData(config.moduleId);
+            },
+          },
           moduleName: config.moduleName,
           rawSource: config.rawSource,
         });
@@ -150,6 +158,7 @@ class Linter {
       pending: pendingStatus,
       moduleId: options.moduleId,
       moduleName: options.moduleId,
+      configResolver: options.configResolver,
       rawSource: source,
     });
 
diff --git a/lib/rules/base.js b/lib/rules/base.js
index 63cbbae9..a3790d01 100644
--- a/lib/rules/base.js
+++ b/lib/rules/base.js
@@ -42,6 +42,7 @@ module.exports = class BaseRule {
     this.ruleName = options.name;
     this._console = options.console || console;
     this._log = options.log;
+    this._configResolver = options.configResolver;
     this._ruleNames = options.ruleNames;
     this.severity = options.defaultSeverity;
 
@@ -60,6 +61,10 @@ module.exports = class BaseRule {
     this.source = options.rawSource.match(reLines);
   }
 
+  get editorConfig() {
+    return this._configResolver.editorConfig() || {};
+  }
+
   get templateEnvironmentData() {
     this._console.warn(
       chalk.yellow(
diff --git a/package.json b/package.json
index 330f99f3..1610b98d 100644
--- a/package.json
+++ b/package.json
@@ -23,9 +23,10 @@
     "test": "mocha"
   },
   "dependencies": {
-    "ember-template-recast": "^3.2.8",
     "chalk": "^2.4.2",
+    "ember-template-recast": "^3.2.8",
     "globby": "^10.0.1",
+    "micromatch": "^4.0.2",
     "minimatch": "^3.0.4",
     "resolve": "^1.12.0",
     "strip-bom": "^4.0.0"


Test patch:
diff --git a/lib/helpers/rule-test-harness.js b/lib/helpers/rule-test-harness.js
index 9740eebf..446ad37d 100644
--- a/lib/helpers/rule-test-harness.js
+++ b/lib/helpers/rule-test-harness.js
@@ -7,7 +7,15 @@ const Linter = require('../index');
 function parseMeta(item) {
   let meta = item !== undefined && typeof item === 'object' && item.meta ? item.meta : {};
   meta.moduleId = meta.moduleId || 'layout.hbs';
-
+  const editorConfig = meta.editorConfig || {};
+
+  if (!('configResolver' in meta)) {
+    meta.configResolver = {
+      editorConfig() {
+        return editorConfig;
+      },
+    };
+  }
   return meta;
 }
 
@@ -76,7 +84,11 @@ module.exports = function generateRuleTests({
 
     function verify(template) {
       linter.config.rules[name] = config;
-      return linter.verify({ source: template, moduleId: meta.moduleId });
+      const options = { source: template, moduleId: meta.moduleId };
+      if ('configResolver' in meta) {
+        options.configResolver = meta.configResolver;
+      }
+      return linter.verify(options);
     }
 
     groupMethodBefore(function() {
diff --git a/test/unit/get-editor-config-test.js b/test/unit/get-editor-config-test.js
new file mode 100644
index 00000000..f96bd7aa
--- /dev/null
+++ b/test/unit/get-editor-config-test.js
@@ -0,0 +1,278 @@
+'use strict';
+
+const path = require('path');
+const expect = require('chai').expect;
+const EditorConfigResolver = require('../../lib/get-editor-config');
+const { createTempDir } = require('broccoli-test-helper');
+
+const initialCWD = process.cwd();
+
+describe('get-editor-config', function() {
+  let project = null;
+  beforeEach(async function() {
+    project = await createTempDir();
+    process.chdir(project.path());
+  });
+  afterEach(async function() {
+    process.chdir(initialCWD);
+    await project.dispose();
+  });
+
+  it('able to read and add info from .editorconfig file if exists', function() {
+    let filePath = path.join(project.path(), 'app.hbs');
+
+    project.write({
+      'app.hbs': '',
+      '.editorconfig': `
+# EditorConfig helps developers define and maintain consistent
+# coding styles between different editors and IDEs
+# editorconfig.org
+
+root = true
+
+
+[*]
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+
+[*.js]
+indent_style = space
+indent_size = 3
+
+[*.hbs]
+insert_final_newline = false
+indent_style = space
+indent_size = 12
+
+[*.css]
+indent_style = space
+indent_size = 5
+
+[*.html]
+indent_style = space
+indent_size = 2
+
+[*.{diff,md}]
+trim_trailing_whitespace = false
+      `,
+    });
+
+    expect(new EditorConfigResolver().getEditorConfigData(filePath)).to.deep.equal({
+      charset: 'utf-8',
+      end_of_line: 'lf',
+      indent_size: 12,
+      indent_style: 'space',
+      insert_final_newline: false,
+      tab_width: 12,
+      trim_trailing_whitespace: true,
+    });
+  });
+
+  it('return empty object if no config found', function() {
+    let filePath = path.join(project.path(), 'app.hbs');
+
+    project.write({
+      'app.hbs': '',
+    });
+
+    expect(new EditorConfigResolver().getEditorConfigData(filePath)).to.deep.equal({});
+  });
+
+  it('able to merge different editor config files', function() {
+    let filePath = path.join(project.path(), 'app', 'app.hbs');
+
+    project.write({
+      app: {
+        'app.hbs': '',
+        '.editorconfig': `
+[*app.hbs]
+insert_final_newline = true
+`,
+      },
+      '.editorconfig': `
+[*]
+indent_style = space
+
+[*.hbs]
+indent_size = 5
+`,
+    });
+
+    expect(new EditorConfigResolver().getEditorConfigData(filePath)).to.deep.equal({
+      indent_size: 5,
+      indent_style: 'space',
+      insert_final_newline: true,
+      tab_width: 5,
+    });
+  });
+
+  it('able to merge different config sections', function() {
+    let filePath = path.join(project.path(), 'app.hbs');
+
+    project.write({
+      'app.hbs': '',
+      '.editorconfig': `
+[*]
+indent_style = space
+
+[*.hbs]
+indent_size = 5
+
+[*app.hbs]
+insert_final_newline = true
+
+`,
+    });
+
+    expect(new EditorConfigResolver().getEditorConfigData(filePath)).to.deep.equal({
+      indent_size: 5,
+      indent_style: 'space',
+      insert_final_newline: true,
+      tab_width: 5,
+    });
+  });
+
+  it('able to resolve relative paths', function() {
+    project.write({
+      src: {
+        'app.hbs': '',
+      },
+      '.editorconfig': `
+[*]
+indent_style = space
+`,
+    });
+
+    expect(new EditorConfigResolver().getEditorConfigData('src/app.hbs')).to.deep.equal({
+      indent_style: 'space',
+    });
+  });
+
+  it('able to resolve config with custom name', function() {
+    let filePath = path.join(project.path(), 'app.hbs');
+
+    project.write({
+      'app.hbs': '',
+      '.newline': `
+[*]
+indent_style = space
+
+[*.hbs]
+indent_size = 5
+
+[*app.hbs]
+insert_final_newline = true
+
+`,
+    });
+
+    expect(
+      new EditorConfigResolver().getEditorConfigData(filePath, { config: '.newline' })
+    ).to.deep.equal({
+      indent_size: 5,
+      indent_style: 'space',
+      insert_final_newline: true,
+      tab_width: 5,
+    });
+  });
+
+  it('return default values if no hbs in editor config', function() {
+    let filePath = path.join(project.path(), 'app.hbs');
+
+    project.write({
+      'app.hbs': '',
+      '.editorconfig': `
+[*]
+indent_style = space
+indent_size = 5      
+`,
+    });
+
+    expect(new EditorConfigResolver().getEditorConfigData(filePath)).to.deep.equal({
+      indent_size: 5,
+      indent_style: 'space',
+      tab_width: 5,
+    });
+  });
+
+  it('return empty object if editor config not relevant', function() {
+    let filePath = path.join(project.path(), 'app.hbs');
+
+    project.write({
+      'app.hbs': '',
+      '.editorconfig': `
+[*.css]
+indent_style = space
+indent_size = 5      
+`,
+    });
+
+    expect(new EditorConfigResolver().getEditorConfigData(filePath)).to.deep.equal({});
+  });
+
+  it('allow specify custom editorconfig for file', function() {
+    let filePath = path.join(project.path(), 'items/app.hbs');
+
+    project.write({
+      'app.hbs': '',
+      items: {
+        'app.hbs': '',
+      },
+      '.editorconfig': `
+# EditorConfig helps developers define and maintain consistent
+# coding styles between different editors and IDEs
+# editorconfig.org
+
+root = true
+
+
+[*]
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+
+[*.js]
+indent_style = space
+indent_size = 3
+
+[*.hbs]
+insert_final_newline = false
+indent_style = space
+indent_size = 12
+
+[items/**.hbs]
+indent_style = tabs
+insert_final_newline = true
+indent_size = 14
+
+[*.css]
+indent_style = space
+indent_size = 5
+
+[*.html]
+indent_style = space
+indent_size = 2
+
+[*.{diff,md}]
+trim_trailing_whitespace = false
+      `,
+    });
+
+    expect(new EditorConfigResolver().getEditorConfigData(filePath)).to.deep.equal({
+      charset: 'utf-8',
+      end_of_line: 'lf',
+      indent_size: 14,
+      indent_style: 'tabs',
+      insert_final_newline: true,
+      tab_width: 14,
+      trim_trailing_whitespace: true,
+    });
+  });
+});