Skip to content

Commit 4b53212

Browse files
authored
feat: add ctx.Globals API for native rules to read declared globals (#1238)
1 parent 6262d2f commit 4b53212

12 files changed

Lines changed: 357 additions & 37 deletions

File tree

internal/config/config.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,31 @@ func mergeLanguageOptions(base, override *LanguageOptions) *LanguageOptions {
584584
return &merged
585585
}
586586

587+
// ExtractGlobals reads the effective `languageOptions.globals` for a merged
588+
// config (the buggy shallow-merged Raw map — see mergeLanguageOptions) and
589+
// normalizes it to a simple "is this name declared" set.
590+
//
591+
// Matches ESLint's own normalizeConfigGlobal (lib/languages/js/source-code/
592+
// source-code.js): only the string `"off"` un-declares a global. Every other
593+
// accepted value — including boolean `false` and `null`, which both map to
594+
// `"readonly"` — still declares it. (`false` does NOT mean "off"; that's a
595+
// common mix-up since other ESLint config knobs use `false`/`"off"`
596+
// interchangeably, but globals don't.)
597+
func ExtractGlobals(langOpts *LanguageOptions) map[string]bool {
598+
if langOpts == nil || langOpts.Raw == nil {
599+
return nil
600+
}
601+
raw, ok := langOpts.Raw["globals"].(map[string]any)
602+
if !ok {
603+
return nil
604+
}
605+
globals := make(map[string]bool, len(raw))
606+
for name, value := range raw {
607+
globals[name] = value != "off"
608+
}
609+
return globals
610+
}
611+
587612
// RulePluginPrefix extracts the plugin prefix from a rule name.
588613
// "@typescript-eslint/no-explicit-any" → "@typescript-eslint"
589614
// "import/no-unresolved" → "import"

internal/config/globals_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package config
2+
3+
import "testing"
4+
5+
// Locks in normalizeConfigGlobal parity with ESLint (source-code.js): only
6+
// the string "off" un-declares a global. Boolean false and null both map to
7+
// "readonly" and remain declared — a common mix-up since other ESLint config
8+
// knobs treat false/"off" as equivalent, but globals don't.
9+
func TestExtractGlobals(t *testing.T) {
10+
langOpts := &LanguageOptions{
11+
Raw: map[string]any{
12+
"globals": map[string]any{
13+
"stringOff": "off",
14+
"stringReadonly": "readonly",
15+
"stringWritable": "writable",
16+
"boolTrue": true,
17+
"boolFalse": false,
18+
"nullValue": nil,
19+
},
20+
},
21+
}
22+
23+
globals := ExtractGlobals(langOpts)
24+
25+
cases := map[string]bool{
26+
"stringOff": false,
27+
"stringReadonly": true,
28+
"stringWritable": true,
29+
"boolTrue": true,
30+
"boolFalse": true,
31+
"nullValue": true,
32+
}
33+
for name, want := range cases {
34+
if got := globals[name]; got != want {
35+
t.Errorf("ExtractGlobals()[%q] = %v, want %v", name, got, want)
36+
}
37+
}
38+
}
39+
40+
func TestExtractGlobals_NoLanguageOptions(t *testing.T) {
41+
if got := ExtractGlobals(nil); got != nil {
42+
t.Errorf("ExtractGlobals(nil) = %v, want nil", got)
43+
}
44+
if got := ExtractGlobals(&LanguageOptions{}); got != nil {
45+
t.Errorf("ExtractGlobals(empty) = %v, want nil", got)
46+
}
47+
}

internal/config/rule_registry.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ func (r *RuleRegistry) GetEnabledRules(config RslintConfig, filePath string, cwd
4848
return nil, nil // file is globally ignored
4949
}
5050

51+
globals := ExtractGlobals(mergedConfig.LanguageOptions)
52+
5153
var enabledRules []linter.ConfiguredRule
5254
for ruleName, ruleConfig := range mergedConfig.Rules {
5355
if ruleConfig.IsEnabled() {
@@ -67,6 +69,7 @@ func (r *RuleRegistry) GetEnabledRules(config RslintConfig, filePath string, cwd
6769
enabledRules = append(enabledRules, linter.ConfiguredRule{
6870
Name: ruleName,
6971
Settings: CloneSettings(mergedConfig.Settings),
72+
Globals: globals,
7073
Severity: ruleConfig.GetSeverity(),
7174
RequiresTypeInfo: ruleImpl.RequiresTypeInfo,
7275
IsEslintPluginRule: ruleImpl.IsEslintPluginRule,

internal/linter/linter.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ func runLintRulesInProgram(opts runProgramOptions) int32 {
136136
// Create disable manager for this file
137137
disableManager := rule.NewDisableManager(file, comments)
138138

139+
// Parse inline `/* global */` comments once per file, same as
140+
// DisableManager above — rules read the merged result off ctx.Globals
141+
// instead of parsing comments or config themselves.
142+
inlineGlobals := rule.ParseInlineGlobals(file, comments)
143+
139144
// For gap files (not in caller's TypeInfoFiles), pass nil TypeChecker
140145
// as defense-in-depth. Type-aware rules are already filtered out by
141146
// getRulesForFile, but this ensures rules with optional TypeChecker
@@ -152,6 +157,7 @@ func runLintRulesInProgram(opts runProgramOptions) int32 {
152157
SourceFile: file,
153158
Program: opts.Program,
154159
Settings: r.Settings,
160+
Globals: rule.MergeGlobals(r.Globals, inlineGlobals),
155161
TypeChecker: fileChecker,
156162
DisableManager: disableManager,
157163
ReportRange: func(textRange core.TextRange, msg rule.RuleMessage) {

internal/linter/types.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@ import (
77
)
88

99
type ConfiguredRule struct {
10-
Name string
11-
Settings map[string]interface{}
10+
Name string
11+
Settings map[string]interface{}
12+
// Globals is the config-declared `languageOptions.globals` for this file
13+
// (name → declared). The linter merges this with inline `/* global */`
14+
// comments (parsed once per file, same as DisableManager) before exposing
15+
// the combined result to rules as ctx.Globals — rules never parse either
16+
// source themselves. Nil when the config declares none.
17+
Globals map[string]bool
1218
Severity rule.DiagnosticSeverity
1319
RequiresTypeInfo bool
1420
// IsEslintPluginRule marks a rule that executes in the Node plugin-lint

internal/rule/inline_globals.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package rule
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
7+
"github.com/microsoft/typescript-go/shim/ast"
8+
)
9+
10+
// inlineGlobalsKeywords lists the directive keywords that introduce a
11+
// `/* global */` comment. "globals" is checked before "global" since it is
12+
// the longer prefix.
13+
var inlineGlobalsKeywords = [...]string{"globals", "global"}
14+
15+
// ParseInlineGlobals scans `/* global ... */` / `/* globals ... */` block
16+
// comments and returns the set of names they declare (name -> declared).
17+
//
18+
// This is the DisableManager-equivalent preprocessing step for globals: it
19+
// runs once per file, over the same real comment tokens DisableManager
20+
// consumes (not a regex over raw source text, so lookalike text inside a
21+
// string literal or another comment can't be mistaken for a directive), and
22+
// its result is merged with config globals before being handed to rules —
23+
// no rule should parse `/* global */` comments itself.
24+
//
25+
// Mirrors ESLint's inline-config handling: only the setting "off" un-declares
26+
// a name; a bare name or any other setting (e.g. "writable") declares it.
27+
func ParseInlineGlobals(sourceFile *ast.SourceFile, comments []*ast.CommentRange) map[string]bool {
28+
if sourceFile.Text() == "" || len(comments) == 0 {
29+
return nil
30+
}
31+
32+
text := sourceFile.Text()
33+
var globals map[string]bool
34+
35+
for _, comment := range comments {
36+
// `/* global */` is only recognized as a block comment, matching
37+
// ESLint's convention (and rslint's prior behavior).
38+
if comment.Kind != ast.KindMultiLineCommentTrivia {
39+
continue
40+
}
41+
content := strings.TrimSpace(text[comment.Pos()+2 : comment.End()-2])
42+
43+
rest, ok := matchInlineGlobalsDirective(content)
44+
if !ok {
45+
continue
46+
}
47+
48+
if globals == nil {
49+
globals = make(map[string]bool)
50+
}
51+
for name, setting := range parseGlobalNameList(rest) {
52+
globals[name] = setting != "off"
53+
}
54+
}
55+
56+
return globals
57+
}
58+
59+
// matchInlineGlobalsDirective reports whether comment content begins with
60+
// the "global"/"globals" directive keyword (followed by whitespace or
61+
// end-of-string, so e.g. "globalConfig setup" is not mistaken for one) and
62+
// returns the remainder to parse as a name list.
63+
func matchInlineGlobalsDirective(content string) (string, bool) {
64+
for _, kw := range inlineGlobalsKeywords {
65+
if !strings.HasPrefix(content, kw) {
66+
continue
67+
}
68+
rest := content[len(kw):]
69+
if rest == "" || rest[0] == ' ' || rest[0] == '\t' || rest[0] == '\n' || rest[0] == '\r' {
70+
return strings.TrimSpace(rest), true
71+
}
72+
}
73+
return "", false
74+
}
75+
76+
// MergeGlobals combines config-declared globals with inline `/* global */`
77+
// comment globals into the single set exposed to rules as ctx.Globals —
78+
// mirroring ESLint's addDeclaredGlobals, which layers inline comments on top
79+
// of config (inline wins on conflict). Returns nil if both are empty.
80+
func MergeGlobals(configGlobals, inlineGlobals map[string]bool) map[string]bool {
81+
if len(configGlobals) == 0 {
82+
return inlineGlobals
83+
}
84+
if len(inlineGlobals) == 0 {
85+
return configGlobals
86+
}
87+
merged := make(map[string]bool, len(configGlobals)+len(inlineGlobals))
88+
for name, declared := range configGlobals {
89+
merged[name] = declared
90+
}
91+
for name, declared := range inlineGlobals {
92+
merged[name] = declared
93+
}
94+
return merged
95+
}
96+
97+
// globalSettingSeparatorPattern collapses whitespace around a `:` or `,`
98+
// separator (e.g. "foo : writable , bar" -> "foo:writable,bar") before
99+
// splitting — mirrors ESLint's own parseStringConfig, which does the same
100+
// normalization so spaces around a separator don't get mistaken for part of
101+
// the name/setting.
102+
var globalSettingSeparatorPattern = regexp.MustCompile(`\s*([:,])\s*`)
103+
104+
// parseGlobalNameList parses a comma-and/or-whitespace separated
105+
// "name[:setting]" list, e.g. "foo, bar:writable baz:off", returning each
106+
// name mapped to its raw setting ("" when none was given).
107+
func parseGlobalNameList(s string) map[string]string {
108+
collapsed := globalSettingSeparatorPattern.ReplaceAllString(strings.TrimSpace(s), "$1")
109+
110+
names := make(map[string]string)
111+
for _, part := range strings.FieldsFunc(collapsed, func(r rune) bool {
112+
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
113+
}) {
114+
name, setting, _ := strings.Cut(part, ":")
115+
if name != "" {
116+
names[name] = setting
117+
}
118+
}
119+
return names
120+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package rule
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
// ---------------------------------------------------------------------------
9+
// matchInlineGlobalsDirective
10+
// ---------------------------------------------------------------------------
11+
12+
func TestMatchInlineGlobalsDirective(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
input string
16+
expectedRest string
17+
expectedOk bool
18+
}{
19+
{"global with names", "global foo, bar", "foo, bar", true},
20+
{"globals with names", "globals foo, bar", "foo, bar", true},
21+
{"bare global", "global", "", true},
22+
{"bare globals", "globals", "", true},
23+
{"global with tab", "global\tfoo", "foo", true},
24+
{"global with newline", "global\nfoo", "foo", true},
25+
{"not a directive", "eslint-disable no-console", "", false},
26+
{"lookalike prefix is not a directive", "globalConfig setup here", "", false},
27+
{"globalsConfig lookalike is not a directive", "globalsConfig setup here", "", false},
28+
}
29+
30+
for _, tt := range tests {
31+
t.Run(tt.name, func(t *testing.T) {
32+
rest, ok := matchInlineGlobalsDirective(tt.input)
33+
if ok != tt.expectedOk {
34+
t.Fatalf("ok = %v, want %v", ok, tt.expectedOk)
35+
}
36+
if ok && rest != tt.expectedRest {
37+
t.Errorf("rest = %q, want %q", rest, tt.expectedRest)
38+
}
39+
})
40+
}
41+
}
42+
43+
// ---------------------------------------------------------------------------
44+
// parseGlobalNameList
45+
// ---------------------------------------------------------------------------
46+
47+
func TestParseGlobalNameList(t *testing.T) {
48+
tests := []struct {
49+
name string
50+
input string
51+
expected map[string]string
52+
}{
53+
{"empty", "", map[string]string{}},
54+
{"single bare name", "foo", map[string]string{"foo": ""}},
55+
{"comma separated", "foo, bar", map[string]string{"foo": "", "bar": ""}},
56+
{"whitespace separated", "foo bar", map[string]string{"foo": "", "bar": ""}},
57+
{"with settings", "foo:readonly, bar:writable, baz:off", map[string]string{"foo": "readonly", "bar": "writable", "baz": "off"}},
58+
{"mixed separators and settings", "foo:writable bar, baz:off", map[string]string{"foo": "writable", "bar": "", "baz": "off"}},
59+
{"extra whitespace", " foo : writable , bar ", map[string]string{"foo": "writable", "bar": ""}},
60+
}
61+
62+
for _, tt := range tests {
63+
t.Run(tt.name, func(t *testing.T) {
64+
result := parseGlobalNameList(tt.input)
65+
if !reflect.DeepEqual(result, tt.expected) {
66+
t.Errorf("parseGlobalNameList(%q) = %v, want %v", tt.input, result, tt.expected)
67+
}
68+
})
69+
}
70+
}

internal/rule/rule.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,15 @@ func (d RuleDiagnostic) Fixes() []RuleFix {
211211
}
212212

213213
type RuleContext struct {
214-
SourceFile *ast.SourceFile
215-
Settings map[string]interface{}
214+
SourceFile *ast.SourceFile
215+
Settings map[string]interface{}
216+
// Globals is the fully resolved set of declared global names for this
217+
// file — config `languageOptions.globals` merged with inline
218+
// `/* global */` comments, computed once per file by the linter (see
219+
// DisableManager, built the same way). Rules should read this instead of
220+
// parsing comments or config themselves. Nil when none are declared;
221+
// a name maps to false only if some source explicitly set it to "off".
222+
Globals map[string]bool
216223
Program *compiler.Program
217224
TypeChecker *checker.Checker
218225
DisableManager *DisableManager

0 commit comments

Comments
 (0)