Skip to content

Commit

Permalink
Adds eslint rule to check command's class name. Closes #1819
Browse files Browse the repository at this point in the history
  • Loading branch information
waldekmastykarz committed Apr 10, 2021
1 parent bdd8271 commit e02bb74
Show file tree
Hide file tree
Showing 8 changed files with 291 additions and 3 deletions.
78 changes: 75 additions & 3 deletions .eslintrc.json → .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,71 @@
{
// list of words used in command names used for word breaking
// sorted alphabetically for easy maintenance
const dictionary = [
'access',
'adaptive',
'app',
'apply',
'approve',
'assets',
'bin',
'catalog',
'client',
'comm',
'content',
'conversation',
'custom',
'default',
'external',
'externalize',
'fun',
'group',
'groupify',
'guest',
'hide',
'historical',
'home',
'hub',
'in',
'init',
'install',
'is',
'list',
'member',
'messaging',
'news',
'oauth2',
'org',
'o365',
'permission',
'place',
'property',
'records',
'recycle',
'role',
'schema',
'service',
'setting',
'settings',
'side',
'site',
'status',
'storage',
'token',
'type',
'user',
'web',
'webhook'
];

// list of words that should be capitalized in a specific way
const capitalized = [
'OAuth2'
];

// sort dictionary to put the longest words first
const sortedDictionary = dictionary.sort((a, b) => b.length - a.length);

module.exports = {
"root": true,
"env": {
"node": true,
Expand All @@ -18,7 +85,8 @@
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
"@typescript-eslint",
"cli-microsoft365"
],
"ignorePatterns": [
"**/pcf-init/assets/**",
Expand All @@ -28,6 +96,8 @@
"*.js"
],
"rules": {
"cli-microsoft365/correct-command-class-name": ["error", sortedDictionary, capitalized],
"cli-microsoft365/correct-command-name": "error",
"indent": "off",
"@typescript-eslint/indent": [
"error",
Expand Down Expand Up @@ -98,7 +168,9 @@
],
"rules": {
"no-console": "error",
"@typescript-eslint/no-empty-function": "off"
"@typescript-eslint/no-empty-function": "off",
"cli-microsoft365/correct-command-class-name": "off",
"cli-microsoft365/correct-command-name": "off"
}
},
{
Expand Down
4 changes: 4 additions & 0 deletions eslint-rules/lib/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports.rules = {
'correct-command-class-name': require('./rules/correct-command-class-name'),
'correct-command-name': require('./rules/correct-command-name')
};
116 changes: 116 additions & 0 deletions eslint-rules/lib/rules/correct-command-class-name.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
function getClassNameFromFilePath(filePath, dictionary, capitalized) {
const pos = filePath.indexOf('/src/m365/');
if (pos < 0) {
// not a command file
return;
}

// /src/m365/ = 10
const relativePath = filePath.substr(pos + 10);
let segments = relativePath.split('/');
segments.splice(segments.indexOf('commands'), 1);

// remove command prefix
const length = segments.length;
if (length > 1) {
const commandPrefix = segments[length - 2];
segments[length - 1] = segments[length - 1].replace(`${commandPrefix}-`, '');
}

// replace last element of array with split words
segments.push(...segments.pop().replace('.ts', '').split('-'));

const words = segments
.map(s => breakWords(s, dictionary))
.flat()
.map(w => capitalizeWord(w, capitalized))

const commandName = [
...words,
'Command'
].join('');

return commandName;
}

function capitalizeWord(word, capitalized) {
const capitalizedWord = capitalized.find(c => c.toLowerCase() === word);
if (capitalizedWord) {
return capitalizedWord;
}

return word.substr(0, 1).toUpperCase() + word.substr(1).toLowerCase();
}

function breakWords(longWord, dictionary) {
const words = [];
for (let i = 0; i < dictionary.length; i++) {
if (longWord.indexOf(dictionary[i]) === 0) {
words.push(dictionary[i]);
longWord = longWord.replace(dictionary[i], '');
i = -1;
}
}

if (longWord) {
words.push(longWord);
}

return words;
}

module.exports = {
// exported for testing
getClassNameFromFilePath: getClassNameFromFilePath,
breakWords: breakWords,
meta: {
type: 'problem',
docs: {
description: 'Incorrect command class name',
suggestion: true
},
fixable: 'code',
messages: {
invalidName: "'{{ actualClassName }}' is not a valid command class name. Expected '{{ expectedClassName }}'"
}
},
create: context => {
return {
'ClassDeclaration': function (node) {
if (node.abstract) {
// command classes are not abstract
return;
}

if (!node.superClass) {
// class doesn't inherit from another class
return;
}

if (node.superClass.name.indexOf('Command') < 0) {
// class doesn't inherit from a command class
return;
}

const expectedClassName = getClassNameFromFilePath(context.getFilename(), context.options[0], context.options[1]);
if (!expectedClassName) {
return;
}

const actualClassName = node.id.name;

if (actualClassName !== expectedClassName) {
context.report({
node: node.id,
messageId: 'invalidName',
data: {
actualClassName,
expectedClassName
},
fix: fixer => fixer.replaceText(node.id, expectedClassName)
});
}
}
}
}
};
65 changes: 65 additions & 0 deletions eslint-rules/lib/rules/correct-command-name.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
function getConstNameFromFilePath(filePath) {
const pos = filePath.indexOf('/src/m365/');
if (pos < 0) {
// not a command file
return;
}

// /src/m365/ = 10
const relativePath = filePath.substr(pos + 10);
const segments = relativePath.split('/');
segments.splice(segments.indexOf('commands'), 1);

const length = segments.length;
if (length === 2) {
// remove service from the command file name
segments[1] = segments[1].replace(`${segments[0]}-`, '');
}

const constName = segments.pop()
.replace('.ts', '')
.split('-')
.map(w => w.toUpperCase())
.join('_');

return constName;
}

// unfortunately we can't auto-fix this rule because the
// const needs to be changed where it's defined rather than
// where it's used
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Incorrect command name',
suggestion: true
},
messages: {
invalidName: "'{{ actualConstName }}' is not a valid command name. Expected '{{ expectedConstName }}'"
}
},
create: context => {
return {
'MethodDefinition[key.name = "name"] MemberExpression > Identifier[name != "commands"]': function (node) {
const actualConstName = node.name;
const expectedConstName = getConstNameFromFilePath(context.getFilename());

if (!expectedConstName) {
return;
}

if (actualConstName !== expectedConstName) {
context.report({
node: node,
messageId: 'invalidName',
data: {
actualConstName,
expectedConstName
}
});
}
}
}
}
};
12 changes: 12 additions & 0 deletions eslint-rules/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions eslint-rules/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "eslint-plugin-cli-microsoft365",
"version": "1.0.0",
"main": "lib/index.js"
}
13 changes: 13 additions & 0 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@
"coveralls": "^3.1.0",
"eslint": "^7.22.0",
"eslint-config-standard": "^16.0.2",
"eslint-plugin-cli-microsoft365": "file:eslint-rules",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.3.1",
Expand Down

0 comments on commit e02bb74

Please sign in to comment.