Skip to content

Commit 37dcdfb

Browse files
committed
fix: More robust importing.
My fix for #248 introduced a stack on error objects that allowed errors to track the location of the error within a series of imports. This turned out to be problematic because our blocks are singletons and so the errors for that block also want to be singletons. Because a block might be imported (and exported) from different blocks, the stack needed to be tracked differently. A previous commit added the CascadingError error for indicating an error was caused by some previous error and used it for block exports. In this commit, I rewrote the code to use CascadingError for block imports as well. This allowed me to stop doing weird things trying to parse a block a second time if it previously had an error -- just to get a different error instance. Building on the MultipleBlockErrors, I've updated our error reporting in a few places to describe the sequence of events that lead up to an error. The CLI now displays the root cause for an error like so: ``` error test/fixtures/basic/error.block.css Two distinct classes cannot be selected on the same element: .foo.bar At test/fixtures/basic/error.block.css:1:5 1: .foo.bar { 2: color: red; caused test/fixtures/basic/transitive-error.block.css Error in imported block. At test/fixtures/basic/transitive-error.block.css:1:1 1: @block error from "./error.block.css"; 2: error test/fixtures/basic/transitive-error.block.css No Block named "error" found in scope. At test/fixtures/basic/transitive-error.block.css:4:3 3: :scope { 4: extends: error; 5: } Found 2 errors in 1 file. ```
1 parent a78b5d8 commit 37dcdfb

14 files changed

+291
-204
lines changed

packages/@css-blocks/cli/src/index.ts

+15-12
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
import { search as searchForConfiguration } from "@css-blocks/config";
55
import {
66
BlockFactory,
7+
CascadingError,
78
Configuration,
89
CssBlockError,
910
ErrorWithPosition,
@@ -203,16 +204,27 @@ export class CLI {
203204
this.exit(errorCount);
204205
}
205206

207+
fileForError(e: CssBlockError): string | undefined {
208+
let loc = e.location;
209+
if (!loc) return;
210+
if (!errorHasRange(loc)) return;
211+
if (!loc.filename) return;
212+
return path.relative(process.cwd(), path.resolve(loc.filename));
213+
}
214+
206215
handleCssBlockError(blockFileRelative: string, error: CssBlockError): number {
207-
if (error instanceof MultipleCssBlockErrors) {
216+
if (error instanceof CascadingError) {
217+
this.handleCssBlockError(this.fileForError(error.cause) || blockFileRelative, error.cause);
218+
} else if (error instanceof MultipleCssBlockErrors) {
208219
let count = 0;
209220
for (let e of error.errors) {
210-
count += this.handleCssBlockError(blockFileRelative, e);
221+
count += this.handleCssBlockError(this.fileForError(e) || blockFileRelative, e);
222+
this.println();
211223
}
212224
return count;
213225
}
214226
let loc = error.location;
215-
let message = `${this.chalk.red("error")}\t${this.chalk.whiteBright(blockFileRelative)}`;
227+
let message = `${this.chalk.red(error instanceof CascadingError ? "caused" : "error")}\t${this.chalk.whiteBright(this.fileForError(error) || blockFileRelative)}`;
216228
if (!errorHasRange(loc)) {
217229
this.println(message, error.origMessage);
218230
} else {
@@ -228,15 +240,6 @@ export class CLI {
228240
if (!errorHasRange(loc)) return;
229241
let filename = path.relative(process.cwd(), path.resolve(loc && loc.filename || blockFileRelative));
230242
this.println("\t" + this.chalk.bold.redBright(e.origMessage));
231-
for (let referenceLocation of e.importStack.reverse()) {
232-
if (referenceLocation.filename) {
233-
referenceLocation.filename = path.relative(process.cwd(), path.resolve(referenceLocation.filename));
234-
}
235-
this.println(
236-
this.chalk.bold.white("\tIn block referenced at"),
237-
this.chalk.bold.whiteBright(charInFile(referenceLocation)),
238-
);
239-
}
240243
if (hasMappedPosition(loc)) {
241244
this.println(
242245
this.chalk.bold.white("\tAt compiled output of"),

packages/@css-blocks/cli/test/cli-test.ts

+13-5
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,26 @@ Found 1 error in 1 file.
4343
let cli = new CLI();
4444
await cli.run(["validate", fixture("basic/transitive-error.block.css")]);
4545

46-
assert.equal(cli.output,
47-
`error\t${relFixture("basic/transitive-error.block.css")}
46+
assert.equal(
47+
cli.output,
48+
`error\ttest/fixtures/basic/error.block.css
4849
\tTwo distinct classes cannot be selected on the same element: .foo.bar
49-
\tAt ${relFixture("basic/error.block.css")}:1:5
50+
\tAt test/fixtures/basic/error.block.css:1:5
5051
\t1: .foo.bar {
5152
\t2: color: red;
52-
error\t${relFixture("basic/transitive-error.block.css")}
53+
caused\ttest/fixtures/basic/transitive-error.block.css
54+
\tError in imported block.
55+
\tAt test/fixtures/basic/transitive-error.block.css:1:1
56+
\t1: @block error from "./error.block.css";
57+
\t2:
58+
59+
error\ttest/fixtures/basic/transitive-error.block.css
5360
\tNo Block named "error" found in scope.
54-
\tAt ${relFixture("basic/transitive-error.block.css")}:4:3
61+
\tAt test/fixtures/basic/transitive-error.block.css:4:3
5562
\t3: :scope {
5663
\t4: extends: error;
5764
\t5: }
65+
5866
Found 2 errors in 1 file.
5967
`);
6068
assert.equal(cli.exitCode, 2);

packages/@css-blocks/core/src/BlockParser/BlockFactory.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,9 @@ export class BlockFactory {
125125
}
126126

127127
getBlock(identifier: FileIdentifier): Promise<Block> {
128-
if (this.promises[identifier]) {
128+
if (this.blocks[identifier]) {
129+
return Promise.resolve(this.blocks[identifier]);
130+
} else if (this.promises[identifier]) {
129131
return this.promises[identifier].catch(() => {
130132
// If we got an error last time, try again.
131133
// Also this makes sure that the error object gives a correct import stack trace.

packages/@css-blocks/core/src/BlockParser/features/import-blocks.ts

+112-84
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ import { BLOCK_IMPORT, CLASS_NAME_IDENT, DEFAULT_EXPORT, isBlockNameReserved } f
55
import { Block } from "../../BlockTree";
66
import * as errors from "../../errors";
77
import { sourceRange } from "../../SourceLocation";
8-
import { allDone } from "../../util";
98
import { BlockFactory } from "../index";
109
import { parseBlockNames, stripQuotes } from "../utils";
1110

1211
const FROM_EXPR = /\s+from\s+/;
1312

13+
interface ParsedImport {
14+
blockPath: string;
15+
atRule: postcss.AtRule;
16+
names: Array<{localName: string; remoteName: string}>;
17+
}
18+
1419
/**
1520
* Resolve all block references for a given block.
1621
* @param block Block to resolve references for
@@ -19,102 +24,125 @@ const FROM_EXPR = /\s+from\s+/;
1924
export async function importBlocks(block: Block, factory: BlockFactory, file: string): Promise<Block> {
2025

2126
let root: postcss.Root | undefined = block.stylesheet;
22-
let namedBlockReferences: Promise<[string, string, postcss.AtRule, Block]>[] = [];
2327

2428
if (!root) {
25-
block.addError(new errors.InvalidBlockSyntax(`Error finding PostCSS root for block ${block.name}`));
26-
} else {
27-
// For each `@block` expression, read in the block file, parse and
28-
// push to block references Promise array.
29-
root.walkAtRules(BLOCK_IMPORT, (atRule: postcss.AtRule) => {
30-
// imports: `<blocks-list> from <block-path>`
31-
// blockList: `<default-block> | <named-blocks> | <default-block> " , " <named-blocks> | <named-blocks> " , " <default-block>`
32-
// blockPath: `' " ' <any-value> ' " ' | " ' " <any-value> " ' "`
33-
let imports = atRule.params;
34-
let [blockList = "", blockPath = ""] = imports.split(FROM_EXPR);
35-
blockPath = stripQuotes(blockPath);
36-
37-
if (!blockList || !blockPath) {
38-
block.addError(new errors.InvalidBlockSyntax(
39-
`Malformed block reference: \`@block ${atRule.params}\``,
40-
sourceRange(factory.configuration, block.stylesheet, file, atRule),
41-
));
42-
} else {
43-
// Import file, then parse file, then save block reference.
44-
let blockPromise: Promise<Block> = factory.getBlockRelative(block.identifier, blockPath);
45-
46-
blockPromise = blockPromise.catch((e) => {
47-
if (e instanceof errors.CssBlockError) {
48-
e.importStack.push(sourceRange(factory.configuration, block.stylesheet, file, atRule));
49-
}
50-
throw e;
51-
});
29+
block.addError(new errors.InvalidBlockSyntax(`Internal Error: Cannot find PostCSS root for block ${block.name}`));
30+
return block;
31+
}
5232

53-
let blockNames = parseBlockNames(blockList, true);
54-
for (let localName of Object.keys(blockNames)) {
55-
let remoteName = blockNames[localName];
56-
// Validate our imported block name is a valid CSS identifier.
57-
if (!CLASS_NAME_IDENT.test(localName)) {
58-
block.addError(new errors.InvalidBlockSyntax(
59-
`Illegal block name in import. "${localName}" is not a legal CSS identifier.`,
60-
sourceRange(factory.configuration, block.stylesheet, file, atRule),
61-
));
62-
}
63-
if (!CLASS_NAME_IDENT.test(remoteName)) {
64-
block.addError(new errors.InvalidBlockSyntax(
65-
`Illegal block name in import. "${remoteName}" is not a legal CSS identifier.`,
66-
sourceRange(factory.configuration, block.stylesheet, file, atRule),
67-
));
68-
}
69-
if (localName === DEFAULT_EXPORT && remoteName === DEFAULT_EXPORT) {
70-
block.addError(new errors.InvalidBlockSyntax(
71-
`Default Block from "${blockPath}" must be aliased to a unique local identifier.`,
72-
sourceRange(factory.configuration, block.stylesheet, file, atRule),
73-
));
74-
}
75-
if (isBlockNameReserved(localName)) {
76-
block.addError(new errors.InvalidBlockSyntax(
77-
`Cannot import "${remoteName}" as reserved word "${localName}"`,
78-
sourceRange(factory.configuration, block.stylesheet, file, atRule),
79-
));
80-
}
33+
let parsedImports = new Array<ParsedImport>();
34+
// For each `@block` expression, read in the block file, parse and
35+
// push to block references Promise array.
36+
root.walkAtRules(BLOCK_IMPORT, (atRule: postcss.AtRule) => {
37+
// imports: `<blocks-list> from <block-path>`
38+
// blockList: `<default-block> | <named-blocks> | <default-block> " , " <named-blocks> | <named-blocks> " , " <default-block>`
39+
// blockPath: `' " ' <any-value> ' " ' | " ' " <any-value> " ' "`
40+
let imports = atRule.params;
41+
let [blockList = "", blockPath = ""] = imports.split(FROM_EXPR);
42+
blockPath = stripQuotes(blockPath);
8143

82-
// Once block is parsed, save named block reference
83-
let namedResult: Promise<[string, string, postcss.AtRule, Block]> = blockPromise.then((block: Block): [string, string, postcss.AtRule, Block] => {
84-
let referencedBlock = block.getExportedBlock(remoteName);
85-
if (!referencedBlock) {
86-
throw new errors.InvalidBlockSyntax(
87-
`Cannot import Block "${remoteName}". No Block named "${remoteName}" exported by "${blockPath}".`,
88-
sourceRange(factory.configuration, block.stylesheet, file, atRule),
89-
);
90-
}
91-
return [localName, blockPath, atRule, referencedBlock];
92-
});
93-
namedBlockReferences.push(namedResult);
44+
if (!blockList || !blockPath) {
45+
block.addError(new errors.InvalidBlockSyntax(
46+
`Malformed block reference: \`@block ${atRule.params}\``,
47+
sourceRange(factory.configuration, block.stylesheet, file, atRule),
48+
));
49+
} else {
50+
let names: ParsedImport["names"] = [];
51+
let blockNames = parseBlockNames(blockList, true);
52+
for (let localName of Object.keys(blockNames)) {
53+
let remoteName = blockNames[localName];
54+
let hasInvalidNames = validateBlockNames(factory.configuration, block, blockPath, localName, remoteName, file, atRule);
55+
if (!hasInvalidNames) {
56+
names.push({ localName, remoteName });
9457
}
9558
}
96-
});
59+
parsedImports.push({ blockPath, atRule, names });
60+
}
61+
});
62+
63+
let localNames: ObjectDictionary<string> = {};
64+
for (let parsedImport of parsedImports) {
65+
let {blockPath, atRule, names} = parsedImport;
66+
let referencedBlock: Block | null = null;
9767

98-
// When all import promises have resolved, save the block references and
99-
// resolve.
100-
let results: Array<[string, string, postcss.AtRule, Block]>;
68+
// Import the main block file referenced by the import path.
10169
try {
102-
results = await allDone(namedBlockReferences);
103-
let localNames: ObjectDictionary<string> = {};
104-
results.forEach(([localName, importPath, atRule, otherBlock]) => {
105-
if (localNames[localName]) {
70+
referencedBlock = await factory.getBlockRelative(block.identifier, parsedImport.blockPath);
71+
} catch (err) {
72+
block.addError(new errors.CascadingError(
73+
"Error in imported block.",
74+
err,
75+
sourceRange(factory.configuration, block.stylesheet, file, atRule),
76+
));
77+
}
78+
79+
for (let {localName, remoteName} of names) {
80+
// check for duplicate local names
81+
if (localNames[localName]) {
82+
block.addError(new errors.InvalidBlockSyntax(
83+
`Blocks ${localNames[localName]} and ${blockPath} cannot both have the name ${localName} in this scope.`,
84+
sourceRange(factory.configuration, block.stylesheet, file, atRule),
85+
));
86+
continue;
87+
} else {
88+
localNames[localName] = blockPath;
89+
}
90+
91+
// Store a reference to the local block if possible
92+
if (referencedBlock) {
93+
let exportedBlock = referencedBlock.getExportedBlock(remoteName);
94+
if (exportedBlock) {
95+
block.addBlockReference(localName, exportedBlock);
96+
} else {
10697
block.addError(new errors.InvalidBlockSyntax(
107-
`Blocks ${localNames[localName]} and ${importPath} cannot both have the name ${localName} in this scope.`,
98+
`Cannot import Block "${remoteName}". No Block named "${remoteName}" exported by "${blockPath}".`,
10899
sourceRange(factory.configuration, block.stylesheet, file, atRule),
109100
));
110-
} else {
111-
block.addBlockReference(localName, otherBlock);
112-
localNames[localName] = importPath;
113101
}
114-
});
115-
} catch (e) {
116-
block.addError(e);
102+
}
117103
}
118104
}
119105
return block;
120106
}
107+
108+
function validateBlockNames(
109+
config: BlockFactory["configuration"],
110+
block: Block,
111+
blockPath: string,
112+
localName: string,
113+
remoteName: string,
114+
file: string,
115+
atRule: postcss.AtRule,
116+
): boolean {
117+
let hasInvalidNames = false;
118+
// Validate our imported block name is a valid CSS identifier.
119+
if (!CLASS_NAME_IDENT.test(localName)) {
120+
hasInvalidNames = true;
121+
block.addError(new errors.InvalidBlockSyntax(
122+
`Illegal block name in import. "${localName}" is not a legal CSS identifier.`,
123+
sourceRange(config, block.stylesheet, file, atRule),
124+
));
125+
}
126+
if (!CLASS_NAME_IDENT.test(remoteName)) {
127+
hasInvalidNames = true;
128+
block.addError(new errors.InvalidBlockSyntax(
129+
`Illegal block name in import. "${remoteName}" is not a legal CSS identifier.`,
130+
sourceRange(config, block.stylesheet, file, atRule),
131+
));
132+
}
133+
if (localName === DEFAULT_EXPORT && remoteName === DEFAULT_EXPORT) {
134+
hasInvalidNames = true;
135+
block.addError(new errors.InvalidBlockSyntax(
136+
`Default Block from "${blockPath}" must be aliased to a unique local identifier.`,
137+
sourceRange(config, block.stylesheet, file, atRule),
138+
));
139+
}
140+
if (isBlockNameReserved(localName)) {
141+
hasInvalidNames = true;
142+
block.addError(new errors.InvalidBlockSyntax(
143+
`Cannot import "${remoteName}" as reserved word "${localName}"`,
144+
sourceRange(config, block.stylesheet, file, atRule),
145+
));
146+
}
147+
return hasInvalidNames;
148+
}

packages/@css-blocks/core/src/errors.ts

+21-7
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,11 @@ interface HasPrefix {
2626
export class CssBlockError extends Error {
2727
static prefix = "Error";
2828
origMessage: string;
29-
importStack: Array<SourceLocation.SourceRange | SourceLocation.MappedSourceRange | SourceLocation.SourceFile>;
3029
private _location?: ErrorLocation;
3130
constructor(message: string, location?: ErrorLocation) {
3231
super(message);
3332
this.origMessage = message;
3433
this._location = location;
35-
this.importStack = new Array();
3634
super.message = this.annotatedMessage();
3735
}
3836

@@ -133,7 +131,7 @@ export class BlockPathError extends CssBlockError {
133131
* clear errors
134132
*/
135133
export class MultipleCssBlockErrors extends CssBlockError {
136-
static prefix = "MultipleCssBlockErrors";
134+
static prefix = "Caused by multiple errors";
137135
private _errors: CssBlockError[] = [];
138136

139137
constructor(errors: CssBlockError[], location?: ErrorLocation, details?: string) {
@@ -151,10 +149,7 @@ export class MultipleCssBlockErrors extends CssBlockError {
151149
}
152150
if (!details) {
153151
details = ":";
154-
let i = 0;
155-
for (let err of this._errors) {
156-
details += `\n\t${++i}. ${err}`;
157-
}
152+
details += errorDetails(this);
158153
}
159154
this.message += details;
160155
}
@@ -178,3 +173,22 @@ export class CascadingError extends CssBlockError {
178173
this.cause = rootCause;
179174
}
180175
}
176+
177+
function errorDetails(error: MultipleCssBlockErrors, indent = ""): string {
178+
let details = "";
179+
let i = 0;
180+
for (let err of error.errors) {
181+
details += `\n${indent}${++i}. ${err}`;
182+
if (err instanceof CascadingError) {
183+
if (err.cause instanceof MultipleCssBlockErrors && err.cause.errors.length > 1) {
184+
details += `\n${indent} ${i > 9 ? " " : ""}Caused by multiple errors:`;
185+
details += errorDetails(err.cause, indent + "\t");
186+
} else {
187+
let cause = err.cause instanceof MultipleCssBlockErrors ? err.cause.errors[0] : err.cause;
188+
details += `\n${indent} ${i > 9 ? " " : ""}Caused by:`;
189+
details += `\n${indent} ${cause}`;
190+
}
191+
}
192+
}
193+
return details;
194+
}

0 commit comments

Comments
 (0)