@gesslar/sassy 3.0.0 → 3.2.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.
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "name": "gesslar",
6
6
  "url": "https://gesslar.dev"
7
7
  },
8
- "version": "3.0.0",
8
+ "version": "3.2.0",
9
9
  "license": "Unlicense",
10
10
  "homepage": "https://github.com/gesslar/sassy#readme",
11
11
  "repository": {
package/src/Compiler.js CHANGED
@@ -117,7 +117,7 @@ export default class Compiler {
117
117
  // Assemble into one object with the proper keys
118
118
  const colors = workColors.reduce(reducer, {})
119
119
  const tokenColors = this.#composeArray(workTokenColors)
120
- const semanticTokenColors = workSemanticTokenColors.reduce(reducer, {})
120
+ const semanticTokenColors = this.#composeObject(workSemanticTokenColors)
121
121
 
122
122
  // Mix and maaatch all jumbly wumbly...
123
123
  const output = Data.mergeObject(
@@ -176,11 +176,35 @@ export default class ResolveCommand extends Command {
176
176
  const matches = this.#findScopeMatches(tokenColors, scopeName)
177
177
 
178
178
  if(matches.length === 0) {
179
+ // Try precedence-based fallback
180
+ const precedenceMatch = this.#findBestPrecedenceMatch(tokenColors, scopeName)
181
+
182
+ if(precedenceMatch) {
183
+ await this.#resolveScopeMatch(
184
+ theme, precedenceMatch.entry, scopeName,
185
+ {scope: precedenceMatch.matchedScope, relation: "via"}
186
+ )
187
+
188
+ return
189
+ }
190
+
179
191
  return Term.info(`No tokenColors entries found for scope '${scopeName}'`)
180
192
  }
181
193
 
182
194
  if(matches.length === 1) {
183
- // Single match - resolve directly
195
+ // Check if a broader scope earlier in the array masks this exact match
196
+ const maskingScope = this.#findMaskingScope(tokenColors, matches[0], scopeName)
197
+
198
+ if(maskingScope) {
199
+ await this.#resolveScopeMatch(
200
+ theme, maskingScope.entry, scopeName,
201
+ {scope: maskingScope.matchedScope, relation: "masked by"}
202
+ )
203
+
204
+ return
205
+ }
206
+
207
+ // No masking - resolve directly
184
208
  await this.#resolveScopeMatch(theme, matches[0], scopeName)
185
209
  } else {
186
210
  // Multiple matches - show disambiguation options
@@ -205,7 +229,94 @@ export default class ResolveCommand extends Command {
205
229
  })
206
230
  }
207
231
 
208
- async #resolveScopeMatch(theme, match, displayName) {
232
+ /**
233
+ * Finds the best precedence match for a target scope that has no exact match.
234
+ * Uses TextMate scope hierarchy rules: a broader scope (fewer segments) that
235
+ * is a prefix of the target scope will match. Returns the most specific
236
+ * (longest) broader scope.
237
+ *
238
+ * @param {Array} tokenColors - Array of tokenColors entries
239
+ * @param {string} targetScope - The scope to find a precedence match for
240
+ * @returns {{entry: object, matchedScope: string}|null} The best match or null
241
+ * @private
242
+ */
243
+ #findBestPrecedenceMatch(tokenColors, targetScope) {
244
+ const targetSegments = targetScope.split(".")
245
+ let bestMatch = null
246
+ let bestLength = 0
247
+
248
+ for(const entry of tokenColors) {
249
+ if(!entry.scope)
250
+ continue
251
+
252
+ const scopes = entry.scope.split(",").map(s => s.trim())
253
+
254
+ for(const scope of scopes) {
255
+ const scopeSegments = scope.split(".")
256
+
257
+ // Must be fewer segments (broader) and a prefix of the target
258
+ if(scopeSegments.length >= targetSegments.length)
259
+ continue
260
+
261
+ const isPrefix = scopeSegments.every((seg, i) =>
262
+ seg === targetSegments[i]
263
+ )
264
+
265
+ if(isPrefix && scopeSegments.length > bestLength) {
266
+ bestLength = scopeSegments.length
267
+ bestMatch = {entry, matchedScope: scope}
268
+ }
269
+ }
270
+ }
271
+
272
+ return bestMatch
273
+ }
274
+
275
+ /**
276
+ * Finds a broader scope that appears earlier in the tokenColors array
277
+ * and would mask the given exact match entry.
278
+ *
279
+ * @param {Array} tokenColors - Array of tokenColors entries
280
+ * @param {object} exactMatch - The exact match entry
281
+ * @param {string} targetScope - The scope being resolved
282
+ * @returns {{entry: object, matchedScope: string}|null} The masking entry or null
283
+ * @private
284
+ */
285
+ #findMaskingScope(tokenColors, exactMatch, targetScope) {
286
+ const targetSegments = targetScope.split(".")
287
+ const exactIndex = tokenColors.indexOf(exactMatch)
288
+ let bestMatch = null
289
+ let bestLength = 0
290
+
291
+ for(let i = 0; i < exactIndex; i++) {
292
+ const entry = tokenColors[i]
293
+
294
+ if(!entry.scope)
295
+ continue
296
+
297
+ const scopes = entry.scope.split(",").map(s => s.trim())
298
+
299
+ for(const scope of scopes) {
300
+ const scopeSegments = scope.split(".")
301
+
302
+ if(scopeSegments.length >= targetSegments.length)
303
+ continue
304
+
305
+ const isPrefix = scopeSegments.every((seg, idx) =>
306
+ seg === targetSegments[idx]
307
+ )
308
+
309
+ if(isPrefix && scopeSegments.length > bestLength) {
310
+ bestLength = scopeSegments.length
311
+ bestMatch = {entry, matchedScope: scope}
312
+ }
313
+ }
314
+ }
315
+
316
+ return bestMatch
317
+ }
318
+
319
+ async #resolveScopeMatch(theme, match, displayName, resolvedVia = null) {
209
320
  const pool = theme.getPool()
210
321
  const settings = match.settings || {}
211
322
  const name = match.name || "Unnamed"
@@ -255,9 +366,13 @@ export default class ResolveCommand extends Command {
255
366
  const fullTrail = this.#buildCompleteTrail(bestToken, trail)
256
367
  const finalValue = bestToken.getValue()
257
368
  const [formattedFinalValue] = this.#formatLeaf(finalValue)
258
- const output = c`{head}${displayName}{/} {hex}${(`${name}`)}{/}\n`+
259
- `${this.#formatOutput(fullTrail)}\n\n{head}`+
260
- `${"Resolution:"}{/} ${formattedFinalValue}`
369
+ const header = resolvedVia
370
+ ? c`{<BU}${displayName}{/} {<I}${resolvedVia.relation}{/} {<BU}${resolvedVia.scope}{/} {<I}in{/} {hex}${(`${name}`)}{/}\n`
371
+ : c`{<BU}${displayName}{/} {<I}in{/} {hex}${(`${name}`)}{/}\n`
372
+
373
+ const output = header +
374
+ `${this.#formatOutput(fullTrail)}\n\n`+
375
+ c`{head}${"Resolution:"}{/} ${formattedFinalValue}`
261
376
 
262
377
  Term.info(output)
263
378
  }