Skip to content

Commit 2f08dd0

Browse files
committed
feat: add new rules space-around-number
1 parent aae7c84 commit 2f08dd0

17 files changed

Lines changed: 501 additions & 122 deletions

File tree

src/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,13 @@ export const plugin: ESLint.Plugin = {
1010
},
1111
}
1212

13-
const allRuleEntries: Array<[string, Linter.RuleEntry]> = Object.keys(rules)
14-
.map(ruleName => [`md-style/${ruleName}`, 'error'])
15-
1613
const recommendedRules: Partial<Linter.RulesRecord> = {
1714
'md-style/valid-heading-anchor': 'error',
1815
'md-style/space-around-inline-element': 'error',
1916
}
20-
const allRules: Partial<Linter.RulesRecord> = Object.fromEntries(allRuleEntries)
17+
const allRules: Partial<Linter.RulesRecord>
18+
= Object.fromEntries(Object.keys(rules)
19+
.map(ruleName => [`md-style/${ruleName}`, 'error']))
2120

2221
interface PluginConfigMap {
2322
recommended: Linter.Config

src/rules/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import spaceAroundInlineElement from './space-around-inline-element/index'
2+
import spaceAroundNumber from './space-around-number'
23
import spaceAroundWord from './space-around-word'
34
import validHeadingAnchor from './valid-heading-anchor/index'
45

56
export const rules = {
67
'space-around-inline-element': spaceAroundInlineElement,
7-
'valid-heading-anchor': validHeadingAnchor,
8+
'space-around-number': spaceAroundNumber,
89
'space-around-word': spaceAroundWord,
10+
'valid-heading-anchor': validHeadingAnchor,
911
}

src/rules/space-around-inline-element/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { InvalidTestCase, ValidTestCase } from 'eslint-vitest-rule-tester'
22
import markdown from '@eslint/markdown'
33
import { run } from 'eslint-vitest-rule-tester'
4-
import { INLINE_SPACE_MESSAGE_IDS as MESSAGE_IDS } from '@/utils/inline-element'
4+
import { MESSAGE_IDS } from '@/rules/space-around-inline-element'
55
import rule, { RULE_NAME } from './index'
66

77
const valid: ValidTestCase[] = [

src/rules/space-around-inline-element/index.ts

Lines changed: 51 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,19 @@ import type { RuleContext, ValueOf } from '@/types'
33
import type { InlineElement } from '@/types/inline-element'
44
import { createRule } from '@/utils'
55
import { getNodeContext, getNodePosition, isNestedInlineElement } from '@/utils/ast'
6-
import { INLINE_SPACE_MESSAGE_IDS as MESSAGE_IDS, validateSpace } from '@/utils/inline-element'
6+
import { validateSpace } from '@/utils/inline-element'
77
import { getSpaceContext } from '@/utils/space'
88

99
export const RULE_NAME = 'space-around-inline-element'
10-
10+
export const MESSAGE_IDS = {
11+
missingSpaceBefore: 'missingSpaceBefore',
12+
missingSpaceAfter: 'missingSpaceAfter',
13+
multipleSpacesBefore: 'multipleSpacesBefore',
14+
multipleSpacesAfter: 'multipleSpacesAfter',
15+
multipleSpacesAfterPunctuation: 'multipleSpacesAfterPunctuation',
16+
unexpectedSpaceBefore: 'unexpectedSpaceBefore',
17+
unexpectedSpaceAfter: 'unexpectedSpaceAfter',
18+
} as const
1119
type MessageIds = ValueOf<typeof MESSAGE_IDS>
1220
type Options = []
1321

@@ -18,6 +26,47 @@ const BEFORE_INLINE_ELEMENT_MESSAGE_IDS = new Set<MessageIds>([
1826
MESSAGE_IDS.unexpectedSpaceBefore,
1927
])
2028

29+
export default createRule<Options, MessageIds>({
30+
name: RULE_NAME,
31+
meta: {
32+
type: 'layout',
33+
docs: {
34+
description: 'Enforce spacing around Markdown inline elements.',
35+
},
36+
messages: {
37+
missingSpaceBefore: 'A space is required before the inline element.',
38+
missingSpaceAfter: 'A space is required after the inline element.',
39+
multipleSpacesBefore: 'Use exactly one space before the inline element.',
40+
multipleSpacesAfter: 'Use exactly one space after the inline element.',
41+
multipleSpacesAfterPunctuation: 'Use one space after punctuation.',
42+
unexpectedSpaceBefore: 'Do not add a space between punctuation and the inline element.',
43+
unexpectedSpaceAfter: 'Do not add a space between the inline element and punctuation.',
44+
},
45+
fixable: 'whitespace',
46+
schema: [],
47+
},
48+
defaultOptions: [],
49+
create(context) {
50+
return {
51+
link(node: Link) {
52+
checkInlineElement(context, node)
53+
},
54+
image(node: Image) {
55+
checkInlineElement(context, node)
56+
},
57+
inlineCode(node: InlineCode) {
58+
checkInlineElement(context, node)
59+
},
60+
emphasis(node: Emphasis) {
61+
checkInlineElement(context, node)
62+
},
63+
strong(node: Strong) {
64+
checkInlineElement(context, node)
65+
},
66+
}
67+
},
68+
})
69+
2170
/**
2271
* Checks one selected inline element and reports the fix range around it.
2372
*/
@@ -63,44 +112,3 @@ function checkInlineElement(context: RuleContext<MessageIds, Options>, node: Inl
63112
})
64113
}
65114
}
66-
67-
export default createRule<Options, MessageIds>({
68-
name: RULE_NAME,
69-
meta: {
70-
type: 'layout',
71-
docs: {
72-
description: 'Enforce spacing around Markdown inline elements.',
73-
},
74-
messages: {
75-
missingSpaceBefore: 'A space is required before the inline element.',
76-
missingSpaceAfter: 'A space is required after the inline element.',
77-
multipleSpacesBefore: 'Use exactly one space before the inline element.',
78-
multipleSpacesAfter: 'Use exactly one space after the inline element.',
79-
multipleSpacesAfterPunctuation: 'Use one space after punctuation.',
80-
unexpectedSpaceBefore: 'Do not add a space between punctuation and the inline element.',
81-
unexpectedSpaceAfter: 'Do not add a space between the inline element and punctuation.',
82-
},
83-
fixable: 'whitespace',
84-
schema: [],
85-
},
86-
defaultOptions: [],
87-
create(context) {
88-
return {
89-
link(node: Link) {
90-
checkInlineElement(context, node)
91-
},
92-
image(node: Image) {
93-
checkInlineElement(context, node)
94-
},
95-
inlineCode(node: InlineCode) {
96-
checkInlineElement(context, node)
97-
},
98-
emphasis(node: Emphasis) {
99-
checkInlineElement(context, node)
100-
},
101-
strong(node: Strong) {
102-
checkInlineElement(context, node)
103-
},
104-
}
105-
},
106-
})
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { InvalidTestCase, ValidTestCase } from 'eslint-vitest-rule-tester'
2+
import markdown from '@eslint/markdown'
3+
import { run } from 'eslint-vitest-rule-tester'
4+
import { SPACE_MESSAGE_IDS as MESSAGE_IDS } from '@/utils/space'
5+
import rule, { RULE_NAME } from './index'
6+
7+
const valid: ValidTestCase[] = [
8+
{
9+
description: 'number between chinese text with spaces',
10+
code: '支持 123 个规则',
11+
},
12+
{
13+
description: 'decimal and percent number between chinese text with spaces',
14+
code: '提升 4.0% 效率',
15+
},
16+
{
17+
description: 'number adjacent to punctuation is allowed',
18+
code: '版本:2.0,现已发布。',
19+
},
20+
]
21+
22+
const invalid: InvalidTestCase[] = [
23+
{
24+
description: 'reports a missing space before a number after CJK text',
25+
code: '支持123 个规则',
26+
output: '支持 123 个规则',
27+
errors: [{ messageId: MESSAGE_IDS.missingSpaceBefore }],
28+
},
29+
{
30+
description: 'reports a missing space after a number before CJK text',
31+
code: '支持 123个规则',
32+
output: '支持 123 个规则',
33+
errors: [{ messageId: MESSAGE_IDS.missingSpaceAfter }],
34+
},
35+
{
36+
description: 'reports missing spaces on both sides of an embedded number',
37+
code: '支持123个规则',
38+
output: '支持 123 个规则',
39+
errors: [{ messageId: MESSAGE_IDS.missingSpacesAround }],
40+
},
41+
{
42+
description: 'reports missing spaces around decimal percent numbers',
43+
code: '提升4.0%效率',
44+
output: '提升 4.0% 效率',
45+
errors: [{ messageId: MESSAGE_IDS.missingSpacesAround }],
46+
},
47+
{
48+
description: 'reports an unexpected space before a number after CJK text',
49+
code: '支持 123 个规则',
50+
output: '支持 123 个规则',
51+
errors: [{ messageId: MESSAGE_IDS.unexpectedSpaceBefore }],
52+
},
53+
{
54+
description: 'reports an unexpected space after a number before CJK text',
55+
code: '支持 123 个规则',
56+
output: '支持 123 个规则',
57+
errors: [{ messageId: MESSAGE_IDS.unexpectedSpaceAfter }],
58+
},
59+
{
60+
description: 'reports unexpected spaces on both sides of a number',
61+
code: '支持 123 个规则',
62+
output: '支持 123 个规则',
63+
errors: [{ messageId: MESSAGE_IDS.unexpectedSpaceAround }],
64+
},
65+
]
66+
67+
run({
68+
name: RULE_NAME,
69+
rule,
70+
valid,
71+
invalid,
72+
configs: {
73+
plugins: { markdown },
74+
language: 'markdown/gfm',
75+
},
76+
})
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type { Text } from 'mdast'
2+
import type { ValueOf } from '@/types'
3+
import type { TokenContext } from '@/utils/ast'
4+
import { createRule } from '@/utils'
5+
import { getNodeContextByParent } from '@/utils/ast'
6+
import { SPACE_MESSAGE_IDS as MESSAGE_IDS } from '@/utils/space'
7+
import { buildTextNodeAst, isNumberType, TEXT_TYPE } from '@/utils/text-tokenizer'
8+
9+
export const RULE_NAME = 'space-around-number'
10+
11+
type MessageIds = ValueOf<typeof MESSAGE_IDS>
12+
type Options = []
13+
14+
export default createRule<Options, MessageIds>({
15+
name: RULE_NAME,
16+
meta: {
17+
type: 'layout',
18+
docs: {
19+
description: 'Enforce a single space between CJK characters and numbers.',
20+
},
21+
messages: {
22+
missingSpaceBefore: 'Add a space before the number.',
23+
missingSpaceAfter: 'Add a space after the number.',
24+
missingSpacesAround: 'Add spaces before and after the number.',
25+
unexpectedSpaceBefore: 'Remove the unexpected space before the number.',
26+
unexpectedSpaceAfter: 'Remove the unexpected space after the number.',
27+
unexpectedSpaceAround: 'Remove the unexpected spaces around the number.',
28+
},
29+
fixable: 'code',
30+
schema: [],
31+
},
32+
defaultOptions: [],
33+
create(context) {
34+
return {
35+
text(node: Text) {
36+
const { fixed, missingBefore, missingAfter, unexpectedBefore, unexpectedAfter } = fixBoundarySpace(node)
37+
38+
if (fixed === node.value)
39+
return
40+
41+
context.report({
42+
node,
43+
messageId: getMessageId({ missingBefore, missingAfter, unexpectedBefore, unexpectedAfter }),
44+
fix(fixer) {
45+
return fixer.replaceText(node, fixed)
46+
},
47+
})
48+
},
49+
}
50+
},
51+
})
52+
53+
interface FixBoundarySpaceResult {
54+
fixed: string
55+
missingBefore: boolean
56+
missingAfter: boolean
57+
unexpectedBefore: boolean
58+
unexpectedAfter: boolean
59+
}
60+
61+
function processSpaceToken(ctx: TokenContext, result: FixBoundarySpaceResult): void {
62+
const { prev, current, next } = ctx
63+
/* v8 ignore if -- @preserve */
64+
if (!current)
65+
return
66+
67+
const CJK2Number = prev?.type === TEXT_TYPE.cjk && isNumberType(next?.type)
68+
const number2CJK = isNumberType(prev?.type) && next?.type === TEXT_TYPE.cjk
69+
const numbers = isNumberType(prev?.type) && isNumberType(next?.type)
70+
const hasUnexpectedSpaces = current.value.length !== 1
71+
72+
if (hasUnexpectedSpaces) {
73+
if (CJK2Number || numbers)
74+
result.unexpectedBefore = true
75+
76+
if (number2CJK || numbers)
77+
result.unexpectedAfter = true
78+
}
79+
80+
if (CJK2Number || number2CJK || hasUnexpectedSpaces) {
81+
result.fixed += ' '
82+
}
83+
else {
84+
result.fixed += current.value
85+
}
86+
}
87+
88+
function processNumberToken(ctx: TokenContext, result: FixBoundarySpaceResult): void {
89+
const { prev, current, next } = ctx
90+
/* v8 ignore if -- @preserve */
91+
if (!current)
92+
return
93+
94+
if (prev?.type === TEXT_TYPE.cjk) {
95+
result.fixed += ' '
96+
result.missingBefore = true
97+
}
98+
99+
result.fixed += current.value
100+
101+
if (next?.type === TEXT_TYPE.cjk) {
102+
result.fixed += ' '
103+
result.missingAfter = true
104+
}
105+
}
106+
107+
function getMessageId(boundary: {
108+
missingBefore: boolean
109+
missingAfter: boolean
110+
unexpectedBefore: boolean
111+
unexpectedAfter: boolean
112+
}): MessageIds {
113+
if (boundary.missingBefore && boundary.missingAfter)
114+
return MESSAGE_IDS.missingSpacesAround
115+
116+
if (boundary.unexpectedBefore && boundary.unexpectedAfter)
117+
return MESSAGE_IDS.unexpectedSpaceAround
118+
119+
if (boundary.missingBefore)
120+
return MESSAGE_IDS.missingSpaceBefore
121+
122+
if (boundary.missingAfter)
123+
return MESSAGE_IDS.missingSpaceAfter
124+
125+
if (boundary.unexpectedBefore)
126+
return MESSAGE_IDS.unexpectedSpaceBefore
127+
128+
return MESSAGE_IDS.unexpectedSpaceAfter
129+
}
130+
131+
function fixBoundarySpace(node: Text): FixBoundarySpaceResult {
132+
const { children } = buildTextNodeAst(node)
133+
const result: FixBoundarySpaceResult = {
134+
fixed: '',
135+
missingBefore: false,
136+
missingAfter: false,
137+
unexpectedBefore: false,
138+
unexpectedAfter: false,
139+
}
140+
141+
for (let i = 0; i < children.length; i += 1) {
142+
const ctx = getNodeContextByParent(children, i)
143+
/* v8 ignore if -- @preserve */
144+
if (!ctx.current)
145+
continue
146+
147+
if (ctx.current.type === TEXT_TYPE.space)
148+
processSpaceToken(ctx, result)
149+
else if (isNumberType(ctx.current.type))
150+
processNumberToken(ctx, result)
151+
else
152+
result.fixed += ctx.current.value
153+
}
154+
155+
return result
156+
}

src/rules/space-around-word/index.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { InvalidTestCase, ValidTestCase } from 'eslint-vitest-rule-tester'
22
import markdown from '@eslint/markdown'
33
import { run } from 'eslint-vitest-rule-tester'
4-
import rule, { MESSAGE_IDS, RULE_NAME } from './index'
4+
import { SPACE_MESSAGE_IDS as MESSAGE_IDS } from '@/utils/space'
5+
import rule, { RULE_NAME } from './index'
56

67
const valid: ValidTestCase[] = [
78
{

0 commit comments

Comments
 (0)