Skip to content

Commit fe6f4ca

Browse files
authored
feat: port rule no-nonoctal-decimal-escape (#837)
1 parent f302b8c commit fe6f4ca

6 files changed

Lines changed: 1400 additions & 0 deletions

File tree

internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ import (
192192
"github.com/web-infra-dev/rslint/internal/rules/no_new_object"
193193
"github.com/web-infra-dev/rslint/internal/rules/no_new_symbol"
194194
"github.com/web-infra-dev/rslint/internal/rules/no_new_wrappers"
195+
"github.com/web-infra-dev/rslint/internal/rules/no_nonoctal_decimal_escape"
195196
"github.com/web-infra-dev/rslint/internal/rules/no_obj_calls"
196197
"github.com/web-infra-dev/rslint/internal/rules/no_octal"
197198
"github.com/web-infra-dev/rslint/internal/rules/no_octal_escape"
@@ -682,6 +683,7 @@ func registerAllCoreEslintRules() {
682683
GlobalRuleRegistry.Register("no-multi-assign", no_multi_assign.NoMultiAssignRule)
683684
GlobalRuleRegistry.Register("no-multi-str", no_multi_str.NoMultiStrRule)
684685
GlobalRuleRegistry.Register("no-nested-ternary", no_nested_ternary.NoNestedTernaryRule)
686+
GlobalRuleRegistry.Register("no-nonoctal-decimal-escape", no_nonoctal_decimal_escape.NoNonoctalDecimalEscapeRule)
685687
GlobalRuleRegistry.Register("no-octal", no_octal.NoOctalRule)
686688
GlobalRuleRegistry.Register("no-octal-escape", no_octal_escape.NoOctalEscapeRule)
687689
GlobalRuleRegistry.Register("no-param-reassign", no_param_reassign.NoParamReassignRule)
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package no_nonoctal_decimal_escape
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/microsoft/typescript-go/shim/ast"
7+
"github.com/microsoft/typescript-go/shim/core"
8+
"github.com/web-infra-dev/rslint/internal/rule"
9+
"github.com/web-infra-dev/rslint/internal/utils"
10+
)
11+
12+
// https://eslint.org/docs/latest/rules/no-nonoctal-decimal-escape
13+
var NoNonoctalDecimalEscapeRule = rule.Rule{
14+
Name: "no-nonoctal-decimal-escape",
15+
Run: func(ctx rule.RuleContext, options any) rule.RuleListeners {
16+
return rule.RuleListeners{
17+
ast.KindStringLiteral: func(node *ast.Node) {
18+
trimmedRange := utils.TrimNodeTextRange(ctx.SourceFile, node)
19+
rawStart := trimmedRange.Pos()
20+
raw := ctx.SourceFile.Text()[rawStart:trimmedRange.End()]
21+
22+
if !containsBackslashDigit89(raw) {
23+
return
24+
}
25+
26+
for _, hit := range scanDecimalEscapes(raw) {
27+
reportDecimalEscape(ctx, rawStart, hit)
28+
}
29+
},
30+
}
31+
},
32+
}
33+
34+
// decimalEscapeHit describes a single \8 or \9 occurrence found in the raw
35+
// source text of a string literal, plus the immediately preceding `\X` escape
36+
// pair (used to detect the special "\0\8" / "\0\9" case).
37+
type decimalEscapeHit struct {
38+
previousEscape string
39+
previousEscapeStart int
40+
decimalEscapeStart int
41+
decimalEscapeEnd int
42+
decimalEscape string
43+
}
44+
45+
func containsBackslashDigit89(raw string) bool {
46+
for i := 0; i+1 < len(raw); i++ {
47+
if raw[i] == '\\' && (raw[i+1] == '8' || raw[i+1] == '9') {
48+
return true
49+
}
50+
}
51+
return false
52+
}
53+
54+
// scanDecimalEscapes walks raw source text and returns every \8 / \9 hit.
55+
// Mirrors ESLint's regex
56+
//
57+
// (?:[^\\]|(?<previousEscape>\\.))*?(?<decimalEscape>\\[89])
58+
//
59+
// where previousEscape captures the LAST `\X` pair before the decimal escape.
60+
// In ESLint's lazy match, that capture only survives when the preceding `\X`
61+
// is immediately adjacent to the decimal escape — any unescaped character
62+
// between them clears it. We replicate that by resetting `previousEscape`
63+
// whenever a non-backslash byte is consumed.
64+
func scanDecimalEscapes(raw string) []decimalEscapeHit {
65+
var hits []decimalEscapeHit
66+
previousEscape := ""
67+
previousEscapeStart := -1
68+
n := len(raw)
69+
for i := 0; i < n; {
70+
if raw[i] != '\\' {
71+
i++
72+
previousEscape = ""
73+
previousEscapeStart = -1
74+
continue
75+
}
76+
if i+1 >= n {
77+
break
78+
}
79+
next := raw[i+1]
80+
if next == '8' || next == '9' {
81+
hits = append(hits, decimalEscapeHit{
82+
previousEscape: previousEscape,
83+
previousEscapeStart: previousEscapeStart,
84+
decimalEscapeStart: i,
85+
decimalEscapeEnd: i + 2,
86+
decimalEscape: raw[i : i+2],
87+
})
88+
i += 2
89+
previousEscape = ""
90+
previousEscapeStart = -1
91+
continue
92+
}
93+
previousEscape = raw[i : i+2]
94+
previousEscapeStart = i
95+
i += 2
96+
}
97+
return hits
98+
}
99+
100+
func reportDecimalEscape(ctx rule.RuleContext, rawStart int, hit decimalEscapeHit) {
101+
decimalEscapeStartAbs := rawStart + hit.decimalEscapeStart
102+
decimalEscapeEndAbs := rawStart + hit.decimalEscapeEnd
103+
digit := hit.decimalEscape[1:]
104+
105+
suggestions := make([]rule.RuleSuggestion, 0, 3)
106+
107+
if hit.previousEscape == "\\0" {
108+
// "\0\X" — replacing with "\0X" would create a legacy octal escape, so
109+
// the rule offers two alternative refactors instead of the single one.
110+
previousEscapeStartAbs := rawStart + hit.previousEscapeStart
111+
nullEscape := unicodeEscape(0)
112+
combined := nullEscape + digit
113+
suggestions = append(suggestions, rule.RuleSuggestion{
114+
Message: rule.RuleMessage{
115+
Id: "refactor",
116+
Description: refactorMessage("\\0"+hit.decimalEscape, combined),
117+
},
118+
FixesArr: []rule.RuleFix{
119+
rule.RuleFixReplaceRange(
120+
core.NewTextRange(previousEscapeStartAbs, decimalEscapeEndAbs),
121+
combined,
122+
),
123+
},
124+
})
125+
digitUnicode := unicodeEscape(rune(digit[0]))
126+
suggestions = append(suggestions, rule.RuleSuggestion{
127+
Message: rule.RuleMessage{
128+
Id: "refactor",
129+
Description: refactorMessage(hit.decimalEscape, digitUnicode),
130+
},
131+
FixesArr: []rule.RuleFix{
132+
rule.RuleFixReplaceRange(
133+
core.NewTextRange(decimalEscapeStartAbs, decimalEscapeEndAbs),
134+
digitUnicode,
135+
),
136+
},
137+
})
138+
} else {
139+
suggestions = append(suggestions, rule.RuleSuggestion{
140+
Message: rule.RuleMessage{
141+
Id: "refactor",
142+
Description: refactorMessage(hit.decimalEscape, digit),
143+
},
144+
FixesArr: []rule.RuleFix{
145+
rule.RuleFixReplaceRange(
146+
core.NewTextRange(decimalEscapeStartAbs, decimalEscapeEndAbs),
147+
digit,
148+
),
149+
},
150+
})
151+
}
152+
153+
escaped := "\\" + hit.decimalEscape
154+
suggestions = append(suggestions, rule.RuleSuggestion{
155+
Message: rule.RuleMessage{
156+
Id: "escapeBackslash",
157+
Description: escapeBackslashMessage(hit.decimalEscape, escaped),
158+
},
159+
FixesArr: []rule.RuleFix{
160+
rule.RuleFixReplaceRange(
161+
core.NewTextRange(decimalEscapeStartAbs, decimalEscapeEndAbs),
162+
escaped,
163+
),
164+
},
165+
})
166+
167+
ctx.ReportRangeWithSuggestions(
168+
core.NewTextRange(decimalEscapeStartAbs, decimalEscapeEndAbs),
169+
rule.RuleMessage{
170+
Id: "decimalEscape",
171+
Description: fmt.Sprintf("Don't use '%s' escape sequence.", hit.decimalEscape),
172+
},
173+
suggestions...,
174+
)
175+
}
176+
177+
func unicodeEscape(ch rune) string {
178+
return fmt.Sprintf("\\u%04x", ch)
179+
}
180+
181+
func refactorMessage(original, replacement string) string {
182+
return fmt.Sprintf("Replace '%s' with '%s'. This maintains the current functionality.", original, replacement)
183+
}
184+
185+
func escapeBackslashMessage(original, replacement string) string {
186+
return fmt.Sprintf("Replace '%s' with '%s' to include the actual backslash character.", original, replacement)
187+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# no-nonoctal-decimal-escape
2+
3+
## Rule Details
4+
5+
Disallows `\8` and `\9` escape sequences in string literals.
6+
7+
Although `"\8"` and `"\9"` evaluate to the same characters as `"8"` and `"9"`, they are non-octal decimal escape sequences kept only for backward compatibility with web JavaScript. Browsers must support them, but Annex B explicitly allows non-web environments to omit them. The recommended fix is to drop the leading backslash, switch the digit to its `\uXXXX` form, or — when the goal really is to include a backslash — escape the backslash itself.
8+
9+
Examples of **incorrect** code for this rule:
10+
11+
```javascript
12+
"\8";
13+
"\9";
14+
const foo = "w\8less";
15+
const bar = "December 1\9";
16+
const baz = "Don't use \8 and \9 escapes.";
17+
const quux = "\0\8";
18+
```
19+
20+
Examples of **correct** code for this rule:
21+
22+
```javascript
23+
"8";
24+
"9";
25+
const foo = "w8less";
26+
const bar = "December 19";
27+
const baz = "Don't use \\8 and \\9 escapes.";
28+
const quux = "\0\u0038";
29+
```
30+
31+
## Options
32+
33+
This rule has no options.
34+
35+
## Original Documentation
36+
37+
- [ESLint no-nonoctal-decimal-escape](https://eslint.org/docs/latest/rules/no-nonoctal-decimal-escape)

0 commit comments

Comments
 (0)