@gesslar/sassy 0.20.2 → 0.21.1

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.
@@ -21,12 +21,10 @@ import c from "@gesslar/colours"
21
21
  import Command from "./Command.js"
22
22
  import Evaluator from "./Evaluator.js"
23
23
  import File from "./File.js"
24
- import FileObject from "./FileObject.js"
25
24
  import Term from "./Term.js"
26
25
  import Theme from "./Theme.js"
27
26
  import ThemePool from "./ThemePool.js"
28
27
 
29
-
30
28
  // oops, need to have @gesslar/colours support this, too!
31
29
  // ansiColors.enabled = colorSupport.hasBasic
32
30
 
@@ -37,6 +35,36 @@ import ThemePool from "./ThemePool.js"
37
35
  * behaviour.
38
36
  */
39
37
  export default class LintCommand extends Command {
38
+
39
+ // Theme section constants
40
+ static SECTIONS = {
41
+ VARS: "vars",
42
+ COLORS: "colors",
43
+ TOKEN_COLORS: "tokenColors",
44
+ SEMANTIC_TOKEN_COLORS: "semanticTokenColors"
45
+ }
46
+
47
+ // Issue severity levels
48
+ static SEVERITY = {
49
+ HIGH: "high",
50
+ MEDIUM: "medium",
51
+ LOW: "low"
52
+ }
53
+
54
+ // Issue type constants
55
+ static ISSUE_TYPES = {
56
+ DUPLICATE_SCOPE: "duplicate-scope",
57
+ UNDEFINED_VARIABLE: "undefined-variable",
58
+ UNUSED_VARIABLE: "unused-variable",
59
+ PRECEDENCE_ISSUE: "precedence-issue"
60
+ }
61
+
62
+ // Template strings for dynamic rule names
63
+ static TEMPLATES = {
64
+ ENTRY_NAME: index => `Entry ${index + 1}`,
65
+ OBJECT_NAME: index => `Object ${index + 1}`,
66
+ VARIABLE_PREFIX: "$"
67
+ }
40
68
  /**
41
69
  * Creates a new LintCommand instance.
42
70
  *
@@ -45,13 +73,13 @@ export default class LintCommand extends Command {
45
73
  constructor(base) {
46
74
  super(base)
47
75
 
48
- this.cliCommand = "lint <file>"
49
- this.cliOptions = {
76
+ this.setCliCommand("lint <file>")
77
+ this.setCliOptions({
50
78
  // Future options could include:
51
79
  // "fix": ["-f, --fix", "automatically fix issues where possible"],
52
80
  // "strict": ["--strict", "treat warnings as errors"],
53
81
  // "format": ["--format <type>", "output format (text, json)", "text"],
54
- }
82
+ })
55
83
  }
56
84
 
57
85
  /**
@@ -63,100 +91,233 @@ export default class LintCommand extends Command {
63
91
  * @returns {Promise<void>} Resolves when linting is complete
64
92
  */
65
93
  async execute(inputArg, options = {}) {
66
- const {cwd} = this
94
+ const cwd = this.getCwd()
67
95
  const fileObject = await this.resolveThemeFileName(inputArg, cwd)
68
96
  const theme = new Theme(fileObject, cwd, options)
69
97
 
70
- theme.cache = this.cache
71
-
98
+ theme.setCache(this.getCache())
72
99
  await theme.load()
73
100
  await theme.build()
74
101
 
75
- const issues = await this.lintTheme(theme)
102
+ const issues = await this.#lintTheme(theme)
76
103
 
77
- this.reportIssues(issues)
104
+ this.#reportIssues(issues)
78
105
  }
79
106
 
80
107
  /**
81
- * Performs comprehensive linting of a theme.
82
- * Returns an array of issues found during validation.
108
+ * Public method to lint a theme and return structured results for external
109
+ * consumption.
110
+ *
111
+ * Returns categorized lint results for tokenColors, semanticTokenColors, and colors.
83
112
  *
84
113
  * @param {Theme} theme - The compiled theme object
85
- * @returns {Promise<Array>} Array of lint issues
114
+ * @returns {Promise<object>} Object containing categorized lint results
86
115
  */
87
- async lintTheme(theme) {
116
+ async lint(theme) {
117
+ const results = {
118
+ [LC.SECTIONS.TOKEN_COLORS]: [],
119
+ [LC.SECTIONS.SEMANTIC_TOKEN_COLORS]: [],
120
+ [LC.SECTIONS.COLORS]: [],
121
+ variables: []
122
+ }
123
+
124
+ const output = theme.getOutput()
125
+
126
+ // Always perform structural linting (works with or without pool)
127
+ if(output?.tokenColors)
128
+ results[LC.SECTIONS.TOKEN_COLORS]
129
+ .push(...this.#lintTokenColorsStructure(output.tokenColors))
130
+
131
+ const pool = theme.getPool()
132
+
133
+ // Only perform variable-dependent linting if pool exists
134
+ if(pool) {
135
+ // Get source data for variable analysis
136
+ const colors = this.#getSection(
137
+ theme, LC.SECTIONS.COLORS)
138
+ const tokenColorTuples = this.#getSection(
139
+ theme, LC.SECTIONS.TOKEN_COLORS)
140
+ const semanticTokenColors = this.#getSection(
141
+ theme, LC.SECTIONS.SEMANTIC_TOKEN_COLORS)
142
+
143
+ // Variable-dependent linting
144
+ results[LC.SECTIONS.COLORS]
145
+ .push(...this.#lintColors(colors, pool))
146
+ results[LC.SECTIONS.TOKEN_COLORS]
147
+ .push(...this.#lintTokenColors(tokenColorTuples, pool))
148
+ results[LC.SECTIONS.SEMANTIC_TOKEN_COLORS]
149
+ .push(...this.#lintSemanticTokenColors(semanticTokenColors, pool))
150
+ results.variables
151
+ .push(...await this.#lintVariables(theme, pool))
152
+ }
153
+
154
+ return results
155
+ }
156
+
157
+ /**
158
+ * Performs structural linting of tokenColors that doesn't require variable
159
+ * information.
160
+ *
161
+ * Checks for duplicate scopes and precedence issues.
162
+ *
163
+ * @param {Array} tokenColors - Array of tokenColor entries
164
+ * @returns {Array} Array of structural issues
165
+ * @private
166
+ */
167
+ #lintTokenColorsStructure(tokenColors) {
168
+ return [
169
+ ...this.#checkDuplicateScopes(tokenColors),
170
+ ...this.#checkPrecedenceIssues(tokenColors),
171
+ ]
172
+ }
173
+
174
+ /**
175
+ * Performs variable-dependent linting of tokenColors data.
176
+ * Checks for undefined variable references.
177
+ *
178
+ * @param {Array<[object, Array]>} tokenColorTuples - Array of [file, tokenColors] tuples
179
+ * @param {ThemePool} pool - The theme's variable pool
180
+ * @returns {Array} Array of variable-related issues
181
+ * @private
182
+ */
183
+ #lintTokenColors(tokenColorTuples, pool) {
184
+ if(tokenColorTuples.length === 0)
185
+ return []
186
+
88
187
  const issues = []
89
- const tokenColors = theme.output?.tokenColors || []
90
- const pool = theme.pool
91
188
 
92
- // Get source tokenColors data (before compilation) for variable usage analysis
93
- const sourceTokenColors = await this.getSourceTokenColors(theme)
189
+ for(const [_, tokenColors] of tokenColorTuples) {
190
+ if(Array.isArray(tokenColors))
191
+ issues.push(
192
+ ...this.#checkUndefinedVariables(
193
+ tokenColors, pool, LC.SECTIONS.TOKEN_COLORS))
194
+ }
195
+
196
+ return issues
197
+ }
198
+
199
+ /**
200
+ * Performs variable-dependent linting of semanticTokenColors data.
201
+ * Checks for undefined variable references.
202
+ *
203
+ * @param {Array<[object, object]>} semanticTokenColorTuples - Array of [file, semanticTokenColors] tuples
204
+ * @param {ThemePool} pool - The theme's variable pool
205
+ * @returns {Array} Array of variable-related issues
206
+ * @private
207
+ */
208
+ #lintSemanticTokenColors(semanticTokenColorTuples, pool) {
209
+ if(semanticTokenColorTuples.length === 0)
210
+ return []
211
+
212
+ const issues = []
213
+
214
+ for(const [_, semanticTokenColors] of semanticTokenColorTuples)
215
+ issues.push(...this.#checkUndefinedVariables(
216
+ [semanticTokenColors], pool, LC.SECTIONS.SEMANTIC_TOKEN_COLORS)
217
+ )
94
218
 
95
- // 1. Check for duplicate scopes
96
- issues.push(...this.checkDuplicateScopes(tokenColors))
219
+ return issues
220
+ }
97
221
 
98
- // 2. Check for undefined variables
99
- issues.push(...this.checkUndefinedVariables(sourceTokenColors, pool))
222
+ /**
223
+ * Performs variable-dependent linting of colors data.
224
+ * Checks for undefined variable references.
225
+ *
226
+ * @param {Array<[object, object]>} colorTuples - Array of [file, colors] tuples
227
+ * @param {ThemePool} pool - The theme's variable pool
228
+ * @returns {Array} Array of variable-related issues
229
+ * @private
230
+ */
231
+ #lintColors(colorTuples, pool) {
232
+ if(colorTuples.length === 0)
233
+ return []
100
234
 
101
- // 3. Check for unused variables
102
- issues.push(...this.checkUnusedVariables(theme, pool))
235
+ const issues = []
103
236
 
104
- // 4. Check for precedence issues
105
- issues.push(...this.checkPrecedenceIssues(tokenColors))
237
+ for(const [_, colors] of colorTuples)
238
+ issues.push(...this.#checkUndefinedVariables(
239
+ [colors], pool, LC.SECTIONS.COLORS)
240
+ )
106
241
 
107
242
  return issues
108
243
  }
109
244
 
110
245
  /**
111
- * Extracts source tokenColors data before compilation for variable analysis.
112
- * This includes data from the main theme file and all imported files.
246
+ * Performs variable-dependent linting for unused variables.
247
+ * Checks for variables defined but never used.
248
+ *
249
+ * @param {Theme} theme - The theme object
250
+ * @param {ThemePool} pool - The theme's variable pool
251
+ * @returns {Promise<Array>} Array of unused variable issues
252
+ * @private
253
+ */
254
+ async #lintVariables(theme, pool) {
255
+ return pool
256
+ ? await this.#checkUnusedVariables(theme, pool)
257
+ : []
258
+ }
259
+
260
+ /**
261
+ * Performs comprehensive linting of a theme.
262
+ * Returns an array of issues found during validation.
113
263
  *
114
264
  * @param {Theme} theme - The compiled theme object
115
- * @returns {Promise<Array>} Array of source tokenColors entries
265
+ * @returns {Promise<Array>} Array of lint issues
266
+ * @private
116
267
  */
117
- async getSourceTokenColors(theme) {
118
- const sourceTokenColors = []
119
-
120
- // Get tokenColors from main theme source
121
- if(theme.source?.theme?.tokenColors)
122
- sourceTokenColors.push(...theme.source.theme.tokenColors)
123
-
124
- // Get tokenColors from imported files
125
- if(theme.dependencies) {
126
- for(const dependency of theme.dependencies) {
127
- // Skip main file, already processed
128
- if(dependency.path !== theme.sourceFile.path) {
129
- try {
130
- const depData = await theme.cache.loadCachedData(dependency)
131
- if(depData?.theme?.tokenColors)
132
- sourceTokenColors.push(...depData.theme.tokenColors)
133
- } catch {
134
- // nothing to see here.
135
- }
136
- }
137
- }
138
- }
268
+ async #lintTheme(theme) {
269
+ const results = await this.lint(theme)
270
+
271
+ // Flatten all results into a single array for backward compatibility
272
+ return [
273
+ ...results[LC.SECTIONS.TOKEN_COLORS],
274
+ ...results[LC.SECTIONS.SEMANTIC_TOKEN_COLORS],
275
+ ...results[LC.SECTIONS.COLORS],
276
+ ...results.variables
277
+ ]
278
+ }
139
279
 
140
- return sourceTokenColors
280
+ /**
281
+ * Extracts a specific section from all theme dependencies (including main theme).
282
+ *
283
+ * Returns an array of [FileObject, sectionData] tuples for linting methods that need
284
+ * to track which file each piece of data originated from for proper error reporting.
285
+ *
286
+ * @param {Theme} theme - The theme object with dependencies
287
+ * @param {string} section - The section name to extract (vars, colors, tokenColors, semanticTokenColors)
288
+ * @returns {Array<[object, object|Array]>} Array of [file, sectionData] tuples
289
+ * @private
290
+ */
291
+ #getSection(theme, section) {
292
+ return Array.from(theme.getDependencies()).map(dep => {
293
+ const source = dep.getSource()
294
+
295
+ if(source?.has(section))
296
+ return [dep.getSourceFile(),source.get(section)]
297
+
298
+ return false
299
+ }).filter(Boolean)
141
300
  }
142
301
 
143
302
  /**
144
303
  * Reports lint issues to the user with appropriate formatting and colors.
145
304
  *
146
305
  * @param {Array} issues - Array of lint issues to report
306
+ * @private
147
307
  */
148
- reportIssues(issues) {
308
+ #reportIssues(issues) {
149
309
  if(issues.length === 0) {
150
310
  Term.info(c`{success}✓{/} No linting issues found`)
311
+
151
312
  return
152
313
  }
153
314
 
154
- const errors = issues.filter(i => i.severity === "high")
155
- const warnings = issues.filter(i => i.severity === "medium")
156
- const infos = issues.filter(i => i.severity === "low")
157
-
315
+ const errors = issues.filter(i => i.severity === LC.SEVERITY.HIGH)
316
+ const warnings = issues.filter(i => i.severity === LC.SEVERITY.MEDIUM)
317
+ const infos = issues.filter(i => i.severity === LC.SEVERITY.LOW)
158
318
  const allIssues = errors.concat(warnings, infos)
159
- allIssues.forEach(issue => this.reportSingleIssue(issue))
319
+
320
+ allIssues.forEach(issue => this.#reportSingleIssue(issue))
160
321
 
161
322
  // Clean summary
162
323
  const parts = []
@@ -173,11 +334,18 @@ export default class LintCommand extends Command {
173
334
  Term.info(`\n${parts.join(", ")}`)
174
335
  }
175
336
 
337
+ /**
338
+ * Returns a colour-coded bullet indicator for a given severity level.
339
+ *
340
+ * @private
341
+ * @param {"high"|"medium"|"low"} severity - Severity level to represent
342
+ * @returns {string} A pre-coloured "●" character for terminal output
343
+ */
176
344
  #getIndicator(severity) {
177
345
  switch(severity) {
178
- case "high": return c`{error}●{/}`
179
- case "medium": return c`{warn}●{/}`
180
- case "low":
346
+ case LC.SEVERITY.HIGH: return c`{error}●{/}`
347
+ case LC.SEVERITY.MEDIUM: return c`{warn}●{/}`
348
+ case LC.SEVERITY.LOW:
181
349
  default: return c`{info}●{/}`
182
350
  }
183
351
  }
@@ -186,28 +354,32 @@ export default class LintCommand extends Command {
186
354
  * Reports a single lint issue with clean, minimal formatting.
187
355
  *
188
356
  * @param {object} issue - The issue to report
357
+ * @private
189
358
  */
190
- reportSingleIssue(issue) {
359
+ #reportSingleIssue(issue) {
191
360
  const indicator = this.#getIndicator(issue.severity)
192
361
 
193
362
  switch(issue.type) {
194
- case "duplicate-scope": {
363
+ case LC.ISSUE_TYPES.DUPLICATE_SCOPE: {
195
364
  const rules = issue.occurrences.map(occ => `{loc}'${occ.name}{/}'`).join(", ")
365
+
196
366
  Term.info(c`${indicator} Scope '{context}${issue.scope}{/}' is duplicated in ${rules}`)
197
367
  break
198
368
  }
199
369
 
200
- case "undefined-variable": {
201
- Term.info(c`${indicator} Variable '{context}${issue.variable}{/}' is used but not defined in '${issue.rule}' (${issue.property} property)`)
370
+ case LC.ISSUE_TYPES.UNDEFINED_VARIABLE: {
371
+ const sectionInfo = issue.section && issue.section !== LC.SECTIONS.TOKEN_COLORS ? ` in ${issue.section}` : ""
372
+
373
+ Term.info(c`${indicator} Variable '{context}${issue.variable}{/}' is used but not defined in '${issue.rule}' (${issue.property} property)${sectionInfo}`)
202
374
  break
203
375
  }
204
376
 
205
- case "unused-variable": {
206
- Term.info(c`${indicator} Variable '{context}${issue.variable}{/}' is defined in '{loc}${issue.occurence}{/}', but is never used`)
377
+ case LC.ISSUE_TYPES.UNUSED_VARIABLE: {
378
+ Term.info(c`${indicator} Variable '{context}${issue.variable}{/}' is defined in '{loc}${issue.occurrence}{/}', but is never used`)
207
379
  break
208
380
  }
209
381
 
210
- case "precedence-issue": {
382
+ case LC.ISSUE_TYPES.PRECEDENCE_ISSUE: {
211
383
  if(issue.broadIndex === issue.specificIndex) {
212
384
  Term.info(c`${indicator} Scope '{context}${issue.broadScope}{/}' makes more specific '{context}${issue.specificScope}{/}' redundant in '{loc}${issue.broadRule}{/}'`)
213
385
  } else {
@@ -225,8 +397,9 @@ export default class LintCommand extends Command {
225
397
  *
226
398
  * @param {Array} tokenColors - Array of tokenColors entries
227
399
  * @returns {Array} Array of duplicate scope issues
400
+ * @private
228
401
  */
229
- checkDuplicateScopes(tokenColors) {
402
+ #checkDuplicateScopes(tokenColors) {
230
403
  const issues = []
231
404
  const scopeOccurrences = new Map()
232
405
 
@@ -235,14 +408,14 @@ export default class LintCommand extends Command {
235
408
  return
236
409
 
237
410
  const scopes = entry.scope.split(",").map(s => s.trim())
411
+
238
412
  scopes.forEach(scope => {
239
- if(!scopeOccurrences.has(scope)) {
413
+ if(!scopeOccurrences.has(scope))
240
414
  scopeOccurrences.set(scope, [])
241
- }
242
415
 
243
416
  scopeOccurrences.get(scope).push({
244
417
  index: index + 1,
245
- name: entry.name || `Entry ${index + 1}`,
418
+ name: entry.name || LC.TEMPLATES.ENTRY_NAME(index),
246
419
  entry
247
420
  })
248
421
  })
@@ -252,8 +425,8 @@ export default class LintCommand extends Command {
252
425
  for(const [scope, occurrences] of scopeOccurrences) {
253
426
  if(occurrences.length > 1) {
254
427
  issues.push({
255
- type: "duplicate-scope",
256
- severity: "medium",
428
+ type: LC.ISSUE_TYPES.DUPLICATE_SCOPE,
429
+ severity: LC.SEVERITY.MEDIUM,
257
430
  scope,
258
431
  occurrences
259
432
  })
@@ -264,113 +437,147 @@ export default class LintCommand extends Command {
264
437
  }
265
438
 
266
439
  /**
267
- * Checks for undefined variables referenced in tokenColors.
440
+ * Checks for undefined variables referenced in theme data.
268
441
  * Returns issues for variables that are used but not defined.
269
442
  *
270
- * @param {Array} tokenColors - Array of tokenColors entries
443
+ * @param {Array|object} themeData - Array of entries or object containing theme data
271
444
  * @param {ThemePool} pool - The theme's variable pool
445
+ * @param {string} section - The section name (tokenColors, semanticTokenColors, colors)
272
446
  * @returns {Array} Array of undefined variable issues
447
+ * @private
273
448
  */
274
- checkUndefinedVariables(tokenColors, pool) {
449
+ #checkUndefinedVariables(themeData, pool, section=LC.SECTIONS.TOKEN_COLORS) {
275
450
  const issues = []
276
- const definedVars = pool ? new Set(pool.getTokens.keys()) : new Set()
277
-
278
- tokenColors.forEach((entry, index) => {
279
- const settings = entry.settings || {}
280
- for(const [key, value] of Object.entries(settings)) {
281
- if(typeof value === "string") {
282
- const {none,parens,braces} = Evaluator.sub.exec(value)?.groups ?? {}
283
- const varName = none || parens || braces
284
-
285
- if(!varName)
286
- return
287
-
288
- if(!definedVars.has(varName)) {
289
- issues.push({
290
- type: "undefined-variable",
291
- severity: "high",
292
- variable: value,
293
- rule: entry.name || `Entry ${index + 1}`,
294
- property: key
295
- })
451
+ const definedVars = pool ? new Set(pool.getTokens().keys()) : new Set()
452
+
453
+ if(section === LC.SECTIONS.TOKEN_COLORS && Array.isArray(themeData)) {
454
+ themeData.forEach((entry, index) => {
455
+ const settings = entry.settings || {}
456
+
457
+ for(const [key, value] of Object.entries(settings)) {
458
+ if(typeof value === "string") {
459
+ const varName = Evaluator.extractVariableName(value)
460
+
461
+ if(!varName)
462
+ return
463
+
464
+ if(!definedVars.has(varName)) {
465
+ issues.push({
466
+ type: LC.ISSUE_TYPES.UNDEFINED_VARIABLE,
467
+ severity: LC.SEVERITY.HIGH,
468
+ variable: value,
469
+ rule: entry.name || LC.TEMPLATES.ENTRY_NAME(index),
470
+ property: key,
471
+ section
472
+ })
473
+ }
296
474
  }
297
475
  }
298
- }
299
- })
476
+ })
477
+ } else if((section === LC.SECTIONS.SEMANTIC_TOKEN_COLORS ||
478
+ section === LC.SECTIONS.COLORS)
479
+ && Array.isArray(themeData)) {
480
+ // Handle semanticTokenColors and colors as objects
481
+ themeData.forEach((dataObject, objIndex) => {
482
+ this.#checkObjectForUndefinedVariables(
483
+ dataObject,
484
+ definedVars,
485
+ issues,
486
+ section,
487
+ LC.TEMPLATES.OBJECT_NAME(objIndex)
488
+ )
489
+ })
490
+ }
300
491
 
301
492
  return issues
302
493
  }
303
494
 
304
495
  /**
305
- * Checks for unused variables defined in vars section but not referenced in theme content.
496
+ * Recursively checks an object for undefined variable references.
497
+ *
498
+ * @param {object} obj - The object to check
499
+ * @param {Set} definedVars - Set of defined variable names
500
+ * @param {Array} issues - Array to push issues to
501
+ * @param {string} section - The section name
502
+ * @param {string} ruleName - The rule/object name for reporting
503
+ * @param {string} path - The current path in the object (for nested properties)
504
+ * @private
505
+ */
506
+ #checkObjectForUndefinedVariables(obj, definedVars, issues, section, ruleName, path = "") {
507
+ for(const [key, value] of Object.entries(obj ?? {})) {
508
+ const currentPath = path ? `${path}.${key}` : key
509
+
510
+ if(typeof value === "string") {
511
+ const varName = Evaluator.extractVariableName(value)
512
+
513
+ if(varName && !definedVars.has(varName)) {
514
+ issues.push({
515
+ type: LC.ISSUE_TYPES.UNDEFINED_VARIABLE,
516
+ severity: LC.SEVERITY.HIGH,
517
+ variable: value,
518
+ rule: ruleName,
519
+ property: currentPath,
520
+ section
521
+ })
522
+ }
523
+ } else if(typeof value === "object" && !Array.isArray(value)) {
524
+ this.#checkObjectForUndefinedVariables(
525
+ value, definedVars, issues, section, ruleName, currentPath
526
+ )
527
+ }
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Checks for unused variables defined in vars section but not referenced in
533
+ * theme content.
534
+ *
306
535
  * Returns issues for variables that are defined in vars but never used.
307
536
  *
308
537
  * @param {Theme} theme - The compiled theme object
309
538
  * @param {ThemePool} pool - The theme's variable pool
310
- * @returns {Array} Array of unused variable issues
539
+ * @returns {Promise<Array>} Array of unused variable issues
540
+ * @private
311
541
  */
312
- checkUnusedVariables(theme, pool) {
542
+ async #checkUnusedVariables(theme, pool) {
313
543
  const issues = []
314
544
 
315
- if(!pool || !theme.source)
545
+ if(!pool || !theme.getSource())
316
546
  return issues
317
547
 
318
548
  // Get variables defined in the vars section only
319
549
  const definedVars = new Map()
320
- const {cwd} = this
321
- const mainFile = new FileObject(theme.sourceFile.path)
322
- const relativeMainPath = File.relativeOrAbsolutePath(cwd, mainFile)
323
- this.collectVarsDefinitions(theme.source.vars, definedVars, "", relativeMainPath)
324
-
325
- // Also check dependencies for vars definitions
326
- if(theme.dependencies) {
327
- for(const dependency of theme.dependencies) {
328
- try {
329
- const depData = theme.cache?.loadCachedDataSync?.(dependency)
330
- if(depData?.vars) {
331
- const depFile = new FileObject(dependency.path)
332
- const relativeDependencyPath = File.relativeOrAbsolutePath(cwd, depFile)
333
- this.collectVarsDefinitions(depData.vars, definedVars, "", relativeDependencyPath)
334
- }
335
- } catch {
336
- // Ignore cache errors
337
- }
338
- }
339
- }
550
+ const cwd = this.getCwd()
340
551
 
341
552
  const usedVars = new Set()
342
553
 
343
- // Find variable usage in colors, tokenColors, and semanticColors sections
344
- if(theme.source.colors) {
345
- this.findVariableUsage(theme.source.colors, usedVars)
346
- }
347
-
348
- if(theme.source.tokenColors) {
349
- this.findVariableUsage(theme.source.tokenColors, usedVars)
350
- }
351
-
352
- if(theme.source.semanticColors) {
353
- this.findVariableUsage(theme.source.semanticColors, usedVars)
354
- }
355
-
356
- // Also check dependencies for usage in these sections
357
- if(theme.dependencies) {
358
- for(const dependency of theme.dependencies) {
359
- try {
360
- const depData = theme.cache?.loadCachedDataSync?.(dependency)
361
- if(depData) {
362
- if(depData.colors)
363
- this.findVariableUsage(depData.colors, usedVars)
364
-
365
- if(depData.tokenColors)
366
- this.findVariableUsage(depData.tokenColors, usedVars)
367
-
368
- if(depData.semanticColors)
369
- this.findVariableUsage(depData.semanticColors, usedVars)
370
- }
371
- } catch {
372
- // Ignore cache errors
554
+ for(const dependency of theme.getDependencies()) {
555
+ try {
556
+ const depData = dependency.getSource()
557
+ const depFile = dependency.getSourceFile()
558
+
559
+ // Collect vars definitions
560
+ if(depData?.vars) {
561
+ const relativeDependencyPath =
562
+ File.relativeOrAbsolutePath(cwd, depFile)
563
+
564
+ this.#collectVarsDefinitions(
565
+ depData.vars,
566
+ definedVars,
567
+ "",
568
+ relativeDependencyPath
569
+ )
373
570
  }
571
+
572
+ // Find variable usage in colors, tokenColors, and semanticTokenColors sections
573
+ this.#findVariableUsage(depData
574
+ ?.get(LC.SECTIONS.COLORS), usedVars)
575
+ this.#findVariableUsage(depData
576
+ ?.get(LC.SECTIONS.TOKEN_COLORS), usedVars)
577
+ this.#findVariableUsage(depData
578
+ ?.get(LC.SECTIONS.SEMANTIC_TOKEN_COLORS), usedVars)
579
+ } catch {
580
+ // Ignore cache errors
374
581
  }
375
582
  }
376
583
 
@@ -378,10 +585,10 @@ export default class LintCommand extends Command {
378
585
  for(const [varName, filename] of definedVars) {
379
586
  if(!usedVars.has(varName)) {
380
587
  issues.push({
381
- type: "unused-variable",
382
- severity: "low",
383
- variable: `$${varName}`,
384
- occurence: filename,
588
+ type: LC.ISSUE_TYPES.UNUSED_VARIABLE,
589
+ severity: LC.SEVERITY.LOW,
590
+ variable: `${LC.TEMPLATES.VARIABLE_PREFIX}${varName}`,
591
+ occurrence: filename,
385
592
  })
386
593
  }
387
594
  }
@@ -393,57 +600,65 @@ export default class LintCommand extends Command {
393
600
  * Recursively collects variable names defined in the vars section.
394
601
  * Adds found variable names to the definedVars map.
395
602
  *
396
- * @param {any} vars - The vars data structure to search
603
+ * @param {object|null} vars - The vars data structure to search
397
604
  * @param {Map} definedVars - Map to add found variable names and filenames to
398
605
  * @param {string} prefix - Current prefix for nested vars
399
606
  * @param {string} filename - The filename where this variable is defined
607
+ * @private
400
608
  */
401
- collectVarsDefinitions(vars, definedVars, prefix = "", filename = "") {
402
- if(!vars || typeof vars !== "object")
403
- return
404
-
405
- for(const [key, value] of Object.entries(vars)) {
609
+ #collectVarsDefinitions(vars, definedVars, prefix = "", filename = "") {
610
+ for(const [key, value] of Object.entries(vars ?? {})) {
406
611
  const varName = prefix ? `${prefix}.${key}` : key
612
+
407
613
  definedVars.set(varName, filename)
408
614
 
409
615
  // If the value is an object, recurse for nested definitions
410
- if(value && typeof value === "object" && !Array.isArray(value)) {
411
- this.collectVarsDefinitions(value, definedVars, varName, filename)
412
- }
616
+ if(typeof value === "object" && !Array.isArray(value))
617
+ this.#collectVarsDefinitions(value, definedVars, varName, filename)
413
618
  }
414
619
  }
415
620
 
416
621
  /**
417
622
  * Recursively finds variable usage in any data structure.
623
+ *
418
624
  * Adds found variable names to the usedVars set.
419
625
  *
420
- * @param {any} data - The data structure to search
626
+ * @param {string|Array|object} data - The data structure to search
421
627
  * @param {Set} usedVars - Set to add found variable names to
628
+ * @private
422
629
  */
423
- findVariableUsage(data, usedVars) {
630
+ #findVariableUsage(data, usedVars) {
631
+ if(!data)
632
+ return
633
+
424
634
  if(typeof data === "string") {
425
635
  if(Evaluator.sub.test(data)) {
426
- const {none, parens, braces} = Evaluator.sub.exec(data)?.groups ?? {}
427
- const varName = none || parens || braces
636
+ const varName = Evaluator.extractVariableName(data)
637
+
428
638
  if(varName) {
429
639
  usedVars.add(varName)
430
640
  }
431
641
  }
432
642
  } else if(Array.isArray(data)) {
433
- data.forEach(item => this.findVariableUsage(item, usedVars))
434
- } else if(data && typeof data === "object") {
435
- Object.values(data).forEach(value => this.findVariableUsage(value, usedVars))
643
+ data.forEach(item => this.#findVariableUsage(item, usedVars))
644
+ } else if(typeof data === "object") {
645
+ Object.values(data).forEach(
646
+ value => this.#findVariableUsage(value, usedVars)
647
+ )
436
648
  }
437
649
  }
438
650
 
439
651
  /**
440
652
  * Checks for precedence issues where broad scopes override specific ones.
441
- * Returns issues for cases where a general scope appears after a more specific one.
653
+ *
654
+ * Returns issues for cases where a general scope appears after a more
655
+ * specific one.
442
656
  *
443
657
  * @param {Array} tokenColors - Array of tokenColors entries
444
658
  * @returns {Array} Array of precedence issue warnings
659
+ * @private
445
660
  */
446
- checkPrecedenceIssues(tokenColors) {
661
+ #checkPrecedenceIssues(tokenColors) {
447
662
  const issues = []
448
663
  const allScopes = []
449
664
 
@@ -453,16 +668,19 @@ export default class LintCommand extends Command {
453
668
  return
454
669
 
455
670
  const scopes = entry.scope.split(",").map(s => s.trim())
671
+
456
672
  scopes.forEach(scope => {
457
673
  allScopes.push({
458
674
  scope,
459
675
  index: index + 1,
460
- name: entry.name || `Entry ${index + 1}`,
676
+ name: entry.name || LC.TEMPLATES.ENTRY_NAME(index),
461
677
  entry
462
678
  })
463
679
  })
464
680
  })
465
681
 
682
+ const {LOW,HIGH} = LC.SEVERITY
683
+
466
684
  // Check each scope against all later scopes
467
685
  for(let i = 0; i < allScopes.length; i++) {
468
686
  const current = allScopes[i]
@@ -472,10 +690,10 @@ export default class LintCommand extends Command {
472
690
 
473
691
  // Check if the current (earlier) scope is broader than the later one
474
692
  // This means the broad scope will mask the specific scope
475
- if(this.isBroaderScope(current.scope, later.scope)) {
693
+ if(this.#isBroaderScope(current.scope, later.scope)) {
476
694
  issues.push({
477
- type: "precedence-issue",
478
- severity: current.index === later.index ? "low" : "high",
695
+ type: LC.ISSUE_TYPES.PRECEDENCE_ISSUE,
696
+ severity: current.index === later.index ? LOW : HIGH,
479
697
  specificScope: later.scope,
480
698
  broadScope: current.scope,
481
699
  specificRule: later.name,
@@ -492,16 +710,34 @@ export default class LintCommand extends Command {
492
710
 
493
711
  /**
494
712
  * Determines if one scope is broader than another.
495
- * A broader scope will match the same tokens as a more specific scope, plus others.
713
+ *
714
+ * A broader scope will match the same tokens as a more specific scope, plus
715
+ * others. Uses proper TextMate scope hierarchy rules.
496
716
  *
497
717
  * @param {string} broadScope - The potentially broader scope
498
718
  * @param {string} specificScope - The potentially more specific scope
499
719
  * @returns {boolean} True if broadScope is broader than specificScope
720
+ * @private
500
721
  */
501
- isBroaderScope(broadScope, specificScope) {
502
- // Simple heuristic: if the specific scope starts with the broad scope + "."
503
- // then the broad scope is indeed broader
504
- // e.g., "keyword" is broader than "keyword.control", "keyword.control.import"
505
- return specificScope.startsWith(broadScope + ".")
722
+ #isBroaderScope(broadScope, specificScope) {
723
+ // Scopes must be different to have a precedence relationship
724
+ if(broadScope === specificScope)
725
+ return false
726
+
727
+ // Split both scopes into segments for proper comparison
728
+ const broadSegments = broadScope.split(".")
729
+ const specificSegments = specificScope.split(".")
730
+
731
+ // A broader scope must have fewer or equal segments
732
+ if(broadSegments.length > specificSegments.length)
733
+ return false
734
+
735
+ // All segments of the broad scope must match the specific scope's prefix
736
+ return broadSegments.every((segment, index) =>
737
+ segment === specificSegments[index]
738
+ )
506
739
  }
507
740
  }
741
+
742
+ // Aliases
743
+ const LC = LintCommand