@gesslar/sassy 0.21.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gesslar/sassy",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "displayName": "Sassy",
5
5
  "description": "Make gorgeous themes that speak as boldly as you do.",
6
6
  "publisher": "gesslar",
package/src/Command.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import Sass from "./Sass.js"
2
2
  import FileObject from "./FileObject.js"
3
3
  import DirectoryObject from "./DirectoryObject.js"
4
+ import Cache from "./Cache.js"
4
5
 
5
6
  /**
6
7
  * Base class for command-line interface commands.
package/src/Data.js CHANGED
@@ -429,7 +429,7 @@ export default class Data {
429
429
  const value = obj[name]
430
430
 
431
431
  // Recursively freeze nested objects
432
- if(value && typeof value === "object")
432
+ if(typeof value === "object" && value !== null)
433
433
  Data.deepFreezeObject(value)
434
434
  }
435
435
 
@@ -483,7 +483,7 @@ export default class Data {
483
483
  * @returns {object} The merged object
484
484
  */
485
485
  static mergeObject(...sources) {
486
- const isObject = obj => obj && typeof obj === "object" && !Array.isArray(obj)
486
+ const isObject = obj => typeof obj === "object" && obj !== null && !Array.isArray(obj)
487
487
 
488
488
  return sources.reduce((acc, obj) => {
489
489
  if(!isObject(obj))
package/src/Evaluator.js CHANGED
@@ -56,6 +56,37 @@ export default class Evaluator {
56
56
  */
57
57
  static func = /(?<captured>(?<func>\w+)\((?<args>[^()]+)\))/
58
58
 
59
+ /**
60
+ * Extracts a variable name from a string containing variable syntax.
61
+ * Supports $(var), $var, and ${var} patterns.
62
+ *
63
+ * @param {string} [str] - String that may contain a variable reference
64
+ * @returns {string|null} The variable name or null if none found
65
+ */
66
+ static extractVariableName(str="") {
67
+ const {none, parens, braces} = Evaluator.sub.exec(str)?.groups ?? {}
68
+
69
+ return none || parens || braces || null
70
+ }
71
+
72
+ /**
73
+ * Extracts function name and arguments from a string containing function syntax.
74
+ * Supports functionName(args) patterns.
75
+ *
76
+ * @param {string} [str] - String that may contain a function call
77
+ * @returns {{func:string, args:string}|null} Object with {func, args} or null if none found
78
+ */
79
+ static extractFunctionCall(str="") {
80
+ const match = Evaluator.func.exec(str)
81
+
82
+ if(!match?.groups)
83
+ return null
84
+
85
+ const {func, args} = match.groups
86
+
87
+ return {func, args}
88
+ }
89
+
59
90
  #pool = new ThemePool()
60
91
  get pool() {
61
92
  return this.#pool
@@ -81,6 +112,18 @@ export default class Evaluator {
81
112
  * - No return value. Evident by the absence of a return statement.
82
113
  *
83
114
  * @param {Array<{flatPath:string,value:unknown}>} decomposed - Variable entries to resolve.
115
+ * @example
116
+ * // Example decomposed input with variables and theme references
117
+ * const evaluator = new Evaluator();
118
+ * const decomposed = [
119
+ * { flatPath: 'vars.primary', value: '#3366cc' },
120
+ * { flatPath: 'theme.colors.background', value: '$(vars.primary)' },
121
+ * { flatPath: 'theme.colors.accent', value: 'lighten($(vars.primary), 20)' }
122
+ * ];
123
+ * evaluator.evaluate(decomposed);
124
+ * // After evaluation, values are resolved:
125
+ * // decomposed[1].value === '#3366cc'
126
+ * // decomposed[2].value === '#5588dd' (lightened color)
84
127
  */
85
128
  evaluate(decomposed) {
86
129
  let it = 0
@@ -229,8 +272,8 @@ export default class Evaluator {
229
272
  * @returns {ThemeToken|null} The resolved token or null.
230
273
  */
231
274
  #resolveVariable(value) {
232
- const {captured,none,parens,braces} = Evaluator.sub.exec(value).groups
233
- const work = none ?? parens ?? braces
275
+ const {captured} = Evaluator.sub.exec(value).groups
276
+ const work = Evaluator.extractVariableName(value)
234
277
  const existing = this.#pool.findToken(work)
235
278
 
236
279
  if(!existing)
@@ -253,7 +296,13 @@ export default class Evaluator {
253
296
  * @returns {ThemeToken|null} The resolved token or null.
254
297
  */
255
298
  #resolveFunction(value) {
256
- const {captured,func,args} = Evaluator.func.exec(value).groups
299
+ const {captured} = Evaluator.func.exec(value).groups
300
+ const result = Evaluator.extractFunctionCall(value)
301
+
302
+ if(!result)
303
+ return null
304
+
305
+ const {func, args} = result
257
306
  const split = args?.split(",").map(a => a.trim()) ?? []
258
307
 
259
308
  // Look up source tokens for arguments to preserve color space
@@ -21,7 +21,6 @@ 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"
@@ -36,6 +35,36 @@ import ThemePool from "./ThemePool.js"
36
35
  * behaviour.
37
36
  */
38
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
+ }
39
68
  /**
40
69
  * Creates a new LintCommand instance.
41
70
  *
@@ -86,34 +115,40 @@ export default class LintCommand extends Command {
86
115
  */
87
116
  async lint(theme) {
88
117
  const results = {
89
- tokenColors: [],
90
- semanticTokenColors: [],
91
- colors: [],
118
+ [LC.SECTIONS.TOKEN_COLORS]: [],
119
+ [LC.SECTIONS.SEMANTIC_TOKEN_COLORS]: [],
120
+ [LC.SECTIONS.COLORS]: [],
92
121
  variables: []
93
122
  }
94
123
 
95
- const pool = theme.getPool()
124
+ const output = theme.getOutput()
96
125
 
97
126
  // Always perform structural linting (works with or without pool)
98
- if(theme.getOutput()?.tokenColors)
99
- results.tokenColors.push(
100
- ...this.#lintTokenColorsStructure(theme.getOutput().tokenColors)
101
- )
127
+ if(output?.tokenColors)
128
+ results[LC.SECTIONS.TOKEN_COLORS]
129
+ .push(...this.#lintTokenColorsStructure(output.tokenColors))
130
+
131
+ const pool = theme.getPool()
102
132
 
103
133
  // Only perform variable-dependent linting if pool exists
104
134
  if(pool) {
105
135
  // Get source data for variable analysis
106
- const colors = await this.getColors(theme)
107
- const tokenColors = await this.#getTokenColors(theme)
108
- const semanticTokenColors = await this.getSemanticTokenColors(theme)
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)
109
142
 
110
143
  // Variable-dependent linting
111
- results.colors.push(...this.#lintColors(colors, pool))
112
- results.tokenColors.push(...this.lintTokenColors(tokenColors, pool))
113
- results.semanticTokenColors.push(
114
- ...this.#lintSemanticTokenColors(semanticTokenColors, pool)
115
- )
116
- results.variables.push(...this.#lintVariables(theme, pool))
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))
117
152
  }
118
153
 
119
154
  return results
@@ -140,44 +175,71 @@ export default class LintCommand extends Command {
140
175
  * Performs variable-dependent linting of tokenColors data.
141
176
  * Checks for undefined variable references.
142
177
  *
143
- * @param {Array} sourceTokenColors - Array of source tokenColor entries
178
+ * @param {Array<[object, Array]>} tokenColorTuples - Array of [file, tokenColors] tuples
144
179
  * @param {ThemePool} pool - The theme's variable pool
145
180
  * @returns {Array} Array of variable-related issues
181
+ * @private
146
182
  */
147
- lintTokenColors(sourceTokenColors, pool) {
148
- return pool
149
- ? this.#checkUndefinedVariables(sourceTokenColors, pool, "tokenColors")
150
- : []
183
+ #lintTokenColors(tokenColorTuples, pool) {
184
+ if(tokenColorTuples.length === 0)
185
+ return []
186
+
187
+ const issues = []
188
+
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
151
197
  }
152
198
 
153
199
  /**
154
200
  * Performs variable-dependent linting of semanticTokenColors data.
155
201
  * Checks for undefined variable references.
156
202
  *
157
- * @param {Array} semanticTokenColors - Array of source semanticTokenColors entries
203
+ * @param {Array<[object, object]>} semanticTokenColorTuples - Array of [file, semanticTokenColors] tuples
158
204
  * @param {ThemePool} pool - The theme's variable pool
159
205
  * @returns {Array} Array of variable-related issues
160
206
  * @private
161
207
  */
162
- #lintSemanticTokenColors(semanticTokenColors, pool) {
163
- return pool && semanticTokenColors.length > 0
164
- ? this.#checkUndefinedVariables(semanticTokenColors, pool, "semanticTokenColors")
165
- : []
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
+ )
218
+
219
+ return issues
166
220
  }
167
221
 
168
222
  /**
169
223
  * Performs variable-dependent linting of colors data.
170
224
  * Checks for undefined variable references.
171
225
  *
172
- * @param {Array} sourceColors - Array of source colors entries
226
+ * @param {Array<[object, object]>} colorTuples - Array of [file, colors] tuples
173
227
  * @param {ThemePool} pool - The theme's variable pool
174
228
  * @returns {Array} Array of variable-related issues
175
229
  * @private
176
230
  */
177
- #lintColors(sourceColors, pool) {
178
- return pool && sourceColors.length > 0
179
- ? this.#checkUndefinedVariables(sourceColors, pool, "colors")
180
- : []
231
+ #lintColors(colorTuples, pool) {
232
+ if(colorTuples.length === 0)
233
+ return []
234
+
235
+ const issues = []
236
+
237
+ for(const [_, colors] of colorTuples)
238
+ issues.push(...this.#checkUndefinedVariables(
239
+ [colors], pool, LC.SECTIONS.COLORS)
240
+ )
241
+
242
+ return issues
181
243
  }
182
244
 
183
245
  /**
@@ -186,12 +248,12 @@ export default class LintCommand extends Command {
186
248
  *
187
249
  * @param {Theme} theme - The theme object
188
250
  * @param {ThemePool} pool - The theme's variable pool
189
- * @returns {Array} Array of unused variable issues
251
+ * @returns {Promise<Array>} Array of unused variable issues
190
252
  * @private
191
253
  */
192
- #lintVariables(theme, pool) {
254
+ async #lintVariables(theme, pool) {
193
255
  return pool
194
- ? this.#checkUnusedVariables(theme, pool)
256
+ ? await this.#checkUnusedVariables(theme, pool)
195
257
  : []
196
258
  }
197
259
 
@@ -208,123 +270,33 @@ export default class LintCommand extends Command {
208
270
 
209
271
  // Flatten all results into a single array for backward compatibility
210
272
  return [
211
- ...results.tokenColors,
212
- ...results.semanticTokenColors,
213
- ...results.colors,
273
+ ...results[LC.SECTIONS.TOKEN_COLORS],
274
+ ...results[LC.SECTIONS.SEMANTIC_TOKEN_COLORS],
275
+ ...results[LC.SECTIONS.COLORS],
214
276
  ...results.variables
215
277
  ]
216
278
  }
217
279
 
218
280
  /**
219
- * Extracts the original source tokenColors data from theme.source and
220
- * dependencies.
281
+ * Extracts a specific section from all theme dependencies (including main theme).
221
282
  *
222
- * Used for variable analysis since we need the uncompiled data with variable
223
- * references.
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.
224
285
  *
225
- * @param {Theme} theme - The compiled theme object (contains both output and source)
226
- * @returns {Promise<Array>} Array of source tokenColors entries with variables intact
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
227
289
  * @private
228
290
  */
229
- async #getTokenColors(theme) {
230
- const sourceTokenColors = []
231
-
232
- // Get tokenColors from main theme source
233
- if(theme.getSource()?.theme?.tokenColors)
234
- sourceTokenColors.push(...theme.getSource().theme.tokenColors)
235
-
236
- // Get tokenColors from imported files
237
- if(theme.hasDependencies()) {
238
- for(const dependency of theme.getDependencies()) {
239
- // Skip main file, already processed
240
- if(dependency.getSourceFile().path !== theme.getSourceFile().path) {
241
- try {
242
- const depData = await theme.getCache().loadCachedData(dependency.getSourceFile())
243
-
244
- if(depData?.theme?.tokenColors)
245
- sourceTokenColors.push(...depData.theme.tokenColors)
246
- } catch {
247
- // nothing to see here.
248
- }
249
- }
250
- }
251
- }
291
+ #getSection(theme, section) {
292
+ return Array.from(theme.getDependencies()).map(dep => {
293
+ const source = dep.getSource()
252
294
 
253
- return sourceTokenColors
254
- }
295
+ if(source?.has(section))
296
+ return [dep.getSourceFile(),source.get(section)]
255
297
 
256
- /**
257
- * Extracts the original source semanticTokenColors data from theme.source
258
- * and dependencies.
259
- *
260
- * Used for variable analysis since we need the uncompiled data with variable
261
- * references.
262
- *
263
- * @param {Theme} theme - The compiled theme object (contains both output and source)
264
- * @returns {Promise<Array>} Array of source semanticTokenColors entries with variables intact
265
- */
266
- async getSemanticTokenColors(theme) {
267
- const sourceSemanticTokenColors = []
268
-
269
- // Get semanticTokenColors from main theme source
270
- if(theme.sourceHasSemanticTokenColors())
271
- sourceSemanticTokenColors.push(theme.getSourceSemanticTokenColors())
272
-
273
- // Get semanticTokenColors from imported files
274
- if(theme.hasDependencies()) {
275
- for(const dependency of theme.getDependencies()) {
276
- // Skip main file, already processed
277
- if(dependency.getSourceFile().path !== theme.getSourceFile().path) {
278
- try {
279
- const depData = await theme.getCache().loadCachedData(dependency.getSourceFile())
280
-
281
- if(depData?.theme?.semanticTokenColors)
282
- sourceSemanticTokenColors.push(depData.theme.semanticTokenColors)
283
- } catch {
284
- // nothing to see here.
285
- }
286
- }
287
- }
288
- }
289
-
290
- return sourceSemanticTokenColors
291
- }
292
-
293
- /**
294
- * Extracts the original source colors data from theme.source and
295
- * dependencies.
296
- *
297
- * Used for variable analysis since we need the uncompiled data with variable
298
- * references.
299
- *
300
- * @param {Theme} theme - The compiled theme object (contains both output and source)
301
- * @returns {Promise<Array>} Array of source colors entries with variables intact
302
- */
303
- async getColors(theme) {
304
- const sourceColors = []
305
-
306
- // Get colors from main theme source
307
- if(theme.getSource()?.theme?.colors)
308
- sourceColors.push(theme.getSource().theme.colors)
309
-
310
- // Get colors from imported files
311
- if(theme.hasDependencies()) {
312
- for(const dependency of theme.getDependencies()) {
313
- // Skip main file, already processed
314
- if(dependency.getSourceFile().path !== theme.getSourceFile().path) {
315
- try {
316
- const depData = await theme.getCache().loadCachedData(dependency.getSourceFile())
317
-
318
- if(depData?.theme?.colors)
319
- sourceColors.push(depData.theme.colors)
320
- } catch {
321
- // nothing to see here.
322
- }
323
- }
324
- }
325
- }
326
-
327
- return sourceColors
298
+ return false
299
+ }).filter(Boolean)
328
300
  }
329
301
 
330
302
  /**
@@ -340,10 +312,9 @@ export default class LintCommand extends Command {
340
312
  return
341
313
  }
342
314
 
343
- const errors = issues.filter(i => i.severity === "high")
344
- const warnings = issues.filter(i => i.severity === "medium")
345
- const infos = issues.filter(i => i.severity === "low")
346
-
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)
347
318
  const allIssues = errors.concat(warnings, infos)
348
319
 
349
320
  allIssues.forEach(issue => this.#reportSingleIssue(issue))
@@ -372,9 +343,9 @@ export default class LintCommand extends Command {
372
343
  */
373
344
  #getIndicator(severity) {
374
345
  switch(severity) {
375
- case "high": return c`{error}●{/}`
376
- case "medium": return c`{warn}●{/}`
377
- case "low":
346
+ case LC.SEVERITY.HIGH: return c`{error}●{/}`
347
+ case LC.SEVERITY.MEDIUM: return c`{warn}●{/}`
348
+ case LC.SEVERITY.LOW:
378
349
  default: return c`{info}●{/}`
379
350
  }
380
351
  }
@@ -389,26 +360,26 @@ export default class LintCommand extends Command {
389
360
  const indicator = this.#getIndicator(issue.severity)
390
361
 
391
362
  switch(issue.type) {
392
- case "duplicate-scope": {
363
+ case LC.ISSUE_TYPES.DUPLICATE_SCOPE: {
393
364
  const rules = issue.occurrences.map(occ => `{loc}'${occ.name}{/}'`).join(", ")
394
365
 
395
366
  Term.info(c`${indicator} Scope '{context}${issue.scope}{/}' is duplicated in ${rules}`)
396
367
  break
397
368
  }
398
369
 
399
- case "undefined-variable": {
400
- const sectionInfo = issue.section && issue.section !== "tokenColors" ? ` in ${issue.section}` : ""
370
+ case LC.ISSUE_TYPES.UNDEFINED_VARIABLE: {
371
+ const sectionInfo = issue.section && issue.section !== LC.SECTIONS.TOKEN_COLORS ? ` in ${issue.section}` : ""
401
372
 
402
373
  Term.info(c`${indicator} Variable '{context}${issue.variable}{/}' is used but not defined in '${issue.rule}' (${issue.property} property)${sectionInfo}`)
403
374
  break
404
375
  }
405
376
 
406
- case "unused-variable": {
407
- 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`)
408
379
  break
409
380
  }
410
381
 
411
- case "precedence-issue": {
382
+ case LC.ISSUE_TYPES.PRECEDENCE_ISSUE: {
412
383
  if(issue.broadIndex === issue.specificIndex) {
413
384
  Term.info(c`${indicator} Scope '{context}${issue.broadScope}{/}' makes more specific '{context}${issue.specificScope}{/}' redundant in '{loc}${issue.broadRule}{/}'`)
414
385
  } else {
@@ -439,13 +410,12 @@ export default class LintCommand extends Command {
439
410
  const scopes = entry.scope.split(",").map(s => s.trim())
440
411
 
441
412
  scopes.forEach(scope => {
442
- if(!scopeOccurrences.has(scope)) {
413
+ if(!scopeOccurrences.has(scope))
443
414
  scopeOccurrences.set(scope, [])
444
- }
445
415
 
446
416
  scopeOccurrences.get(scope).push({
447
417
  index: index + 1,
448
- name: entry.name || `Entry ${index + 1}`,
418
+ name: entry.name || LC.TEMPLATES.ENTRY_NAME(index),
449
419
  entry
450
420
  })
451
421
  })
@@ -455,8 +425,8 @@ export default class LintCommand extends Command {
455
425
  for(const [scope, occurrences] of scopeOccurrences) {
456
426
  if(occurrences.length > 1) {
457
427
  issues.push({
458
- type: "duplicate-scope",
459
- severity: "medium",
428
+ type: LC.ISSUE_TYPES.DUPLICATE_SCOPE,
429
+ severity: LC.SEVERITY.MEDIUM,
460
430
  scope,
461
431
  occurrences
462
432
  })
@@ -476,28 +446,27 @@ export default class LintCommand extends Command {
476
446
  * @returns {Array} Array of undefined variable issues
477
447
  * @private
478
448
  */
479
- #checkUndefinedVariables(themeData, pool, section = "tokenColors") {
449
+ #checkUndefinedVariables(themeData, pool, section=LC.SECTIONS.TOKEN_COLORS) {
480
450
  const issues = []
481
451
  const definedVars = pool ? new Set(pool.getTokens().keys()) : new Set()
482
452
 
483
- if(section === "tokenColors" && Array.isArray(themeData)) {
453
+ if(section === LC.SECTIONS.TOKEN_COLORS && Array.isArray(themeData)) {
484
454
  themeData.forEach((entry, index) => {
485
455
  const settings = entry.settings || {}
486
456
 
487
457
  for(const [key, value] of Object.entries(settings)) {
488
458
  if(typeof value === "string") {
489
- const {none,parens,braces} = Evaluator.sub.exec(value)?.groups ?? {}
490
- const varName = none || parens || braces
459
+ const varName = Evaluator.extractVariableName(value)
491
460
 
492
461
  if(!varName)
493
462
  return
494
463
 
495
464
  if(!definedVars.has(varName)) {
496
465
  issues.push({
497
- type: "undefined-variable",
498
- severity: "high",
466
+ type: LC.ISSUE_TYPES.UNDEFINED_VARIABLE,
467
+ severity: LC.SEVERITY.HIGH,
499
468
  variable: value,
500
- rule: entry.name || `Entry ${index + 1}`,
469
+ rule: entry.name || LC.TEMPLATES.ENTRY_NAME(index),
501
470
  property: key,
502
471
  section
503
472
  })
@@ -505,12 +474,18 @@ export default class LintCommand extends Command {
505
474
  }
506
475
  }
507
476
  })
508
- } else if((section === "semanticTokenColors" || section === "colors") && Array.isArray(themeData)) {
477
+ } else if((section === LC.SECTIONS.SEMANTIC_TOKEN_COLORS ||
478
+ section === LC.SECTIONS.COLORS)
479
+ && Array.isArray(themeData)) {
509
480
  // Handle semanticTokenColors and colors as objects
510
481
  themeData.forEach((dataObject, objIndex) => {
511
- if(dataObject && typeof dataObject === "object") {
512
- this.#checkObjectForUndefinedVariables(dataObject, definedVars, issues, section, `Object ${objIndex + 1}`)
513
- }
482
+ this.#checkObjectForUndefinedVariables(
483
+ dataObject,
484
+ definedVars,
485
+ issues,
486
+ section,
487
+ LC.TEMPLATES.OBJECT_NAME(objIndex)
488
+ )
514
489
  })
515
490
  }
516
491
 
@@ -529,24 +504,23 @@ export default class LintCommand extends Command {
529
504
  * @private
530
505
  */
531
506
  #checkObjectForUndefinedVariables(obj, definedVars, issues, section, ruleName, path = "") {
532
- for(const [key, value] of Object.entries(obj)) {
507
+ for(const [key, value] of Object.entries(obj ?? {})) {
533
508
  const currentPath = path ? `${path}.${key}` : key
534
509
 
535
510
  if(typeof value === "string") {
536
- const {none, parens, braces} = Evaluator.sub.exec(value)?.groups ?? {}
537
- const varName = none || parens || braces
511
+ const varName = Evaluator.extractVariableName(value)
538
512
 
539
513
  if(varName && !definedVars.has(varName)) {
540
514
  issues.push({
541
- type: "undefined-variable",
542
- severity: "high",
515
+ type: LC.ISSUE_TYPES.UNDEFINED_VARIABLE,
516
+ severity: LC.SEVERITY.HIGH,
543
517
  variable: value,
544
518
  rule: ruleName,
545
519
  property: currentPath,
546
520
  section
547
521
  })
548
522
  }
549
- } else if(value && typeof value === "object" && !Array.isArray(value)) {
523
+ } else if(typeof value === "object" && !Array.isArray(value)) {
550
524
  this.#checkObjectForUndefinedVariables(
551
525
  value, definedVars, issues, section, ruleName, currentPath
552
526
  )
@@ -562,10 +536,10 @@ export default class LintCommand extends Command {
562
536
  *
563
537
  * @param {Theme} theme - The compiled theme object
564
538
  * @param {ThemePool} pool - The theme's variable pool
565
- * @returns {Array} Array of unused variable issues
539
+ * @returns {Promise<Array>} Array of unused variable issues
566
540
  * @private
567
541
  */
568
- #checkUnusedVariables(theme, pool) {
542
+ async #checkUnusedVariables(theme, pool) {
569
543
  const issues = []
570
544
 
571
545
  if(!pool || !theme.getSource())
@@ -574,67 +548,36 @@ export default class LintCommand extends Command {
574
548
  // Get variables defined in the vars section only
575
549
  const definedVars = new Map()
576
550
  const cwd = this.getCwd()
577
- const mainFile = new FileObject(theme.getSourceFile().path)
578
- const relativeMainPath = File.relativeOrAbsolutePath(cwd, mainFile)
579
-
580
- this.#collectVarsDefinitions(theme.getSource().vars, definedVars, "", relativeMainPath)
581
-
582
- // Also check dependencies for vars definitions
583
- if(theme.hasDependencies()) {
584
- for(const dependency of theme.getDependencies()) {
585
- try {
586
- const depData = theme.getCache()?.loadCachedDataSync?.(dependency.getSourceFile())
587
-
588
- if(depData?.vars) {
589
- const depFile = new FileObject(dependency.getSourceFile().path)
590
- const relativeDependencyPath = File.relativeOrAbsolutePath(
591
- cwd, depFile
592
- )
593
-
594
- this.#collectVarsDefinitions(depData.vars, definedVars, "", relativeDependencyPath)
595
- }
596
- } catch {
597
- // Ignore cache errors
598
- }
599
- }
600
- }
601
551
 
602
552
  const usedVars = new Set()
603
553
 
604
- // Find variable usage in colors, tokenColors, and semanticColors sections
605
- const themeSource = theme.getSource()
606
-
607
- if(themeSource?.colors) {
608
- this.#findVariableUsage(themeSource.colors, usedVars)
609
- }
610
-
611
- if(themeSource?.tokenColors) {
612
- this.#findVariableUsage(themeSource.tokenColors, usedVars)
613
- }
614
-
615
- if(themeSource?.semanticColors) {
616
- this.#findVariableUsage(themeSource.semanticColors, usedVars)
617
- }
618
-
619
- // Also check dependencies for usage in these sections
620
- if(theme.hasDependencies()) {
621
- for(const dependency of theme.getDependencies()) {
622
- try {
623
- const depData = theme.getCache()?.loadCachedDataSync?.(dependency.getSourceFile())
624
-
625
- if(depData) {
626
- if(depData.colors)
627
- this.#findVariableUsage(depData.colors, usedVars)
628
-
629
- if(depData.tokenColors)
630
- this.#findVariableUsage(depData.tokenColors, usedVars)
631
-
632
- if(depData.semanticColors)
633
- this.#findVariableUsage(depData.semanticColors, usedVars)
634
- }
635
- } catch {
636
- // 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
+ )
637
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
638
581
  }
639
582
  }
640
583
 
@@ -642,10 +585,10 @@ export default class LintCommand extends Command {
642
585
  for(const [varName, filename] of definedVars) {
643
586
  if(!usedVars.has(varName)) {
644
587
  issues.push({
645
- type: "unused-variable",
646
- severity: "low",
647
- variable: `$${varName}`,
648
- occurence: filename,
588
+ type: LC.ISSUE_TYPES.UNUSED_VARIABLE,
589
+ severity: LC.SEVERITY.LOW,
590
+ variable: `${LC.TEMPLATES.VARIABLE_PREFIX}${varName}`,
591
+ occurrence: filename,
649
592
  })
650
593
  }
651
594
  }
@@ -664,18 +607,14 @@ export default class LintCommand extends Command {
664
607
  * @private
665
608
  */
666
609
  #collectVarsDefinitions(vars, definedVars, prefix = "", filename = "") {
667
- if(!vars || typeof vars !== "object")
668
- return
669
-
670
- for(const [key, value] of Object.entries(vars)) {
610
+ for(const [key, value] of Object.entries(vars ?? {})) {
671
611
  const varName = prefix ? `${prefix}.${key}` : key
672
612
 
673
613
  definedVars.set(varName, filename)
674
614
 
675
615
  // If the value is an object, recurse for nested definitions
676
- if(value && typeof value === "object" && !Array.isArray(value)) {
616
+ if(typeof value === "object" && !Array.isArray(value))
677
617
  this.#collectVarsDefinitions(value, definedVars, varName, filename)
678
- }
679
618
  }
680
619
  }
681
620
 
@@ -689,10 +628,12 @@ export default class LintCommand extends Command {
689
628
  * @private
690
629
  */
691
630
  #findVariableUsage(data, usedVars) {
631
+ if(!data)
632
+ return
633
+
692
634
  if(typeof data === "string") {
693
635
  if(Evaluator.sub.test(data)) {
694
- const {none, parens, braces} = Evaluator.sub.exec(data)?.groups ?? {}
695
- const varName = none || parens || braces
636
+ const varName = Evaluator.extractVariableName(data)
696
637
 
697
638
  if(varName) {
698
639
  usedVars.add(varName)
@@ -700,7 +641,7 @@ export default class LintCommand extends Command {
700
641
  }
701
642
  } else if(Array.isArray(data)) {
702
643
  data.forEach(item => this.#findVariableUsage(item, usedVars))
703
- } else if(data && typeof data === "object") {
644
+ } else if(typeof data === "object") {
704
645
  Object.values(data).forEach(
705
646
  value => this.#findVariableUsage(value, usedVars)
706
647
  )
@@ -732,12 +673,14 @@ export default class LintCommand extends Command {
732
673
  allScopes.push({
733
674
  scope,
734
675
  index: index + 1,
735
- name: entry.name || `Entry ${index + 1}`,
676
+ name: entry.name || LC.TEMPLATES.ENTRY_NAME(index),
736
677
  entry
737
678
  })
738
679
  })
739
680
  })
740
681
 
682
+ const {LOW,HIGH} = LC.SEVERITY
683
+
741
684
  // Check each scope against all later scopes
742
685
  for(let i = 0; i < allScopes.length; i++) {
743
686
  const current = allScopes[i]
@@ -749,8 +692,8 @@ export default class LintCommand extends Command {
749
692
  // This means the broad scope will mask the specific scope
750
693
  if(this.#isBroaderScope(current.scope, later.scope)) {
751
694
  issues.push({
752
- type: "precedence-issue",
753
- severity: current.index === later.index ? "low" : "high",
695
+ type: LC.ISSUE_TYPES.PRECEDENCE_ISSUE,
696
+ severity: current.index === later.index ? LOW : HIGH,
754
697
  specificScope: later.scope,
755
698
  broadScope: current.scope,
756
699
  specificRule: later.name,
@@ -769,7 +712,7 @@ export default class LintCommand extends Command {
769
712
  * Determines if one scope is broader than another.
770
713
  *
771
714
  * A broader scope will match the same tokens as a more specific scope, plus
772
- * others.
715
+ * others. Uses proper TextMate scope hierarchy rules.
773
716
  *
774
717
  * @param {string} broadScope - The potentially broader scope
775
718
  * @param {string} specificScope - The potentially more specific scope
@@ -777,9 +720,24 @@ export default class LintCommand extends Command {
777
720
  * @private
778
721
  */
779
722
  #isBroaderScope(broadScope, specificScope) {
780
- // Simple heuristic: if the specific scope starts with the broad scope + "."
781
- // then the broad scope is indeed broader
782
- // e.g., "keyword" is broader than "keyword.control", "keyword.control.import"
783
- return specificScope.startsWith(broadScope + ".")
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
+ )
784
739
  }
785
740
  }
741
+
742
+ // Aliases
743
+ const LC = LintCommand
@@ -87,6 +87,14 @@ export default class ResolveCommand extends Command {
87
87
  * @param {object} theme - The compiled theme object with pool
88
88
  * @param {string} colorName - The color key to resolve
89
89
  * @returns {void}
90
+ * @example
91
+ * // Resolve a color variable from a compiled theme
92
+ * await resolveCommand.resolveColor(theme, 'colors.primary');
93
+ * // Output:
94
+ * // colors.primary:
95
+ * // $(vars.accent)
96
+ * // → #3366cc
97
+ * // Resolution: #3366cc
90
98
  */
91
99
  async resolveColor(theme, colorName) {
92
100
  const pool = theme.getPool()
@@ -243,6 +251,7 @@ export default class ResolveCommand extends Command {
243
251
 
244
252
  // Temporarily replace tokenColors with semanticTokenColors for resolution
245
253
  const themeOutput = theme.getOutput()
254
+
246
255
  if(themeOutput?.semanticTokenColors) {
247
256
  themeOutput.tokenColors = themeOutput.semanticTokenColors
248
257
  }
@@ -426,7 +435,12 @@ export default class ResolveCommand extends Command {
426
435
  }
427
436
 
428
437
  if(this.#func.test(value)) {
429
- const {func,args} = this.#func.exec(value).groups
438
+ const result = Evaluator.extractFunctionCall(value)
439
+
440
+ if(!result)
441
+ return [c`{leaf}${value}{/}`, "literal"]
442
+
443
+ const {func, args} = result
430
444
 
431
445
  return [
432
446
  c`{func}${func}{/}{parens}${"("}{/}{leaf}${args}{/}{parens}${")"}{/}`,
@@ -435,9 +449,9 @@ export default class ResolveCommand extends Command {
435
449
  }
436
450
 
437
451
  if(this.#sub.test(value)) {
452
+ const varValue = Evaluator.extractVariableName(value) || value
438
453
  const {parens,none,braces} = Evaluator.sub.exec(value)?.groups || {}
439
454
  const style = (braces && ["{","}"]) || (parens && ["(",")"]) || (none && ["",""])
440
- const varValue = braces || parens || none || value
441
455
 
442
456
  return [
443
457
  c`{func}{/}{parens}${style[0]}{/}{leaf}${varValue}{/}{parens}${style[1]}{/}`,
package/src/Session.js CHANGED
@@ -481,7 +481,9 @@ export default class Session {
481
481
  if(this.#watcher)
482
482
  await this.#watcher.close()
483
483
 
484
- const dependencies = Array.from(this.#theme.getDependencies()).map(d => d.getSourceFile().path)
484
+ const dependencies = Array.from(this.#theme
485
+ .getDependencies())
486
+ .map(d => d.getSourceFile().path)
485
487
 
486
488
  this.#watcher = chokidar.watch(dependencies, {
487
489
  // Prevent watching own output files
package/src/Theme.js CHANGED
@@ -21,6 +21,7 @@ import FileObject from "./FileObject.js"
21
21
  import Term from "./Term.js"
22
22
  import ThemePool from "./ThemePool.js"
23
23
  import Util from "./Util.js"
24
+ import Cache from "./Cache.js"
24
25
 
25
26
  const outputFileExtension = "color-theme.json"
26
27
  const obviouslyASentinelYouCantMissSoShutUpAboutIt = "kakadoodoo"
@@ -113,7 +114,7 @@ export default class Theme {
113
114
  * Gets a specific compilation option.
114
115
  *
115
116
  * @param {string} option - The option name to retrieve
116
- * @returns {*} The option value or undefined if not set
117
+ * @returns {unknown} The option value or undefined if not set
117
118
  */
118
119
  getOption(option) {
119
120
  return this.#options?.[option] ?? undefined
@@ -282,9 +283,9 @@ export default class Theme {
282
283
  }
283
284
 
284
285
  /**
285
- * Gets the array of file dependencies.
286
+ * Gets the set of file dependencies.
286
287
  *
287
- * @returns {Set<Dependency>} Array of dependency files
288
+ * @returns {Set<Dependency>} Set of dependency files
288
289
  */
289
290
  getDependencies() {
290
291
  return this.#dependencies
@@ -306,6 +307,11 @@ export default class Theme {
306
307
  return this
307
308
  }
308
309
 
310
+ /**
311
+ * Checks if the theme has any dependencies.
312
+ *
313
+ * @returns {boolean} True if theme has dependencies
314
+ */
309
315
  hasDependencies() {
310
316
  return this.#dependencies.size > 0
311
317
  }
@@ -484,7 +490,7 @@ export default class Theme {
484
490
 
485
491
  this.#source = source
486
492
 
487
- this.addDependency(this.#sourceFile, this.#source)
493
+ this.addDependency(this.#sourceFile, new Map(Object.entries(this.#source)))
488
494
 
489
495
  return this
490
496
  }
@@ -542,6 +548,10 @@ export default class Theme {
542
548
  }
543
549
  }
544
550
 
551
+ /**
552
+ * Dependency class represents a theme file dependency.
553
+ * Manages the relationship between a file reference and its parsed source data.
554
+ */
545
555
  export class Dependency {
546
556
  #sourceFile = null
547
557
  #source = null
@@ -581,6 +591,11 @@ export class Dependency {
581
591
  return this
582
592
  }
583
593
 
594
+ /**
595
+ * Gets the parsed source data for this dependency.
596
+ *
597
+ * @returns {object|null} The parsed source data
598
+ */
584
599
  getSource() {
585
600
  return this.#source
586
601
  }
package/src/cli.js CHANGED
@@ -111,9 +111,10 @@ void (async function main() {
111
111
  .version(pkgJson.version)
112
112
 
113
113
  const commands = [BuildCommand, ResolveCommand, LintCommand]
114
-
114
+
115
115
  for(const CommandClass of commands) {
116
116
  const command = new CommandClass({cwd, packageJson: pkgJson})
117
+
117
118
  command.setCache(cache)
118
119
  await command.buildCli(program)
119
120
  command.addCliOptions(alwaysAvailable, false)