Skip to content

Commit 53c3a66

Browse files
committed
fix(css-blocks): Fix BlockPath parser to correctly set default block and class values. Improve error message location reporting. Improve error messages for invalid identifiers.
1 parent eb039a8 commit 53c3a66

File tree

2 files changed

+86
-48
lines changed

2 files changed

+86
-48
lines changed

packages/css-blocks/src/BlockSyntax/BlockPath.ts

+58-33
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,18 @@ export interface StateToken {
1717
namespace: string;
1818
name: string;
1919
value?: string;
20+
quoted: boolean;
2021
}
2122

2223
type Token = BlockToken | ClassToken | StateToken;
2324

24-
const isBlock = (token?: Token): token is BlockToken => !!token && token.type === 'block';
25-
const isClass = (token?: Token): token is ClassToken => !!token && token.type === 'class';
26-
const isState = (token?: Token): token is StateToken => !!token && token.type === 'state';
27-
const hasName = (token?: Token): boolean => !!token && !!token.name;
28-
const hasNamespace = (token?: Token): boolean => isState(token) && !!token.namespace;
25+
const isBlock = (token?: Partial<Token>): token is BlockToken => !!token && token.type === 'block';
26+
const isClass = (token?: Partial<Token>): token is ClassToken => !!token && token.type === 'class';
27+
const isState = (token?: Partial<Token>): token is StateToken => !!token && token.type === 'state';
28+
const isQuoted = (token?: Partial<Token>): boolean => isState(token) && !!token.quoted;
29+
const isIdent = (ident?: string): boolean => !ident || CSS_IDENT.test(ident);
30+
const hasName = (token?: Partial<Token>): boolean => !!token && !!token.name;
31+
const hasNamespace = (token?: Partial<Token>): boolean => isState(token) && !!token.namespace;
2932

3033
const STATE_BEGIN = "[";
3134
const STATE_END = "]";
@@ -43,7 +46,7 @@ export const ERRORS = {
4346
noname: "Block path segments must include a valid name",
4447
unclosedState: "Unclosed state selector",
4548
mismatchedQuote: "No closing quote found in Block path",
46-
illegalChar: (c: string) => `Unexpected character "${c}" found in Block path.`,
49+
invalidIdent: (i: string) => `Invalid identifier "${i}" found in Block path.`,
4750
expectsSepInsteadRec: (c: string) => `Expected separator tokens "[" or ".", instead found \`${c}\``,
4851
illegalCharNotInState: (c: string) => `Only state selectors may contain the \`${c}\` character.`,
4952
illegalCharInState: (c: string) => `State selectors may not contain the \`${c}\` character.`,
@@ -75,6 +78,7 @@ class Walker {
7578

7679
next(): string { return this.data[this.idx++]; }
7780
peek(): string { return this.data[this.idx]; }
81+
index(): number { return this.idx; }
7882

7983
/**
8084
* Consume all characters that do not match the provided Set or strings
@@ -101,23 +105,31 @@ export class BlockPath {
101105
private _class: ClassToken;
102106
private _state: StateToken;
103107

108+
private walker: Walker;
104109
private tokens: Token[] = [];
105110

106111
/**
107112
* Throw a new BlockPathError with the given message.
108113
* @param msg The error message.
109114
*/
110-
private throw(msg: string): never {
111-
throw new BlockPathError(msg, this._location);
115+
private throw(msg: string, len = 0): never {
116+
let location;
117+
if (this._location) {
118+
location = {
119+
...this._location,
120+
column: (this._location.column || 0) + this.walker.index() - len
121+
};
122+
}
123+
throw new BlockPathError(msg, location);
112124
}
113125

114126
/**
115127
* Used by `tokenize` to insert a newly constructed token.
116128
* @param token The token to insert.
117129
*/
118-
private addToken(token: Token): void {
130+
private addToken(token: Partial<Token>): void {
119131

120-
// Final validation of incoming data.
132+
// Final validation of incoming data. Blocks may have no name. States must have a namespace.
121133
if (!isBlock(token) && !hasName(token)) { this.throw(ERRORS.noname); }
122134
if (isState(token) && !hasNamespace(token)) { this.throw(ERRORS.namespace); }
123135

@@ -127,13 +139,17 @@ export class BlockPath {
127139
}
128140
if (isClass(token)) {
129141
this._class = this._class ? this.throw(ERRORS.multipleOfType(token.type)) : token;
142+
// If no block has been added yet, automatically inject the `self` block name.
143+
if (!this._block) { this.addToken({ type: "block", name: "" }); }
130144
}
131145
if (isState(token)) {
132146
this._state = this._state ? this.throw(ERRORS.multipleOfType(token.type)) : token;
147+
// If no class has been added yet, automatically inject the root class.
148+
if (!this._class) { this.addToken({ type: "class", name: "root" }); }
133149
}
134150

135151
// Add the token.
136-
this.tokens.push(token);
152+
this.tokens.push(token as Token);
137153
}
138154

139155
/**
@@ -144,8 +160,8 @@ export class BlockPath {
144160
private tokenize(str: string): void {
145161
let char,
146162
working = "",
147-
walker = new Walker(str),
148-
token: Token = { type: 'block', name: '' };
163+
walker = this.walker = new Walker(str),
164+
token: Partial<Token> = { type: 'block' };
149165

150166
while (char = walker.next()) {
151167

@@ -154,38 +170,27 @@ export class BlockPath {
154170
// If a period, we've finished the previous token and are now building a class name.
155171
case char === CLASS_BEGIN:
156172
if (isState(token)) { this.throw(ERRORS.illegalCharInState(char)); }
173+
if (!isIdent(working)) { return this.throw(ERRORS.invalidIdent(working), working.length); }
157174
token.name = working;
158175
this.addToken(token);
159-
token = { type: 'class', name: '' };
176+
token = { type: 'class' };
160177
working = "";
161178
break;
162179

163180
// If the beginning of a state, we've finished the previous token and are now building a state.
164181
case char === STATE_BEGIN:
165182
if (isState(token)) { this.throw(ERRORS.illegalCharInState(char)); }
183+
if (!isIdent(working)) { return this.throw(ERRORS.invalidIdent(working), working.length); }
166184
token.name = working;
167185
this.addToken(token);
168-
token = { type: 'state', namespace: '', name: '' };
186+
token = { type: 'state' };
169187
working = "";
170188
break;
171189

172-
// If the end of a state, set the state part we've been working on and finish.
173-
case char === STATE_END:
174-
if (!isState(token)) { return this.throw(ERRORS.illegalCharNotInState(char)); }
175-
token.name ? (token.value = working) : (token.name = working);
176-
this.addToken(token);
177-
working = "";
178-
179-
// The character immediately following a `STATE_END` *must* be another `SEPARATORS`
180-
// Depending on the next value, seed our token input
181-
let next = walker.next();
182-
if (next && !SEPARATORS.has(next)) { this.throw(ERRORS.expectsSepInsteadRec(next)); }
183-
token = (next === STATE_BEGIN) ? { type: 'state', namespace: '', name: '' } : { type: 'class', name: '' };
184-
break;
185-
186190
// When we find a namespace terminator, set the namespace property of the state token we're working on.
187191
case char === NAMESPACE_END:
188192
if (!isState(token)) { return this.throw(ERRORS.illegalCharNotInState(char)); }
193+
if (!isIdent(working)) { return this.throw(ERRORS.invalidIdent(working), working.length); }
189194
token.namespace = working;
190195
working = "";
191196
break;
@@ -194,6 +199,7 @@ export class BlockPath {
194199
case char === VALUE_START:
195200
if (!isState(token)) { this.throw(ERRORS.illegalCharNotInState(char)); }
196201
if (!working) { this.throw(ERRORS.noname); }
202+
if (!isIdent(working)) { return this.throw(ERRORS.invalidIdent(working), working.length); }
197203
token.name = working;
198204
working = "";
199205
break;
@@ -202,10 +208,28 @@ export class BlockPath {
202208
case char === SINGLE_QUOTE || char === DOUBLE_QUOTE:
203209
if (!isState(token)) { return this.throw(ERRORS.illegalCharNotInState(char)); }
204210
working = walker.consume(char);
211+
token.quoted = true;
205212
if (walker.peek() !== char) { this.throw(ERRORS.mismatchedQuote); }
206213
walker.next(); // Throw away the other quote
207214
break;
208215

216+
// If the end of a state, set the state part we've been working on and finish.
217+
case char === STATE_END:
218+
if (!isState(token)) { return this.throw(ERRORS.illegalCharNotInState(char)); }
219+
if ((!hasName(token) || !isQuoted(token)) && !isIdent(working)) {
220+
console.log(working); return this.throw(ERRORS.invalidIdent(working), working.length);
221+
}
222+
(hasName(token)) ? (token.value = working) : (token.name = working);
223+
this.addToken(token);
224+
working = "";
225+
226+
// The character immediately following a `STATE_END` *must* be another `SEPARATORS`
227+
// Depending on the next value, seed our token input
228+
let next = walker.next();
229+
if (next && !SEPARATORS.has(next)) { this.throw(ERRORS.expectsSepInsteadRec(next)); }
230+
token = (next === STATE_BEGIN) ? { type: 'state' } : { type: 'class' };
231+
break;
232+
209233
// We should never encounter whitespace in this switch statement.
210234
// The only place whitespace is allowed is between quotes, which
211235
// is handled above.
@@ -217,7 +241,6 @@ export class BlockPath {
217241
// TODO: We need to handle invalid character escapes here!
218242
default:
219243
working += char;
220-
if (!CSS_IDENT.test(working)) { return this.throw(ERRORS.illegalChar(char)); }
221244

222245
}
223246

@@ -228,11 +251,13 @@ export class BlockPath {
228251
if (isState(token)) { this.throw(ERRORS.unclosedState); }
229252

230253
// Class and Block tokens are not explicitly terminated and may be sealed when we
231-
// get to the end.
254+
// get to the end. If no class has been discovered, automatically add our root class.
232255
if (!isState(token) && working) {
256+
if (!isIdent(working)) { return this.throw(ERRORS.invalidIdent(working), working.length); }
233257
token.name = working;
234258
this.addToken(token);
235259
}
260+
if (!this._class) { this.addToken({ type: "class", name: "root" }); }
236261
}
237262

238263
/**
@@ -281,7 +306,7 @@ export class BlockPath {
281306
* Get the parsed state name of this Block Path and return the `StateInfo`
282307
*/
283308
get state(): StateInfo | undefined {
284-
return {
309+
return this._state && {
285310
group: this._state.value ? this._state.name : undefined,
286311
name: this._state.value || this._state.name,
287312
};
@@ -298,7 +323,7 @@ export class BlockPath {
298323
* Return a new BlockPath without the parent-most token.
299324
*/
300325
childPath() {
301-
return BlockPath.from(this.tokens.slice(1));
326+
return BlockPath.from(this.tokens.slice(this._block.name ? 1 : 2));
302327
}
303328

304329
/**

packages/css-blocks/test/BlockSyntax/block-path-test.ts

+28-15
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export class BlockPathTests {
1313

1414
@test "finds the class"() {
1515
let path = new BlockPath(".test");
16+
console.log(path.block, path.path);
1617
assert.equal(path.block, "");
1718
assert.equal(path.path, ".test");
1819
}
@@ -26,7 +27,7 @@ export class BlockPathTests {
2627
@test "finds the block with a state"() {
2728
let path = new BlockPath("block[state|my-state]");
2829
assert.equal(path.block, "block");
29-
assert.equal(path.path, "[state|my-state]");
30+
assert.equal(path.path, ".root[state|my-state]");
3031
}
3132

3233
@test "finds the block and class with a state"() {
@@ -38,19 +39,19 @@ export class BlockPathTests {
3839
@test "finds a a state with value"() {
3940
let path = new BlockPath("[state|my-state=value]");
4041
assert.equal(path.block, "");
41-
assert.equal(path.path, `[state|my-state="value"]`);
42+
assert.equal(path.path, `.root[state|my-state="value"]`);
4243
}
4344

4445
@test "finds a state with value in single quotes"() {
4546
let path = new BlockPath("[state|my-state='my value']");
4647
assert.equal(path.block, "");
47-
assert.equal(path.path, `[state|my-state="my value"]`);
48+
assert.equal(path.path, `.root[state|my-state="my value"]`);
4849
}
4950

5051
@test "finds a state with value in double quotes"() {
5152
let path = new BlockPath(`[state|my-state="my value"]`);
5253
assert.equal(path.block, "");
53-
assert.equal(path.path, `[state|my-state="my value"]`);
54+
assert.equal(path.path, `.root[state|my-state="my value"]`);
5455
}
5556

5657
@test "finds a class with a state and value"() {
@@ -89,6 +90,13 @@ export class BlockPathTests {
8990
assert.equal(path.path, `.class[state|my-state="my value"]`);
9091
}
9192

93+
@test "finds .root when passed empty string"() {
94+
let path = new BlockPath("");
95+
assert.equal(path.block, "");
96+
assert.equal(path.path, ".root");
97+
assert.equal(path.state, undefined);
98+
}
99+
92100
@test "parentPath returns the parent's path"() {
93101
let path = new BlockPath("block.class[state|my-state]");
94102
assert.equal(path.parentPath().toString(), "block.class");
@@ -97,7 +105,7 @@ export class BlockPathTests {
97105
path = new BlockPath("block.class");
98106
assert.equal(path.parentPath().toString(), "block");
99107
path = new BlockPath("block[state|my-state]");
100-
assert.equal(path.parentPath().toString(), "block");
108+
assert.equal(path.parentPath().toString(), "block.root");
101109
}
102110

103111
@test "childPath returns the child's path"() {
@@ -108,7 +116,7 @@ export class BlockPathTests {
108116
path = new BlockPath("block.class");
109117
assert.equal(path.childPath().toString(), ".class");
110118
path = new BlockPath("block[state|my-state]");
111-
assert.equal(path.childPath().toString(), "[state|my-state]");
119+
assert.equal(path.childPath().toString(), ".root[state|my-state]");
112120
}
113121

114122
@test "sub-path properties return expected values"() {
@@ -121,7 +129,7 @@ export class BlockPathTests {
121129

122130
path = new BlockPath("block[state|my-state=foobar]");
123131
assert.equal(path.block, "block");
124-
assert.equal(path.path, `[state|my-state="foobar"]`);
132+
assert.equal(path.path, `.root[state|my-state="foobar"]`);
125133
assert.equal(path.class, "root");
126134
// assert.equal(path.state && path.state.namespace, "state");
127135
assert.equal(path.state && path.state.group, "my-state");
@@ -233,18 +241,23 @@ export class BlockPathTests {
233241
}
234242

235243
@test "unescaped illegal characters in identifiers throw."() {
244+
let loc = {
245+
filename: 'foo.scss',
246+
line: 10,
247+
column: 20
248+
};
236249
assert.throws(() => {
237-
let path = new BlockPath(`block+name`);
238-
}, ERRORS.illegalChar('+'));
250+
let path = new BlockPath(`block+name`, loc);
251+
}, `${ERRORS.invalidIdent('block+name')} (foo.scss:10:21)`);
239252
assert.throws(() => {
240-
let path = new BlockPath(`block[#name|foo=bar]`);
241-
}, ERRORS.illegalChar('#'));
253+
let path = new BlockPath(`block[#name|foo=bar]`, loc);
254+
}, `${ERRORS.invalidIdent('#name')} (foo.scss:10:27)`);
242255
assert.throws(() => {
243-
let path = new BlockPath(`block[name|fo&o=bar]`);
244-
}, ERRORS.illegalChar('&'));
256+
let path = new BlockPath(`block[name|fo&o=bar]`, loc);
257+
}, `${ERRORS.invalidIdent('fo&o')} (foo.scss:10:32)`);
245258
assert.throws(() => {
246-
let path = new BlockPath(`block[name|foo=1bar]`);
247-
}, ERRORS.illegalChar('1'));
259+
let path = new BlockPath(`block[name|foo=1bar]`, loc);
260+
}, `${ERRORS.invalidIdent('1bar')} (foo.scss:10:36)`);
248261

249262
// Quoted values may have illegal strings
250263
let path = new BlockPath(`block[name|foo="1bar"]`);

0 commit comments

Comments
 (0)