├── .stlintrc ├── test2.styl ├── .gitignore ├── .travis.yml ├── src ├── core │ ├── ast │ │ ├── each.ts │ │ ├── obj.ts │ │ ├── rgb.ts │ │ ├── params.ts │ │ ├── return.ts │ │ ├── atrule.ts │ │ ├── block.ts │ │ ├── bool.ts │ │ ├── group.ts │ │ ├── keyframes.ts │ │ ├── querylist.ts │ │ ├── supports.ts │ │ ├── unit.ts │ │ ├── import.ts │ │ ├── comment.ts │ │ ├── condition.ts │ │ ├── query.ts │ │ ├── unaryop.ts │ │ ├── media.ts │ │ ├── ident.ts │ │ ├── literal.ts │ │ ├── func.ts │ │ ├── call.ts │ │ ├── property.ts │ │ ├── ternary.ts │ │ ├── feature.ts │ │ ├── selector.ts │ │ ├── tree.ts │ │ ├── member.ts │ │ ├── binop.ts │ │ ├── value.ts │ │ ├── index.ts │ │ └── node.ts │ ├── types │ │ ├── IStats.ts │ │ ├── reader.ts │ │ ├── response.ts │ │ ├── context.ts │ │ ├── line.ts │ │ ├── autocomplete.ts │ │ ├── state.ts │ │ ├── message.ts │ │ ├── content.ts │ │ ├── ast │ │ │ ├── snode.ts │ │ │ └── node.ts │ │ ├── reporter.ts │ │ ├── config.ts │ │ └── rule.ts │ ├── helpers │ │ ├── lcfirst.ts │ │ ├── splitAndStrip.ts │ │ ├── index.ts │ │ ├── isPlainObject.ts │ │ ├── mergeArray.ts │ │ ├── splitLines.ts │ │ ├── checkPrefix.ts │ │ ├── shortcutColor.ts │ │ ├── calcPosition.ts │ │ ├── unwrapObject.ts │ │ └── objToHash.ts │ ├── reporters │ │ ├── silentReporter.ts │ │ ├── jsonReporter.ts │ │ └── rawReporter.ts │ ├── runner.ts │ ├── visitor.ts │ ├── preprocessor.ts │ ├── documentator │ │ ├── readmePatcher.ts │ │ └── documentator.ts │ ├── autocomplete.ts │ ├── parser.ts │ ├── line.ts │ ├── content.ts │ ├── reporter.ts │ ├── reader.ts │ ├── baseConfig.ts │ ├── rule.ts │ └── checker.ts ├── doc.ts ├── autocomplete │ └── index.ts ├── rules │ ├── emptyLines.ts │ ├── index.ts │ ├── mixedSpaces.ts │ ├── commaInObject.ts │ ├── leadingZero.ts │ ├── prefixVarsWithDollar.ts │ ├── semicolons.ts │ ├── quotePref.ts │ ├── colons.ts │ ├── useMixinInsteadUnit.ts │ ├── brackets.ts │ ├── depthControl.ts │ ├── color.ts │ └── sortOrder.ts ├── typings.d.ts ├── preprocessors │ └── safeComments.ts ├── commander.ts ├── config.ts ├── linter.ts └── defaultRules.json ├── tests ├── staff │ ├── disable-sort-order-rule.json │ ├── subfolder │ │ ├── extends.json │ │ └── preprocessors │ │ │ └── preprocessor.js │ ├── preprocessor.js │ ├── extra │ │ └── testRule.js │ ├── extends.js │ ├── test.styl │ ├── config.json │ ├── extends.json │ └── bootstrap.ts ├── rules │ ├── emptyLinesTest.ts │ ├── bracketsTest.ts │ ├── semicolonTest.ts │ ├── leadingZeroTest.ts │ ├── mixedSpacesTest.ts │ ├── quotePrefTest.ts │ ├── commaInObjectTest.ts │ ├── prefixVarsWithDollarTest.ts │ ├── useMixinInsteadUnitTest.ts │ ├── colonsTest.ts │ ├── colorTest.ts │ ├── sortOrderTest.ts │ └── depthControlTest.ts ├── grepTest.ts ├── helpers │ ├── shortcutColorTest.ts │ └── calcPositionTest.ts ├── beforeCheckNodeTest.ts ├── extraRulesTest.ts ├── smokeTest.ts ├── extendsConfigTest.ts ├── ignoreDirectivesTest.ts └── configTest.ts ├── .prettierrc.json ├── test.styl ├── .editorconfig ├── tsconfig.json ├── webpack.config.js ├── LICENSE ├── index.ts ├── bin └── stlint ├── package.json ├── tslint.json └── readme.md /.stlintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": {} 3 | } 4 | -------------------------------------------------------------------------------- /test2.styl: -------------------------------------------------------------------------------- 1 | .tab 2 | color #DDD; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | .DS_Store 4 | 5 | 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - 8 5 | - stable 6 | -------------------------------------------------------------------------------- /src/core/ast/each.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Each extends Node {} 4 | -------------------------------------------------------------------------------- /src/core/ast/obj.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Obj extends Node {} 4 | -------------------------------------------------------------------------------- /src/core/ast/rgb.ts: -------------------------------------------------------------------------------- 1 | import { Unit } from './unit'; 2 | 3 | export class RGB extends Unit {} 4 | -------------------------------------------------------------------------------- /src/core/ast/params.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Params extends Node {} 4 | -------------------------------------------------------------------------------- /src/core/ast/return.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Return extends Node {} 4 | -------------------------------------------------------------------------------- /src/core/ast/atrule.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Atrule extends Node { 4 | } 5 | -------------------------------------------------------------------------------- /src/core/ast/block.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Block extends Node { 4 | } 5 | -------------------------------------------------------------------------------- /src/core/ast/bool.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Bool extends Node { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /src/core/ast/group.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Group extends Node { 4 | } 5 | -------------------------------------------------------------------------------- /src/core/ast/keyframes.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Keyframes extends Node {} 4 | -------------------------------------------------------------------------------- /src/core/ast/querylist.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Querylist extends Node {} 4 | -------------------------------------------------------------------------------- /tests/staff/disable-sort-order-rule.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "sortOrder": false 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/staff/subfolder/extends.json: -------------------------------------------------------------------------------- 1 | { 2 | "preprocessors": ["./preprocessors/preprocessor.js"] 3 | } 4 | -------------------------------------------------------------------------------- /src/core/ast/supports.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Supports extends Node { 4 | } 5 | -------------------------------------------------------------------------------- /src/core/types/IStats.ts: -------------------------------------------------------------------------------- 1 | export interface IStats { 2 | isFile(): boolean; 3 | isDirectory(): boolean; 4 | } 5 | -------------------------------------------------------------------------------- /src/core/types/reader.ts: -------------------------------------------------------------------------------- 1 | export interface IFile { 2 | path: string; 3 | } 4 | 5 | export type Files = File[]; 6 | -------------------------------------------------------------------------------- /src/core/ast/unit.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Unit extends Node { 4 | value: string = ''; 5 | } 6 | -------------------------------------------------------------------------------- /tests/staff/subfolder/preprocessors/preprocessor.js: -------------------------------------------------------------------------------- 1 | module.exports = function (str) { 2 | return str + 'i work'; 3 | }; 4 | -------------------------------------------------------------------------------- /src/core/ast/import.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Import extends Node { 4 | value: string = ''; 5 | } 6 | -------------------------------------------------------------------------------- /src/core/ast/comment.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Comment extends Node { 4 | value: string = ''; 5 | } 6 | -------------------------------------------------------------------------------- /src/core/ast/condition.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Condition extends Node { 4 | cond: Node | null = null; 5 | } 6 | -------------------------------------------------------------------------------- /tests/staff/preprocessor.js: -------------------------------------------------------------------------------- 1 | module.exports = function (str) { 2 | return str + '\n' + 3 | '.color\n' + 4 | '\tcolor: #fFfFfF;\n' 5 | }; 6 | -------------------------------------------------------------------------------- /src/core/ast/query.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Query extends Node { 4 | predicate: string = ''; 5 | type: Node | null = null; 6 | } 7 | -------------------------------------------------------------------------------- /src/core/types/response.ts: -------------------------------------------------------------------------------- 1 | import { IMessagePack } from './message'; 2 | 3 | export interface IResponse { 4 | passed: boolean; 5 | errors?: IMessagePack[]; 6 | } 7 | -------------------------------------------------------------------------------- /src/core/ast/unaryop.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class UnaryOp extends Node { 4 | left: Node | null = null; 5 | right: Node | null = null; 6 | } 7 | -------------------------------------------------------------------------------- /src/core/ast/media.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | import { INode } from '../types/ast/node'; 3 | 4 | export class Media extends Node { 5 | query: INode | null = null; 6 | } 7 | -------------------------------------------------------------------------------- /src/core/ast/ident.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | import { Value } from './value'; 3 | 4 | export class Ident extends Node { 5 | key: string = ''; 6 | value: Value | null = null; 7 | } 8 | -------------------------------------------------------------------------------- /src/core/ast/literal.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Literal extends Node { 4 | val: string = ''; 5 | 6 | toString(): string { 7 | return this.val; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/core/helpers/lcfirst.ts: -------------------------------------------------------------------------------- 1 | export const lcfirst = (str: string) => 2 | str[0].toLowerCase() + str.substr(1); 3 | 4 | export const ucfirst = (str: string) => 5 | str[0].toUpperCase() + str.substr(1); 6 | -------------------------------------------------------------------------------- /src/core/types/context.ts: -------------------------------------------------------------------------------- 1 | export interface IContext { 2 | hashDeep: number 3 | inHash: boolean 4 | inComment: boolean 5 | openBracket: boolean 6 | vars: Dictionary 7 | valueToVar: Dictionary 8 | } 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 4, 4 | "useTabs": true, 5 | "semi": true, 6 | "singleQuote": true, 7 | "endOfLine": "lf", 8 | "arrow-parens": "always" 9 | } 10 | -------------------------------------------------------------------------------- /src/doc.ts: -------------------------------------------------------------------------------- 1 | export const doc = (options = {}) => { 2 | const {Documentator} = require('./core/documentator/documentator'); 3 | const documentator = new Documentator(options); 4 | documentator.generate(); 5 | }; 6 | -------------------------------------------------------------------------------- /src/core/ast/func.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | import { INode } from '../types/ast/node'; 3 | 4 | export class Func extends Node { 5 | key: string = ''; 6 | value: string = ''; 7 | params: INode[] = []; 8 | } 9 | -------------------------------------------------------------------------------- /test.styl: -------------------------------------------------------------------------------- 1 | .test 2 | max-height $headerHeight 3 | // @stlint-ignore 4 | border 10px 5 | color red 6 | margin-bottom 10px 7 | 8 | getBgFor($name) 9 | return getField("exterior." + $name + ".backgroundColor", $p) 10 | -------------------------------------------------------------------------------- /src/core/helpers/splitAndStrip.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Split str by reg exp 3 | * @param re 4 | * @param line 5 | */ 6 | export const splitAndStrip = (re: RegExp, line: string): string[] => 7 | line.split(re).filter((str) => str.length > 0); 8 | -------------------------------------------------------------------------------- /src/core/reporters/silentReporter.ts: -------------------------------------------------------------------------------- 1 | import { Reporter } from '../reporter'; 2 | 3 | export class SilentReporter extends Reporter { 4 | log(): void { 5 | // ignore 6 | } 7 | reset(): void { 8 | // ignore 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/core/ast/call.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class Call extends Node { 4 | key: string = ''; 5 | toString(): string { 6 | return `${this.key}(${this.nodes.map((arg) => arg.toString(), this).join(', ')})`; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/core/ast/property.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | import { Value } from './value'; 3 | import { Ident } from './ident'; 4 | 5 | export class Property extends Node { 6 | key: Ident | string = ''; 7 | value: Value | null = null; 8 | } 9 | -------------------------------------------------------------------------------- /src/core/types/line.ts: -------------------------------------------------------------------------------- 1 | export interface ILine { 2 | line: string; 3 | lineno: number; 4 | lines: ILine[]; 5 | next(): null | ILine; 6 | prev(): null | ILine; 7 | isEmpty(): boolean; 8 | isLast(): boolean; 9 | isIgnored: boolean; 10 | } 11 | -------------------------------------------------------------------------------- /src/core/ast/ternary.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | import { Ident } from './ident'; 3 | import { Value } from './value'; 4 | 5 | export class Ternary extends Node { 6 | cond!: Ident; 7 | trueExpr!: Value; 8 | falseExpr!: Value; 9 | } 10 | -------------------------------------------------------------------------------- /src/core/types/autocomplete.ts: -------------------------------------------------------------------------------- 1 | export interface ISuggestionItem { 2 | title: string; 3 | } 4 | 5 | export type Suggestions = ISuggestionItem[]; 6 | 7 | export type AutocompleteFunction = (str: string, offset: number, lineOffset: number) => Suggestions; 8 | -------------------------------------------------------------------------------- /tests/staff/extra/testRule.js: -------------------------------------------------------------------------------- 1 | function TestRule() { 2 | this.checkLine = (line) => { 3 | if (line.lineno === 1) { 4 | this.msg('Test error on test line', 1, 1, line.length); 5 | } 6 | }; 7 | } 8 | 9 | module.exports.TestRule = TestRule; 10 | -------------------------------------------------------------------------------- /src/core/ast/feature.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | import { INode } from '../types/ast/node'; 3 | 4 | export class Feature extends Node { 5 | segments: INode[] = []; 6 | toString(): string { 7 | return this.segments.join(''); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/core/ast/selector.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | import { INode } from '../types/ast/node'; 3 | 4 | export class Selector extends Node { 5 | segments: INode[] = []; 6 | toString(): string { 7 | return this.segments.join(''); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/staff/extends.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "rules": { 3 | "color": { 4 | "conf": "test-extends", 5 | "enabled": false, 6 | "allowOnlyInVar": 5, 7 | "allowShortcut": 7 8 | } 9 | }, 10 | "grep": "sortOrder", 11 | "reporter": "silent" 12 | }; 13 | -------------------------------------------------------------------------------- /tests/staff/test.styl: -------------------------------------------------------------------------------- 1 | $p = { 2 | color: #CCC 3 | padding: 10px 4 | background: url('./test.png') 5 | } 6 | 7 | .red 8 | margin-left 10px 9 | color #ccc 10 | 11 | @supports display flex 12 | div 13 | display flex 14 | 15 | dark 16 | color red 17 | 18 | test extends dark 19 | -------------------------------------------------------------------------------- /src/core/ast/tree.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | import { ISNode } from '../types/ast/snode'; 3 | import { INode } from '../types/ast/node'; 4 | 5 | export class Tree extends Node { 6 | readonly parent: INode | null = null; 7 | constructor(block: ISNode) { 8 | super(block, null); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/staff/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tests/staff/extends.json", 3 | "rules": { 4 | "color": { 5 | "conf": "test-config", 6 | "enabled": false, 7 | "allowOnlyInVar": 3 8 | }, 9 | "someTestRule": false 10 | }, 11 | "grep": "color", 12 | "reporter": "raw" 13 | } 14 | -------------------------------------------------------------------------------- /src/core/types/state.ts: -------------------------------------------------------------------------------- 1 | export type modes = 'always' | 'never' | 'lowercase' | 'uppercase' | 'double' | 'single' | 'grouped' | 'alphabetical'; 2 | 3 | export interface IState { 4 | conf: modes; 5 | enabled?: boolean; 6 | [key: string]: any; 7 | } 8 | 9 | export type State = IState | [ modes, boolean] | [ modes ] | boolean; 10 | -------------------------------------------------------------------------------- /src/autocomplete/index.ts: -------------------------------------------------------------------------------- 1 | import { Suggestions } from '../core/types/autocomplete'; 2 | 3 | // tslint:disable-next-line:completed-docs 4 | export function defaultAutocomplete(): Suggestions { 5 | return [ 6 | { 7 | title: 'StLint is fantastic!' 8 | }, 9 | { 10 | title: 'Stylus is perfect!' 11 | } 12 | ]; 13 | } 14 | -------------------------------------------------------------------------------- /src/core/types/message.ts: -------------------------------------------------------------------------------- 1 | export interface IFix { 2 | replace: string; 3 | } 4 | 5 | export interface IMessage { 6 | rule: string; 7 | descr: string; 8 | path: string; 9 | line: number; 10 | endline: number; 11 | start: number; 12 | end: number; 13 | fix: IFix | null; 14 | } 15 | 16 | export interface IMessagePack { 17 | message: IMessage[] 18 | } 19 | -------------------------------------------------------------------------------- /tests/staff/extends.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "color": { 4 | "conf": "test-extends", 5 | "enabled": false, 6 | "allowOnlyInVar": 5, 7 | "allowShortcut": 7 8 | }, 9 | "someTestRule": { 10 | "conf": "always", 11 | "enabled": false 12 | } 13 | }, 14 | "grep": "sortOrder", 15 | "reporter": "silent" 16 | } 17 | -------------------------------------------------------------------------------- /src/core/ast/member.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | import { INode } from '../types/ast/node'; 3 | 4 | export class Member extends Node { 5 | left: INode | null = null; 6 | right: INode | null = null; 7 | 8 | toString(): string | string { 9 | return (this.left && this.right) ? `${this.left.toString()}.${this.right.toString()}` : super.toString(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/core/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './calcPosition'; 2 | export * from './checkPrefix'; 3 | export * from './isPlainObject'; 4 | export * from './lcfirst'; 5 | 6 | export * from './mergeArray'; 7 | export * from './objToHash'; 8 | export * from './shortcutColor'; 9 | export * from './unwrapObject'; 10 | export * from './splitLines'; 11 | export * from './splitAndStrip'; 12 | -------------------------------------------------------------------------------- /src/core/helpers/isPlainObject.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if element is simple plaint object 3 | * 4 | * @param obj 5 | */ 6 | export const isPlainObject = (obj: unknown): obj is object => { 7 | if (typeof obj !== 'object') { 8 | return false; 9 | } 10 | 11 | return !( 12 | obj && 13 | obj.constructor && 14 | !{}.hasOwnProperty.call(obj.constructor.prototype, 'isPrototypeOf') 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/core/helpers/mergeArray.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Merge two array 3 | * @param a 4 | * @param b 5 | */ 6 | export function mergeArray(a: Array, b: Array): Array { 7 | let result = a.map((value, index) => b[index] !== undefined ? b[index] : a[index]); 8 | 9 | if (b.length > a.length) { 10 | result = result.concat(b.slice(a.length)); 11 | } 12 | 13 | return result; 14 | } 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{LICENSE,.gitattributes,.gitignore,.npmignore,.eslintignore}] 11 | indent_style = space 12 | 13 | [*.{json,yml,md,yaspellerrc,bowerrc,babelrc,snakeskinrc,eslintrc,tsconfig,pzlrrc}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /src/core/helpers/splitLines.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Split line on lines 3 | * @param content 4 | */ 5 | import { Line } from '../line'; 6 | 7 | const SPLIT_REG = /\n/; 8 | 9 | export const splitLines = (content: string): Line[] => { 10 | const 11 | lines: Line[] = []; 12 | 13 | content.split(SPLIT_REG) 14 | .forEach((ln, index) => { 15 | lines[index + 1] = new Line(ln, index + 1, lines); 16 | }); 17 | 18 | return lines; 19 | }; 20 | -------------------------------------------------------------------------------- /src/rules/emptyLines.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../core/rule'; 2 | import { Line } from '../core/line'; 3 | 4 | /** 5 | * Check if document has several empty lines 6 | */ 7 | export class EmptyLines extends Rule { 8 | checkLine(line: Line): void { 9 | if (line.isEmpty()) { 10 | const prev = line.prev(); 11 | 12 | if (prev && prev.isEmpty()) { 13 | this.msg('Deny several empty lines', line.lineno); 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/core/ast/binop.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | 3 | export class BinOp extends Node { 4 | left: Node | null = null; 5 | right: Node | null = null; 6 | 7 | toString(): string | string { 8 | let right = this.right ? this.right.toString() : ''; 9 | 10 | if (right) { 11 | right = /^[0-9]$/.test(right) ? `[${right}]` : `.${right}`; 12 | } 13 | 14 | return (this.left && right) ? this.left.toString() + right : super.toString(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/rules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './color'; 2 | export * from './colons'; 3 | export * from './leadingZero'; 4 | export * from './useMixinInsteadUnit'; 5 | export * from './semicolons'; 6 | export * from './quotePref'; 7 | export * from './sortOrder'; 8 | export * from './prefixVarsWithDollar'; 9 | export * from './mixedSpaces'; 10 | export * from './commaInObject'; 11 | export * from './depthControl'; 12 | export * from './emptyLines'; 13 | export * from './brackets'; 14 | -------------------------------------------------------------------------------- /src/core/ast/value.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node'; 2 | import { INode } from '../types/ast/node'; 3 | 4 | export class Value extends Node implements INode { 5 | get key(): string { 6 | if (this.nodes.length && (this.nodes[0]).key) { 7 | return String((this.nodes[0]).key); 8 | } 9 | 10 | return ''; 11 | } 12 | 13 | set key(value: string) { 14 | // do nothing 15 | } 16 | 17 | toString(): string { 18 | return this.nodes.join(' '); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | 5 | "target": "es2016", 6 | "moduleResolution": "classic", 7 | "module": "none", 8 | "types": [ 9 | "@types/node", 10 | "@types/chai", 11 | "@types/mocha", 12 | "@types/node", 13 | "@types/async", 14 | "@types/glob", 15 | "./src/typings" 16 | ] 17 | 18 | }, 19 | "exclude": [ 20 | "./node_modules" 21 | ], 22 | "sourceMap": true 23 | } 24 | -------------------------------------------------------------------------------- /src/core/reporters/jsonReporter.ts: -------------------------------------------------------------------------------- 1 | import { Reporter } from '../reporter'; 2 | 3 | export class JsonReporter extends Reporter { 4 | /** 5 | * @override 6 | */ 7 | log(): void { 8 | if (this.response.errors) { 9 | this.response.errors.forEach((error) => error.message.forEach((message) => { 10 | message.descr = `${message.rule}: ${message.descr}`; 11 | })); 12 | } 13 | 14 | console.clear(); 15 | console.log(JSON.stringify(this.response, null, 2)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/core/helpers/checkPrefix.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Used in conjunction with the valid check (for valid css) 3 | * 4 | * @param [prop] the property to prepend prefixes to 5 | * @param [css] the css key we're checking against (from valid.json) 6 | * @param [valid] the valid.json object 7 | * @returns {boolean} true if at least one match found, false if not 8 | */ 9 | export const checkPrefix = (prop: string, css: string, valid: ValidCSS): boolean => 10 | valid.prefixes.some((prefix) => prop === prefix + css); 11 | -------------------------------------------------------------------------------- /src/core/helpers/shortcutColor.ts: -------------------------------------------------------------------------------- 1 | const 2 | regOneElementColor = /([a-f0-9])\1{5}/i, 3 | regThreeElementColor = /([a-f0-9])\1([a-f0-9])\2([a-f0-9])\3/i; 4 | 5 | /** 6 | * Return shortcut for color if it possible 7 | * @param color 8 | */ 9 | export function shortcutColor(color: string): string { 10 | if (regOneElementColor.test(color)) { 11 | return color.replace(regOneElementColor, '$1$1$1'); 12 | } 13 | 14 | if (regThreeElementColor.test(color)) { 15 | return color.replace(regThreeElementColor, '$1$2$3'); 16 | } 17 | 18 | return color; 19 | } 20 | -------------------------------------------------------------------------------- /src/core/types/content.ts: -------------------------------------------------------------------------------- 1 | import { ILine } from './line'; 2 | import { IMessage } from './message'; 3 | 4 | export type eachLineCallback = (line: ILine, lineno: number) => void | false; 5 | 6 | export interface IContent { 7 | toString(): string; 8 | 9 | firstLine(): ILine; 10 | 11 | /** 12 | * Apply callback on every lines. if callback returns false - break cycle 13 | * @param callback 14 | */ 15 | forEach(callback: eachLineCallback): void; 16 | getLine(lineno: number): ILine | null; 17 | 18 | applyFixes(messages: IMessage[]): IContent; 19 | } 20 | -------------------------------------------------------------------------------- /src/rules/mixedSpaces.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../core/rule'; 2 | import { ILine } from '../core/types/line'; 3 | 4 | /** 5 | * check for mixed spaces and tabs 6 | */ 7 | export class MixedSpaces extends Rule { 8 | checkLine(line: ILine): boolean { 9 | const 10 | mixed = /( \t|\t )[\t\s]*/.exec(line.line), 11 | isMixed = mixed !== null; 12 | 13 | if (isMixed && mixed && !this.context.inComment) { 14 | this.msg('mixed spaces and tabs', line.lineno, mixed.index, mixed.index + mixed[0].length); 15 | } 16 | 17 | return isMixed; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'stylus/lib/parser'; 2 | declare module 'strip-json-comments'; 3 | declare module 'columnify'; 4 | declare module 'chalk'; 5 | declare module 'node-watch'; 6 | declare module 'native-require'; 7 | declare module 'escaper'; 8 | 9 | declare module '*.json' { 10 | const value: any; 11 | export default value; 12 | } 13 | 14 | // @ts-ignore 15 | interface Dictionary {[key: string]: T} 16 | 17 | interface ValidCSS { 18 | css: string[]; 19 | html: string[]; 20 | prefixes: string[]; 21 | pseudo: string[]; 22 | scope: string[]; 23 | } 24 | -------------------------------------------------------------------------------- /src/core/runner.ts: -------------------------------------------------------------------------------- 1 | import { Visitor } from './visitor'; 2 | import { Node } from './ast/index'; 3 | import { INode } from './types/ast/node'; 4 | 5 | export class Runner extends Visitor { 6 | constructor(ast: INode, readonly fn: (node: INode) => void) { 7 | super(ast); 8 | } 9 | 10 | visitNode(node: INode, parent: INode): INode { 11 | this.fn(node); 12 | 13 | node.nodes.forEach((elm) => this.visit(elm, parent)); 14 | 15 | if (node.value && node.value instanceof Node) { 16 | this.visit(node.value, parent); 17 | } 18 | 19 | return node; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/rules/emptyLinesTest.ts: -------------------------------------------------------------------------------- 1 | import { EmptyLines } from '../../src/rules/index'; 2 | import { parseAndRun } from '../staff/bootstrap'; 3 | import { expect } from 'chai'; 4 | 5 | describe('Empty lines Test', () => { 6 | it('Should show error on several empty lines', () => { 7 | const rule = new EmptyLines({ 8 | conf: 'always' 9 | }); 10 | 11 | parseAndRun( 12 | '.test\n' + 13 | '\tborder 1px solid #ccc\n' + 14 | '\n' + 15 | '\n' + 16 | '\tcolor red\n' + 17 | '' 18 | , 19 | rule 20 | ); 21 | 22 | expect(rule.errors.length).to.be.equal(1) 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/rules/bracketsTest.ts: -------------------------------------------------------------------------------- 1 | import { parseAndRun } from '../staff/bootstrap'; 2 | import { expect } from 'chai'; 3 | import { Brackets } from '../../src/rules/index'; 4 | 5 | describe('Brackets Test', () => { 6 | it('Should show error on using brackets', () => { 7 | const rule = new Brackets({ 8 | conf: 'never' 9 | }); 10 | 11 | parseAndRun( 12 | '$p = {\n' + 13 | '\tcolor: red\n' + 14 | '}\n' + 15 | '.test {\n' + 16 | '\tborder 1px solid #ccc\n' + 17 | '\tcolor red\n' + 18 | '}' 19 | , 20 | rule 21 | ); 22 | 23 | expect(rule.errors.length).to.equal(2); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/core/types/ast/snode.ts: -------------------------------------------------------------------------------- 1 | export interface ISNode { 2 | lineno: number; 3 | column: number; 4 | 5 | block?: ISNode | null; 6 | nodeName: string; 7 | path?: string; 8 | name?: string; 9 | string?: string; 10 | expr?: ISNode; 11 | val?: ISNode | string; 12 | left?: ISNode; 13 | right?: ISNode; 14 | type?: ISNode; 15 | cond?: ISNode; 16 | trueExpr?: ISNode; 17 | falseExpr?: ISNode; 18 | predicate?: string; 19 | 20 | nodes: ISNode[]; 21 | params?: ISNode; 22 | args?: ISNode; 23 | vals?: Dictionary; 24 | keys?: Dictionary; 25 | 26 | [key: string]: unknown; 27 | toString(): string; 28 | } 29 | -------------------------------------------------------------------------------- /src/core/helpers/calcPosition.ts: -------------------------------------------------------------------------------- 1 | import { splitLines } from './splitLines'; 2 | 3 | /** 4 | * Calc position in text by line and column 5 | * 6 | * @param line 7 | * @param column 8 | * @param content 9 | */ 10 | 11 | export const calcPosition = (line: number, column: number, content: string): number => { 12 | if (line === 1) { 13 | return column - 1; 14 | } 15 | 16 | let 17 | position = 0; 18 | 19 | splitLines(content) 20 | .forEach((ln, lineno) => { 21 | if (lineno >= line) { 22 | return false; 23 | } 24 | 25 | position += ln.line.length + 1; 26 | }); 27 | 28 | return position + column - 1; 29 | }; 30 | -------------------------------------------------------------------------------- /src/core/types/reporter.ts: -------------------------------------------------------------------------------- 1 | import { IResponse } from './response'; 2 | import { IMessagePack } from './message'; 3 | 4 | export type ReporterType = 'json' | 'silent' | 'raw'; 5 | 6 | export interface IReporter { 7 | errors: IMessagePack[]; 8 | 9 | response: IResponse; 10 | reset(): void; 11 | 12 | add( 13 | rule: string, 14 | message: string, 15 | line: number, 16 | start: number, 17 | end?: number, 18 | fix?: string | null, 19 | endLine?: number 20 | ): void; 21 | 22 | display(exit: boolean): void; 23 | log(): void; 24 | setPath(path: string): void; 25 | fillResponse(): void; 26 | 27 | filterErrors(grep: string): void; 28 | } 29 | -------------------------------------------------------------------------------- /tests/grepTest.ts: -------------------------------------------------------------------------------- 1 | import { Linter } from '../src/linter'; 2 | import { expect } from 'chai'; 3 | 4 | const 5 | wrongContent = '.tab\n\tcolor: #ccc;'; 6 | 7 | describe('Test grep option', () => { 8 | describe('Set grep option', () => { 9 | it('should show only errors contains grep option', () => { 10 | const linter = new Linter({ 11 | grep: 'color', 12 | reporter: 'silent' 13 | }); 14 | 15 | linter.lint('./test.styl', wrongContent); 16 | linter.display(false); 17 | 18 | const response = linter.reporter.response; 19 | 20 | expect(response.passed).to.be.false; 21 | expect(response.errors && response.errors.length).to.be.equal(2); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/core/helpers/unwrapObject.ts: -------------------------------------------------------------------------------- 1 | import { isPlainObject } from './isPlainObject'; 2 | 3 | export const unwrapObject = (obj: Dictionary, prefix: string[] = []) => { 4 | let result: Dictionary = {}; 5 | 6 | Object.keys(obj).forEach((_key) => { 7 | const 8 | key = prefix.concat([_key]).join('.'), 9 | item = obj[_key]; 10 | 11 | if (Array.isArray(item)) { 12 | item.forEach((value: string, index: number) => { 13 | result[value] = `${key}[${index}]`; 14 | }); 15 | 16 | } else if (isPlainObject(item)) { 17 | result = {...result, ...unwrapObject(item, prefix.concat([_key]))}; 18 | 19 | } else { 20 | result[item] = key; 21 | } 22 | }); 23 | 24 | return result; 25 | }; 26 | -------------------------------------------------------------------------------- /tests/helpers/shortcutColorTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { shortcutColor } from '../../src/core/helpers/index'; 3 | 4 | describe('colorCanBeShortcut helper test', () => { 5 | it('Should check color can have shortcut form', () => { 6 | const colors: Array<[string, string]> = [ 7 | ['#ffffff', '#fff'], 8 | ['#FFFFFF', '#FFF'], 9 | ['#fffffd', '#fffffd'], 10 | ['#ffccff', '#fcf'], 11 | ['#113322', '#132'], 12 | ['#113321', '#113321'], 13 | ['#abccba', '#abccba'], 14 | ['#cccddd', '#cccddd'], 15 | ['#000000', '#000'], 16 | ['#ddd', '#ddd'] 17 | ]; 18 | 19 | colors.forEach((pars) => { 20 | expect(shortcutColor(pars[0])).to.be.equal(pars[1]); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/core/helpers/objToHash.ts: -------------------------------------------------------------------------------- 1 | import { Ident, Obj, Property } from '../ast/index'; 2 | import { INode } from '../types/ast/node'; 3 | 4 | export const objTohash = (node: INode): Dictionary => { 5 | const result: Dictionary = {}; 6 | 7 | node.nodes.forEach((prop) => { 8 | if (prop instanceof Property && prop.key instanceof Ident && prop.value) { 9 | const subkey = prop.key.key; 10 | 11 | if (prop.value.nodes && prop.value.nodes[0] && prop.value.nodes[0] instanceof Obj) { 12 | result[subkey] = objTohash(prop.value.nodes[0]); 13 | 14 | } else { 15 | if (prop.value.nodes.length > 1) { 16 | result[subkey] = prop.value.nodes.map((node) => node.toString()); 17 | } else { 18 | result[subkey] = prop.value.toString(); 19 | } 20 | } 21 | } 22 | }); 23 | 24 | return result; 25 | }; 26 | -------------------------------------------------------------------------------- /src/core/visitor.ts: -------------------------------------------------------------------------------- 1 | import { ISNode } from './types/ast/snode'; 2 | import { INode } from './types/ast/node'; 3 | 4 | export abstract class Visitor { 5 | root: In; 6 | 7 | protected constructor(root: In) { 8 | this.root = root; 9 | } 10 | 11 | abstract visitNode(node: In, parent: Out | null): Out; 12 | 13 | methodNotExists(method: string, node: In): void { 14 | // ignore 15 | } 16 | 17 | visit(node: In, parent: Out | null): Out { 18 | const method = 'visit' + (node).constructor.name; 19 | 20 | const fn: undefined | ((node: In, parent: Out | null) => Out) = (this)[method]; 21 | 22 | if (fn && typeof fn === 'function') { 23 | return fn.call(this, node, parent); 24 | } 25 | 26 | this.methodNotExists(method, node); 27 | 28 | return this.visitNode(node, parent); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const nodeExternals = require('webpack-node-externals'); 3 | 4 | module.exports = { 5 | target: 'node', 6 | entry: './index.ts', 7 | context: path.resolve(__dirname), 8 | externals: [nodeExternals()], 9 | devtool: false, 10 | resolve: { 11 | extensions: [ '.ts', '.js' ] 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.(ts)$/, 17 | use: { 18 | loader: 'awesome-typescript-loader', 19 | }, 20 | exclude: path.resolve(__dirname, "node_modules/stylus") 21 | } 22 | ] 23 | }, 24 | 25 | node: { 26 | __dirname: false, 27 | fs: 'mock' 28 | }, 29 | 30 | output: { 31 | filename: 'index.js', 32 | libraryTarget: "umd", 33 | path: path.resolve(__dirname, './') 34 | }, 35 | 36 | mode: 'development', 37 | optimization: { 38 | minimize: false 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/preprocessors/safeComments.ts: -------------------------------------------------------------------------------- 1 | import Escaper = require('escaper'); 2 | 3 | /** 4 | * Replace all comments to safe (without error) value 5 | * @param content 6 | */ 7 | export function safeComments(content: string): string { 8 | const 9 | replacedText: string[] = [], 10 | str = Escaper.replace(content, { 11 | strings: [ 12 | 'comments' 13 | ] 14 | }, replacedText); 15 | 16 | if (replacedText.length && str !== content && typeof str === 'string') { 17 | return Escaper.paste(str, replacedText.map((comment) => { 18 | if (comment.indexOf('/*') === 0) { 19 | return comment.replace(/(\/\*)(.*)(\*\/)/s, (res, ...match) => 20 | match[0] + 21 | match[1].split(/\n/).fill('empty').join('\n') + 22 | match[2] 23 | ); 24 | } 25 | 26 | return /@stlint/.test(comment) ? comment : '// empty'; 27 | })); 28 | } 29 | 30 | return content; 31 | } 32 | -------------------------------------------------------------------------------- /tests/rules/semicolonTest.ts: -------------------------------------------------------------------------------- 1 | import { Semicolons } from '../../src/rules/index'; 2 | import { expect } from 'chai'; 3 | import { splitAndRun } from '../staff/bootstrap'; 4 | 5 | describe('Semicolons test', () => { 6 | it('Should check the line has semicolons and they are needed', () => { 7 | const rule = new Semicolons({ 8 | conf: 'always' 9 | }); 10 | 11 | splitAndRun( 12 | '.test\n' + 13 | '\tmax-height red;\n' + 14 | '\tborder black', 15 | rule 16 | ); 17 | 18 | expect(rule.errors.length).to.be.equal(1); 19 | }); 20 | it('Should check the line has semicolons and they are not needed', () => { 21 | const rule = new Semicolons({ 22 | conf: 'never' 23 | }); 24 | 25 | splitAndRun( 26 | '.test\n' + 27 | '\tmax-height red\n' + 28 | '\tborder black', 29 | rule 30 | ); 31 | 32 | expect(rule.errors.length).to.be.equal(0); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/core/types/config.ts: -------------------------------------------------------------------------------- 1 | import { State } from './state'; 2 | import { ReporterType } from './reporter'; 3 | 4 | export interface IConfig { 5 | [key: string]: any; 6 | configName: string 7 | configFile: string 8 | 9 | debug: boolean 10 | reporter: ReporterType 11 | 12 | basepath: string 13 | path: string 14 | 15 | preprocessors: string[]; 16 | autocompletes: string[]; 17 | 18 | grep: string 19 | doc: string 20 | fix: boolean 21 | 22 | rules: Dictionary; 23 | defaultRules: Dictionary; 24 | 25 | excludes: string[] 26 | 27 | watch: boolean 28 | 29 | stylusParserOptions: Dictionary 30 | reportOptions: Dictionary 31 | 32 | extends: string | string[]; 33 | extraRules: string | string[]; 34 | 35 | customProperties: string[]; 36 | 37 | extendsOption(from: Dictionary, to: Dictionary): void; 38 | applyConfig(path: string, config: Dictionary): void; 39 | } 40 | -------------------------------------------------------------------------------- /src/core/types/rule.ts: -------------------------------------------------------------------------------- 1 | import { INode } from './ast/node'; 2 | import { ILine } from './line'; 3 | import { IState } from './state'; 4 | import { IContent } from './content'; 5 | import { IConfig } from './config'; 6 | 7 | export type ErrorArray = [string, string, number, number, number, null | string, number]; 8 | 9 | export interface IRule { 10 | state: T; 11 | config: IConfig; 12 | setConfig(config: IConfig): void; 13 | 14 | cache: Dictionary; 15 | 16 | nodesFilter: string[] | null; 17 | 18 | errors: ErrorArray[]; 19 | 20 | context: Dictionary; 21 | clearContext(): void; 22 | 23 | checkNode?(node: INode, content: IContent): void; 24 | checkLine?(line: ILine, index: number, content: IContent): void; 25 | 26 | msg(message: string, line: number, start: number, end: number, fix: null | string, endLine: number): void; 27 | 28 | isMatchType(type: string): boolean; 29 | clearErrors(): void; 30 | } 31 | -------------------------------------------------------------------------------- /src/core/ast/index.ts: -------------------------------------------------------------------------------- 1 | export * from './node'; 2 | export * from './selector'; 3 | export * from './tree'; 4 | export * from './group'; 5 | export * from './block'; 6 | export * from './property'; 7 | export * from './literal'; 8 | export * from './value'; 9 | export * from './rgb'; 10 | export * from './ident'; 11 | export * from './import'; 12 | export * from './obj'; 13 | export * from './unit'; 14 | export * from './call'; 15 | export * from './member'; 16 | export * from './binop'; 17 | export * from './func'; 18 | export * from './comment'; 19 | export * from './params'; 20 | export * from './bool'; 21 | export * from './each'; 22 | export * from './condition'; 23 | export * from './unaryop'; 24 | export * from './media'; 25 | export * from './querylist'; 26 | export * from './query'; 27 | export * from './feature'; 28 | export * from './keyframes'; 29 | export * from './atrule'; 30 | export * from './ternary'; 31 | export * from './supports'; 32 | export * from './return'; 33 | -------------------------------------------------------------------------------- /src/core/types/ast/node.ts: -------------------------------------------------------------------------------- 1 | import { ISNode } from './snode'; 2 | import { ILine } from '../line'; 3 | import { IContent } from '../content'; 4 | 5 | export interface INode { 6 | lineno: number; 7 | column: number; 8 | 9 | content: IContent | null; 10 | 11 | /** 12 | * Get line object 13 | */ 14 | line: ILine | null; 15 | 16 | parent: INode | null; 17 | block?: INode | null; 18 | 19 | nodeName: string; 20 | key: string | INode; 21 | 22 | nodes: INode[]; 23 | segments: INode[]; 24 | 25 | source: ISNode | null; 26 | 27 | value: INode | string | null; 28 | append(node: INode, listField?: keyof T): void; 29 | toString(): string; 30 | 31 | getSibling(next?: boolean): null | INode; 32 | getChild(findClass?: string, last?: boolean): null | INode; 33 | previousSibling(): null | INode; 34 | nextSibling(): null | INode; 35 | 36 | closest(parentClass: string): null | INode; 37 | lastChild(findClass?: string): null | INode; 38 | firstChild(findClass?: string): null | INode; 39 | } 40 | -------------------------------------------------------------------------------- /tests/rules/leadingZeroTest.ts: -------------------------------------------------------------------------------- 1 | import { LeadingZero } from '../../src/rules/index'; 2 | import { expect } from 'chai'; 3 | import { checkLine } from '../staff/bootstrap'; 4 | 5 | describe('Leading Zero test', () => { 6 | it('Should check the line has wrong unit notation', () => { 7 | const rule = new LeadingZero({ 8 | conf: 'always' 9 | }); 10 | 11 | expect(checkLine('font-size: .1em', rule)).to.be.false; 12 | 13 | expect(checkLine('font-size:.1em', rule)).to.be.false; 14 | 15 | expect(checkLine('font-size : .1111px', rule)).to.be.false; 16 | 17 | expect(rule.errors.length).to.be.equal(3); 18 | }); 19 | it('Should check the line has right unit notation', () => { 20 | const rule = new LeadingZero({ 21 | conf: 'always' 22 | }); 23 | 24 | expect(checkLine('font-size: 0.1em', rule)).to.be.true; 25 | 26 | expect(checkLine('font-size:0.1em', rule)).to.be.true; 27 | 28 | expect(checkLine('font-size : 0.1111px', rule)).to.be.true; 29 | 30 | expect(rule.errors.length).to.be.equal(0); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/core/preprocessor.ts: -------------------------------------------------------------------------------- 1 | import { Content } from './content'; 2 | import _require = require('native-require'); 3 | import { safeComments } from '../preprocessors/safeComments'; 4 | 5 | export class Preprocessor { 6 | private list: Array<(str: string) => string> = []; 7 | 8 | constructor(files: string[]) { 9 | if (files.length) { 10 | this.list = files.map((file) => { 11 | const func = _require(file); 12 | 13 | if (typeof func === 'function') { 14 | return func; 15 | } 16 | 17 | return null; 18 | }) 19 | .filter((f) => f); 20 | } 21 | 22 | this.list.push(safeComments); 23 | } 24 | 25 | /** 26 | * Apply some preprocessors function to content 27 | * @param content 28 | */ 29 | apply(content: Content): Content { 30 | if (!this.list.length) { 31 | return content; 32 | } 33 | 34 | const str = this.list.reduce((str, func) => func(str), content.content); 35 | 36 | if (typeof str === 'string' && str !== content.content) { 37 | return new Content(str); 38 | } 39 | 40 | return content; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/rules/commaInObject.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../core/rule'; 2 | import { ILine } from '../core/types/line'; 3 | 4 | const 5 | reg = /(,)(\s)*$/, 6 | keyValue = /:/, 7 | hashEnd = /}/; 8 | 9 | /** 10 | * Allow or deny commas in object hash 11 | */ 12 | export class CommaInObject extends Rule { 13 | checkLine(line: ILine): void | boolean { 14 | if (!this.context.inHash) { 15 | return; 16 | } 17 | 18 | let hasComma = false; 19 | const match = reg.exec(line.line); 20 | 21 | if (match) { 22 | hasComma = true; 23 | } 24 | 25 | if (hasComma && this.state.conf === 'never') { 26 | this.msg('Remove comma from object hash', line.lineno, match ? match.index + 1 : 0, match ? match.index + 1 : 0, ''); 27 | 28 | } else if (!hasComma && this.state.conf === 'always') { 29 | const next = line.next(); 30 | 31 | if (keyValue.test(line.line) && !hashEnd.test(line.line) && next && !hashEnd.test(next.line)) { 32 | this.msg('Add comma after object key: value', line.lineno, line.line.length - 2); 33 | } 34 | } 35 | 36 | return hasComma; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/core/documentator/readmePatcher.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFileSync } from 'fs'; 2 | import { RuleDocs } from './documentator'; 3 | 4 | /** 5 | * Patch readme file 6 | * @param result 7 | */ 8 | export function readmePatcher(result: RuleDocs[]): void { 9 | const readmeFile = process.cwd() + '/readme.md'; 10 | 11 | readFile(readmeFile, 'utf-8', (err, readme: string) => { 12 | if (err) { 13 | throw err; 14 | } 15 | 16 | const 17 | text = result.map((item: RuleDocs) => 18 | [ 19 | '\n', 20 | `### ${item.name}`, 21 | `${item.description}\n`, 22 | '**Default value**', 23 | '```json', 24 | `${JSON.stringify(item.default, null, 2)}`, 25 | '```', 26 | '----' 27 | ].join('\n') 28 | ).join(''); 29 | 30 | const readmeNew = readme.replace( 31 | /(.*)/msg, 32 | `${text}` 33 | ); 34 | 35 | if (readmeNew !== readme) { 36 | writeFileSync(readmeFile, readmeNew); 37 | console.log('Readme file patched'); 38 | } else { 39 | console.log('Readme file not patched'); 40 | } 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /src/commander.ts: -------------------------------------------------------------------------------- 1 | import { ucfirst } from './core/helpers/lcfirst'; 2 | import yargs = require('yargs'); 3 | import { Autocomplete } from './core/autocomplete'; 4 | import { Linter } from './linter'; 5 | 6 | export class Commander { 7 | private linter: Linter; 8 | 9 | constructor(options: Dictionary = {}) { 10 | this.linter = new Linter(options); 11 | } 12 | 13 | exec(command: string): void { 14 | const ucommand = ucfirst(command); 15 | 16 | if ((this as any)[`command${ucommand}`]) { 17 | (this as any)[`command${ucommand}`](); 18 | } 19 | 20 | process.exit(); 21 | } 22 | 23 | protected commandAutocomplete(): void { 24 | const { content, offset, offsetline } = yargs.options({ 25 | content: { type: 'string' }, 26 | offset: { type: 'number' }, 27 | offsetline: { type: 'number' } 28 | }).argv; 29 | 30 | const config = this.linter.config; 31 | 32 | const autocomplete = new Autocomplete(config); 33 | 34 | console.clear(); 35 | console.log( 36 | JSON.stringify({ 37 | suggests: autocomplete.getItems( 38 | content || '', 39 | offset || 0, 40 | offsetline || 0 41 | ) 42 | }) 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) Automattic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /tests/rules/mixedSpacesTest.ts: -------------------------------------------------------------------------------- 1 | import { MixedSpaces } from '../../src/rules/index'; 2 | import { expect } from 'chai'; 3 | import { checkLine, splitAndRun } from '../staff/bootstrap'; 4 | 5 | describe('Mixed spaces check test', () => { 6 | it('Should check the line has mixed tabs and spaces', () => { 7 | const rule = new MixedSpaces({ 8 | conf: 'always' 9 | }); 10 | 11 | expect(checkLine('\tcolor red', rule)).to.be.false; 12 | 13 | expect(checkLine('\t color red', rule)).to.be.true; 14 | 15 | expect(checkLine(' \tcolor red', rule)).to.be.true; 16 | 17 | expect(checkLine('\t\tfont-size .1111px', rule)).to.be.false; 18 | 19 | expect(checkLine(' font-size .1111px', rule)).to.be.false; 20 | 21 | expect(rule.errors.length).to.be.equal(2); 22 | }); 23 | describe('In cssdoc', () => { 24 | it('Should not find the error', () => { 25 | const rule = new MixedSpaces({ 26 | conf: 'always' 27 | }); 28 | 29 | splitAndRun('/**\n' + 30 | '\t * Base rule\n' + 31 | '\t */\n' + 32 | '\t&__offer-info\n' + 33 | '\t\tmargin-top basis(2.375)', 34 | rule); 35 | 36 | expect(rule.errors.length).to.be.equal(0); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/core/autocomplete.ts: -------------------------------------------------------------------------------- 1 | import _require = require('native-require'); 2 | import { defaultAutocomplete } from '../autocomplete/index'; 3 | import { AutocompleteFunction, Suggestions } from './types/autocomplete'; 4 | import { IConfig } from './types/config'; 5 | 6 | export class Autocomplete { 7 | private list: AutocompleteFunction[] = []; 8 | 9 | constructor(readonly config: IConfig) { 10 | if (config.autocompletes.length) { 11 | this.list = config.autocompletes.map((file) => { 12 | const func = _require(file); 13 | 14 | if (typeof func === 'function') { 15 | return func; 16 | } 17 | 18 | return null; 19 | }) 20 | .filter((f) => f); 21 | } 22 | 23 | this.list.push(defaultAutocomplete); 24 | } 25 | 26 | /** 27 | * Apply some preprocessors function to content 28 | * 29 | * @param search 30 | * @param offset 31 | * @param lineOffset 32 | */ 33 | getItems(search: string, offset: number, lineOffset: number): Suggestions { 34 | if (!this.list.length) { 35 | return []; 36 | } 37 | 38 | return this.list.reduce( 39 | (res, func) => 40 | res.concat(func.call(this, search, offset, lineOffset)), [] as Suggestions 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/core/parser.ts: -------------------------------------------------------------------------------- 1 | import Parser = require('stylus/lib/parser'); 2 | import { Tree } from './ast/index'; 3 | import { Translator } from './translator'; 4 | import { ISNode } from './types/ast/snode'; 5 | import { Content } from './content'; 6 | 7 | export class StylusParser { 8 | /** 9 | * @param options Stylus parser options 10 | */ 11 | constructor(readonly options: Dictionary = {}) { 12 | } 13 | 14 | /** 15 | * Parse use native stylus parser into StylusAST and convert it in our AST 16 | * 17 | * @param {string} content 18 | * @returns {Tree} 19 | */ 20 | parse(content: Content): Tree { 21 | const 22 | parser = new Parser(content.toString(), this.options); 23 | 24 | try { 25 | const 26 | stylusAST: ISNode = parser.parse({ 27 | resolver: (path: string) => { 28 | console.log(path); 29 | } 30 | }); 31 | 32 | const 33 | translator = new Translator(stylusAST, content); 34 | 35 | return translator.transpile(); 36 | } catch (err) { 37 | 38 | err.lineno = parser.lexer.lineno || err.lineno || 1; 39 | err.column = parser.lexer.column || err.column || 1; 40 | err.message = `Syntax error: ${err.message} (${err.lineno},${err.column})`; 41 | 42 | throw err; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/rules/leadingZero.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../core/rule'; 2 | import { ILine } from '../core/types/line'; 3 | 4 | const decimalRe = /[^\d+](0+\.\d+)|[\s,(:](\.\d+)/i; 5 | const leadZeroRe = /([^\d+])(0+\.\d+)/; 6 | const nonZeroRe = /([\s,(:])(\.\d+)/; 7 | 8 | /** 9 | * Check for leading 0 on numbers ( 0.5 ) 10 | */ 11 | export class LeadingZero extends Rule { 12 | checkLine(line: ILine): void | boolean { 13 | if (!decimalRe.test(line.line)) { 14 | return; 15 | } 16 | 17 | const leadZeroFound = leadZeroRe.exec(line.line); 18 | const leadZeroMissing = nonZeroRe.exec(line.line); 19 | 20 | if (this.state.conf === 'always' && leadZeroMissing) { 21 | this.msg( 22 | 'leading zeros for decimal points are required', 23 | line.lineno, 24 | leadZeroMissing.index + leadZeroMissing[1].length + 1, 25 | leadZeroMissing.index + leadZeroMissing[1].length + 1, 26 | '0.' 27 | ); 28 | } else if (this.state.conf === 'never' && leadZeroFound) { 29 | this.msg( 30 | 'leading zeros for decimal points are unnecessary', 31 | line.lineno, 32 | leadZeroFound.index + leadZeroFound[1].length + 1, 33 | leadZeroFound.index + leadZeroFound[1].length + 1, 34 | '' 35 | ); 36 | } 37 | 38 | return Boolean(leadZeroFound); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/helpers/calcPositionTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { calcPosition } from '../../src/core/helpers/index'; 3 | 4 | const multyLineText = '.item2-title\n' + // 1 13 5 | ' display flex\n' + // 2 27 6 | ' align-items center\n' + // 3 47 7 | ' box-sizing border-box\n' + // 4 70 8 | ' height $p.item2Height\n' + // 5 93 9 | ' padding-left $p.paddingLeft - $p.border[0]\n' + // 6 137 10 | '\n' + // 7 138 11 | ' font-size basis(1.875)\n' + // 8 162 12 | '\n' + // 9 163 13 | ' border $p.border\n' + // 10 181 14 | ' border-width 0 $p.border[0]\n' + // 11 210 15 | ' color $p.secondSlideTitleColor`;\n'; // 12 244 16 | 17 | describe('CalcPosition helper test', () => { 18 | it('Should calc position right', () => { 19 | const content = multyLineText; 20 | expect(calcPosition(1, 1, content)).to.equal(0); 21 | expect(calcPosition(1, 5, content)).to.equal(4); 22 | expect(calcPosition(2, 5, content)).to.equal(17); 23 | expect(calcPosition(10, 5, content)).to.equal(167); 24 | expect(calcPosition(-1, 5, content)).to.equal(4); 25 | expect(calcPosition(100, 5, content)).to.equal(249); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/core/line.ts: -------------------------------------------------------------------------------- 1 | import { ILine } from './types/line'; 2 | 3 | export class Line implements ILine { 4 | readonly line: string; 5 | readonly lineno: number = 1; 6 | isIgnored: boolean = false; 7 | 8 | readonly lines: Line[] = []; 9 | 10 | constructor(line: string, lineno: number = 1, lines: Line[] = []) { 11 | this.line = line; 12 | this.lineno = lineno; 13 | this.lines = lines; 14 | } 15 | 16 | /** 17 | * Get next line 18 | */ 19 | next(): ILine | null { 20 | const index = this.lines.indexOf(this); 21 | 22 | if (index !== -1) { 23 | return this.lines[index + 1] || null; 24 | } 25 | 26 | return null; 27 | } 28 | 29 | /** 30 | * Get previous line 31 | */ 32 | prev(): ILine | null { 33 | const index = this.lines.indexOf(this); 34 | 35 | if (index !== -1) { 36 | return this.lines[index - 1] || null; 37 | } 38 | 39 | return null; 40 | } 41 | 42 | /** 43 | * Check the line is empty 44 | */ 45 | isEmpty(): boolean { 46 | return this.line.trim().length === 0; 47 | } 48 | 49 | /** 50 | * This is last line 51 | */ 52 | isLast(): boolean { 53 | const 54 | index = this.lines.indexOf(this); 55 | 56 | let lastIndex = this.lines.length - 1; 57 | 58 | for (let i = lastIndex; i > 0; i -= 1) { 59 | if (this.lines[i].isEmpty()) { 60 | lastIndex = i - 1; 61 | } 62 | } 63 | 64 | return index === lastIndex; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/rules/prefixVarsWithDollar.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../core/rule'; 2 | import { Func, Ident, Tree } from '../core/ast/index'; 3 | import { IState } from '../core/types/state'; 4 | 5 | interface IPrefixVarState extends IState { 6 | prefix: string; 7 | allowConst?: boolean; 8 | } 9 | 10 | /** 11 | * Check that $ is used when declaring vars 12 | */ 13 | export class PrefixVarsWithDollar extends Rule { 14 | nodesFilter: string[] = ['ident']; 15 | 16 | checkNode(node: Ident): boolean | void { 17 | if (!(node.parent instanceof Tree) || (node.value instanceof Func)) { 18 | return; 19 | } 20 | 21 | const 22 | hasDollar = node.key.indexOf(this.state.prefix) === 0; 23 | 24 | if (this.state.conf === 'always' && hasDollar === false) { 25 | if (this.state.allowConst && /^[A-Z0-9_]+$/.test(node.key)) { 26 | return; 27 | } 28 | 29 | this.msg( 30 | `Variables and parameters must be prefixed with the ${this.state.prefix} sign (${node.key})`, 31 | node.lineno, 32 | node.column, 33 | node.column + node.key.length - 1 34 | ); 35 | } else if (this.state.conf === 'never' && hasDollar === true) { 36 | this.msg( 37 | `${this.state.prefix} sign is disallowed for variables and parameters (${node.key})`, 38 | node.lineno, node.column, 39 | node.column + node.key.length - 1 40 | ); 41 | } 42 | 43 | return hasDollar; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/rules/semicolons.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../core/rule'; 2 | import { ILine } from '../core/types/line'; 3 | 4 | // we only want to check semicolons on properties/values 5 | const ignoreRe = /(^[*#.])|[&>/]|{|}|if|for(?!\w)|else|@block|@media|([}{=,])$/igm; 6 | 7 | /** 8 | * Check that selector properties are sorted accordingly 9 | */ 10 | export class Semicolons extends Rule { 11 | checkLine(line: ILine): void | boolean { 12 | if (ignoreRe.test(line.line.trim())) { 13 | return; 14 | } 15 | 16 | let 17 | semicolon; 18 | const 19 | index = line.line.indexOf(';'); 20 | 21 | if (this.state.conf === 'never' && index !== -1) { 22 | semicolon = true; 23 | } 24 | 25 | // for reasons that perplex me, even when the first use 26 | // of this at the top returns true, sometimes the method 27 | // still runs, so we add this second ignoreCheck here to catch it 28 | if (this.state.conf === 'always' && !ignoreRe.test(line.line.trim())) { 29 | if (index === -1 && 30 | line.line.indexOf('}') === -1 && 31 | line.line.indexOf('{') === -1) { 32 | semicolon = false; 33 | } 34 | } 35 | 36 | if (this.state.conf === 'never' && semicolon === true) { 37 | this.msg('unnecessary semicolon found', line.lineno, index + 1, index + 1, ''); 38 | } else if (this.state.conf === 'always' && semicolon === false) { 39 | this.msg('missing semicolon', line.lineno, line.line.length + 1, line.line.length + 1, ';'); 40 | } 41 | 42 | return semicolon; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/beforeCheckNodeTest.ts: -------------------------------------------------------------------------------- 1 | import { Linter } from '../src/linter'; 2 | import { Rule } from '../src/core/rule'; 3 | import { expect } from 'chai'; 4 | 5 | describe('Before check node checker doing some work', () => { 6 | it('Should parse hash all variables', () => { 7 | const linter = new Linter(); 8 | 9 | linter.lint('./test.styl', `$p = { 10 | colors: #ccc #ddd #eee rgba(0, 0, 0, 0.1) 11 | width: basis(10, 20px, #ccc) 1px solid, 12 | color: { 13 | fff: black red 14 | }, 15 | background: $colors.white[0], 16 | offersShopsText: $colors.grey[2], 17 | discountBgColor: $colors.base.third 18 | } 19 | 20 | color = #ccc` 21 | ); 22 | 23 | expect(Rule.getContext().vars).to.be.deep.equal({ 24 | $p: { 25 | colors: ['#ccc', '#ddd', '#eee', 'rgba(0, 0, 0, 0.1)'], 26 | width: ['basis(10, 20px, #ccc)', '1px', 'solid'], 27 | color: {fff: ['black', 'red']}, 28 | background: '$colors.white[0]', 29 | offersShopsText: '$colors.grey[2]', 30 | discountBgColor: '$colors.base.third' 31 | }, 32 | color: '#ccc' 33 | }); 34 | 35 | expect(Rule.getContext().valueToVar).to.be.deep.equal({ 36 | '#ccc': 'color', 37 | '#ddd': '$p.colors[1]', 38 | '#eee': '$p.colors[2]', 39 | '$colors.base.third': '$p.discountBgColor', 40 | '$colors.grey[2]': '$p.offersShopsText', 41 | '$colors.white[0]': '$p.background', 42 | '1px': '$p.width[1]', 43 | 'basis(10, 20px, #ccc)': '$p.width[0]', 44 | 'black': '$p.color.fff[0]', 45 | 'red': '$p.color.fff[1]', 46 | 'rgba(0, 0, 0, 0.1)': '$p.colors[3]', 47 | 'solid': '$p.width[2]' 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /tests/extraRulesTest.ts: -------------------------------------------------------------------------------- 1 | import { Linter } from '../src/linter'; 2 | import { expect } from 'chai'; 3 | 4 | describe('Test `extraRules` options', () => { 5 | describe('like folder', () => { 6 | it('Should load rules from path and check these', () => { 7 | const linter = new Linter({ 8 | extraRules: './tests/staff/extra' 9 | }); 10 | 11 | linter.lint('./test.styl', '.test\n\tcolor red'); 12 | 13 | expect(linter.reporter.response.passed).to.be.false; 14 | const errors = linter.reporter.response.errors; 15 | expect(errors && errors[0].message[0].rule).to.be.equal('testRule'); 16 | }); 17 | }); 18 | 19 | describe('like files', () => { 20 | it('Should load rules from path and check these', () => { 21 | const linter = new Linter({ 22 | extraRules: './tests/staff/extra/testRule.js' 23 | }); 24 | 25 | linter.lint('./test.styl', '.test\n\tcolor red'); 26 | 27 | expect(linter.reporter.response.passed).to.be.false; 28 | const errors = linter.reporter.response.errors; 29 | expect(errors && errors[0].message[0].rule).to.be.equal('testRule'); 30 | }); 31 | }); 32 | 33 | describe('like list of files', () => { 34 | it('Should load rules from all paths and check these', () => { 35 | const linter = new Linter({ 36 | extraRules: [ 37 | './tests/staff/extra/testRule.js' 38 | ] 39 | }); 40 | 41 | linter.lint('./test.styl', '.test\n\tcolor red'); 42 | 43 | expect(linter.reporter.response.passed).to.be.false; 44 | const errors = linter.reporter.response.errors; 45 | expect(errors && errors[0].message[0].rule).to.be.equal('testRule'); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/rules/quotePrefTest.ts: -------------------------------------------------------------------------------- 1 | import { QuotePref } from '../../src/rules/index'; 2 | import { expect } from 'chai'; 3 | import { splitAndRun } from '../staff/bootstrap'; 4 | 5 | describe('Test quote pref rule', () => { 6 | describe('You use single quotes', () => { 7 | it('Should throw error if line has double quote', () => { 8 | const rule = new QuotePref({ 9 | conf: 'single' 10 | }); 11 | 12 | splitAndRun( 13 | '.test\n' + 14 | '\tbackground-image: url("./logo.png")\n', 15 | rule 16 | ); 17 | 18 | expect(rule.errors.length).to.be.equal(1); 19 | }); 20 | it('Should not throw error if line has single quote', () => { 21 | const rule = new QuotePref({ 22 | conf: 'single' 23 | }); 24 | 25 | splitAndRun( 26 | '.test\n' + 27 | '\tbackground-image: url(\'./logo.png\')\n', 28 | rule 29 | ); 30 | 31 | expect(rule.errors.length).to.be.equal(0); 32 | }); 33 | }); 34 | describe('You use double quotes', () => { 35 | it('Should throw error if in line single quote', () => { 36 | const rule = new QuotePref({ 37 | conf: 'double' 38 | }); 39 | 40 | splitAndRun( 41 | '.test\n' + 42 | '\tbackground-image: url(\'./logo.png\')\n', 43 | rule 44 | ); 45 | 46 | expect(rule.errors.length).to.be.equal(1); 47 | }); 48 | it('Should not throw error if line has double quote', () => { 49 | const rule = new QuotePref({ 50 | conf: 'double' 51 | }); 52 | 53 | splitAndRun( 54 | '.test\n' + 55 | '\tbackground-image: url("./logo.png")\n', 56 | rule 57 | ); 58 | 59 | expect(rule.errors.length).to.be.equal(0); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { Linter } from './src/linter'; 2 | import { Reader } from './src/core/reader'; 3 | import * as astList from './src/core/ast/index'; 4 | 5 | export * from './src/core/rule'; 6 | export const ast = astList; 7 | export * from './src/doc'; 8 | export * from './src/linter'; 9 | export * from './src/commander'; 10 | export * from './src/core/content'; 11 | export * from './src/core/parser'; 12 | export * from './src/core/runner'; 13 | export * from './src/core/visitor'; 14 | export * from './src/core/translator'; 15 | export * from './src/core/line'; 16 | export * from './src/core/reader'; 17 | export * from './src/core/checker'; 18 | export * from './src/core/baseConfig'; 19 | 20 | /** 21 | * Main stylus checker 22 | * 23 | * @param path 24 | * @param content 25 | * @param options 26 | * @constructor 27 | */ 28 | export async function StylusLinter(path: string | string[], content?: string, options: Dictionary = {}): Promise { 29 | const 30 | linter = new Linter(options), 31 | first = () => Array.isArray(path) ? path[0] : path; 32 | 33 | if (content) { 34 | linter.lint(first(), content); 35 | return linter.display(); 36 | } 37 | 38 | if (!path) { 39 | path = linter.config.path || process.cwd(); 40 | } 41 | 42 | const 43 | reader = new Reader(linter.config), 44 | readAndDisplay = async () => { 45 | await reader.read(path, linter.lint.bind(linter)); 46 | 47 | linter.display(!linter.config.watch); 48 | }; 49 | 50 | if (linter.config.watch) { 51 | linter.watch(Array.isArray(path) ? path[0] : path, () => { 52 | console.log('Recheck files...'); 53 | readAndDisplay(); 54 | }); 55 | } 56 | 57 | await readAndDisplay(); 58 | } 59 | -------------------------------------------------------------------------------- /tests/staff/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { IRule } from '../../src/core/types/rule'; 2 | import { Runner } from '../../src/core/runner'; 3 | import { StylusParser } from '../../src/core/parser'; 4 | import { Rule } from '../../src/core/rule'; 5 | import { Content } from '../../src/core/content'; 6 | 7 | /** 8 | * Parse tree AST and apply rule 9 | * 10 | * @param text 11 | * @param rule 12 | */ 13 | export const parseAndRun = (text: string, rule: IRule) => { 14 | const 15 | content = new Content(text), 16 | parser = new StylusParser({}), 17 | ast = parser.parse(content); 18 | 19 | if (rule.checkNode) { 20 | const 21 | runner = new Runner(ast, (node) => { 22 | if (rule.checkNode && rule.isMatchType(node.nodeName)) { 23 | rule.checkNode(node, content); 24 | } 25 | }); 26 | 27 | runner.visit(ast, null); 28 | } 29 | 30 | if (rule.checkLine) { 31 | splitAndRun(text, rule, content); 32 | } 33 | }; 34 | 35 | /** 36 | * Split content on lines and apply rule on every lines 37 | * 38 | * @param text 39 | * @param rule 40 | * @param content 41 | */ 42 | export const splitAndRun = (text: string, rule: IRule, content: Content = new Content(text)) => { 43 | if (rule.checkLine) { 44 | Rule.clearContext(); 45 | 46 | content.forEach((line, index) => { 47 | if (index) { 48 | Rule.beforeCheckLine(line); 49 | rule.checkLine && rule.checkLine(line, index, content); 50 | } 51 | }); 52 | } 53 | }; 54 | 55 | /** 56 | * Check rule only on one line 57 | * 58 | * @param line 59 | * @param rule 60 | */ 61 | export const checkLine = (line: string, rule: IRule): void | boolean => { 62 | const content = new Content(line); 63 | 64 | return rule.checkLine && rule.checkLine(content.firstLine(), 0, content); 65 | }; 66 | -------------------------------------------------------------------------------- /src/rules/quotePref.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../core/rule'; 2 | import { ILine } from '../core/types/line'; 3 | 4 | const stringRe = /(?=["'])(?:"[^"\\]*(?:\\[\s\S][^"\\]*)*"|'[^'\\]*(?:\\[\s\S][^'\\]*)*')/g; 5 | 6 | /** 7 | * Check that quote style is consistent with config 8 | */ 9 | export class QuotePref extends Rule { 10 | checkLine(line: ILine): void | boolean { 11 | if (line.line.indexOf('"') === -1 && line.line.indexOf("'") === -1) { 12 | return; 13 | } 14 | 15 | stringRe.lastIndex = 0; 16 | 17 | let badQuotes = false; 18 | let hasInnerQuote = true; 19 | let match = stringRe.exec(line.line); 20 | 21 | while (match !== null) { 22 | const content = match[0].slice(1, -1); 23 | 24 | if (this.state.conf === 'single' && match[0].indexOf('"') === 0) { 25 | hasInnerQuote = content.indexOf("'") !== -1; 26 | 27 | if (!hasInnerQuote) { 28 | badQuotes = true; 29 | this.msg( 30 | `Preferred quote style is ${this.state.conf} quotes`, 31 | line.lineno, 32 | match.index + 1, 33 | match[0].length + match.index, 34 | match[0].replace(/^"/g, '\'').replace(/'$/g, '\'') 35 | ); 36 | } 37 | 38 | } else if (this.state.conf === 'double' && match[0].indexOf("'") === 0) { 39 | hasInnerQuote = content.indexOf('"') !== -1; 40 | 41 | if (!hasInnerQuote) { 42 | badQuotes = true; 43 | 44 | this.msg( 45 | `Preferred quote style is ${this.state.conf} quotes`, 46 | line.lineno, 47 | match.index + 1, 48 | match[0].length + match.index, 49 | match[0].replace(/^'/g, '"').replace(/'$/g, '"') 50 | ); } 51 | } 52 | 53 | match = stringRe.exec(line.line); 54 | } 55 | 56 | return badQuotes; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/rules/colons.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../core/rule'; 2 | import { ILine } from '../core/types/line'; 3 | 4 | const validJSON = require('../data/valid.json'); 5 | 6 | /** 7 | * Use/Do not use colons after property 8 | */ 9 | export class Colons extends Rule { 10 | checkLine(line: ILine): void | boolean { 11 | if (this.context.inHash) { 12 | return; 13 | } 14 | 15 | let colon = this.state.conf === 'always'; 16 | let hasPseudo = false; 17 | let hasScope = false; 18 | const arr = line.line.split(/ /); 19 | 20 | if (this.state.conf === 'always' && 21 | arr.length > 1 && 22 | arr[0].indexOf(':') === -1 && 23 | arr[0].indexOf(',') === -1) { 24 | colon = false; 25 | } else if (this.state.conf === 'never' && line.line.indexOf(':') !== -1) { 26 | // check for pseudo selector 27 | hasPseudo = validJSON.pseudo.some((val: string) => line.line.indexOf(val) !== -1); 28 | 29 | // check for scope selector 30 | hasScope = validJSON.scope.some((val: string) => line.line.indexOf(val) !== -1); 31 | 32 | const 33 | index = line.line.indexOf(':'), 34 | url = /url\(.*?\)/i.exec(line.line); 35 | 36 | if (url && url.index < index && url[0].length + url.index > index) { 37 | colon = false; 38 | } else { 39 | if (!hasPseudo && !hasScope) { 40 | colon = true; 41 | } 42 | } 43 | } 44 | 45 | if (this.state.conf === 'always' && colon === false) { 46 | this.msg( 47 | 'missing colon between property and value', 48 | line.lineno, 49 | arr[0].length + 1, 50 | arr[0].length + 1, 51 | ': ' 52 | ); 53 | } else if (this.state.conf === 'never' && colon === true) { 54 | const index = line.line.indexOf(':'); 55 | this.msg('unnecessary colon found', line.lineno, index + 1, index + 2, ' '); 56 | } 57 | 58 | return colon; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/core/reporters/rawReporter.ts: -------------------------------------------------------------------------------- 1 | import { IMessagePack } from '../types/message'; 2 | import columnify = require('columnify'); 3 | import chalk from 'chalk'; 4 | import { Reporter } from '../reporter'; 5 | 6 | interface RawMessage { 7 | file?: string 8 | line: number 9 | description: string 10 | rule: string 11 | } 12 | 13 | export class RawReporter extends Reporter { 14 | /** 15 | * @override 16 | */ 17 | log(): void { 18 | const 19 | cwd = process.cwd(), 20 | warningsOrErrors = [...this.errors], // TODO add warning mode 21 | messagesToFile: Dictionary = {}, 22 | msg = [], 23 | columns = process.stdout.columns || this.options.maxWidth || 400, 24 | calcWidth = (percent: number): number => Math.ceil((columns / 100) * percent) - 4, 25 | pl = (str: string, percent: number): string => str.padEnd(calcWidth(percent), ' '); 26 | 27 | warningsOrErrors.forEach((pack: IMessagePack) => { 28 | pack.message.forEach((message) => { 29 | const path = message.path.replace(cwd, ''); 30 | 31 | if (!messagesToFile[path]) { 32 | messagesToFile[path] = []; 33 | } 34 | 35 | const row = { 36 | //file: chalk.magenta(pl(path, 30)), 37 | line: chalk.yellow(pl(message.line.toString(), 3)), 38 | description: chalk.red(pl(message.descr, 75)), 39 | rule: chalk.cyan(message.rule) 40 | }; 41 | 42 | messagesToFile[path].push(row); 43 | }); 44 | }); 45 | 46 | const msgGrouped = Object.keys(messagesToFile).map((file) => 47 | [ 48 | chalk.blue(file), 49 | columnify(messagesToFile[file], this.options), 50 | '' 51 | ].join('\n') 52 | ); 53 | 54 | msg.push(msgGrouped.join('\n')); 55 | 56 | const cnt = this.errors.length; 57 | 58 | msg.push(`\nStlint: ${(cnt ? chalk.red(cnt) : chalk.green(0))} Errors.\n`); 59 | 60 | console.log(msg.join('')); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { State } from './core/types/state'; 2 | import data = require('./defaultRules.json'); 3 | import { BaseConfig } from './core/baseConfig'; 4 | import { ReporterType } from './core/types/reporter'; 5 | import { IConfig } from './core/types/config'; 6 | import chalk from 'chalk'; 7 | import { resolve } from 'path'; 8 | 9 | export class Config extends BaseConfig implements IConfig { 10 | debug: boolean = false; 11 | reporter: ReporterType = 'raw'; 12 | 13 | rules: Dictionary = {...data}; 14 | defaultRules: Dictionary = Object.freeze({...data}); 15 | 16 | excludes: string[] = ['node_modules/']; 17 | 18 | watch: boolean = false; 19 | 20 | basepath: string = ''; 21 | path: string = ''; 22 | 23 | grep: string = ''; 24 | doc: string = ''; 25 | fix: boolean = false; 26 | 27 | stylusParserOptions: Dictionary = {}; 28 | 29 | extends: string | string[] = ''; 30 | customProperties: string[] = []; 31 | 32 | reportOptions: Dictionary = { 33 | columnSplitter: ' | ', 34 | headingTransform: (heading: string) => 35 | chalk.yellow(heading.toUpperCase()), 36 | truncate: false 37 | }; 38 | 39 | constructor(options: Dictionary) { 40 | super(); 41 | 42 | this.extendsOption(options, this); 43 | 44 | if (!this.basepath) { 45 | this.basepath = process.cwd(); 46 | } 47 | 48 | if (!this.configFile) { 49 | this.configFile = resolve(this.basepath, this.configName); 50 | } 51 | 52 | const 53 | customConfig = this.readFile(this.configFile); 54 | 55 | this.extendsOption(options, customConfig); 56 | 57 | if (customConfig.extends) { 58 | if (Array.isArray(customConfig.extends)) { 59 | customConfig.extends.forEach(this.extendsByPath.bind(this)); 60 | } else { 61 | this.extendsByPath(customConfig.extends); 62 | } 63 | } 64 | 65 | this.applyConfig(this.configFile, customConfig); 66 | 67 | delete options.extraRules; 68 | 69 | this.extendsOption(options, this); // options are main 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/rules/useMixinInsteadUnit.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../core/rule'; 2 | import { INode } from '../core/types/ast/node'; 3 | import { IState } from '../core/types/state'; 4 | 5 | interface IUseMixinInsteadunitState extends IState { 6 | mixin: string; 7 | unitType: string; 8 | allowOneUnit?: boolean; 9 | } 10 | 11 | /** 12 | * Allo or deny some mixin instead of unit statement 13 | */ 14 | export class useMixinInsteadUnit extends Rule { 15 | nodesFilter: string[] = ['unit', 'call']; 16 | 17 | checkNode(node: INode): void | boolean { 18 | 19 | if (this.state.conf === 'always') { 20 | if (node.value && typeof node.value === 'string') { 21 | const unit = RegExp('([\\d]+)' + this.state.unitType).exec(node.value); 22 | 23 | if (unit) { 24 | const 25 | unitSize: number = Number(unit[1]); 26 | 27 | if (this.state.allowOneUnit && unitSize === 1) { 28 | return false; 29 | } 30 | 31 | let 32 | fix = ''; 33 | 34 | if (this.state.mixin === 'basis') { 35 | const 36 | basis = (unitSize / 8); 37 | 38 | fix = `basis(${basis})`; 39 | } 40 | 41 | this.msg( 42 | `Use "${this.state.mixin}" mixin instead "${this.state.unitType}"`, 43 | node.lineno, 44 | node.column, 45 | node.column + node.value.trimRight().length - 1, 46 | fix || null 47 | ); 48 | 49 | return true; 50 | } 51 | } 52 | } else { 53 | if (node.nodeName === 'call' && typeof node.key === 'string' && node.key === this.state.mixin) { 54 | let fix = null; 55 | 56 | if (this.state.mixin === 'basis' && node.nodes[0].toString()) { 57 | const 58 | unitSize: number = Number(node.nodes[0].toString()), 59 | basis = (unitSize * 8); 60 | 61 | fix = `${basis}px`; 62 | } 63 | 64 | this.msg(`Do not use "${this.state.mixin}" mixin`, 65 | node.lineno, 66 | node.column, 67 | node.column + node.toString().length - 1, 68 | fix || null); 69 | } 70 | } 71 | 72 | return false; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/core/content.ts: -------------------------------------------------------------------------------- 1 | import { eachLineCallback, IContent } from './types/content'; 2 | import { Line } from './line'; 3 | import { IMessage } from './types/message'; 4 | import { calcPosition, splitLines } from './helpers/index'; 5 | 6 | export class Content implements IContent { 7 | protected lines: Line[]; 8 | 9 | constructor(readonly content: string) { 10 | this.lines = splitLines(content); 11 | } 12 | 13 | toString(): string { 14 | return this.content; 15 | } 16 | 17 | /** 18 | * Get first line 19 | */ 20 | firstLine(): Line { 21 | return this.lines[1]; 22 | } 23 | 24 | /** 25 | * Apply callback on every line 26 | * 27 | * @param callback 28 | */ 29 | forEach(callback: eachLineCallback): void { 30 | for (let lineno = 1; lineno < this.lines.length; lineno += 1) { 31 | if (callback(this.lines[lineno], lineno) === false) { 32 | break; 33 | } 34 | } 35 | } 36 | 37 | /** 38 | * Return line 39 | * @param lineno 40 | */ 41 | getLine(lineno: number): Line { 42 | if (!this.lines[lineno]) { 43 | throw new Error('Line not exists'); 44 | } 45 | 46 | return this.lines[lineno]; 47 | } 48 | 49 | /** 50 | * Apply some fix to text 51 | * 52 | * @param messages 53 | */ 54 | applyFixes(messages: IMessage[]): Content { 55 | let content = this.content; 56 | 57 | messages.forEach((message, index) => { 58 | if (message.fix) { 59 | const 60 | start = calcPosition(message.line, message.start, content), 61 | end = calcPosition(message.endline, message.end, content), 62 | oldPart = content.substring(start, end + 1), 63 | fix = message.fix.replace.toString(), 64 | diffLines = splitLines(fix).length - splitLines(oldPart).length; 65 | 66 | content = content.substr(0, start) + fix + content.substr(end + 1); 67 | 68 | if (diffLines) { 69 | for (let i = index + 1; i < messages.length; i += 1) { 70 | messages[i].line += diffLines; 71 | messages[i].endline += diffLines; 72 | } 73 | } 74 | } 75 | }); 76 | 77 | return new Content(content); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/rules/commaInObjectTest.ts: -------------------------------------------------------------------------------- 1 | import { CommaInObject } from '../../src/rules/index'; 2 | import { expect } from 'chai'; 3 | import { splitAndRun } from '../staff/bootstrap'; 4 | 5 | describe('Comma In Object test', () => { 6 | describe('Need use', () => { 7 | describe('Right content', () => { 8 | it('Should check object fields have trailing comma', () => { 9 | const rule = new CommaInObject({ 10 | conf: 'always' 11 | }); 12 | 13 | splitAndRun( 14 | '$p = {\n' + 15 | '\ta: #ccc,\n' + 16 | '\tb: #ddd\n' + 17 | '}.test\n' + 18 | '\tmax-height red;\n' + 19 | '\tborder black', 20 | rule 21 | ); 22 | 23 | expect(rule.errors.length).to.be.equal(0); 24 | }); 25 | }); 26 | describe('Wrong content', () => { 27 | it('Should check object fields have trailing comma', () => { 28 | const rule = new CommaInObject({ 29 | conf: 'always' 30 | }); 31 | 32 | splitAndRun( 33 | '$p = {\n' + 34 | '\ta: #ccc\n' + 35 | '\tb: #ddd\n' + 36 | '\tc: #ddd\n' + 37 | '}.test\n' + 38 | '\tmax-height red;\n' + 39 | '\tborder black', 40 | rule 41 | ); 42 | 43 | expect(rule.errors.length).to.be.equal(2); 44 | }); 45 | }); 46 | }); 47 | 48 | describe('Do not need use', () => { 49 | describe('Right content', () => { 50 | it('Should check object fields have not trailing comma', () => { 51 | const rule = new CommaInObject({ 52 | conf: 'never' 53 | }); 54 | 55 | splitAndRun( 56 | '$p = {\n' + 57 | '\ta: #ccc\n' + 58 | '\tb: #ddd\n' + 59 | '}.test\n' + 60 | '\tmax-height red;\n' + 61 | '\tborder black', 62 | rule 63 | ); 64 | 65 | expect(rule.errors.length).to.be.equal(0); 66 | }); 67 | }); 68 | describe('Wrong content', () => { 69 | it('Should check object fields have not trailing comma', () => { 70 | const rule = new CommaInObject({ 71 | conf: 'never' 72 | }); 73 | 74 | splitAndRun( 75 | '$p = {\n' + 76 | '\ta: #ccc,\n' + 77 | '\tc: #ccc,\n' + 78 | '\tb: #ddd\n' + 79 | '}.test\n' + 80 | '\tmax-height red;\n' + 81 | '\tborder black', 82 | rule 83 | ); 84 | 85 | expect(rule.errors.length).to.be.equal(2); 86 | }); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /bin/stlint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const yargs = require('yargs'); 6 | const stlint = require('../').StylusLinter; 7 | const Linter = require('../').Linter; 8 | const Commander = require('../').Commander; 9 | 10 | const options = yargs 11 | .usage('Usage: $0 file') 12 | .option('info', { 13 | alias: 'i', 14 | describe: 'Show info', 15 | type: 'boolean' 16 | }) 17 | .option('config', { 18 | alias: 'c', 19 | describe: 'Location of custom config file', 20 | type: 'string' 21 | }) 22 | .option('command', { 23 | alias: 'cmd', 24 | describe: 'Some command for run', 25 | type: 'string' 26 | }) 27 | .option('reporter', { 28 | alias: 'r', 29 | describe: 'Reporter name', 30 | type: 'string' 31 | }) 32 | .option('grep', { 33 | alias: 'g', 34 | describe: 'Only run rules matching this string or regexp', 35 | type: 'string' 36 | }) 37 | .option('newline', { 38 | alias: 'nl', 39 | describe: 'Character for replacing newline', 40 | default: '', 41 | type: 'string' 42 | }) 43 | .option('fix', { 44 | alias: 'f', 45 | describe: 'Try fix some rules', 46 | type: 'boolean' 47 | }) 48 | .option('doc', { 49 | describe: 'Generate doc', 50 | type: 'string' 51 | }) 52 | .option('watch', { 53 | alias: 'w', 54 | describe: 'Watch changes', 55 | type: 'boolean' 56 | }) 57 | .version(require('../package').version) 58 | .alias('version', 'v') 59 | .help('help') 60 | .alias('help', 'h') 61 | .alias('help', '?') 62 | .example('$0 file.styl', 'Run Stylus Linter on .styl-file') 63 | .example('$0 ./directory', 'Run Stylus Linter on directory with .styl-files') 64 | .example('$0 ./directory --fix', 'Run Stylus Linter on directory with .styl-files and try fix some issues') 65 | .example('$0 ./directory --watch', 'Check all .styl files in directory after some of these was changed') 66 | .epilogue('MIT') 67 | .argv; 68 | 69 | if (options.newline) { 70 | options.content = options.content.replace(new RegExp(`${options.newline}`, 'g'), '\n'); 71 | } 72 | 73 | if (options.command) { 74 | const commander = new Commander(); 75 | commander.exec(options.command); 76 | 77 | } else if (options.info) { 78 | const linter = new Linter(options); 79 | linter.info(); 80 | 81 | } else if (options.doc) { 82 | require('../').doc(options); 83 | 84 | } else { 85 | stlint(options._[0], options.content, options); 86 | } 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stlint", 3 | "version": "1.0.65", 4 | "description": "Stylus Linter", 5 | "main": "index.js", 6 | "bin": { 7 | "stlint": "./bin/stlint" 8 | }, 9 | "files": [ 10 | "bin/", 11 | "index.js", 12 | "src/" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/stylus/stlint" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/stylus/stlint/issues" 20 | }, 21 | "scripts": { 22 | "newversion": "npm test && npm version patch --no-git-tag-version && npm run build && npm run doc && npm run newversiongit && npm publish ./", 23 | "newversiongit": "git add --all && git commit -m \"New version $npm_package_version. Read more https://github.com/stylus/stlint/releases/tag/$npm_package_version \" && git tag $npm_package_version && git push --tags origin HEAD:master", 24 | "start": "webpack --watch", 25 | "build": "webpack", 26 | "doc": "./bin/stlint --doc rules --fix", 27 | "test2": "./bin/stlint ./test.styl", 28 | "test": "mocha tests/**/**.ts tests/**.ts", 29 | "fix": "tslint -c tslint.json ./src/**/*.ts ./src/**/**/*.ts ./src/*.ts --fix" 30 | }, 31 | "keywords": [ 32 | "lint", 33 | "linter", 34 | "stylus", 35 | "stylus-linter", 36 | "stlint" 37 | ], 38 | "author": "Chupurnov Valeriy", 39 | "license": "MIT", 40 | "dependencies": { 41 | "@types/yargs": "^15.0.3", 42 | "async": "^2.6.3", 43 | "chalk": "^2.4.2", 44 | "columnify": "^1.5.4", 45 | "escaper": "^3.0.3", 46 | "glob": "^7.1.6", 47 | "husky": "^4.2.3", 48 | "native-require": "^1.1.4", 49 | "node-watch": "^0.6.3", 50 | "prettier": "^1.19.1", 51 | "strip-json-comments": "^2.0.1", 52 | "stylus": "^0.54.7", 53 | "yargs": "^13.3.0" 54 | }, 55 | "devDependencies": { 56 | "@types/async": "^2.4.2", 57 | "@types/chai": "^4.2.9", 58 | "@types/glob": "^7.1.1", 59 | "@types/mocha": "^5.2.7", 60 | "@types/node": "^11.15.7", 61 | "awesome-typescript-loader": "^5.2.1", 62 | "chai": "^4.2.0", 63 | "mocha": "^6.2.2", 64 | "ts-node": "^8.6.2", 65 | "tslint": "^5.20.1", 66 | "tslint-config-prettier": "^1.18.0", 67 | "tslint-plugin-prettier": "^2.1.0", 68 | "typescript": "^3.7.5", 69 | "typings": "^2.1.1", 70 | "webpack": "^4.41.6", 71 | "webpack-cli": "^3.3.11", 72 | "webpack-node-externals": "^1.7.2" 73 | }, 74 | "mocha": { 75 | "require": [ 76 | "ts-node/register", 77 | "tests/staff/bootstrap.ts" 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/smokeTest.ts: -------------------------------------------------------------------------------- 1 | import { Linter } from '../src/linter'; 2 | import { expect } from 'chai'; 3 | 4 | const 5 | wrongContent = '.tab\n\tcolor: #ccc' 6 | 7 | describe('Smoke test', () => { 8 | it('should work fine', () => { 9 | const linter = new Linter(); 10 | linter.lint('./test.styl', wrongContent); 11 | 12 | const response = linter.reporter.response; 13 | 14 | expect(response.passed).to.be.false; 15 | expect(response.errors && response.errors.length).to.be.equal(3); 16 | }); 17 | 18 | describe('Empty file test', () => { 19 | it('should work fine', () => { 20 | const linter = new Linter(); 21 | linter.lint('./test.styl', ''); 22 | 23 | const response = linter.reporter.response; 24 | 25 | expect(response.passed).to.be.true; 26 | expect(response.errors).to.be.equal(void(0)); 27 | }); 28 | }); 29 | 30 | describe('Broken content file test', () => { 31 | it('should work fine', () => { 32 | const linter = new Linter(); 33 | linter.lint('./test.styl', '.'); 34 | 35 | const response = linter.reporter.response; 36 | 37 | expect(response.passed).to.be.false; 38 | expect(response.errors && response.errors.length).to.be.equal(1); 39 | }); 40 | 41 | describe('Broken content 2', () => { 42 | it('should work fine', () => { 43 | const linter = new Linter(); 44 | linter.lint('./test.styl', '.t'); 45 | 46 | const response = linter.reporter.response; 47 | 48 | expect(response.passed).to.be.false; 49 | expect(response.errors && response.errors.length).to.be.equal(1); 50 | }); 51 | }); 52 | }); 53 | describe('Try Syntax error', () => { 54 | describe('Get hash field', () => { 55 | it('should not return error', () => { 56 | const 57 | linter = new Linter(); 58 | 59 | linter.lint('./test.styl', '$p = {\n' + 60 | '\toptionColor: #CCC\n' + 61 | '}\n' + 62 | '.test\n' + 63 | '\tmargin-top $p.optionColor' 64 | ); 65 | 66 | const response = linter.reporter.response; 67 | 68 | expect(response.passed).to.be.true; 69 | }); 70 | }); 71 | describe('Hash field with index', () => { 72 | it('should not return error', () => { 73 | const 74 | linter = new Linter(); 75 | 76 | linter.lint('./test.styl', '$colors = {\n' + 77 | '\twhite: #CCC #FFF #F00\n' + 78 | '}\n' + 79 | '$p = {\n' + 80 | '\toptionColor: $colors.white[0]\n' + 81 | '}\n' + 82 | '.b-checkbox-list\n' + 83 | '\tcolor $colors.white[1]' 84 | ); 85 | 86 | const response = linter.reporter.response; 87 | 88 | expect(response.passed).to.be.true; 89 | }); 90 | }); 91 | describe('sss', () => { 92 | it('sss', () => { 93 | const 94 | linter = new Linter(); 95 | linter.lint('./test.styl'); 96 | }); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/rules/brackets.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../core/rule'; 2 | import { ILine } from '../core/types/line'; 3 | import { splitAndStrip, checkPrefix } from '../core/helpers/index'; 4 | 5 | const 6 | ignoreRe = /\(.*\)|@extend|\(|if|for(?!\w)|else|return|@block|@media|@import|@require|,$/, 7 | stripRe = /(?=\S)\[\S+]|([.#])\w+/, 8 | equalsRe = /( =|\?=|\+=|-=)+/, 9 | validJSON: ValidCSS = require('../data/valid.json'); 10 | 11 | /** 12 | * Check for brackets 13 | */ 14 | export class Brackets extends Rule { 15 | checkLine(line: ILine): void | boolean { 16 | if (this.context.inHash || line.isEmpty() || equalsRe.test(line.line) || ignoreRe.test(line.line)) { 17 | return; 18 | } 19 | 20 | let 21 | arr = ['hint'], 22 | isCSS = false, 23 | isMixin = false, 24 | bracket: false | number = false; 25 | 26 | if (this.state.conf === 'never') { 27 | if (line.line.indexOf('{') !== -1 && line.line.indexOf('=') === -1 && line.line.indexOf('}') === -1) { 28 | bracket = line.line.indexOf('{'); 29 | } else if (line.line.indexOf('{') === -1 && line.line.indexOf('}') !== -1) { 30 | bracket = line.line.indexOf('}'); 31 | } 32 | 33 | } else if (this.state.conf === 'always') { 34 | 35 | arr = splitAndStrip(new RegExp(/[\s\t,:]/), line.line); 36 | 37 | if (typeof arr[0] !== 'undefined') { 38 | arr[0] = arr[0].replace(stripRe, '').trim(); 39 | 40 | isCSS = validJSON.css.some((css) => arr[0] === css || checkPrefix(arr[0], css, validJSON)); 41 | 42 | isMixin = this.config.customProperties.some((mixin) => arr[0] === mixin); 43 | } 44 | 45 | // basically, we don't care about properties like margin or padding 46 | if (line.line.trim().indexOf('}') !== -1 || isCSS || isMixin) { 47 | return; 48 | } 49 | 50 | if (line.line.indexOf('{') !== -1) { 51 | bracket = line.line.indexOf('{'); 52 | this.context.openBracket = true; 53 | } else if (line.line.indexOf('}') !== -1 && this.context.openBracket) { 54 | bracket = line.line.indexOf('}'); 55 | this.context.openBracket = false; 56 | } 57 | } 58 | 59 | if (this.state.conf === 'never' && bracket !== false) { 60 | this.msg('unnecessary bracket', line.lineno, bracket + 1, line.lineno, ''); 61 | 62 | } else if (this.state.conf === 'always' && bracket === false) { 63 | this.msg('always use brackets when defining selectors', 64 | line.lineno, 65 | line.line.length, 66 | line.lineno, 67 | line.line[line.line.length - 1] + (this.context.openBracket ? '}' : ' {') 68 | ); 69 | 70 | if (!this.context.openBracket) { 71 | this.context.openBracket = true; 72 | } 73 | } 74 | 75 | if (this.state.conf === 'always' && line.isLast() && this.context.openBracket) { 76 | this.msg('need close bracket', 77 | line.lineno, 78 | line.line.length, 79 | line.lineno, 80 | line.line[line.line.length - 1] + '\n}' 81 | ); 82 | } 83 | 84 | return bracket !== false; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/rules/depthControl.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../core/rule'; 2 | import { IState } from '../core/types/state'; 3 | import { Block, Selector, Property, Obj, Ident, Node, Media, Condition } from '../core/ast/index'; 4 | import { INode } from '../core/types/ast/node'; 5 | 6 | interface IDepthControlState extends IState { 7 | indentPref?: 'tab' | number 8 | } 9 | 10 | /** 11 | * Control depth spaces or tab 12 | */ 13 | export class DepthControl extends Rule { 14 | 15 | nodesFilter: string[] = ['block', 'selector', 'obj']; 16 | 17 | checkNode(node: Block | Selector | Obj): void { 18 | const 19 | indentPref: number = typeof this.state.indentPref === 'number' ? this.state.indentPref : 1; 20 | 21 | if (node instanceof Block || node instanceof Selector) { 22 | let 23 | parentNode: INode | null = node.closest('selector|media|condition|keyframes|func'), 24 | needCheckPreviousSelector = false, 25 | prev: INode | null = parentNode; 26 | 27 | if (parentNode && parentNode instanceof Selector) { 28 | while (prev && parentNode) { 29 | prev = prev.previousSibling(); 30 | 31 | if (prev && prev instanceof Selector && prev.lineno === parentNode.lineno) { 32 | parentNode = prev; 33 | } else { 34 | break; 35 | } 36 | } 37 | } 38 | 39 | if (parentNode) { 40 | if (node instanceof Block) { 41 | node.nodes.forEach((child) => { 42 | if (child.line && child.line.isIgnored) { 43 | return; 44 | } 45 | 46 | if ( 47 | parentNode && 48 | ( 49 | child instanceof Property || 50 | child instanceof Media || 51 | child instanceof Condition 52 | ) && 53 | child.column - indentPref !== parentNode.column 54 | ) { 55 | this.msg('incorrect indent', child.lineno, 1, child.column); 56 | } 57 | }); 58 | } else if (node.column - indentPref !== parentNode.column) { 59 | needCheckPreviousSelector = true; 60 | } 61 | } else if (node instanceof Selector && node.column !== 1) { 62 | needCheckPreviousSelector = true; 63 | } 64 | 65 | if (needCheckPreviousSelector) { 66 | prev = node.previousSibling(); 67 | 68 | if (!prev || prev.lineno !== node.lineno) { 69 | this.msg('incorrect indent', node.lineno, 1, node.column); 70 | } 71 | } 72 | 73 | return; 74 | } 75 | 76 | if (node instanceof Obj) { 77 | const 78 | key: Ident | Property | null = node.closest('ident|property'); 79 | 80 | if (key) { 81 | const parentColumn = (key instanceof Property && key.key instanceof Ident) ? key.key.column : key.column; 82 | 83 | node.nodes.forEach((child) => { 84 | if (child.line && child.line.isIgnored) { 85 | return; 86 | } 87 | 88 | if (child instanceof Property && child.key instanceof Ident && child.key.column - indentPref !== parentColumn) { 89 | this.msg('incorrect indent', child.key.lineno, 1, child.key.column); 90 | } 91 | }); 92 | } 93 | 94 | return; 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/core/reporter.ts: -------------------------------------------------------------------------------- 1 | import { IReporter } from './types/reporter'; 2 | import { IMessagePack } from './types/message'; 3 | import { IResponse } from './types/response'; 4 | import { inspect } from 'util'; 5 | 6 | export abstract class Reporter implements IReporter { 7 | 8 | static getInstance(type: string, config: Dictionary): IReporter { 9 | switch (type) { 10 | case 'json': 11 | return new (require('./reporters/jsonReporter').JsonReporter)(config); 12 | 13 | case 'silent': 14 | return new (require('./reporters/silentReporter').SilentReporter)(config); 15 | 16 | default: 17 | return new (require('./reporters/rawReporter').RawReporter)(config); 18 | } 19 | } 20 | 21 | errors: IMessagePack[] = []; 22 | 23 | response: IResponse = { 24 | passed: true 25 | }; 26 | 27 | private path: string = ''; 28 | 29 | protected constructor(readonly options: Dictionary) { 30 | } 31 | 32 | /** 33 | * Set current working file 34 | * @param path 35 | */ 36 | setPath(path: string): void { 37 | this.path = path; 38 | } 39 | 40 | /** 41 | * Add new error in message pull 42 | * @param rule 43 | * @param message 44 | * @param line 45 | * @param start 46 | * @param end 47 | * @param fix 48 | * @param endLine 49 | */ 50 | add( 51 | rule: string, 52 | message: string, 53 | line: number = 1, 54 | start: number = 1, 55 | end: number = 1, 56 | fix: string | null = null, 57 | endLine: number = line 58 | ): void { 59 | this.errors.push({ 60 | message: [{ 61 | rule, 62 | descr: message, 63 | path: this.path, 64 | line, 65 | endline: endLine >= line ? endLine : line, 66 | start, 67 | end: end > start ? end : start, 68 | fix: (fix !== undefined && fix !== null) ? { 69 | replace: fix.toString() 70 | } : null 71 | }] 72 | }); 73 | } 74 | 75 | /** 76 | * Output data some methods 77 | */ 78 | abstract log(): void; 79 | 80 | /** 81 | * Fill response object 82 | */ 83 | fillResponse(): void { 84 | this.response.passed = !this.errors.length; 85 | this.response.errors = this.errors.length ? this.errors : undefined; 86 | } 87 | 88 | /** 89 | * Prepare data and output result 90 | * @param exit 91 | */ 92 | display(exit: boolean): void { 93 | this.fillResponse(); 94 | this.log(); 95 | this.reset(); 96 | 97 | if (exit) { 98 | process.exit(this.response.passed ? 0 : 1); 99 | } 100 | } 101 | 102 | /** 103 | * Reset all error stores 104 | */ 105 | reset(): void { 106 | this.errors.length = 0; 107 | this.response = { 108 | passed: true 109 | }; 110 | } 111 | 112 | /** 113 | * Filter messages 114 | * @param grep 115 | */ 116 | filterErrors(grep: string): void { 117 | this.errors = this.errors.filter( 118 | (error) => { 119 | error.message = error.message.filter((msg) => !!msg.descr.match(grep) || !!msg.rule.match(grep)); 120 | 121 | return error.message.length; 122 | } 123 | ); 124 | } 125 | } 126 | 127 | export const log = (val: any) => console.log(inspect(val, { 128 | depth: 10 129 | })); 130 | -------------------------------------------------------------------------------- /src/core/documentator/documentator.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../../config'; 2 | import { Glob } from 'glob'; 3 | import { readFileSync } from 'fs'; 4 | import { readmePatcher } from './readmePatcher'; 5 | // @ts-ignore 6 | import * as ts from 'typescript'; 7 | import { lcfirst } from '../helpers/lcfirst'; 8 | import { State } from '../types/state'; 9 | 10 | /** 11 | * Visit all ts nodes 12 | * 13 | * @param callback 14 | * @param node 15 | */ 16 | function visit(callback: (node: ts.Node) => void, node: ts.Node): void { 17 | callback(node); 18 | 19 | ts.forEachChild(node, visit.bind(null, callback)); 20 | } 21 | 22 | export interface RuleDocs { 23 | name: string; 24 | description: string; 25 | default: State; 26 | } 27 | 28 | export class Documentator { 29 | private config: Config; 30 | 31 | constructor(options: Dictionary) { 32 | this.config = new Config(options); 33 | } 34 | 35 | /** 36 | * Generate documentation 37 | */ 38 | generate(): void { 39 | switch (this.config.doc) { 40 | default: 41 | this.generateRules(); 42 | } 43 | } 44 | 45 | /** 46 | * Generate rules docs 47 | */ 48 | private generateRules(): void { 49 | const 50 | result: RuleDocs[] = []; 51 | 52 | new Glob('./src/rules/*.ts', {}, async (err: Error | null, files: string[]) => { 53 | if (err) { 54 | throw err; 55 | } 56 | 57 | files.forEach(async (file) => { 58 | const match = /\/(\w+)\.ts/.exec(file); 59 | 60 | if (match) { 61 | const rule = match[1]; 62 | if (rule !== 'index') { 63 | 64 | const sourceFile = ts.createSourceFile( 65 | file, 66 | readFileSync(file).toString(), 67 | ts.ScriptTarget.ES2018, 68 | /*setParentNodes */ true 69 | ); 70 | 71 | visit((node) => { 72 | switch (node.kind) { 73 | case ts.SyntaxKind.ClassDeclaration: { 74 | const 75 | name = lcfirst(node.name.escapedText); 76 | 77 | let 78 | description = (node.jsDoc && node.jsDoc[0]) ? node.jsDoc[0].comment : ''; 79 | 80 | description = description 81 | .replace(/\t/g, ' ') 82 | .replace(/(```stylus)(.*)(```)/s, (...match: string[]) => { 83 | match[2] = match[2] 84 | .split('\n') 85 | .map((line) => 86 | line 87 | .replace(/^[ \t]+\*/g, '') 88 | .replace(/^ /g, '') 89 | ) 90 | .join('\n'); 91 | return `${match[1]}${match[2]}${match[3]}`; 92 | }); 93 | 94 | result.push({ 95 | name, 96 | description, 97 | default: this.config.defaultRules[name] 98 | }); 99 | } 100 | } 101 | }, sourceFile); 102 | } 103 | } 104 | }); 105 | 106 | if (!this.config.fix) { 107 | return this.log(result); 108 | } 109 | 110 | readmePatcher(result); 111 | }); 112 | } 113 | 114 | /** 115 | * 116 | * @param data 117 | */ 118 | private log(data: object): void { 119 | console.log(JSON.stringify(data)); 120 | process.exit(); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/core/reader.ts: -------------------------------------------------------------------------------- 1 | import { Glob } from 'glob'; 2 | import { relative } from 'path'; 3 | import { map } from 'async'; 4 | import { readFile, stat } from 'fs'; 5 | import { IConfig } from './types/config'; 6 | 7 | type ReaderCallback = (file: string, content: string) => void; 8 | 9 | export class Reader { 10 | constructor(readonly config: IConfig) { 11 | } 12 | 13 | /** 14 | * Check `dir` parameter for folder or file call `readFolder` or `readFiles` 15 | * 16 | * @param dir 17 | * @param callback 18 | * @return Promise 19 | */ 20 | read(dir: string | string[], callback: ReaderCallback): Promise { 21 | return new Promise(async (resolve) => { 22 | if (typeof dir !== 'string' && !(dir instanceof Array)) { 23 | throw new TypeError('getFiles err. Expected string or array, but received: ' + typeof dir); 24 | } 25 | 26 | if (typeof dir === 'string') { 27 | if (dir === process.cwd()) { 28 | dir = dir + '/**/*.styl'; 29 | await this.readFolder(dir, callback); 30 | resolve(); 31 | return; 32 | } 33 | 34 | return stat(dir, async (err, stats) => { 35 | if (!stats || err) { 36 | throw Error('Stlint Error: No such file or dir exists!'); 37 | } 38 | 39 | if (stats.isFile()) { 40 | await this.readFiles([dir.toString()], callback); 41 | } else if (stats.isDirectory()) { 42 | await this.readFolder(dir.toString() + '/**/*.styl', callback); 43 | } 44 | 45 | resolve(); 46 | }); 47 | } 48 | 49 | return Promise.all(dir.map((path) => this.read(path, callback))); 50 | }); 51 | } 52 | 53 | /** 54 | * Find all 'styl' files in the directory and call `readFiles` 55 | * 56 | * @param dir 57 | * @param callback 58 | * @return Promise 59 | */ 60 | readFolder(dir: string, callback: ReaderCallback): Promise { 61 | return new Promise((resolve) => 62 | new Glob(dir, {}, async (err: Error | null, files: string[]) => { 63 | if (err) { 64 | throw err; 65 | } 66 | 67 | if (this.config.excludes && this.config.excludes.length) { 68 | files = files.filter((file) => { 69 | const 70 | relPath = relative(dir.replace('/**/*.styl', ''), file); 71 | 72 | return !this.config.excludes.some((exclude) => { 73 | const reg = new RegExp(exclude); 74 | 75 | return reg.test(relPath); 76 | }); 77 | }); 78 | } 79 | 80 | await this.readFiles(files, callback); 81 | 82 | resolve(); 83 | })); 84 | } 85 | 86 | /** 87 | * Read all files from array and call ReaderCallback 88 | * 89 | * @param files 90 | * @param callback 91 | * @return Promise 92 | */ 93 | readFiles(files: string[], callback: ReaderCallback): Promise { 94 | return new Promise((resolve) => { 95 | map(files, readFile, (error: Error | null | void, buffer: (Buffer | void)[] | void) => { 96 | if (error) { 97 | throw error; 98 | } 99 | 100 | if (buffer) { 101 | buffer.forEach(async (bf, index) => { 102 | bf && await callback(files[index], bf.toString()); 103 | 104 | if (index === files.length - 1) { 105 | resolve(); 106 | } 107 | }); 108 | } 109 | }); 110 | }); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/core/baseConfig.ts: -------------------------------------------------------------------------------- 1 | import { isPlainObject, mergeArray } from './helpers/index'; 2 | import { existsSync, readFileSync, statSync } from 'fs'; 3 | import stripJsonComments = require('strip-json-comments'); 4 | import { resolve, dirname, extname } from 'path'; 5 | import { IStats } from './types/IStats'; 6 | import _require = require('native-require'); 7 | 8 | export class BaseConfig { 9 | configName: string = '.stlintrc'; 10 | configFile: string = ''; 11 | 12 | extraRules: string | string[] = ''; 13 | 14 | preprocessors: string[] = []; 15 | autocompletes: string[] = []; 16 | 17 | /** 18 | * Wrapper for path.statSync 19 | * @param path 20 | */ 21 | statSync(path: string): IStats { 22 | return statSync(path); 23 | } 24 | 25 | /** 26 | * Read some file format 27 | * @param configFile 28 | */ 29 | readFile(configFile: string): Dictionary { 30 | const ext = extname(configFile) || ''; 31 | 32 | try { 33 | switch (ext.toLowerCase()) { 34 | case '.js': 35 | return _require(configFile); 36 | default: 37 | return this.readJSONFile(configFile); 38 | } 39 | } catch { 40 | } 41 | 42 | return {}; 43 | } 44 | 45 | /** 46 | * Read JSON File 47 | */ 48 | readJSONFile(configFile: string): Dictionary { 49 | if (existsSync(configFile)) { 50 | try { 51 | return JSON.parse(stripJsonComments(readFileSync(configFile, 'utf8'))); 52 | } catch { 53 | } 54 | } 55 | 56 | return {}; 57 | } 58 | 59 | /** 60 | * Try read config file .stlintrc 61 | */ 62 | applyConfig(configFile: string, customConfig: Dictionary): void { 63 | const 64 | dir = dirname(configFile), 65 | normalizePath = (extra: string): string => resolve(dir, extra); 66 | 67 | if (customConfig.extraRules) { 68 | customConfig.extraRules = Array.isArray(customConfig.extraRules) ? 69 | customConfig.extraRules.map(normalizePath) : normalizePath(customConfig.extraRules); 70 | } 71 | 72 | if (customConfig.preprocessors) { 73 | customConfig.preprocessors = customConfig.preprocessors.map(normalizePath); 74 | } 75 | 76 | if (customConfig.autocompletes) { 77 | customConfig.autocompletes = customConfig.autocompletes.map(normalizePath); 78 | } 79 | 80 | this.extendsOption(customConfig, this); 81 | } 82 | 83 | /** 84 | * Extends second object from first 85 | * 86 | * @param from 87 | * @param to 88 | */ 89 | extendsOption(from: Dictionary, to: Dictionary): Dictionary { 90 | const result: Dictionary = to; 91 | 92 | Object.keys(from).forEach((key) => { 93 | if (isPlainObject(from[key]) && isPlainObject(to[key])) { 94 | result[key] = {...this.extendsOption(from[key], {...to[key]})}; 95 | 96 | } else if (Array.isArray(from[key]) && Array.isArray(to[key])) { 97 | result[key] = mergeArray(to[key], from[key]); 98 | 99 | } else { 100 | result[key] = from[key]; 101 | } 102 | }); 103 | 104 | return result; 105 | } 106 | 107 | /** 108 | * Load extra config files 109 | */ 110 | extendsByPath(pathOrPackage: string): void { 111 | const 112 | path: string = /^\./.test(pathOrPackage) ? 113 | resolve(process.cwd(), pathOrPackage) : resolve(process.cwd(), 'node_modules', pathOrPackage), 114 | stat = this.statSync(path); 115 | 116 | const 117 | file = stat.isFile() ? path : resolve(path, this.configName); 118 | 119 | this.applyConfig(file, this.readFile(file)); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /tests/rules/prefixVarsWithDollarTest.ts: -------------------------------------------------------------------------------- 1 | import { PrefixVarsWithDollar } from '../../src/rules/index'; 2 | import { parseAndRun } from '../staff/bootstrap'; 3 | import { expect } from 'chai'; 4 | 5 | const withDollar = '$test = #ccc\n' + 6 | '$p = {}\n' + 7 | '.tab\n' + 8 | '\tposition absolte\n' + 9 | '\tmargin 10px\n' + 10 | '\tbackground-color $p.color\n' + 11 | ''; 12 | 13 | const withoutDollar = 'test = #ccc\n' + 14 | 'p = {}\n' + 15 | '.tab\n' + 16 | '\tposition absolte\n' + 17 | '\tmargin 10px\n' + 18 | '\tbackground-color $p.color\n' + 19 | ''; 20 | 21 | describe('Test prefixVarsWithDollar rule', () => { 22 | describe('For mixins', () => { 23 | it('should not doing check', () => { 24 | const rule = new PrefixVarsWithDollar({ 25 | conf: 'always', 26 | prefix: '$' 27 | }); 28 | 29 | parseAndRun('$c = #ccc\n' + 30 | 'app()\n' + 31 | '\tcolor #ccc\n' + 32 | '\n' + 33 | '.test\n' + 34 | '\tfont-size 10px\n' + 35 | '\tapp()\n', 36 | rule); 37 | 38 | expect(rule.errors.length).to.be.equal(0); 39 | }); 40 | }); 41 | describe('Always', () => { 42 | it('should check variable name and set error if name is not starting with dollar', () => { 43 | const rule = new PrefixVarsWithDollar({ 44 | conf: 'always', 45 | prefix: '$' 46 | }); 47 | 48 | parseAndRun(withoutDollar, rule); 49 | 50 | expect(rule.errors.length).to.be.equal(2); 51 | }); 52 | it('should check variable name and not set error if name is starting with dollar', () => { 53 | const rule = new PrefixVarsWithDollar({ 54 | conf: 'always', 55 | prefix: '$' 56 | }); 57 | 58 | parseAndRun(withDollar, rule); 59 | 60 | expect(rule.errors.length).to.be.equal(0); 61 | }); 62 | }); 63 | describe('Never', () => { 64 | it('should check variable name and set error if name is starting with dollar', () => { 65 | const rule = new PrefixVarsWithDollar({ 66 | conf: 'never', 67 | prefix: '$' 68 | }); 69 | 70 | parseAndRun(withDollar, rule); 71 | 72 | expect(rule.errors.length).to.be.equal(2); 73 | }); 74 | it('should check variable name and not set error if name is not starting with dollar', () => { 75 | const rule = new PrefixVarsWithDollar({ 76 | conf: 'never', 77 | prefix: '$' 78 | }); 79 | 80 | parseAndRun(withoutDollar, rule); 81 | 82 | expect(rule.errors.length).to.be.equal(0); 83 | }); 84 | }); 85 | describe('Check another prefix', () => { 86 | it('should check variable name and set error if name is not starting with this prefix', () => { 87 | const rule = new PrefixVarsWithDollar({ 88 | conf: 'always', 89 | prefix: '_' 90 | }); 91 | 92 | parseAndRun('$p = #ccc', rule); 93 | 94 | expect(rule.errors.length).to.be.equal(1); 95 | }); 96 | describe('Without error', () => { 97 | it('should check variable name and set error if name is not starting with this prefix', () => { 98 | const rule = new PrefixVarsWithDollar({ 99 | conf: 'always', 100 | prefix: '_' 101 | }); 102 | 103 | parseAndRun('_p = #ccc', rule); 104 | 105 | expect(rule.errors.length).to.be.equal(0); 106 | }); 107 | }); 108 | describe('Allow constant', () => { 109 | it('should allow use variable in uppercase', () => { 110 | const rule = new PrefixVarsWithDollar({ 111 | conf: 'always', 112 | prefix: '$', 113 | allowConst: true 114 | }); 115 | 116 | parseAndRun('COLOR_GRAY = #ccc', rule); 117 | 118 | expect(rule.errors.length).to.be.equal(0); 119 | }); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /tests/rules/useMixinInsteadUnitTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { useMixinInsteadUnit } from '../../src/rules/index'; 3 | import { parseAndRun } from '../staff/bootstrap'; 4 | 5 | describe('Use basis mixin test', () => { 6 | describe('Need replace px unit to basis(*)', () => { 7 | it('Should check the AST has Unit node with wrong unit notation', () => { 8 | 9 | const rule = new useMixinInsteadUnit({ 10 | conf: 'always', 11 | mixin: 'basis', 12 | unitType: 'px' 13 | }); 14 | 15 | parseAndRun('.tab\n\tfontsize 12px', rule); 16 | 17 | expect(rule.errors.length).to.be.equal(1); 18 | expect(rule.errors[0][1]).to.be.equal('Use "basis" mixin instead "px"'); 19 | expect(rule.errors[0][5]).to.be.equal('basis(1.5)'); 20 | }); 21 | describe('Deny use some mixin', () => { 22 | it('Should check the AST has Unit node with wrong unit notation but no need replace', () => { 23 | 24 | const rule = new useMixinInsteadUnit({ 25 | conf: 'never', 26 | mixin: 'basis', 27 | unitType: 'px' 28 | }); 29 | 30 | parseAndRun('.tab\n\tfontsize 12px', rule); 31 | 32 | expect(rule.errors.length).to.be.equal(0); 33 | }); 34 | it('Should check the AST has ? node with wrong "basis" notation', () => { 35 | 36 | const rule = new useMixinInsteadUnit({ 37 | conf: 'never', 38 | mixin: 'basis', 39 | unitType: 'px' 40 | }); 41 | 42 | parseAndRun('.tab\n\tfontsize basis(1.5)', rule); 43 | 44 | expect(rule.errors.length).to.be.equal(1); 45 | expect(rule.errors[0][1]).to.be.equal('Do not use "basis" mixin'); 46 | }); 47 | describe('Another mixin name', () => { 48 | it('Should check the AST has ? node with wrong "MixinName" notation', () => { 49 | 50 | const rule = new useMixinInsteadUnit({ 51 | conf: 'never', 52 | mixin: 'MixinName', 53 | unitType: 'px' 54 | }); 55 | 56 | parseAndRun('.tab\n\tfontsize MixinName(1.5)', rule); 57 | 58 | expect(rule.errors.length).to.be.equal(1); 59 | expect(rule.errors[0][1]).to.be.equal('Do not use "MixinName" mixin'); 60 | }); 61 | }); 62 | }); 63 | describe('One unit', () => { 64 | describe('Enable', () => { 65 | it('Should check the AST has Unit node with wrong unit notation but ignore it', () => { 66 | 67 | const rule = new useMixinInsteadUnit({ 68 | conf: 'always', 69 | mixin: 'basis', 70 | unitType: 'px', 71 | allowOneUnit: true 72 | }); 73 | 74 | parseAndRun('.tab\n\tfontsize 1px', rule); 75 | 76 | expect(rule.errors.length).to.be.equal(0); 77 | }); 78 | }); 79 | describe('Disable', () => { 80 | it('Should check the AST has Unit node with wrong unit notation', () => { 81 | 82 | const rule = new useMixinInsteadUnit({ 83 | conf: 'always', 84 | mixin: 'basis', 85 | unitType: 'px', 86 | allowOneUnit: false 87 | }); 88 | 89 | parseAndRun('.tab\n\tfontsize 1px', rule); 90 | 91 | expect(rule.errors.length).to.be.equal(1); 92 | expect(rule.errors[0][1]).to.be.equal('Use "basis" mixin instead "px"'); 93 | expect(rule.errors[0][5]).to.be.equal('basis(0.125)'); 94 | }); 95 | }); 96 | }); 97 | }); 98 | describe('Use another unit type', () => { 99 | it('Should check the AST has Unit node with wrong unit notation', () => { 100 | 101 | const rule = new useMixinInsteadUnit({ 102 | conf: 'always', 103 | mixin: 'percent', 104 | unitType: 'em' 105 | }); 106 | 107 | parseAndRun('.tab\n\twidth 1em', rule); 108 | 109 | expect(rule.errors.length).to.be.equal(1); 110 | expect(rule.errors[0][1]).to.be.equal('Use "percent" mixin instead "em"'); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/core/ast/node.ts: -------------------------------------------------------------------------------- 1 | import { INode } from '../types/ast/node'; 2 | import { ISNode } from '../types/ast/snode'; 3 | import { Line } from '../line'; 4 | import { IContent } from '../types/content'; 5 | 6 | export class Node implements INode { 7 | 8 | get nodeName(): string { 9 | return this.constructor.name.toLowerCase(); 10 | } 11 | 12 | parent: INode | null = null; 13 | lineno: number = 0; 14 | column: number = 0; 15 | nodes: INode[] = []; 16 | segments: INode[] = []; 17 | source: ISNode | null = null; 18 | 19 | key: string | INode = ''; 20 | 21 | /** 22 | * Content 23 | */ 24 | content: IContent | null = null; 25 | 26 | /** 27 | * Get line object 28 | */ 29 | get line(): Line | null { 30 | return (this.content && this.lineno && this.content.getLine(this.lineno)) || null; 31 | } 32 | 33 | value: string | INode | null = ''; 34 | 35 | constructor(block: ISNode, parent: INode | null) { 36 | this.lineno = block.lineno; 37 | this.column = block.column; 38 | this.source = block; 39 | this.parent = parent; 40 | } 41 | 42 | append(node: INode, listField: keyof T = 'nodes'): void { 43 | const list = (this)[listField]; 44 | 45 | if (list && Array.isArray(list) && node instanceof Node) { 46 | list.push(node); 47 | } 48 | 49 | node.parent = this; 50 | } 51 | 52 | /** 53 | * Use stylus source 54 | */ 55 | toString(): string { 56 | if (this.source) { 57 | return this.source.toString(); 58 | } 59 | 60 | return this.value ? this.value.toString() : ' '; 61 | } 62 | 63 | getSibling(next: boolean = false): null | INode { 64 | if (this.parent && this.parent.nodes.length) { 65 | const index = this.parent.nodes.indexOf(this); 66 | 67 | if (index !== -1 && ((!next && index > 0) || (next && index < this.parent.nodes.length - 2))) { 68 | return (this.parent.nodes[index + (next ? 1 : -1)]) || null; 69 | } 70 | } 71 | 72 | return null; 73 | } 74 | 75 | /** 76 | * Get previous node in parent.nodes 77 | */ 78 | previousSibling(): null | INode { 79 | return this.getSibling(); 80 | } 81 | 82 | /** 83 | * Get next node in parent.nodes 84 | */ 85 | nextSibling(): null | INode { 86 | return this.getSibling(true); 87 | } 88 | 89 | /** 90 | * Get matched parent 91 | * @param parentClass 92 | */ 93 | closest(parentClass: string): null | T { 94 | const 95 | reg = RegExp(`^(${parentClass})$`, 'i'); 96 | 97 | let 98 | node = this.parent; 99 | 100 | while (node) { 101 | if (reg.test(node.nodeName)) { 102 | return node; 103 | } 104 | 105 | node = node.parent; 106 | } 107 | 108 | return null; 109 | } 110 | 111 | getChild(findClass?: string, last: boolean | undefined = false): null | INode { 112 | let 113 | node: INode | null | void = this.nodes[last ? this.nodes.length - 1 : 0]; 114 | 115 | if (findClass === undefined) { 116 | return node || null; 117 | } 118 | 119 | if (node) { 120 | const 121 | reg = RegExp(`^(${findClass})$`, 'i'); 122 | 123 | while (node) { 124 | if (reg.test(node.nodeName)) { 125 | return node; 126 | } 127 | 128 | node = (last ? node.previousSibling() : node.nextSibling()); 129 | } 130 | } 131 | 132 | return null; 133 | } 134 | 135 | /** 136 | * Get first matched child 137 | * @param findClass 138 | */ 139 | lastChild(findClass?: string): null | T { 140 | return this.getChild(findClass, true); 141 | } 142 | 143 | /** 144 | * Get last matched child 145 | * @param findClass 146 | */ 147 | firstChild(findClass?: string): null | T { 148 | return this.getChild(findClass, false); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /tests/extendsConfigTest.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../src/config'; 2 | import { expect } from 'chai'; 3 | import { IState } from '../src/core/types/state'; 4 | 5 | describe('Test extends option', () => { 6 | const tmp: Dictionary = {}; 7 | 8 | beforeEach(() => { 9 | tmp.readJSONFile = Config.prototype.readJSONFile; 10 | tmp.statSync = Config.prototype.statSync; 11 | }); 12 | afterEach(() => { 13 | Config.prototype.readJSONFile = tmp.readJSONFile; 14 | Config.prototype.statSync = tmp.statSync; 15 | }); 16 | 17 | describe('Define `extends` options', () => { 18 | describe('like filepath', () => { 19 | it('Should load config from `extends` and redefine some values from it config', () => { 20 | Config.prototype.readJSONFile = (path) => 21 | path.match('testrc') ? { 22 | rules: { 23 | color: false, 24 | colons: false 25 | } 26 | } : {}; 27 | 28 | Config.prototype.statSync = () => ({ 29 | isDirectory(): boolean { 30 | return false; 31 | }, 32 | isFile(): boolean { 33 | return true; 34 | } 35 | }); 36 | 37 | const config = new Config({ 38 | rules: { 39 | color: true 40 | }, 41 | extends: './tests/.testrc' 42 | }); 43 | 44 | expect((config.rules.color)).to.be.true; // redefine 45 | expect((config.rules.colons)).to.be.false; // from extends config 46 | expect((config.rules.prefixVarsWithDollar)).to.be.deep.equal({ 47 | allowConst: true, 48 | conf: 'always', 49 | prefix: '$' 50 | }); // default option 51 | 52 | }); 53 | }); 54 | describe('like array of filepath', () => { 55 | it('Should load all configs from `extends`', () => { 56 | Config.prototype.readJSONFile = (path) => { 57 | switch (true) { 58 | case !!path.match('testrA'): 59 | return { 60 | rules: { 61 | color: { 62 | conf: 'lowercase' 63 | } 64 | } 65 | }; 66 | case !!path.match('testrB'): 67 | return { 68 | rules: { 69 | color: { 70 | conf: 'test' 71 | } 72 | } 73 | }; 74 | } 75 | 76 | return {}; 77 | }; 78 | 79 | Config.prototype.statSync = () => ({ 80 | isDirectory(): boolean { 81 | return false; 82 | }, 83 | isFile(): boolean { 84 | return true; 85 | } 86 | }); 87 | 88 | const config = new Config({ 89 | extends: [ 90 | './tests/.testrA', 91 | './tests/.testrB' 92 | ] 93 | }); 94 | 95 | expect((config.rules.color).conf).to.be.equal('test'); // redefine 96 | expect((config.rules.prefixVarsWithDollar)).to.be.deep.equal({ 97 | allowConst: true, 98 | conf: 'always', 99 | prefix: '$' 100 | }); // default option 101 | 102 | }); 103 | }); 104 | describe('like array of packages', () => { 105 | it('Should load all configs from packages root from node_modules folder', () => { 106 | Config.prototype.readJSONFile = (path) => { 107 | path = path.replace(process.cwd(), ''); 108 | 109 | switch (path) { 110 | case '/node_modules/stlint-a': 111 | return { 112 | rules: { 113 | color: { 114 | conf: 'lowercase' 115 | } 116 | } 117 | }; 118 | case '/node_modules/stlint-b': 119 | return { 120 | rules: { 121 | color: { 122 | conf: 'test' 123 | } 124 | } 125 | }; 126 | } 127 | 128 | return {}; 129 | }; 130 | 131 | Config.prototype.statSync = () => ({ 132 | isDirectory(): boolean { 133 | return false; 134 | }, 135 | isFile(): boolean { 136 | return true; 137 | } 138 | }); 139 | 140 | const config = new Config({ 141 | extends: [ 142 | 'stlint-a', 143 | 'stlint-b' 144 | ] 145 | }); 146 | 147 | expect((config.rules.color).conf).to.be.equal('test'); // redefine 148 | expect((config.rules.prefixVarsWithDollar)).to.be.deep.equal({ 149 | conf: 'always', 150 | allowConst: true, 151 | prefix: '$' 152 | }); // default option 153 | 154 | }); 155 | }); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /src/core/rule.ts: -------------------------------------------------------------------------------- 1 | import { ErrorArray, IRule } from './types/rule'; 2 | import { IState } from './types/state'; 3 | import { lcfirst, objTohash, unwrapObject } from './helpers/index'; 4 | import { ILine } from './types/line'; 5 | import { IContext } from './types/context'; 6 | import { INode } from './types/ast/node'; 7 | import { Ident, Obj, Value } from './ast/index'; 8 | import { IConfig } from './types/config'; 9 | 10 | const initContext: () => IContext = () => ({ 11 | hashDeep: 0, 12 | inHash: false, 13 | inComment: false, 14 | openBracket: false, 15 | vars: {}, 16 | valueToVar: {} 17 | }); 18 | 19 | const 20 | hashStartRe = /\$?[\w]+\s*[=:]\s*{/, 21 | hashEndRe = /}/, 22 | startMultiComment = /\/\*/, 23 | endMultiComment = /\*\//; 24 | 25 | export class Rule implements IRule { 26 | config!: IConfig; 27 | setConfig(config: IConfig): void { 28 | this.config = config; 29 | } 30 | 31 | get context(): IContext { 32 | return Rule.context; 33 | } 34 | 35 | /** 36 | * Rule name 37 | */ 38 | get name(): string { 39 | return lcfirst(this.constructor.name); 40 | } 41 | 42 | static clearContext(): void { 43 | Rule.context = initContext(); 44 | } 45 | 46 | static getContext(): IContext { 47 | return Rule.context; 48 | } 49 | 50 | /** 51 | * 52 | * @param node 53 | */ 54 | static beforeCheckNode(node: INode): void { 55 | if (node instanceof Ident && node.value instanceof Value) { 56 | const isHash = node.value.nodes && node.value.nodes.length && node.value.nodes[0] instanceof Obj; 57 | 58 | this.context.vars[node.key] = isHash ? objTohash(node.value.nodes[0]) : node.value.nodes[0].toString(); 59 | 60 | this.context.valueToVar = {...this.context.valueToVar, ...unwrapObject(this.context.vars)}; 61 | } 62 | } 63 | 64 | /** 65 | * Check hash object etc 66 | * @param line 67 | */ 68 | static beforeCheckLine(line: ILine): void { 69 | if (hashStartRe.test(line.line)) { 70 | Rule.context.hashDeep += 1; 71 | } 72 | 73 | Rule.context.inHash = Rule.context.hashDeep > 0; 74 | 75 | if (Rule.context.hashDeep && hashEndRe.test(line.line)) { 76 | Rule.context.hashDeep -= 1; 77 | } 78 | 79 | if (startMultiComment.test(line.line)) { 80 | Rule.context.inComment = true; 81 | } 82 | 83 | if (Rule.context.inComment) { 84 | const prev = line.prev(); 85 | 86 | if (prev && endMultiComment.test(prev.line)) { 87 | Rule.context.inComment = false; 88 | } 89 | } 90 | } 91 | 92 | private static context: IContext = initContext(); 93 | 94 | nodesFilter: string[] | null = null; 95 | 96 | state: T = { 97 | conf: 'always', 98 | enabled: true 99 | }; 100 | 101 | cache: Dictionary = {}; 102 | 103 | hashErrors: Dictionary = {}; 104 | errors: ErrorArray[] = []; 105 | 106 | constructor(readonly conf?: T | false) { 107 | if (conf === undefined) { 108 | return; 109 | } 110 | 111 | if (typeof conf !== 'boolean') { 112 | if (Array.isArray(conf)) { 113 | this.state.conf = conf[0]; 114 | this.state.enabled = conf[1] === undefined || Boolean(conf[1]); 115 | } else { 116 | this.state = {...this.state, ...conf}; 117 | 118 | if (conf.enabled === undefined) { 119 | this.state.enabled = true; 120 | } 121 | } 122 | } else { 123 | this.state.enabled = conf; 124 | } 125 | } 126 | 127 | clearErrors(): void { 128 | this.errors.length = 0; 129 | this.hashErrors = {}; 130 | } 131 | 132 | clearContext(): void { 133 | Rule.clearContext(); 134 | } 135 | 136 | /** 137 | * Add error message in list 138 | * 139 | * @param message 140 | * @param line 141 | * @param start 142 | * @param end 143 | * @param fix 144 | * @param endLine 145 | */ 146 | msg( 147 | message: string, 148 | line: number = 1, 149 | start: number = 1, 150 | end: number = 1, 151 | fix: null | string = null, 152 | endLine: number = line 153 | ): void { 154 | const 155 | error: ErrorArray = [this.name, message, line, start, end, fix, endLine], 156 | hash = error.join('&'); 157 | 158 | if (!this.hashErrors[hash]) { 159 | this.hashErrors[hash] = true; 160 | this.errors.push(error); 161 | } 162 | } 163 | 164 | /** 165 | * Check type included in filter 166 | * @param type 167 | */ 168 | isMatchType(type: string): boolean { 169 | return !this.nodesFilter || this.nodesFilter.includes(type); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /tests/ignoreDirectivesTest.ts: -------------------------------------------------------------------------------- 1 | import { Linter } from '../src/linter'; 2 | import { expect } from 'chai'; 3 | import { IMessage, IMessagePack } from '../src/core/types/message'; 4 | 5 | describe('Test ignore directives', () => { 6 | describe('Multiline', () => { 7 | it('should ignore error in disable block', () => { 8 | const linter = new Linter(); 9 | 10 | linter.lint('./test.styl', 11 | '$p = {\n' + 12 | '\ta: #CCC\n' + 13 | '\tb: #ccc\n' + 14 | '\tc: 10px\n' + 15 | '}\n' + 16 | '.test\n' + 17 | '\tmargin-top 20px' + 18 | '\tcolor #ccc\n' 19 | ); 20 | 21 | let response = linter.reporter.response; 22 | 23 | expect(response.passed).to.be.false; 24 | expect(response.errors && response.errors.length).to.be.equal(5); 25 | 26 | linter.reporter.reset(); 27 | 28 | linter.lint('./test.styl', 29 | '$p = {\n' + 30 | '\ta: #CCC\n' + 31 | '\t// @stlint-disable\n' + 32 | '\tb: #ccc\n' + 33 | '\tc: 10px\n' + 34 | '\t// @stlint-enable\n' + 35 | '}\n' + 36 | '.test\n' + 37 | '\tmargin-top 20px' + 38 | '\t// @stlint-disable\n' + 39 | '\tcolor #ccc\n' + 40 | '\t// @stlint-enable\n' 41 | ); 42 | 43 | response = linter.reporter.response; 44 | 45 | expect(response.passed).to.be.false; 46 | 47 | expect(response.errors && response.errors.length).to.be.equal(1); 48 | 49 | }); 50 | }); 51 | describe('One line', () => { 52 | it('should ignore error in line after @stlint-ignore directive', () => { 53 | const 54 | linter = new Linter(); 55 | 56 | linter.lint('./test.styl', 57 | '$p = {\n' + 58 | '\ta: #CCC\n' + 59 | '\tb: #ccc\n' + 60 | '\tc: 10px\n' + 61 | '}\n' + 62 | '.test\n' + 63 | '\tmargin-top 20px\n' + 64 | '\tcolor #ccc\n' + 65 | '\tbackground-color #ddd\n' 66 | ); 67 | 68 | let response = linter.reporter.response; 69 | 70 | expect(response.passed).to.be.false; 71 | expect(response.errors && response.errors.length).to.be.equal(7); 72 | 73 | linter.reporter.reset(); 74 | 75 | linter.lint('./test.styl', 76 | '$p = {\n' + 77 | '\ta: #CCC\n' + 78 | '\t// @stlint-ignore\n' + 79 | '\tb: #ccc\n' + // 2 errors ignored 80 | '\tc: 10px\n' + // error 81 | '}\n' + 82 | '.test\n' + 83 | '\tmargin-top 20px\n' + // error 84 | '\t// @stlint-ignore\n' + 85 | '\tcolor #ccc\n' + // ignored 86 | '\tbackground-color #ddd\n' // 2 errors 87 | ); 88 | 89 | response = linter.reporter.response; 90 | 91 | expect(response.passed).to.be.false; 92 | expect(response.errors && response.errors.length).to.be.equal(4); 93 | }); 94 | describe('Ignore line in order rule', () => { 95 | it('should ignore error in line after @stlint-ignore directive', () => { 96 | const 97 | linter = new Linter(), 98 | getOrderError = (errors?: IMessagePack[]): IMessage => { 99 | if (!errors) { 100 | throw new Error('We do not have any errors'); 101 | } 102 | 103 | const [error] = errors.filter((value) => value.message[0].rule === 'sortOrder'); 104 | 105 | if (!error || !error.message || !error.message[0]) { 106 | throw new Error('We do not have sortOrder error'); 107 | } 108 | 109 | return error && error.message && error.message[0]; 110 | }; 111 | 112 | linter.lint('./test.styl', 113 | '.test\n' + 114 | '\tborder 10px\n' + 115 | '\tmax-height $headerHeight\n' + 116 | '\tcolor red\n' + 117 | '\tmargin-bottom 10px\n' 118 | ); 119 | 120 | let response = linter.reporter.response; 121 | 122 | expect(response.passed).to.be.false; 123 | expect(response.errors && response.errors.length).to.be.equal(3); 124 | 125 | let orderError = getOrderError(response.errors); 126 | 127 | expect(orderError.line).to.be.equal(2); 128 | 129 | linter.reporter.reset(); 130 | 131 | linter.lint('./test.styl', 132 | '.test\n' + 133 | '\t// @stlint-ignore\n' + 134 | '\tborder 10px\n' + 135 | '\tmax-height $headerHeight\n' + 136 | '\tcolor red\n' + 137 | '\tmargin-bottom 10px\n' 138 | ); 139 | 140 | response = linter.reporter.response; 141 | 142 | expect(response.passed).to.be.false; 143 | 144 | expect(response.errors && response.errors.length).to.be.equal(2); 145 | 146 | orderError = getOrderError(response.errors); 147 | 148 | expect(orderError.line).to.be.equal(5); 149 | }); 150 | }); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /tests/configTest.ts: -------------------------------------------------------------------------------- 1 | import { Linter } from '../src/linter'; 2 | import { expect } from 'chai'; 3 | import * as path from 'path'; 4 | import { IState } from '../src/core/types/state'; 5 | 6 | describe('Test extends options', () => { 7 | describe('Replace order array in sortOrder rule', () => { 8 | it('Should replace order array', () => { 9 | const linter = new Linter({ 10 | rules: { 11 | sortOrder: { 12 | conf: 'grouped', 13 | order: [ 14 | 'padding', 15 | 'background', 16 | 'absolute', 17 | 'left', 18 | 'color', 19 | 'outline' 20 | ] 21 | } 22 | }, 23 | grep: 'sortOrder', 24 | reporter: 'silent', 25 | fix: true 26 | }); 27 | 28 | expect((linter.config.rules.sortOrder).order).to.be.deep.equal([ 29 | 'padding', 30 | 'background', 31 | 'absolute', 32 | 'left', 33 | 'color', 34 | 'outline' 35 | ]); 36 | 37 | expect(linter.config.grep).to.be.equal('sortOrder'); 38 | expect(linter.config.reporter).to.be.equal('silent'); 39 | expect(linter.config.fix).to.be.true; 40 | }); 41 | }); 42 | 43 | describe('Use JS file as config', () => { 44 | it('Should replace default rule', () => { 45 | const 46 | linter1 = new Linter(), 47 | linter2 = new Linter({ 48 | extends: [path.join(__dirname, './staff/extends.js')] 49 | }), 50 | state1 = linter1.config.rules.color, 51 | state2 = linter2.config.rules.color; 52 | 53 | expect(state1.conf).to.be.equal('uppercase'); 54 | expect(state2.conf).to.be.equal('test-extends'); 55 | }); 56 | }); 57 | 58 | describe('Extends rules', () => { 59 | it('Should replace default rule', () => { 60 | const 61 | linter1 = new Linter(), 62 | linter2 = new Linter({ 63 | extends: [path.join(__dirname, './staff/extends.json')] 64 | }), 65 | state1 = linter1.config.rules.color, 66 | state2 = linter2.config.rules.color; 67 | 68 | expect(state1.conf).to.be.equal('uppercase'); 69 | expect(state2.conf).to.be.equal('test-extends'); 70 | }); 71 | 72 | describe('With custom rules', () => { 73 | it('Should not replace custom rule', () => { 74 | const 75 | linter = new Linter({ 76 | extends: [path.join(__dirname, './staff/extends.json')], 77 | rules: { 78 | color: { 79 | conf: 'test-sign', 80 | enabled: 2 81 | } 82 | } 83 | }), 84 | state = linter.config.rules.color; 85 | 86 | expect(state.conf).to.be.equal('test-sign'); 87 | expect(state.enabled).to.be.equal(2); 88 | expect(state.allowOnlyInVar).to.be.equal(5); 89 | expect(state.denyRGB).to.be.equal(true); 90 | }); 91 | describe('Use file config', () => { 92 | it('Should not replace custom rule', () => { 93 | const 94 | linter = new Linter({ 95 | config: path.join(__dirname, './staff/config.json') 96 | }), 97 | state = linter.config.rules.color, 98 | someTestRuleState = linter.config.rules.someTestRule; 99 | 100 | expect(state.conf).to.be.equal('test-config'); // from config.json 101 | expect(state.enabled).to.be.equal(false); // from config.json 102 | expect(state.allowOnlyInVar).to.be.equal(3); // from config.json 103 | expect(state.denyRGB).to.be.equal(true); // default 104 | expect(state.allowShortcut).to.be.equal(7); // from extends.json 105 | expect(someTestRuleState).to.be.equal(false); // from config.json 106 | }); 107 | }); 108 | }); 109 | }); 110 | 111 | describe('Preprocess content', () => { 112 | it('Should load preprocessor function and apply this to content before lint', () => { 113 | const 114 | linter = new Linter({ 115 | preprocessors: [ 116 | path.join(__dirname, './staff/preprocessor.js') 117 | ] 118 | }); 119 | 120 | const rightContent = '.c\n\tabsolute left right 0'; 121 | 122 | linter.lint('./test.styl', rightContent); 123 | 124 | const response = linter.reporter.response; 125 | 126 | expect(response.passed).to.be.false; 127 | 128 | expect(response.errors && response.errors.length).to.be.equal(4); 129 | }); 130 | 131 | describe('Path to preprocessor', () => { 132 | it('Should calculate by config file', () => { 133 | const 134 | linter = new Linter({ 135 | extends: [ 136 | path.join(__dirname, './staff/subfolder/extends.json') 137 | ] 138 | }); 139 | 140 | const rightContent = '.c\n\tabsolute left right 0'; 141 | 142 | const content = linter.lint('./test.styl', rightContent); 143 | 144 | expect(content.content).to.be.equal(rightContent + 'i work'); 145 | }); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/rules/color.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../core/rule'; 2 | import { IState } from '../core/types/state'; 3 | import { Call, RGB } from '../core/ast/index'; 4 | import { shortcutColor } from '../core/helpers/index'; 5 | 6 | interface IColorState extends IState { 7 | allowOnlyInVar?: boolean 8 | allowShortcut?: boolean 9 | denyRBG?: boolean 10 | } 11 | 12 | /** 13 | * Process all color values. Allow or deny use it not in variable and use uppercase or lowercase statements 14 | * For example this code has error - because we use only color in `uppercase` 15 | * ```stylus 16 | * .test 17 | * color #ccc 18 | * ``` 19 | * If `allowOnlyInVar` === true code above also has error - no use color without variable 20 | * 21 | * Fixed code 22 | * ```stylus 23 | * $color = #CCC 24 | * .test 25 | * color $color 26 | * ``` 27 | */ 28 | export class Color extends Rule { 29 | nodesFilter: string[] = ['rgb', 'call']; 30 | 31 | checkNode(node: RGB | Call): void | boolean { 32 | if (node instanceof Call) { 33 | return this.checkRGB(node); 34 | } 35 | 36 | const checkReg = this.state.conf !== 'lowercase' ? /[a-z]/ : /[A-Z]/; 37 | 38 | let fixed = false; 39 | 40 | if (this.state.allowOnlyInVar && node.closest('block')) { 41 | const fix = this.context.valueToVar[node.value] || 42 | this.context.valueToVar[node.value.toLowerCase()] || 43 | this.context.valueToVar[node.value.toUpperCase()]; 44 | 45 | this.msg( 46 | 'Set color only in variable' + (fix ? `(${fix})` : ''), 47 | node.lineno, node.column, 48 | node.column + node.value.length - 1, 49 | fix || null 50 | ); 51 | 52 | fixed = !!fix; 53 | } 54 | 55 | if (node.value && typeof node.value === 'string' && checkReg.test(node.value)) { 56 | const fix = node.value.toString(); 57 | 58 | this.msg( 59 | `Only ${ this.state.conf } HEX format`, 60 | node.lineno, 61 | node.column, 62 | node.column + node.value.length - 1, 63 | fixed ? null : this.state.conf === 'uppercase' ? fix.toUpperCase() : fix.toLowerCase() 64 | ); 65 | 66 | return true; 67 | } 68 | 69 | if (this.checkShortcutErrors(node) === true) { 70 | return true; 71 | } 72 | 73 | return false; 74 | } 75 | 76 | private RGBToHex(...args: [number, number, number]): string { 77 | return '#' + args.map((c) => { 78 | let 79 | hex = c.toString(16); 80 | 81 | if (hex.length === 1) { 82 | hex = '0' + hex; 83 | } 84 | 85 | return hex; 86 | }).join(''); 87 | } 88 | 89 | /** 90 | * Check using rgba and rgb notation 91 | * @param node 92 | */ 93 | private checkRGB(node: Call): void { 94 | if (this.state.denyRGB) { 95 | if (node.key && /^rgb(a)?$/i.test(node.key) && node.nodes.length > 2) { 96 | let 97 | fix = ''; 98 | 99 | const 100 | firstValue = node.nodes[0] ? node.nodes[0].toString() : '0'; 101 | 102 | let hex = this.RGBToHex( 103 | parseInt(firstValue, 10), 104 | parseInt(node.nodes[1] ? node.nodes[1].toString() : '0', 10), 105 | parseInt(node.nodes[2] ? node.nodes[2].toString() : '0', 10) 106 | ); 107 | 108 | hex = this.state.conf === 'uppercase' ? hex.toUpperCase() : hex.toLowerCase(); 109 | 110 | if (node.key === 'rgb') { 111 | fix = hex; 112 | } else { 113 | if (/^#[0-9a-f]+/i.test(firstValue)) { 114 | return; 115 | } 116 | 117 | fix = `rgba(${hex}, ${node.nodes[3] ? node.nodes[3].toString() : '1'})`; 118 | } 119 | 120 | const 121 | line = node.line, 122 | endIndex = line ? line.line.indexOf(')', node.column - 1) + 1 : node.column + node.key.length - 1; 123 | 124 | this.msg( 125 | 'Deny rgb/rgba format', 126 | node.lineno, 127 | node.column, 128 | endIndex, 129 | fix 130 | ); 131 | } 132 | } 133 | } 134 | 135 | private checkShortcutErrors(node: RGB): void | true { 136 | if (node.value && typeof node.value === 'string') { 137 | if (this.state.allowShortcut) { 138 | const shortcut = shortcutColor(node.value); 139 | 140 | if (shortcut !== node.value) { 141 | const fix = this.state.conf === 'uppercase' ? shortcut.toUpperCase() : shortcut.toLowerCase(); 142 | 143 | this.msg( 144 | `Color ${ node.value } can have shortcut`, 145 | node.lineno, 146 | node.column, 147 | node.column + node.value.length - 1, 148 | fix 149 | ); 150 | 151 | return true; 152 | } 153 | } else { 154 | if (node.value.length < 5) { 155 | const 156 | color = node.value.replace(/([a-f0-9])([a-f0-9])([a-f0-9])/i, '$1$1$2$2$3$3'), 157 | fix = this.state.conf === 'uppercase' ? color.toUpperCase() : color.toLowerCase(); 158 | 159 | this.msg( 160 | 'Color must not have shortcut', 161 | node.lineno, 162 | node.column, 163 | node.column + node.value.length - 1, 164 | fix 165 | ); 166 | 167 | return true; 168 | } 169 | } 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /tests/rules/colonsTest.ts: -------------------------------------------------------------------------------- 1 | import { Colons } from '../../src/rules/index'; 2 | import { expect } from 'chai'; 3 | import { checkLine, splitAndRun } from '../staff/bootstrap'; 4 | import { Linter } from '../../src/linter'; 5 | 6 | describe('Colons test', () => { 7 | it('Should check the line has colons and they are needed', () => { 8 | const rule = new Colons({ 9 | conf: 'always' 10 | }); 11 | 12 | expect(checkLine('color:#ccc', rule)).to.be.true; 13 | 14 | expect(rule.errors.length).to.be.equal(0); 15 | }); 16 | 17 | describe('In hash object', () => { 18 | it('Should not find errors', () => { 19 | const rule = new Colons({ 20 | conf: 'never' 21 | }); 22 | 23 | splitAndRun('$p = {\n\tcolor: #ccc;\n}', rule); 24 | 25 | expect(rule.errors.length).to.be.equal(0); 26 | }); 27 | }); 28 | 29 | describe('For variable', () => { 30 | it('Should work some way like for usual value', () => { 31 | const rule = new Colons({ 32 | conf: 'never' 33 | }); 34 | 35 | splitAndRun( 36 | '$p = {\n' + 37 | '\tb: 1px solid #ccc\n' + 38 | '}\n' + 39 | '.test\n' + 40 | '\tmax-height: $headerHeight\n' + 41 | '\tborder: $p.border', 42 | rule 43 | ); 44 | 45 | expect(rule.errors.length).to.be.equal(2); 46 | }); 47 | describe('For nested variable', () => { 48 | it('Should not count errors', () => { 49 | const rule = new Colons({ 50 | conf: 'never' 51 | }); 52 | 53 | splitAndRun( 54 | '$p = {\n' + 55 | '\tc: {test: 15px}\n' + 56 | '\tb: 1px solid #ccc\n' + 57 | '}\n' + 58 | '.test\n' + 59 | '\tmax-height: $headerHeight\n' + 60 | '\tborder: $p.border', 61 | rule 62 | ); 63 | 64 | expect(rule.errors.length).to.be.equal(2); 65 | }); 66 | }); 67 | }); 68 | 69 | it('Should check the line does not have colons but they are needed', () => { 70 | const rule = new Colons({ 71 | conf: 'always' 72 | }); 73 | 74 | expect(checkLine('color #ccc', rule)).to.be.false; 75 | 76 | expect(rule.errors.length).to.be.equal(1); 77 | }); 78 | 79 | it('Should check the line has colons but they are not needed', () => { 80 | const rule = new Colons({ 81 | conf: 'never' 82 | }); 83 | 84 | expect(checkLine('color:#ccc', rule)).to.be.true; 85 | 86 | expect(rule.errors.length).to.be.equal(1); 87 | }); 88 | 89 | it('Should check the line does not have colons and they are not needed', () => { 90 | const rule = new Colons({ 91 | conf: 'never' 92 | }); 93 | 94 | expect(checkLine('color #ccc', rule)).to.be.false; 95 | 96 | expect(rule.errors.length).to.be.equal(0); 97 | }); 98 | 99 | describe('Detect pseudo elements', () => { 100 | it('Should detect different between pseudo element and property: value expression', () => { 101 | const rule = new Colons({ 102 | conf: 'never' 103 | }); 104 | 105 | expect(checkLine('.tab:first-child', rule)).to.be.not.true; 106 | }); 107 | }); 108 | 109 | describe('Colons in url', () => { 110 | it('Should not find error url', () => { 111 | const rule = new Colons({ 112 | conf: 'never' 113 | }); 114 | 115 | splitAndRun( 116 | '.test\n' + 117 | '\tmax-height $headerHeight\n' + 118 | '\tborder basis(10)\n' + 119 | '\tbackground url(https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmEU9fChc4AMP6lbBP.woff2)', 120 | rule 121 | ); 122 | 123 | expect(rule.errors.length).to.be.equal(0); 124 | }); 125 | }); 126 | 127 | describe('Colons in comment', () => { 128 | it('Should not find error', () => { 129 | const linter = new Linter({ 130 | grep: 'colons', 131 | reporter: 'silent', 132 | fix: true 133 | }); 134 | 135 | linter.lint('./test.styl', 136 | '/*!\n' + 137 | ' * V4Fire Client Core\n' + 138 | ' * https://github.com/V4Fire/Client\n' + 139 | ' *\n' + 140 | ' * Released under the MIT license\n' + 141 | ' * https://github.com/V4Fire/Client/blob/master/LICENSE\n' + 142 | ' */\n' + 143 | '.test\n' + 144 | '\tmax-height $headerHeight\n' + 145 | '\tborder basis(10)\n' + 146 | '' 147 | ); 148 | 149 | expect(linter.reporter.response.passed).to.be.true; 150 | }); 151 | 152 | describe('All lines', () => { 153 | it('Should not be changed', () => { 154 | const linter = new Linter({ 155 | reporter: 'silent', 156 | fix: true 157 | }); 158 | 159 | console.log(linter.lint('./test.styl', 160 | '/*!\n' + 161 | ' * V4Fire Client Core\n' + 162 | ' * https://github.com/V4Fire/Client\n' + 163 | ' *\n' + 164 | ' * Released under the MIT license\n' + 165 | ' * https://github.com/V4Fire/Client/blob/master/LICENSE\n' + 166 | ' */\n' + 167 | '.test\n' + 168 | '\tmax-height $headerHeight\n' + 169 | '\tborder 10px\n' + 170 | '' 171 | ).content); 172 | 173 | expect(linter.reporter.response.passed).to.be.false; 174 | const errors = linter.reporter.response.errors; 175 | expect(errors && errors.length).to.be.eq(1); 176 | expect(errors && errors[0].message[0].line).to.be.eq(10); 177 | }); 178 | }); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /src/core/checker.ts: -------------------------------------------------------------------------------- 1 | import { IRule } from './types/rule'; 2 | import * as rules from '../rules/index'; 3 | import { INode } from './types/ast/node'; 4 | import { Tree } from './ast/index'; 5 | import { Runner } from './runner'; 6 | import { Linter } from '../linter'; 7 | import { Line } from './line'; 8 | import { Rule } from './rule'; 9 | import { IReporter } from './types/reporter'; 10 | import { lcfirst } from './helpers/lcfirst'; 11 | import { statSync, readdirSync } from 'fs'; 12 | import { resolve } from 'path'; 13 | import _require = require('native-require'); 14 | import { IContent } from './types/content'; 15 | import { IState } from './types/state'; 16 | 17 | export class Checker { 18 | rulesListForNodes: IRule[] = []; 19 | rulesListForLines: IRule[] = []; 20 | rulesList: IRule[] = []; 21 | 22 | constructor(readonly linter: Linter) { 23 | } 24 | 25 | /** 26 | * Load and init rules (and external rules too) 27 | */ 28 | loadAndInitRules(): void { 29 | 30 | this.rulesList = this.initRules(rules); 31 | 32 | if (this.linter.config.extraRules) { 33 | const extraRules = this.loadRules(this.linter.config.extraRules); 34 | this.rulesList = this.rulesList.concat(this.initRules(extraRules)); 35 | } 36 | 37 | this.rulesListForLines = this.rulesList.filter((rule) => rule.checkLine); 38 | this.rulesListForNodes = this.rulesList.filter((rule) => rule.checkNode); 39 | } 40 | 41 | /** 42 | * Create instance od all rules all rules 43 | * @param rulesConstructors 44 | */ 45 | private initRules(rulesConstructors: Dictionary): IRule[] { 46 | const 47 | rulesNames: string[] = Object.keys(rulesConstructors), 48 | config = this.linter.config; 49 | 50 | return rulesNames 51 | .filter((key) => typeof rulesConstructors[key] === 'function') 52 | .map((key: string): IRule => { 53 | let options = config.rules[lcfirst(key)]; 54 | 55 | if (options === true && config.defaultRules[lcfirst(key)]) { 56 | options = config.defaultRules[lcfirst(key)]; 57 | } 58 | 59 | if (!(rulesConstructors[key].prototype instanceof Rule)) { 60 | rulesConstructors[key].prototype = new Rule(options); 61 | rulesConstructors[key].prototype.constructor = rulesConstructors[key]; 62 | } 63 | 64 | const rule: IRule = new (rulesConstructors)[key](options); 65 | 66 | rule.setConfig(config); 67 | 68 | return rule; 69 | }) 70 | .filter((rule) => rule.state.enabled); 71 | } 72 | 73 | /** 74 | * Load rules from folder 75 | */ 76 | private loadRules(path: string | string[]): Dictionary { 77 | let results: Dictionary = {}; 78 | 79 | if (Array.isArray(path)) { 80 | path.map(this.loadRules.bind(this)).forEach((rules) => { 81 | results = {...results, ...rules}; 82 | }); 83 | 84 | return results; 85 | } 86 | 87 | const stat = statSync(path); 88 | 89 | if (stat.isFile()) { 90 | results = {...results, ...this.requireRule(path)}; 91 | } else if (stat.isDirectory()) { 92 | readdirSync(path).forEach((file) => { 93 | // @ts-ignore 94 | results = {...results, ...this.requireRule(resolve(path, file))}; 95 | }); 96 | } 97 | 98 | return results; 99 | } 100 | 101 | /** 102 | * Load one rule or several rules 103 | * @param path 104 | */ 105 | private requireRule(path: string): Dictionary { 106 | 107 | if (/\.js$/.test(path)) { 108 | try { 109 | const rule = _require(`${path}`); 110 | 111 | if (typeof rule === 'function') { 112 | return { 113 | [rule.name]: rule 114 | }; 115 | } else { 116 | return { 117 | ...rule 118 | }; 119 | } 120 | } catch (e) { 121 | this.linter.reporter.add('JS', e.message, 1, 1); 122 | } 123 | } 124 | 125 | return {}; 126 | } 127 | 128 | /** 129 | * Check whole AST 130 | * 131 | * @param ast 132 | * @param content 133 | */ 134 | checkASTRules(ast: Tree, content: IContent): void { 135 | try { 136 | const runner = new Runner(ast, this.check.bind(this, content)); 137 | runner.visit(ast, null); 138 | 139 | } catch (e) { 140 | if (this.linter.config.debug) { 141 | throw e; 142 | } 143 | 144 | this.linter.reporter.add('astRulesError', e.message, e.lineno || 1, 0); 145 | 146 | } finally { 147 | this.afterCheck(); 148 | } 149 | } 150 | 151 | /** 152 | * Check line by line 153 | * @param content 154 | */ 155 | checkLineRules(content: IContent): void { 156 | try { 157 | content.forEach((line, index) => { 158 | if (index && !line.isIgnored) { 159 | Rule.beforeCheckLine(line); 160 | this.rulesListForLines.forEach((rule) => rule.checkLine && rule.checkLine(line, index, content)); 161 | } 162 | }); 163 | 164 | } catch (e) { 165 | this.linter.reporter.add('Line', e.message, e.lineno || 1, 0); 166 | 167 | } finally { 168 | this.afterCheck(); 169 | } 170 | } 171 | 172 | private check(content: IContent, node: INode): void { 173 | const type = node.nodeName; 174 | 175 | Rule.beforeCheckNode(node); 176 | 177 | this.rulesListForNodes.forEach((rule: IRule) => { 178 | const line = node.line; 179 | 180 | if (line && !line.isIgnored && rule.checkNode && rule.isMatchType(type)) { 181 | rule.checkNode(node, content); 182 | } 183 | }); 184 | } 185 | 186 | /** 187 | * After checking put errors in reporter 188 | */ 189 | private afterCheck(): void { 190 | const reporter: IReporter = this.linter.reporter; 191 | 192 | this.rulesList.forEach((rule) => { 193 | rule.errors.forEach((msg) => reporter.add.apply(reporter, msg)); 194 | rule.clearErrors(); 195 | }); 196 | 197 | reporter.fillResponse(); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/linter.ts: -------------------------------------------------------------------------------- 1 | import { Reporter } from './core/reporter'; 2 | import { StylusParser } from './core/parser'; 3 | import { Checker } from './core/checker'; 4 | import { Preprocessor } from './core/preprocessor'; 5 | import { existsSync, readFileSync, writeFileSync } from 'fs'; 6 | import { resolve } from 'path'; 7 | import { IReporter } from './core/types/reporter'; 8 | import { Rule } from './core/rule'; 9 | import { IConfig } from './core/types/config'; 10 | import { Config } from './config'; 11 | import watch = require('node-watch'); 12 | import { Content } from './core/content'; 13 | import { IContent } from './core/types/content'; 14 | import { IMessage } from './core/types/message'; 15 | 16 | const pkg = require('../package.json'); 17 | 18 | export class Linter { 19 | options: Dictionary = {}; 20 | 21 | config: IConfig; 22 | 23 | reporter: IReporter; 24 | parser: StylusParser; 25 | checker: Checker; 26 | preprocessor!: Preprocessor; 27 | 28 | /** 29 | * @param options 30 | */ 31 | constructor(options: Dictionary = {}) { 32 | this.options = options; 33 | 34 | if (options.config && typeof options.config === 'string') { 35 | options.configFile = options.config; 36 | } 37 | 38 | this.config = new Config(this.options); 39 | 40 | this.reporter = Reporter.getInstance(this.config.reporter, this.config.reportOptions); 41 | 42 | this.parser = new StylusParser(this.config.stylusParserOptions); 43 | this.checker = new Checker(this); 44 | 45 | try { 46 | this.preprocessor = new Preprocessor(this.config.preprocessors); 47 | } catch (e) { 48 | this.reporter.add('preprocessorError', e.message, e.lineno || 1, e.startOffset || 1); 49 | } 50 | } 51 | 52 | /** 53 | * Parse styl file and check rules 54 | */ 55 | lint(path: string, str: string | null = null): Content { 56 | path = resolve(path); 57 | 58 | if (!existsSync(path)) { 59 | throw new Error('File not exists'); 60 | } 61 | 62 | if (typeof str !== 'string') { 63 | str = readFileSync(path, 'utf8'); 64 | } 65 | 66 | let content = new Content(str); 67 | 68 | if (this.preprocessor) { 69 | content = this.preprocessor.apply(content); 70 | } 71 | 72 | try { 73 | 74 | this.checker.loadAndInitRules(); 75 | 76 | this.reporter.setPath(path); 77 | 78 | Rule.clearContext(); 79 | 80 | this.fillIgnoredLines(content); 81 | 82 | try { 83 | const ast = this.parser.parse(content); 84 | this.checker.checkASTRules(ast, content); 85 | 86 | } catch (e) { 87 | if (this.config.debug) { 88 | throw e; 89 | } 90 | 91 | this.reporter.add('syntaxError', e.message, e.lineno, e.startOffset); 92 | } 93 | 94 | this.checker.checkLineRules(content); 95 | } catch (e) { 96 | if (this.config.debug) { 97 | throw e; 98 | } 99 | } finally { 100 | if (this.config.grep) { 101 | this.reporter.filterErrors(this.config.grep); 102 | } 103 | 104 | if (this.config.fix && str !== null && this.reporter.errors && this.reporter.errors.length) { 105 | this.fix(path, new Content(str)); 106 | } 107 | } 108 | 109 | return content; 110 | } 111 | 112 | protected fillIgnoredLines(content: Content): void { 113 | let 114 | ignoreBlock = false; 115 | 116 | content.forEach((line) => { 117 | if (ignoreBlock) { 118 | line.isIgnored = true; 119 | if (/@stlint-enable/.test(line.line)) { 120 | ignoreBlock = false; 121 | } 122 | 123 | } else if (/@stlint-ignore/.test(line.line)) { 124 | line.isIgnored = true; 125 | const next = line.next(); 126 | 127 | if (next) { 128 | next.isIgnored = true; 129 | } 130 | 131 | } else if (/@stlint-disable/.test(line.line)) { 132 | ignoreBlock = true; 133 | } 134 | }); 135 | } 136 | 137 | /** 138 | * Watch to some directory or file 139 | * 140 | * @param path 141 | * @param callback 142 | */ 143 | watch(path: string, callback: () => void): void { 144 | watch(path, { 145 | encoding: 'utf-8', 146 | recursive: true, 147 | filter: /\.styl$/ 148 | }, callback); 149 | } 150 | 151 | /** 152 | * Print all errors or warnings 153 | */ 154 | display(exit: boolean = true): void { 155 | this.reporter.display(exit); 156 | } 157 | 158 | /** 159 | * Try fix some errors 160 | */ 161 | fix(path: string, content: IContent): string { 162 | let 163 | diffContent = content; 164 | 165 | const 166 | fixes: IMessage[] = this.reporter.errors.reduce((fxs, error) => { 167 | error.message.forEach((message: IMessage) => { 168 | if (message.fix !== null) { 169 | fxs.push({...message}); 170 | } 171 | }); 172 | 173 | return fxs; 174 | }, []); 175 | 176 | fixes.sort((a, b) => a.line - b.line); 177 | 178 | diffContent = diffContent.applyFixes(fixes); 179 | 180 | if (diffContent.toString() !== content.toString()) { 181 | this.saveFix(path, diffContent.toString()); 182 | } 183 | 184 | return diffContent.toString(); 185 | } 186 | 187 | saveFix(path: string, content: string): void { 188 | writeFileSync(path, content); 189 | } 190 | 191 | info(): void { 192 | const 193 | rules = Object.keys(this.config.rules) 194 | .filter((ruleKey) => ruleKey.match(this.config.grep)) 195 | .reduce((rls, ruleKey) => { 196 | rls[ruleKey] = this.config.rules[ruleKey]; 197 | return rls; 198 | }, {}); 199 | 200 | console.log( 201 | `Version: ${pkg.version}\n` + 202 | `Config: ${this.config.configFile}\n` + 203 | (this.config.extraRules ? `Extra Rules: ${JSON.stringify(this.config.extraRules)}\n` : '') + 204 | (this.config.extends ? `Extends: ${JSON.stringify(this.config.extends)}\n` : '') + 205 | `Rules: ${JSON.stringify(rules, null, 2)}\n` + 206 | '' 207 | ); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/defaultRules.json: -------------------------------------------------------------------------------- 1 | { 2 | "mixedSpaces": { 3 | "indentPref": "tab" 4 | }, 5 | 6 | "prefixVarsWithDollar": { 7 | "conf": "always", 8 | "prefix": "$", 9 | "allowConst": true 10 | }, 11 | 12 | "emptyLines": true, 13 | 14 | "brackets": [ 15 | "never" 16 | ], 17 | 18 | "commaInObject": [ 19 | "never" 20 | ], 21 | 22 | "depthControl": { 23 | "indentPref": "tab" 24 | }, 25 | 26 | "quotePref": [ 27 | "double" 28 | ], 29 | 30 | "semicolons": [ 31 | "never" 32 | ], 33 | 34 | "colons": [ 35 | "never" 36 | ], 37 | 38 | "color": { 39 | "conf": "uppercase", 40 | "enabled": true, 41 | "allowOnlyInVar": true, 42 | "allowShortcut": true, 43 | "denyRGB": true 44 | }, 45 | 46 | "leadingZero": [ 47 | "always" 48 | ], 49 | 50 | "useMixinInsteadUnit": { 51 | "conf": "always", 52 | "mixin": "basis", 53 | "unitType": "px", 54 | "allowOneUnit": false 55 | }, 56 | 57 | "sortOrder": { 58 | "conf": "grouped", 59 | "startGroupChecking": 6, 60 | "order": [ 61 | [ 62 | "absolute", 63 | "position", 64 | "z-index", 65 | "top", 66 | "right", 67 | "bottom", 68 | "left" 69 | ], 70 | 71 | [ 72 | "content", 73 | "display", 74 | 75 | "flexbox", 76 | "flex", 77 | "flex-grow", 78 | "flex-shrink", 79 | "flex-basis", 80 | "flex-direction", 81 | "order", 82 | "flex-order", 83 | "flex-wrap", 84 | "flex-flow", 85 | "justify-content", 86 | "align-self", 87 | "align-items", 88 | "align-content", 89 | "flex-pack", 90 | "flex-align", 91 | 92 | "box-sizing", 93 | "vertical-align", 94 | 95 | "size", 96 | "width", 97 | "height", 98 | "max-width", 99 | "min-width", 100 | "max-height", 101 | "min-height", 102 | 103 | "overflow", 104 | "overflow-x", 105 | "overflow-y", 106 | 107 | "float", 108 | "clear", 109 | 110 | "visibility", 111 | "opacity", 112 | 113 | "margin", 114 | "margin-top", 115 | "margin-right", 116 | "margin-bottom", 117 | "margin-left", 118 | "padding", 119 | "padding-top", 120 | "padding-right", 121 | "padding-bottom", 122 | "padding-left" 123 | ], 124 | 125 | [ 126 | "font", 127 | "font-family", 128 | "font-size", 129 | "font-weight", 130 | "font-style", 131 | "font-variant", 132 | "font-size-adjust", 133 | "font-stretch", 134 | 135 | "line-height", 136 | "letter-spacing", 137 | 138 | "text-align", 139 | "text-align-last", 140 | "text-decoration", 141 | "text-emphasis", 142 | "text-emphasis-position", 143 | "text-emphasis-style", 144 | "text-emphasis-color", 145 | "text-indent", 146 | "text-justify", 147 | "text-outline", 148 | "text-transform", 149 | "text-wrap", 150 | "text-overflow", 151 | "text-overflow-ellipsis", 152 | "text-overflow-mode", 153 | 154 | "word-spacing", 155 | "word-wrap", 156 | "word-break", 157 | "tab-size", 158 | "hyphens" 159 | ], 160 | 161 | [ 162 | "pointer-events", 163 | 164 | "border", 165 | "border-spacing", 166 | "border-collapse", 167 | "border-width", 168 | "border-style", 169 | "border-color", 170 | "border-top", 171 | "border-top-width", 172 | "border-top-style", 173 | "border-top-color", 174 | "border-right", 175 | "border-right-width", 176 | "border-right-style", 177 | "border-right-color", 178 | "border-bottom", 179 | "border-bottom-width", 180 | "border-bottom-style", 181 | "border-bottom-color", 182 | "border-left", 183 | "border-left-width", 184 | "border-left-style", 185 | "border-left-color", 186 | "border-radius", 187 | "border-top-left-radius", 188 | "border-top-right-radius", 189 | "border-bottom-right-radius", 190 | "border-bottom-left-radius", 191 | "border-image", 192 | "border-image-source", 193 | "border-image-slice", 194 | "border-image-width", 195 | "border-image-outset", 196 | "border-image-repeat", 197 | "border-top-image", 198 | "border-right-image", 199 | "border-bottom-image", 200 | "border-left-image", 201 | "border-corner-image", 202 | "border-top-left-image", 203 | "border-top-right-image", 204 | "border-bottom-right-image", 205 | "border-bottom-left-image", 206 | 207 | "color", 208 | 209 | "background", 210 | "filter", 211 | "background-color", 212 | "background-image", 213 | "background-attachment", 214 | "background-position", 215 | "background-position-x", 216 | "background-position-y", 217 | "background-clip", 218 | "background-origin", 219 | "background-size", 220 | "background-repeat", 221 | 222 | "clip", 223 | "list-style", 224 | 225 | "outline", 226 | "outline-width", 227 | "outline-style", 228 | "outline-color", 229 | "outline-offset", 230 | 231 | "cursor", 232 | "box-shadow", 233 | "text-shadow", 234 | "table-layout", 235 | "backface-visibility", 236 | "will-change", 237 | "transition", 238 | "transform", 239 | "animation" 240 | ] 241 | ] 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /tests/rules/colorTest.ts: -------------------------------------------------------------------------------- 1 | import { Color } from '../../src/rules/index'; 2 | import { expect } from 'chai'; 3 | import { parseAndRun } from '../staff/bootstrap'; 4 | 5 | describe('Color test', () => { 6 | describe('Boolean enable state', () => { 7 | it('Should work like default options', () => { 8 | const rule = new Color({ 9 | conf: 'always' 10 | }); 11 | 12 | parseAndRun('.tab\n\tcolor: #ccc\n\tbackground-color #fff', rule); 13 | 14 | expect(rule.errors.length).to.be.equal(2); 15 | }); 16 | }); 17 | describe('Need uppercase notation', () => { 18 | it('Should check the AST has RGB node with wrong color notation', () => { 19 | const rule = new Color({ 20 | conf: 'uppercase' 21 | }); 22 | 23 | parseAndRun('.tab\n\tcolor: #ccc\n\tbackground-color #fff', rule); 24 | 25 | expect(rule.errors.length).to.be.equal(2); 26 | }); 27 | it('Should check the AST has RGB node with right color notation', () => { 28 | const rule = new Color({ 29 | conf: 'uppercase', 30 | allowShortcut: true 31 | }); 32 | 33 | parseAndRun('.tab\n\tcolor: #CCC', rule); 34 | 35 | expect(rule.errors.length).to.be.equal(0); 36 | }); 37 | }); 38 | describe('Need lowercase notation', () => { 39 | it('Should check the AST has RGB node with right color notation', () => { 40 | const rule = new Color({ 41 | conf: 'lowercase', 42 | allowShortcut: true 43 | }); 44 | 45 | parseAndRun('.tab\n\tcolor: #ccc\n\tbackground-color #fff', rule); 46 | 47 | expect(rule.errors.length).to.be.equal(0); 48 | }); 49 | 50 | it('Should check the AST has RGB node with wrong color notation', () => { 51 | const rule = new Color({ 52 | conf: 'lowercase' 53 | }); 54 | 55 | parseAndRun('.tab\n\tcolor: #CCC', rule); 56 | 57 | expect(rule.errors.length).to.be.equal(1); 58 | }); 59 | }); 60 | describe('RGB notation', () => { 61 | describe('Deny rgb notation', () => { 62 | it('Should find error in RGB node with rgb or rgba color notation', () => { 63 | const rule = new Color({ 64 | conf: 'uppercase', 65 | denyRGB: true, 66 | allowShortcut: true 67 | }); 68 | 69 | parseAndRun( 70 | '.tab\n\tcolor: rgba(127, 127, 127, 0.6)\n\tbackground-color rgb(0, 0, 0)\n\tborder-color rgba(#CCC, 1)', 71 | rule 72 | ); 73 | 74 | expect(rule.errors.length).to.be.equal(2); 75 | }); 76 | }); 77 | describe('Allow rgb notation', () => { 78 | it('Should not find error in RGB node with rgb or rgba color notation', () => { 79 | const rule = new Color({ 80 | conf: 'uppercase', 81 | denyRGB: false 82 | }); 83 | 84 | parseAndRun('.tab\n\tcolor: rgba(127, 127, 127, 0.6)\n\tbackground-color rgb(0, 0, 0)', rule); 85 | 86 | expect(rule.errors.length).to.be.equal(0); 87 | }); 88 | }); 89 | }); 90 | describe('Only in variable', () => { 91 | it('Should check RGB node only in variable', () => { 92 | const rule = new Color({ 93 | conf: 'uppercase', 94 | allowOnlyInVar: true, 95 | allowShortcut: true 96 | }); 97 | 98 | parseAndRun('$stop = #FFF\n' + 99 | '$p = {\n' + 100 | '\tcolor: #FFF\n' + 101 | '}\n' + 102 | '.tab\n' + 103 | '\tcolor: #CCC\n' + 104 | '\tcolor: $p.color\n', rule); 105 | 106 | expect(rule.errors.length).to.be.equal(1); 107 | }); 108 | describe('Without error', () => { 109 | it('Should check RGB node only in variable', () => { 110 | const rule = new Color({ 111 | conf: 'uppercase', 112 | allowOnlyInVar: true, 113 | allowShortcut: true 114 | }); 115 | 116 | parseAndRun('$stop = #FFF\n' + 117 | '$p = {\n' + 118 | '\tcolor: #FFF\n' + 119 | '}\n' + 120 | '.tab\n' + 121 | '\tcolor $p.color\n' + 122 | '\tbackground-color $stop', rule); 123 | 124 | expect(rule.errors.length).to.be.equal(0); 125 | }); 126 | }); 127 | describe('Disable option rule', () => { 128 | it('Should not check RGB node only in variable', () => { 129 | const rule = new Color({ 130 | conf: 'uppercase', 131 | allowOnlyInVar: false, 132 | allowShortcut: true 133 | }); 134 | 135 | parseAndRun('$stop = #FFF\n' + 136 | '$p = {\n' + 137 | '\tcolor: #FFF\n' + 138 | '}\n' + 139 | '.tab\n' + 140 | '\tcolor: #CCC\n' + 141 | '\tcolor: $p.color\n', rule); 142 | 143 | expect(rule.errors.length).to.be.equal(0); 144 | }); 145 | }); 146 | }); 147 | describe('Shortcut', () => { 148 | describe('Allow shortcut', () => { 149 | describe('Wrong value', () => { 150 | it('Should throw the error if content can have shortcut color notation', () => { 151 | const rule = new Color({ 152 | conf: 'lowercase', 153 | allowShortcut: true 154 | }); 155 | 156 | parseAndRun('.tab\n\tcolor: #cccccc\n\tbackground-color #ffffff', rule); 157 | 158 | expect(rule.errors.length).to.be.equal(2); 159 | }); 160 | }); 161 | describe('Right value', () => { 162 | it('Should not throw the error if content has shortcut color notation', () => { 163 | const rule = new Color({ 164 | conf: 'lowercase', 165 | allowShortcut: true 166 | }); 167 | 168 | parseAndRun('.tab\n\tcolor: #ccc\n\tbackground-color #fffffd', rule); 169 | 170 | expect(rule.errors.length).to.be.equal(0); 171 | }); 172 | }); 173 | }); 174 | describe('Deny shortcut', () => { 175 | describe('Wrong value', () => { 176 | it('Should throw the error if content have shortcut color notation', () => { 177 | const rule = new Color({ 178 | conf: 'lowercase', 179 | allowShortcut: false 180 | }); 181 | 182 | parseAndRun('.tab\n\tcolor: #ccc\n\tbackground-color #fff', rule); 183 | 184 | expect(rule.errors.length).to.be.equal(2); 185 | }); 186 | }); 187 | describe('Right value', () => { 188 | it('Should not throw the error if content have not shortcut color notation', () => { 189 | const rule = new Color({ 190 | conf: 'lowercase', 191 | allowShortcut: false 192 | }); 193 | 194 | parseAndRun('.tab\n\tcolor: #cccccc\n\tbackground-color #fffffd', rule); 195 | 196 | expect(rule.errors.length).to.be.equal(0); 197 | }); 198 | }); 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /tests/rules/sortOrderTest.ts: -------------------------------------------------------------------------------- 1 | import { SortOrder } from '../../src/rules/index'; 2 | import { parseAndRun } from '../staff/bootstrap'; 3 | import { expect } from 'chai'; 4 | import data = require('../../src/defaultRules.json'); 5 | import { Linter } from '../../src/linter'; 6 | import * as path from 'path'; 7 | 8 | const content = '.tab\n' + 9 | '\tcolor #CCC\n' + 10 | '\tmargin 10px\n' + 11 | '\tbackground-color $p.color\n' + 12 | ''; 13 | 14 | describe('Test order rule', () => { 15 | describe('Disable order rule', () => { 16 | it('should not check properties order', () => { 17 | const linter = new Linter({ 18 | rules: { 19 | sortOrder: false 20 | }, 21 | grep: 'sortOrder', 22 | reporter: 'silent' 23 | }); 24 | 25 | linter.lint('./test.styl', content); 26 | linter.display(false); 27 | 28 | const response = linter.reporter.response; 29 | 30 | expect(response.passed).to.be.true; 31 | }); 32 | 33 | describe('Use config', () => { 34 | it('should not check properties order', () => { 35 | const linter = new Linter({ 36 | config: path.join(__dirname, '../staff/disable-sort-order-rule.json'), 37 | grep: 'sortOrder', 38 | reporter: 'silent' 39 | }); 40 | 41 | linter.lint('./test.styl', content); 42 | linter.display(false); 43 | 44 | const response = linter.reporter.response; 45 | 46 | expect(response.passed).to.be.true; 47 | }); 48 | }); 49 | }); 50 | 51 | describe('Alphabetical order', () => { 52 | it('should check properties in alphabetical order', () => { 53 | const rule = new SortOrder({ 54 | conf: 'alphabetical' 55 | }); 56 | 57 | parseAndRun(content, rule); 58 | 59 | expect(rule.errors.length).to.be.equal(1); 60 | }); 61 | }); 62 | 63 | describe('Custom order', () => { 64 | describe('Matched order', () => { 65 | it('should check properties sorted in custom order', () => { 66 | const rule = new SortOrder({ 67 | conf: 'grouped', 68 | order: [ 69 | 'color', 70 | 'margin', 71 | 'background-color' 72 | ] 73 | }); 74 | 75 | parseAndRun(content, rule); 76 | 77 | expect(rule.errors.length).to.be.equal(0); 78 | }); 79 | }); 80 | 81 | describe('Not matched order', () => { 82 | it('should check properties sorted in custom order', () => { 83 | const rule = new SortOrder({ 84 | conf: 'grouped', 85 | order: [ 86 | 'margin', 87 | 'background-color', 88 | 'color' 89 | ] 90 | }); 91 | 92 | parseAndRun(content, rule); 93 | 94 | expect(rule.errors.length).to.be.equal(1); 95 | }); 96 | }); 97 | }); 98 | 99 | describe('Grouped order', () => { 100 | it('should check properties order by list positions', () => { 101 | const rule = new SortOrder({ 102 | conf: 'grouped', 103 | startGroupChecking: 1, 104 | order: [ 105 | [ 106 | 'position', 107 | 'right', 108 | 'left', 109 | 'flexbox' 110 | ], 111 | ['font-size'], 112 | [ 113 | 'color', 114 | 'background-color' 115 | ] 116 | ] 117 | }); 118 | 119 | parseAndRun('.tab\n' + 120 | '\tposition absolute\n' + 121 | '\tright 10px\n' + 122 | '\tleft 10px\n' + 123 | '\tflexbox()\n' + 124 | '\tfont-size 10px\n' + 125 | '\tcolor #CCC\n' + 126 | '\tbackground-color $p.color\n' + 127 | '', rule); 128 | 129 | expect(rule.errors.length).to.be.equal(2); 130 | }); 131 | 132 | describe('Set big startGroupChecking', () => { 133 | it('should not check properties in alphabetical order', () => { 134 | const rule = new SortOrder({ 135 | conf: 'grouped', 136 | startGroupChecking: 7, 137 | order: [ 138 | [ 139 | 'position', 140 | 'right', 141 | 'left' 142 | ], 143 | ['font-size'], 144 | [ 145 | 'color', 146 | 'background-color' 147 | ] 148 | ] 149 | }); 150 | 151 | parseAndRun('.tab\n' + 152 | '\tposition absolute\n' + 153 | '\tright 10px\n' + 154 | '\tleft 10px\n' + 155 | '\tfont-size 10px\n' + 156 | '\tcolor #CCC\n' + 157 | '\tbackground-color $p.color\n' + 158 | '', rule); 159 | 160 | expect(rule.errors.length).to.be.equal(0); 161 | }); 162 | }); 163 | 164 | describe('Different register', () => { 165 | it('should check the properties has some order', () => { 166 | const rule = new SortOrder({ 167 | conf: 'grouped', 168 | startGroupChecking: 17, 169 | order: [ 170 | [ 171 | 'position', 172 | 'right', 173 | 'useGPU', 174 | 'usegRu', 175 | 'left' 176 | ], 177 | ['font-size'], 178 | [ 179 | 'color', 180 | 'background-color' 181 | ] 182 | ] 183 | }); 184 | 185 | parseAndRun('.tab\n' + 186 | '\tposition absolute\n' + 187 | '\tright 10px\n' + 188 | '\tuseGPU();\n' + 189 | '\tuseGRU();\n' + 190 | '\tleft 10px\n' + 191 | '\tfont-size 10px\n' + 192 | '\tcolor #CCC\n' + 193 | '\tbackground-color $p.color\n' + 194 | '', rule); 195 | 196 | expect(rule.errors.length).to.be.equal(0); 197 | }); 198 | }); 199 | 200 | describe('Check default options rule', () => { 201 | it('Should check by default options', () => { 202 | const rule = new SortOrder({ 203 | conf: 'grouped', 204 | startGroupChecking: 6, 205 | order: (data).sortOrder.order 206 | }); 207 | 208 | parseAndRun('&__item3-title\n' + 209 | '\tabsolute top basis(3) right basis(3)\n' + 210 | '\tz-index 100\n' + 211 | '\n' + 212 | '\tdisplay flex\n' + 213 | '\tflex-direction column\n' + 214 | '\tjustify-content center\n' + 215 | '\talign-items center\n' + 216 | '\tsize 12px 0\n' + 217 | '\twidth basis(40)\n' + 218 | '\tpadding basis(4) 0\n' + 219 | '\n' + 220 | '\tfont Roboto\n' + 221 | '\tfont-aboto Bold\n' + 222 | '\tfont-roboto Bold\n' + 223 | '\tfont-toboto Bold\n' + 224 | '\tfont-size 12px\n' + 225 | '\n' + 226 | '\tborder-radius 7px\n' + 227 | '\tbackground-color $p.dialogBackground' + 228 | '\n' + 229 | '\tdiv&\n' + 230 | '\t\tcursor default\n' + 231 | '\n' + 232 | '\ta&\n' + 233 | '\t\tuser-select none\n' + 234 | '\t\t&::before\n' + 235 | '\t\t\tabsolute left top\n' + 236 | '\n' + 237 | '\t\t\tcontent ""\n' + 238 | '\t\t\tdisplay block\n' + 239 | '\t\t\twidth 100%\n' + 240 | '\t\t\theight basis()\n' + 241 | '\n' + 242 | '\t\t\tbackground-image $p.shadowBgColor\n' + 243 | '\t&_selected_true\n' + 244 | '\t\tpadding-left $p.paddingLeft' + 245 | '', rule); 246 | 247 | expect(rule.errors.length).to.be.equal(0); 248 | }); 249 | }); 250 | }); 251 | }); 252 | -------------------------------------------------------------------------------- /tests/rules/depthControlTest.ts: -------------------------------------------------------------------------------- 1 | import { DepthControl } from '../../src/rules/index'; 2 | import { expect } from 'chai'; 3 | import { parseAndRun } from '../staff/bootstrap'; 4 | 5 | describe('Depth control test', () => { 6 | describe('Right content', () => { 7 | it('Should check all line has normal indent by previous', () => { 8 | const rule = new DepthControl({ 9 | conf: 'always', 10 | indentPref: 'tab' 11 | }); 12 | 13 | parseAndRun( 14 | '$p = {\n' + 15 | '\ta: #ccc,\n' + 16 | '\tb: #ddd\n' + 17 | '}\n' + 18 | '.test\n' + 19 | '\tmax-height red;\n' + 20 | '\n' + 21 | '\tborder black', 22 | rule 23 | ); 24 | 25 | expect(rule.errors.length).to.be.equal(0); 26 | }); 27 | describe('Selector after nested selector', () => { 28 | it('Should not throw error', () => { 29 | const rule = new DepthControl({ 30 | conf: 'always', 31 | indentPref: 'tab' 32 | }); 33 | 34 | parseAndRun( 35 | '.test\n' + 36 | '\t&__offer\n' + 37 | '\t\tdisplay block\n' + 38 | '\n' + 39 | '\t\t&:last-child\n' + 40 | '\t\t\tbackground-position top center\n' + 41 | '\n' + 42 | '\t&__rtb-offers\n' + 43 | '\t\tdisplay block\n', 44 | rule 45 | ); 46 | 47 | expect(rule.errors.length).to.be.equal(0); 48 | }); 49 | }); 50 | describe('Property after @media', () => { 51 | describe('Property has indent equal @media\'s indent + 1', () => { 52 | it('Should not show error', () => { 53 | const rule = new DepthControl({ 54 | conf: 'always' 55 | }); 56 | 57 | parseAndRun( 58 | '.test\n' + 59 | '\tmax-height red;\n' + 60 | '\t@media screen and (max-width 600px) \n' + 61 | '\t\tmax-height red;\n' + 62 | '\t\tborder black', 63 | rule 64 | ); 65 | 66 | expect(rule.errors.length).to.be.equal(0); 67 | }); 68 | }); 69 | describe('Property has indent not equal @media\'s indent + 1', () => { 70 | it('Should show error', () => { 71 | const rule = new DepthControl({ 72 | conf: 'always' 73 | }); 74 | 75 | parseAndRun( 76 | '.test\n' + 77 | '\tmax-height red;\n' + 78 | '\t@media screen and (max-width 600px) \n' + 79 | '\t\t\tborder black', 80 | rule 81 | ); 82 | 83 | expect(rule.errors.length).to.be.equal(1); 84 | }); 85 | }); 86 | }); 87 | }); 88 | describe('Wrong content', () => { 89 | it('Should check all line has normal indent by previous', () => { 90 | const rule = new DepthControl({ 91 | conf: 'always', 92 | indentPref: 'tab' 93 | }); 94 | 95 | parseAndRun( 96 | '$p = {\n' + 97 | '\ta: #ccc,\n' + 98 | '\t\tb: #ddd\n' + 99 | '}\n' + 100 | '.test\n' + 101 | '\tmax-height red\n' + 102 | '\n' + 103 | '\t\t\tmax-height red;\n' + 104 | '\tborder black', 105 | rule 106 | ); 107 | 108 | expect(rule.errors.length).to.be.equal(2); 109 | }); 110 | 111 | describe('Selector after selector', () => { 112 | describe('In one line', () => { 113 | it('Should not show error', () => { 114 | const rule = new DepthControl({ 115 | conf: 'always' 116 | }); 117 | 118 | parseAndRun( 119 | '.test, .test2\n' + 120 | '\tmax-height red;\n' 121 | , rule 122 | ); 123 | 124 | expect(rule.errors.length).to.be.equal(0); 125 | }); 126 | }); 127 | describe('On different lines', () => { 128 | it('Should show error', () => { 129 | const rule = new DepthControl({ 130 | conf: 'always' 131 | }); 132 | 133 | parseAndRun( 134 | '.test,\n' + 135 | '\t\t.test2\n' + 136 | '\tmax-height red;\n' 137 | , rule 138 | ); 139 | 140 | // Property indent wrong too because it's parents .test2 and .test 141 | expect(rule.errors.length).to.be.equal(2); 142 | }); 143 | }); 144 | }); 145 | 146 | describe('Use spaces', () => { 147 | it('Should check all line has normal indent by previous', () => { 148 | const rule = new DepthControl({ 149 | conf: 'always', 150 | indentPref: 4 // TODO does not work fo 2 - because of lexer.js remove some spaces 151 | }); 152 | 153 | parseAndRun( 154 | '$p = {\n' + 155 | ' a: #ccc\n' + 156 | ' b: #ddd\n' + 157 | '}\n' + 158 | '.test\n' + 159 | ' max-height red;\n' + 160 | '\n' + 161 | ' max-height red;\n' + 162 | ' border black', 163 | rule 164 | ); 165 | 166 | expect(rule.errors.length).to.be.equal(0); 167 | }); 168 | }); 169 | }); 170 | describe('Depth control in the hash', () => { 171 | it('Should check depth in hash', () => { 172 | const rule = new DepthControl({ 173 | conf: 'always' 174 | }); 175 | 176 | parseAndRun( 177 | '$p = {\n' + 178 | '\ta: #ccc\n' + 179 | '\t\tb: #ddd\n' + 180 | '}\n' + 181 | '.test\n' + 182 | '\tcolor red\n' 183 | , 184 | rule 185 | ); 186 | 187 | expect(rule.errors.length).to.be.equal(1); 188 | }); 189 | 190 | describe('hash in hash', () => { 191 | it('Should check depth', () => { 192 | const rule = new DepthControl({ 193 | conf: 'always' 194 | }); 195 | 196 | parseAndRun( 197 | '$p = {\n' + 198 | '\ta: #ccc\n' + 199 | '\tb: {\n' + 200 | '\t\tc: #ddd\n' + 201 | '\t\td: #fff\n' + 202 | '\t}\n' + 203 | '}\n' + 204 | '.test\n' + 205 | '\tcolor red\n' 206 | , 207 | rule 208 | ); 209 | 210 | expect(rule.errors.length).to.be.equal(0); 211 | }); 212 | describe('Wrong depth', () => { 213 | it('Should check depth', () => { 214 | const rule = new DepthControl({ 215 | conf: 'always' 216 | }); 217 | 218 | parseAndRun( 219 | '$p = {\n' + 220 | '\ta: #ccc\n' + 221 | '\tb: {\n' + 222 | '\t\tc: #ddd\n' + 223 | '\td: #fff\n' + 224 | '\t}\n' + 225 | '}\n' + 226 | '.test\n' + 227 | '\tcolor red\n' 228 | , 229 | rule 230 | ); 231 | 232 | expect(rule.errors.length).to.be.equal(1); 233 | }); 234 | }); 235 | }); 236 | }); 237 | describe('IF ELSE statement', () => { 238 | describe('Right depth', () => { 239 | it('Should not show error', () => { 240 | const rule = new DepthControl({ 241 | conf: 'always' 242 | }); 243 | 244 | parseAndRun( 245 | '$p = {\n' + 246 | '\tshow: true\n' + 247 | '}\n' + 248 | '.test\n' + 249 | '\tdisplay none\n' + 250 | '\tif $p.show\n' + 251 | '\t\tdisplay block\n' + 252 | '' 253 | , 254 | rule 255 | ); 256 | 257 | expect(rule.errors.length).to.be.equal(0); 258 | }); 259 | }); 260 | describe('Wrong depth', () => { 261 | it('Should show error', () => { 262 | const rule = new DepthControl({ 263 | conf: 'always' 264 | }); 265 | 266 | parseAndRun( 267 | '$p = {\n' + 268 | '\tshow: true\n' + 269 | '}\n' + 270 | '.test\n' + 271 | '\tdisplay none\n' + 272 | '\tif $p.show\n' + 273 | '\t\t\tdisplay block\n' + 274 | '' 275 | , 276 | rule 277 | ); 278 | 279 | expect(rule.errors.length).to.be.equal(1); 280 | }); 281 | }); 282 | }); 283 | 284 | describe('Keyframes', () => { 285 | describe('Right depth', () => { 286 | it('Should not show error', () => { 287 | const rule = new DepthControl({ 288 | conf: 'always' 289 | }); 290 | 291 | parseAndRun( 292 | '@keyframes spinner\n' + 293 | '\t0%\n' + 294 | '\t\ttransform rotate(0)\n' + 295 | '\t100%\n' + 296 | '\t\ttransform rotate(360deg)\n' + 297 | '' 298 | , 299 | rule 300 | ); 301 | 302 | expect(rule.errors.length).to.be.equal(0); 303 | }); 304 | }); 305 | describe('Wrong depth', () => { 306 | it('Should show error', () => { 307 | const rule = new DepthControl({ 308 | conf: 'always' 309 | }); 310 | 311 | parseAndRun( 312 | '@keyframes spinner\n' + 313 | '\t\t0%\n' + 314 | '\t\t\ttransform rotate(0)\n' + 315 | '\t100%\n' + 316 | '\t\ttransform rotate(360deg)\n' + 317 | '' 318 | , 319 | rule 320 | ); 321 | 322 | expect(rule.errors.length).to.be.equal(2); 323 | }); 324 | }); 325 | }); 326 | 327 | describe('Inside mixin', () => { 328 | describe('Right depth', () => { 329 | it('Should not show error', () => { 330 | const rule = new DepthControl({ 331 | conf: 'always' 332 | }); 333 | 334 | parseAndRun( 335 | '.test\n' + 336 | '\tchildren-shadow()\n' + 337 | '\t\tposition relative\n' + 338 | '' 339 | , 340 | rule 341 | ); 342 | 343 | expect(rule.errors.length).to.be.equal(0); 344 | }); 345 | }); 346 | describe('Wrong depth', () => { 347 | it('Should show error', () => { 348 | const rule = new DepthControl({ 349 | conf: 'always' 350 | }); 351 | 352 | parseAndRun( 353 | '.test\n' + 354 | '\tchildren-shadow()\n' + 355 | '\t\t\tposition relative\n' + 356 | '' 357 | , 358 | rule 359 | ); 360 | 361 | expect(rule.errors.length).to.be.equal(1); 362 | }); 363 | }); 364 | }); 365 | }); 366 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended", 4 | "tslint-config-prettier", 5 | "tslint-plugin-prettier" 6 | ], 7 | "exclude": [ 8 | "node_modules/**/*" 9 | ], 10 | "rules": { 11 | "member-ordering": false, 12 | "adjacent-overload-signatures": true, 13 | "ban-comma-operator": false, 14 | "member-access": false, 15 | "no-any": false, 16 | "no-empty-interface": false, 17 | "no-import-side-effect": false, 18 | "no-inferrable-types": [ 19 | true, 20 | "ignore-params", 21 | "ignore-properties" 22 | ], 23 | "no-internal-module": true, 24 | "no-magic-numbers": false, 25 | "no-namespace": false, 26 | "no-non-null-assertion": false, 27 | "no-parameter-reassignment": false, 28 | "no-reference": true, 29 | "no-unnecessary-type-assertion": false, 30 | "no-var-requires": false, 31 | "no-var-keyword": true, 32 | "only-arrow-functions": [ 33 | true, 34 | "allow-declarations", 35 | "allow-named-functions" 36 | ], 37 | "prefer-for-of": false, 38 | "prefer-template": false, 39 | "promise-function-async": false, 40 | "typedef": [ 41 | true, 42 | "call-signature", 43 | "parameter", 44 | "property-declaration", 45 | "member-variable-declaration" 46 | ], 47 | "typedef-whitespace": [ 48 | true, 49 | { 50 | "call-signature": "nospace", 51 | "index-signature": "nospace", 52 | "parameter": "nospace", 53 | "property-declaration": "nospace", 54 | "variable-declaration": "nospace" 55 | }, 56 | { 57 | "call-signature": "onespace", 58 | "index-signature": "onespace", 59 | "parameter": "onespace", 60 | "property-declaration": "onespace", 61 | "variable-declaration": "onespace" 62 | } 63 | ], 64 | "unified-signatures": true, 65 | "await-promise": false, 66 | "curly": true, 67 | "forin": false, 68 | "label-position": true, 69 | "no-arg": true, 70 | "no-bitwise": true, 71 | "no-conditional-assignment": true, 72 | "no-console": false, 73 | "no-construct": true, 74 | "no-debugger": false, 75 | "no-duplicate-super": true, 76 | "no-duplicate-switch-case": true, 77 | "no-duplicate-variable": true, 78 | "no-empty": [ 79 | true, 80 | "allow-empty-catch" 81 | ], 82 | "no-eval": true, 83 | "no-floating-promises": false, 84 | "no-for-in-array": true, 85 | "no-implicit-dependencies": false, 86 | "no-inferred-empty-object-type": false, 87 | "no-invalid-template-strings": true, 88 | "no-invalid-this": false, 89 | "no-misused-new": true, 90 | "no-null-keyword": false, 91 | "no-object-literal-type-assertion": false, 92 | "no-return-await": true, 93 | "no-shadowed-variable": false, 94 | "no-sparse-arrays": true, 95 | "no-string-literal": true, 96 | "no-string-throw": true, 97 | "no-submodule-imports": false, 98 | "no-switch-case-fall-through": true, 99 | "no-this-assignment": [ 100 | true, 101 | { 102 | "allowed-names": [ 103 | "^that" 104 | ], 105 | "allow-destructuring": true 106 | } 107 | ], 108 | "no-unbound-method": [ 109 | true, 110 | "ignore-static" 111 | ], 112 | "no-unsafe-any": false, 113 | "no-unsafe-finally": true, 114 | "no-unused-expression": [ 115 | false, 116 | "allow-fast-null-checks", 117 | "allow-new" 118 | ], 119 | "no-unused-variable": [ 120 | true, 121 | { 122 | "ignore-pattern": "^_", 123 | "check-parameters": true 124 | } 125 | ], 126 | "no-use-before-declare": true, 127 | "no-void-expression": false, 128 | "prefer-conditional-expression": true, 129 | "prefer-object-spread": true, 130 | "radix": true, 131 | "restrict-plus-operands": true, 132 | "strict-boolean-expressions": false, 133 | "strict-type-predicates": true, 134 | "switch-default": false, 135 | "triple-equals": [ 136 | true, 137 | "allow-null-check" 138 | ], 139 | "typeof-compare": true, 140 | "use-default-type-parameter": false, 141 | "use-isnan": true, 142 | "cyclomatic-complexity": [ 143 | true, 144 | 50 145 | ], 146 | "deprecation": true, 147 | "eofline": true, 148 | "indent": [ 149 | true, 150 | "tabs" 151 | ], 152 | "linebreak-style": [ 153 | true, 154 | "LF" 155 | ], 156 | "max-classes-per-file": [ 157 | true, 158 | 5, 159 | "exclude-class-expressions" 160 | ], 161 | "max-file-line-count": [ 162 | true, 163 | 1700 164 | ], 165 | "max-line-length": [ 166 | true, 167 | 120 168 | ], 169 | "no-default-export": false, 170 | "no-duplicate-imports": true, 171 | "no-mergeable-namespace": true, 172 | "no-require-imports": false, 173 | "object-literal-sort-keys": false, 174 | "prefer-const": true, 175 | "trailing-comma": [ 176 | true, 177 | { 178 | "multiline": "never", 179 | "singleline": "never" 180 | } 181 | ], 182 | "align": [ 183 | true, 184 | "elements", 185 | "members", 186 | "parameters", 187 | "statements" 188 | ], 189 | "array-type": false, 190 | "arrow-parens": true, 191 | "arrow-return-shorthand": [ 192 | true, 193 | "multiline" 194 | ], 195 | "binary-expression-operand-order": true, 196 | "callable-types": false, 197 | "class-name": false, 198 | "comment-format": [ 199 | false, 200 | "check-space", 201 | "check-uppercase" 202 | ], 203 | "completed-docs": [ 204 | true, 205 | { 206 | "functions": { 207 | "visibilities": [ 208 | "exported" 209 | ] 210 | }, 211 | "properties": { 212 | "locations": "all", 213 | "privacies": [ 214 | "public", 215 | "protected" 216 | ] 217 | }, 218 | "methods": { 219 | "locations": "all", 220 | "privacies": [ 221 | "public", 222 | "protected" 223 | ] 224 | }, 225 | "tags": { 226 | "content": { 227 | "see": [ 228 | "#.*" 229 | ] 230 | }, 231 | "exists": [ 232 | "inheritdoc" 233 | ] 234 | } 235 | } 236 | ], 237 | "encoding": true, 238 | "file-header": false, 239 | "import-spacing": true, 240 | "interface-name": false, 241 | "interface-over-type-literal": true, 242 | "jsdoc-format": true, 243 | "match-default-export-name": false, 244 | "newline-before-return": false, 245 | "new-parens": true, 246 | "no-angle-bracket-type-assertion": false, 247 | "no-boolean-literal-compare": false, 248 | "no-consecutive-blank-lines": [ 249 | true, 250 | 1 251 | ], 252 | "no-irregular-whitespace": true, 253 | "no-parameter-properties": false, 254 | "no-redundant-jsdoc": false, 255 | "no-reference-import": true, 256 | "no-trailing-whitespace": true, 257 | "no-unnecessary-callback-wrapper": true, 258 | "no-unnecessary-initializer": true, 259 | "no-unnecessary-qualifier": true, 260 | "number-literal-format": true, 261 | "object-literal-key-quotes": [ 262 | true, 263 | "consistent-as-needed" 264 | ], 265 | "object-literal-shorthand": true, 266 | "one-line": [ 267 | true, 268 | "check-catch", 269 | "check-finally", 270 | "check-else", 271 | "check-open-brace", 272 | "check-whitespace" 273 | ], 274 | "one-variable-per-declaration": false, 275 | "ordered-imports": false, 276 | "prefer-function-over-method": false, 277 | "prefer-method-signature": true, 278 | "prefer-switch": [ 279 | true, 280 | { 281 | "min-cases": 3 282 | } 283 | ], 284 | "quotemark": [ 285 | true, 286 | "single", 287 | "avoid-escape", 288 | "avoid-template" 289 | ], 290 | "return-undefined": true, 291 | "semicolon": [ 292 | true, 293 | "always", 294 | "ignore-interfaces", 295 | "strict-bound-class-methods" 296 | ], 297 | "space-before-function-paren": [ 298 | true, 299 | { 300 | "anonymous": "always", 301 | "asyncArrow": "always" 302 | } 303 | ], 304 | "space-within-parens": [ 305 | true, 306 | 0 307 | ], 308 | "switch-final-break": true, 309 | "type-literal-delimiter": true, 310 | "variable-name": [ 311 | true, 312 | "check-format", 313 | "ban-keywords", 314 | "allow-leading-underscore", 315 | "allow-pascal-case" 316 | ], 317 | "whitespace": [ 318 | true, 319 | "check-branch", 320 | "check-decl", 321 | "check-operator", 322 | "check-module", 323 | "check-separator", 324 | "check-rest-spread", 325 | "check-type", 326 | "check-type-operator", 327 | "check-preblock" 328 | ] 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/rules/sortOrder.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../core/rule'; 2 | import { Block, Property, Value, Node } from '../core/ast/index'; 3 | import { IState } from '../core/types/state'; 4 | import { Line } from '../core/line'; 5 | import { Content } from '../core/content'; 6 | import { INode } from '../core/types/ast/node'; 7 | 8 | interface IOrderState extends IState { 9 | order?: Array, 10 | startGroupChecking?: number 11 | } 12 | 13 | interface PropertyNameAndBound { 14 | name: string; 15 | startLine: number; 16 | endLine: number; 17 | } 18 | 19 | type Properties = PropertyNameAndBound[]; 20 | type PropertiesGroups = Array; 21 | 22 | interface FixObject { 23 | first?: Property | Value | void; 24 | last?: Property | Value | void; 25 | toString(): string; 26 | fix: Array>; 27 | } 28 | 29 | /** 30 | * Rule for checking properties order. Can use alphabetical order or order from grouped array 31 | */ 32 | export class SortOrder extends Rule { 33 | nodesFilter: string[] = ['block']; 34 | 35 | checkNode(node: Block, content: Content): void { 36 | const 37 | propertiesGroups: PropertiesGroups = [], 38 | propertyToLine: Dictionary = {}; 39 | 40 | this.fillPropertiesNameAndLine(node, propertiesGroups, propertyToLine, content); 41 | 42 | // sort only 2 and more properties 43 | if (propertiesGroups.reduce((cnt, array) => { 44 | cnt += array.length; 45 | return cnt; 46 | }, 0) < 2) { 47 | return; 48 | } 49 | 50 | this.sort(propertiesGroups); 51 | 52 | propertiesGroups.forEach((properties, group) => { 53 | const 54 | hasOrderError = this.hasSortError(node, properties, group), 55 | fixObject: FixObject = this.getFixObject(node, properties, content, group); 56 | 57 | if (hasOrderError && fixObject.last && fixObject.first) { 58 | const 59 | lastLine = this.getLastLine(fixObject.last); 60 | 61 | this.msg( 62 | `Properties have wrong order - ${properties.map((item) => item.name).join(', ')}`, 63 | fixObject.first.lineno, 64 | 1, 65 | content.getLine(lastLine).line.length, 66 | fixObject, // We can change 'fix' array in checkSeparatorLines 67 | lastLine 68 | ); 69 | } 70 | 71 | this.checkSeparatorLines(hasOrderError, properties, propertyToLine, fixObject); 72 | }); 73 | } 74 | 75 | private forEachProperty( 76 | node: Node, 77 | groupId: number, 78 | callback: (child: Property | Value, indexInGroup: number) => void | boolean 79 | ): void | boolean { 80 | let child: INode, group = 0, indexInGroup = 0, result: void | boolean; 81 | 82 | for (let i = 0; i < node.nodes.length; i += 1) { 83 | child = node.nodes[i]; 84 | 85 | if (child.line && child.line.isIgnored) { 86 | continue; 87 | } 88 | 89 | if (child instanceof Property || child instanceof Value) { 90 | if (group === groupId) { 91 | result = callback(child, indexInGroup); 92 | 93 | if (result !== undefined) { 94 | return result; 95 | } 96 | 97 | indexInGroup += 1; 98 | } 99 | } else { 100 | group += 1; 101 | indexInGroup = 0; 102 | } 103 | } 104 | } 105 | 106 | private getLastLine(child: Node): number { 107 | return (child.value && child.value instanceof Node) ? child.value.lineno : child.lineno; 108 | } 109 | 110 | private fillPropertiesNameAndLine( 111 | node: Block, 112 | properties: PropertiesGroups, 113 | propertyToLine: Dictionary, 114 | content: Content 115 | ): void { 116 | let group: PropertyNameAndBound[] = []; 117 | 118 | node.nodes.forEach((child) => { 119 | if (child.line && child.line.isIgnored) { 120 | return; 121 | } 122 | 123 | if (child instanceof Property || child instanceof Value) { 124 | const 125 | name = child.key.toString().toLowerCase(); 126 | 127 | group.push( 128 | { 129 | name, 130 | startLine: child.lineno, 131 | endLine: this.getLastLine(child) 132 | } 133 | ); 134 | 135 | propertyToLine[name] = content.getLine(child.lineno); 136 | } else { 137 | if (group.length) { 138 | properties.push(group); 139 | } 140 | 141 | group = []; 142 | } 143 | }); 144 | 145 | if (group.length) { 146 | properties.push(group); 147 | } 148 | } 149 | 150 | private sort(properties: PropertiesGroups): void { 151 | if (this.state.conf === 'alphabetical') { 152 | this.sortAlphabetical(properties); 153 | } else { 154 | this.fillCacheOrder(this.state.order || []); 155 | this.sortByGroupedOrder(properties); 156 | } 157 | } 158 | 159 | private sortAlphabetical(properties: PropertiesGroups): void { 160 | properties 161 | .forEach((group) => group.sort((a, b) => { 162 | if (a.name === b.name) { 163 | return 0; 164 | } 165 | 166 | return a.name > b.name ? 1 : -1; 167 | })); 168 | } 169 | 170 | private fillCacheOrder(order: Array): void { 171 | if (!this.cache.order) { 172 | this.cache.keyToGroup = {}; 173 | 174 | let groupIndex = 0; 175 | 176 | this.cache.order = order.reduce((sort, key) => { 177 | if (typeof key === 'string') { 178 | sort.push(key.toLowerCase()); 179 | } else { 180 | sort.push.apply(sort, key.map((subkey) => subkey.toLowerCase())); 181 | key.forEach((subkey) => this.cache.keyToGroup[subkey.toLowerCase()] = groupIndex); 182 | groupIndex += 1; 183 | } 184 | return sort; 185 | }, []); 186 | } 187 | } 188 | 189 | private sortByGroupedOrder(properties: PropertiesGroups): void { 190 | properties 191 | .forEach((group) => group.sort((keyA, keyB) => { 192 | const 193 | values = >{ 194 | keyA: keyA.name, 195 | keyB: keyB.name 196 | }, 197 | index = >{ 198 | keyA: this.cache.order.indexOf(keyA.name), 199 | keyB: this.cache.order.indexOf(keyB.name) 200 | }, 201 | keys = Object.keys(index); 202 | 203 | for (const key of keys) { 204 | if (index[key] === -1) { 205 | const parts = values[key].split('-'); 206 | 207 | if (parts.length > 1) { 208 | let l = parts.length - 1; 209 | 210 | while (l > 0 && index[key] === -1) { 211 | index[key] = this.cache.order.indexOf(parts.slice(0, l).join('-')); 212 | 213 | if (index[key] !== -1) { 214 | index[key] += 1; 215 | } 216 | l -= 1; 217 | } 218 | } 219 | } 220 | 221 | if (index[key] === -1) { 222 | return values.keyA > values.keyB ? 1 : -1; 223 | } 224 | } 225 | 226 | if (index.keyA === index.keyB) { 227 | return values.keyA > values.keyB ? 1 : -1; 228 | } 229 | 230 | return index.keyA - index.keyB; 231 | }) 232 | ); 233 | } 234 | 235 | private hasSortError(node: Node, properties: Properties, groupId: number): boolean { 236 | return this.forEachProperty(node, groupId, (child, index) => { 237 | const name = child.key.toString().toLowerCase(); 238 | 239 | if (properties[index].name !== name) { 240 | return true; 241 | } 242 | }) || false; 243 | } 244 | 245 | /** 246 | * Returns fix object for fix some part of stylus file 247 | * 248 | * @param node 249 | * @param properties 250 | * @param content 251 | * @param groupId 252 | */ 253 | private getFixObject(node: Node, properties: Properties, content: Content, groupId: number): FixObject { 254 | let 255 | index = 0, 256 | indexNoOrdered = 0, 257 | last: Property | Value | void = void (0), 258 | first: Property | Value | void = void (0); 259 | const 260 | fix: Array> = []; 261 | 262 | const 263 | partLines: Array> = []; 264 | 265 | this.forEachProperty(node, groupId, (child) => { 266 | const name = child.key.toString().toLowerCase(); 267 | 268 | if (properties[index].name !== name) { 269 | if (!first) { 270 | first = child; 271 | } 272 | 273 | last = child; 274 | 275 | let 276 | start = properties[index].startLine; 277 | 278 | if (start !== properties[index].endLine) { 279 | const st = []; 280 | 281 | for (; start <= properties[index].endLine; start += 1) { 282 | st.push(content.getLine(start)); 283 | } 284 | 285 | fix[indexNoOrdered] = st; 286 | } else { 287 | fix[indexNoOrdered] = content.getLine(start); 288 | } 289 | } 290 | 291 | if (first) { 292 | const end = this.getLastLine(child); 293 | 294 | let 295 | start = child.lineno; 296 | 297 | if (start !== end) { 298 | const st = []; 299 | 300 | for (; start <= end; start += 1) { 301 | st.push(content.getLine(start)); 302 | } 303 | 304 | partLines[indexNoOrdered] = st; 305 | } else { 306 | partLines[indexNoOrdered] = content.getLine(start); 307 | } 308 | 309 | indexNoOrdered += 1; 310 | } 311 | 312 | index += 1; 313 | }); 314 | 315 | for (let i = 0; i < fix.length; i += 1) { 316 | if (fix[i] === undefined) { 317 | fix[i] = partLines[i]; 318 | } 319 | } 320 | 321 | const result = { 322 | first, 323 | last, 324 | fix, 325 | toString(): string { 326 | return result.fix 327 | .reduce>((array, line) => { 328 | if (Array.isArray(line)) { 329 | array.push(...line); 330 | } else { 331 | array.push(line); 332 | } 333 | 334 | return array; 335 | }, []) 336 | .map((line) => typeof line === 'string' ? line : line.line) 337 | .join('\n'); 338 | 339 | } 340 | }; 341 | 342 | return result; 343 | } 344 | 345 | private checkSeparatorLines( 346 | hasOrderError: boolean, 347 | properties: Properties, 348 | propertyToLine: Dictionary, 349 | fixObject: FixObject 350 | ): void { 351 | const 352 | startGroupChecking = this.state.startGroupChecking || 6; 353 | 354 | if ( 355 | properties.length >= startGroupChecking && 356 | this.state.conf === 'grouped' 357 | ) { 358 | let 359 | lastGroup: null | number | void = null; 360 | 361 | properties.forEach((property) => { 362 | const group = this.getGroupByName(property.name); 363 | 364 | if (group !== undefined && group !== lastGroup) { 365 | if (lastGroup !== null) { 366 | const line = propertyToLine[property.name]; 367 | 368 | if (line) { 369 | if (!hasOrderError) { 370 | const prev = line.prev(); 371 | 372 | if (prev && prev.line.trim().length !== 0) { 373 | this.msg('Need new line after group', prev.lineno, 1, prev.line.length, prev.line + '\n'); 374 | } 375 | } else { 376 | this.addSeparateLineAfterLine(fixObject, line); 377 | } 378 | } 379 | } 380 | 381 | lastGroup = group; 382 | } 383 | }); 384 | } 385 | } 386 | 387 | private getGroupByName(name: string): number | void { 388 | let 389 | group = this.cache.keyToGroup[name]; 390 | 391 | if (group === undefined) { 392 | const parts = name.split('-'); 393 | 394 | if (parts.length > 1) { 395 | let l = parts.length - 1; 396 | 397 | while (l > 0 && group === undefined) { 398 | group = this.cache.keyToGroup[parts.slice(0, l).join('-')]; 399 | 400 | l -= 1; 401 | } 402 | } 403 | } 404 | 405 | return group; 406 | } 407 | 408 | private addSeparateLineAfterLine(fixObject: FixObject, line: Line): void { 409 | const 410 | index = fixObject.fix.indexOf(line) - 1, 411 | prev = fixObject.fix[index]; 412 | 413 | if (index > 0 && prev && prev instanceof Line && prev.line && prev.line.trim().length) { 414 | fixObject.fix = [...fixObject.fix.slice(0, index + 1), '', ...fixObject.fix.slice(index + 1)]; 415 | } 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Stylus Linter 2 | [![Build Status](https://travis-ci.org/stylus/stlint.svg?branch=master)](https://travis-ci.org/stylus/stlint) 3 | [![NPM version](https://img.shields.io/npm/v/stlint.svg)](https://www.npmjs.org/package/stlint) 4 | [![NPM Downloads](https://img.shields.io/npm/dm/stlint.svg)](https://npmcharts.com/compare/stlint?minimal=true) 5 | 6 | [![NPM](https://nodei.co/npm/stlint.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/stlint/) 7 | 8 | * [Issues](https://github.com/stylus/stlint/issues) 9 | * [Installation](#installation) 10 | * [Example cli Usage](#example-cli-usage) 11 | * [CLI](#cli) 12 | * [Non CLI Usage](#non-cli-usage) 13 | * [As Part of Your Workflow](#as-part-of-your-workflow) 14 | * [Ignore errors](#ignore-errors) 15 | * [Rules](#rules) 16 | * [License](#license) 17 | 18 | ## Installation 19 | 20 | As part of your project 21 | ```bash 22 | npm i stlint -D 23 | ``` 24 | 25 | As a cli tool: 26 | ```bash 27 | npm install stlint -g 28 | ``` 29 | 30 | ## Example cli Usage: 31 | `npx stlint` Run stlint on cwd 32 | 33 | `stlint` Run stlint on cwd as global 34 | 35 | `stlint path/to/filename.styl` Run stlint on a file 36 | 37 | `stlint path/to/dir --watch` Watch dir, run stlint on file change 38 | 39 | `stlint --help` Get list of commands 40 | 41 | `stlint --version` Get version number 42 | 43 | `stlint --config path/to/config/.configrc` Run stlint with custom config settings 44 | 45 | `stlint styl/ --watch -c path/to/config/.configrc` Watch dir, use custom config 46 | 47 | `stlint --command autocomplete --content #e --offset 0 --lineoffset 0` Get autocomplete suggestions for `#` string 48 | 49 | ## CLI 50 | `-h` or `--help` Display list of commands 51 | 52 | `-w` or `--watch` Watch file or directory and run lint on change 53 | 54 | `-c` or `--config` Pass in location of custom config file 55 | 56 | `-v` or `--version` Display current version 57 | 58 | `-g` or `--grep` Only run rules matching this string or regexp 59 | 60 | `-f` or `--fix` Try fix some issues 61 | 62 | `-i` or `--info` Show info about version and config (can be used with --grep for filter rules) 63 | 64 | `-r` or `--reporter` Reporter "raw", "json" or "silent" 65 | 66 | All another options from [config](#Config file) 67 | 68 | ## Non CLI Usage 69 | ```javascript 70 | const StylusLinter = require('stlint').StylusLinter; 71 | StylusLinter('path/to/stylus/', { 72 | watch: true 73 | }); 74 | ``` 75 | or check only one file or text 76 | ```javascript 77 | const Linter = require('stlint').Linter; 78 | const linter = new Linter({ 79 | ...options 80 | }); 81 | 82 | // if you want check content 83 | linter.lint('./test.styl', 'content'); 84 | 85 | // if you want check file 86 | linter.lint('./test.styl'); 87 | 88 | // and display errors. 89 | linter.display(); 90 | ``` 91 | 92 | ## Config file 93 | Create `.stlintrc` file in project root 94 | ```json 95 | { 96 | "reporter": "raw", 97 | "watch": false, 98 | "extends": ["stlint-v4fire", "./test/.myfileconfig.json"], 99 | "extraRules": "./my-rules/", 100 | "rules": { 101 | "color": false, 102 | "colons": ["always"], 103 | "depthControl": { 104 | "indentPref": 4 105 | } 106 | }, 107 | "path": "./src", 108 | "excludes": ["node_modules/"], 109 | "stylusParserOptions": {}, 110 | "reportOptions": { 111 | "columnSplitter": " | ", 112 | "maxWidth": 70, 113 | "minWidth": 70, 114 | "truncate": false 115 | } 116 | } 117 | ``` 118 | ## As Part of Your Workflow 119 | Stlint integrations with IDEs are available. 120 | 121 | * [WebStorm / PhpStorm / IntelliJ IDEA](https://github.com/stylus/stlint-idea-plugin) 122 | * [VSCode](https://marketplace.visualstudio.com/items?itemName=xdan.stlint-vscode-plugin) 123 | 124 | ## Ignore errors 125 | sometimes you want to ignore the error for this there are two directives: 126 | * `@stlint-ignore` - ignores only one line after it 127 | * `@stlint-disable` `@stlint-enable` - ignore block (@stlint-enable is optional) 128 | 129 | For example, in the following code, some errors will be ignored. 130 | ```stylus 131 | $p = { 132 | a: #CCC 133 | // @stlint-ignore 134 | b: #ccc // need use uppercase notation will be ignored 135 | c: 10px 136 | } 137 | .test 138 | margin-top 20px 139 | // @stlint-disable 140 | padding-top 20px // need use mixin will be ignored 141 | color #ccc // need use uppercase notation and use variable will be ignored 142 | // @stlint-enable 143 | background-color #ddd 144 | ``` 145 | 146 | Respectively, in order not to display errors of the entire file, it is enough to add an 147 | `@stlint-disable` directive to its beginning 148 | 149 | ```stylus 150 | // @stlint-disable - all errors below will be ignored 151 | $p = { 152 | a: #CCC 153 | b: #ccc 154 | c: 10px 155 | } 156 | .test 157 | margin-top 20px 158 | padding-top 20px 159 | color #ccc 160 | background-color #ddd 161 | ``` 162 | 163 | ## Rules 164 | 165 | 166 | ### brackets 167 | Check for brackets 168 | 169 | **Default value** 170 | ```json 171 | [ 172 | "never" 173 | ] 174 | ``` 175 | ---- 176 | 177 | ### colons 178 | Use/Do not use colons after property 179 | 180 | **Default value** 181 | ```json 182 | [ 183 | "never" 184 | ] 185 | ``` 186 | ---- 187 | 188 | ### color 189 | Process all color values. Allow or deny use it not in variable and use uppercase or lowercase statements 190 | For example this code has error - because we use only color in `uppercase` 191 | ```stylus 192 | .test 193 | color #ccc 194 | ``` 195 | If `allowOnlyInVar` === true code above also has error - no use color without variable 196 | 197 | Fixed code 198 | ```stylus 199 | $color = #CCC 200 | .test 201 | color $color 202 | ``` 203 | 204 | **Default value** 205 | ```json 206 | { 207 | "conf": "uppercase", 208 | "enabled": true, 209 | "allowOnlyInVar": true, 210 | "allowShortcut": true, 211 | "denyRGB": true 212 | } 213 | ``` 214 | ---- 215 | 216 | ### commaInObject 217 | Allow or deny commas in object hash 218 | 219 | **Default value** 220 | ```json 221 | [ 222 | "never" 223 | ] 224 | ``` 225 | ---- 226 | 227 | ### depthControl 228 | Control depth spaces or tab 229 | 230 | **Default value** 231 | ```json 232 | { 233 | "indentPref": "tab" 234 | } 235 | ``` 236 | ---- 237 | 238 | ### emptyLines 239 | Check if document has several empty lines 240 | 241 | **Default value** 242 | ```json 243 | true 244 | ``` 245 | ---- 246 | 247 | ### leadingZero 248 | Check for leading 0 on numbers ( 0.5 ) 249 | 250 | **Default value** 251 | ```json 252 | [ 253 | "always" 254 | ] 255 | ``` 256 | ---- 257 | 258 | ### mixedSpaces 259 | check for mixed spaces and tabs 260 | 261 | **Default value** 262 | ```json 263 | { 264 | "indentPref": "tab" 265 | } 266 | ``` 267 | ---- 268 | 269 | ### prefixVarsWithDollar 270 | Check that $ is used when declaring vars 271 | 272 | **Default value** 273 | ```json 274 | { 275 | "conf": "always", 276 | "prefix": "$", 277 | "allowConst": true 278 | } 279 | ``` 280 | ---- 281 | 282 | ### quotePref 283 | Check that quote style is consistent with config 284 | 285 | **Default value** 286 | ```json 287 | [ 288 | "double" 289 | ] 290 | ``` 291 | ---- 292 | 293 | ### semicolons 294 | Check that selector properties are sorted accordingly 295 | 296 | **Default value** 297 | ```json 298 | [ 299 | "never" 300 | ] 301 | ``` 302 | ---- 303 | 304 | ### sortOrder 305 | Rule for checking properties order. Can use alphabetical order or order from grouped array 306 | 307 | **Default value** 308 | ```json 309 | { 310 | "conf": "grouped", 311 | "startGroupChecking": 6, 312 | "order": [ 313 | [ 314 | "absolute", 315 | "position", 316 | "z-index", 317 | "top", 318 | "right", 319 | "bottom", 320 | "left" 321 | ], 322 | [ 323 | "content", 324 | "display", 325 | "flexbox", 326 | "flex", 327 | "flex-grow", 328 | "flex-shrink", 329 | "flex-basis", 330 | "flex-direction", 331 | "order", 332 | "flex-order", 333 | "flex-wrap", 334 | "flex-flow", 335 | "justify-content", 336 | "align-self", 337 | "align-items", 338 | "align-content", 339 | "flex-pack", 340 | "flex-align", 341 | "box-sizing", 342 | "vertical-align", 343 | "size", 344 | "width", 345 | "height", 346 | "max-width", 347 | "min-width", 348 | "max-height", 349 | "min-height", 350 | "overflow", 351 | "overflow-x", 352 | "overflow-y", 353 | "float", 354 | "clear", 355 | "visibility", 356 | "opacity", 357 | "margin", 358 | "margin-top", 359 | "margin-right", 360 | "margin-bottom", 361 | "margin-left", 362 | "padding", 363 | "padding-top", 364 | "padding-right", 365 | "padding-bottom", 366 | "padding-left" 367 | ], 368 | [ 369 | "font", 370 | "font-family", 371 | "font-size", 372 | "font-weight", 373 | "font-style", 374 | "font-variant", 375 | "font-size-adjust", 376 | "font-stretch", 377 | "line-height", 378 | "letter-spacing", 379 | "text-align", 380 | "text-align-last", 381 | "text-decoration", 382 | "text-emphasis", 383 | "text-emphasis-position", 384 | "text-emphasis-style", 385 | "text-emphasis-color", 386 | "text-indent", 387 | "text-justify", 388 | "text-outline", 389 | "text-transform", 390 | "text-wrap", 391 | "text-overflow", 392 | "text-overflow-ellipsis", 393 | "text-overflow-mode", 394 | "word-spacing", 395 | "word-wrap", 396 | "word-break", 397 | "tab-size", 398 | "hyphens" 399 | ], 400 | [ 401 | "pointer-events", 402 | "border", 403 | "border-spacing", 404 | "border-collapse", 405 | "border-width", 406 | "border-style", 407 | "border-color", 408 | "border-top", 409 | "border-top-width", 410 | "border-top-style", 411 | "border-top-color", 412 | "border-right", 413 | "border-right-width", 414 | "border-right-style", 415 | "border-right-color", 416 | "border-bottom", 417 | "border-bottom-width", 418 | "border-bottom-style", 419 | "border-bottom-color", 420 | "border-left", 421 | "border-left-width", 422 | "border-left-style", 423 | "border-left-color", 424 | "border-radius", 425 | "border-top-left-radius", 426 | "border-top-right-radius", 427 | "border-bottom-right-radius", 428 | "border-bottom-left-radius", 429 | "border-image", 430 | "border-image-source", 431 | "border-image-slice", 432 | "border-image-width", 433 | "border-image-outset", 434 | "border-image-repeat", 435 | "border-top-image", 436 | "border-right-image", 437 | "border-bottom-image", 438 | "border-left-image", 439 | "border-corner-image", 440 | "border-top-left-image", 441 | "border-top-right-image", 442 | "border-bottom-right-image", 443 | "border-bottom-left-image", 444 | "color", 445 | "background", 446 | "filter", 447 | "background-color", 448 | "background-image", 449 | "background-attachment", 450 | "background-position", 451 | "background-position-x", 452 | "background-position-y", 453 | "background-clip", 454 | "background-origin", 455 | "background-size", 456 | "background-repeat", 457 | "clip", 458 | "list-style", 459 | "outline", 460 | "outline-width", 461 | "outline-style", 462 | "outline-color", 463 | "outline-offset", 464 | "cursor", 465 | "box-shadow", 466 | "text-shadow", 467 | "table-layout", 468 | "backface-visibility", 469 | "will-change", 470 | "transition", 471 | "transform", 472 | "animation" 473 | ] 474 | ] 475 | } 476 | ``` 477 | ---- 478 | 479 | ### useMixinInsteadUnit 480 | Allo or deny some mixin instead of unit statement 481 | 482 | **Default value** 483 | ```json 484 | { 485 | "conf": "always", 486 | "mixin": "basis", 487 | "unitType": "px", 488 | "allowOneUnit": false 489 | } 490 | ``` 491 | ---- 492 | 493 | ## Self rules 494 | You can create folder and use it for extra rules 495 | ```json 496 | { 497 | "extraRules": "/Users/v-chupurnov/WebstormProjects/test/rules/" 498 | } 499 | ``` 500 | In this folder you can create native JavaScript files 501 | ```javascript 502 | const Rgb = require('stlint').ast.RGB; 503 | 504 | function TestRule() { 505 | nodesFilter = ['rgb']; // can be one of https://github.com/stylus/stlint/tree/master/src/core/ast 506 | 507 | /** 508 | * Check the AST nodes 509 | * @param node 510 | */ 511 | this.checkNode = (node) => { 512 | if (node instanceof Rgb) { 513 | console.log(this.state.conf); // test111 514 | console.log(this.state.someExtraVariable); // 112 515 | // this.msg('Test error on test node', node.lineno, node.column, node.line.length); 516 | } 517 | }; 518 | 519 | /** 520 | * Check every line 521 | * @param line 522 | */ 523 | this.checkLine = (line) => { 524 | if (line.lineno === 1) { 525 | // this.msg('Test error on test line', line.lineno, 1, line.line.length); 526 | } 527 | }; 528 | } 529 | 530 | module.exports.TestRule = TestRule; 531 | ``` 532 | And you need add this rule in your config 533 | ```json 534 | { 535 | "extraRules": "/Users/v-chupurnov/WebstormProjects/test/rules/", 536 | "rules": { 537 | "testRule": { 538 | "conf": "test111", 539 | "someExtraVariable": 112, 540 | "enabled": true 541 | } 542 | } 543 | } 544 | ``` 545 | 546 | ## License 547 | 548 | [The MIT License](https://raw.githubusercontent.com/stylus/stlint/master/LICENSE). 549 | --------------------------------------------------------------------------------