├── bin ├── .gitignore └── coffeelint ├── lib └── .gitignore ├── .npmignore ├── test ├── fixtures │ ├── syntax_error.coffee │ ├── subdir │ │ └── subdir │ │ │ └── subdir.coffee │ ├── findconfigtest │ │ ├── sevenspaces.coffee │ │ ├── package │ │ │ ├── sixspaces.coffee │ │ │ └── package.json │ │ └── coffeelint.json │ ├── fourspaces.coffee │ ├── clean.coffee │ ├── fourspaces.json │ ├── twospaces.warning.json │ ├── cyclo_fail.coffee │ ├── custom_rules │ │ ├── rule_module.json │ │ └── voldemort.coffee │ └── mock_node_modules │ │ └── he_who_must_not_be_named │ │ ├── index.js │ │ └── he_who_must_not_be_named.coffee ├── coffeelint.json ├── test_plusplus.coffee ├── test_backticks.coffee ├── test_throw.coffee ├── test_empty_param_list.coffee ├── test_debugger.coffee ├── test_literate.litcoffee ├── test_levels.coffee ├── test_no_stand_alone_at.coffee ├── test_no_interpolation_in_single_quotes.coffee ├── test_duplicate_key.coffee ├── test_coffeelint.coffee ├── test_comment_config.coffee ├── test_line_endings.coffee ├── test_1.5.0_plus.coffee ├── test_no_unnecessary_fat_arrows.coffee ├── test_tabs.coffee ├── test_identifier.coffee ├── test_braces.coffee ├── test_line_length.coffee ├── test_constructor_needs_parens.coffee ├── test_no_implicit_parens.coffee ├── test_newlines_after_classes.coffee ├── test_spacing.coffee ├── test_colon_assignment_spacing.coffee ├── test_semicolons.coffee ├── test_trailing.coffee ├── test_missing_fat_arrows.coffee ├── test_no_unnecessary_double_quotes.coffee ├── test_indent.coffee ├── test_cyclomatic_complexity.coffee ├── test_arrows.coffee └── test_commandline.coffee ├── .gitignore ├── vowsrunner.coffee ├── .travis.yml ├── .dir-locals.el ├── src ├── rules.coffee ├── rules │ ├── no_debugger.coffee │ ├── non_empty_constructor_needs_parens.coffee │ ├── no_plusplus.coffee │ ├── no_backticks.coffee │ ├── no_tabs.coffee │ ├── no_empty_param_list.coffee │ ├── newlines_after_classes.coffee │ ├── no_interpolation_in_single_quotes.coffee │ ├── line_endings.coffee │ ├── no_stand_alone_at.coffee │ ├── no_implicit_parens.coffee │ ├── no_unnecessary_fat_arrows.coffee │ ├── no_throwing_strings.coffee │ ├── empty_constructor_needs_parens.coffee │ ├── max_line_length.coffee │ ├── no_trailing_whitespace.coffee │ ├── camel_case_classes.coffee │ ├── cyclomatic_complexity.coffee │ ├── no_implicit_braces.coffee │ ├── duplicate_key.coffee │ ├── no_unnecessary_double_quotes.coffee │ ├── arrow_spacing.coffee │ ├── no_trailing_semicolons.coffee │ ├── colon_assignment_spacing.coffee │ ├── missing_fat_arrows.coffee │ ├── space_operators.coffee │ └── indentation.coffee ├── htmldoc.coffee ├── base_linter.coffee ├── configfinder.coffee ├── lexical_linter.coffee ├── ast_linter.coffee ├── line_linter.coffee ├── coffeelint.coffee └── coffeelint-schema.json ├── coffeelint.json ├── LICENSE ├── package.json ├── README.md ├── Cakefile ├── 3rd_party_rules.md └── generated_coffeelint.json /bin/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | .git* 3 | utils/ 4 | -------------------------------------------------------------------------------- /test/fixtures/syntax_error.coffee: -------------------------------------------------------------------------------- 1 | x = [1, 2 2 | -------------------------------------------------------------------------------- /test/fixtures/subdir/subdir/subdir.coffee: -------------------------------------------------------------------------------- 1 | here = 'is an error'; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/* 2 | node_modules 3 | *.log 4 | utils 5 | .package.json 6 | -------------------------------------------------------------------------------- /test/fixtures/findconfigtest/sevenspaces.coffee: -------------------------------------------------------------------------------- 1 | f = -> 2 | null 3 | -------------------------------------------------------------------------------- /test/fixtures/findconfigtest/package/sixspaces.coffee: -------------------------------------------------------------------------------- 1 | f = -> 2 | null 3 | -------------------------------------------------------------------------------- /test/fixtures/fourspaces.coffee: -------------------------------------------------------------------------------- 1 | x = () -> 2 | do -> 3 | return 1234 4 | -------------------------------------------------------------------------------- /vowsrunner.coffee: -------------------------------------------------------------------------------- 1 | require 'coffee-script/register' 2 | require './node_modules/vows/bin/vows' 3 | 4 | -------------------------------------------------------------------------------- /test/fixtures/clean.coffee: -------------------------------------------------------------------------------- 1 | ### 2 | A lint free script. 3 | ### 4 | 5 | x = () -> 6 | return 1234 + 4567 7 | -------------------------------------------------------------------------------- /test/coffeelint.json: -------------------------------------------------------------------------------- 1 | { 2 | "": "Force all tests under test/ to use this default config unless explicitly requested" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/fourspaces.json: -------------------------------------------------------------------------------- 1 | { 2 | "indentation" : { 3 | "level" : "error", 4 | "value" : 4 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/twospaces.warning.json: -------------------------------------------------------------------------------- 1 | { 2 | "indentation" : { 3 | "level" : "warn", 4 | "value" : 2 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.8" 4 | - "0.10" 5 | 6 | branches: 7 | only: 8 | - master 9 | - next 10 | -------------------------------------------------------------------------------- /test/fixtures/cyclo_fail.coffee: -------------------------------------------------------------------------------- 1 | x = -> 2 | 1 and 2 and 3 and 3 | 4 and 5 and 6 and 4 | 7 and 8 and 9 and 5 | 10 and 11 and 12 6 | -------------------------------------------------------------------------------- /test/fixtures/findconfigtest/coffeelint.json: -------------------------------------------------------------------------------- 1 | { 2 | "indentation" : { 3 | "level" : "error", 4 | "value" : 7 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/custom_rules/rule_module.json: -------------------------------------------------------------------------------- 1 | { 2 | "he_who_must_not_be_named" : { 3 | "module": "he_who_must_not_be_named" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/mock_node_modules/he_who_must_not_be_named/index.js: -------------------------------------------------------------------------------- 1 | require('coffee-script'); 2 | module.exports = require('./he_who_must_not_be_named'); 3 | -------------------------------------------------------------------------------- /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ;;; Directory Local Variables 2 | ;;; See Info node `(emacs) Directory Variables' for more information. 3 | 4 | ((coffee-mode 5 | (tab-width . 4) 6 | (coffee-tab-width . 4))) 7 | -------------------------------------------------------------------------------- /test/fixtures/findconfigtest/package/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "coffeelintConfig": { 4 | "indentation" : { 5 | "level" : "error", 6 | "value" : 6 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/rules.coffee: -------------------------------------------------------------------------------- 1 | 2 | # CoffeeLint error levels. 3 | ERROR = 'error' 4 | WARN = 'warn' 5 | IGNORE = 'ignore' 6 | 7 | # CoffeeLint's default rule configuration. 8 | module.exports = 9 | 10 | coffeescript_error : 11 | level : ERROR 12 | message : '' # The default coffeescript error is fine. 13 | -------------------------------------------------------------------------------- /test/fixtures/custom_rules/voldemort.coffee: -------------------------------------------------------------------------------- 1 | 2 | class Voldemort 3 | 4 | avadaKadavra: (enemy) -> 5 | enemy.die() 6 | 7 | generateHorcruxes: (scrifices = []) -> 8 | voldemort = [] 9 | for s in scrifices 10 | voldemort.push new Horcrux @avadaKadavra(s) 11 | 12 | return voldemort 13 | 14 | 15 | -------------------------------------------------------------------------------- /coffeelint.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrow_spacing": { 3 | "level": "error" 4 | }, 5 | "indentation" : { 6 | "level" : "error", 7 | "value" : 4 8 | }, 9 | 10 | "cyclomatic_complexity" : { 11 | "level" : "warn", 12 | "value" : 11 13 | }, 14 | 15 | "line_endings" : { 16 | "value" : "unix", 17 | "level" : "error" 18 | }, 19 | 20 | "space_operators" : { 21 | "level" : "error" 22 | } 23 | 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/rules/no_debugger.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class NoDebugger 3 | 4 | rule: 5 | name: 'no_debugger' 6 | level : 'warn' 7 | message : 'Debugger statements will cause warnings' 8 | description: """ 9 | This rule detects the `debugger` statement. 10 | This rule is `warn` by default. 11 | """ 12 | 13 | tokens: [ "DEBUGGER" ] 14 | 15 | lintToken : (token, tokenApi) -> 16 | return {context : "found '#{token[0]}'"} 17 | -------------------------------------------------------------------------------- /test/fixtures/mock_node_modules/he_who_must_not_be_named/he_who_must_not_be_named.coffee: -------------------------------------------------------------------------------- 1 | 2 | matcher = /Voldemort/i 3 | 4 | module.exports = class HeWhoMustNotBeNamed 5 | rule: 6 | name: 'he_who_must_not_be_named' 7 | level : 'error' 8 | message : "Forbidden variable name. The snatchers have been alerted" 9 | description: """ 10 | """ 11 | 12 | tokens: [ 'IDENTIFIER' ] 13 | 14 | lintToken : (token, tokenApi) -> 15 | if matcher.test(token[1]) 16 | true 17 | 18 | -------------------------------------------------------------------------------- /src/rules/non_empty_constructor_needs_parens.coffee: -------------------------------------------------------------------------------- 1 | 2 | ParentClass = require './empty_constructor_needs_parens.coffee' 3 | 4 | module.exports = class NonEmptyConstructorNeedsParens extends ParentClass 5 | 6 | rule: 7 | name: 'non_empty_constructor_needs_parens' 8 | level: 'ignore' 9 | message: 'Invoking a constructor without parens and with arguments' 10 | description: 11 | "Requires constructors with parameters to include the parens" 12 | 13 | handleExpectedCallStart: (expectedCallStart) -> 14 | if expectedCallStart[0] is 'CALL_START' and expectedCallStart.generated 15 | return true 16 | -------------------------------------------------------------------------------- /src/htmldoc.coffee: -------------------------------------------------------------------------------- 1 | _ = require "underscore" 2 | rules = (require "./coffeelint").RULES 3 | 4 | render = () -> 5 | rulesHTML = "" 6 | 7 | for ruleName in _.sortBy (_.keys rules), ((s) -> s) 8 | rule = rules[ruleName] 9 | rule.name = ruleName 10 | rule.description = "[no description provided]" unless rule.description 11 | console.log ruleTemplate rule 12 | 13 | ruleTemplate = _.template """ 14 | 15 | <%= name %> 16 | 17 | <%= description %> 18 |

default level: <%= level %>

19 | 20 | 21 | """ 22 | 23 | render() 24 | -------------------------------------------------------------------------------- /src/rules/no_plusplus.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class NoPlusPlus 3 | 4 | rule: 5 | name: 'no_plusplus' 6 | level : 'ignore' 7 | message : 'The increment and decrement operators are forbidden' 8 | description: """ 9 | This rule forbids the increment and decrement arithmetic operators. 10 | Some people believe the ++ and -- to be cryptic 11 | and the cause of bugs due to misunderstandings of their precedence 12 | rules. 13 | This rule is disabled by default. 14 | """ 15 | 16 | tokens: [ "++", "--" ] 17 | 18 | lintToken : (token, tokenApi) -> 19 | return {context : "found '#{token[0]}'"} 20 | -------------------------------------------------------------------------------- /src/rules/no_backticks.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class NoBackticks 3 | 4 | rule: 5 | name: 'no_backticks' 6 | level : 'error' 7 | message : 'Backticks are forbidden' 8 | description: """ 9 | Backticks allow snippets of JavaScript to be embedded in 10 | CoffeeScript. While some folks consider backticks useful in a few 11 | niche circumstances, they should be avoided because so none of 12 | JavaScript's "bad parts", like with and eval, 13 | sneak into CoffeeScript. 14 | This rule is enabled by default. 15 | """ 16 | 17 | tokens: [ "JS" ] 18 | 19 | lintToken : (token, tokenApi) -> 20 | true 21 | -------------------------------------------------------------------------------- /bin/coffeelint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var path = require("path"); 4 | var fs = require("fs"); 5 | 6 | // path.existsSync was moved to fs.existsSync node 0.6 -> 0.8 7 | var existsFn = fs.existsSync || path.existsSync; 8 | 9 | var thisdir = path.dirname(fs.realpathSync(__filename)); 10 | 11 | // This setup allows for VERY fast development. You can clear the lib 12 | // directory then run without having to recompile the coffeescript. 13 | // 14 | // I use this so vim runs the newest code while I work on CoffeeLint. -Asa 15 | commandline = path.join(thisdir, '..', "lib", "commandline.js"); 16 | if (existsFn(commandline)) { 17 | require(commandline); 18 | } else { 19 | require('coffee-script/register'); 20 | require('../src/commandline'); 21 | } 22 | -------------------------------------------------------------------------------- /src/rules/no_tabs.coffee: -------------------------------------------------------------------------------- 1 | 2 | indentationRegex = /\S/ 3 | module.exports = class NoTabs 4 | rule: 5 | name: 'no_tabs' 6 | level : 'error' 7 | message : 'Line contains tab indentation' 8 | description: """ 9 | This rule forbids tabs in indentation. Enough said. It is enabled by 10 | default. 11 | """ 12 | 13 | lintLine: (line, lineApi) -> 14 | # Only check lines that have compiled tokens. This helps 15 | # us ignore tabs in the middle of multi line strings, heredocs, etc. 16 | # since they are all reduced to a single token whose line number 17 | # is the start of the expression. 18 | indentation = line.split(indentationRegex)[0] 19 | if lineApi.lineHasToken() and '\t' in indentation 20 | true 21 | else 22 | null 23 | -------------------------------------------------------------------------------- /src/rules/no_empty_param_list.coffee: -------------------------------------------------------------------------------- 1 | 2 | 3 | module.exports = class NoEmptyParamList 4 | 5 | rule: 6 | name: 'no_empty_param_list' 7 | level : 'ignore' 8 | message : 'Empty parameter list is forbidden' 9 | description: """ 10 | This rule prohibits empty parameter lists in function definitions. 11 |
12 |             # The empty parameter list in here is unnecessary:
13 |             myFunction = () ->
14 | 
15 |             # We might favor this instead:
16 |             myFunction = ->
17 |             
18 |             
19 | Empty parameter lists are permitted by default. 20 | """ 21 | 22 | tokens: [ "PARAM_START" ] 23 | 24 | lintToken : (token, tokenApi) -> 25 | nextType = tokenApi.peek()[0] 26 | return nextType is 'PARAM_END' 27 | 28 | -------------------------------------------------------------------------------- /test/test_plusplus.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | 7 | vows.describe('plusplus').addBatch({ 8 | 9 | 'The increment and decrement operators' : 10 | 11 | topic : ''' 12 | y++ 13 | ++y 14 | x-- 15 | --x 16 | ''' 17 | 18 | 'are permitted by default' : (source) -> 19 | errors = coffeelint.lint(source) 20 | assert.isArray(errors) 21 | assert.isEmpty(errors) 22 | 23 | 'can be forbidden' : (source) -> 24 | errors = coffeelint.lint(source, {no_plusplus: {'level':'error'}}) 25 | assert.isArray(errors) 26 | assert.lengthOf(errors, 4) 27 | error = errors[0] 28 | assert.equal(error.lineNumber, 1) 29 | assert.equal(error.rule, 'no_plusplus') 30 | 31 | }).export(module) 32 | -------------------------------------------------------------------------------- /src/rules/newlines_after_classes.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class NewlinesAfterClasses 3 | 4 | rule: 5 | name: 'newlines_after_classes' 6 | value : 3 7 | level : 'ignore' 8 | message : 'Wrong count of newlines between a class and other code' 9 | description: "Checks the number of newlines between classes and other 10 | code" 11 | 12 | lintLine: (line, lineApi) -> 13 | ending = lineApi.config[@rule.name].value 14 | 15 | return null if not ending or lineApi.isLastLine() 16 | 17 | { lineNumber, context } = lineApi 18 | if not context.class.inClass and 19 | context.class.lastUnemptyLineInClass? and 20 | (lineNumber - context.class.lastUnemptyLineInClass) isnt 21 | ending 22 | got = lineNumber - context.class.lastUnemptyLineInClass 23 | return { context: "Expected #{ending} got #{got}" } 24 | 25 | null 26 | 27 | -------------------------------------------------------------------------------- /test/test_backticks.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('backticks').addBatch({ 7 | 8 | 'Backticks' : 9 | 10 | topic : "`with(document) alert(height);`" 11 | 12 | 'are forbidden by default' : (source) -> 13 | errors = coffeelint.lint(source) 14 | assert.isArray(errors) 15 | assert.lengthOf(errors, 1) 16 | error = errors[0] 17 | assert.equal(error.rule, 'no_backticks') 18 | assert.equal(error.lineNumber, 1) 19 | assert.equal(error.message, "Backticks are forbidden") 20 | 21 | 'can be permitted' : (source) -> 22 | config = {no_backticks : {level:'ignore'}} 23 | errors = coffeelint.lint(source, config) 24 | assert.isArray(errors) 25 | assert.isEmpty(errors) 26 | 27 | }).export(module) 28 | 29 | -------------------------------------------------------------------------------- /test/test_throw.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('throw').addBatch({ 7 | 8 | 'Throwing strings' : 9 | 10 | topic : ''' 11 | throw 'my error' 12 | throw "#{1234}" 13 | throw """ 14 | long string 15 | """ 16 | ''' 17 | 18 | 'is forbidden by default' : (source) -> 19 | errors = coffeelint.lint(source) 20 | assert.lengthOf(errors, 3) 21 | error = errors[0] 22 | assert.equal(error.message, 'Throwing strings is forbidden') 23 | assert.equal(error.rule, 'no_throwing_strings') 24 | 25 | 'can be permitted' : (source) -> 26 | config = {no_throwing_strings : {level : 'ignore'}} 27 | errors = coffeelint.lint(source, config) 28 | assert.isEmpty(errors) 29 | 30 | }).export(module) 31 | -------------------------------------------------------------------------------- /src/rules/no_interpolation_in_single_quotes.coffee: -------------------------------------------------------------------------------- 1 | 2 | 3 | module.exports = class NoInterpolationInSingleQuotes 4 | 5 | rule: 6 | name: 'no_interpolation_in_single_quotes' 7 | level: 'ignore' 8 | message: 'Interpolation in single quoted strings is forbidden' 9 | description: ''' 10 | This rule prohibits string interpolation in a single quoted string. 11 |
12 |             # String interpolation in single quotes is not allowed:
13 |             foo = '#{bar}'
14 | 
15 |             # Double quotes is OK of course
16 |             foo = "#{bar}"
17 |             
18 |             
19 | String interpolation in single quoted strings is permitted by 20 | default. 21 | ''' 22 | 23 | tokens: [ 'STRING' ] 24 | 25 | lintToken : (token, tokenApi) -> 26 | tokenValue = token[1] 27 | 28 | hasInterpolation = tokenValue.match(/#\{[^}]+\}/) 29 | return hasInterpolation 30 | -------------------------------------------------------------------------------- /src/rules/line_endings.coffee: -------------------------------------------------------------------------------- 1 | 2 | 3 | module.exports = class LineEndings 4 | 5 | rule: 6 | name: 'line_endings' 7 | level : 'ignore' 8 | value : 'unix' # or 'windows' 9 | message : 'Line contains incorrect line endings' 10 | description: """ 11 | This rule ensures your project uses only windows or 12 | unix line endings. This rule is disabled by default. 13 | """ 14 | 15 | lintLine: (line, lineApi) -> 16 | ending = lineApi.config[@rule.name]?.value 17 | 18 | return null if not ending or lineApi.isLastLine() or not line 19 | 20 | lastChar = line[line.length - 1] 21 | valid = if ending == 'windows' 22 | lastChar == '\r' 23 | else if ending == 'unix' 24 | lastChar != '\r' 25 | else 26 | throw new Error("unknown line ending type: #{ending}") 27 | if not valid 28 | return {context:"Expected #{ending}"} 29 | else 30 | return null 31 | -------------------------------------------------------------------------------- /test/test_empty_param_list.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('params').addBatch({ 7 | 8 | 'Empty param list' : 9 | 10 | topic : () -> 11 | ''' 12 | blah = () -> 13 | ''' 14 | 15 | 'are allowed by default' : (source) -> 16 | errors = coffeelint.lint(source) 17 | assert.isArray(errors) 18 | assert.isEmpty(errors) 19 | 20 | 'can be forbidden' : (source) -> 21 | config = {no_empty_param_list : {level:'error'}} 22 | errors = coffeelint.lint(source, config) 23 | assert.isArray(errors) 24 | assert.lengthOf(errors, 1) 25 | error = errors[0] 26 | assert.equal(error.lineNumber, 1) 27 | assert.equal(error.message, 'Empty parameter list is forbidden') 28 | assert.equal(error.rule, 'no_empty_param_list') 29 | 30 | }).export(module) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | CoffeeLint 2 | Copyright (c) 2011 Matthew Perpick 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/test_debugger.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | 7 | vows.describe('no_debugger').addBatch({ 8 | 9 | 'The debugger statement' : 10 | 11 | topic : -> 12 | ''' 13 | debugger 14 | ''' 15 | 16 | 'causes a warning when present' : (source) -> 17 | errors = coffeelint.lint(source) 18 | assert.isArray(errors) 19 | assert.lengthOf(errors, 1) 20 | error = errors[0] 21 | assert.equal(error.level, 'warn') 22 | assert.equal(error.lineNumber, 1) 23 | assert.equal(error.rule, 'no_debugger') 24 | 25 | 'can be set to error' : (source) -> 26 | errors = coffeelint.lint(source, {no_debugger: {'level':'error'}}) 27 | assert.isArray(errors) 28 | assert.isArray(errors) 29 | assert.lengthOf(errors, 1) 30 | error = errors[0] 31 | assert.equal(error.lineNumber, 1) 32 | assert.equal(error.rule, 'no_debugger') 33 | 34 | }).export(module) 35 | -------------------------------------------------------------------------------- /test/test_literate.litcoffee: -------------------------------------------------------------------------------- 1 | The post-test process involves linting all of the files under `test/`. By 2 | writing this file in Literate (style?) it verifies that literate files are 3 | automatically detected. 4 | 5 | 6 | path = require 'path' 7 | vows = require 'vows' 8 | assert = require 'assert' 9 | coffeelint = require path.join('..', 'lib', 'coffeelint') 10 | 11 | vows.describe('literate').addBatch({ 12 | 13 | Markdown uses trailing spaces to force a line break. 14 | 15 | 'Trailing whitespace in markdown' : 16 | 17 | topic : 18 | 19 | The line of code is written weird because I had trouble getting the 4 space 20 | prefix in place. 21 | 22 | """This is some `Markdown`. \n\n 23 | \n x = 1234 \n y = 1 24 | """ 25 | 26 | 'is ignored' : (source) -> 27 | 28 | The 3rd parameter here indicates that the incoming source is literate. 29 | 30 | errors = coffeelint.lint(source, {}, true) 31 | 32 | This intentionally includes trailing whitespace in code so it also verifies 33 | that the way `Markdown` spaces are stripped are not also stripping code. 34 | 35 | assert.equal(errors.length, 1) 36 | 37 | }).export(module) 38 | -------------------------------------------------------------------------------- /src/rules/no_stand_alone_at.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class NoStandAloneAt 3 | 4 | rule: 5 | name: 'no_stand_alone_at' 6 | level : 'ignore' 7 | message : '@ must not be used stand alone' 8 | description: """ 9 | This rule checks that no stand alone @ are in use, they are 10 | discouraged. Further information in CoffeScript issue 12 | #1601 13 | """ 14 | 15 | 16 | tokens: [ "@" ] 17 | 18 | lintToken : (token, tokenApi) -> 19 | nextToken = tokenApi.peek() 20 | spaced = token.spaced 21 | isIdentifier = nextToken[0] == 'IDENTIFIER' 22 | isIndexStart = nextToken[0] == 'INDEX_START' 23 | isDot = nextToken[0] == '.' 24 | 25 | # https://github.com/jashkenas/coffee-script/issues/1601 26 | # @::foo is valid, but @:: behaves inconsistently and is planned for 27 | # removal. Technically @:: is a stand alone ::, but I think it makes 28 | # sense to group it into no_stand_alone_at 29 | if nextToken[0] == '::' 30 | protoProperty = tokenApi.peek(2) 31 | isValidProtoProperty = protoProperty[0] == 'IDENTIFIER' 32 | 33 | if spaced or (not isIdentifier and not isIndexStart and 34 | not isDot and not isValidProtoProperty) 35 | return true 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/rules/no_implicit_parens.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class NoImplicitParens 3 | 4 | rule: 5 | name: 'no_implicit_parens' 6 | strict : true 7 | level : 'ignore' 8 | message : 'Implicit parens are forbidden' 9 | description: """ 10 | This rule prohibits implicit parens on function calls. 11 |
12 |             # Some folks don't like this style of coding.
13 |             myFunction a, b, c
14 | 
15 |             # And would rather it always be written like this:
16 |             myFunction(a, b, c)
17 |             
18 |             
19 | Implicit parens are permitted by default, since their use is 20 | idiomatic CoffeeScript. 21 | """ 22 | 23 | 24 | tokens: [ "CALL_END" ] 25 | 26 | lintToken : (token, tokenApi) -> 27 | if token.generated 28 | unless tokenApi.config[@rule.name].strict == false 29 | return true 30 | else 31 | # If strict mode is turned off it allows implicit parens when the 32 | # expression is spread over multiple lines. 33 | i = -1 34 | loop 35 | t = tokenApi.peek(i) 36 | if not t? or t[0] == 'CALL_START' 37 | return true 38 | if t.newLine 39 | return null 40 | i -= 1 41 | -------------------------------------------------------------------------------- /src/rules/no_unnecessary_fat_arrows.coffee: -------------------------------------------------------------------------------- 1 | any = (arr, test) -> arr.reduce ((res, elt) -> res or test elt), false 2 | 3 | module.exports = class NoUnnecessaryFatArrows 4 | 5 | rule: 6 | name: 'no_unnecessary_fat_arrows' 7 | level: 'warn' 8 | message: 'Unnecessary fat arrow' 9 | description: """ 10 | Disallows defining functions with fat arrows when `this` 11 | is not used within the function. 12 | """ 13 | 14 | lintAST: (node, @astApi) -> 15 | @lintNode node 16 | undefined 17 | 18 | lintNode: (node) -> 19 | if (@isFatArrowCode node) and (not @needsFatArrow node) 20 | error = @astApi.createError 21 | lineNumber: node.locationData.first_line + 1 22 | @errors.push error 23 | node.eachChild (child) => @lintNode child 24 | 25 | isCode: (node) -> @astApi.getNodeName(node) is 'Code' 26 | isFatArrowCode: (node) -> @isCode(node) and node.bound 27 | isValue: (node) -> @astApi.getNodeName(node) is 'Value' 28 | 29 | isThis: (node) => 30 | @isValue(node) and node.base.value is 'this' 31 | 32 | 33 | needsFatArrow: (node) => 34 | @isCode(node) and ( 35 | any(node.params, (param) => param.contains(@isThis)?) or 36 | node.body.contains(@isThis)? or 37 | node.body.contains((child) => 38 | @isFatArrowCode(child) and @needsFatArrow(child))? 39 | ) 40 | -------------------------------------------------------------------------------- /test/test_levels.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('levels').addBatch({ 7 | 8 | 'CoffeeLint' : 9 | 10 | topic : "abc = 123;", 11 | 12 | 'can ignore errors' : (source) -> 13 | config = 14 | no_trailing_semicolons : {level: 'ignore'} 15 | errors = coffeelint.lint(source, config) 16 | assert.isEmpty(errors) 17 | 18 | 'can return warnings' : (source) -> 19 | config = 20 | no_trailing_semicolons : {level: 'warn'} 21 | errors = coffeelint.lint(source, config) 22 | assert.isArray(errors) 23 | assert.lengthOf(errors, 1) 24 | error = errors[0] 25 | assert.equal(error.level, 'warn') 26 | 27 | 'can return errors' : (source) -> 28 | config = 29 | no_trailing_semicolons : {level: 'error'} 30 | errors = coffeelint.lint(source, config) 31 | assert.isArray(errors) 32 | assert.lengthOf(errors, 1) 33 | error = errors[0] 34 | assert.equal(error.level, 'error') 35 | 36 | 'catches unknown levels' : (source) -> 37 | 38 | config = 39 | no_trailing_semicolons : {level: 'foobar'} 40 | assert.throws () -> 41 | coffeelint.lint(source, config) 42 | 43 | 44 | }).export(module) 45 | -------------------------------------------------------------------------------- /src/rules/no_throwing_strings.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class NoThrowingStrings 3 | 4 | rule: 5 | name: 'no_throwing_strings' 6 | level : 'error' 7 | message : 'Throwing strings is forbidden' 8 | description: """ 9 | This rule forbids throwing string literals or interpolations. While 10 | JavaScript (and CoffeeScript by extension) allow any expression to 11 | be thrown, it is best to only throw Error objects, 14 | because they contain valuable debugging information like the stack 15 | trace. Because of JavaScript's dynamic nature, CoffeeLint cannot 16 | ensure you are always throwing instances of Error. It will 17 | only catch the simple but real case of throwing literal strings. 18 |
19 |             # CoffeeLint will catch this:
20 |             throw "i made a boo boo"
21 | 
22 |             # ... but not this:
23 |             throw getSomeString()
24 |             
25 |             
26 | This rule is enabled by default. 27 | """ 28 | 29 | tokens: [ "THROW" ] 30 | 31 | lintToken : (token, tokenApi) -> 32 | [n1, n2] = [tokenApi.peek(), tokenApi.peek(2)] 33 | # Catch literals and string interpolations, which are wrapped in 34 | # parens. 35 | nextIsString = n1[0] == 'STRING' or (n1[0] == '(' and n2[0] == 'STRING') 36 | return nextIsString 37 | -------------------------------------------------------------------------------- /test/test_no_stand_alone_at.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('stand alone @').addBatch({ 7 | 8 | 'Stand alone @' : 9 | 10 | topic : () -> 11 | """ 12 | @alright 13 | @ .error 14 | @ok() 15 | @ notok 16 | @[ok] 17 | @.ok 18 | not(@).ok 19 | @::ok 20 | @:: #notok 21 | """ 22 | 23 | 'are allowed by default' : (source) -> 24 | errors = coffeelint.lint(source) 25 | assert.isArray(errors) 26 | assert.isEmpty(errors) 27 | 28 | 'can be forbidden' : (source) -> 29 | config = {no_stand_alone_at : {level:'error'}} 30 | errors = coffeelint.lint(source, config) 31 | assert.isArray(errors) 32 | assert.lengthOf(errors, 4) 33 | error = errors[0] 34 | assert.equal(error.lineNumber, 2) 35 | assert.equal(error.rule, 'no_stand_alone_at') 36 | error = errors[1] 37 | assert.equal(error.lineNumber, 4) 38 | assert.equal(error.rule, 'no_stand_alone_at') 39 | error = errors[2] 40 | assert.equal(error.lineNumber, 7) 41 | assert.equal(error.rule, 'no_stand_alone_at') 42 | error = errors[3] 43 | assert.equal(error.lineNumber, 9) 44 | assert.equal(error.rule, 'no_stand_alone_at') 45 | 46 | }).export(module) 47 | -------------------------------------------------------------------------------- /src/rules/empty_constructor_needs_parens.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class EmptyConstructorNeedsParens 3 | 4 | rule: 5 | name: 'empty_constructor_needs_parens' 6 | level: 'ignore' 7 | message: 'Invoking a constructor without parens and without arguments' 8 | description: 9 | "Requires constructors with no parameters to include the parens" 10 | 11 | tokens: [ 'UNARY' ] 12 | 13 | # Return an error if the given indentation token is not correct. 14 | lintToken: (token, tokenApi) -> 15 | if token[1] is 'new' 16 | # Find the last chained identifier, e.g. Bar in new foo.bar.Bar(). 17 | identifierIndex = 1 18 | loop 19 | expectedIdentifier = tokenApi.peek(identifierIndex) 20 | expectedCallStart = tokenApi.peek(identifierIndex + 1) 21 | if expectedIdentifier?[0] is 'IDENTIFIER' 22 | if expectedCallStart?[0] is '.' 23 | identifierIndex += 2 24 | continue 25 | break 26 | 27 | # The callStart is generated if your parameters are all on the same 28 | # line with implicit parens, and if your parameters start on the 29 | # next line, but is missing if there are no params and no parens. 30 | if expectedIdentifier?[0] is 'IDENTIFIER' and expectedCallStart? 31 | return @handleExpectedCallStart expectedCallStart 32 | 33 | handleExpectedCallStart: (expectedCallStart) -> 34 | if expectedCallStart[0] isnt 'CALL_START' 35 | return true 36 | -------------------------------------------------------------------------------- /src/rules/max_line_length.coffee: -------------------------------------------------------------------------------- 1 | 2 | regexes = 3 | literateComment: /// 4 | ^ 5 | \#\s # This is prefixed on MarkDown lines. 6 | /// 7 | longUrlComment : /// 8 | ^\s*\# # indentation, up to comment 9 | \s* 10 | http[^\s]+$ # Link that takes up the rest of the line without spaces. 11 | /// 12 | 13 | module.exports = class MaxLineLength 14 | 15 | rule: 16 | name: 'max_line_length' 17 | value: 80 18 | level : 'error' 19 | limitComments: true 20 | message : 'Line exceeds maximum allowed length' 21 | description: """ 22 | This rule imposes a maximum line length on your code. Python's style 24 | guide does a good job explaining why you might want to limit the 25 | length of your lines, though this is a matter of taste. 26 | 27 | Lines can be no longer than eighty characters by default. 28 | """ 29 | 30 | lintLine: (line, lineApi) -> 31 | max = lineApi.config[@rule.name]?.value 32 | limitComments = lineApi.config[@rule.name]?.limitComments 33 | 34 | lineLength = line.length 35 | if lineApi.isLiterate() and regexes.literateComment.test(line) 36 | lineLength -= 2 37 | 38 | if max and max < lineLength and not regexes.longUrlComment.test(line) 39 | 40 | unless limitComments 41 | if lineApi.getLineTokens().length is 0 42 | return 43 | 44 | return { 45 | context: "Length is #{lineLength}, max is #{max}" 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coffeelint", 3 | "description": "Lint your CoffeeScript", 4 | "version": "1.2.0", 5 | "homepage": "http://www.coffeelint.org", 6 | "keywords": [ 7 | "lint", 8 | "coffeescript", 9 | "coffee-script" 10 | ], 11 | "author": "Matthew Perpick ", 12 | "main": "./lib/coffeelint.js", 13 | "engines": { 14 | "node": ">=0.8.0" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git://github.com/clutchski/coffeelint.git" 19 | }, 20 | "bin": { 21 | "coffeelint": "./bin/coffeelint" 22 | }, 23 | "dependencies": { 24 | "optimist": ">=0.2.8", 25 | "coffee-script": "~1.7", 26 | "glob": ">=3.1.9", 27 | "browserify": "~3.37", 28 | "coffeeify": "~0.6.0" 29 | }, 30 | "devDependencies": { 31 | "vows": ">=0.6.0", 32 | "underscore": ">=1.4.4" 33 | }, 34 | "licenses": [ 35 | { 36 | "type": "MIT", 37 | "url": "http://github.com/clutchski/coffeelint/raw/master/LICENSE" 38 | } 39 | ], 40 | "scripts": { 41 | "pretest": "cake compile", 42 | "test": "coffee vowsrunner.coffee --spec test/*.coffee test/*.litcoffee", 43 | "posttest": "npm run lint", 44 | "prepublish": "cake prepublish", 45 | "publish": "cake publish", 46 | "install": "cake install", 47 | "lint": "cake compile && ./bin/coffeelint -f coffeelint.json src/*.coffee test/*.coffee test/*.litcoffee", 48 | "lint-csv": "cake compile && ./bin/coffeelint --csv -f coffeelint.json src/*.coffee test/*.coffee", 49 | "lint-jslint": "cake compile && ./bin/coffeelint --jslint -f coffeelint.json src/*.coffee test/*.coffee", 50 | "compile": "cake compile" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/test_no_interpolation_in_single_quotes.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('no_interpolation_in_single_quotes').addBatch({ 7 | 8 | 'Interpolation in single quotes' : 9 | 10 | topic : () -> 11 | ''' 12 | foo = '#{inter}foo#{polation}' 13 | ''' 14 | 15 | 'interpolation in single quotes is allowed by default' : (source) -> 16 | errors = coffeelint.lint(source) 17 | assert.isArray(errors) 18 | assert.isEmpty(errors) 19 | 20 | 'interpolation in single quotes can be forbidden' : (source) -> 21 | config = {no_interpolation_in_single_quotes : {level:'error'}} 22 | errors = coffeelint.lint(source, config) 23 | assert.lengthOf(errors, 1) 24 | error = errors[0] 25 | assert.equal(error.lineNumber, 1) 26 | assert.equal(error.message, 27 | 'Interpolation in single quoted strings is forbidden' 28 | ) 29 | assert.equal(error.rule, 'no_interpolation_in_single_quotes') 30 | 31 | 32 | 'Interpolation in double quotes' : 33 | 34 | topic : () -> 35 | ''' 36 | foo = "#{inter}foo#{polation}" 37 | ''' 38 | 39 | 'interpolation in double quotes is always allowed' : (source) -> 40 | config = {no_interpolation_in_single_quotes : {level:'error'}} 41 | errors = coffeelint.lint(source, config) 42 | assert.isArray(errors) 43 | assert.isEmpty(errors) 44 | 45 | 46 | }).export(module) 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | CoffeeLint 2 | ========== 3 | 4 | CoffeeLint is a style checker that helps keep CoffeeScript code 5 | clean and consistent. 6 | 7 | For guides on installing, using and configuring CoffeeLint, head over 8 | [here](http://www.coffeelint.org). 9 | 10 | To suggest a feature, report a bug, or general discussion, head over 11 | [here](http://github.com/clutchski/coffeelint/issues/). 12 | 13 | ## Contributing 14 | 15 | * New rules should be set to a `warn` level. Developers will expect new changes to NOT break their existing workflow, so unless your change is extremely usefull, default to `warn`. Expect discussion if you choose to use `error`. 16 | 17 | * Look at existing rules and test structures when deciding how to name your rule. `no_foo.coffee` is used for many tests designed to catch specific errors, whereas `foo.coffee` is used for tests that are designed to enforce formatting and syntax. 18 | 19 | ### Steps 20 | 21 | 1. Fork the repo locally. 22 | 2. Run `npm install` to get dependencies. 23 | 3. Create your rule in a single file as `src/rules/your_rule_here.coffee`, using the existing 24 | rules as a guide. 25 | You may examine the AST and tokens using 26 | [http://asaayers.github.io/clfiddle/](http://asaayers.github.io/clfiddle/). 27 | 4. Add your test file `my_test.coffee` to the `test` directory. 28 | 5. Register your rule in `src/coffeelint.coffee`. 29 | 6. Run your test using `coffee vowsrunner.coffee --spec test/your_test_here.coffee`. 30 | 7. Run the whole tests suite using `npm test`. 31 | 8. Squash all commits into a single commit when done. 32 | 9. Submit a pull request. 33 | 34 | [![Build Status](https://secure.travis-ci.org/clutchski/coffeelint.png)](http://travis-ci.org/clutchski/coffeelint) 35 | 36 | -------------------------------------------------------------------------------- /src/rules/no_trailing_whitespace.coffee: -------------------------------------------------------------------------------- 1 | 2 | regexes = 3 | trailingWhitespace : /[^\s]+[\t ]+\r?$/ 4 | onlySpaces: /^[\t ]+\r?$/ 5 | lineHasComment : /^\s*[^\#]*\#/ 6 | 7 | module.exports = class NoTrailingWhitespace 8 | 9 | rule: 10 | name: 'no_trailing_whitespace' 11 | level : 'error' 12 | message : 'Line ends with trailing whitespace' 13 | allowed_in_comments : false 14 | allowed_in_empty_lines: true 15 | description: """ 16 | This rule forbids trailing whitespace in your code, since it is 17 | needless cruft. It is enabled by default. 18 | """ 19 | 20 | lintLine: (line, lineApi) -> 21 | unless lineApi.config['no_trailing_whitespace']?.allowed_in_empty_lines 22 | if regexes.onlySpaces.test(line) 23 | return true 24 | 25 | if regexes.trailingWhitespace.test(line) 26 | # By default only the regex above is needed. 27 | unless lineApi.config['no_trailing_whitespace']?.allowed_in_comments 28 | return true 29 | 30 | line = line 31 | tokens = lineApi.tokensByLine[lineApi.lineNumber] 32 | 33 | # If we're in a block comment there won't be any tokens on this 34 | # line. Some previous line holds the token spanning multiple lines. 35 | if !tokens 36 | return null 37 | 38 | # To avoid confusion when a string might contain a "#", every string 39 | # on this line will be removed. before checking for a comment 40 | for str in (token[1] for token in tokens when token[0] == 'STRING') 41 | line = line.replace(str, 'STRING') 42 | 43 | if !regexes.lineHasComment.test(line) 44 | return true 45 | -------------------------------------------------------------------------------- /test/test_duplicate_key.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | 7 | vows.describe('linelength').addBatch({ 8 | 9 | 'Duplicate Keys' : 10 | 11 | topic : """ 12 | class SomeThing 13 | getConfig: -> 14 | one = 1 15 | one = 5 16 | @config = 17 | keyA: one 18 | keyB: one 19 | keyA: 2 20 | getConfig: -> 21 | @config = 22 | foo: 1 23 | 24 | @getConfig: -> 25 | config = 26 | foo: 1 27 | """ 28 | 29 | 'should error by default' : (source) -> 30 | # Moved to a variable to avoid lines being too long. 31 | message = "Duplicate key defined in object or class" 32 | errors = coffeelint.lint(source) 33 | # Verify the two actual duplicate keys are found and it is not 34 | # mistaking @getConfig as a duplicate key 35 | assert.equal(errors.length, 2) 36 | error = errors[0] 37 | assert.equal(error.lineNumber, 8) # 2nd getA 38 | assert.equal(error.message, message) 39 | assert.equal(error.rule, 'duplicate_key') 40 | error = errors[1] 41 | assert.equal(error.lineNumber, 9) # 2nd getConfig 42 | assert.equal(error.message, message) 43 | assert.equal(error.rule, 'duplicate_key') 44 | 45 | 'is optional' : (source) -> 46 | for length in [null, 0, false] 47 | config = 48 | duplicate_key : 49 | level: 'ignore' 50 | errors = coffeelint.lint(source, config) 51 | assert.isEmpty(errors) 52 | 53 | }).export(module) 54 | 55 | -------------------------------------------------------------------------------- /Cakefile: -------------------------------------------------------------------------------- 1 | fs = require 'fs' 2 | browserify = require 'browserify' 3 | CoffeeScript = require 'coffee-script' 4 | 5 | 6 | copySync = (src, dest) -> 7 | fs.writeFileSync dest, fs.readFileSync(src) 8 | 9 | coffeeSync = (input, output) -> 10 | coffee = fs.readFileSync(input).toString() 11 | fs.writeFileSync output, CoffeeScript.compile(coffee) 12 | 13 | 14 | task 'compile', 'Compile Coffeelint', -> 15 | console.log 'Compiling Coffeelint...' 16 | fs.mkdirSync 'lib' unless fs.existsSync 'lib' 17 | invoke 'compile:browserify' 18 | invoke 'compile:commandline' 19 | 20 | task 'compile:commandline', 'Compiles commandline.js', -> 21 | coffeeSync 'src/commandline.coffee', 'lib/commandline.js' 22 | coffeeSync 'src/configfinder.coffee', 'lib/configfinder.js' 23 | 24 | task 'compile:browserify', 'Uses browserify to compile coffeelint', -> 25 | b = browserify [ './src/coffeelint.coffee' ] 26 | opts = 27 | standalone: 'coffeelint' 28 | b.transform require('coffeeify') 29 | b.bundle(opts).pipe fs.createWriteStream('lib/coffeelint.js') 30 | 31 | task 'prepublish', 'Prepublish', -> 32 | { npm_config_argv } = process.env 33 | if npm_config_argv? and JSON.parse(npm_config_argv).original[0] is 'install' 34 | return 35 | 36 | copySync 'package.json', '.package.json' 37 | packageJson = require './package.json' 38 | 39 | delete packageJson.dependencies.browserify 40 | delete packageJson.dependencies.coffeeify 41 | delete packageJson.scripts.install 42 | 43 | fs.writeFileSync 'package.json', JSON.stringify(packageJson, undefined, 2) 44 | 45 | invoke 'compile' 46 | 47 | task 'publish', 'publish', -> 48 | copySync '.package.json', 'package.json' 49 | 50 | task 'install', 'Install', -> 51 | unless require("fs").existsSync("lib/commandline.js") 52 | invoke 'compile' 53 | 54 | 55 | -------------------------------------------------------------------------------- /test/test_coffeelint.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | 7 | vows.describe('coffeelint').addBatch({ 8 | 9 | "CoffeeLint's version number" : 10 | 11 | topic : coffeelint.VERSION 12 | 13 | 'exists' : (version) -> 14 | assert.isString(version) 15 | 16 | "CoffeeLint's errors" : 17 | 18 | topic : () -> coffeelint.lint """ 19 | a = () ->\t 20 | 1234 21 | """ 22 | 23 | 'are sorted by line number' : (errors) -> 24 | assert.isArray(errors) 25 | assert.lengthOf(errors, 2) 26 | assert.equal(errors[1].lineNumber, 2) 27 | assert.equal(errors[0].lineNumber, 1) 28 | 29 | "Errors in the source" : 30 | 31 | topic : ''' 32 | fruits = [orange, apple, banana] 33 | switch 'a' 34 | when in fruits 35 | something 36 | ''' 37 | 38 | 'are reported' : (source) -> 39 | errors = coffeelint.lint(source) 40 | assert.isArray(errors) 41 | assert.lengthOf(errors, 1) 42 | error = errors[0] 43 | assert.equal(error.rule, 'coffeescript_error') 44 | assert.equal(error.lineNumber, 3) 45 | 46 | if error.message.indexOf('on line') != -1 47 | m = "Error: Parse error on line 3: Unexpected 'RELATION'" 48 | else if error.message.indexOf('SyntaxError:') != -1 49 | m = "SyntaxError: unexpected RELATION" 50 | else 51 | # CoffeeLint changed the format to be more complex. I don't 52 | # think an exact match really needs to be verified. 53 | return 54 | 55 | assert.equal(error.message, m) 56 | 57 | }).export(module) 58 | -------------------------------------------------------------------------------- /src/rules/camel_case_classes.coffee: -------------------------------------------------------------------------------- 1 | 2 | regexes = 3 | camelCase : /^[A-Z][a-zA-Z\d]*$/ 4 | 5 | module.exports = class CamelCaseClasses 6 | 7 | rule: 8 | name: 'camel_case_classes' 9 | level : 'error' 10 | message : 'Class names should be camel cased' 11 | description: """ 12 | This rule mandates that all class names are CamelCased. Camel 13 | casing class names is a generally accepted way of distinguishing 14 | constructor functions - which require the 'new' prefix to behave 15 | properly - from plain old functions. 16 |
17 |             # Good!
18 |             class BoaConstrictor
19 | 
20 |             # Bad!
21 |             class boaConstrictor
22 |             
23 |             
24 | This rule is enabled by default. 25 | """ 26 | 27 | tokens: [ 'CLASS' ] 28 | 29 | lintToken: (token, tokenApi) -> 30 | # TODO: you can do some crazy shit in CoffeeScript, like 31 | # class func().ClassName. Don't allow that. 32 | 33 | # Don't try to lint the names of anonymous classes. 34 | if token.newLine? or tokenApi.peek()[0] in ['INDENT', 'EXTENDS'] 35 | return null 36 | 37 | # It's common to assign a class to a global namespace, e.g. 38 | # exports.MyClassName, so loop through the next tokens until 39 | # we find the real identifier. 40 | className = null 41 | offset = 1 42 | until className 43 | if tokenApi.peek(offset + 1)?[0] == '.' 44 | offset += 2 45 | else if tokenApi.peek(offset)?[0] == '@' 46 | offset += 1 47 | else 48 | className = tokenApi.peek(offset)[1] 49 | 50 | # Now check for the error. 51 | if not regexes.camelCase.test(className) 52 | return {context: "class name: #{className}"} 53 | -------------------------------------------------------------------------------- /src/rules/cyclomatic_complexity.coffee: -------------------------------------------------------------------------------- 1 | module.exports = class NoTabs 2 | 3 | rule: 4 | name: 'cyclomatic_complexity' 5 | value : 10 6 | level : 'ignore' 7 | message : 'The cyclomatic complexity is too damn high' 8 | description : 'Examine the complexity of your application.' 9 | 10 | # returns the "complexity" value of the current node. 11 | getComplexity : (node) -> 12 | name = @astApi.getNodeName node 13 | complexity = if name in ['If', 'While', 'For', 'Try'] 14 | 1 15 | else if name == 'Op' and node.operator in ['&&', '||'] 16 | 1 17 | else if name == 'Switch' 18 | node.cases.length 19 | else 20 | 0 21 | return complexity 22 | 23 | lintAST : (node, @astApi) -> 24 | @lintNode node 25 | undefined 26 | 27 | # Lint the AST node and return its cyclomatic complexity. 28 | lintNode : (node, line) -> 29 | 30 | # Get the complexity of the current node. 31 | name = @astApi?.getNodeName node 32 | complexity = @getComplexity(node) 33 | 34 | # Add the complexity of all child's nodes to this one. 35 | node.eachChild (childNode) => 36 | nodeLine = childNode.locationData.first_line 37 | complexity += @lintNode(childNode, nodeLine) if childNode 38 | 39 | rule = @astApi.config[@rule.name] 40 | 41 | # If the current node is a function, and it's over our limit, add an 42 | # error to the list. 43 | if name == 'Code' and complexity >= rule.value 44 | error = @astApi.createError { 45 | context: complexity + 1 46 | lineNumber: line + 1 47 | lineNumberEnd: node.locationData.last_line + 1 48 | } 49 | @errors.push error if error 50 | 51 | # Return the complexity for the benefit of parent nodes. 52 | return complexity 53 | -------------------------------------------------------------------------------- /src/rules/no_implicit_braces.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class NoImplicitBraces 3 | 4 | rule: 5 | name: 'no_implicit_braces' 6 | level : 'ignore' 7 | message : 'Implicit braces are forbidden' 8 | strict: true 9 | description: """ 10 | This rule prohibits implicit braces when declaring object literals. 11 | Implicit braces can make code more difficult to understand, 12 | especially when used in combination with optional parenthesis. 13 |
14 |             # Do you find this code ambiguous? Is it a
15 |             # function call with three arguments or four?
16 |             myFunction a, b, 1:2, 3:4
17 | 
18 |             # While the same code written in a more
19 |             # explicit manner has no ambiguity.
20 |             myFunction(a, b, {1:2, 3:4})
21 |             
22 |             
23 | Implicit braces are permitted by default, since their use is 24 | idiomatic CoffeeScript. 25 | """ 26 | 27 | tokens: [ "{" ] 28 | 29 | lintToken: (token, tokenApi) -> 30 | if token.generated 31 | 32 | # If strict mode is turned off it allows implicit braces when the 33 | # object is declared over multiple lines. 34 | unless tokenApi.config[@rule.name].strict 35 | [ previousToken ] = tokenApi.peek(-1) 36 | if previousToken is 'INDENT' 37 | return 38 | 39 | @isPartOfClass(tokenApi) 40 | 41 | 42 | isPartOfClass: (tokenApi) -> 43 | # Peek back to the last line break. If there is a class 44 | # definition, ignore the generated brace. 45 | i = -1 46 | loop 47 | t = tokenApi.peek(i) 48 | if not t? or t[0] == 'TERMINATOR' 49 | return true 50 | if t[0] == 'CLASS' 51 | return null 52 | i -= 1 53 | -------------------------------------------------------------------------------- /src/base_linter.coffee: -------------------------------------------------------------------------------- 1 | # Patch the source properties onto the destination. 2 | extend = (destination, sources...) -> 3 | for source in sources 4 | (destination[k] = v for k, v of source) 5 | return destination 6 | 7 | # Patch any missing attributes from defaults to source. 8 | defaults = (source, defaults) -> 9 | extend({}, defaults, source) 10 | 11 | module.exports = class BaseLinter 12 | 13 | constructor: (@source, @config, rules) -> 14 | @setupRules rules 15 | 16 | isObject: (obj) -> 17 | obj is Object(obj) 18 | 19 | # Create an error object for the given rule with the given 20 | # attributes. 21 | createError: (ruleName, attrs = {}) -> 22 | 23 | # Level should default to what's in the config, but can be overridden. 24 | attrs.level ?= @config[ruleName].level 25 | 26 | level = attrs.level 27 | if level not in ['ignore', 'warn', 'error'] 28 | throw new Error("unknown level #{level}") 29 | 30 | if level in ['error', 'warn'] 31 | attrs.rule = ruleName 32 | return defaults(attrs, @config[ruleName]) 33 | else 34 | null 35 | 36 | acceptRule: (rule) -> 37 | throw new Error "acceptRule needs to be overridden in the subclass" 38 | 39 | # Only rules that have a level of error or warn will even get constructed. 40 | setupRules: (rules) -> 41 | @rules = [] 42 | for name, RuleConstructor of rules 43 | level = @config[name].level 44 | if level in ['error', 'warn'] 45 | rule = new RuleConstructor this, @config 46 | if @acceptRule(rule) 47 | @rules.push rule 48 | else if level isnt 'ignore' 49 | throw new Error("unknown level #{level}") 50 | 51 | normalizeResult: (p, result) -> 52 | if result is true 53 | return @createError p.rule.name 54 | if @isObject result 55 | return @createError p.rule.name, result 56 | -------------------------------------------------------------------------------- /test/test_comment_config.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('comment config').addBatch({ 7 | 8 | 'Disable statements' : 9 | 10 | topic : () -> 11 | """ 12 | # coffeelint: disable=no_trailing_semicolons 13 | a 'you get a semi-colon'; 14 | b 'you get a semi-colon'; 15 | # coffeelint: enable=no_trailing_semicolons 16 | c 'everybody gets a semi-colon'; 17 | """ 18 | 19 | 'can disable rules in your config' : (source) -> 20 | config = 21 | no_trailing_semicolons : {level: 'error'} 22 | errors = coffeelint.lint(source, config) 23 | assert.equal(errors.length, 1) 24 | 25 | 'Enable statements' : 26 | 27 | topic : () -> 28 | """ 29 | # coffeelint: enable=no_implicit_parens 30 | a 'implicit parens here' 31 | b 'implicit parens', 'also here' 32 | # coffeelint: disable=no_implicit_parens 33 | c 'implicit parens allowed here' 34 | """ 35 | 36 | 'can enable rules not in your config' : (source) -> 37 | errors = coffeelint.lint(source) 38 | assert.equal(errors.length, 2) 39 | 40 | 'Enable all statements' : 41 | topic : () -> 42 | """ 43 | # coffeelint: disable=no_trailing_semicolons,no_implicit_parens 44 | a 'you get a semi-colon'; 45 | b 'you get a semi-colon'; 46 | # coffeelint: enable 47 | c 'everybody gets a semi-colon'; 48 | """ 49 | 50 | 'will re-enable all rules in your config' : (source) -> 51 | config = 52 | no_implicit_parens : {level: 'error'} 53 | no_trailing_semicolons : {level: 'error'} 54 | errors = coffeelint.lint(source, config) 55 | assert.equal(errors.length, 2) 56 | 57 | }).export(module) 58 | -------------------------------------------------------------------------------- /test/test_line_endings.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('line endings').addBatch({ 7 | 8 | 'Unix line endings' : 9 | 10 | topic : 'x = 1\ny=2' 11 | 12 | 'are allowed by default' : (source) -> 13 | errors = coffeelint.lint(source) 14 | assert.isEmpty(errors) 15 | 16 | 'can be forbidden' : (source) -> 17 | config = {line_endings : {level:'error', value:'windows'}} 18 | errors = coffeelint.lint(source, config) 19 | assert.isArray(errors) 20 | assert.lengthOf(errors, 1) 21 | error = errors[0] 22 | assert.equal(error.lineNumber, 1) 23 | assert.equal(error.message, 'Line contains incorrect line endings') 24 | assert.equal(error.context, 'Expected windows') 25 | assert.equal(error.rule, 'line_endings') 26 | 27 | 'Windows line endings' : 28 | 29 | topic : 'x = 1\r\ny=2' 30 | 31 | 'are allowed by default' : (source) -> 32 | errors = coffeelint.lint(source) 33 | assert.isEmpty(errors) 34 | 35 | 'can be forbidden' : (source) -> 36 | config = {line_endings : {level:'error', value:'unix'}} 37 | errors = coffeelint.lint(source, config) 38 | assert.isArray(errors) 39 | assert.lengthOf(errors, 1) 40 | error = errors[0] 41 | assert.equal(error.lineNumber, 1) 42 | assert.equal(error.message, 'Line contains incorrect line endings') 43 | assert.equal(error.context, 'Expected unix') 44 | assert.equal(error.rule, 'line_endings') 45 | 46 | 'Unknown line endings' : 47 | 48 | topic : 'x = 1\ny=2' 49 | 50 | 'throw errors' : (source) -> 51 | config = 52 | line_endings : {level: 'error', value: 'osx'} 53 | assert.throws () -> 54 | coffeelint.lint(source, config) 55 | 56 | 57 | }).export(module) 58 | -------------------------------------------------------------------------------- /src/configfinder.coffee: -------------------------------------------------------------------------------- 1 | ### 2 | Helpers for finding CoffeeLint config in standard locations, similar to how 3 | JSHint does. 4 | ### 5 | 6 | fs = require 'fs' 7 | path = require 'path' 8 | 9 | # Cache for findFile 10 | findFileResults = {} 11 | 12 | # Searches for a file with a specified name starting with 'dir' and going all 13 | # the way up either until it finds the file or hits the root. 14 | findFile = (name, dir) -> 15 | dir = dir or process.cwd() 16 | filename = path.normalize(path.join(dir, name)) 17 | return findFileResults[filename] if findFileResults[filename] 18 | parent = path.resolve(dir, "../") 19 | if fs.existsSync(filename) 20 | findFileResults[filename] = filename 21 | else if dir is parent 22 | findFileResults[filename] = null 23 | else 24 | findFile name, parent 25 | 26 | # Possibly find CoffeeLint configuration within a package.json file. 27 | loadNpmConfig = (dir) -> 28 | fp = findFile("package.json", dir) 29 | loadJSON(fp).coffeelintConfig if fp 30 | 31 | # Parse a JSON file gracefully. 32 | loadJSON = (filename) -> 33 | try 34 | JSON.parse(fs.readFileSync(filename).toString()) 35 | catch e 36 | console.error "Could not load JSON file '%s': %s", filename, e 37 | null 38 | 39 | # Tries to find a configuration file in either project directory (if file is 40 | # given), as either the package.json's 'coffeelintConfig' property, or a project 41 | # specific 'coffeelint.json' or a global 'coffeelint.json' in the home 42 | # directory. 43 | exports.getConfig = (filename = null) -> 44 | if filename 45 | dir = path.dirname(path.resolve(filename)) 46 | npmConfig = loadNpmConfig(dir) 47 | return npmConfig if npmConfig 48 | projConfig = findFile("coffeelint.json", dir) 49 | return loadJSON(projConfig) if projConfig 50 | envs = process.env.HOME or process.env.HOMEPATH or process.env.USERPROFILE 51 | home = path.normalize(path.join(envs, "coffeelint.json")) 52 | if fs.existsSync(home) 53 | console.log 'loaded', home 54 | return loadJSON(home) 55 | -------------------------------------------------------------------------------- /src/rules/duplicate_key.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class DuplicateKey 3 | 4 | rule: 5 | # I don't know of any legitimate reason to define duplicate keys in an 6 | # object. It seems to always be a mistake, it's also a syntax error in 7 | # strict mode. 8 | # See http://jslinterrors.com/duplicate-key-a/ 9 | name: 'duplicate_key' 10 | level: 'error' 11 | message: 'Duplicate key defined in object or class' 12 | description: 13 | "Prevents defining duplicate keys in object literals and classes" 14 | 15 | tokens: [ 'IDENTIFIER', "{","}" ] 16 | 17 | constructor: -> 18 | @braceScopes = [] # A stack tracking keys defined in nexted scopes. 19 | 20 | lintToken : ([type], tokenApi) -> 21 | 22 | if type in [ "{","}" ] 23 | @lintBrace arguments... 24 | return undefined 25 | 26 | if type is "IDENTIFIER" 27 | @lintIdentifier arguments... 28 | 29 | lintIdentifier : (token, tokenApi) -> 30 | key = token[1] 31 | 32 | # Class names might not be in a scope 33 | return null if not @currentScope? 34 | nextToken = tokenApi.peek(1) 35 | 36 | # Exit if this identifier isn't being assigned. A and B 37 | # are identifiers, but only A should be examined: 38 | # A = B 39 | return null if nextToken[1] isnt ':' 40 | previousToken = tokenApi.peek(-1) 41 | 42 | # Assigning "@something" and "something" are not the same thing 43 | key = "@#{key}" if previousToken[0] == '@' 44 | 45 | # Added a prefix to not interfere with things like "constructor". 46 | key = "identifier-#{key}" 47 | if @currentScope[key] 48 | return true 49 | else 50 | @currentScope[key] = token 51 | null 52 | 53 | lintBrace : (token) -> 54 | if token[0] == '{' 55 | @braceScopes.push @currentScope if @currentScope? 56 | @currentScope = {} 57 | else 58 | @currentScope = @braceScopes.pop() 59 | 60 | return null 61 | -------------------------------------------------------------------------------- /src/rules/no_unnecessary_double_quotes.coffee: -------------------------------------------------------------------------------- 1 | 2 | 3 | module.exports = class NoUnnecessaryDoubleQuotes 4 | 5 | rule: 6 | name: 'no_unnecessary_double_quotes' 7 | level : 'ignore' 8 | message : 'Unnecessary double quotes are forbidden' 9 | description: ''' 10 | This rule prohibits double quotes unless string interpolation is 11 | used or the string contains single quotes. 12 |
13 |             # Double quotes are discouraged:
14 |             foo = "bar"
15 | 
16 |             # Unless string interpolation is used:
17 |             foo = "#{bar}baz"
18 | 
19 |             # Or they prevent cumbersome escaping:
20 |             foo = "I'm just following the 'rules'"
21 |             
22 |             
23 | Double quotes are permitted by default. 24 | ''' 25 | 26 | tokens: [ 'STRING' ] 27 | 28 | lintToken : (token, tokenApi) -> 29 | tokenValue = token[1] 30 | 31 | stringValue = tokenValue.match(/^\"(.*)\"$/) 32 | return false unless stringValue # no double quotes, all OK 33 | 34 | hasLegalConstructs = @isInterpolated(tokenApi) or 35 | @containsSingleQuote(tokenValue) 36 | 37 | return not hasLegalConstructs 38 | 39 | isInterpolated : (tokenApi) -> 40 | currentIndex = tokenApi.i 41 | isInterpolated = false 42 | lineTokens = tokenApi.tokensByLine[tokenApi.lineNumber] 43 | 44 | # Traverse backwards to find signs that the current string token is 45 | # generated by the coffee rewriter for string interpolation 46 | for i in [1..currentIndex] 47 | token = tokenApi.peek(-i) 48 | tokenName = token[0] 49 | 50 | if tokenName is ')' and token.stringEnd 51 | break # No interpolation, we can quit 52 | else if tokenName is '(' and 53 | token.origin?[1] is "string interpolation" 54 | isInterpolated = true 55 | break 56 | 57 | return isInterpolated 58 | 59 | containsSingleQuote : (tokenValue) -> 60 | return tokenValue.indexOf("'") isnt -1 61 | -------------------------------------------------------------------------------- /3rd_party_rules.md: -------------------------------------------------------------------------------- 1 | Creating a 3rd party rule 2 | ========================= 3 | 4 | If your rule is overly specific or too restrictive it may not be accepted into 5 | the CoffeeLint repo. This doesn't mean you can't have your rule. 6 | 7 | Setup your repo 8 | --------------- 9 | 10 | My personal preference is to not commit compiled Javascript files to a 11 | CoffeeScript repo. 12 | 13 | 1. `npm init` 14 | 2. `npm install --save coffee-script` When it asks about `main` leave it as `index.js` 15 | 3. Create an `index.js` it only needs two lines 16 | 17 | require('coffee-script'); 18 | module.exports = require('./your_rule_name.coffee'); 19 | 20 | 4. `sudo npm link`: Once you run this CoffeeLint will be able to `require` your module 21 | 5. Build your rule, make sure the file name matches `index.js`'s `require` 22 | 23 | [link]: https://npmjs.org/doc/cli/npm-link.html 24 | 25 | Loading your rule 26 | ----------------- 27 | 28 | It's probably best once you get this worked out, to commit a coffeelint.json to 29 | your repo with your rule enabled. Here is an example: 30 | 31 | 32 | { 33 | "your_rule_name": { 34 | "module": "your-rule-name" 35 | } 36 | } 37 | 38 | 39 | * `your_rule_name`: This MUST match the name inside your rule. A few of of the 40 | built in rules are no_plus_plus, no_tabs, and cyclomatic_complexity 41 | * `your-rule-name`: npm's convention is to use dashes. CoffeeLint is going to 42 | run `require('your-rule-name')` to find this rule. 43 | 44 | Publishing your rule 45 | -------------------- 46 | 47 | Once you're ready you can publish it as a normal npm package. Remember to 48 | mention in your readme that they will need to `npm install -g your-rule-name`. 49 | If it's not global CoffeeLint won't see it. 50 | 51 | By convention rule authors add the keyword `coffeelintrule` to their npm 52 | `package.json` so custom rules can be found easily. Click 53 | [here](https://npmjs.org/search?q=coffeelintrule) to list all currently available 54 | custom rules on npm. 55 | 56 | Verify Installation 57 | ------------------- 58 | 59 | This will verify your development install when you used `sudo npm link` or when 60 | users run `npm install -g your-rule-name` 61 | 62 | Verify your installation using: 63 | 64 | node -p "require('your-rule-name');" 65 | 66 | You should get output similar to 67 | 68 | [Function: YourRuleName] 69 | -------------------------------------------------------------------------------- /src/rules/arrow_spacing.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class ArrowSpacing 3 | 4 | rule: 5 | name: 'arrow_spacing' 6 | level : 'ignore' 7 | message : 'Function arrow (->) must be spaced properly' 8 | description: """ 9 |

This rule checks to see that there is spacing before and after 10 | the arrow operator that declares a function. This rule is disabled 11 | by default.

Note that if arrow_spacing is enabled, and you 12 | pass an empty function as a parameter, arrow_spacing will accept 13 | either a space or no space in-between the arrow operator and the 14 | parenthesis

15 |
# Both of this will not trigger an error,
16 |             # even with arrow_spacing enabled.
17 |             x(-> 3)
18 |             x( -> 3)
19 | 
20 |             # However, this will trigger an error
21 |             x((a,b)-> 3)
22 |             
23 |             
24 | """ 25 | 26 | tokens: [ '->' ] 27 | 28 | lintToken : (token, tokenApi) -> 29 | # Throw error unless the following happens. 30 | # 31 | # We will take a look at the previous token to see 32 | # 1. That the token is properly spaced 33 | # 2. Wasn't generated by the CoffeeScript compiler 34 | # 3. That it is just indentation 35 | # 4. If the function declaration has no parameters 36 | # e.g. x(-> 3) 37 | # x( -> 3) 38 | # 39 | # or a statement is wrapped in parentheses 40 | # e.g. (-> true)() 41 | # 42 | # we will accept either having a space or not having a space there. 43 | 44 | pp = tokenApi.peek(-1) 45 | unless (token.spaced? or token.newLine? or @atEof(tokenApi)) and 46 | # Throw error unless the previous token... 47 | ((pp.spaced? or pp[0] is 'TERMINATOR') or #1 48 | pp.generated? or #2 49 | pp[0] is "INDENT" or #3 50 | (pp[1] is "(" and not pp.generated?)) #4 51 | true 52 | else 53 | null 54 | 55 | # Are there any more meaningful tokens following the current one? 56 | atEof: (tokenApi) -> 57 | {tokens, i } = tokenApi 58 | for token in tokens.slice(i + 1) 59 | unless token.generated or token[0] in ['OUTDENT', 'TERMINATOR'] 60 | return false 61 | true 62 | -------------------------------------------------------------------------------- /test/test_1.5.0_plus.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | CoffeeScript = require 'coffee-script' 5 | CoffeeScript.old_tokens = CoffeeScript.tokens 6 | CoffeeScript.tokens = (text) -> 7 | CoffeeScript.updated_tokens_called = true 8 | tokens = CoffeeScript.old_tokens(text) 9 | for token in tokens 10 | if typeof token[2] == "number" 11 | if token[0] == 'INDENT' or token[1] == 'OUTDENT' 12 | token[2] = {first_line: token[2] - 1, last_line: token[2]} 13 | else 14 | token[2] = {first_line: token[2], last_line: token[2]} 15 | token 16 | coffeelint = require path.join('..', 'lib', 'coffeelint') 17 | 18 | 19 | vows.describe("CoffeeScript 1.5.0+").addBatch({ 20 | 21 | "lineNumber has become an object" : 22 | 23 | topic : () -> 24 | """ 25 | x = 1234; 26 | y = 1234; z = 1234 27 | """ 28 | 29 | 'work with 1.5.0+ tokens' : (source) -> 30 | assert.isUndefined(CoffeeScript.updated_tokens_called) 31 | errors = coffeelint.lint(source) 32 | assert.isArray(errors) 33 | assert.lengthOf(errors, 1) 34 | assert.isTrue(CoffeeScript.updated_tokens_called) 35 | error = errors[0] 36 | assert.equal(error.lineNumber, 1) 37 | assert.equal(error.message, "Line contains a trailing semicolon") 38 | assert.equal(error.rule, 'no_trailing_semicolons') 39 | 40 | "for indentation last_line is the correct value for lineNumber" : 41 | 42 | topic : () -> 43 | """ 44 | x = () -> 45 | 'two spaces' 46 | 47 | a = () -> 48 | 'four spaces' 49 | """ 50 | 51 | 'works with 1.5.0+' : (source) -> 52 | errors = coffeelint.lint(source) 53 | assert.equal(errors.length, 1) 54 | error = errors[0] 55 | msg = 'Line contains inconsistent indentation' 56 | assert.equal(error.message, msg) 57 | assert.equal(error.rule, 'indentation') 58 | assert.equal(error.lineNumber, 5) 59 | assert.equal(error.context, "Expected 2 got 4") 60 | 61 | }).addBatch({ 62 | 63 | "Cleanup" : () -> 64 | 65 | CoffeeScript.tokens = CoffeeScript.old_tokens 66 | delete CoffeeScript.old_tokens 67 | 68 | }).export(module) 69 | -------------------------------------------------------------------------------- /src/rules/no_trailing_semicolons.coffee: -------------------------------------------------------------------------------- 1 | 2 | regexes = 3 | trailingSemicolon : /;\r?$/ 4 | 5 | module.exports = class NoTrailingSemicolons 6 | 7 | rule: 8 | name: 'no_trailing_semicolons' 9 | level : 'error' 10 | message : 'Line contains a trailing semicolon' 11 | description: """ 12 | This rule prohibits trailing semicolons, since they are needless 13 | cruft in CoffeeScript. 14 |
15 |             # This semicolon is meaningful.
16 |             x = '1234'; console.log(x)
17 | 
18 |             # This semicolon is redundant.
19 |             alert('end of line');
20 |             
21 |             
22 | Trailing semicolons are forbidden by default. 23 | """ 24 | 25 | 26 | lintLine: (line, lineApi) -> 27 | 28 | # The TERMINATOR token is extended through to the next token. As a 29 | # result a line with a comment DOES have a token: the TERMINATOR from 30 | # the last line of code. 31 | lineTokens = lineApi.getLineTokens() 32 | if lineTokens.length is 1 and lineTokens[0][0] in ['TERMINATOR', 'HERECOMMENT'] 33 | return 34 | 35 | newLine = line 36 | if lineTokens.length > 1 and lineTokens[lineTokens.length - 1][0] is 'TERMINATOR' 37 | 38 | # startPos contains the end position of the last non-TERMINATOR token 39 | # endPos contains the start position of the TERMINATOR token 40 | # if startPos and endPos arent equal, that probably means a comment 41 | # was sliced out of the tokenizer 42 | 43 | startPos = lineTokens[lineTokens.length - 2][2].last_column + 1 44 | endPos = lineTokens[lineTokens.length - 1][2].first_column 45 | if (startPos isnt endPos) 46 | startCounter = startPos 47 | while line[startCounter] isnt "#" and startCounter < line.length 48 | startCounter++ 49 | newLine = line.substring(0, startCounter).replace(/\s*$/, '') 50 | 51 | hasSemicolon = regexes.trailingSemicolon.test(newLine) 52 | [first..., last] = lineTokens 53 | hasNewLine = last and last.newLine? 54 | 55 | # Don't throw errors when the contents of multiline strings, 56 | # regexes and the like end in ";" 57 | if hasSemicolon and not hasNewLine and lineApi.lineHasToken() and 58 | last[0] isnt 'STRING' 59 | return true 60 | -------------------------------------------------------------------------------- /src/rules/colon_assignment_spacing.coffee: -------------------------------------------------------------------------------- 1 | module.exports = class ColonAssignmentSpacing 2 | 3 | rule : 4 | name : 'colon_assignment_spacing' 5 | level : 'ignore' 6 | message :'Colon assignment without proper spacing' 7 | spacing : 8 | left : 0 9 | right : 0 10 | description : """ 11 |

This rule checks to see that there is spacing before and 12 | after the colon in a colon assignment (i.e., classes, objects). 13 | The spacing amount is specified by 14 | spacing.left and spacing.right, respectively. 15 | A zero value means no spacing required. 16 |

17 |

18 |             #
19 |             # If spacing.left and spacing.right is 1
20 |             #
21 | 
22 |             # Good
23 |             object = {spacing : true}
24 |             class Dog
25 |               canBark : true
26 | 
27 |             # Bad
28 |             object = {spacing: true}
29 |             class Cat
30 |               canBark: false
31 |             
32 | """ 33 | 34 | tokens : [':'] 35 | 36 | lintToken : (token, tokenApi) -> 37 | spacingAllowances = tokenApi.config[@rule.name].spacing 38 | previousToken = tokenApi.peek -1 39 | nextToken = tokenApi.peek 1 40 | 41 | getSpaceFromToken = (direction) -> 42 | switch direction 43 | when 'left' 44 | token[2].first_column - previousToken[2].last_column - 1 45 | when 'right' 46 | nextToken[2].first_column - token[2].first_column - 1 47 | 48 | checkSpacing = (direction) -> 49 | spacing = getSpaceFromToken direction 50 | # when spacing is negative, the neighboring token is a newline 51 | isSpaced = if spacing < 0 then true else spacing is parseInt spacingAllowances[direction] 52 | [isSpaced, spacing] 53 | 54 | [isLeftSpaced, leftSpacing] = checkSpacing 'left' 55 | [isRightSpaced, rightSpacing] = checkSpacing 'right' 56 | 57 | if isLeftSpaced and isRightSpaced 58 | null 59 | else 60 | context : 61 | """ 62 | Incorrect spacing around column #{token[2].first_column}. 63 | Expected left: #{spacingAllowances.left}, right: #{spacingAllowances.right}. 64 | Got left: #{leftSpacing}, right: #{rightSpacing}. 65 | """ -------------------------------------------------------------------------------- /test/test_no_unnecessary_fat_arrows.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | runLint = (source) -> 7 | config = no_unnecessary_fat_arrows: level: 'error' 8 | coffeelint.lint source, config 9 | 10 | shouldError = (source, numErrors = 1) -> 11 | topic: source 12 | 'errors for unnecessary arrow': (source) -> 13 | errors = runLint source 14 | assert.lengthOf errors, numErrors, "Expected #{numErrors} errors" 15 | error = errors[0] 16 | assert.equal error.rule, 'no_unnecessary_fat_arrows' 17 | 18 | shouldPass = (source) -> 19 | topic: source 20 | 'does not error for necessary arrow': (source) -> 21 | errors = runLint source 22 | assert.isEmpty errors, "Expected no errors, got #{errors}" 23 | 24 | vows.describe('no unnecessary fat arrows').addBatch({ 25 | 26 | 'empty function' : shouldError '=>' 27 | 'simple function' : shouldError '=> 1' 28 | 'function with this' : shouldPass '=> this' 29 | 'function with this.a' : shouldPass '=> this.a' 30 | 'function with @' : shouldPass '=> @' 31 | 'function with @a' : shouldPass '=> @a' 32 | 33 | 'nested simple functions': 34 | 'with inner fat arrow': shouldError '-> => 1' 35 | 'with outer fat arrow': shouldError '=> -> 1' 36 | 'with both fat arrows': shouldError '=> => 1', 2 37 | 38 | 'nested functions with this inside': 39 | 'with inner fat arrow': shouldPass '-> => this' 40 | 'with outer fat arrow': shouldError '=> -> this' 41 | 'with both fat arrows': shouldPass '=> => this' 42 | 43 | 'nested functions with this outside': 44 | 'with inner fat arrow': shouldError '-> (this; =>)' 45 | 'with outer fat arrow': shouldPass '=> (this; ->)' 46 | 'with both fat arrows': shouldError '=> (this; =>)' 47 | 48 | 'deeply nested simple function' : shouldError '-> -> -> -> => 1' 49 | 'deeply nested function with this' : shouldPass '-> -> -> -> => this' 50 | 51 | 'functions with multiple statements': shouldError """ 52 | f = -> 53 | x = 2 54 | z ((a) => x; a) 55 | """ 56 | 57 | 'functions with parameters' : shouldError '(a) =>' 58 | 'functions with parameter assignment' : shouldPass '(@a) =>' 59 | 'functions with destructuring parameter assignment': shouldPass '({@a}) =>' 60 | 61 | }).export(module) 62 | -------------------------------------------------------------------------------- /test/test_tabs.coffee: -------------------------------------------------------------------------------- 1 | # 2 | # Tab tests. 3 | # 4 | 5 | 6 | path = require 'path' 7 | vows = require 'vows' 8 | assert = require 'assert' 9 | coffeelint = require path.join('..', 'lib', 'coffeelint') 10 | 11 | 12 | vows.describe('tabs').addBatch({ 13 | 14 | 'Tabs' : 15 | 16 | topic : () -> 17 | """ 18 | x = () -> 19 | \ty = () -> 20 | \t\treturn 1234 21 | """ 22 | 23 | 'can be forbidden' : (source) -> 24 | config = {} 25 | errors = coffeelint.lint(source, config) 26 | assert.equal(errors.length, 4) 27 | error = errors[1] 28 | assert.equal(error.lineNumber, 2) 29 | assert.equal("Line contains tab indentation", error.message) 30 | assert.equal(error.rule, 'no_tabs') 31 | 32 | 'can be permitted' : (source) -> 33 | config = 34 | no_tabs : {level: 'ignore'} 35 | indentation : {level: 'error', value: 1} 36 | errors = coffeelint.lint(source, config) 37 | assert.equal(errors.length, 0) 38 | 39 | 'are forbidden by default' : (source) -> 40 | config = 41 | indentation : {level: 'error', value: 1} 42 | errors = coffeelint.lint(source, config) 43 | assert.isArray(errors) 44 | assert.equal(errors.length, 2) 45 | 46 | 'are allowed in strings' : () -> 47 | source = "x = () -> '\t'" 48 | errors = coffeelint.lint(source) 49 | assert.equal(errors.length, 0) 50 | 51 | 'Tabs in multi-line strings' : 52 | 53 | topic : ''' 54 | x = 1234 55 | y = """ 56 | \t\tasdf 57 | """ 58 | ''' 59 | 60 | 'are ignored' : (errors) -> 61 | errors = coffeelint.lint(errors) 62 | assert.isEmpty(errors) 63 | 64 | 'Tabs in Heredocs' : 65 | 66 | topic : ''' 67 | ### 68 | \t\tMy Heredoc 69 | ### 70 | ''' 71 | 72 | 'are ignored' : (errors) -> 73 | errors = coffeelint.lint(errors) 74 | assert.isEmpty(errors) 75 | 76 | 'Tabs in multi line regular expressions' : 77 | 78 | topic : ''' 79 | /// 80 | \t\tMy Heredoc 81 | /// 82 | ''' 83 | 84 | 'are ignored' : (errors) -> 85 | errors = coffeelint.lint(errors) 86 | assert.isEmpty(errors) 87 | 88 | 89 | }).export(module) 90 | -------------------------------------------------------------------------------- /test/test_identifier.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | 7 | vows.describe('identifiers').addBatch({ 8 | 9 | 'Camel cased class names' : 10 | 11 | topic : """ 12 | class Animal 13 | 14 | class Wolf extends Animal 15 | 16 | class BurmesePython extends Animal 17 | 18 | class Band 19 | 20 | class ELO extends Band 21 | 22 | class Eiffel65 extends Band 23 | 24 | class nested.Name 25 | 26 | class deeply.nested.Name 27 | """ 28 | 29 | 'are valid by default' : (source) -> 30 | errors = coffeelint.lint(source) 31 | assert.isEmpty(errors) 32 | 33 | 'Non camel case class names' : 34 | 35 | topic : """ 36 | class animal 37 | 38 | class wolf extends Animal 39 | 40 | class Burmese_Python extends Animal 41 | 42 | class canadaGoose extends Animal 43 | """ 44 | 45 | 'are rejected by default' : (source) -> 46 | errors = coffeelint.lint(source) 47 | assert.lengthOf(errors, 4) 48 | error = errors[0] 49 | assert.equal(error.lineNumber, 1) 50 | assert.equal(error.message, 'Class names should be camel cased') 51 | assert.equal(error.context, 'class name: animal') 52 | assert.equal(error.rule, 'camel_case_classes') 53 | 54 | 'can be permitted' : (source) -> 55 | config = 56 | camel_case_classes : {level : 'ignore'} 57 | errors = coffeelint.lint(source, config) 58 | assert.isEmpty(errors) 59 | 60 | 'Anonymous class names' : 61 | 62 | topic : """ 63 | x = class 64 | m : -> 123 65 | 66 | y = class extends x 67 | m : -> 456 68 | 69 | z = class 70 | 71 | r = class then 1:2 72 | """ 73 | 74 | 'are permitted' : (source) -> 75 | errors = coffeelint.lint(source) 76 | assert.isEmpty(errors) 77 | 78 | 'Inner classes are permitted' : 79 | 80 | topic : ''' 81 | class X 82 | class @Y 83 | f : 123 84 | class @constructor.Z 85 | f : 456 86 | ''' 87 | 88 | 'are permitted' : (source) -> 89 | errors = coffeelint.lint(source) 90 | assert.lengthOf(errors, 0) 91 | 92 | }).export(module) 93 | -------------------------------------------------------------------------------- /src/lexical_linter.coffee: -------------------------------------------------------------------------------- 1 | 2 | class TokenApi 3 | constructor: (CoffeeScript, source, @config, @tokensByLine) -> 4 | @tokens = CoffeeScript.tokens(source) 5 | @lines = source.split('\n') 6 | @tokensByLine = {} # A map of tokens by line. 7 | 8 | i: 0 # The index of the current token we're linting. 9 | 10 | # Return the token n places away from the current token. 11 | peek : (n = 1) -> 12 | @tokens[@i + n] || null 13 | 14 | 15 | BaseLinter = require './base_linter.coffee' 16 | 17 | # 18 | # A class that performs checks on the output of CoffeeScript's lexer. 19 | # 20 | module.exports = class LexicalLinter extends BaseLinter 21 | 22 | constructor : (source, config, rules, CoffeeScript) -> 23 | super source, config, rules 24 | 25 | @tokenApi = new TokenApi CoffeeScript, source, @config, @tokensByLine 26 | # This needs to be available on the LexicalLinter so it can be passed 27 | # to the LineLinter when this finishes running. 28 | @tokensByLine = @tokenApi.tokensByLine 29 | 30 | acceptRule: (rule) -> 31 | return typeof rule.lintToken is 'function' 32 | 33 | # Return a list of errors encountered in the given source. 34 | lint : () -> 35 | errors = [] 36 | 37 | for token, i in @tokenApi.tokens 38 | @tokenApi.i = i 39 | errors.push(error) for error in @lintToken(token) 40 | errors 41 | 42 | 43 | # Return an error if the given token fails a lint check, false otherwise. 44 | lintToken : (token) -> 45 | [type, value, lineNumber] = token 46 | 47 | if typeof lineNumber == "object" 48 | if type == 'OUTDENT' or type == 'INDENT' 49 | lineNumber = lineNumber.last_line 50 | else 51 | lineNumber = lineNumber.first_line 52 | @tokensByLine[lineNumber] ?= [] 53 | @tokensByLine[lineNumber].push(token) 54 | # CoffeeScript loses line numbers of interpolations and multi-line 55 | # regexes, so fake it by using the last line number we know. 56 | @lineNumber = lineNumber or @lineNumber or 0 57 | 58 | @tokenApi.lineNumber = @lineNumber 59 | 60 | # Multiple rules might run against the same token to build context. 61 | # Every every rule should run even if something has already produced an 62 | # error for the same token. 63 | errors = [] 64 | for rule in @rules when token[0] in rule.tokens 65 | v = @normalizeResult rule, rule.lintToken(token, @tokenApi) 66 | errors.push v if v? 67 | errors 68 | 69 | createError : (ruleName, attrs = {}) -> 70 | attrs.lineNumber = @lineNumber + 1 71 | attrs.line = @tokenApi.lines[@lineNumber] 72 | super ruleName, attrs 73 | 74 | -------------------------------------------------------------------------------- /src/rules/missing_fat_arrows.coffee: -------------------------------------------------------------------------------- 1 | any = (arr, test) -> arr.reduce ((res, elt) -> res or test elt), false 2 | 3 | module.exports = class MissingFatArrows 4 | 5 | rule: 6 | name: 'missing_fat_arrows' 7 | level: 'ignore' 8 | message: 'Used `this` in a function without a fat arrow' 9 | description: """ 10 | Warns when you use `this` inside a function that wasn't defined 11 | with a fat arrow. This rule does not apply to methods defined in a 12 | class, since they have `this` bound to the class instance (or the 13 | class itself, for class methods). 14 | 15 | It is impossible to statically determine whether a function using 16 | `this` will be bound with the correct `this` value due to language 17 | features like `Function.prototype.call` and 18 | `Function.prototype.bind`, so this rule may produce false positives. 19 | """ 20 | 21 | lintAST: (node, @astApi) -> 22 | @lintNode node 23 | undefined 24 | 25 | lintNode: (node, methods = []) -> 26 | if (not @isFatArrowCode node) and 27 | # Ignore any nodes we know to be methods 28 | (node not in methods) and 29 | (@needsFatArrow node) 30 | error = @astApi.createError 31 | lineNumber: node.locationData.first_line + 1 32 | @errors.push error 33 | 34 | node.eachChild (child) => @lintNode child, 35 | switch 36 | when @isClass node then @methodsOfClass node 37 | # Once we've hit a function, we know we can't be in the top 38 | # level of a method anymore, so we can safely reset the methods 39 | # to empty to save work. 40 | when @isCode node then [] 41 | else methods 42 | 43 | isCode: (node) => @astApi.getNodeName(node) is 'Code' 44 | isClass: (node) => @astApi.getNodeName(node) is 'Class' 45 | isValue: (node) => @astApi.getNodeName(node) is 'Value' 46 | isObject: (node) => @astApi.getNodeName(node) is 'Obj' 47 | isThis: (node) => @isValue(node) and node.base.value is 'this' 48 | isFatArrowCode: (node) => @isCode(node) and node.bound 49 | 50 | needsFatArrow: (node) -> 51 | @isCode(node) and ( 52 | any(node.params, (param) => param.contains(@isThis)?) or 53 | node.body.contains(@isThis)? 54 | ) 55 | 56 | methodsOfClass: (classNode) -> 57 | bodyNodes = classNode.body.expressions 58 | returnNode = bodyNodes[bodyNodes.length - 1] 59 | if returnNode? and @isValue(returnNode) and @isObject(returnNode.base) 60 | returnNode.base.properties 61 | .map((assignNode) -> assignNode.value) 62 | .filter(@isCode) 63 | else [] 64 | 65 | -------------------------------------------------------------------------------- /test/test_braces.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('braces').addBatch({ 7 | 8 | 'Implicit braces' : 9 | 10 | topic : () -> 11 | ''' 12 | a = 1:2 13 | y = 14 | 'a':'b' 15 | 3:4 16 | ''' 17 | 18 | 'are allowed by default' : (source) -> 19 | errors = coffeelint.lint(source) 20 | assert.isArray(errors) 21 | assert.isEmpty(errors) 22 | 23 | 'can be forbidden' : (source) -> 24 | config = {no_implicit_braces : {level:'error'}} 25 | errors = coffeelint.lint(source, config) 26 | assert.isArray(errors) 27 | assert.lengthOf(errors, 2) 28 | error = errors[0] 29 | assert.equal(error.lineNumber, 1) 30 | assert.equal(error.message, 'Implicit braces are forbidden') 31 | assert.equal(error.rule, 'no_implicit_braces') 32 | 33 | 'Implicit braces strict' : 34 | topic: """ 35 | foo = 36 | bar: 37 | baz: 1 38 | thing: 'a' 39 | baz: ['a', 'b', 'c'] 40 | """ 41 | 42 | "blocks all implicit braces by default": (source) -> 43 | config = {no_implicit_braces : {level:'error'}} 44 | errors = coffeelint.lint(source, config) 45 | assert.isArray(errors) 46 | assert.lengthOf(errors, 2) 47 | assert.equal(rule, 'no_implicit_braces') for {rule} in errors 48 | 49 | "allows braces at the end of lines when strict is false": (source) -> 50 | config = 51 | no_implicit_braces : 52 | level:'error' 53 | strict: false 54 | errors = coffeelint.lint(source, config) 55 | assert.isArray(errors) 56 | assert.isEmpty(errors) 57 | 58 | 'Implicit braces in class definitions' : 59 | 60 | topic : () -> 61 | ''' 62 | class Animal 63 | walk: -> 64 | 65 | class Wolf extends Animal 66 | howl: -> 67 | 68 | class nested.Name 69 | constructor: (@options) -> 70 | 71 | class deeply.nested.Name 72 | constructor: (@options) -> 73 | 74 | x = class 75 | m : -> 123 76 | 77 | y = class extends x 78 | m : -> 456 79 | 80 | z = class 81 | 82 | r = class then 1:2 83 | ''' 84 | 85 | 'are always ignored' : (source) -> 86 | config = {no_implicit_braces : {level:'error'}} 87 | errors = coffeelint.lint(source) 88 | assert.isArray(errors) 89 | assert.isEmpty(errors) 90 | 91 | }).export(module) 92 | -------------------------------------------------------------------------------- /test/test_line_length.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | 7 | vows.describe('linelength').addBatch({ 8 | 9 | 'Maximum line length' : 10 | 11 | topic : () -> 12 | # Every line generated here is a comment. 13 | line = (length) -> 14 | return '# ' + new Array(length - 1).join('-') 15 | lengths = [50, 79, 80, 81, 100, 200] 16 | (line(l) for l in lengths).join("\n") 17 | 18 | 'defaults to 80' : (source) -> 19 | errors = coffeelint.lint(source) 20 | assert.equal(errors.length, 3) 21 | error = errors[0] 22 | assert.equal(error.lineNumber, 4) 23 | assert.equal(error.message, "Line exceeds maximum allowed length") 24 | assert.equal(error.rule, 'max_line_length') 25 | 26 | 'is configurable' : (source) -> 27 | config = 28 | max_line_length : 29 | value: 99 30 | level: 'error' 31 | errors = coffeelint.lint(source, config) 32 | assert.equal(errors.length, 2) 33 | 34 | 'is optional' : (source) -> 35 | for length in [null, 0, false] 36 | config = 37 | max_line_length : 38 | value: length 39 | level: 'ignore' 40 | errors = coffeelint.lint(source, config) 41 | assert.isEmpty(errors) 42 | 43 | 'can ignore comments': (source) -> 44 | config = 45 | max_line_length: 46 | limitComments: false 47 | 48 | errors = coffeelint.lint(source, config) 49 | assert.isEmpty(errors) 50 | 51 | 'Literate Line Length' : 52 | topic: -> 53 | # This creates a line with 80 Xs. 54 | source = new Array(81).join('X') + "\n" 55 | 56 | # Long URLs are ignored by default even in Literate code. 57 | source += "http://testing.example.com/really-really-long-url-" + 58 | "that-shouldnt-have-to-be-split-to-avoid-the-lint-error" 59 | 60 | '': (source) -> 61 | errors = coffeelint.lint(source, {}, true) 62 | assert.isEmpty(errors) 63 | 64 | 'Maximum length exceptions': 65 | topic: """ 66 | # Since the line length check only reads lines in isolation it will 67 | # see the following line as a comment even though it's in a string. 68 | # I don't think that's a problem. 69 | # 70 | # http://testing.example.com/really-really-long-url-that-shouldnt-have-to-be-split-to-avoid-the-lint-error 71 | """ 72 | 73 | 'excludes long urls': (source) -> 74 | errors = coffeelint.lint(source) 75 | assert.isEmpty(errors) 76 | 77 | }).export(module) 78 | -------------------------------------------------------------------------------- /test/test_constructor_needs_parens.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('newparens').addBatch({ 7 | 8 | 'Missing Parentheses on "new Foo"' : 9 | 10 | topic: () -> 11 | """ 12 | class Foo 13 | 14 | # Warn about missing parens here 15 | a = new Foo 16 | b = new bar.foo.Foo 17 | # The parens make it clear no parameters are intended 18 | c = new Foo() 19 | d = new bar.foo.Foo() 20 | e = new Foo 1, 2 21 | f = new bar.foo.Foo 1, 2 22 | # Since this does have a parameter it should not require parens 23 | g = new bar.foo.Foo 24 | config: 'parameter' 25 | """ 26 | 27 | 'warns about missing parens': (source) -> 28 | config = 29 | empty_constructor_needs_parens: 30 | level: 'error' 31 | errors = coffeelint.lint(source, config) 32 | assert.equal(errors.length, 2) 33 | assert.equal(errors[0].lineNumber, 4) 34 | assert.equal(errors[0].rule, 'empty_constructor_needs_parens') 35 | assert.equal(errors[1].lineNumber, 5) 36 | assert.equal(errors[1].rule, 'empty_constructor_needs_parens') 37 | 38 | 'Missing Parentheses on "new Foo 1, 2"' : 39 | 40 | topic: () -> 41 | """ 42 | class Foo 43 | 44 | a = new Foo 45 | b = new Foo() 46 | # Warn about missing parens here 47 | c = new Foo 1, 2 48 | d = new Foo 49 | config: 'parameter' 50 | e = new bar.foo.Foo 1, 2 51 | f = new bar.foo.Foo 52 | config: 'parameter' 53 | # But not here 54 | g = new Foo(1, 2) 55 | h = new Foo( 56 | config: 'parameter' 57 | ) 58 | i = new bar.foo.Foo(1, 2) 59 | j = new bar.foo.Foo( 60 | config: 'parameter' 61 | ) 62 | """ 63 | 64 | 'warns about missing parens': (source) -> 65 | config = 66 | non_empty_constructor_needs_parens: 67 | level: 'error' 68 | errors = coffeelint.lint(source, config) 69 | assert.equal(errors.length, 4) 70 | assert.equal(errors[0].lineNumber, 6) 71 | assert.equal(errors[0].rule, 'non_empty_constructor_needs_parens') 72 | assert.equal(errors[1].lineNumber, 7) 73 | assert.equal(errors[1].rule, 'non_empty_constructor_needs_parens') 74 | assert.equal(errors[2].lineNumber, 9) 75 | assert.equal(errors[2].rule, 'non_empty_constructor_needs_parens') 76 | assert.equal(errors[3].lineNumber, 10) 77 | assert.equal(errors[3].rule, 'non_empty_constructor_needs_parens') 78 | 79 | }).export(module) 80 | -------------------------------------------------------------------------------- /src/ast_linter.coffee: -------------------------------------------------------------------------------- 1 | BaseLinter = require './base_linter.coffee' 2 | 3 | node_children = 4 | Class: ['variable', 'parent', 'body'] 5 | Code: ['params', 'body'] 6 | For: ['body', 'source', 'guard', 'step'] 7 | If: ['condition', 'body', 'elseBody'] 8 | Obj: ['properties'] 9 | Op: ['first', 'second'] 10 | Switch: ['subject', 'cases', 'otherwise'] 11 | Try: ['attempt', 'recovery', 'ensure'] 12 | Value: ['base', 'properties'] 13 | While: ['condition', 'guard', 'body'] 14 | 15 | hasChildren = (node, children) -> 16 | node?.children?.length is children.length and 17 | node?.children.every (elem, i) -> elem is children[i] 18 | 19 | class ASTApi 20 | constructor: (@config) -> 21 | getNodeName: (node) -> 22 | name = node?.constructor?.name 23 | if node_children[name] 24 | return name 25 | else 26 | for own name, children of node_children 27 | if hasChildren(node, children) 28 | return name 29 | 30 | 31 | # A class that performs static analysis of the abstract 32 | # syntax tree. 33 | module.exports = class ASTLinter extends BaseLinter 34 | 35 | constructor : (source, config, rules, @CoffeeScript) -> 36 | super source, config, rules 37 | @astApi = new ASTApi @config 38 | 39 | # This uses lintAST instead of lintNode because I think it makes it a bit 40 | # more clear that the rule needs to walk the AST on its own. 41 | acceptRule: (rule) -> 42 | return typeof rule.lintAST is 'function' 43 | 44 | lint : () -> 45 | errors = [] 46 | try 47 | @node = @CoffeeScript.nodes(@source) 48 | catch coffeeError 49 | # If for some reason you shut off the 'coffeescript_error' rule err 50 | # will be null and should NOT be added to errors 51 | err = @_parseCoffeeScriptError(coffeeError) 52 | errors.push err if err? 53 | return errors 54 | 55 | for rule in @rules 56 | @astApi.createError = (attrs = {}) => 57 | @createError rule.rule.name, attrs 58 | 59 | # HACK: Push the local errors object into the plugin. This is a 60 | # temporary solution until I have a way for it to really return 61 | # multiple errors. 62 | rule.errors = errors 63 | v = @normalizeResult rule, rule.lintAST(@node, @astApi) 64 | 65 | return v if v? 66 | errors 67 | 68 | _parseCoffeeScriptError : (coffeeError) -> 69 | rule = @config['coffeescript_error'] 70 | 71 | message = coffeeError.toString() 72 | 73 | # Parse the line number 74 | lineNumber = -1 75 | if coffeeError.location? 76 | lineNumber = coffeeError.location.first_line + 1 77 | else 78 | match = /line (\d+)/.exec message 79 | lineNumber = parseInt match[1], 10 if match?.length > 1 80 | attrs = { 81 | message: message 82 | level: rule.level 83 | lineNumber: lineNumber 84 | } 85 | return @createError 'coffeescript_error', attrs 86 | 87 | 88 | -------------------------------------------------------------------------------- /test/test_no_implicit_parens.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('parens').addBatch({ 7 | 8 | 'Implicit parens' : 9 | 10 | topic : () -> 11 | ''' 12 | console.log 'implict parens' 13 | blah = (a, b) -> 14 | blah 'a', 'b' 15 | 16 | class A 17 | @configure(1, 2, 3) 18 | 19 | constructor: -> 20 | 21 | class B 22 | _defaultC = 5 23 | 24 | constructor: (a) -> 25 | @c = a ? _defaultC 26 | ''' 27 | 28 | 'are allowed by default' : (source) -> 29 | errors = coffeelint.lint(source) 30 | assert.isArray(errors) 31 | assert.isEmpty(errors) 32 | 33 | 'can be forbidden' : (source) -> 34 | config = {no_implicit_parens : {level:'error'}} 35 | errors = coffeelint.lint(source, config) 36 | assert.isArray(errors) 37 | assert.lengthOf(errors, 2) 38 | error = errors[0] 39 | assert.equal(error.lineNumber, 1) 40 | assert.equal(error.message, 'Implicit parens are forbidden') 41 | assert.equal(error.rule, 'no_implicit_parens') 42 | 43 | 'No implicit parens strict' : 44 | topic: """ 45 | blah = (a, b) -> 46 | blah 'a' 47 | , 'b' 48 | """ 49 | 50 | "blocks all implicit parens by default": (source) -> 51 | config = {no_implicit_parens : {level:'error'}} 52 | errors = coffeelint.lint(source, config) 53 | assert.isArray(errors) 54 | assert.lengthOf(errors, 1) 55 | assert.equal(rule, 'no_implicit_parens') for {rule} in errors 56 | 57 | "allows parens at the end of lines when strict is false": (source) -> 58 | config = 59 | no_implicit_parens: 60 | level:'error' 61 | strict: false 62 | errors = coffeelint.lint(source, config) 63 | assert.isArray(errors) 64 | assert.isEmpty(errors) 65 | 66 | 'Nested no implicit parens strict' : 67 | topic: """ 68 | blah = (a, b) -> 69 | 70 | blah 'a' 71 | , blah('c', 'd') 72 | 73 | blah 'a' 74 | , (blah 'c' 75 | , 'd') 76 | """ 77 | 78 | "blocks all implicit parens by default": (source) -> 79 | config = {no_implicit_parens : {level:'error'}} 80 | errors = coffeelint.lint(source, config) 81 | assert.isArray(errors) 82 | assert.lengthOf(errors, 3) 83 | assert.equal(rule, 'no_implicit_parens') for {rule} in errors 84 | 85 | "allows parens at the end of lines when strict is false": (source) -> 86 | config = 87 | no_implicit_parens: 88 | level:'error' 89 | strict: false 90 | errors = coffeelint.lint(source, config) 91 | assert.isArray(errors) 92 | assert.isEmpty(errors) 93 | 94 | }).export(module) 95 | -------------------------------------------------------------------------------- /src/rules/space_operators.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class SpaceOperators 3 | 4 | rule: 5 | name: 'space_operators' 6 | level : 'ignore' 7 | message : 'Operators must be spaced properly' 8 | description: "This rule enforces that operators have space around them." 9 | 10 | tokens: [ "+", "-", "=", "**", "MATH", "COMPARE", "LOGIC", 11 | "COMPOUND_ASSIGN", "(", ")", "CALL_START", "CALL_END" ] 12 | 13 | constructor: -> 14 | @callTokens = [] # A stack tracking the call token pairs. 15 | @parenTokens = [] # A stack tracking the parens token pairs. 16 | 17 | lintToken : ([type], tokenApi) -> 18 | 19 | # These just keep track of state 20 | if type in [ "CALL_START", "CALL_END" ] 21 | @lintCall arguments... 22 | return undefined 23 | if type in [ "(", ")" ] 24 | @lintParens arguments... 25 | return undefined 26 | 27 | 28 | # These may return errors 29 | if type in [ "+", "-" ] 30 | @lintPlus arguments... 31 | else 32 | @lintMath arguments... 33 | 34 | lintPlus: (token, tokenApi) -> 35 | # We can't check this inside of interpolations right now, because the 36 | # plusses used for the string type co-ercion are marked not spaced. 37 | if @isInInterpolation() or @isInExtendedRegex() 38 | return null 39 | 40 | p = tokenApi.peek(-1) 41 | unaries = ['TERMINATOR', '(', '=', '-', '+', ',', 'CALL_START', 42 | 'INDEX_START', '..', '...', 'COMPARE', 'IF', 43 | 'THROW', 'LOGIC', 'POST_IF', ':', '[', 'INDENT', 44 | 'COMPOUND_ASSIGN', 'RETURN', 'MATH', 'BY', 'LEADING_WHEN'] 45 | isUnary = if not p then false else p[0] in unaries 46 | if (isUnary and token.spaced) or 47 | (not isUnary and not token.spaced and not token.newLine) 48 | return {context: token[1]} 49 | else 50 | null 51 | 52 | lintMath: (token, tokenApi) -> 53 | if not token.spaced and not token.newLine 54 | return {context: token[1]} 55 | else 56 | null 57 | 58 | isInExtendedRegex : () -> 59 | for t in @callTokens 60 | return true if t.isRegex 61 | return false 62 | 63 | lintCall : (token, tokenApi) -> 64 | if token[0] == 'CALL_START' 65 | p = tokenApi.peek(-1) 66 | # Track regex calls, to know (approximately) if we're in an 67 | # extended regex. 68 | token.isRegex = p and p[0] == 'IDENTIFIER' and p[1] == 'RegExp' 69 | @callTokens.push(token) 70 | else 71 | @callTokens.pop() 72 | return null 73 | 74 | isInInterpolation : () -> 75 | for t in @parenTokens 76 | return true if t.isInterpolation 77 | return false 78 | 79 | lintParens : (token, tokenApi) -> 80 | if token[0] == '(' 81 | p1 = tokenApi.peek(-1) 82 | n1 = tokenApi.peek(1) 83 | n2 = tokenApi.peek(2) 84 | # String interpolations start with '' + so start the type co-ercion, 85 | # so track if we're inside of one. This is most definitely not 86 | # 100% true but what else can we do? 87 | i = n1 and n2 and n1[0] == 'STRING' and n2[0] == '+' 88 | token.isInterpolation = i 89 | @parenTokens.push(token) 90 | else 91 | @parenTokens.pop() 92 | # We're not linting, just tracking interpolations. 93 | null 94 | -------------------------------------------------------------------------------- /test/test_newlines_after_classes.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | 7 | vows.describe('newlines_after_classes').addBatch({ 8 | 9 | 'Classfile ends with end of class' : 10 | 11 | topic : () -> 12 | """ 13 | class Foo 14 | 15 | constructor: () -> 16 | bla() 17 | 18 | a: "b" 19 | c: "d" 20 | """ 21 | 22 | "won't match" : (source) -> 23 | config = 24 | newlines_after_classes: 25 | level: 'error' 26 | value: 3 27 | indentation: 28 | level: 'ignore' 29 | value: 4 30 | errors = coffeelint.lint(source, config) 31 | assert.equal(errors.length, 0) 32 | 33 | 34 | 'Class with arbitrary Code following' : 35 | 36 | topic : () -> 37 | """ 38 | class Foo 39 | 40 | constructor: ( ) -> 41 | bla() 42 | 43 | a: "b" 44 | c: "d" 45 | 46 | 47 | 48 | class Bar extends Foo 49 | 50 | constructor: ( ) -> 51 | bla() 52 | """ 53 | 54 | "defaults to ignore newlines_after_classes" : (source) -> 55 | config = 56 | indentation: 57 | level: 'ignore' 58 | value: 4 59 | errors = coffeelint.lint(source, config) 60 | assert.equal(errors.length, 0) 61 | 62 | "has too few newlines after class" : (source) -> 63 | config = 64 | newlines_after_classes: 65 | level: 'error' 66 | value: 4 67 | indentation: 68 | level: 'ignore' 69 | value: 4 70 | errors = coffeelint.lint(source, config) 71 | assert.equal(errors.length, 1) 72 | error = errors[0] 73 | msg = 'Wrong count of newlines between a class and other code' 74 | assert.equal(error.message, msg) 75 | assert.equal(error.rule, 'newlines_after_classes') 76 | assert.equal(error.lineNumber, 10) 77 | assert.equal(error.context, "Expected 4 got 3") 78 | 79 | "has too many newlines after class" : (source) -> 80 | config = 81 | newlines_after_classes: 82 | level: 'error' 83 | value: 2 84 | indentation: 85 | level: 'ignore' 86 | value: 4 87 | errors = coffeelint.lint(source, config) 88 | assert.equal(errors.length, 1) 89 | error = errors[0] 90 | msg = 'Wrong count of newlines between a class and other code' 91 | assert.equal(error.message, msg) 92 | assert.equal(error.rule, 'newlines_after_classes') 93 | assert.equal(error.lineNumber, 10) 94 | assert.equal(error.context, "Expected 2 got 3") 95 | 96 | "works OK" : (source) -> 97 | config = 98 | newlines_after_classes: 99 | level: 'error' 100 | value: 3 101 | indentation: 102 | level: 'ignore' 103 | value: 4 104 | errors = coffeelint.lint(source, config) 105 | assert.equal(errors.length, 0) 106 | 107 | }).export(module) 108 | -------------------------------------------------------------------------------- /generated_coffeelint.json: -------------------------------------------------------------------------------- 1 | { 2 | "coffeescript_error": { 3 | "level": "error" 4 | }, 5 | "arrow_spacing": { 6 | "name": "arrow_spacing", 7 | "level": "ignore" 8 | }, 9 | "no_tabs": { 10 | "name": "no_tabs", 11 | "level": "error" 12 | }, 13 | "no_trailing_whitespace": { 14 | "name": "no_trailing_whitespace", 15 | "level": "error", 16 | "allowed_in_comments": false, 17 | "allowed_in_empty_lines": true 18 | }, 19 | "max_line_length": { 20 | "name": "max_line_length", 21 | "value": 80, 22 | "level": "error", 23 | "limitComments": true 24 | }, 25 | "line_endings": { 26 | "name": "line_endings", 27 | "level": "ignore", 28 | "value": "unix" 29 | }, 30 | "no_trailing_semicolons": { 31 | "name": "no_trailing_semicolons", 32 | "level": "error" 33 | }, 34 | "indentation": { 35 | "name": "indentation", 36 | "value": 2, 37 | "level": "error" 38 | }, 39 | "camel_case_classes": { 40 | "name": "camel_case_classes", 41 | "level": "error" 42 | }, 43 | "colon_assignment_spacing": { 44 | "name": "colon_assignment_spacing", 45 | "level": "ignore", 46 | "spacing": { 47 | "left": 0, 48 | "right": 0 49 | } 50 | }, 51 | "no_implicit_braces": { 52 | "name": "no_implicit_braces", 53 | "level": "ignore", 54 | "strict": true 55 | }, 56 | "no_plusplus": { 57 | "name": "no_plusplus", 58 | "level": "ignore" 59 | }, 60 | "no_throwing_strings": { 61 | "name": "no_throwing_strings", 62 | "level": "error" 63 | }, 64 | "no_backticks": { 65 | "name": "no_backticks", 66 | "level": "error" 67 | }, 68 | "no_implicit_parens": { 69 | "name": "no_implicit_parens", 70 | "strict": true, 71 | "level": "ignore" 72 | }, 73 | "no_empty_param_list": { 74 | "name": "no_empty_param_list", 75 | "level": "ignore" 76 | }, 77 | "no_stand_alone_at": { 78 | "name": "no_stand_alone_at", 79 | "level": "ignore" 80 | }, 81 | "space_operators": { 82 | "name": "space_operators", 83 | "level": "ignore" 84 | }, 85 | "duplicate_key": { 86 | "name": "duplicate_key", 87 | "level": "error" 88 | }, 89 | "empty_constructor_needs_parens": { 90 | "name": "empty_constructor_needs_parens", 91 | "level": "ignore" 92 | }, 93 | "cyclomatic_complexity": { 94 | "name": "cyclomatic_complexity", 95 | "value": 10, 96 | "level": "ignore" 97 | }, 98 | "newlines_after_classes": { 99 | "name": "newlines_after_classes", 100 | "value": 3, 101 | "level": "ignore" 102 | }, 103 | "no_unnecessary_fat_arrows": { 104 | "name": "no_unnecessary_fat_arrows", 105 | "level": "warn" 106 | }, 107 | "missing_fat_arrows": { 108 | "name": "missing_fat_arrows", 109 | "level": "ignore" 110 | }, 111 | "non_empty_constructor_needs_parens": { 112 | "name": "non_empty_constructor_needs_parens", 113 | "level": "ignore" 114 | }, 115 | "no_unnecessary_double_quotes": { 116 | "name": "no_unnecessary_double_quotes", 117 | "level": "ignore" 118 | }, 119 | "no_debugger": { 120 | "name": "no_debugger", 121 | "level": "warn" 122 | }, 123 | "no_interpolation_in_single_quotes": { 124 | "name": "no_interpolation_in_single_quotes", 125 | "level": "ignore" 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /test/test_spacing.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('spacing').addBatch({ 7 | 8 | 'No spaces around binary operators' : 9 | 10 | topic : -> 11 | ''' 12 | x=1 13 | 1+1 14 | 1-1 15 | 1/1 16 | 1*1 17 | 1==1 18 | 1>=1 19 | 1>1 20 | 1<1 21 | 1<=1 22 | 1%1 23 | (a='b') -> a 24 | a|b 25 | a&b 26 | a*=-5 27 | a*=-b 28 | a*=5 29 | a*=a 30 | -a+=-2 31 | -a+=-a 32 | -a+=2 33 | -a+=a 34 | a*-b 35 | a**b 36 | a//b 37 | a%%b 38 | ''' 39 | 40 | 'are permitted by default' : (source) -> 41 | errors = coffeelint.lint(source) 42 | assert.isEmpty(errors) 43 | 44 | 'can be forbidden' : (source) -> 45 | config = {space_operators : {level:'error'}} 46 | errors = coffeelint.lint(source, config) 47 | assert.lengthOf(errors, source.split("\n").length) 48 | error = errors[0] 49 | assert.equal(error.rule, 'space_operators') 50 | assert.equal(error.lineNumber, 1) 51 | assert.equal(error.message, "Operators must be spaced properly") 52 | 53 | 'Correctly spaced operators' : 54 | 55 | topic : -> 56 | ''' 57 | x = 1 58 | 1 + 1 59 | 1 - 1 60 | 1 / 1 61 | 1 * 1 62 | 1 == 1 63 | 1 >= 1 64 | 1 > 1 65 | 1 < 1 66 | 1 <= 1 67 | (a = 'b') -> a 68 | +1 69 | -1 70 | y = -2 71 | x = -1 72 | y = x++ 73 | x = y++ 74 | 1 + (-1) 75 | -1 + 1 76 | x(-1) 77 | x(-1, 1, -1) 78 | x[..-1] 79 | x[-1..] 80 | x[-1...-1] 81 | 1 < -1 82 | a if -1 83 | a unless -1 84 | a if -1 and 1 85 | a if -1 or 1 86 | 1 and -1 87 | 1 or -1 88 | "#{a}#{b}" 89 | [+1, -1] 90 | [-1, +1] 91 | {a: -1} 92 | /// #{a} /// 93 | if -1 then -1 else -1 94 | a | b 95 | a & b 96 | a *= 5 97 | a *= -5 98 | a *= b 99 | a *= -b 100 | -a *= 5 101 | -a *= -5 102 | -a *= b 103 | -a *= -b 104 | a * -b 105 | a ** b 106 | a // b 107 | a %% b 108 | return -1 109 | for x in xs by -1 then x 110 | 111 | switch x 112 | when -1 then 42 113 | ''' 114 | 115 | 'are permitted' : (source) -> 116 | config = {space_operators : {level:'error'}} 117 | errors = coffeelint.lint(source, config) 118 | assert.isEmpty(errors) 119 | 120 | 'Spaces around unary operators' : 121 | 122 | topic : -> 123 | ''' 124 | + 1 125 | - - 1 126 | ''' 127 | 128 | 'are permitted by default' : (source) -> 129 | errors = coffeelint.lint(source) 130 | assert.isEmpty(errors) 131 | 132 | 'can be forbidden' : (source) -> 133 | config = {space_operators : {level:'error'}} 134 | errors = coffeelint.lint(source, config) 135 | assert.lengthOf(errors, 2) 136 | error = errors[0] 137 | 138 | }).export(module) 139 | 140 | -------------------------------------------------------------------------------- /test/test_colon_assignment_spacing.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('colon_assignment_spacing').addBatch({ 7 | 8 | 'Equal spacing around assignment' : 9 | 10 | topic : -> 11 | ''' 12 | object = {spacing : true} 13 | class Dog 14 | barks : true 15 | stringyObject = 16 | 'stringkey' : 'ok' 17 | ''' 18 | 19 | 'will not return an error' : (source) -> 20 | config = 21 | 'colon_assignment_spacing' : 22 | level : 'error' 23 | spacing : 24 | left : 1 25 | right : 1 26 | errors = coffeelint.lint(source, config) 27 | assert.isEmpty(errors) 28 | 29 | 'No space before assignment' : 30 | 31 | topic : -> 32 | ''' 33 | object = {spacing: true} 34 | object = 35 | spacing: true 36 | class Dog 37 | barks: true 38 | stringyObject = 39 | 'stringkey': 'ok' 40 | ''' 41 | 42 | 'will not return an error' : (source) -> 43 | config = 44 | 'colon_assignment_spacing' : 45 | level : 'error' 46 | spacing : 47 | left : 0 48 | right : 1 49 | errors = coffeelint.lint(source, config) 50 | assert.isEmpty(errors) 51 | 52 | 'Newline to the right of assignment' : 53 | 54 | topic : -> 55 | """ 56 | query: 57 | method: 'GET' 58 | isArray: false 59 | """ 60 | 61 | 'will not return an error' : (source) -> 62 | config = 63 | 'colon_assignment_spacing' : 64 | level : 'error' 65 | spacing : 66 | left : 0 67 | right : 1 68 | errors = coffeelint.lint(source, config) 69 | assert.isEmpty(errors) 70 | 71 | 'Improper spacing around assignment' : 72 | 73 | topic : -> 74 | ''' 75 | object = {spacing: false} 76 | class Cat 77 | barks: false 78 | stringyObject = 79 | 'stringkey': 'notcool' 80 | ''' 81 | 82 | 'will return an error' : (source) -> 83 | config = 84 | 'colon_assignment_spacing' : 85 | level : 'error' 86 | spacing : 87 | left : 1 88 | right : 1 89 | errors = coffeelint.lint(source, config) 90 | assert.equal(errors.length, 3) 91 | 92 | 'will ignore an error' : (source) -> 93 | config = 94 | 'colon_assignment_spacing' : 95 | level : 'ignore' 96 | spacing : 97 | left : 1 98 | right : 1 99 | errors = coffeelint.lint(source, config) 100 | assert.isEmpty(errors) 101 | 102 | 'Should not complain about strings' : 103 | 104 | topic : -> 105 | ''' 106 | foo = (stuff) -> 107 | throw new Error("Error: stuff required") unless stuff? 108 | # do real work 109 | ''' 110 | 111 | 'will return an error' : (source) -> 112 | config = 113 | 'colon_assignment_spacing' : 114 | level : 'error' 115 | spacing : 116 | left : 1 117 | right : 1 118 | errors = coffeelint.lint(source, config) 119 | assert.isEmpty(errors) 120 | 121 | }).export(module) -------------------------------------------------------------------------------- /test/test_semicolons.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | configError = {no_trailing_semicolons : {level : 'error'}} 7 | configIgnore = {no_trailing_semicolons : {level : 'ignore'}} 8 | 9 | vows.describe('semicolons').addBatch({ 10 | 11 | 'Semicolons at end of lines' : 12 | 13 | topic : () -> 14 | """ 15 | x = 1234; 16 | y = 1234; z = 1234 17 | """ 18 | 19 | 'are forbidden' : (source) -> 20 | errors = coffeelint.lint(source) 21 | assert.lengthOf(errors, 1) 22 | error = errors[0] 23 | assert.equal(error.lineNumber, 1) 24 | assert.equal(error.message, "Line contains a trailing semicolon") 25 | assert.equal(error.rule, 'no_trailing_semicolons') 26 | 27 | 'can be ignored' : (source) -> 28 | errors = coffeelint.lint(source, configIgnore) 29 | assert.isEmpty(errors) 30 | 31 | 'Semicolons in multiline expressions' : 32 | 33 | topic : ''' 34 | x = "asdf; 35 | asdf" 36 | 37 | y = """ 38 | #{asdf1}; 39 | _#{asdf2}_; 40 | asdf; 41 | """ 42 | 43 | z = /// 44 | a*\; 45 | /// 46 | ''' 47 | 48 | 'are ignored' : (source) -> 49 | errors = coffeelint.lint(source) 50 | assert.isEmpty(errors) 51 | 52 | 'Trailing semicolon in comments' : 53 | topic : "undefined\n# comment;\nundefined" 54 | 55 | 'are ignored' : (source) -> 56 | errors = coffeelint.lint(source, {}) 57 | assert.isEmpty(errors) 58 | 59 | 'Trailing semicolon in comments with no semicolon in statement': 60 | 61 | topic : "x = 3 #set x to 3;" 62 | 63 | 'are ignored' : (source) -> 64 | errors = coffeelint.lint(source, configIgnore) 65 | assert.isEmpty(errors) 66 | 67 | 'will throw an error' : (source) -> 68 | errors = coffeelint.lint(source, configError) 69 | assert.isEmpty(errors) 70 | 71 | 'Trailing semicolon in comments with semicolon in statement': 72 | 73 | topic : "x = 3; #set x to 3;" 74 | 75 | 'are ignored' : (source) -> 76 | errors = coffeelint.lint(source, configIgnore) 77 | assert.isEmpty(errors) 78 | 79 | 'will throw an error' : (source) -> 80 | errors = coffeelint.lint(source, configError) 81 | assert.lengthOf(errors, 1) 82 | error = errors[0] 83 | assert.equal(error.lineNumber, 1) 84 | assert.equal(error.message, "Line contains a trailing semicolon") 85 | assert.equal(error.rule, 'no_trailing_semicolons') 86 | 87 | 'Trailing semicolon in block comments' : 88 | 89 | topic : "###\nThis is a block comment;\n###" 90 | 91 | 'are ignored' : (source) -> 92 | errors = coffeelint.lint(source, configIgnore) 93 | assert.isEmpty(errors) 94 | 95 | 'are ignored even if config level is error' : (source) -> 96 | errors = coffeelint.lint(source, configError) 97 | assert.isEmpty(errors) 98 | 99 | 'Semicolons with windows line endings' : 100 | 101 | topic : () -> 102 | "x = 1234;\r\n" 103 | 104 | 'works as expected' : (source) -> 105 | config = { 106 | line_endings : {value : 'windows'} 107 | } 108 | errors = coffeelint.lint(source, config) 109 | assert.lengthOf(errors, 1) 110 | error = errors[0] 111 | assert.equal(error.lineNumber, 1) 112 | assert.equal(error.message, "Line contains a trailing semicolon") 113 | assert.equal(error.rule, 'no_trailing_semicolons') 114 | 115 | }).export(module) 116 | -------------------------------------------------------------------------------- /test/test_trailing.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('trailing').addBatch({ 7 | 8 | 'Trailing whitespace' : 9 | 10 | topic : () -> 11 | "x = 1234 \ny = 1" 12 | 13 | 'is forbidden by default' : (source) -> 14 | errors = coffeelint.lint(source) 15 | assert.equal(errors.length, 1) 16 | error = errors[0] 17 | assert.isObject(error) 18 | assert.equal(error.lineNumber, 1) 19 | assert.equal(error.message, "Line ends with trailing whitespace") 20 | assert.equal(error.rule, 'no_trailing_whitespace') 21 | 22 | 'can be permitted' : (source) -> 23 | config = {no_trailing_whitespace: {level: 'ignore'}} 24 | errors = coffeelint.lint(source, config) 25 | assert.equal(errors.length, 0) 26 | 27 | 'Trailing whitespace in comments' : 28 | topic : "x = 1234 # markdown comment \ny=1" 29 | 30 | 'is forbidden by default' : (source) -> 31 | errors = coffeelint.lint(source) 32 | assert.equal(errors.length, 1) 33 | error = errors[0] 34 | assert.isObject(error) 35 | assert.equal(error.lineNumber, 1) 36 | assert.equal(error.message, "Line ends with trailing whitespace") 37 | assert.equal(error.rule, 'no_trailing_whitespace') 38 | 39 | "can be permitted" : (source) -> 40 | config = {no_trailing_whitespace: {allowed_in_comments: true}} 41 | errors = coffeelint.lint(source, config) 42 | assert.equal(errors.length, 0) 43 | 44 | "a # in a string": 45 | topic: "x = 'some # string' " 46 | "does not confuse trailing_whitespace" : (source) -> 47 | config = {no_trailing_whitespace: {allowed_in_comments: true}} 48 | errors = coffeelint.lint(source, config) 49 | assert.isNotEmpty(errors) 50 | 51 | "Trailing whitespace in block comments" : 52 | topic : "###\nblock comment with trailing space: \n###" 53 | 54 | 'is forbidden by default' : (source) -> 55 | errors = coffeelint.lint(source) 56 | assert.equal(errors.length, 1) 57 | error = errors[0] 58 | assert.isObject(error) 59 | assert.equal(error.lineNumber, 2) 60 | assert.equal(error.message, "Line ends with trailing whitespace") 61 | assert.equal(error.rule, 'no_trailing_whitespace') 62 | 63 | "can be permitted" : (source) -> 64 | config = {no_trailing_whitespace: {allowed_in_comments: true}} 65 | errors = coffeelint.lint(source, config) 66 | assert.equal(errors.length, 0) 67 | 68 | "On empty lines": # https://github.com/clutchski/coffeelint/issues/39 69 | topic: "x = 1234\n \n" 70 | 71 | 'allowed by default': (source) -> 72 | errors = coffeelint.lint(source) 73 | assert.equal(errors.length, 0) 74 | 75 | 'can be forbidden': (source) -> 76 | config = {no_trailing_whitespace: {allowed_in_empty_lines: false}} 77 | 78 | errors = coffeelint.lint(source, config) 79 | assert.equal(errors.length, 1) 80 | error = errors[0] 81 | assert.isObject(error) 82 | assert.equal(error.lineNumber, 2) 83 | assert.equal(error.message, "Line ends with trailing whitespace") 84 | assert.equal(error.rule, 'no_trailing_whitespace') 85 | 86 | 'Trailing tabs' : 87 | 88 | topic : () -> 89 | "x = 1234\t" 90 | 91 | 'are forbidden as well' : (source) -> 92 | errors = coffeelint.lint(source, {}) 93 | assert.equal(errors.length, 1) 94 | 95 | 'Windows line endings' : 96 | 97 | topic : 'x = 1234\r\ny = 5678' 98 | 99 | 'are permitted' : (source) -> 100 | assert.isEmpty(coffeelint.lint(source)) 101 | 102 | }).export(module) 103 | 104 | -------------------------------------------------------------------------------- /test/test_missing_fat_arrows.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | {inspect} = require 'util' 5 | coffeelint = require path.join('..', 'lib', 'coffeelint') 6 | 7 | RULE = 'missing_fat_arrows' 8 | 9 | runLint = (source) -> 10 | config = {} 11 | config[name] = level: 'ignore' for name, rule of coffeelint.RULES 12 | config[RULE].level = 'error' 13 | coffeelint.lint source, config 14 | 15 | shouldError = (source, numErrors = 1) -> 16 | topic: source 17 | 'errors for missing arrow': (source) -> 18 | errors = runLint source 19 | assert.lengthOf errors, numErrors, 20 | "Expected #{numErrors} errors, got #{inspect errors}" 21 | error = errors[0] 22 | assert.equal error.rule, RULE 23 | 24 | shouldPass = (source) -> 25 | topic: source 26 | 'does not error for no missing arrows': (source) -> 27 | errors = runLint source 28 | assert.isEmpty errors, "Expected no errors, got #{inspect errors}" 29 | 30 | vows.describe(RULE).addBatch({ 31 | 32 | 'empty function' : shouldPass '->' 33 | 'function without this' : shouldPass '-> 1' 34 | 'function with this' : shouldError '-> this' 35 | 'function with this.a' : shouldError '-> this.a' 36 | 'function with @' : shouldError '-> @' 37 | 'function with @a' : shouldError '-> @a' 38 | 39 | 'nested functions with this inside': 40 | 'with inner fat arrow': shouldPass '-> => this' 41 | 'with outer fat arrow': shouldError '=> -> this' 42 | 'with both fat arrows': shouldPass '=> => this' 43 | 44 | 'nested functions with this outside': 45 | 'with inner fat arrow': shouldError '-> (this; =>)' 46 | 'with outer fat arrow': shouldPass '=> (this; ->)' 47 | 'with both fat arrows': shouldPass '=> (this; =>)' 48 | 49 | 'deeply nested functions': 50 | 'with thin arrow' : shouldError '-> -> -> -> -> this' 51 | 'with fat arrow' : shouldPass '-> -> -> -> => this' 52 | 'with wrong fat arrow': shouldError '-> -> => -> -> this' 53 | 54 | 'functions with multiple statements' : shouldError """ 55 | f = -> 56 | this.x = 2 57 | z ((a) -> a; this.x) 58 | """, 2 59 | 60 | 'functions with parameters' : shouldPass '(a) ->' 61 | 'functions with parameter assignment' : shouldError '(@a) ->' 62 | 'functions with destructuring parameter assignment': shouldError '({@a}) ->' 63 | 64 | 'class instance method': 65 | 'without this': shouldPass """ 66 | class A 67 | @m: -> 1 68 | """ 69 | 'with this': shouldPass """ 70 | class A 71 | @m: -> this 72 | """ 73 | 74 | 'class method': 75 | 'without this': shouldPass """ 76 | class A 77 | m: -> 1 78 | """ 79 | 'with this': shouldPass """ 80 | class A 81 | m: -> this 82 | """ 83 | 84 | 'function in class body': 85 | 'without this': shouldPass """ 86 | class A 87 | f = -> 1 88 | x: 2 89 | """ 90 | 'with this': shouldError """ 91 | class A 92 | f = -> this 93 | x: 2 94 | """ 95 | 96 | 'function inside class instance method': 97 | 'without this': shouldPass """ 98 | class A 99 | m: -> -> 1 100 | """ 101 | 'with this': shouldError """ 102 | class A 103 | m: -> -> @a 104 | """ 105 | 106 | 'mixture of class methods and function in class body': 107 | 'with this': shouldPass """ 108 | class A 109 | f = => this 110 | m: -> this 111 | @n: -> this 112 | o: -> this 113 | @p: -> this 114 | """ 115 | 116 | 'https://github.com/clutchski/coffeelint/issues/215': 117 | 'method with block comment': shouldPass """ 118 | class SimpleClass 119 | 120 | ### 121 | A block comment 122 | ### 123 | doNothing: () -> 124 | """ 125 | 126 | }).export(module) 127 | -------------------------------------------------------------------------------- /test/test_no_unnecessary_double_quotes.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('no_unnecessary_double_quotes').addBatch({ 7 | 8 | 'Single quotes' : 9 | 10 | topic : () -> 11 | ''' 12 | foo = 'single' 13 | ''' 14 | 15 | 'single quotes should always be allowed' : (source) -> 16 | config = {no_unnecessary_double_quotes : {level:'error'}} 17 | errors = coffeelint.lint(source, config) 18 | assert.isArray(errors) 19 | assert.isEmpty(errors) 20 | 21 | 22 | 'Unnecessary double quotes' : 23 | 24 | topic : () -> 25 | ''' 26 | foo = "double" 27 | ''' 28 | 29 | 'double quotes are allowed by default' : (source) -> 30 | errors = coffeelint.lint(source) 31 | assert.isArray(errors) 32 | assert.isEmpty(errors) 33 | 34 | 'double quotes can be forbidden' : (source) -> 35 | config = {no_unnecessary_double_quotes : {level:'error'}} 36 | errors = coffeelint.lint(source, config) 37 | assert.isArray(errors) 38 | assert.lengthOf(errors, 1) 39 | error = errors[0] 40 | assert.equal(error.lineNumber, 1) 41 | assert.equal(error.message, 42 | 'Unnecessary double quotes are forbidden' 43 | ) 44 | assert.equal(error.rule, 'no_unnecessary_double_quotes') 45 | 46 | 47 | 'Useful double quotes' : 48 | 49 | topic : () -> 50 | ''' 51 | interpolation = "inter#{polation}" 52 | multipleInterpolation = "#{foo}bar#{baz}" 53 | singleQuote = "single'quote" 54 | ''' 55 | 56 | 'string interpolation should always be allowed' : (source) -> 57 | config = {no_unnecessary_double_quotes : {level:'error'}} 58 | errors = coffeelint.lint(source, config) 59 | assert.isArray(errors) 60 | assert.isEmpty(errors) 61 | 62 | 63 | 'Block strings with double quotes' : 64 | 65 | topic : () -> 66 | ''' 67 | foo = """ 68 | doubleblock 69 | """ 70 | ''' 71 | 72 | 'block strings with double quotes are not allowed' : (source) -> 73 | config = {no_unnecessary_double_quotes : {level:'error'}} 74 | errors = coffeelint.lint(source, config) 75 | assert.lengthOf(errors, 1) 76 | error = errors[0] 77 | assert.equal(error.lineNumber, 1) 78 | assert.equal(error.message, 79 | 'Unnecessary double quotes are forbidden' 80 | ) 81 | assert.equal(error.rule, 'no_unnecessary_double_quotes') 82 | 83 | 84 | 'Block strings with useful double quotes' : 85 | 86 | topic : () -> 87 | ''' 88 | foo = """ 89 | #{interpolation}foo 'some single quotes for good measure' 90 | """ 91 | ''' 92 | 93 | 'block strings with useful content should be allowed' : (source) -> 94 | config = {no_unnecessary_double_quotes : {level:'error'}} 95 | errors = coffeelint.lint(source, config) 96 | assert.isArray(errors) 97 | assert.isEmpty(errors) 98 | 99 | 100 | 'Block strings with single quotes' : 101 | 102 | topic : () -> 103 | """ 104 | foo = ''' 105 | singleblock 106 | ''' 107 | """ 108 | 109 | 'block strings with single quotes should be allowed' : (source) -> 110 | config = {no_unnecessary_double_quotes : {level:'error'}} 111 | errors = coffeelint.lint(source, config) 112 | assert.isArray(errors) 113 | assert.isEmpty(errors) 114 | 115 | 116 | 'Hand concatenated string with parenthesis' : 117 | 118 | topic : () -> 119 | ''' 120 | foo = (("inter") + "polation") 121 | ''' 122 | 123 | 'double quotes should not be allowed' : (source) -> 124 | config = {no_unnecessary_double_quotes : {level:'error'}} 125 | errors = coffeelint.lint(source, config) 126 | assert.lengthOf(errors, 2) 127 | error = errors[0] 128 | assert.equal(error.lineNumber, 1) 129 | assert.equal(error.message, 130 | 'Unnecessary double quotes are forbidden' 131 | ) 132 | assert.equal(error.rule, 'no_unnecessary_double_quotes') 133 | 134 | 135 | }).export(module) 136 | -------------------------------------------------------------------------------- /src/line_linter.coffee: -------------------------------------------------------------------------------- 1 | 2 | class LineApi 3 | constructor: (source, @config, @tokensByLine, @literate) -> 4 | @line = null 5 | @lines = source.split('\n') 6 | @lineCount = @lines.length 7 | 8 | # maintains some contextual information 9 | # inClass: bool; in class or not 10 | # lastUnemptyLineInClass: null or lineNumber, if the last not-empty 11 | # line was in a class it holds its number 12 | # classIndents: the number of indents within a class 13 | @context = { 14 | class: { 15 | inClass : false 16 | lastUnemptyLineInClass : null 17 | classIndents : null 18 | } 19 | } 20 | 21 | lineNumber: 0 22 | 23 | isLiterate: -> @literate 24 | 25 | # maintain the contextual information for class-related stuff 26 | maintainClassContext : (line) -> 27 | if @context.class.inClass 28 | if @lineHasToken 'INDENT' 29 | @context.class.classIndents++ 30 | else if @lineHasToken 'OUTDENT' 31 | @context.class.classIndents-- 32 | if @context.class.classIndents is 0 33 | @context.class.inClass = false 34 | @context.class.classIndents = null 35 | 36 | if @context.class.inClass and not line.match( /^\s*$/ ) 37 | @context.class.lastUnemptyLineInClass = @lineNumber 38 | else 39 | unless line.match(/\\s*/) 40 | @context.class.lastUnemptyLineInClass = null 41 | 42 | if @lineHasToken 'CLASS' 43 | @context.class.inClass = true 44 | @context.class.lastUnemptyLineInClass = @lineNumber 45 | @context.class.classIndents = 0 46 | 47 | null 48 | 49 | isLastLine : () -> 50 | return @lineNumber == @lineCount - 1 51 | 52 | # Return true if the given line actually has tokens. 53 | # Optional parameter to check for a specific token type and line number. 54 | lineHasToken : (tokenType = null, lineNumber = null) -> 55 | lineNumber = lineNumber ? @lineNumber 56 | unless tokenType? 57 | return @tokensByLine[lineNumber]? 58 | else 59 | tokens = @tokensByLine[lineNumber] 60 | return null unless tokens? 61 | for token in tokens 62 | return true if token[0] == tokenType 63 | return false 64 | 65 | # Return tokens for the given line number. 66 | getLineTokens : () -> 67 | @tokensByLine[@lineNumber] || [] 68 | 69 | 70 | BaseLinter = require './base_linter.coffee' 71 | 72 | # Some repeatedly used regular expressions. 73 | configStatement = /coffeelint:\s*(disable|enable)(?:=([\w\s,]*))?/ 74 | 75 | # 76 | # A class that performs regex checks on each line of the source. 77 | # 78 | module.exports = class LineLinter extends BaseLinter 79 | 80 | # This is exposed here so coffeelint.coffee can reuse it 81 | @configStatement: configStatement 82 | 83 | constructor : (source, config, rules, tokensByLine, literate = false) -> 84 | super source, config, rules 85 | 86 | @lineApi = new LineApi source, config, tokensByLine, literate 87 | 88 | # Store suppressions in the form of { line #: type } 89 | @block_config = 90 | enable : {} 91 | disable : {} 92 | 93 | acceptRule: (rule) -> 94 | return typeof rule.lintLine is 'function' 95 | 96 | lint : () -> 97 | errors = [] 98 | for line, lineNumber in @lineApi.lines 99 | @lineApi.lineNumber = @lineNumber = lineNumber 100 | 101 | @lineApi.maintainClassContext line 102 | @collectInlineConfig line 103 | 104 | errors.push(error) for error in @lintLine(line) 105 | errors 106 | 107 | # Return an error if the line contained failed a rule, null otherwise. 108 | lintLine : (line) -> 109 | 110 | # Multiple rules might run against the same line to build context. 111 | # Every every rule should run even if something has already produced an 112 | # error for the same token. 113 | errors = [] 114 | for rule in @rules 115 | v = @normalizeResult rule, rule.lintLine(line, @lineApi) 116 | errors.push v if v? 117 | errors 118 | 119 | collectInlineConfig : (line) -> 120 | # Check for block config statements enable and disable 121 | result = configStatement.exec(line) 122 | if result? 123 | cmd = result[1] 124 | rules = [] 125 | if result[2]? 126 | for r in result[2].split(',') 127 | rules.push r.replace(/^\s+|\s+$/g, "") 128 | @block_config[cmd][@lineNumber] = rules 129 | return null 130 | 131 | 132 | createError: (rule, attrs = {}) -> 133 | attrs.lineNumber = @lineNumber + 1 # Lines are indexed by zero. 134 | attrs.level = @config[rule]?.level 135 | super rule, attrs 136 | 137 | -------------------------------------------------------------------------------- /src/rules/indentation.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class Indentation 3 | 4 | rule: 5 | name: 'indentation' 6 | value : 2 7 | level : 'error' 8 | message : 'Line contains inconsistent indentation' 9 | description: """ 10 | This rule imposes a standard number of spaces to be used for 11 | indentation. Since whitespace is significant in CoffeeScript, it's 12 | critical that a project chooses a standard indentation format and 13 | stays consistent. Other roads lead to darkness.
 #
 14 |             Enabling this option will prevent this ugly
 15 |             # but otherwise valid CoffeeScript.
 16 |             twoSpaces = () ->
 17 |               fourSpaces = () ->
 18 |                   eightSpaces = () ->
 19 |                         'this is valid CoffeeScript'
 20 | 
 21 |             
 22 |             
23 | Two space indentation is enabled by default. 24 | """ 25 | 26 | 27 | tokens: [ 'INDENT', "[", "]" ] 28 | 29 | constructor: -> 30 | @arrayTokens = [] # A stack tracking the array token pairs. 31 | 32 | # Return an error if the given indentation token is not correct. 33 | lintToken: (token, tokenApi) -> 34 | [type, numIndents, lineNumber] = token 35 | 36 | if type in [ "[", "]" ] 37 | @lintArray(token) 38 | return undefined 39 | 40 | return null if token.generated? 41 | 42 | # HACK: CoffeeScript's lexer insert indentation in string 43 | # interpolations that start with spaces e.g. "#{ 123 }" 44 | # so ignore such cases. Are there other times an indentation 45 | # could possibly follow a '+'? 46 | previous = tokenApi.peek(-2) 47 | isInterpIndent = previous and previous[0] == '+' 48 | 49 | # Ignore the indentation inside of an array, so that 50 | # we can allow things like: 51 | # x = ["foo", 52 | # "bar"] 53 | previous = tokenApi.peek(-1) 54 | isArrayIndent = @inArray() and previous?.newLine 55 | 56 | # Ignore indents used to for formatting on multi-line expressions, so 57 | # we can allow things like: 58 | # a = b = 59 | # c = d 60 | previousSymbol = tokenApi.peek(-1)?[0] 61 | isMultiline = previousSymbol in ['=', ','] 62 | 63 | # Summarize the indentation conditions we'd like to ignore 64 | ignoreIndent = isInterpIndent or isArrayIndent or isMultiline 65 | 66 | # Compensate for indentation in function invocations that span multiple 67 | # lines, which can be ignored. 68 | if @isChainedCall tokenApi 69 | { lines, lineNumber } = tokenApi 70 | currentLine = lines[lineNumber] 71 | prevNum = 1 72 | 73 | # keep going back until we are not at a comment or a blank line 74 | prevNum += 1 while (/^\s*(#|$)/.test(lines[lineNumber - prevNum])) 75 | previousLine = lines[lineNumber - prevNum] 76 | 77 | previousIndentation = previousLine.match(/^(\s*)/)[1].length 78 | # I don't know why, but when inside a function, you make a chained 79 | # call and define an inline callback as a parameter, the body of 80 | # that callback gets the indentation reported higher than it really 81 | # is. See issue #88 82 | # NOTE: Adding this line moved the cyclomatic complexity over the 83 | # limit, I'm not sure why 84 | numIndents = currentLine.match(/^(\s*)/)[1].length 85 | numIndents -= previousIndentation 86 | 87 | 88 | # Now check the indentation. 89 | expected = tokenApi.config[@rule.name].value 90 | if not ignoreIndent and numIndents != expected 91 | return { 92 | context: "Expected #{expected} got #{numIndents}" 93 | } 94 | # Return true if the current token is inside of an array. 95 | inArray : () -> 96 | return @arrayTokens.length > 0 97 | 98 | # Lint the given array token. 99 | lintArray : (token) -> 100 | # Track the array token pairs 101 | if token[0] == '[' 102 | @arrayTokens.push(token) 103 | else if token[0] == ']' 104 | @arrayTokens.pop() 105 | # Return null, since we're not really linting 106 | # anything here. 107 | null 108 | 109 | # Return true if the current token is part of a property access 110 | # that is split across lines, for example: 111 | # $('body') 112 | # .addClass('foo') 113 | # .removeClass('bar') 114 | isChainedCall: (tokenApi) -> 115 | { tokens, i } = tokenApi 116 | # Get the index of the second most recent new line. 117 | lines = (i for token, i in tokens[..i] when token.newLine?) 118 | 119 | lastNewLineIndex = if lines then lines[lines.length - 2] else null 120 | 121 | # Bail out if there is no such token. 122 | return false if not lastNewLineIndex? 123 | 124 | # Otherwise, figure out if that token or the next is an attribute 125 | # look-up. 126 | tokens = [tokens[lastNewLineIndex], tokens[lastNewLineIndex + 1]] 127 | 128 | return !!(t for t in tokens when t and t[0] == '.').length 129 | 130 | -------------------------------------------------------------------------------- /test/test_indent.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | 7 | vows.describe('indent').addBatch({ 8 | 9 | 'Indentation' : 10 | 11 | topic : () -> 12 | """ 13 | x = () -> 14 | 'two spaces' 15 | 16 | a = () -> 17 | 'four spaces' 18 | """ 19 | 20 | 'defaults to two spaces' : (source) -> 21 | errors = coffeelint.lint(source) 22 | assert.equal(errors.length, 1) 23 | error = errors[0] 24 | msg = 'Line contains inconsistent indentation' 25 | assert.equal(error.message, msg) 26 | assert.equal(error.rule, 'indentation') 27 | assert.equal(error.lineNumber, 5) 28 | assert.equal(error.context, "Expected 2 got 4") 29 | 30 | 'can be overridden' : (source) -> 31 | config = 32 | indentation: 33 | level: 'error' 34 | value: 4 35 | errors = coffeelint.lint(source, config) 36 | assert.equal(errors.length, 1) 37 | error = errors[0] 38 | assert.equal(error.lineNumber, 2) 39 | 40 | 'is optional' : (source) -> 41 | config = 42 | indentation: 43 | level: 'ignore' 44 | value: 4 45 | errors = coffeelint.lint(source, config) 46 | assert.equal(errors.length, 0) 47 | 48 | 'Nested indentation errors' : 49 | 50 | topic : () -> 51 | """ 52 | x = () -> 53 | y = () -> 54 | 1234 55 | """ 56 | 57 | 'are caught' : (source) -> 58 | errors = coffeelint.lint(source) 59 | assert.lengthOf(errors, 1) 60 | error = errors[0] 61 | assert.equal(error.lineNumber, 3) 62 | 63 | 'Compiler generated indentation' : 64 | 65 | topic : () -> 66 | """ 67 | () -> 68 | if 1 then 2 else 3 69 | """ 70 | 71 | 'is ignored when not using two spaces' : (source) -> 72 | config = 73 | indentation: 74 | value: 4 75 | errors = coffeelint.lint(source, config) 76 | assert.isEmpty(errors) 77 | 78 | 'Indentation inside interpolation' : 79 | 80 | topic : 'a = "#{ 1234 }"' 81 | 82 | 'is ignored' : (source) -> 83 | errors = coffeelint.lint(source) 84 | assert.isEmpty(errors) 85 | 86 | 'Indentation in multi-line expressions' : 87 | 88 | topic : """ 89 | x = '1234' + '1234' + '1234' + 90 | '1234' + '1234' 91 | """ 92 | 93 | 'is ignored' : (source) -> 94 | errors = coffeelint.lint(source) 95 | assert.isEmpty(errors) 96 | 97 | 'Indentation across line breaks' : 98 | 99 | topic : () -> 100 | """ 101 | days = ["mon", "tues", "wed", 102 | "thurs", "fri" 103 | "sat", "sun"] 104 | 105 | x = myReallyLongFunctionName = 106 | 1234 107 | 108 | arr = [() -> 109 | 1234 110 | ] 111 | """ 112 | 113 | 'is ignored' : (source) -> 114 | errors = coffeelint.lint(source) 115 | assert.isEmpty(errors) 116 | 117 | 'Indentation on seperate line invocation' : 118 | 119 | topic : """ 120 | rockinRockin 121 | .around -> 122 | 3 123 | 124 | rockrockrock. 125 | around -> 126 | 1234 127 | """ 128 | 129 | 'is ignored. Issue #4' : (source) -> 130 | errors = coffeelint.lint(source) 131 | assert.isEmpty(errors) 132 | 133 | 'Indented chained invocations' : 134 | 135 | topic : """ 136 | $('body') 137 | .addClass('k') 138 | .removeClass 'k' 139 | .animate() 140 | .hide() 141 | """ 142 | 143 | 'is permitted' : (source) -> 144 | assert.isEmpty(coffeelint.lint(source)) 145 | 146 | 'Ignore comment in indented chained invocations' : 147 | topic : () -> 148 | """ 149 | test() 150 | .r((s) -> 151 | # Ignore this comment 152 | # Ignore this one too 153 | # Ignore this one three 154 | ab() 155 | x() 156 | y() 157 | ) 158 | .s() 159 | """ 160 | 'no error when comment is in first line of a chain' : (source) -> 161 | config = 162 | indentation: 163 | value: 4 164 | errors = coffeelint.lint(source, config) 165 | assert.isEmpty(errors) 166 | 167 | 'Ignore blank line in indented chained invocations' : 168 | topic : () -> 169 | """ 170 | test() 171 | .r((s) -> 172 | 173 | 174 | ab() 175 | x() 176 | y() 177 | ) 178 | .s() 179 | """ 180 | 'no error when blank line is in first line of a chain' : (source) -> 181 | config = 182 | indentation: 183 | value: 4 184 | errors = coffeelint.lint(source, config) 185 | assert.isEmpty(errors) 186 | 187 | 'Arbitrarily indented arguments' : 188 | 189 | topic : """ 190 | myReallyLongFunction withLots, 191 | ofArguments, 192 | everywhere 193 | """ 194 | 195 | 'are permitted' : (source) -> 196 | errors = coffeelint.lint(source) 197 | assert.isEmpty(errors) 198 | 199 | 'Indenting a callback in a chained call inside a function': 200 | 201 | topic: """ 202 | someFunction = -> 203 | $.when(somePromise) 204 | .done (result) -> 205 | foo = result.bar 206 | """ 207 | 'is permitted. See issue #88': (source) -> 208 | errors = coffeelint.lint(source) 209 | assert.isEmpty(errors) 210 | 211 | }).export(module) 212 | -------------------------------------------------------------------------------- /test/test_cyclomatic_complexity.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | 7 | # Return the cyclomatic complexity of a code snippet with one function. 8 | getComplexity = (source) -> 9 | config = {cyclomatic_complexity : {level: 'error', value: 0}} 10 | errors = coffeelint.lint(source, config) 11 | assert.isNotEmpty(errors) 12 | assert.lengthOf(errors, 1) 13 | error = errors[0] 14 | assert.equal(error.rule, 'cyclomatic_complexity') 15 | return error.context 16 | 17 | 18 | vows.describe('cyclomatic complexity').addBatch({ 19 | 20 | 'Cyclomatic complexity' : 21 | 22 | topic : """ 23 | x = -> 24 | 1 and 2 and 3 and 25 | 4 and 5 and 6 and 26 | 7 and 8 and 9 and 27 | 10 and 11 28 | """ 29 | 30 | 'defaults to ignore' : (source) -> 31 | errors = coffeelint.lint(source) 32 | assert.isArray(errors) 33 | assert.isEmpty(errors) 34 | 35 | 'can be enabled' : (source) -> 36 | config = {cyclomatic_complexity : {level: 'error'}} 37 | errors = coffeelint.lint(source, config) 38 | assert.isArray(errors) 39 | assert.lengthOf(errors, 1) 40 | error = errors[0] 41 | assert.equal(error.rule, 'cyclomatic_complexity') 42 | assert.equal(error.context, 11) 43 | assert.equal(error.lineNumber, 1) 44 | assert.equal(error.lineNumberEnd, 5) 45 | 46 | 'can be enabled with configurable complexity' : (source) -> 47 | config = {cyclomatic_complexity : {level: 'error', value: 12}} 48 | errors = coffeelint.lint(source) 49 | assert.isArray(errors) 50 | assert.isEmpty(errors) 51 | 52 | 'An empty function' : 53 | 54 | topic : "x = () -> 1234" 55 | 56 | 'has a complexity of one' : (source) -> 57 | complexity = getComplexity(source) 58 | assert.equal(complexity, 1) 59 | 60 | 'If statement' : 61 | 62 | topic : "x = () -> 2 if $ == true" 63 | 64 | 'increments the complexity' : (source) -> 65 | complexity = getComplexity(source) 66 | assert.equal(complexity, 2) 67 | 68 | 69 | 'If Else statement' : 70 | 71 | topic : 'y = -> if $ then 1 else 3' 72 | 73 | 'increments the complexity' : (source) -> 74 | complexity = getComplexity(source) 75 | assert.equal(complexity, 2) 76 | 77 | 'If ElseIf statement' : 78 | 79 | topic : """ 80 | x = -> 81 | if 1233 82 | 'abc' 83 | else if 456 84 | 'xyz' 85 | """ 86 | 87 | 'has a complexity of three' : (source) -> 88 | complexity = getComplexity(source) 89 | assert.equal(complexity, 3) 90 | 91 | 'If If-Else Else statement' : 92 | 93 | topic : """ 94 | z = () -> 95 | if x 96 | 1 97 | else if y 98 | 2 99 | else 100 | 3 101 | """ 102 | 103 | 'has a complexity of three' : (source) -> 104 | complexity = getComplexity(source) 105 | assert.equal(complexity, 3) 106 | 107 | 'Nested if statements' : 108 | 109 | topic : """ 110 | z = () -> 111 | if abc? 112 | if other? 113 | 123 114 | """ 115 | 116 | 'has a complexity of three' : (source) -> 117 | complexity = getComplexity(source) 118 | assert.equal(complexity, 3) 119 | 120 | 121 | 'A while loop' : 122 | 123 | topic : """ 124 | x = () -> 125 | while 1 126 | 'asdf' 127 | """ 128 | 129 | 'increments complexity' : (source) -> 130 | complexity = getComplexity(source) 131 | assert.equal(complexity, 2) 132 | 133 | 'An until loop' : 134 | 135 | topic : "x = () -> log 'a' until $?" 136 | 137 | 'increments complexity' : (source) -> 138 | complexity = getComplexity(source) 139 | assert.equal(complexity, 2) 140 | 141 | 'A for loop' : 142 | 143 | topic : """ 144 | x = () -> 145 | for i in window 146 | log i 147 | """ 148 | 149 | 'increments complexity' : (source) -> 150 | complexity = getComplexity(source) 151 | assert.equal(complexity, 2) 152 | 153 | 'A list comprehension' : 154 | 155 | topic : "x = -> [a for a in window]" 156 | 157 | 'increments complexity' : (source) -> 158 | complexity = getComplexity(source) 159 | assert.equal(complexity, 2) 160 | 161 | 'Try / Catch blocks' : 162 | 163 | topic : """ 164 | x = () -> 165 | try 166 | divide("byZero") 167 | catch error 168 | log("uh oh") 169 | """ 170 | 171 | 'increments complexity' : (source) -> 172 | assert.equal(getComplexity(source), 2) 173 | 174 | 'Try / Catch / Finally blocks' : 175 | 176 | topic : """ 177 | x = () -> 178 | try 179 | divide("byZero") 180 | catch error 181 | log("uh oh") 182 | finally 183 | clean() 184 | """ 185 | 186 | 'increments complexity' : (source) -> 187 | assert.equal(getComplexity(source), 2) 188 | 189 | 'Switch statements without an else' : 190 | 191 | topic : ''' 192 | x = () -> 193 | switch a 194 | when "b" then "b" 195 | when "c" then "c" 196 | when "d" then "d" 197 | ''' 198 | 199 | 'increase complexity by the number of cases' : (source) -> 200 | complexity = getComplexity(source) 201 | assert.equal(complexity, 4) 202 | 203 | 'Switch statements with an else' : 204 | 205 | topic : ''' 206 | x = () -> 207 | switch a 208 | when "b" then "b" 209 | when "c" then "c" 210 | when "d" then "d" 211 | else "e" 212 | ''' 213 | 214 | 'increase complexity by the number of cases' : (source) -> 215 | complexity = getComplexity(source) 216 | assert.equal(complexity, 4) 217 | 218 | 'And operators' : 219 | 220 | topic : 'x = () -> $ and window' 221 | 222 | 'increments the complexity' : (source) -> 223 | complexity = getComplexity(source) 224 | assert.equal(complexity, 2) 225 | 226 | 'Or operators' : 227 | 228 | topic : 'x = () -> $ or window' 229 | 230 | 'increments the complexity' : (source) -> 231 | complexity = getComplexity(source) 232 | assert.equal(complexity, 2) 233 | 234 | 'A complicated example' : 235 | 236 | topic : """ 237 | x = () -> 238 | if a and b and c or d and c or e 239 | if x or d or e of f 240 | 1 241 | else if window 242 | while 1 and 3 243 | 2 244 | while false 245 | y 246 | return false 247 | """ 248 | 249 | 'works' : (source) -> 250 | config = {cyclomatic_complexity : {level: 'error'}} 251 | errors = coffeelint.lint(source, config) 252 | assert.isArray(errors) 253 | assert.lengthOf(errors, 1) 254 | error = errors[0] 255 | assert.equal(error.rule, 'cyclomatic_complexity') 256 | assert.equal(error.lineNumber, 1) 257 | assert.equal(error.lineNumberEnd, 10) 258 | assert.equal(error.context, 14) 259 | 260 | }).export(module) 261 | -------------------------------------------------------------------------------- /test/test_arrows.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | vows = require 'vows' 3 | assert = require 'assert' 4 | coffeelint = require path.join('..', 'lib', 'coffeelint') 5 | 6 | vows.describe('arrows').addBatch({ 7 | 8 | 'No spacing around the arrow operator' : 9 | 10 | topic : -> 11 | ''' 12 | test1 = (foo, bar)->console.log("foobar") 13 | test2 = (foo, bar) ->console.log("foo->bar") 14 | test3 = (foo, bar)-> console.log("foo->bar") 15 | test4 = (foo, bar)-> 16 | console.log("foo->bar") 17 | test5 = (foo, bar) -> 18 | console.log("foo->bar") 19 | ''' 20 | 21 | 'will return an error' : (source) -> 22 | config = { 23 | "indentation" : { "value": 2, "level": "error" } 24 | "arrow_spacing": { "level": "error"} 25 | } 26 | errors = coffeelint.lint(source, config) 27 | assert.equal(errors.length, 4) 28 | assert.equal(errors[0].lineNumber, 1) 29 | assert.equal(errors[1].lineNumber, 2) 30 | assert.equal(errors[2].lineNumber, 3) 31 | assert.equal(errors[3].lineNumber, 4) 32 | 33 | 'will be ignored (no error)' : (source) -> 34 | config = { "arrow_spacing": { "level": "ignore" } } 35 | errors = coffeelint.lint(source, config) 36 | assert.isEmpty(errors) 37 | 38 | 'Handles good spacing when parentheses are generated' : 39 | 40 | topic : -> 41 | ''' 42 | testingThis 43 | .around -> 44 | -> 4 45 | 46 | testingThis 47 | .around -> 48 | 4 49 | 50 | testingThis 51 | .around -> 4 52 | 53 | testingThis = 54 | -> 5 55 | 56 | testingThis.around (a, b) -> 57 | -> "4" 58 | 59 | ''' 60 | 61 | 'when spacing is not required around arrow operator' : (source) -> 62 | config = { 63 | "indentation": { "value": 2, "level": "error" }, 64 | "arrow_spacing": { "level": "ignore" } 65 | } 66 | errors = coffeelint.lint(source, config) 67 | assert.isEmpty(errors) 68 | 69 | 'when spacing is required around arrow operator' : (source) -> 70 | config = { 71 | "indentation": { "value": 2, "level": "error" }, 72 | "arrow_spacing": { "level": "error" } 73 | } 74 | errors = coffeelint.lint(source, config) 75 | assert.isEmpty(errors) 76 | 77 | 'Handles bad spacing when parentheses are generated' : 78 | 79 | topic : -> 80 | ''' 81 | testingThis 82 | .around -> 83 | ->4 84 | 85 | testingThis 86 | .around ->4 87 | 88 | testingThis = 89 | ->5 90 | 91 | testingThis.around (a, b) -> 92 | ->"4" 93 | 94 | testingThis ->-> "X" 95 | 96 | testingThis ->->"X" 97 | ''' 98 | 99 | 'when spacing is required around arrow operator' : (source) -> 100 | config = { "arrow_spacing": { "level": "error" } } 101 | errors = coffeelint.lint(source, config) 102 | assert.equal(errors[0].lineNumber, 3) 103 | assert.equal(errors[1].lineNumber, 6) 104 | assert.equal(errors[2].lineNumber, 9) 105 | assert.equal(errors[3].lineNumber, 12) 106 | assert.equal(errors[4].lineNumber, 14) 107 | assert.equal(errors[5].lineNumber, 16) 108 | assert.equal(errors[6].lineNumber, 16) 109 | assert.equal(errors.length, 7) 110 | 111 | 'when spacing is not required around arrow operator' : (source) -> 112 | config = { "arrow_spacing": { "level": "ignore" } } 113 | errors = coffeelint.lint(source, config) 114 | assert.isEmpty(errors) 115 | 116 | 'Ignore spacing for non-generated parentheses' : 117 | # if the function has no parameters (and thus no parentheses), 118 | # it will accept a lack of spacing preceding the arrow (first example) 119 | topic : -> 120 | ''' 121 | x(-> 3) 122 | x( -> 3) 123 | x((a,b) -> c) 124 | (-> true)() 125 | ''' 126 | 'when spacing is required around arrow operator' : (source) -> 127 | config = { "arrow_spacing": { "level": "error" } } 128 | errors = coffeelint.lint(source, config) 129 | assert.equal(errors.length, 0) 130 | 131 | 'when spacing is not required around arrow operator' : (source) -> 132 | config = { "arrow_spacing": { "level": "ignore" } } 133 | errors = coffeelint.lint(source, config) 134 | assert.isEmpty(errors, 0) 135 | 136 | 'Handle an arrow at beginning of statement' : 137 | topic : -> 138 | ''' 139 | @waitForSelector ".application", 140 | -> @test.pass "homepage loaded ok" 141 | -> @test.fail "homepage didn't load" 142 | 2000 143 | ''' 144 | 145 | 'when spacing is required around arrow operator' : (source) -> 146 | config = { "arrow_spacing": { "level": "error" } } 147 | errors = coffeelint.lint(source, config) 148 | assert.equal(errors.length, 0) 149 | 150 | 'when spacing is not required around arrow operator' : (source) -> 151 | config = { "arrow_spacing": { "level": "ignore" } } 152 | errors = coffeelint.lint(source, config) 153 | assert.isEmpty(errors, 0) 154 | 155 | 'Handle a nested arrow at end of file' : 156 | topic : -> 157 | 'class A\n f: ->' 158 | 159 | 'when spacing is required around arrow operator' : (source) -> 160 | config = { "arrow_spacing": { "level": "error" } } 161 | errors = coffeelint.lint(source, config) 162 | assert.equal(errors.length, 0) 163 | 164 | 'when spacing is not required around arrow operator' : (source) -> 165 | config = { "arrow_spacing": { "level": "ignore" } } 166 | errors = coffeelint.lint(source, config) 167 | assert.isEmpty(errors, 0) 168 | 169 | 'Handle a nested arrow at end of file' : 170 | topic : -> 171 | 'define ->\n class A\n f: ->' 172 | 173 | 'when spacing is required around arrow operator' : (source) -> 174 | config = { "arrow_spacing": { "level": "error" } } 175 | errors = coffeelint.lint(source, config) 176 | assert.equal(errors.length, 0) 177 | 178 | 'when spacing is not required around arrow operator' : (source) -> 179 | config = { "arrow_spacing": { "level": "ignore" } } 180 | errors = coffeelint.lint(source, config) 181 | assert.isEmpty(errors, 0) 182 | 183 | 'Handle an arrow at end of file' : 184 | topic : -> 185 | 'f: ->' 186 | 187 | 'when spacing is required around arrow operator' : (source) -> 188 | config = { "arrow_spacing": { "level": "error" } } 189 | errors = coffeelint.lint(source, config) 190 | assert.equal(errors.length, 0) 191 | 192 | 'when spacing is not required around arrow operator' : (source) -> 193 | config = { "arrow_spacing": { "level": "ignore" } } 194 | errors = coffeelint.lint(source, config) 195 | assert.isEmpty(errors, 0) 196 | 197 | 'Handle an arrow at end of file' : 198 | topic : -> 199 | '{f: ->}' 200 | 201 | 'when spacing is required around arrow operator' : (source) -> 202 | config = { "arrow_spacing": { "level": "error" } } 203 | errors = coffeelint.lint(source, config) 204 | assert.equal(errors.length, 1) 205 | 206 | 'when spacing is not required around arrow operator' : (source) -> 207 | config = { "arrow_spacing": { "level": "ignore" } } 208 | errors = coffeelint.lint(source, config) 209 | assert.isEmpty(errors, 0) 210 | 211 | }).export(module) 212 | -------------------------------------------------------------------------------- /src/coffeelint.coffee: -------------------------------------------------------------------------------- 1 | ### 2 | CoffeeLint 3 | 4 | Copyright (c) 2011 Matthew Perpick. 5 | CoffeeLint is freely distributable under the MIT license. 6 | ### 7 | 8 | 9 | # Coffeelint's namespace. 10 | # Browserify wrapps this file in a UMD that will set window.coffeelint to 11 | # exports 12 | coffeelint = exports 13 | 14 | if window? 15 | # If we're in the browser assume CoffeeScript is already loaded. 16 | CoffeeScript = window.CoffeeScript 17 | else 18 | # By storing this in a variable it prevents browserify from finding this 19 | # dependency. If it isn't hidden there is an error attempting to inline 20 | # CoffeeScript. if browserify uses `-i` to ignore the dependency it 21 | # creates an empty shim which breaks NodeJS 22 | # https://github.com/substack/node-browserify/issues/471 23 | cs = 'coffee-script' 24 | CoffeeScript = require cs 25 | 26 | # Browserify will inline the file at compile time. 27 | packageJSON = require('./../package.json') 28 | 29 | # The current version of Coffeelint. 30 | coffeelint.VERSION = packageJSON.version 31 | 32 | 33 | # CoffeeLint error levels. 34 | ERROR = 'error' 35 | WARN = 'warn' 36 | IGNORE = 'ignore' 37 | 38 | coffeelint.RULES = RULES = require './rules.coffee' 39 | 40 | # Patch the source properties onto the destination. 41 | extend = (destination, sources...) -> 42 | for source in sources 43 | (destination[k] = v for k, v of source) 44 | return destination 45 | 46 | # Patch any missing attributes from defaults to source. 47 | defaults = (source, defaults) -> 48 | extend({}, defaults, source) 49 | 50 | # Helper to remove rules from disabled list 51 | difference = (a, b) -> 52 | j = 0 53 | while j < a.length 54 | if a[j] in b 55 | a.splice(j, 1) 56 | else 57 | j++ 58 | 59 | LineLinter = require './line_linter.coffee' 60 | LexicalLinter = require './lexical_linter.coffee' 61 | ASTLinter = require './ast_linter.coffee' 62 | 63 | # Merge default and user configuration. 64 | mergeDefaultConfig = (userConfig) -> 65 | config = {} 66 | for rule, ruleConfig of RULES 67 | config[rule] = defaults(userConfig[rule], ruleConfig) 68 | 69 | 70 | return config 71 | 72 | coffeelint.invertLiterate = (source) -> 73 | source = CoffeeScript.helpers.invertLiterate source 74 | # Strip the first 4 spaces from every line. After this the markdown is 75 | # commented and all of the other code should be at their natural location. 76 | newSource = "" 77 | for line in source.split "\n" 78 | if line.match(/^#/) 79 | # strip trailing space 80 | line = line.replace /\s*$/, '' 81 | # Strip the first 4 spaces of every line. This is how Markdown 82 | # indicates code, so in the end this pulls everything back to where it 83 | # would be indented if it hadn't been written in literate style. 84 | line = line.replace /^\s{4}/g, '' 85 | newSource += "#{line}\n" 86 | 87 | newSource 88 | 89 | _rules = {} 90 | coffeelint.registerRule = (RuleConstructor, ruleName = undefined) -> 91 | p = new RuleConstructor 92 | 93 | name = p?.rule?.name or "(unknown)" 94 | e = (msg) -> throw new Error "Invalid rule: #{name} #{msg}" 95 | unless p.rule? 96 | e "Rules must provide rule attribute with a default configuration." 97 | 98 | e "Rule defaults require a name" unless p.rule.name? 99 | 100 | if ruleName? and ruleName isnt p.rule.name 101 | e "Mismatched rule name: #{ruleName}" 102 | 103 | e "Rule defaults require a message" unless p.rule.message? 104 | e "Rule defaults require a description" unless p.rule.description? 105 | unless p.rule.level in [ 'ignore', 'warn', 'error' ] 106 | e "Default level must be 'ignore', 'warn', or 'error'" 107 | 108 | if typeof p.lintToken is 'function' 109 | e "'tokens' is required for 'lintToken'" unless p.tokens 110 | else if typeof p.lintLine isnt 'function' and 111 | typeof p.lintAST isnt 'function' 112 | e "Rules must implement lintToken, lintLine, or lintAST" 113 | 114 | # Capture the default options for the new rule. 115 | RULES[p.rule.name] = p.rule 116 | _rules[p.rule.name] = RuleConstructor 117 | 118 | # These all need to be explicitly listed so they get picked up by browserify. 119 | coffeelint.registerRule require './rules/arrow_spacing.coffee' 120 | coffeelint.registerRule require './rules/no_tabs.coffee' 121 | coffeelint.registerRule require './rules/no_trailing_whitespace.coffee' 122 | coffeelint.registerRule require './rules/max_line_length.coffee' 123 | coffeelint.registerRule require './rules/line_endings.coffee' 124 | coffeelint.registerRule require './rules/no_trailing_semicolons.coffee' 125 | coffeelint.registerRule require './rules/indentation.coffee' 126 | coffeelint.registerRule require './rules/camel_case_classes.coffee' 127 | coffeelint.registerRule require './rules/colon_assignment_spacing.coffee' 128 | coffeelint.registerRule require './rules/no_implicit_braces.coffee' 129 | coffeelint.registerRule require './rules/no_plusplus.coffee' 130 | coffeelint.registerRule require './rules/no_throwing_strings.coffee' 131 | coffeelint.registerRule require './rules/no_backticks.coffee' 132 | coffeelint.registerRule require './rules/no_implicit_parens.coffee' 133 | coffeelint.registerRule require './rules/no_empty_param_list.coffee' 134 | coffeelint.registerRule require './rules/no_stand_alone_at.coffee' 135 | coffeelint.registerRule require './rules/space_operators.coffee' 136 | coffeelint.registerRule require './rules/duplicate_key.coffee' 137 | coffeelint.registerRule require './rules/empty_constructor_needs_parens.coffee' 138 | coffeelint.registerRule require './rules/cyclomatic_complexity.coffee' 139 | coffeelint.registerRule require './rules/newlines_after_classes.coffee' 140 | coffeelint.registerRule require './rules/no_unnecessary_fat_arrows.coffee' 141 | coffeelint.registerRule require './rules/missing_fat_arrows.coffee' 142 | coffeelint.registerRule( 143 | require './rules/non_empty_constructor_needs_parens.coffee' 144 | ) 145 | coffeelint.registerRule require './rules/no_unnecessary_double_quotes.coffee' 146 | coffeelint.registerRule require './rules/no_debugger.coffee' 147 | coffeelint.registerRule( 148 | require './rules/no_interpolation_in_single_quotes.coffee' 149 | ) 150 | 151 | hasSyntaxError = (source) -> 152 | try 153 | # If there are syntax errors this will abort the lexical and line 154 | # linters. 155 | CoffeeScript.tokens(source) 156 | return false 157 | return true 158 | 159 | # Check the source against the given configuration and return an array 160 | # of any errors found. An error is an object with the following 161 | # properties: 162 | # 163 | # { 164 | # rule : 'Name of the violated rule', 165 | # lineNumber: 'Number of the line that caused the violation', 166 | # level: 'The error level of the violated rule', 167 | # message: 'Information about the violated rule', 168 | # context: 'Optional details about why the rule was violated' 169 | # } 170 | # 171 | coffeelint.lint = (source, userConfig = {}, literate = false) -> 172 | source = @invertLiterate source if literate 173 | 174 | config = mergeDefaultConfig(userConfig) 175 | 176 | # Check ahead for inline enabled rules 177 | disabled_initially = [] 178 | for l in source.split('\n') 179 | s = LineLinter.configStatement.exec(l) 180 | if s?.length > 2 and 'enable' in s 181 | for r in s[1..] 182 | unless r in ['enable','disable'] 183 | unless r of config and config[r].level in ['warn','error'] 184 | disabled_initially.push r 185 | config[r] = { level: 'error' } 186 | 187 | # Do AST linting first so all compile errors are caught. 188 | astErrors = new ASTLinter(source, config, _rules, CoffeeScript).lint() 189 | errors = [].concat(astErrors) 190 | 191 | # only do further checks if the syntax is okay, otherwise they just fail 192 | # with syntax error exceptions 193 | unless hasSyntaxError(source) 194 | # Do lexical linting. 195 | lexicalLinter = new LexicalLinter(source, config, _rules, CoffeeScript) 196 | lexErrors = lexicalLinter.lint() 197 | errors = errors.concat(lexErrors) 198 | 199 | # Do line linting. 200 | tokensByLine = lexicalLinter.tokensByLine 201 | lineLinter = new LineLinter(source, config, _rules, tokensByLine, 202 | literate) 203 | lineErrors = lineLinter.lint() 204 | errors = errors.concat(lineErrors) 205 | block_config = lineLinter.block_config 206 | else 207 | # default this so it knows what to do 208 | block_config = 209 | enable : {} 210 | disable : {} 211 | 212 | # Sort by line number and return. 213 | errors.sort((a, b) -> a.lineNumber - b.lineNumber) 214 | 215 | # Disable/enable rules for inline blocks 216 | all_errors = errors 217 | errors = [] 218 | disabled = disabled_initially 219 | next_line = 0 220 | for i in [0...source.split('\n').length] 221 | for cmd of block_config 222 | rules = block_config[cmd][i] 223 | { 224 | 'disable': -> 225 | disabled = disabled.concat(rules) 226 | 'enable': -> 227 | difference(disabled, rules) 228 | disabled = disabled_initially if rules.length is 0 229 | }[cmd]() if rules? 230 | # advance line and append relevant messages 231 | while next_line is i and all_errors.length > 0 232 | next_line = all_errors[0].lineNumber - 1 233 | e = all_errors[0] 234 | if e.lineNumber is i + 1 or not e.lineNumber? 235 | e = all_errors.shift() 236 | errors.push e unless e.rule in disabled 237 | 238 | errors 239 | -------------------------------------------------------------------------------- /src/coffeelint-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "JSON schema for coffeelint.json files", 3 | "$schema": "http://json-schema.org/draft-04/schema#", 4 | "definitions": { 5 | "level": { 6 | "description": "Determines the error level", 7 | "type": "string", 8 | "enum": [ "warn", "error", "ignore" ] 9 | }, 10 | "coffeelint": { 11 | "type": "object", 12 | "additionalProperties": true, 13 | "properties": { 14 | "arrow_spacing": { 15 | "description": "This rule checks to see that there is spacing before and after the arrow operator that declares a function.", 16 | "type": "object", 17 | "properties": { 18 | "level": { 19 | "$ref": "#/definitions/level" 20 | } 21 | } 22 | }, 23 | "camel_case_classes": { 24 | "description": "This rule mandates that all class names are CamelCased. Camel casing class names is a generally accepted way of distinguishing constructor functions - which require the 'new' prefix to behave properly - from plain old functions.", 25 | "type": "object", 26 | "properties": { 27 | "level": { 28 | "$ref": "#/definitions/level" 29 | } 30 | } 31 | }, 32 | "coffeescript_error": { 33 | "type": "object", 34 | "properties": { 35 | "level": { 36 | "$ref": "#/definitions/level" 37 | } 38 | } 39 | }, 40 | "colon_assignment_spacing": { 41 | "description": "This rule checks to see that there is spacing before and after the colon in a colon assignment (i.e., classes, objects).", 42 | "type": "object", 43 | "properties": { 44 | "level": { 45 | "$ref": "#/definitions/level" 46 | }, 47 | "spacing": { 48 | "type": "object", 49 | "properties": { 50 | "left": { 51 | "type": "integer", 52 | "enum": [0, 1] 53 | }, 54 | "right": { 55 | "type": "integer", 56 | "enum": [0, 1] 57 | } 58 | } 59 | } 60 | } 61 | }, 62 | "cyclomatic_complexity": { 63 | "description": "Examine the complexity of your application.", 64 | "type": "object", 65 | "properties": { 66 | "level": { 67 | "$ref": "#/definitions/level" 68 | }, 69 | "value": { 70 | "type": "integer" 71 | } 72 | } 73 | }, 74 | "duplicate_key": { 75 | "description": "Prevents defining duplicate keys in object literals and classes.", 76 | "type": "object", 77 | "properties": { 78 | "level": { 79 | "$ref": "#/definitions/level" 80 | } 81 | } 82 | }, 83 | "empty_constructor_needs_parens": { 84 | "description": "Requires constructors with no parameters to include the parens.", 85 | "type": "object", 86 | "properties": { 87 | "level": { 88 | "$ref": "#/definitions/level" 89 | } 90 | } 91 | }, 92 | "indentation": { 93 | "description": " This rule imposes a standard number of spaces to be used for indentation. Since whitespace is significant in CoffeeScript, it's critical that a project chooses a standard indentation format and stays consistent. Other roads lead to darkness.", 94 | "type": "object", 95 | "properties": { 96 | "level": { 97 | "$ref": "#/definitions/level" 98 | }, 99 | "value": { 100 | "type": "integer", 101 | "enum": [2, 4] 102 | } 103 | } 104 | }, 105 | "line_endings": { 106 | "description": "This rule ensures your project uses only windows or unix line endings.", 107 | "type": "object", 108 | "properties": { 109 | "level": { 110 | "$ref": "#/definitions/level" 111 | }, 112 | "value": { 113 | "type": "string", 114 | "enum": [ "unix", "windows" ] 115 | } 116 | } 117 | }, 118 | "max_line_length": { 119 | "description": "This rule imposes a maximum line length on your code.", 120 | "type": "object", 121 | "properties": { 122 | "level": { 123 | "$ref": "#/definitions/level" 124 | }, 125 | "value": { 126 | "type": "integer" 127 | }, 128 | "limitComments": { 129 | "type": "boolean" 130 | } 131 | } 132 | }, 133 | "missing_fat_arrows": { 134 | "description": "Warns when you use `this` inside a function that wasn't defined with a fat arrow. This rule does not apply to methods defined in a class, since they have `this` bound to the class instance (or the class itself, for class methods).", 135 | "type": "object", 136 | "properties": { 137 | "level": { 138 | "$ref": "#/definitions/level" 139 | } 140 | } 141 | }, 142 | "newlines_after_classes": { 143 | "description": "Checks the number of newlines between classes and other code.", 144 | "type": "object", 145 | "properties": { 146 | "level": { 147 | "$ref": "#/definitions/level" 148 | }, 149 | "value": { 150 | "type": "integer", 151 | "enum": [0, 1, 2] 152 | } 153 | } 154 | }, 155 | "no_backticks": { 156 | "description": "Backticks allow snippets of JavaScript to be embedded in CoffeeScript. While some folks consider backticks useful in a few niche circumstances, they should be avoided because so none of JavaScript's \"bad parts\", like with and eval, sneak into CoffeeScript.", 157 | "type": "object", 158 | "properties": { 159 | "level": { 160 | "$ref": "#/definitions/level" 161 | } 162 | } 163 | }, 164 | "no_debugger": { 165 | "description": "This rule detects the `debugger` statement.", 166 | "type": "object", 167 | "properties": { 168 | "level": { 169 | "$ref": "#/definitions/level" 170 | } 171 | } 172 | }, 173 | "no_empty_param_list": { 174 | "description": "This rule prohibits empty parameter lists in function definitions.", 175 | "type": "object", 176 | "properties": { 177 | "level": { 178 | "$ref": "#/definitions/level" 179 | } 180 | } 181 | }, 182 | "no_implicit_braces": { 183 | "description": "This rule prohibits implicit braces when declaring object literals. Implicit braces can make code more difficult to understand, especially when used in combination with optional parenthesis.", 184 | "type": "object", 185 | "properties": { 186 | "level": { 187 | "$ref": "#/definitions/level" 188 | }, 189 | "strict": { 190 | "type": "boolean" 191 | } 192 | } 193 | }, 194 | "no_implicit_parens": { 195 | "description": "This rule prohibits implicit parens on function calls.", 196 | "type": "object", 197 | "properties": { 198 | "level": { 199 | "$ref": "#/definitions/level" 200 | } 201 | } 202 | }, 203 | "no_interpolation_in_single_quotes": { 204 | "description": "This rule prohibits string interpolation in a single quoted string.", 205 | "type": "object", 206 | "properties": { 207 | "level": { 208 | "$ref": "#/definitions/level" 209 | } 210 | } 211 | }, 212 | "no_plusplus": { 213 | "description": "This rule forbids the increment and decrement arithmetic operators. Some people believe the ++ and -- to be cryptic and the cause of bugs due to misunderstandings of their precedence rules.", 214 | "type": "object", 215 | "properties": { 216 | "level": { 217 | "$ref": "#/definitions/level" 218 | } 219 | } 220 | }, 221 | "no_stand_alone_at": { 222 | "description": "This rule checks that no stand alone @ are in use, they are discouraged.", 223 | "type": "object", 224 | "properties": { 225 | "level": { 226 | "$ref": "#/definitions/level" 227 | } 228 | } 229 | }, 230 | "no_tabs": { 231 | "description": "This rule forbids tabs in indentation. Enough said.", 232 | "type": "object", 233 | "properties": { 234 | "level": { 235 | "$ref": "#/definitions/level" 236 | } 237 | } 238 | }, 239 | "no_throwing_strings": { 240 | "description": " This rule forbids throwing string literals or interpolations. While JavaScript (and CoffeeScript by extension) allow any expression to be thrown, it is best to only throw Error objects, because they contain valuable debugging information like the stack trace.", 241 | "type": "object", 242 | "properties": { 243 | "level": { 244 | "$ref": "#/definitions/level" 245 | } 246 | } 247 | }, 248 | "no_trailing_semicolons": { 249 | "description": "This rule prohibits trailing semicolons, since they are needless cruft in CoffeeScript.", 250 | "type": "object", 251 | "properties": { 252 | "level": { 253 | "$ref": "#/definitions/level" 254 | } 255 | } 256 | }, 257 | "no_trailing_whitespace": { 258 | "description": "This rule forbids trailing whitespace in your code, since it is needless cruft.", 259 | "type": "object", 260 | "properties": { 261 | "level": { 262 | "$ref": "#/definitions/level" 263 | }, 264 | "allowed_in_comments": { 265 | "type": "boolean" 266 | }, 267 | "allowed_in_empty_lines": { 268 | "type": "boolean" 269 | } 270 | } 271 | }, 272 | "no_unnecessary_double_quotes": { 273 | "description": "This rule prohibits double quotes unless string interpolation is used or the string contains single quotes.", 274 | "type": "object", 275 | "properties": { 276 | "level": { 277 | "$ref": "#/definitions/level" 278 | } 279 | } 280 | }, 281 | "no_unnecessary_fat_arrows": { 282 | "description": "Disallows defining functions with fat arrows when `this` is not used within the function. ", 283 | "type": "object", 284 | "properties": { 285 | "level": { 286 | "$ref": "#/definitions/level" 287 | } 288 | } 289 | }, 290 | "non_empty_constructor_needs_parens": { 291 | "description": "Requires constructors with parameters to include the parens.", 292 | "type": "object", 293 | "properties": { 294 | "level": { 295 | "$ref": "#/definitions/level" 296 | } 297 | } 298 | }, 299 | "space_operators": { 300 | "description": "This rule enforces that operators have space around them. ", 301 | "type": "object", 302 | "properties": { 303 | "level": { 304 | "$ref": "#/definitions/level" 305 | } 306 | } 307 | } 308 | } 309 | } 310 | }, 311 | 312 | "oneOf": [ { "$ref": "#/definitions/coffeelint" } ], 313 | "type": "object" 314 | } -------------------------------------------------------------------------------- /test/test_commandline.coffee: -------------------------------------------------------------------------------- 1 | # 2 | # Tests for the command line tool. 3 | # 4 | 5 | path = require 'path' 6 | fs = require 'fs' 7 | vows = require 'vows' 8 | assert = require 'assert' 9 | {spawn, exec} = require 'child_process' 10 | coffeelint = require path.join('..', 'lib', 'coffeelint') 11 | 12 | 13 | # The path to the command line tool. 14 | coffeelintPath = path.join('bin', 'coffeelint') 15 | 16 | # Run the coffeelint command line with the given 17 | # args. Callback will be called with (error, stdout, 18 | # stderr) 19 | commandline = (args, callback) -> 20 | exec("#{coffeelintPath} #{args.join(" ")}", callback) 21 | 22 | 23 | process.env.HOME = "" 24 | process.env.HOMEPATH = "" 25 | process.env.USERPROFILE = "" 26 | process.env.COFFEELINT_CONFIG = "" 27 | 28 | # Custom rules are loaded by module name. Using a relative path in the test is 29 | # an unrealistic example when rules can be installed with `npm install -g 30 | # some-custom-rule`. This will setup a fake version of node_modules to a 31 | # relative path doesn't have to be used. 32 | process.env.NODE_PATH += ":" + path.resolve( __dirname, 33 | "fixtures/mock_node_modules/") 34 | 35 | vows.describe('commandline').addBatch({ 36 | 37 | 'with no args' : 38 | 39 | topic : () -> 40 | commandline [], this.callback 41 | return undefined 42 | 43 | 'shows usage' : (error, stdout, stderr) -> 44 | assert.isNotNull(error) 45 | assert.notEqual(error.code, 0) 46 | assert.include(stderr, "Usage") 47 | assert.isEmpty(stdout) 48 | 49 | 'version' : 50 | topic : () -> 51 | commandline ["--version"], this.callback 52 | return undefined 53 | 54 | 'exists' : (error, stdout, stderr) -> 55 | assert.isNull(error) 56 | assert.isEmpty(stderr) 57 | assert.isString(stdout) 58 | assert.include(stdout, coffeelint.VERSION) 59 | 60 | 'with clean source' : 61 | 62 | topic : () -> 63 | args = [ 64 | '--noconfig' 65 | 'test/fixtures/clean.coffee' 66 | ] 67 | commandline args, this.callback 68 | return undefined 69 | 70 | 'passes' : (error, stdout, stderr) -> 71 | assert.isNull(error) 72 | assert.include(stdout, '0 errors and 0 warnings') 73 | assert.isEmpty(stderr) 74 | 75 | 'with failing source' : 76 | 77 | topic : () -> 78 | args = [ 79 | '--noconfig' 80 | 'test/fixtures/fourspaces.coffee' 81 | ] 82 | commandline args, this.callback 83 | return undefined 84 | 85 | 'works' : (error, stdout, stderr) -> 86 | assert.isNotNull(error) 87 | assert.include(stdout.toLowerCase(), 'line') 88 | 89 | 'with findconfig and local coffeelint.json' : 90 | 91 | topic : () -> 92 | args = [ 93 | 'test/fixtures/findconfigtest/sevenspaces.coffee' 94 | ] 95 | commandline args, this.callback 96 | return undefined 97 | 98 | 'works' : (error, stdout, stderr) -> 99 | assert.isNull(error) 100 | 101 | 'with findconfig and local package.json' : 102 | 103 | topic : () -> 104 | args = [ 105 | 'test/fixtures/findconfigtest/package/sixspaces.coffee' 106 | ] 107 | commandline args, this.callback 108 | return undefined 109 | 110 | 'works' : (error, stdout, stderr) -> 111 | assert.isNull(error) 112 | 113 | 'with custom configuration' : 114 | 115 | topic : () -> 116 | args = [ 117 | '-f' 118 | 'test/fixtures/fourspaces.json' 119 | 'test/fixtures/fourspaces.coffee' 120 | ] 121 | 122 | commandline args, this.callback 123 | return undefined 124 | 125 | 'works' : (error, stdout, stderr) -> 126 | assert.isNull(error) 127 | 128 | 'with --rule parameter for a custom plugin': 129 | topic : () -> 130 | args = [ 131 | '--rules' 132 | # It's up to NodeJS to resolve the actual path. The top of the 133 | # file modifies NODE_PATH so this can look like a 3rd party 134 | # module. 135 | "he_who_must_not_be_named" 136 | 'test/fixtures/custom_rules/voldemort.coffee' 137 | ] 138 | 139 | commandline args, this.callback 140 | return undefined 141 | 142 | 'works' : (error, stdout, stderr) -> 143 | assert.isNotNull(error) 144 | assert.include(stdout.toLowerCase(), 'forbidden variable name') 145 | 146 | 'with `module` specified for a specific rule': 147 | topic : () -> 148 | args = [ 149 | '-f' 150 | 'test/fixtures/custom_rules/rule_module.json' 151 | 'test/fixtures/custom_rules/voldemort.coffee' 152 | ] 153 | 154 | commandline args, this.callback 155 | return undefined 156 | 157 | 'works' : (error, stdout, stderr) -> 158 | assert.isNotNull(error) 159 | assert.include(stdout.toLowerCase(), 'forbidden variable name') 160 | 161 | 'with multiple sources' : 162 | 163 | topic : () -> 164 | args = [ 165 | '--noconfig' 166 | '-f' 167 | 'test/fixtures/fourspaces.json' 168 | 'test/fixtures/fourspaces.coffee' 169 | 'test/fixtures/clean.coffee' 170 | ] 171 | 172 | commandline args, this.callback 173 | return undefined 174 | 175 | 'works' : (error, stdout, stderr) -> 176 | assert.isNotNull(error) 177 | 178 | 'with configuration file' : 179 | 180 | topic : () -> 181 | configPath = 'generated_coffeelint.json' 182 | configFile = fs.openSync configPath, 'w' 183 | commandline ['--makeconfig'], (error, stdout, stderr) => 184 | fs.writeSync configFile, stdout 185 | assert.isNull(error) 186 | args = [ 187 | '-f' 188 | configPath 189 | 'test/fixtures/clean.coffee' 190 | ] 191 | commandline args, (args...) => 192 | this.callback stdout, args... 193 | 194 | return undefined 195 | 196 | 'works' : (config, error, stdout, stderr) -> 197 | assert.isNotNull(config) 198 | # This will throw an exception if it doesn't parse. 199 | JSON.parse config 200 | assert.isNotNull(stdout) 201 | assert.isNull(error) 202 | 203 | 'does not fail on warnings' : 204 | 205 | topic : () -> 206 | args = [ 207 | '-f' 208 | 'test/fixtures/twospaces.warning.json' 209 | 'test/fixtures/fourspaces.coffee' 210 | ] 211 | 212 | commandline args, this.callback 213 | return undefined 214 | 215 | 'works' : (error, stdout, stderr) -> 216 | assert.isNull(error) 217 | 218 | 'with broken source' : 219 | 220 | topic : () -> 221 | args = [ 222 | '--noconfig' 223 | 'test/fixtures/syntax_error.coffee' 224 | ] 225 | commandline args, this.callback 226 | return undefined 227 | 228 | 'fails' : (error, stdout, stderr) -> 229 | assert.isNotNull(error) 230 | 231 | 'recurses subdirectories' : 232 | 233 | topic : () -> 234 | args = [ 235 | '--noconfig', 236 | '-r', 237 | 'test/fixtures/clean.coffee', 238 | 'test/fixtures/subdir' 239 | ] 240 | commandline args, this.callback 241 | return undefined 242 | 243 | 'and reports errors' : (error, stdout, stderr) -> 244 | assert.isNotNull(error, "returned err") 245 | assert.include(stdout.toLowerCase(), 'line') 246 | 247 | 'allows JSLint XML reporting' : 248 | 249 | # FIXME: Not sure how to unit test escaping w/o major refactoring 250 | topic : () -> 251 | args = [ 252 | '-f' 253 | 'coffeelint.json' 254 | 'test/fixtures/cyclo_fail.coffee' 255 | '--jslint' 256 | ] 257 | commandline args, this.callback 258 | return undefined 259 | 260 | 'Handles cyclomatic complexity check' : (error, stdout, stderr) -> 261 | assert.include(stdout.toLowerCase(), 'cyclomatic complexity') 262 | 263 | 'using stdin': 264 | 265 | 'with working string': 266 | topic: () -> 267 | exec("echo y = 1 | #{coffeelintPath} --noconfig --stdin", 268 | this.callback) 269 | return undefined 270 | 271 | 'passes': (error, stdout, stderr) -> 272 | assert.isNull(error) 273 | assert.isEmpty(stderr) 274 | assert.isString(stdout) 275 | assert.include(stdout, '0 errors and 0 warnings') 276 | 277 | 'with failing string due to whitespace': 278 | topic: () -> 279 | exec("echo 'x = 1 '| #{coffeelintPath} --noconfig --stdin", 280 | this.callback) 281 | return undefined 282 | 283 | 'fails': (error, stdout, stderr) -> 284 | assert.isNotNull(error) 285 | assert.include(stdout.toLowerCase(), 'trailing whitespace') 286 | 287 | 'literate coffeescript': 288 | 289 | 'with working string': 290 | topic: () -> 291 | exec("echo 'This is Markdown\n\n y = 1' | " + 292 | "#{coffeelintPath} --noconfig --stdin --literate", 293 | this.callback) 294 | return undefined 295 | 296 | 'passes': (error, stdout, stderr) -> 297 | assert.isNull(error) 298 | assert.isEmpty(stderr) 299 | assert.isString(stdout) 300 | assert.include(stdout, '0 errors and 0 warnings') 301 | 302 | 'with failing string due to whitespace': 303 | topic: () -> 304 | exec("echo 'This is Markdown\n\n x = 1 \n y=2'| " + 305 | "#{coffeelintPath} --noconfig --stdin --literate", 306 | this.callback) 307 | return undefined 308 | 309 | 'fails': (error, stdout, stderr) -> 310 | assert.isNotNull(error) 311 | assert.include(stdout.toLowerCase(), 'trailing whitespace') 312 | 313 | 'using environment config file': 314 | 315 | 'with non existing enviroment set config file': 316 | topic: () -> 317 | args = [ 318 | 'test/fixtures/clean.coffee' 319 | ] 320 | process.env.COFFEELINT_CONFIG = "not_existing_293ujff" 321 | commandline args, this.callback 322 | return undefined 323 | 324 | 'passes': (error, stdout, stderr) -> 325 | assert.isNull(error) 326 | 327 | 'with existing enviroment set config file': 328 | topic: () -> 329 | args = [ 330 | 'test/fixtures/fourspaces.coffee' 331 | ] 332 | conf = "test/fixtures/fourspaces.json" 333 | process.env.COFFEELINT_CONFIG = conf 334 | commandline args, this.callback 335 | return undefined 336 | 337 | 'passes': (error, stdout, stderr) -> 338 | assert.isNull(error) 339 | 340 | 'with existing enviroment set config file + --noconfig': 341 | topic: () -> 342 | args = [ 343 | '--noconfig' 344 | 'test/fixtures/fourspaces.coffee' 345 | ] 346 | conf = "test/fixtures/fourspaces.json" 347 | process.env.COFFEELINT_CONFIG = conf 348 | commandline args, this.callback 349 | return undefined 350 | 351 | 'fails': (error, stdout, stderr) -> 352 | assert.isNotNull(error) 353 | 354 | 'reports using basic reporter': 355 | 'with option q set': 356 | 'and no errors occured': 357 | topic: () -> 358 | args = [ '-q', '--noconfig', 'test/fixtures/clean.coffee' ] 359 | commandline args, this.callback 360 | return undefined 361 | 362 | 'no output': (err, stdout, stderr) -> 363 | assert.isEmpty(stdout) 364 | 365 | 'and errors occured': 366 | topic: () -> 367 | args = [ '-q', 'test/fixtures/syntax_error.coffee' ] 368 | commandline args, this.callback 369 | return undefined 370 | 371 | 'output': (error, stdout, stderr) -> 372 | assert.isNotEmpty(stdout) 373 | 374 | 'with option q not set': 375 | 'and no errors occured': 376 | topic: () -> 377 | args = [ 'test/fixtures/clean.coffee' ] 378 | commandline args, this.callback 379 | return undefined 380 | 381 | 'output': (err, stdout, stderr) -> 382 | assert.isNotEmpty(stdout) 383 | 384 | 'and errors occured': 385 | topic: () -> 386 | args = [ 'test/fixtures/syntax_error.coffee' ] 387 | commandline args, this.callback 388 | return undefined 389 | 390 | 'output': (error, stdout, stderr) -> 391 | assert.isNotEmpty(stdout) 392 | 393 | }).export(module) 394 | --------------------------------------------------------------------------------