Skip to content

Commit

Permalink
feat: fix 2 bugs and add child combinator ">"
Browse files Browse the repository at this point in the history
Bug: Just because a trie element exists for a more specific scope doesn‘t mean its parent scopes will match, so we need to collect the trie elements with less specific scopes too.

Bug: If the number of scope names in both rules‘ scope paths are not
equal, the parent scope names won‘t be compared at all. Instead, the rule with
the longest scope path is preferred. This goes against the TextMate
manual (https://macromates.com/manual/en/scope_selectors). In
particular, the following line in “Ranking Matches”:
> Rules 1 and 2 applied again to the scope selector when removing the deepest element (in the case of a tie)

Feature: Add support for the child combinator (the `>` operator). This
allows for styling a parent-child relationship specifically.
  • Loading branch information
aleclarson committed Jun 7, 2024
1 parent 09effd8 commit 62d814f
Show file tree
Hide file tree
Showing 2 changed files with 96 additions and 55 deletions.
149 changes: 95 additions & 54 deletions src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,26 +161,41 @@ export class ScopeStack {
}
}

function _scopePathMatchesParentScopes(scopePath: ScopeStack | null, parentScopes: ScopeName[] | null): boolean {
if (parentScopes === null) {
function _scopePathMatchesParentScopes(scopePath: ScopeStack | null, parentScopes: readonly ScopeName[]): boolean {
if (parentScopes.length === 0) {
return true;
}

let index = 0;
let scopePattern = parentScopes[index];
for (let index = 0; index < parentScopes.length; index++) {
let scopePattern = parentScopes[index];
let scopeMustMatch = false;

while (scopePath) {
if (_matchesScope(scopePath.scopeName, scopePattern)) {
index++;
if (index === parentScopes.length) {
return true;
// Check for a child combinator (a parent-child relationship)
if (scopePattern === '>') {
if (index === parentScopes.length - 1) {
return false;
}
scopePattern = parentScopes[index];
scopePattern = parentScopes[++index];
scopeMustMatch = true;
}

while (scopePath) {
if (_matchesScope(scopePath.scopeName, scopePattern)) {
break;
}
if (scopeMustMatch) {
return false;
}
scopePath = scopePath.parent
}

if (!scopePath) {
return false;
}
scopePath = scopePath.parent;
}

return false;
// All parent scopes were matched.
return true;
}

function _matchesScope(scopeName: ScopeName, scopePattern: ScopeName): boolean {
Expand Down Expand Up @@ -427,17 +442,19 @@ export class ColorMap {
}
}

const emptyParentScopes= Object.freeze(<ScopeName[]>[]);

export class ThemeTrieElementRule {

scopeDepth: number;
parentScopes: ScopeName[] | null;
parentScopes: readonly ScopeName[];
fontStyle: number;
foreground: number;
background: number;

constructor(scopeDepth: number, parentScopes: ScopeName[] | null, fontStyle: number, foreground: number, background: number) {
constructor(scopeDepth: number, parentScopes: readonly ScopeName[] | null, fontStyle: number, foreground: number, background: number) {
this.scopeDepth = scopeDepth;
this.parentScopes = parentScopes;
this.parentScopes = parentScopes || emptyParentScopes;
this.fontStyle = fontStyle;
this.foreground = foreground;
this.background = background;
Expand Down Expand Up @@ -489,55 +506,79 @@ export class ThemeTrieElement {
this._rulesWithParentScopes = rulesWithParentScopes;
}

private static _sortBySpecificity(arr: ThemeTrieElementRule[]): ThemeTrieElementRule[] {
if (arr.length === 1) {
return arr;
}
arr.sort(this._cmpBySpecificity);
return arr;
}

private static _cmpBySpecificity(a: ThemeTrieElementRule, b: ThemeTrieElementRule): number {
if (a.scopeDepth === b.scopeDepth) {
const aParentScopes = a.parentScopes;
const bParentScopes = b.parentScopes;
let aParentScopesLen = aParentScopes === null ? 0 : aParentScopes.length;
let bParentScopesLen = bParentScopes === null ? 0 : bParentScopes.length;
if (aParentScopesLen === bParentScopesLen) {
for (let i = 0; i < aParentScopesLen; i++) {
const aLen = aParentScopes![i].length;
const bLen = bParentScopes![i].length;
if (aLen !== bLen) {
return bLen - aLen;
}
}
// First, compare the scope depths of both rules. The “scope depth” of a rule is
// the number of segments (delimited by dots) in the rule's deepest scope name
// (i.e. the final scope name in the scope path delimited by spaces).
if (a.scopeDepth !== b.scopeDepth) {
return b.scopeDepth - a.scopeDepth;
}

// Traverse the parent scopes depth-first, comparing the specificity of both
// rules' parent scopes, which matches the behavior described by ”Ranking Matches”
// in TextMate 1.5's manual: https://macromates.com/manual/en/scope_selectors
// Start at index 0 for both rules, since the parent scopes were reversed
// beforehand (i.e. index 0 is the deepest parent scope).
let aParentIndex = 0;
let bParentIndex = 0;

while (true) {
// Child combinators don't affect specificity.
if (a.parentScopes[aParentIndex] === '>') {
aParentIndex++;
}
if (b.parentScopes[bParentIndex] === '>') {
bParentIndex++;
}

// This is a scope-by-scope comparison, so we need to stop once a rule runs
// out of parent scopes.
if (aParentIndex >= a.parentScopes.length || bParentIndex >= b.parentScopes.length) {
break;
}

// When sorting by scope name specificity, it's safe to treat a longer parent
// scope as more specific. If both rules' parent scopes match a given scope
// path, the longer parent scope will always be more specific.
const parentScopeLengthDelta =
b.parentScopes[bParentIndex].length - a.parentScopes[aParentIndex].length;

if (parentScopeLengthDelta !== 0) {
return parentScopeLengthDelta;
}
return bParentScopesLen - aParentScopesLen;
}
return b.scopeDepth - a.scopeDepth;

// If a depth-first, scope-by-scope comparison resulted in a tie, the rule with
// more parent scopes is considered more specific.
return b.parentScopes.length - a.parentScopes.length;
}

public match(scope: ScopeName): ThemeTrieElementRule[] {
if (scope === '') {
return ThemeTrieElement._sortBySpecificity((<ThemeTrieElementRule[]>[]).concat(this._mainRule).concat(this._rulesWithParentScopes));
}
let childRules: ThemeTrieElementRule[] | undefined;
if (scope !== '') {
let dotIndex = scope.indexOf('.')
let head: string
let tail: string
if (dotIndex === -1) {
head = scope
tail = ''
} else {
head = scope.substring(0, dotIndex)
tail = scope.substring(dotIndex + 1)
}

let dotIndex = scope.indexOf('.');
let head: string;
let tail: string;
if (dotIndex === -1) {
head = scope;
tail = '';
} else {
head = scope.substring(0, dotIndex);
tail = scope.substring(dotIndex + 1);
if (this._children.hasOwnProperty(head)) {
childRules = this._children[head].match(tail);
}
}

if (this._children.hasOwnProperty(head)) {
return this._children[head].match(tail);
}
const rules = [this._mainRule].concat(this._rulesWithParentScopes);
rules.sort(ThemeTrieElement._cmpBySpecificity);

return ThemeTrieElement._sortBySpecificity((<ThemeTrieElementRule[]>[]).concat(this._mainRule).concat(this._rulesWithParentScopes));
// If an element exists for a deeper scope, its rule specificity is greater than
// the current element, but we still need to return the current element's rules in
// case none of the deeper elements match (due to parent scope requirements).
return childRules ? childRules.concat(rules) : rules;
}

public insert(scopeDepth: number, scope: ScopeName, parentScopes: ScopeName[] | null, fontStyle: number, foreground: number, background: number): void {
Expand Down
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export function strcmp(a: string, b: string): number {
return 0;
}

export function strArrCmp(a: string[] | null, b: string[] | null): number {
export function strArrCmp(a: readonly string[] | null, b: readonly string[] | null): number {
if (a === null && b === null) {
return 0;
}
Expand Down

0 comments on commit 62d814f

Please sign in to comment.