diff --git a/lib/expressions.js b/lib/expressions.js new file mode 100644 index 00000000..325a35e6 --- /dev/null +++ b/lib/expressions.js @@ -0,0 +1,18 @@ +module.exports.atxClosedHeaderNoSpaceRe = /(?:^#+\S)|(?:\S#+\s*$)/; +module.exports.atxClosedHeaderSpaceRe = /(?:^#+\s\s+?\S)|(?:\S\s\s+?#+\s*$)/; +module.exports.atxHeaderSpaceRe = /^#+\s*\S/; +module.exports.bareUrlRe = /(?:http|ftp)s?:\/\/[^\s]*/; +module.exports.dollarCommandRe = /^(\s*)(\$\s)/; +module.exports.emptyLinkRe = /\[[^\]]*](?:\((?:#?|(?:<>))\))|(?:\[[^\]]*])/; +module.exports.htmlRe = /<[^>]*>/; +module.exports.listItemMarkerRe = /^[\s>]*(?:[*+-]|\d+\.)\s+/; +module.exports.listItemMarkerInterruptsRe = /^[\s>]*(?:[*+-]|1\.)\s+/; +module.exports.reversedLinkRe = /\([^)]+\)\[[^\]^][^\]]*]/; +module.exports.spaceAfterBlockQuote = />\s+\S/; +module.exports.spaceBeforeHeaderRe = /^\s+\S/; +module.exports.spaceInsideCodeRe = /`(?:(?:\s[^`]*)|(?:[^`]*\s))`/; +module.exports.spaceInsideEmphasisRe = /(\*\*?|__?)(?:(?:\s.+)|(?:.+\s))\1/; +module.exports.spaceInsideLinkRe = /\[(?:(?:\s[^\]]*)|(?:[^\]]*\s))](?=\(\S*\))/; +module.exports.tabRe = /\t+/; +module.exports.trailingPunctuationRe = /.$/; +module.exports.trailingSpaceRe = /\s+$/; diff --git a/lib/rules.js b/lib/rules.js index ab03f448..7583ee91 100644 --- a/lib/rules.js +++ b/lib/rules.js @@ -1,1181 +1,44 @@ "use strict"; -var shared = require("./shared"); - -// Range regular expressions -var atxClosedHeaderNoSpaceRe = /(?:^#+\S)|(?:\S#+\s*$)/; -var atxClosedHeaderSpaceRe = /(?:^#+\s\s+?\S)|(?:\S\s\s+?#+\s*$)/; -var atxHeaderSpaceRe = /^#+\s*\S/; -var bareUrlRe = /(?:http|ftp)s?:\/\/[^\s]*/; -var dollarCommandRe = /^(\s*)(\$\s)/; -var emptyLinkRe = /\[[^\]]*](?:\((?:#?|(?:<>))\))|(?:\[[^\]]*])/; -var htmlRe = /<[^>]*>/; -var listItemMarkerRe = /^[\s>]*(?:[*+-]|\d+\.)\s+/; -var listItemMarkerInterruptsRe = /^[\s>]*(?:[*+-]|1\.)\s+/; -var reversedLinkRe = /\([^)]+\)\[[^\]^][^\]]*]/; -var spaceAfterBlockQuote = />\s+\S/; -var spaceBeforeHeaderRe = /^\s+\S/; -var spaceInsideCodeRe = /`(?:(?:\s[^`]*)|(?:[^`]*\s))`/; -var spaceInsideEmphasisRe = /(\*\*?|__?)(?:(?:\s.+)|(?:.+\s))\1/; -var spaceInsideLinkRe = /\[(?:(?:\s[^\]]*)|(?:[^\]]*\s))](?=\(\S*\))/; -var tabRe = /\t+/; -var trailingPunctuationRe = /.$/; -var trailingSpaceRe = /\s+$/; -var defaultLineLength = 80; -function longLineReFunc(options) { - var lineLength = options.line_length || defaultLineLength; - return new RegExp("^(.{" + lineLength + "})(.*\\s.*)$"); -} - -// Escapes a string for use in a RegExp -function escapeForRegExp(str) { - return str.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); -} - -// Returns the indent for a token -function indentFor(token) { - var line = token.line.replace(/^[\s>]*(> |>)/, ""); - return line.length - line.trimLeft().length; -} - -// Returns the heading style for a heading token -function headingStyleFor(token) { - if ((token.map[1] - token.map[0]) === 1) { - if (/#\s*$/.test(token.line)) { - return "atx_closed"; - } - return "atx"; - } - return "setext"; -} - -// Returns the unordered list style for a list item token -function unorderedListStyleFor(token) { - switch (token.line.trimLeft().substr(0, 1)) { - case "-": - return "dash"; - case "+": - return "plus"; - // case "*": - default: - return "asterisk"; - } -} - -// Calls the provided function for each matching token -function filterTokens(params, type, callback) { - (params.tokenLists[type] || []).forEach(callback); -} - -// Calls the provided function for each line (with context) -function forEachLine(params, callback) { - if (!params.forEachLine) { - var lineMetadata = new Array(params.lines.length); - var fenceStart = null; - var inFence = false; - // Find fenced code by pattern (parser ignores "``` close fence") - params.lines.forEach(function forLine(line, lineIndex) { - var metadata = 0; - var match = /^(`{3,}|~{3,})/.exec(line); - var fence = match && match[1]; - if (fence && - (!inFence || (fence.substr(0, fenceStart.length) === fenceStart))) { - metadata = inFence ? 2 : 6; - fenceStart = inFence ? null : fence; - inFence = !inFence; - } else if (inFence) { - metadata = 1; - } - lineMetadata[lineIndex] = metadata; - }); - // Find code blocks normally - filterTokens(params, "code_block", function forToken(token) { - for (var i = token.map[0]; i < token.map[1]; i++) { - lineMetadata[i] = 1; - } - }); - // Find tables normally - filterTokens(params, "table_open", function forToken(token) { - for (var i = token.map[0]; i < token.map[1]; i++) { - lineMetadata[i] += 8; - } - }); - params.forEachLine = lineMetadata; - } - // Invoke callback - params.lines.forEach(function forLine(line, lineIndex) { - var metadata = params.forEachLine[lineIndex]; - callback( - line, - lineIndex, - !!(metadata & 7), - (((metadata & 6) >> 1) || 2) - 2, - !!(metadata & 8)); - }); -} - -// Calls the provided function for each specified inline child token -function forEachInlineChild(params, type, callback) { - filterTokens(params, "inline", function forToken(token) { - token.children.forEach(function forChild(child) { - if (child.type === type) { - callback(child, token); - } - }); - }); -} - -// Calls the provided function for each heading's content -function forEachHeading(params, callback) { - var heading = null; - params.tokens.forEach(function forToken(token) { - if (token.type === "heading_open") { - heading = token; - } else if (token.type === "heading_close") { - heading = null; - } else if ((token.type === "inline") && heading) { - callback(heading, token.content); - } - }); -} - -// Returns (nested) lists as a flat array (in order) -function flattenLists(params) { - if (!params.flattenLists) { - var lists = []; - var stack = []; - var current = null; - var lastWithMap = { "map": [ 0, 1 ] }; - params.tokens.forEach(function forToken(token) { - if ((token.type === "bullet_list_open") || - (token.type === "ordered_list_open")) { - // Save current context and start a new one - stack.push(current); - current = { - "unordered": (token.type === "bullet_list_open"), - "parentsUnordered": !current || - (current.unordered && current.parentsUnordered), - "open": token, - "items": [], - "nesting": stack.length - 1, - "lastLineIndex": -1, - "insert": lists.length - }; - } else if ((token.type === "bullet_list_close") || - (token.type === "ordered_list_close")) { - // Finalize current context and restore previous - current.lastLineIndex = lastWithMap.map[1]; - lists.splice(current.insert, 0, current); - delete current.insert; - current = stack.pop(); - } else if (token.type === "list_item_open") { - // Add list item - current.items.push(token); - } else if (token.map) { - // Track last token with map - lastWithMap = token; - } - }); - params.flattenLists = lists; - } - return params.flattenLists; -} - module.exports = [ - { - "name": "MD001", - "desc": "Header levels should only increment by one level at a time", - "tags": [ "headers" ], - "aliases": [ "header-increment" ], - "regexp": null, - "func": function MD001(params, errors) { - var prevLevel = 0; - filterTokens(params, "heading_open", function forToken(token) { - var level = parseInt(token.tag.slice(1), 10); - if (prevLevel && (level > prevLevel)) { - errors.addDetailIf(token.lineNumber, - "h" + (prevLevel + 1), "h" + level); - } - prevLevel = level; - }); - } - }, - - { - "name": "MD002", - "desc": "First header should be a top level header", - "tags": [ "headers" ], - "aliases": [ "first-header-h1" ], - "regexp": null, - "func": function MD002(params, errors) { - var level = params.options.level || 1; - var tag = "h" + level; - params.tokens.every(function forToken(token) { - if (token.type === "heading_open") { - errors.addDetailIf(token.lineNumber, tag, token.tag); - return false; - } - return true; - }); - } - }, - - { - "name": "MD003", - "desc": "Header style", - "tags": [ "headers" ], - "aliases": [ "header-style" ], - "regexp": null, - "func": function MD003(params, errors) { - var style = params.options.style || "consistent"; - filterTokens(params, "heading_open", function forToken(token) { - var styleForToken = headingStyleFor(token); - if (style === "consistent") { - style = styleForToken; - } - if (styleForToken !== style) { - var h12 = /h[12]/.test(token.tag); - var setextWithAtx = - (style === "setext_with_atx") && - ((h12 && (styleForToken === "setext")) || - (!h12 && (styleForToken === "atx"))); - var setextWithAtxClosed = - (style === "setext_with_atx_closed") && - ((h12 && (styleForToken === "setext")) || - (!h12 && (styleForToken === "atx_closed"))); - if (!setextWithAtx && !setextWithAtxClosed) { - var expected = style; - if (style === "setext_with_atx") { - expected = h12 ? "setext" : "atx"; - } else if (style === "setext_with_atx_closed") { - expected = h12 ? "setext" : "atx_closed"; - } - errors.addDetailIf(token.lineNumber, expected, styleForToken); - } - } - }); - } - }, - - { - "name": "MD004", - "desc": "Unordered list style", - "tags": [ "bullet", "ul" ], - "aliases": [ "ul-style" ], - "regexp": listItemMarkerRe, - "func": function MD004(params, errors) { - var style = params.options.style || "consistent"; - var expectedStyle = style; - var nestingStyles = []; - flattenLists(params).forEach(function forList(list) { - if (list.unordered) { - if (expectedStyle === "consistent") { - expectedStyle = unorderedListStyleFor(list.items[0]); - } - list.items.forEach(function forItem(item) { - var itemStyle = unorderedListStyleFor(item); - if (style === "sublist") { - var nesting = list.nesting; - if (!nestingStyles[nesting] && - (itemStyle !== nestingStyles[nesting - 1])) { - nestingStyles[nesting] = itemStyle; - } else { - errors.addDetailIf(item.lineNumber, - nestingStyles[nesting], itemStyle); - } - } else { - errors.addDetailIf(item.lineNumber, expectedStyle, itemStyle); - } - }); - } - }); - } - }, - - { - "name": "MD005", - "desc": "Inconsistent indentation for list items at the same level", - "tags": [ "bullet", "ul", "indentation" ], - "aliases": [ "list-indent" ], - "regexp": listItemMarkerRe, - "func": function MD005(params, errors) { - flattenLists(params).forEach(function forList(list) { - var indent = indentFor(list.items[0]); - list.items.forEach(function forItem(item) { - errors.addDetailIf(item.lineNumber, indent, indentFor(item)); - }); - }); - } - }, - - { - "name": "MD006", - "desc": "Consider starting bulleted lists at the beginning of the line", - "tags": [ "bullet", "ul", "indentation" ], - "aliases": [ "ul-start-left" ], - "regexp": listItemMarkerRe, - "func": function MD006(params, errors) { - flattenLists(params).forEach(function forList(list) { - if (list.unordered && !list.nesting) { - errors.addDetailIf(list.open.lineNumber, 0, indentFor(list.open)); - } - }); - } - }, - - { - "name": "MD007", - "desc": "Unordered list indentation", - "tags": [ "bullet", "ul", "indentation" ], - "aliases": [ "ul-indent" ], - "regexp": listItemMarkerRe, - "func": function MD007(params, errors) { - var optionsIndent = params.options.indent || 2; - var prevIndent = 0; - flattenLists(params).forEach(function forList(list) { - if (list.unordered && list.parentsUnordered) { - var indent = indentFor(list.open); - if (indent > prevIndent) { - errors.addDetailIf(list.open.lineNumber, - prevIndent + optionsIndent, indent); - } - prevIndent = indent; - } - }); - } - }, - - { - "name": "MD009", - "desc": "Trailing spaces", - "tags": [ "whitespace" ], - "aliases": [ "no-trailing-spaces" ], - "regexp": trailingSpaceRe, - "func": function MD009(params, errors) { - var brSpaces = params.options.br_spaces || 0; - var listItemEmptyLines = params.options.list_item_empty_lines; - var allowListItemEmptyLines = - (listItemEmptyLines === undefined) ? false : !!listItemEmptyLines; - var listItemLineNumbers = []; - if (allowListItemEmptyLines) { - filterTokens(params, "list_item_open", function forToken(token) { - for (var i = token.map[0]; i < token.map[1]; i++) { - listItemLineNumbers.push(i + 1); - } - }); - } - forEachLine(params, function forLine(line, lineIndex) { - var lineNumber = lineIndex + 1; - if (trailingSpaceRe.test(line) && - (listItemLineNumbers.indexOf(lineNumber) === -1)) { - var expected = (brSpaces < 2) ? 0 : brSpaces; - errors.addDetailIf(lineNumber, - expected, line.length - line.trimRight().length); - } - }); - } - }, - - { - "name": "MD010", - "desc": "Hard tabs", - "tags": [ "whitespace", "hard_tab" ], - "aliases": [ "no-hard-tabs" ], - "regexp": tabRe, - "func": function MD010(params, errors) { - var codeBlocks = params.options.code_blocks; - var includeCodeBlocks = (codeBlocks === undefined) ? true : !!codeBlocks; - forEachLine(params, function forLine(line, lineIndex, inCode) { - if (tabRe.test(line) && (!inCode || includeCodeBlocks)) { - errors.addDetail(lineIndex + 1, - "Column: " + (line.indexOf("\t") + 1)); - } - }); - } - }, - - { - "name": "MD011", - "desc": "Reversed link syntax", - "tags": [ "links" ], - "aliases": [ "no-reversed-links" ], - "regexp": reversedLinkRe, - "func": function MD011(params, errors) { - forEachInlineChild(params, "text", function forToken(token) { - var match = reversedLinkRe.exec(token.content); - if (match) { - errors.addDetail(token.lineNumber, match[0]); - } - }); - } - }, - - { - "name": "MD012", - "desc": "Multiple consecutive blank lines", - "tags": [ "whitespace", "blank_lines" ], - "aliases": [ "no-multiple-blanks" ], - "regexp": null, - "func": function MD012(params, errors) { - var maximum = params.options.maximum || 1; - var count = 0; - forEachLine(params, function forLine(line, lineIndex, inCode) { - count = (inCode || line.trim().length) ? 0 : count + 1; - if (maximum < count) { - errors.addDetailIf(lineIndex + 1, maximum, count); - } - }); - } - }, - - { - "name": "MD013", - "desc": "Line length", - "tags": [ "line_length" ], - "aliases": [ "line-length" ], - "regexp": longLineReFunc, - "func": function MD013(params, errors) { - var lineLength = params.options.line_length || defaultLineLength; - var codeBlocks = params.options.code_blocks; - var includeCodeBlocks = (codeBlocks === undefined) ? true : !!codeBlocks; - var tables = params.options.tables; - var includeTables = (tables === undefined) ? true : !!tables; - var headers = params.options.headers; - var includeHeaders = (headers === undefined) ? true : !!headers; - var headerLineNumbers = []; - if (!includeHeaders) { - forEachHeading(params, function forHeading(heading) { - headerLineNumbers.push(heading.lineNumber); - }); - } - var linkOnlyLineNumbers = []; - filterTokens(params, "inline", function forToken(token) { - if (((token.children.length === 2) || (token.children.length === 3)) && - (token.children[0].type === "link_open") && - (token.children[token.children.length - 1].type === "link_close")) { - linkOnlyLineNumbers.push(token.lineNumber); - } - }); - var longLineRe = longLineReFunc(params.options); - var labelRe = /^\s*\[.*[^\\]]:/; - forEachLine(params, - function forLine(line, lineIndex, inCode, onFence, inTable) { - var lineNumber = lineIndex + 1; - if ((includeCodeBlocks || !inCode) && - (includeTables || !inTable) && - (includeHeaders || (headerLineNumbers.indexOf(lineNumber)) < 0) && - (linkOnlyLineNumbers.indexOf(lineNumber) < 0) && - longLineRe.test(line) && - !labelRe.test(line)) { - errors.addDetailIf(lineNumber, lineLength, line.length); - } - }); - } - }, - - { - "name": "MD014", - "desc": "Dollar signs used before commands without showing output", - "tags": [ "code" ], - "aliases": [ "commands-show-output" ], - "regexp": dollarCommandRe, - "func": function MD014(params, errors) { - [ "code_block", "fence" ].forEach(function forType(type) { - filterTokens(params, type, function forToken(token) { - var allBlank = true; - if (token.content && token.content.split(shared.newLineRe) - .every(function forLine(line) { - return !line || (allBlank = false) || dollarCommandRe.test(line); - }) && !allBlank) { - errors.addContext(token.lineNumber, - token.content.split(shared.newLineRe)[0].trim()); - } - }); - }); - } - }, - - { - "name": "MD018", - "desc": "No space after hash on atx style header", - "tags": [ "headers", "atx", "spaces" ], - "aliases": [ "no-missing-space-atx" ], - "regexp": atxHeaderSpaceRe, - "func": function MD018(params, errors) { - forEachLine(params, function forLine(line, lineIndex, inCode) { - if (!inCode && /^#+[^#\s]/.test(line) && !/#$/.test(line)) { - errors.addContext(lineIndex + 1, line.trim()); - } - }); - } - }, - - { - "name": "MD019", - "desc": "Multiple spaces after hash on atx style header", - "tags": [ "headers", "atx", "spaces" ], - "aliases": [ "no-multiple-space-atx" ], - "regexp": atxHeaderSpaceRe, - "func": function MD019(params, errors) { - filterTokens(params, "heading_open", function forToken(token) { - if ((headingStyleFor(token) === "atx") && - /^#+\s\s/.test(token.line)) { - errors.addContext(token.lineNumber, token.line.trim()); - } - }); - } - }, - - { - "name": "MD020", - "desc": "No space inside hashes on closed atx style header", - "tags": [ "headers", "atx_closed", "spaces" ], - "aliases": [ "no-missing-space-closed-atx" ], - "regexp": atxClosedHeaderNoSpaceRe, - "func": function MD020(params, errors) { - forEachLine(params, function forLine(line, lineIndex, inCode) { - if (!inCode && /^#+[^#]*[^\\]#+$/.test(line)) { - var left = /^#+[^#\s]/.test(line); - var right = /[^#\s]#+$/.test(line); - if (left || right) { - errors.addContext(lineIndex + 1, line.trim(), left, right); - } - } - }); - } - }, - - { - "name": "MD021", - "desc": "Multiple spaces inside hashes on closed atx style header", - "tags": [ "headers", "atx_closed", "spaces" ], - "aliases": [ "no-multiple-space-closed-atx" ], - "regexp": atxClosedHeaderSpaceRe, - "func": function MD021(params, errors) { - filterTokens(params, "heading_open", function forToken(token) { - if (headingStyleFor(token) === "atx_closed") { - var left = /^#+\s\s/.test(token.line); - var right = /\s\s#+$/.test(token.line); - if (left || right) { - errors.addContext(token.lineNumber, token.line.trim(), left, right); - } - } - }); - } - }, - - { - "name": "MD022", - "desc": "Headers should be surrounded by blank lines", - "tags": [ "headers", "blank_lines" ], - "aliases": [ "blanks-around-headers" ], - "regexp": null, - "func": function MD022(params, errors) { - var prevHeadingLineNumber = 0; - var prevMaxLineIndex = -1; - var needBlankLine = false; - params.tokens.forEach(function forToken(token) { - if (token.type === "heading_open") { - if ((token.map[0] - prevMaxLineIndex) === 0) { - errors.addContext(token.lineNumber, token.line.trim()); - } - } else if (token.type === "heading_close") { - needBlankLine = true; - } - if (token.map) { - if (needBlankLine) { - if ((token.map[0] - prevMaxLineIndex) === 0) { - errors.addContext(prevHeadingLineNumber, - params.lines[prevHeadingLineNumber - 1].trim()); - } - needBlankLine = false; - } - prevMaxLineIndex = Math.max(prevMaxLineIndex, token.map[1]); - } - if (token.type === "heading_open") { - prevHeadingLineNumber = token.lineNumber; - } - }); - } - }, - - { - "name": "MD023", - "desc": "Headers must start at the beginning of the line", - "tags": [ "headers", "spaces" ], - "aliases": [ "header-start-left" ], - "regexp": spaceBeforeHeaderRe, - "func": function MD023(params, errors) { - filterTokens(params, "heading_open", function forToken(token) { - if (spaceBeforeHeaderRe.test(token.line)) { - errors.addContext(token.lineNumber, token.line); - } - }); - } - }, - - { - "name": "MD024", - "desc": "Multiple headers with the same content", - "tags": [ "headers" ], - "aliases": [ "no-duplicate-header" ], - "regexp": null, - "func": function MD024(params, errors) { - var knownContent = []; - forEachHeading(params, function forHeading(heading, content) { - if (knownContent.indexOf(content) === -1) { - knownContent.push(content); - } else { - errors.addContext(heading.lineNumber, heading.line.trim()); - } - }); - } - }, - - { - "name": "MD025", - "desc": "Multiple top level headers in the same document", - "tags": [ "headers" ], - "aliases": [ "single-h1" ], - "regexp": null, - "func": function MD025(params, errors) { - var level = params.options.level || 1; - var tag = "h" + level; - var hasTopLevelHeading = false; - filterTokens(params, "heading_open", function forToken(token) { - if (token.tag === tag) { - if (hasTopLevelHeading) { - errors.addContext(token.lineNumber, token.line.trim()); - } else if (token.lineNumber === 1) { - hasTopLevelHeading = true; - } - } - }); - } - }, - - { - "name": "MD026", - "desc": "Trailing punctuation in header", - "tags": [ "headers" ], - "aliases": [ "no-trailing-punctuation" ], - "regexp": trailingPunctuationRe, - "func": function MD026(params, errors) { - var punctuation = params.options.punctuation || ".,;:!?"; - var re = new RegExp("[" + punctuation + "]$"); - forEachHeading(params, function forHeading(heading, content) { - var match = re.exec(content); - if (match) { - errors.addDetail(heading.lineNumber, - "Punctuation: '" + match[0] + "'"); - } - }); - } - }, - - { - "name": "MD027", - "desc": "Multiple spaces after blockquote symbol", - "tags": [ "blockquote", "whitespace", "indentation" ], - "aliases": [ "no-multiple-space-blockquote" ], - "regexp": spaceAfterBlockQuote, - "func": function MD027(params, errors) { - var blockquoteNesting = 0; - var listItemNesting = 0; - params.tokens.forEach(function forToken(token) { - if (token.type === "blockquote_open") { - blockquoteNesting++; - } else if (token.type === "blockquote_close") { - blockquoteNesting--; - } else if (token.type === "list_item_open") { - listItemNesting++; - } else if (token.type === "list_item_close") { - listItemNesting--; - } else if ((token.type === "inline") && (blockquoteNesting > 0)) { - var multipleSpaces = listItemNesting ? - /^(\s*>)+\s\s+>/.test(token.line) : - /^(\s*>)+\s\s/.test(token.line); - if (multipleSpaces) { - errors.addContext(token.lineNumber, token.line); - } - token.content.split(shared.newLineRe) - .forEach(function forLine(line, offset) { - if (/^\s/.test(line)) { - errors.addContext(token.lineNumber + offset, "> " + line); - } - }); - } - }); - } - }, - - { - "name": "MD028", - "desc": "Blank line inside blockquote", - "tags": [ "blockquote", "whitespace" ], - "aliases": [ "no-blanks-blockquote" ], - "regexp": null, - "func": function MD028(params, errors) { - var prevToken = {}; - params.tokens.forEach(function forToken(token) { - if ((token.type === "blockquote_open") && - (prevToken.type === "blockquote_close")) { - errors.add(token.lineNumber - 1); - } - prevToken = token; - }); - } - }, - - { - "name": "MD029", - "desc": "Ordered list item prefix", - "tags": [ "ol" ], - "aliases": [ "ol-prefix" ], - "regexp": listItemMarkerRe, - "func": function MD029(params, errors) { - var style = params.options.style || "one"; - flattenLists(params).forEach(function forList(list) { - if (!list.unordered) { - var number = 1; - list.items.forEach(function forItem(item) { - var match = /^[\s>]*([^.)]*)[.)]/.exec(item.line); - errors.addDetailIf(item.lineNumber, - String(number), !match || match[1]); - if (style === "ordered") { - number++; - } - }); - } - }); - } - }, - - { - "name": "MD030", - "desc": "Spaces after list markers", - "tags": [ "ol", "ul", "whitespace" ], - "aliases": [ "list-marker-space" ], - "regexp": listItemMarkerRe, - "func": function MD030(params, errors) { - var ulSingle = params.options.ul_single || 1; - var olSingle = params.options.ol_single || 1; - var ulMulti = params.options.ul_multi || 1; - var olMulti = params.options.ol_multi || 1; - flattenLists(params).forEach(function forList(list) { - var lineCount = list.lastLineIndex - list.open.map[0]; - var allSingle = lineCount === list.items.length; - var expectedSpaces = list.unordered ? - (allSingle ? ulSingle : ulMulti) : - (allSingle ? olSingle : olMulti); - list.items.forEach(function forItem(item) { - var match = /^[\s>]*\S+(\s+)/.exec(item.line); - errors.addDetailIf(item.lineNumber, - expectedSpaces, (match ? match[1].length : 0)); - }); - }); - } - }, - - { - "name": "MD031", - "desc": "Fenced code blocks should be surrounded by blank lines", - "tags": [ "code", "blank_lines" ], - "aliases": [ "blanks-around-fences" ], - "regexp": null, - "func": function MD031(params, errors) { - var lines = params.lines; - forEachLine(params, function forLine(line, i, inCode, onFence) { - if (((onFence > 0) && (i - 1 >= 0) && lines[i - 1].length) || - ((onFence < 0) && (i + 1 < lines.length) && lines[i + 1].length)) { - errors.addContext(i + 1, lines[i].trim()); - } - }); - } - }, - - { - "name": "MD032", - "desc": "Lists should be surrounded by blank lines", - "tags": [ "bullet", "ul", "ol", "blank_lines" ], - "aliases": [ "blanks-around-lists" ], - "regexp": null, - "func": function MD032(params, errors) { - var blankOrListRe = /^[\s>]*($|\s)/; - var inList = false; - var prevLine = ""; - forEachLine(params, function forLine(line, lineIndex, inCode, onFence) { - if (!inCode || onFence) { - var lineTrim = line.trim(); - var listMarker = listItemMarkerRe.test(lineTrim); - if (listMarker && !inList && !blankOrListRe.test(prevLine)) { - // Check whether this list prefix can interrupt a paragraph - if (listItemMarkerInterruptsRe.test(lineTrim)) { - errors.addContext(lineIndex + 1, lineTrim); - } else { - listMarker = false; - } - } else if (!listMarker && inList && !blankOrListRe.test(line)) { - errors.addContext(lineIndex, lineTrim); - } - inList = listMarker; - } - prevLine = line; - }); - } - }, - - { - "name": "MD033", - "desc": "Inline HTML", - "tags": [ "html" ], - "aliases": [ "no-inline-html" ], - "regexp": htmlRe, - "func": function MD033(params, errors) { - var allowedElements = (params.options.allowed_elements || []) - .map(function forElement(element) { - return element.toLowerCase(); - }); - function forToken(token) { - token.content.split(shared.newLineRe) - .forEach(function forLine(line, offset) { - var allowed = (line.match(/<[^/\s>!]*/g) || []) - .filter(function forElement(element) { - return element.length > 1; - }) - .map(function forElement(element) { - return element.slice(1).toLowerCase(); - }) - .filter(function forElement(element) { - return allowedElements.indexOf(element) === -1; - }); - if (allowed.length) { - errors.addDetail(token.lineNumber + offset, - "Element: " + allowed[0]); - } - }); - } - filterTokens(params, "html_block", forToken); - forEachInlineChild(params, "html_inline", forToken); - } - }, - - { - "name": "MD034", - "desc": "Bare URL used", - "tags": [ "links", "url" ], - "aliases": [ "no-bare-urls" ], - "regexp": bareUrlRe, - "func": function MD034(params, errors) { - filterTokens(params, "inline", function forToken(token) { - var inLink = false; - token.children.forEach(function forChild(child) { - var match = null; - if (child.type === "link_open") { - inLink = true; - } else if (child.type === "link_close") { - inLink = false; - } else if ((child.type === "text") && - !inLink && - (match = bareUrlRe.exec(child.content))) { - errors.addContext(child.lineNumber, match[0]); - } - }); - }); - } - }, - - { - "name": "MD035", - "desc": "Horizontal rule style", - "tags": [ "hr" ], - "aliases": [ "hr-style" ], - "regexp": null, - "func": function MD035(params, errors) { - var style = params.options.style || "consistent"; - filterTokens(params, "hr", function forToken(token) { - var lineTrim = token.line.trim(); - if (style === "consistent") { - style = lineTrim; - } - errors.addDetailIf(token.lineNumber, style, lineTrim); - }); - } - }, - - { - "name": "MD036", - "desc": "Emphasis used instead of a header", - "tags": [ "headers", "emphasis" ], - "aliases": [ "no-emphasis-as-header" ], - "regexp": null, - "func": function MD036(params, errors) { - var punctuation = params.options.punctuation || ".,;:!?"; - var re = new RegExp("[" + punctuation + "]$"); - function base(token) { - if (token.type === "paragraph_open") { - return function inParagraph(t) { - if ((t.type === "inline") && - (t.children.length === 3) && - ((t.children[0].type === "strong_open") || - (t.children[0].type === "em_open")) && - (t.children[1].type === "text") && - !re.test(t.children[1].content)) { - errors.addContext(t.lineNumber, t.children[1].content); - } - }; - } else if (token.type === "blockquote_open") { - return function inBlockquote(t) { - if (t.type !== "blockquote_close") { - return inBlockquote; - } - }; - } else if (token.type === "list_item_open") { - return function inListItem(t) { - if (t.type !== "list_item_close") { - return inListItem; - } - }; - } - } - var state = base; - params.tokens.forEach(function forToken(token) { - state = state(token) || base; - }); - } - }, - - { - "name": "MD037", - "desc": "Spaces inside emphasis markers", - "tags": [ "whitespace", "emphasis" ], - "aliases": [ "no-space-in-emphasis" ], - "regexp": spaceInsideEmphasisRe, - "func": function MD037(params, errors) { - forEachInlineChild(params, "text", function forToken(token) { - var left = /\s(\*\*?|__?)\s.+\1/.exec(token.content); - var right = /(\*\*?|__?).+\s\1\s/.exec(token.content); - if (left) { - errors.addContext(token.lineNumber, left[0].trim()); - } else if (right) { - errors.addContext(token.lineNumber, right[0].trim(), false, true); - } - }); - } - }, - - { - "name": "MD038", - "desc": "Spaces inside code span elements", - "tags": [ "whitespace", "code" ], - "aliases": [ "no-space-in-code" ], - "regexp": spaceInsideCodeRe, - "func": function MD038(params, errors) { - var inlineCodeSpansRe = /(?:^|[^\\])((`+)((?:.*?[^`])|)\2(?!`))/g; - forEachInlineChild(params, "code_inline", - function forToken(token) { - var line = params.lines[token.lineNumber - 1]; - var match = null; - while ((match = inlineCodeSpansRe.exec(line)) !== null) { - var inlineCodeSpan = match[1]; - var content = match[3]; - if (/^\s([^`]|$)/.test(content)) { - errors.addContext(token.lineNumber, inlineCodeSpan); - } else if (/[^`]\s$/.test(content)) { - errors.addContext(token.lineNumber, inlineCodeSpan, false, true); - } - } - }); - } - }, - - { - "name": "MD039", - "desc": "Spaces inside link text", - "tags": [ "whitespace", "links" ], - "aliases": [ "no-space-in-links" ], - "regexp": spaceInsideLinkRe, - "func": function MD039(params, errors) { - filterTokens(params, "inline", function forToken(token) { - var inLink = false; - var linkText = ""; - token.children.forEach(function forChild(child) { - if (child.type === "link_open") { - inLink = true; - linkText = ""; - } else if (child.type === "link_close") { - inLink = false; - var left = linkText.trimLeft().length !== linkText.length; - var right = linkText.trimRight().length !== linkText.length; - if (left || right) { - errors.addContext( - token.lineNumber, "[" + linkText + "]", left, right); - } - } else if (inLink) { - linkText += child.content; - } - }); - }); - } - }, - - { - "name": "MD040", - "desc": "Fenced code blocks should have a language specified", - "tags": [ "code", "language" ], - "aliases": [ "fenced-code-language" ], - "regexp": null, - "func": function MD040(params, errors) { - filterTokens(params, "fence", function forToken(token) { - if (!token.info.trim()) { - errors.addContext(token.lineNumber, token.line); - } - }); - } - }, - - { - "name": "MD041", - "desc": "First line in file should be a top level header", - "tags": [ "headers" ], - "aliases": [ "first-line-h1" ], - "regexp": null, - "func": function MD041(params, errors) { - var level = params.options.level || 1; - var frontMatterTitle = params.options.front_matter_title; - var tag = "h" + level; - var frontMatterTitleRe = - new RegExp(frontMatterTitle || "^\\s*title:", "i"); - params.tokens.every(function forToken(token, index) { - if (token.type === "heading_open") { - if (!((token.lineNumber === 1) || (index > 0)) || - (token.tag !== tag)) { - errors.addContext(token.lineNumber, token.line); - } - return false; - } else if (token.type === "html_block") { - return true; - } - if (((frontMatterTitle !== undefined) && !frontMatterTitle) || - !params.frontMatterLines.some(function forLine(line) { - return frontMatterTitleRe.test(line); - })) { - errors.addContext(token.lineNumber, token.line); - } - return false; - }); - } - }, - - { - "name": "MD042", - "desc": "No empty links", - "tags": [ "links" ], - "aliases": [ "no-empty-links" ], - "regexp": emptyLinkRe, - "func": function MD042(params, errors) { - filterTokens(params, "inline", function forToken(token) { - var inLink = false; - var linkText = ""; - var emptyLink = false; - token.children.forEach(function forChild(child) { - if (child.type === "link_open") { - inLink = true; - linkText = ""; - child.attrs.forEach(function forAttr(attr) { - if (attr[0] === "href" && (!attr[1] || (attr[1] === "#"))) { - emptyLink = true; - } - }); - } else if (child.type === "link_close") { - inLink = false; - if (emptyLink) { - errors.addContext(child.lineNumber, "[" + linkText + "]"); - } - } else if (inLink) { - linkText += child.content; - } - }); - }); - } - }, - - { - "name": "MD043", - "desc": "Required header structure", - "tags": [ "headers" ], - "aliases": [ "required-headers" ], - "regexp": null, - "func": function MD043(params, errors) { - var requiredHeaders = params.options.headers; - if (requiredHeaders) { - var levels = {}; - [ 1, 2, 3, 4, 5, 6 ].forEach(function forLevel(level) { - levels["h" + level] = "######".substr(-level); - }); - var i = 0; - var optional = false; - forEachHeading(params, function forHeading(heading, content) { - if (!errors.length) { - var actual = levels[heading.tag] + " " + content; - var expected = requiredHeaders[i++] || "[None]"; - if (expected === "*") { - optional = true; - } else if (expected.toLowerCase() === actual.toLowerCase()) { - optional = false; - } else if (optional) { - i--; - } else { - errors.addDetailIf(heading.lineNumber, expected, actual); - } - } - }); - if ((i < requiredHeaders.length) && !errors.length) { - errors.addContext(params.lines.length, requiredHeaders[i]); - } - } - } - }, - - { - "name": "MD044", - "desc": "Proper names should have the correct capitalization", - "tags": [ "spelling" ], - "aliases": [ "proper-names" ], - "regexp": null, - "func": function MD044(params, errors) { - var names = params.options.names || []; - var codeBlocks = params.options.code_blocks; - var includeCodeBlocks = (codeBlocks === undefined) ? true : !!codeBlocks; - names.forEach(function forName(name) { - var escapedName = escapeForRegExp(name); - var namePattern = "\\S*\\b(" + escapedName + ")\\b\\S*"; - var anyNameRe = new RegExp(namePattern, "gi"); - function forToken(token) { - var fenceOffset = (token.type === "fence") ? 1 : 0; - token.content.split(shared.newLineRe) - .forEach(function forLine(line, index) { - var match = null; - while ((match = anyNameRe.exec(line)) !== null) { - var fullMatch = match[0]; - if (!bareUrlRe.test(fullMatch)) { - var wordMatch = fullMatch - .replace(/^\W*/, "").replace(/\W*$/, ""); - if (names.indexOf(wordMatch) === -1) { - var lineNumber = token.lineNumber + index + fenceOffset; - errors.addDetailIf(lineNumber, name, match[1]); - } - } - } - }); - } - forEachInlineChild(params, "text", forToken); - if (includeCodeBlocks) { - forEachInlineChild(params, "code_inline", forToken); - filterTokens(params, "code_block", forToken); - filterTokens(params, "fence", forToken); - } - }); - } - } + require("./rules/headerIncrement"), + require("./rules/firstHeaderH1"), + require("./rules/headerStyle"), + require("./rules/ulStyle"), + require("./rules/listIndent"), + require("./rules/ulStartLeft"), + require("./rules/ulIndent"), + require("./rules/noTrailingSpaces"), + require("./rules/noHardTabs"), + require("./rules/noReversedLinks"), + require("./rules/noMultipleBlanks"), + require("./rules/lineLength"), + require("./rules/commandsShowOutput"), + require("./rules/noMissingSpaceAtx"), + require("./rules/noMultipleSpaceAtx"), + require("./rules/noMissingSpaceClosedAtx"), + require("./rules/noMultipleSpaceClosedAtx"), + require("./rules/blanksAroundHeaders"), + require("./rules/headerStartLeft"), + require("./rules/noDuplicateHeader"), + require("./rules/singleH1"), + require("./rules/noTrailingPunctuation"), + require("./rules/noMultipleSpaceBlockQuote"), + require("./rules/noBlanksBlockquote"), + require("./rules/olPrefix"), + require("./rules/listMarkerSpace"), + require("./rules/blanksAroundFences"), + require("./rules/blanksAroundLists"), + require("./rules/noInlineHtml"), + require("./rules/noBareUrls"), + require("./rules/hrStyle"), + require("./rules/noEmphasisAsHeader"), + require("./rules/noSpaceInEmphasis"), + require("./rules/noSpaceInCode"), + require("./rules/noSpaceInLinks"), + require("./rules/fencedCodeLanguage"), + require("./rules/firstLineH1"), + require("./rules/noEmptyLinks"), + require("./rules/requiredHeaders"), + require("./rules/properNames") ]; diff --git a/lib/rules/blanksAroundFences.js b/lib/rules/blanksAroundFences.js new file mode 100644 index 00000000..5ffa4cd6 --- /dev/null +++ b/lib/rules/blanksAroundFences.js @@ -0,0 +1,19 @@ +var expressions = require("../expressions"); +var shared = require("../shared"); + +module.exports = { + "name": "MD031", + "desc": "Fenced code blocks should be surrounded by blank lines", + "tags": [ "code", "blank_lines" ], + "aliases": [ "blanks-around-fences" ], + "regexp": null, + "func": function MD031(params, errors) { + var lines = params.lines; + shared.forEachLine(params, function forLine(line, i, inCode, onFence) { + if (((onFence > 0) && (i - 1 >= 0) && lines[i - 1].length) || + ((onFence < 0) && (i + 1 < lines.length) && lines[i + 1].length)) { + errors.addContext(i + 1, lines[i].trim()); + } + }); + } +}; diff --git a/lib/rules/blanksAroundHeaders.js b/lib/rules/blanksAroundHeaders.js new file mode 100644 index 00000000..09725787 --- /dev/null +++ b/lib/rules/blanksAroundHeaders.js @@ -0,0 +1,34 @@ +module.exports = { + "name": "MD022", + "desc": "Headers should be surrounded by blank lines", + "tags": [ "headers", "blank_lines" ], + "aliases": [ "blanks-around-headers" ], + "regexp": null, + "func": function MD022(params, errors) { + var prevHeadingLineNumber = 0; + var prevMaxLineIndex = -1; + var needBlankLine = false; + params.tokens.forEach(function forToken(token) { + if (token.type === "heading_open") { + if ((token.map[0] - prevMaxLineIndex) === 0) { + errors.addContext(token.lineNumber, token.line.trim()); + } + } else if (token.type === "heading_close") { + needBlankLine = true; + } + if (token.map) { + if (needBlankLine) { + if ((token.map[0] - prevMaxLineIndex) === 0) { + errors.addContext(prevHeadingLineNumber, + params.lines[prevHeadingLineNumber - 1].trim()); + } + needBlankLine = false; + } + prevMaxLineIndex = Math.max(prevMaxLineIndex, token.map[1]); + } + if (token.type === "heading_open") { + prevHeadingLineNumber = token.lineNumber; + } + }); + } +}; diff --git a/lib/rules/blanksAroundLists.js b/lib/rules/blanksAroundLists.js new file mode 100644 index 00000000..31820548 --- /dev/null +++ b/lib/rules/blanksAroundLists.js @@ -0,0 +1,33 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD032", + "desc": "Lists should be surrounded by blank lines", + "tags": [ "bullet", "ul", "ol", "blank_lines" ], + "aliases": [ "blanks-around-lists" ], + "regexp": null, + "func": function MD032(params, errors) { + var blankOrListRe = /^[\s>]*($|\s)/; + var inList = false; + var prevLine = ""; + shared.forEachLine(params, function forLine(line, lineIndex, inCode, onFence) { + if (!inCode || onFence) { + var lineTrim = line.trim(); + var listMarker = expressions.listItemMarkerRe.test(lineTrim); + if (listMarker && !inList && !blankOrListRe.test(prevLine)) { + // Check whether this list prefix can interrupt a paragraph + if (expressions.listItemMarkerInterruptsRe.test(lineTrim)) { + errors.addContext(lineIndex + 1, lineTrim); + } else { + listMarker = false; + } + } else if (!listMarker && inList && !blankOrListRe.test(line)) { + errors.addContext(lineIndex, lineTrim); + } + inList = listMarker; + } + prevLine = line; + }); + } +}; diff --git a/lib/rules/commandsShowOutput.js b/lib/rules/commandsShowOutput.js new file mode 100644 index 00000000..a2f0b798 --- /dev/null +++ b/lib/rules/commandsShowOutput.js @@ -0,0 +1,24 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD014", + "desc": "Dollar signs used before commands without showing output", + "tags": [ "code" ], + "aliases": [ "commands-show-output" ], + "regexp": expressions.dollarCommandRe, + "func": function MD014(params, errors) { + [ "code_block", "fence" ].forEach(function forType(type) { + shared.filterTokens(params, type, function forToken(token) { + var allBlank = true; + if (token.content && token.content.split(shared.newLineRe) + .every(function forLine(line) { + return !line || (allBlank = false) || expressions.dollarCommandRe.test(line); + }) && !allBlank) { + errors.addContext(token.lineNumber, + token.content.split(shared.newLineRe)[0].trim()); + } + }); + }); + } +}; diff --git a/lib/rules/fencedCodeLanguage.js b/lib/rules/fencedCodeLanguage.js new file mode 100644 index 00000000..29e3240d --- /dev/null +++ b/lib/rules/fencedCodeLanguage.js @@ -0,0 +1,16 @@ +var shared = require("../shared"); + +module.exports = { + "name": "MD040", + "desc": "Fenced code blocks should have a language specified", + "tags": [ "code", "language" ], + "aliases": [ "fenced-code-language" ], + "regexp": null, + "func": function MD040(params, errors) { + shared.filterTokens(params, "fence", function forToken(token) { + if (!token.info.trim()) { + errors.addContext(token.lineNumber, token.line); + } + }); + } +}; diff --git a/lib/rules/firstHeaderH1.js b/lib/rules/firstHeaderH1.js new file mode 100644 index 00000000..a3884c9c --- /dev/null +++ b/lib/rules/firstHeaderH1.js @@ -0,0 +1,18 @@ +module.exports = { + "name": "MD002", + "desc": "First header should be a top level header", + "tags": [ "headers" ], + "aliases": [ "first-header-h1" ], + "regexp": null, + "func": function MD002(params, errors) { + var level = params.options.level || 1; + var tag = "h" + level; + params.tokens.every(function forToken(token) { + if (token.type === "heading_open") { + errors.addDetailIf(token.lineNumber, tag, token.tag); + return false; + } + return true; + }); + } +}; diff --git a/lib/rules/firstLineH1.js b/lib/rules/firstLineH1.js new file mode 100644 index 00000000..8d7392e8 --- /dev/null +++ b/lib/rules/firstLineH1.js @@ -0,0 +1,32 @@ +module.exports = { + "name": "MD041", + "desc": "First line in file should be a top level header", + "tags": [ "headers" ], + "aliases": [ "first-line-h1" ], + "regexp": null, + "func": function MD041(params, errors) { + var level = params.options.level || 1; + var frontMatterTitle = params.options.front_matter_title; + var tag = "h" + level; + var frontMatterTitleRe = + new RegExp(frontMatterTitle || "^\\s*title:", "i"); + params.tokens.every(function forToken(token, index) { + if (token.type === "heading_open") { + if (!((token.lineNumber === 1) || (index > 0)) || + (token.tag !== tag)) { + errors.addContext(token.lineNumber, token.line); + } + return false; + } else if (token.type === "html_block") { + return true; + } + if (((frontMatterTitle !== undefined) && !frontMatterTitle) || + !params.frontMatterLines.some(function forLine(line) { + return frontMatterTitleRe.test(line); + })) { + errors.addContext(token.lineNumber, token.line); + } + return false; + }); + } +}; diff --git a/lib/rules/headerIncrement.js b/lib/rules/headerIncrement.js new file mode 100644 index 00000000..d2d575c0 --- /dev/null +++ b/lib/rules/headerIncrement.js @@ -0,0 +1,20 @@ +var shared = require("../shared"); + +module.exports = { +"name": "MD001", +"desc": "Header levels should only increment by one level at a time", +"tags": [ "headers" ], +"aliases": [ "header-increment" ], +"regexp": null, +"func": function MD001(params, errors) { + var prevLevel = 0; + shared.filterTokens(params, "heading_open", function forToken(token) { + var level = parseInt(token.tag.slice(1), 10); + if (prevLevel && (level > prevLevel)) { + errors.addDetailIf(token.lineNumber, + "h" + (prevLevel + 1), "h" + level); + } + prevLevel = level; + }); + } +}; diff --git a/lib/rules/headerStartLeft.js b/lib/rules/headerStartLeft.js new file mode 100644 index 00000000..d70124d0 --- /dev/null +++ b/lib/rules/headerStartLeft.js @@ -0,0 +1,17 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD023", + "desc": "Headers must start at the beginning of the line", + "tags": [ "headers", "spaces" ], + "aliases": [ "header-start-left" ], + "regexp": expressions.spaceBeforeHeaderRe, + "func": function MD023(params, errors) { + shared.filterTokens(params, "heading_open", function forToken(token) { + if (expressions.spaceBeforeHeaderRe.test(token.line)) { + errors.addContext(token.lineNumber, token.line); + } + }); + } +}; diff --git a/lib/rules/headerStyle.js b/lib/rules/headerStyle.js new file mode 100644 index 00000000..62dc65e3 --- /dev/null +++ b/lib/rules/headerStyle.js @@ -0,0 +1,38 @@ +var shared = require("../shared"); + +module.exports = { + "name": "MD003", + "desc": "Header style", + "tags": [ "headers" ], + "aliases": [ "header-style" ], + "regexp": null, + "func": function MD003(params, errors) { + var style = params.options.style || "consistent"; + shared.filterTokens(params, "heading_open", function forToken(token) { + var styleForToken = shared.headingStyleFor(token); + if (style === "consistent") { + style = styleForToken; + } + if (styleForToken !== style) { + var h12 = /h[12]/.test(token.tag); + var setextWithAtx = + (style === "setext_with_atx") && + ((h12 && (styleForToken === "setext")) || + (!h12 && (styleForToken === "atx"))); + var setextWithAtxClosed = + (style === "setext_with_atx_closed") && + ((h12 && (styleForToken === "setext")) || + (!h12 && (styleForToken === "atx_closed"))); + if (!setextWithAtx && !setextWithAtxClosed) { + var expected = style; + if (style === "setext_with_atx") { + expected = h12 ? "setext" : "atx"; + } else if (style === "setext_with_atx_closed") { + expected = h12 ? "setext" : "atx_closed"; + } + errors.addDetailIf(token.lineNumber, expected, styleForToken); + } + } + }); + } +}; diff --git a/lib/rules/hrStyle.js b/lib/rules/hrStyle.js new file mode 100644 index 00000000..334dabad --- /dev/null +++ b/lib/rules/hrStyle.js @@ -0,0 +1,19 @@ +var shared = require("../shared"); + +module.exports = { + "name": "MD035", + "desc": "Horizontal rule style", + "tags": [ "hr" ], + "aliases": [ "hr-style" ], + "regexp": null, + "func": function MD035(params, errors) { + var style = params.options.style || "consistent"; + shared.filterTokens(params, "hr", function forToken(token) { + var lineTrim = token.line.trim(); + if (style === "consistent") { + style = lineTrim; + } + errors.addDetailIf(token.lineNumber, style, lineTrim); + }); + } +}; diff --git a/lib/rules/lineLength.js b/lib/rules/lineLength.js new file mode 100644 index 00000000..e9df2dce --- /dev/null +++ b/lib/rules/lineLength.js @@ -0,0 +1,46 @@ +var shared = require("../shared"); + +module.exports = { + "name": "MD013", + "desc": "Line length", + "tags": [ "line_length" ], + "aliases": [ "line-length" ], + "regexp": shared.longLineReFunc, + "func": function MD013(params, errors) { + var lineLength = params.options.line_length || shared.defaultLineLength; + var codeBlocks = params.options.code_blocks; + var includeCodeBlocks = (codeBlocks === undefined) ? true : !!codeBlocks; + var tables = params.options.tables; + var includeTables = (tables === undefined) ? true : !!tables; + var headers = params.options.headers; + var includeHeaders = (headers === undefined) ? true : !!headers; + var headerLineNumbers = []; + if (!includeHeaders) { + shared.forEachHeading(params, function forHeading(heading) { + headerLineNumbers.push(heading.lineNumber); + }); + } + var linkOnlyLineNumbers = []; + shared.filterTokens(params, "inline", function forToken(token) { + if (((token.children.length === 2) || (token.children.length === 3)) && + (token.children[0].type === "link_open") && + (token.children[token.children.length - 1].type === "link_close")) { + linkOnlyLineNumbers.push(token.lineNumber); + } + }); + var longLineRe = shared.longLineReFunc(params.options); + var labelRe = /^\s*\[.*[^\\]]:/; + shared.forEachLine(params, + function forLine(line, lineIndex, inCode, onFence, inTable) { + var lineNumber = lineIndex + 1; + if ((includeCodeBlocks || !inCode) && + (includeTables || !inTable) && + (includeHeaders || (headerLineNumbers.indexOf(lineNumber)) < 0) && + (linkOnlyLineNumbers.indexOf(lineNumber) < 0) && + longLineRe.test(line) && + !labelRe.test(line)) { + errors.addDetailIf(lineNumber, lineLength, line.length); + } + }); + } +}; diff --git a/lib/rules/listIndent.js b/lib/rules/listIndent.js new file mode 100644 index 00000000..30b4a44a --- /dev/null +++ b/lib/rules/listIndent.js @@ -0,0 +1,18 @@ +var expressions = require("../expressions"); +var shared = require("../shared"); + +module.exports = { + "name": "MD005", + "desc": "Inconsistent indentation for list items at the same level", + "tags": [ "bullet", "ul", "indentation" ], + "aliases": [ "list-indent" ], + "regexp": expressions.listItemMarkerRe, + "func": function MD005(params, errors) { + shared.flattenLists(params).forEach(function forList(list) { + var indent = shared.indentFor(list.items[0]); + list.items.forEach(function forItem(item) { + errors.addDetailIf(item.lineNumber, indent, shared.indentFor(item)); + }); + }); + } +}; diff --git a/lib/rules/listMarkerSpace.js b/lib/rules/listMarkerSpace.js new file mode 100644 index 00000000..67a9ead6 --- /dev/null +++ b/lib/rules/listMarkerSpace.js @@ -0,0 +1,28 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD030", + "desc": "Spaces after list markers", + "tags": [ "ol", "ul", "whitespace" ], + "aliases": [ "list-marker-space" ], + "regexp": expressions.listItemMarkerRe, + "func": function MD030(params, errors) { + var ulSingle = params.options.ul_single || 1; + var olSingle = params.options.ol_single || 1; + var ulMulti = params.options.ul_multi || 1; + var olMulti = params.options.ol_multi || 1; + shared.flattenLists(params).forEach(function forList(list) { + var lineCount = list.lastLineIndex - list.open.map[0]; + var allSingle = lineCount === list.items.length; + var expectedSpaces = list.unordered ? + (allSingle ? ulSingle : ulMulti) : + (allSingle ? olSingle : olMulti); + list.items.forEach(function forItem(item) { + var match = /^[\s>]*\S+(\s+)/.exec(item.line); + errors.addDetailIf(item.lineNumber, + expectedSpaces, (match ? match[1].length : 0)); + }); + }); + } +}; diff --git a/lib/rules/noBareUrls.js b/lib/rules/noBareUrls.js new file mode 100644 index 00000000..de845a49 --- /dev/null +++ b/lib/rules/noBareUrls.js @@ -0,0 +1,27 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD034", + "desc": "Bare URL used", + "tags": [ "links", "url" ], + "aliases": [ "no-bare-urls" ], + "regexp": expressions.bareUrlRe, + "func": function MD034(params, errors) { + shared.filterTokens(params, "inline", function forToken(token) { + var inLink = false; + token.children.forEach(function forChild(child) { + var match = null; + if (child.type === "link_open") { + inLink = true; + } else if (child.type === "link_close") { + inLink = false; + } else if ((child.type === "text") && + !inLink && + (match = expressions.bareUrlRe.exec(child.content))) { + errors.addContext(child.lineNumber, match[0]); + } + }); + }); + } +}; diff --git a/lib/rules/noBlanksBlockQuote.js b/lib/rules/noBlanksBlockQuote.js new file mode 100644 index 00000000..c31768ab --- /dev/null +++ b/lib/rules/noBlanksBlockQuote.js @@ -0,0 +1,17 @@ +module.exports = { + "name": "MD028", + "desc": "Blank line inside blockquote", + "tags": [ "blockquote", "whitespace" ], + "aliases": [ "no-blanks-blockquote" ], + "regexp": null, + "func": function MD028(params, errors) { + var prevToken = {}; + params.tokens.forEach(function forToken(token) { + if ((token.type === "blockquote_open") && + (prevToken.type === "blockquote_close")) { + errors.add(token.lineNumber - 1); + } + prevToken = token; + }); + } +}; diff --git a/lib/rules/noDuplicateHeader.js b/lib/rules/noDuplicateHeader.js new file mode 100644 index 00000000..1eba6b90 --- /dev/null +++ b/lib/rules/noDuplicateHeader.js @@ -0,0 +1,19 @@ +var shared = require("../shared"); + +module.exports = { + "name": "MD024", + "desc": "Multiple headers with the same content", + "tags": [ "headers" ], + "aliases": [ "no-duplicate-header" ], + "regexp": null, + "func": function MD024(params, errors) { + var knownContent = []; + shared.forEachHeading(params, function forHeading(heading, content) { + if (knownContent.indexOf(content) === -1) { + knownContent.push(content); + } else { + errors.addContext(heading.lineNumber, heading.line.trim()); + } + }); + } +}; diff --git a/lib/rules/noEmphasisAsHeader.js b/lib/rules/noEmphasisAsHeader.js new file mode 100644 index 00000000..eb96ab71 --- /dev/null +++ b/lib/rules/noEmphasisAsHeader.js @@ -0,0 +1,41 @@ +module.exports = { + "name": "MD036", + "desc": "Emphasis used instead of a header", + "tags": [ "headers", "emphasis" ], + "aliases": [ "no-emphasis-as-header" ], + "regexp": null, + "func": function MD036(params, errors) { + var punctuation = params.options.punctuation || ".,;:!?"; + var re = new RegExp("[" + punctuation + "]$"); + function base(token) { + if (token.type === "paragraph_open") { + return function inParagraph(t) { + if ((t.type === "inline") && + (t.children.length === 3) && + ((t.children[0].type === "strong_open") || + (t.children[0].type === "em_open")) && + (t.children[1].type === "text") && + !re.test(t.children[1].content)) { + errors.addContext(t.lineNumber, t.children[1].content); + } + }; + } else if (token.type === "blockquote_open") { + return function inBlockquote(t) { + if (t.type !== "blockquote_close") { + return inBlockquote; + } + }; + } else if (token.type === "list_item_open") { + return function inListItem(t) { + if (t.type !== "list_item_close") { + return inListItem; + } + }; + } + } + var state = base; + params.tokens.forEach(function forToken(token) { + state = state(token) || base; + }); + } +}; diff --git a/lib/rules/noEmptyLinks.js b/lib/rules/noEmptyLinks.js new file mode 100644 index 00000000..a16e4c1c --- /dev/null +++ b/lib/rules/noEmptyLinks.js @@ -0,0 +1,35 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD042", + "desc": "No empty links", + "tags": [ "links" ], + "aliases": [ "no-empty-links" ], + "regexp": expressions.emptyLinkRe, + "func": function MD042(params, errors) { + shared.filterTokens(params, "inline", function forToken(token) { + var inLink = false; + var linkText = ""; + var emptyLink = false; + token.children.forEach(function forChild(child) { + if (child.type === "link_open") { + inLink = true; + linkText = ""; + child.attrs.forEach(function forAttr(attr) { + if (attr[0] === "href" && (!attr[1] || (attr[1] === "#"))) { + emptyLink = true; + } + }); + } else if (child.type === "link_close") { + inLink = false; + if (emptyLink) { + errors.addContext(child.lineNumber, "[" + linkText + "]"); + } + } else if (inLink) { + linkText += child.content; + } + }); + }); + } +}; diff --git a/lib/rules/noHardTabs.js b/lib/rules/noHardTabs.js new file mode 100644 index 00000000..df0b7848 --- /dev/null +++ b/lib/rules/noHardTabs.js @@ -0,0 +1,20 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD010", + "desc": "Hard tabs", + "tags": [ "whitespace", "hard_tab" ], + "aliases": [ "no-hard-tabs" ], + "regexp": expressions.tabRe, + "func": function MD010(params, errors) { + var codeBlocks = params.options.code_blocks; + var includeCodeBlocks = (codeBlocks === undefined) ? true : !!codeBlocks; + shared.forEachLine(params, function forLine(line, lineIndex, inCode) { + if (expressions.tabRe.test(line) && (!inCode || includeCodeBlocks)) { + errors.addDetail(lineIndex + 1, + "Column: " + (line.indexOf("\t") + 1)); + } + }); + } +}; diff --git a/lib/rules/noInlineHtml.js b/lib/rules/noInlineHtml.js new file mode 100644 index 00000000..19ac7b44 --- /dev/null +++ b/lib/rules/noInlineHtml.js @@ -0,0 +1,37 @@ +var expressions = require("../expressions"); +var shared = require("../shared"); + +module.exports = { + "name": "MD033", + "desc": "Inline HTML", + "tags": [ "html" ], + "aliases": [ "no-inline-html" ], + "regexp": expressions.htmlRe, + "func": function MD033(params, errors) { + var allowedElements = (params.options.allowed_elements || []) + .map(function forElement(element) { + return element.toLowerCase(); + }); + function forToken(token) { + token.content.split(shared.newLineRe) + .forEach(function forLine(line, offset) { + var allowed = (line.match(/<[^/\s>!]*/g) || []) + .filter(function forElement(element) { + return element.length > 1; + }) + .map(function forElement(element) { + return element.slice(1).toLowerCase(); + }) + .filter(function forElement(element) { + return allowedElements.indexOf(element) === -1; + }); + if (allowed.length) { + errors.addDetail(token.lineNumber + offset, + "Element: " + allowed[0]); + } + }); + } + shared.filterTokens(params, "html_block", forToken); + shared.forEachInlineChild(params, "html_inline", forToken); + } +}; diff --git a/lib/rules/noMissingSpaceAtx.js b/lib/rules/noMissingSpaceAtx.js new file mode 100644 index 00000000..984b3529 --- /dev/null +++ b/lib/rules/noMissingSpaceAtx.js @@ -0,0 +1,17 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD018", + "desc": "No space after hash on atx style header", + "tags": [ "headers", "atx", "spaces" ], + "aliases": [ "no-missing-space-atx" ], + "regexp": expressions.atxHeaderSpaceRe, + "func": function MD018(params, errors) { + shared.forEachLine(params, function forLine(line, lineIndex, inCode) { + if (!inCode && /^#+[^#\s]/.test(line) && !/#$/.test(line)) { + errors.addContext(lineIndex + 1, line.trim()); + } + }); + } +}; diff --git a/lib/rules/noMissingSpaceClosedAtx.js b/lib/rules/noMissingSpaceClosedAtx.js new file mode 100644 index 00000000..967a9154 --- /dev/null +++ b/lib/rules/noMissingSpaceClosedAtx.js @@ -0,0 +1,21 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD020", + "desc": "No space inside hashes on closed atx style header", + "tags": [ "headers", "atx_closed", "spaces" ], + "aliases": [ "no-missing-space-closed-atx" ], + "regexp": expressions.atxClosedHeaderNoSpaceRe, + "func": function MD020(params, errors) { + shared.forEachLine(params, function forLine(line, lineIndex, inCode) { + if (!inCode && /^#+[^#]*[^\\]#+$/.test(line)) { + var left = /^#+[^#\s]/.test(line); + var right = /[^#\s]#+$/.test(line); + if (left || right) { + errors.addContext(lineIndex + 1, line.trim(), left, right); + } + } + }); + } +}; diff --git a/lib/rules/noMultipleBlanks.js b/lib/rules/noMultipleBlanks.js new file mode 100644 index 00000000..47bb098c --- /dev/null +++ b/lib/rules/noMultipleBlanks.js @@ -0,0 +1,19 @@ +var shared = require("../shared"); + +module.exports = { + "name": "MD012", + "desc": "Multiple consecutive blank lines", + "tags": [ "whitespace", "blank_lines" ], + "aliases": [ "no-multiple-blanks" ], + "regexp": null, + "func": function MD012(params, errors) { + var maximum = params.options.maximum || 1; + var count = 0; + shared.forEachLine(params, function forLine(line, lineIndex, inCode) { + count = (inCode || line.trim().length) ? 0 : count + 1; + if (maximum < count) { + errors.addDetailIf(lineIndex + 1, maximum, count); + } + }); + } +}; diff --git a/lib/rules/noMultipleSpaceAtx.js b/lib/rules/noMultipleSpaceAtx.js new file mode 100644 index 00000000..c96bb8b9 --- /dev/null +++ b/lib/rules/noMultipleSpaceAtx.js @@ -0,0 +1,18 @@ +var expressions = require("../expressions"); +var shared = require("../shared"); + +module.exports = { + "name": "MD019", + "desc": "Multiple spaces after hash on atx style header", + "tags": [ "headers", "atx", "spaces" ], + "aliases": [ "no-multiple-space-atx" ], + "regexp": expressions.atxHeaderSpaceRe, + "func": function MD019(params, errors) { + shared.filterTokens(params, "heading_open", function forToken(token) { + if ((shared.headingStyleFor(token) === "atx") && + /^#+\s\s/.test(token.line)) { + errors.addContext(token.lineNumber, token.line.trim()); + } + }); + } +}; diff --git a/lib/rules/noMultipleSpaceBlockQuote.js b/lib/rules/noMultipleSpaceBlockQuote.js new file mode 100644 index 00000000..7d50980b --- /dev/null +++ b/lib/rules/noMultipleSpaceBlockQuote.js @@ -0,0 +1,38 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD027", + "desc": "Multiple spaces after blockquote symbol", + "tags": [ "blockquote", "whitespace", "indentation" ], + "aliases": [ "no-multiple-space-blockquote" ], + "regexp": expressions.spaceAfterBlockQuote, + "func": function MD027(params, errors) { + var blockquoteNesting = 0; + var listItemNesting = 0; + params.tokens.forEach(function forToken(token) { + if (token.type === "blockquote_open") { + blockquoteNesting++; + } else if (token.type === "blockquote_close") { + blockquoteNesting--; + } else if (token.type === "list_item_open") { + listItemNesting++; + } else if (token.type === "list_item_close") { + listItemNesting--; + } else if ((token.type === "inline") && (blockquoteNesting > 0)) { + var multipleSpaces = listItemNesting ? + /^(\s*>)+\s\s+>/.test(token.line) : + /^(\s*>)+\s\s/.test(token.line); + if (multipleSpaces) { + errors.addContext(token.lineNumber, token.line); + } + token.content.split(shared.newLineRe) + .forEach(function forLine(line, offset) { + if (/^\s/.test(line)) { + errors.addContext(token.lineNumber + offset, "> " + line); + } + }); + } + }); + } +}; diff --git a/lib/rules/noMultipleSpaceClosedAtx.js b/lib/rules/noMultipleSpaceClosedAtx.js new file mode 100644 index 00000000..2800cc8a --- /dev/null +++ b/lib/rules/noMultipleSpaceClosedAtx.js @@ -0,0 +1,21 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD021", + "desc": "Multiple spaces inside hashes on closed atx style header", + "tags": [ "headers", "atx_closed", "spaces" ], + "aliases": [ "no-multiple-space-closed-atx" ], + "regexp": expressions.atxClosedHeaderSpaceRe, + "func": function MD021(params, errors) { + shared.filterTokens(params, "heading_open", function forToken(token) { + if (shared.headingStyleFor(token) === "atx_closed") { + var left = /^#+\s\s/.test(token.line); + var right = /\s\s#+$/.test(token.line); + if (left || right) { + errors.addContext(token.lineNumber, token.line.trim(), left, right); + } + } + }); + } +}; diff --git a/lib/rules/noReversedLinks.js b/lib/rules/noReversedLinks.js new file mode 100644 index 00000000..1f08ea0f --- /dev/null +++ b/lib/rules/noReversedLinks.js @@ -0,0 +1,18 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD011", + "desc": "Reversed link syntax", + "tags": [ "links" ], + "aliases": [ "no-reversed-links" ], + "regexp": expressions.reversedLinkRe, + "func": function MD011(params, errors) { + shared.forEachInlineChild(params, "text", function forToken(token) { + var match = expressions.reversedLinkRe.exec(token.content); + if (match) { + errors.addDetail(token.lineNumber, match[0]); + } + }); + } +}; diff --git a/lib/rules/noSpaceInCode.js b/lib/rules/noSpaceInCode.js new file mode 100644 index 00000000..42884fb8 --- /dev/null +++ b/lib/rules/noSpaceInCode.js @@ -0,0 +1,27 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD038", + "desc": "Spaces inside code span elements", + "tags": [ "whitespace", "code" ], + "aliases": [ "no-space-in-code" ], + "regexp": expressions.spaceInsideCodeRe, + "func": function MD038(params, errors) { + var inlineCodeSpansRe = /(?:^|[^\\])((`+)((?:.*?[^`])|)\2(?!`))/g; + shared.forEachInlineChild(params, "code_inline", + function forToken(token) { + var line = params.lines[token.lineNumber - 1]; + var match = null; + while ((match = inlineCodeSpansRe.exec(line)) !== null) { + var inlineCodeSpan = match[1]; + var content = match[3]; + if (/^\s([^`]|$)/.test(content)) { + errors.addContext(token.lineNumber, inlineCodeSpan); + } else if (/[^`]\s$/.test(content)) { + errors.addContext(token.lineNumber, inlineCodeSpan, false, true); + } + } + }); + } +}; diff --git a/lib/rules/noSpaceInEmphasis.js b/lib/rules/noSpaceInEmphasis.js new file mode 100644 index 00000000..89fb2326 --- /dev/null +++ b/lib/rules/noSpaceInEmphasis.js @@ -0,0 +1,21 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD037", + "desc": "Spaces inside emphasis markers", + "tags": [ "whitespace", "emphasis" ], + "aliases": [ "no-space-in-emphasis" ], + "regexp": expressions.spaceInsideEmphasisRe, + "func": function MD037(params, errors) { + shared.forEachInlineChild(params, "text", function forToken(token) { + var left = /\s(\*\*?|__?)\s.+\1/.exec(token.content); + var right = /(\*\*?|__?).+\s\1\s/.exec(token.content); + if (left) { + errors.addContext(token.lineNumber, left[0].trim()); + } else if (right) { + errors.addContext(token.lineNumber, right[0].trim(), false, true); + } + }); + } +}; diff --git a/lib/rules/noSpaceInLinks.js b/lib/rules/noSpaceInLinks.js new file mode 100644 index 00000000..a249ed2e --- /dev/null +++ b/lib/rules/noSpaceInLinks.js @@ -0,0 +1,32 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD039", + "desc": "Spaces inside link text", + "tags": [ "whitespace", "links" ], + "aliases": [ "no-space-in-links" ], + "regexp": expressions.spaceInsideLinkRe, + "func": function MD039(params, errors) { + shared.filterTokens(params, "inline", function forToken(token) { + var inLink = false; + var linkText = ""; + token.children.forEach(function forChild(child) { + if (child.type === "link_open") { + inLink = true; + linkText = ""; + } else if (child.type === "link_close") { + inLink = false; + var left = linkText.trimLeft().length !== linkText.length; + var right = linkText.trimRight().length !== linkText.length; + if (left || right) { + errors.addContext( + token.lineNumber, "[" + linkText + "]", left, right); + } + } else if (inLink) { + linkText += child.content; + } + }); + }); + } +}; diff --git a/lib/rules/noTrailingPunctuation.js b/lib/rules/noTrailingPunctuation.js new file mode 100644 index 00000000..71e3f978 --- /dev/null +++ b/lib/rules/noTrailingPunctuation.js @@ -0,0 +1,21 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD026", + "desc": "Trailing punctuation in header", + "tags": [ "headers" ], + "aliases": [ "no-trailing-punctuation" ], + "regexp": expressions.trailingPunctuationRe, + "func": function MD026(params, errors) { + var punctuation = params.options.punctuation || ".,;:!?"; + var re = new RegExp("[" + punctuation + "]$"); + shared.forEachHeading(params, function forHeading(heading, content) { + var match = re.exec(content); + if (match) { + errors.addDetail(heading.lineNumber, + "Punctuation: '" + match[0] + "'"); + } + }); + } +}; diff --git a/lib/rules/noTrailingSpaces.js b/lib/rules/noTrailingSpaces.js new file mode 100644 index 00000000..d24fd67b --- /dev/null +++ b/lib/rules/noTrailingSpaces.js @@ -0,0 +1,33 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD009", + "desc": "Trailing spaces", + "tags": [ "whitespace" ], + "aliases": [ "no-trailing-spaces" ], + "regexp": expressions.trailingSpaceRe, + "func": function MD009(params, errors) { + var brSpaces = params.options.br_spaces || 0; + var listItemEmptyLines = params.options.list_item_empty_lines; + var allowListItemEmptyLines = + (listItemEmptyLines === undefined) ? false : !!listItemEmptyLines; + var listItemLineNumbers = []; + if (allowListItemEmptyLines) { + shared.filterTokens(params, "list_item_open", function forToken(token) { + for (var i = token.map[0]; i < token.map[1]; i++) { + listItemLineNumbers.push(i + 1); + } + }); + } + shared.forEachLine(params, function forLine(line, lineIndex) { + var lineNumber = lineIndex + 1; + if (expressions.trailingSpaceRe.test(line) && + (listItemLineNumbers.indexOf(lineNumber) === -1)) { + var expected = (brSpaces < 2) ? 0 : brSpaces; + errors.addDetailIf(lineNumber, + expected, line.length - line.trimRight().length); + } + }); + } +}; diff --git a/lib/rules/olPrefix.js b/lib/rules/olPrefix.js new file mode 100644 index 00000000..8fbd035e --- /dev/null +++ b/lib/rules/olPrefix.js @@ -0,0 +1,26 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD029", + "desc": "Ordered list item prefix", + "tags": [ "ol" ], + "aliases": [ "ol-prefix" ], + "regexp": expressions.listItemMarkerRe, + "func": function MD029(params, errors) { + var style = params.options.style || "one"; + shared.flattenLists(params).forEach(function forList(list) { + if (!list.unordered) { + var number = 1; + list.items.forEach(function forItem(item) { + var match = /^[\s>]*([^.)]*)[.)]/.exec(item.line); + errors.addDetailIf(item.lineNumber, + String(number), !match || match[1]); + if (style === "ordered") { + number++; + } + }); + } + }); + } +}; diff --git a/lib/rules/properNames.js b/lib/rules/properNames.js new file mode 100644 index 00000000..451bf68c --- /dev/null +++ b/lib/rules/properNames.js @@ -0,0 +1,44 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD044", + "desc": "Proper names should have the correct capitalization", + "tags": [ "spelling" ], + "aliases": [ "proper-names" ], + "regexp": null, + "func": function MD044(params, errors) { + var names = params.options.names || []; + var codeBlocks = params.options.code_blocks; + var includeCodeBlocks = (codeBlocks === undefined) ? true : !!codeBlocks; + names.forEach(function forName(name) { + var escapedName = shared.escapeForRegExp(name); + var namePattern = "\\S*\\b(" + escapedName + ")\\b\\S*"; + var anyNameRe = new RegExp(namePattern, "gi"); + function forToken(token) { + var fenceOffset = (token.type === "fence") ? 1 : 0; + token.content.split(shared.newLineRe) + .forEach(function forLine(line, index) { + var match = null; + while ((match = anyNameRe.exec(line)) !== null) { + var fullMatch = match[0]; + if (!expressions.bareUrlRe.test(fullMatch)) { + var wordMatch = fullMatch + .replace(/^\W*/, "").replace(/\W*$/, ""); + if (names.indexOf(wordMatch) === -1) { + var lineNumber = token.lineNumber + index + fenceOffset; + errors.addDetailIf(lineNumber, name, match[1]); + } + } + } + }); + } + shared.forEachInlineChild(params, "text", forToken); + if (includeCodeBlocks) { + shared.forEachInlineChild(params, "code_inline", forToken); + shared.filterTokens(params, "code_block", forToken); + shared.filterTokens(params, "fence", forToken); + } + }); + } +}; diff --git a/lib/rules/requiredHeaders.js b/lib/rules/requiredHeaders.js new file mode 100644 index 00000000..ed467229 --- /dev/null +++ b/lib/rules/requiredHeaders.js @@ -0,0 +1,38 @@ +var shared = require("../shared"); + +module.exports = { + "name": "MD043", + "desc": "Required header structure", + "tags": [ "headers" ], + "aliases": [ "required-headers" ], + "regexp": null, + "func": function MD043(params, errors) { + var requiredHeaders = params.options.headers; + if (requiredHeaders) { + var levels = {}; + [ 1, 2, 3, 4, 5, 6 ].forEach(function forLevel(level) { + levels["h" + level] = "######".substr(-level); + }); + var i = 0; + var optional = false; + shared.forEachHeading(params, function forHeading(heading, content) { + if (!errors.length) { + var actual = levels[heading.tag] + " " + content; + var expected = requiredHeaders[i++] || "[None]"; + if (expected === "*") { + optional = true; + } else if (expected.toLowerCase() === actual.toLowerCase()) { + optional = false; + } else if (optional) { + i--; + } else { + errors.addDetailIf(heading.lineNumber, expected, actual); + } + } + }); + if ((i < requiredHeaders.length) && !errors.length) { + errors.addContext(params.lines.length, requiredHeaders[i]); + } + } + } +}; diff --git a/lib/rules/singleH1.js b/lib/rules/singleH1.js new file mode 100644 index 00000000..d237e0b5 --- /dev/null +++ b/lib/rules/singleH1.js @@ -0,0 +1,23 @@ +var shared = require("../shared"); + +module.exports = { + "name": "MD025", + "desc": "Multiple top level headers in the same document", + "tags": [ "headers" ], + "aliases": [ "single-h1" ], + "regexp": null, + "func": function MD025(params, errors) { + var level = params.options.level || 1; + var tag = "h" + level; + var hasTopLevelHeading = false; + shared.filterTokens(params, "heading_open", function forToken(token) { + if (token.tag === tag) { + if (hasTopLevelHeading) { + errors.addContext(token.lineNumber, token.line.trim()); + } else if (token.lineNumber === 1) { + hasTopLevelHeading = true; + } + } + }); + } +}; diff --git a/lib/rules/ulIndent.js b/lib/rules/ulIndent.js new file mode 100644 index 00000000..ea8677f1 --- /dev/null +++ b/lib/rules/ulIndent.js @@ -0,0 +1,24 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD007", + "desc": "Unordered list indentation", + "tags": [ "bullet", "ul", "indentation" ], + "aliases": [ "ul-indent" ], + "regexp": expressions.listItemMarkerRe, + "func": function MD007(params, errors) { + var optionsIndent = params.options.indent || 2; + var prevIndent = 0; + shared.flattenLists(params).forEach(function forList(list) { + if (list.unordered && list.parentsUnordered) { + var indent = shared.indentFor(list.open); + if (indent > prevIndent) { + errors.addDetailIf(list.open.lineNumber, + prevIndent + optionsIndent, indent); + } + prevIndent = indent; + } + }); + } +}; diff --git a/lib/rules/ulStartLeft.js b/lib/rules/ulStartLeft.js new file mode 100644 index 00000000..2ecf49ce --- /dev/null +++ b/lib/rules/ulStartLeft.js @@ -0,0 +1,17 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD006", + "desc": "Consider starting bulleted lists at the beginning of the line", + "tags": [ "bullet", "ul", "indentation" ], + "aliases": [ "ul-start-left" ], + "regexp": expressions.listItemMarkerRe, + "func": function MD006(params, errors) { + shared.flattenLists(params).forEach(function forList(list) { + if (list.unordered && !list.nesting) { + errors.addDetailIf(list.open.lineNumber, 0, shared.indentFor(list.open)); + } + }); + } +}; diff --git a/lib/rules/ulStyle.js b/lib/rules/ulStyle.js new file mode 100644 index 00000000..2303321d --- /dev/null +++ b/lib/rules/ulStyle.js @@ -0,0 +1,37 @@ +var shared = require("../shared"); +var expressions = require("../expressions"); + +module.exports = { + "name": "MD004", + "desc": "Unordered list style", + "tags": [ "bullet", "ul" ], + "aliases": [ "ul-style" ], + "regexp": expressions.listItemMarkerRe, + "func": function MD004(params, errors) { + var style = params.options.style || "consistent"; + var expectedStyle = style; + var nestingStyles = []; + shared.flattenLists(params).forEach(function forList(list) { + if (list.unordered) { + if (expectedStyle === "consistent") { + expectedStyle = shared.unorderedListStyleFor(list.items[0]); + } + list.items.forEach(function forItem(item) { + var itemStyle = shared.unorderedListStyleFor(item); + if (style === "sublist") { + var nesting = list.nesting; + if (!nestingStyles[nesting] && + (itemStyle !== nestingStyles[nesting - 1])) { + nestingStyles[nesting] = itemStyle; + } else { + errors.addDetailIf(item.lineNumber, + nestingStyles[nesting], itemStyle); + } + } else { + errors.addDetailIf(item.lineNumber, expectedStyle, itemStyle); + } + }); + } + }); + } +}; diff --git a/lib/shared.js b/lib/shared.js index 53cdaf19..b88c1419 100644 --- a/lib/shared.js +++ b/lib/shared.js @@ -60,3 +60,175 @@ function clearHtmlCommentText(text) { return text; } module.exports.clearHtmlCommentText = clearHtmlCommentText; + +var defaultLineLength = 80; +module.exports.defaultLineLength = defaultLineLength; + +function longLineReFunc(options) { + var lineLength = options.line_length || defaultLineLength; + return new RegExp("^(.{" + lineLength + "})(.*\\s.*)$"); +} +module.exports.longLineReFunc = longLineReFunc; + +// Escapes a string for use in a RegExp +function escapeForRegExp(str) { + return str.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); +} +module.exports.escapeForRegExp = escapeForRegExp; + +// Returns the indent for a token +function indentFor(token) { + var line = token.line.replace(/^[\s>]*(> |>)/, ""); + return line.length - line.trimLeft().length; +} +module.exports.indentFor = indentFor; + +// Returns the heading style for a heading token +function headingStyleFor(token) { + if ((token.map[1] - token.map[0]) === 1) { + if (/#\s*$/.test(token.line)) { + return "atx_closed"; + } + return "atx"; + } + return "setext"; +} +module.exports.headingStyleFor = headingStyleFor; + +// Returns the unordered list style for a list item token +function unorderedListStyleFor(token) { + switch (token.line.trimLeft().substr(0, 1)) { + case "-": + return "dash"; + case "+": + return "plus"; + // case "*": + default: + return "asterisk"; + } +} +module.exports.unorderedListStyleFor = unorderedListStyleFor; + +// Calls the provided function for each matching token +function filterTokens(params, type, callback) { + (params.tokenLists[type] || []).forEach(callback); +} +module.exports.filterTokens = filterTokens; + +// Calls the provided function for each line (with context) +function forEachLine(params, callback) { + if (!params.forEachLine) { + var lineMetadata = new Array(params.lines.length); + var fenceStart = null; + var inFence = false; + // Find fenced code by pattern (parser ignores "``` close fence") + params.lines.forEach(function forLine(line, lineIndex) { + var metadata = 0; + var match = /^(`{3,}|~{3,})/.exec(line); + var fence = match && match[1]; + if (fence && + (!inFence || (fence.substr(0, fenceStart.length) === fenceStart))) { + metadata = inFence ? 2 : 6; + fenceStart = inFence ? null : fence; + inFence = !inFence; + } else if (inFence) { + metadata = 1; + } + lineMetadata[lineIndex] = metadata; + }); + // Find code blocks normally + filterTokens(params, "code_block", function forToken(token) { + for (var i = token.map[0]; i < token.map[1]; i++) { + lineMetadata[i] = 1; + } + }); + // Find tables normally + filterTokens(params, "table_open", function forToken(token) { + for (var i = token.map[0]; i < token.map[1]; i++) { + lineMetadata[i] += 8; + } + }); + params.forEachLine = lineMetadata; + } + // Invoke callback + params.lines.forEach(function forLine(line, lineIndex) { + var metadata = params.forEachLine[lineIndex]; + callback( + line, + lineIndex, + !!(metadata & 7), + (((metadata & 6) >> 1) || 2) - 2, + !!(metadata & 8)); + }); +} +module.exports.forEachLine = forEachLine; + +// Calls the provided function for each specified inline child token +function forEachInlineChild(params, type, callback) { + filterTokens(params, "inline", function forToken(token) { + token.children.forEach(function forChild(child) { + if (child.type === type) { + callback(child, token); + } + }); + }); +} +module.exports.forEachInlineChild = forEachInlineChild; + +// Calls the provided function for each heading's content +function forEachHeading(params, callback) { + var heading = null; + params.tokens.forEach(function forToken(token) { + if (token.type === "heading_open") { + heading = token; + } else if (token.type === "heading_close") { + heading = null; + } else if ((token.type === "inline") && heading) { + callback(heading, token.content); + } + }); +} +module.exports.forEachHeading = forEachHeading; + +// Returns (nested) lists as a flat array (in order) +function flattenLists(params) { + if (!params.flattenLists) { + var lists = []; + var stack = []; + var current = null; + var lastWithMap = { "map": [ 0, 1 ] }; + params.tokens.forEach(function forToken(token) { + if ((token.type === "bullet_list_open") || + (token.type === "ordered_list_open")) { + // Save current context and start a new one + stack.push(current); + current = { + "unordered": (token.type === "bullet_list_open"), + "parentsUnordered": !current || + (current.unordered && current.parentsUnordered), + "open": token, + "items": [], + "nesting": stack.length - 1, + "lastLineIndex": -1, + "insert": lists.length + }; + } else if ((token.type === "bullet_list_close") || + (token.type === "ordered_list_close")) { + // Finalize current context and restore previous + current.lastLineIndex = lastWithMap.map[1]; + lists.splice(current.insert, 0, current); + delete current.insert; + current = stack.pop(); + } else if (token.type === "list_item_open") { + // Add list item + current.items.push(token); + } else if (token.map) { + // Track last token with map + lastWithMap = token; + } + }); + params.flattenLists = lists; + } + return params.flattenLists; +} +module.exports.flattenLists = flattenLists;