Repository: ember-template-lint/ember-template-lint
Pull request #919 (2019-11-11 16:12:38)

Use `.editorconfig` to choose default value for `linebreak-style`
ref: https://github.com/ember-template-lint/ember-template-lint/pull/906

fix: https://github.com/ember-template-lint/ember-template-lint/issues/284


Patch:
diff --git a/docs/rule/linebreak-style.md b/docs/rule/linebreak-style.md
index 3ab01900..1e05d408 100644
--- a/docs/rule/linebreak-style.md
+++ b/docs/rule/linebreak-style.md
@@ -8,3 +8,5 @@ This rule is configured with a boolean, or a string value:
 
 * boolean -- `true` for enforcing consistency (all `CRLF` or all `LF` not both in a single file)
 * string -- `system` for the current platforms default line ending / `unix` for LF linebreaks / `windows` for CRLF linebreaks
+
+If `.editorconfig` file present, rule configuration will be inherit from it.
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/lib/rules/lint-linebreak-style.js b/lib/rules/lint-linebreak-style.js
index b08bb666..60ef5642 100644
--- a/lib/rules/lint-linebreak-style.js
+++ b/lib/rules/lint-linebreak-style.js
@@ -20,7 +20,20 @@ function toUserString(value) {
   return value.replace('\r', 'CR').replace('\n', 'LF');
 }
 
+const matchMap = {
+  lf: '\n',
+  cr: '\r',
+  crlf: '\r\n',
+};
+
 module.exports = class LineBreakStyle extends Rule {
+  constructor(options) {
+    super(options);
+    const configuredIndent = this.editorConfig['end_of_line'];
+    if (['lf', 'cr', 'crlf'].includes(configuredIndent)) {
+      this.config = matchMap[configuredIndent];
+    }
+  }
   parseConfig(config) {
     let configType = typeof config;
 
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,
+    });
+  });
+});
diff --git a/test/unit/rules/lint-linebreak-style-test.js b/test/unit/rules/lint-linebreak-style-test.js
index 2c008875..cd271227 100644
--- a/test/unit/rules/lint-linebreak-style-test.js
+++ b/test/unit/rules/lint-linebreak-style-test.js
@@ -24,6 +24,13 @@ generateRuleTests({
       config: 'unix',
       template: 'testing\nthis',
     },
+    {
+      meta: {
+        editorConfig: { end_of_line: 'crlf' },
+      },
+      config: 'unix',
+      template: 'testing\r\nthis',
+    },
   ],
 
   bad: [