-
Notifications
You must be signed in to change notification settings - Fork 153
/
Copy pathConflictResolver.ts
480 lines (407 loc) · 20 KB
/
ConflictResolver.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
import { assertNever } from "@opticss/util";
import { CompoundSelector, ParsedSelector, parseSelector, postcss, postcssSelectorParser as selectorParser } from "opticss";
import { isAttributeNode, isClassNode, isRootNode, toAttrToken } from "../BlockParser";
import { Resolution, getResolution, isResolution } from "../BlockSyntax";
import { Block, BlockClass, Style } from "../BlockTree";
import { ResolvedConfiguration } from "../configuration";
import * as errors from "../errors";
import { QueryKeySelector } from "../query";
import { SourceFile, SourceRange, sourceRange } from "../SourceLocation";
import { expandProp } from "../util/propertyParser";
import { Conflicts, detectConflicts } from "./conflictDetection";
enum ConflictType {
conflict,
noConflict,
sameValues,
}
const SIBLING_COMBINATORS = new Set(["+", "~"]);
const HIERARCHICAL_COMBINATORS = new Set([" ", ">"]);
const CONTIGUOUS_COMBINATORS = new Set(["+", ">"]);
const NONCONTIGUOUS_COMBINATORS = new Set(["~", " "]);
function updateConflict(t1: ConflictType, t2: ConflictType): ConflictType {
switch (t1) {
case ConflictType.conflict:
return ConflictType.conflict;
case ConflictType.noConflict:
return t2;
case ConflictType.sameValues:
switch (t2) {
case ConflictType.conflict:
return ConflictType.conflict;
default:
return ConflictType.sameValues;
}
default:
return assertNever(t1);
}
}
interface ResolutionDecls {
decl: postcss.Declaration;
resolution: Resolution;
isOverride: boolean;
localDecls: SimpleDecl[];
}
interface SimpleDecl {
prop: string;
value: string;
}
/**
* `ConflictResolver` is a utility class that crawls a Block, the block it inherits from,
* and any other explicitly referenced blocks where resolution rules are applied, and
* resolves property values accordingly.
*/
export class ConflictResolver {
readonly config: ResolvedConfiguration;
private _reservedClassNames: Set<string>;
constructor(config: ResolvedConfiguration, reservedClassNames: Set<string>) {
this.config = config;
this._reservedClassNames = reservedClassNames;
}
/**
* Given a ruleset and Block, resolve all conflicts against the parent block as an override
* by automatically injecting `resolve-inherited()` calls for conflicting properties.
* @param root The PostCSS ruleset to operate on.
* @param block The owner block of these rules.
*/
resolveInheritance(root: postcss.Root, block: Block) {
let blockBase = block.base;
let blockBaseName = block.getReferencedBlockLocalName(block.base);
// If this block inherits from another block, walk every rule set.
if (blockBase && blockBaseName) {
root.walkRules((rule) => {
// These two conflicts caches persist between comma separated selectors
// so we don't resolve the same Properties or Style twice in a single pass.
let handledConflicts = new Conflicts<string>();
let handledObjects = new Conflicts<Style>();
// For each key selector:
let parsedSelectors = block.getParsedSelectors(rule);
parsedSelectors.forEach((sel) => {
let key = sel.key;
let obj: Style | null = null;
let container: BlockClass | null;
// Fetch the associated `Style`. If does not exist (ex: malformed selector), skip.
for (let node of key.nodes) {
if (isRootNode(node)) {
container = obj = block.rootClass;
}
if (isClassNode(node)) {
container = obj = block.getClass(node.value);
}
else if (isAttributeNode(node)) {
obj = container!.getAttributeValue(toAttrToken(node));
}
}
if (!obj) { return; }
// Fetch the set of Style conflicts. If the Style has already
// been handled, skip.
let objectConflicts = handledObjects.getConflictSet(key.pseudoelement && key.pseudoelement.value);
if (objectConflicts.has(obj)) { return; }
objectConflicts.add(obj);
// Fetch the parent Style this Style inherits from. If none, skip.
let base = obj.base;
if (!base) { return; }
// Handle the inheritance conflicts
let baseSource = base.asSource();
let conflicts = detectConflicts(obj, base);
let handledConflictSet = handledConflicts.getConflictSet(key.pseudoelement && key.pseudoelement.value);
let conflictingProps = conflicts.getConflictSet(key.pseudoelement && key.pseudoelement.value);
// Given a rule set and Set of conflicting properties, inject `resolve-inherited`
// calls for the conflicts for `resolve()` to use later.
if (!conflictingProps || conflictingProps.size === 0) { return; }
let ruleProps = new Set<string>();
rule.walkDecls((decl) => {
ruleProps.add(decl.prop);
});
conflictingProps.forEach(([thisProp, _]) => {
if (ruleProps.has(thisProp) && !handledConflictSet.has(thisProp)) {
handledConflictSet.add(thisProp);
rule.prepend(postcss.decl({prop: thisProp, value: `resolve-inherited("${blockBaseName}${baseSource}")`}));
}
});
});
});
}
}
/**
* Given a ruleset and Block, resolve all `resolve()` and `resolve-inherited()`
* calls with the appropriate values from the local block and resolved blocks.
* @param root The PostCSS ruleset to operate on.
* @param block The owner block of these rules.
*/
resolve(root: postcss.Root, block: Block) {
const resolutions: Set<ResolutionDecls> = new Set();
root.walkDecls((decl) => {
if (!isResolution(decl.value)) { return; }
resolutions.add({
decl,
resolution: getResolution(decl.value),
isOverride: false,
localDecls: [],
});
});
resolutions.forEach((res) => {
const { decl, resolution, localDecls } = res;
// Expand the property to all its possible representations.
let propExpansion = expandProp(decl.prop, decl.value);
let foundRes = false;
decl.parent.walkDecls(({ prop, value }) => {
// If this is the same resolution declaration, and no local decls
// have been found, this is an override resolution.
if (value === decl.value) {
foundRes = (localDecls.length === 0) ? false : true;
res.isOverride = (localDecls.length === 0);
}
// If this property isn't a concern of the resolution, or is a resolution itself, skip.
if (isResolution(value) || !propExpansion[prop]) { return; }
// Throw if resolutions are not all before or after values for the same property.
if (localDecls.length && foundRes) {
throw new errors.InvalidBlockSyntax(`Resolving ${decl.prop} must happen either before or after all other values for ${decl.prop}.`, this.sourceRange(block, decl));
}
// Save the applicable local decl.
localDecls.push({ prop, value });
});
// If no local declarations found setting this value, throw.
if (!localDecls.length) {
throw new errors.InvalidBlockSyntax(`Cannot resolve ${decl.prop} without a concrete value.`, this.sourceRange(block, decl));
}
// Look up the block that contains the requested resolution.
let other: Style | undefined = block.lookup(resolution.path);
if (!other) {
throw new errors.InvalidBlockSyntax(`Cannot find ${resolution.path}`, this.sourceRange(block, decl));
}
// If trying to resolve rule from the same block, throw.
if (block.equal(other && other.block)) {
throw new errors.InvalidBlockSyntax(`Cannot resolve conflicts with your own block.`, this.sourceRange(block, decl));
}
// If trying to resolve (read: not inheritance resolution) from an ancestor block, throw.
else if (!resolution.isInherited && other && other.block.isAncestorOf(block)) {
throw new errors.InvalidBlockSyntax(`Cannot resolve conflicts with ancestors of your own block.`, this.sourceRange(block, decl));
}
// Crawl up inheritance tree of the other block and attempt to resolve the conflict at each level.
// XXX Should this really abort when it finds the first conflict?
let foundConflict = ConflictType.noConflict;
do {
foundConflict = this.resolveConflictWith(resolution.path, other, res);
other = other.base;
} while (other && foundConflict === ConflictType.noConflict);
// If no conflicting Declarations were found (aka: calling for a resolution
// with nothing to resolve), throw error.
if (!resolution.isInherited && foundConflict === ConflictType.noConflict) {
throw new errors.InvalidBlockSyntax(`There are no conflicting values for ${decl.prop} found in any selectors targeting ${resolution.path}.`, this.sourceRange(block, decl));
}
// Remove resolution Declaration. Do after traversal because otherwise we mess up postcss' iterator.
decl.remove();
});
}
private resolveConflictWith(
referenceStr: string,
other: Style,
resolution: ResolutionDecls,
): ConflictType {
const { decl, localDecls, isOverride } = resolution;
const root = other.block.stylesheet;
const curSel = parseSelector((<postcss.Rule>decl.parent)); // can't use the cache, it's already been rewritten.
// This should never happen, but it satisfies the compiler.
if (root === undefined) {
throw new TypeError(`Cannot resolve. The block for ${referenceStr} is missing a stylesheet root`);
}
// Something to consider: when resolving against a sub-block that has overridden a property, do we need
// to include the base object selector(s) in the key selector as well?
const query = new QueryKeySelector(other);
const result = query.execute(root, other.block);
let foundConflict: ConflictType = ConflictType.noConflict;
for (let cs of curSel) {
let resultSelectors = cs.key.pseudoelement ? result.other[cs.key.pseudoelement.value] : result.main;
if (!resultSelectors || resultSelectors.length === 0) continue;
// we reverse the selectors because otherwise the insertion order causes them to be backwards from the
// source order of the target selector
for (let s of resultSelectors.reverse()) {
let newSelectors = this.mergeKeySelectors(other.block.rewriteSelector(s.parsedSelector, this.config, this._reservedClassNames), cs);
if (newSelectors === null) { continue; }
// avoid duplicate selector via permutation
let newSelStr = newSelectors.join(",\n");
let newRule = postcss.rule({ selector: newSelStr });
let newRuleContext = reproduceContext(s.rule, newRule);
// For every declaration in the other ruleset,
const remoteDecls: SimpleDecl[] = [];
s.rule.walkDecls((overrideDecl): true | void => {
// If this is another resolution, skip. This resolution handles it.
if (isResolution(overrideDecl.value)) { return true; }
// Expand the property to all its possible representations.
let propExpansion = expandProp(overrideDecl.prop, overrideDecl.value);
// If these properties no not match, skip.
if (!propExpansion[decl.prop]) {
let localPropExpansion = expandProp(decl.prop, decl.value);
let discovered = false;
for (let prop of Object.keys(localPropExpansion)) {
discovered = discovered || !!propExpansion[prop];
}
if (!discovered) { return true; }
}
// Save the remote decl values in order discovered.
remoteDecls.push({ prop: decl.prop, value: propExpansion[decl.prop] });
});
// If no applicable attributes on the other selector, return as not in conflict.
if (!remoteDecls.length) { continue; }
// Check if all the values are the same, skip resolution for this selector if they are.
if (localDecls.length === remoteDecls.length) {
// TODO: Better list comparison here, this is dirty.
if (localDecls.reduce((c, d) => `${c}${d.prop}:${d.value};`, "") === remoteDecls.reduce((c, d) => `${c}${d.prop}:${d.value};`, "")) {
foundConflict = updateConflict(foundConflict, ConflictType.sameValues);
continue;
}
}
// Add all found declarations to the new rule.
foundConflict = updateConflict(foundConflict, ConflictType.conflict);
for (let {prop, value} of isOverride ? localDecls : remoteDecls) {
newRule.append(postcss.decl({ prop, value }));
}
// Insert the new rule.
if (newRule.nodes && newRule.nodes.length > 0) {
let parent = decl.parent.parent;
if (parent) {
let rule = decl.parent as postcss.Rule;
parent.insertAfter(rule, newRuleContext);
}
}
}
}
return foundConflict;
}
/**
* Splits a CompoundSelector linked list into an array of [ CompoundSelector, Combinator, CompoundSelector ],
* where the first CompoundSelector is all but the last selector segment.
* @param s The compound selector to split.
* @returns [ CompoundSelector, Combinator, CompoundSelector ]
*/
private splitSelector(s: CompoundSelector): [CompoundSelector, selectorParser.Combinator, CompoundSelector] | [undefined, undefined, CompoundSelector] {
s = s.clone();
let last = s.removeLast();
if (last) {
return [s, last.combinator, last.selector];
} else {
return [undefined, undefined, s];
}
}
/**
* Given two conflicting ParsedSelectors, return a list of selector rules that
* select elements with both rules present.
* @param s1 Conflicting ParsedSelector 1.
* @param s2 Conflicting ParsedSelector 2.
* @returns A list of ParsedSelector rules that select all possible elements that can have both styles applied.
*/
private mergeKeySelectors(s1: ParsedSelector, s2: ParsedSelector): ParsedSelector[] {
// We can not currently handle selectors with more than one combinator.
if (s1.length > 2 && s2.length > 2) {
throw new errors.InvalidBlockSyntax(`Cannot resolve selectors with more than 1 combinator at this time [FIXME].`);
}
// Split the two combinators into constituent parts.
let [context1, combinator1, key1] = this.splitSelector(s1.selector);
let [context2, combinator2, key2] = this.splitSelector(s2.selector);
// Create the new merged key selector. Ex: ``.foo ~ .bar && .biz > .baz => .bar.baz`
let mergedKey = key1.clone().mergeNodes(key2);
// Construct our new conflict-free selector list.
let mergedSelectors: CompoundSelector[] = [];
// If both selectors have contexts, we need to do some CSS magic.
if (context1 && context2 && combinator1 && combinator2) {
// If both selectors use the `>` or `+` combinator, combine the contexts.
// Ex: `.foo + .foo` and `.bar + .bar` => `.foo.bar + .foo.bar`
if (CONTIGUOUS_COMBINATORS.has(combinator1.value) && combinator1.value === combinator2.value) {
mergedSelectors.push(context1.clone().mergeNodes(context2).append(combinator1, mergedKey));
}
// If selector 1 uses `+` or `~` and selector 2 uses ` ` or `>`, place the hierarchical combinator first.
// Ex: `.foo + .foo` and `.biz > .baz` => `.biz > .foo + .foo.baz + .foo.bar`
else if (SIBLING_COMBINATORS.has(combinator1.value) && HIERARCHICAL_COMBINATORS.has(combinator2.value)) {
mergedSelectors.push(context2.clone().append(combinator2, context1).append(combinator1, mergedKey));
}
// Reverse of above.
// If selector 2 uses `+` or `~` and selector 1 uses ` ` or `>`, place the hierarchical combinator first.
// Ex: `.biz > .baz` and `.foo + .foo` => `.biz > .foo + .foo.baz + .foo.bar`
else if (HIERARCHICAL_COMBINATORS.has(combinator1.value) && SIBLING_COMBINATORS.has(combinator2.value)) {
mergedSelectors.push(context1.clone().append(combinator1, context2).append(combinator2, mergedKey));
}
// " "," "; ~,~
else if (NONCONTIGUOUS_COMBINATORS.has(combinator1.value) && NONCONTIGUOUS_COMBINATORS.has(combinator2.value)) {
mergedSelectors.push(context1.clone().mergeNodes(context2).append(combinator2, mergedKey));
mergedSelectors.push(context1.clone().append(combinator1, context2.clone()).append(combinator2, mergedKey.clone()));
mergedSelectors.push(context2.clone().append(combinator1, context1.clone()).append(combinator2, mergedKey.clone()));
}
// " ", >; ~,+
else if (
NONCONTIGUOUS_COMBINATORS.has(combinator1.value) && CONTIGUOUS_COMBINATORS.has(combinator2.value) &&
((HIERARCHICAL_COMBINATORS.has(combinator1.value) && HIERARCHICAL_COMBINATORS.has(combinator2.value)) ||
(SIBLING_COMBINATORS.has(combinator1.value) && SIBLING_COMBINATORS.has(combinator2.value)))
) {
mergedSelectors.push(context1.clone().mergeNodes(context2).append(combinator2, mergedKey));
mergedSelectors.push(context1.clone().append(combinator1, context2.clone()).append(combinator2, mergedKey.clone()));
}
// >, " "; +,~
else if (
NONCONTIGUOUS_COMBINATORS.has(combinator2.value) && CONTIGUOUS_COMBINATORS.has(combinator1.value) &&
((HIERARCHICAL_COMBINATORS.has(combinator2.value) && HIERARCHICAL_COMBINATORS.has(combinator1.value)) ||
(SIBLING_COMBINATORS.has(combinator2.value) && SIBLING_COMBINATORS.has(combinator1.value)))
) {
mergedSelectors.push(context1.clone().mergeNodes(context2).append(combinator1, mergedKey));
mergedSelectors.push(context2.clone().append(combinator2, context1.clone()).append(combinator1, mergedKey.clone()));
}
// We've encountered a use case we don't recognize...
else {
throw new errors.InvalidBlockSyntax(`Cannot merge selectors with combinators: '${combinator1.value}' and '${combinator2.value}' [FIXME?].`);
}
}
// If selector 1 has a context, use it as the context for our merged key.
// Ex: .foo && .context > .bar => .context > .foo.bar
else if (context1 && combinator1) {
mergedSelectors.push(context1.clone().append(combinator1, mergedKey));
}
// If selector 2 has a context, use it as the context for our merged key.
// Ex: .context ~ .foo && .bar => .context ~ .foo.bar
else if (context2 && combinator2) {
mergedSelectors.push(context2.clone().append(combinator2, mergedKey));
}
// Otherwise, our merged key *is* our conflict-free selector.
// Ex: .foo && .bar => .foo.bar
else {
mergedSelectors.push(mergedKey);
}
// Wrap our list of CompoundSelectors in ParsedSelector containers and return.
return mergedSelectors.map(sel => new ParsedSelector(sel, sel.toString()));
}
sourceRange(block: Block, node: postcss.Node): SourceRange | SourceFile | undefined {
let blockPath = this.config.importer.debugIdentifier(block.identifier, this.config);
return sourceRange(this.config, block.stylesheet, blockPath, node);
}
}
interface InstanceOf<T> {
constructor: { new (): T };
}
function shallowClone(node: InstanceOf<postcss.Container>) {
let cloned = new node.constructor();
for (let i in node) {
if (!node.hasOwnProperty(i)) continue;
let value = node[i];
let type = typeof value;
if (i === "parent" && type === "object") {
// skip it
} else if (i === "source") {
cloned[i] = value;
} else if (value instanceof Array) {
// skip it
} else if (type === "object" && value !== null) {
// skip it
} else {
cloned[i] = value;
}
}
return cloned;
}
function reproduceContext(hasContext: postcss.Node, needsContext: postcss.Node): postcss.Node {
if (!hasContext.parent || hasContext.parent.type === "root") {
return needsContext;
}
let parent = hasContext.parent;
// The typings for postcss don't model the nodes as classes so we have to cast through unknown.
let newParent = shallowClone(<InstanceOf<postcss.Container>><unknown>parent);
newParent.append(needsContext);
return reproduceContext(parent, newParent);
}