@gesslar/sassy 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,498 @@
1
+ /**
2
+ * @file LintCommand.js
3
+ *
4
+ * Defines the LintCommand class for comprehensive theme file validation.
5
+ * Provides static analysis of theme configuration files to identify:
6
+ * - Duplicate scope definitions across tokenColor rules
7
+ * - Undefined variable references in theme content
8
+ * - Unused variables defined in vars but never referenced
9
+ * - Scope precedence issues where broad scopes mask specific ones
10
+ * - TextMate scope selector conflicts and redundancies
11
+ *
12
+ * Integrates with the theme compilation pipeline to analyse both source
13
+ * and compiled theme data, ensuring accurate variable resolution tracking.
14
+ * Supports modular themes with import dependencies and provides detailed,
15
+ * colour-coded reporting for different severity levels.
16
+ */
17
+
18
+ import c from "@gesslar/colours"
19
+ // import colorSupport from "color-support"
20
+
21
+ import Command from "./Command.js"
22
+ import Evaluator from "./Evaluator.js"
23
+ import Term from "./Term.js"
24
+ import Theme from "./Theme.js"
25
+ import ThemePool from "./ThemePool.js"
26
+
27
+
28
+ // oops, need to have @gesslar/colours support this, too!
29
+ // ansiColors.enabled = colorSupport.hasBasic
30
+
31
+ /**
32
+ * Command handler for linting theme files for potential issues.
33
+ * Validates tokenColors for duplicate scopes, undefined variables, unused
34
+ * variables, and precedence issues that could cause unexpected theme
35
+ * behaviour.
36
+ */
37
+ export default class LintCommand extends Command {
38
+ /**
39
+ * Creates a new LintCommand instance.
40
+ *
41
+ * @param {object} base - Base configuration containing cwd and packageJson
42
+ */
43
+ constructor(base) {
44
+ super(base)
45
+
46
+ this.cliCommand = "lint <file>"
47
+ this.cliOptions = {
48
+ // Future options could include:
49
+ // "fix": ["-f, --fix", "automatically fix issues where possible"],
50
+ // "strict": ["--strict", "treat warnings as errors"],
51
+ // "format": ["--format <type>", "output format (text, json)", "text"],
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Executes the lint command for a given theme file.
57
+ * Validates the theme and reports any issues found.
58
+ *
59
+ * @param {string} inputArg - Path to the theme file to lint
60
+ * @param {object} options - Linting options
61
+ * @returns {Promise<void>} Resolves when linting is complete
62
+ */
63
+ async execute(inputArg, options = {}) {
64
+ const {cwd} = this
65
+ const fileObject = await this.resolveThemeFileName(inputArg, cwd)
66
+ const theme = new Theme(fileObject, cwd, options)
67
+
68
+ theme.cache = this.cache
69
+
70
+ await theme.load()
71
+ await theme.build()
72
+
73
+ const issues = await this.lintTheme(theme)
74
+
75
+ this.reportIssues(issues)
76
+ }
77
+
78
+ /**
79
+ * Performs comprehensive linting of a theme.
80
+ * Returns an array of issues found during validation.
81
+ *
82
+ * @param {Theme} theme - The compiled theme object
83
+ * @returns {Promise<Array>} Array of lint issues
84
+ */
85
+ async lintTheme(theme) {
86
+ const issues = []
87
+ const tokenColors = theme.output?.tokenColors || []
88
+ const pool = theme.pool
89
+
90
+ // Get source tokenColors data (before compilation) for variable usage analysis
91
+ const sourceTokenColors = await this.getSourceTokenColors(theme)
92
+
93
+ // 1. Check for duplicate scopes
94
+ issues.push(...this.checkDuplicateScopes(tokenColors))
95
+
96
+ // 2. Check for undefined variables
97
+ issues.push(...this.checkUndefinedVariables(sourceTokenColors, pool))
98
+
99
+ // 3. Check for unused variables
100
+ issues.push(...this.checkUnusedVariables(theme, pool))
101
+
102
+ // 4. Check for precedence issues
103
+ issues.push(...this.checkPrecedenceIssues(tokenColors))
104
+
105
+ return issues
106
+ }
107
+
108
+ /**
109
+ * Extracts source tokenColors data before compilation for variable analysis.
110
+ * This includes data from the main theme file and all imported files.
111
+ *
112
+ * @param {Theme} theme - The compiled theme object
113
+ * @returns {Promise<Array>} Array of source tokenColors entries
114
+ */
115
+ async getSourceTokenColors(theme) {
116
+ const sourceTokenColors = []
117
+
118
+ // Get tokenColors from main theme source
119
+ if(theme.source?.theme?.tokenColors)
120
+ sourceTokenColors.push(...theme.source.theme.tokenColors)
121
+
122
+ // Get tokenColors from imported files
123
+ if(theme.dependencies) {
124
+ for(const dependency of theme.dependencies) {
125
+ // Skip main file, already processed
126
+ if(dependency.path !== theme.sourceFile.path) {
127
+ try {
128
+ const depData = await theme.cache.loadCachedData(dependency)
129
+ if(depData?.theme?.tokenColors)
130
+ sourceTokenColors.push(...depData.theme.tokenColors)
131
+ } catch {
132
+ // nothing to see here.
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ return sourceTokenColors
139
+ }
140
+
141
+ /**
142
+ * Reports lint issues to the user with appropriate formatting and colors.
143
+ *
144
+ * @param {Array} issues - Array of lint issues to report
145
+ */
146
+ reportIssues(issues) {
147
+ if(issues.length === 0) {
148
+ Term.info(c`{success}✓{/} No linting issues found`)
149
+ return
150
+ }
151
+
152
+ const errors = issues.filter(i => i.severity === "high")
153
+ const warnings = issues.filter(i => i.severity === "medium")
154
+ const infos = issues.filter(i => i.severity === "low")
155
+
156
+ const allIssues = errors.concat(warnings, infos)
157
+ allIssues.forEach(issue => this.reportSingleIssue(issue))
158
+
159
+ // Clean summary
160
+ const parts = []
161
+
162
+ if(errors.length > 0)
163
+ parts.push(`${errors.length} error${errors.length === 1 ? "" : "s"}`)
164
+
165
+ if(warnings.length > 0)
166
+ parts.push(`${warnings.length} warning${warnings.length === 1 ? "" : "s"}`)
167
+
168
+ if(infos.length > 0)
169
+ parts.push(`${infos.length} info`)
170
+
171
+ Term.info(`\n${parts.join(", ")}`)
172
+ }
173
+
174
+ #getIndicator(severity) {
175
+ switch(severity) {
176
+ case "high": return c`{error}●{/}`
177
+ case "medium": return c`{warn}●{/}`
178
+ case "low":
179
+ default: return c`{info}●{/}`
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Reports a single lint issue with clean, minimal formatting.
185
+ *
186
+ * @param {object} issue - The issue to report
187
+ */
188
+ reportSingleIssue(issue) {
189
+ const indicator = this.#getIndicator(issue.severity)
190
+
191
+ switch(issue.type) {
192
+ case "duplicate-scope": {
193
+ const rules = issue.occurrences.map(occ => `'${occ.name}'`).join(", ")
194
+ Term.info(c`${indicator} Scope '{context}${issue.scope}{/}' is duplicated in ${rules}`)
195
+ break
196
+ }
197
+
198
+ case "undefined-variable": {
199
+ Term.info(c`${indicator} Variable '{context}${issue.variable}{/}' is used but not defined in '${issue.rule}' (${issue.property} property)`)
200
+ break
201
+ }
202
+
203
+ case "unused-variable": {
204
+ Term.info(c`${indicator} Variable '{context}${issue.variable}{/}' is defined but never used`)
205
+ break
206
+ }
207
+
208
+ case "precedence-issue": {
209
+ if(issue.broadIndex === issue.specificIndex) {
210
+ Term.info(c`${indicator} Scope '{context}${issue.broadScope}{/}' makes more specific '{context}${issue.specificScope}' redundant in '${issue.broadRule}{/}'`)
211
+ } else {
212
+ Term.info(c`${indicator} Scope '{context}${issue.broadScope}{/}' in '${issue.broadRule}' masks more specific '{context}${issue.specificScope}{/}' in '${issue.specificRule}'`)
213
+ }
214
+
215
+ break
216
+ }
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Checks for duplicate scopes across tokenColors rules.
222
+ * Returns issues for scopes that appear in multiple rules.
223
+ *
224
+ * @param {Array} tokenColors - Array of tokenColors entries
225
+ * @returns {Array} Array of duplicate scope issues
226
+ */
227
+ checkDuplicateScopes(tokenColors) {
228
+ const issues = []
229
+ const scopeOccurrences = new Map()
230
+
231
+ tokenColors.forEach((entry, index) => {
232
+ if(!entry.scope)
233
+ return
234
+
235
+ const scopes = entry.scope.split(",").map(s => s.trim())
236
+ scopes.forEach(scope => {
237
+ if(!scopeOccurrences.has(scope)) {
238
+ scopeOccurrences.set(scope, [])
239
+ }
240
+
241
+ scopeOccurrences.get(scope).push({
242
+ index: index + 1,
243
+ name: entry.name || `Entry ${index + 1}`,
244
+ entry
245
+ })
246
+ })
247
+ })
248
+
249
+ // Report duplicate scopes
250
+ for(const [scope, occurrences] of scopeOccurrences) {
251
+ if(occurrences.length > 1) {
252
+ issues.push({
253
+ type: "duplicate-scope",
254
+ severity: "medium",
255
+ scope,
256
+ occurrences
257
+ })
258
+ }
259
+ }
260
+
261
+ return issues
262
+ }
263
+
264
+ /**
265
+ * Checks for undefined variables referenced in tokenColors.
266
+ * Returns issues for variables that are used but not defined.
267
+ *
268
+ * @param {Array} tokenColors - Array of tokenColors entries
269
+ * @param {ThemePool} pool - The theme's variable pool
270
+ * @returns {Array} Array of undefined variable issues
271
+ */
272
+ checkUndefinedVariables(tokenColors, pool) {
273
+ const issues = []
274
+ const definedVars = pool ? new Set(pool.getTokens.keys()) : new Set()
275
+
276
+ tokenColors.forEach((entry, index) => {
277
+ const settings = entry.settings || {}
278
+ for(const [key, value] of Object.entries(settings)) {
279
+ if(typeof value === "string") {
280
+ const {none,parens,braces} = Evaluator.sub.exec(value)?.groups ?? {}
281
+ const varName = none || parens || braces
282
+
283
+ if(!varName)
284
+ return
285
+
286
+ if(!definedVars.has(varName)) {
287
+ issues.push({
288
+ type: "undefined-variable",
289
+ severity: "high",
290
+ variable: value,
291
+ rule: entry.name || `Entry ${index + 1}`,
292
+ property: key
293
+ })
294
+ }
295
+ }
296
+ }
297
+ })
298
+
299
+ return issues
300
+ }
301
+
302
+ /**
303
+ * Checks for unused variables defined in vars section but not referenced in theme content.
304
+ * Returns issues for variables that are defined in vars but never used.
305
+ *
306
+ * @param {Theme} theme - The compiled theme object
307
+ * @param {ThemePool} pool - The theme's variable pool
308
+ * @returns {Array} Array of unused variable issues
309
+ */
310
+ checkUnusedVariables(theme, pool) {
311
+ const issues = []
312
+
313
+ if(!pool || !theme.source)
314
+ return issues
315
+
316
+ // Get variables defined in the vars section only
317
+ const definedVars = new Set()
318
+ this.collectVarsDefinitions(theme.source.vars, definedVars)
319
+
320
+ // Also check dependencies for vars definitions
321
+ if(theme.dependencies) {
322
+ for(const dependency of theme.dependencies) {
323
+ try {
324
+ const depData = theme.cache?.loadCachedDataSync?.(dependency)
325
+ if(depData?.vars) {
326
+ this.collectVarsDefinitions(depData.vars, definedVars)
327
+ }
328
+ } catch {
329
+ // Ignore cache errors
330
+ }
331
+ }
332
+ }
333
+
334
+ const usedVars = new Set()
335
+
336
+ // Find variable usage in colors, tokenColors, and semanticColors sections
337
+ if(theme.source.colors) {
338
+ this.findVariableUsage(theme.source.colors, usedVars)
339
+ }
340
+
341
+ if(theme.source.tokenColors) {
342
+ this.findVariableUsage(theme.source.tokenColors, usedVars)
343
+ }
344
+
345
+ if(theme.source.semanticColors) {
346
+ this.findVariableUsage(theme.source.semanticColors, usedVars)
347
+ }
348
+
349
+ // Also check dependencies for usage in these sections
350
+ if(theme.dependencies) {
351
+ for(const dependency of theme.dependencies) {
352
+ try {
353
+ const depData = theme.cache?.loadCachedDataSync?.(dependency)
354
+ if(depData) {
355
+ if(depData.colors)
356
+ this.findVariableUsage(depData.colors, usedVars)
357
+
358
+ if(depData.tokenColors)
359
+ this.findVariableUsage(depData.tokenColors, usedVars)
360
+
361
+ if(depData.semanticColors)
362
+ this.findVariableUsage(depData.semanticColors, usedVars)
363
+ }
364
+ } catch {
365
+ // Ignore cache errors
366
+ }
367
+ }
368
+ }
369
+
370
+ // Find vars-defined variables that are never used in content sections
371
+ for(const varName of definedVars) {
372
+ if(!usedVars.has(varName)) {
373
+ issues.push({
374
+ type: "unused-variable",
375
+ severity: "low",
376
+ variable: `$${varName}`
377
+ })
378
+ }
379
+ }
380
+
381
+ return issues
382
+ }
383
+
384
+ /**
385
+ * Recursively collects variable names defined in the vars section.
386
+ * Adds found variable names to the definedVars set.
387
+ *
388
+ * @param {any} vars - The vars data structure to search
389
+ * @param {Set} definedVars - Set to add found variable names to
390
+ * @param {string} prefix - Current prefix for nested vars
391
+ */
392
+ collectVarsDefinitions(vars, definedVars, prefix = "") {
393
+ if(!vars || typeof vars !== "object")
394
+ return
395
+
396
+ for(const [key, value] of Object.entries(vars)) {
397
+ const varName = prefix ? `${prefix}.${key}` : key
398
+ definedVars.add(varName)
399
+
400
+ // If the value is an object, recurse for nested definitions
401
+ if(value && typeof value === "object" && !Array.isArray(value)) {
402
+ this.collectVarsDefinitions(value, definedVars, varName)
403
+ }
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Recursively finds variable usage in any data structure.
409
+ * Adds found variable names to the usedVars set.
410
+ *
411
+ * @param {any} data - The data structure to search
412
+ * @param {Set} usedVars - Set to add found variable names to
413
+ */
414
+ findVariableUsage(data, usedVars) {
415
+ if(typeof data === "string") {
416
+ if(Evaluator.sub.test(data)) {
417
+ const {none, parens, braces} = Evaluator.sub.exec(data)?.groups ?? {}
418
+ const varName = none || parens || braces
419
+ if(varName) {
420
+ usedVars.add(varName)
421
+ }
422
+ }
423
+ } else if(Array.isArray(data)) {
424
+ data.forEach(item => this.findVariableUsage(item, usedVars))
425
+ } else if(data && typeof data === "object") {
426
+ Object.values(data).forEach(value => this.findVariableUsage(value, usedVars))
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Checks for precedence issues where broad scopes override specific ones.
432
+ * Returns issues for cases where a general scope appears after a more specific one.
433
+ *
434
+ * @param {Array} tokenColors - Array of tokenColors entries
435
+ * @returns {Array} Array of precedence issue warnings
436
+ */
437
+ checkPrecedenceIssues(tokenColors) {
438
+ const issues = []
439
+ const allScopes = []
440
+
441
+ // Build a flat list of all scopes with their rule info
442
+ tokenColors.forEach((entry, index) => {
443
+ if(!entry.scope)
444
+ return
445
+
446
+ const scopes = entry.scope.split(",").map(s => s.trim())
447
+ scopes.forEach(scope => {
448
+ allScopes.push({
449
+ scope,
450
+ index: index + 1,
451
+ name: entry.name || `Entry ${index + 1}`,
452
+ entry
453
+ })
454
+ })
455
+ })
456
+
457
+ // Check each scope against all later scopes
458
+ for(let i = 0; i < allScopes.length; i++) {
459
+ const current = allScopes[i]
460
+
461
+ for(let j = i + 1; j < allScopes.length; j++) {
462
+ const later = allScopes[j]
463
+
464
+ // Check if the current (earlier) scope is broader than the later one
465
+ // This means the broad scope will mask the specific scope
466
+ if(this.isBroaderScope(current.scope, later.scope)) {
467
+ issues.push({
468
+ type: "precedence-issue",
469
+ severity: current.index === later.index ? "low" : "high",
470
+ specificScope: later.scope,
471
+ broadScope: current.scope,
472
+ specificRule: later.name,
473
+ broadRule: current.name,
474
+ specificIndex: later.index,
475
+ broadIndex: current.index
476
+ })
477
+ }
478
+ }
479
+ }
480
+
481
+ return issues
482
+ }
483
+
484
+ /**
485
+ * Determines if one scope is broader than another.
486
+ * A broader scope will match the same tokens as a more specific scope, plus others.
487
+ *
488
+ * @param {string} broadScope - The potentially broader scope
489
+ * @param {string} specificScope - The potentially more specific scope
490
+ * @returns {boolean} True if broadScope is broader than specificScope
491
+ */
492
+ isBroaderScope(broadScope, specificScope) {
493
+ // Simple heuristic: if the specific scope starts with the broad scope + "."
494
+ // then the broad scope is indeed broader
495
+ // e.g., "keyword" is broader than "keyword.control", "keyword.control.import"
496
+ return specificScope.startsWith(broadScope + ".")
497
+ }
498
+ }